* split local cache implementation into a separate class

* allow custom implementations provided by plugins
This commit is contained in:
Andrew Dolgov 2022-11-23 21:18:40 +03:00
parent 30c04adfa6
commit 10a1dd35e3
No known key found for this signature in database
GPG Key ID: 1A56B4FA25D4AF2A
5 changed files with 252 additions and 145 deletions

30
classes/cache/adapter.php vendored Normal file
View File

@ -0,0 +1,30 @@
<?php
interface Cache_Adapter {
public function set_dir(string $dir) : void;
public function get_dir(): string;
public function make_dir(): bool;
public function is_writable(?string $filename = null): bool;
public function exists(string $filename): bool;
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
*/
public function get_size(string $filename);
/**
* @param mixed $data
*
* @return int|false Bytes written or false if an error occurred.
*/
public function put(string $filename, $data);
public function touch(string $filename): bool;
public function get(string $filename): ?string;
public function get_full_path(string $filename): string;
/**
* @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise
*/
public function get_mime_type(string $filename);
/**
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
*/
public function send(string $filename);
public function expire_all(): void;
}

143
classes/cache/local.php vendored Normal file
View File

@ -0,0 +1,143 @@
<?php
class Cache_Local implements Cache_Adapter {
private string $dir;
public function set_dir(string $dir) : void {
$this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir));
}
public function get_dir(): string {
return $this->dir;
}
public function make_dir(): bool {
if (!is_dir($this->dir)) {
return mkdir($this->dir);
}
return false;
}
public function is_writable(?string $filename = null): bool {
if ($filename) {
if (file_exists($this->get_full_path($filename)))
return is_writable($this->get_full_path($filename));
else
return is_writable($this->dir);
} else {
return is_writable($this->dir);
}
}
public function exists(string $filename): bool {
return file_exists($this->get_full_path($filename));
}
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
*/
public function get_size(string $filename) {
if ($this->exists($filename))
return filesize($this->get_full_path($filename));
else
return -1;
}
public function get_full_path(string $filename): string {
return $this->dir . "/" . basename(clean($filename));
}
public function get(string $filename): ?string {
if ($this->exists($filename))
return file_get_contents($this->get_full_path($filename));
else
return null;
}
/**
* @param mixed $data
*
* @return int|false Bytes written or false if an error occurred.
*/
public function put(string $filename, $data) {
return file_put_contents($this->get_full_path($filename), $data);
}
public function touch(string $filename): bool {
return touch($this->get_full_path($filename));
}
/**
* @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise
*/
public function get_mime_type(string $filename) {
if ($this->exists($filename))
return mime_content_type($this->get_full_path($filename));
else
return null;
}
/**
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
*/
public function send(string $filename) {
return $this->send_local_file($this->get_full_path($filename));
}
public function expire_all(): void {
$dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir");
foreach ($dirs as $cache_dir) {
$num_deleted = 0;
if (is_writable($cache_dir) && !file_exists("$cache_dir/.no-auto-expiry")) {
$files = glob("$cache_dir/*");
if ($files) {
foreach ($files as $file) {
if (time() - filemtime($file) > 86400*Config::get(Config::CACHE_MAX_DAYS)) {
unlink($file);
++$num_deleted;
}
}
}
Debug::log("Expired $cache_dir: removed $num_deleted files.");
}
}
}
/**
* this is essentially a wrapper for readfile() which allows plugins to hook
* output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else
*
* hook function should return true if request was handled (or at least attempted to)
*
* note that this can be called without user context so the plugin to handle this
* should be loaded systemwide in config.php
*
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
*/
function send_local_file(string $filename) {
if (file_exists($filename)) {
if (is_writable($filename)) touch($filename);
$tmppluginhost = new PluginHost();
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM);
//$tmppluginhost->load_data();
if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename))
return true;
$stamp = gmdate("D, d M Y H:i:s", (int)filemtime($filename)) . " GMT";
header("Last-Modified: $stamp", true);
return readfile($filename);
} else {
return false;
}
}
}

View File

