federator/php/federator/api.php
Yannis Vogel d9b02bd95b
fix phan errors
fix lots of phan errors, now we only have phan errors left from temporary implementations. These will be fixed in the next commits as we properly re-integrate and remove temporary-code
2025-04-21 21:06:03 +02:00

350 lines
10 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Sascha Nitsch (grumpydeveloper)
**/
namespace Federator;
/**
* main API class
*/
class Api extends Main
{
/**
* called path
*
* @var string $path
*/
private $path;
/**
* path elements for the API call
*
* @var array<string> $paths
* */
private $paths;
/**
* current user
*
* @var Data\User|false $user
* */
private $user;
/**
* cache time default to 0
*
* @var int $cacheTime
*/
private $cacheTime = 0;
/**
* constructor
*/
public function __construct()
{
$this->contentType = "application/activity+json";
Main::__construct();
}
/**
* set path
*
* @param string $call
* path of called function
* @return void
*/
public function setPath($call)
{
$this->path = $call;
while ($this->path[0] === '/') {
$this->path = substr($this->path, 1);
}
$this->paths = explode("/", $this->path);
}
/**
* main API function
*/
public function run(): void
{
$this->setPath((string) $_REQUEST['_call']);
$this->openDatabase();
$this->loadPlugins();
$retval = "";
$handler = null;
if ($this->connector === null) {
http_response_code(500);
return;
}
if (array_key_exists('HTTP_X_SESSION', $_SERVER) && array_key_exists('HTTP_X_PROFILE', $_SERVER)) {
$this->user = DIO\User::getUserBySession(
$this->dbh,
$_SERVER['HTTP_X_SESSION'],
$_SERVER['HTTP_X_PROFILE'],
$this->connector,
$this->cache
);
if ($this->user === false) {
http_response_code(403);
return;
}
}
switch ($this->paths[0]) {
case '.well-known':
case 'nodeinfo':
$handler = new Api\WellKnown($this);
break;
case 'fedusers':
$handler = new Api\FedUsers($this);
break;
case 'v1':
switch ($this->paths[1]) {
case 'dummy':
$handler = new Api\V1\Dummy($this);
break;
}
break;
}
$printresponse = true;
if ($handler !== null) {
try {
$printresponse = $handler->exec($this->paths, $this->user);
if ($printresponse) {
$retval = $handler->toJson();
}
} catch (Exceptions\Exception $e) {
$this->setResponseCode($e->getRetCode());
$retval = json_encode(array(
"error" => $e->getMessage()
));
}
} else {
$this->responseCode = 404;
}
if (sizeof($this->headers) != 0) {
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}
}
if ($this->responseCode != 200) {
http_response_code($this->responseCode);
}
if ($printresponse) {
if ($this->redirect !== null) {
header("Location: $this->redirect");
}
if ($this->responseCode != 404) {
header("Content-type: " . $this->contentType);
header("Access-Control-Allow-Origin: *");
}
if ($this->cacheTime == 0) {
header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache");
header("Expires: 0");
} else {
$ts = gmdate("D, d M Y H:i:s", time() + $this->cacheTime) . " GMT";
header("Expires: $ts");
header("Pragma: cache");
header("Cache-Control: max-age=" . $this->cacheTime);
}
echo $retval;
} else {
if (!headers_sent()) {
header("Content-type: " . $this->contentType);
}
}
}
/**
* check if the current user has the given permission
*
* @param string|string[] $permission
* permission(s) to check for
* @param string $exception Exception Type
* @param string $message optional message
* @throws Exceptions\PermissionDenied
*/
public function checkPermission($permission, $exception = "\Exceptions\PermissionDenied", $message = null): void
{
// generic check first
if ($this->user === false) {
throw new Exceptions\PermissionDenied();
}
if ($this->user->id == 0) {
throw new Exceptions\PermissionDenied();
}
if (!is_array($permission)) {
$permission = array(
$permission
);
}
// LoggedIn is handled above
foreach ($permission as $p) {
if ($this->user->hasPermission($p)) {
return;
}
}
throw new $exception($message);
}
/**
* check if the headers include a valid signature
*
* @param string[] $headers
* permission(s) to check for
* @throws Exceptions\PermissionDenied
* @return string|Exceptions\PermissionDenied
*/
public static function checkSignature($headers)
{
$signatureHeader = $headers['Signature'] ?? null;
if (!isset($signatureHeader)) {
http_response_code(400);
throw new Exceptions\PermissionDenied("Missing Signature header");
}
// Parse Signature header
preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches);
$signatureParts = array_combine($matches[1], $matches[2]);
$signature = base64_decode($signatureParts['signature']);
$keyId = $signatureParts['keyId'];
$signedHeaders = explode(' ', $signatureParts['headers']);
// Fetch public key from `keyId` (usually actor URL + #main-key)
[$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']);
if ($info['http_code'] != 200) {
http_response_code(500);
throw new Exceptions\PermissionDenied("Failed to fetch public key from keyId: $keyId");
}
$actor = json_decode($publicKeyData, true);
if (!is_array($actor) || !isset($actor['id'])) {
throw new Exceptions\PermissionDenied("Invalid actor data");
}
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
if (!isset($publicKeyPem)) {
http_response_code(500);
throw new Exceptions\PermissionDenied("Invalid public key format from actor with keyId: $keyId");
}
// Reconstruct the signed string
$signedString = '';
foreach ($signedHeaders as $header) {
if ($header === '(request-target)') {
$method = strtolower($_SERVER['REQUEST_METHOD']);
$path = $_SERVER['REQUEST_URI'];
$headerValue = "$method $path";
} else {
$headerValue = $headers[ucwords($header, '-')] ?? '';
}
$signedString .= strtolower($header) . ": " . $headerValue . "\n";
}
$signedString = rtrim($signedString);
// Verify the signature
$pubkeyRes = openssl_pkey_get_public($publicKeyPem);
$verified = false;
if ($pubkeyRes instanceof \OpenSSLAsymmetricKey && is_string($signature)) {
$verified = openssl_verify($signedString, $signature, $pubkeyRes, OPENSSL_ALGO_SHA256);
}
if ($verified != 1) {
http_response_code(500);
throw new Exceptions\PermissionDenied("Signature verification failed for publicKey with keyId: $keyId");
}
$actorId = $actor['id'];
// Signature is valid!
return "Signature verified from actor: " . $actorId;
}
/**
* remove unwanted elements from html input
*
* @param string $_input
* input to strip
* @return string stripped input
*/
public static function stripHTML(string $_input): string
{
$out = preg_replace('/<(script[^>]*)>/i', '&lt;${1}&gt;', $_input);
$out = preg_replace('/<\/(script)>/i', '&lt;/${1};&gt;', $out);
return $out;
}
/**
* is given parameter in POST data
*
* @param string $_key
* parameter to check
* @return bool true if in
*/
public static function hasPost(string $_key): bool
{
return array_key_exists($_key, $_POST);
}
/**
* SQL escape given POST parameter
*
* @param string $key
* key to escape
* @param boolean $int
* is parameter an int
* @return int|string
*/
public function escapePost(string $key, $int = false)
{
if (!array_key_exists($key, $_POST)) {
return $int ? 0 : "";
}
if ($int === true) {
return intval($_POST[$key]);
}
$ret = $this->dbh->escape_string($this->stripHTML((string) $_POST[$key]));
return $ret;
}
/**
* update $data with POST info using optional alias $altName
*
* @param string $name
* parameter name
* @param array<string, mixed> $data
* array to update
* @param bool $int
* is data an integer
* @param string $altName
* optional alternative name in POST
* @return void
*/
public function updateString($name, &$data, $int = false, $altName = "")
{
if ($this->hasPost($altName ?: $name)) {
$content = $this->escapePost($altName ?: $name, $int);
$data[$name] = $content;
}
}
/**
* set cache time
*
* @param int $time time in seconds
* @return void
*/
public function setCacheTime($time)
{
$this->cacheTime = $time;
}
}