Compare commits

...

13 Commits

Author SHA1 Message Date
Andrew Dolgov 52180c9f8f
DiskCache: enforce basename() on filenames passed to cache adapter 2022-11-26 14:15:45 +03:00
Andrew Dolgov 3212c51ce8
migrate favicons directly to new cache 2022-11-24 23:43:46 +03:00
Andrew Dolgov a30b9bb649
rework favicon storage to use DiskCache 2022-11-24 23:31:33 +03:00
Andrew Dolgov be6bc72a74
DiskCache: tweak how expiration is invoked 2022-11-24 18:49:36 +03:00
Andrew Dolgov 3180b35807
deprecate DiskCache->touch() 2022-11-24 08:16:56 +03:00
Andrew Dolgov 9732d8fc9f
update_rss_feed: use DiskCache to store feed data 2022-11-23 22:09:04 +03:00
Andrew Dolgov 10a1dd35e3
* split local cache implementation into a separate class
* allow custom implementations provided by plugins
2022-11-23 21:18:40 +03:00
Andrew Dolgov 30c04adfa6
Merge branch 'weblate-integration' 2022-11-23 17:27:42 +03:00
xosé m cb2f1ac2d9 Translated using Weblate (Galician)
Currently translated at 100.0% (701 of 701 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/gl/
2022-11-23 11:05:12 +00:00
fox 9a0dcdd6cc Merge pull request 'Address upcoming string interpolation deprecation (PHP 8.2)' (#90) from wn/tt-rss:feature/php82-str-intrp-deprecation into master
Reviewed-on: https://dev.tt-rss.org/tt-rss/tt-rss/pulls/90
2022-11-12 20:24:52 +03:00
wn_ d376cd6142 Address upcoming string interpolation deprecation.
https://wiki.php.net/rfc/deprecate_dollar_brace_string_interpolation
2022-11-12 16:20:59 +00:00
xosé m 413d824f23 Translated using Weblate (Galician)
Currently translated at 100.0% (701 of 701 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/gl/
2022-08-25 06:49:25 +00:00
TonyRL e8b3cdcf4a Translated using Weblate (Chinese (Traditional))
Currently translated at 97.8% (686 of 701 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/zh_Hant/
2022-08-25 06:49:24 +00:00
22 changed files with 478 additions and 269 deletions

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

@ -0,0 +1,36 @@
<?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);
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise
*/
public function get_mtime(string $filename);
/**
* @param mixed $data
*
* @return int|false Bytes written or false if an error occurred.
*/
public function put(string $filename, $data);
public function get(string $filename): ?string;
public function get_full_path(string $filename): string;
public function remove(string $filename) : bool;
/**
* @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);
/** Catchall function to expire all subfolders/prefixes in the cache, invoked on the backend */
public function expire_all(): void;
}

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

@ -0,0 +1,146 @@
<?php
class Cache_Local implements Cache_Adapter {
private string $dir;
public function remove(string $filename): bool {
return unlink($this->get_full_path($filename));
}
public function get_mtime(string $filename) {
return filemtime($this->get_full_path($filename));
}
public function set_dir(string $dir) : void {
$this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir));
$this->make_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);
}
/**
* @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;
return readfile($filename);
} else {
return false;
}
}
}

View File

@ -17,7 +17,7 @@ class Db_Migrations {
function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql"): void { function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql"): void {
$plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin); $plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin);
$this->initialize($plugin_dir . "/${schema_suffix}", $this->initialize("{$plugin_dir}/{$schema_suffix}",
strtolower("ttrss_migrations_plugin_" . get_class($plugin)), strtolower("ttrss_migrations_plugin_" . get_class($plugin)),
$base_is_latest); $base_is_latest);
} }
@ -31,7 +31,7 @@ class Db_Migrations {
} }
private function set_version(int $version): void { private function set_version(int $version): void {
Debug::log("Updating table {$this->migrations_table} with version ${version}...", Debug::LOG_EXTENDED); Debug::log("Updating table {$this->migrations_table} with version {$version}...", Debug::LOG_EXTENDED);
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}"); $sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
@ -170,7 +170,7 @@ class Db_Migrations {
try { try {
$this->migrate_to($i); $this->migrate_to($i);
} catch (PDOException $e) { } catch (PDOException $e) {
user_error("Failed to apply migration ${i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING); user_error("Failed to apply migration {$i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
return false; return false;
//throw $e; //throw $e;
} }
@ -184,7 +184,7 @@ class Db_Migrations {
*/ */
private function get_lines(int $version) : array { private function get_lines(int $version) : array {
if ($version > 0) if ($version > 0)
$filename = "{$this->migrations_path}/${version}.sql"; $filename = "{$this->migrations_path}/{$version}.sql";
else else
$filename = "{$this->base_path}/{$this->base_filename}"; $filename = "{$this->base_path}/{$this->base_filename}";
@ -196,7 +196,7 @@ class Db_Migrations {
fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"])); fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]));
} else { } else {
user_error("Requested schema file ${filename} not found.", E_USER_ERROR); user_error("Requested schema file {$filename} not found.", E_USER_ERROR);
return []; return [];
} }
} }

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,47 +196,52 @@ 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 = clone $p; // we need separate object instances for separate directories
$this->adapter->set_dir($dir);
return;
}
}
$this->adapter = new Cache_Local();
$this->adapter->set_dir($dir);
} }
public function get_dir(): string { public function remove(string $filename): bool {
return $this->dir; return $this->adapter->remove($filename);
}
public function set_dir(string $dir) : void {
$this->adapter->set_dir($dir);
}
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise
*/
public function get_mtime(string $filename) {
return $this->adapter->get_mtime(basename($filename));
} }
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(basename($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(basename($filename));
} }
/** /**
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise * @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(basename($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));
} }
/** /**
@ -244,11 +250,27 @@ class DiskCache {
* @return int|false Bytes written or false if an error occurred. * @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(basename($filename), $data);
} }
/** @deprecated we can't assume cached files are local, and other storages
* might not support this operation (object metadata may be immutable) */
public function touch(string $filename): bool { public function touch(string $filename): bool {
return touch($this->get_full_path($filename)); user_error("DiskCache: called unsupported method touch() for $filename", E_USER_DEPRECATED);
return false;
}
public function get(string $filename): ?string {
return $this->adapter->get(basename($filename));
}
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,25 +293,69 @@ class DiskCache {
return false; return false;
} }
public function get(string $filename): ?string { public function send(string $filename) {
if ($this->exists($filename)) $filename = basename($filename);
return file_get_contents($this->get_full_path($filename));
else if (!$this->exists($filename)) {
return null; header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
echo "File not found.";
return false;
}
$gmt_modified = gmdate("D, d M Y H:i:s", (int)$this->get_mtime($filename)) . " GMT";
if (($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') == $gmt_modified) {
header('HTTP/1.1 304 Not Modified');
return false;
}
$mimetype = $this->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");
$stamp_expires = gmdate("D, d M Y H:i:s",
(int)$this->get_mtime($filename) + 86400 * Config::get(Config::CACHE_MAX_DAYS)) . " GMT";
header("Expires: $stamp_expires", true);
header("Last-Modified: $gmt_modified", true);
header("Cache-Control: public");
header_remove("Pragma");
return $this->adapter->send($filename);
}
public function get_full_path(string $filename): string {
return $this->adapter->get_full_path(basename($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(basename($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 {
$mimetype = $this->get_mime_type($filename); $mimetype = $this->adapter->get_mime_type(basename($filename));
if ($mimetype) if ($mimetype)
return isset($this->mimeMap[$mimetype]) ? $this->mimeMap[$mimetype] : ""; return isset($this->mimeMap[$mimetype]) ? $this->mimeMap[$mimetype] : "";
@ -297,22 +363,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 +427,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

@ -1163,11 +1163,28 @@ class Feeds extends Handler_Protected {
} }
static function _get_icon_file(int $feed_id): string { static function _get_icon_file(int $feed_id): string {
return Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; $favicon_cache = new DiskCache('feed-icons');
return $favicon_cache->get_full_path((string)$feed_id);
} }
static function _has_icon(int $id): bool { static function _get_icon_url(int $feed_id, string $fallback_url = "") : string {
return is_file(Config::get(Config::ICONS_DIR) . "/$id.ico") && filesize(Config::get(Config::ICONS_DIR) . "/$id.ico") > 0; if (self::_has_icon($feed_id)) {
$icon_url = Config::get_self_url() . "/public.php?" . http_build_query([
'op' => 'feed_icon',
'id' => $feed_id,
]);
return $icon_url;
}
return $fallback_url;
}
static function _has_icon(int $feed_id): bool {
$favicon_cache = new DiskCache('feed-icons');
return $favicon_cache->exists((string)$feed_id);
} }
/** /**
@ -1191,16 +1208,9 @@ class Feeds extends Handler_Protected {
if ($id < LABEL_BASE_INDEX) { if ($id < LABEL_BASE_INDEX) {
return "label"; return "label";
} else { } else {
$icon = self::_get_icon_file($id); return self::_get_icon_url($id);
if ($icon && file_exists($icon)) {
return Config::get(Config::ICONS_URL) . "/" . basename($icon) . "?" . filemtime($icon);
}
} }
break;
} }
return false;
} }
/** /**
@ -1749,11 +1759,11 @@ class Feeds extends Handler_Protected {
} }
if (!$allow_archived) { if (!$allow_archived) {
$from_qpart = "${ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id), ttrss_feeds"; $from_qpart = "{$ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id), ttrss_feeds";
$feed_check_qpart = "ttrss_user_entries.feed_id = ttrss_feeds.id AND"; $feed_check_qpart = "ttrss_user_entries.feed_id = ttrss_feeds.id AND";
} else { } else {
$from_qpart = "${ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id) $from_qpart = "{$ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id)
LEFT JOIN ttrss_feeds ON (feed_id = ttrss_feeds.id)"; LEFT JOIN ttrss_feeds ON (feed_id = ttrss_feeds.id)";
$feed_check_qpart = ""; $feed_check_qpart = "";
} }
@ -2238,7 +2248,7 @@ class Feeds extends Handler_Protected {
* @return array{0: string, 1: array<int, string>} [$search_query_part, $search_words] * @return array{0: string, 1: array<int, string>} [$search_query_part, $search_words]
*/ */
private static function _search_to_sql(string $search, string $search_language, int $owner_uid): array { private static function _search_to_sql(string $search, string $search_language, int $owner_uid): array {
$keywords = str_getcsv(preg_replace('/(-?\w+)\:"(\w+)/', '"${1}:${2}', trim($search)), ' '); $keywords = str_getcsv(preg_replace('/(-?\w+)\:"(\w+)/', '"{$1}:{$2}', trim($search)), ' ');
$query_keywords = array(); $query_keywords = array();
$search_words = array(); $search_words = array();
$search_query_leftover = array(); $search_query_leftover = array();

View File

@ -769,6 +769,18 @@ class Handler_Public extends Handler {
} }
} }
function feed_icon() : void {
$id = (int)$_REQUEST['id'];
$cache = new DiskCache('feed-icons');
if ($cache->exists((string)$id)) {
$cache->send((string)$id);
} else {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
echo "File not found.";
}
}
private function _make_article_tag_uri(int $id, string $timestamp): string { private function _make_article_tag_uri(int $id, string $timestamp): string {
$timestamp = date("Y-m-d", strtotime($timestamp)); $timestamp = date("Y-m-d", strtotime($timestamp));

View File

@ -14,15 +14,15 @@ class PluginHandler extends Handler_Protected {
if (validate_csrf($csrf_token) || $plugin->csrf_ignore($method)) { if (validate_csrf($csrf_token) || $plugin->csrf_ignore($method)) {
$plugin->$method(); $plugin->$method();
} else { } else {
user_error("Rejected ${plugin_name}->${method}(): invalid CSRF token.", E_USER_WARNING); user_error("Rejected {$plugin_name}->{$method}(): invalid CSRF token.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNAUTHORIZED); print Errors::to_json(Errors::E_UNAUTHORIZED);
} }
} else { } else {
user_error("Rejected ${plugin_name}->${method}(): unknown method.", E_USER_WARNING); user_error("Rejected {$plugin_name}->{$method}(): unknown method.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNKNOWN_METHOD); print Errors::to_json(Errors::E_UNKNOWN_METHOD);
} }
} else { } else {
user_error("Rejected ${plugin_name}->${method}(): unknown plugin.", E_USER_WARNING); user_error("Rejected {$plugin_name}->{$method}(): unknown plugin.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN); print Errors::to_json(Errors::E_UNKNOWN_PLUGIN);
} }
} }

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

@ -454,14 +454,15 @@ class Pref_Feeds extends Handler_Protected {
function removeIcon(): void { function removeIcon(): void {
$feed_id = (int) $_REQUEST["feed_id"]; $feed_id = (int) $_REQUEST["feed_id"];
$icon_file = Config::get(Config::ICONS_DIR) . "/$feed_id.ico";
$cache = new DiskCache('feed-icons');
$feed = ORM::for_table('ttrss_feeds') $feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid']) ->where('owner_uid', $_SESSION['uid'])
->find_one($feed_id); ->find_one($feed_id);
if ($feed && file_exists($icon_file)) { if ($feed && $cache->exists((string)$feed_id)) {
if (unlink($icon_file)) { if ($cache->remove((string)$feed_id)) {
$feed->set([ $feed->set([
'favicon_avg_color' => null, 'favicon_avg_color' => null,
'favicon_last_checked' => '1970-01-01', 'favicon_last_checked' => '1970-01-01',
@ -486,24 +487,25 @@ class Pref_Feeds extends Handler_Protected {
if ($feed && $tmp_file && move_uploaded_file($_FILES['icon_file']['tmp_name'], $tmp_file)) { if ($feed && $tmp_file && move_uploaded_file($_FILES['icon_file']['tmp_name'], $tmp_file)) {
if (filesize($tmp_file) < Config::get(Config::MAX_FAVICON_FILE_SIZE)) { if (filesize($tmp_file) < Config::get(Config::MAX_FAVICON_FILE_SIZE)) {
$new_filename = Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; $cache = new DiskCache('feed-icons');
if (file_exists($new_filename)) unlink($new_filename); if ($cache->put((string)$feed_id, file_get_contents($tmp_file))) {
if (rename($tmp_file, $new_filename)) {
chmod($new_filename, 0644);
$feed->set([ $feed->set([
'favicon_avg_color' => null, 'favicon_avg_color' => null,
'favicon_is_custom' => true, 'favicon_is_custom' => true,
]); ]);
if ($feed->save()) { if ($feed->save()) {
$rc = self::E_ICON_UPLOAD_SUCCESS; $rc = self::E_ICON_UPLOAD_SUCCESS;
}
} else {
$rc = self::E_ICON_RENAME_FAILED;
} }
} else {
$rc = self::E_ICON_RENAME_FAILED;
}
@unlink($tmp_file);
} else { } else {
$rc = self::E_ICON_FILE_TOO_LARGE; $rc = self::E_ICON_FILE_TOO_LARGE;
} }
@ -1186,9 +1188,10 @@ class Pref_Feeds extends Handler_Protected {
$pdo->commit(); $pdo->commit();
if (file_exists(Config::get(Config::ICONS_DIR) . "/$id.ico")) { $favicon_cache = new DiskCache('feed-icons');
unlink(Config::get(Config::ICONS_DIR) . "/$id.ico");
} if ($favicon_cache->exists((string)$id))
$favicon_cache->remove((string)$id);
} else { } else {
Labels::remove(Labels::feed_to_label_id($id), $owner_uid); Labels::remove(Labels::feed_to_label_id($id), $owner_uid);

View File

@ -61,7 +61,7 @@ class Pref_Labels extends Handler_Protected {
if ($kind == "fg" || $kind == "bg") { if ($kind == "fg" || $kind == "bg") {
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET $sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
${kind}_color = ? WHERE id = ? {$kind}_color = ? WHERE id = ?
AND owner_uid = ?"); AND owner_uid = ?");
$sth->execute([$color, $id, $_SESSION['uid']]); $sth->execute([$color, $id, $_SESSION['uid']]);

View File

@ -240,7 +240,7 @@ class Pref_Prefs extends Handler_Protected {
$user->full_name = clean($_POST['full_name']); $user->full_name = clean($_POST['full_name']);
if ($user->email != $new_email) { if ($user->email != $new_email) {
Logger::log(E_USER_NOTICE, "Email address of user ".$user->login." has been changed to ${new_email}."); Logger::log(E_USER_NOTICE, "Email address of user {$user->login} has been changed to {$new_email}.");
if ($user->email) { if ($user->email) {
$mailer = new Mailer(); $mailer = new Mailer();

View File

@ -82,11 +82,10 @@ class Pref_Users extends Handler_Administrative {
<?php while ($row = $sth->fetch()) { ?> <?php while ($row = $sth->fetch()) { ?>
<li> <li>
<?php <?php
$icon_file = Config::get(Config::ICONS_URL) . "/" . $row["id"] . ".ico"; $icon_url = Feeds::_get_icon_url($row['id'], 'images/blank_icon.gif');
$icon = file_exists($icon_file) ? $icon_file : "images/blank_icon.gif";
?> ?>
<img class="icon" src="<?= $icon_file ?>"> <img class="icon" src="<?= htmlspecialchars($icon_url) ?>">
<a target="_blank" href="<?= htmlspecialchars($row["site_url"]) ?>"> <a target="_blank" href="<?= htmlspecialchars($row["site_url"]) ?>">
<?= htmlspecialchars($row["title"]) ?> <?= htmlspecialchars($row["title"]) ?>

View File

@ -451,7 +451,7 @@ class RPC extends Handler_Protected {
$params["safe_mode"] = !empty($_SESSION["safe_mode"]); $params["safe_mode"] = !empty($_SESSION["safe_mode"]);
$params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES); $params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES);
$params["icons_url"] = Config::get(Config::ICONS_URL); $params["icons_url"] = Config::get(Config::SELF_URL_PATH) . '/public.php';
$params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME); $params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME);
$params["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE); $params["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE);
$params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT); $params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT);

View File

@ -37,20 +37,29 @@ class RSSUtils {
$pdo = Db::pdo(); $pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ?"); $sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ?");
// check icon files once every Config::get(Config::CACHE_MAX_DAYS) days $cache = new DiskCache('feed-icons');
$icon_files = array_filter(glob(Config::get(Config::ICONS_DIR) . "/*.ico"),
fn(string $f) => filemtime($f) < time() - 86400 * Config::get(Config::CACHE_MAX_DAYS));
foreach ($icon_files as $icon) { if ($cache->is_writable()) {
$feed_id = basename($icon, ".ico"); $dh = opendir($cache->get_full_path(""));
$sth->execute([$feed_id]); if ($dh) {
while (($icon = readdir($dh)) !== false) {
if ($cache->get_mtime($icon) < time() - 86400 * Config::get(Config::CACHE_MAX_DAYS)) {
if ($sth->fetch()) { $sth->execute([(int)$icon]);
@touch($icon);
} else { if ($sth->fetch()) {
Debug::log("Removing orphaned feed icon: $icon"); $cache->put($icon, $cache->get($icon));
unlink($icon); } else {
$icon_path = $cache->get_full_path($icon);
Debug::log("Removing orphaned feed icon: $icon_path");
unlink($icon);
}
}
}
closedir($dh);
} }
} }
} }
@ -338,6 +347,9 @@ class RSSUtils {
$pdo = Db::pdo(); $pdo = Db::pdo();
/** @var DiskCache $cache */
$cache = new DiskCache('feeds');
if (Config::get(Config::DB_TYPE) == "pgsql") { if (Config::get(Config::DB_TYPE) == "pgsql") {
$favicon_interval_qpart = "favicon_last_checked < NOW() - INTERVAL '12 hour'"; $favicon_interval_qpart = "favicon_last_checked < NOW() - INTERVAL '12 hour'";
} else { } else {
@ -387,7 +399,7 @@ class RSSUtils {
$date_feed_processed = date('Y-m-d H:i'); $date_feed_processed = date('Y-m-d H:i');
$cache_filename = Config::get(Config::CACHE_DIR) . "/feeds/" . sha1($feed_obj->feed_url) . ".xml"; $cache_filename = sha1($feed_obj->feed_url) . ".xml";
$pluginhost = new PluginHost(); $pluginhost = new PluginHost();
$user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $feed_obj->owner_uid); $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $feed_obj->owner_uid);
@ -423,13 +435,13 @@ class RSSUtils {
// try cache // try cache
if (!$feed_data && if (!$feed_data &&
is_readable($cache_filename) && $cache->exists($cache_filename) &&
!$feed_obj->auth_login && !$feed_obj->auth_pass && !$feed_obj->auth_login && !$feed_obj->auth_pass &&
filemtime($cache_filename) > time() - 30) { $cache->get_mtime($cache_filename) > time() - 30) {
Debug::log("using local cache: {$cache_filename}.", Debug::LOG_VERBOSE); Debug::log("using local cache: {$cache_filename}.", Debug::LOG_VERBOSE);
$feed_data = file_get_contents($cache_filename); $feed_data = $cache->get($cache_filename);
if ($feed_data) { if ($feed_data) {
$rss_hash = sha1($feed_data); $rss_hash = sha1($feed_data);
@ -477,12 +489,12 @@ class RSSUtils {
} }
// cache vanilla feed data for re-use // cache vanilla feed data for re-use
if ($feed_data && !$feed_obj->auth_pass && !$feed_obj->auth_login && is_writable(Config::get(Config::CACHE_DIR) . "/feeds")) { if ($feed_data && !$feed_obj->auth_pass && !$feed_obj->auth_login && $cache->is_writable()) {
$new_rss_hash = sha1($feed_data); $new_rss_hash = sha1($feed_data);
if ($new_rss_hash != $rss_hash) { if ($new_rss_hash != $rss_hash) {
Debug::log("saving to local cache: $cache_filename", Debug::LOG_VERBOSE); Debug::log("saving to local cache: $cache_filename", Debug::LOG_VERBOSE);
file_put_contents($cache_filename, $feed_data); $cache->put($cache_filename, $feed_data);
} }
} }
} }
@ -590,21 +602,28 @@ class RSSUtils {
if ($feed_obj->favicon_needs_check || $force_refetch) { if ($feed_obj->favicon_needs_check || $force_refetch) {
/* terrible hack: if we crash on floicon shit here, we won't check // restrict update attempts to once per 12h
* the icon avgcolor again (unless the icon got updated) */ $feed_obj->favicon_last_checked = Db::NOW();
$feed_obj->save();
$favicon_file = Config::get(Config::ICONS_DIR) . "/$feed.ico"; $favicon_cache = new DiskCache('feed-icons');
$favicon_modified = file_exists($favicon_file) ? filemtime($favicon_file) : -1;
$favicon_modified = $favicon_cache->exists($feed) ? $favicon_cache->get_mtime($feed) : -1;
// don't try to redownload custom favicons
if (!$feed_obj->favicon_is_custom) { if (!$feed_obj->favicon_is_custom) {
Debug::log("favicon: trying to update favicon...", Debug::LOG_VERBOSE); Debug::log("favicon: trying to update favicon...", Debug::LOG_VERBOSE);
self::update_favicon($feed_obj->site_url, $feed); self::update_favicon($feed_obj->site_url, $feed);
if ((file_exists($favicon_file) ? filemtime($favicon_file) : -1) > $favicon_modified) if (!$favicon_cache->exists($feed) || $favicon_cache->get_mtime($feed) > $favicon_modified) {
$feed_obj->favicon_avg_color = null; $feed_obj->favicon_avg_color = null;
$feed_obj->save();
}
} }
if (is_readable($favicon_file) && function_exists("imagecreatefromstring") && empty($feed_obj->favicon_avg_color)) { /* terrible hack: if we crash on floicon shit here, we won't check
* the icon avgcolor again (unless icon got updated) */
if (file_exists($favicon_cache->get_full_path($feed)) && function_exists("imagecreatefromstring") && empty($feed_obj->favicon_avg_color)) {
require_once "colors.php"; require_once "colors.php";
Debug::log("favicon: trying to calculate average color...", Debug::LOG_VERBOSE); Debug::log("favicon: trying to calculate average color...", Debug::LOG_VERBOSE);
@ -612,13 +631,13 @@ class RSSUtils {
$feed_obj->favicon_avg_color = 'fail'; $feed_obj->favicon_avg_color = 'fail';
$feed_obj->save(); $feed_obj->save();
$feed_obj->favicon_avg_color = \Colors\calculate_avg_color($favicon_file); $feed_obj->favicon_avg_color = \Colors\calculate_avg_color($favicon_cache->get_full_path($feed));
$feed_obj->save(); $feed_obj->save();
Debug::log("favicon: avg color: {$feed_obj->favicon_avg_color}", Debug::LOG_VERBOSE); Debug::log("favicon: avg color: {$feed_obj->favicon_avg_color}", Debug::LOG_VERBOSE);
} else if ($feed_obj->favicon_avg_color == 'fail') { } else if ($feed_obj->favicon_avg_color == 'fail') {
Debug::log("floicon failed $favicon_file, not trying to recalculate avg color", Debug::LOG_VERBOSE); Debug::log("floicon failed on $feed, not trying to recalculate avg color", Debug::LOG_VERBOSE);
} }
} }
@ -1323,8 +1342,6 @@ class RSSUtils {
} else { } else {
Debug::log("cache_enclosures: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error); Debug::log("cache_enclosures: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error);
} }
} else if (is_writable($local_filename)) {
$cache->touch($local_filename);
} }
} }
} }
@ -1350,8 +1367,6 @@ class RSSUtils {
} else { } else {
Debug::log("cache_media: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error); Debug::log("cache_media: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error);
} }
} else if ($cache->is_writable($local_filename)) {
$cache->touch($local_filename);
} }
} }
@ -1673,9 +1688,36 @@ class RSSUtils {
$tmph->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); $tmph->run_hooks(PluginHost::HOOK_HOUSE_KEEPING);
} }
static function housekeeping_common(): void { /** migrates favicons from legacy storage in feed-icons/ to cache/feed-icons/using new naming (sans .ico suffix) */
DiskCache::expire(); static function migrate_feed_icons() : void {
$old_dir = Config::get(Config::ICONS_DIR);
$new_dir = Config::get(Config::CACHE_DIR) . '/feed-icons';
$dh = opendir($old_dir);
$cache = new DiskCache('feed-icons');
if ($dh) {
while (($old_filename = readdir($dh)) !== false) {
if (strpos($old_filename, ".ico") !== false) {
$new_filename = str_replace(".ico", "", $old_filename);
$old_full_path = "$old_dir/$old_filename";
if (is_file($old_full_path) && $cache->put($new_filename, file_get_contents($old_full_path))) {
unlink($old_full_path);
}
}
}
closedir($dh);
}
}
static function housekeeping_common(): void {
$cache = new DiskCache("");
$cache->expire_all();
self::migrate_feed_icons();
self::expire_lock_files(); self::expire_lock_files();
self::expire_error_log(); self::expire_error_log();
self::expire_feed_archive(); self::expire_feed_archive();
@ -1693,8 +1735,6 @@ class RSSUtils {
* @return false|string * @return false|string
*/ */
static function update_favicon(string $site_url, int $feed) { static function update_favicon(string $site_url, int $feed) {
$icon_file = Config::get(Config::ICONS_DIR) . "/$feed.ico";
$favicon_urls = self::get_favicon_urls($site_url); $favicon_urls = self::get_favicon_urls($site_url);
if (count($favicon_urls) == 0) { if (count($favicon_urls) == 0) {
@ -1749,21 +1789,18 @@ class RSSUtils {
break; break;
} }
Debug::log("favicon: $favicon_url looks valid, saving to $icon_file", Debug::LOG_VERBOSE); $favicon_cache = new DiskCache('feed-icons');
$fp = @fopen($icon_file, "w"); if ($favicon_cache->is_writable()) {
Debug::log("favicon: $favicon_url looks valid, saving to cache", Debug::LOG_VERBOSE);
if ($fp) { // we deal with this manually
if (!$favicon_cache->exists(".no-auto-expiry"))
fwrite($fp, $contents); $favicon_cache->put(".no-auto-expiry", "");
fclose($fp);
chmod($icon_file, 0644);
clearstatcache();
return $icon_file;
return $favicon_cache->put((string)$feed, $contents);
} else { } else {
Debug::log("favicon: failed to open $icon_file for writing", Debug::LOG_VERBOSE); Debug::log("favicon: $favicon_url skipping, local cache is not writable", Debug::LOG_VERBOSE);
} }
} }

View File

@ -100,7 +100,7 @@ const Feeds = {
if (id > 0) { if (id > 0) {
if (has_img) { if (has_img) {
this.setIcon(id, false, this.setIcon(id, false,
App.getInitParam("icons_url") + "/" + id + ".ico?" + has_img); App.getInitParam("icons_url") + '?' + dojo.objectToQuery({op: 'feed_icon', id: id}));
} else { } else {
this.setIcon(id, false, 'images/blank_icon.gif'); this.setIcon(id, false, 'images/blank_icon.gif');
} }
@ -678,8 +678,10 @@ const Feeds = {
}); });
}, },
renderIcon: function(feed_id, exists) { renderIcon: function(feed_id, exists) {
const icon_url = App.getInitParam("icons_url") + '?' + dojo.objectToQuery({op: 'feed_icon', id: feed_id});
return feed_id && exists ? return feed_id && exists ?
`<img class="icon" src="${App.escapeHtml(App.getInitParam("icons_url"))}/${feed_id}.ico">` : `<img class="icon" src="${App.escapeHtml(icon_url)}">` :
`<i class='icon-no-feed material-icons'>rss_feed</i>`; `<i class='icon-no-feed material-icons'>rss_feed</i>`;
} }
}; };

View File

@ -88,18 +88,18 @@ function get_list_of_locales($locale) {
if ($modifier) { if ($modifier) {
if ($country) { if ($country) {
if ($charset) if ($charset)
array_push($locale_names, "${lang}_$country.$charset@$modifier"); array_push($locale_names, "{$lang}_$country.$charset@$modifier");
array_push($locale_names, "${lang}_$country@$modifier"); array_push($locale_names, "{$lang}_$country@$modifier");
} elseif ($charset) } elseif ($charset)
array_push($locale_names, "${lang}.$charset@$modifier"); array_push($locale_names, "{$lang}.$charset@$modifier");
array_push($locale_names, "$lang@$modifier"); array_push($locale_names, "$lang@$modifier");
} }
if ($country) { if ($country) {
if ($charset) if ($charset)
array_push($locale_names, "${lang}_$country.$charset"); array_push($locale_names, "{$lang}_$country.$charset");
array_push($locale_names, "${lang}_$country"); array_push($locale_names, "{$lang}_$country");
} elseif ($charset) } elseif ($charset)
array_push($locale_names, "${lang}.$charset"); array_push($locale_names, "{$lang}.$charset");
array_push($locale_names, $lang); array_push($locale_names, $lang);
} }

Binary file not shown.

View File

@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-06-05 11:48+0300\n" "POT-Creation-Date: 2022-06-05 11:48+0300\n"
"PO-Revision-Date: 2022-06-09 13:15+0000\n" "PO-Revision-Date: 2022-11-23 11:05+0000\n"
"Last-Translator: xosé m. <correoxm@disroot.org>\n" "Last-Translator: xosé m. <correoxm@disroot.org>\n"
"Language-Team: Galician <https://weblate.tt-rss.org/projects/tt-rss/messages/" "Language-Team: Galician <https://weblate.tt-rss.org/projects/tt-rss/messages/"
"gl/>\n" "gl/>\n"
@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.12.2\n" "X-Generator: Weblate 4.14.2\n"
#: backend.php:60 #: backend.php:60
msgid "Use default" msgid "Use default"
@ -260,7 +260,7 @@ msgstr "Axuda sobre atallos de teclado"
#: index.php:289 #: index.php:289
msgid "Logout" msgid "Logout"
msgstr "Desconectar" msgstr "Pechar Sesión"
#: prefs.php:20 prefs.php:139 classes/pref/prefs.php:942 classes/rpc.php:646 #: prefs.php:20 prefs.php:139 classes/pref/prefs.php:942 classes/rpc.php:646
msgid "Preferences" msgid "Preferences"
@ -1844,7 +1844,7 @@ msgstr "O artigo compartido aparecerá na fonte Publicados."
#: plugins/bookmarklets/init.php:324 include/login_form.php:177 #: plugins/bookmarklets/init.php:324 include/login_form.php:177
msgid "Log in" msgid "Log in"
msgstr "Conectar" msgstr "Acceder"
#: plugins/bookmarklets/init.php:344 #: plugins/bookmarklets/init.php:344
#, php-format #, php-format
@ -1966,7 +1966,7 @@ msgstr "Fallou a validación da conta (cambiou o contrasinal)"
#: include/sessions.php:49 #: include/sessions.php:49
msgid "Session failed to validate (account is disabled)" msgid "Session failed to validate (account is disabled)"
msgstr "Fallou a validación da conta (conta está desactivada)" msgstr "Fallou a validación da sesión (conta está desactivada)"
#: include/sessions.php:53 #: include/sessions.php:53
msgid "Session failed to validate (user not found)" msgid "Session failed to validate (user not found)"
@ -2908,7 +2908,7 @@ msgid ""
"You will need to log out and back in to disable it." "You will need to log out and back in to disable it."
msgstr "" msgstr ""
"Tiny Tiny RSS estase executando en modo seguro. Tódolos decorados e " "Tiny Tiny RSS estase executando en modo seguro. Tódolos decorados e "
"complementos están desactivados. Terás que desconectar e volver a conectarte " "complementos están desactivados. Terás que pechar sesión e volver a acceder "
"para desactivalo." "para desactivalo."
#: js/CommonDialogs.js:53 #: js/CommonDialogs.js:53
@ -2921,7 +2921,7 @@ msgstr "Fontes dispoñibles"
#: js/CommonDialogs.js:85 js/PrefFeedTree.js:446 #: js/CommonDialogs.js:85 js/PrefFeedTree.js:446
msgid "Login" msgid "Login"
msgstr "Identificador:" msgstr "Acceso"
#: js/CommonDialogs.js:101 #: js/CommonDialogs.js:101
msgid "This feed requires authentication." msgid "This feed requires authentication."

Binary file not shown.

View File

@ -8,16 +8,16 @@ msgstr ""
"Project-Id-Version: Tiny Tiny RSS\n" "Project-Id-Version: Tiny Tiny RSS\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-06-05 11:48+0300\n" "POT-Creation-Date: 2022-06-05 11:48+0300\n"
"PO-Revision-Date: 2021-11-21 17:41+0000\n" "PO-Revision-Date: 2022-08-25 06:49+0000\n"
"Last-Translator: TonyRL <tony_lao@outlook.com>\n" "Last-Translator: TonyRL <tony_lao@outlook.com>\n"
"Language-Team: Chinese (Traditional) <https://weblate.tt-rss.org/projects/tt-" "Language-Team: Chinese (Traditional) <https://weblate.tt-rss.org/projects/"
"rss/messages/zh_Hant/>\n" "tt-rss/messages/zh_Hant/>\n"
"Language: zh_TW\n" "Language: zh_TW\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 4.9.1\n" "X-Generator: Weblate 4.14\n"
#: backend.php:60 #: backend.php:60
msgid "Use default" msgid "Use default"
@ -238,7 +238,7 @@ msgstr "隱藏(顯示)已讀摘要"
#: index.php:273 #: index.php:273
msgid "UI layout:" msgid "UI layout:"
msgstr "" msgstr "使用者介面配置:"
#: index.php:274 classes/rpc.php:639 #: index.php:274 classes/rpc.php:639
msgid "Toggle combined mode" msgid "Toggle combined mode"
@ -1976,16 +1976,12 @@ msgid "Update daemon is not updating feeds."
msgstr "更新守護行程沒有更新摘要。" msgstr "更新守護行程沒有更新摘要。"
#: js/App.js:606 #: js/App.js:606
#, fuzzy, java-printf-format #, java-printf-format
#| msgid ""
#| "URL scheme reported by your browser (%a) doesn't match server-configured "
#| "SELF_URL_PATH (%b), check X-Forwarded-Proto."
msgid "" msgid ""
"URL scheme reported by your browser (%a) doesn't match server-configured " "URL scheme reported by your browser (%a) doesn't match server-configured "
"SELF_URL_PATH (%b), check X-Forwarded-Proto." "SELF_URL_PATH (%b), check X-Forwarded-Proto."
msgstr "" msgstr "瀏覽器報告的 URL 配置(%a與伺服器配置的 SELF_URL_PATH%b不符合請檢查 X"
"瀏覽器報告的URL配置a與伺服器配置的 SELF_URL_PATHb不符合請檢查 " "-Forwarded-Proto。"
"X-Forwarded-Proto。"
#: js/App.js:613 #: js/App.js:613
msgid "Fatal error" msgid "Fatal error"
@ -2624,10 +2620,9 @@ msgid "Reset to defaults?"
msgstr "重設為預設狀態?" msgstr "重設為預設狀態?"
#: js/PrefHelpers.js:373 #: js/PrefHelpers.js:373
#, fuzzy, java-printf-format, javascript-format #, java-printf-format, javascript-format
#| msgid "Error while parsing document."
msgid "Error while loading plugins list: %s." msgid "Error while loading plugins list: %s."
msgstr "解析文檔時發生錯誤。" msgstr "載入擴充套件列表時發生錯誤:%s。"
#: js/PrefHelpers.js:422 #: js/PrefHelpers.js:422
msgid "Clear data" msgid "Clear data"
@ -2866,7 +2861,7 @@ msgstr "堆疊追踪"
#: js/App.js:653 #: js/App.js:653
msgid "Additional information" msgid "Additional information"
msgstr "" msgstr "額外資訊"
#: js/Article.js:207 #: js/Article.js:207
msgid "Attachments" msgid "Attachments"
@ -3024,16 +3019,12 @@ msgid "System plugins are enabled using global configuration."
msgstr "" msgstr ""
#: js/PrefHelpers.js:577 #: js/PrefHelpers.js:577
#, fuzzy
#| msgid "Uninstall"
msgid "Install" msgid "Install"
msgstr "解除安裝" msgstr "安裝"
#: js/PrefHelpers.js:654 #: js/PrefHelpers.js:654
#, fuzzy
#| msgid "Playing..."
msgid "Updating..." msgid "Updating..."
msgstr "播放中……" msgstr "更新中……"
#: js/PrefHelpers.js:687 #: js/PrefHelpers.js:687
msgid "Updates complete" msgid "Updates complete"

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:
- . - .

View File

@ -31,10 +31,10 @@ class Cache_Starred_Images extends Plugin {
chmod($this->cache_status->get_dir(), 0777); chmod($this->cache_status->get_dir(), 0777);
if (!$this->cache->exists(".no-auto-expiry")) if (!$this->cache->exists(".no-auto-expiry"))
$this->cache->touch(".no-auto-expiry"); $this->cache->put(".no-auto-expiry", "");
if (!$this->cache_status->exists(".no-auto-expiry")) if (!$this->cache_status->exists(".no-auto-expiry"))
$this->cache_status->touch(".no-auto-expiry"); $this->cache_status->put(".no-auto-expiry", "");
if ($this->cache->is_writable() && $this->cache_status->is_writable()) { if ($this->cache->is_writable() && $this->cache_status->is_writable()) {
$host->add_hook($host::HOOK_HOUSE_KEEPING, $this); $host->add_hook($host::HOOK_HOUSE_KEEPING, $this);