Merge branch 'openvk:master' into bug-tracker

This commit is contained in:
n1rwana 2022-08-25 01:28:28 +03:00 committed by GitHub
commit a40caa6fa2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1439 additions and 9 deletions

87
ServiceAPI/Apps.php Normal file
View file

@ -0,0 +1,87 @@
<?php declare(strict_types=1);
namespace openvk\ServiceAPI;
use openvk\Web\Models\Entities\User;
use openvk\Web\Models\Repositories\Applications;
class Apps implements Handler
{
private $user;
private $apps;
public function __construct(?User $user)
{
$this->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);
}
}

View file

@ -1,5 +1,6 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
namespace openvk\ServiceAPI; namespace openvk\ServiceAPI;
use openvk\Web\Models\Entities\Post;
use openvk\Web\Models\Entities\User; use openvk\Web\Models\Entities\User;
use openvk\Web\Models\Repositories\Posts; use openvk\Web\Models\Repositories\Posts;
@ -55,4 +56,19 @@ class Wall implements Handler
$resolve((array) $res); $resolve((array) $res);
} }
function newStatus(string $text, callable $resolve, callable $reject): void
{
$post = new Post;
$post->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());
}
} }

42
VKAPI/Handlers/Pay.php Normal file
View file

@ -0,0 +1,42 @@
<?php declare(strict_types=1);
namespace openvk\VKAPI\Handlers;
use openvk\Web\Models\Repositories\Applications;
final class Pay extends VKAPIRequestHandler
{
function getIdByMarketingId(string $marketing_id): int
{
[$hexId, $signature] = explode("_", $marketing_id);
try {
$key = CHANDLER_ROOT_CONF["security"]["secret"];
if(sodium_memcmp(base64_decode($signature), hash_hmac("sha512/224", $hexId, $key, true)) == -1)
$this->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;
}
}

View file

@ -19,7 +19,7 @@ final class Wall extends VKAPIRequestHandler
$items = []; $items = [];
$profiles = []; $profiles = [];
$groups = []; $groups = [];
$count = $posts->getPostCountOnUserWall($owner_id); $cnt = $posts->getPostCountOnUserWall($owner_id);
$wallOnwer = (new UsersRepo)->get($owner_id); $wallOnwer = (new UsersRepo)->get($owner_id);
@ -143,15 +143,15 @@ final class Wall extends VKAPIRequestHandler
} }
return (object) [ return (object) [
"count" => $count, "count" => $cnt,
"items" => (array)$items, "items" => $items,
"profiles" => (array)$profilesFormatted, "profiles" => $profilesFormatted,
"groups" => (array)$groupsFormatted "groups" => $groupsFormatted
]; ];
} else } else
return (object) [ return (object) [
"count" => $count, "count" => $cnt,
"items" => (array)$items "items" => $items
]; ];
} }

View file

@ -0,0 +1,316 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Entities;
use Chandler\Database\DatabaseConnection;
use Nette\Utils\Image;
use Nette\Utils\UnknownImageFileException;
use openvk\Web\Models\Repositories\Notes;
use openvk\Web\Models\Repositories\Users;
use openvk\Web\Models\RowModel;
class Application extends RowModel
{
protected $tableName = "apps";
const PERMS = [
"notify",
"friends",
"photos",
"audio",
"video",
"stories",
"pages",
"status",
"notes",
"messages",
"wall",
"ads",
"docs",
"groups",
"notifications",
"stats",
"email",
"market",
];
private function getAvatarsDir(): string
{
$uploadSettings = OPENVK_ROOT_CONF["openvk"]["preferences"]["uploads"];
if($uploadSettings["mode"] === "server" && $uploadSettings["server"]["kind"] === "cdn")
return $uploadSettings["server"]["directory"];
else
return OPENVK_ROOT . "/storage/";
}
function getId(): int
{
return $this->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);
}
}

View file

