Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ jobs:
working-directory: FreshRSS/extensions/xExtension-RssCloud
run: find . \( -name '*.php' -o -name '*.phtml' \) -print0 | xargs -0 -n1 php -l > /dev/null

- name: phpunit
working-directory: FreshRSS/extensions/xExtension-RssCloud
# Core's PHPUnit and core's autoloader, so there is no second set of dev dependencies to
# keep in step. Runs against both cores in the matrix, which is the point: the tests
# exercise the extension against whichever FreshRSS is checked out beside it.
run: >-
../../vendor/bin/phpunit --bootstrap tests/bootstrap.php
--display-notices --display-deprecations tests

- name: phpcs
working-directory: FreshRSS
run: |
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ class it touches lives there — so clone it into a FreshRSS checkout at
```sh
composer install

# phpunit, using core's autoloader as the bootstrap
( cd extensions/xExtension-RssCloud && \
../../vendor/bin/phpunit --bootstrap tests/bootstrap.php tests )

# phpstan, using core's ruleset scoped to this extension
( cd extensions/xExtension-RssCloud && ../../vendor/bin/phpstan analyse -c phpstan.neon )

Expand All @@ -203,7 +207,9 @@ sed '/(?-i:extensions)/d' phpcs.xml > phpcs-extensions.xml
vendor/bin/phpcs --standard=phpcs-extensions.xml extensions/xExtension-RssCloud -s
```

CI runs exactly these against FreshRSS `edge` on every push and pull request.
CI runs exactly these on every push and pull request, against **both** ends of the supported range:
FreshRSS `edge` and `1.29.0`. Analysing only the development tip once let a call to an edge-only
core method ship as a fatal error on every released version, so the floor is checked too.

### Commits