@ -1,6 +1,7 @@
<?php <?php
class DiskCache { class DiskCache implements Cache_Adapter {
private string $dir; /** @var Cache_Adapter $adapter */
private $adapter;
/** /**
* https://stackoverflow.com/a/53662733 * https://stackoverflow.com/a/53662733
@ -195,60 +196,63 @@ class DiskCache {
]; ];
public function __construct(string $dir) { public function __construct(string $dir) {
$this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir)); foreach (PluginHost::getInstance()->get_plugins() as $n => $p) {
if (implements_interface($p, "Cache_Adapter")) {
/** @var Cache_Adapter $p */
$this->adapter = $p;
$this->adapter->set_dir($dir);
return;
}
}
$this->adapter = new Cache_Local();
$this->adapter->set_dir($dir);
} }
public function get_dir(): string { public function set_dir(string $dir) : void {
return $this->dir; $this->adapter->set_dir($dir);
} }
public function make_dir(): bool { public function make_dir(): bool {
if (!is_dir($this->dir)) { return $this->adapter->make_dir();
return mkdir($this->dir);
}
return false;
} }
public function is_writable(?string $filename = null): bool { public function is_writable(?string $filename = null): bool {
if ($filename) { return $this->adapter->is_writable($filename);
if (file_exists($this->get_full_path($filename)))
return is_writable($this->get_full_path($filename));
else
return is_writable($this->dir);
} else {
return is_writable($this->dir);
}
} }
public function exists(string $filename): bool { public function exists(string $filename): bool {
return file_exists($this->get_full_path($filename)); return $this->adapter->exists($filename);
} }
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
*/
public function get_size(string $filename) { public function get_size(string $filename) {
if ($this->exists($filename)) return $this->adapter->get_size($filename);
return filesize($this->get_full_path($filename));
else
return -1;
} }
public function get_full_path(string $filename): string {
return $this->dir . "/" . basename(clean($filename));
}
/**
* @param mixed $data
*
* @return int|false Bytes written or false if an error occurred.
*/
public function put(string $filename, $data) { public function put(string $filename, $data) {
return file_put_contents($this->get_full_path($filename), $data); return $this->adapter->put($filename, $data);
} }
public function touch(string $filename): bool { public function touch(string $filename): bool {
return touch($this->get_full_path($filename)); return $this->adapter->touch($filename);
}
public function get(string $filename): ?string {
return $this->adapter->get($filename);
}
static function expire(): void {
$adapter = new Cache_Local();
$adapter->expire_all();
}
public function expire_all(): void {
$this->adapter->expire_all();
}
public function get_dir(): string {
return $this->adapter->get_dir();
} }
/** Downloads $url to cache as $local_filename if its missing (unless $force-ed) /** Downloads $url to cache as $local_filename if its missing (unless $force-ed)
@ -271,21 +275,44 @@ class DiskCache {
return false; return false;
} }
public function get(string $filename): ?string { /**
if ($this->exists($filename)) * @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
return file_get_contents($this->get_full_path($filename)); */
else public function send(string $filename) {
return null; $mimetype = $this->adapter->get_mime_type($filename);
if ($mimetype == "application/octet-stream")
$mimetype = "video/mp4";
# block SVG because of possible embedded javascript (.....)
$mimetype_blacklist = [ "image/svg+xml" ];
/* only serve video and images */
if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) {
http_response_code(400);
header("Content-type: text/plain");
print "Stored file has disallowed content type ($mimetype)";
return false;
}
$fake_extension = $this->get_fake_extension($filename);
if ($fake_extension)
$fake_extension = ".$fake_extension";
header("Content-Disposition: inline; filename=\"{$filename}{$fake_extension}\"");
header("Content-type: $mimetype");
return $this->adapter->send($filename);
}
public function get_full_path(string $filename): string {
return $this->adapter->get_full_path($filename);
} }
/**
* @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise
*/
public function get_mime_type(string $filename) { public function get_mime_type(string $filename) {
if ($this->exists($filename)) return $this->adapter->get_mime_type($filename);
return mime_content_type($this->get_full_path($filename));
else
return null;
} }
public function get_fake_extension(string $filename): string { public function get_fake_extension(string $filename): string {
@ -297,22 +324,8 @@ class DiskCache {
return ""; return "";
} }
/**
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
*/
public function send(string $filename) {
$fake_extension = $this->get_fake_extension($filename);
if ($fake_extension)
$fake_extension = ".$fake_extension";
header("Content-Disposition: inline; filename=\"{$filename}{$fake_extension}\"");
return $this->send_local_file($this->get_full_path($filename));
}
public function get_url(string $filename): string { public function get_url(string $filename): string {
return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->dir) . "/" . basename($filename); return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->adapter->get_dir()) . "/" . basename($filename);
} }
// check for locally cached (media) URLs and rewrite to local versions // check for locally cached (media) URLs and rewrite to local versions
@ -375,84 +388,4 @@ class DiskCache {
} }
return $res; return $res;
} }
static function expire(): void {
$dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir");
foreach ($dirs as $cache_dir) {
$num_deleted = 0;
if (is_writable($cache_dir) && !file_exists("$cache_dir/.no-auto-expiry")) {
$files = glob("$cache_dir/*");
if ($files) {
foreach ($files as $file) {
if (time() - filemtime($file) > 86400*Config::get(Config::CACHE_MAX_DAYS)) {
unlink($file);
++$num_deleted;
}
}
}
Debug::log("Expired $cache_dir: removed $num_deleted files.");
}
}
}
/* */
/**
* this is essentially a wrapper for readfile() which allows plugins to hook
* output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else
*
* hook function should return true if request was handled (or at least attempted to)
*
* note that this can be called without user context so the plugin to handle this
* should be loaded systemwide in config.php
*
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
*/
function send_local_file(string $filename) {
if (file_exists($filename)) {
if (is_writable($filename)) touch($filename);
$mimetype = mime_content_type($filename);
// this is hardly ideal but 1) only media is cached in images/ and 2) seemingly only mp4
// video files are detected as octet-stream by mime_content_type()
if ($mimetype == "application/octet-stream")
$mimetype = "video/mp4";
# block SVG because of possible embedded javascript (.....)
$mimetype_blacklist = [ "image/svg+xml" ];
/* only serve video and images */
if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) {
http_response_code(400);
header("Content-type: text/plain");
print "Stored file has disallowed content type ($mimetype)";
return false;
}
$tmppluginhost = new PluginHost();
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM);
//$tmppluginhost->load_data();
if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename))
return true;
header("Content-type: $mimetype");
$stamp = gmdate("D, d M Y H:i:s", (int)filemtime($filename)) . " GMT";
header("Last-Modified: $stamp", true);
return readfile($filename);
} else {
return false;
}
}
} }