@ -0,0 +1,69 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Repositories;
use Chandler\Database\DatabaseConnection;
use Nette\Database\Table\ActiveRow;
use openvk\Web\Models\Entities\Application;
use openvk\Web\Models\Entities\User;
class Applications
{
private $context;
private $apps;
private $appRels;
function __construct()
{
$this->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()));
}
}

View file

@ -102,6 +102,15 @@ final class AboutPresenter extends OpenVKPresenter
. "# covered from unauthorized persons (for example, due to\n" . "# covered from unauthorized persons (for example, due to\n"
. "# lack of rights to access the admin panel)\n\n" . "# lack of rights to access the admin panel)\n\n"
. "User-Agent: *\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: /rpc\n"
. "Disallow: /language\n" . "Disallow: /language\n"
. "Disallow: /badbrowser.php\n" . "Disallow: /badbrowser.php\n"

View file

@ -0,0 +1,138 @@
<?php declare(strict_types=1);
namespace openvk\Web\Presenters;
use openvk\Web\Models\Entities\Application;
use openvk\Web\Models\Repositories\Applications;
final class AppsPresenter extends OpenVKPresenter
{
private $apps;
function __construct(Applications $apps)
{
$this->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;
}
}

View file

@ -173,6 +173,7 @@
(<b>{$thisUser->getNotificationsCount()}</b>) (<b>{$thisUser->getNotificationsCount()}</b>)
{/if} {/if}
</a> </a>
<a href="/apps?act=installed" class="link">{_my_apps}</a>
<a href="/settings" class="link">{_my_settings}</a> <a href="/settings" class="link">{_my_settings}</a>
{var $canAccessAdminPanel = $thisUser->getChandlerUser()->can("access")->model("admin")->whichBelongsTo(NULL)} {var $canAccessAdminPanel = $thisUser->getChandlerUser()->can("access")->model("admin")->whichBelongsTo(NULL)}

View file

@ -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}
<a href="/apps?act=dev">{_own_apps_alternate}</a> »
<a href="/app{$id}">{$name}</a> »
{_edit}
{/if}
{/block}
{block content}
<div class="container_gray">
<h4>{_main_information}</h4>
<form method="POST" enctype="multipart/form-data">
<table cellspacing="7" cellpadding="0" width="60%" border="0" align="center">
<tbody>
<tr>
<td width="120" valign="top">
<span class="nobold">{_name}: </span>
</td>
<td>
<input type="text" name="name" value="{$name ?? ''}" />
</td>
</tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_description}: </span>
</td>
<td>
<input type="text" name="desc" value="{$desc ?? ''}" />
</td>
</tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_avatar}: </span>
</td>
<td>
<input type="file" name="ava" accept="image/*" />
</td>
</tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_app_news}: </span>
</td>
<td>
<input type="text" name="note" placeholder="{ovk_scheme(true) . $_SERVER['HTTP_HOST']}/note{$thisUser->getId()}_10" value="{$note ?? ''}" />
</td>
</tr>
<tr>
<td width="120" valign="top">
<span class="nobold">URL: </span>
</td>
<td>
<input type="text" name="url" value="{$url ?? ''}" />
</td>
</tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_app_state}: </span>
</td>
<td>
<input type="checkbox" name="enable" n:attr="checked => ($on ?? false)" /> {_app_enabled}
</td>
</tr>
<tr>
<td>
</td>
<td>
<input type="hidden" name="hash" value="{$csrfToken}" />
<input type="submit" value="{_save}" class="button" />
</td>
</tr>
</tbody>
</table>
</form>
<br/>
<h4>{_additional_information}</h4>
<div>
<ul style="color: unset;">
{if $create}
<li>{_app_creation_hint_url}</li>
<li>{_app_creation_hint_iframe}</li>
{else}
<li>{tr("app_balance", $coins)|noescape} (<a href="javascript:withdraw({$id})">{_app_withdrawal_q}</a>)</li>
<li>{tr("app_users", $users)|noescape}</li>
{/if}
</ul>
</div>
</div>
<script>
async function withdraw(id) {
let coins = await API.Apps.withdrawFunds(id);
if(coins == 0)
MessageBox({_app_withdrawal}, {_app_withdrawal_empty}, ["OK"], [Function.noop]);
else
MessageBox({_app_withdrawal}, {tr("app_withdrawal_created", $coins)}, ["OK"], [Function.noop]);
}
</script>
{/block}

