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/VKAPI/Handlers/Wall.php b/VKAPI/Handlers/Wall.php index cb4720dc..c669cd02 100644 --- a/VKAPI/Handlers/Wall.php +++ b/VKAPI/Handlers/Wall.php @@ -19,7 +19,7 @@ final class Wall extends VKAPIRequestHandler $items = []; $profiles = []; $groups = []; - $count = $posts->getPostCountOnUserWall($owner_id); + $cnt = $posts->getPostCountOnUserWall($owner_id); $wallOnwer = (new UsersRepo)->get($owner_id); @@ -143,15 +143,15 @@ final class Wall extends VKAPIRequestHandler } return (object) [ - "count" => $count, - "items" => (array)$items, - "profiles" => (array)$profilesFormatted, - "groups" => (array)$groupsFormatted + "count" => $cnt, + "items" => $items, + "profiles" => $profilesFormatted, + "groups" => $groupsFormatted ]; } else return (object) [ - "count" => $count, - "items" => (array)$items + "count" => $cnt, + "items" => $items ]; } diff --git a/Web/Models/Entities/Application.php b/Web/Models/Entities/Application.php new file mode 100644 index 00000000..74569485 --- /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("adler32", $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/AboutPresenter.php b/Web/Presenters/AboutPresenter.php index 8e493bc5..6a281d07 100644 --- a/Web/Presenters/AboutPresenter.php +++ b/Web/Presenters/AboutPresenter.php @@ -102,6 +102,15 @@ final class AboutPresenter extends OpenVKPresenter . "# covered from unauthorized persons (for example, due to\n" . "# lack of rights to access the admin panel)\n\n" . "User-Agent: *\n" + . "Disallow: /albums/create\n" + . "Disallow: /videos/upload\n" + . "Disallow: /invite\n" + . "Disallow: /groups_create\n" + . "Disallow: /notifications\n" + . "Disallow: /settings\n" + . "Disallow: /edit\n" + . "Disallow: /gifts\n" + . "Disallow: /support\n" . "Disallow: /rpc\n" . "Disallow: /language\n" . "Disallow: /badbrowser.php\n" 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/@layout.xml b/Web/Presenters/templates/@layout.xml index ebd5fb8a..e717acfe 100644 --- a/Web/Presenters/templates/@layout.xml +++ b/Web/Presenters/templates/@layout.xml @@ -173,6 +173,7 @@ ({$thisUser->getNotificationsCount()}) {/if} + {_my_apps} {_my_settings} {var $canAccessAdminPanel = $thisUser->getChandlerUser()->can("access")->model("admin")->whichBelongsTo(NULL)} 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} +
+ {_app_dev}: {$owner->getFullName()} +
+${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/en.strings b/locales/en.strings index 9e7478e0..c92e6143 100644 --- a/locales/en.strings +++ b/locales/en.strings @@ -72,6 +72,7 @@ "description" = "Description"; "save" = "Save"; "main_information" = "Main information"; +"additional_information" = "Additional information"; "nickname" = "Nickname"; "online" = "Online"; "was_online" = "was online"; @@ -708,6 +709,74 @@ "users_gifts" = "Gifts"; +/* Apps */ +"app" = "Application"; +"apps" = "Applications"; +"my_apps" = "My Apps"; +"all_apps" = "All apps"; +"installed_apps" = "Installed apps"; +"own_apps" = "Own apps"; +"own_apps_alternate" = "My apps"; + +"app_play" = "start"; +"app_uninstall" = "uninstall"; +"app_edit" = "edit"; +"app_dev" = "Developer"; + +"create_app" = "Create an application"; +"edit_app" = "Edit an application"; +"new_app" = "New application"; +"app_news" = "A news note"; +"app_state" = "Status"; +"app_enabled" = "Enabled"; +"app_creation_hint_url" = "Specify in the URL the exact address together with the scheme (https), the port (80) and the required request parameters."; +"app_creation_hint_iframe" = "Your application will be opened in an iframe."; +"app_balance" = "Your application has $1 votes to its credit."; +"app_users" = "Your application is used by $1 people."; +"app_withdrawal_q" = "withdraw?"; +"app_withdrawal" = "Withdrawal"; +"app_withdrawal_empty" = "Couldn't withdraw emptiness, sorry."; +"app_withdrawal_created" = "A request to withdraw $1 votes has been created. Awaiting crediting."; + +"appjs_payment" = "Purchase payment"; +"appjs_payment_intro" = "You are about to pay for an order in the application"; +"appjs_order_items" = "Order items"; +"appjs_payment_total" = "Total amount payable"; +"appjs_payment_confirm" = "Pay"; +"appjs_err_funds" = "Failed to pay: insufficient funds."; + +"appjs_wall_post" = "Publish a post"; +"appjs_wall_post_desc" = "wants to publish a post on your wall"; + +"appjs_sperm_friends" = "your Friends"; +"appjs_sperm_friends_desc" = "add users as friends and read your friends list"; +"appjs_sperm_wall" = "your Wall"; +"appjs_sperm_wall_desc" = "see your news, your wall and create posts on it"; +"appjs_sperm_messages" = "your Messages"; +"appjs_sperm_messages_desc" = "read and write messages on your behalf"; +"appjs_sperm_groups" = "your Groups"; +"appjs_sperm_groups_desc" = "see a list of your groups and subscribe you to other"; +"appjs_sperm_likes" = "Likes feature"; +"appjs_sperm_likes_desc" = "give and take away likes to posts"; + +"appjs_sperm_request" = "Access request"; +"appjs_sperm_requests" = "requests access to"; +"appjs_sperm_can" = "The app will be able to"; +"appjs_sperm_allow" = "Allow"; +"appjs_sperm_disallow" = "Disallow"; + +"app_uninstalled" = "Application is disabled"; +"app_uninstalled_desc" = "It will no longer be able to perform actions on your behalf."; +"app_err_not_found" = "Application not found"; +"app_err_not_found_desc" = "Incorrect identifier or it has been disabled."; +"app_err_forbidden_desc" = "This application is not yours."; +"app_err_url" = "Incorrect address"; +"app_err_url_desc" = "The address of the application did not pass the check, make sure it is correct."; +"app_err_ava" = "Unable to upload an avatar"; +"app_err_ava_desc" = "Avatar too big or wrong: general error #$res."; +"app_err_note" = "Failed to attach a news note"; +"app_err_note_desc" = "Make sure the link is correct and the note belongs to you."; + /* Support */ "support_opened" = "Opened"; diff --git a/locales/ru.strings b/locales/ru.strings index c6ec5911..2e59d2a2 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,74 @@ "users_gifts" = "Подарки"; +/* Apps */ +"app" = "Приложение"; +"apps" = "Приложения"; +"my_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/locales/ua.strings b/locales/ua.strings index d4f31e8a..c0dcf8a2 100644 --- a/locales/ua.strings +++ b/locales/ua.strings @@ -74,6 +74,7 @@ "description" = "Опис"; "save" = "Зберегти"; "main_information" = "Основна інформація"; +"additional_information" = "Додаткова інформація"; "nickname" = "Нікнейм"; "online" = "Онлайн"; "was_online" = "був у мережі"; @@ -384,6 +385,11 @@ "edit_note" = "Редагувати нотатку"; "actions" = "Дії"; "notes_start_screen" = "За допомогою нотаток Ви можете ділитися подіями з життя з друзями, а також бути в курсі того, що відбувається у них."; +"note_preview" = "Попередній перегляд"; +"note_preview_warn" = "Увага: Це всього лише попередній перегляд"; +"note_preview_warn_details" = "Після збереження, нотатки можуть мати інший вигляд. Також не викликайте перегляд занадто часто."; +"note_preview_empty_err" = "Попередній перегляд неможливий: Немає імені чи змісту."; + "edited" = "Відредаговано"; "notes_zero" = "Жодної нотатки"; @@ -557,9 +563,9 @@ "two_factor_authentication_login" = "У вас увімкнена двофакторна автентифікація. Для входу введіть код отриманий в додатку."; "two_factor_authentication_settings_1" = "Двофакторну автентифікацію через TOTP можна використовувати навіть без інтернету. Для цього вам знадобиться застосунок для генерації кодів, наприклад Google Authenticator для Android та iOS або відкриті Aegis Authenticator чи andOTP для Android. Важливо: на пристрої має бути точна дата та час."; -"two_factor_authentication_settings_2" = "Використовуючи додаток для двофакторної автентифікації, відскануйте наведений нижче QR-код:"; +"two_factor_authentication_settings_2" = "Використовуючи застосунок для двофакторної автентифікації, відскануйте наведений нижче QR-код:"; "two_factor_authentication_settings_3" = "або вручну введіть секретний ключ: $1."; -"two_factor_authentication_settings_4" = "Тепер введіть код, який вам надав додаток, і пароль від вашої сторінки, щоб ми могли підтвердити, що ви дійсно ви."; +"two_factor_authentication_settings_4" = "Тепер введіть код, який вам надав застосунок, і пароль від вашої сторінки, щоб ми могли підтвердити, що ви дійсно ви."; "connect" = "Підключити"; "enable" = "Включити"; @@ -749,6 +755,74 @@ "users_gifts" = "Подарунки"; +/* Apps */ +"app" = "Застосунок"; +"apps" = "Застосунки"; +"my_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" = "Виведення коштів"; +"app_withdrawal_q" = "вивести?"; +"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 diff --git a/themepacks/openvk_modern/stylesheet.css b/themepacks/openvk_modern/stylesheet.css index d19da3b5..590710e4 100644 --- a/themepacks/openvk_modern/stylesheet.css +++ b/themepacks/openvk_modern/stylesheet.css @@ -230,3 +230,21 @@ input[type=checkbox] { #votesBalance { border-bottom: none; } + +.floating_sidebar { + position: fixed; + top: 50px; + right: 0; + align-items: start; + width: 21%; +} +.minilink .counter { + background-color: #2B587A; + margin: 0; + padding: 0.5px 2px; + left: 10px; + font-size: 7px; + color: #fff; + position: absolute; + margin-top: -6px; +}