mirror of
https://github.com/openvk/openvk
synced 2025-02-09 00:09:33 +03:00
* feat(lint): add php-cs-fixer for linting Removing previous CODE_STYLE as it was not enforced anyway and using PER-CS 2.0. This is not the reformatting commit. * style: format code according to PER-CS 2.0 with php-cs-fixer * ci(actions): add lint action Resolves #1132.
419 lines
15 KiB
PHP
419 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace openvk\Web\Presenters;
|
|
|
|
use Chandler\Security\Authenticator;
|
|
use Chandler\Database\DatabaseConnection as DB;
|
|
use openvk\VKAPI\Exceptions\APIErrorException;
|
|
use openvk\Web\Models\Entities\{User, APIToken};
|
|
use openvk\Web\Models\Repositories\{Users, APITokens};
|
|
use lfkeitel\phptotp\{Base32, Totp};
|
|
use WhichBrowser;
|
|
|
|
final class VKAPIPresenter extends OpenVKPresenter
|
|
{
|
|
protected $silent = true;
|
|
private function logRequest(string $object, string $method): void
|
|
{
|
|
$date = date(DATE_COOKIE);
|
|
$params = json_encode($_REQUEST);
|
|
$log = "[$date] $object.$method called with $params\r\n";
|
|
file_put_contents(OPENVK_ROOT . "/VKAPI/debug.log", $log, FILE_APPEND | LOCK_EX);
|
|
}
|
|
|
|
private function fail(int $code, string $message, string $object, string $method): void
|
|
{
|
|
header("HTTP/1.1 400 Bad API Call");
|
|
header("Content-Type: application/json");
|
|
|
|
$payload = [
|
|
"error_code" => $code,
|
|
"error_msg" => $message,
|
|
"request_params" => [
|
|
[
|
|
"key" => "method",
|
|
"value" => "$object.$method",
|
|
],
|
|
[
|
|
"key" => "oauth",
|
|
"value" => 1,
|
|
],
|
|
],
|
|
];
|
|
|
|
foreach ($_GET as $key => $value) {
|
|
array_unshift($payload["request_params"], [ "key" => $key, "value" => $value ]);
|
|
}
|
|
|
|
exit(json_encode($payload));
|
|
}
|
|
|
|
private function twofaFail(int $userId): void
|
|
{
|
|
header("HTTP/1.1 401 Unauthorized");
|
|
header("Content-Type: application/json");
|
|
|
|
$payload = [
|
|
"error" => "need_validation",
|
|
"error_description" => "use app code",
|
|
"validation_type" => "2fa_app",
|
|
"validation_sid" => "2fa_" . $userId . "_2839041_randommessdontread",
|
|
"phone_mask" => "+374 ** *** 420",
|
|
"redirect_url" => "https://http.cat/418", // Not implemented yet :( So there is a photo of cat :3
|
|
"validation_resend" => "nowhere",
|
|
];
|
|
|
|
exit(json_encode($payload));
|
|
}
|
|
|
|
private function badMethod(string $object, string $method): void
|
|
{
|
|
$this->fail(3, "Unknown method passed.", $object, $method);
|
|
}
|
|
|
|
private function badMethodCall(string $object, string $method, string $param): void
|
|
{
|
|
$this->fail(100, "Required parameter '$param' missing.", $object, $method);
|
|
}
|
|
|
|
public function onStartup(): void
|
|
{
|
|
parent::onStartup();
|
|
|
|
# idk, but in case we will ever support non-standard HTTP credential authflow
|
|
$origin = "*";
|
|
if (isset($_SERVER["HTTP_REFERER"])) {
|
|
$refOrigin = parse_url($_SERVER["HTTP_REFERER"], PHP_URL_SCHEME) . "://" . parse_url($_SERVER["HTTP_REFERER"], PHP_URL_HOST);
|
|
if ($refOrigin !== false) {
|
|
$origin = $refOrigin;
|
|
}
|
|
}
|
|
|
|
if (!is_null($this->queryParam("requestPort"))) {
|
|
$origin .= ":" . ((int) $this->queryParam("requestPort"));
|
|
}
|
|
|
|
header("Access-Control-Allow-Origin: $origin");
|
|
|
|
if ($_SERVER["REQUEST_METHOD"] === "OPTIONS") {
|
|
header("Access-Control-Allow-Methods: POST, PUT, DELETE");
|
|
header("Access-Control-Allow-Headers: " . $_SERVER["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"]);
|
|
header("Access-Control-Max-Age: -1");
|
|
exit; # Terminate request processing as this is definitely a CORS preflight request.
|
|
}
|
|
}
|
|
|
|
public function renderPhotoUpload(string $signature): void
|
|
{
|
|
$secret = CHANDLER_ROOT_CONF["security"]["secret"];
|
|
$queryString = rawurldecode($_SERVER["QUERY_STRING"]);
|
|
$computedSignature = hash_hmac("sha3-224", $queryString, $secret);
|
|
if (!(strlen($signature) == 56 && sodium_memcmp($signature, $computedSignature) == 0)) {
|
|
header("HTTP/1.1 422 Unprocessable Entity");
|
|
exit("Try harder <3");
|
|
}
|
|
|
|
$data = unpack("vDOMAIN/Z10FIELD/vMF/vMP/PTIME/PUSER/PGROUP", base64_decode($queryString));
|
|
if ((time() - $data["TIME"]) > 600) {
|
|
header("HTTP/1.1 422 Unprocessable Entity");
|
|
exit("Expired");
|
|
}
|
|
|
|
$folder = __DIR__ . "/../../tmp/api-storage/photos";
|
|
$maxSize = OPENVK_ROOT_CONF["openvk"]["preferences"]["uploads"]["api"]["maxFileSize"];
|
|
$maxFiles = OPENVK_ROOT_CONF["openvk"]["preferences"]["uploads"]["api"]["maxFilesPerDomain"];
|
|
$usrFiles = sizeof(glob("$folder/$data[USER]_*.oct"));
|
|
if ($usrFiles >= $maxFiles) {
|
|
header("HTTP/1.1 507 Insufficient Storage");
|
|
exit("There are $maxFiles pending already. Please save them before uploading more :3");
|
|
}
|
|
|
|
# Not multifile
|
|
if ($data["MF"] === 0) {
|
|
$file = $_FILES[$data["FIELD"]];
|
|
if (!$file) {
|
|
header("HTTP/1.0 400");
|
|
exit("No file");
|
|
} elseif ($file["error"] != UPLOAD_ERR_OK) {
|
|
header("HTTP/1.0 500");
|
|
exit("File could not be consumed");
|
|
} elseif ($file["size"] > $maxSize) {
|
|
header("HTTP/1.0 507 Insufficient Storage");
|
|
exit("File is too big");
|
|
}
|
|
|
|
move_uploaded_file($file["tmp_name"], "$folder/$data[USER]_" . ($usrFiles + 1) . ".oct");
|
|
header("HTTP/1.0 202 Accepted");
|
|
|
|
$photo = $data["USER"] . "|" . ($usrFiles + 1) . "|" . $data["GROUP"];
|
|
exit(json_encode([
|
|
"server" => "ephemeral",
|
|
"photo" => $photo,
|
|
"hash" => hash_hmac("sha3-224", $photo, $secret),
|
|
]));
|
|
}
|
|
|
|
$files = [];
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$file = $_FILES[$data["FIELD"] . $i] ?? null;
|
|
if (!$file || $file["error"] != UPLOAD_ERR_OK || $file["size"] > $maxSize) {
|
|
continue;
|
|
} elseif ((sizeof($files) + $usrFiles) > $maxFiles) {
|
|
# Clear uploaded files since they can't be saved anyway
|
|
foreach ($files as $f) {
|
|
unlink($f);
|
|
}
|
|
|
|
header("HTTP/1.1 507 Insufficient Storage");
|
|
exit("There are $maxFiles pending already. Please save them before uploading more :3");
|
|
}
|
|
|
|
$files[++$usrFiles] = move_uploaded_file($file["tmp_name"], "$folder/$data[USER]_$usrFiles.oct");
|
|
}
|
|
|
|
if (sizeof($files) === 0) {
|
|
header("HTTP/1.0 400");
|
|
exit("No file");
|
|
}
|
|
|
|
$filesManifest = [];
|
|
foreach ($files as $id => $file) {
|
|
$filesManifest[] = ["keyholder" => $data["USER"], "resource" => $id, "club" => $data["GROUP"]];
|
|
}
|
|
|
|
$filesManifest = json_encode($filesManifest);
|
|
$manifestHash = hash_hmac("sha3-224", $filesManifest, $secret);
|
|
header("HTTP/1.0 202 Accepted");
|
|
exit(json_encode([
|
|
"server" => "ephemeral",
|
|
"photos_list" => $filesManifest,
|
|
"album_id" => "undefined",
|
|
"hash" => $manifestHash,
|
|
]));
|
|
}
|
|
|
|
public function renderRoute(string $object, string $method): void
|
|
{
|
|
$callback = $this->queryParam("callback");
|
|
$authMechanism = $this->queryParam("auth_mechanism") ?? "token";
|
|
if ($authMechanism === "roaming") {
|
|
if ($callback) {
|
|
$this->fail(-1, "User authorization failed: roaming mechanism is unavailable with jsonp.", $object, $method);
|
|
}
|
|
|
|
if (!$this->user->identity) {
|
|
$this->fail(5, "User authorization failed: roaming mechanism is selected, but user is not logged in.", $object, $method);
|
|
} else {
|
|
$identity = $this->user->identity;
|
|
}
|
|
} else {
|
|
if (is_null($this->requestParam("access_token"))) {
|
|
$identity = null;
|
|
} else {
|
|
$token = (new APITokens())->getByCode($this->requestParam("access_token"));
|
|
if (!$token) {
|
|
$identity = null;
|
|
} else {
|
|
$identity = $token->getUser();
|
|
$platform = $token->getPlatform();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!is_null($identity) && $identity->isBanned()) {
|
|
$this->fail(18, "User account is deactivated", $object, $method);
|
|
}
|
|
|
|
$object = ucfirst(strtolower($object));
|
|
$handlerClass = "openvk\\VKAPI\\Handlers\\$object";
|
|
if (!class_exists($handlerClass)) {
|
|
$this->badMethod($object, $method);
|
|
}
|
|
|
|
$handler = new $handlerClass($identity, $platform);
|
|
if (!is_callable([$handler, $method])) {
|
|
$this->badMethod($object, $method);
|
|
}
|
|
|
|
$has_rss = false;
|
|
$route = new \ReflectionMethod($handler, $method);
|
|
$params = [];
|
|
foreach ($route->getParameters() as $parameter) {
|
|
if ($parameter->getName() == 'rss') {
|
|
$has_rss = true;
|
|
}
|
|
|
|
$val = $this->requestParam($parameter->getName());
|
|
if (is_null($val)) {
|
|
if ($parameter->allowsNull()) {
|
|
$val = null;
|
|
} elseif ($parameter->isDefaultValueAvailable()) {
|
|
$val = $parameter->getDefaultValue();
|
|
} elseif ($parameter->isOptional()) {
|
|
$val = null;
|
|
} else {
|
|
$this->badMethodCall($object, $method, $parameter->getName());
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Проверка типа параметра
|
|
$type = $parameter->getType();
|
|
if (($type && !$type->isBuiltin()) || is_null($val)) {
|
|
$params[] = $val;
|
|
} else {
|
|
settype($val, $parameter->getType()->getName());
|
|
$params[] = $val;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Just ignore the exception, since
|
|
// some args are intended for internal use
|
|
}
|
|
}
|
|
|
|
define("VKAPI_DECL_VER", $this->requestParam("v") ?? "4.100", false);
|
|
|
|
try {
|
|
$res = $handler->{$method}(...$params);
|
|
} catch (APIErrorException $ex) {
|
|
$this->fail($ex->getCode(), $ex->getMessage(), $object, $method);
|
|
}
|
|
|
|
$result = null;
|
|
|
|
if ($this->queryParam("rss") == '1' && $has_rss) {
|
|
$feed = new \Bhaktaraz\RSSGenerator\Feed();
|
|
$res->appendTo($feed);
|
|
|
|
$result = strval($feed);
|
|
|
|
header("Content-Type: application/rss+xml;charset=UTF-8");
|
|
} else {
|
|
$result = json_encode([
|
|
"response" => $res,
|
|
]);
|
|
|
|
if ($callback) {
|
|
$result = $callback . '(' . $result . ')';
|
|
header('Content-Type: application/javascript');
|
|
} else {
|
|
header("Content-Type: application/json");
|
|
}
|
|
}
|
|
|
|
$size = strlen($result);
|
|
header("Content-Length: $size");
|
|
|
|
exit($result);
|
|
}
|
|
|
|
public function renderTokenLogin(): void
|
|
{
|
|
if ($this->requestParam("grant_type") !== "password") {
|
|
$this->fail(7, "Invalid grant type", "internal", "acquireToken");
|
|
} elseif (is_null($this->requestParam("username")) || is_null($this->requestParam("password"))) {
|
|
$this->fail(100, "Password and username not passed", "internal", "acquireToken");
|
|
}
|
|
|
|
$chUser = DB::i()->getContext()->table("ChandlerUsers")->where("login", $this->requestParam("username"))->fetch();
|
|
if (!$chUser) {
|
|
$this->fail(28, "Invalid username or password", "internal", "acquireToken");
|
|
}
|
|
|
|
$auth = Authenticator::i();
|
|
if (!$auth->verifyCredentials($chUser->id, $this->requestParam("password"))) {
|
|
$this->fail(28, "Invalid username or password", "internal", "acquireToken");
|
|
}
|
|
|
|
$uId = $chUser->related("profiles.user")->fetch()->id;
|
|
$user = (new Users())->get($uId);
|
|
|
|
$code = $this->requestParam("code");
|
|
if ($user->is2faEnabled() && !($code === (new Totp())->GenerateToken(Base32::decode($user->get2faSecret())) || $user->use2faBackupCode((int) $code))) {
|
|
if ($this->requestParam("2fa_supported") == "1") {
|
|
$this->twofaFail($user->getId());
|
|
} else {
|
|
$this->fail(28, "Invalid 2FA code", "internal", "acquireToken");
|
|
}
|
|
}
|
|
|
|
$token = null;
|
|
$tokenIsStale = true;
|
|
$platform = $this->requestParam("client_name");
|
|
$acceptsStale = $this->requestParam("accepts_stale");
|
|
if ($acceptsStale == "1") {
|
|
if (is_null($platform)) {
|
|
$this->fail(101, "accepts_stale can only be used with explicitly set client_name", "internal", "acquireToken");
|
|
}
|
|
|
|
$token = (new APITokens())->getStaleByUser($uId, $platform);
|
|
}
|
|
|
|
if (is_null($token)) {
|
|
$tokenIsStale = false;
|
|
|
|
$token = new APIToken();
|
|
$token->setUser($user);
|
|
$token->setPlatform($platform ?? (new WhichBrowser\Parser(getallheaders()))->toString());
|
|
$token->save();
|
|
}
|
|
|
|
$payload = json_encode([
|
|
"access_token" => $token->getFormattedToken(),
|
|
"expires_in" => 0,
|
|
"user_id" => $uId,
|
|
"is_stale" => $tokenIsStale,
|
|
"secret" => "super_secret_value",
|
|
]);
|
|
|
|
$size = strlen($payload);
|
|
header("Content-Type: application/json");
|
|
header("Content-Length: $size");
|
|
exit($payload);
|
|
}
|
|
|
|
public function renderOAuthLogin()
|
|
{
|
|
$this->assertUserLoggedIn();
|
|
|
|
$client = $this->queryParam("client_name");
|
|
$postmsg = $this->queryParam("prefers_postMessage") ?? '0';
|
|
$stale = $this->queryParam("accepts_stale") ?? '0';
|
|
$origin = null;
|
|
$url = $this->queryParam("redirect_uri");
|
|
if (is_null($url) || is_null($client)) {
|
|
exit("<b>Error:</b> redirect_uri and client_name params are required.");
|
|
}
|
|
|
|
if ($url != "about:blank") {
|
|
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
|
exit("<b>Error:</b> Invalid URL passed to redirect_uri.");
|
|
}
|
|
|
|
$parsedUrl = (object) parse_url($url);
|
|
if ($parsedUrl->scheme != 'https' && $parsedUrl->scheme != 'http') {
|
|
exit("<b>Error:</b> redirect_uri should either point to about:blank or to a web resource.");
|
|
}
|
|
|
|
$origin = "$parsedUrl->scheme://$parsedUrl->host";
|
|
if (!is_null($parsedUrl->port ?? null)) {
|
|
$origin .= ":$parsedUrl->port";
|
|
}
|
|
|
|
$url .= strpos($url, '?') === false ? '?' : '&';
|
|
} else {
|
|
$url .= "#";
|
|
if ($postmsg == '1') {
|
|
exit("<b>Error:</b> prefers_postMessage can only be set if redirect_uri is not about:blank");
|
|
}
|
|
}
|
|
|
|
$this->template->clientName = $client;
|
|
$this->template->usePostMessage = $postmsg == '1';
|
|
$this->template->acceptsStale = $stale == '1';
|
|
$this->template->origin = $origin;
|
|
$this->template->redirectUri = $url;
|
|
}
|
|
}
|