Expand Down
42 changes: 31 additions & 11 deletions RssCloud/Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,32 @@ public function directory(string $resourceUrl): string {
* @return RssCloudState|null
*/
public function load(string $resourceUrl): ?array {
$json = @file_get_contents($this->directory($resourceUrl) . '/!cloud.json');
return self::readState($this->directory($resourceUrl), $resourceUrl);
}

/**
* Read and normalise the state file held in one subscription directory.
*
* Shared by {@see self::load()} and {@see self::all()}, which differ only in how they arrive at
* a directory: `load()` derives it from a resource URL, while `all()` walks them and recovers
* the URL from the file, the directory name being a one-way hash. Parsing this in two places
* left the copies free to diverge, and they had.
*
* An absent file is unremarkable — {@see self::save()} creates the directory before writing it,
* and {@see self::addSubscriber()} can create one on its own — so only content that exists and
* cannot be used is worth logging.
*
* @param string $context names the subscription in the log
* @return RssCloudState|null
*/
private static function readState(string $directory, string $context): ?array {
$json = @file_get_contents($directory . '/!cloud.json');
if (!is_string($json) || $json === '') {
return null;
}
$state = json_decode($json, true);
if (!is_array($state) || !is_string($state['url'] ?? null)) {
Minz_Log::warning('rssCloud: invalid state JSON for ' . $resourceUrl, RSSCLOUD_LOG);
if (!is_array($state) || !is_string($state['url'] ?? null) || $state['url'] === '') {
Minz_Log::warning('rssCloud: invalid state JSON for ' . $context, RSSCLOUD_LOG);
return null;
}
return self::normalise($state);
Expand All @@ -59,7 +78,8 @@ public function load(string $resourceUrl): ?array {
private static function normalise(array $state): array {
return [
'url' => is_string($state['url'] ?? null) ? $state['url'] : '',
'kind' => is_string($state['kind'] ?? null) ? $state['kind'] : self::KIND_FEED,
// Constrained to the two known kinds, so that a caller may key off it without checking.
'kind' => ($state['kind'] ?? null) === self::KIND_OPML ? self::KIND_OPML : self::KIND_FEED,
'endpoint' => is_string($state['endpoint'] ?? null) ? $state['endpoint'] : '',
'registerProcedure' => is_string($state['registerProcedure'] ?? null) ? $state['registerProcedure'] : '',
'lease_start' => is_numeric($state['lease_start'] ?? null) ? (int)$state['lease_start'] : 0,
Expand Down Expand Up @@ -145,18 +165,18 @@ public function forget(string $resourceUrl): void {
/**
* Iterate over every known subscription.
*
* Yields in directory order, which is `sha1()` order and so arbitrary: anything displaying
* these has to sort them itself. Being a generator, the result is single-pass and cannot be
* counted without first collecting it.
*
* @return iterable<RssCloudState>
*/
public function all(): iterable {
$directories = @glob($this->basePath . '/resources/*', GLOB_ONLYDIR | GLOB_NOSORT);
foreach ($directories ?: [] as $directory) {
$json = @file_get_contents($directory . '/!cloud.json');
if (!is_string($json) || $json === '') {
continue;
}
$state = json_decode($json, true);
if (is_array($state) && is_string($state['url'] ?? null) && $state['url'] !== '') {
yield self::normalise($state);
$state = self::readState($directory, $directory);
if ($state !== null) {
yield $state;
}
}
}
Expand Down
40 changes: 40 additions & 0 deletions configure.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
declare(strict_types=1);
/** @var RssCloudExtension $this */
$callback = $this->callbackUrl();
$subscriptions = $this->subscriptions();
?>
<form action="<?= _url('extension', 'configure', 'e', urlencode($this->getName())) ?>" method="post">
<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
Expand Down Expand Up @@ -87,3 +88,42 @@
</div>
</div>
</form>

<h2><?= _t('ext.rsscloud.status') ?></h2>
<p class="help"><?= _i('help') ?> <?= _t('ext.rsscloud.status.help') ?></p>

<?php if ($subscriptions === []): ?>
<p><?= _t('ext.rsscloud.status.none') ?></p>
<?php else: ?>
<table>
<thead>
<tr>
<th><?= _t('ext.rsscloud.status.resource') ?></th>
<th><?= _t('ext.rsscloud.status.kind') ?></th>
<th><?= _t('ext.rsscloud.status.state') ?></th>
<th><?= _t('ext.rsscloud.status.endpoint') ?></th>
<th><?= _t('ext.rsscloud.status.renewed') ?></th>
<th><?= _t('ext.rsscloud.status.notified') ?></th>
<th><?= _t('ext.rsscloud.status.subscribers') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($subscriptions as $subscription): $state = $subscription['state']; ?>
<tr>
<td><?= htmlspecialchars($state['url'], ENT_COMPAT, 'UTF-8') ?></td>
<td><?= _t('ext.rsscloud.status.kind.' . $state['kind']) ?></td>
<td>
<?= _t('ext.rsscloud.status.' . $subscription['status']) ?>
<?php if ($state['error_message'] !== ''): ?>
<br /><small><?= htmlspecialchars($state['error_message'], ENT_COMPAT, 'UTF-8') ?></small>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($state['endpoint'], ENT_COMPAT, 'UTF-8') ?></td>
<td><?= $state['lease_start'] > 0 ? timestamptodate($state['lease_start']) : _t('ext.rsscloud.status.never') ?></td>
<td><?= $state['last_notify'] > 0 ? timestamptodate($state['last_notify']) : _t('ext.rsscloud.status.never') ?></td>
<td><?= htmlspecialchars(implode(', ', $subscription['subscribers']), ENT_COMPAT, 'UTF-8') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
52 changes: 52 additions & 0 deletions extension.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ final class RssCloudExtension extends Minz_Extension {
/** Upper bound on `pleaseNotify` calls issued in a single refresh cycle, so cron cannot stall. */
public const MAX_SUBSCRIPTIONS_PER_RUN = 10;

/** A cloud server is registered and believed to be notifying us. */
public const STATUS_ACTIVE = 'active';

/** The last `pleaseNotify` was rejected or could not be made; `error_message` says why. */
public const STATUS_ERROR = 'error';

/** Discovered, but no registration has been attempted yet. */
public const STATUS_PENDING = 'pending';

/** Registered successfully, but the lease is older than the renewal window and may have lapsed. */
public const STATUS_STALE = 'stale';

/** Feed attribute holding the resource URL this feed is (or should be) subscribed under. */
private const FEED_ATTRIBUTE = 'rssCloud';

Expand Down Expand Up @@ -149,6 +161,46 @@ public function renewSeconds(): int {
return min(self::MAX_RENEW_HOURS, max(1, $hours)) * 3600;
}

/**
* Every known subscription with the state the configuration screen displays, resource-sorted
* because {@see RssCloud_Registry::all()} yields in hash order.
*
* Collected rather than streamed: the view needs to know whether there is anything at all
* before it commits to drawing a table, which a generator cannot answer.
*
* @return list<array{state:RssCloudState,status:self::STATUS_*,subscribers:list<string>}>
*/
public function subscriptions(): array {
$registry = $this->registry();
$renewSeconds = $this->renewSeconds();

$rows = [];
foreach ($registry->all() as $state) {
$rows[] = [
'state' => $state,
'status' => self::statusOf($state, $renewSeconds),
'subscribers' => $registry->subscribers($state['url']),
];
}
usort($rows, static fn(array $a, array $b): int => strcmp($a['state']['url'], $b['state']['url']));

return $rows;
}

/**
* @param RssCloudState $state
* @return self::STATUS_*
*/
private static function statusOf(array $state, int $renewSeconds): string {
if ($state['error']) {
return self::STATUS_ERROR;
}
if ($state['lease_start'] <= 0) {
return self::STATUS_PENDING;
}
return RssCloud_Subscriber::isHealthy($state, $renewSeconds) ? self::STATUS_ACTIVE : self::STATUS_STALE;
}

public function maxStalenessSeconds(): int {
return max(1, $this->getSystemConfigurationInt('max_staleness_hours') ?? self::DEFAULT_MAX_STALENESS_HOURS) * 3600;
}
Expand Down
17 changes: 17 additions & 0 deletions i18n/en/ext.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,22 @@
'renew_hours.help' => 'rssCloud does not negotiate a lease duration. Subscriptions expire after 25 hours and are meant to be renewed every 24, so the default of 23 leaves a margin. Anything above 24 would simply lapse, and is capped.',
'skip_polling' => 'Skip polling covered resources',
'skip_polling.help' => 'Stop polling a resource on a timer while its cloud subscription is healthy. It is still polled if it goes stale, or when refreshed individually.',
'status' => 'Subscriptions',
'status.active' => 'Active',
'status.endpoint' => 'Cloud server',
'status.error' => 'Failed',
'status.help' => 'One row per resource this instance has discovered a cloud server for. "Renewed" is when registration was last attempted, not when it last succeeded; the state column says whether it did.',
'status.kind' => 'Type',
'status.kind.feed' => 'Feed',
'status.kind.opml' => 'Dynamic OPML',
'status.never' => 'Never',
'status.none' => 'No cloud servers have been discovered yet. Resources are registered as they are refreshed, so this fills in once feeds advertising a cloud have been fetched at least once.',
'status.notified' => 'Last notified',
'status.pending' => 'Pending',
'status.renewed' => 'Renewal attempted',
'status.resource' => 'Resource',
'status.stale' => 'Stale',
'status.state' => 'State',
'status.subscribers' => 'Users',
),
);
Loading