1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2025-04-23 05:45:51 +00:00

Prototype Nextcloud user metadata fetcher

This commit is contained in:
J. King 2025-03-12 07:30:41 -04:00
parent 6e65f288a7
commit e2e4ec36c4
2 changed files with 206 additions and 0 deletions
lib
REST.php
REST/NextcloudNews

View file

@ -31,6 +31,11 @@ class REST {
'strip' => '/index.php/apps/news/api/v1-3',
'class' => REST\NextcloudNews\V1_3::class,
],
'ncn_ocs' => [ // Nextcloud user metadata https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html#user-metadata
'match' => '/ocs/v1.php/cloud/users/',
'strip' => '/ocs/v1.php/cloud/users/',
'class' => REST\NextcloudNews\OCS::class,
],
'ttrss_api' => [ // Tiny Tiny RSS https://tt-rss.org/ApiReference/
'match' => '/tt-rss/api',
'strip' => '/tt-rss/api',

View file

@ -0,0 +1,201 @@
<?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\NextcloudNews;
use GuzzleHttp\Psr7\Response;
use MensBeam\Mime\MimeType;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\User\ExceptionConflict;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
class OCS extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const TYPES = [
"application/json",
"text/json",
"application/xml",
"text/xml",
];
protected const BASE_META = [
200 => [
'status' => "ok",
'statuscode' => 200,
'message' => "OK"
],
403 => [
'status' => "failure",
'statuscode' => 998,
'message' => ""
],
404 => [
'status' => "failure",
'statuscode' => 404,
'message' => "User does not exist"
],
];
// This is current as of Nextcloud 31
protected const BASE_DATA = [
'enabled' => true,
'storageLocation' => "/",
'id' => null,
'firstLoginTimestamp' => -1,
'lastLoginTimestamp' => null,
'lastLogin' => null,
'backend' => "Database",
'subadmin' => [],
'quota' => [
'free' => -3,
'used' => 0,
'total' => -3,
'relative' => 0,
'quota' => -3,
],
'manager' => "",
'avatarScope' => "v2-federated",
'email' => null,
'emailScope' => "v2-federated",
'additional_mail' => [],
'additional_mailScope' => [],
'displayname' => null,
'display-name' => null,
'displaynameScope' => null,
'phone' => "",
'phoneScope' => "v2-local",
'address' => "",
'addressScope' => "v2-local",
'website' => "",
'websiteScope' => "v2-local",
'twitter' => "",
'twitterScope' => "v2-local",
'fediverse' => "",
'fediverseScope' => "v2-local",
'organisation' => "",
'organisationScope' => "v2-local",
'role' => "",
'roleScope' => "v2-local",
'headline' => "",
'headlineScope' => "v2-local",
'biography' => "",
'biographyScope' => "v2-local",
'profile_enabled' => "0",
'profile_enabledScope' => "v2-local",
'pronouns' => "",
'pronounsScope' => "v2-federated",
'groups' => [],
'language' => null,
'locale' => "",
'notify_email' => null,
'backendCapabilities' => [
'setDisplayName' => false,
'setPassword' => false,
],
];
public function __construct() {
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
// respond to OPTIONS rquests
if ($req->getMethod() === "OPTIONS") {
return HTTP::challenge(HTTP::respEmpty(204, [
'Allow' => "GET,HEAD",
'Vary' => "Accept",
]));
}
// try to authenticate
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
} else {
return HTTP::respEmpty(401);
}
// get the request path only; this is assumed to already be normalized
// and will contain the user ID
$target = parse_url($req->getRequestTarget())['path'] ?? "";
// perform content negotiation; we'll prefer JSON if the client doesn't care, but for backwards compatibility we must use XML if the client expresses no preference at all
$type = MimeType::negotiate(self::TYPES, $req->getHeaderLine("Accept")) ?? "application/xml";
// only administrators can view users other than themselves
if ($target !== Arsse::$user->id && !$this->isAdmin()) {
return $this->respond(403, $type);
}
// retrieve the user's metadata and format a response
try {
// this call will throw an exception if the user does not exist
$meta = Arsse::$user->propertiesGet($target, false);
$now = Arsse::$obj->get(\DateTimeImmutable::class)->getTimestamp();
$data = self::BASE_DATA;
$data['id'] = $target;
$data['language'] = $meta['lang'] ?? "en";
$data['lastLoginTimestamp'] = $now; // we are not session-based, so we just return the current time
$data['lastLogin'] = $now * 1000;
$data['displayname'] = $target; // we don't have a display name, but clients will probably rely on this, so we fill it in
$data['display-name'] = $target;
if ($meta['admin']) {
$data['groups'][] = "admin";
}
return $this->respond(200, $type, $data);
} catch (ExceptionConflict $e) {
return $this->respond(404, $type);
}
}
protected function respond(int $code, string $type, ?array $data = null): ResponseInterface {
// Nextcloud sends a weird 404 response when it should send 403
$status = $code === 403 ? 404 : $code;
$xml = in_array($type, ["application/xml", "text/xml"]);
$body = [
'ocs' => [
'meta' => self::BASE_META[$code],
'data' => !$data && !$xml ? new \stdClass : $data, // we need a stdClass for the JSON encoder to return an empty object
],
];
// the response formatting code was lifted from the Fever implementation, with changes
if ($xml) {
$d = new \DOMDocument("1.0", "utf-8");
$d->appendChild($this->makeXMLAssoc($data['ocs'], $d->createElement("ocs")));
return HTTP::respXml($d->saveXML($d->documentElement, \LIBXML_NOEMPTYTAG), $status);
} else {
return HTTP::respJson($body, $status, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
}
}
protected function makeXMLAssoc(array $data, \DOMElement $p): \DOMElement {
$d = $p->ownerDocument;
foreach ($data as $k => $v) {
if (!is_array($v) || !$v) {
$p->appendChild($d->createElement($k, (string) $v));
} elseif (isset($v[0])) {
// this is a very simplistic check for an indexed array
// it would not pass muster in the face of generic data,
// but we'll assume our code produces only well-ordered
// indexed arrays
$p->appendChild($this->makeXMLIndexed($v, $d->createElement($k)));
} else {
$p->appendChild($this->makeXMLAssoc($v, $d->createElement($k)));
}
}
return $p;
}
protected function makeXMLIndexed(array $data, \DOMElement $p): \DOMElement {
$d = $p->ownerDocument;
foreach ($data as $v) {
if (!is_array($v) || !$v) {
$p->appendChild($d->createElement("element", (string) $v));
} elseif (isset($v[0])) {
// this case is never encountered with Nextcloud's output
$p->appendChild($this->makeXMLIndexed($v, $d->createElement("element"))); // @codeCoverageIgnore
} else {
// this case is never encountered with Nextcloud's output
$p->appendChild($this->makeXMLAssoc($v, $d->createElement("element"))); // @codeCoverageIgnore
}
}
return $p;
}
}