true,
// Maximum length of feed
'max_items' => 25,
// Set to true to store feeds as RSS 2.0, false as Atom
'use_rss_format' => true,
// Directory of feed files
'dir_feeds' => 'feeds',
// Instance administrator contact
'instance_contact' => '',
// Instance visual customization
'icon' => 'https://raw.githubusercontent.com/Warhammer40kGroup/wh40k-icon/master/src/svgs/librarius-02.svg',
'logo' => 'https://raw.githubusercontent.com/Warhammer40kGroup/wh40k-icon/master/src/svgs/librarius-02.svg',
'custom_xslt' => '',
'custom_css' => '',
// Admin ID to attach instance messages to all feeds
'admin_id' => '',
// An exported cookie file to access walled content
'cookie_file' => '',
// Time before feeds are considered abandoned
'delete_abandoned_after' => 31536000, // 1 year -> 60*60*24*365
'delete_bogus_after' => 7776000, // 3 months -> 60*60*24*30*3
// External modules
'mod_custom_extractor' => __DIR__ . '/custom_extractor.php',
'mod_readability' => __DIR__ . '/vendor/autoload.php',
);
// Create a unique id for users
function make_id()
{
return hash('sha256', random_bytes(18));
}
// Check if an id is the admin
function is_admin($param_id)
{
global $g_config;
return !empty($param_id) && !empty($g_config['admin_id']) && (hash_equals($g_config['admin_id'], $param_id));
}
// Read configuration from JSON file
function update_configuration()
{
global $g_config_file, $g_config;
$json = @file_get_contents($g_config_file);
if (!empty($json))
{
$data = json_decode($json);
foreach(array_keys($g_config) as $k)
if (property_exists($data, $k))
$g_config[$k] = $data->$k;
return;
}
if (empty($g_config['admin_id']))
$g_config['admin_id'] = make_id();
file_put_contents($g_config_file, json_encode($g_config, JSON_PRETTY_PRINT));
}
// Helper to load readability and custom extraction modules
function load_ext_modules()
{
global $g_config;
$mods = ['mod_custom_extractor', 'mod_readability'];
foreach ($mods as $mod)
{
try
{
if (is_file($g_config[$mod]))
require_once $g_config[$mod];
}
catch (\Throwable $e)
{
}
}
}
// Sanitize a string to remove any invalid characters
function sanitize_text($s)
{
if (empty($s))
return '';
$s = trim($s);
// Remove control characters
$s = preg_replace('/(?>[\x00-\x1F]|\xC2[\x80-\x9F]|\xE2[\x80-\x8F]{2}|\xE2\x80[\xA4-\xA8]|\xE2\x81[\x9F-\xAF])/', ' ', $s);
// Reduce all multiple whitespace to a single space
$s = preg_replace('/\s+/', ' ', $s);
$s = html_entity_decode($s);
$s = htmlspecialchars($s);
return $s;
}
// Create the title of the RSS-Librarian instance
function get_title($param_id)
{
return 'RSS-Librarian' . ((!empty($param_id)) ? (' (' . (is_admin($param_id) ? 'admin' : substr($param_id, 0, 4)) . ')') : '');
}
// Get a simple local version number
function get_version()
{
$f = basename(__FILE__);
return 'two-' . date('Y-m-d', filemtime($f)) . '-' . substr(hash_file('sha256', $f), 0, 7);
}
// Produce path for local feed file
function get_local_feed_file($param_id)
{
global $g_config;
return $g_config['dir_feeds'] . '/' . $param_id . '.xml';
}
// Produce URL for user feed
function get_feed_url($param_id)
{
global $g_url_base;
return $g_url_base . '/' . get_local_feed_file($param_id);
}
// Helper to attach new XML element to existing one
function sxml_attach(SimpleXMLElement $to, SimpleXMLElement $from)
{
$toDom = dom_import_simplexml($to);
$fromDom = dom_import_simplexml($from);
$new_node = $toDom->ownerDocument->importNode($fromDom, true);
$firstSibling = $toDom->getElementsByTagName('item')->item(0);
$toDom->insertBefore($new_node, $firstSibling);
}
// Creates the base stub for an Atom feed
function make_atom_feed($title, $subtitle, $personal_url, $feed_url, $ts_updated)
{
global $g_url_librarian, $g_config;
return '
' . (($g_config['custom_xslt'] !== "") ? '' : '') . '
' . sanitize_text($enclosure[0]) . '
'; } // Fetch all meta tags with into a lookup dictionary function get_all_meta_tags($html) { $result = []; if (preg_match_all('/ $key) { $val = trim($matches[2][$i]); array_key_exists($key, $result) ? $result[$key] .= '; ' . $val : $result[$key] = $val; } } return $result; } // Fetch content of a URL or peek into the header to fetch only the mimetype function fetch_url($url, $peek_header = false) { global $g_config; $cookie_file = ($g_config['cookie_file'] ?? '') ?: tempnam(sys_get_temp_dir(), 'rsslib_cookies_'); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_NOBODY => $peek_header, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 10, CURLOPT_USERAGENT => 'Mozilla/5.0', CURLOPT_ENCODING => '', CURLOPT_COOKIEFILE => $cookie_file, CURLOPT_COOKIEJAR => $cookie_file, CURLOPT_TIMEOUT => 15, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, ]) ; $content = curl_exec($ch); if (empty($g_config['cookie_file'])) @unlink($cookie_file); if ($content === false) return ["", "", 0]; $content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $content_type = explode(';', $content_type)[0]; $content_length = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD); return [$content, $content_type, $content_length]; } // "Extract" binary files function extract_content_binary($url) { $html = ''; $enclosure = null; [$content_data, $content_type, $content_length] = fetch_url($url, true); if (str_contains($content_type, "text/")) return []; $enclosure = [$url, $content_type, $content_length]; $html = make_enclosure_embed($enclosure); $exp = explode('/', $url); return [ 'title' => end($exp), 'content' => $html, 'enclosure' => $enclosure, ]; } // Local custom extraction path function extract_content_custom($url) { global $g_config; if (function_exists('custom_extractor')) return custom_extractor($url); return []; } // Extract text from HTML/structured input using readability or metatags function extract_content_structured($url, $html = null) { global $g_config; if (empty($html)) [$html, $content_type, $content_length] = fetch_url($url); $meta = get_all_meta_tags($html); // Tidy up HTML if (function_exists('tidy_parse_string')) { $tidy = tidy_parse_string($html, array(), 'UTF8'); $tidy->cleanRepair(); $html = $tidy->value; } // Local Readability.php extraction path $rdata = []; if (class_exists('fivefilters\Readability\Readability')) { try { $readability = new Readability(fixRelativeURLs: true, originalURL: $url); $article = @$readability->parse($html); $rdata = [ 'title' => $article->title, 'content' => $article->content, 'author' => $article->byline, ]; } catch (ParseException $e) {} } // Fallback solution: Naive body extraction if (!isset($rdata['content']) || empty($rdata['content'])) { libxml_use_internal_errors(true); $doc = new DOMDocument(); $doc->loadHTML('' . $html, LIBXML_NOWARNING | LIBXML_NOERROR); libxml_clear_errors(); $bodyNodes = $doc->getElementsByTagName('body'); $body = $bodyNodes->item(0); $rdata['content'] = $doc->saveHTML($body) ?: ''; } return [ 'title' => ($rdata['title'] ?? '') ?: ($meta['og:title'] ?? '') ?: ($meta['twitter:title'] ?? '') ?: ($meta['title'] ?? '') ?: $url, 'content' => ($rdata['content'] ?? '') ?: ($meta['og:description'] ?? '') ?: ($meta['twitter:description'] ?? '') ?: ($meta['description'] ?? '') ?: '', 'author' => ($rdata['author'] ?? '') ?: ($meta['og:site_name'] ?? '') ?: ($meta['twitter:site'] ?? '') ?: ($meta['fediverse:creator'] ?? '') ?: ($meta['author'] ?? '') ?: '', ]; } // Extract content using the first successful method, otherwise just return URL function extract_content($url) { global $g_config; $item = []; if (empty($item)) $item = extract_content_binary($url); if (empty($item)) $item = extract_content_custom($url); if (empty($item)) $item = extract_content_structured($url); $item['url'] = $url; $item['date'] = time(); if (!$g_config['extract_content'] || empty($item['content'])) $item['content'] = 'No content available, please enable reader mode for this entry.'; return $item; } // Remove URL from personal feed function remove_url($param_id, $param_url) { $items = read_feed_file($param_id); foreach($items as $i => $item) { if ($item['url'] == $param_url) { unset($items[$i]); $items = array_values($items); write_feed_file($param_id, $items); return true; } } return false; } // Add stored article item to array of items if a personal feed function add_item($items, $item) { global $g_config; // Check max item count, remove anything beyond $c = count($items); while ($c >= $g_config['max_items']) { unset($items[$c-1]); $c--; } // Fetch content and add to items array_unshift($items, $item); return $items; } // Add a custom article text to a personal feed function add_custom_item($param_id, $message) { global $g_url_librarian; $items = read_feed_file($param_id); $item = [ 'url' => '', 'title' => 'RSS-Librarian Instance Notice ' . date("Y-m-d H:i"), 'content' => $message, 'date' => time(), 'author' => 'Admin', ]; $items = add_item($items, $item); write_feed_file($param_id, $items); } // Log into admin feed function add_admin_log_item($msg) { global $g_config; add_custom_item($g_config['admin_id'], $msg); } // Add URL to personal feed function add_url($param_id, $param_url) { $items = read_feed_file($param_id); // Turn parameter into fully qualified URL $parsed = parse_url($param_url); if (empty($parsed['scheme'])) $param_url = 'https://' . ltrim($param_url, '/'); // Check if item already exists foreach($items as $item) { if ($item['url'] == $param_url) return false; } $items = add_item($items, extract_content($param_url)); write_feed_file($param_id, $items); return true; } // Count number of feeds in feed directory function count_feeds() { global $g_config; $filecount = count(glob($g_config['dir_feeds'] . '/*.xml')); return $filecount; } // Fetch personal URL string function get_personal_url($param_id) { global $g_url_librarian; return $g_url_librarian . '?id=' . $param_id; } // Go through feeds directory and clean up likely abandoned feed files function run_maintenance($is_dry_run) { global $g_config; $num_removed = 0; $dir = new DirectoryIterator($g_config['dir_feeds']); $current_time = time(); $abandoned_feeds = array(); foreach ($dir as $fileinfo) { if ($fileinfo->isDot() || $fileinfo->getExtension() != "xml") continue; $age = $current_time - $fileinfo->getMTime(); $feed_file = $fileinfo->getPathname(); $feed_id = substr($fileinfo->getBasename(), 0, -4); // Skip admin feed if ($feed_id == $g_config['admin_id']) continue; // Delete abandoned files older than $g_delete_abandoned_after if ($age > $g_config['delete_abandoned_after']) $abandoned_feeds[] = $feed_id; // These are likely files created by accident (max one entry) else if ($age > $g_config['delete_bogus_after']) { $items = read_feed_file($feed_id); if (count($items) <= 1) $abandoned_feeds[] = $feed_id; } } if (!$is_dry_run) foreach($abandoned_feeds as $abandoned) unlink(get_local_feed_file($abandoned)); return $abandoned_feeds; } // Display a list of URLs that are stored in a personal feed and allow removal of items function show_saved_urls($param_id) { if (empty($param_id)) return; $items = read_feed_file($param_id); print('Please confirm that you have bookmarked:
Your personal URL
and will subscribe with a feed reader to:
Your personal feed
There are currently ' . count($abandoned_feeds) . ' abandoned feeds.
' . sanitize_text($param_url) . ' removed
'); } else { if(add_url($param_id, $param_url)) { print('' . sanitize_text($param_url) . ' added
'); add_admin_log_item('User ' . $param_id . ' added:URL already added!
'); } } print('