View File

@ -454,7 +454,7 @@ class PluginHost {
// WIP hack // WIP hack
// we can't catch incompatible method signatures via Throwable // we can't catch incompatible method signatures via Throwable
// this also enables global tt-rss safe mode in case there are more plugins like this // this also enables global tt-rss safe mode in case there are more plugins like this
if (($_SESSION["plugin_blacklist"][$class] ?? 0)) { if (!getenv('TTRSS_XDEBUG_ENABLED') && ($_SESSION["plugin_blacklist"][$class] ?? 0)) {
// only report once per-plugin per-session // only report once per-plugin per-session
if ($_SESSION["plugin_blacklist"][$class] < 2) { if ($_SESSION["plugin_blacklist"][$class] < 2) {

View File

@ -1,7 +1,7 @@
parameters: parameters:
level: 6 level: 6
parallel: parallel:
maximumNumberOfProcesses: 2 maximumNumberOfProcesses: 4
reportUnmatchedIgnoredErrors: false reportUnmatchedIgnoredErrors: false
ignoreErrors: ignoreErrors:
- '#Constant.*\b(SUBSTRING_FOR_DATE|SCHEMA_VERSION|SELF_USER_AGENT|LABEL_BASE_INDEX|PLUGIN_FEED_BASE_INDEX)\b.*not found#' - '#Constant.*\b(SUBSTRING_FOR_DATE|SCHEMA_VERSION|SELF_USER_AGENT|LABEL_BASE_INDEX|PLUGIN_FEED_BASE_INDEX)\b.*not found#'
@ -24,5 +24,6 @@ parameters:
- plugins/**/test/* - plugins/**/test/*
- plugins.local/**/test/* - plugins.local/**/test/*
- plugins.local/*/vendor/intervention/* - plugins.local/*/vendor/intervention/*
- plugins.local/cache_s3/vendor/*
paths: paths:
- . - .