mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2024-12-22 21:22:40 +00:00
Implement CLI for tokens
This commit is contained in:
parent
3795b1ccd8
commit
fa6d641634
9 changed files with 212 additions and 48 deletions
|
@ -6,6 +6,7 @@ New features:
|
||||||
- Support for API level 15 of Tiny Tiny RSS
|
- Support for API level 15 of Tiny Tiny RSS
|
||||||
- Support for feed icons in Fever
|
- Support for feed icons in Fever
|
||||||
- Command-line functionality for managing user metadata
|
- Command-line functionality for managing user metadata
|
||||||
|
- Command-line functionality for managing Miniflux login tokens
|
||||||
|
|
||||||
Bug fixes:
|
Bug fixes:
|
||||||
- Use icons specified in Atom feeds when available
|
- Use icons specified in Atom feeds when available
|
||||||
|
|
44
lib/CLI.php
44
lib/CLI.php
|
@ -8,6 +8,7 @@ namespace JKingWeb\Arsse;
|
||||||
|
|
||||||
use JKingWeb\Arsse\REST\Fever\User as Fever;
|
use JKingWeb\Arsse\REST\Fever\User as Fever;
|
||||||
use JKingWeb\Arsse\ImportExport\OPML;
|
use JKingWeb\Arsse\ImportExport\OPML;
|
||||||
|
use JKingWeb\Arsse\REST\Miniflux\Token as Miniflux;
|
||||||
|
|
||||||
class CLI {
|
class CLI {
|
||||||
public const USAGE = <<<USAGE_TEXT
|
public const USAGE = <<<USAGE_TEXT
|
||||||
|
@ -27,6 +28,9 @@ Usage:
|
||||||
arsse.php user unset-pass <username>
|
arsse.php user unset-pass <username>
|
||||||
[--oldpass=<pass>] [--fever]
|
[--oldpass=<pass>] [--fever]
|
||||||
arsse.php user auth <username> <password> [--fever]
|
arsse.php user auth <username> <password> [--fever]
|
||||||
|
arsse.php token list <username>
|
||||||
|
arsse.php token create <username> [<label>]
|
||||||
|
arsse.php token revoke <username> [<token>]
|
||||||
arsse.php import <username> [<file>]
|
arsse.php import <username> [<file>]
|
||||||
[-f | --flat] [-r | --replace]
|
[-f | --flat] [-r | --replace]
|
||||||
arsse.php export <username> [<file>]
|
arsse.php export <username> [<file>]
|
||||||
|
@ -125,6 +129,24 @@ Commands:
|
||||||
The --fever option may be used to test the user's Fever protocol password,
|
The --fever option may be used to test the user's Fever protocol password,
|
||||||
if any.
|
if any.
|
||||||
|
|
||||||
|
token list <username>
|
||||||
|
|
||||||
|
Lists available tokens for <username> in a simple tabular format. These
|
||||||
|
tokens act as an alternative means of authentication for the Miniflux
|
||||||
|
protocol and may be required by some clients. They do not expire.
|
||||||
|
|
||||||
|
token create <username> [<label>]
|
||||||
|
|
||||||
|
Creates a new login token for <username> and prints it. These tokens act
|
||||||
|
as an alternative means of authentication for the Miniflux protocol and
|
||||||
|
may be required by some clients. An optional label may be specified to
|
||||||
|
give the token a meaningful name.
|
||||||
|
|
||||||
|
token revoke <username> [<token>]
|
||||||
|
|
||||||
|
Deletes the specified token from the database. The token itself must be
|
||||||
|
supplied, not its label. If it is omitted all tokens are revoked.
|
||||||
|
|
||||||
import <username> [<file>]
|
import <username> [<file>]
|
||||||
|
|
||||||
Imports the feeds, folders, and tags found in the OPML formatted <file>
|
Imports the feeds, folders, and tags found in the OPML formatted <file>
|
||||||
|
@ -278,6 +300,15 @@ USAGE_TEXT;
|
||||||
$u = $args['<username>'];
|
$u = $args['<username>'];
|
||||||
$file = $this->resolveFile($args['<file>'], "r");
|
$file = $this->resolveFile($args['<file>'], "r");
|
||||||
return (int) !Arsse::$obj->get(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r']));
|
return (int) !Arsse::$obj->get(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r']));
|
||||||
|
case "token list":
|
||||||
|
case "list token": // command reconstruction yields this order for "token list" command
|
||||||
|
return $this->tokenList($args['<username>']);
|
||||||
|
case "token create":
|
||||||
|
echo Arsse::$obj->get(Miniflux::class)->tokenGenerate($args['<username>'], $args['<label>']).\PHP_EOL;
|
||||||
|
return 0;
|
||||||
|
case "token revoke":
|
||||||
|
Arsse::$db->tokenRevoke($args['<username>'], "miniflux.login", $args['<token>']);
|
||||||
|
return 0;
|
||||||
case "user add":
|
case "user add":
|
||||||
$out = $this->userAddOrSetPassword("add", $args["<username>"], $args["<password>"]);
|
$out = $this->userAddOrSetPassword("add", $args["<username>"], $args["<password>"]);
|
||||||
if ($args['--admin']) {
|
if ($args['--admin']) {
|
||||||
|
@ -315,6 +346,8 @@ USAGE_TEXT;
|
||||||
case "user list":
|
case "user list":
|
||||||
case "user":
|
case "user":
|
||||||
return $this->userList();
|
return $this->userList();
|
||||||
|
default:
|
||||||
|
throw new Exception("constantUnknown", $cmd); // @codeCoverageIgnore
|
||||||
}
|
}
|
||||||
} catch (AbstractException $e) {
|
} catch (AbstractException $e) {
|
||||||
$this->logError($e->getMessage());
|
$this->logError($e->getMessage());
|
||||||
|
@ -365,4 +398,15 @@ USAGE_TEXT;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function tokenList(string $user): int {
|
||||||
|
$list = Arsse::$obj->get(Miniflux::class)->tokenList($user);
|
||||||
|
usort($list, function($v1, $v2) {
|
||||||
|
return $v1['label'] <=> $v2['label'];
|
||||||
|
});
|
||||||
|
foreach ($list as $t) {
|
||||||
|
echo $t['id']." ".$t['label'].\PHP_EOL;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
31
lib/REST/Miniflux/Token.php
Normal file
31
lib/REST/Miniflux/Token.php
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
/** @license MIT
|
||||||
|
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||||
|
* See LICENSE and AUTHORS files for details */
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace JKingWeb\Arsse\REST\Miniflux;
|
||||||
|
|
||||||
|
use JKingWeb\Arsse\Arsse;
|
||||||
|
use JKingWeb\Arsse\User\ExceptionConflict;
|
||||||
|
|
||||||
|
class Token {
|
||||||
|
protected const TOKEN_LENGTH = 32;
|
||||||
|
|
||||||
|
public function tokenGenerate(string $user, ?string $label = null): string {
|
||||||
|
// Miniflux produces tokens in base64url alphabet
|
||||||
|
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
|
||||||
|
return Arsse::$db->tokenCreate($user, "miniflux.login", $t, null, $label);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tokenList(string $user): array {
|
||||||
|
if (!Arsse::$db->userExists($user)) {
|
||||||
|
throw new ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach (Arsse::$db->tokenList($user, "miniflux.login") as $r) {
|
||||||
|
$out[] = ['label' => $r['data'], 'id' => $r['id']];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,7 +34,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
|
||||||
|
|
||||||
protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
|
protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
|
||||||
protected const ACCEPTED_TYPES_JSON = ["application/json"];
|
protected const ACCEPTED_TYPES_JSON = ["application/json"];
|
||||||
protected const TOKEN_LENGTH = 32;
|
|
||||||
protected const DEFAULT_ENTRY_LIMIT = 100;
|
protected const DEFAULT_ENTRY_LIMIT = 100;
|
||||||
protected const DEFAULT_ORDER_COL = "modified_date";
|
protected const DEFAULT_ORDER_COL = "modified_date";
|
||||||
protected const DATE_FORMAT_SEC = "Y-m-d\TH:i:sP";
|
protected const DATE_FORMAT_SEC = "Y-m-d\TH:i:sP";
|
||||||
|
@ -1201,21 +1200,4 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
|
||||||
protected function opmlExport(): ResponseInterface {
|
protected function opmlExport(): ResponseInterface {
|
||||||
return new GenericResponse(Arsse::$obj->get(OPML::class)->export(Arsse::$user->id), 200, ['Content-Type' => "application/xml"]);
|
return new GenericResponse(Arsse::$obj->get(OPML::class)->export(Arsse::$user->id), 200, ['Content-Type' => "application/xml"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function tokenGenerate(string $user, string $label): string {
|
|
||||||
// Miniflux produces tokens in base64url alphabet
|
|
||||||
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
|
|
||||||
return Arsse::$db->tokenCreate($user, "miniflux.login", $t, null, $label);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function tokenList(string $user): array {
|
|
||||||
if (!Arsse::$db->userExists($user)) {
|
|
||||||
throw new ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
|
|
||||||
}
|
|
||||||
$out = [];
|
|
||||||
foreach (Arsse::$db->tokenList($user, "miniflux.login") as $r) {
|
|
||||||
$out[] = ['label' => $r['data'], 'id' => $r['id']];
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ use JKingWeb\Arsse\Database;
|
||||||
use JKingWeb\Arsse\Service;
|
use JKingWeb\Arsse\Service;
|
||||||
use JKingWeb\Arsse\CLI;
|
use JKingWeb\Arsse\CLI;
|
||||||
use JKingWeb\Arsse\REST\Fever\User as FeverUser;
|
use JKingWeb\Arsse\REST\Fever\User as FeverUser;
|
||||||
|
use JKingWeb\Arsse\REST\Miniflux\Token as MinifluxToken;
|
||||||
use JKingWeb\Arsse\ImportExport\OPML;
|
use JKingWeb\Arsse\ImportExport\OPML;
|
||||||
|
|
||||||
/** @covers \JKingWeb\Arsse\CLI */
|
/** @covers \JKingWeb\Arsse\CLI */
|
||||||
|
@ -418,4 +419,54 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
|
||||||
["arsse.php user unset john bogus", "john", ['bogus' => null], [], 1],
|
["arsse.php user unset john bogus", "john", ['bogus' => null], [], 1],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testListTokens(): void {
|
||||||
|
$data = [
|
||||||
|
['label' => 'Ook', 'id' => "TOKEN 1"],
|
||||||
|
['label' => 'Eek', 'id' => "TOKEN 2"],
|
||||||
|
['label' => null, 'id' => "TOKEN 3"],
|
||||||
|
['label' => 'Ack', 'id' => "TOKEN 4"],
|
||||||
|
];
|
||||||
|
$exp = implode(\PHP_EOL, [
|
||||||
|
"TOKEN 3 ",
|
||||||
|
"TOKEN 4 Ack",
|
||||||
|
"TOKEN 2 Eek",
|
||||||
|
"TOKEN 1 Ook",
|
||||||
|
]);
|
||||||
|
$t = \Phake::mock(MinifluxToken::class);
|
||||||
|
\Phake::when(Arsse::$obj)->get(MinifluxToken::class)->thenReturn($t);
|
||||||
|
\Phake::when($t)->tokenList->thenReturn($data);
|
||||||
|
$this->assertConsole($this->cli, "arsse.php token list john", 0, $exp);
|
||||||
|
\Phake::verify($t)->tokenList("john");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateToken(): void {
|
||||||
|
$t = \Phake::mock(MinifluxToken::class);
|
||||||
|
\Phake::when(Arsse::$obj)->get(MinifluxToken::class)->thenReturn($t);
|
||||||
|
\Phake::when($t)->tokenGenerate->thenReturn("RANDOM TOKEN");
|
||||||
|
$this->assertConsole($this->cli, "arse.php token create jane", 0, "RANDOM TOKEN");
|
||||||
|
\Phake::verify($t)->tokenGenerate("jane", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateTokenWithLabel(): void {
|
||||||
|
$t = \Phake::mock(MinifluxToken::class);
|
||||||
|
\Phake::when(Arsse::$obj)->get(MinifluxToken::class)->thenReturn($t);
|
||||||
|
\Phake::when($t)->tokenGenerate->thenReturn("RANDOM TOKEN");
|
||||||
|
$this->assertConsole($this->cli, "arse.php token create jane Ook", 0, "RANDOM TOKEN");
|
||||||
|
\Phake::verify($t)->tokenGenerate("jane", "Ook");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevokeAToken(): void {
|
||||||
|
Arsse::$db = \Phake::mock(Database::class);
|
||||||
|
\Phake::when(Arsse::$db)->tokenRevoke->thenReturn(true);
|
||||||
|
$this->assertConsole($this->cli, "arse.php token revoke jane TOKEN_ID", 0);
|
||||||
|
\Phake::verify(Arsse::$db)->tokenRevoke("jane", "miniflux.login", "TOKEN_ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevokeAllTokens(): void {
|
||||||
|
Arsse::$db = \Phake::mock(Database::class);
|
||||||
|
\Phake::when(Arsse::$db)->tokenRevoke->thenReturn(true);
|
||||||
|
$this->assertConsole($this->cli, "arse.php token revoke jane", 0);
|
||||||
|
\Phake::verify(Arsse::$db)->tokenRevoke("jane", "miniflux.login", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
13
tests/cases/REST/Miniflux/PDO/TestToken.php
Normal file
13
tests/cases/REST/Miniflux/PDO/TestToken.php
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
/** @license MIT
|
||||||
|
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||||
|
* See LICENSE and AUTHORS files for details */
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace JKingWeb\Arsse\TestCase\REST\Miniflux\PDO;
|
||||||
|
|
||||||
|
/** @covers \JKingWeb\Arsse\REST\Miniflux\Token<extended>
|
||||||
|
* @group optional */
|
||||||
|
class TestToken extends \JKingWeb\Arsse\TestCase\REST\Miniflux\TestV1 {
|
||||||
|
use \JKingWeb\Arsse\Test\PDOTest;
|
||||||
|
}
|
70
tests/cases/REST/Miniflux/TestToken.php
Normal file
70
tests/cases/REST/Miniflux/TestToken.php
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
/** @license MIT
|
||||||
|
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||||
|
* See LICENSE and AUTHORS files for details */
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace JKingWeb\Arsse\TestCase\REST\Miniflux;
|
||||||
|
|
||||||
|
use JKingWeb\Arsse\Arsse;
|
||||||
|
use JKingWeb\Arsse\Database;
|
||||||
|
use JKingWeb\Arsse\Db\Transaction;
|
||||||
|
use JKingWeb\Arsse\REST\Miniflux\Token;
|
||||||
|
use JKingWeb\Arsse\Test\Result;
|
||||||
|
|
||||||
|
/** @covers \JKingWeb\Arsse\REST\Miniflux\Token<extended> */
|
||||||
|
class TestToken extends \JKingWeb\Arsse\Test\AbstractTest {
|
||||||
|
protected const NOW = "2020-12-09T22:35:10.023419Z";
|
||||||
|
protected const TOKEN = "Tk2o9YubmZIL2fm2w8Z4KlDEQJz532fNSOcTG0s2_xc=";
|
||||||
|
|
||||||
|
protected $h;
|
||||||
|
protected $transaction;
|
||||||
|
|
||||||
|
public function setUp(): void {
|
||||||
|
self::clearData();
|
||||||
|
self::setConf();
|
||||||
|
// create a mock database interface
|
||||||
|
Arsse::$db = \Phake::mock(Database::class);
|
||||||
|
$this->transaction = \Phake::mock(Transaction::class);
|
||||||
|
\Phake::when(Arsse::$db)->begin->thenReturn($this->transaction);
|
||||||
|
$this->h = new Token();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tearDown(): void {
|
||||||
|
self::clearData();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function v($value) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateTokens(): void {
|
||||||
|
\Phake::when(Arsse::$db)->tokenCreate->thenReturn("RANDOM TOKEN");
|
||||||
|
$this->assertSame("RANDOM TOKEN", $this->h->tokenGenerate("ook", "Eek"));
|
||||||
|
\Phake::verify(Arsse::$db)->tokenCreate("ook", "miniflux.login", \Phake::capture($token), null, "Eek");
|
||||||
|
$this->assertRegExp("/^[A-Za-z0-9_\-]{43}=$/", $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListTheTokensOfAUser(): void {
|
||||||
|
$out = [
|
||||||
|
['id' => "TOKEN 1", 'data' => "Ook"],
|
||||||
|
['id' => "TOKEN 2", 'data' => "Eek"],
|
||||||
|
['id' => "TOKEN 3", 'data' => "Ack"],
|
||||||
|
];
|
||||||
|
$exp = [
|
||||||
|
['label' => "Ook", 'id' => "TOKEN 1"],
|
||||||
|
['label' => "Eek", 'id' => "TOKEN 2"],
|
||||||
|
['label' => "Ack", 'id' => "TOKEN 3"],
|
||||||
|
];
|
||||||
|
\Phake::when(Arsse::$db)->tokenList->thenReturn(new Result($this->v($out)));
|
||||||
|
\Phake::when(Arsse::$db)->userExists->thenReturn(true);
|
||||||
|
$this->assertSame($exp, $this->h->tokenList("john.doe@example.com"));
|
||||||
|
\Phake::verify(Arsse::$db)->tokenList("john.doe@example.com", "miniflux.login");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListTheTokensOfAMissingUser(): void {
|
||||||
|
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
|
||||||
|
$this->assertException("doesNotExist", "User", "ExceptionConflict");
|
||||||
|
$this->h->tokenList("john.doe@example.com");
|
||||||
|
}
|
||||||
|
}
|
|
@ -978,34 +978,4 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
|
||||||
$this->assertMessage(new TextResponse("EXPORT DATA", 200, ['Content-Type' => "application/xml"]), $this->req("GET", "/export"));
|
$this->assertMessage(new TextResponse("EXPORT DATA", 200, ['Content-Type' => "application/xml"]), $this->req("GET", "/export"));
|
||||||
\Phake::verify($opml)->export(Arsse::$user->id);
|
\Phake::verify($opml)->export(Arsse::$user->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGenerateTokens(): void {
|
|
||||||
\Phake::when(Arsse::$db)->tokenCreate->thenReturn("RANDOM TOKEN");
|
|
||||||
$this->assertSame("RANDOM TOKEN", V1::tokenGenerate("ook", "Eek"));
|
|
||||||
\Phake::verify(Arsse::$db)->tokenCreate("ook", "miniflux.login", \Phake::capture($token), null, "Eek");
|
|
||||||
$this->assertRegExp("/^[A-Za-z0-9_\-]{43}=$/", $token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testListTheTokensOfAUser(): void {
|
|
||||||
$out = [
|
|
||||||
['id' => "TOKEN 1", 'data' => "Ook"],
|
|
||||||
['id' => "TOKEN 2", 'data' => "Eek"],
|
|
||||||
['id' => "TOKEN 3", 'data' => "Ack"],
|
|
||||||
];
|
|
||||||
$exp = [
|
|
||||||
['label' => "Ook", 'id' => "TOKEN 1"],
|
|
||||||
['label' => "Eek", 'id' => "TOKEN 2"],
|
|
||||||
['label' => "Ack", 'id' => "TOKEN 3"],
|
|
||||||
];
|
|
||||||
\Phake::when(Arsse::$db)->tokenList->thenReturn(new Result($this->v($out)));
|
|
||||||
\Phake::when(Arsse::$db)->userExists->thenReturn(true);
|
|
||||||
$this->assertSame($exp, V1::tokenList("john.doe@example.com"));
|
|
||||||
\Phake::verify(Arsse::$db)->tokenList("john.doe@example.com", "miniflux.login");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testListTheTokensOfAMissingUser(): void {
|
|
||||||
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
|
|
||||||
$this->assertException("doesNotExist", "User", "ExceptionConflict");
|
|
||||||
V1::tokenList("john.doe@example.com");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -118,7 +118,9 @@
|
||||||
<file>cases/REST/Miniflux/TestErrorResponse.php</file>
|
<file>cases/REST/Miniflux/TestErrorResponse.php</file>
|
||||||
<file>cases/REST/Miniflux/TestStatus.php</file>
|
<file>cases/REST/Miniflux/TestStatus.php</file>
|
||||||
<file>cases/REST/Miniflux/TestV1.php</file>
|
<file>cases/REST/Miniflux/TestV1.php</file>
|
||||||
|
<file>cases/REST/Miniflux/TestToken.php</file>
|
||||||
<file>cases/REST/Miniflux/PDO/TestV1.php</file>
|
<file>cases/REST/Miniflux/PDO/TestV1.php</file>
|
||||||
|
<file>cases/REST/Miniflux/PDO/TestToken.php</file>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
<testsuite name="NCNv1">
|
<testsuite name="NCNv1">
|
||||||
<file>cases/REST/NextcloudNews/TestVersions.php</file>
|
<file>cases/REST/NextcloudNews/TestVersions.php</file>
|
||||||
|
|
Loading…
Reference in a new issue