View file

@ -0,0 +1,53 @@
{extends "../@listView.xml"}
{block title}
{_apps}
{/block}
{block header}
{_apps}
<div n:if="$act === 'dev'" style="float: right;">
<span><b>
<a href="/editapp?act=create">{_create}</a>
</b></span>
</div>
{/block}
{block tabs}
<div n:attr="id => ($act === 'list' ? 'activetabs' : 'ki')" class="tab">
<a n:attr="id => ($act === 'list' ? 'act_tab_a' : 'ki')" href="?act=list">{_all_apps}</a>
</div>
<div n:attr="id => ($act === 'installed' ? 'activetabs' : 'ki')" class="tab">
<a n:attr="id => ($act === 'installed' ? 'act_tab_a' : 'ki')" href="?act=installed">{_installed_apps}</a>
</div>
<div n:attr="id => ($act === 'dev' ? 'activetabs' : 'ki')" class="tab">
<a n:attr="id => ($act === 'dev' ? 'act_tab_a' : 'ki')" href="?act=dev">{_own_apps}</a>
</div>
{/block}
{* BEGIN ELEMENTS DESCRIPTION *}
{block link|strip|stripHtml}
/app{$x->getId()}
{/block}
{block preview}
<img style="max-width: 75px;" src="{$x->getAvatarUrl()}" />
{/block}
{block name}
{$x->getName()}
{/block}
{block description}
{$x->getDescription()}
{/block}
{block actions}
<a href="/app{$x->getId()}" class="profile_link">{_app_play}</a>
<a n:if="$x->isInstalledBy($thisUser)" href="/apps/uninstall?hash={rawurlencode($csrfToken)}&app={$x->getId()}" class="profile_link">{_app_uninstall}</a>
<a n:if="$thisUser->getId() == $x->getOwner()->getId()" href="/editapp?app={$x->getId()}" class="profile_link">{_app_edit}</a>
{/block}

View file

@ -0,0 +1,37 @@
{extends "../@layout.xml"}
{block title}
{$name}
{/block}
{block header}
{$name}
{/block}
{block content}
<center>
<iframe id="appFrame" referrerpolicy="unsafe-url" sandbox="allow-scripts" frameBorder="0" src="{$url}" height="600" width="600"></iframe>
</center>
<div n:if="!is_null($news)" id="news">
<h4>{$news->getName()}</h4>
<div id="app_news_container">
{$news->getText()|noescape}
</div>
</div>
<center>
<p>
{_app_dev}: <a href="{$owner->getURL()}">{$owner->getFullName()}</a>
</p>
</center>
<script>
window.appId = {$id};
window.appTitle = {$name};
window.appPerms = {$perms};
window.appOrigin = {$origin};
</script>
{script "js/al_games.js"}
{/block}

View file

@ -21,6 +21,7 @@ services:
- openvk\Web\Presenters\GiftsPresenter - openvk\Web\Presenters\GiftsPresenter
- openvk\Web\Presenters\MessengerPresenter - openvk\Web\Presenters\MessengerPresenter
- openvk\Web\Presenters\TopicsPresenter - openvk\Web\Presenters\TopicsPresenter
- openvk\Web\Presenters\AppsPresenter
- openvk\Web\Presenters\ThemepacksPresenter - openvk\Web\Presenters\ThemepacksPresenter
- openvk\Web\Presenters\VKAPIPresenter - openvk\Web\Presenters\VKAPIPresenter
- openvk\Web\Models\Repositories\Users - openvk\Web\Models\Repositories\Users
@ -44,4 +45,5 @@ services:
- openvk\Web\Models\Repositories\Vouchers - openvk\Web\Models\Repositories\Vouchers
- openvk\Web\Models\Repositories\Gifts - openvk\Web\Models\Repositories\Gifts
- openvk\Web\Models\Repositories\Topics - openvk\Web\Models\Repositories\Topics
- openvk\Web\Models\Repositories\Applications
- openvk\Web\Models\Repositories\ContentSearchRepository - openvk\Web\Models\Repositories\ContentSearchRepository

