udpate core to match master

This commit is contained in:
2025-10-04 22:43:10 -05:00
parent f686b6f28b
commit de2341805e
40 changed files with 2758 additions and 1304 deletions

View File

@@ -6,6 +6,8 @@ namespace Shimmie2;
use MicroHTML\HTMLElement;
use function MicroHTML\{emptyHTML,rawHTML,HTML,HEAD,BODY};
require_once "core/event.php";
enum PageMode: string
@@ -17,6 +19,22 @@ enum PageMode: string
case MANUAL = 'manual';
}
class Cookie
{
public string $name;
public string $value;
public int $time;
public string $path;
public function __construct(string $name, string $value, int $time, string $path)
{
$this->name = $name;
$this->value = $value;
$this->time = $time;
$this->path = $path;
}
}
/**
* Class Page
*
@@ -111,6 +129,7 @@ class BasePage
public string $title = "";
public string $heading = "";
public string $subheading = "";
public bool $left_enabled = true;
/** @var string[] */
public array $html_headers = [];
@@ -118,7 +137,7 @@ class BasePage
/** @var string[] */
public array $http_headers = [];
/** @var string[][] */
/** @var Cookie[] */
public array $cookies = [];
/** @var Block[] */
@@ -155,6 +174,11 @@ class BasePage
$this->flash[] = $message;
}
public function disable_left(): void
{
$this->left_enabled = false;
}
/**
* Add a line to the HTML head section.
*/
@@ -185,7 +209,7 @@ class BasePage
public function add_cookie(string $name, string $value, int $time, string $path): void
{
$full_name = COOKIE_PREFIX . "_" . $name;
$this->cookies[] = [$full_name, $value, $time, $path];
$this->cookies[] = new Cookie($full_name, $value, $time, $path);
}
public function get_cookie(string $name): ?string
@@ -246,7 +270,7 @@ class BasePage
header($head);
}
foreach ($this->cookies as $c) {
setcookie($c[0], $c[1], $c[2], $c[3]);
setcookie($c->name, $c->value, $c->time, $c->path);
}
} else {
print "Error: Headers have already been sent to the client.";
@@ -258,7 +282,7 @@ class BasePage
*/
public function display(): void
{
if ($this->mode!=PageMode::MANUAL) {
if ($this->mode != PageMode::MANUAL) {
$this->send_headers();
}
@@ -359,8 +383,6 @@ class BasePage
$data_href = get_base_href();
$theme_name = $config->get_string(SetupConfig::THEME, 'default');
$this->add_html_header("<script type='text/javascript'>base_href = '$data_href';</script>", 40);
# static handler will map these to themes/foo/static/bar.ico or ext/static_files/static/bar.ico
$this->add_html_header("<link rel='icon' type='image/x-icon' href='$data_href/favicon.ico'>", 41);
$this->add_html_header("<link rel='apple-touch-icon' href='$data_href/apple-touch-icon.png'>", 42);
@@ -371,7 +393,18 @@ class BasePage
$config_latest = max($config_latest, filemtime($conf));
}
/*** Generate CSS cache files ***/
$css_cache_file = $this->get_css_cache_file($theme_name, $config_latest);
$this->add_html_header("<link rel='stylesheet' href='$data_href/$css_cache_file' type='text/css'>", 43);
$initjs_cache_file = $this->get_initjs_cache_file($theme_name, $config_latest);
$this->add_html_header("<script src='$data_href/$initjs_cache_file' type='text/javascript'></script>", 44);
$js_cache_file = $this->get_js_cache_file($theme_name, $config_latest);
$this->add_html_header("<script defer src='$data_href/$js_cache_file' type='text/javascript'></script>", 44);
}
private function get_css_cache_file(string $theme_name, int $config_latest): string
{
$css_latest = $config_latest;
$css_files = array_merge(
zglob("ext/{" . Extension::get_enabled_extensions_as_string() . "}/style.css"),
@@ -387,9 +420,8 @@ class BasePage
foreach ($css_files as $css) {
$mcss->addSource($css);
}
file_put_contents($css_cache_file, $css_data);
$mcss->save($css_cache_file);
}
$this->add_html_header("<link rel='stylesheet' href='$data_href/$css_cache_file' type='text/css'>", 43);
return $css_cache_file;
}
@@ -425,7 +457,6 @@ class BasePage
"vendor/bower-asset/jquery/dist/jquery.min.js",
"vendor/bower-asset/jquery-timeago/jquery.timeago.js",
"vendor/bower-asset/js-cookie/src/js.cookie.js",
"ext/static_files/modernizr-3.3.1.custom.js",
],
zglob("ext/{" . Extension::get_enabled_extensions_as_string() . "}/script.js"),
zglob("themes/$theme_name/{" . implode(",", $this->get_theme_scripts()) . "}")
@@ -440,14 +471,14 @@ class BasePage
foreach ($js_files as $js) {
$mcss->addSource($js);
}
file_put_contents($js_cache_file, $js_data);
$mcss->save($js_cache_file);
}
$this->add_html_header("<script defer src='$data_href/$js_cache_file' type='text/javascript'></script>", 44);
return $js_cache_file;
}
/**
* @return array A list of stylesheets relative to the theme root.
* @return string[] A list of stylesheets relative to the theme root.
*/
protected function get_theme_stylesheets(): array
{
@@ -456,13 +487,16 @@ class BasePage
/**
* @return array A list of script files relative to the theme root.
* @return string[] A list of script files relative to the theme root.
*/
protected function get_theme_scripts(): array
{
return ["script.js"];
}
/**
* @return array{0: NavLink[], 1: NavLink[]}
*/
protected function get_nav_links(): array
{
$pnbe = send_event(new PageNavBuildingEvent());
@@ -472,14 +506,14 @@ class BasePage
$active_link = null;
// To save on event calls, we check if one of the top-level links has already been marked as active
foreach ($nav_links as $link) {
if ($link->active===true) {
if ($link->active === true) {
$active_link = $link;
break;
}
}
$sub_links = null;
// If one is, we just query for sub-menu options under that one tab
if ($active_link!==null) {
if ($active_link !== null) {
$psnbe = send_event(new PageSubNavBuildingEvent($active_link->name));
$sub_links = $psnbe->links;
} else {
@@ -489,22 +523,23 @@ class BasePage
// Now we check for a current link so we can identify the sub-links to show
foreach ($psnbe->links as $sub_link) {
if ($sub_link->active===true) {
if ($sub_link->active === true) {
$sub_links = $psnbe->links;
break;
}
}
// If the active link has been detected, we break out
if ($sub_links!==null) {
if ($sub_links !== null) {
$link->active = true;
break;
}
}
}
$sub_links = $sub_links??[];
usort($nav_links, "Shimmie2\sort_nav_links");
usort($sub_links, "Shimmie2\sort_nav_links");
$sub_links = $sub_links ?? [];
usort($nav_links, fn (NavLink $a, NavLink $b) => $a->order - $b->order);
usort($sub_links, fn (NavLink $a, NavLink $b) => $a->order - $b->order);
return [$nav_links, $sub_links];
}
@@ -512,10 +547,9 @@ class BasePage
/**
* turns the Page into HTML
*/
public function render()
public function render(): void
{
$head_html = $this->head_html();
$body_html = $this->body_html();
global $config, $user;
$head = $this->head_html();
$body = $this->body_html();
@@ -541,10 +575,8 @@ class BasePage
$html_header_html = $this->get_all_html_headers();
return "
<head>
<title>{$this->title}</title>
$html_header_html
</head>
<title>{$this->title}</title>
$html_header_html
";
}
@@ -571,30 +603,23 @@ class BasePage
}
}
$wrapper = "";
if (strlen($this->heading) > 100) {
$wrapper = ' style="height: 3em; overflow: auto;"';
}
$footer_html = $this->footer_html();
$flash_html = $this->flash ? "<b id='flash'>".nl2br(html_escape(implode("\n", $this->flash)))."</b>" : "";
return "
<body>
<header>
<h1$wrapper>{$this->heading}</h1>
$sub_block_html
</header>
<nav>
$left_block_html
</nav>
<article>
$flash_html
$main_block_html
</article>
<footer>
$footer_html
</footer>
</body>
<header>
<h1>{$this->heading}</h1>
$sub_block_html
</header>
<nav>
$left_block_html
</nav>
<article>
$flash_html
$main_block_html
</article>
<footer>
$footer_html
</footer>
";
}
@@ -609,7 +634,7 @@ class BasePage
<a href=\"https://code.shishnet.org/shimmie2/\">Shimmie</a> &copy;
<a href=\"https://www.shishnet.org/\">Shish</a> &amp;
<a href=\"https://github.com/shish/shimmie2/graphs/contributors\">The Team</a>
2007-2023,
2007-2024,
based on the Danbooru concept.
$debug
$contact
@@ -619,9 +644,10 @@ class BasePage
class PageNavBuildingEvent extends Event
{
/** @var NavLink[] */
public array $links = [];
public function add_nav_link(string $name, Link $link, string $desc, ?bool $active = null, int $order = 50)
public function add_nav_link(string $name, Link $link, string $desc, ?bool $active = null, int $order = 50): void
{
$this->links[] = new NavLink($name, $link, $desc, $active, $order);
}
@@ -631,15 +657,16 @@ class PageSubNavBuildingEvent extends Event
{
public string $parent;
/** @var NavLink[] */
public array $links = [];
public function __construct(string $parent)
{
parent::__construct();
$this->parent= $parent;
$this->parent = $parent;
}
public function add_nav_link(string $name, Link $link, string|HTMLElement $desc, ?bool $active = null, int $order = 50)
public function add_nav_link(string $name, Link $link, string|HTMLElement $desc, ?bool $active = null, int $order = 50): void
{
$this->links[] = new NavLink($name, $link, $desc, $active, $order);
}
@@ -661,8 +688,8 @@ class NavLink
$this->link = $link;
$this->description = $description;
$this->order = $order;
if ($active==null) {
$query = ltrim(_get_query(), "/");
if ($active == null) {
$query = _get_query();
if ($query === "") {
// This indicates the front page, so we check what's set as the front page
$front_page = trim($config->get_string(SetupConfig::FRONT_PAGE), "/");
@@ -672,7 +699,7 @@ class NavLink
} else {
$this->active = self::is_active([$link->page], $front_page);
}
} elseif ($query===$link->page) {
} elseif ($query === $link->page) {
$this->active = true;
} else {
$this->active = self::is_active([$link->page]);
@@ -682,23 +709,26 @@ class NavLink
}
}
/**
* @param string[] $pages_matched
*/
public static function is_active(array $pages_matched, string $url = null): bool
{
/**
* Woo! We can actually SEE THE CURRENT PAGE!! (well... see it highlighted in the menu.)
*/
$url = $url??ltrim(_get_query(), "/");
$url = $url ?? _get_query();
$re1='.*?';
$re2='((?:[a-z][a-z_]+))';
$re1 = '.*?';
$re2 = '((?:[a-z][a-z_]+))';
if (preg_match_all("/".$re1.$re2."/is", $url, $matches)) {
$url=$matches[1][0];
$url = $matches[1][0];
}
$count_pages_matched = count($pages_matched);
for ($i=0; $i < $count_pages_matched; $i++) {
for ($i = 0; $i < $count_pages_matched; $i++) {
if ($url == $pages_matched[$i]) {
return true;
}
@@ -707,8 +737,3 @@ class NavLink
return false;
}
}
function sort_nav_links(NavLink $a, NavLink $b): int
{
return $a->order - $b->order;
}

View File

@@ -6,7 +6,7 @@ namespace Shimmie2;
use MicroHTML\HTMLElement;
use function MicroHTML\{A,B,BR,IMG,OPTION,SELECT,emptyHTML};
use function MicroHTML\{A,B,BR,IMG,emptyHTML,joinHTML};
/**
* Class BaseThemelet
@@ -61,11 +61,11 @@ class BaseThemelet
}
$custom_classes = "";
if (class_exists("Shimmie2\Relationships")) {
if (property_exists($image, 'parent_id') && $image->parent_id !== null) {
if (Extension::is_enabled(RelationshipsInfo::KEY)) {
if ($image['parent_id'] !== null) {
$custom_classes .= "shm-thumb-has_parent ";
}
if (property_exists($image, 'has_children') && bool_escape($image->has_children)) {
if ($image['has_children']) {
$custom_classes .= "shm-thumb-has_child ";
}
}
@@ -84,29 +84,21 @@ class BaseThemelet
}
return A(
[
"href"=>$view_link,
"class"=>"thumb shm-thumb shm-thumb-link $custom_classes",
"data-tags"=>$tags,
"data-height"=>$image->height,
"data-width"=>$image->width,
"data-mime"=>$image->get_mime(),
"data-post-id"=>$id,
],
$attrs,
IMG(
[
"id"=>"thumb_$id",
"title"=>$tip,
"alt"=>$tip,
"height"=>$tsize[1],
"width"=>$tsize[0],
"src"=>$thumb_link,
"id" => "thumb_$id",
"title" => $tip,
"alt" => $tip,
"height" => $tsize[1],
"width" => $tsize[0],
"src" => $thumb_link,
]
)
);
}
public function display_paginator(Page $page, string $base, ?string $query, int $page_number, int $total_pages, bool $show_random = false)
public function display_paginator(Page $page, string $base, ?string $query, int $page_number, int $total_pages, bool $show_random = false): void
{
if ($total_pages == 0) {
$total_pages = 1;
@@ -116,18 +108,18 @@ class BaseThemelet
$page->add_html_header("<link rel='first' href='".make_http(make_link($base.'/1', $query))."'>");
if ($page_number < $total_pages) {
$page->add_html_header("<link rel='prefetch' href='".make_http(make_link($base.'/'.($page_number+1), $query))."'>");
$page->add_html_header("<link rel='next' href='".make_http(make_link($base.'/'.($page_number+1), $query))."'>");
$page->add_html_header("<link rel='prefetch' href='".make_http(make_link($base.'/'.($page_number + 1), $query))."'>");
$page->add_html_header("<link rel='next' href='".make_http(make_link($base.'/'.($page_number + 1), $query))."'>");
}
if ($page_number > 1) {
$page->add_html_header("<link rel='previous' href='".make_http(make_link($base.'/'.($page_number-1), $query))."'>");
$page->add_html_header("<link rel='previous' href='".make_http(make_link($base.'/'.($page_number - 1), $query))."'>");
}
$page->add_html_header("<link rel='last' href='".make_http(make_link($base.'/'.$total_pages, $query))."'>");
}
private function gen_page_link(string $base_url, ?string $query, int $page, string $name): HTMLElement
{
return A(["href"=>make_link($base_url.'/'.$page, $query)], $name);
return A(["href" => make_link($base_url.'/'.$page, $query)], $name);
}
private function gen_page_link_block(string $base_url, ?string $query, int $page, int $current_page, string $name): HTMLElement
@@ -139,19 +131,6 @@ class BaseThemelet
return $paginator;
}
protected function implode(string|HTMLElement $glue, array $pieces): HTMLElement
{
$out = emptyHTML();
$n = 0;
foreach ($pieces as $piece) {
if ($n++ > 0) {
$out->appendChild($glue);
}
$out->appendChild($piece);
}
return $out;
}
private function build_paginator(int $current_page, int $total_pages, string $base_url, ?string $query, bool $show_random): HTMLElement
{
$next = $current_page + 1;
@@ -179,10 +158,10 @@ class BaseThemelet
foreach (range($start, $end) as $i) {
$pages[] = $this->gen_page_link_block($base_url, $query, $i, $current_page, (string)$i);
}
$pages_html = $this->implode(" | ", $pages);
$pages_html = joinHTML(" | ", $pages);
return emptyHTML(
$this->implode(" | ", [
joinHTML(" | ", [
$first_html,
$prev_html,
$random_html,

View File

@@ -45,7 +45,7 @@ class Block
*/
public bool $is_content = true;
public function __construct(string $header=null, string|\MicroHTML\HTMLElement $body=null, string $section="main", int $position=50, string $id=null)
public function __construct(string $header = null, string|\MicroHTML\HTMLElement $body = null, string $section = "main", int $position = 50, string $id = null)
{
$this->header = $header;
$this->body = (string)$body;
@@ -63,7 +63,7 @@ class Block
/**
* Get the HTML for this block.
*/
public function get_html(bool $hidable=false): string
public function get_html(bool $hidable = false): string
{
$h = $this->header;
$b = $this->body;

View File

@@ -10,8 +10,8 @@ class EventTracingCache implements CacheInterface
{
private CacheInterface $engine;
private \EventTracer $tracer;
private int $hits=0;
private int $misses=0;
private int $hits = 0;
private int $misses = 0;
public function __construct(CacheInterface $engine, \EventTracer $tracer)
{
@@ -19,7 +19,7 @@ class EventTracingCache implements CacheInterface
$this->tracer = $tracer;
}
public function get($key, $default=null)
public function get($key, $default = null)
{
if ($key === "__etc_cache_hits") {
return $this->hits;
@@ -29,7 +29,7 @@ class EventTracingCache implements CacheInterface
}
$sentinel = "__etc_sentinel";
$this->tracer->begin("Cache Get", ["key"=>$key]);
$this->tracer->begin("Cache Get", ["key" => $key]);
$val = $this->engine->get($key, $sentinel);
if ($val != $sentinel) {
$res = "hit";
@@ -39,13 +39,13 @@ class EventTracingCache implements CacheInterface
$val = $default;
$this->misses++;
}
$this->tracer->end(null, ["result"=>$res]);
$this->tracer->end(null, ["result" => $res]);
return $val;
}
public function set($key, $value, $ttl = null)
{
$this->tracer->begin("Cache Set", ["key"=>$key, "ttl"=>$ttl]);
$this->tracer->begin("Cache Set", ["key" => $key, "ttl" => $ttl]);
$val = $this->engine->set($key, $value, $ttl);
$this->tracer->end();
return $val;
@@ -53,7 +53,7 @@ class EventTracingCache implements CacheInterface
public function delete($key)
{
$this->tracer->begin("Cache Delete", ["key"=>$key]);
$this->tracer->begin("Cache Delete", ["key" => $key]);
$val = $this->engine->delete($key);
$this->tracer->end();
return $val;
@@ -67,6 +67,11 @@ class EventTracingCache implements CacheInterface
return $val;
}
/**
* @param string[] $keys
* @param mixed $default
* @return iterable<mixed>
*/
public function getMultiple($keys, $default = null)
{
$this->tracer->begin("Cache Get Multiple", ["keys" => $keys]);
@@ -75,6 +80,9 @@ class EventTracingCache implements CacheInterface
return $val;
}
/**
* @param array<string, mixed> $values
*/
public function setMultiple($values, $ttl = null)
{
$this->tracer->begin("Cache Set Multiple", ["keys" => array_keys($values)]);
@@ -83,6 +91,9 @@ class EventTracingCache implements CacheInterface
return $val;
}
/**
* @param string[] $keys
*/
public function deleteMultiple($keys)
{
$this->tracer->begin("Cache Delete Multiple", ["keys" => $keys]);
@@ -93,16 +104,15 @@ class EventTracingCache implements CacheInterface
public function has($key)
{
$this->tracer->begin("Cache Has", ["key"=>$key]);
$this->tracer->begin("Cache Has", ["key" => $key]);
$val = $this->engine->has($key);
$this->tracer->end(null, ["exists"=>$val]);
$this->tracer->end(null, ["exists" => $val]);
return $val;
}
}
function loadCache(?string $dsn): CacheInterface
{
$matches = [];
$c = null;
if ($dsn && !isset($_GET['DISABLE_CACHE'])) {
$url = parse_url($dsn);

66
core/cli_app.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use Symfony\Component\Console\Input\{ArgvInput,InputOption,InputDefinition,InputInterface};
use Symfony\Component\Console\Output\{OutputInterface,ConsoleOutput};
class CliApp extends \Symfony\Component\Console\Application
{
public function __construct()
{
parent::__construct('Shimmie', VERSION);
$this->setAutoExit(false);
}
protected function getDefaultInputDefinition(): InputDefinition
{
$definition = parent::getDefaultInputDefinition();
$definition->addOption(new InputOption(
'--user',
'-u',
InputOption::VALUE_REQUIRED,
'Log in as the given user'
));
return $definition;
}
public function run(InputInterface $input = null, OutputInterface $output = null): int
{
global $user;
$input ??= new ArgvInput();
$output ??= new ConsoleOutput();
if ($input->hasParameterOption(['--user', '-u'])) {
$name = $input->getParameterOption(['--user', '-u']);
$user = User::by_name($name);
if (is_null($user)) {
die("Unknown user '$name'\n");
} else {
send_event(new UserLoginEvent($user));
}
}
$log_level = SCORE_LOG_WARNING;
if (true === $input->hasParameterOption(['--quiet', '-q'], true)) {
$log_level = SCORE_LOG_ERROR;
} else {
if ($input->hasParameterOption('-vvv', true) || $input->hasParameterOption('--verbose=3', true) || 3 === $input->getParameterOption('--verbose', false, true)) {
$log_level = SCORE_LOG_DEBUG;
} elseif ($input->hasParameterOption('-vv', true) || $input->hasParameterOption('--verbose=2', true) || 2 === $input->getParameterOption('--verbose', false, true)) {
$log_level = SCORE_LOG_DEBUG;
} elseif ($input->hasParameterOption('-v', true) || $input->hasParameterOption('--verbose=1', true) || $input->hasParameterOption('--verbose', true) || $input->getParameterOption('--verbose', false, true)) {
$log_level = SCORE_LOG_INFO;
}
}
if (!defined("CLI_LOG_LEVEL")) {
define("CLI_LOG_LEVEL", $log_level);
}
return parent::run($input, $output);
}
}

View File

@@ -10,10 +10,12 @@ namespace Shimmie2;
class CommandBuilder
{
private string $executable;
/** @var string[] */
private array $args = [];
/** @var string[] */
public array $output;
public function __construct(String $executable)
public function __construct(string $executable)
{
if (empty($executable)) {
throw new \InvalidArgumentException("executable cannot be empty");

View File

@@ -16,7 +16,7 @@ interface Config
* so that the next time a page is loaded it will use the new
* configuration.
*/
public function save(string $name=null): void;
public function save(string $name = null): void;
//@{ /*--------------------------------- SET ------------------------------------------------------*/
/**
@@ -41,6 +41,8 @@ interface Config
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
*
* @param mixed[] $value
*/
public function set_array(string $name, array $value): void;
//@} /*--------------------------------------------------------------------------------------------*/
@@ -93,6 +95,8 @@ interface Config
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*
* @param mixed[] $value
*/
public function set_default_array(string $name, array $value): void;
//@} /*--------------------------------------------------------------------------------------------*/
@@ -101,27 +105,30 @@ interface Config
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_int(string $name, ?int $default=null): ?int;
public function get_int(string $name, ?int $default = null): ?int;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_float(string $name, ?float $default=null): ?float;
public function get_float(string $name, ?float $default = null): ?float;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_string(string $name, ?string $default=null): ?string;
public function get_string(string $name, ?string $default = null): ?string;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_bool(string $name, ?bool $default=null): ?bool;
public function get_bool(string $name, ?bool $default = null): ?bool;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*
* @param mixed[] $default
* @return mixed[]
*/
public function get_array(string $name, ?array $default=[]): ?array;
public function get_array(string $name, ?array $default = []): ?array;
//@} /*--------------------------------------------------------------------------------------------*/
}
@@ -134,6 +141,7 @@ interface Config
*/
abstract class BaseConfig implements Config
{
/** @var array<string, mixed> */
public array $values = [];
public function set_int(string $name, ?int $value): void
@@ -162,7 +170,7 @@ abstract class BaseConfig implements Config
public function set_array(string $name, ?array $value): void
{
if ($value!=null) {
if ($value != null) {
$this->values[$name] = implode(",", $value);
} else {
$this->values[$name] = null;
@@ -205,17 +213,32 @@ abstract class BaseConfig implements Config
}
}
public function get_int(string $name, ?int $default=null): ?int
/**
* @template T of int|null
* @param T $default
* @return T|int
*/
public function get_int(string $name, ?int $default = null): ?int
{
return (int)($this->get($name, $default));
}
public function get_float(string $name, ?float $default=null): ?float
/**
* @template T of float|null
* @param T $default
* @return T|float
*/
public function get_float(string $name, ?float $default = null): ?float
{
return (float)($this->get($name, $default));
}
public function get_string(string $name, ?string $default=null): ?string
/**
* @template T of string|null
* @param T $default
* @return T|string
*/
public function get_string(string $name, ?string $default = null): ?string
{
$val = $this->get($name, $default);
if (!is_string($val) && !is_null($val)) {
@@ -224,12 +247,22 @@ abstract class BaseConfig implements Config
return $val;
}
public function get_bool(string $name, ?bool $default=null): ?bool
/**
* @template T of bool|null
* @param T $default
* @return T|bool
*/
public function get_bool(string $name, ?bool $default = null): ?bool
{
return bool_escape($this->get($name, $default));
}
public function get_array(string $name, ?array $default=[]): ?array
/**
* @template T of array<string>|null
* @param T $default
* @return T|array<string>
*/
public function get_array(string $name, ?array $default = null): ?array
{
$val = $this->get($name);
if (is_null($val)) {
@@ -241,7 +274,7 @@ abstract class BaseConfig implements Config
return explode(",", $val);
}
private function get(string $name, $default=null)
private function get(string $name, mixed $default = null): mixed
{
if (isset($this->values[$name])) {
return $this->values[$name];
@@ -285,30 +318,30 @@ class DatabaseConfig extends BaseConfig
$this->table_name = $table_name;
$this->sub_value = $sub_value;
$this->sub_column = $sub_column;
$this->cache_name = empty($sub_value) ? "config" : "config_{$sub_value}";
$cached = $cache->get($this->cache_name);
if (!is_null($cached)) {
$this->values = $cached;
} else {
$this->values = [];
$query = "SELECT name, value FROM {$this->table_name}";
$args = [];
if (!empty($sub_column)&&!empty($sub_value)) {
$query .= " WHERE $sub_column = :sub_value";
$args["sub_value"] = $sub_value;
}
foreach ($this->database->get_all($query, $args) as $row) {
$this->values[$row["name"]] = $row["value"];
}
$cache->set($this->cache_name, $this->values);
}
$this->cache_name = empty($sub_value) ? "config" : "config_{$sub_column}_{$sub_value}";
$this->values = cache_get_or_set($this->cache_name, fn () => $this->get_values());
}
public function save(string $name=null): void
private function get_values(): mixed
{
$values = [];
$query = "SELECT name, value FROM {$this->table_name}";
$args = [];
if (!empty($this->sub_column) && !empty($this->sub_value)) {
$query .= " WHERE {$this->sub_column} = :sub_value";
$args["sub_value"] = $this->sub_value;
}
foreach ($this->database->get_all($query, $args) as $row) {
$values[$row["name"]] = $row["value"];
}
return $values;
}
public function save(string $name = null): void
{
global $cache;
@@ -319,10 +352,10 @@ class DatabaseConfig extends BaseConfig
}
} else {
$query = "DELETE FROM {$this->table_name} WHERE name = :name";
$args = ["name"=>$name];
$args = ["name" => $name];
$cols = ["name","value"];
$params = [":name",":value"];
if (!empty($this->sub_column)&&!empty($this->sub_value)) {
if (!empty($this->sub_column) && !empty($this->sub_value)) {
$query .= " AND $this->sub_column = :sub_value";
$args["sub_value"] = $this->sub_value;
$cols[] = $this->sub_column;
@@ -331,7 +364,7 @@ class DatabaseConfig extends BaseConfig
$this->database->execute($query, $args);
$args["value"] =$this->values[$name];
$args["value"] = $this->values[$name];
$this->database->execute(
"INSERT INTO {$this->table_name} (".join(",", $cols).") VALUES (".join(",", $params).")",
$args

View File

@@ -7,6 +7,8 @@ namespace Shimmie2;
use FFSPHP\PDO;
use FFSPHP\PDOStatement;
require_once __DIR__ . '/exceptions.php';
enum DatabaseDriverID: string
{
case MYSQL = "mysql";
@@ -14,8 +16,28 @@ enum DatabaseDriverID: string
case SQLITE = "sqlite";
}
class DatabaseException extends SCoreException
{
public string $query;
/** @var array<string, mixed> */
public array $args;
/**
* @param array<string, mixed> $args
*/
public function __construct(string $msg, string $query, array $args)
{
parent::__construct($msg);
$this->error = $msg;
$this->query = $query;
$this->args = $args;
}
}
/**
* A class for controlled database access
*
* @phpstan-type QueryArgs array<string, string|int|bool|null>
*/
class Database
{
@@ -36,6 +58,7 @@ class Database
* How many queries this DB object has run
*/
public int $query_count = 0;
/** @var string[] */
public array $queries = [];
public function __construct(string $dsn)
@@ -57,7 +80,7 @@ class Database
private function connect_engine(): void
{
if (preg_match("/^([^:]*)/", $this->dsn, $matches)) {
$db_proto=$matches[1];
$db_proto = $matches[1];
} else {
throw new ServerError("Can't figure out database engine");
}
@@ -106,6 +129,28 @@ class Database
}
}
/**
* @template T
* @param callable():T $callback
* @return T
*/
public function with_savepoint(callable $callback, string $name = "sp"): mixed
{
global $_tracer;
try {
$_tracer->begin("Savepoint $name");
$this->execute("SAVEPOINT $name");
$ret = $callback();
$this->execute("RELEASE SAVEPOINT $name");
$_tracer->end();
return $ret;
} catch (\Exception $e) {
$this->execute("ROLLBACK TO SAVEPOINT $name");
$_tracer->end();
throw $e;
}
}
private function get_engine(): DBEngine
{
if (is_null($this->engine)) {
@@ -129,16 +174,18 @@ class Database
return $this->get_engine()->get_version($this->get_db());
}
/**
* @param QueryArgs $args
*/
private function count_time(string $method, float $start, string $query, ?array $args): void
{
global $_tracer, $tracer_enabled;
$dur = ftime() - $start;
// trim whitespace
$query = preg_replace('/[\n\t ]/m', ' ', $query);
$query = preg_replace('/ +/m', ' ', $query);
$query = preg_replace('/[\n\t ]+/m', ' ', $query);
$query = trim($query);
if ($tracer_enabled) {
$_tracer->complete($start * 1000000, $dur * 1000000, "DB Query", ["query"=>$query, "args"=>$args, "method"=>$method]);
$_tracer->complete($start * 1000000, $dur * 1000000, "DB Query", ["query" => $query, "args" => $args, "method" => $method]);
}
$this->queries[] = $query;
$this->query_count++;
@@ -150,31 +197,32 @@ class Database
$this->get_engine()->set_timeout($this->get_db(), $time);
}
public function notify(string $channel, ?string $data=null): void
public function notify(string $channel, ?string $data = null): void
{
$this->get_engine()->notify($this->get_db(), $channel, $data);
}
/**
* @param QueryArgs $args
*/
public function _execute(string $query, array $args = []): PDOStatement
{
try {
$ret = $this->get_db()->execute(
"-- " . str_replace("%2F", "/", urlencode($_GET['q'] ?? '')). "\n" .
$uri = $_SERVER['REQUEST_URI'] ?? "unknown uri";
return $this->get_db()->execute(
"-- $uri\n" .
$query,
$args
);
if ($ret === false) {
throw new SCoreException("Query failed", $query);
}
/** @noinspection PhpIncompatibleReturnTypeInspection */
return $ret;
} catch (\PDOException $pdoe) {
throw new SCoreException($pdoe->getMessage(), $query);
throw new DatabaseException($pdoe->getMessage(), $query, $args);
}
}
/**
* Execute an SQL query with no return
*
* @param QueryArgs $args
*/
public function execute(string $query, array $args = []): PDOStatement
{
@@ -186,6 +234,9 @@ class Database
/**
* Execute an SQL query and return a 2D array.
*
* @param QueryArgs $args
* @return array<array<string, mixed>>
*/
public function get_all(string $query, array $args = []): array
{
@@ -197,6 +248,8 @@ class Database
/**
* Execute an SQL query and return a iterable object for use with generators.
*
* @param QueryArgs $args
*/
public function get_all_iterable(string $query, array $args = []): PDOStatement
{
@@ -208,6 +261,9 @@ class Database
/**
* Execute an SQL query and return a single row.
*
* @param QueryArgs $args
* @return array<string, mixed>
*/
public function get_row(string $query, array $args = []): ?array
{
@@ -219,6 +275,9 @@ class Database
/**
* Execute an SQL query and return the first column of each row.
*
* @param QueryArgs $args
* @return array<mixed>
*/
public function get_col(string $query, array $args = []): array
{
@@ -230,6 +289,8 @@ class Database
/**
* Execute an SQL query and return the first column of each row as a single iterable object.
*
* @param QueryArgs $args
*/
public function get_col_iterable(string $query, array $args = []): \Generator
{
@@ -243,6 +304,9 @@ class Database
/**
* Execute an SQL query and return the the first column => the second column.
*
* @param QueryArgs $args
* @return array<string, mixed>
*/
public function get_pairs(string $query, array $args = []): array
{
@@ -255,6 +319,8 @@ class Database
/**
* Execute an SQL query and return the the first column => the second column as an iterable object.
*
* @param QueryArgs $args
*/
public function get_pairs_iterable(string $query, array $args = []): \Generator
{
@@ -268,8 +334,10 @@ class Database
/**
* Execute an SQL query and return a single value, or null.
*
* @param QueryArgs $args
*/
public function get_one(string $query, array $args = [])
public function get_one(string $query, array $args = []): mixed
{
$_start = ftime();
$row = $this->_execute($query, $args)->fetch();
@@ -279,13 +347,15 @@ class Database
/**
* Execute an SQL query and returns a bool indicating if any data was returned
*
* @param QueryArgs $args
*/
public function exists(string $query, array $args = []): bool
{
$_start = ftime();
$row = $this->_execute($query, $args)->fetch();
$this->count_time("exists", $_start, $query, $args);
if ($row==null) {
if ($row == null) {
return false;
}
return true;
@@ -345,7 +415,7 @@ class Database
return $this->get_db();
}
public function standardise_boolean(string $table, string $column, bool $include_postgres=false): void
public function standardise_boolean(string $table, string $column, bool $include_postgres = false): void
{
$d = $this->get_driver_id();
if ($d == DatabaseDriverID::MYSQL) {

View File

@@ -16,7 +16,7 @@ abstract class DBEngine
{
public DatabaseDriverID $id;
public function init(PDO $db)
public function init(PDO $db): void
{
}
@@ -30,18 +30,18 @@ abstract class DBEngine
return 'CREATE TABLE '.$name.' ('.$data.')';
}
abstract public function set_timeout(PDO $db, ?int $time);
abstract public function set_timeout(PDO $db, ?int $time): void;
abstract public function get_version(PDO $db): string;
abstract public function notify(PDO $db, string $channel, ?string $data=null): void;
abstract public function notify(PDO $db, string $channel, ?string $data = null): void;
}
class MySQL extends DBEngine
{
public DatabaseDriverID $id = DatabaseDriverID::MYSQL;
public function init(PDO $db)
public function init(PDO $db): void
{
$db->exec("SET NAMES utf8;");
}
@@ -66,7 +66,7 @@ class MySQL extends DBEngine
// $db->exec("SET SESSION MAX_EXECUTION_TIME=".$time.";");
}
public function notify(PDO $db, string $channel, ?string $data=null): void
public function notify(PDO $db, string $channel, ?string $data = null): void
{
}
@@ -80,7 +80,7 @@ class PostgreSQL extends DBEngine
{
public DatabaseDriverID $id = DatabaseDriverID::PGSQL;
public function init(PDO $db)
public function init(PDO $db): void
{
$addr = array_key_exists('REMOTE_ADDR', $_SERVER) ? get_real_ip() : 'local';
$db->exec("SET application_name TO 'shimmie [$addr]';");
@@ -110,7 +110,7 @@ class PostgreSQL extends DBEngine
$db->exec("SET statement_timeout TO ".$time.";");
}
public function notify(PDO $db, string $channel, ?string $data=null): void
public function notify(PDO $db, string $channel, ?string $data = null): void
{
if ($data) {
$db->exec("NOTIFY $channel, '$data';");
@@ -126,7 +126,7 @@ class PostgreSQL extends DBEngine
}
// shimmie functions for export to sqlite
function _unix_timestamp($date): int
function _unix_timestamp(string $date): int
{
return \Safe\strtotime($date);
}
@@ -134,31 +134,31 @@ function _now(): string
{
return date("Y-m-d H:i:s");
}
function _floor($a): float
function _floor(float|int $a): float
{
return floor($a);
}
function _log($a, $b=null): float
function _log(float $a, ?float $b = null): float
{
if (is_null($b)) {
return log($a);
} else {
return log($a, $b);
return log($b, $a);
}
}
function _isnull($a): bool
function _isnull(mixed $a): bool
{
return is_null($a);
}
function _md5($a): string
function _md5(string $a): string
{
return md5($a);
}
function _concat($a, $b): string
function _concat(string $a, string $b): string
{
return $a . $b;
}
function _lower($a): string
function _lower(string $a): string
{
return strtolower($a);
}
@@ -166,7 +166,7 @@ function _rand(): int
{
return rand();
}
function _ln($n): float
function _ln(float $n): float
{
return log($n);
}
@@ -175,7 +175,7 @@ class SQLite extends DBEngine
{
public DatabaseDriverID $id = DatabaseDriverID::SQLITE;
public function init(PDO $db)
public function init(PDO $db): void
{
ini_set('sqlite.assoc_case', '0');
$db->exec("PRAGMA foreign_keys = ON;");
@@ -222,7 +222,7 @@ class SQLite extends DBEngine
// There doesn't seem to be such a thing for SQLite, so it does nothing
}
public function notify(PDO $db, string $channel, ?string $data=null): void
public function notify(PDO $db, string $channel, ?string $data = null): void
{
}

View File

@@ -77,11 +77,11 @@ class PageRequestEvent extends Event
parent::__construct();
global $config;
// trim starting slashes
$path = ltrim($path, "/");
$this->method = $method;
// if path is not specified, use the default front page
if (empty($path)) { /* empty is faster than strlen */
// if we're looking at the root of the install,
// use the default front page
if ($path == "") {
$path = $config->get_string(SetupConfig::FRONT_PAGE);
}
$this->path = $path;
@@ -258,73 +258,12 @@ class PageRequestEvent extends Event
}
/**
* Sent when index.php is called from the command line
*/
class CommandEvent extends Event
class CliGenEvent extends Event
{
public string $cmd = "help";
/**
* @var string[]
*/
public array $args = [];
/**
* #param string[] $args
*/
public function __construct(array $args)
{
public function __construct(
public \Symfony\Component\Console\Application $app
) {
parent::__construct();
global $user;
$opts = [];
$log_level = SCORE_LOG_WARNING;
$arg_count = count($args);
for ($i=1; $i<$arg_count; $i++) {
switch ($args[$i]) {
case '-u':
$user = User::by_name($args[++$i]);
if (is_null($user)) {
die("Unknown user");
} else {
send_event(new UserLoginEvent($user));
}
break;
case '-q':
$log_level += 10;
break;
case '-v':
$log_level -= 10;
break;
default:
$opts[] = $args[$i];
break;
}
}
if (!defined("CLI_LOG_LEVEL")) {
define("CLI_LOG_LEVEL", $log_level);
}
if (count($opts) > 0) {
$this->cmd = $opts[0];
$this->args = array_slice($opts, 1);
} else {
print "\n";
print "Usage: php {$args[0]} [flags] [command]\n";
print "\n";
print "Flags:\n";
print "\t-u [username]\n";
print "\t\tLog in as the specified user\n";
print "\t-q / -v\n";
print "\t\tBe quieter / more verbose\n";
print "\t\tScale is debug - info - warning - error - critical\n";
print "\t\tDefault is to show warnings and above\n";
print "\n";
print "Currently known commands:\n";
}
}
}

View File

@@ -9,15 +9,13 @@ namespace Shimmie2;
*/
class SCoreException extends \RuntimeException
{
public ?string $query;
public string $error;
public int $http_code = 500;
public function __construct(string $msg, ?string $query=null)
public function __construct(string $msg)
{
parent::__construct($msg);
$this->error = $msg;
$this->query = $query;
}
}

View File

@@ -22,9 +22,10 @@ abstract class Extension
protected Themelet $theme;
public ExtensionInfo $info;
/** @var string[] */
private static array $enabled_extensions = [];
public function __construct($class = null)
public function __construct(?string $class = null)
{
$class = $class ?? get_called_class();
$this->theme = $this->get_theme_object($class);
@@ -42,9 +43,13 @@ abstract class Extension
$normal = "Shimmie2\\{$base}Theme";
if (class_exists($custom)) {
return new $custom();
$c = new $custom();
assert(is_a($c, Themelet::class));
return $c;
} elseif (class_exists($normal)) {
return new $normal();
$n = new $normal();
assert(is_a($n, Themelet::class));
return $n;
} else {
return new Themelet();
}
@@ -69,7 +74,7 @@ abstract class Extension
$extras
) as $key) {
$ext = ExtensionInfo::get_by_key($key);
if ($ext===null || !$ext->is_supported()) {
if ($ext === null || !$ext->is_supported()) {
continue;
}
// FIXME: error if one of our dependencies isn't supported
@@ -82,11 +87,14 @@ abstract class Extension
}
}
public static function is_enabled(string $key): ?bool
public static function is_enabled(string $key): bool
{
return in_array($key, self::$enabled_extensions);
}
/**
* @return string[]
*/
public static function get_enabled_extensions(): array
{
return self::$enabled_extensions;
@@ -102,7 +110,7 @@ abstract class Extension
return $config->get_int($name, 0);
}
protected function set_version(string $name, int $ver)
protected function set_version(string $name, int $ver): void
{
global $config;
$config->set_int($name, $ver);
@@ -110,6 +118,10 @@ abstract class Extension
}
}
class ExtensionNotFound extends SCoreException
{
}
enum ExtensionVisibility
{
case DEFAULT;
@@ -135,7 +147,7 @@ abstract class ExtensionInfo
public const SHISH_NAME = "Shish";
public const SHISH_EMAIL = "webmaster@shishnet.org";
public const SHIMMIE_URL = "https://code.shishnet.org/shimmie2/";
public const SHISH_AUTHOR = [self::SHISH_NAME=>self::SHISH_EMAIL];
public const SHISH_AUTHOR = [self::SHISH_NAME => self::SHISH_EMAIL];
public const LICENSE_GPLV2 = "GPLv2";
public const LICENSE_MIT = "MIT";
@@ -149,8 +161,11 @@ abstract class ExtensionInfo
public string $name;
public string $license;
public string $description;
/** @var array<string, string|null> */
public array $authors = [];
/** @var string[] */
public array $dependencies = [];
/** @var string[] */
public array $conflicts = [];
public ExtensionVisibility $visibility = ExtensionVisibility::DEFAULT;
public ExtensionCategory $category = ExtensionCategory::GENERAL;
@@ -164,7 +179,7 @@ abstract class ExtensionInfo
public function is_supported(): bool
{
if ($this->supported===null) {
if ($this->supported === null) {
$this->check_support();
}
return $this->supported;
@@ -172,14 +187,17 @@ abstract class ExtensionInfo
public function get_support_info(): string
{
if ($this->supported===null) {
if ($this->supported === null) {
$this->check_support();
}
return $this->support_info;
}
/** @var array<string, ExtensionInfo> */
private static array $all_info_by_key = [];
/** @var array<string, ExtensionInfo> */
private static array $all_info_by_class = [];
/** @var string[] */
private static array $core_extensions = [];
protected function __construct()
@@ -196,7 +214,7 @@ abstract class ExtensionInfo
return Extension::is_enabled($this->key);
}
private function check_support()
private function check_support(): void
{
global $database;
$this->support_info = "";
@@ -215,16 +233,25 @@ abstract class ExtensionInfo
$this->supported = empty($this->support_info);
}
/**
* @return ExtensionInfo[]
*/
public static function get_all(): array
{
return array_values(self::$all_info_by_key);
}
/**
* @return string[]
*/
public static function get_all_keys(): array
{
return array_keys(self::$all_info_by_key);
}
/**
* @return string[]
*/
public static function get_core_extensions(): array
{
return self::$core_extensions;
@@ -247,21 +274,22 @@ abstract class ExtensionInfo
return self::$all_info_by_class[$normal];
} else {
$infos = print_r(array_keys(self::$all_info_by_class), true);
throw new SCoreException("$normal not found in {$infos}");
throw new ExtensionNotFound("$normal not found in {$infos}");
}
}
public static function load_all_extension_info()
public static function load_all_extension_info(): void
{
foreach (get_subclasses_of("Shimmie2\ExtensionInfo") as $class) {
foreach (get_subclasses_of(ExtensionInfo::class) as $class) {
$extension_info = new $class();
assert(is_a($extension_info, ExtensionInfo::class));
if (array_key_exists($extension_info->key, self::$all_info_by_key)) {
throw new ServerError("Extension Info $class with key $extension_info->key has already been loaded");
}
self::$all_info_by_key[$extension_info->key] = $extension_info;
self::$all_info_by_class[$class] = $extension_info;
if ($extension_info->core===true) {
if ($extension_info->core === true) {
self::$core_extensions[] = $extension_info->key;
}
}
@@ -275,7 +303,7 @@ abstract class ExtensionInfo
*/
abstract class FormatterExtension extends Extension
{
public function onTextFormatting(TextFormattingEvent $event)
public function onTextFormatting(TextFormattingEvent $event): void
{
$event->formatted = $this->format($event->formatted);
$event->stripped = $this->strip($event->stripped);
@@ -293,9 +321,10 @@ abstract class FormatterExtension extends Extension
*/
abstract class DataHandlerExtension extends Extension
{
/** @var string[] */
protected array $SUPPORTED_MIME = [];
protected function move_upload_to_archive(DataUploadEvent $event)
public function onDataUpload(DataUploadEvent $event): void
{
global $config;
@@ -356,72 +385,18 @@ abstract class DataHandlerExtension extends Extension
}
}
public function onDataUpload(DataUploadEvent $event)
{
$supported_mime = $this->supported_mime($event->mime);
$check_contents = $this->check_contents($event->tmpname);
if ($supported_mime && $check_contents) {
$this->move_upload_to_archive($event);
send_event(new ThumbnailGenerationEvent($event->hash, $event->mime));
/* Check if we are replacing an image */
if (!is_null($event->replace_id)) {
/* hax: This seems like such a dirty way to do this.. */
/* Check to make sure the image exists. */
$existing = Image::by_id($event->replace_id);
if (is_null($existing)) {
throw new UploadException("Post to replace does not exist!");
}
if ($existing->hash === $event->hash) {
throw new UploadException("The uploaded post is the same as the one to replace.");
}
// even more hax..
$event->metadata['tags'] = $existing->get_tag_list();
$image = $this->create_image_from_data(warehouse_path(Image::IMAGE_DIR, $event->hash), $event->metadata);
send_event(new ImageReplaceEvent($event->replace_id, $image));
$_id = $event->replace_id;
assert(!is_null($_id));
$event->image_id = $_id;
} else {
$image = $this->create_image_from_data(warehouse_path(Image::IMAGE_DIR, $event->hash), $event->metadata);
$iae = send_event(new ImageAdditionEvent($image));
$event->image_id = $iae->image->id;
$event->merged = $iae->merged;
// Rating Stuff.
if (!empty($event->metadata['rating'])) {
$rating = $event->metadata['rating'];
send_event(new RatingSetEvent($image, $rating));
}
// Locked Stuff.
if (!empty($event->metadata['locked'])) {
$locked = $event->metadata['locked'];
send_event(new LockSetEvent($image, $locked));
}
}
} elseif ($supported_mime && !$check_contents) {
// We DO support this extension - but the file looks corrupt
throw new UploadException("Invalid or corrupted file");
}
}
public function onThumbnailGeneration(ThumbnailGenerationEvent $event)
public function onThumbnailGeneration(ThumbnailGenerationEvent $event): void
{
$result = false;
if ($this->supported_mime($event->mime)) {
if ($this->supported_mime($event->image->get_mime())) {
if ($event->force) {
$result = $this->create_thumb($event->hash, $event->mime);
$result = $this->create_thumb($event->image);
} else {
$outname = warehouse_path(Image::THUMBNAIL_DIR, $event->hash);
$outname = $event->image->get_thumb_filename();
if (file_exists($outname)) {
return;
}
$result = $this->create_thumb($event->hash, $event->mime);
$result = $this->create_thumb($event->image);
}
}
if ($result) {
@@ -429,65 +404,48 @@ abstract class DataHandlerExtension extends Extension
}
}
public function onDisplayingImage(DisplayingImageEvent $event)
public function onDisplayingImage(DisplayingImageEvent $event): void
{
global $page;
global $config, $page;
if ($this->supported_mime($event->image->get_mime())) {
// @phpstan-ignore-next-line
$this->theme->display_image($page, $event->image);
$this->theme->display_image($event->image);
if ($config->get_bool(ImageConfig::SHOW_META) && method_exists($this->theme, "display_metadata")) {
$this->theme->display_metadata($event->image);
}
}
}
public function onMediaCheckProperties(MediaCheckPropertiesEvent $event)
public function onMediaCheckProperties(MediaCheckPropertiesEvent $event): void
{
if ($this->supported_mime($event->image->get_mime())) {
$this->media_check_properties($event);
}
}
protected function create_image_from_data(string $filename, array $metadata): Image
{
$image = new Image();
assert(is_readable($filename));
$image->filesize = filesize($filename);
$image->hash = md5_file($filename);
$image->filename = (($pos = strpos($metadata['filename'], '?')) !== false) ? substr($metadata['filename'], 0, $pos) : $metadata['filename'];
$image->set_mime(MimeType::get_for_file($filename, get_file_ext($metadata["filename"]) ?? null));
$image->tag_array = is_array($metadata['tags']) ? $metadata['tags'] : Tag::explode($metadata['tags']);
$image->source = $metadata['source'];
if (empty($image->get_mime())) {
throw new UploadException("Unable to determine MIME for $filename");
}
try {
send_event(new MediaCheckPropertiesEvent($image));
} catch (MediaException $e) {
throw new UploadException("Unable to scan media properties $filename / $image->filename / $image->hash: ".$e->getMessage());
}
return $image;
}
abstract protected function media_check_properties(MediaCheckPropertiesEvent $event): void;
abstract protected function check_contents(string $tmpname): bool;
abstract protected function create_thumb(string $hash, string $mime): bool;
abstract protected function create_thumb(Image $image): bool;
protected function supported_mime(string $mime): bool
{
return MimeType::matches_array($mime, $this->SUPPORTED_MIME);
}
/**
* @return string[]
*/
public static function get_all_supported_mimes(): array
{
$arr = [];
foreach (get_subclasses_of("Shimmie2\DataHandlerExtension") as $handler) {
foreach (get_subclasses_of(DataHandlerExtension::class) as $handler) {
$handler = (new $handler());
assert(is_a($handler, DataHandlerExtension::class));
$arr = array_merge($arr, $handler->SUPPORTED_MIME);
}
// Not sure how to handle this otherwise, don't want to set up a whole other event for this one class
if (class_exists("Shimmie2\TranscodeImage")) {
if (Extension::is_enabled(TranscodeImageInfo::KEY)) {
$arr = array_merge($arr, TranscodeImage::get_enabled_mimes());
}
@@ -495,6 +453,9 @@ abstract class DataHandlerExtension extends Extension
return $arr;
}
/**
* @return string[]
*/
public static function get_all_supported_exts(): array
{
$arr = [];

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use MicroCRUD\TextColumn;
use function MicroHTML\INPUT;
class AutoCompleteColumn extends TextColumn
{
public function read_input(array $inputs): \MicroHTML\HTMLElement
{
return INPUT([
"type" => "text",
"name" => "r_{$this->name}",
"class" => "autocomplete_tags",
"placeholder" => $this->title,
"value" => @$inputs["r_{$this->name}"]
]);
}
public function create_input(array $inputs): \MicroHTML\HTMLElement
{
return INPUT([
"type" => "text",
"name" => "c_{$this->name}",
"class" => "autocomplete_tags",
"placeholder" => $this->title,
"value" => @$inputs["c_{$this->name}"]
]);
}
}

View File

@@ -9,9 +9,6 @@ namespace Shimmie2;
*/
class ImageAdditionEvent extends Event
{
public User $user;
public bool $merged = false;
/**
* A new image is being added to the database - just the image,
* metadata will come later with ImageInfoSetEvent (and if that
@@ -24,10 +21,6 @@ class ImageAdditionEvent extends Event
}
}
class ImageAdditionException extends SCoreException
{
}
/**
* An image is being deleted.
*/
@@ -52,18 +45,25 @@ class ImageDeletionEvent extends Event
*/
class ImageReplaceEvent extends Event
{
public string $old_hash;
public string $new_hash;
/**
* Replaces an image.
* Replaces an image file.
*
* Updates an existing ID in the database to use a new image
* file, leaving the tags and such unchanged. Also removes
* the old image file and thumbnail from the disk.
*/
public function __construct(
public int $id,
public Image $image
public Image $image,
public string $tmp_filename,
) {
parent::__construct();
$this->old_hash = $image->hash;
$hash = md5_file($tmp_filename);
assert($hash !== false, "Failed to hash file $tmp_filename");
$this->new_hash = $hash;
}
}
@@ -82,9 +82,8 @@ class ThumbnailGenerationEvent extends Event
* Request a thumbnail be made for an image object
*/
public function __construct(
public string $hash,
public string $mime,
public bool $force=false
public Image $image,
public bool $force = false
) {
parent::__construct();
$this->generated = false;

View File

@@ -8,6 +8,13 @@ use GQLA\Type;
use GQLA\Field;
use GQLA\Query;
enum ImagePropType
{
case BOOL;
case INT;
case STRING;
}
/**
* Class Image
*
@@ -16,15 +23,18 @@ use GQLA\Query;
* As of 2.2, this no longer necessarily represents an
* image per se, but could be a video, sound file, or any
* other supported upload type.
*
* @implements \ArrayAccess<string, mixed>
*/
#[\AllowDynamicProperties]
#[Type(name: "Post")]
class Image
class Image implements \ArrayAccess
{
public const IMAGE_DIR = "images";
public const THUMBNAIL_DIR = "thumbs";
public ?int $id = null;
private bool $in_db = false;
public int $id;
#[Field]
public int $height = 0;
#[Field]
@@ -44,9 +54,9 @@ class Image
public int $owner_id;
public string $owner_ip;
#[Field]
public ?string $posted = null;
public string $posted;
#[Field]
public ?string $source;
public ?string $source = null;
#[Field]
public bool $locked = false;
public ?bool $lossless = null;
@@ -55,18 +65,25 @@ class Image
public ?bool $image = null;
public ?bool $audio = null;
public ?int $length = null;
public ?string $tmp_file = null;
public static array $bool_props = ["locked", "lossless", "video", "audio", "image"];
public static array $int_props = ["id", "owner_id", "height", "width", "filesize", "length"];
/** @var array<string, ImagePropType> */
public static array $prop_types = [];
/** @var array<string, mixed> */
private array $dynamic_props = [];
/**
* One will very rarely construct an image directly, more common
* would be to use Image::by_id, Image::by_hash, etc.
*
* @param array<string|int, mixed>|null $row
*/
public function __construct(?array $row=null)
public function __construct(?array $row = null)
{
if (!is_null($row)) {
foreach ($row as $name => $value) {
// some databases return both key=>value and numeric indices,
// we only want the key=>value ones
if (is_numeric($name)) {
continue;
} elseif (property_exists($this, $name)) {
@@ -106,6 +123,7 @@ class Image
}
}
}
$this->in_db = true;
}
}
@@ -149,11 +167,11 @@ class Image
public static function by_id(int $post_id): ?Image
{
global $database;
if ($post_id > 2**32) {
if ($post_id > 2 ** 32) {
// for some reason bots query huge numbers and pollute the DB error logs...
return null;
}
$row = $database->get_row("SELECT * FROM images WHERE images.id=:id", ["id"=>$post_id]);
$row = $database->get_row("SELECT * FROM images WHERE images.id=:id", ["id" => $post_id]);
return ($row ? new Image($row) : null);
}
@@ -170,26 +188,29 @@ class Image
{
global $database;
$hash = strtolower($hash);
$row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", ["hash"=>$hash]);
$row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", ["hash" => $hash]);
return ($row ? new Image($row) : null);
}
public static function by_id_or_hash(string $id): ?Image
{
return (is_numeric($id) && strlen($id) != 32) ? Image::by_id((int)$id) : Image::by_hash($id);
return (is_numberish($id) && strlen($id) != 32) ? Image::by_id((int)$id) : Image::by_hash($id);
}
public static function by_random(array $tags=[], int $limit_range=0): ?Image
/**
* @param string[] $tags
*/
public static function by_random(array $tags = [], int $limit_range = 0): ?Image
{
$max = Image::count_images($tags);
$max = Search::count_images($tags);
if ($max < 1) {
return null;
} // From Issue #22 - opened by HungryFeline on May 30, 2011.
} // From Issue #22 - opened by HungryFeline on May 30, 2011.
if ($limit_range > 0 && $max > $limit_range) {
$max = $limit_range;
}
$rand = mt_rand(0, $max-1);
$set = Image::find_images($rand, 1, $tags);
$rand = mt_rand(0, $max - 1);
$set = Search::find_images($rand, 1, $tags);
if (count($set) > 0) {
return $set[0];
} else {
@@ -197,136 +218,6 @@ class Image
}
}
private static function find_images_internal(int $start = 0, ?int $limit = null, array $tags=[]): iterable
{
global $database, $user;
if ($start < 0) {
$start = 0;
}
if ($limit !== null && $limit < 1) {
$limit = 1;
}
if (SPEED_HAX) {
if (!$user->can(Permissions::BIG_SEARCH) and count($tags) > 3) {
throw new PermissionDeniedException("Anonymous users may only search for up to 3 tags at a time");
}
}
$querylet = Image::build_search_querylet($tags, $limit, $start);
return $database->get_all_iterable($querylet->sql, $querylet->variables);
}
/**
* Search for an array of images
*
* @param string[] $tags
* @return Image[]
*/
#[Query(name: "posts", type: "[Post!]!", args: ["tags" => "[string!]"])]
public static function find_images(int $offset = 0, ?int $limit = null, array $tags=[]): array
{
$result = self::find_images_internal($offset, $limit, $tags);
$images = [];
foreach ($result as $row) {
$images[] = new Image($row);
}
return $images;
}
/**
* Search for an array of images, returning a iterable object of Image
*/
public static function find_images_iterable(int $start = 0, ?int $limit = null, array $tags=[]): \Generator
{
$result = self::find_images_internal($start, $limit, $tags);
foreach ($result as $row) {
yield new Image($row);
}
}
/*
* Image-related utility functions
*/
public static function count_total_images(): int
{
global $cache, $database;
$total = $cache->get("image-count");
if (is_null($total)) {
$total = (int)$database->get_one("SELECT COUNT(*) FROM images");
$cache->set("image-count", $total, 600);
}
return $total;
}
public static function count_tag(string $tag): int
{
global $database;
return (int)$database->get_one(
"SELECT count FROM tags WHERE LOWER(tag) = LOWER(:tag)",
["tag"=>$tag]
);
}
/**
* Count the number of image results for a given search
*
* @param String[] $tags
*/
public static function count_images(array $tags=[]): int
{
global $cache, $database;
$tag_count = count($tags);
if (SPEED_HAX && $tag_count === 0) {
// total number of images in the DB
$total = self::count_total_images();
} elseif (SPEED_HAX && $tag_count === 1 && !preg_match("/[:=><\*\?]/", $tags[0])) {
if (!str_starts_with($tags[0], "-")) {
// one tag - we can look that up directly
$total = self::count_tag($tags[0]);
} else {
// one negative tag - subtract from the total
$total = self::count_total_images() - self::count_tag(substr($tags[0], 1));
}
} else {
// complex query
// implode(tags) can be too long for memcache...
$cache_key = "image-count:" . md5(Tag::implode($tags));
$total = $cache->get($cache_key);
if (is_null($total)) {
if (Extension::is_enabled(RatingsInfo::KEY)) {
$tags[] = "rating:*";
}
$querylet = Image::build_search_querylet($tags);
$total = (int)$database->get_one("SELECT COUNT(*) AS cnt FROM ($querylet->sql) AS tbl", $querylet->variables);
if (SPEED_HAX && $total > 5000) {
// when we have a ton of images, the count
// won't change dramatically very often
$cache->set($cache_key, $total, 3600);
}
}
}
if (is_null($total)) {
return 0;
}
return $total;
}
/**
* Count the number of pages for a given search
*
* @param String[] $tags
*/
public static function count_pages(array $tags=[]): int
{
global $config;
return (int)ceil(Image::count_images($tags) / $config->get_int(IndexConfig::IMAGES));
}
/*
* Accessors & mutators
*/
@@ -337,9 +228,9 @@ class Image
* Rather than simply $this_id + 1, one must take into account
* deleted images and search queries
*
* @param String[] $tags
* @param string[] $tags
*/
public function get_next(array $tags=[], bool $next=true): ?Image
public function get_next(array $tags = [], bool $next = true): ?Image
{
global $database;
@@ -351,31 +242,18 @@ class Image
$dir = "ASC";
}
if (count($tags) === 0) {
$row = $database->get_row('
SELECT images.*
FROM images
WHERE images.id '.$gtlt.' '.$this->id.'
ORDER BY images.id '.$dir.'
LIMIT 1
');
} else {
$tags[] = 'id'. $gtlt . $this->id;
$tags[] = 'order:id_'. strtolower($dir);
$querylet = Image::build_search_querylet($tags);
$querylet->append_sql(' LIMIT 1');
$row = $database->get_row($querylet->sql, $querylet->variables);
}
return ($row ? new Image($row) : null);
$tags[] = 'id'. $gtlt . $this->id;
$tags[] = 'order:id_'. strtolower($dir);
$images = Search::find_images(0, 1, $tags);
return (count($images) > 0) ? $images[0] : null;
}
/**
* The reverse of get_next
*
* @param String[] $tags
* @param string[] $tags
*/
public function get_prev(array $tags=[]): ?Image
public function get_prev(array $tags = []): ?Image
{
return $this->get_next($tags, false);
}
@@ -386,7 +264,9 @@ class Image
#[Field(name: "owner")]
public function get_owner(): User
{
return User::by_id($this->owner_id);
$user = User::by_id($this->owner_id);
assert(!is_null($user));
return $user;
}
/**
@@ -397,92 +277,73 @@ class Image
global $database;
if ($owner->id != $this->owner_id) {
$database->execute("
UPDATE images
SET owner_id=:owner_id
WHERE id=:id
", ["owner_id"=>$owner->id, "id"=>$this->id]);
UPDATE images
SET owner_id=:owner_id
WHERE id=:id
", ["owner_id" => $owner->id, "id" => $this->id]);
log_info("core_image", "Owner for Post #{$this->id} set to {$owner->name}");
}
}
public function save_to_db()
public function save_to_db(): void
{
global $database, $user;
$cut_name = substr($this->filename, 0, 255);
if (is_null($this->posted) || $this->posted == "") {
$this->posted = date('Y-m-d H:i:s', time());
}
$props_to_save = [
"filename" => substr($this->filename, 0, 255),
"filesize" => $this->filesize,
"hash" => $this->hash,
"mime" => strtolower($this->mime),
"ext" => strtolower($this->ext),
"source" => $this->source,
"width" => $this->width,
"height" => $this->height,
"lossless" => $this->lossless,
"video" => $this->video,
"video_codec" => $this->video_codec,
"image" => $this->image,
"audio" => $this->audio,
"length" => $this->length
];
if (!$this->in_db) {
$props_to_save["owner_id"] = $user->id;
$props_to_save["owner_ip"] = get_real_ip();
$props_to_save["posted"] = date('Y-m-d H:i:s', time());
$props_sql = implode(", ", array_keys($props_to_save));
$vals_sql = implode(", ", array_map(fn ($prop) => ":$prop", array_keys($props_to_save)));
if (is_null($this->id)) {
$database->execute(
"INSERT INTO images(
owner_id, owner_ip,
filename, filesize,
hash, mime, ext,
width, height,
posted, source
)
VALUES (
:owner_id, :owner_ip,
:filename, :filesize,
:hash, :mime, :ext,
0, 0,
:posted, :source
)",
[
"owner_id" => $user->id, "owner_ip" => get_real_ip(),
"filename" => $cut_name, "filesize" => $this->filesize,
"hash" => $this->hash, "mime" => strtolower($this->mime),
"ext" => strtolower($this->ext),
"posted" => $this->posted, "source" => $this->source
]
"INSERT INTO images($props_sql) VALUES ($vals_sql)",
$props_to_save,
);
$this->id = $database->get_last_insert_id('images_id_seq');
$this->in_db = true;
} else {
$props_sql = implode(", ", array_map(fn ($prop) => "$prop = :$prop", array_keys($props_to_save)));
$database->execute(
"UPDATE images SET ".
"filename = :filename, filesize = :filesize, hash = :hash, ".
"mime = :mime, ext = :ext, width = 0, height = 0, ".
"posted = :posted, source = :source ".
"WHERE id = :id",
[
"filename" => $cut_name,
"filesize" => $this->filesize,
"hash" => $this->hash,
"mime" => strtolower($this->mime),
"ext" => strtolower($this->ext),
"posted" => $this->posted,
"source" => $this->source,
"id" => $this->id,
]
"UPDATE images SET $props_sql WHERE id = :id",
array_merge(
$props_to_save,
["id" => $this->id]
)
);
}
$database->execute(
"UPDATE images SET ".
"lossless = :lossless, ".
"video = :video, video_codec = :video_codec, audio = :audio,image = :image, ".
"height = :height, width = :width, ".
"length = :length WHERE id = :id",
[
"id" => $this->id,
"width" => $this->width ?? 0,
"height" => $this->height ?? 0,
"lossless" => $this->lossless,
"video" => $this->video,
"video_codec" => $this->video_codec,
"image" => $this->image,
"audio" => $this->audio,
"length" => $this->length
]
);
// For the future: automatically save dynamic props instead of
// requiring each extension to do it manually.
/*
$props_sql = "UPDATE images SET ";
$props_sql .= implode(", ", array_map(fn ($prop) => "$prop = :$prop", array_keys($this->dynamic_props)));
$props_sql .= " WHERE id = :id";
$database->execute($props_sql, array_merge($this->dynamic_props, ["id" => $this->id]));
*/
}
/**
* Get this image's tags as an array.
*
* @return String[]
* @return string[]
*/
#[Field(name: "tags", type: "[string!]!")]
public function get_tag_array(): array
@@ -490,12 +351,12 @@ class Image
global $database;
if (!isset($this->tag_array)) {
$this->tag_array = $database->get_col("
SELECT tag
FROM image_tags
JOIN tags ON image_tags.tag_id = tags.id
WHERE image_id=:id
ORDER BY tag
", ["id"=>$this->id]);
SELECT tag
FROM image_tags
JOIN tags ON image_tags.tag_id = tags.id
WHERE image_id=:id
ORDER BY tag
", ["id" => $this->id]);
sort($this->tag_array);
}
return $this->tag_array;
@@ -616,22 +477,18 @@ class Image
* Get the image's mime type.
*/
#[Field(name: "mime")]
public function get_mime(): ?string
public function get_mime(): string
{
if ($this->mime===MimeType::WEBP&&$this->lossless) {
if ($this->mime === MimeType::WEBP && $this->lossless) {
return MimeType::WEBP_LOSSLESS;
}
$m = $this->mime;
if (is_null($m)) {
$m = MimeMap::get_for_extension($this->ext)[0];
}
return $m;
return strtolower($this->mime);
}
/**
* Set the image's mime type.
*/
public function set_mime($mime): void
public function set_mime(string $mime): void
{
$this->mime = $mime;
$ext = FileExtension::get_for_mime($this->get_mime());
@@ -659,7 +516,7 @@ class Image
$new_source = null;
}
if ($new_source != $old_source) {
$database->execute("UPDATE images SET source=:source WHERE id=:id", ["source"=>$new_source, "id"=>$this->id]);
$database->execute("UPDATE images SET source=:source WHERE id=:id", ["source" => $new_source, "id" => $this->id]);
log_info("core_image", "Source for Post #{$this->id} set to: $new_source (was $old_source)");
}
}
@@ -676,8 +533,9 @@ class Image
{
global $database;
if ($locked !== $this->locked) {
$database->execute("UPDATE images SET locked=:yn WHERE id=:id", ["yn"=>$locked, "id"=>$this->id]);
log_info("core_image", "Setting Post #{$this->id} lock to: $locked");
$database->execute("UPDATE images SET locked=:yn WHERE id=:id", ["yn" => $locked, "id" => $this->id]);
$s = $locked ? "locked" : "unlocked";
log_info("core_image", "Setting Post #{$this->id} to $s");
}
}
@@ -697,39 +555,38 @@ class Image
FROM image_tags
WHERE image_id = :id
)
", ["id"=>$this->id]);
", ["id" => $this->id]);
$database->execute("
DELETE
FROM image_tags
WHERE image_id=:id
", ["id"=>$this->id]);
DELETE
FROM image_tags
WHERE image_id=:id
", ["id" => $this->id]);
}
/**
* Set the tags for this image.
*
* @param string[] $unfiltered_tags
*/
public function set_tags(array $unfiltered_tags): void
{
global $cache, $database, $page;
$unfiltered_tags = array_unique($unfiltered_tags);
$tags = array_unique($unfiltered_tags);
$tags = [];
foreach ($unfiltered_tags as $tag) {
foreach ($tags as $tag) {
if (mb_strlen($tag, 'UTF-8') > 255) {
$page->flash("Can't set a tag longer than 255 characters");
continue;
throw new TagSetException("Can't set a tag longer than 255 characters");
}
if (str_starts_with($tag, "-")) {
$page->flash("Can't set a tag which starts with a minus");
continue;
throw new TagSetException("Can't set a tag which starts with a minus");
}
if (str_contains($tag, "*")) {
throw new TagSetException("Can't set a tag which contains a wildcard (*)");
}
$tags[] = $tag;
}
if (count($tags) <= 0) {
throw new SCoreException('Tried to set zero tags');
throw new TagSetException('Tried to set zero tags');
}
if (strtolower(Tag::implode($tags)) != strtolower($this->get_tag_list())) {
@@ -748,7 +605,7 @@ class Image
FROM image_tags
WHERE image_id = :id
)
", ["id"=>$this->id]);
", ["id" => $this->id]);
log_info("core_image", "Tags for Post #{$this->id} set to: ".Tag::implode($tags));
$cache->delete("image-{$this->id}-tags");
@@ -762,18 +619,16 @@ class Image
{
global $database;
$this->delete_tags_from_image();
$database->execute("DELETE FROM images WHERE id=:id", ["id"=>$this->id]);
$database->execute("DELETE FROM images WHERE id=:id", ["id" => $this->id]);
log_info("core_image", 'Deleted Post #'.$this->id.' ('.$this->hash.')');
unlink($this->get_image_filename());
unlink($this->get_thumb_filename());
$this->remove_image_only(quiet: true);
}
/**
* This function removes an image (and thumbnail) from the DISK ONLY.
* It DOES NOT remove anything from the database.
*/
public function remove_image_only(): void
public function remove_image_only(bool $quiet = false): void
{
$img_del = @unlink($this->get_image_filename());
$thumb_del = @unlink($this->get_thumb_filename());
@@ -788,229 +643,10 @@ class Image
}
}
public function parse_link_template(string $tmpl, int $n=0): string
public function parse_link_template(string $tmpl, int $n = 0): string
{
$plte = send_event(new ParseLinkTemplateEvent($tmpl, $this));
$tmpl = $plte->link;
return load_balance_url($tmpl, $this->hash, $n);
}
private static function tag_or_wildcard_to_ids(string $tag): array
{
global $database;
$sq = "SELECT id FROM tags WHERE LOWER(tag) LIKE LOWER(:tag)";
if ($database->get_driver_id() === DatabaseDriverID::SQLITE) {
$sq .= "ESCAPE '\\'";
}
return $database->get_col($sq, ["tag" => Tag::sqlify($tag)]);
}
/**
* @param String[] $terms
*/
private static function build_search_querylet(
array $terms,
?int $limit=null,
?int $offset=null
): Querylet {
global $config;
$tag_conditions = [];
$img_conditions = [];
$order = null;
/*
* Turn a bunch of strings into a bunch of TagCondition
* and ImgCondition objects
*/
$stpen = 0; // search term parse event number
foreach (array_merge([null], $terms) as $term) {
$stpe = send_event(new SearchTermParseEvent($stpen++, $term, $terms));
$order ??= $stpe->order;
$img_conditions = array_merge($img_conditions, $stpe->img_conditions);
$tag_conditions = array_merge($tag_conditions, $stpe->tag_conditions);
}
$order = ($order ?: "images.".$config->get_string(IndexConfig::ORDER));
/*
* Turn a bunch of Querylet objects into a base query
*
* Must follow the format
*
* SELECT images.*
* FROM (...) AS images
* WHERE (...)
*
* ie, return a set of images.* columns, and end with a WHERE
*/
// no tags, do a simple search
if (count($tag_conditions) === 0) {
$query = new Querylet("SELECT images.* FROM images WHERE 1=1");
}
// one tag sorted by ID - we can fetch this from the image_tags table,
// and do the offset / limit there, which is 10x faster than fetching
// all the image_tags and doing the offset / limit on the result.
elseif (
count($tag_conditions) === 1
&& empty($img_conditions)
&& ($order == "id DESC" || $order == "images.id DESC")
&& !is_null($offset)
&& !is_null($limit)
) {
$tc = $tag_conditions[0];
$in = $tc->positive ? "IN" : "NOT IN";
// IN (SELECT id FROM tags) is 100x slower than doing a separate
// query and then a second query for IN(first_query_results)??
$tag_array = self::tag_or_wildcard_to_ids($tc->tag);
if (count($tag_array) == 0) {
// if wildcard expanded to nothing, take a shortcut
if ($tc->positive) {
$query = new Querylet("SELECT images.* FROM images WHERE 1=0");
} else {
$query = new Querylet("SELECT images.* FROM images WHERE 1=1");
}
} else {
$set = implode(', ', $tag_array);
$query = new Querylet("
SELECT images.*
FROM images INNER JOIN (
SELECT it.image_id
FROM image_tags it
WHERE it.tag_id $in ($set)
ORDER BY it.image_id DESC
LIMIT :limit OFFSET :offset
) a on a.image_id = images.id
ORDER BY images.id DESC
", ["limit"=>$limit, "offset"=>$offset]);
// don't offset at the image level because
// we already offset at the image_tags level
$order = null;
$limit = null;
$offset = null;
}
}
// more than one tag, or more than zero other conditions, or a non-default sort order
else {
$positive_tag_id_array = [];
$positive_wildcard_id_array = [];
$negative_tag_id_array = [];
$all_nonexistent_negatives = true;
foreach ($tag_conditions as $tq) {
$tag_ids = self::tag_or_wildcard_to_ids($tq->tag);
$tag_count = count($tag_ids);
if ($tq->positive) {
$all_nonexistent_negatives = false;
if ($tag_count== 0) {
# one of the positive tags had zero results, therefor there
# can be no results; "where 1=0" should shortcut things
return new Querylet("SELECT images.* FROM images WHERE 1=0");
} elseif ($tag_count==1) {
// All wildcard terms that qualify for a single tag can be treated the same as non-wildcards
$positive_tag_id_array[] = $tag_ids[0];
} else {
// Terms that resolve to multiple tags act as an OR within themselves
// and as an AND in relation to all other terms,
$positive_wildcard_id_array[] = $tag_ids;
}
} else {
if ($tag_count > 0) {
$all_nonexistent_negatives = false;
// Unlike positive criteria, negative criteria are all handled in an OR fashion,
// so we can just compile them all into a single sub-query.
$negative_tag_id_array = array_merge($negative_tag_id_array, $tag_ids);
}
}
}
assert($positive_tag_id_array || $positive_wildcard_id_array || $negative_tag_id_array || $all_nonexistent_negatives, @$_GET['q']);
if ($all_nonexistent_negatives) {
$query = new Querylet("SELECT images.* FROM images WHERE 1=1");
} elseif (!empty($positive_tag_id_array) || !empty($positive_wildcard_id_array)) {
$inner_joins = [];
if (!empty($positive_tag_id_array)) {
foreach ($positive_tag_id_array as $tag) {
$inner_joins[] = "= $tag";
}
}
if (!empty($positive_wildcard_id_array)) {
foreach ($positive_wildcard_id_array as $tags) {
$positive_tag_id_list = join(', ', $tags);
$inner_joins[] = "IN ($positive_tag_id_list)";
}
}
$first = array_shift($inner_joins);
$sub_query = "SELECT it.image_id FROM image_tags it ";
$i = 0;
foreach ($inner_joins as $inner_join) {
$i++;
$sub_query .= " INNER JOIN image_tags it$i ON it$i.image_id = it.image_id AND it$i.tag_id $inner_join ";
}
if (!empty($negative_tag_id_array)) {
$negative_tag_id_list = join(', ', $negative_tag_id_array);
$sub_query .= " LEFT JOIN image_tags negative ON negative.image_id = it.image_id AND negative.tag_id IN ($negative_tag_id_list) ";
}
$sub_query .= "WHERE it.tag_id $first ";
if (!empty($negative_tag_id_array)) {
$sub_query .= " AND negative.image_id IS NULL";
}
$sub_query .= " GROUP BY it.image_id ";
$query = new Querylet("
SELECT images.*
FROM images
INNER JOIN ($sub_query) a on a.image_id = images.id
");
} elseif (!empty($negative_tag_id_array)) {
$negative_tag_id_list = join(', ', $negative_tag_id_array);
$query = new Querylet("
SELECT images.*
FROM images
LEFT JOIN image_tags negative ON negative.image_id = images.id AND negative.tag_id in ($negative_tag_id_list)
WHERE negative.image_id IS NULL
");
} else {
throw new SCoreException("No criteria specified");
}
}
/*
* Merge all the image metadata searches into one generic querylet
* and append to the base querylet with "AND blah"
*/
if (!empty($img_conditions)) {
$n = 0;
$img_sql = "";
$img_vars = [];
foreach ($img_conditions as $iq) {
if ($n++ > 0) {
$img_sql .= " AND";
}
if (!$iq->positive) {
$img_sql .= " NOT";
}
$img_sql .= " (" . $iq->qlet->sql . ")";
$img_vars = array_merge($img_vars, $iq->qlet->variables);
}
$query->append_sql(" AND ");
$query->append(new Querylet($img_sql, $img_vars));
}
if (!is_null($order)) {
$query->append(new Querylet(" ORDER BY ".$order));
}
if (!is_null($limit)) {
$query->append(new Querylet(" LIMIT :limit ", ["limit" => $limit]));
$query->append(new Querylet(" OFFSET :offset ", ["offset" => $offset]));
}
return $query;
}
}

View File

@@ -12,18 +12,19 @@ namespace Shimmie2;
* Add a directory full of images
*
* @param string $base
* @return array
* @param string[] $extra_tags
* @return UploadResult[]
*/
function add_dir(string $base): array
function add_dir(string $base, array $extra_tags = []): array
{
global $database;
$results = [];
foreach (list_files($base) as $full_path) {
$short_path = str_replace($base, "", $full_path);
$filename = basename($full_path);
$tags = path_to_tags($short_path);
$result = "$short_path (".str_replace(" ", ", ", $tags).")... ";
$tags = array_merge(path_to_tags($short_path), $extra_tags);
try {
$more_results = $database->with_savepoint(function () use ($full_path, $filename, $tags) {
$dae = send_event(new DataUploadEvent($full_path, basename($full_path), 0, [
@@ -38,26 +39,13 @@ function add_dir(string $base): array
});
$results = array_merge($results, $more_results);
} catch (UploadException $ex) {
$result .= "failed: ".$ex->getMessage();
$results[] = new UploadError($filename, $ex->getMessage());
}
$results[] = $result;
}
return $results;
}
/**
* Sends a DataUploadEvent for a file.
*/
function add_image(string $tmpname, string $filename, string $tags, ?string $source=null): DataUploadEvent
{
return send_event(new DataUploadEvent($tmpname, [
'filename' => pathinfo($filename, PATHINFO_BASENAME),
'tags' => Tag::explode($tags),
'source' => $source,
]));
}
function get_file_ext(string $filename): ?string
{
return pathinfo($filename)['extension'] ?? null;
@@ -71,7 +59,7 @@ function get_file_ext(string $filename): ?string
* @param int $orig_width
* @param int $orig_height
* @param bool $use_dpi_scaling Enables the High-DPI scaling.
* @return array
* @return array{0: int, 1: int}
*/
function get_thumbnail_size(int $orig_width, int $orig_height, bool $use_dpi_scaling = false): array
{
@@ -119,53 +107,60 @@ function get_thumbnail_size(int $orig_width, int $orig_height, bool $use_dpi_sca
}
}
/**
* @return array{0: int, 1: int, 2: float}
*/
function get_scaled_by_aspect_ratio(int $original_width, int $original_height, int $max_width, int $max_height): array
{
$xscale = ($max_width/ $original_width);
$yscale = ($max_height/ $original_height);
$xscale = ($max_width / $original_width);
$yscale = ($max_height / $original_height);
$scale = ($yscale < $xscale) ? $yscale : $xscale ;
return [(int)($original_width*$scale), (int)($original_height*$scale), $scale];
return [(int)($original_width * $scale), (int)($original_height * $scale), $scale];
}
/**
* Fetches the thumbnails height and width settings and applies the High-DPI scaling setting before returning the dimensions.
*
* @return array [width, height]
* @return array{0: int, 1: int}
*/
function get_thumbnail_max_size_scaled(): array
{
global $config;
$scaling = $config->get_int(ImageConfig::THUMB_SCALING);
$max_width = $config->get_int(ImageConfig::THUMB_WIDTH) * ($scaling/100);
$max_height = $config->get_int(ImageConfig::THUMB_HEIGHT) * ($scaling/100);
$max_width = $config->get_int(ImageConfig::THUMB_WIDTH) * ($scaling / 100);
$max_height = $config->get_int(ImageConfig::THUMB_HEIGHT) * ($scaling / 100);
return [$max_width, $max_height];
}
function create_image_thumb(string $hash, string $mime, string $engine = null)
function create_image_thumb(Image $image, string $engine = null): void
{
global $config;
$inname = warehouse_path(Image::IMAGE_DIR, $hash);
$outname = warehouse_path(Image::THUMBNAIL_DIR, $hash);
$tsize = get_thumbnail_max_size_scaled();
create_scaled_image(
$inname,
$outname,
$tsize,
$mime,
$image->get_image_filename(),
$image->get_thumb_filename(),
get_thumbnail_max_size_scaled(),
$image->get_mime(),
$engine,
$config->get_string(ImageConfig::THUMB_FIT)
);
}
function create_scaled_image(string $inname, string $outname, array $tsize, string $mime, ?string $engine = null, ?string $resize_type = null)
{
/**
* @param array{0: int, 1: int} $tsize
*/
function create_scaled_image(
string $inname,
string $outname,
array $tsize,
string $mime,
?string $engine = null,
?string $resize_type = null
): void {
global $config;
if (empty($engine)) {
$engine = $config->get_string(ImageConfig::THUMB_ENGINE);
@@ -207,7 +202,7 @@ function redirect_to_next_image(Image $image, ?string $search = null): void
$target_image = $image->get_next($search_terms);
if ($target_image === null) {
$redirect_target = referer_or(make_link("post/list"), ['post/view']);
$redirect_target = referer_or(search_link(), ['post/view']);
} else {
$redirect_target = make_link("post/view/{$target_image->id}", null, $query);
}

View File

@@ -23,9 +23,13 @@ use GQLA\Query;
*/
class Querylet
{
/**
* @param string $sql
* @param array<string, mixed> $variables
*/
public function __construct(
public string $sql,
public array $variables=[],
public array $variables = [],
) {
}
@@ -34,16 +38,6 @@ class Querylet
$this->sql .= $querylet->sql;
$this->variables = array_merge($this->variables, $querylet->variables);
}
public function append_sql(string $sql): void
{
$this->sql .= $sql;
}
public function add_variable($var): void
{
$this->variables[] = $var;
}
}
/**
@@ -53,7 +47,7 @@ class TagCondition
{
public function __construct(
public string $tag,
public bool $positive,
public bool $positive = true,
) {
}
}
@@ -66,7 +60,7 @@ class ImgCondition
{
public function __construct(
public Querylet $qlet,
public bool $positive,
public bool $positive = true,
) {
}
}

View File

@@ -26,14 +26,10 @@ class TagUsage
* @return TagUsage[]
*/
#[Query(name: "tags", type: '[TagUsage!]!')]
public static function tags(string $search, int $limit=10): array
public static function tags(string $search, int $limit = 10): array
{
global $cache, $database;
if (!$search) {
return [];
}
$search = strtolower($search);
if (
$search == '' ||
@@ -48,16 +44,16 @@ class TagUsage
$limitSQL = "";
$search = str_replace('_', '\_', $search);
$search = str_replace('%', '\%', $search);
$SQLarr = ["search"=>"$search%"]; #, "cat_search"=>"%:$search%"];
$SQLarr = ["search" => "$search%"]; #, "cat_search"=>"%:$search%"];
if ($limit !== 0) {
$limitSQL = "LIMIT :limit";
$SQLarr['limit'] = $limit;
$cache_key .= "-" . $limit;
}
$res = $cache->get($cache_key);
if (is_null($res)) {
$res = $database->get_pairs(
$res = cache_get_or_set(
$cache_key,
fn () => $database->get_pairs(
"
SELECT tag, count
FROM tags
@@ -68,9 +64,9 @@ class TagUsage
$limitSQL
",
$SQLarr
);
$cache->set($cache_key, $res, 600);
}
),
600
);
$counts = [];
foreach ($res as $k => $v) {
@@ -90,7 +86,8 @@ class TagUsage
*/
class Tag
{
private static $tag_id_cache = [];
/** @var array<string, int> */
private static array $tag_id_cache = [];
public static function get_or_create_id(string $tag): int
{
@@ -104,17 +101,17 @@ class Tag
$id = $database->get_one(
"SELECT id FROM tags WHERE LOWER(tag) = LOWER(:tag)",
["tag"=>$tag]
["tag" => $tag]
);
if (empty($id)) {
// a new tag
$database->execute(
"INSERT INTO tags(tag) VALUES (:tag)",
["tag"=>$tag]
["tag" => $tag]
);
$id = $database->get_one(
"SELECT id FROM tags WHERE LOWER(tag) = LOWER(:tag)",
["tag"=>$tag]
["tag" => $tag]
);
}
@@ -122,18 +119,19 @@ class Tag
return $id;
}
/** @param string[] $tags */
public static function implode(array $tags): string
{
sort($tags, SORT_FLAG_CASE|SORT_STRING);
sort($tags, SORT_FLAG_CASE | SORT_STRING);
return implode(' ', $tags);
}
/**
* Turn a human-supplied string into a valid tag array.
*
* #return string[]
* @return string[]
*/
public static function explode(string $tags, bool $tagme=true): array
public static function explode(string $tags, bool $tagme = true): array
{
global $database;
@@ -151,7 +149,7 @@ class Tag
$new = [];
$i = 0;
$tag_count = count($tag_array);
while ($i<$tag_count) {
while ($i < $tag_count) {
$tag = $tag_array[$i];
$negative = '';
if (!empty($tag) && ($tag[0] == '-')) {
@@ -165,7 +163,7 @@ class Tag
FROM aliases
WHERE LOWER(oldtag)=LOWER(:tag)
",
["tag"=>$tag]
["tag" => $tag]
);
if (empty($newtags)) {
//tag has no alias, use old tag
@@ -199,9 +197,13 @@ class Tag
public static function sanitize(string $tag): string
{
$tag = preg_replace("/\s/", "", $tag); # whitespace
assert($tag !== null);
$tag = preg_replace('/\x20[\x0e\x0f]/', '', $tag); # unicode RTL
assert($tag !== null);
$tag = preg_replace("/\.+/", ".", $tag); # strings of dots?
assert($tag !== null);
$tag = preg_replace("/^(\.+[\/\\\\])+/", "", $tag); # trailing slashes?
assert($tag !== null);
$tag = trim($tag, ", \t\n\r\0\x0B");
if ($tag == ".") {
@@ -211,9 +213,13 @@ class Tag
return $tag;
}
/**
* @param string[] $tags1
* @param string[] $tags2
*/
public static function compare(array $tags1, array $tags2): bool
{
if (count($tags1)!==count($tags2)) {
if (count($tags1) !== count($tags2)) {
return false;
}
@@ -225,6 +231,11 @@ class Tag
return $tags1 == $tags2;
}
/**
* @param string[] $source
* @param string[] $remove
* @return string[]
*/
public static function get_diff_tags(array $source, array $remove): array
{
$before = array_map('strtolower', $source);
@@ -238,6 +249,10 @@ class Tag
return $after;
}
/**
* @param string[] $tags
* @return string[]
*/
public static function sanitize_array(array $tags): array
{
global $page;
@@ -269,53 +284,4 @@ class Tag
// $term = str_replace("?", "_", $term);
return $term;
}
/**
* Kind of like urlencode, but using a custom scheme so that
* tags always fit neatly between slashes in a URL. Use this
* when you want to put an arbitrary tag into a URL.
*/
public static function caret(string $input): string
{
$to_caret = [
"^" => "^",
"/" => "s",
"\\" => "b",
"?" => "q",
"&" => "a",
"." => "d",
];
foreach ($to_caret as $from => $to) {
$input = str_replace($from, '^' . $to, $input);
}
return $input;
}
/**
* Use this when you want to get a tag out of a URL
*/
public static function decaret(string $str): string
{
$from_caret = [
"^" => "^",
"s" => "/",
"b" => "\\",
"q" => "?",
"a" => "&",
"d" => ".",
];
$out = "";
$length = strlen($str);
for ($i=0; $i<$length; $i++) {
if ($str[$i] == "^") {
$i++;
$out .= $from_caret[$str[$i]] ?? '';
} else {
$out .= $str[$i];
}
}
return $out;
}
}

View File

@@ -22,7 +22,7 @@ require_once "core/urls.php";
* and other such things that aren't ready yet
*/
function install()
function install(): void
{
date_default_timezone_set('UTC');
@@ -48,11 +48,16 @@ function install()
if ($dsn) {
do_install($dsn);
} else {
ask_questions();
if (PHP_SAPI == 'cli') {
print("INSTALL_DSN needs to be set for CLI installation\n");
exit(1);
} else {
ask_questions();
}
}
}
function get_dsn()
function get_dsn(): ?string
{
if (getenv("INSTALL_DSN")) {
$dsn = getenv("INSTALL_DSN");
@@ -68,7 +73,7 @@ function get_dsn()
return $dsn;
}
function do_install($dsn)
function do_install(string $dsn): void
{
try {
create_dirs();
@@ -79,7 +84,7 @@ function do_install($dsn)
}
}
function ask_questions()
function ask_questions(): void
{
$warnings = [];
$errors = [];
@@ -116,9 +121,9 @@ function ask_questions()
";
}
$db_s = in_array(DatabaseDriverID::SQLITE->value, $drivers) ? '<option value="'. DatabaseDriverID::SQLITE->value .'">SQLite</option>' : "";
$db_m = in_array(DatabaseDriverID::MYSQL->value, $drivers) ? '<option value="'. DatabaseDriverID::MYSQL->value .'">MySQL</option>' : "";
$db_p = in_array(DatabaseDriverID::PGSQL->value, $drivers) ? '<option value="'. DatabaseDriverID::PGSQL->value .'">PostgreSQL</option>' : "";
$db_s = in_array(DatabaseDriverID::SQLITE->value, $drivers) ? '<option value="'. DatabaseDriverID::SQLITE->value .'">SQLite</option>' : "";
$warn_msg = $warnings ? "<h3>Warnings</h3>".implode("\n<p>", $warnings) : "";
$err_msg = $errors ? "<h3>Errors</h3>".implode("\n<p>", $errors) : "";
@@ -136,9 +141,9 @@ function ask_questions()
<tr>
<th>Type:</th>
<td><select name="database_type" id="database_type" onchange="update_qs();">
$db_m
$db_s
$db_m
$db_p
$db_s
</select></td>
</tr>
<tr class="dbconf mysql pgsql">
@@ -165,13 +170,9 @@ function ask_questions()
return document.querySelectorAll(n);
}
function update_qs() {
Array.prototype.forEach.call(q('.dbconf'), function(el, i){
el.style.display = 'none';
});
q('.dbconf').forEach(el => el.style.display = 'none');
let seldb = q("#database_type")[0].value || "none";
Array.prototype.forEach.call(q('.'+seldb), function(el, i){
el.style.display = null;
});
q('.'+seldb).forEach(el => el.style.display = null);
}
</script>
</form>
@@ -182,8 +183,9 @@ function ask_questions()
The username provided must have access to create tables within the database.
</p>
<p class="dbconf sqlite">
For SQLite the database name will be a filename on disk, relative to
where shimmie was installed.
SQLite with default settings is fine for tens of users with thousands
of images. For thousands of users or millions of images, postgres is
recommended.
</p>
<p class="dbconf none">
Drivers can generally be downloaded with your OS package manager;
@@ -194,7 +196,7 @@ EOD
}
function create_dirs()
function create_dirs(): void
{
$data_exists = file_exists("data") || mkdir("data");
$data_writable = $data_exists && (is_writable("data") || chmod("data", 0755));
@@ -211,7 +213,7 @@ function create_dirs()
}
}
function create_tables(Database $db)
function create_tables(Database $db): void
{
try {
if ($db->count_tables() > 0) {
@@ -296,6 +298,8 @@ function create_tables(Database $db)
if ($db->is_transaction_open()) {
$db->commit();
}
// Ensure that we end this code in a transaction (for testing)
$db->begin_transaction();
} catch (\PDOException $e) {
throw new InstallerException(
"PDO Error:",
@@ -308,7 +312,7 @@ function create_tables(Database $db)
}
}
function write_config($dsn)
function write_config(string $dsn): void
{
$file_content = "<" . "?php\ndefine('DATABASE_DSN', '$dsn');\n";
@@ -317,11 +321,16 @@ function write_config($dsn)
}
if (file_put_contents("data/config/shimmie.conf.php", $file_content, LOCK_EX)) {
header("Location: index.php?flash=Installation%20complete");
die_nicely(
"Installation Successful",
"<p>If you aren't redirected, <a href=\"index.php\">click here to Continue</a>."
);
if (PHP_SAPI == 'cli') {
print("Installation Successful\n");
exit(0);
} else {
header("Location: index.php?flash=Installation%20complete");
die_nicely(
"Installation Successful",
"<p>If you aren't redirected, <a href=\"index.php\">click here to Continue</a>."
);
}
} else {
$h_file_content = htmlentities($file_content);
throw new InstallerException(

View File

@@ -16,12 +16,12 @@ define("SCORE_LOG_DEBUG", 10);
define("SCORE_LOG_NOTSET", 0);
const LOGGING_LEVEL_NAMES = [
SCORE_LOG_NOTSET=>"Not Set",
SCORE_LOG_DEBUG=>"Debug",
SCORE_LOG_INFO=>"Info",
SCORE_LOG_WARNING=>"Warning",
SCORE_LOG_ERROR=>"Error",
SCORE_LOG_CRITICAL=>"Critical",
SCORE_LOG_NOTSET => "Not Set",
SCORE_LOG_DEBUG => "Debug",
SCORE_LOG_INFO => "Info",
SCORE_LOG_WARNING => "Warning",
SCORE_LOG_ERROR => "Error",
SCORE_LOG_CRITICAL => "Critical",
];
/**
@@ -31,7 +31,7 @@ const LOGGING_LEVEL_NAMES = [
* When taking action, a log event should be stored by the server
* Quite often, both of these happen at once, hence log_*() having $flash
*/
function log_msg(string $section, int $priority, string $message, ?string $flash=null)
function log_msg(string $section, int $priority, string $message, ?string $flash = null): void
{
global $page;
send_event(new LogEvent($section, $priority, $message));
@@ -47,23 +47,23 @@ function log_msg(string $section, int $priority, string $message, ?string $flash
}
// More shorthand ways of logging
function log_debug(string $section, string $message, ?string $flash=null)
function log_debug(string $section, string $message, ?string $flash = null): void
{
log_msg($section, SCORE_LOG_DEBUG, $message, $flash);
}
function log_info(string $section, string $message, ?string $flash=null)
function log_info(string $section, string $message, ?string $flash = null): void
{
log_msg($section, SCORE_LOG_INFO, $message, $flash);
}
function log_warning(string $section, string $message, ?string $flash=null)
function log_warning(string $section, string $message, ?string $flash = null): void
{
log_msg($section, SCORE_LOG_WARNING, $message, $flash);
}
function log_error(string $section, string $message, ?string $flash=null)
function log_error(string $section, string $message, ?string $flash = null): void
{
log_msg($section, SCORE_LOG_ERROR, $message, $flash);
}
function log_critical(string $section, string $message, ?string $flash=null)
function log_critical(string $section, string $message, ?string $flash = null): void
{
log_msg($section, SCORE_LOG_CRITICAL, $message, $flash);
}

View File

@@ -6,7 +6,7 @@ namespace Shimmie2;
use MicroHTML\HTMLElement;
use function MicroHTML\emptyHTML;
use function MicroHTML\{emptyHTML};
use function MicroHTML\A;
use function MicroHTML\FORM;
use function MicroHTML\INPUT;
@@ -15,12 +15,8 @@ use function MicroHTML\OPTION;
use function MicroHTML\PRE;
use function MicroHTML\P;
use function MicroHTML\SELECT;
use function MicroHTML\TABLE;
use function MicroHTML\THEAD;
use function MicroHTML\TFOOT;
use function MicroHTML\TR;
use function MicroHTML\TH;
use function MicroHTML\TD;
use function MicroHTML\SPAN;
use function MicroHTML\{TABLE,THEAD,TFOOT,TR,TH,TD};
function SHM_FORM(string $target, bool $multipart = false, string $form_id = "", string $onsubmit = "", string $name = ""): HTMLElement
{
@@ -50,21 +46,30 @@ function SHM_FORM(string $target, bool $multipart = false, string $form_id = "",
);
}
function SHM_SIMPLE_FORM($target, ...$children): HTMLElement
/**
* @param array<string|HTMLElement|null> $children
*/
function SHM_SIMPLE_FORM(string $target, ...$children): HTMLElement
{
$form = SHM_FORM($target);
$form->appendChild(emptyHTML(...$children));
return $form;
}
function SHM_SUBMIT(string $text, array $args=[]): HTMLElement
/**
* @param array<string, mixed> $args
*/
function SHM_SUBMIT(string $text, array $args = []): HTMLElement
{
$args["type"] = "submit";
$args["value"] = $text;
return INPUT($args);
}
function SHM_A(string $href, string|HTMLElement $text, string $id="", string $class="", array $args=[]): HTMLElement
/**
* @param array<string, mixed> $args
*/
function SHM_A(string $href, string|HTMLElement $text, string $id = "", string $class = "", array $args = []): HTMLElement
{
$args["href"] = make_link($href);
@@ -81,24 +86,24 @@ function SHM_A(string $href, string|HTMLElement $text, string $id="", string $cl
function SHM_COMMAND_EXAMPLE(string $ex, string $desc): HTMLElement
{
return DIV(
["class"=>"command_example"],
["class" => "command_example"],
PRE($ex),
P($desc)
);
}
function SHM_USER_FORM(User $duser, string $target, string $title, $body, $foot): HTMLElement
function SHM_USER_FORM(User $duser, string $target, string $title, HTMLElement $body, HTMLElement|string $foot): HTMLElement
{
if (is_string($foot)) {
$foot = TFOOT(TR(TD(["colspan"=>"2"], INPUT(["type"=>"submit", "value"=>$foot]))));
$foot = TFOOT(TR(TD(["colspan" => "2"], INPUT(["type" => "submit", "value" => $foot]))));
}
return SHM_SIMPLE_FORM(
$target,
P(
INPUT(["type"=>'hidden', "name"=>'id', "value"=>$duser->id]),
INPUT(["type" => 'hidden', "name" => 'id', "value" => $duser->id]),
TABLE(
["class"=>"form"],
THEAD(TR(TH(["colspan"=>"2"], $title))),
["class" => "form"],
THEAD(TR(TH(["colspan" => "2"], $title))),
$body,
$foot
)
@@ -110,14 +115,14 @@ function SHM_USER_FORM(User $duser, string $target, string $title, $body, $foot)
* Generates a <select> element and sets up the given options.
*
* @param string $name The name attribute of <select>.
* @param array $options An array of pairs of parameters for <option> tags. First one is value, second one is text. Example: ('optionA', 'Choose Option A').
* @param array $selected_options The values of options that should be pre-selected.
* @param array<string|int, string> $options An array of pairs of parameters for <option> tags. First one is value, second one is text. Example: ('optionA', 'Choose Option A').
* @param array<string> $selected_options The values of options that should be pre-selected.
* @param bool $required Wether the <select> element is required.
* @param bool $multiple Wether the <select> element is multiple-choice.
* @param bool $empty_option Whether the first option should be an empty one.
* @param array $attrs Additional attributes dict for <select>. Example: ["id"=>"some_id", "class"=>"some_class"].
* @param array<string, mixed> $attrs Additional attributes dict for <select>. Example: ["id"=>"some_id", "class"=>"some_class"].
*/
function SHM_SELECT(string $name, array $options, array $selected_options=[], bool $required=false, bool $multiple=false, bool $empty_option=false, array $attrs=[]): HTMLElement
function SHM_SELECT(string $name, array $options, array $selected_options = [], bool $required = false, bool $multiple = false, bool $empty_option = false, array $attrs = []): HTMLElement
{
if ($required) {
$attrs["required"] = "";
@@ -143,10 +148,10 @@ function SHM_SELECT(string $name, array $options, array $selected_options=[], bo
return SELECT($attrs, ...$_options);
}
function SHM_OPTION(string $value, string $text, bool $selected=false): HTMLElement
function SHM_OPTION(string $value, string $text, bool $selected = false): HTMLElement
{
if ($selected) {
return OPTION(["value"=>$value, "selected"=>""], $text);
return OPTION(["value" => $value, "selected" => ""], $text);
}
return OPTION(["value" => $value], $text);

View File

@@ -10,6 +10,9 @@ namespace Shimmie2;
/**
* Return the unique elements of an array, case insensitively
*
* @param array<string> $array
* @return list<string>
*/
function array_iunique(array $array): array
{
@@ -54,42 +57,16 @@ function ip_in_range(string $IP, string $CIDR): bool
/**
* Delete an entire file heirachy
*
* from a patch by Christian Walde; only intended for use in the
* "extension manager" extension, but it seems to fit better here
*/
function deltree(string $f): void
function deltree(string $dir): void
{
//Because Windows (I know, bad excuse)
if (PHP_OS === 'WINNT') {
$real = realpath($f);
$path = realpath('./').'\\'.str_replace('/', '\\', $f);
if ($path != $real) {
rmdir($path);
} else {
foreach (glob($f.'/*') as $sf) {
if (is_dir($sf) && !is_link($sf)) {
deltree($sf);
} else {
unlink($sf);
}
}
rmdir($f);
}
} else {
if (is_link($f)) {
unlink($f);
} elseif (is_dir($f)) {
foreach (glob($f.'/*') as $sf) {
if (is_dir($sf) && !is_link($sf)) {
deltree($sf);
} else {
unlink($sf);
}
}
rmdir($f);
}
$di = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::KEY_AS_PATHNAME);
$ri = new \RecursiveIteratorIterator($di, \RecursiveIteratorIterator::CHILD_FIRST);
/** @var \SplFileInfo $file */
foreach ($ri as $filename => $file) {
$file->isDir() ? rmdir($filename) : unlink($filename);
}
rmdir($dir);
}
/**
@@ -102,9 +79,13 @@ function full_copy(string $source, string $target): void
if (is_dir($source)) {
@mkdir($target);
$d = dir($source);
$d = dir_ex($source);
while (false !== ($entry = $d->read())) {
while (true) {
$entry = $d->read();
if ($entry === false) {
break;
}
if ($entry == '.' || $entry == '..') {
continue;
}
@@ -124,8 +105,10 @@ function full_copy(string $source, string $target): void
/**
* Return a list of all the regular files in a directory and subdirectories
*
* @return string[]
*/
function list_files(string $base, string $_sub_dir=""): array
function list_files(string $base, string $_sub_dir = ""): array
{
assert(is_dir($base));
@@ -185,6 +168,7 @@ function stream_file(string $file, int $start, int $end): void
while (!feof($fp) && ($p = ftell($fp)) <= $end) {
if ($p + $buffer > $end) {
$buffer = $end - $p + 1;
assert($buffer >= 0);
}
echo fread($fp, $buffer);
flush_output();
@@ -204,13 +188,13 @@ function stream_file(string $file, int $start, int $end): void
# http://www.php.net/manual/en/function.http-parse-headers.php#112917
if (!function_exists('http_parse_headers')) {
/**
* #return string[]
* @return array<string, string|string[]>
*/
function http_parse_headers(string $raw_headers): array
{
$headers = []; // $headers = [];
$headers = [];
foreach (explode("\n", $raw_headers) as $i => $h) {
foreach (explode("\n", $raw_headers) as $h) {
$h = explode(':', $h, 2);
if (isset($h[1])) {
@@ -232,6 +216,8 @@ if (!function_exists('http_parse_headers')) {
/**
* HTTP Headers can sometimes be lowercase which will cause issues.
* In cases like these, we need to make sure to check for them if the camelcase version does not exist.
*
* @param array<string, mixed> $headers
*/
function find_header(array $headers, string $name): ?string
{
@@ -268,6 +254,8 @@ function get_subclasses_of(string $parent): array
/**
* Like glob, with support for matching very long patterns with braces.
*
* @return string[]
*/
function zglob(string $pattern): array
{
@@ -289,52 +277,6 @@ function zglob(string $pattern): array
}
}
/**
* Figure out the path to the shimmie install directory.
*
* eg if shimmie is visible at https://foo.com/gallery, this
* function should return /gallery
*
* PHP really, really sucks.
*/
function get_base_href(): string
{
if (defined("BASE_HREF") && !empty(BASE_HREF)) {
return BASE_HREF;
}
$possible_vars = ['SCRIPT_NAME', 'PHP_SELF', 'PATH_INFO', 'ORIG_PATH_INFO'];
$ok_var = null;
foreach ($possible_vars as $var) {
if (isset($_SERVER[$var]) && substr($_SERVER[$var], -4) === '.php') {
$ok_var = $_SERVER[$var];
break;
}
}
assert(!empty($ok_var));
$dir = dirname($ok_var);
$dir = str_replace("\\", "/", $dir);
$dir = str_replace("//", "/", $dir);
$dir = rtrim($dir, "/");
return $dir;
}
/**
* The opposite of the standard library's parse_url
*/
function unparse_url(array $parsed_url): string
{
$scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : '';
$host = $parsed_url['host'] ?? '';
$port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : '';
$user = $parsed_url['user'] ?? '';
$pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : '';
$pass = ($user || $pass) ? "$pass@" : '';
$path = $parsed_url['path'] ?? '';
$query = !empty($parsed_url['query']) ? '?' . $parsed_url['query'] : '';
$fragment = !empty($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : '';
return "$scheme$user$pass$host$port$path$query$fragment";
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Input / Output Sanitising *
@@ -389,7 +331,7 @@ function url_escape(?string $input): string
/**
* Turn all manner of HTML / INI / JS / DB booleans into a PHP one
*/
function bool_escape($input): bool
function bool_escape(mixed $input): bool
{
/*
Sometimes, I don't like PHP -- this, is one of those times...
@@ -432,7 +374,7 @@ function no_escape(string $input): string
* Given a 1-indexed numeric-ish thing, return a zero-indexed
* number between 0 and $max
*/
function page_number(string $input, ?int $max=null): int
function page_number(string $input, ?int $max = null): int
{
if (!is_numeric($input)) {
$pageNumber = 0;
@@ -443,10 +385,23 @@ function page_number(string $input, ?int $max=null): int
} else {
$pageNumber = $input - 1;
}
return $pageNumber;
return (int)$pageNumber;
}
function clamp(int $val, ?int $min=null, ?int $max=null): int
function is_numberish(string $s): bool
{
return is_numeric($s);
}
/**
* Because apparently phpstan thinks that if $i is an int, type(-$i) == int|float
*/
function negative_int(int $i): int
{
return -$i;
}
function clamp(int $val, ?int $min = null, ?int $max = null): int
{
if (!is_null($min) && $val < $min) {
$val = $min;
@@ -522,17 +477,17 @@ function to_shorthand_int(int|float $int): string
{
assert($int >= 0);
if ($int >= pow(1024, 4)) {
return sprintf("%.1fTB", $int / pow(1024, 4));
} elseif ($int >= pow(1024, 3)) {
return sprintf("%.1fGB", $int / pow(1024, 3));
} elseif ($int >= pow(1024, 2)) {
return sprintf("%.1fMB", $int / pow(1024, 2));
} elseif ($int >= 1024) {
return sprintf("%.1fKB", $int / 1024);
} else {
return (string)$int;
}
return match (true) {
$int >= pow(1024, 4) * 10 => sprintf("%.0fTB", $int / pow(1024, 4)),
$int >= pow(1024, 4) => sprintf("%.1fTB", $int / pow(1024, 4)),
$int >= pow(1024, 3) * 10 => sprintf("%.0fGB", $int / pow(1024, 3)),
$int >= pow(1024, 3) => sprintf("%.1fGB", $int / pow(1024, 3)),
$int >= pow(1024, 2) * 10 => sprintf("%.0fMB", $int / pow(1024, 2)),
$int >= pow(1024, 2) => sprintf("%.1fMB", $int / pow(1024, 2)),
$int >= pow(1024, 1) * 10 => sprintf("%.0fKB", $int / pow(1024, 1)),
$int >= pow(1024, 1) => sprintf("%.1fKB", $int / pow(1024, 1)),
default => (string)$int,
};
}
abstract class TIME_UNITS
{
@@ -543,12 +498,12 @@ abstract class TIME_UNITS
public const DAYS = "d";
public const YEARS = "y";
public const CONVERSION = [
self::MILLISECONDS=>1000,
self::SECONDS=>60,
self::MINUTES=>60,
self::HOURS=>24,
self::DAYS=>365,
self::YEARS=>PHP_INT_MAX
self::MILLISECONDS => 1000,
self::SECONDS => 60,
self::MINUTES => 60,
self::HOURS => 24,
self::DAYS => 365,
self::YEARS => PHP_INT_MAX
];
}
function format_milliseconds(int $input, string $min_unit = TIME_UNITS::SECONDS): string
@@ -559,17 +514,17 @@ function format_milliseconds(int $input, string $min_unit = TIME_UNITS::SECONDS)
$found = false;
foreach (TIME_UNITS::CONVERSION as $unit=>$conversion) {
foreach (TIME_UNITS::CONVERSION as $unit => $conversion) {
$count = $remainder % $conversion;
$remainder = floor($remainder / $conversion);
if ($found||$unit==$min_unit) {
if ($found || $unit == $min_unit) {
$found = true;
} else {
continue;
}
if ($count==0&&$remainder<1) {
if ($count == 0 && $remainder < 1) {
break;
}
$output = "$count".$unit." ".$output;
@@ -590,7 +545,7 @@ function parse_to_milliseconds(string $input): int
$output += $length;
}
} else {
foreach (TIME_UNITS::CONVERSION as $unit=>$conversion) {
foreach (TIME_UNITS::CONVERSION as $unit => $conversion) {
if (preg_match('/([0-9]+)'.$unit.'/i', $input, $match)) {
$length = $match[1];
if (is_numeric($length)) {
@@ -607,7 +562,7 @@ function parse_to_milliseconds(string $input): int
/**
* Turn a date into a time, a date, an "X minutes ago...", etc
*/
function autodate(string $date, bool $html=true): string
function autodate(string $date, bool $html = true): string
{
$cpu = date('c', \Safe\strtotime($date));
$hum = date('F j, Y; H:i', \Safe\strtotime($date));
@@ -643,6 +598,10 @@ function isValidDate(string $date): bool
return false;
}
/**
* @param array<string, string> $inputs
* @return array<string, mixed>
*/
function validate_input(array $inputs): array
{
$outputs = [];
@@ -675,6 +634,7 @@ function validate_input(array $inputs): array
}
$outputs[$key] = $id;
} elseif (in_array('user_name', $flags)) {
// @phpstan-ignore-next-line - phpstan thinks $value can never be empty?
if (strlen($value) < 1) {
throw new InvalidInput("Username must be at least 1 character");
} elseif (!preg_match('/^[a-zA-Z0-9-_]+$/', $value)) {
@@ -685,8 +645,7 @@ function validate_input(array $inputs): array
}
$outputs[$key] = $value;
} elseif (in_array('user_class', $flags)) {
global $_shm_user_classes;
if (!array_key_exists($value, $_shm_user_classes)) {
if (!array_key_exists($value, UserClass::$known_classes)) {
throw new InvalidInput("Invalid user class: ".html_escape($value));
}
$outputs[$key] = $value;
@@ -762,6 +721,12 @@ function join_path(string ...$paths): string
/**
* Perform callback on each item returned by an iterator.
*
* @template T
* @template U
* @param callable(U):T $callback
* @param \iterator<U> $iter
* @return \Generator<T>
*/
function iterator_map(callable $callback, \iterator $iter): \Generator
{
@@ -772,20 +737,26 @@ function iterator_map(callable $callback, \iterator $iter): \Generator
/**
* Perform callback on each item returned by an iterator and combine the result into an array.
*
* @template T
* @template U
* @param callable(U):T $callback
* @param \iterator<U> $iter
* @return array<T>
*/
function iterator_map_to_array(callable $callback, \iterator $iter): array
{
return iterator_to_array(iterator_map($callback, $iter));
}
function stringer($s): string
function stringer(mixed $s): string
{
if (is_array($s)) {
if (isset($s[0])) {
return "[" . implode(", ", array_map("Shimmie2\stringer", $s)) . "]";
} else {
$pairs = [];
foreach ($s as $k=>$v) {
foreach ($s as $k => $v) {
$pairs[] = "\"$k\"=>" . stringer($v);
}
return "[" . implode(", ", $pairs) . "]";
@@ -808,3 +779,24 @@ function stringer($s): string
}
return "<Unstringable>";
}
/**
* If a value is in the cache, return it; otherwise, call the callback
* to generate it and store it in the cache.
*
* @template T
* @param string $key
* @param callable():T $callback
* @param int|null $ttl
* @return T
*/
function cache_get_or_set(string $key, callable $callback, ?int $ttl = null): mixed
{
global $cache;
$value = $cache->get($key);
if ($value === null) {
$value = $callback();
$cache->set($key, $value, $ttl);
}
return $value;
}

View File

@@ -11,7 +11,7 @@ require_once "core/urls.php";
* be included right at the very start of index.php and tests/bootstrap.php
*/
function die_nicely($title, $body, $code=0)
function die_nicely(string $title, string $body, int $code = 0): void
{
$data_href = get_base_href();
print("<!DOCTYPE html>

View File

@@ -23,7 +23,10 @@ function _load_event_listeners(): void
$_tracer->begin("Load Event Listeners");
$cache_path = data_path("cache/shm_event_listeners.php");
$ver = preg_replace("/[^a-zA-Z0-9\.]/", "_", VERSION);
$key = md5(Extension::get_enabled_extensions_as_string());
$cache_path = data_path("cache/event_listeners/el.$ver.$key.php");
if (SPEED_HAX && file_exists($cache_path)) {
require_once($cache_path);
} else {
@@ -49,7 +52,7 @@ function _set_event_listeners(): void
global $_shm_event_listeners;
$_shm_event_listeners = [];
foreach (get_subclasses_of("Shimmie2\Extension") as $class) {
foreach (get_subclasses_of(Extension::class) as $class) {
/** @var Extension $extension */
$extension = new $class();
@@ -76,11 +79,16 @@ function _namespaced_class_name(string $class): string
return str_replace("Shimmie2\\", "", $class);
}
/**
* Dump the event listeners to a file for faster loading.
*
* @param array<string, array<int, Extension>> $event_listeners
*/
function _dump_event_listeners(array $event_listeners, string $path): void
{
$p = "<"."?php\nnamespace Shimmie2;\n";
foreach (get_subclasses_of("Shimmie2\Extension") as $class) {
foreach (get_subclasses_of(Extension::class) as $class) {
$scn = _namespaced_class_name($class);
$p .= "\$$scn = new $scn(); ";
}
@@ -104,7 +112,7 @@ global $_shm_event_count;
$_shm_event_count = 0;
$_shm_timeout = null;
function shm_set_timeout(?int $timeout=null): void
function shm_set_timeout(?int $timeout = null): void
{
global $_shm_timeout;
if ($timeout) {
@@ -159,7 +167,7 @@ function send_event(Event $event): Event
if ($tracer_enabled) {
$_tracer->end();
}
if ($event->stop_processing===true) {
if ($event->stop_processing === true) {
break;
}
}

34
core/stdlib_ex.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
/**
* @template T
* @param T|false $x
* @return T
*/
function false_throws(mixed $x, ?callable $errorgen = null): mixed
{
if ($x === false) {
$msg = "Unexpected false";
if ($errorgen) {
$msg = $errorgen();
}
throw new \Exception($msg);
}
return $x;
}
# https://github.com/thecodingmachine/safe/pull/428
function inet_pton_ex(string $ip_address): string
{
return false_throws(inet_pton($ip_address));
}
function dir_ex(string $directory): \Directory
{
return false_throws(dir($directory));
}
function filter_var_ex(mixed $variable, int $filter = FILTER_DEFAULT, mixed $options = null): mixed
{
return false_throws(filter_var($variable, $filter, $options));
}

View File

@@ -9,7 +9,7 @@ namespace Shimmie2;
* Shimmie will set the values to their defaults
*
* All of these can be over-ridden by placing a 'define' in
* data/config/shimmie.conf.php
* data/config/shimmie.conf.php.
*
* Do NOT change them in this file. These are the defaults only!
*
@@ -17,13 +17,13 @@ namespace Shimmie2;
* define("SPEED_HAX", true);
*/
function _d(string $name, $value): void
function _d(string $name, mixed $value): void
{
if (!defined($name)) {
define($name, $value);
}
}
$_g = file_exists(".git") ? '+' : '';
_d("DATABASE_DSN", null); // string PDO database connection details
_d("DATABASE_TIMEOUT", 10000); // int Time to wait for each statement to complete
_d("CACHE_DSN", null); // string cache connection details
@@ -37,4 +37,4 @@ _d("EXTRA_EXTS", ""); // string optional extra extensions
_d("BASE_HREF", null); // string force a specific base URL (default is auto-detect)
_d("TRACE_FILE", null); // string file to log performance data into
_d("TRACE_THRESHOLD", 0.0); // float log pages which take more time than this many seconds
_d("REVERSE_PROXY_X_HEADERS", false); // boolean get request IPs from "X-Real-IP" and protocol from "X-Forwarded-Proto" HTTP headers
_d("TRUSTED_PROXIES", []); // array trust "X-Real-IP" / "X-Forwarded-For" / "X-Forwarded-Proto" headers from these IP ranges

282
core/testcase.php Normal file
View File

@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
if (class_exists("\\PHPUnit\\Framework\\TestCase")) {
abstract class ShimmiePHPUnitTestCase extends \PHPUnit\Framework\TestCase
{
protected static string $anon_name = "anonymous";
protected static string $admin_name = "demo";
protected static string $user_name = "test";
protected string $wipe_time = "test";
/**
* Start a DB transaction for each test class
*/
public static function setUpBeforeClass(): void
{
global $_tracer, $database;
$_tracer->begin(get_called_class());
$database->begin_transaction();
parent::setUpBeforeClass();
}
/**
* Start a savepoint for each test
*/
public function setUp(): void
{
global $database, $_tracer;
$_tracer->begin($this->name());
$_tracer->begin("setUp");
$class = str_replace("Test", "", get_class($this));
try {
if (!ExtensionInfo::get_for_extension_class($class)->is_supported()) {
$this->markTestSkipped("$class not supported with this database");
}
} catch (ExtensionNotFound $e) {
// ignore - this is a core test rather than an extension test
}
// Set up a clean environment for each test
$database->execute("SAVEPOINT test_start");
self::log_out();
foreach ($database->get_col("SELECT id FROM images") as $image_id) {
send_event(new ImageDeletionEvent(Image::by_id((int)$image_id), true));
}
$_tracer->end(); # setUp
$_tracer->begin("test");
}
public function tearDown(): void
{
global $_tracer, $database;
$database->execute("ROLLBACK TO test_start");
$_tracer->end(); # test
$_tracer->end(); # $this->getName()
}
public static function tearDownAfterClass(): void
{
parent::tearDownAfterClass();
global $_tracer, $database;
$database->rollback();
$_tracer->end(); # get_called_class()
$_tracer->clear();
$_tracer->flush("data/test-trace.json");
}
/**
* @param array<string, mixed> $args
* @return array<string, string|mixed[]>
*/
private static function check_args(array $args): array
{
if (!$args) {
return [];
}
foreach ($args as $k => $v) {
if (is_array($v)) {
$args[$k] = $v;
} else {
$args[$k] = (string)$v;
}
}
return $args;
}
/**
* @param array<string, mixed> $get_args
* @param array<string, mixed> $post_args
*/
protected static function request(
string $method,
string $page_name,
array $get_args = [],
array $post_args = []
): Page {
// use a fresh page
global $page;
$get_args = self::check_args($get_args);
$post_args = self::check_args($post_args);
if (str_contains($page_name, "?")) {
throw new \RuntimeException("Query string included in page name");
}
$_SERVER['REQUEST_METHOD'] = $method;
$_SERVER['REQUEST_URI'] = make_link($page_name, http_build_query($get_args));
$_GET = $get_args;
$_POST = $post_args;
$page = new Page();
send_event(new PageRequestEvent($method, $page_name, $get_args, $post_args));
if ($page->mode == PageMode::REDIRECT) {
$page->code = 302;
}
return $page;
}
/**
* @param array<string, mixed> $args
*/
protected static function get_page(string $page_name, array $args = []): Page
{
return self::request("GET", $page_name, $args, []);
}
/**
* @param array<string, mixed> $args
*/
protected static function post_page(string $page_name, array $args = []): Page
{
return self::request("POST", $page_name, [], $args);
}
// page things
protected function assert_title(string $title): void
{
global $page;
$this->assertStringContainsString($title, $page->title);
}
protected function assert_title_matches(string $title): void
{
global $page;
$this->assertStringMatchesFormat($title, $page->title);
}
protected function assert_no_title(string $title): void
{
global $page;
$this->assertStringNotContainsString($title, $page->title);
}
protected function assert_response(int $code): void
{
global $page;
$this->assertEquals($code, $page->code);
}
protected function page_to_text(string $section = null): string
{
global $page;
if ($page->mode == PageMode::PAGE) {
$text = $page->title . "\n";
foreach ($page->blocks as $block) {
if (is_null($section) || $section == $block->section) {
$text .= $block->header . "\n";
$text .= $block->body . "\n\n";
}
}
return $text;
} elseif ($page->mode == PageMode::DATA) {
return $page->data;
} else {
$this->fail("Page mode is {$page->mode->name} (only PAGE and DATA are supported)");
}
}
/**
* Assert that the page contains the given text somewhere in the blocks
*/
protected function assert_text(string $text, string $section = null): void
{
$this->assertStringContainsString($text, $this->page_to_text($section));
}
protected function assert_no_text(string $text, string $section = null): void
{
$this->assertStringNotContainsString($text, $this->page_to_text($section));
}
/**
* Assert that the page contains the given text somewhere in the binary data
*/
protected function assert_content(string $content): void
{
global $page;
$this->assertStringContainsString($content, $page->data);
}
protected function assert_no_content(string $content): void
{
global $page;
$this->assertStringNotContainsString($content, $page->data);
}
/**
* @param string[] $tags
* @param int[] $results
*/
protected function assert_search_results(array $tags, array $results): void
{
$images = Search::find_images(0, null, $tags);
$ids = [];
foreach ($images as $image) {
$ids[] = $image->id;
}
$this->assertEquals($results, $ids);
}
protected function assertException(string $type, callable $function): \Exception|null
{
$exception = null;
try {
call_user_func($function);
} catch (\Exception $e) {
$exception = $e;
}
self::assertThat(
$exception,
new \PHPUnit\Framework\Constraint\Exception($type),
"Expected exception of type $type, but got " . ($exception ? get_class($exception) : "none")
);
return $exception;
}
// user things
protected static function log_in_as_admin(): void
{
send_event(new UserLoginEvent(User::by_name(self::$admin_name)));
}
protected static function log_in_as_user(): void
{
send_event(new UserLoginEvent(User::by_name(self::$user_name)));
}
protected static function log_out(): void
{
global $config;
send_event(new UserLoginEvent(User::by_id($config->get_int("anon_id", 0))));
}
// post things
protected function post_image(string $filename, string $tags): int
{
$dae = send_event(new DataUploadEvent($filename, basename($filename), 0, [
"filename" => $filename,
"tags" => $tags,
]));
if (count($dae->images) == 0) {
throw new \Exception("Upload failed :(");
}
return $dae->images[0]->id;
}
protected function delete_image(int $image_id): void
{
$img = Image::by_id($image_id);
if ($img) {
send_event(new ImageDeletionEvent($img, true));
}
}
}
} else {
abstract class ShimmiePHPUnitTestCase
{
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
require_once "core/basepage.php";
class BasePageTest extends TestCase
{
public function test_page(): void
{
$page = new BasePage();
$page->set_mode(PageMode::PAGE);
ob_start();
$page->display();
ob_end_clean();
$this->assertTrue(true); // doesn't crash
}
public function test_file(): void
{
$page = new BasePage();
$page->set_mode(PageMode::FILE);
$page->set_file("tests/pbx_screenshot.jpg");
ob_start();
$page->display();
ob_end_clean();
$this->assertTrue(true); // doesn't crash
}
public function test_data(): void
{
$page = new BasePage();
$page->set_mode(PageMode::DATA);
$page->set_data("hello world");
ob_start();
$page->display();
ob_end_clean();
$this->assertTrue(true); // doesn't crash
}
public function test_redirect(): void
{
$page = new BasePage();
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect("/new/page");
ob_start();
$page->display();
ob_end_clean();
$this->assertTrue(true); // doesn't crash
}
public function test_subNav(): void
{
// the default theme doesn't send this, so let's have
// a random test manually
send_event(new PageSubNavBuildingEvent("system"));
$this->assertTrue(true); // doesn't crash
}
}

21
core/tests/BlockTest.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
require_once "core/block.php";
class BlockTest extends TestCase
{
public function test_basic(): void
{
$b = new Block("head", "body");
$this->assertEquals(
"<section id='headmain'><h3 data-toggle-sel='#headmain' class=''>head</h3><div class='blockbody'>body</div></section>\n",
$b->get_html()
);
}
}

22
core/tests/InitTest.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
class InitTest extends TestCase
{
public function testInitExt(): void
{
send_event(new InitExtEvent());
$this->assertTrue(true);
}
public function testDatabaseUpgrade(): void
{
send_event(new DatabaseUpgradeEvent());
$this->assertTrue(true);
}
}

View File

@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
require_once "core/polyfills.php";
class PolyfillsTest extends TestCase
{
public function test_html_escape(): void
{
$this->assertEquals(
"Foo &amp; &lt;main&gt;",
html_escape("Foo & <main>")
);
$this->assertEquals(
"Foo & <main>",
html_unescape("Foo &amp; &lt;main&gt;")
);
$x = "Foo &amp; &lt;waffles&gt;";
$this->assertEquals(html_escape(html_unescape($x)), $x);
}
public function test_int_escape(): void
{
$this->assertEquals(0, int_escape(""));
$this->assertEquals(1, int_escape("1"));
$this->assertEquals(-1, int_escape("-1"));
$this->assertEquals(-1, int_escape("-1.5"));
$this->assertEquals(0, int_escape(null));
}
public function test_url_escape(): void
{
$this->assertEquals("%5E%5Co%2F%5E", url_escape("^\o/^"));
$this->assertEquals("", url_escape(null));
}
public function test_bool_escape(): void
{
$this->assertTrue(bool_escape(true));
$this->assertFalse(bool_escape(false));
$this->assertTrue(bool_escape("true"));
$this->assertFalse(bool_escape("false"));
$this->assertTrue(bool_escape("t"));
$this->assertFalse(bool_escape("f"));
$this->assertTrue(bool_escape("T"));
$this->assertFalse(bool_escape("F"));
$this->assertTrue(bool_escape("yes"));
$this->assertFalse(bool_escape("no"));
$this->assertTrue(bool_escape("Yes"));
$this->assertFalse(bool_escape("No"));
$this->assertTrue(bool_escape("on"));
$this->assertFalse(bool_escape("off"));
$this->assertTrue(bool_escape(1));
$this->assertFalse(bool_escape(0));
$this->assertTrue(bool_escape("1"));
$this->assertFalse(bool_escape("0"));
}
public function test_clamp(): void
{
$this->assertEquals(5, clamp(0, 5, 10)); // too small
$this->assertEquals(5, clamp(5, 5, 10)); // lower limit
$this->assertEquals(7, clamp(7, 5, 10)); // ok
$this->assertEquals(10, clamp(10, 5, 10)); // upper limit
$this->assertEquals(10, clamp(15, 5, 10)); // too large
$this->assertEquals(0, clamp(0, null, 10)); // no lower limit
$this->assertEquals(10, clamp(10, 0, null)); // no upper limit
$this->assertEquals(42, clamp(42, null, null)); // no limit
}
public function test_truncate(): void
{
$this->assertEquals("test words", truncate("test words", 10), "No truncation if string is short enough");
$this->assertEquals("test...", truncate("test words", 9), "Truncate when string is too long");
$this->assertEquals("test...", truncate("test words", 7), "Truncate to the same breakpoint");
$this->assertEquals("te...", truncate("test words", 5), "Breakpoints past the limit don't matter");
$this->assertEquals("o...", truncate("oneVeryLongWord", 4), "Hard-break if there are no breakpoints");
}
public function test_to_shorthand_int(): void
{
// 0-9 should have 1 decimal place, 10+ should have none
$this->assertEquals("1.1GB", to_shorthand_int(1231231231));
$this->assertEquals("10KB", to_shorthand_int(10240));
$this->assertEquals("9.2KB", to_shorthand_int(9440));
$this->assertEquals("2", to_shorthand_int(2));
}
public function test_parse_shorthand_int(): void
{
$this->assertEquals(-1, parse_shorthand_int("foo"));
$this->assertEquals(33554432, parse_shorthand_int("32M"));
$this->assertEquals(44441, parse_shorthand_int("43.4KB"));
$this->assertEquals(1231231231, parse_shorthand_int("1231231231"));
}
public function test_format_milliseconds(): void
{
$this->assertEquals("", format_milliseconds(5));
$this->assertEquals("5s", format_milliseconds(5000));
$this->assertEquals("1y 213d 16h 53m 20s", format_milliseconds(50000000000));
}
public function test_parse_to_milliseconds(): void
{
$this->assertEquals(10, parse_to_milliseconds("10"));
$this->assertEquals(5000, parse_to_milliseconds("5s"));
$this->assertEquals(50000000000, parse_to_milliseconds("1y 213d 16h 53m 20s"));
}
public function test_autodate(): void
{
$this->assertEquals(
"<time datetime='2012-06-23T16:14:22+00:00'>June 23, 2012; 16:14</time>",
autodate("2012-06-23 16:14:22")
);
}
public function test_validate_input(): void
{
$_POST = [
"foo" => " bar ",
"to_null" => " ",
"num" => "42",
];
$this->assertEquals(
["foo" => "bar"],
validate_input(["foo" => "string,trim,lower"])
);
//$this->assertEquals(
// ["to_null"=>null],
// validate_input(["to_null"=>"string,trim,nullify"])
//);
$this->assertEquals(
["num" => 42],
validate_input(["num" => "int"])
);
}
public function test_sanitize_path(): void
{
$this->assertEquals(
"one",
sanitize_path("one")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one\\two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one/two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one\\\\two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one//two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one\\\\\\two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one///two")
);
$this->assertEquals(
DIRECTORY_SEPARATOR."one".DIRECTORY_SEPARATOR."two".DIRECTORY_SEPARATOR,
sanitize_path("\\/one/\\/\\/two\\/")
);
}
public function test_join_path(): void
{
$this->assertEquals(
"one",
join_path("one")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
join_path("one", "two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two".DIRECTORY_SEPARATOR."three",
join_path("one", "two", "three")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two".DIRECTORY_SEPARATOR."three",
join_path("one/two", "three")
);
$this->assertEquals(
DIRECTORY_SEPARATOR."one".DIRECTORY_SEPARATOR."two".DIRECTORY_SEPARATOR."three".DIRECTORY_SEPARATOR,
join_path("\\/////\\\\one/\///"."\\//two\/\\//\\//", "//\/\\\/three/\\/\/")
);
}
public function test_stringer(): void
{
$this->assertEquals(
'["foo"=>"bar", "baz"=>[1, 2, 3], "qux"=>["a"=>"b"]]',
stringer(["foo" => "bar", "baz" => [1,2,3], "qux" => ["a" => "b"]])
);
}
public function test_ip_in_range(): void
{
$this->assertTrue(ip_in_range("1.2.3.4", "1.2.0.0/16"));
$this->assertFalse(ip_in_range("4.3.2.1", "1.2.0.0/16"));
// A single IP should be interpreted as a /32
$this->assertTrue(ip_in_range("1.2.3.4", "1.2.3.4"));
}
public function test_deltree(): void
{
$tmp = sys_get_temp_dir();
$dir = "$tmp/test_deltree";
mkdir($dir);
file_put_contents("$dir/foo", "bar");
mkdir("$dir/baz");
file_put_contents("$dir/baz/.qux", "quux");
$this->assertTrue(file_exists($dir));
$this->assertTrue(file_exists("$dir/foo"));
$this->assertTrue(file_exists("$dir/baz"));
$this->assertTrue(file_exists("$dir/baz/.qux"));
deltree($dir);
$this->assertFalse(file_exists($dir));
}
}

547
core/tests/SearchTest.php Normal file
View File

@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\Constraint\IsEqual;
require_once "core/imageboard/search.php";
class SearchTest extends ShimmiePHPUnitTestCase
{
public function testWeirdTags(): void
{
$this->log_in_as_user();
$image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "question? colon:thing exclamation!");
$image_id_2 = $this->post_image("tests/bedroom_workshop.jpg", "question. colon_thing exclamation%");
$this->assert_search_results(["question?"], [$image_id_1]);
$this->assert_search_results(["question."], [$image_id_2]);
$this->assert_search_results(["colon:thing"], [$image_id_1]);
$this->assert_search_results(["colon_thing"], [$image_id_2]);
$this->assert_search_results(["exclamation!"], [$image_id_1]);
$this->assert_search_results(["exclamation%"], [$image_id_2]);
}
public function testOrder(): void
{
$this->log_in_as_user();
$i1 = $this->post_image("tests/pbx_screenshot.jpg", "question? colon:thing exclamation!");
$i2 = $this->post_image("tests/bedroom_workshop.jpg", "question. colon_thing exclamation%");
$i3 = $this->post_image("tests/favicon.png", "another");
$is1 = Search::find_images(0, null, ["order=random_4123"]);
$ids1 = array_map(fn ($image) => $image->id, $is1);
$this->assertEquals(3, count($ids1));
$is2 = Search::find_images(0, null, ["order=random_4123"]);
$ids2 = array_map(fn ($image) => $image->id, $is2);
$this->assertEquals(3, count($ids1));
$is3 = Search::find_images(0, null, ["order=random_6543"]);
$ids3 = array_map(fn ($image) => $image->id, $is3);
$this->assertEquals(3, count($ids3));
$this->assertEquals($ids1, $ids2);
$this->assertNotEquals($ids1, $ids3);
}
/**
* @return int[]
*/
public function testUpload(): array
{
$this->log_in_as_user();
$image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "thing computer screenshot pbx phone");
$image_id_2 = $this->post_image("tests/bedroom_workshop.jpg", "thing computer computing bedroom workshop");
$this->log_out();
# make sure both uploads were ok
$this->assertTrue($image_id_1 > 0);
$this->assertTrue($image_id_2 > 0);
return [$image_id_1, $image_id_2];
}
/** ******************************************************
* Test turning a string into an abstract query
*
* @param string $tags
* @param TagCondition[] $expected_tag_conditions
* @param ImgCondition[] $expected_img_conditions
* @param string $expected_order
*/
private function assert_TTC(
string $tags,
array $expected_tag_conditions,
array $expected_img_conditions,
string $expected_order,
): void {
$class = new \ReflectionClass(Search::class);
$terms_to_conditions = $class->getMethod("terms_to_conditions");
$terms_to_conditions->setAccessible(true); // Use this if you are running PHP older than 8.1.0
$obj = new Search();
[$tag_conditions, $img_conditions, $order] = $terms_to_conditions->invokeArgs($obj, [Tag::explode($tags, false)]);
static::assertThat(
[
"tags" => $expected_tag_conditions,
"imgs" => $expected_img_conditions,
"order" => $expected_order,
],
new IsEqual([
"tags" => $tag_conditions,
"imgs" => $img_conditions,
"order" => $order,
])
);
}
public function testTTC_Empty(): void
{
$this->assert_TTC(
"",
[
],
[
new ImgCondition(new Querylet("trash != :true", ["true" => true])),
new ImgCondition(new Querylet("private != :true OR owner_id = :private_owner_id", [
"private_owner_id" => 1,
"true" => true])),
new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])),
],
"images.id DESC"
);
}
public function testTTC_Hash(): void
{
$this->assert_TTC(
"hash=1234567890",
[
],
[
new ImgCondition(new Querylet("trash != :true", ["true" => true])),
new ImgCondition(new Querylet("private != :true OR owner_id = :private_owner_id", [
"private_owner_id" => 1,
"true" => true])),
new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])),
new ImgCondition(new Querylet("images.hash = :hash", ["hash" => "1234567890"])),
],
"images.id DESC"
);
}
public function testTTC_Ratio(): void
{
$this->assert_TTC(
"ratio=42:12345",
[
],
[
new ImgCondition(new Querylet("trash != :true", ["true" => true])),
new ImgCondition(new Querylet("private != :true OR owner_id = :private_owner_id", [
"private_owner_id" => 1,
"true" => true])),
new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])),
new ImgCondition(new Querylet("width / :width1 = height / :height1", ['width1' => 42,
'height1' => 12345])),
],
"images.id DESC"
);
}
public function testTTC_Order(): void
{
$this->assert_TTC(
"order=score",
[
],
[
new ImgCondition(new Querylet("trash != :true", ["true" => true])),
new ImgCondition(new Querylet("private != :true OR owner_id = :private_owner_id", [
"private_owner_id" => 1,
"true" => true])),
new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])),
],
"images.numeric_score DESC"
);
}
/** ******************************************************
* Test turning an abstract query into SQL + fetching the results
*
* @param string[] $tcs
* @param string[] $ics
* @param string $order
* @param int $limit
* @param int $start
* @param int[] $res
* @param string[] $path
*/
private function assert_BSQ(
array $tcs = [],
array $ics = [],
string $order = "id DESC",
int $limit = 9999,
int $start = 0,
array $res = [],
array $path = null,
): void {
global $database;
$tcs = array_map(
fn ($tag) => ($tag[0] == "-") ?
new TagCondition(substr($tag, 1), false) :
new TagCondition($tag),
$tcs
);
$ics = array_map(
fn ($ic) => send_event(new SearchTermParseEvent(0, $ic, []))->img_conditions,
$ics
);
$ics = array_merge(...$ics);
Search::$_search_path = [];
$class = new \ReflectionClass(Search::class);
$build_search_querylet = $class->getMethod("build_search_querylet");
$build_search_querylet->setAccessible(true); // Use this if you are running PHP older than 8.1.0
$obj = new Search();
$querylet = $build_search_querylet->invokeArgs($obj, [$tcs, $ics, $order, $limit, $start]);
$results = $database->get_all($querylet->sql, $querylet->variables);
static::assertThat(
[
"res" => array_map(fn ($row) => $row['id'], $results),
"path" => Search::$_search_path,
],
new IsEqual([
"res" => $res,
"path" => $path ?? Search::$_search_path,
])
);
}
/* * * * * * * * * * *
* No-tag search *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_NoTags(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: [],
res: [$image_ids[1], $image_ids[0]],
path: ["no_tags"],
);
}
/* * * * * * * * * * *
* Fast-path search *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_FastPath_NoResults(): void
{
$this->testUpload();
$this->assert_BSQ(
tcs: ["maumaumau"],
res: [],
path: ["fast", "invalid_tag"],
);
}
#[Depends('testUpload')]
public function testBSQ_FastPath_OneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["pbx"],
res: [$image_ids[0]],
path: ["fast"],
);
}
#[Depends('testUpload')]
public function testBSQ_FastPath_ManyResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer"],
res: [$image_ids[1], $image_ids[0]],
path: ["fast"],
);
}
#[Depends('testUpload')]
public function testBSQ_FastPath_WildNoResults(): void
{
$this->testUpload();
$this->assert_BSQ(
tcs: ["asdfasdf*"],
res: [],
path: ["fast", "invalid_tag"],
);
}
/**
* Only the first image matches both the wildcard and the tag.
* This checks for a bug where searching for "a* b" would return
* an image tagged "a1 a2" because the number of matched tags
* was equal to the number of searched tags.
*
* https://github.com/shish/shimmie2/issues/547
*/
#[Depends('testUpload')]
public function testBSQ_FastPath_WildOneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["screen*"],
res: [$image_ids[0]],
path: ["fast"],
);
}
/**
* Test that the fast path doesn't return duplicate results
* when a wildcard matches one image multiple times.
*/
#[Depends('testUpload')]
public function testBSQ_FastPath_WildManyResults(): void
{
$image_ids = $this->testUpload();
// two images match comp* - one matches it once, one matches it twice
$this->assert_BSQ(
tcs: ["comp*"],
res: [$image_ids[1], $image_ids[0]],
path: ["fast"],
);
}
/* * * * * * * * * * *
* General search *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_GeneralPath_NoResults(): void
{
$this->testUpload();
# multiple tags, one of which doesn't exist
# (test the "one tag doesn't exist = no hits" path)
$this->assert_BSQ(
tcs: ["computer", "not_a_tag"],
res: [],
path: ["general", "invalid_tag"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_OneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer", "screenshot"],
res: [$image_ids[0]],
path: ["general", "some_positives"],
);
}
/**
* Only the first image matches both the wildcard and the tag.
* This checks for a bug where searching for "a* b" would return
* an image tagged "a1 a2" because the number of matched tags
* was equal to the number of searched tags.
*
* https://github.com/shish/shimmie2/issues/547
*/
#[Depends('testUpload')]
public function testBSQ_GeneralPath_WildOneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["comp*", "screenshot"],
res: [$image_ids[0]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_ManyResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer", "thing"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_WildManyResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["comp*", "-asdf"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractValidFromResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer", "-pbx"],
res: [$image_ids[1]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractNotValidFromResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer", "-not_a_tag"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractValidFromDefault(): void
{
$image_ids = $this->testUpload();
// negative tag alone, should remove the image with that tag
$this->assert_BSQ(
tcs: ["-pbx"],
res: [$image_ids[1]],
path: ["general", "only_negative_tags"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractNotValidFromDefault(): void
{
$image_ids = $this->testUpload();
// negative that doesn't exist, should return all results
$this->assert_BSQ(
tcs: ["-not_a_tag"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "all_nonexistent_negatives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractMultipleNotValidFromDefault(): void
{
$image_ids = $this->testUpload();
// multiple negative tags that don't exist, should return all results
$this->assert_BSQ(
tcs: ["-not_a_tag", "-also_not_a_tag"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "all_nonexistent_negatives"],
);
}
/* * * * * * * * * * *
* Meta Search *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_ImgCond_NoResults(): void
{
$this->testUpload();
$this->assert_BSQ(
ics: ["hash=1234567890"],
res: [],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["ratio=42:12345"],
res: [],
path: ["no_tags"],
);
}
#[Depends('testUpload')]
public function testBSQ_ImgCond_OneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
ics: ["hash=feb01bab5698a11dd87416724c7a89e3"],
res: [$image_ids[0]],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["id={$image_ids[1]}"],
res: [$image_ids[1]],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["filename=screenshot"],
res: [$image_ids[0]],
path: ["no_tags"],
);
}
#[Depends('testUpload')]
public function testBSQ_ImgCond_ManyResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
ics: ["size=640x480"],
res: [$image_ids[1], $image_ids[0]],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["tags=5"],
res: [$image_ids[1], $image_ids[0]],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["ext=jpg"],
res: [$image_ids[1], $image_ids[0]],
path: ["no_tags"],
);
}
/* * * * * * * * * * *
* Mixed *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_TagCondWithImgCond(): void
{
$image_ids = $this->testUpload();
// multiple tags, many results
$this->assert_BSQ(
tcs: ["computer"],
ics: ["size=640x480"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "some_positives"],
);
}
/**
* get_images
*/
#[Depends('testUpload')]
public function test_get_images(): void
{
$image_ids = $this->testUpload();
$res = Search::get_images($image_ids);
$this->assertGreaterThan($res[0]->id, $res[1]->id);
$res = Search::get_images(array_reverse($image_ids));
$this->assertLessThan($res[0]->id, $res[1]->id);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
class StdLibExTest extends ShimmiePHPUnitTestCase
{
public function testJsonEncodeOk(): void
{
$this->assertEquals(
'{"a":1,"b":2,"c":3,"d":4,"e":5}',
\Safe\json_encode(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5])
);
}
public function testJsonEncodeError(): void
{
$e = $this->assertException(\Exception::class, function () {
\Safe\json_encode("\xB1\x31");
});
$this->assertEquals(
"Malformed UTF-8 characters, possibly incorrectly encoded",
$e->getMessage()
);
}
}

21
core/tests/TagTest.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
require_once "core/imageboard/tag.php";
class TagTest extends TestCase
{
public function test_compare(): void
{
$this->assertFalse(Tag::compare(["foo"], ["bar"]));
$this->assertFalse(Tag::compare(["foo"], ["foo", "bar"]));
$this->assertTrue(Tag::compare([], []));
$this->assertTrue(Tag::compare(["foo"], ["FoO"]));
$this->assertTrue(Tag::compare(["foo", "bar"], ["bar", "FoO"]));
}
}

273
core/tests/UrlsTest.php Normal file
View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Depends;
require_once "core/urls.php";
class UrlsTest extends TestCase
{
/**
* An integration test for
* - search_link()
* - make_link()
* - _get_query()
* - get_search_terms()
*/
#[Depends("test_search_link")]
public function test_get_search_terms_from_search_link(): void
{
/**
* @param array<string> $vars
* @return array<string>
*/
$gst = function (array $terms): array {
$pre = new PageRequestEvent("GET", _get_query(search_link($terms)), [], []);
$pre->page_matches("post/list/{search}/{page}");
return Tag::explode($pre->get_arg('search'));
};
global $config;
foreach ([true, false] as $nice_urls) {
$config->set_bool(SetupConfig::NICE_URLS, $nice_urls);
$this->assertEquals(
["bar", "foo"],
$gst(["foo", "bar"])
);
$this->assertEquals(
["AC/DC"],
$gst(["AC/DC"])
);
$this->assertEquals(
["cat*", "rating=?"],
$gst(["rating=?", "cat*"]),
);
}
}
#[Depends("test_get_base_href")]
public function test_make_link(): void
{
global $config;
foreach ([true, false] as $nice_urls) {
$config->set_bool(SetupConfig::NICE_URLS, $nice_urls);
// basic
$this->assertEquals(
$nice_urls ? "/test/foo" : "/test/index.php?q=foo",
make_link("foo")
);
// remove leading slash from path
$this->assertEquals(
$nice_urls ? "/test/foo" : "/test/index.php?q=foo",
make_link("/foo")
);
// query
$this->assertEquals(
$nice_urls ? "/test/foo?a=1&b=2" : "/test/index.php?q=foo&a=1&b=2",
make_link("foo", "a=1&b=2")
);
// hash
$this->assertEquals(
$nice_urls ? "/test/foo#cake" : "/test/index.php?q=foo#cake",
make_link("foo", null, "cake")
);
// query + hash
$this->assertEquals(
$nice_urls ? "/test/foo?a=1&b=2#cake" : "/test/index.php?q=foo&a=1&b=2#cake",
make_link("foo", "a=1&b=2", "cake")
);
}
}
#[Depends("test_make_link")]
public function test_search_link(): void
{
global $config;
foreach ([true, false] as $nice_urls) {
$config->set_bool(SetupConfig::NICE_URLS, $nice_urls);
$this->assertEquals(
$nice_urls ? "/test/post/list/bar%20foo/1" : "/test/index.php?q=post/list/bar%20foo/1",
search_link(["foo", "bar"])
);
$this->assertEquals(
$nice_urls ? "/test/post/list/AC%2FDC/1" : "/test/index.php?q=post/list/AC%2FDC/1",
search_link(["AC/DC"])
);
$this->assertEquals(
$nice_urls ? "/test/post/list/cat%2A%20rating%3D%3F/1" : "/test/index.php?q=post/list/cat%2A%20rating%3D%3F/1",
search_link(["rating=?", "cat*"])
);
}
}
#[Depends("test_get_base_href")]
public function test_get_query(): void
{
// just validating an assumption that this test relies upon
$this->assertEquals(get_base_href(), "/test");
$this->assertEquals(
"tasty/cake",
_get_query("/test/tasty/cake"),
'http://$SERVER/$INSTALL_DIR/$PATH should return $PATH'
);
$this->assertEquals(
"tasty/cake",
_get_query("/test/index.php?q=tasty/cake"),
'http://$SERVER/$INSTALL_DIR/index.php?q=$PATH should return $PATH'
);
$this->assertEquals(
"tasty/cake%20pie",
_get_query("/test/index.php?q=tasty/cake%20pie"),
'URL encoded paths should be left alone'
);
$this->assertEquals(
"tasty/cake%20pie",
_get_query("/test/tasty/cake%20pie"),
'URL encoded queries should be left alone'
);
$this->assertEquals(
"",
_get_query("/test/"),
'If just viewing install directory, should return /'
);
$this->assertEquals(
"",
_get_query("/test/index.php"),
'If just viewing index.php, should return /'
);
$this->assertEquals(
"post/list/tasty%2Fcake/1",
_get_query("/test/post/list/tasty%2Fcake/1"),
'URL encoded niceurls should be left alone, even encoded slashes'
);
$this->assertEquals(
"post/list/tasty%2Fcake/1",
_get_query("/test/index.php?q=post/list/tasty%2Fcake/1"),
'URL encoded uglyurls should be left alone, even encoded slashes'
);
}
public function test_is_https_enabled(): void
{
$this->assertFalse(is_https_enabled(), "HTTPS should be disabled by default");
$_SERVER['HTTPS'] = "on";
$this->assertTrue(is_https_enabled(), "HTTPS should be enabled when set to 'on'");
unset($_SERVER['HTTPS']);
}
public function test_get_base_href(): void
{
// PHP_SELF should point to "the currently executing script
// relative to the document root"
$this->assertEquals("", get_base_href(["PHP_SELF" => "/index.php"]));
$this->assertEquals("/mydir", get_base_href(["PHP_SELF" => "/mydir/index.php"]));
// SCRIPT_FILENAME should point to "the absolute pathname of
// the currently executing script" and DOCUMENT_ROOT should
// point to "the document root directory under which the
// current script is executing"
$this->assertEquals("", get_base_href([
"PHP_SELF" => "<invalid>",
"SCRIPT_FILENAME" => "/var/www/html/index.php",
"DOCUMENT_ROOT" => "/var/www/html",
]), "root directory");
$this->assertEquals("/mydir", get_base_href([
"PHP_SELF" => "<invalid>",
"SCRIPT_FILENAME" => "/var/www/html/mydir/index.php",
"DOCUMENT_ROOT" => "/var/www/html",
]), "subdirectory");
$this->assertEquals("", get_base_href([
"PHP_SELF" => "<invalid>",
"SCRIPT_FILENAME" => "/var/www/html/index.php",
"DOCUMENT_ROOT" => "/var/www/html/",
]), "trailing slash in DOCUMENT_ROOT root should be ignored");
$this->assertEquals("/mydir", get_base_href([
"PHP_SELF" => "<invalid>",
"SCRIPT_FILENAME" => "/var/www/html/mydir/index.php",
"DOCUMENT_ROOT" => "/var/www/html/",
]), "trailing slash in DOCUMENT_ROOT subdir should be ignored");
}
#[Depends("test_is_https_enabled")]
#[Depends("test_get_base_href")]
public function test_make_http(): void
{
$this->assertEquals(
"http://cli-command/test/foo",
make_http("foo"),
"relative to shimmie root"
);
$this->assertEquals(
"http://cli-command/foo",
make_http("/foo"),
"relative to web server"
);
$this->assertEquals(
"https://foo.com",
make_http("https://foo.com"),
"absolute URL should be left alone"
);
}
public function test_modify_url(): void
{
$this->assertEquals(
"/foo/bar?a=3&b=2",
modify_url("/foo/bar?a=1&b=2", ["a" => "3"])
);
$this->assertEquals(
"https://blah.com/foo/bar?b=2",
modify_url("https://blah.com/foo/bar?a=1&b=2", ["a" => null])
);
$this->assertEquals(
"/foo/bar",
modify_url("/foo/bar?a=1&b=2", ["a" => null, "b" => null])
);
}
public function test_referer_or(): void
{
unset($_SERVER['HTTP_REFERER']);
$this->assertEquals(
"foo",
referer_or("foo")
);
$_SERVER['HTTP_REFERER'] = "cake";
$this->assertEquals(
"cake",
referer_or("foo")
);
$_SERVER['HTTP_REFERER'] = "cake";
$this->assertEquals(
"foo",
referer_or("foo", ["cake"])
);
}
public function tearDown(): void
{
global $config;
$config->set_bool(SetupConfig::NICE_URLS, true);
parent::tearDown();
}
}

196
core/tests/UtilTest.php Normal file
View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
require_once "core/util.php";
class UtilTest extends TestCase
{
public function test_get_theme(): void
{
$this->assertEquals("default", get_theme());
}
public function test_get_memory_limit(): void
{
get_memory_limit();
$this->assertTrue(true);
}
public function test_check_gd_version(): void
{
check_gd_version();
$this->assertTrue(true);
}
public function test_check_im_version(): void
{
check_im_version();
$this->assertTrue(true);
}
public function test_human_filesize(): void
{
$this->assertEquals("123.00B", human_filesize(123));
$this->assertEquals("123B", human_filesize(123, 0));
$this->assertEquals("120.56KB", human_filesize(123456));
}
public function test_generate_key(): void
{
$this->assertEquals(20, strlen(generate_key()));
}
public function test_warehouse_path(): void
{
$hash = "7ac19c10d6859415";
$this->assertEquals(
join_path(DATA_DIR, "base", $hash),
warehouse_path("base", $hash, false, 0)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", $hash),
warehouse_path("base", $hash, false, 1)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", $hash),
warehouse_path("base", $hash, false, 2)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", $hash),
warehouse_path("base", $hash, false, 3)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", $hash),
warehouse_path("base", $hash, false, 4)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", $hash),
warehouse_path("base", $hash, false, 5)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", $hash),
warehouse_path("base", $hash, false, 6)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", "94", $hash),
warehouse_path("base", $hash, false, 7)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", "94", "15", $hash),
warehouse_path("base", $hash, false, 8)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", "94", "15", $hash),
warehouse_path("base", $hash, false, 9)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", "94", "15", $hash),
warehouse_path("base", $hash, false, 10)
);
}
public function test_load_balance_url(): void
{
$hash = "7ac19c10d6859415";
$ext = "jpg";
// pseudo-randomly select one of the image servers, balanced in given ratio
$this->assertEquals(
"https://baz.mycdn.com/7ac19c10d6859415.jpg",
load_balance_url("https://{foo=10,bar=5,baz=5}.mycdn.com/$hash.$ext", $hash)
);
// N'th and N+1'th results should be different
$this->assertNotEquals(
load_balance_url("https://{foo=10,bar=5,baz=5}.mycdn.com/$hash.$ext", $hash, 0),
load_balance_url("https://{foo=10,bar=5,baz=5}.mycdn.com/$hash.$ext", $hash, 1)
);
}
public function test_path_to_tags(): void
{
$this->assertEquals(
[],
path_to_tags("nope.jpg")
);
$this->assertEquals(
[],
path_to_tags("\\")
);
$this->assertEquals(
[],
path_to_tags("/")
);
$this->assertEquals(
[],
path_to_tags("C:\\")
);
$this->assertEquals(
["test", "tag"],
path_to_tags("123 - test tag.jpg")
);
$this->assertEquals(
["foo", "bar"],
path_to_tags("/foo/bar/baz.jpg")
);
$this->assertEquals(
["cake", "pie", "foo", "bar"],
path_to_tags("/foo/bar/123 - cake pie.jpg")
);
$this->assertEquals(
["bacon", "lemon"],
path_to_tags("\\bacon\\lemon\\baz.jpg")
);
$this->assertEquals(
["category:tag"],
path_to_tags("/category:/tag/baz.jpg")
);
}
public function test_contact_link(): void
{
$this->assertEquals(
"mailto:asdf@example.com",
contact_link("asdf@example.com")
);
$this->assertEquals(
"http://example.com",
contact_link("http://example.com")
);
$this->assertEquals(
"https://foo.com/bar",
contact_link("foo.com/bar")
);
$this->assertEquals(
"john",
contact_link("john")
);
}
public function test_get_user(): void
{
// TODO: HTTP_AUTHORIZATION
// TODO: cookie user + session
// fallback to anonymous
$this->assertEquals(
"Anonymous",
_get_user()->name
);
}
}

View File

@@ -4,14 +4,12 @@ declare(strict_types=1);
namespace Shimmie2;
use PhpParser\Node\Expr\Cast\Double;
class Link
{
public ?string $page;
public ?string $query;
public function __construct(?string $page=null, ?string $query=null)
public function __construct(?string $page = null, ?string $query = null)
{
$this->page = $page;
$this->query = $query;
@@ -43,9 +41,10 @@ function search_link(array $terms = [], int $page = 1): string
* Figure out the correct way to link to a page, taking into account
* things like the nice URLs setting.
*
* eg make_link("post/list") becomes "/v2/index.php?q=post/list"
* eg make_link("foo/bar") becomes either "/v2/foo/bar" (niceurls) or
* "/v2/index.php?q=foo/bar" (uglyurls)
*/
function make_link(?string $page=null, ?string $query=null, ?string $fragment=null): string
function make_link(?string $page = null, ?string $query = null, ?string $fragment = null): string
{
global $config;
@@ -176,14 +175,22 @@ function unparse_url(array $parsed_url): string
/**
* Take the current URL and modify some parameters
*
* @param array<string, mixed> $changes
*/
function modify_current_url(array $changes): string
{
return modify_url($_SERVER['REQUEST_URI'], $changes);
}
/**
* Take a URL and modify some parameters
*
* @param array<string, mixed> $changes
*/
function modify_url(string $url, array $changes): string
{
/** @var array<string, mixed> */
$parts = parse_url($url);
$params = [];
@@ -224,8 +231,10 @@ function make_http(string $link): string
/**
* If HTTP_REFERER is set, and not blacklisted, then return it
* Else return a default $dest
*
* @param string[]|null $blacklist
*/
function referer_or(string $dest, ?array $blacklist=null): string
function referer_or(string $dest, ?array $blacklist = null): string
{
if (empty($_SERVER['HTTP_REFERER'])) {
return $dest;

View File

@@ -11,12 +11,6 @@ use MicroHTML\HTMLElement;
use function MicroHTML\INPUT;
function _new_user(array $row): User
{
return new User($row);
}
/**
* Class User
*
@@ -54,16 +48,14 @@ class User
*/
public function __construct(array $row)
{
global $_shm_user_classes;
$this->id = int_escape((string)$row['id']);
$this->name = $row['name'];
$this->email = $row['email'];
$this->join_date = $row['joindate'];
$this->passhash = $row['pass'];
if (array_key_exists($row["class"], $_shm_user_classes)) {
$this->class = $_shm_user_classes[$row["class"]];
if (array_key_exists($row["class"], UserClass::$known_classes)) {
$this->class = UserClass::$known_classes[$row["class"]];
} else {
throw new ServerError("User '{$this->name}' has invalid class '{$row["class"]}'");
}
@@ -98,7 +90,7 @@ class User
} else {
$query = "SELECT * FROM users WHERE name = :name AND md5(pass || :ip) = :sess";
}
$row = $database->get_row($query, ["name"=>$name, "ip"=>get_session_ip($config), "sess"=>$session]);
$row = $database->get_row($query, ["name" => $name, "ip" => get_session_ip($config), "sess" => $session]);
$cache->set("user-session:$name-$session", $row, 600);
}
return is_null($row) ? null : new User($row);
@@ -113,7 +105,7 @@ class User
return new User($cached);
}
}
$row = $database->get_row("SELECT * FROM users WHERE id = :id", ["id"=>$id]);
$row = $database->get_row("SELECT * FROM users WHERE id = :id", ["id" => $id]);
if ($id === 1) {
$cache->set('user-id:'.$id, $row, 600);
}
@@ -124,7 +116,7 @@ class User
public static function by_name(string $name): ?User
{
global $database;
$row = $database->get_row("SELECT * FROM users WHERE LOWER(name) = LOWER(:name)", ["name"=>$name]);
$row = $database->get_row("SELECT * FROM users WHERE LOWER(name) = LOWER(:name)", ["name" => $name]);
return is_null($row) ? null : new User($row);
}
@@ -188,7 +180,7 @@ class User
public function set_class(string $class): void
{
global $database;
$database->execute("UPDATE users SET class=:class WHERE id=:id", ["class"=>$class, "id"=>$this->id]);
$database->execute("UPDATE users SET class=:class WHERE id=:id", ["class" => $class, "id" => $this->id]);
log_info("core-user", 'Set class for '.$this->name.' to '.$class);
}
@@ -200,7 +192,7 @@ class User
}
$old_name = $this->name;
$this->name = $name;
$database->execute("UPDATE users SET name=:name WHERE id=:id", ["name"=>$this->name, "id"=>$this->id]);
$database->execute("UPDATE users SET name=:name WHERE id=:id", ["name" => $this->name, "id" => $this->id]);
log_info("core-user", "Changed username for {$old_name} to {$this->name}");
}
@@ -208,19 +200,15 @@ class User
{
global $database;
$hash = password_hash($password, PASSWORD_BCRYPT);
if (is_string($hash)) {
$this->passhash = $hash;
$database->execute("UPDATE users SET pass=:hash WHERE id=:id", ["hash"=>$this->passhash, "id"=>$this->id]);
log_info("core-user", 'Set password for '.$this->name);
} else {
throw new SCoreException("Failed to hash password");
}
$this->passhash = $hash;
$database->execute("UPDATE users SET pass=:hash WHERE id=:id", ["hash" => $this->passhash, "id" => $this->id]);
log_info("core-user", 'Set password for '.$this->name);
}
public function set_email(string $address): void
{
global $database;
$database->execute("UPDATE users SET email=:email WHERE id=:id", ["email"=>$address, "id"=>$this->id]);
$database->execute("UPDATE users SET email=:email WHERE id=:id", ["email" => $address, "id" => $this->id]);
log_info("core-user", 'Set email for '.$this->name);
}

View File

@@ -6,13 +6,6 @@ namespace Shimmie2;
use GQLA\Type;
use GQLA\Field;
use GQLA\Query;
/**
* @global UserClass[] $_shm_user_classes
*/
global $_shm_user_classes;
$_shm_user_classes = [];
/**
* Class UserClass
@@ -20,30 +13,39 @@ $_shm_user_classes = [];
#[Type(name: "UserClass")]
class UserClass
{
/** @var array<string, UserClass> */
public static array $known_classes = [];
#[Field]
public ?string $name = null;
public ?UserClass $parent = null;
/** @var array<string, bool> */
public array $abilities = [];
/**
* @param array<string, bool> $abilities
*/
public function __construct(string $name, string $parent = null, array $abilities = [])
{
global $_shm_user_classes;
$this->name = $name;
$this->abilities = $abilities;
if (!is_null($parent)) {
$this->parent = $_shm_user_classes[$parent];
$this->parent = static::$known_classes[$parent];
}
$_shm_user_classes[$name] = $this;
static::$known_classes[$name] = $this;
}
/**
* @return string[]
*/
#[Field(type: "[Permission!]!")]
public function permissions(): array
{
$perms = [];
foreach ((new \ReflectionClass('\Shimmie2\Permissions'))->getConstants() as $k => $v) {
foreach ((new \ReflectionClass(Permissions::class))->getConstants() as $k => $v) {
if ($this->can($v)) {
$perms[] = $v;
}
@@ -61,10 +63,9 @@ class UserClass
} elseif (!is_null($this->parent)) {
return $this->parent->can($ability);
} else {
global $_shm_user_classes;
$min_dist = 9999;
$min_ability = null;
foreach ($_shm_user_classes['base']->abilities as $a => $cando) {
foreach (UserClass::$known_classes['base']->abilities as $a => $cando) {
$v = levenshtein($ability, $a);
if ($v < $min_dist) {
$min_dist = $v;
@@ -93,6 +94,7 @@ unset($_all_false);
// Ghost users can't do anything
new UserClass("ghost", "base", [
Permissions::READ_PM => true,
]);
// Anonymous users can't do anything by default, but

View File

@@ -54,8 +54,8 @@ function contact_link(?string $contact = null): ?string
function is_https_enabled(): bool
{
// check forwarded protocol
if (REVERSE_PROXY_X_HEADERS && !empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') {
$_SERVER['HTTPS']='on';
if (is_trusted_proxy() && !empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') {
$_SERVER['HTTPS'] = 'on';
}
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
}
@@ -80,10 +80,10 @@ function get_memory_limit(): int
global $config;
// thumbnail generation requires lots of memory
$default_limit = 8*1024*1024; // 8 MB of memory is PHP's default.
$default_limit = 8 * 1024 * 1024; // 8 MB of memory is PHP's default.
$shimmie_limit = $config->get_int(MediaConfig::MEM_LIMIT);
if ($shimmie_limit < 3*1024*1024) {
if ($shimmie_limit < 3 * 1024 * 1024) {
// we aren't going to fit, override
$shimmie_limit = $default_limit;
}
@@ -143,7 +143,7 @@ function check_gd_version(): int
*/
function check_im_version(): int
{
$convert_check = exec("convert");
$convert_check = exec("convert --version");
return (empty($convert_check) ? 0 : 1);
}
@@ -181,8 +181,7 @@ function is_bot(): bool
/**
* Get real IP if behind a reverse proxy
*/
function get_real_ip()
function get_real_ip(): string
{
$ip = $_SERVER['REMOTE_ADDR'];
@@ -257,9 +256,9 @@ function only_strings(array $map): array
* @param int $splits The number of octet pairs to split the hash into. Caps out at strlen($hash)/2.
* @return string
*/
function warehouse_path(string $base, string $hash, bool $create=true, int $splits = WH_SPLITS): string
function warehouse_path(string $base, string $hash, bool $create = true, int $splits = WH_SPLITS): string
{
$dirs =[DATA_DIR, $base];
$dirs = [DATA_DIR, $base];
$splits = min($splits, strlen($hash) / 2);
for ($i = 0; $i < $splits; $i++) {
$dirs[] = substr($hash, $i * 2, 2);
@@ -280,13 +279,13 @@ function warehouse_path(string $base, string $hash, bool $create=true, int $spli
function data_path(string $filename, bool $create = true): string
{
$filename = join_path("data", $filename);
if ($create&&!file_exists(dirname($filename))) {
if ($create && !file_exists(dirname($filename))) {
mkdir(dirname($filename), 0755, true);
}
return $filename;
}
function load_balance_url(string $tmpl, string $hash, int $n=0): string
function load_balance_url(string $tmpl, string $hash, int $n = 0): string
{
static $flexihashes = [];
$matches = [];
@@ -324,7 +323,14 @@ function load_balance_url(string $tmpl, string $hash, int $n=0): string
return $tmpl;
}
function fetch_url(string $url, string $mfile): ?array
class FetchException extends \Exception
{
}
/**
* @return array<string, string|string[]>
*/
function fetch_url(string $url, string $mfile): array
{
global $config;
@@ -342,7 +348,10 @@ function fetch_url(string $url, string $mfile): ?array
$response = curl_exec($ch);
if ($response === false) {
return null;
throw new FetchException("cURL failed: ".curl_error($ch));
}
if ($response === true) { // we use CURLOPT_RETURNTRANSFER, so this should never happen
throw new FetchException("cURL failed successfully??");
}
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
@@ -353,11 +362,7 @@ function fetch_url(string $url, string $mfile): ?array
curl_close($ch);
fwrite($fp, $body);
fclose($fp);
return $headers;
}
if ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "wget") {
} elseif ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "wget") {
$s_url = escapeshellarg($url);
$s_mfile = escapeshellarg($mfile);
system("wget --no-check-certificate $s_url --output-document=$s_mfile");
@@ -369,7 +374,7 @@ function fetch_url(string $url, string $mfile): ?array
$fp_in = @fopen($url, "r");
$fp_out = fopen($mfile, "w");
if (!$fp_in || !$fp_out) {
return null;
throw new FetchException("fopen failed");
}
$length = 0;
while (!feof($fp_in) && $length <= $config->get_int(UploadConfig::SIZE)) {
@@ -381,14 +386,22 @@ function fetch_url(string $url, string $mfile): ?array
fclose($fp_out);
$headers = http_parse_headers(implode("\n", $http_response_header));
return $headers;
} else {
throw new FetchException("No transload engine configured");
}
return null;
if (filesize($mfile) == 0) {
@unlink($mfile);
throw new FetchException("No data found in $url -- perhaps the site has hotlink protection?");
}
return $headers;
}
function path_to_tags(string $path): string
/**
* @return string[]
*/
function path_to_tags(string $path): array
{
$matches = [];
$tags = [];
@@ -409,7 +422,7 @@ function path_to_tags(string $path): string
$category_to_inherit = "";
foreach (explode(" ", $dir) as $tag) {
$tag = trim($tag);
if ($tag=="") {
if ($tag == "") {
continue;
}
if (substr_compare($tag, ":", -1) === 0) {
@@ -417,7 +430,7 @@ function path_to_tags(string $path): string
// which is for inheriting to tags on the subfolder
$category_to_inherit = $tag;
} else {
if ($category!="" && !str_contains($tag, ":")) {
if ($category != "" && !str_contains($tag, ":")) {
// This indicates that category inheritance is active,
// and we've encountered a tag that does not specify a category.
// So we attach the inherited category to the tag.
@@ -432,9 +445,12 @@ function path_to_tags(string $path): string
$category = $category_to_inherit;
}
return implode(" ", $tags);
return $tags;
}
/**
* @return string[]
*/
function get_dir_contents(string $dir): array
{
assert(!empty($dir));
@@ -450,20 +466,10 @@ function get_dir_contents(string $dir): array
function remove_empty_dirs(string $dir): bool
{
assert(!empty($dir));
$result = true;
if (!is_dir($dir)) {
return false;
}
$items = array_diff(
scandir(
$dir
),
['..', '.']
);
$items = get_dir_contents($dir);
;
foreach ($items as $item) {
$path = join_path($dir, $item);
if (is_dir($path)) {
@@ -472,31 +478,21 @@ function remove_empty_dirs(string $dir): bool
$result = false;
}
}
if ($result===true) {
if ($result === true) {
$result = rmdir($dir);
}
return $result;
}
/**
* @return string[]
*/
function get_files_recursively(string $dir): array
{
assert(!empty($dir));
if (!is_dir($dir)) {
return [];
}
$things = array_diff(
scandir(
$dir
),
['..', '.']
);
$things = get_dir_contents($dir);
$output = [];
foreach ($things as $thing) {
$path = join_path($dir, $thing);
if (is_file($path)) {
@@ -511,6 +507,8 @@ function get_files_recursively(string $dir): array
/**
* Returns amount of files & total size of dir.
*
* @return array{"path": string, "total_files": int, "total_mb": string}
*/
function scan_dir(string $path): array
{
@@ -570,6 +568,11 @@ function get_debug_info(): string
return $debug;
}
/**
* Collects some debug information (execution time, memory usage, queries, etc)
*
* @return array<string, mixed>
*/
function get_debug_info_arr(): array
{
global $cache, $config, $_shm_event_count, $database, $_shm_load_start;
@@ -583,7 +586,7 @@ function get_debug_info_arr(): array
return [
"time" => round(ftime() - $_shm_load_start, 2),
"dbtime" => round($database->dbtime, 2),
"mem_mb" => round(((memory_get_peak_usage(true)+512)/1024)/1024, 2),
"mem_mb" => round(((memory_get_peak_usage(true) + 512) / 1024) / 1024, 2),
"files" => count(get_included_files()),
"query_count" => $database->query_count,
// "query_log" => $database->queries,
@@ -599,6 +602,9 @@ function get_debug_info_arr(): array
* Request initialisation stuff *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* @param string[] $files
*/
function require_all(array $files): void
{
foreach ($files as $filename) {
@@ -606,7 +612,7 @@ function require_all(array $files): void
}
}
function _load_core_files()
function _load_core_files(): void
{
global $_tracer;
$_tracer->begin("Load Core Files");
@@ -659,20 +665,8 @@ function _set_up_shimmie_environment(): void
// The trace system has a certain amount of memory consumption every time it is used,
// so to prevent running out of memory during complex operations code that uses it should
// check if tracer output is enabled before making use of it.
$tracer_enabled = constant('TRACE_FILE')!==null;
}
function _get_themelet_files(string $_theme): array
{
$base_themelets = [];
$base_themelets[] = 'themes/'.$_theme.'/page.class.php';
$base_themelets[] = 'themes/'.$_theme.'/themelet.class.php';
$ext_themelets = zglob("ext/{".Extension::get_enabled_extensions_as_string()."}/theme.php");
$custom_themelets = zglob('themes/'.$_theme.'/{'.Extension::get_enabled_extensions_as_string().'}.theme.php');
return array_merge($base_themelets, $ext_themelets, $custom_themelets);
// @phpstan-ignore-next-line - TRACE_FILE is defined in config
$tracer_enabled = !is_null('TRACE_FILE');
}
@@ -684,8 +678,6 @@ function _fatal_error(\Exception $e): void
$version = VERSION;
$message = $e->getMessage();
$phpver = phpversion();
$query = is_subclass_of($e, "Shimmie2\SCoreException") ? $e->query : null;
$code = is_subclass_of($e, "Shimmie2\SCoreException") ? $e->http_code : 500;
//$hash = exec("git rev-parse HEAD");
//$h_hash = $hash ? "<p><b>Hash:</b> $hash" : "";
@@ -697,14 +689,17 @@ function _fatal_error(\Exception $e): void
foreach ($t as $n => $f) {
$c = $f['class'] ?? '';
$t = $f['type'] ?? '';
$i = $f['file'] ?? 'unknown file';
$l = $f['line'] ?? -1;
$a = implode(", ", array_map("Shimmie2\stringer", $f['args'] ?? []));
print("$n: {$f['file']}({$f['line']}): {$c}{$t}{$f['function']}({$a})\n");
print("$n: {$i}({$l}): {$c}{$t}{$f['function']}({$a})\n");
}
print("Message: $message\n");
if ($query) {
print("Query: {$query}\n");
if (is_a($e, DatabaseException::class)) {
print("Query: {$e->query}\n");
print("Args: ".var_export($e->args, true)."\n");
}
print("Version: $version (on $phpver)\n");
@@ -752,7 +747,7 @@ function _get_user(): User
}
}
}
if ($page->get_cookie("user") && $page->get_cookie("session")) {
if (is_null($my_user) && $page->get_cookie("user") && $page->get_cookie("session")) {
$my_user = User::by_session($page->get_cookie("user"), $page->get_cookie("session"));
}
if (is_null($my_user)) {
@@ -763,11 +758,6 @@ function _get_user(): User
return $my_user;
}
function _get_query(): string
{
return (@$_POST["q"] ?: @$_GET["q"]) ?: "/";
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* HTML Generation *
@@ -812,7 +802,7 @@ function make_form(string $target, bool $multipart = false, string $form_id = ""
}
const BYTE_DENOMINATIONS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
function human_filesize(int $bytes, $decimals = 2): string
function human_filesize(int $bytes, int $decimals = 2): string
{
$factor = floor((strlen(strval($bytes)) - 1) / 3);
return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @BYTE_DENOMINATIONS[$factor];