From d767d8e2eb101bce36f2ad011c6ecea18c5a8db3 Mon Sep 17 00:00:00 2001 From: celestora Date: Sat, 20 Aug 2022 21:07:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20(#674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServiceAPI/Apps.php | 87 +++++++ ServiceAPI/Wall.php | 16 ++ VKAPI/Handlers/Pay.php | 42 +++ Web/Models/Entities/Application.php | 316 +++++++++++++++++++++++ Web/Models/Repositories/Applications.php | 69 +++++ Web/Presenters/AppsPresenter.php | 138 ++++++++++ Web/Presenters/templates/Apps/Edit.xml | 112 ++++++++ Web/Presenters/templates/Apps/List.xml | 53 ++++ Web/Presenters/templates/Apps/Play.xml | 37 +++ Web/di.yml | 2 + Web/routes.yml | 8 + Web/static/css/style.css | 7 + Web/static/js/GameAPI.js | 54 ++++ Web/static/js/al_games.js | 225 ++++++++++++++++ install/sqls/00030-apps.sql | 22 ++ locales/ru.strings | 68 +++++ openvk-example.yml | 2 + 17 files changed, 1258 insertions(+) create mode 100644 ServiceAPI/Apps.php create mode 100644 VKAPI/Handlers/Pay.php create mode 100644 Web/Models/Entities/Application.php create mode 100644 Web/Models/Repositories/Applications.php create mode 100644 Web/Presenters/AppsPresenter.php create mode 100644 Web/Presenters/templates/Apps/Edit.xml create mode 100644 Web/Presenters/templates/Apps/List.xml create mode 100644 Web/Presenters/templates/Apps/Play.xml create mode 100644 Web/static/js/GameAPI.js create mode 100644 Web/static/js/al_games.js create mode 100644 install/sqls/00030-apps.sql diff --git a/ServiceAPI/Apps.php b/ServiceAPI/Apps.php new file mode 100644 index 00000000..521b117e --- /dev/null +++ b/ServiceAPI/Apps.php @@ -0,0 +1,87 @@ +user = $user; + $this->apps = new Applications; + } + + function getUserInfo(callable $resolve, callable $reject): void + { + $hexId = dechex($this->user->getId()); + $sign = hash_hmac("sha512/224", $hexId, CHANDLER_ROOT_CONF["security"]["secret"], true); + $marketingId = $hexId . "_" . base64_encode($sign); + + $resolve([ + "id" => $this->user->getId(), + "marketing_id" => $marketingId, + "name" => [ + "first" => $this->user->getFirstName(), + "last" => $this->user->getLastName(), + "full" => $this->user->getFullName(), + ], + "ava" => $this->user->getAvatarUrl(), + ]); + } + + function updatePermission(int $app, string $perm, string $state, callable $resolve, callable $reject): void + { + $app = $this->apps->get($app); + if(!$app || !$app->isEnabled()) { + $reject("No application with this id found"); + return; + } + + if(!$app->setPermission($this->user, $perm, $state == "yes")) + $reject("Invalid permission $perm"); + + $resolve(1); + } + + function pay(int $appId, float $amount, callable $resolve, callable $reject): void + { + $app = $this->apps->get($appId); + if(!$app || !$app->isEnabled()) { + $reject("No application with this id found"); + return; + } + + $coinsLeft = $this->user->getCoins() - $amount; + if($coinsLeft < 0) { + $reject(41, "Not enough money"); + return; + } + + $this->user->setCoins($coinsLeft); + $this->user->save(); + $app->addCoins($amount); + + $t = time(); + $resolve($t . "," . hash_hmac("whirlpool", "$appId:$amount:$t", CHANDLER_ROOT_CONF["security"]["secret"])); + } + + function withdrawFunds(int $appId, callable $resolve, callable $reject): void + { + $app = $this->apps->get($appId); + if(!$app) { + $reject("No application with this id found"); + return; + } else if($app->getOwner()->getId() != $this->user->getId()) { + $reject("You don't have rights to edit this app"); + return; + } + + $coins = $app->getBalance(); + $app->withdrawCoins(); + $resolve($coins); + } +} \ No newline at end of file diff --git a/ServiceAPI/Wall.php b/ServiceAPI/Wall.php index af74464f..628ceb22 100644 --- a/ServiceAPI/Wall.php +++ b/ServiceAPI/Wall.php @@ -1,5 +1,6 @@ setOwner($this->user->getId()); + $post->setWall($this->user->getId()); + $post->setCreated(time()); + $post->setContent($text); + $post->setAnonymous(false); + $post->setFlags(0); + $post->setNsfw(false); + $post->save(); + + $resolve($post->getId()); + } } diff --git a/VKAPI/Handlers/Pay.php b/VKAPI/Handlers/Pay.php new file mode 100644 index 00000000..e5fb93a7 --- /dev/null +++ b/VKAPI/Handlers/Pay.php @@ -0,0 +1,42 @@ +fail(4, "Invalid marketing id"); + } catch (\SodiumException $e) { + $this->fail(4, "Invalid marketing id"); + } + + return hexdec($hexId); + } + + function verifyOrder(int $app_id, float $amount, string $signature): bool + { + $this->requireUser(); + + $app = (new Applications())->get($app_id); + if(!$app) + $this->fail(26, "No app found with this id"); + else if($app->getOwner()->getId() != $this->getUser()->getId()) + $this->fail(15, "Access error"); + + [$time, $signature] = explode(",", $signature); + try { + $key = CHANDLER_ROOT_CONF["security"]["secret"]; + if(sodium_memcmp($signature, hash_hmac("whirlpool", "$app_id:$amount:$time", $key)) == -1) + $this->fail(4, "Invalid order"); + } catch (\SodiumException $e) { + $this->fail(4, "Invalid order"); + } + + return true; + } +} \ No newline at end of file diff --git a/Web/Models/Entities/Application.php b/Web/Models/Entities/Application.php new file mode 100644 index 00000000..e79c8db5 --- /dev/null +++ b/Web/Models/Entities/Application.php @@ -0,0 +1,316 @@ +getRecord()->id; + } + + function getOwner(): User + { + return (new Users)->get($this->getRecord()->owner); + } + + function getName(): string + { + return $this->getRecord()->name; + } + + function getDescription(): string + { + return $this->getRecord()->description; + } + + function getAvatarUrl(): string + { + $serverUrl = ovk_scheme(true) . $_SERVER["HTTP_HOST"]; + if(is_null($this->getRecord()->avatar_hash)) + return "$serverUrl/assets/packages/static/openvk/img/camera_200.png"; + + $hash = $this->getRecord()->avatar_hash; + switch(OPENVK_ROOT_CONF["openvk"]["preferences"]["uploads"]["mode"]) { + default: + case "default": + case "basic": + return "$serverUrl/blob_" . substr($hash, 0, 2) . "/$hash" . "_app_avatar.png"; + case "accelerated": + return "$serverUrl/openvk-datastore/$hash.app_avatar.png"; + case "server": + $settings = (object) OPENVK_ROOT_CONF["openvk"]["preferences"]["uploads"]["server"]; + return ( + $settings->protocol ?? ovk_scheme() . + "://" . $settings->host . + $settings->path . + substr($hash, 0, 2) . "/$hash.app_avatar.png" + ); + } + } + + function getNote(): ?Note + { + if(!$this->getRecord()->news) + return NULL; + + return (new Notes)->get($this->getRecord()->news); + } + + function getNoteLink(): string + { + $note = $this->getNote(); + if(!$note) + return ""; + + return ovk_scheme(true) . $_SERVER["HTTP_HOST"] . "/note" . $note->getPrettyId(); + } + + function getBalance(): float + { + return $this->getRecord()->coins; + } + + function getURL(): string + { + return $this->getRecord()->address; + } + + function getOrigin(): string + { + $parsed = parse_url($this->getURL()); + + return ( + ($parsed["scheme"] ?? "https") . "://" + . ($parsed["host"] ?? "127.0.0.1") . ":" + . ($parsed["port"] ?? "443") + ); + } + + function getUsersCount(): int + { + $cx = DatabaseConnection::i()->getContext(); + return sizeof($cx->table("app_users")->where("app", $this->getId())); + } + + function getInstallationEntry(User $user): ?array + { + $cx = DatabaseConnection::i()->getContext(); + $entry = $cx->table("app_users")->where([ + "app" => $this->getId(), + "user" => $user->getId(), + ])->fetch(); + + if(!$entry) + return NULL; + + return $entry->toArray(); + } + + function getPermissions(User $user): array + { + $permMask = 0; + $installInfo = $this->getInstallationEntry($user); + if(!$installInfo) + $this->install($user); + else + $permMask = $installInfo["access"]; + + $res = []; + for($i = 0; $i < sizeof(self::PERMS); $i++) { + $checkVal = 1 << $i; + if(($permMask & $checkVal) > 0) + $res[] = self::PERMS[$i]; + } + + return $res; + } + + function isInstalledBy(User $user): bool + { + return !is_null($this->getInstallationEntry($user)); + } + + function setNoteLink(?string $link): bool + { + if(!$link) { + $this->stateChanges("news", NULL); + + return true; + } + + preg_match("%note([0-9]+)_([0-9]+)$%", $link, $matches); + if(sizeof($matches) != 3) + return false; + + $owner = is_null($this->getRecord()) ? $this->changes["owner"] : $this->getRecord()->owner; + [, $ownerId, $vid] = $matches; + if($ownerId != $owner) + return false; + + $note = (new Notes)->getNoteById((int) $ownerId, (int) $vid); + if(!$note) + return false; + + $this->stateChanges("news", $note->getId()); + + return true; + } + + function setAvatar(array $file): int + { + if($file["error"] !== UPLOAD_ERR_OK) + return -1; + + try { + $image = Image::fromFile($file["tmp_name"]); + } catch (UnknownImageFileException $e) { + return -2; + } + + $hash = hash_file("murmur3a", $file["tmp_name"]); + if(!is_dir($this->getAvatarsDir() . substr($hash, 0, 2))) + if(!mkdir($this->getAvatarsDir() . substr($hash, 0, 2))) + return -3; + + $image->resize(140, 140, Image::STRETCH); + $image->save($this->getAvatarsDir() . substr($hash, 0, 2) . "/$hash" . "_app_avatar.png"); + + $this->stateChanges("avatar_hash", $hash); + + return 0; + } + + function setPermission(User $user, string $perm, bool $enabled): bool + { + $permMask = 0; + $installInfo = $this->getInstallationEntry($user); + if(!$installInfo) + $this->install($user); + else + $permMask = $installInfo["access"]; + + $index = array_search($perm, self::PERMS); + if($index === false) + return false; + + $permVal = 1 << $index; + $permMask = $enabled ? ($permMask | $permVal) : ($permMask ^ $permVal); + + $cx = DatabaseConnection::i()->getContext(); + $cx->table("app_users")->where([ + "app" => $this->getId(), + "user" => $user->getId(), + ])->update([ + "access" => $permMask, + ]); + + return true; + } + + function isEnabled(): bool + { + return (bool) $this->getRecord()->enabled; + } + + function enable(): void + { + $this->stateChanges("enabled", 1); + $this->save(); + } + + function disable(): void + { + $this->stateChanges("enabled", 0); + $this->save(); + } + + function install(User $user): void + { + if(!$this->getInstallationEntry($user)) { + $cx = DatabaseConnection::i()->getContext(); + $cx->table("app_users")->insert([ + "app" => $this->getId(), + "user" => $user->getId(), + ]); + } + } + + function uninstall(User $user): void + { + $cx = DatabaseConnection::i()->getContext(); + $cx->table("app_users")->where([ + "app" => $this->getId(), + "user" => $user->getId(), + ])->delete(); + } + + function addCoins(float $coins): float + { + $res = $this->getBalance() + $coins; + $this->stateChanges("coins", $res); + $this->save(); + + return $res; + } + + function withdrawCoins(): void + { + $balance = $this->getBalance(); + $tax = ($balance / 100) * OPENVK_ROOT_CONF["openvk"]["preferences"]["apps"]["withdrawTax"]; + + $owner = $this->getOwner(); + $owner->setCoins($owner->getCoins() + ($balance - $tax)); + $this->setCoins(0.0); + $this->save(); + $owner->save(); + } + + function delete(bool $softly = true): void + { + if($softly) + throw new \UnexpectedValueException("Can't delete apps softly."); + + $cx = DatabaseConnection::i()->getContext(); + $cx->table("app_users")->where("app", $this->getId())->delete(); + + parent::delete(false); + } +} \ No newline at end of file diff --git a/Web/Models/Repositories/Applications.php b/Web/Models/Repositories/Applications.php new file mode 100644 index 00000000..3aa6e5be --- /dev/null +++ b/Web/Models/Repositories/Applications.php @@ -0,0 +1,69 @@ +context = DatabaseConnection::i()->getContext(); + $this->apps = $this->context->table("apps"); + $this->appRels = $this->context->table("app_users"); + } + + private function toApp(?ActiveRow $ar): ?Application + { + return is_null($ar) ? NULL : new Application($ar); + } + + function get(int $id): ?Application + { + return $this->toApp($this->apps->get($id)); + } + + function getList(int $page = 1, ?int $perPage = NULL): \Traversable + { + $perPage = $perPage ?? OPENVK_DEFAULT_PER_PAGE; + $apps = $this->apps->where("enabled", 1)->page($page, $perPage); + foreach($apps as $app) + yield new Application($app); + } + + function getListCount(): int + { + return sizeof($this->apps->where("enabled", 1)); + } + + function getByOwner(User $owner, int $page = 1, ?int $perPage = NULL): \Traversable + { + $perPage = $perPage ?? OPENVK_DEFAULT_PER_PAGE; + $apps = $this->apps->where("owner", $owner->getId())->page($page, $perPage); + foreach($apps as $app) + yield new Application($app); + } + + function getOwnCount(User $owner): int + { + return sizeof($this->apps->where("owner", $owner->getId())); + } + + function getInstalled(User $user, int $page = 1, ?int $perPage = NULL): \Traversable + { + $perPage = $perPage ?? OPENVK_DEFAULT_PER_PAGE; + $apps = $this->appRels->where("user", $user->getId())->page($page, $perPage); + foreach($apps as $appRel) + yield $this->get($appRel->app); + } + + function getInstalledCount(User $user): int + { + return sizeof($this->appRels->where("user", $user->getId())); + } +} \ No newline at end of file diff --git a/Web/Presenters/AppsPresenter.php b/Web/Presenters/AppsPresenter.php new file mode 100644 index 00000000..02fb8922 --- /dev/null +++ b/Web/Presenters/AppsPresenter.php @@ -0,0 +1,138 @@ +apps = $apps; + + parent::__construct(); + } + + function renderPlay(int $app): void + { + $this->assertUserLoggedIn(); + + $app = $this->apps->get($app); + if(!$app || !$app->isEnabled()) + $this->notFound(); + + $this->template->id = $app->getId(); + $this->template->name = $app->getName(); + $this->template->desc = $app->getDescription(); + $this->template->origin = $app->getOrigin(); + $this->template->url = $app->getURL(); + $this->template->owner = $app->getOwner(); + $this->template->news = $app->getNote(); + $this->template->perms = $app->getPermissions($this->user->identity); + } + + function renderUnInstall(): void + { + $this->assertUserLoggedIn(); + $this->assertNoCSRF(); + + $app = $this->apps->get((int) $this->queryParam("app")); + if(!$app) + $this->flashFail("err", tr("app_err_not_found"), tr("app_err_not_found_desc")); + + $app->uninstall($this->user->identity); + $this->flashFail("succ", tr("app_uninstalled"), tr("app_uninstalled_desc")); + } + + function renderEdit(): void + { + $this->assertUserLoggedIn(); + + $app = NULL; + if($this->queryParam("act") !== "create") { + if(empty($this->queryParam("app"))) + $this->flashFail("err", tr("app_err_not_found"), tr("app_err_not_found_desc")); + + $app = $this->apps->get((int) $this->queryParam("app")); + if(!$app) + $this->flashFail("err", tr("app_err_not_found"), tr("app_err_not_found_desc")); + + if($app->getOwner()->getId() != $this->user->identity->getId()) + $this->flashFail("err", tr("forbidden"), tr("app_err_forbidden_desc")); + } + + if($_SERVER["REQUEST_METHOD"] === "POST") { + if(!$app) { + $app = new Application; + $app->setOwner($this->user->id); + } + + if(!filter_var($this->postParam("url"), FILTER_VALIDATE_URL)) + $this->flashFail("err", tr("app_err_url"), tr("app_err_url_desc")); + + if(isset($_FILES["ava"]) && $_FILES["ava"]["size"] > 0) { + if(($res = $app->setAvatar($_FILES["ava"])) !== 0) + $this->flashFail("err", tr("app_err_ava"), tr("app_err_ava_desc", $res)); + } + + if(empty($this->postParam("note"))) { + $app->setNoteLink(NULL); + } else { + if(!$app->setNoteLink($this->postParam("note"))) + $this->flashFail("err", tr("app_err_note"), tr("app_err_note_desc")); + } + + $app->setName($this->postParam("name")); + $app->setDescription($this->postParam("desc")); + $app->setAddress($this->postParam("url")); + if($this->postParam("enable") === "on") + $app->enable(); + else + $app->disable(); # no need to save since enable/disable will call save() internally + + $this->redirect("/editapp?act=edit&app=" . $app->getId()); # will exit here + } + + if(!is_null($app)) { + $this->template->create = false; + $this->template->id = $app->getId(); + $this->template->name = $app->getName(); + $this->template->desc = $app->getDescription(); + $this->template->coins = $app->getBalance(); + $this->template->origin = $app->getOrigin(); + $this->template->url = $app->getURL(); + $this->template->note = $app->getNoteLink(); + $this->template->users = $app->getUsersCount(); + $this->template->on = $app->isEnabled(); + } else { + $this->template->create = true; + } + } + + function renderList(): void + { + $this->assertUserLoggedIn(); + + $act = $this->queryParam("act"); + if(!in_array($act, ["list", "installed", "dev"])) + $act = "installed"; + + $page = (int) ($this->queryParam("p") ?? 1); + if($act == "list") { + $apps = $this->apps->getList($page); + $count = $this->apps->getListCount(); + } else if($act == "installed") { + $apps = $this->apps->getInstalled($this->user->identity, $page); + $count = $this->apps->getInstalledCount($this->user->identity); + } else if($act == "dev") { + $apps = $this->apps->getByOwner($this->user->identity, $page); + $count = $this->apps->getOwnCount($this->user->identity); + } + + $this->template->act = $act; + $this->template->iterator = $apps; + $this->template->count = $count; + $this->template->page = $page; + } +} \ No newline at end of file diff --git a/Web/Presenters/templates/Apps/Edit.xml b/Web/Presenters/templates/Apps/Edit.xml new file mode 100644 index 00000000..b8d9c8b0 --- /dev/null +++ b/Web/Presenters/templates/Apps/Edit.xml @@ -0,0 +1,112 @@ +{extends "../@layout.xml"} + +{block title} + {if $create} + {_create_app} + {else} + {_edit_app} + {/if} +{/block} + +{block header} + {if $create} + {_new_app} + {else} + {_own_apps_alternate} » + {$name} » + {_edit} + {/if} +{/block} + +{block content} +
+

{_main_information}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {_name}: + + +
+ {_description}: + + +
+ {_avatar}: + + +
+ {_app_news}: + + +
+ URL: + + +
+ {_app_state}: + + {_app_enabled} +
+ + + + +
+
+
+ +

{_additional_information}

+
+
    + {if $create} +
  • {_app_creation_hint_url}
  • +
  • {_app_creation_hint_iframe}
  • + {else} +
  • {tr("app_balance", $coins)|noescape} ({_app_withdrawal_q})
  • +
  • {tr("app_users", $users)|noescape}
  • + {/if} +
+
+
+ + +{/block} diff --git a/Web/Presenters/templates/Apps/List.xml b/Web/Presenters/templates/Apps/List.xml new file mode 100644 index 00000000..fbf4c6cc --- /dev/null +++ b/Web/Presenters/templates/Apps/List.xml @@ -0,0 +1,53 @@ +{extends "../@listView.xml"} + +{block title} + {_apps} +{/block} + +{block header} + {_apps} + +
+ + {_create} + +
+{/block} + +{block tabs} +
+ {_all_apps} +
+ +
+ {_installed_apps} +
+ +
+ {_own_apps} +
+{/block} + +{* BEGIN ELEMENTS DESCRIPTION *} + +{block link|strip|stripHtml} + /app{$x->getId()} +{/block} + +{block preview} + +{/block} + +{block name} + {$x->getName()} +{/block} + +{block description} + {$x->getDescription()} +{/block} + +{block actions} + {_app_play} + {_app_uninstall} + {_app_edit} +{/block} diff --git a/Web/Presenters/templates/Apps/Play.xml b/Web/Presenters/templates/Apps/Play.xml new file mode 100644 index 00000000..91cfe5d5 --- /dev/null +++ b/Web/Presenters/templates/Apps/Play.xml @@ -0,0 +1,37 @@ +{extends "../@layout.xml"} + +{block title} + {$name} +{/block} + +{block header} + {$name} +{/block} + +{block content} +
+ +
+ +
+

{$news->getName()}

+
+ {$news->getText()|noescape} +
+
+ +
+

+ {_app_dev}: {$owner->getFullName()} +

+
+ + + + {script "js/al_games.js"} +{/block} diff --git a/Web/di.yml b/Web/di.yml index 1e47bbc1..daa2e753 100644 --- a/Web/di.yml +++ b/Web/di.yml @@ -20,6 +20,7 @@ services: - openvk\Web\Presenters\GiftsPresenter - openvk\Web\Presenters\MessengerPresenter - openvk\Web\Presenters\TopicsPresenter + - openvk\Web\Presenters\AppsPresenter - openvk\Web\Presenters\ThemepacksPresenter - openvk\Web\Presenters\VKAPIPresenter - openvk\Web\Models\Repositories\Users @@ -39,4 +40,5 @@ services: - openvk\Web\Models\Repositories\Vouchers - openvk\Web\Models\Repositories\Gifts - openvk\Web\Models\Repositories\Topics + - openvk\Web\Models\Repositories\Applications - openvk\Web\Models\Repositories\ContentSearchRepository diff --git a/Web/routes.yml b/Web/routes.yml index 55225df3..2b346610 100644 --- a/Web/routes.yml +++ b/Web/routes.yml @@ -259,6 +259,14 @@ routes: handler: "Gifts->userGifts" - url: "/gifts" handler: "Gifts->stub" + - url: "/app{num}" + handler: "Apps->play" + - url: "/apps" + handler: "Apps->list" + - url: "/editapp" + handler: "Apps->edit" + - url: "/apps/uninstall" + handler: "Apps->unInstall" - url: "/admin" handler: "Admin->index" - url: "/admin/users" diff --git a/Web/static/css/style.css b/Web/static/css/style.css index 633d8a29..d35154c1 100644 --- a/Web/static/css/style.css +++ b/Web/static/css/style.css @@ -2044,6 +2044,13 @@ table td[width="120"] { height: 11px; } +#app_news_container { + margin-bottom: 30px; + overflow-x: hidden; + overflow-y: auto; + max-height: 250px; +} + @keyframes appearing { from { opacity: 0; diff --git a/Web/static/js/GameAPI.js b/Web/static/js/GameAPI.js new file mode 100644 index 00000000..66b53ec1 --- /dev/null +++ b/Web/static/js/GameAPI.js @@ -0,0 +1,54 @@ +const VKAPI = Object.create(null); + +VKAPI._makeRequest = function(type, params) { + return new Promise((succ, fail) => { + let uuid = crypto.randomUUID(); + let request = params; + request["@type"] = type; + request.transaction = uuid; + + let listener = e => { + if (e.source !== window.parent) + return; + + if (e.data.transaction !== uuid) + return; + + let resp = e.data; + let ok = resp.ok; + delete resp.transaction; + delete resp.ok; + + (ok ? succ : fail)(resp); + window.removeEventListener("message", listener); + }; + + let origin = document.referrer.split("/").slice(0, 3).join("/"); + window.addEventListener("message", listener); + window.parent.postMessage(request, origin); + }); +} + +VKAPI.getUser = function() { + return VKAPI._makeRequest("UserInfoRequest", {}); +} + +VKAPI.makePost = function(text) { + return VKAPI._makeRequest("WallPostRequest", { + text: text + }); +} + +VKAPI.execute = function(method, params) { + return VKAPI._makeRequest("VkApiRequest", { + method: method, + params: params + }); +} + +VKAPI.buy = function(price, item) { + return VKAPI._makeRequest("PaymentRequest", { + outSum: price, + description: item + }); +} \ No newline at end of file diff --git a/Web/static/js/al_games.js b/Web/static/js/al_games.js new file mode 100644 index 00000000..78bd738c --- /dev/null +++ b/Web/static/js/al_games.js @@ -0,0 +1,225 @@ +const perms = { + friends: [tr("appjs_sperm_friends"), tr("appjs_sperm_friends_desc")], + wall: [tr("appjs_sperm_wall"), tr("appjs_sperm_wall_desc")], + messages: [tr("appjs_sperm_messages"), tr("appjs_sperm_messages_desc")], + groups: [tr("appjs_sperm_groups"), tr("appjs_sperm_groups_desc")], + likes: [tr("appjs_sperm_likes"), tr("appjs_sperm_likes_desc")] +} + +function escapeHtml(unsafe) +{ + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function toQueryString(obj, prefix) { + var str = [], + p; + for (p in obj) { + if (obj.hasOwnProperty(p)) { + var k = prefix ? prefix + "[" + p + "]" : p, + v = obj[p]; + str.push((v !== null && typeof v === "object") ? + serialize(v, k) : + encodeURIComponent(k) + "=" + encodeURIComponent(v)); + } + } + return str.join("&"); +} + +function handleWallPostRequest(event) { + let mBoxContent = ` + ${tr("app")} ${window.appTitle} ${tr("appjs_wall_post_desc")}:
+

${escapeHtml(event.data.text)}

+ `; + + MessageBox(tr("appjs_wall_post"), mBoxContent, [tr("appjs_sperm_allow"), tr("appjs_sperm_deny")], [ + async () => { + let id = await API.Wall.newStatus(event.data.text); + event.source.postMessage({ + transaction: event.data.transaction, + ok: true, + post: { + id: id, + text: event.data.text + } + }, '*'); + }, + () => { + event.source.postMessage({ + transaction: event.data.transaction, + ok: false, + error: "User cancelled action" + }, '*'); + } + ]); +} + +async function handleVkApiRequest(event) { + let method = event.data.method; + if(!/^[a-z]+\.[a-z0-9]+$/.test(method)) { + event.source.postMessage({ + transaction: event.data.transaction, + ok: false, + error: "API Method name is invalid" + }, '*'); + return; + } + + let domain = method.split(".")[0]; + if(domain === "newsfeed") + domain = "wall"; + + if(!window.appPerms.includes(domain)) { + if(typeof perms[domain] === "undefined") { + event.source.postMessage({ + transaction: event.data.transaction, + ok: false, + error: "This API method is not supported" + }, '*'); + return; + } + + let dInfo = perms[domain]; + let allowed = false; + await (new Promise(r => { + MessageBox( + tr("appjs_sperm_request"), + `

${tr("app")} ${window.appTitle} ${tr("appjs_sperm_requests")} ${dInfo[0]}. ${tr("appjs_sperm_can")} ${dInfo[1]}.`, + [tr("appjs_sperm_allow"), tr("appjs_sperm_deny")], + [ + () => { + API.Apps.updatePermission(window.appId, domain, "yes").then(() => { + window.appPerms.push(domain); + allowed = true; + r(); + }); + }, + () => { + r(); + } + ] + ) + })); + + if(!allowed) { + event.source.postMessage({ + transaction: event.data.transaction, + ok: false, + error: "No permission to use this method" + }, '*'); + return; + } + } + + let params = toQueryString(event.data.params); + let apiResponse = await (await fetch("/method/" + method + "?auth_mechanism=roaming&" + params)).json(); + if(typeof apiResponse.error_code !== "undefined") { + event.source.postMessage({ + transaction: event.data.transaction, + ok: false, + error: apiResponse.error_code + ": " + apiResponse.error_msg + }, '*'); + return; + } + + event.source.postMessage({ + transaction: event.data.transaction, + ok: true, + response: apiResponse.response + }, '*'); +} + +function handlePayment(event) { + let payload = event.data; + if(payload.outSum < 0) { + event.source.postMessage({ + transaction: payload.transaction, + ok: false, + error: "negative sum" + }, '*'); + } + + MessageBox( + tr("appjs_payment"), + ` +

${tr("appjs_payment_intro")} ${window.appTitle}.
${tr("appjs_order_items")}: ${payload.description}

+

${tr("appjs_payment_total")}: ${payload.outSum} ${tr("points_count")}. + `, + [tr("appjs_payment_confirm"), tr("cancel")], + [ + async () => { + let sign; + try { + sign = await API.Apps.pay(window.appId, payload.outSum); + } catch(e) { + MessageBox(tr("error"), tr("appjs_err_funds"), ["OK"], [Function.noop]); + + event.source.postMessage({ + transaction: payload.transaction, + ok: false, + error: "Payment error[" + e.code + "]: " + e.message + }, '*'); + + return; + } + + event.source.postMessage({ + transaction: payload.transaction, + ok: true, + outSum: payload.outSum, + description: payload.description, + signature: sign + }, '*'); + }, + () => { + event.source.postMessage({ + transaction: payload.transaction, + ok: false, + error: "User cancelled payment" + }, '*'); + } + ] + ) +} + +async function onNewMessage(event) { + if(event.source !== appFrame.contentWindow) + return; + + let payload = event.data; + switch(payload["@type"]) { + case "VkApiRequest": + handleVkApiRequest(event); + break; + + case "WallPostRequest": + handleWallPostRequest(event); + break; + + case "PaymentRequest": + handlePayment(event); + break; + + case "UserInfoRequest": + event.source.postMessage({ + transaction: payload.transaction, + ok: true, + user: await API.Apps.getUserInfo() + }, '*'); + break; + + default: + event.source.postMessage({ + transaction: payload.transaction, + ok: false, + error: "Unknown query type" + }, '*'); + } +} + +window.addEventListener("message", onNewMessage); \ No newline at end of file diff --git a/install/sqls/00030-apps.sql b/install/sqls/00030-apps.sql new file mode 100644 index 00000000..c1e2125d --- /dev/null +++ b/install/sqls/00030-apps.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS `apps` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `owner` bigint unsigned NOT NULL, + `name` varchar(255) NOT NULL, + `description` text NOT NULL, + `avatar_hash` char(8) DEFAULT NULL, + `news` bigint DEFAULT NULL, + `address` varchar(1024) NOT NULL, + `coins` decimal(20,6) NOT NULL DEFAULT '0.000000', + `enabled` bit(1) NOT NULL DEFAULT b'0', + PRIMARY KEY (`id`), + KEY `owner` (`owner`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +CREATE TABLE IF NOT EXISTS `app_users` ( + `app` bigint unsigned NOT NULL, + `user` bigint unsigned NOT NULL, + `access` smallint unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`app`,`user`), + KEY `app` (`app`), + KEY `user` (`user`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; diff --git a/locales/ru.strings b/locales/ru.strings index af93c4db..c7e4dea3 100644 --- a/locales/ru.strings +++ b/locales/ru.strings @@ -72,6 +72,7 @@ "description" = "Описание"; "save" = "Сохранить"; "main_information" = "Основная информация"; +"additional_information" = "Дополнительная информация"; "nickname" = "Никнейм"; "online" = "Онлайн"; "was_online" = "был в сети"; @@ -753,6 +754,73 @@ "users_gifts" = "Подарки"; +/* Apps */ +"app" = "Приложение"; +"apps" = "Приложения"; +"all_apps" = "Все приложения"; +"installed_apps" = "Мои приложения"; +"own_apps" = "Управление"; +"own_apps_alternate" = "Мои приложения"; + +"app_play" = "запустить"; +"app_uninstall" = "отключить"; +"app_edit" = "редактировать"; +"app_dev" = "Разработчик"; + +"create_app" = "Создать приложение"; +"edit_app" = "Редактировать приложение"; +"new_app" = "Новое приложение"; +"app_news" = "Заметка с новостями"; +"app_state" = "Состояние"; +"app_enabled" = "Включено"; +"app_creation_hint_url" = "Укажите в URL точный адрес вместе со схемой (https), портом (80) и нужными параметрами запроса."; +"app_creation_hint_iframe" = "Ваше приложение будет открыто в iframe."; +"app_balance" = "На счету вашего приложения $1 голосов."; +"app_users" = "Вашим приложением пользуются $1 человек."; +"app_withdrawal_q" = "вывести?"; +"app_withdrawal" = "Вывод средств"; +"app_withdrawal_empty" = "Не удалось вывести пустоту, извините."; +"app_withdrawal_created" = "Заявка на вывод $1 голосов была создана. Ожидайте зачисления."; + +"appjs_payment" = "Оплата покупки"; +"appjs_payment_intro" = "Вы собираетесь оплатить заказ в приложении"; +"appjs_order_items" = "Состав заказа"; +"appjs_payment_total" = "Итоговая сумма к оплате"; +"appjs_payment_confirm" = "Оплатить"; +"appjs_err_funds" = "Не удалось оплатить покупку: недостаточно средств."; + +"appjs_wall_post" = "Опубликовать пост"; +"appjs_wall_post_desc" = "хочет опубликовать на вашей стене пост"; + +"appjs_sperm_friends" = "вашим Друзьям"; +"appjs_sperm_friends_desc" = "добавлять пользователей в друзья и читать ваш список друзей"; +"appjs_sperm_wall" = "вашей Стене"; +"appjs_sperm_wall_desc" = "смотреть ваши новости, вашу стену и создавать на ней посты"; +"appjs_sperm_messages" = "вашим Сообщениям"; +"appjs_sperm_messages_desc" = "читать и писать от вашего имени сообщения"; +"appjs_sperm_groups" = "вашим Сообществам"; +"appjs_sperm_groups_desc" = "смотреть список ваших групп и подписывать вас на другие"; +"appjs_sperm_likes" = "функционалу Лайков"; +"appjs_sperm_likes_desc" = "ставить и убирать отметки \"мне нравится\" с записей"; + +"appjs_sperm_request" = "Запрос доступа"; +"appjs_sperm_requests" = "запрашивает доступ к"; +"appjs_sperm_can" = "Приложение сможет"; +"appjs_sperm_allow" = "Разрешить"; +"appjs_sperm_disallow" = "Не разрешать"; + +"app_uninstalled" = "Приложение отключено"; +"app_uninstalled_desc" = "Оно больше не сможет выполнять действия от вашего имени."; +"app_err_not_found" = "Приложение не найдено"; +"app_err_not_found_desc" = "Некорректный идентифиактор или оно было отключено."; +"app_err_forbidden_desc" = "Это приложение не ваше."; +"app_err_url" = "Неправильный адрес"; +"app_err_url_desc" = "Адрес приложения не прошёл проверку, убедитесь что он указан правильно."; +"app_err_ava" = "Не удалось загрузить аватарку"; +"app_err_ava_desc" = "Аватарка слишком большая или кривая: ошибка общего характера №$res."; +"app_err_note" = "Не удалось прикрепить новостную заметку"; +"app_err_note_desc" = "Убедитесь что ссылка правильная и заметка принадлежит вам."; + /* Support */ "support_opened" = "Открытые"; diff --git a/openvk-example.yml b/openvk-example.yml index cceb2be3..32f03edd 100644 --- a/openvk-example.yml +++ b/openvk-example.yml @@ -19,6 +19,8 @@ openvk: - "index.php" photos: upgradeStructure: true + apps: + withdrawTax: 8 security: requireEmail: false requirePhone: false