* split local cache implementation into a separate class
* allow custom implementations provided by plugins
This commit is contained in:
parent
30c04adfa6
commit
10a1dd35e3
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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:
|
||||||
- .
|
- .
|
||||||
|
|
Loading…
Reference in New Issue