diff --git a/README.md b/README.md index 152e05bf..934ff0b7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ If you want, you can add your instance to the list above so that people can regi 1. Install PHP 7.4, web-server, Composer, Node.js, Yarn and [Chandler](https://github.com/openvk/chandler) -* PHP 8.1 is supported too, however it was not tested carefully, so be aware. +* PHP 8 is still being tested; the functionality of the engine on this version of PHP is not yet guaranteed. 2. Install MySQL-compatible database. diff --git a/README_RU.md b/README_RU.md index cc4f672f..7de91c39 100644 --- a/README_RU.md +++ b/README_RU.md @@ -30,7 +30,7 @@ _[English](README.md)_ 1. Установите PHP 7.4, веб-сервер, Composer, Node.js, Yarn и [Chandler](https://github.com/openvk/chandler) -* PHP 8 еще **не** тестировался, поэтому не стоит ожидать, что он будет работать (UPD: он не работает). +* PHP 8 пока ещё тестируется, работоспособность движка на этой версии PHP пока не гарантируется. 2. Установите MySQL-совместимую базу данных. diff --git a/VKAPI/Handlers/Audio.php b/VKAPI/Handlers/Audio.php index 3fa68e72..413a2a3a 100644 --- a/VKAPI/Handlers/Audio.php +++ b/VKAPI/Handlers/Audio.php @@ -1,22 +1,788 @@ fail(0404, "Audio not found"); + else if(!$audio->canBeViewedBy($this->getUser())) + $this->fail(201, "Access denied to audio(" . $audio->getPrettyId() . ")"); + + # рофлан ебало + $privApi = $hash && $GLOBALS["csrfCheck"]; + $audioObj = $audio->toVkApiStruct($this->getUser()); + if(!$privApi) { + $audioObj->manifest = false; + $audioObj->keys = false; + } + + if($need_user) { + $user = (new \openvk\Web\Models\Repositories\Users)->get($audio->getOwner()->getId()); + $audioObj->user = (object) [ + "id" => $user->getId(), + "photo" => $user->getAvatarUrl(), + "name" => $user->getCanonicalName(), + "name_gen" => $user->getCanonicalName(), + ]; + } + + return $audioObj; + } + + private function streamToResponse(EntityStream $es, int $offset, int $count, ?string $hash = NULL): object + { + $items = []; + foreach($es->offsetLimit($offset, $count) as $audio) { + $items[] = $this->toSafeAudioStruct($audio, $hash); + } + + return (object) [ + "count" => sizeof($items), + "items" => $items, + ]; + } + + private function validateGenre(?string& $genre_str, ?int $genre_id): void + { + if(!is_null($genre_str)) { + if(!in_array($genre_str, AEntity::genres)) + $this->fail(8, "Invalid genre_str"); + } else if(!is_null($genre_id)) { + $genre_str = array_flip(AEntity::vkGenres)[$genre_id] ?? NULL; + if(!$genre_str) + $this->fail(8, "Invalid genre ID $genre_id"); + } + } + + private function audioFromAnyId(string $id): ?AEntity + { + $descriptor = explode("_", $id); + if(sizeof($descriptor) === 1) { + if(ctype_digit($descriptor[0])) { + $audio = (new Audios)->get((int) $descriptor[0]); + } else { + $aid = base64_decode($descriptor[0], true); + if(!$aid) + $this->fail(8, "Invalid audio $id"); + + $audio = (new Audios)->get((int) $aid); + } + } else if(sizeof($descriptor) === 2) { + $audio = (new Audios)->getByOwnerAndVID((int) $descriptor[0], (int) $descriptor[1]); + } else { + $this->fail(8, "Invalid audio $id"); + } + + return $audio; + } + + function getById(string $audios, ?string $hash = NULL, int $need_user = 0): object + { + $this->requireUser(); + + $audioIds = array_unique(explode(",", $audios)); + if(sizeof($audioIds) === 1) { + $audio = $this->audioFromAnyId($audioIds[0]); + + return (object) [ + "count" => 1, + "items" => [ + $this->toSafeAudioStruct($audio, $hash, (bool) $need_user), + ], + ]; + } else if(sizeof($audioIds) > 6000) { + $this->fail(1980, "Can't get more than 6000 audios at once"); + } + + $audios = []; + foreach($audioIds as $id) + $audios[] = $this->getById($id, $hash)->items[0]; + + return (object) [ + "count" => sizeof($audios), + "items" => $audios, + ]; + } + + function isLagtrain(string $audio_id): int + { + $this->requireUser(); + + $audio = $this->audioFromAnyId($audio_id); + if(!$audio) + $this->fail(0404, "Audio not found"); + + # Possible information disclosure risks are acceptable :D + return (int) (strpos($audio->getName(), "Lagtrain") !== false); + } + + // TODO stub + function getRecommendations(): object + { + return (object) [ + "count" => 0, + "items" => [], + ]; + } + + function getPopular(?int $genre_id = NULL, ?string $genre_str = NULL, int $offset = 0, int $count = 100, ?string $hash = NULL): object + { + $this->requireUser(); + $this->validateGenre($genre_str, $genre_id); + + $results = (new Audios)->getGlobal(Audios::ORDER_POPULAR, $genre_str); + + return $this->streamToResponse($results, $offset, $count, $hash); + } + + function getFeed(?int $genre_id = NULL, ?string $genre_str = NULL, int $offset = 0, int $count = 100, ?string $hash = NULL): object + { + $this->requireUser(); + $this->validateGenre($genre_str, $genre_id); + + $results = (new Audios)->getGlobal(Audios::ORDER_NEW, $genre_str); + + return $this->streamToResponse($results, $offset, $count, $hash); + } + + function search(string $q, int $auto_complete = 0, int $lyrics = 0, int $performer_only = 0, int $sort = 2, int $search_own = 0, int $offset = 0, int $count = 30, ?string $hash = NULL): object + { + $this->requireUser(); + + if(($auto_complete + $search_own) != 0) + $this->fail(10, "auto_complete and search_own are not supported"); + else if($count > 300 || $count < 1) + $this->fail(8, "count is invalid: $count"); + + $results = (new Audios)->search($q, $sort, (bool) $performer_only, (bool) $lyrics); + + return $this->streamToResponse($results, $offset, $count, $hash); + } + + function getCount(int $owner_id, int $uploaded_only = 0): int + { + $this->requireUser(); + + if($owner_id < 0) { + $owner_id *= -1; + $group = (new Clubs)->get($owner_id); + if(!$group) + $this->fail(0404, "Group not found"); + + return (new Audios)->getClubCollectionSize($group); + } + + $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id); + if(!$user) + $this->fail(0404, "User not found"); + + if(!$user->getPrivacyPermission("audios.read", $this->getUser())) + $this->fail(15, "Access denied"); + + if($uploaded_only) { + return DatabaseConnection::i()->getContext()->table("audios") + ->where([ + "deleted" => false, + "owner" => $owner_id, + ])->count(); + } + + return (new Audios)->getUserCollectionSize($user); + } + + function get(int $owner_id = 0, int $album_id = 0, string $audio_ids = '', int $need_user = 1, int $offset = 0, int $count = 100, int $uploaded_only = 0, int $need_seed = 0, ?string $shuffle_seed = NULL, int $shuffle = 0, ?string $hash = NULL): object { - $serverUrl = ovk_scheme(true) . $_SERVER["SERVER_NAME"]; - - return (object) [ - "count" => 1, - "items" => [(object) [ - "id" => 1, - "owner_id" => 1, - "artist" => "В ОВК ПОКА НЕТ МУЗЫКИ", - "title" => "ЖДИТЕ :)))", - "duration" => 22, - "url" => $serverUrl . "/assets/packages/static/openvk/audio/nomusic.mp3" - ]] - ]; + $this->requireUser(); + + $shuffleSeed = NULL; + $shuffleSeedStr = NULL; + if($shuffle == 1) { + if(!$shuffle_seed) { + if($need_seed == 1) { + $shuffleSeed = openssl_random_pseudo_bytes(6); + $shuffleSeedStr = base64_encode($shuffleSeed); + $shuffleSeed = hexdec(bin2hex($shuffleSeed)); + } else { + $hOffset = ((int) date("i") * 60) + (int) date("s"); + $thisHour = time() - $hOffset; + $shuffleSeed = $thisHour + $this->getUser()->getId(); + $shuffleSeedStr = base64_encode(hex2bin(dechex($shuffleSeed))); + } + } else { + $shuffleSeed = hexdec(bin2hex(base64_decode($shuffle_seed))); + $shuffleSeedStr = $shuffle_seed; + } + } + + if($album_id != 0) { + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "album_id invalid"); + else if(!$album->canBeViewedBy($this->getUser())) + $this->fail(600, "Can't open this album for reading"); + + $songs = []; + $list = $album->getAudios($offset, $count, $shuffleSeed); + + foreach($list as $song) + $songs[] = $this->toSafeAudioStruct($song, $hash, $need_user == 1); + + $response = (object) [ + "count" => sizeof($songs), + "items" => $songs, + ]; + if(!is_null($shuffleSeed)) + $response->shuffle_seed = $shuffleSeedStr; + + return $response; + } + + if(!empty($audio_ids)) { + $audio_ids = explode(",", $audio_ids); + if(!$audio_ids) + $this->fail(10, "Audio::get@L0d186:explode(string): Unknown error"); + else if(sizeof($audio_ids) < 1) + $this->fail(8, "Invalid audio_ids syntax"); + + if(!is_null($shuffleSeed)) + $audio_ids = knuth_shuffle($audio_ids, $shuffleSeed); + + $obj = $this->getById(implode(",", $audio_ids), $hash, $need_user); + if(!is_null($shuffleSeed)) + $obj->shuffle_seed = $shuffleSeedStr; + + return $obj; + } + + $dbCtx = DatabaseConnection::i()->getContext(); + if($uploaded_only == 1) { + if($owner_id <= 0) + $this->fail(8, "uploaded_only can only be used with owner_id > 0"); + + $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id); + + if(!$user) + $this->fail(0602, "Invalid user"); + + if(!$user->getPrivacyPermission("audios.read", $this->getUser())) + $this->fail(15, "Access denied: this user chose to hide his audios"); + + if(!is_null($shuffleSeed)) { + $audio_ids = []; + $query = $dbCtx->table("audios")->select("virtual_id")->where([ + "owner" => $owner_id, + "deleted" => 0, + ]); + + foreach($query as $res) + $audio_ids[] = $res->virtual_id; + + $audio_ids = knuth_shuffle($audio_ids, $shuffleSeed); + $audio_ids = array_slice($audio_ids, $offset, $count); + $audio_q = ""; # audio.getById query + foreach($audio_ids as $aid) + $audio_q .= ",$owner_id" . "_$aid"; + + $obj = $this->getById(substr($audio_q, 1), $hash, $need_user); + $obj->shuffle_seed = $shuffleSeedStr; + + return $obj; + } + + $res = (new Audios)->getByUploader((new \openvk\Web\Models\Repositories\Users)->get($owner_id)); + + return $this->streamToResponse($res, $offset, $count, $hash, $need_user); + } + + $query = $dbCtx->table("audio_relations")->select("audio")->where("entity", $owner_id); + if(!is_null($shuffleSeed)) { + $audio_ids = []; + foreach($query as $aid) + $audio_ids[] = $aid->audio; + + $audio_ids = knuth_shuffle($audio_ids, $shuffleSeed); + $audio_ids = array_slice($audio_ids, $offset, $count); + $audio_q = ""; + foreach($audio_ids as $aid) + $audio_q .= ",$aid"; + + $obj = $this->getById(substr($audio_q, 1), $hash, $need_user); + $obj->shuffle_seed = $shuffleSeedStr; + + return $obj; + } + + $items = []; + + if($owner_id > 0) { + $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id); + + if(!$user) + $this->fail(50, "Invalid user"); + + if(!$user->getPrivacyPermission("audios.read", $this->getUser())) + $this->fail(15, "Access denied: this user chose to hide his audios"); + } + + $audios = (new Audios)->getByEntityID($owner_id, $offset, $count); + foreach($audios as $audio) + $items[] = $this->toSafeAudioStruct($audio, $hash, $need_user == 1); + + return (object) [ + "count" => sizeof($items), + "items" => $items, + ]; } + + function getLyrics(int $lyrics_id): object + { + $this->requireUser(); + + $audio = (new Audios)->get($lyrics_id); + if(!$audio || !$audio->getLyrics()) + $this->fail(0404, "Not found"); + + if(!$audio->canBeViewedBy($this->getUser())) + $this->fail(201, "Access denied to lyrics"); + + return (object) [ + "lyrics_id" => $lyrics_id, + "text" => preg_replace("%\r\n?%", "\n", $audio->getLyrics()), + ]; + } + + function beacon(int $aid, ?int $gid = NULL): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $audio = (new Audios)->get($aid); + if(!$audio) + $this->fail(0404, "Not Found"); + else if(!$audio->canBeViewedBy($this->getUser())) + $this->fail(201, "Insufficient permissions to listen this audio"); + + $group = NULL; + if(!is_null($gid)) { + $group = (new Clubs)->get($gid); + if(!$group) + $this->fail(0404, "Not Found"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(203, "Insufficient rights to this group"); + } + + return (int) $audio->listen($group ?? $this->getUser()); + } + + function setBroadcast(string $audio, string $target_ids): array + { + $this->requireUser(); + + [$owner, $aid] = explode("_", $audio); + $song = (new Audios)->getByOwnerAndVID((int) $owner, (int) $aid); + $ids = []; + foreach(explode(",", $target_ids) as $id) { + $id = (int) $id; + if($id > 0) { + if ($id != $this->getUser()->getId()) { + $this->fail(600, "Can't listen on behalf of $id"); + } else { + $ids[] = $id; + $this->beacon($song->getId()); + continue; + } + } + + $group = (new Clubs)->get($id * -1); + if(!$group) + $this->fail(0404, "Not Found"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(203,"Insufficient rights to this group"); + + $ids[] = $id; + $this->beacon($song ? $song->getId() : 0, $id * -1); + } + + return $ids; + } + + function getBroadcastList(string $filter = "all", int $active = 0, ?string $hash = NULL): object + { + $this->requireUser(); + + if(!in_array($filter, ["all", "friends", "groups"])) + $this->fail(8, "Invalid filter $filter"); + + $broadcastList = $this->getUser()->getBroadcastList($filter); + $items = []; + foreach($broadcastList as $res) { + $struct = $res->toVkApiStruct(); + $status = $res->getCurrentAudioStatus(); + + $struct->status_audio = $status ? $this->toSafeAudioStruct($status) : NULL; + $items[] = $struct; + } + + return (object) [ + "count" => sizeof($items), + "items" => $items, + ]; + } + + function edit(int $owner_id, int $audio_id, ?string $artist = NULL, ?string $title = NULL, ?string $text = NULL, ?int $genre_id = NULL, ?string $genre_str = NULL, int $no_search = 0): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $audio = (new Audios)->getByOwnerAndVID($owner_id, $audio_id); + if(!$audio) + $this->fail(0404, "Not Found"); + else if(!$audio->canBeModifiedBy($this->getUser())) + $this->fail(201, "Insufficient permissions to edit this audio"); + + if(!is_null($genre_id)) { + $genre = array_flip(AEntity::vkGenres)[$genre_id] ?? NULL; + if(!$genre) + $this->fail(8, "Invalid genre ID $genre_id"); + + $audio->setGenre($genre); + } else if(!is_null($genre_str)) { + if(!in_array($genre_str, AEntity::genres)) + $this->fail(8, "Invalid genre ID $genre_str"); + + $audio->setGenre($genre_str); + } + + $lyrics = 0; + if(!is_null($text)) { + $audio->setLyrics($text); + $lyrics = $audio->getId(); + } + + if(!is_null($artist)) + $audio->setPerformer($artist); + + if(!is_null($title)) + $audio->setName($title); + + $audio->setSearchability(!((bool) $no_search)); + $audio->setEdited(time()); + $audio->save(); + + return $lyrics; + } + + function add(int $audio_id, int $owner_id, ?int $group_id = NULL, ?int $album_id = NULL): string + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + if(!is_null($album_id)) + $this->fail(10, "album_id not implemented"); + + // TODO get rid of dups + $to = $this->getUser(); + if(!is_null($group_id)) { + $group = (new Clubs)->get($group_id); + if(!$group) + $this->fail(0404, "Invalid group_id"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(203, "Insufficient rights to this group"); + + $to = $group; + } + + $audio = (new Audios)->getByOwnerAndVID($owner_id, $audio_id); + if(!$audio) + $this->fail(0404, "Not found"); + else if(!$audio->canBeViewedBy($this->getUser())) + $this->fail(201, "Access denied to audio(owner=$owner_id, vid=$audio_id)"); + + try { + $audio->add($to); + } catch(\OverflowException $ex) { + $this->fail(300, "Album is full"); + } + + return $audio->getPrettyId(); + } + + function delete(int $audio_id, int $owner_id, ?int $group_id = NULL): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $from = $this->getUser(); + if(!is_null($group_id)) { + $group = (new Clubs)->get($group_id); + if(!$group) + $this->fail(0404, "Invalid group_id"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(203, "Insufficient rights to this group"); + + $from = $group; + } + + $audio = (new Audios)->getByOwnerAndVID($owner_id, $audio_id); + if(!$audio) + $this->fail(0404, "Not found"); + + $audio->remove($from); + + return 1; + } + + function restore(int $audio_id, int $owner_id, ?int $group_id = NULL, ?string $hash = NULL): object + { + $this->requireUser(); + + $vid = $this->add($audio_id, $owner_id, $group_id); + + return $this->getById($vid, $hash)->items[0]; + } + + function getAlbums(int $owner_id = 0, int $offset = 0, int $count = 50, int $drop_private = 1): object + { + $this->requireUser(); + + $owner_id = $owner_id == 0 ? $this->getUser()->getId() : $owner_id; + $playlists = []; + + if($owner_id > 0 && $owner_id != $this->getUser()->getId()) { + $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id); + + if(!$user->getPrivacyPermission("audios.read", $this->getUser())) + $this->fail(50, "Access to playlists denied"); + } + + foreach((new Audios)->getPlaylistsByEntityId($owner_id, $offset, $count) as $playlist) { + if(!$playlist->canBeViewedBy($this->getUser())) { + if($drop_private == 1) + continue; + + $playlists[] = NULL; + continue; + } + + $playlists[] = $playlist->toVkApiStruct($this->getUser()); + } + + return (object) [ + "count" => sizeof($playlists), + "items" => $playlists, + ]; + } + + function searchAlbums(string $query, int $offset = 0, int $limit = 25, int $drop_private = 0): object + { + $this->requireUser(); + + $playlists = []; + $search = (new Audios)->searchPlaylists($query)->offsetLimit($offset, $limit); + foreach($search as $playlist) { + if(!$playlist->canBeViewedBy($this->getUser())) { + if($drop_private == 0) + $playlists[] = NULL; + + continue; + } + + $playlists[] = $playlist->toVkApiStruct($this->getUser()); + } + + return (object) [ + "count" => sizeof($playlists), + "items" => $playlists, + ]; + } + + function addAlbum(string $title, ?string $description = NULL, int $group_id = 0): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $group = NULL; + if($group_id != 0) { + $group = (new Clubs)->get($group_id); + if(!$group) + $this->fail(0404, "Invalid group_id"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this group"); + } + + $album = new Playlist; + $album->setName($title); + if(!is_null($group)) + $album->setOwner($group_id * -1); + else + $album->setOwner($this->getUser()->getId()); + + if(!is_null($description)) + $album->setDescription($description); + + $album->save(); + if(!is_null($group)) + $album->bookmark($group); + else + $album->bookmark($this->getUser()); + + return $album->getId(); + } + + function editAlbum(int $album_id, ?string $title = NULL, ?string $description = NULL): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "Album not found"); + else if(!$album->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this album"); + + if(!is_null($title)) + $album->setName($title); + + if(!is_null($description)) + $album->setDescription($description); + + $album->setEdited(time()); + $album->save(); + + return (int) !(!$title && !$description); + } + + function deleteAlbum(int $album_id): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "Album not found"); + else if(!$album->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this album"); + + $album->delete(); + + return 1; + } + + function moveToAlbum(int $album_id, string $audio_ids): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "Album not found"); + else if(!$album->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this album"); + + $audios = []; + $audio_ids = array_unique(explode(",", $audio_ids)); + if(sizeof($audio_ids) < 1 || sizeof($audio_ids) > 1000) + $this->fail(8, "audio_ids must contain at least 1 audio and at most 1000"); + + foreach($audio_ids as $audio_id) { + $audio = $this->audioFromAnyId($audio_id); + if(!$audio) + continue; + else if(!$audio->canBeViewedBy($this->getUser())) + continue; + + $audios[] = $audio; + } + + if(sizeof($audios) < 1) + return 0; + + $res = 1; + try { + foreach ($audios as $audio) + $res = min($res, (int) $album->add($audio)); + } catch(\OutOfBoundsException $ex) { + return 0; + } + + return $res; + } + + function removeFromAlbum(int $album_id, string $audio_ids): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "Album not found"); + else if(!$album->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this album"); + + $audios = []; + $audio_ids = array_unique(explode(",", $audio_ids)); + if(sizeof($audio_ids) < 1 || sizeof($audio_ids) > 1000) + $this->fail(8, "audio_ids must contain at least 1 audio and at most 1000"); + + foreach($audio_ids as $audio_id) { + $audio = $this->audioFromAnyId($audio_id); + if(!$audio) + continue; + else if($audio->canBeViewedBy($this->getUser())) + continue; + + $audios[] = $audio; + } + + if(sizeof($audios) < 1) + return 0; + + foreach($audios as $audio) + $album->remove($audio); + + return 1; + } + + function copyToAlbum(int $album_id, string $audio_ids): int + { + return $this->moveToAlbum($album_id, $audio_ids); + } + + function bookmarkAlbum(int $id): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($id); + if(!$album) + $this->fail(0404, "Not found"); + + if(!$album->canBeViewedBy($this->getUser())) + $this->fail(600, "Access error"); + + return (int) $album->bookmark($this->getUser()); + } + + function unBookmarkAlbum(int $id): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($id); + if(!$album) + $this->fail(0404, "Not found"); + + if(!$album->canBeViewedBy($this->getUser())) + $this->fail(600, "Access error"); + + return (int) $album->unbookmark($this->getUser()); + } } diff --git a/VKAPI/Handlers/Friends.php b/VKAPI/Handlers/Friends.php index f7873520..59cf5679 100644 --- a/VKAPI/Handlers/Friends.php +++ b/VKAPI/Handlers/Friends.php @@ -4,7 +4,7 @@ use openvk\Web\Models\Repositories\Users as UsersRepo; final class Friends extends VKAPIRequestHandler { - function get(int $user_id, string $fields = "", int $offset = 0, int $count = 100): object + function get(int $user_id = 0, string $fields = "", int $offset = 0, int $count = 100): object { $i = 0; $offset++; @@ -13,6 +13,14 @@ final class Friends extends VKAPIRequestHandler $users = new UsersRepo; $this->requireUser(); + + if ($user_id == 0) { + $user_id = $this->getUser()->getId(); + } + + if (is_null($users->get($user_id))) { + $this->fail(100, "One of the parameters specified was missing or invalid"); + } foreach($users->get($user_id)->getFriends($offset, $count) as $friend) { $friends[$i] = $friend->getId(); diff --git a/VKAPI/Handlers/Groups.php b/VKAPI/Handlers/Groups.php index 9cc6a08c..007b68ea 100644 --- a/VKAPI/Handlers/Groups.php +++ b/VKAPI/Handlers/Groups.php @@ -292,7 +292,8 @@ final class Groups extends VKAPIRequestHandler int $topics = NULL, int $adminlist = NULL, int $topicsAboveWall = NULL, - int $hideFromGlobalFeed = NULL) + int $hideFromGlobalFeed = NULL, + int $audio = NULL) { $this->requireUser(); $this->willExecuteWriteAction(); @@ -303,17 +304,22 @@ final class Groups extends VKAPIRequestHandler if(!$club || !$club->canBeModifiedBy($this->getUser())) $this->fail(15, "You can't modify this group."); if(!empty($screen_name) && !$club->setShortcode($screen_name)) $this->fail(103, "Invalid shortcode."); - !is_null($title) ? $club->setName($title) : NULL; - !is_null($description) ? $club->setAbout($description) : NULL; - !is_null($screen_name) ? $club->setShortcode($screen_name) : NULL; - !is_null($website) ? $club->setWebsite((!parse_url($website, PHP_URL_SCHEME) ? "https://" : "") . $website) : NULL; - !is_null($wall) ? $club->setWall($wall) : NULL; - !is_null($topics) ? $club->setEveryone_Can_Create_Topics($topics) : NULL; - !is_null($adminlist) ? $club->setAdministrators_List_Display($adminlist) : NULL; - !is_null($topicsAboveWall) ? $club->setDisplay_Topics_Above_Wall($topicsAboveWall) : NULL; - !is_null($hideFromGlobalFeed) ? $club->setHide_From_Global_Feed($hideFromGlobalFeed) : NULL; + !empty($title) ? $club->setName($title) : NULL; + !empty($description) ? $club->setAbout($description) : NULL; + !empty($screen_name) ? $club->setShortcode($screen_name) : NULL; + !empty($website) ? $club->setWebsite((!parse_url($website, PHP_URL_SCHEME) ? "https://" : "") . $website) : NULL; + !empty($wall) ? $club->setWall($wall) : NULL; + !empty($topics) ? $club->setEveryone_Can_Create_Topics($topics) : NULL; + !empty($adminlist) ? $club->setAdministrators_List_Display($adminlist) : NULL; + !empty($topicsAboveWall) ? $club->setDisplay_Topics_Above_Wall($topicsAboveWall) : NULL; + !empty($hideFromGlobalFeed) ? $club->setHide_From_Global_Feed($hideFromGlobalFeed) : NULL; + in_array($audio, [0, 1]) ? $club->setEveryone_can_upload_audios($audio) : NULL; - $club->save(); + try { + $club->save(); + } catch(\TypeError $e) { + $this->fail(8, "Nothing changed"); + } return 1; } @@ -370,7 +376,7 @@ final class Groups extends VKAPIRequestHandler $arr->items[$i]->can_see_all_posts = 1; break; case "can_see_audio": - $arr->items[$i]->can_see_audio = 0; + $arr->items[$i]->can_see_audio = 1; break; case "can_write_private_message": $arr->items[$i]->can_write_private_message = 0; @@ -469,7 +475,7 @@ final class Groups extends VKAPIRequestHandler "wall" => $club->canPost() == true ? 1 : 0, "photos" => 1, "video" => 0, - "audio" => 0, + "audio" => $club->isEveryoneCanUploadAudios() ? 1 : 0, "docs" => 0, "topics" => $club->isEveryoneCanCreateTopics() == true ? 1 : 0, "wiki" => 0, diff --git a/VKAPI/Handlers/Photos.php b/VKAPI/Handlers/Photos.php index e3e9abac..bb1a22f0 100644 --- a/VKAPI/Handlers/Photos.php +++ b/VKAPI/Handlers/Photos.php @@ -432,13 +432,11 @@ final class Photos extends VKAPIRequestHandler if(empty($photo_ids)) { $album = (new Albums)->getAlbumByOwnerAndId($owner_id, $album_id); - if(!$album->getOwner()->getPrivacyPermission('photos.read', $this->getUser())) { - $this->fail(21, "This user chose to hide his albums."); - } - - if(!$album || $album->isDeleted()) { + if(!$album || $album->isDeleted()) $this->fail(21, "Invalid album"); - } + + if(!$album->getOwner()->getPrivacyPermission('photos.read', $this->getUser())) + $this->fail(21, "This user chose to hide his albums."); $photos = array_slice(iterator_to_array($album->getPhotos(1, $count + $offset)), $offset); $res["count"] = sizeof($photos); @@ -456,8 +454,7 @@ final class Photos extends VKAPIRequestHandler "items" => [] ]; - foreach($photos as $photo) - { + foreach($photos as $photo) { $id = explode("_", $photo); $phot = (new PhotosRepo)->getByOwnerAndVID((int)$id[0], (int)$id[1]); diff --git a/VKAPI/Handlers/Status.php b/VKAPI/Handlers/Status.php index 843f42bd..a1b104a2 100644 --- a/VKAPI/Handlers/Status.php +++ b/VKAPI/Handlers/Status.php @@ -8,13 +8,23 @@ final class Status extends VKAPIRequestHandler function get(int $user_id = 0, int $group_id = 0) { $this->requireUser(); - if($user_id == 0 && $group_id == 0) { - return $this->getUser()->getStatus(); - } else { - if($group_id > 0) - $this->fail(501, "Group statuses are not implemented"); - else - return (new UsersRepo)->get($user_id)->getStatus(); + + if($user_id == 0 && $group_id == 0) + $user_id = $this->getUser()->getId(); + + if($group_id > 0) + $this->fail(501, "Group statuses are not implemented"); + else { + $user = (new UsersRepo)->get($user_id); + $audioStatus = $user->getCurrentAudioStatus(); + if($audioStatus) { + return [ + "status" => $user->getStatus(), + "audio" => $audioStatus->toVkApiStruct(), + ]; + } + + return $user->getStatus(); } } diff --git a/VKAPI/Handlers/Users.php b/VKAPI/Handlers/Users.php index 6d67ed39..d1c29705 100644 --- a/VKAPI/Handlers/Users.php +++ b/VKAPI/Handlers/Users.php @@ -37,8 +37,8 @@ final class Users extends VKAPIRequestHandler } else if($usr->isBanned()) { $response[$i] = (object)[ "id" => $usr->getId(), - "first_name" => $usr->getFirstName(), - "last_name" => $usr->getLastName(), + "first_name" => $usr->getFirstName(true), + "last_name" => $usr->getLastName(true), "deactivated" => "banned", "ban_reason" => $usr->getBanReason() ]; @@ -47,8 +47,8 @@ final class Users extends VKAPIRequestHandler } else { $response[$i] = (object)[ "id" => $usr->getId(), - "first_name" => $usr->getFirstName(), - "last_name" => $usr->getLastName(), + "first_name" => $usr->getFirstName(true), + "last_name" => $usr->getLastName(true), "is_closed" => false, "can_access_closed" => true, ]; @@ -96,6 +96,12 @@ final class Users extends VKAPIRequestHandler case "status": if($usr->getStatus() != NULL) $response[$i]->status = $usr->getStatus(); + + $audioStatus = $usr->getCurrentAudioStatus(); + + if($audioStatus) + $response[$i]->status_audio = $audioStatus->toVkApiStruct(); + break; case "screen_name": if($usr->getShortCode() != NULL) @@ -160,6 +166,18 @@ final class Users extends VKAPIRequestHandler case "interests": $response[$i]->interests = $usr->getInterests(); break; + case "quotes": + $response[$i]->interests = $usr->getFavoriteQuote(); + break; + case "email": + $response[$i]->interests = $usr->getEmail(); + break; + case "telegram": + $response[$i]->interests = $usr->getTelegram(); + break; + case "about": + $response[$i]->interests = $usr->getDescription(); + break; case "rating": $response[$i]->rating = $usr->getRating(); break; diff --git a/VKAPI/Handlers/Wall.php b/VKAPI/Handlers/Wall.php index 61c75703..907f06fa 100644 --- a/VKAPI/Handlers/Wall.php +++ b/VKAPI/Handlers/Wall.php @@ -16,6 +16,7 @@ use openvk\Web\Models\Repositories\Videos as VideosRepo; use openvk\Web\Models\Entities\Note; use openvk\Web\Models\Repositories\Notes as NotesRepo; use openvk\Web\Models\Repositories\Polls as PollsRepo; +use openvk\Web\Models\Repositories\Audios as AudiosRepo; final class Wall extends VKAPIRequestHandler { @@ -59,6 +60,11 @@ final class Wall extends VKAPIRequestHandler $attachments[] = $attachment->getApiStructure(); } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) { $attachments[] = $attachment->toVkApiStruct(); + } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) { + $attachments[] = [ + "type" => "audio", + "audio" => $attachment->toVkApiStruct($this->getUser()), + ]; } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) { $repostAttachments = []; @@ -234,6 +240,11 @@ final class Wall extends VKAPIRequestHandler $attachments[] = $attachment->getApiStructure(); } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) { $attachments[] = $attachment->toVkApiStruct(); + } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) { + $attachments[] = [ + "type" => "audio", + "audio" => $attachment->toVkApiStruct($this->getUser()) + ]; } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) { $repostAttachments = []; @@ -458,6 +469,9 @@ final class Wall extends VKAPIRequestHandler $attachmentType = "note"; elseif(str_contains($attac, "poll")) $attachmentType = "poll"; + elseif(str_contains($attac, "audio")) + $attachmentType = "audio"; + else $this->fail(205, "Unknown attachment type"); @@ -499,6 +513,10 @@ final class Wall extends VKAPIRequestHandler $this->fail(100, "Poll does not exist"); if($attacc->getOwner()->getId() != $this->getUser()->getId()) $this->fail(43, "You do not have access to this poll"); + } elseif($attachmentType == "audio") { + $attacc = (new AudiosRepo)->getByOwnerAndVID($attachmentOwner, $attachmentId); + if(!$attacc || $attacc->isDeleted()) + $this->fail(100, "Audio does not exist"); $post->attach($attacc); } @@ -579,6 +597,11 @@ final class Wall extends VKAPIRequestHandler $attachments[] = $this->getApiPhoto($attachment); } elseif($attachment instanceof \openvk\Web\Models\Entities\Note) { $attachments[] = $attachment->toVkApiStruct(); + } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) { + $attachments[] = [ + "type" => "audio", + "audio" => $attachment->toVkApiStruct($this->getUser()), + ]; } } @@ -638,6 +661,9 @@ final class Wall extends VKAPIRequestHandler $comment = (new CommentsRepo)->get($comment_id); # один хуй айди всех комментов общий + if(!$comment || $comment->isDeleted()) + $this->fail(100, "Invalid comment"); + $profiles = []; $attachments = []; @@ -645,6 +671,11 @@ final class Wall extends VKAPIRequestHandler foreach($comment->getChildren() as $attachment) { if($attachment instanceof \openvk\Web\Models\Entities\Photo) { $attachments[] = $this->getApiPhoto($attachment); + } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) { + $attachments[] = [ + "type" => "audio", + "audio" => $attachment->toVkApiStruct($this->getUser()), + ]; } } @@ -736,6 +767,8 @@ final class Wall extends VKAPIRequestHandler $attachmentType = "photo"; elseif(str_contains($attac, "video")) $attachmentType = "video"; + elseif(str_contains($attac, "audio")) + $attachmentType = "audio"; else $this->fail(205, "Unknown attachment type"); @@ -761,6 +794,12 @@ final class Wall extends VKAPIRequestHandler if(!$attacc->getOwner()->getPrivacyPermission('videos.read', $this->getUser())) $this->fail(11, "Access to video denied"); + $comment->attach($attacc); + } elseif($attachmentType == "audio") { + $attacc = (new AudiosRepo)->getByOwnerAndVID($attachmentOwner, $attachmentId); + if(!$attacc || $attacc->isDeleted()) + $this->fail(100, "Audio does not exist"); + $comment->attach($attacc); } } @@ -883,7 +922,7 @@ final class Wall extends VKAPIRequestHandler return [ "type" => "photo", "photo" => [ - "album_id" => $attachment->getAlbum() ? $attachment->getAlbum()->getId() : NULL, + "album_id" => $attachment->getAlbum() ? $attachment->getAlbum()->getId() : 0, "date" => $attachment->getPublicationTime()->timestamp(), "id" => $attachment->getVirtualId(), "owner_id" => $attachment->getOwner()->getId(), diff --git a/Web/Models/Entities/APIToken.php b/Web/Models/Entities/APIToken.php index f5744ec3..0cc53148 100644 --- a/Web/Models/Entities/APIToken.php +++ b/Web/Models/Entities/APIToken.php @@ -48,7 +48,7 @@ class APIToken extends RowModel $this->delete(); } - function save(): void + function save(?bool $log = false): void { if(is_null($this->getRecord())) $this->stateChanges("secret", bin2hex(openssl_random_pseudo_bytes(36))); diff --git a/Web/Models/Entities/Audio.php b/Web/Models/Entities/Audio.php new file mode 100644 index 00000000..11d8502b --- /dev/null +++ b/Web/Models/Entities/Audio.php @@ -0,0 +1,469 @@ + 1, + "Pop" => 2, + "Rap" => 3, + "Hip-Hop" => 3, # VK API lists №3 as Rap & Hip-Hop, but these genres are distinct in OpenVK + "Easy Listening" => 4, + "House" => 5, + "Dance" => 5, + "Instrumental" => 6, + "Metal" => 7, + "Alternative" => 21, + "Dubstep" => 8, + "Jazz" => 1001, + "Blues" => 1001, + "Drum & Bass" => 10, + "Trance" => 11, + "Chanson" => 12, + "Ethnic" => 13, + "Acoustic" => 14, + "Vocal" => 14, + "Reggae" => 15, + "Classical" => 16, + "Indie Pop" => 17, + "Speech" => 19, + "Disco" => 22, + "Other" => 18, + ]; + + private function fileLength(string $filename): int + { + if(!Shell::commandAvailable("ffmpeg") || !Shell::commandAvailable("ffprobe")) + throw new \Exception(); + + $error = NULL; + $streams = Shell::ffprobe("-i", $filename, "-show_streams", "-select_streams a", "-loglevel error")->execute($error); + if($error !== 0) + throw new \DomainException("$filename is not recognized as media container"); + else if(empty($streams) || ctype_space($streams)) + throw new \DomainException("$filename does not contain any audio streams"); + + $vstreams = Shell::ffprobe("-i", $filename, "-show_streams", "-select_streams v", "-loglevel error")->execute($error); + + # check if audio has cover (attached_pic) + preg_match("%attached_pic=([0-1])%", $vstreams, $hasCover); + if(!empty($vstreams) && !ctype_space($vstreams) && ((int)($hasCover[1]) !== 1)) + throw new \DomainException("$filename is a video"); + + $durations = []; + preg_match_all('%duration=([0-9\.]++)%', $streams, $durations); + if(sizeof($durations[1]) === 0) + throw new \DomainException("$filename does not contain any meaningful audio streams"); + + $length = 0; + foreach($durations[1] as $duration) { + $duration = floatval($duration); + if($duration < 1.0 || $duration > 65536.0) + throw new \DomainException("$filename does not contain any meaningful audio streams"); + else + $length = max($length, $duration); + } + + return (int) round($length, 0, PHP_ROUND_HALF_EVEN); + } + + /** + * @throws \Exception + */ + protected function saveFile(string $filename, string $hash): bool + { + $duration = $this->fileLength($filename); + + $kid = openssl_random_pseudo_bytes(16); + $key = openssl_random_pseudo_bytes(16); + $tok = openssl_random_pseudo_bytes(28); + $ss = ceil($duration / 15); + + $this->stateChanges("kid", $kid); + $this->stateChanges("key", $key); + $this->stateChanges("token", $tok); + $this->stateChanges("segment_size", $ss); + $this->stateChanges("length", $duration); + + try { + $args = [ + str_replace("enabled", "available", OPENVK_ROOT), + str_replace("enabled", "available", $this->getBaseDir()), + $hash, + $filename, + + bin2hex($kid), + bin2hex($key), + bin2hex($tok), + $ss, + ]; + + if(Shell::isPowershell()) { + Shell::powershell("-executionpolicy bypass", "-File", __DIR__ . "/../shell/processAudio.ps1", ...$args) + ->start(); + } else { + Shell::bash(__DIR__ . "/../shell/processAudio.sh", ...$args) // Pls workkkkk + ->start(); // idk, not tested :") + } + + # Wait until processAudio will consume the file + $start = time(); + while(file_exists($filename)) + if(time() - $start > 5) + throw new \RuntimeException("Timed out waiting FFMPEG"); + + } catch(UnknownCommandException $ucex) { + exit(OPENVK_ROOT_CONF["openvk"]["debug"] ? "bash/pwsh is not installed" : VIDEOS_FRIENDLY_ERROR); + } + + return true; + } + + function getTitle(): string + { + return $this->getRecord()->name; + } + + function getPerformer(): string + { + return $this->getRecord()->performer; + } + + function getName(): string + { + return $this->getPerformer() . " — " . $this->getTitle(); + } + + function getGenre(): ?string + { + return $this->getRecord()->genre; + } + + function getLyrics(): ?string + { + return !is_null($this->getRecord()->lyrics) ? htmlspecialchars($this->getRecord()->lyrics, ENT_DISALLOWED | ENT_XHTML) : NULL; + } + + function getLength(): int + { + return $this->getRecord()->length; + } + + function getFormattedLength(): string + { + $len = $this->getLength(); + $mins = floor($len / 60); + $secs = $len - ($mins * 60); + + return ( + str_pad((string) $mins, 2, "0", STR_PAD_LEFT) + . ":" . + str_pad((string) $secs, 2, "0", STR_PAD_LEFT) + ); + } + + function getSegmentSize(): float + { + return $this->getRecord()->segment_size; + } + + function getListens(): int + { + return $this->getRecord()->listens; + } + + function getOriginalURL(bool $force = false): string + { + $disallowed = !OPENVK_ROOT_CONF["openvk"]["preferences"]["music"]["exposeOriginalURLs"] && !$force; + if(!$this->isAvailable() || $disallowed) + return ovk_scheme(true) + . $_SERVER["HTTP_HOST"] . ":" + . $_SERVER["HTTP_PORT"] + . "/assets/packages/static/openvk/audio/nomusic.mp3"; + + $key = bin2hex($this->getRecord()->token); + + return str_replace(".mpd", "_fragments", $this->getURL()) . "/original_$key.mp3"; + } + + function getURL(?bool $force = false): string + { + if ($this->isWithdrawn()) return ""; + + return parent::getURL(); + } + + function getKeys(): array + { + $keys[bin2hex($this->getRecord()->kid)] = bin2hex($this->getRecord()->key); + + return $keys; + } + + function isAnonymous(): bool + { + return false; + } + + function isExplicit(): bool + { + return (bool) $this->getRecord()->explicit; + } + + function isWithdrawn(): bool + { + return (bool) $this->getRecord()->withdrawn; + } + + function isUnlisted(): bool + { + return (bool) $this->getRecord()->unlisted; + } + + # NOTICE may flush model to DB if it was just processed + function isAvailable(): bool + { + if($this->getRecord()->processed) + return true; + + # throttle requests to isAvailable to prevent DoS attack if filesystem is actually an S3 storage + if(time() - $this->getRecord()->checked < 5) + return false; + + try { + $fragments = str_replace(".mpd", "_fragments", $this->getFileName()); + $original = "original_" . bin2hex($this->getRecord()->token) . ".mp3"; + if(file_exists("$fragments/$original")) { + # Original gets uploaded after fragments + $this->stateChanges("processed", 0x01); + + return true; + } + } finally { + $this->stateChanges("checked", time()); + $this->save(); + } + + return false; + } + + function isInLibraryOf($entity): bool + { + return sizeof(DatabaseConnection::i()->getContext()->table("audio_relations")->where([ + "entity" => $entity->getId() * ($entity instanceof Club ? -1 : 1), + "audio" => $this->getId(), + ])) != 0; + } + + function add($entity): bool + { + if($this->isInLibraryOf($entity)) + return false; + + $entityId = $entity->getId() * ($entity instanceof Club ? -1 : 1); + $audioRels = DatabaseConnection::i()->getContext()->table("audio_relations"); + if(sizeof($audioRels->where("entity", $entityId)) > 65536) + throw new \OverflowException("Can't have more than 65536 audios in a playlist"); + + $audioRels->insert([ + "entity" => $entityId, + "audio" => $this->getId(), + ]); + + return true; + } + + function remove($entity): bool + { + if(!$this->isInLibraryOf($entity)) + return false; + + DatabaseConnection::i()->getContext()->table("audio_relations")->where([ + "entity" => $entity->getId() * ($entity instanceof Club ? -1 : 1), + "audio" => $this->getId(), + ])->delete(); + + return true; + } + + function listen($entity, Playlist $playlist = NULL): bool + { + $listensTable = DatabaseConnection::i()->getContext()->table("audio_listens"); + $lastListen = $listensTable->where([ + "entity" => $entity->getRealId(), + "audio" => $this->getId(), + ])->order("index DESC")->fetch(); + + if(!$lastListen || (time() - $lastListen->time >= $this->getLength())) { + $listensTable->insert([ + "entity" => $entity->getRealId(), + "audio" => $this->getId(), + "time" => time(), + "playlist" => $playlist ? $playlist->getId() : NULL, + ]); + + if($entity instanceof User) { + $this->stateChanges("listens", ($this->getListens() + 1)); + $this->save(); + + if($playlist) { + $playlist->incrementListens(); + $playlist->save(); + } + } + + $entity->setLast_played_track($this->getId()); + $entity->save(); + + return true; + } + + $lastListen->update([ + "time" => time(), + ]); + + return false; + } + + /** + * Returns compatible with VK API 4.x, 5.x structure. + * + * Always sets album(_id) to NULL at this time. + * If genre is not present in VK genre list, fallbacks to "Other". + * The url and manifest properties will be set to false if the audio can't be played (processing, removed). + * + * Aside from standard VK properties, this method will also return some OVK extended props: + * 1. added - Is in the library of $user? + * 2. editable - Can be edited by $user? + * 3. withdrawn - Removed due to copyright request? + * 4. ready - Can be played at this time? + * 5. genre_str - Full name of genre, NULL if it's undefined + * 6. manifest - URL to MPEG-DASH manifest + * 7. keys - ClearKey DRM keys + * 8. explicit - Marked as NSFW? + * 9. searchable - Can be found via search? + * 10. unique_id - Unique ID of audio + * + * @notice that in case if exposeOriginalURLs is set to false in config, "url" will always contain link to nomusic.mp3, + * unless $forceURLExposure is set to true. + * + * @notice may trigger db flush if the audio is not processed yet, use with caution on unsaved models. + * + * @param ?User $user user, relative to whom "added", "editable" will be set + * @param bool $forceURLExposure force set "url" regardless of config + */ + function toVkApiStruct(?User $user = NULL, bool $forceURLExposure = false): object + { + $obj = (object) []; + $obj->unique_id = base64_encode((string) $this->getId()); + $obj->id = $obj->aid = $this->getVirtualId(); + $obj->artist = $this->getPerformer(); + $obj->title = $this->getTitle(); + $obj->duration = $this->getLength(); + $obj->album_id = $obj->album = NULL; # i forgor to implement + $obj->url = false; + $obj->manifest = false; + $obj->keys = false; + $obj->genre_id = $obj->genre = self::vkGenres[$this->getGenre() ?? ""] ?? 18; # return Other if no match + $obj->genre_str = $this->getGenre(); + $obj->owner_id = $this->getOwner()->getId(); + if($this->getOwner() instanceof Club) + $obj->owner_id *= -1; + + $obj->lyrics = NULL; + if(!is_null($this->getLyrics())) + $obj->lyrics = $this->getId(); + + $obj->added = $user && $this->isInLibraryOf($user); + $obj->editable = $user && $this->canBeModifiedBy($user); + $obj->searchable = !$this->isUnlisted(); + $obj->explicit = $this->isExplicit(); + $obj->withdrawn = $this->isWithdrawn(); + $obj->ready = $this->isAvailable() && !$obj->withdrawn; + if($obj->ready) { + $obj->url = $this->getOriginalURL($forceURLExposure); + $obj->manifest = $this->getURL(); + $obj->keys = $this->getKeys(); + } + + return $obj; + } + + function setOwner(int $oid): void + { + # WARNING: API implementation won't be able to handle groups like that, don't remove + if($oid <= 0) + throw new \OutOfRangeException("Only users can be owners of audio!"); + + $this->stateChanges("owner", $oid); + } + + function setGenre(string $genre): void + { + if(!in_array($genre, Audio::genres)) { + $this->stateChanges("genre", NULL); + return; + } + + $this->stateChanges("genre", $genre); + } + + function setCopyrightStatus(bool $withdrawn = true): void { + $this->stateChanges("withdrawn", $withdrawn); + } + + function setSearchability(bool $searchable = true): void { + $this->stateChanges("unlisted", !$searchable); + } + + function setToken(string $tok): void { + throw new \LogicException("Changing keys is not supported."); + } + + function setKid(string $kid): void { + throw new \LogicException("Changing keys is not supported."); + } + + function setKey(string $key): void { + throw new \LogicException("Changing keys is not supported."); + } + + function setLength(int $len): void { + throw new \LogicException("Changing length is not supported."); + } + + function setSegment_Size(int $len): void { + throw new \LogicException("Changing length is not supported."); + } + + function delete(bool $softly = true): void + { + $ctx = DatabaseConnection::i()->getContext(); + $ctx->table("audio_relations")->where("audio", $this->getId()) + ->delete(); + $ctx->table("audio_listens")->where("audio", $this->getId()) + ->delete(); + $ctx->table("playlist_relations")->where("media", $this->getId()) + ->delete(); + + parent::delete($softly); + } +} diff --git a/Web/Models/Entities/Club.php b/Web/Models/Entities/Club.php index 46e8e747..319678f3 100644 --- a/Web/Models/Entities/Club.php +++ b/Web/Models/Entities/Club.php @@ -371,6 +371,29 @@ class Club extends RowModel { return $this->getRecord()->alert; } + + function getRealId(): int + { + return $this->getId() * -1; + } + + function isEveryoneCanUploadAudios(): bool + { + return (bool) $this->getRecord()->everyone_can_upload_audios; + } + + function canUploadAudio(?User $user): bool + { + if(!$user) + return NULL; + + return $this->isEveryoneCanUploadAudios() || $this->canBeModifiedBy($user); + } + + function getAudiosCollectionSize() + { + return (new \openvk\Web\Models\Repositories\Audios)->getClubCollectionSize($this); + } function toVkApiStruct(?User $user = NULL): object { @@ -381,26 +404,27 @@ class Club extends RowModel $res->screen_name = $this->getShortCode(); $res->is_closed = 0; $res->deactivated = NULL; - $res->is_admin = $this->canBeModifiedBy($user); + $res->is_admin = $user && $this->canBeModifiedBy($user); - if($this->canBeModifiedBy($user)) { + if($user && $this->canBeModifiedBy($user)) { $res->admin_level = 3; } - $res->is_member = $this->getSubscriptionStatus($user) ? 1 : 0; + $res->is_member = $user && $this->getSubscriptionStatus($user) ? 1 : 0; $res->type = "group"; $res->photo_50 = $this->getAvatarUrl("miniscule"); $res->photo_100 = $this->getAvatarUrl("tiny"); $res->photo_200 = $this->getAvatarUrl("normal"); - $res->can_create_topic = $this->canBeModifiedBy($user) ? 1 : ($this->isEveryoneCanCreateTopics() ? 1 : 0); + $res->can_create_topic = $user && $this->canBeModifiedBy($user) ? 1 : ($this->isEveryoneCanCreateTopics() ? 1 : 0); - $res->can_post = $this->canBeModifiedBy($user) ? 1 : ($this->canPost() ? 1 : 0); + $res->can_post = $user && $this->canBeModifiedBy($user) ? 1 : ($this->canPost() ? 1 : 0); return $res; } use Traits\TBackDrops; use Traits\TSubscribable; + use Traits\TAudioStatuses; } diff --git a/Web/Models/Entities/Correspondence.php b/Web/Models/Entities/Correspondence.php index e4b7d96e..a972425e 100644 --- a/Web/Models/Entities/Correspondence.php +++ b/Web/Models/Entities/Correspondence.php @@ -131,7 +131,7 @@ class Correspondence */ function getPreviewMessage(): ?Message { - $messages = $this->getMessages(1, NULL, 1); + $messages = $this->getMessages(1, NULL, 1, 0); return $messages[0] ?? NULL; } diff --git a/Web/Models/Entities/MediaCollection.php b/Web/Models/Entities/MediaCollection.php index 05f3835c..1f061988 100644 --- a/Web/Models/Entities/MediaCollection.php +++ b/Web/Models/Entities/MediaCollection.php @@ -17,7 +17,17 @@ abstract class MediaCollection extends RowModel protected $specialNames = []; - private $relations; + protected $relations; + + /** + * Maximum amount of items Collection can have + */ + const MAX_ITEMS = INF; + + /** + * Maximum amount of Collections with same "owner" allowed + */ + const MAX_COUNT = INF; function __construct(?ActiveRow $ar = NULL) { @@ -70,18 +80,29 @@ abstract class MediaCollection extends RowModel } abstract function getCoverURL(): ?string; - - function fetch(int $page = 1, ?int $perPage = NULL): \Traversable + + function fetchClassic(int $offset = 0, ?int $limit = NULL): \Traversable { - $related = $this->getRecord()->related("$this->relTableName.collection")->page($page, $perPage ?? OPENVK_DEFAULT_PER_PAGE)->order("media ASC"); + $related = $this->getRecord()->related("$this->relTableName.collection") + ->limit($limit ?? OPENVK_DEFAULT_PER_PAGE, $offset) + ->order("media ASC"); + foreach($related as $rel) { $media = $rel->ref($this->entityTableName, "media"); if(!$media) continue; - + yield new $this->entityClassName($media); } } + + function fetch(int $page = 1, ?int $perPage = NULL): \Traversable + { + $page = max(1, $page); + $perPage ??= OPENVK_DEFAULT_PER_PAGE; + + return $this->fetchClassic($perPage * ($page - 1), $perPage); + } function size(): int { @@ -110,7 +131,7 @@ abstract class MediaCollection extends RowModel { return $this->getRecord()->special_type !== 0; } - + function add(RowModel $entity): bool { $this->entitySuitable($entity); @@ -118,6 +139,10 @@ abstract class MediaCollection extends RowModel if(!$this->allowDuplicates) if($this->has($entity)) return false; + + if(self::MAX_ITEMS != INF) + if(sizeof($this->relations->where("collection", $this->getId())) > self::MAX_ITEMS) + throw new \OutOfBoundsException("Collection is full"); $this->relations->insert([ "collection" => $this->getId(), @@ -127,14 +152,14 @@ abstract class MediaCollection extends RowModel return true; } - function remove(RowModel $entity): void + function remove(RowModel $entity): bool { $this->entitySuitable($entity); - $this->relations->where([ + return $this->relations->where([ "collection" => $this->getId(), "media" => $entity->getId(), - ])->delete(); + ])->delete() > 0; } function has(RowModel $entity): bool @@ -148,6 +173,33 @@ abstract class MediaCollection extends RowModel return !is_null($rel); } - + + function save(?bool $log = false): void + { + $thisTable = DatabaseConnection::i()->getContext()->table($this->tableName); + if(self::MAX_COUNT != INF) + if(isset($this->changes["owner"])) + if(sizeof($thisTable->where("owner", $this->changes["owner"])) > self::MAX_COUNT) + throw new \OutOfBoundsException("Maximum amount of collections"); + + if(is_null($this->getRecord())) + if(!isset($this->changes["created"])) + $this->stateChanges("created", time()); + else + $this->stateChanges("edited", time()); + + parent::save($log); + } + + function delete(bool $softly = true): void + { + if(!$softly) { + $this->relations->where("collection", $this->getId()) + ->delete(); + } + + parent::delete($softly); + } + use Traits\TOwnable; } diff --git a/Web/Models/Entities/PasswordReset.php b/Web/Models/Entities/PasswordReset.php index cf0e73ea..372c63f8 100644 --- a/Web/Models/Entities/PasswordReset.php +++ b/Web/Models/Entities/PasswordReset.php @@ -54,11 +54,11 @@ class PasswordReset extends RowModel } } - function save(): void + function save(?bool $log = false): void { $this->stateChanges("key", base64_encode(openssl_random_pseudo_bytes(46))); $this->stateChanges("timestamp", time()); - parent::save(); + parent::save($log); } } diff --git a/Web/Models/Entities/Playlist.php b/Web/Models/Entities/Playlist.php new file mode 100644 index 00000000..c027a038 --- /dev/null +++ b/Web/Models/Entities/Playlist.php @@ -0,0 +1,256 @@ +importTable = DatabaseConnection::i()->getContext()->table("playlist_imports"); + } + + function getCoverURL(string $size = "normal"): ?string + { + $photo = (new Photos)->get((int) $this->getRecord()->cover_photo_id); + return is_null($photo) ? "/assets/packages/static/openvk/img/song.jpg" : $photo->getURLBySizeId($size); + } + + function getLength(): int + { + return $this->getRecord()->length; + } + + function getAudios(int $offset = 0, ?int $limit = NULL, ?int $shuffleSeed = NULL): \Traversable + { + if(!$shuffleSeed) { + foreach ($this->fetchClassic($offset, $limit) as $e) + yield $e; # No, I can't return, it will break with [] + + return; + } + + $ids = []; + foreach($this->relations->select("media AS i")->where("collection", $this->getId()) as $rel) + $ids[] = $rel->i; + + $ids = knuth_shuffle($ids, $shuffleSeed); + $ids = array_slice($ids, $offset, $limit ?? OPENVK_DEFAULT_PER_PAGE); + foreach($ids as $id) + yield (new Audios)->get($id); + } + + function add(RowModel $audio): bool + { + if($res = parent::add($audio)) { + $this->stateChanges("length", $this->getRecord()->length + $audio->getLength()); + $this->save(); + } + + return $res; + } + + function remove(RowModel $audio): bool + { + if($res = parent::remove($audio)) { + $this->stateChanges("length", $this->getRecord()->length - $audio->getLength()); + $this->save(); + } + + return $res; + } + + function isBookmarkedBy(RowModel $entity): bool + { + $id = $entity->getId(); + if($entity instanceof Club) + $id *= -1; + + return !is_null($this->importTable->where([ + "entity" => $id, + "playlist" => $this->getId(), + ])->fetch()); + } + + function bookmark(RowModel $entity): bool + { + if($this->isBookmarkedBy($entity)) + return false; + + $id = $entity->getId(); + if($entity instanceof Club) + $id *= -1; + + if($this->importTable->where("entity", $id)->count() > self::MAX_COUNT) + throw new \OutOfBoundsException("Maximum amount of playlists"); + + $this->importTable->insert([ + "entity" => $id, + "playlist" => $this->getId(), + ]); + + return true; + } + + function unbookmark(RowModel $entity): bool + { + $id = $entity->getId(); + if($entity instanceof Club) + $id *= -1; + + $count = $this->importTable->where([ + "entity" => $id, + "playlist" => $this->getId(), + ])->delete(); + + return $count > 0; + } + + function getDescription(): ?string + { + return $this->getRecord()->description; + } + + function getDescriptionHTML(): ?string + { + return htmlspecialchars($this->getRecord()->description, ENT_DISALLOWED | ENT_XHTML); + } + + function getListens() + { + return $this->getRecord()->listens; + } + + function toVkApiStruct(?User $user = NULL): object + { + $oid = $this->getOwner()->getId(); + if($this->getOwner() instanceof Club) + $oid *= -1; + + return (object) [ + "id" => $this->getId(), + "owner_id" => $oid, + "title" => $this->getName(), + "description" => $this->getDescription(), + "size" => $this->size(), + "length" => $this->getLength(), + "created" => $this->getCreationTime()->timestamp(), + "modified" => $this->getEditTime() ? $this->getEditTime()->timestamp() : NULL, + "accessible" => $this->canBeViewedBy($user), + "editable" => $this->canBeModifiedBy($user), + "bookmarked" => $this->isBookmarkedBy($user), + "listens" => $this->getListens(), + "cover_url" => $this->getCoverURL(), + ]; + } + + function setLength(): void + { + throw new \LogicException("Can't set length of playlist manually"); + } + + function resetLength(): bool + { + $this->stateChanges("length", 0); + + return true; + } + + function delete(bool $softly = true): void + { + $ctx = DatabaseConnection::i()->getContext(); + $ctx->table("playlist_imports")->where("playlist", $this->getId()) + ->delete(); + + parent::delete($softly); + } + + function hasAudio(Audio $audio): bool + { + $ctx = DatabaseConnection::i()->getContext(); + return !is_null($ctx->table("playlist_relations")->where([ + "collection" => $this->getId(), + "media" => $audio->getId() + ])->fetch()); + } + + function getCoverPhotoId(): ?int + { + return $this->getRecord()->cover_photo_id; + } + + function canBeModifiedBy(User $user): bool + { + if(!$user) + return false; + + if($this->getOwner() instanceof User) + return $user->getId() == $this->getOwner()->getId(); + else + return $this->getOwner()->canBeModifiedBy($user); + } + + function getLengthInMinutes(): int + { + return (int)round($this->getLength() / 60, PHP_ROUND_HALF_DOWN); + } + + function fastMakeCover(int $owner, array $file) + { + $cover = new Photo; + $cover->setOwner($owner); + $cover->setDescription("Playlist cover image"); + $cover->setFile($file); + $cover->setCreated(time()); + $cover->save(); + + $this->setCover_photo_id($cover->getId()); + + return $cover; + } + + function getURL(): string + { + return "/playlist" . $this->getOwner()->getRealId() . "_" . $this->getId(); + } + + function incrementListens() + { + $this->stateChanges("listens", ($this->getListens() + 1)); + } + + function getMetaDescription(): string + { + $length = $this->getLengthInMinutes(); + + $props = []; + $props[] = tr("audios_count", $this->size()); + $props[] = "" . tr("listens_count", $this->getListens()) . ""; + if($length > 0) $props[] = tr("minutes_count", $length); + $props[] = tr("created_playlist") . " " . $this->getPublicationTime(); + # if($this->getEditTime()) $props[] = tr("updated_playlist") . " " . $this->getEditTime(); + + return implode(" • ", $props); + } +} diff --git a/Web/Models/Entities/Poll.php b/Web/Models/Entities/Poll.php index 6f2885b1..7e32505f 100644 --- a/Web/Models/Entities/Poll.php +++ b/Web/Models/Entities/Poll.php @@ -279,12 +279,12 @@ class Poll extends Attachable return $poll; } - function save(): void + function save(?bool $log = false): void { if(empty($this->choicesToPersist)) throw new InvalidStateException; - parent::save(); + parent::save($log); foreach($this->choicesToPersist as $option) { DatabaseConnection::i()->getContext()->table("poll_options")->insert([ "poll" => $this->getId(), diff --git a/Web/Models/Entities/Report.php b/Web/Models/Entities/Report.php index d449a2e8..5487056f 100644 --- a/Web/Models/Entities/Report.php +++ b/Web/Models/Entities/Report.php @@ -5,7 +5,7 @@ use Nette\Database\Table\ActiveRow; use openvk\Web\Models\RowModel; use openvk\Web\Models\Entities\Club; use Chandler\Database\DatabaseConnection; -use openvk\Web\Models\Repositories\{Applications, Comments, Notes, Reports, Users, Posts, Photos, Videos, Clubs}; +use openvk\Web\Models\Repositories\{Applications, Comments, Notes, Reports, Audios, Users, Posts, Photos, Videos, Clubs}; use Chandler\Database\DatabaseConnection as DB; use Nette\InvalidStateException as ISE; use Nette\Database\Table\Selection; @@ -74,6 +74,7 @@ class Report extends RowModel else if ($this->getContentType() == "note") return (new Notes)->get($this->getContentId()); else if ($this->getContentType() == "app") return (new Applications)->get($this->getContentId()); else if ($this->getContentType() == "user") return (new Users)->get($this->getContentId()); + else if ($this->getContentType() == "audio") return (new Audios)->get($this->getContentId()); else return null; } diff --git a/Web/Models/Entities/Traits/TAudioStatuses.php b/Web/Models/Entities/Traits/TAudioStatuses.php new file mode 100644 index 00000000..f957a104 --- /dev/null +++ b/Web/Models/Entities/Traits/TAudioStatuses.php @@ -0,0 +1,38 @@ +getRealId() < 0) return true; + return (bool) $this->getRecord()->audio_broadcast_enabled; + } + + function getCurrentAudioStatus() + { + if(!$this->isBroadcastEnabled()) return NULL; + + $audioId = $this->getRecord()->last_played_track; + + if(!$audioId) return NULL; + $audio = (new Audios)->get($audioId); + + if(!$audio || $audio->isDeleted()) + return NULL; + + $listensTable = DatabaseConnection::i()->getContext()->table("audio_listens"); + $lastListen = $listensTable->where([ + "entity" => $this->getRealId(), + "audio" => $audio->getId(), + "time >" => (time() - $audio->getLength()) - 10, + ])->fetch(); + + if($lastListen) + return $audio; + + return NULL; + } +} diff --git a/Web/Models/Entities/Traits/TOwnable.php b/Web/Models/Entities/Traits/TOwnable.php index 9dc9ce2a..08e5fde3 100644 --- a/Web/Models/Entities/Traits/TOwnable.php +++ b/Web/Models/Entities/Traits/TOwnable.php @@ -4,6 +4,12 @@ use openvk\Web\Models\Entities\User; trait TOwnable { + function canBeViewedBy(?User $user): bool + { + // TODO implement normal check in master + return true; + } + function canBeModifiedBy(User $user): bool { if(method_exists($this, "isCreatedBySystem")) diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index aaf00ec9..40ef7aef 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -4,7 +4,7 @@ use morphos\Gender; use openvk\Web\Themes\{Themepack, Themepacks}; use openvk\Web\Util\DateTime; use openvk\Web\Models\RowModel; -use openvk\Web\Models\Entities\{Photo, Message, Correspondence, Gift}; +use openvk\Web\Models\Entities\{Photo, Message, Correspondence, Gift, Audio}; use openvk\Web\Models\Repositories\{Applications, Bans, Comments, Notes, Posts, Users, Clubs, Albums, Gifts, Notifications, Videos, Photos}; use openvk\Web\Models\Exceptions\InvalidUserNameException; use Nette\Database\Table\ActiveRow; @@ -190,7 +190,7 @@ class User extends RowModel function getMorphedName(string $case = "genitive", bool $fullName = true): string { $name = $fullName ? ($this->getLastName() . " " . $this->getFirstName()) : $this->getFirstName(); - if(!preg_match("%^[А-яё\-]+$%", $name)) + if(!preg_match("%[А-яё\-]+$%", $name)) return $name; # name is probably not russian $inflected = inflectName($name, $case, $this->isFemale() ? Gender::FEMALE : Gender::MALE); @@ -455,6 +455,7 @@ class User extends RowModel "length" => 1, "mappings" => [ "photos", + "audios", "videos", "messages", "notes", @@ -462,7 +463,7 @@ class User extends RowModel "news", "links", "poster", - "apps" + "apps", ], ])->get($id); } @@ -482,6 +483,7 @@ class User extends RowModel "friends.add", "wall.write", "messages.write", + "audios.read", ], ])->get($id); } @@ -720,8 +722,8 @@ class User extends RowModel for($i = 0; $i < 10 - $this->get2faBackupCodeCount(); $i++) { $codes[] = [ - owner => $this->getId(), - code => random_int(10000000, 99999999) + "owner" => $this->getId(), + "code" => random_int(10000000, 99999999) ]; } @@ -1010,6 +1012,7 @@ class User extends RowModel "friends.add", "wall.write", "messages.write", + "audios.read", ], ])->set($id, $status)->toInteger()); } @@ -1020,6 +1023,7 @@ class User extends RowModel "length" => 1, "mappings" => [ "photos", + "audios", "videos", "messages", "notes", @@ -1027,7 +1031,7 @@ class User extends RowModel "news", "links", "poster", - "apps" + "apps", ], ])->set($id, (int) $status)->toInteger(); @@ -1223,6 +1227,11 @@ class User extends RowModel return $response; } + function getRealId() + { + return $this->getId(); + } + function toVkApiStruct(): object { $res = (object) []; @@ -1239,7 +1248,47 @@ class User extends RowModel return $res; } + + function getAudiosCollectionSize() + { + return (new \openvk\Web\Models\Repositories\Audios)->getUserCollectionSize($this); + } + + function getBroadcastList(string $filter = "friends", bool $shuffle = false) + { + $dbContext = DatabaseConnection::i()->getContext(); + $entityIds = []; + $query = $dbContext->table("subscriptions")->where("follower", $this->getRealId()); + + if($filter != "all") + $query = $query->where("model = ?", "openvk\\Web\\Models\\Entities\\" . ($filter == "groups" ? "Club" : "User")); + + foreach($query as $_rel) { + $entityIds[] = $_rel->model == "openvk\\Web\\Models\\Entities\\Club" ? $_rel->target * -1 : $_rel->target; + } + + if($shuffle) { + $shuffleSeed = openssl_random_pseudo_bytes(6); + $shuffleSeed = hexdec(bin2hex($shuffleSeed)); + $entityIds = knuth_shuffle($entityIds, $shuffleSeed); + } + + $entityIds = array_slice($entityIds, 0, 10); + + $returnArr = []; + + foreach($entityIds as $id) { + $entit = $id > 0 ? (new Users)->get($id) : (new Clubs)->get(abs($id)); + + if($id > 0 && $entit->isDeleted()) continue; + $returnArr[] = $entit; + } + + return $returnArr; + } + use Traits\TBackDrops; use Traits\TSubscribable; + use Traits\TAudioStatuses; } diff --git a/Web/Models/Entities/Video.php b/Web/Models/Entities/Video.php index 2e56414c..a9d565c5 100644 --- a/Web/Models/Entities/Video.php +++ b/Web/Models/Entities/Video.php @@ -1,7 +1,7 @@ $owner, "id" => $id ])->fetch(); - - return new Album($album); + + return $album ? new Album($album) : NULL; } } diff --git a/Web/Models/Repositories/Audios.php b/Web/Models/Repositories/Audios.php new file mode 100644 index 00000000..64457299 --- /dev/null +++ b/Web/Models/Repositories/Audios.php @@ -0,0 +1,296 @@ +context = DatabaseConnection::i()->getContext(); + $this->audios = $this->context->table("audios"); + $this->rels = $this->context->table("audio_relations"); + + $this->playlists = $this->context->table("playlists"); + $this->playlistImports = $this->context->table("playlist_imports"); + $this->playlistRels = $this->context->table("playlist_relations"); + } + + function get(int $id): ?Audio + { + $audio = $this->audios->get($id); + if(!$audio) + return NULL; + + return new Audio($audio); + } + + function getPlaylist(int $id): ?Playlist + { + $playlist = $this->playlists->get($id); + if(!$playlist) + return NULL; + + return new Playlist($playlist); + } + + function getByOwnerAndVID(int $owner, int $vId): ?Audio + { + $audio = $this->audios->where([ + "owner" => $owner, + "virtual_id" => $vId, + ])->fetch(); + if(!$audio) return NULL; + + return new Audio($audio); + } + + function getPlaylistByOwnerAndVID(int $owner, int $vId): ?Playlist + { + $playlist = $this->playlists->where([ + "owner" => $owner, + "id" => $vId, + ])->fetch(); + if(!$playlist) return NULL; + + return new Playlist($playlist); + } + + function getByEntityID(int $entity, int $offset = 0, ?int $limit = NULL, ?int& $deleted = nullptr): \Traversable + { + $limit ??= OPENVK_DEFAULT_PER_PAGE; + $iter = $this->rels->where("entity", $entity)->limit($limit, $offset)->order("index DESC"); + foreach($iter as $rel) { + $audio = $this->get($rel->audio); + if(!$audio || $audio->isDeleted()) { + $deleted++; + continue; + } + + yield $audio; + } + } + + function getPlaylistsByEntityId(int $entity, int $offset = 0, ?int $limit = NULL, ?int& $deleted = nullptr): \Traversable + { + $limit ??= OPENVK_DEFAULT_PER_PAGE; + $iter = $this->playlistImports->where("entity", $entity)->limit($limit, $offset); + foreach($iter as $rel) { + $playlist = $this->getPlaylist($rel->playlist); + if(!$playlist || $playlist->isDeleted()) { + $deleted++; + continue; + } + + yield $playlist; + } + } + + function getByUser(User $user, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + return $this->getByEntityID($user->getId(), ($perPage * ($page - 1)), $perPage, $deleted); + } + + function getRandomThreeAudiosByEntityId(int $id): Array + { + $iter = $this->rels->where("entity", $id); + $ids = []; + + foreach($iter as $it) + $ids[] = $it->audio; + + $shuffleSeed = openssl_random_pseudo_bytes(6); + $shuffleSeed = hexdec(bin2hex($shuffleSeed)); + + $ids = knuth_shuffle($ids, $shuffleSeed); + $ids = array_slice($ids, 0, 3); + $audios = []; + + foreach($ids as $id) { + $audio = $this->get((int)$id); + + if(!$audio || $audio->isDeleted()) + continue; + + $audios[] = $audio; + } + + return $audios; + } + + function getByClub(Club $club, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + return $this->getByEntityID($club->getId() * -1, ($perPage * ($page - 1)), $perPage, $deleted); + } + + function getPlaylistsByUser(User $user, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + return $this->getPlaylistsByEntityId($user->getId(), ($perPage * ($page - 1)), $perPage, $deleted); + } + + function getPlaylistsByClub(Club $club, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + return $this->getPlaylistsByEntityId($club->getId() * -1, ($perPage * ($page - 1)), $perPage, $deleted); + } + + function getCollectionSizeByEntityId(int $id): int + { + return sizeof($this->rels->where("entity", $id)); + } + + function getUserCollectionSize(User $user): int + { + return sizeof($this->rels->where("entity", $user->getId())); + } + + function getClubCollectionSize(Club $club): int + { + return sizeof($this->rels->where("entity", $club->getId() * -1)); + } + + function getUserPlaylistsCount(User $user): int + { + return sizeof($this->playlistImports->where("entity", $user->getId())); + } + + function getClubPlaylistsCount(Club $club): int + { + return sizeof($this->playlistImports->where("entity", $club->getId() * -1)); + } + + function getByUploader(User $user): EntityStream + { + $search = $this->audios->where([ + "owner" => $user->getId(), + "deleted" => 0, + ]); + + return new EntityStream("Audio", $search); + } + + function getGlobal(int $order, ?string $genreId = NULL): EntityStream + { + $search = $this->audios->where([ + "deleted" => 0, + "unlisted" => 0, + "withdrawn" => 0, + ])->order($order == Audios::ORDER_NEW ? "created DESC" : "listens DESC"); + + if(!is_null($genreId)) + $search = $search->where("genre", $genreId); + + return new EntityStream("Audio", $search); + } + + function search(string $query, int $sortMode = 0, bool $performerOnly = false, bool $withLyrics = false): EntityStream + { + $columns = $performerOnly ? "performer" : "performer, name"; + $order = (["created", "length", "listens"][$sortMode] ?? "") . " DESC"; + + $search = $this->audios->where([ + "unlisted" => 0, + "deleted" => 0, + ])->where("MATCH ($columns) AGAINST (? WITH QUERY EXPANSION)", $query)->order($order); + + if($withLyrics) + $search = $search->where("lyrics IS NOT NULL"); + + return new EntityStream("Audio", $search); + } + + function searchPlaylists(string $query): EntityStream + { + $search = $this->playlists->where([ + "deleted" => 0, + ])->where("MATCH (`name`, `description`) AGAINST (? IN BOOLEAN MODE)", $query); + + return new EntityStream("Playlist", $search); + } + + function getNew(): EntityStream + { + return new EntityStream("Audio", $this->audios->where("created >= " . (time() - 259200))->where(["withdrawn" => 0, "deleted" => 0, "unlisted" => 0])->order("created DESC")->limit(25)); + } + + function getPopular(): EntityStream + { + return new EntityStream("Audio", $this->audios->where("listens > 0")->where(["withdrawn" => 0, "deleted" => 0, "unlisted" => 0])->order("listens DESC")->limit(25)); + } + + function isAdded(int $user_id, int $audio_id): bool + { + return !is_null($this->rels->where([ + "entity" => $user_id, + "audio" => $audio_id + ])->fetch()); + } + + function find(string $query, array $pars = [], string $sort = "id DESC", int $page = 1, ?int $perPage = NULL): \Traversable + { + $query = "%$query%"; + $result = $this->audios->where([ + "unlisted" => 0, + "deleted" => 0, + ]); + + $notNullParams = []; + + foreach($pars as $paramName => $paramValue) + if($paramName != "before" && $paramName != "after" && $paramName != "only_performers") + $paramValue != NULL ? $notNullParams+=["$paramName" => "%$paramValue%"] : NULL; + else + $paramValue != NULL ? $notNullParams+=["$paramName" => "$paramValue"] : NULL; + + $nnparamsCount = sizeof($notNullParams); + + if($notNullParams["only_performers"] == "1") { + $result->where("performer LIKE ?", $query); + } else { + $result->where("name LIKE ? OR performer LIKE ?", $query, $query); + } + + if($nnparamsCount > 0) { + foreach($notNullParams as $paramName => $paramValue) { + switch($paramName) { + case "before": + $result->where("created < ?", $paramValue); + break; + case "after": + $result->where("created > ?", $paramValue); + break; + case "with_lyrics": + $result->where("lyrics IS NOT NULL"); + break; + } + } + } + + return new Util\EntityStream("Audio", $result->order($sort)); + } + + function findPlaylists(string $query, int $page = 1, ?int $perPage = NULL): \Traversable + { + $query = "%$query%"; + $result = $this->playlists->where("name LIKE ?", $query); + + return new Util\EntityStream("Playlist", $result); + } +} diff --git a/Web/Models/Repositories/Messages.php b/Web/Models/Repositories/Messages.php index 4c870806..538338ed 100644 --- a/Web/Models/Repositories/Messages.php +++ b/Web/Models/Repositories/Messages.php @@ -52,7 +52,6 @@ class Messages $query = file_get_contents(__DIR__ . "/../sql/get-correspondencies-count.tsql"); DatabaseConnection::i()->getConnection()->query(file_get_contents(__DIR__ . "/../sql/mysql-msg-fix.tsql")); $count = DatabaseConnection::i()->getConnection()->query($query, $id, $class, $id, $class)->fetch()->cnt; - bdump($count); return $count; } } diff --git a/Web/Models/shell/processAudio.ps1 b/Web/Models/shell/processAudio.ps1 new file mode 100644 index 00000000..f60a9aed --- /dev/null +++ b/Web/Models/shell/processAudio.ps1 @@ -0,0 +1,39 @@ +$ovkRoot = $args[0] +$storageDir = $args[1] +$fileHash = $args[2] +$hashPart = $fileHash.substring(0, 2) +$filename = $args[3] +$audioFile = [System.IO.Path]::GetTempFileName() +$temp = [System.IO.Path]::GetTempFileName() + +$keyID = $args[4] +$key = $args[5] +$token = $args[6] +$seg = $args[7] + +$shell = Get-WmiObject Win32_process -filter "ProcessId = $PID" +$shell.SetPriority(16384) # because there's no "nice" program in Windows we just set a lower priority for entire tree + +Remove-Item $temp +Remove-Item $audioFile +New-Item -ItemType "directory" $temp +New-Item -ItemType "directory" ("$temp/$fileHash" + '_fragments') +New-Item -ItemType "directory" ("$storageDir/$hashPart/$fileHash" + '_fragments') +Set-Location -Path $temp + +Move-Item $filename $audioFile +ffmpeg -i $audioFile -f dash -encryption_scheme cenc-aes-ctr -encryption_key $key ` + -encryption_kid $keyID -map 0:a -vn -c:a aac -ar 44100 -seg_duration $seg ` + -use_timeline 1 -use_template 1 -init_seg_name ($fileHash + '_fragments/0_0.$ext$') ` + -media_seg_name ($fileHash + '_fragments/chunk$Number%06d$_$RepresentationID$.$ext$') -adaptation_sets 'id=0,streams=a' ` + "$fileHash.mpd" + +ffmpeg -i $audioFile -vn -ar 44100 "original_$token.mp3" +Move-Item "original_$token.mp3" ($fileHash + '_fragments') + +Get-ChildItem -Path ($fileHash + '_fragments/*') | Move-Item -Destination ("$storageDir/$hashPart/$fileHash" + '_fragments') +Move-Item -Path ("$fileHash.mpd") -Destination "$storageDir/$hashPart" + +cd .. +Remove-Item -Recurse $temp +Remove-Item $audioFile diff --git a/Web/Models/shell/processAudio.sh b/Web/Models/shell/processAudio.sh new file mode 100644 index 00000000..fa8346e0 --- /dev/null +++ b/Web/Models/shell/processAudio.sh @@ -0,0 +1,35 @@ +ovkRoot=$1 +storageDir=$2 +fileHash=$3 +hashPart=$(echo $fileHash | cut -c1-2) +filename=$4 +audioFile=$(mktemp) +temp=$(mktemp -d) + +keyID=$5 +key=$6 +token=$7 +seg=$8 + +trap 'rm -f "$temp" "$audioFile"' EXIT + +mkdir -p "$temp/$fileHash"_fragments +mkdir -p "$storageDir/$hashPart/$fileHash"_fragments +cd "$temp" + +mv "$filename" "$audioFile" +ffmpeg -i "$audioFile" -f dash -encryption_scheme cenc-aes-ctr -encryption_key "$key" \ + -encryption_kid "$keyID" -map 0 -vn -c:a aac -ar 44100 -seg_duration "$seg" \ + -use_timeline 1 -use_template 1 -init_seg_name "$fileHash"_fragments/0_0."\$ext\$" \ + -media_seg_name "$fileHash"_fragments/chunk"\$Number"%06d\$_"\$RepresentationID\$"."\$ext\$" -adaptation_sets 'id=0,streams=a' \ + "$fileHash.mpd" + +ffmpeg -i "$audioFile" -vn -ar 44100 "original_$token.mp3" +mv "original_$token.mp3" "$fileHash"_fragments + +mv "$fileHash"_fragments "$storageDir/$hashPart" +mv "$fileHash.mpd" "$storageDir/$hashPart" + +cd .. +rm -rf "$temp" +rm -f "$audioFile" diff --git a/Web/Presenters/AdminPresenter.php b/Web/Presenters/AdminPresenter.php index 14fbbc74..658d2f4b 100644 --- a/Web/Presenters/AdminPresenter.php +++ b/Web/Presenters/AdminPresenter.php @@ -3,7 +3,19 @@ namespace openvk\Web\Presenters; use Chandler\Database\Log; use Chandler\Database\Logs; use openvk\Web\Models\Entities\{Voucher, Gift, GiftCategory, User, BannedLink}; -use openvk\Web\Models\Repositories\{Bans, ChandlerGroups, ChandlerUsers, Photos, Posts, Users, Clubs, Videos, Vouchers, Gifts, BannedLinks}; +use openvk\Web\Models\Repositories\{Audios, + ChandlerGroups, + ChandlerUsers, + Users, + Clubs, + Util\EntityStream, + Vouchers, + Gifts, + BannedLinks, + Bans, + Photos, + Posts, + Videos}; use Chandler\Database\DatabaseConnection; final class AdminPresenter extends OpenVKPresenter @@ -14,9 +26,10 @@ final class AdminPresenter extends OpenVKPresenter private $gifts; private $bannedLinks; private $chandlerGroups; + private $audios; private $logs; - function __construct(Users $users, Clubs $clubs, Vouchers $vouchers, Gifts $gifts, BannedLinks $bannedLinks, ChandlerGroups $chandlerGroups) + function __construct(Users $users, Clubs $clubs, Vouchers $vouchers, Gifts $gifts, BannedLinks $bannedLinks, ChandlerGroups $chandlerGroups, Audios $audios) { $this->users = $users; $this->clubs = $clubs; @@ -24,8 +37,9 @@ final class AdminPresenter extends OpenVKPresenter $this->gifts = $gifts; $this->bannedLinks = $bannedLinks; $this->chandlerGroups = $chandlerGroups; + $this->audios = $audios; $this->logs = DatabaseConnection::i()->getContext()->table("ChandlerLogs"); - + parent::__construct(); } @@ -43,6 +57,15 @@ final class AdminPresenter extends OpenVKPresenter $count = $repo->find($query)->size(); return $repo->find($query)->page($page, 20); } + + private function searchPlaylists(&$count) + { + $query = $this->queryParam("q") ?? ""; + $page = (int) ($this->queryParam("p") ?? 1); + + $count = $this->audios->findPlaylists($query)->size(); + return $this->audios->findPlaylists($query)->page($page, 20); + } function onStartup(): void { @@ -578,6 +601,54 @@ final class AdminPresenter extends OpenVKPresenter $this->redirect("/admin/users/id" . $user->getId()); } + function renderMusic(): void + { + $this->template->mode = in_array($this->queryParam("act"), ["audios", "playlists"]) ? $this->queryParam("act") : "audios"; + if ($this->template->mode === "audios") + $this->template->audios = $this->searchResults($this->audios, $this->template->count); + else + $this->template->playlists = $this->searchPlaylists($this->template->count); + } + + function renderEditMusic(int $audio_id): void + { + $audio = $this->audios->get($audio_id); + $this->template->audio = $audio; + + try { + $this->template->owner = $audio->getOwner()->getId(); + } catch(\Throwable $e) { + $this->template->owner = 1; + } + + if ($_SERVER["REQUEST_METHOD"] === "POST") { + $audio->setName($this->postParam("name")); + $audio->setPerformer($this->postParam("performer")); + $audio->setLyrics($this->postParam("text")); + $audio->setGenre($this->postParam("genre")); + $audio->setOwner((int) $this->postParam("owner")); + $audio->setExplicit(!empty($this->postParam("explicit"))); + $audio->setDeleted(!empty($this->postParam("deleted"))); + $audio->setWithdrawn(!empty($this->postParam("withdrawn"))); + $audio->save(); + } + } + + function renderEditPlaylist(int $playlist_id): void + { + $playlist = $this->audios->getPlaylist($playlist_id); + $this->template->playlist = $playlist; + + if ($_SERVER["REQUEST_METHOD"] === "POST") { + $playlist->setName($this->postParam("name")); + $playlist->setDescription($this->postParam("description")); + $playlist->setCover_Photo_Id((int) $this->postParam("photo")); + $playlist->setOwner((int) $this->postParam("owner")); + $playlist->setDeleted(!empty($this->postParam("deleted"))); + $playlist->save(); + } + } + function renderLogs(): void { $filter = []; diff --git a/Web/Presenters/AudioPresenter.php b/Web/Presenters/AudioPresenter.php new file mode 100644 index 00000000..8ec17012 --- /dev/null +++ b/Web/Presenters/AudioPresenter.php @@ -0,0 +1,696 @@ +audios = $audios; + } + + function renderPopular(): void + { + $this->renderList(NULL, "popular"); + } + + function renderNew(): void + { + $this->renderList(NULL, "new"); + } + + function renderList(?int $owner = NULL, ?string $mode = "list"): void + { + $this->template->_template = "Audio/List.xml"; + $page = (int)($this->queryParam("p") ?? 1); + $audios = []; + + if ($mode === "list") { + $entity = NULL; + if ($owner < 0) { + $entity = (new Clubs)->get($owner * -1); + if (!$entity || $entity->isBanned()) + $this->redirect("/audios" . $this->user->id); + + $audios = $this->audios->getByClub($entity, $page, 10); + $audiosCount = $this->audios->getClubCollectionSize($entity); + } else { + $entity = (new Users)->get($owner); + if (!$entity || $entity->isDeleted() || $entity->isBanned()) + $this->redirect("/audios" . $this->user->id); + + if(!$entity->getPrivacyPermission("audios.read", $this->user->identity)) + $this->flashFail("err", tr("forbidden"), tr("forbidden_comment")); + + $audios = $this->audios->getByUser($entity, $page, 10); + $audiosCount = $this->audios->getUserCollectionSize($entity); + } + + if (!$entity) + $this->notFound(); + + $this->template->owner = $entity; + $this->template->ownerId = $owner; + $this->template->club = $owner < 0 ? $entity : NULL; + $this->template->isMy = ($owner > 0 && ($entity->getId() === $this->user->id)); + $this->template->isMyClub = ($owner < 0 && $entity->canBeModifiedBy($this->user->identity)); + } else if ($mode === "new") { + $audios = $this->audios->getNew(); + $audiosCount = $audios->size(); + } else if ($mode === "playlists") { + if($owner < 0) { + $entity = (new Clubs)->get(abs($owner)); + if (!$entity || $entity->isBanned()) + $this->redirect("/playlists" . $this->user->id); + + $playlists = $this->audios->getPlaylistsByClub($entity, $page, 10); + $playlistsCount = $this->audios->getClubPlaylistsCount($entity); + } else { + $entity = (new Users)->get($owner); + if (!$entity || $entity->isDeleted() || $entity->isBanned()) + $this->redirect("/playlists" . $this->user->id); + + if(!$entity->getPrivacyPermission("audios.read", $this->user->identity)) + $this->flashFail("err", tr("forbidden"), tr("forbidden_comment")); + + $playlists = $this->audios->getPlaylistsByUser($entity, $page, 9); + $playlistsCount = $this->audios->getUserPlaylistsCount($entity); + } + + $this->template->playlists = iterator_to_array($playlists); + $this->template->playlistsCount = $playlistsCount; + $this->template->owner = $entity; + $this->template->ownerId = $owner; + $this->template->club = $owner < 0 ? $entity : NULL; + $this->template->isMy = ($owner > 0 && ($entity->getId() === $this->user->id)); + $this->template->isMyClub = ($owner < 0 && $entity->canBeModifiedBy($this->user->identity)); + } else { + $audios = $this->audios->getPopular(); + $audiosCount = $audios->size(); + } + + // $this->renderApp("owner=$owner"); + if ($audios !== []) { + $this->template->audios = iterator_to_array($audios); + $this->template->audiosCount = $audiosCount; + } + + $this->template->mode = $mode; + $this->template->page = $page; + + if(in_array($mode, ["list", "new", "popular"]) && $this->user->identity) + $this->template->friendsAudios = $this->user->identity->getBroadcastList("all", true); + } + + function renderEmbed(int $owner, int $id): void + { + $audio = $this->audios->getByOwnerAndVID($owner, $id); + if(!$audio) { + header("HTTP/1.1 404 Not Found"); + exit("" . tr("audio_embed_not_found") . "."); + } else if($audio->isDeleted()) { + header("HTTP/1.1 410 Not Found"); + exit("" . tr("audio_embed_deleted") . "."); + } else if($audio->isWithdrawn()) { + header("HTTP/1.1 451 Unavailable for legal reasons"); + exit("" . tr("audio_embed_withdrawn") . "."); + } else if(!$audio->canBeViewedBy(NULL)) { + header("HTTP/1.1 403 Forbidden"); + exit("" . tr("audio_embed_forbidden") . "."); + } else if(!$audio->isAvailable()) { + header("HTTP/1.1 425 Too Early"); + exit("" . tr("audio_embed_processing") . "."); + } + + $this->template->audio = $audio; + } + + function renderUpload(): void + { + $this->assertUserLoggedIn(); + + $group = NULL; + $isAjax = $this->postParam("ajax", false) == 1; + if(!is_null($this->queryParam("gid"))) { + $gid = (int) $this->queryParam("gid"); + $group = (new Clubs)->get($gid); + if(!$group) + $this->flashFail("err", tr("forbidden"), tr("not_enough_permissions_comment"), null, $isAjax); + + if(!$group->canUploadAudio($this->user->identity)) + $this->flashFail("err", tr("forbidden"), tr("not_enough_permissions_comment"), null, $isAjax); + } + + $this->template->group = $group; + + if($_SERVER["REQUEST_METHOD"] !== "POST") + return; + + $upload = $_FILES["blob"]; + if(isset($upload) && file_exists($upload["tmp_name"])) { + if($upload["size"] > self::MAX_AUDIO_SIZE) + $this->flashFail("err", tr("error"), tr("media_file_corrupted_or_too_large"), null, $isAjax); + } else { + $err = !isset($upload) ? 65536 : $upload["error"]; + $err = str_pad(dechex($err), 9, "0", STR_PAD_LEFT); + $readableError = tr("error_generic"); + + switch($upload["error"]) { + default: + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + $readableError = tr("file_too_big"); + break; + case UPLOAD_ERR_PARTIAL: + $readableError = tr("file_loaded_partially"); + break; + case UPLOAD_ERR_NO_FILE: + $readableError = tr("file_not_uploaded"); + break; + case UPLOAD_ERR_NO_TMP_DIR: + $readableError = "Missing a temporary folder."; + break; + case UPLOAD_ERR_CANT_WRITE: + case UPLOAD_ERR_EXTENSION: + $readableError = "Failed to write file to disk. "; + break; + } + + $this->flashFail("err", tr("error"), $readableError . " " . tr("error_code", $err), null, $isAjax); + } + + $performer = $this->postParam("performer"); + $name = $this->postParam("name"); + $lyrics = $this->postParam("lyrics"); + $genre = empty($this->postParam("genre")) ? "Other" : $this->postParam("genre"); + $nsfw = ($this->postParam("explicit") ?? "off") === "on"; + if(empty($performer) || empty($name) || iconv_strlen($performer . $name) > 128) # FQN of audio must not be more than 128 chars + $this->flashFail("err", tr("error"), tr("error_insufficient_info"), null, $isAjax); + + $audio = new Audio; + $audio->setOwner($this->user->id); + $audio->setName($name); + $audio->setPerformer($performer); + $audio->setLyrics(empty($lyrics) ? NULL : $lyrics); + $audio->setGenre($genre); + $audio->setExplicit($nsfw); + + try { + $audio->setFile($upload); + } catch(\DomainException $ex) { + $e = $ex->getMessage(); + $this->flashFail("err", tr("error"), tr("media_file_corrupted_or_too_large") . " $e.", null, $isAjax); + } catch(\RuntimeException $ex) { + $this->flashFail("err", tr("error"), tr("ffmpeg_timeout"), null, $isAjax); + } catch(\BadMethodCallException $ex) { + $this->flashFail("err", tr("error"), "Загрузка аудио под Linux на данный момент не реализована. Следите за обновлениями: https://github.com/openvk/openvk/pull/512/commits", null, $isAjax); + } catch(\Exception $ex) { + $this->flashFail("err", tr("error"), tr("ffmpeg_not_installed"), null, $isAjax); + } + + $audio->save(); + $audio->add($group ?? $this->user->identity); + + if(!$isAjax) + $this->redirect(is_null($group) ? "/audios" . $this->user->id : "/audios-" . $group->getId()); + else { + $redirectLink = "/audios"; + + if(!is_null($group)) + $redirectLink .= $group->getRealId(); + else + $redirectLink .= $this->user->id; + + $pagesCount = (int)ceil((new Audios)->getCollectionSizeByEntityId(isset($group) ? $group->getRealId() : $this->user->id) / 10); + $redirectLink .= "?p=".$pagesCount; + + $this->returnJson([ + "success" => true, + "redirect_link" => $redirectLink, + ]); + } + } + + function renderListen(int $id): void + { + if ($_SERVER["REQUEST_METHOD"] === "POST") { + $this->assertNoCSRF(); + + if(is_null($this->user)) + $this->returnJson(["success" => false]); + + $audio = $this->audios->get($id); + + if ($audio && !$audio->isDeleted() && !$audio->isWithdrawn()) { + if(!empty($this->postParam("playlist"))) { + $playlist = (new Audios)->getPlaylist((int)$this->postParam("playlist")); + + if(!$playlist || $playlist->isDeleted() || !$playlist->canBeViewedBy($this->user->identity) || !$playlist->hasAudio($audio)) + $playlist = NULL; + } + + $listen = $audio->listen($this->user->identity, $playlist); + + $returnArr = ["success" => $listen]; + + if($playlist) + $returnArr["new_playlists_listens"] = $playlist->getListens(); + + $this->returnJson($returnArr); + } + + $this->returnJson(["success" => false]); + } else { + $this->redirect("/"); + } + } + + function renderSearch(): void + { + $this->redirect("/search?type=audios"); + } + + function renderNewPlaylist(): void + { + $this->assertUserLoggedIn(); + $this->willExecuteWriteAction(true); + + $owner = $this->user->id; + + if ($this->requestParam("gid")) { + $club = (new Clubs)->get((int) abs((int)$this->requestParam("gid"))); + if (!$club || $club->isBanned() || !$club->canBeModifiedBy($this->user->identity)) + $this->redirect("/audios" . $this->user->id); + + $owner = ($club->getId() * -1); + + $this->template->club = $club; + } + + $this->template->owner = $owner; + + if ($_SERVER["REQUEST_METHOD"] === "POST") { + $title = $this->postParam("title"); + $description = $this->postParam("description"); + $audios = !empty($this->postParam("audios")) ? array_slice(explode(",", $this->postParam("audios")), 0, 1000) : []; + + if(empty($title) || iconv_strlen($title) < 1) + $this->flashFail("err", tr("error"), tr("set_playlist_name")); + + $playlist = new Playlist; + $playlist->setOwner($owner); + $playlist->setName(substr($title, 0, 125)); + $playlist->setDescription(substr($description, 0, 2045)); + + if($_FILES["cover"]["error"] === UPLOAD_ERR_OK) { + if(!str_starts_with($_FILES["cover"]["type"], "image")) + $this->flashFail("err", tr("error"), tr("not_a_photo")); + + try { + $playlist->fastMakeCover($this->user->id, $_FILES["cover"]); + } catch(\Throwable $e) { + $this->flashFail("err", tr("error"), tr("invalid_cover_photo")); + } + } + + $playlist->save(); + + foreach($audios as $audio) { + $audio = $this->audios->get((int)$audio); + + if(!$audio || $audio->isDeleted() || !$audio->canBeViewedBy($this->user->identity)) + continue; + + $playlist->add($audio); + } + + $playlist->bookmark(isset($club) ? $club : $this->user->identity); + $this->redirect("/playlist" . $owner . "_" . $playlist->getId()); + } else { + if(isset($club)) { + $this->template->audios = iterator_to_array($this->audios->getByClub($club, 1, 10)); + $count = (new Audios)->getClubCollectionSize($club); + } else { + $this->template->audios = iterator_to_array($this->audios->getByUser($this->user->identity, 1, 10)); + $count = (new Audios)->getUserCollectionSize($this->user->identity); + } + + $this->template->pagesCount = ceil($count / 10); + } + } + + function renderPlaylistAction(int $id) { + $this->assertUserLoggedIn(); + $this->willExecuteWriteAction(true); + $this->assertNoCSRF(); + + if ($_SERVER["REQUEST_METHOD"] !== "POST") { + header("HTTP/1.1 405 Method Not Allowed"); + $this->redirect("/"); + } + + $playlist = $this->audios->getPlaylist($id); + + if(!$playlist || $playlist->isDeleted()) + $this->flashFail("err", "error", tr("invalid_playlist"), null, true); + + switch ($this->queryParam("act")) { + case "bookmark": + if(!$playlist->isBookmarkedBy($this->user->identity)) + $playlist->bookmark($this->user->identity); + else + $this->flashFail("err", "error", tr("playlist_already_bookmarked"), null, true); + + break; + case "unbookmark": + if($playlist->isBookmarkedBy($this->user->identity)) + $playlist->unbookmark($this->user->identity); + else + $this->flashFail("err", "error", tr("playlist_not_bookmarked"), null, true); + + break; + case "delete": + if($playlist->canBeModifiedBy($this->user->identity)) { + $tmOwner = $playlist->getOwner(); + $playlist->delete(); + } else + $this->flashFail("err", "error", tr("access_denied"), null, true); + + $this->returnJson(["success" => true, "id" => $tmOwner->getRealId()]); + break; + default: + break; + } + + $this->returnJson(["success" => true]); + } + + function renderEditPlaylist(int $owner_id, int $virtual_id) + { + $this->assertUserLoggedIn(); + $this->willExecuteWriteAction(); + + $playlist = $this->audios->getPlaylistByOwnerAndVID($owner_id, $virtual_id); + $page = (int)($this->queryParam("p") ?? 1); + if (!$playlist || $playlist->isDeleted() || !$playlist->canBeModifiedBy($this->user->identity)) + $this->notFound(); + + $this->template->playlist = $playlist; + $this->template->page = $page; + + $audios = iterator_to_array($playlist->fetch(1, $playlist->size())); + $this->template->audios = array_slice($audios, 0, 10); + $audiosIds = []; + + foreach($audios as $aud) + $audiosIds[] = $aud->getId(); + + $this->template->audiosIds = implode(",", array_unique($audiosIds)) . ","; + $this->template->ownerId = $owner_id; + $this->template->owner = $playlist->getOwner(); + $this->template->pagesCount = $pagesCount = ceil($playlist->size() / 10); + + if($_SERVER["REQUEST_METHOD"] !== "POST") + return; + + $title = $this->postParam("title"); + $description = $this->postParam("description"); + $new_audios = !empty($this->postParam("audios")) ? explode(",", rtrim($this->postParam("audios"), ",")) : []; + + if(empty($title) || iconv_strlen($title) < 1) + $this->flashFail("err", tr("error"), tr("set_playlist_name")); + + $playlist->setName(ovk_proc_strtr($title, 125)); + $playlist->setDescription(ovk_proc_strtr($description, 2045)); + $playlist->setEdited(time()); + $playlist->resetLength(); + + if($_FILES["new_cover"]["error"] === UPLOAD_ERR_OK) { + if(!str_starts_with($_FILES["new_cover"]["type"], "image")) + $this->flashFail("err", tr("error"), tr("not_a_photo")); + + try { + $playlist->fastMakeCover($this->user->id, $_FILES["new_cover"]); + } catch(\Throwable $e) { + $this->flashFail("err", tr("error"), tr("invalid_cover_photo")); + } + } + + $playlist->save(); + + DatabaseConnection::i()->getContext()->table("playlist_relations")->where([ + "collection" => $playlist->getId() + ])->delete(); + + foreach ($new_audios as $new_audio) { + $audio = (new Audios)->get((int)$new_audio); + + if(!$audio || $audio->isDeleted()) + continue; + + $playlist->add($audio); + } + + $this->redirect("/playlist".$playlist->getPrettyId()); + } + + function renderPlaylist(int $owner_id, int $virtual_id): void + { + $playlist = $this->audios->getPlaylistByOwnerAndVID($owner_id, $virtual_id); + $page = (int)($this->queryParam("p") ?? 1); + if (!$playlist || $playlist->isDeleted()) + $this->notFound(); + + $this->template->playlist = $playlist; + $this->template->page = $page; + $this->template->audios = iterator_to_array($playlist->fetch($page, 10)); + $this->template->ownerId = $owner_id; + $this->template->owner = $playlist->getOwner(); + $this->template->isBookmarked = $this->user->identity && $playlist->isBookmarkedBy($this->user->identity); + $this->template->isMy = $this->user->identity && $playlist->getOwner()->getId() === $this->user->id; + $this->template->canEdit = $this->user->identity && $playlist->canBeModifiedBy($this->user->identity); + } + + function renderAction(int $audio_id): void + { + $this->assertUserLoggedIn(); + $this->willExecuteWriteAction(true); + $this->assertNoCSRF(); + + if ($_SERVER["REQUEST_METHOD"] !== "POST") { + header("HTTP/1.1 405 Method Not Allowed"); + $this->redirect("/"); + } + + $audio = $this->audios->get($audio_id); + + if(!$audio || $audio->isDeleted()) + $this->flashFail("err", "error", tr("invalid_audio"), null, true); + + switch ($this->queryParam("act")) { + case "add": + if($audio->isWithdrawn()) + $this->flashFail("err", "error", tr("invalid_audio"), null, true); + + if(!$audio->isInLibraryOf($this->user->identity)) + $audio->add($this->user->identity); + else + $this->flashFail("err", "error", tr("do_have_audio"), null, true); + + break; + + case "remove": + if($audio->isInLibraryOf($this->user->identity)) + $audio->remove($this->user->identity); + else + $this->flashFail("err", "error", tr("do_not_have_audio"), null, true); + + break; + case "remove_club": + $club = (new Clubs)->get((int)$this->postParam("club")); + + if(!$club || !$club->canBeModifiedBy($this->user->identity)) + $this->flashFail("err", "error", tr("access_denied"), null, true); + + if($audio->isInLibraryOf($club)) + $audio->remove($club); + else + $this->flashFail("err", "error", tr("group_hasnt_audio"), null, true); + + break; + case "add_to_club": + $club = (new Clubs)->get((int)$this->postParam("club")); + + if(!$club || !$club->canBeModifiedBy($this->user->identity)) + $this->flashFail("err", "error", tr("access_denied"), null, true); + + if(!$audio->isInLibraryOf($club)) + $audio->add($club); + else + $this->flashFail("err", "error", tr("group_has_audio"), null, true); + + break; + case "delete": + if($audio->canBeModifiedBy($this->user->identity)) + $audio->delete(); + else + $this->flashFail("err", "error", tr("access_denied"), null, true); + + break; + case "edit": + $audio = $this->audios->get($audio_id); + if (!$audio || $audio->isDeleted() || $audio->isWithdrawn()) + $this->flashFail("err", "error", tr("invalid_audio"), null, true); + + if ($audio->getOwner()->getId() !== $this->user->id) + $this->flashFail("err", "error", tr("access_denied"), null, true); + + $performer = $this->postParam("performer"); + $name = $this->postParam("name"); + $lyrics = $this->postParam("lyrics"); + $genre = empty($this->postParam("genre")) ? "undefined" : $this->postParam("genre"); + $nsfw = (int)($this->postParam("explicit") ?? 0) === 1; + $unlisted = (int)($this->postParam("unlisted") ?? 0) === 1; + if(empty($performer) || empty($name) || iconv_strlen($performer . $name) > 128) # FQN of audio must not be more than 128 chars + $this->flashFail("err", tr("error"), tr("error_insufficient_info"), null, true); + + $audio->setName($name); + $audio->setPerformer($performer); + $audio->setLyrics(empty($lyrics) ? NULL : $lyrics); + $audio->setGenre($genre); + $audio->setExplicit($nsfw); + $audio->setSearchability($unlisted); + $audio->setEdited(time()); + $audio->save(); + + $this->returnJson(["success" => true, "new_info" => [ + "name" => ovk_proc_strtr($audio->getTitle(), 40), + "performer" => ovk_proc_strtr($audio->getPerformer(), 40), + "lyrics" => nl2br($audio->getLyrics() ?? ""), + "lyrics_unformatted" => $audio->getLyrics() ?? "", + "explicit" => $audio->isExplicit(), + "genre" => $audio->getGenre(), + "unlisted" => $audio->isUnlisted(), + ]]); + break; + + default: + break; + } + + $this->returnJson(["success" => true]); + } + + function renderPlaylists(int $owner) + { + $this->renderList($owner, "playlists"); + } + + function renderApiGetContext() + { + if ($_SERVER["REQUEST_METHOD"] !== "POST") { + header("HTTP/1.1 405 Method Not Allowed"); + $this->redirect("/"); + } + + $ctx_type = $this->postParam("context"); + $ctx_id = (int)($this->postParam("context_entity")); + $page = (int)($this->postParam("page") ?? 1); + $perPage = 10; + + switch($ctx_type) { + default: + case "entity_audios": + if($ctx_id >= 0) { + $entity = $ctx_id != 0 ? (new Users)->get($ctx_id) : $this->user->identity; + + if(!$entity || !$entity->getPrivacyPermission("audios.read", $this->user->identity)) + $this->flashFail("err", "Error", "Can't get queue", 80, true); + + $audios = $this->audios->getByUser($entity, $page, $perPage); + $audiosCount = $this->audios->getUserCollectionSize($entity); + } else { + $entity = (new Clubs)->get(abs($ctx_id)); + + if(!$entity || $entity->isBanned()) + $this->flashFail("err", "Error", "Can't get queue", 80, true); + + $audios = $this->audios->getByClub($entity, $page, $perPage); + $audiosCount = $this->audios->getClubCollectionSize($entity); + } + break; + case "new_audios": + $audios = $this->audios->getNew(); + $audiosCount = $audios->size(); + break; + case "popular_audios": + $audios = $this->audios->getPopular(); + $audiosCount = $audios->size(); + break; + case "playlist_context": + $playlist = $this->audios->getPlaylist($ctx_id); + + if (!$playlist || $playlist->isDeleted()) + $this->flashFail("err", "Error", "Can't get queue", 80, true); + + $audios = $playlist->fetch($page, 10); + $audiosCount = $playlist->size(); + break; + case "search_context": + $stream = $this->audios->search($this->postParam("query"), 2, $this->postParam("type") === "by_performer"); + $audios = $stream->page($page, 10); + $audiosCount = $stream->size(); + break; + } + + $pagesCount = ceil($audiosCount / $perPage); + + # костылёк для получения плееров в пикере аудиозаписей + if((int)($this->postParam("returnPlayers")) === 1) { + $this->template->audios = $audios; + $this->template->page = $page; + $this->template->pagesCount = $pagesCount; + $this->template->count = $audiosCount; + + return 0; + } + + $audiosArr = []; + + foreach($audios as $audio) { + $audiosArr[] = [ + "id" => $audio->getId(), + "name" => $audio->getTitle(), + "performer" => $audio->getPerformer(), + "keys" => $audio->getKeys(), + "url" => $audio->getUrl(), + "length" => $audio->getLength(), + "available" => $audio->isAvailable(), + "withdrawn" => $audio->isWithdrawn(), + ]; + } + + $resultArr = [ + "success" => true, + "page" => $page, + "perPage" => $perPage, + "pagesCount" => $pagesCount, + "count" => $audiosCount, + "items" => $audiosArr, + ]; + + $this->returnJson($resultArr); + } +} diff --git a/Web/Presenters/BlobPresenter.php b/Web/Presenters/BlobPresenter.php index 7bb3e2be..5987281d 100644 --- a/Web/Presenters/BlobPresenter.php +++ b/Web/Presenters/BlobPresenter.php @@ -18,6 +18,8 @@ final class BlobPresenter extends OpenVKPresenter function renderFile(/*string*/ $dir, string $name, string $format) { + header("Access-Control-Allow-Origin: *"); + $dir = $this->getDirName($dir); $base = realpath(OPENVK_ROOT . "/storage/$dir"); $path = realpath(OPENVK_ROOT . "/storage/$dir/$name.$format"); @@ -37,5 +39,5 @@ final class BlobPresenter extends OpenVKPresenter readfile($path); exit; - } + } } diff --git a/Web/Presenters/CommentPresenter.php b/Web/Presenters/CommentPresenter.php index e005af86..86ccb126 100644 --- a/Web/Presenters/CommentPresenter.php +++ b/Web/Presenters/CommentPresenter.php @@ -2,7 +2,7 @@ namespace openvk\Web\Presenters; use openvk\Web\Models\Entities\{Comment, Notifications\MentionNotification, Photo, Video, User, Topic, Post}; use openvk\Web\Models\Entities\Notifications\CommentNotification; -use openvk\Web\Models\Repositories\{Comments, Clubs, Videos, Photos}; +use openvk\Web\Models\Repositories\{Comments, Clubs, Videos, Photos, Audios}; final class CommentPresenter extends OpenVKPresenter { @@ -103,8 +103,27 @@ final class CommentPresenter extends OpenVKPresenter } } } + + $audios = []; + + if(!empty($this->postParam("audios"))) { + $un = rtrim($this->postParam("audios"), ","); + $arr = explode(",", $un); + + if(sizeof($arr) < 11) { + foreach($arr as $dat) { + $ids = explode("_", $dat); + $audio = (new Audios)->getByOwnerAndVID((int)$ids[0], (int)$ids[1]); + + if(!$audio || $audio->isDeleted()) + continue; + + $audios[] = $audio; + } + } + } - if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1) + if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1 && sizeof($audios) < 1) $this->flashFail("err", tr("error_when_publishing_comment"), tr("error_comment_empty")); try { @@ -126,6 +145,9 @@ final class CommentPresenter extends OpenVKPresenter if(sizeof($videos) > 0) foreach($videos as $vid) $comment->attach($vid); + + foreach($audios as $audio) + $comment->attach($audio); if($entity->getOwner()->getId() !== $this->user->identity->getId()) if(($owner = $entity->getOwner()) instanceof User) diff --git a/Web/Presenters/GroupPresenter.php b/Web/Presenters/GroupPresenter.php index d3a46fd5..eb8f446c 100644 --- a/Web/Presenters/GroupPresenter.php +++ b/Web/Presenters/GroupPresenter.php @@ -3,7 +3,7 @@ namespace openvk\Web\Presenters; use openvk\Web\Models\Entities\{Club, Photo, Post}; use Nette\InvalidStateException; use openvk\Web\Models\Entities\Notifications\ClubModeratorNotification; -use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers, Topics}; +use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers, Topics, Audios}; use Chandler\Security\Authenticator; final class GroupPresenter extends OpenVKPresenter @@ -31,6 +31,8 @@ final class GroupPresenter extends OpenVKPresenter $this->template->albumsCount = (new Albums)->getClubAlbumsCount($club); $this->template->topics = (new Topics)->getLastTopics($club, 3); $this->template->topicsCount = (new Topics)->getClubTopicsCount($club); + $this->template->audios = (new Audios)->getRandomThreeAudiosByEntityId($club->getRealId()); + $this->template->audiosCount = (new Audios)->getClubCollectionSize($club); } $this->template->club = $club; @@ -218,6 +220,7 @@ final class GroupPresenter extends OpenVKPresenter $club->setAdministrators_List_Display(empty($this->postParam("administrators_list_display")) ? 0 : $this->postParam("administrators_list_display")); $club->setEveryone_Can_Create_Topics(empty($this->postParam("everyone_can_create_topics")) ? 0 : 1); $club->setDisplay_Topics_Above_Wall(empty($this->postParam("display_topics_above_wall")) ? 0 : 1); + $club->setEveryone_can_upload_audios(empty($this->postParam("upload_audios")) ? 0 : 1); $club->setHide_From_Global_Feed(empty($this->postParam("hide_from_global_feed")) ? 0 : 1); $website = $this->postParam("website") ?? ""; diff --git a/Web/Presenters/MessengerPresenter.php b/Web/Presenters/MessengerPresenter.php index d5ffb988..e04e1adc 100644 --- a/Web/Presenters/MessengerPresenter.php +++ b/Web/Presenters/MessengerPresenter.php @@ -128,7 +128,7 @@ final class MessengerPresenter extends OpenVKPresenter $messages = []; $correspondence = new Correspondence($this->user->identity, $correspondent); - foreach($correspondence->getMessages(1, $lastMsg === 0 ? NULL : $lastMsg) as $message) + foreach($correspondence->getMessages(1, $lastMsg === 0 ? NULL : $lastMsg, NULL, 0) as $message) $messages[] = $message->simplify(); header("Content-Type: application/json"); diff --git a/Web/Presenters/OpenVKPresenter.php b/Web/Presenters/OpenVKPresenter.php index 80ab0621..a171c2e3 100644 --- a/Web/Presenters/OpenVKPresenter.php +++ b/Web/Presenters/OpenVKPresenter.php @@ -198,6 +198,9 @@ abstract class OpenVKPresenter extends SimplePresenter { $user = Authenticator::i()->getUser(); + if(!$this->template) + $this->template = new \stdClass; + $this->template->isXmas = intval(date('d')) >= 1 && date('m') == 12 || intval(date('d')) <= 15 && date('m') == 1 ? true : false; $this->template->isTimezoned = Session::i()->get("_timezoneOffset"); diff --git a/Web/Presenters/PhotosPresenter.php b/Web/Presenters/PhotosPresenter.php index 0a8b87e4..aeb8ba1e 100644 --- a/Web/Presenters/PhotosPresenter.php +++ b/Web/Presenters/PhotosPresenter.php @@ -336,7 +336,10 @@ final class PhotosPresenter extends OpenVKPresenter if(is_null($this->user) || $this->user->id != $ownerId) $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied")); - $redirect = $photo->getAlbum()->getOwner() instanceof User ? "/id0" : "/club" . $ownerId; + if(!is_null($album = $photo->getAlbum())) + $redirect = $album->getOwner() instanceof User ? "/id0" : "/club" . $ownerId; + else + $redirect = "/id0"; $photo->isolate(); $photo->delete(); diff --git a/Web/Presenters/ReportPresenter.php b/Web/Presenters/ReportPresenter.php index a87154c8..0c4cbd83 100644 --- a/Web/Presenters/ReportPresenter.php +++ b/Web/Presenters/ReportPresenter.php @@ -23,7 +23,7 @@ final class ReportPresenter extends OpenVKPresenter if ($_SERVER["REQUEST_METHOD"] === "POST") $this->assertNoCSRF(); - $act = in_array($this->queryParam("act"), ["post", "photo", "video", "group", "comment", "note", "app", "user"]) ? $this->queryParam("act") : NULL; + $act = in_array($this->queryParam("act"), ["post", "photo", "video", "group", "comment", "note", "app", "user", "audio"]) ? $this->queryParam("act") : NULL; if (!$this->queryParam("orig")) { $this->template->reports = $this->reports->getReports(0, (int)($this->queryParam("p") ?? 1), $act, $_SERVER["REQUEST_METHOD"] !== "POST"); @@ -88,7 +88,7 @@ final class ReportPresenter extends OpenVKPresenter if(!$id) exit(json_encode([ "error" => tr("error_segmentation") ])); - if(in_array($this->queryParam("type"), ["post", "photo", "video", "group", "comment", "note", "app", "user"])) { + if(in_array($this->queryParam("type"), ["post", "photo", "video", "group", "comment", "note", "app", "user", "audio"])) { if (count(iterator_to_array($this->reports->getDuplicates($this->queryParam("type"), $id, NULL, $this->user->id))) <= 0) { $report = new Report; $report->setUser_id($this->user->id); diff --git a/Web/Presenters/SearchPresenter.php b/Web/Presenters/SearchPresenter.php index fadf9954..d80c06c3 100644 --- a/Web/Presenters/SearchPresenter.php +++ b/Web/Presenters/SearchPresenter.php @@ -1,7 +1,7 @@ videos = new Videos; $this->apps = new Applications; $this->notes = new Notes; + $this->audios = new Audios; parent::__construct(); } function renderIndex(): void { + $this->assertUserLoggedIn(); + $query = $this->queryParam("query") ?? ""; $type = $this->queryParam("type") ?? "users"; $sorter = $this->queryParam("sort") ?? "id"; $invert = $this->queryParam("invert") == 1 ? "ASC" : "DESC"; $page = (int) ($this->queryParam("p") ?? 1); - $this->willExecuteWriteAction(); - if($query != "") - $this->assertUserLoggedIn(); - # https://youtu.be/pSAWM5YuXx8 $repos = [ @@ -47,7 +47,7 @@ final class SearchPresenter extends OpenVKPresenter "posts" => "posts", "comments" => "comments", "videos" => "videos", - "audios" => "posts", + "audios" => "audios", "apps" => "apps", "notes" => "notes" ]; @@ -62,7 +62,17 @@ final class SearchPresenter extends OpenVKPresenter break; case "rating": $sort = "rating " . $invert; - break; + break; + case "length": + if($type != "audios") break; + + $sort = "length " . $invert; + break; + case "listens": + if($type != "audios") break; + + $sort = "listens " . $invert; + break; } $parameters = [ @@ -86,18 +96,21 @@ final class SearchPresenter extends OpenVKPresenter "hometown" => $this->queryParam("hometown") != "" ? $this->queryParam("hometown") : NULL, "before" => $this->queryParam("datebefore") != "" ? strtotime($this->queryParam("datebefore")) : NULL, "after" => $this->queryParam("dateafter") != "" ? strtotime($this->queryParam("dateafter")) : NULL, - "gender" => $this->queryParam("gender") != "" && $this->queryParam("gender") != 2 ? $this->queryParam("gender") : NULL + "gender" => $this->queryParam("gender") != "" && $this->queryParam("gender") != 2 ? $this->queryParam("gender") : NULL, + "only_performers" => $this->queryParam("only_performers") == "on" ? "1" : NULL, + "with_lyrics" => $this->queryParam("with_lyrics") == "on" ? true : NULL, ]; $repo = $repos[$type] or $this->throwError(400, "Bad Request", "Invalid search entity $type."); $results = $this->{$repo}->find($query, $parameters, $sort); - $iterator = $results->page($page); + $iterator = $results->page($page, 14); $count = $results->size(); $this->template->iterator = iterator_to_array($iterator); $this->template->count = $count; $this->template->type = $type; $this->template->page = $page; + $this->template->perPage = 14; } } diff --git a/Web/Presenters/SupportPresenter.php b/Web/Presenters/SupportPresenter.php index 8163dbf8..b834d712 100644 --- a/Web/Presenters/SupportPresenter.php +++ b/Web/Presenters/SupportPresenter.php @@ -67,7 +67,7 @@ final class SupportPresenter extends OpenVKPresenter $this->template->count = $this->tickets->getTicketsCountByUserId($this->user->id); if($this->template->mode === "list") { $this->template->page = (int) ($this->queryParam("p") ?? 1); - $this->template->tickets = $this->tickets->getTicketsByUserId($this->user->id, $this->template->page); + $this->template->tickets = iterator_to_array($this->tickets->getTicketsByUserId($this->user->id, $this->template->page)); } if($this->template->mode === "new") diff --git a/Web/Presenters/UserPresenter.php b/Web/Presenters/UserPresenter.php index 51ddc6aa..56d5685b 100644 --- a/Web/Presenters/UserPresenter.php +++ b/Web/Presenters/UserPresenter.php @@ -5,7 +5,7 @@ use openvk\Web\Util\Sms; use openvk\Web\Themes\Themepacks; use openvk\Web\Models\Entities\{Photo, Post, EmailChangeVerification}; use openvk\Web\Models\Entities\Notifications\{CoinsTransferNotification, RatingUpNotification}; -use openvk\Web\Models\Repositories\{Users, Clubs, Albums, Videos, Notes, Vouchers, EmailChangeVerifications}; +use openvk\Web\Models\Repositories\{Users, Clubs, Albums, Videos, Notes, Vouchers, EmailChangeVerifications, Audios}; use openvk\Web\Models\Exceptions\InvalidUserNameException; use openvk\Web\Util\Validator; use Chandler\Security\Authenticator; @@ -45,7 +45,10 @@ final class UserPresenter extends OpenVKPresenter $this->template->videosCount = (new Videos)->getUserVideosCount($user); $this->template->notes = (new Notes)->getUserNotes($user, 1, 4); $this->template->notesCount = (new Notes)->getUserNotesCount($user); - + $this->template->audios = (new Audios)->getRandomThreeAudiosByEntityId($user->getId()); + $this->template->audiosCount = (new Audios)->getUserCollectionSize($user); + $this->template->audioStatus = $user->getCurrentAudioStatus(); + $this->template->user = $user; } } @@ -55,7 +58,7 @@ final class UserPresenter extends OpenVKPresenter $this->assertUserLoggedIn(); $user = $this->users->get($id); - $page = abs($this->queryParam("p") ?? 1); + $page = abs((int)($this->queryParam("p") ?? 1)); if(!$user) $this->notFound(); elseif (!$user->getPrivacyPermission('friends.read', $this->user->identity ?? NULL)) @@ -169,6 +172,7 @@ final class UserPresenter extends OpenVKPresenter if ($this->postParam("gender") <= 1 && $this->postParam("gender") >= 0) $user->setSex($this->postParam("gender")); + $user->setAudio_broadcast_enabled($this->checkbox("broadcast_music")); if(!empty($this->postParam("phone")) && $this->postParam("phone") !== $user->getPhone()) { if(!OPENVK_ROOT_CONF["openvk"]["credentials"]["smsc"]["enable"]) @@ -241,6 +245,7 @@ final class UserPresenter extends OpenVKPresenter } $user->setStatus(empty($this->postParam("status")) ? NULL : $this->postParam("status")); + $user->setAudio_broadcast_enabled($this->postParam("broadcast") == 1); $user->save(); $this->returnJson([ @@ -430,10 +435,11 @@ final class UserPresenter extends OpenVKPresenter "friends.add", "wall.write", "messages.write", + "audios.read", ]; foreach($settings as $setting) { $input = $this->postParam(str_replace(".", "_", $setting)); - $user->setPrivacySetting($setting, min(3, abs($input ?? $user->getPrivacySetting($setting)))); + $user->setPrivacySetting($setting, min(3, (int)abs((int)$input ?? $user->getPrivacySetting($setting)))); } } else if($_GET['act'] === "finance.top-up") { $token = $this->postParam("key0") . $this->postParam("key1") . $this->postParam("key2") . $this->postParam("key3"); @@ -474,6 +480,7 @@ final class UserPresenter extends OpenVKPresenter } else if($_GET['act'] === "lMenu") { $settings = [ "menu_bildoj" => "photos", + "menu_muziko" => "audios", "menu_filmetoj" => "videos", "menu_mesagoj" => "messages", "menu_notatoj" => "notes", diff --git a/Web/Presenters/VKAPIPresenter.php b/Web/Presenters/VKAPIPresenter.php index 431379c5..963c9ccc 100644 --- a/Web/Presenters/VKAPIPresenter.php +++ b/Web/Presenters/VKAPIPresenter.php @@ -233,8 +233,13 @@ final class VKAPIPresenter extends OpenVKPresenter $this->badMethodCall($object, $method, $parameter->getName()); } - settype($val, $parameter->getType()->getName()); - $params[] = $val; + try { + settype($val, $parameter->getType()->getName()); + $params[] = $val; + } catch (\Throwable $e) { + // Just ignore the exception, since + // some args are intended for internal use + } } define("VKAPI_DECL_VER", $this->requestParam("v") ?? "4.100", false); diff --git a/Web/Presenters/WallPresenter.php b/Web/Presenters/WallPresenter.php index 2f9d611d..a01c8262 100644 --- a/Web/Presenters/WallPresenter.php +++ b/Web/Presenters/WallPresenter.php @@ -3,7 +3,7 @@ namespace openvk\Web\Presenters; use openvk\Web\Models\Exceptions\TooMuchOptionsException; use openvk\Web\Models\Entities\{Poll, Post, Photo, Video, Club, User}; use openvk\Web\Models\Entities\Notifications\{MentionNotification, RepostNotification, WallPostNotification}; -use openvk\Web\Models\Repositories\{Posts, Users, Clubs, Albums, Notes, Videos, Comments, Photos}; +use openvk\Web\Models\Repositories\{Posts, Users, Clubs, Albums, Notes, Videos, Comments, Photos, Audios}; use Chandler\Database\DatabaseConnection; use Nette\InvalidStateException as ISE; use Bhaktaraz\RSSGenerator\Item; @@ -154,7 +154,7 @@ final class WallPresenter extends OpenVKPresenter $this->template->paginatorConf = (object) [ "count" => sizeof($posts), "page" => (int) ($_GET["p"] ?? 1), - "amount" => sizeof($posts->page((int) ($_GET["p"] ?? 1), $perPage)), + "amount" => $posts->page((int) ($_GET["p"] ?? 1), $perPage)->count(), "perPage" => $perPage, ]; $this->template->posts = []; @@ -182,7 +182,7 @@ final class WallPresenter extends OpenVKPresenter $this->template->paginatorConf = (object) [ "count" => $count, "page" => (int) ($_GET["p"] ?? 1), - "amount" => sizeof($posts), + "amount" => $posts->getRowCount(), "perPage" => $pPage, ]; foreach($posts as $post) @@ -311,8 +311,27 @@ final class WallPresenter extends OpenVKPresenter } } } + + $audios = []; + + if(!empty($this->postParam("audios"))) { + $un = rtrim($this->postParam("audios"), ","); + $arr = explode(",", $un); + + if(sizeof($arr) < 11) { + foreach($arr as $dat) { + $ids = explode("_", $dat); + $audio = (new Audios)->getByOwnerAndVID((int)$ids[0], (int)$ids[1]); + + if(!$audio || $audio->isDeleted()) + continue; + + $audios[] = $audio; + } + } + } - if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1 && !$poll && !$note) + if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1 && sizeof($audios) < 1 && !$poll && !$note) $this->flashFail("err", tr("failed_to_publish_post"), tr("post_is_empty_or_too_big")); try { @@ -341,6 +360,9 @@ final class WallPresenter extends OpenVKPresenter if(!is_null($note)) $post->attach($note); + + foreach($audios as $audio) + $post->attach($audio); if($wall > 0 && $wall !== $this->user->identity->getId()) (new WallPostNotification($wallOwner, $post, $this->user->identity))->emit(); diff --git a/Web/Presenters/templates/@layout.xml b/Web/Presenters/templates/@layout.xml index f8a975e0..31e0bc5c 100644 --- a/Web/Presenters/templates/@layout.xml +++ b/Web/Presenters/templates/@layout.xml @@ -16,6 +16,8 @@ {script "js/node_modules/umbrellajs/umbrella.min.js"} {script "js/l10n.js"} {script "js/openvk.cls.js"} + {script "js/node_modules/dashjs/dist/dash.all.min.js"} + {script "js/al_music.js"} {css "js/node_modules/tippy.js/dist/backdrop.css"} {css "js/node_modules/tippy.js/dist/border.css"} @@ -122,6 +124,7 @@ +