View file

@ -283,6 +283,14 @@ routes:
handler: "Gifts->userGifts" handler: "Gifts->userGifts"
- url: "/gifts" - url: "/gifts"
handler: "Gifts->stub" 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" - url: "/admin"
handler: "Admin->index" handler: "Admin->index"
- url: "/admin/users" - url: "/admin/users"

View file

@ -2044,6 +2044,13 @@ table td[width="120"] {
height: 11px; height: 11px;
} }
#app_news_container {
margin-bottom: 30px;
overflow-x: hidden;
overflow-y: auto;
max-height: 250px;
}
@keyframes appearing { @keyframes appearing {
from { from {
opacity: 0; opacity: 0;

54
Web/static/js/GameAPI.js Normal file
View file

@ -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
});
}

225
Web/static/js/al_games.js Normal file
View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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 = `
<b>${tr("app")} <i>${window.appTitle}</i> ${tr("appjs_wall_post_desc")}:</b><br/>
<p style="padding: 8px; border: 1px solid gray;">${escapeHtml(event.data.text)}</p>
`;
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"),
`<p>${tr("app")} <b>${window.appTitle}</b> ${tr("appjs_sperm_requests")} <b>${dInfo[0]}</b>. ${tr("appjs_sperm_can")} <b>${dInfo[1]}</b>.`,
[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"),
`
<p>${tr("appjs_payment_intro")} <b>${window.appTitle}</b>.<br/>${tr("appjs_order_items")}: <b>${payload.description}</b></p>
<p>${tr("appjs_payment_total")}: <big><b>${payload.outSum}</b></big> ${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);

View file

@ -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;

View file

@ -72,6 +72,7 @@
"description" = "Description"; "description" = "Description";
"save" = "Save"; "save" = "Save";
"main_information" = "Main information"; "main_information" = "Main information";
"additional_information" = "Additional information";
"nickname" = "Nickname"; "nickname" = "Nickname";
"online" = "Online"; "online" = "Online";
"was_online" = "was online"; "was_online" = "was online";
@ -708,6 +709,74 @@
"users_gifts" = "Gifts"; "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 <b>$1</b> votes to its credit.";
"app_users" = "Your application is used by <b>$1</b> 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 */
"support_opened" = "Opened"; "support_opened" = "Opened";

View file

@ -72,6 +72,7 @@
"description" = "Описание"; "description" = "Описание";
"save" = "Сохранить"; "save" = "Сохранить";
"main_information" = "Основная информация"; "main_information" = "Основная информация";
"additional_information" = "Дополнительная информация";
"nickname" = "Никнейм"; "nickname" = "Никнейм";
"online" = "Онлайн"; "online" = "Онлайн";
"was_online" = "был в сети"; "was_online" = "был в сети";
@ -753,6 +754,74 @@
"users_gifts" = "Подарки"; "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" = "На счету вашего приложения <b>$1</b> голосов.";
"app_users" = "Вашим приложением пользуются <b>$1</b> человек.";
"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 */
"support_opened" = "Открытые"; "support_opened" = "Открытые";

View file

@ -74,6 +74,7 @@
"description" = "Опис"; "description" = "Опис";
"save" = "Зберегти"; "save" = "Зберегти";
"main_information" = "Основна інформація"; "main_information" = "Основна інформація";
"additional_information" = "Додаткова інформація";
"nickname" = "Нікнейм"; "nickname" = "Нікнейм";
"online" = "Онлайн"; "online" = "Онлайн";
"was_online" = "був у мережі"; "was_online" = "був у мережі";
@ -384,6 +385,11 @@
"edit_note" = "Редагувати нотатку"; "edit_note" = "Редагувати нотатку";
"actions" = "Дії"; "actions" = "Дії";
"notes_start_screen" = "За допомогою нотаток Ви можете ділитися подіями з життя з друзями, а також бути в курсі того, що відбувається у них."; "notes_start_screen" = "За допомогою нотаток Ви можете ділитися подіями з життя з друзями, а також бути в курсі того, що відбувається у них.";
"note_preview" = "Попередній перегляд";
"note_preview_warn" = "Увага: Це всього лише попередній перегляд";
"note_preview_warn_details" = "Після збереження, нотатки можуть мати інший вигляд. Також не викликайте перегляд занадто часто.";
"note_preview_empty_err" = "Попередній перегляд неможливий: Немає імені чи змісту.";
"edited" = "Відредаговано"; "edited" = "Відредаговано";
"notes_zero" = "Жодної нотатки"; "notes_zero" = "Жодної нотатки";
@ -557,9 +563,9 @@
"two_factor_authentication_login" = "У вас увімкнена двофакторна автентифікація. Для входу введіть код отриманий в додатку."; "two_factor_authentication_login" = "У вас увімкнена двофакторна автентифікація. Для входу введіть код отриманий в додатку.";
"two_factor_authentication_settings_1" = "Двофакторну автентифікацію через TOTP можна використовувати навіть без інтернету. Для цього вам знадобиться застосунок для генерації кодів, наприклад Google Authenticator для Android та iOS або відкриті <b>Aegis Authenticator чи andOTP</b> для Android. Важливо: на пристрої має бути точна дата та час."; "two_factor_authentication_settings_1" = "Двофакторну автентифікацію через TOTP можна використовувати навіть без інтернету. Для цього вам знадобиться застосунок для генерації кодів, наприклад Google Authenticator для Android та iOS або відкриті <b>Aegis Authenticator чи andOTP</b> для Android. Важливо: на пристрої має бути точна дата та час.";
"two_factor_authentication_settings_2" = "Використовуючи додаток для двофакторної автентифікації, відскануйте наведений нижче QR-код:"; "two_factor_authentication_settings_2" = "Використовуючи застосунок для двофакторної автентифікації, відскануйте наведений нижче QR-код:";
"two_factor_authentication_settings_3" = "або вручну введіть секретний ключ: <b>$1</b>."; "two_factor_authentication_settings_3" = "або вручну введіть секретний ключ: <b>$1</b>.";
"two_factor_authentication_settings_4" = "Тепер введіть код, який вам надав додаток, і пароль від вашої сторінки, щоб ми могли підтвердити, що ви дійсно ви."; "two_factor_authentication_settings_4" = "Тепер введіть код, який вам надав застосунок, і пароль від вашої сторінки, щоб ми могли підтвердити, що ви дійсно ви.";
"connect" = "Підключити"; "connect" = "Підключити";
"enable" = "Включити"; "enable" = "Включити";
@ -749,6 +755,74 @@
"users_gifts" = "Подарунки"; "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" = "На рахунку вашого застосунку <b>$1</b> голосів.";
"app_users" = "Вашим застосунком користуються <b>$1</b> користувачів.";
"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 */
"support_opened" = "Відкриті"; "support_opened" = "Відкриті";

View file

@ -19,6 +19,8 @@ openvk:
- "index.php" - "index.php"
photos: photos:
upgradeStructure: true upgradeStructure: true
apps:
withdrawTax: 8
security: security:
requireEmail: false requireEmail: false
requirePhone: false requirePhone: false

View file

@ -230,3 +230,21 @@ input[type=checkbox] {
#votesBalance { #votesBalance {
border-bottom: none; 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;
}