diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..843c6ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+thumbs/assets/*
+thumbs/avatars/*
+asset/files/*
+api/private/config.php
\ No newline at end of file
diff --git a/RobloxOld.css b/RobloxOld.css
new file mode 100644
index 0000000..50c2bf8
--- /dev/null
+++ b/RobloxOld.css
@@ -0,0 +1,11 @@
+
+*
+{
+ font-size: 12px;
+ font-family: 'Comic Sans MS', Verdana, Arial, Helvetica, sans-serif;
+}
+H1
+{
+ font-weight: bold;
+ font-size: larger;
+}
diff --git a/XD.php b/XD.php
new file mode 100644
index 0000000..f2343b8
--- /dev/null
+++ b/XD.php
@@ -0,0 +1,9 @@
+buildHeader();
+?>
+Do not run game:HttpGet("http://polygon.pizzaboxer.xyz/XD") in studio
+please no
+Plz
+buildFooter();
\ No newline at end of file
diff --git a/admin.php b/admin.php
new file mode 100644
index 0000000..bda5e55
--- /dev/null
+++ b/admin.php
@@ -0,0 +1,138 @@
+ "Moderator",
+ Users::STAFF_ADMINISTRATOR => "Administrator",
+ Users::STAFF_CATALOG => "Catalog Manager"
+];
+
+$servermemory = System::GetMemoryUsage();
+$usersOnline = Users::GetUsersOnline();
+$pendingRenders = Polygon::GetPendingRenders();
+
+$usage = (object)
+[
+ "Memory" => (object)
+ [
+ "Total" => System::GetFileSize($servermemory->total),
+ "SytemUsage" => System::GetFileSize($servermemory->total-$servermemory->free),
+ "PHPUsage" => System::GetFileSize(memory_get_usage(true))
+ ],
+
+ "Disk" => (object)
+ [
+ "Total" => System::GetFileSize(disk_total_space("/")),
+ "SystemUsage" => System::GetFileSize(disk_total_space("/")-disk_free_space("/")),
+ "PolygonUsage" => System::GetFolderSize("/var/www/pizzaboxer.xyz/polygon/"),
+ "PolygonSharedUsage" => System::GetFolderSize("/var/www/pizzaboxer.xyz/polygonshared/"),
+ "ThumbnailUsage" => System::GetFolderSize("/var/www/pizzaboxer.xyz/polygoncdn/"),
+ ]
+];
+
+$pageBuilder = new PageBuilder(["title" => SITE_CONFIG["site"]["name"]." Administration"]);
+$pageBuilder->buildHeader();
+?>
+
+
+
userId}\">{$row->username} - {$timestamp}
", + "message" => Polygon::FilterText($row->text) + ]; + } + else + { + $GroupInfo = Groups::GetGroupInfo($row->groupId, true, true); + $GroupInfo->name = htmlspecialchars($GroupInfo->name); + + $feed[] = + [ + "userName" => $GroupInfo->name, + "img" => Thumbnails::GetAssetFromID($GroupInfo->emblem), + "header" => "id}\">{$GroupInfo->name} - posted by userId}\">{$row->username} - {$timestamp}
", + "message" => Polygon::FilterText($row->text) + ]; + } +} + +$FeedCount = $FeedResults->rowCount(); + +if($FeedCount < 15) +{ + $feed[] = + [ + "userName" => "Your feed is currently empty!", + "img" => "/img/feed/friends.png", + "header" => "=$text["header"][$banType]?>
+Done at: =date('j/n/Y g:i:s A \G\M\T')?>
+Reason:
+', '
', $markdown->text($_POST["moderationNote"], true))?> +
=$text["footer"][$banType]?>
+ +Reactivate + "POST", "admin" => Users::STAFF, "secure" => true]); + +$renderType = $_POST['renderType'] ?? false; +$assetID = $_POST['assetID'] ?? false; + +if(!$renderType) API::respond(400, false, "Bad Request"); +if(!in_array($renderType, ["Avatar", "Asset"])) API::respond(400, false, "Invalid render type"); +if(!$assetID || !is_numeric($assetID)) API::respond(400, false, "Bad Request"); + +if($renderType == "Asset") +{ + $asset = Catalog::GetAssetInfo($assetID); + if(!$asset) API::respond(200, false, "The asset you requested does not exist"); + switch($asset->type) + { + case 9: Polygon::RequestRender("Place", $assetID); break; //place + case 4: Polygon::RequestRender("Mesh", $assetID); break; // mesh + case 8: case 19: Polygon::RequestRender("Model", $assetID); break; // hat/gear + case 11: case 12: Polygon::RequestRender("Clothing", $assetID); break; // shirt/pants + case 17: Polygon::RequestRender("Head", $assetID); break; // head + case 10: Polygon::RequestRender("UserModel", $assetID); break; // user generated model + case 2: // t-shirt + $image = new Upload(SITE_CONFIG['paths']['assets'].$asset->imageID); + + Thumbnails::UploadAsset($image, $asset->imageID, 420, 420, ["keepRatio" => true, "align" => "T"]); + + //process initial tshirt thumbnail + $template = imagecreatefrompng($_SERVER['DOCUMENT_ROOT']."/img/tshirt-template.png"); + $shirtdecal = Image::Resize(SITE_CONFIG['paths']['thumbs_assets']."{$asset->imageID}-420x420.png", 250, 250); + imagesavealpha($template, true); + imagesavealpha($shirtdecal, true); + Image::MergeLayers($template, $shirtdecal, 85, 85, 0, 0, 250, 250, 100); + + imagepng($template, SITE_CONFIG['paths']['thumbs_assets']."$assetID-420x420.png"); + + Thumbnails::UploadToCDN(SITE_CONFIG['paths']['thumbs_assets']."$assetID-420x420.png"); + break; + case 13: // decal + $image = new Upload(SITE_CONFIG['paths']['assets'].$asset->imageID); + + Thumbnails::UploadAsset($image, $asset->imageID, 420, 420, ["keepRatio" => true, "align" => "C"]); + Thumbnails::UploadAsset($image, $assetID, 420, 420); + break; + case 3: // audio + Image::RenderFromStaticImage("audio", $assetID); + break; + default: API::respond(200, false, "This asset cannot be re-rendered"); + } +} +else if($renderType == "Avatar") +{ + $user = Users::GetInfoFromID($assetID); + if(!$user) API::respond(200, false, "The user you requested does not exist"); + Polygon::RequestRender("Avatar", $assetID); +} + +Users::LogStaffAction("[ Render ] Re-rendered $renderType ID $assetID"); +API::respond(200, true, "Render request has been successfully submitted! See render status here"); \ No newline at end of file diff --git a/api/admin/upload.php b/api/admin/upload.php new file mode 100644 index 0000000..1470da2 --- /dev/null +++ b/api/admin/upload.php @@ -0,0 +1,119 @@ + "POST", "admin" => [Users::STAFF_CATALOG, Users::STAFF_ADMINISTRATOR], "secure" => true]); + +$file = $_FILES["file"] ?? false; +$name = $_POST["name"] ?? ""; +$description = $_POST["description"] ?? ""; +$type = $_POST["type"] ?? false; +$uploadas = $_POST["creator"] ?? "Polygon"; +$creator = Users::GetIDFromName($uploadas); + +if(!$file) API::respond(200, false, "You must select a file"); +if(strlen($name) == 0) API::respond(200, false, "You must specify a name"); +if(strlen($name) > 50) API::respond(200, false, "Name cannot be longer than 50 characters"); +if(!$creator) API::respond(400, false, "The user you're trying to create as does not exist"); +if(Polygon::FilterText($name, false, false, true) != $name) API::respond(400, false, "The name contains inappropriate text"); + +//$lastCreation = $pdo->query("SELECT created FROM assets WHERE creator = 2 ORDER BY id DESC")->fetchColumn(); +//if($lastCreation+60 > time()) API::respond(400, false, "Please wait ".(60-(time()-$lastCreation))." seconds before creating a new asset"); + +if($type == 1) //image - this is for textures and stuff +{ + if(!in_array($file["type"], ["image/png", "image/jpg", "image/jpeg"])) API::respond(400, false, "Must be a png or jpg file"); + + $image = new Upload($file); + if(!$image->uploaded) API::respond(500, false, "Failed to process image - please contact an admin"); + $image->allowed = ['image/png', 'image/jpg', 'image/jpeg']; + $image->image_convert = 'png'; + + $imageId = Catalog::CreateAsset(["type" => $type, "creator" => $creator, "name" => $name, "description" => $description, "approved" => 1]); + Image::Process($image, ["name" => "$imageId", "resize" => false, "dir" => "assets/"]); + Thumbnails::UploadAsset($image, $imageId, 420, 420, ["keepRatio" => true, "align" => "C"]); +} +elseif($type == 3) // audio +{ + if(!in_array($file["type"], ["audio/mpeg", "audio/ogg", "video/ogg", "audio/mid", "audio/wav"])) API::respond(400, false, "Must be an mpeg, wav, ogg or midi audio. - ".$file["type"]); + $assetId = Catalog::CreateAsset(["type" => $type, "creator" => $creator, "name" => $name, "description" => $description, "audioType" => $file["type"], "approved" => 1]); + copy($file["tmp_name"], SITE_CONFIG["paths"]["assets"] . $assetId); + Image::RenderFromStaticImage("audio", $assetId); +} +elseif($type == 4) //mesh +{ + if(!str_ends_with($file["name"], ".mesh")) API::respond(400, false, "Must be a .mesh file"); + $assetId = Catalog::CreateAsset(["type" => $type, "creator" => $creator, "name" => $name, "description" => $description, "approved" => 1]); + copy($file["tmp_name"], SITE_CONFIG["paths"]["assets"] . $assetId); + Polygon::RequestRender("Mesh", $assetId); +} +elseif($type == 5) //lua +{ + if(!str_ends_with($file["name"], ".lua")) API::respond(400, false, "Must be a .lua file"); + $assetId = Catalog::CreateAsset(["type" => $type, "creator" => $creator, "name" => $name, "description" => $description, "approved" => 1]); + copy($file["tmp_name"], SITE_CONFIG["paths"]["assets"] . $assetId); + Image::RenderFromStaticImage("Script", $assetId); +} +elseif($type == 8) //hat +{ + if(!str_ends_with($file["name"], ".xml") && !str_ends_with($file["name"], ".rbxm")) API::respond(400, false, "Must be an rbxm or xml file"); + $assetId = Catalog::CreateAsset(["type" => $type, "creator" => $creator, "name" => $name, "description" => $description, "approved" => 1]); + copy($file["tmp_name"], SITE_CONFIG["paths"]["assets"] . $assetId); + Polygon::RequestRender("Model", $assetId); +} +elseif($type == 17) //head +{ + if(!str_ends_with($file["name"], ".xml") && !str_ends_with($file["name"], ".rbxm")) API::respond(400, false, "Must be an rbxm or xml file"); + $assetId = Catalog::CreateAsset(["type" => $type, "creator" => $creator, "name" => $name, "description" => $description, "approved" => 1]); + copy($file["tmp_name"], SITE_CONFIG["paths"]["assets"] . $assetId); + Polygon::RequestRender("Head", $assetId); +} +elseif($type == 18) //faces are literally just decals lmao (with a minor alteration to the xml) +{ + if(!in_array($file["type"], ["image/png", "image/jpg", "image/jpeg"])) API::respond(400, false, "Must be a png or jpg file"); + + $image = new Upload($file); + if(!$image->uploaded) API::respond(500, false, "Failed to process image - please contact an admin"); + $image->allowed = ['image/png', 'image/jpg', 'image/jpeg']; + $image->image_convert = 'png'; + + $imageId = Catalog::CreateAsset(["type" => 1, "creator" => $creator, "name" => $name, "description" => $description, "approved" => 1]); + Image::Process($image, ["name" => "$imageId", "resize" => false, "dir" => "assets/"]); + Thumbnails::UploadAsset($image, $imageId, 420, 420, ["keepRatio" => true, "align" => "C"]); + + $itemId = Catalog::CreateAsset(["type" => $type, "creator" => $creator, "name" => $name, "description" => $description, "imageID" => $imageId, "approved" => 1]); + + file_put_contents(SITE_CONFIG['paths']['assets'].$itemId, Catalog::GenerateGraphicXML("Face", $imageId)); + + Thumbnails::UploadAsset($image, $itemId, 420, 420); +} +elseif($type == 19) //gear +{ + if(!str_ends_with($file["name"], ".xml") && !str_ends_with($file["name"], ".rbxm")) API::respond(400, false, "Must be an rbxm or xml file"); + + $assetId = Catalog::CreateAsset(["type" => $type, "creator" => $creator, "name" => $name, "description" => $description, "approved" => 1, "gear_attributes" => '{"melee":false,"powerup":false,"ranged":false,"navigation":false,"explosive":false,"musical":false,"social":false,"transport":false,"building":false}']); + copy($file["tmp_name"], SITE_CONFIG["paths"]["assets"] . $assetId); + Polygon::RequestRender("Model", $assetId); +} +else if ($type == 24) // animation +{ + if(!str_ends_with($file["name"], ".xml") && !str_ends_with($file["name"], ".rbxm")) API::respond(400, false, "Must be an rbxm or xml file"); + + $assetId = Catalog::CreateAsset(["type" => $type, "creator" => $creator, "name" => $name, "description" => $description, "approved" => 1]); + copy($file["tmp_name"], SITE_CONFIG["paths"]["assets"] . $assetId); + Image::RenderFromStaticImage("Animation", $assetId); +} + +Users::LogStaffAction("[ Asset creation ] Created \"$name\" [ID ".($itemId ?? $assetId ?? $imageId)."]"); +API::respondCustom([ + "status" => 200, + "success" => true, + "message" => "".Catalog::GetTypeByNum($type)." successfully created!", + "assetID" => ($itemId ?? $assetId ?? $imageId) +]); \ No newline at end of file diff --git a/api/catalog/get-comments.php b/api/catalog/get-comments.php new file mode 100644 index 0000000..dbb7796 --- /dev/null +++ b/api/catalog/get-comments.php @@ -0,0 +1,40 @@ +run("SELECT COUNT(*) FROM asset_comments WHERE assetID = :AssetID", [":AssetID" => $AssetID])->fetchColumn(); +if($CommentsCount == 0) API::respond(200, true, "This item does not have any comments"); + +$Pagination = Pagination($Page, $CommentsCount, 15); + +$Comments = Database::singleton()->run( + "SELECT asset_comments.*, users.username FROM asset_comments + INNER JOIN users ON users.id = asset_comments.author + WHERE assetID = :AssetID + ORDER BY id DESC LIMIT 15 OFFSET :Offset", + [":AssetID" => $AssetID, ":Offset" => $Pagination->Offset] +); + +$Items = []; + +while($Comment = $Comments->fetch(\PDO::FETCH_OBJ)) +{ + $Items[] = + [ + "time" => strtolower(timeSince($Comment->time)), + "commenter_name" => $Comment->username, + "commenter_id" => $Comment->author, + "commenter_avatar" => Thumbnails::GetAvatar($Comment->author), + "content" => nl2br(Polygon::FilterText($Comment->content)) + ]; +} + +API::respondCustom(["status" => 200, "success" => true, "message" => "OK", "items" => $Items, "pages" => $Pagination->Pages]); \ No newline at end of file diff --git a/api/catalog/post-comment.php b/api/catalog/post-comment.php new file mode 100644 index 0000000..d62e8cb --- /dev/null +++ b/api/catalog/post-comment.php @@ -0,0 +1,34 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST['assetID']) || !isset($_POST['content'])); + +$uid = SESSION["user"]["id"]; +$id = $_POST['assetID']; +$content = $_POST['content']; + +$item = Catalog::GetAssetInfo($id); +if(!$item) API::respond(400, false, "Asset does not exist"); +if(!$item->comments) API::respond(400, false, "Comments are unavailable for this asset"); +if(!strlen($content)) API::respond(400, false, "Comment cannot be empty"); +if(strlen($content) > 100) API::respond(400, false, "Comment cannot be longer than 128 characters"); + +$lastComment = Database::singleton()->run( + "SELECT time FROM asset_comments WHERE time+60 > UNIX_TIMESTAMP() AND author = :uid", + [":uid" => $uid] +); + +if($lastComment->rowCount()) API::respond(400, false, "Please wait ".GetReadableTime($lastComment->fetchColumn(), ["RelativeTime" => "1 minute"])." before posting a new comment"); + +Database::singleton()->run( + "INSERT INTO asset_comments (author, content, assetID, time) + VALUES (:uid, :content, :aid, UNIX_TIMESTAMP())", + [":uid" => $uid, ":content" => $content, ":aid" => $id] +); + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/catalog/purchase.php b/api/catalog/purchase.php new file mode 100644 index 0000000..40f0ac3 --- /dev/null +++ b/api/catalog/purchase.php @@ -0,0 +1,79 @@ + "POST", "logged_in" => true, "secure" => true]); + +function getPrice($price) +{ + return $price ? ' '.$price.'' : 'Free'; +} + +$uid = SESSION["user"]["id"]; +$id = $_POST['id'] ?? false; +$price = $_POST['price'] ?? 0; + +$item = Catalog::GetAssetInfo($id); +if(!$item) API::respond(400, false, "Asset does not exist"); +if(Catalog::OwnsAsset($uid, $id)) API::respond(400, false, "User already owns asset"); +if(!$item->sale) API::respond(400, false, "Asset is off-sale"); +if(SESSION["user"]["currency"] - $item->price < 0) API::respond(400, false, "User cannot afford asset"); + +if($item->price != $price) +{ + die(json_encode( + [ + "status" => 200, + "success" => true, + "message" => "Item price changed", + "header" => "Item Price Has Changed", + "text" => 'While you were shopping, the price of this item changed from '.getPrice($price).' to '.getPrice($item->price).'.', + "buttons" => [['class'=>'btn btn-success btn-confirm-purchase', 'text'=>'Buy Now'], ['class'=>'btn btn-secondary', 'dismiss'=>true, 'text'=>'Cancel']], + "footer" => 'Your balance after this transaction will be '.(SESSION["user"]["currency"] - $item->price), + "newprice" => $item->price + ])); +} + +$IsAlt = false; + +foreach(Users::GetAlternateAccounts($item->creator) as $alt) +{ + if($alt["userid"] == $uid) $IsAlt = true; +} + +if (!$IsAlt) +{ + Database::singleton()->run( + "UPDATE users SET currency = currency - :price WHERE id = :uid; + UPDATE users SET currency = currency + :price WHERE id = :seller", + [":price" => $item->price, ":uid" => $uid, ":seller" => $item->creator] + ); +} + +Database::singleton()->run( + "INSERT INTO ownedAssets (assetId, userId, timestamp) VALUES (:aid, :uid, UNIX_TIMESTAMP())", + [":aid" => $id, ":uid" => $uid] +); + +Database::singleton()->run( + "INSERT INTO transactions (purchaser, seller, assetId, amount, flagged, timestamp) + VALUES (:uid, :sid, :aid, :price, :flagged, UNIX_TIMESTAMP())", + [":uid" => $uid, ":sid" => $item->creator, ":aid" => $id, ":price" => $item->price, ":flagged" => (int)$IsAlt] +); + +Database::singleton()->run("UPDATE assets SET Sales = Sales + 1 WHERE id = :AssetID", [":AssetID" => $id]); + +die(json_encode( +[ + "status" => 200, + "success" => true, + "message" => "OK", + "header" => "Purchase Complete!", + "image" => Thumbnails::GetAsset($item), + "text" => "You have successfully purchased the ".htmlspecialchars($item->name)." ".Catalog::GetTypeByNum($item->type)." from ".$item->username." for ".getPrice($item->price), + "buttons" => [['class' => 'btn btn-primary continue-shopping', 'dismiss' => true, 'text' => 'Continue Shopping']], +])); \ No newline at end of file diff --git a/api/develop/getCreations.php b/api/develop/getCreations.php new file mode 100644 index 0000000..94b39d8 --- /dev/null +++ b/api/develop/getCreations.php @@ -0,0 +1,40 @@ + "POST", "logged_in" => true, "secure" => true]); + +$userid = SESSION["user"]["id"]; +$type = $_POST["type"] ?? false; +$page = $_POST["page"] ?? 1; +$assets = []; + +if (!Catalog::GetTypeByNum($type)) API::respond(400, false, "Invalid asset type"); + +$assets = Database::singleton()->run( + "SELECT * FROM assets WHERE creator = :uid AND type = :type ORDER BY id DESC", + [":uid" => $userid, ":type" => $type] +); + +$items = []; + +while ($asset = $assets->fetch(\PDO::FETCH_OBJ)) +{ + $items[] = + [ + "name" => htmlspecialchars($asset->name), + "id" => $asset->id, + "version" => $asset->type == 9 ? $asset->Version : false, + "thumbnail" => Thumbnails::GetAsset($asset), + "item_url" => "/".encode_asset_name($asset->name)."-item?id={$asset->id}", + "config_url" => $asset->type == 9 ? "/places/{$asset->id}/update" : "/my/item?ID={$asset->id}", + "created" => date("j/n/Y", $asset->created), + "sales-total" => $asset->Sales, + "sales-week" => 0 //$info->sales_week + ]; +} + +die(json_encode(["status" => 200, "success" => true, "message" => "OK", "assets" => $items])); \ No newline at end of file diff --git a/api/develop/upload.php b/api/develop/upload.php new file mode 100644 index 0000000..eece5a7 --- /dev/null +++ b/api/develop/upload.php @@ -0,0 +1,168 @@ + "POST", "logged_in" => true, "secure" => true]); + +$userid = SESSION["user"]["id"]; +$file = $_FILES["file"] ?? false; +$name = $_POST["name"] ?? false; +$type = $_POST["type"] ?? false; + +if(!$file) API::respond(200, false, "You must select a file"); +if(!in_array($file["type"], ["image/png", "image/jpg", "image/jpeg"]) && $type != 10) API::respond(200, false, "Must be a .png or .jpg file"); + +if(empty($name)) API::respond(200, false, "You must specify a name"); +if(strlen($name) > 50) API::respond(200, false, "The name is too long"); +if(Polygon::IsExplicitlyFiltered($name)) API::respond(200, false, "The name contains inappropriate text"); + +if(!in_array($type, [2, 10, 11, 12, 13])) API::respond(200, false, "You can't upload that type of content!"); + +$lastCreation = Database::singleton()->run( + "SELECT created FROM assets WHERE creator = :uid ORDER BY id DESC", + [":uid" => $userid] +)->fetchColumn(); + +if($userid != 1 && $lastCreation+30 > time()) API::respond(200, false, "Please wait ".(30-(time()-$lastCreation))." seconds before creating a new asset"); + +// tshirts are a bit messy but straightforward: +// the image asset itself must be 128x128 with the texture resized to preserve aspect ratio +// the image thumbnail should have the texture positioned top +// +// shirts and pants should ideally be 585x559 but it doesnt really matter - +// just as long as it looks right on the avatar. if it doesnt then disapprove +// +// decals are a lot more messy: +// the image asset itself is scaled to be 256 pixels in width, while preserving the texture ratio +// the image thumbnail should have the texture positioned center +// the decal asset however must have the texture stretched to 1:1 for all its respective sizes +// [example: https://www.roblox.com/Item.aspx?ID=8553820] +// +// we won't have to worry about image size constraints as they're always gonna be +// resized to fit in a smaller resolution +// +// refer to here for the thumbnail sizes: https://github.com/matthewdean/roblox-web-apis +// +// THUMBNAIL SIZES FOR EACH ITEM TYPE +// legend: [f = fit] [t = top] [c = center] [s = stretch] // [M = Model] [He = Head] [S = Shirt] [P = Pants] +// +// | 48x48 | 60x62 | 75x75 | 100x100 | 110x110 | 160x100 | 250x250 | 352x352 | 420x230 | 420x420 | +// +-------+-------+-------+---------+---------+---------+---------+---------+---------+---------+ +// Image | |yes (f)| | | | | | | | yes (t) | +// +-------+-------+-------+---------+---------+---------+---------+---------+---------+---------+ +// T-Shirt | | | | yes | yes | | | | | yes | +// +-------+-------+-------+---------+---------+---------+---------+---------+---------+---------+ +// Audio | | | yes | yes | yes | | yes | yes | yes | yes | +// +-------+-------+-------+---------+---------+---------+---------+---------+---------+---------+ +// Hat/Gear | yes | | yes | yes | yes | | yes | yes | yes (fc)| yes | +// +-------+-------+-------+---------+---------+---------+---------+---------+---------+---------+ +// Place | yes |yes(fc)| yes | yes | yes | yes | yes | yes | yes | yes | +// +-------+-------+-------+---------+---------+---------+---------+---------+---------+---------+ +// M/He/S/P | yes | | yes | yes | yes | | yes | yes | | yes | +// +-------+-------+-------+---------+---------+---------+---------+---------+---------+---------+ +// Decal/Face |yes (s)| |yes (s)| yes (s) | yes (s) | | yes (s) | yes (s) | yes (s) | yes (s) | +// +-------+-------+-------+---------+---------+---------+---------+---------+---------+---------+ + +$image = new Upload($file); +if(!$image->uploaded) API::respond(200, false, "Failed to process image - please contact an admin"); +$image->allowed = ['image/png', 'image/jpg', 'image/jpeg']; +$image->image_convert = 'png'; + +$imageId = Catalog::CreateAsset(["type" => 1, "creator" => SESSION["user"]["id"], "name" => $name, "description" => Catalog::GetTypeByNum($type)." Image"]); + +if ($type == 2) //tshirt +{ + $Processed = Image::Process($image, ["name" => "$imageId", "keepRatio" => true, "align" => "T", "x" => 128, "y" => 128, "dir" => "assets/"]); + if ($Processed !== true) API::respond(200, false, "Image processing failed: $Processed"); + + Thumbnails::UploadAsset($image, $imageId, 420, 420, ["keepRatio" => true, "align" => "T"]); + + $itemId = Catalog::CreateAsset(["type" => 2, "creator" => SESSION["user"]["id"], "name" => $name, "description" => "T-Shirt", "imageID" => $imageId]); + + file_put_contents(SITE_CONFIG['paths']['assets'].$itemId, Catalog::GenerateGraphicXML("T-Shirt", $imageId)); + + //process initial tshirt thumbnail + $template = imagecreatefrompng($_SERVER['DOCUMENT_ROOT']."/img/tshirt-template.png"); + $shirtdecal = Image::Resize(SITE_CONFIG['paths']['thumbs_assets']."/$imageId-420x420.png", 250, 250); + imagesavealpha($template, true); + imagesavealpha($shirtdecal, true); + Image::MergeLayers($template, $shirtdecal, 85, 85, 0, 0, 250, 250, 100); + + imagepng($template, SITE_CONFIG['paths']['thumbs_assets']."/$itemId-420x420.png"); + + Thumbnails::UploadToCDN(SITE_CONFIG['paths']['thumbs_assets']."/$itemId-420x420.png"); +} +else if ($type == 11 || $type == 12) //shirt / pants +{ + $Processed = Image::Process($image, ["name" => "$imageId", "x" => 585, "y" => 559, "dir" => "assets/"]); + if ($Processed !== true) API::respond(200, false, "Image processing failed: $Processed"); + + Thumbnails::UploadAsset($image, $imageId, 420, 420, ["keepRatio" => true, "align" => "C"]); + + $itemId = Catalog::CreateAsset(["type" => $type, "creator" => SESSION["user"]["id"], "name" => $name, "description" => Catalog::GetTypeByNum($type), "imageID" => $imageId]); + file_put_contents(SITE_CONFIG['paths']['assets'].$itemId, Catalog::GenerateGraphicXML(Catalog::GetTypeByNum($type), $imageId)); + Polygon::RequestRender("Clothing", $itemId); +} +else if ($type == 10) // model +{ + $ModelXML = file_get_contents($file["tmp_name"]); + $ModelXML = str_ireplace("http://".$_SERVER['HTTP_HOST']."/asset/?id=", "%ROBLOXASSETURL%", $ModelXML); + $ModelXML = str_ireplace("http://".$_SERVER['HTTP_HOST']."/asset?id=", "%ROBLOXASSETURL%", $ModelXML); + $isScript = stripos($ModelXML, 'class="Script" referent="RBX0"'); + + if (strlen($ModelXML) > 16000000) api::respond(200, false, "Model cannot be larger than 16 megabytes"); + + libxml_use_internal_errors(true); + $SimpleXML = simplexml_load_string($ModelXML); + + if ($SimpleXML === false) + { + api::respond(200, false, "Model File is invalid, are you sure it is an older format place file?"); + } + + $modelId = Catalog::CreateAsset([ + "type" => 10, + "creator" => SESSION["user"]["id"], + "name" => $name, + "description" => "Model", + "PublicDomain" => 0, + "approved" => $isScript ? 1 : 0 + ]); + + file_put_contents(Polygon::GetSharedResource("assets/{$modelId}"), $ModelXML); + Gzip::Compress(Polygon::GetSharedResource("assets/{$modelId}")); + + if ($isScript) + { + //put script image as thumbnail + Image::RenderFromStaticImage("Script", $modelId); + } + else + { + // user uploaded models are rendered as "usermodels" - this is just normal model rendering except there's no alpha + // no roblox thumbnails had transparency up until like 2013 anyway so its not that big of a deal + Polygon::RequestRender("UserModel", $modelId); + } +} +else if ($type == 13) //decal +{ + $Processed = Image::Process($image, ["name" => "$imageId", "x" => 256, "scaleY" => true, "dir" => "assets/"]); + if ($Processed !== true) api::respond(200, false, "Image processing failed: $Processed"); + + Thumbnails::UploadAsset($image, $imageId, 420, 420, ["keepRatio" => true, "align" => "C"]); + + $itemId = Catalog::CreateAsset(["type" => 13, "creator" => SESSION["user"]["id"], "name" => $name, "description" => "Decal", "imageID" => $imageId]); + + file_put_contents(SITE_CONFIG['paths']['assets'].$itemId, Catalog::GenerateGraphicXML("Decal", $imageId)); + Thumbnails::UploadAsset($image, $itemId, 420, 420); +} + + +API::respond(200, true, Catalog::GetTypeByNum($type)." successfully created!"); \ No newline at end of file diff --git a/api/discord/check-verification.php b/api/discord/check-verification.php new file mode 100644 index 0000000..b0c30cf --- /dev/null +++ b/api/discord/check-verification.php @@ -0,0 +1,28 @@ + "GET", "api" => "DiscordBot"]); + +if (isset($_GET["Token"]) && isset($_GET["DiscordID"])) +{ + $userInfo = Database::singleton()->run("SELECT * FROM users WHERE discordKey = :key", [":key" => $_GET["Token"]])->fetch(\PDO::FETCH_OBJ); + if (!$userInfo) API::respond(200, false, "InvalidKey"); // check if verification key is valid + if ($userInfo->discordID != NULL) API::respond(200, false, "AlreadyVerified"); // check if mercury account is already verified + + Database::singleton()->run( + "UPDATE users SET discordID = :id, discordVerifiedTime = UNIX_TIMESTAMP() WHERE discordKey = :key", + [":id" => $_GET["DiscordID"], ":key" => $_GET["Token"]] + ); + + API::respond(200, true, $userInfo->username); +} +else if (isset($_GET["DiscordID"])) +{ + $username = Database::singleton()->run("SELECT username FROM users WHERE discordID = :id", [":id" => $_GET["DiscordID"]]); + if (!$username->rowCount()) API::respond(200, false, "NotVerified"); // check if discord account is already verified + API::respond(200, true, $username->fetchColumn()); +} + +API::respond(400, false, "Bad Request"); \ No newline at end of file diff --git a/api/discord/whois.php b/api/discord/whois.php new file mode 100644 index 0000000..a6c4585 --- /dev/null +++ b/api/discord/whois.php @@ -0,0 +1,34 @@ + "GET", "api" => "DiscordBot"]); + +if (isset($_GET["UserName"])) +{ + $userInfo = Database::singleton()->run( + "SELECT id, username, blurb, adminlevel, jointime, lastonline, discordID, discordVerifiedTime FROM users WHERE username = :name", + [":name" => $_GET["UserName"]] + )->fetch(\PDO::FETCH_OBJ); + if (!$userInfo) API::respond(200, false, "DoesntExist"); +} +else if (isset($_GET["DiscordID"])) +{ + $userInfo = Database::singleton()->run( + "SELECT id, username, blurb, adminlevel, jointime, lastonline, discordID, discordVerifiedTime FROM users WHERE discordID = :id", + [":id" => $_GET["DiscordID"]] + )->fetch(\PDO::FETCH_OBJ); + if (!$userInfo) API::respond(200, false, "NotVerified"); +} +else +{ + API::respond(400, false, "Bad Request"); +} + +$userInfo->thumbnail = Thumbnails::GetAvatar($userInfo->id, 420, 420, true); + +$userInfo->blurb = str_ireplace(["@everyone", "@here"], ["[everyone]", "[here]"], $userInfo->blurb); +$userInfo->blurb = preg_replace("/<(@[0-9]+)>/i", "[$1]", $userInfo->blurb); +API::respond(200, true, $userInfo); \ No newline at end of file diff --git a/api/friends/accept-all.php b/api/friends/accept-all.php new file mode 100644 index 0000000..19bdcac --- /dev/null +++ b/api/friends/accept-all.php @@ -0,0 +1,16 @@ + "POST", "logged_in" => true, "secure" => true]); + +$RequestsCount = Database::singleton()->run( + "SELECT COUNT(*) FROM friends WHERE receiverId = :UserID AND status = 0", [":UserID" => SESSION["user"]["id"]] +)->fetchColumn(); + +if($RequestsCount == 0) API::respond(200, false, "You don't have any friend requests to accept right now"); + +Database::singleton()->run("UPDATE friends SET status = 1 WHERE receiverId = :UserID AND status = 0", [":UserID" => SESSION["user"]["id"]]); + +API::respond(200, true, "All your friend requests have been accepted"); \ No newline at end of file diff --git a/api/friends/accept.php b/api/friends/accept.php new file mode 100644 index 0000000..869dda4 --- /dev/null +++ b/api/friends/accept.php @@ -0,0 +1,26 @@ + "POST", "logged_in" => true, "secure" => true]); + +$FriendID = API::GetParameter("POST", "FriendID", "int"); + +$FriendRequest = Database::singleton()->run("SELECT * FROM friends WHERE id = :FriendID AND status = 0", [":FriendID" => $FriendID]); +$FriendRequestInfo = $FriendRequest->fetch(\PDO::FETCH_OBJ); + +if($FriendRequest->rowCount() == 0) API::respond(200, false, "Friend request doesn't exist"); +if((int) $FriendRequestInfo->receiverId != SESSION["user"]["id"]) API::respond(200, false, "You are not the recipient of this friend request"); + +Database::singleton()->run("UPDATE friends SET status = 1 WHERE id = :FriendID", [":FriendID" => $FriendID]); + +// since we're the one receiving it, we just need to update our pending requests +Database::singleton()->run( + "UPDATE users + SET PendingFriendRequests = (SELECT COUNT(*) FROM friends WHERE receiverId = users.id AND status = 0) + WHERE id = :UserID", + [":UserID" => SESSION["user"]["id"]] +); + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/friends/get-friend-requests.php b/api/friends/get-friend-requests.php new file mode 100644 index 0000000..ae4746b --- /dev/null +++ b/api/friends/get-friend-requests.php @@ -0,0 +1,36 @@ + "POST", "logged_in" => true, "secure" => true]); + +$Page = API::GetParameter("POST", "Page", "int", 1); + +$RequestsCount = Database::singleton()->run( + "SELECT COUNT(*) FROM friends WHERE receiverId = :UserID AND status = 0", + [":UserID" => SESSION["user"]["id"]] +)->fetchColumn(); +if($RequestsCount == 0) API::respond(200, true, "You're all up-to-date with your friend requests"); + +$Pagination = Pagination($Page, $RequestsCount, 18); + +$Requests = Database::singleton()->run( + "SELECT * FROM friends WHERE receiverId = :UserID AND status = 0 LIMIT 18 OFFSET :Offset", + [":UserID" => SESSION["user"]["id"], ":Offset" => $Pagination->Offset] +); + +while($Request = $Requests->fetch(\PDO::FETCH_OBJ)) +{ + $Items[] = + [ + "Username" => Users::GetNameFromID($Request->requesterId), + "UserID" => $Request->requesterId, + "Avatar" => Thumbnails::GetAvatar($Request->requesterId), + "FriendID" => $Request->id + ]; +} + +API::respondCustom(["status" => 200, "success" => true, "message" => "OK", "items" => $Items, "count" => (int) $RequestsCount, "pages" => (int) $Pagination->Pages]); \ No newline at end of file diff --git a/api/friends/getFriendRequests.php b/api/friends/getFriendRequests.php new file mode 100644 index 0000000..f76b5cc --- /dev/null +++ b/api/friends/getFriendRequests.php @@ -0,0 +1,40 @@ + "POST", "logged_in" => true, "secure" => true]); + +$userid = SESSION["user"]["id"]; +$page = $_POST['page'] ?? 1; + +$friendCount = Database::singleton()->run( + "SELECT COUNT(*) FROM friends WHERE receiverId = :uid AND status = 0", + [":uid" => $userid] +)->fetchColumn(); + +$pagination = Pagination($page, $friendCount, 18); + +if (!$pagination->Pages) API::respond(200, true, "You're all up-to-date with your friend requests!"); + +$friends = Database::singleton()->run( + "SELECT * FROM friends WHERE receiverId = :uid AND status = 0 LIMIT 18 OFFSET :offset", + [":uid" => $userid, ":offset" => $pagination->Offset] +); + +$items = []; + +while ($friend = $friends->fetch(\PDO::FETCH_OBJ)) +{ + $items[] = + [ + "username" => Users::GetNameFromID($friend->requesterId), + "userid" => $friend->requesterId, + "avatar" => Thumbnails::GetAvatar($friend->requesterId), + "friendid" => $friend->id + ]; +} + +API::respondCustom(["status" => 200, "success" => true, "message" => "OK", "requests" => $items, "pages" => $pagination->Pages]); \ No newline at end of file diff --git a/api/friends/getFriends.php b/api/friends/getFriends.php new file mode 100644 index 0000000..3a2dffd --- /dev/null +++ b/api/friends/getFriends.php @@ -0,0 +1,51 @@ + "POST"]); + +$url = $_SERVER['HTTP_REFERER'] ?? false; +$userId = $_POST['userID'] ?? false; +$page = $_POST['page'] ?? 1; +$order = strpos($url, "/home") ? "lastonline DESC" : "id"; +$limit = strpos($url, "/friends") ? 18 : 6; +$self = str_ends_with($url, "/user") || str_ends_with($url, "/friends") || strpos($url, "/home"); + +if (!Users::GetInfoFromID($userId)) API::respond(400, false, "User does not exist"); + +$friendCount = Database::singleton()->run( + "SELECT COUNT(*) FROM friends WHERE :uid IN (requesterId, receiverId) AND status = 1", + [":uid" => $userId] +)->fetchColumn(); + +$pagination = Pagination($page, $friendCount, $limit); + +if (!$pagination->Pages) API::respond(200, true, ($self ? "You do" : Users::GetNameFromID($userId)." does")."n't have any friends"); + +$friends = Database::singleton()->run( + "SELECT friends.*, users.username, users.id AS userId, users.status, users.lastonline FROM friends + INNER JOIN users ON users.id = (CASE WHEN requesterId = :uid THEN receiverId ELSE requesterId END) + WHERE :uid IN (requesterId, receiverId) AND friends.status = 1 + ORDER BY {$order} LIMIT :limit OFFSET :offset", + [":uid" => $userId, ":limit" => $limit, ":offset" => $pagination->Offset] +); + +$items = []; + +while ($friend = $friends->fetch(\PDO::FETCH_OBJ)) +{ + $items[] = + [ + "username" => $friend->username, + "userid" => $friend->userId, + "avatar" => Thumbnails::GetAvatar($friend->userId), + "friendid" => $friend->id, + "status" => Polygon::FilterText($friend->status) + ]; +} + +API::respondCustom(["status" => 200, "success" => true, "message" => "OK", "items" => $items, "pages" => $pagination->Pages]); \ No newline at end of file diff --git a/api/friends/revoke-all.php b/api/friends/revoke-all.php new file mode 100644 index 0000000..c44ae31 --- /dev/null +++ b/api/friends/revoke-all.php @@ -0,0 +1,16 @@ + "POST", "logged_in" => true, "secure" => true]); + +$RequestsCount = Database::singleton()->run( + "SELECT COUNT(*) FROM friends WHERE receiverId = :UserID AND status = 0", [":UserID" => SESSION["user"]["id"]] +)->fetchColumn(); + +if($RequestsCount == 0) API::respond(200, false, "You don't have any friend requests to decline right now"); + +Database::singleton()->run("UPDATE friends SET status = 2 WHERE receiverId = :UserID AND status = 0", [":UserID" => SESSION["user"]["id"]]); + +API::respond(200, true, "All your friend requests have been decline"); \ No newline at end of file diff --git a/api/friends/revoke.php b/api/friends/revoke.php new file mode 100644 index 0000000..a522758 --- /dev/null +++ b/api/friends/revoke.php @@ -0,0 +1,26 @@ + "POST", "logged_in" => true, "secure" => true]); + +$FriendID = API::GetParameter("POST", "FriendID", "int"); + +$FriendConnection = Database::singleton()->run("SELECT * FROM friends WHERE id = :FriendID AND NOT status = 2", [":FriendID" => $FriendID]); +$FriendConnectionInfo = $FriendConnection->fetch(\PDO::FETCH_OBJ); + +if($FriendConnection->rowCount() == 0) API::respond(200, false, "Friend connection doesn't exist"); +if(!in_array(SESSION["user"]["id"], [$FriendConnectionInfo->requesterId, $FriendConnectionInfo->receiverId])) API::respond(200, false, "You are not a part of this friend connection"); + +Database::singleton()->run("UPDATE friends SET status = 2 WHERE id = :FriendID", [":FriendID" => $FriendID]); + +// a pending request revocation can come from either the sender or the receiver, so we update both +Database::singleton()->run( + "UPDATE users + SET PendingFriendRequests = (SELECT COUNT(*) FROM friends WHERE receiverId = users.id AND status = 0) + WHERE id IN (:RequesterID, :ReceiverID)", + [":RequesterID" => $FriendConnectionInfo->requesterId, ":ReceiverID" => $FriendConnectionInfo->receiverId] +); + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/friends/send.php b/api/friends/send.php new file mode 100644 index 0000000..283e155 --- /dev/null +++ b/api/friends/send.php @@ -0,0 +1,39 @@ + "POST", "logged_in" => true, "secure" => true]); + +$UserID = API::GetParameter("POST", "UserID", "int"); + +if ($UserID == SESSION["user"]["id"]) API::respond(200, false, "You can't perform friend operations on yourself"); +if (!Users::GetInfoFromID($UserID)) API::respond(200, false, "That user doesn't exist"); + +$FriendConnection = Database::singleton()->run( + "SELECT status FROM friends WHERE :UserID IN (requesterId, receiverId) AND :ReceiverID IN (requesterId, receiverId) AND NOT status = 2", + [":UserID" => SESSION["user"]["id"], ":ReceiverID" => $UserID] +); +if($FriendConnection->rowCount() != 0) API::respond(200, false, "Friend connection already exists"); + +$LastRequest = Database::singleton()->run( + "SELECT timeSent FROM friends WHERE requesterId = :UserID AND timeSent+60 > UNIX_TIMESTAMP()", + [":UserID" => SESSION["user"]["id"]] +); +if($LastRequest->rowCount() != 0) API::respond(200, false, "Please wait ".GetReadableTime($LastRequest->fetchColumn(), ["RelativeTime" => "1 minute"])." before sending another request"); + +Database::singleton()->run( + "INSERT INTO friends (requesterId, receiverId, timeSent) VALUES (:UserID, :ReceiverID, UNIX_TIMESTAMP())", + [":UserID" => SESSION["user"]["id"], ":ReceiverID" => $UserID] +); + +// update the user's pending requests +Database::singleton()->run( + "UPDATE users + SET PendingFriendRequests = (SELECT COUNT(*) FROM friends WHERE receiverId = users.id AND status = 0) + WHERE id = :ReceiverID", + [":ReceiverID" => $UserID] +); + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/games/fetch-running.php b/api/games/fetch-running.php new file mode 100644 index 0000000..6bf6a85 --- /dev/null +++ b/api/games/fetch-running.php @@ -0,0 +1,42 @@ + "POST", "logged_in" => true]); + +$PlaceID = API::GetParameter("POST", "PlaceID", "int", false); + +$GameJobCount = Database::singleton()->run( + "SELECT COUNT(*) FROM GameJobs WHERE Status = \"Ready\" AND PlaceID = :PlaceID", + [":PlaceID" => $PlaceID] +)->fetchColumn(); + +$Pagination = Pagination(API::GetParameter("POST", "Page", "int", 1), $GameJobCount, 5); + +$GameJobs = Database::singleton()->run( + "SELECT GameJobs.*, assets.MaxPlayers FROM GameJobs + INNER JOIN assets ON assets.id = PlaceID + WHERE Status = \"Ready\" AND PlaceID = :PlaceID + ORDER BY PlayerCount DESC LIMIT 5 OFFSET :Offset", + [":PlaceID" => $PlaceID, ":Offset" => $Pagination->Offset] +); + +if ($GameJobs->rowCount() == 0) +{ + API::respond(200, true, "No games are currently running for this place"); +} + +while ($GameJob = $GameJobs->fetch(\PDO::FETCH_OBJ)) +{ + $Items[] = + [ + "JobID" => $GameJob->JobID, + "PlayerCount" => (int) $GameJob->PlayerCount, + "MaximumPlayers" => (int) $GameJob->MaxPlayers, + "IngamePlayers" => Games::GetPlayersInGame($GameJob->JobID) + ]; +} + +die(json_encode(["status" => 200, "success" => true, "message" => "OK", "pages" => $Pagination->Pages, "items" => $Items])); \ No newline at end of file diff --git a/api/games/fetch.php b/api/games/fetch.php new file mode 100644 index 0000000..9845e28 --- /dev/null +++ b/api/games/fetch.php @@ -0,0 +1,101 @@ + "POST", "logged_in" => true]); + +$Filters = +[ + "Default" => "ServerRunning DESC, ActivePlayers DESC, LastServerUpdate DESC, updated DESC", + "Top Played" => "Visits DESC", + "Recently Updated" => "updated DESC" +]; + +$Query = API::GetParameter("POST", "Query", "string", ""); +$FilterBy = API::GetParameter("POST", "FilterBy", ["Default", "Top Played", "Recently Updated"], "Default"); +$Version = API::GetParameter("POST", "FilterVersion", ["All", "2010", "2011", "2012"], "All"); +$CreatorID = API::GetParameter("POST", "CreatorID", "int", false); + +$QueryParameters = "type = 9"; +$ValueParameters = []; + +if (strlen($Query)) +{ + $QueryParameters .= " AND name LIKE :Query"; + $ValueParameters[":Query"] = "%{$Query}%"; +} + +if ($Version != "All") +{ + $QueryParameters .= " AND Version = :Version"; + $ValueParameters[":Version"] = $Version; +} + +if ($CreatorID !== false) +{ + $Limit = 10; + $OrderBy = "created DESC"; + $QueryParameters .= " AND creator = :CreatorID"; + $ValueParameters[":CreatorID"] = $CreatorID; +} +else +{ + $Limit = 24; + $OrderBy = $Filters[$FilterBy]; + +} + +$PlaceCount = Database::singleton()->run("SELECT COUNT(*) FROM assets WHERE {$QueryParameters}", $ValueParameters)->fetchColumn(); + +$Pagination = Pagination(API::GetParameter("POST", "Page", "int", 1), $PlaceCount, $Limit); +$ValueParameters[":Limit"] = $Limit; +$ValueParameters[":Offset"] = $Pagination->Offset; + +$Places = Database::singleton()->run( + "SELECT assets.*, users.username FROM assets + INNER JOIN users ON users.id = assets.creator + WHERE {$QueryParameters} ORDER BY {$OrderBy} LIMIT :Limit OFFSET :Offset", + $ValueParameters +); + +if ($Places->rowCount() == 0) +{ + if ($CreatorID === false) + { + API::respond(200, true, "No games matched your query"); + } + else if ($CreatorID == SESSION["user"]["id"]) + { + API::respond(200, true, "You do not have any active places. Manage My Places"); + } + else + { + API::respond(200, true, Users::GetNameFromID($CreatorID) . " does not have any active places"); + } +} + +while ($Place = $Places->fetch(\PDO::FETCH_OBJ)) +{ + $Items[] = + [ + "PlaceID" => (int) $Place->id, + "Name" => Polygon::FilterText($Place->name), + "Description" => Polygon::FilterText($Place->description), + "Visits" => number_format($Place->Visits), + "OnlinePlayers" => $Place->ServerRunning ? number_format($Place->ActivePlayers) : false, + "Location" => "/" . encode_asset_name($Place->name) . "-place?id={$Place->id}", + "Thumbnail" => Thumbnails::GetAsset($Place, 768, 432), + "CreatorName" => $Place->username, + "CreatorID" => $Place->creator, + "Version" => (int) $Place->Version, + "Uncopylocked" => (bool) $Place->publicDomain, + "CanPlayGame" => (bool) Games::CanPlayGame($Place) + ]; +} + +die(json_encode(["status" => 200, "success" => true, "message" => "OK", "pages" => $Pagination->Pages, "items" => $Items])); \ No newline at end of file diff --git a/api/games/placelauncher.php b/api/games/placelauncher.php new file mode 100644 index 0000000..f819b16 --- /dev/null +++ b/api/games/placelauncher.php @@ -0,0 +1,254 @@ + + [ + "Message" => "Waiting for a server", + "Code" => 0 + ], + + "Loading" => + [ + "Message" => "A server is loading the game", + "Code" => 1 + ], + + "Joining" => + [ + "Message" => "The server is ready. Joining the game...", + "Code" => 2 + ], + + "Error" => + [ + "Message" => "An error occured. Please try again later", + "Code" => 4 + ], + + "Expired" => + [ + "Message" => "There are no game servers available at this time. Please try again later.", + "Code" => 4 + ], + + "GameEnded" => + [ + "Message" => "The game you requested has ended", + "Code" => 5 + ], + + "GameFull" => + [ + "Message" => "The game you requested is full. Please try again later", + "Code" => 6 + ], + + "Ratelimit" => + [ + "Message" => "You are joining games too fast. Please try again later", + "Code" => 11 + ], + + "Unauthorized" => + [ + "Message" => "Cannot join game with no authenticated user.", + "Code" => 4 + ] +]; + +$IsTeleport = isset($_GET["isTeleport"]) && $_GET['isTeleport'] == "true"; + +if ($IsTeleport) +{ + $UserInfo = Users::GetInfoFromJobTicket(); +} +else +{ + API::initialize(["method" => "GET", "logged_in" => true, "secure" => true]); + $UserInfo = (object)SESSION["user"]; +} + +$Request = API::GetParameter("GET", "request", ["RequestGame", "RequestGameJob", "RequestFollowUser", "CheckGameJobStatus"]); + +if ($IsTeleport && GetUserAgent() != "Roblox/WinInet") +{ + die(json_encode([ + "Error" => "Request is not authorized from specified origin", + "userAgent" => $_SERVER["HTTP_USER_AGENT"] ?? null, + "referrer" => $_SERVER["HTTP_REFERER"] ?? null + ])); +} + +if (!$UserInfo) +{ + Respond("Unauthorized"); +} + +function Respond($Status, $JobID = null, $Version = null, $JoinScriptUrl = null) +{ + global $Statuses; + + $Response = []; + $StatusInfo = $Statuses[$Status]; + + $Response["jobId"] = $JobID; + $Response["status"] = $StatusInfo["Code"]; + $Response["joinScriptUrl"] = $JoinScriptUrl; + $Response["authenticationUrl"] = $JoinScriptUrl ? "https://{$_SERVER['HTTP_HOST']}/Login/Negotiate.ashx" : null; + $Response["authenticationTicket"] = $JoinScriptUrl ? "0" : null; + $Response["message"] = $StatusInfo["Message"]; + $Response["version"] = $Version; + + die(json_encode($Response)); +} + +function CheckRatelimit() +{ + global $UserInfo; + + $SessionsRequested = Database::singleton()->run( + "SELECT COUNT(*) FROM GameJobSessions WHERE UserID = :UserID AND TimeCreated + 60 > UNIX_TIMESTAMP()", + [":UserID" => $UserInfo->id] + )->fetchColumn(); + + if ($SessionsRequested >= 2) Respond("Ratelimit"); +} + +function CreateNewSession($Job) +{ + global $UserInfo, $IsTeleport; + + $Ticket = generateUUID(); + $SecurityTicket = generateUUID(); + + CheckRatelimit(); + + Database::singleton()->run( + "INSERT INTO GameJobSessions (Ticket, SecurityTicket, JobID, IsTeleport, UserID, TimeCreated) + VALUES (:Ticket, :SecurityTicket, :JobID, :IsTeleport, :UserID, UNIX_TIMESTAMP())", + [":Ticket" => $Ticket, ":SecurityTicket" => $SecurityTicket, ":JobID" => $Job->JobID, ":IsTeleport" => (int)$IsTeleport, ":UserID" => $UserInfo->id] + ); + + Respond("Joining", $Job->JobID, $Job->Version, "http://{$_SERVER['HTTP_HOST']}/Game/Join.ashx?JobTicket={$Ticket}"); +} + +if ($Request == "RequestGame") // clicking the "play" button +{ + $PlaceID = API::GetParameter("GET", "placeId", "int"); + $PlaceInfo = Database::singleton()->run("SELECT * FROM assets WHERE id = :PlaceID", [":PlaceID" => $PlaceID])->fetch(\PDO::FETCH_OBJ); + + if (!$PlaceInfo || $PlaceInfo->type != 9) Respond("Error"); + if ($IsTeleport && $PlaceInfo->Version != 2012) Respond("Error"); // TODO - add multi-client support for individual places? + + if (!Games::CanPlayGame($PlaceInfo)) + { + Respond("Error"); + } + + // check for an available game job + $AvailableJob = Database::singleton()->run( + "SELECT GameJobs.* FROM GameJobs + WHERE NOT Status IN (\"Closed\", \"Crashed\") + AND PlaceID = :PlaceID + AND PlayerCount < :MaxPlayers + LIMIT 1", + [":PlaceID" => $PlaceID, ":MaxPlayers" => $PlaceInfo->MaxPlayers] + )->fetch(\PDO::FETCH_OBJ); + + if ($AvailableJob) + { + if ($AvailableJob->Status == "Ready") + { + CreateNewSession($AvailableJob); + } + else + { + Respond($AvailableJob->Status == "Pending" ? "Waiting" : "Loading", $AvailableJob->JobID); + } + } + else + { + CheckRatelimit(); + + // get an available server + $GameServer = Database::singleton()->run( + "SELECT * FROM GameServers + WHERE Online + AND LastUpdated + 65 > UNIX_TIMESTAMP() + AND ActiveJobs < MaximumJobs + AND CpuUsage < 90 + AND AvailableMemory > 1024 + ORDER BY Priority ASC LIMIT 1" + )->fetch(\PDO::FETCH_OBJ); + + if (!$GameServer) Respond("Expired"); + + $JobID = generateUUID(); + + $ServersRequested = Database::singleton()->run( + "SELECT COUNT(*) FROM GameJobs WHERE RequestedBy = :UserID AND TimeCreated + 60 > UNIX_TIMESTAMP()", + [":UserID" => $UserInfo->id] + )->fetchColumn(); + + if ($ServersRequested >= 2) Respond("Error"); + + // request a new job + $GameJob = Database::singleton()->run( + "INSERT INTO GameJobs (RequestedBy, JobID, ServerID, Version, PlaceID, TimeCreated, LastUpdated) + VALUES (:UserID, :JobID, :ServerID, :Version, :PlaceID, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())", + [ + ":UserID" => $UserInfo->id, + ":JobID" => $JobID, + ":ServerID" => $GameServer->ServerID, + ":Version" => $PlaceInfo->Version, + ":PlaceID" => $PlaceInfo->id + ] + ); + + $Request = "{\"Operation\":\"OpenJob\", \"JobID\":\"{$JobID}\", \"Version\":{$PlaceInfo->Version}, \"PlaceID\":{$PlaceInfo->id}}"; + $Socket = fsockopen($GameServer->ServiceAddress, $GameServer->ServicePort); + fwrite($Socket, $Request); + fclose($Socket); + + Respond("Waiting", $JobID); + } +} +else if ($Request == "RequestFollowUser") // joining a user's game +{ + +} +else if ($Request == "RequestGameJob" || $Request == "CheckGameJobStatus") +{ + $JobID = API::GetParameter("GET", "jobId", "string"); + + // check for an available game job + $AvailableJob = Database::singleton()->run( + "SELECT GameJobs.*, assets.MaxPlayers FROM GameJobs + INNER JOIN assets ON assets.id = PlaceID + WHERE JobID = :JobID", + [":JobID" => $JobID] + )->fetch(\PDO::FETCH_OBJ); + + if (!Games::CanPlayGame((int)$AvailableJob->PlaceID)) + { + Respond("Error"); + } + + if (!$AvailableJob) Respond("Error"); + if ($AvailableJob->Status == "Closed" || $AvailableJob->Status == "Crashed") Respond("GameEnded"); + if ($AvailableJob->PlayerCount >= $AvailableJob->MaxPlayers) Respond("GameFull"); + + if ($AvailableJob->Status == "Pending") Respond("Waiting", $JobID); + if ($AvailableJob->Status == "Loading") Respond("Loading", $JobID); + + CreateNewSession($AvailableJob); +} \ No newline at end of file diff --git a/api/games/shutdown.php b/api/games/shutdown.php new file mode 100644 index 0000000..1e12ca5 --- /dev/null +++ b/api/games/shutdown.php @@ -0,0 +1,26 @@ + "GET", "logged_in" => true, "secure" => true]); + +$JobID = API::GetParameter("GET", "jobId", "string"); +$JobInfo = Database::singleton()->run("SELECT * FROM GameJobs WHERE JobID = :JobID", [":JobID" => $JobID])->fetch(\PDO::FETCH_OBJ); + +if (!$JobInfo) API::respond(200, false, "The requested game does not exist"); + +$ServerInfo = Database::singleton()->run("SELECT * FROM GameServers WHERE ServerID = :ServerID", [":ServerID" => $JobInfo->ServerID])->fetch(\PDO::FETCH_OBJ); +$PlaceInfo = Database::singleton()->run("SELECT * FROM assets WHERE id = :PlaceID", [":PlaceID" => $JobInfo->PlaceID])->fetch(\PDO::FETCH_OBJ); + +if ($PlaceInfo->creator != SESSION["user"]["id"] && !Users::IsAdmin(Users::STAFF_ADMINISTRATOR)) API::respond(200, false, "The requested game cannot be shut down"); +if ($JobInfo->Status == "Closed" || $JobInfo->Status == "Crashed") API::respond(200, false, "The requested game has already been shut down"); +if ($JobInfo->Status != "Ready") API::respond(200, false, "The requested game cannot be shut down"); + +$Request = "{\"Operation\":\"CloseJob\", \"JobID\":\"{$JobID}\"}"; +$Socket = fsockopen($ServerInfo->ServiceAddress, $ServerInfo->ServicePort); +fwrite($Socket, $Request); +fclose($Socket); + +API::respond(200, true, "The requested game has been shut down"); \ No newline at end of file diff --git a/api/groups/admin/get-members.php b/api/groups/admin/get-members.php new file mode 100644 index 0000000..457d374 --- /dev/null +++ b/api/groups/admin/get-members.php @@ -0,0 +1,55 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(isset($_POST["Page"]) && !is_numeric($_POST["Page"])) API::respond(400, false, "Page is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; +$Page = $_POST["Page"] ?? 1; +$Members = []; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); +$Rank = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); + +if($Rank->Level == 0) API::respond(200, false, "You are not a member of this group"); +if(!$Rank->Permissions->CanManageGroupAdmin) API::respond(200, false, "You are not allowed to perform this action"); + +$MemberCount = Database::singleton()->run( + "SELECT COUNT(*) FROM groups_members + WHERE GroupID = :GroupID AND Rank < :RankLevel AND NOT Pending", + [":GroupID" => $GroupID, ":RankLevel" => $Rank->Level] +)->fetchColumn(); + +$Pages = ceil($MemberCount/12); +$Offset = ($Page - 1)*12; + +if(!$Pages) API::respond(200, true, "This group does not have any members."); + +$MembersQuery = Database::singleton()->run( + "SELECT users.username, users.id, Rank FROM groups_members + INNER JOIN users ON users.id = groups_members.UserID + WHERE GroupID = :GroupID AND Rank < :RankLevel AND NOT Pending + ORDER BY Joined DESC LIMIT 12 OFFSET $Offset", + [":GroupID" => $GroupID, ":RankLevel" => $Rank->Level] +); + +while($Member = $MembersQuery->fetch(\PDO::FETCH_OBJ)) +{ + $Members[] = + [ + "UserName" => $Member->username, + "UserID" => $Member->id, + "RoleLevel" => $Member->Rank, + "Avatar" => Thumbnails::GetAvatar($Member->id) + ]; +} + +die(json_encode(["status" => 200, "success" => true, "message" => "OK", "pages" => $Pages, "count" => $MemberCount, "items" => $Members])); \ No newline at end of file diff --git a/api/groups/admin/get-roles.php b/api/groups/admin/get-roles.php new file mode 100644 index 0000000..4b4de4e --- /dev/null +++ b/api/groups/admin/get-roles.php @@ -0,0 +1,47 @@ + "POST"]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; +$Roles = []; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); +$Rank = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); + +if($Rank->Level == 0) API::respond(200, false, "You are not a member of this group"); +if(!$Rank->Permissions->CanManageGroupAdmin) API::respond(200, false, "You are not allowed to perform this action"); + +if($Rank->Level == 255) +{ + $RolesQuery = Database::singleton()->run( + "SELECT * FROM groups_ranks WHERE GroupID = :GroupID ORDER BY Rank ASC", + [":GroupID" => $GroupID] + ); +} +else +{ + $RolesQuery = Database::singleton()->run( + "SELECT * FROM groups_ranks WHERE GroupID = :GroupID AND Rank < :MyRank ORDER BY Rank ASC", + [":GroupID" => $GroupID, ":MyRank" => $Rank->Level] + ); +} + +while($Role = $RolesQuery->fetch(\PDO::FETCH_OBJ)) +{ + $Roles[] = + [ + "Name" => htmlspecialchars($Role->Name), + "Description" => htmlspecialchars($Role->Description), + "Rank" => $Role->Rank, + "Permissions" => json_decode($Role->Permissions) + ]; +} + +die(json_encode(["status" => 200, "success" => true, "message" => "OK", "items" => $Roles])); \ No newline at end of file diff --git a/api/groups/admin/request-relationship.php b/api/groups/admin/request-relationship.php new file mode 100644 index 0000000..8cd9d5a --- /dev/null +++ b/api/groups/admin/request-relationship.php @@ -0,0 +1,114 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(!isset($_POST["Recipient"])) API::respond(400, false, "Recipient is not set"); + +if(!isset($_POST["Type"])) API::respond(400, false, "Type is not set"); +if(!in_array($_POST["Type"], ["ally", "enemy"])) API::respond(400, false, "Type is not valid"); + +$GroupID = $_POST["GroupID"] ?? false; +$RecipientName = $_POST["Recipient"] ?? false; +$Type = $_POST["Type"] ?? false; +$Groups = []; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); + +$Recipient = Database::singleton()->run("SELECT * FROM groups WHERE name = :GroupName", [":GroupName" => $RecipientName]); +$RecipientInfo = $Recipient->fetch(\PDO::FETCH_OBJ); + +if(!$Recipient->rowCount()) API::respond(200, false, "No group with that name exists"); + +$MyRank = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); +if(!$MyRank->Permissions->CanManageRelationships) API::respond(200, false, "You are not allowed to manage this group's relationships"); + +if($RecipientInfo->id == $GroupID) +{ + if($Type == "ally") API::respond(200, false, "You cannot send an ally request to your own group"); + else if($Type == "enemy") API::respond(200, false, "You cannot declare your own group as an enemy"); +} + +$Relationship = Database::singleton()->run( + "SELECT * FROM groups_relationships WHERE :GroupID IN (Declarer, Recipient) AND :Recipient IN (Declarer, Recipient) AND Status != 2", + [":GroupID" => $GroupID, ":Recipient" => $RecipientInfo->id] +); +$RelationshipInfo = $Relationship->fetch(\PDO::FETCH_OBJ); + +if($Relationship->rowCount()) +{ + if($RelationshipInfo->Type == "Allies") + { + if($RelationshipInfo->Status == 0) + { + if($RelationshipInfo->Declarer == $GroupID) + { + API::respond(200, false, "You already have an outgoing ally request to this group"); + } + else + { + API::respond(200, false, "You already have an incoming ally request from this group"); + } + } + else if($RelationshipInfo->Status == 1) + { + API::respond(200, false, "You are already allies with this group!"); + } + } + else if($RelationshipInfo->Type == "Enemies") + { + API::respond(200, false, "You are already enemies with this group!"); + } +} + +if($Type == "ally") +{ + $LastRequest = Database::singleton()->run("SELECT Declared FROM groups_relationships WHERE Declarer = :GroupID AND Declared+3600 > UNIX_TIMESTAMP()", [":GroupID" => $GroupID]); + if($LastRequest->rowCount()) + API::respond(429, false, "Please wait ".GetReadableTime($LastRequest->fetchColumn(), ["RelativeTime" => "1 hour"])." before sending a new ally request"); + + Database::singleton()->run( + "INSERT INTO groups_relationships (Type, Declarer, Recipient, Status, Declared) + VALUES (\"Allies\", :GroupID, :Recipient, 0, UNIX_TIMESTAMP())", + [":GroupID" => $GroupID, ":Recipient" => $RecipientInfo->id] + ); + + Groups::LogAction( + $GroupID, "Send Ally Request", + sprintf( + "%s sent an ally request to %s", + SESSION["user"]["id"], SESSION["user"]["username"], $RecipientInfo->id, htmlspecialchars($RecipientInfo->name) + ) + ); + API::respond(200, true, "Ally request has been sent to ".Polygon::FilterText($RecipientInfo->name)); +} +else if($Type == "enemy") +{ + $LastRequest = Database::singleton()->run("SELECT Declared FROM groups_relationships WHERE Declarer = :GroupID AND Declared+3600 > UNIX_TIMESTAMP()", [":GroupID" => $GroupID]); + if($LastRequest->rowCount()) + API::respond(429, false, "Please wait ".GetReadableTime($LastRequest->fetchColumn(), ["RelativeTime" => "1 hour"])." before sending a new ally request"); + + Database::singleton()->run( + "INSERT INTO groups_relationships (Type, Declarer, Recipient, Status, Declared, Established) + VALUES (\"Enemies\", :GroupID, :Recipient, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())", + [":GroupID" => $GroupID, ":Recipient" => $RecipientInfo->id] + ); + + Groups::LogAction( + $GroupID, "Create Enemy", + sprintf( + "%s declared %s as an enemy", + SESSION["user"]["id"], SESSION["user"]["username"], $RecipientInfo->id, htmlspecialchars($RecipientInfo->name) + ) + ); + API::respond(200, true, Polygon::FilterText($RecipientInfo->name)." is now your enemy!"); +} + +API::respond(200, false, "An unexpected error occurred"); \ No newline at end of file diff --git a/api/groups/admin/update-member.php b/api/groups/admin/update-member.php new file mode 100644 index 0000000..8265409 --- /dev/null +++ b/api/groups/admin/update-member.php @@ -0,0 +1,61 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(!isset($_POST["UserID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["UserID"])) API::respond(400, false, "GroupID is not a number"); + +if(isset($_POST["RoleLevel"]) && !is_numeric($_POST["RoleLevel"])) API::respond(400, false, "RoleLevel is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; +$UserID = $_POST["UserID"] ?? false; + +$RoleLevel = $_POST["RoleLevel"] ?? false; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); + +$MyRole = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); +$UserRole = Groups::GetUserRank($UserID, $GroupID); + +if($MyRole->Level == 0) API::respond(200, false, "You are not a member of this group"); +if(!$MyRole->Permissions->CanManageGroupAdmin) API::respond(200, false, "You are not allowed to perform this action"); +if($UserRole->Level == 0) API::respond(200, false, "That user is not a member of this group"); + +if($RoleLevel !== false) +{ + if(!Groups::GetRankInfo($GroupID, $RoleLevel)) API::respond(200, false, "That role does not exist"); + if($RoleLevel == 0 || $RoleLevel == 255) API::respond(200, false, "That role cannot be manually assigned to a member"); + + if($UserRole->Level == $RoleLevel) API::respond(200, false, "The role you tried to assign is the user's current role"); + if($MyRole->Level <= $RoleLevel) API::respond(200, false, "You can only assign roles lower than yours"); + if($MyRole->Level <= $UserRole->Level) API::respond(200, false, "You can only modify the role of a user who is a role lower than yours"); + + Database::singleton()->run( + "UPDATE groups_members SET Rank = :RoleLevel WHERE GroupID = :GroupID AND UserID = :UserID", + [":GroupID" => $GroupID, ":UserID" => $UserID, ":RoleLevel" => $RoleLevel] + ); + + $UserName = Users::GetNameFromID($UserID); + $RoleName = Groups::GetRankInfo($GroupID, $RoleLevel)->Name; + $Action = $RoleLevel > $UserRole->Level ? "promoted" : "demoted"; + + Groups::LogAction( + $GroupID, "Change Rank", + sprintf( + "%s %s %s from %s to %s", + SESSION["user"]["id"], SESSION["user"]["username"], $Action, $UserID, $UserName, htmlspecialchars($UserRole->Name), htmlspecialchars($RoleName) + ) + ); + + API::respond(200, true, "$UserName has been $Action to " . htmlspecialchars($RoleName)); +} + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/groups/admin/update-relationship.php b/api/groups/admin/update-relationship.php new file mode 100644 index 0000000..a112268 --- /dev/null +++ b/api/groups/admin/update-relationship.php @@ -0,0 +1,108 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(!isset($_POST["Recipient"])) API::respond(400, false, "Recipient is not set"); +if(!is_numeric($_POST["Recipient"])) API::respond(400, false, "Recipient is not a number"); + +if(!isset($_POST["Action"])) API::respond(400, false, "Action is not set"); +if(!in_array($_POST["Action"], ["accept", "decline"])) API::respond(400, false, "Action is not valid"); + +$GroupID = $_POST["GroupID"] ?? false; +$Recipient = $_POST["Recipient"] ?? false; +$Action = $_POST["Action"] ?? false; +$Groups = []; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); +if(!Groups::GetGroupInfo($Recipient)) API::respond(200, false, "Recipient group does not exist"); + +$MyRank = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); +if(!$MyRank->Permissions->CanManageRelationships) API::respond(200, false, "You are not allowed to manage this group's relationships"); + +$Relationship = Database::singleton()->run( + "SELECT groups_relationships.*, groups.name FROM groups_relationships + INNER JOIN groups ON groups.id = (CASE WHEN Declarer = :GroupID THEN Recipient ELSE Declarer END) + WHERE :GroupID IN (Declarer, Recipient) AND :Recipient IN (Declarer, Recipient) AND Status != 2", + [":GroupID" => $GroupID, ":Recipient" => $Recipient] +); +$RelationshipInfo = $Relationship->fetch(\PDO::FETCH_OBJ); + +if(!$Relationship->rowCount()) API::respond(200, false, "You are not in a relationship with this group"); + +if($Action == "accept") +{ + if($RelationshipInfo->Type == "Enemies") API::respond(200, false, "You cannot accept an enemy relationship"); + if($RelationshipInfo->Status != 0) API::respond(200, false, "You are already in a relationship with this group"); + + Database::singleton()->run( + "UPDATE groups_relationships SET Status = 1, Established = UNIX_TIMESTAMP() WHERE ID = :RelationshipID", + [":RelationshipID" => $RelationshipInfo->ID] + ); + + Groups::LogAction( + $GroupID, "Accept Ally Request", + sprintf( + "%s accepted an ally request from %s", + SESSION["user"]["id"], SESSION["user"]["username"], $Recipient, htmlspecialchars($RelationshipInfo->name) + ) + ); + + API::respond(200, true, "You have accepted {$RelationshipInfo->name}'s ally request"); +} +else if($Action == "decline") +{ + Database::singleton()->run( + "UPDATE groups_relationships SET Status = 2, Broken = UNIX_TIMESTAMP() WHERE ID = :RelationshipID", + [":RelationshipID" => $RelationshipInfo->ID] + ); + + if($RelationshipInfo->Type == "Allies") + { + if($RelationshipInfo->Status == 0) + { + Groups::LogAction( + $GroupID, "Decline Ally Request", + sprintf( + "%s declined an ally request from %s", + SESSION["user"]["id"], SESSION["user"]["username"], $Recipient, htmlspecialchars($RelationshipInfo->name) + ) + ); + + API::respond(200, true, "You have declined ".Polygon::FilterText($RelationshipInfo->name)."'s ally request"); + } + else if($RelationshipInfo->Status == 1) + { + Groups::LogAction( + $GroupID, "Delete Ally", + sprintf( + "%s removed %s as an ally", + SESSION["user"]["id"], SESSION["user"]["username"], $Recipient, htmlspecialchars($RelationshipInfo->name) + ) + ); + + API::respond(200, true, "You are no longer allies with ".Polygon::FilterText($RelationshipInfo->name)); + } + } + else if($RelationshipInfo->Type == "Enemies") + { + Groups::LogAction( + $GroupID, "Delete Enemy", + sprintf( + "%s removed %s as an enemy", + SESSION["user"]["id"], SESSION["user"]["username"], $Recipient, htmlspecialchars($RelationshipInfo->name) + ) + ); + + API::respond(200, true, "You are no longer enemies with ".Polygon::FilterText($RelationshipInfo->name)); + } +} + +API::respond(200, false, "An unexpected error occurred"); \ No newline at end of file diff --git a/api/groups/admin/update-roles.php b/api/groups/admin/update-roles.php new file mode 100644 index 0000000..cc8544c --- /dev/null +++ b/api/groups/admin/update-roles.php @@ -0,0 +1,170 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(!isset($_POST["Roles"])) API::respond(400, false, "Roles is not set"); + +$GroupID = $_POST["GroupID"]; +$Roles = json_decode($_POST["Roles"]); + +if(!$Roles) API::respond(400, false, "Roles is not valid JSON"); +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); + +$MyRole = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); + +if($MyRole->Level == 0) API::respond(200, false, "You are not a member of this group"); +if($MyRole->Level != 255) API::respond(200, false, "You are not allowed to perform this action"); + +function FindRolesWithRank($Rank) +{ + global $Roles; + $Count = 0; + + foreach ($Roles as $Role) + { + if (!isset($Role->Rank)) continue; + if ($Role->Rank == $Rank) $Count += 1; + } + + return $Count; +} + +function FindRoleWithRank($Rank) +{ + global $Roles; + + foreach ($Roles as $Role) + { + if (!isset($Role->Rank)) continue; + if ($Role->Rank == $Rank) return $Role; + } + + return false; +} + +$Permissions = +[ + "CanViewGroupWall", + "CanViewGroupStatus", + "CanPostOnGroupWall", + "CanPostGroupStatus", + "CanDeleteGroupWallPosts", + "CanAcceptJoinRequests", + "CanKickLowerRankedMembers", + "CanRoleLowerRankedMembers", + "CanManageRelationships", + "CanCreateAssets", + "CanConfigureAssets", + "CanSpendFunds", + "CanManageGames", + "CanManageGroupAdmin", + "CanViewAuditLog" +]; + +if(FindRolesWithRank(0) == 0) API::respond(200, false, "You can not remove the Guest role"); +if(FindRolesWithRank(255) == 0) API::respond(200, false, "You can not remove the Owner role"); +if(count($Roles) < 3) API::respond(200, false, "There must be at least three roles"); +if(count($Roles) > 10) API::respond(200, false, "There must be no more than ten roles"); + +foreach($Roles as $Role) +{ + if(!isset($Role->Name) || !isset($Role->Description) || !isset($Role->Rank) || !isset($Role->Permissions)) + API::respond(200, false, "Roles are missing parameters"); + + if($Role->Rank < 0 || $Role->Rank > 255) API::respond(200, false, "Each role must have a rank number between 0 and 255"); + if(FindRolesWithRank($Role->Rank) > 1) API::respond(200, false, "Each role must have a unique rank number"); + + $CurrentRole = Groups::GetRankInfo($GroupID, $Role->Rank); + + if($CurrentRole === false) $Role->Action = "Create"; + else $Role->Action = "Update"; + + if($Role->Rank == 0) + { + if($Role->Name != $CurrentRole->Name || $Role->Description != $CurrentRole->Description) + API::respond(200, false, "You can not modify the Guest role"); + } + + if($Role->Rank == 255 && $Role->Permissions != $CurrentRole->Permissions) + API::respond(200, false, "You can not modify the permissions of the Owner role"); + + if(strlen($Role->Name) < 3) API::respond(200, false, "Role names must be at least 3 characters long"); + if(strlen($Role->Name) > 15) API::respond(200, false, "Role names must be no longer than 15 characters"); + + if(strlen($Role->Description) < 3) API::respond(200, false, "Role description must at least 3 characters long"); + if(strlen($Role->Description) > 64) API::respond(200, false, "Role description must be no longer than 64 characters"); + + foreach ($Permissions as $Permission) + { + if(!isset($Role->Permissions->$Permission)) API::respond(200, false, "Role is missing a permission"); + if(!is_bool($Role->Permissions->$Permission)) API::respond(200, false, "Role permission property must have a boolean value"); + } + + if(count((array)$Role->Permissions) != count($Permissions)) API::respond(200, false, "Role permissions contains an incorrect number of permissions"); +} + +foreach($Roles as $Role) +{ + if($Role->Action == "Create") + { + // if(SESSION["user"]["id"] == 1) echo "Creating Role {$Role->Rank}\r\n"; + + Database::singleton()->run( + "INSERT INTO groups_ranks (GroupID, Name, Description, Rank, Permissions, Created) + VALUES (:GroupID, :Name, :Description, :Rank, :Permissions, UNIX_TIMESTAMP())", + [":GroupID" => $GroupID, ":Name" => $Role->Name, ":Description" => $Role->Description, ":Rank" => $Role->Rank, ":Permissions" => json_encode($Role->Permissions)] + ); + } +} + +$GroupRoles = Groups::GetGroupRanks($GroupID, true); +while($ExistingRole = $GroupRoles->fetch(\PDO::FETCH_OBJ)) +{ + $Role = FindRoleWithRank($ExistingRole->Rank); + + if($Role == false) + { + // if(SESSION["user"]["id"] == 1) echo "Deleting Role {$ExistingRole->Rank}\r\n"; + + // for this one we gotta move the members with a role thats being deleted to the lowest rank + // slight issue with this is for a brief period, members assigned the role thats being deleted + // will have no role - if the timing is just right this could mess up the view of the group page + + // delete the rank by the oldest id - so that we dont accidentally delete the new one + Database::singleton()->run( + "DELETE FROM groups_ranks WHERE GroupID = :GroupID AND Rank = :Rank ORDER BY id ASC LIMIT 1", + [":GroupID" => $GroupID, ":Rank" => $ExistingRole->Rank] + ); + + $NewRank = Database::singleton()->run( + "SELECT Rank FROM polygon.groups_ranks WHERE GroupID = :GroupID AND Rank != 0 ORDER BY Rank ASC LIMIT 1", + [":GroupID" => $GroupID] + )->fetchColumn(); + + // if(SESSION["user"]["id"] == 1) echo "Updating existing members to {$NewRank}\r\n"; + + Database::singleton()->run( + "UPDATE groups_members SET Rank = :NewRank WHERE GroupID = :GroupID AND Rank = :Rank", + [":GroupID" => $GroupID, ":Rank" => $ExistingRole->Rank, ":NewRank" => $NewRank] + ); + } + else if(isset($Role->Action) && $Role->Action == "Update") + { + // if(SESSION["user"]["id"] == 1) echo "Updating Role {$Role->Rank}\r\n"; + + Database::singleton()->run( + "UPDATE groups_ranks SET Name = :Name, Description = :Description, Permissions = :Permissions + WHERE GroupID = :GroupID AND Rank = :Rank", + [":GroupID" => $GroupID, ":Name" => $Role->Name, ":Description" => $Role->Description, ":Rank" => $Role->Rank, ":Permissions" => json_encode($Role->Permissions)] + ); + } +} + +die(json_encode(["status" => 200, "success" => true, "message" => "Group roles have successfully been updated"])); \ No newline at end of file diff --git a/api/groups/delete-wall-post.php b/api/groups/delete-wall-post.php new file mode 100644 index 0000000..37f1e12 --- /dev/null +++ b/api/groups/delete-wall-post.php @@ -0,0 +1,44 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(!isset($_POST["PostID"])) API::respond(400, false, "PostID is not set"); +if(!is_numeric($_POST["PostID"])) API::respond(400, false, "PostID is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; +$PostID = $_POST["PostID"] ?? false; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); + +$Rank = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); +if(!$Rank->Permissions->CanDeleteGroupWallPosts) API::respond(200, false, "You are not allowed to delete wall posts on this group"); + +$PostInfo = Database::singleton()->run( + "SELECT * FROM groups_wall WHERE id = :PostID AND :GroupID = :GroupID", + [":PostID" => $PostID, ":GroupID" => $GroupID] +)->fetch(\PDO::FETCH_OBJ); + +if(!$PostInfo) API::respond(200, false, "Wall post does not exist"); + +Groups::LogAction( + $GroupID, "Delete Post", + sprintf( + "%s deleted post \"%s\" by %s", + SESSION["user"]["id"], SESSION["user"]["username"], htmlspecialchars($PostInfo->Content), $PostInfo->PosterID, Users::GetNameFromID($PostInfo->PosterID) + ) +); + +Database::singleton()->run( + "DELETE FROM groups_wall WHERE id = :PostID AND :GroupID = :GroupID", + [":PostID" => $PostID, ":GroupID" => $GroupID] +); + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/groups/get-audit.php b/api/groups/get-audit.php new file mode 100644 index 0000000..e858dd7 --- /dev/null +++ b/api/groups/get-audit.php @@ -0,0 +1,79 @@ + "POST"]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(isset($_POST["Page"]) && !is_numeric($_POST["Page"])) API::respond(400, false, "Page is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; +$Filter = $_POST["Filter"] ?? "All Actions"; +$Page = $_POST["Page"] ?? 1; +$Logs = []; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); +$MyRank = Groups::GetUserRank(SESSION["user"]["id"] ?? 0, $GroupID); +if(!$MyRank->Permissions->CanViewAuditLog) API::respond(200, false, "You cannot audit this group"); + +if($Filter == "All Actions") +{ + $LogCount = Database::singleton()->run( + "SELECT COUNT(*) FROM groups_audit WHERE GroupID = :GroupID", + [":GroupID" => $GroupID] + )->fetchColumn(); +} +else +{ + $LogCount = Database::singleton()->run( + "SELECT COUNT(*) FROM groups_audit WHERE GroupID = :GroupID AND Category = :Action", + [":GroupID" => $GroupID, ":Action" => $Filter] + )->fetchColumn(); +} + +$Pages = ceil($LogCount/20); +$Offset = ($Page - 1)*20; + +if(!$Pages) API::respond(200, true, "This group does not have any logs for this action."); + +if($Filter == "All Actions") +{ + $LogsQuery = Database::singleton()->run( + "SELECT groups_audit.*, users.username FROM groups_audit + INNER JOIN users ON users.id = UserId + WHERE GroupID = :GroupID + ORDER BY Time DESC LIMIT 20 OFFSET $Offset", + [":GroupID" => $GroupID] + ); +} +else +{ + $LogsQuery = Database::singleton()->run( + "SELECT groups_audit.*, users.username FROM groups_audit + INNER JOIN users ON users.id = UserId + WHERE GroupID = :GroupID AND Category = :Action + ORDER BY Time DESC LIMIT 20 OFFSET $Offset", + [":GroupID" => $GroupID, ":Action" => $Filter] + ); +} + +while($Log = $LogsQuery->fetch(\PDO::FETCH_OBJ)) +{ + $Logs[] = + [ + "Date" => date('j/n/y G:i', $Log->Time), + "UserName" => $Log->username, + "UserID" => $Log->UserID, + "UserAvatar" => Thumbnails::GetAvatar($Log->UserID), + "Rank" => Polygon::FilterText($Log->Rank), + "Description" => Polygon::FilterText($Log->Description, false) + ]; +} + +die(json_encode(["status" => 200, "success" => true, "message" => "OK", "pages" => $Pages, "items" => $Logs])); \ No newline at end of file diff --git a/api/groups/get-members.php b/api/groups/get-members.php new file mode 100644 index 0000000..2199ba8 --- /dev/null +++ b/api/groups/get-members.php @@ -0,0 +1,53 @@ + "POST"]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(!isset($_POST["RankLevel"])) API::respond(400, false, "RankLevel is not set"); +if(!is_numeric($_POST["RankLevel"])) API::respond(400, false, "RankLevel is not a number"); + +if(isset($_POST["Page"]) && !is_numeric($_POST["Page"])) API::respond(400, false, "Page is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; +$RankID = $_POST["RankLevel"] ?? false; +$Members = []; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); +if(!Groups::GetRankInfo($GroupID, $RankID)) API::respond(200, false, "Group rank does not exist"); + +$MemberCount = Database::singleton()->run( + "SELECT COUNT(*) FROM groups_members WHERE GroupID = :GroupID AND Rank = :RankID AND NOT Pending", + [":GroupID" => $GroupID, ":RankID" => $RankID] +)->fetchColumn(); + +$Pagination = Pagination($_POST["Page"] ?? 1, $MemberCount, 12); + +if($Pagination->Pages == 0) API::respond(200, true, "This group does not have any members of this rank."); + +$MembersQuery = Database::singleton()->run( + "SELECT users.username, users.id, Rank FROM groups_members + INNER JOIN users ON users.id = groups_members.UserID + WHERE GroupID = :GroupID AND Rank = :RankID AND NOT Pending + ORDER BY Joined DESC LIMIT 12 OFFSET :Offset", + [":GroupID" => $GroupID, ":RankID" => $RankID, ":Offset" => $Pagination->Offset] +); + +while($Member = $MembersQuery->fetch(\PDO::FETCH_OBJ)) +{ + $Members[] = + [ + "UserName" => $Member->username, + "UserID" => $Member->id, + "RoleLevel" => $Member->Rank, + "Avatar" => Thumbnails::GetAvatar($Member->id) + ]; +} + +die(json_encode(["status" => 200, "success" => true, "message" => "OK", "pages" => $Pagination->Pages, "count" => $MemberCount, "items" => $Members])); \ No newline at end of file diff --git a/api/groups/get-related.php b/api/groups/get-related.php new file mode 100644 index 0000000..d5f4493 --- /dev/null +++ b/api/groups/get-related.php @@ -0,0 +1,82 @@ + "POST"]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(!isset($_POST["Type"])) API::respond(400, false, "Type is not set"); +if(!in_array($_POST["Type"], ["Pending Allies", "Allies", "Enemies"])) API::respond(400, false, "Type is not valid"); + +if(isset($_POST["Page"]) && !is_numeric($_POST["Page"])) API::respond(400, false, "Page is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; +$Type = $_POST["Type"] ?? false; +$Page = $_POST["Page"] ?? 1; +$Groups = []; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); + +if($Type == "Pending Allies") +{ + if(!SESSION) API::respond(200, false, "You are not allowed to get this group's pending allies"); + $MyRank = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); + if(!$MyRank->Permissions->CanManageRelationships) API::respond(200, false, "You are not allowed to get this group's pending allies"); + + $GroupsCount = Database::singleton()->run( + "SELECT COUNT(*) FROM groups_relationships WHERE Recipient = :GroupID AND Type = \"Allies\" AND Status = 0", + [":GroupID" => $GroupID] + )->fetchColumn(); +} +else +{ + $GroupsCount = Database::singleton()->run( + "SELECT COUNT(*) FROM groups_relationships WHERE :GroupID IN (Declarer, Recipient) AND Type = :Type AND Status = 1", + [":GroupID" => $GroupID, ":Type" => $Type] + )->fetchColumn(); +} + +$Pages = ceil($GroupsCount/12); +$Offset = ($Page - 1)*12; + +if(!$Pages) API::respond(200, true, "This group does not have any $Type"); + +if($Type == "Pending Allies") +{ + $GroupsQuery = Database::singleton()->run( + "SELECT groups.name, groups.id, groups.emblem, groups.MemberCount FROM groups_relationships + INNER JOIN groups ON groups.id = (CASE WHEN Declarer = :GroupID THEN Recipient ELSE Declarer END) + WHERE Recipient = :GroupID AND Type = \"Allies\" AND Status = 0 + ORDER BY Declared DESC LIMIT 12 OFFSET $Offset", + [":GroupID" => $GroupID] + ); +} +else +{ + $GroupsQuery = Database::singleton()->run( + "SELECT groups.name, groups.id, groups.emblem, groups.MemberCount FROM groups_relationships + INNER JOIN groups ON groups.id = (CASE WHEN Declarer = :GroupID THEN Recipient ELSE Declarer END) + WHERE :GroupID IN (Declarer, Recipient) AND Type = :Type AND Status = 1 + ORDER BY Established DESC LIMIT 12 OFFSET $Offset", + [":GroupID" => $GroupID, ":Type" => $Type] + ); +} + +while($Group = $GroupsQuery->fetch(\PDO::FETCH_OBJ)) +{ + $Groups[] = + [ + "Name" => Polygon::FilterText($Group->name), + "ID" => $Group->id, + "MemberCount" => $Group->MemberCount, + "Emblem" => Thumbnails::GetAssetFromID($Group->emblem) + ]; +} + +die(json_encode(["status" => 200, "success" => true, "message" => "OK", "pages" => $Pages, "items" => $Groups])); \ No newline at end of file diff --git a/api/groups/get-wall.php b/api/groups/get-wall.php new file mode 100644 index 0000000..c9117d7 --- /dev/null +++ b/api/groups/get-wall.php @@ -0,0 +1,55 @@ + "POST"]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(isset($_POST["Page"]) && !is_numeric($_POST["Page"])) API::respond(400, false, "Page is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; +$Wall = []; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); + +if(SESSION) $Rank = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); +else $Rank = Groups::GetRankInfo($GroupID, 0); + +if(!$Rank->Permissions->CanViewGroupWall) API::respond(200, false, "You are not allowed to view this group wall"); + +$PostCount = Database::singleton()->run( + "SELECT COUNT(*) FROM groups_wall WHERE GroupID = :GroupID AND NOT Deleted", + [":GroupID" => $GroupID] +)->fetchColumn(); + +$Pagination = Pagination($_POST["Page"] ?? 1, $PostCount, 15); + +if($Pagination->Pages == 0) API::respond(200, true, "This group does not have any wall posts."); + +$PostQuery = Database::singleton()->run( + "SELECT groups_wall.id, users.username AS PosterName, PosterID, Content, TimePosted FROM groups_wall + INNER JOIN users ON users.id = PosterID WHERE GroupID = :GroupID AND NOT Deleted + ORDER BY TimePosted DESC LIMIT 15 OFFSET :Offset", + [":GroupID" => $GroupID, ":Offset" => $Pagination->Offset] +); + +while($Post = $PostQuery->fetch(\PDO::FETCH_OBJ)) +{ + $Wall[] = + [ + "id" => $Post->id, + "username" => $Post->PosterName, + "userid" => $Post->PosterID, + "content" => nl2br(Polygon::FilterText($Post->Content)), + "time" => date('j/n/Y g:i:s A', $Post->TimePosted), + "avatar" => Thumbnails::GetAvatar($Post->PosterID) + ]; +} + +die(json_encode(["status" => 200, "success" => true, "message" => "OK", "pages" => $Pagination->Pages, "count" => $PostCount, "items" => $Wall])); \ No newline at end of file diff --git a/api/groups/join-group.php b/api/groups/join-group.php new file mode 100644 index 0000000..35a7450 --- /dev/null +++ b/api/groups/join-group.php @@ -0,0 +1,35 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); +if(Groups::CheckIfUserInGroup(SESSION["user"]["id"], $GroupID)) API::respond(200, false, "You are already in this group"); + +if(Groups::GetUserGroups(SESSION["user"]["id"])->rowCount() >= 20) API::respond(200, false, "You have reached the maximum number of groups"); + +$RateLimit = Database::singleton()->run("SELECT Joined FROM groups_members WHERE UserID = :UserID AND Joined+300 > UNIX_TIMESTAMP()", [":UserID" => SESSION["user"]["id"]]); +if($RateLimit->rowCount()) + API::respond(200, false, "Please wait ".GetReadableTime($RateLimit->fetchColumn(), ["RelativeTime" => "5 minutes"])." before joining a new group"); + +$RankLevel = Database::singleton()->run( + "SELECT Rank FROM groups_ranks WHERE GroupID = :GroupID AND Rank != 0 ORDER BY Rank ASC LIMIT 1", + [":GroupID" => $GroupID] +)->fetchColumn(); + +Database::singleton()->run( + "INSERT INTO groups_members (GroupID, UserID, Rank, Joined) VALUES (:GroupID, :UserID, :RankLevel, UNIX_TIMESTAMP())", + [":GroupID" => $GroupID, ":UserID" => SESSION["user"]["id"], ":RankLevel" => $RankLevel] +); + +Database::singleton()->run("UPDATE groups SET MemberCount = MemberCount + 1 WHERE id = :GroupID", [":GroupID" => $GroupID]); + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/groups/leave-group.php b/api/groups/leave-group.php new file mode 100644 index 0000000..3a865e5 --- /dev/null +++ b/api/groups/leave-group.php @@ -0,0 +1,26 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +$GroupID = $_POST["GroupID"] ?? false; +$GroupInfo = Groups::GetGroupInfo($GroupID); + +if(!$GroupInfo) API::respond(200, false, "Group does not exist"); +if($GroupInfo->creator == SESSION["user"]["id"]) API::respond(200, false, "You are the creator of this group"); +if(!Groups::CheckIfUserInGroup(SESSION["user"]["id"], $GroupID)) API::respond(200, false, "You are not in this group"); + +Database::singleton()->run( + "DELETE FROM groups_members WHERE GroupID = :GroupID AND UserID = :UserID", + [":GroupID" => $GroupID, ":UserID" => SESSION["user"]["id"]] +); + +Database::singleton()->run("UPDATE groups SET MemberCount = MemberCount - 1 WHERE id = :GroupID", [":GroupID" => $GroupID]); + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/groups/post-shout.php b/api/groups/post-shout.php new file mode 100644 index 0000000..43d0017 --- /dev/null +++ b/api/groups/post-shout.php @@ -0,0 +1,59 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(!isset($_POST["Content"])) API::respond(400, false, "Content is not set"); + +$GroupID = $_POST["GroupID"] ?? false; +$Content = $_POST["Content"] ?? false; +$GroupInfo = Groups::GetGroupInfo($GroupID); + +if(!$GroupInfo) API::respond(200, false, "Group does not exist"); + +$Rank = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); + +if(!$Rank->Permissions->CanPostGroupStatus) API::respond(200, false, "You are not allowed to post on this group wall"); + +if(strlen($Content) < 3) API::respond(200, false, "Group shout must be at least 3 characters long"); +if(strlen($Content) > 255) API::respond(200, false, "Group shout can not be longer than 64 characters"); + +$LastPost = Database::singleton()->run( + "SELECT timestamp FROM feed WHERE groupId = :GroupID AND userId = :UserID AND timestamp+300 > UNIX_TIMESTAMP()", + [":GroupID" => $GroupID, ":UserID" => SESSION["user"]["id"]] +); + +if($LastPost->rowCount()) + API::respond(200, false, "Please wait ".GetReadableTime($LastPost->fetchColumn(), ["RelativeTime" => "5 minutes"])." before posting a group shout"); + +Groups::LogAction( + $GroupID, "Post Shout", + sprintf( + "%s changed the group status to: %s", + SESSION["user"]["id"], SESSION["user"]["username"], htmlspecialchars($Content) + ) +); + +Database::singleton()->run( + "INSERT INTO feed (groupId, userId, text, timestamp) VALUES (:GroupID, :UserID, :Content, UNIX_TIMESTAMP())", + [":GroupID" => $GroupID, ":UserID" => SESSION["user"]["id"], ":Content" => $Content] +); + +Discord::SendToWebhook( + [ + "username" => $GroupInfo->name, + "content" => $Content."\n(Posted by ".SESSION["user"]["username"].")", + "avatar_url" => Thumbnails::GetAssetFromID($GroupInfo->emblem) + ], + Discord::WEBHOOK_KUSH +); + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/groups/post-wall.php b/api/groups/post-wall.php new file mode 100644 index 0000000..a8cebb8 --- /dev/null +++ b/api/groups/post-wall.php @@ -0,0 +1,41 @@ + "POST", "logged_in" => true, "secure" => true]); + +if(!isset($_POST["GroupID"])) API::respond(400, false, "GroupID is not set"); +if(!is_numeric($_POST["GroupID"])) API::respond(400, false, "GroupID is not a number"); + +if(!isset($_POST["Content"])) API::respond(400, false, "Content is not set"); + +$GroupID = $_POST["GroupID"] ?? false; +$Content = $_POST["Content"] ?? false; + +if(!Groups::GetGroupInfo($GroupID)) API::respond(200, false, "Group does not exist"); + +$Rank = Groups::GetUserRank(SESSION["user"]["id"], $GroupID); + +if(!$Rank->Permissions->CanPostOnGroupWall) API::respond(200, false, "You are not allowed to post on this group wall"); + +if(strlen($Content) < 3) API::respond(200, false, "Wall post must be at least 3 characters long"); +if(strlen($Content) > 255) API::respond(200, false, "Wall post can not be longer than 255 characters"); + +$LastPost = Database::singleton()->run( + "SELECT TimePosted FROM groups_wall + WHERE GroupID = :GroupID AND PosterID = :UserID AND TimePosted+60 > UNIX_TIMESTAMP() + ORDER BY TimePosted DESC LIMIT 1", + [":GroupID" => $GroupID, ":UserID" => SESSION["user"]["id"]] +); + +if(SESSION["user"]["id"] != 1 && $LastPost->rowCount()) + API::respond(200, false, "Please wait ".(60-(time()-$LastPost->fetchColumn()))." seconds before posting a new wall post"); + +Database::singleton()->run( + "INSERT INTO groups_wall (GroupID, PosterID, Content, TimePosted) VALUES (:GroupID, :UserID, :Content, UNIX_TIMESTAMP())", + [":GroupID" => $GroupID, ":UserID" => SESSION["user"]["id"], ":Content" => $Content] +); + +API::respond(200, true, "OK"); \ No newline at end of file diff --git a/api/ide/toolbox.php b/api/ide/toolbox.php new file mode 100644 index 0000000..8dccccc --- /dev/null +++ b/api/ide/toolbox.php @@ -0,0 +1,121 @@ +"Bricks", + 1=>"Robots", + 2=>"Chassis", + 3=>"Furniture", + 4=>"Roads", + 5=>"Billboards", + 6=>"Game Objects", + "MyDecals"=>"My Decals", + "FreeDecals"=>"Free Decals", + "MyModels"=>"My Models", + "FreeModels"=>"Free Models" +]; + +$category = isset($_POST['category']) && isset($categories[$_POST['category']]) ? $_POST['category'] : "FreeModels"; +$categoryText = $categories[$category]; +$type = strpos($category, "Decals") ? 13 : 10; +$page = $_POST['page'] ?? 1; +$keywd = $_POST['keyword'] ?? false; + +if(is_numeric($category)) //static category +{ + //$query = $pdo->prepare("SELECT COUNT(*) FROM catalog_items WHERE toolboxCategory = :category"); + //$query->bindParam(":category", $categoryText, \PDO::PARAM_STR); +} +else //dynamic category - user assets, catalog assets +{ + if(SESSION && strpos($categoryText, "My") !== false) //get assets from inventory + { + $assetCount = Database::singleton()->run( + "SELECT COUNT(*) FROM assets WHERE type = :type AND approved = 1 AND id IN (SELECT assetId FROM ownedAssets WHERE userId = :uid)", + [":uid" => SESSION["user"]["id"], ":type" => $type] + )->fetchColumn(); + } + else //get assets from catalog + { + $assetCount = Database::singleton()->run( + "SELECT COUNT(*) FROM assets WHERE type = :type AND approved = 1 AND (name LIKE :q OR description LIKE :q)", + [":type" => $type, ":q" => "%{$keywd}%"] + )->fetchColumn(); + } +} + +$pagination = Pagination($page, $assetCount, 20); + +if(is_numeric($category)) //static category +{ + //$query = $pdo->prepare("SELECT * FROM catalog_items WHERE toolboxCategory = :category ORDER BY id ASC LIMIT 20 OFFSET :offset"); + //$query->bindParam(":category", $categoryText, \PDO::PARAM_STR); +} +else //dynamic category - user assets, catalog assets +{ + if(strpos($categoryText, "My") !== false) //get assets from inventory + { + $assets = Database::singleton()->run( + "SELECT assets.* FROM ownedAssets + INNER JOIN assets ON assets.id = assetId WHERE userId = :uid AND assets.type = :type + ORDER BY timestamp DESC LIMIT 20 OFFSET :offset", + [":uid" => SESSION["user"]["id"], ":type" => $type, ":offset" => $pagination->Offset] + ); + } + else //get assets from catalog + { + $assets = Database::singleton()->run( + "SELECT * FROM assets WHERE type = :type AND approved = 1 AND (name LIKE :q OR description LIKE :q) + ORDER BY updated DESC LIMIT 20 OFFSET :offset", + [":type" => $type, ":q" => "%{$keywd}%", ":offset" => $pagination->Offset] + ); + } +} +?> +') + { + $markup = $trimmedMarkup; + $markup = substr($markup, 3); + + $position = strpos($markup, "
"); + + $markup = substr_replace($markup, '', $position, 4); + } + + return $markup; + } + + # + # Deprecated Methods + # + + function parse($text) + { + $markup = $this->text($text); + + return $markup; + } + + protected function sanitiseElement(array $Element) + { + static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; + static $safeUrlNameToAtt = array( + 'a' => 'href', + 'img' => 'src', + ); + + if (isset($safeUrlNameToAtt[$Element['name']])) + { + $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); + } + + if ( ! empty($Element['attributes'])) + { + foreach ($Element['attributes'] as $att => $val) + { + # filter out badly parsed attribute + if ( ! preg_match($goodAttribute, $att)) + { + unset($Element['attributes'][$att]); + } + # dump onevent attribute + elseif (self::striAtStart($att, 'on')) + { + unset($Element['attributes'][$att]); + } + } + } + + return $Element; + } + + protected function filterUnsafeUrlInAttribute(array $Element, $attribute) + { + foreach ($this->safeLinksWhitelist as $scheme) + { + if (self::striAtStart($Element['attributes'][$attribute], $scheme)) + { + return $Element; + } + } + + $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); + + return $Element; + } + + # + # Static Methods + # + + protected static function escape($text, $allowQuotes = false) + { + return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); + } + + protected static function striAtStart($string, $needle) + { + $len = strlen($needle); + + if ($len > strlen($string)) + { + return false; + } + else + { + return strtolower(substr($string, 0, $len)) === strtolower($needle); + } + } + + static function instance($name = 'default') + { + if (isset(self::$instances[$name])) + { + return self::$instances[$name]; + } + + $instance = new static(); + + self::$instances[$name] = $instance; + + return $instance; + } + + private static $instances = array(); + + # + # Fields + # + + protected $DefinitionData; + + # + # Read-Only + + protected $specialCharacters = array( + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', + ); + + protected $StrongRegex = array( + '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s', + '_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us', + ); + + protected $EmRegex = array( + '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', + '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', + ); + + protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?'; + + protected $voidElements = array( + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + ); + + protected $textLevelElements = array( + 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', + 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', + 'i', 'rp', 'del', 'code', 'strike', 'marquee', + 'q', 'rt', 'ins', 'font', 'strong', + 's', 'tt', 'kbd', 'mark', + 'u', 'xm', 'sub', 'nobr', + 'sup', 'ruby', + 'var', 'span', + 'wbr', 'time', + ); +} diff --git a/api/private/classes/Sonata/GoogleAuthenticator/FixedBitNotation.php b/api/private/classes/Sonata/GoogleAuthenticator/FixedBitNotation.php new file mode 100644 index 0000000..c248ec0 --- /dev/null +++ b/api/private/classes/Sonata/GoogleAuthenticator/FixedBitNotation.php @@ -0,0 +1,292 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\GoogleAuthenticator; + +/** + * FixedBitNotation. + * + * The FixedBitNotation class is for binary to text conversion. It + * can handle many encoding schemes, formally defined or not, that + * use a fixed number of bits to encode each character. + * + * @author Andre DeMarre + */ +final class FixedBitNotation +{ + /** + * @var string + */ + private $chars; + + /** + * @var int + */ + private $bitsPerCharacter; + + /** + * @var int + */ + private $radix; + + /** + * @var bool + */ + private $rightPadFinalBits; + + /** + * @var bool + */ + private $padFinalGroup; + + /** + * @var string + */ + private $padCharacter; + + /** + * @var string[] + */ + private $charmap; + + /** + * @param int $bitsPerCharacter Bits to use for each encoded character + * @param string $chars Base character alphabet + * @param bool $rightPadFinalBits How to encode last character + * @param bool $padFinalGroup Add padding to end of encoded output + * @param string $padCharacter Character to use for padding + */ + public function __construct(int $bitsPerCharacter, ?string $chars = null, bool $rightPadFinalBits = false, bool $padFinalGroup = false, string $padCharacter = '=') + { + // Ensure validity of $chars + if (!\is_string($chars) || ($charLength = \strlen($chars)) < 2) { + $chars = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-,'; + $charLength = 64; + } + + // Ensure validity of $bitsPerCharacter + if ($bitsPerCharacter < 1) { + // $bitsPerCharacter must be at least 1 + $bitsPerCharacter = 1; + $radix = 2; + } elseif ($charLength < 1 << $bitsPerCharacter) { + // Character length of $chars is too small for $bitsPerCharacter + // Set $bitsPerCharacter to greatest acceptable value + $bitsPerCharacter = 1; + $radix = 2; + + while ($charLength >= ($radix <<= 1) && $bitsPerCharacter < 8) { + ++$bitsPerCharacter; + } + + $radix >>= 1; + } elseif ($bitsPerCharacter > 8) { + // $bitsPerCharacter must not be greater than 8 + $bitsPerCharacter = 8; + $radix = 256; + } else { + $radix = 1 << $bitsPerCharacter; + } + + $this->chars = $chars; + $this->bitsPerCharacter = $bitsPerCharacter; + $this->radix = $radix; + $this->rightPadFinalBits = $rightPadFinalBits; + $this->padFinalGroup = $padFinalGroup; + $this->padCharacter = $padCharacter[0]; + } + + /** + * Encode a string. + * + * @param string $rawString Binary data to encode + */ + public function encode($rawString): string + { + // Unpack string into an array of bytes + $bytes = unpack('C*', $rawString); + $byteCount = \count($bytes); + + $encodedString = ''; + $byte = array_shift($bytes); + $bitsRead = 0; + + $chars = $this->chars; + $bitsPerCharacter = $this->bitsPerCharacter; + $rightPadFinalBits = $this->rightPadFinalBits; + $padFinalGroup = $this->padFinalGroup; + $padCharacter = $this->padCharacter; + + // Generate encoded output; + // each loop produces one encoded character + for ($c = 0; $c < $byteCount * 8 / $bitsPerCharacter; ++$c) { + // Get the bits needed for this encoded character + if ($bitsRead + $bitsPerCharacter > 8) { + // Not enough bits remain in this byte for the current + // character + // Save the remaining bits before getting the next byte + $oldBitCount = 8 - $bitsRead; + $oldBits = $byte ^ ($byte >> $oldBitCount << $oldBitCount); + $newBitCount = $bitsPerCharacter - $oldBitCount; + + if (!$bytes) { + // Last bits; match final character and exit loop + if ($rightPadFinalBits) { + $oldBits <<= $newBitCount; + } + $encodedString .= $chars[$oldBits]; + + if ($padFinalGroup) { + // Array of the lowest common multiples of + // $bitsPerCharacter and 8, divided by 8 + $lcmMap = [1 => 1, 2 => 1, 3 => 3, 4 => 1, 5 => 5, 6 => 3, 7 => 7, 8 => 1]; + $bytesPerGroup = $lcmMap[$bitsPerCharacter]; + $pads = (int) ($bytesPerGroup * 8 / $bitsPerCharacter + - ceil((\strlen($rawString) % $bytesPerGroup) + * 8 / $bitsPerCharacter)); + $encodedString .= str_repeat($padCharacter[0], $pads); + } + + break; + } + + // Get next byte + $byte = array_shift($bytes); + $bitsRead = 0; + } else { + $oldBitCount = 0; + $newBitCount = $bitsPerCharacter; + } + + // Read only the needed bits from this byte + $bits = $byte >> 8 - ($bitsRead + $newBitCount); + $bits ^= $bits >> $newBitCount << $newBitCount; + $bitsRead += $newBitCount; + + if ($oldBitCount) { + // Bits come from seperate bytes, add $oldBits to $bits + $bits = ($oldBits << $newBitCount) | $bits; + } + + $encodedString .= $chars[$bits]; + } + + return $encodedString; + } + + /** + * Decode a string. + * + * @param string $encodedString Data to decode + * @param bool $caseSensitive + * @param bool $strict Returns null if $encodedString contains + * an undecodable character + */ + public function decode($encodedString, $caseSensitive = true, $strict = false): string + { + if (!$encodedString || !\is_string($encodedString)) { + // Empty string, nothing to decode + return ''; + } + + $chars = $this->chars; + $bitsPerCharacter = $this->bitsPerCharacter; + $radix = $this->radix; + $rightPadFinalBits = $this->rightPadFinalBits; + $padCharacter = $this->padCharacter; + + // Get index of encoded characters + if ($this->charmap) { + $charmap = $this->charmap; + } else { + $charmap = []; + + for ($i = 0; $i < $radix; ++$i) { + $charmap[$chars[$i]] = $i; + } + + $this->charmap = $charmap; + } + + // The last encoded character is $encodedString[$lastNotatedIndex] + $lastNotatedIndex = \strlen($encodedString) - 1; + + // Remove trailing padding characters + while ($encodedString[$lastNotatedIndex] === $padCharacter[0]) { + $encodedString = substr($encodedString, 0, $lastNotatedIndex); + --$lastNotatedIndex; + } + + $rawString = ''; + $byte = 0; + $bitsWritten = 0; + + // Convert each encoded character to a series of unencoded bits + for ($c = 0; $c <= $lastNotatedIndex; ++$c) { + if (!isset($charmap[$encodedString[$c]]) && !$caseSensitive) { + // Encoded character was not found; try other case + if (isset($charmap[$cUpper = strtoupper($encodedString[$c])])) { + $charmap[$encodedString[$c]] = $charmap[$cUpper]; + } elseif (isset($charmap[$cLower = strtolower($encodedString[$c])])) { + $charmap[$encodedString[$c]] = $charmap[$cLower]; + } + } + + if (isset($charmap[$encodedString[$c]])) { + $bitsNeeded = 8 - $bitsWritten; + $unusedBitCount = $bitsPerCharacter - $bitsNeeded; + + // Get the new bits ready + if ($bitsNeeded > $bitsPerCharacter) { + // New bits aren't enough to complete a byte; shift them + // left into position + $newBits = $charmap[$encodedString[$c]] << $bitsNeeded + - $bitsPerCharacter; + $bitsWritten += $bitsPerCharacter; + } elseif ($c !== $lastNotatedIndex || $rightPadFinalBits) { + // Zero or more too many bits to complete a byte; + // shift right + $newBits = $charmap[$encodedString[$c]] >> $unusedBitCount; + $bitsWritten = 8; //$bitsWritten += $bitsNeeded; + } else { + // Final bits don't need to be shifted + $newBits = $charmap[$encodedString[$c]]; + $bitsWritten = 8; + } + + $byte |= $newBits; + + if (8 === $bitsWritten || $c === $lastNotatedIndex) { + // Byte is ready to be written + $rawString .= pack('C', $byte); + + if ($c !== $lastNotatedIndex) { + // Start the next byte + $bitsWritten = $unusedBitCount; + $byte = ($charmap[$encodedString[$c]] + ^ ($newBits << $unusedBitCount)) << 8 - $bitsWritten; + } + } + } elseif ($strict) { + // Unable to decode character; abort + return null; + } + } + + return $rawString; + } +} + +// NEXT_MAJOR: Remove class alias +class_alias('Sonata\GoogleAuthenticator\FixedBitNotation', 'Google\Authenticator\FixedBitNotation', false); diff --git a/api/private/classes/Sonata/GoogleAuthenticator/GoogleAuthenticator.php b/api/private/classes/Sonata/GoogleAuthenticator/GoogleAuthenticator.php new file mode 100644 index 0000000..d3fc52f --- /dev/null +++ b/api/private/classes/Sonata/GoogleAuthenticator/GoogleAuthenticator.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\GoogleAuthenticator; + +/** + * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format + */ +final class GoogleAuthenticator implements GoogleAuthenticatorInterface +{ + /** + * @var int + */ + private $passCodeLength; + + /** + * @var int + */ + private $secretLength; + + /** + * @var int + */ + private $pinModulo; + + /** + * @var \DateTimeInterface + */ + private $instanceTime; + + /** + * @var int + */ + private $codePeriod; + + /** + * @var int + */ + private $periodSize = 30; + + public function __construct(int $passCodeLength = 6, int $secretLength = 10, ?\DateTimeInterface $instanceTime = null, int $codePeriod = 30) + { + /* + * codePeriod is the duration in seconds that the code is valid. + * periodSize is the length of a period to calculate periods since Unix epoch. + * periodSize cannot be larger than the codePeriod. + */ + + $this->passCodeLength = $passCodeLength; + $this->secretLength = $secretLength; + $this->codePeriod = $codePeriod; + $this->periodSize = $codePeriod < $this->periodSize ? $codePeriod : $this->periodSize; + $this->pinModulo = 10 ** $passCodeLength; + $this->instanceTime = $instanceTime ?? new \DateTimeImmutable(); + } + + /** + * @param string $secret + * @param string $code + * @param int $discrepancy + */ + public function checkCode($secret, $code, $discrepancy = 1): bool + { + /** + * Discrepancy is the factor of periodSize ($discrepancy * $periodSize) allowed on either side of the + * given codePeriod. For example, if a code with codePeriod = 60 is generated at 10:00:00, a discrepancy + * of 1 will allow a periodSize of 30 seconds on either side of the codePeriod resulting in a valid code + * from 09:59:30 to 10:00:29. + * + * The result of each comparison is stored as a timestamp here instead of using a guard clause + * (https://refactoring.com/catalog/replaceNestedConditionalWithGuardClauses.html). This is to implement + * constant time comparison to make side-channel attacks harder. See + * https://cryptocoding.net/index.php/Coding_rules#Compare_secret_strings_in_constant_time for details. + * Each comparison uses hash_equals() instead of an operator to implement constant time equality comparison + * for each code. + */ + $periods = floor($this->codePeriod / $this->periodSize); + + $result = 0; + for ($i = -$discrepancy; $i < $periods + $discrepancy; ++$i) { + $dateTime = new \DateTimeImmutable('@'.($this->instanceTime->getTimestamp() - ($i * $this->periodSize))); + $result = hash_equals($this->getCode($secret, $dateTime), $code) ? $dateTime->getTimestamp() : $result; + } + + return $result > 0; + } + + /** + * NEXT_MAJOR: add the interface typehint to $time and remove deprecation. + * + * @param string $secret + * @param float|string|int|\DateTimeInterface|null $time + */ + public function getCode($secret, /* \DateTimeInterface */ $time = null): string + { + if (null === $time) { + $time = $this->instanceTime; + } + + if ($time instanceof \DateTimeInterface) { + $timeForCode = floor($time->getTimestamp() / $this->periodSize); + } else { + @trigger_error( + 'Passing anything other than null or a DateTimeInterface to $time is deprecated as of 2.0 '. + 'and will not be possible as of 3.0.', + \E_USER_DEPRECATED + ); + $timeForCode = $time; + } + + $base32 = new FixedBitNotation(5, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', true, true); + $secret = $base32->decode($secret); + + $timeForCode = str_pad(pack('N', $timeForCode), 8, \chr(0), \STR_PAD_LEFT); + + $hash = hash_hmac('sha1', $timeForCode, $secret, true); + $offset = \ord(substr($hash, -1)); + $offset &= 0xF; + + $truncatedHash = $this->hashToInt($hash, $offset) & 0x7FFFFFFF; + + return str_pad((string) ($truncatedHash % $this->pinModulo), $this->passCodeLength, '0', \STR_PAD_LEFT); + } + + /** + * NEXT_MAJOR: Remove this method. + * + * @param string $user + * @param string $hostname + * @param string $secret + * + * @deprecated deprecated as of 2.1 and will be removed in 3.0. Use Sonata\GoogleAuthenticator\GoogleQrUrl::generate() instead. + */ + public function getUrl($user, $hostname, $secret): string + { + @trigger_error(sprintf( + 'Using %s() is deprecated as of 2.1 and will be removed in 3.0. '. + 'Use Sonata\GoogleAuthenticator\GoogleQrUrl::generate() instead.', + __METHOD__ + ), \E_USER_DEPRECATED); + + $issuer = \func_get_args()[3] ?? null; + $accountName = sprintf('%s@%s', $user, $hostname); + + // manually concat the issuer to avoid a change in URL + $url = GoogleQrUrl::generate($accountName, $secret); + + if ($issuer) { + $url .= '%26issuer%3D'.$issuer; + } + + return $url; + } + + public function generateSecret(): string + { + return (new FixedBitNotation(5, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', true, true)) + ->encode(random_bytes($this->secretLength)); + } + + private function hashToInt(string $bytes, int $start): int + { + return unpack('N', substr(substr($bytes, $start), 0, 4))[1]; + } +} + +// NEXT_MAJOR: Remove class alias +class_alias('Sonata\GoogleAuthenticator\GoogleAuthenticator', 'Google\Authenticator\GoogleAuthenticator', false); diff --git a/api/private/classes/Sonata/GoogleAuthenticator/GoogleAuthenticatorInterface.php b/api/private/classes/Sonata/GoogleAuthenticator/GoogleAuthenticatorInterface.php new file mode 100644 index 0000000..4dbe601 --- /dev/null +++ b/api/private/classes/Sonata/GoogleAuthenticator/GoogleAuthenticatorInterface.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\GoogleAuthenticator; + +interface GoogleAuthenticatorInterface +{ + /** + * @param string $secret + * @param string $code + */ + public function checkCode($secret, $code, $discrepancy = 1): bool; + + /** + * NEXT_MAJOR: add the interface typehint to $time and remove deprecation. + * + * @param string $secret + * @param float|string|int|\DateTimeInterface|null $time + */ + public function getCode($secret, /* \DateTimeInterface */ $time = null): string; + + /** + * NEXT_MAJOR: Remove this method. + * + * @param string $user + * @param string $hostname + * @param string $secret + * + * @deprecated deprecated as of 2.1 and will be removed in 3.0. Use Sonata\GoogleAuthenticator\GoogleQrUrl::generate() instead. + */ + public function getUrl($user, $hostname, $secret): string; + + public function generateSecret(): string; +} diff --git a/api/private/classes/Sonata/GoogleAuthenticator/GoogleQrUrl.php b/api/private/classes/Sonata/GoogleAuthenticator/GoogleQrUrl.php new file mode 100644 index 0000000..e0f6e60 --- /dev/null +++ b/api/private/classes/Sonata/GoogleAuthenticator/GoogleQrUrl.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\GoogleAuthenticator; + +/** + * Responsible for QR image url generation. + * + * @see http://goqr.me/api/ + * @see http://goqr.me/api/doc/ + * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format + * + * @author Iltar van der Berg+ * gd_version src_name src_name_body src_name_ext + * src_pathname src_mime src_x src_y + * src_type src_bits src_pixels + * src_size src_size_kb src_size_mb src_size_human + * dst_path dst_name_body dst_pathname + * dst_name dst_name_ext dst_x dst_y + * date time host server ip + *+ * The tokens must be enclosed in square brackets: [dst_x] will be replaced by the width of the picture + * + * Default value is null + * + * @access public + * @var string; + */ + var $image_text; + + /** + * Sets the text direction for the text label + * + * Value is either 'h' or 'v', as in horizontal and vertical + * + * Note that if you use a TrueType font, you can use {@link image_text_angle} instead + * + * Default value is h (horizontal) + * + * @access public + * @var string; + */ + var $image_text_direction; + + /** + * Sets the text color for the text label + * + * Value is an hexadecimal color, such as #FFFFFF + * + * Default value is #FFFFFF (white) + * + * @access public + * @var string; + */ + var $image_text_color; + + /** + * Sets the text opacity in the text label + * + * Value is a percentage, as an integer between 0 (transparent) and 100 (opaque) + * + * Default value is 100 + * + * @access public + * @var integer + */ + var $image_text_opacity; + + /** + * Sets the text background color for the text label + * + * Value is an hexadecimal color, such as #FFFFFF + * + * Default value is null (no background) + * + * @access public + * @var string; + */ + var $image_text_background; + + /** + * Sets the text background opacity in the text label + * + * Value is a percentage, as an integer between 0 (transparent) and 100 (opaque) + * + * Default value is 100 + * + * @access public + * @var integer + */ + var $image_text_background_opacity; + + /** + * Sets the text font in the text label + * + * Value is a an integer between 1 and 5 for GD built-in fonts. 1 is the smallest font, 5 the biggest + * Value can also be a string, which represents the path to a GDF or TTF font (TrueType). + * + * Default value is 5 + * + * @access public + * @var mixed; + */ + var $image_text_font; + + /** + * Sets the text font size for TrueType fonts + * + * Value is a an integer, and represents the font size in pixels (GD1) or points (GD1) + * + * Note that this setting is only applicable to TrueType fonts, and has no effects with GD fonts + * + * Default value is 16 + * + * @access public + * @var integer; + */ + var $image_text_size; + + /** + * Sets the text angle for TrueType fonts + * + * Value is a an integer between 0 and 360, in degrees, with 0 degrees being left-to-right reading text. + * + * Note that this setting is only applicable to TrueType fonts, and has no effects with GD fonts + * For GD fonts, you can use {@link image_text_direction} instead + * + * Default value is null (so it is determined by the value of {@link image_text_direction}) + * + * @access public + * @var integer; + */ + var $image_text_angle; + + /** + * Sets the text label position within the image + * + * Value is one or two out of 'TBLR' (top, bottom, left, right) + * + * The positions are as following: + *
+ * TL T TR + * L R + * BL B BR + *+ * + * Default value is null (centered, horizontal and vertical) + * + * Note that is {@link image_text_x} and {@link image_text_y} are used, this setting has no effect + * + * @access public + * @var string; + */ + var $image_text_position; + + /** + * Sets the text label absolute X position within the image + * + * Value is in pixels, representing the distance between the left of the image and the label + * If a negative value is used, it will represent the distance between the right of the image and the label + * + * Default value is null (so {@link image_text_position} is used) + * + * @access public + * @var integer + */ + var $image_text_x; + + /** + * Sets the text label absolute Y position within the image + * + * Value is in pixels, representing the distance between the top of the image and the label + * If a negative value is used, it will represent the distance between the bottom of the image and the label + * + * Default value is null (so {@link image_text_position} is used) + * + * @access public + * @var integer + */ + var $image_text_y; + + /** + * Sets the text label padding + * + * Value is in pixels, representing the distance between the text and the label background border + * + * Default value is 0 + * + * This setting can be overriden by {@link image_text_padding_x} and {@link image_text_padding_y} + * + * @access public + * @var integer + */ + var $image_text_padding; + + /** + * Sets the text label horizontal padding + * + * Value is in pixels, representing the distance between the text and the left and right label background borders + * + * Default value is null + * + * If set, this setting overrides the horizontal part of {@link image_text_padding} + * + * @access public + * @var integer + */ + var $image_text_padding_x; + + /** + * Sets the text label vertical padding + * + * Value is in pixels, representing the distance between the text and the top and bottom label background borders + * + * Default value is null + * + * If set, his setting overrides the vertical part of {@link image_text_padding} + * + * @access public + * @var integer + */ + var $image_text_padding_y; + + /** + * Sets the text alignment + * + * Value is a string, which can be either 'L', 'C' or 'R' + * + * Default value is 'C' + * + * This setting is relevant only if the text has several lines. + * + * Note that this setting is only applicable to GD fonts, and has no effects with TrueType fonts + * + * @access public + * @var string; + */ + var $image_text_alignment; + + /** + * Sets the text line spacing + * + * Value is an integer, in pixels + * + * Default value is 0 + * + * This setting is relevant only if the text has several lines. + * + * Note that this setting is only applicable to GD fonts, and has no effects with TrueType fonts + * + * @access public + * @var integer + */ + var $image_text_line_spacing; + + /** + * Sets the height of the reflection + * + * Value is an integer in pixels, or a string which format can be in pixels or percentage. + * For instance, values can be : 40, '40', '40px' or '40%' + * + * Default value is null, no reflection + * + * @access public + * @var mixed; + */ + var $image_reflection_height; + + /** + * Sets the space between the source image and its relection + * + * Value is an integer in pixels, which can be negative + * + * Default value is 2 + * + * This setting is relevant only if {@link image_reflection_height} is set + * + * @access public + * @var integer + */ + var $image_reflection_space; + + /** + * Sets the initial opacity of the reflection + * + * Value is an integer between 0 (no opacity) and 100 (full opacity). + * The reflection will start from {@link image_reflection_opacity} and end up at 0 + * + * Default value is 60 + * + * This setting is relevant only if {@link image_reflection_height} is set + * + * @access public + * @var integer + */ + var $image_reflection_opacity; + + /** + * Automatically rotates the image according to EXIF data (JPEG only) + * + * Default value is true + * + * @access public + * @var boolean; + */ + var $image_auto_rotate; + + /** + * Flips the image vertically or horizontally + * + * Value is either 'h' or 'v', as in horizontal and vertical + * + * Default value is null (no flip) + * + * @access public + * @var string; + */ + var $image_flip; + + /** + * Rotates the image by increments of 45 degrees + * + * Value is either 90, 180 or 270 + * + * Default value is null (no rotation) + * + * @access public + * @var string; + */ + var $image_rotate; + + /** + * Crops an image + * + * Values are four dimensions, or two, or one (CSS style) + * They represent the amount cropped top, right, bottom and left. + * These values can either be in an array, or a space separated string. + * Each value can be in pixels (with or without 'px'), or percentage (of the source image) + * + * For instance, are valid: + *
+ * $foo->image_crop = 20 OR array(20);
+ * $foo->image_crop = '20px' OR array('20px');
+ * $foo->image_crop = '20 40' OR array('20', 40);
+ * $foo->image_crop = '-20 25%' OR array(-20, '25%');
+ * $foo->image_crop = '20px 25%' OR array('20px', '25%');
+ * $foo->image_crop = '20% 25%' OR array('20%', '25%');
+ * $foo->image_crop = '20% 25% 10% 30%' OR array('20%', '25%', '10%', '30%');
+ * $foo->image_crop = '20px 25px 2px 2px' OR array('20px', '25%px', '2px', '2px');
+ * $foo->image_crop = '20 25% 40px 10%' OR array(20, '25%', '40px', '10%');
+ *
+ *
+ * If a value is negative, the image will be expanded, and the extra parts will be filled with black
+ *
+ * Default value is null (no cropping)
+ *
+ * @access public
+ * @var string OR array;
+ */
+ var $image_crop;
+
+ /**
+ * Crops an image, before an eventual resizing
+ *
+ * See {@link image_crop} for valid formats
+ *
+ * Default value is null (no cropping)
+ *
+ * @access public
+ * @var string OR array;
+ */
+ var $image_precrop;
+
+ /**
+ * Adds a bevel border on the image
+ *
+ * Value is a positive integer, representing the thickness of the bevel
+ *
+ * If the bevel colors are the same as the background, it makes a fade out effect
+ *
+ * Default value is null (no bevel)
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_bevel;
+
+ /**
+ * Top and left bevel color
+ *
+ * Value is a color, in hexadecimal format
+ * This setting is used only if {@link image_bevel} is set
+ *
+ * Default value is #FFFFFF
+ *
+ * @access public
+ * @var string;
+ */
+ var $image_bevel_color1;
+
+ /**
+ * Right and bottom bevel color
+ *
+ * Value is a color, in hexadecimal format
+ * This setting is used only if {@link image_bevel} is set
+ *
+ * Default value is #000000
+ *
+ * @access public
+ * @var string;
+ */
+ var $image_bevel_color2;
+
+ /**
+ * Adds a single-color border on the outer of the image
+ *
+ * Values are four dimensions, or two, or one (CSS style)
+ * They represent the border thickness top, right, bottom and left.
+ * These values can either be in an array, or a space separated string.
+ * Each value can be in pixels (with or without 'px'), or percentage (of the source image)
+ *
+ * See {@link image_crop} for valid formats
+ *
+ * If a value is negative, the image will be cropped.
+ * Note that the dimensions of the picture will be increased by the borders' thickness
+ *
+ * Default value is null (no border)
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_border;
+
+ /**
+ * Border color
+ *
+ * Value is a color, in hexadecimal format.
+ * This setting is used only if {@link image_border} is set
+ *
+ * Default value is #FFFFFF
+ *
+ * @access public
+ * @var string;
+ */
+ var $image_border_color;
+
+ /**
+ * Sets the opacity for the borders
+ *
+ * Value is a percentage, as an integer between 0 (transparent) and 100 (opaque)
+ *
+ * Unless used with {@link image_border}, this setting has no effect
+ *
+ * Default value is 100
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_border_opacity;
+
+ /**
+ * Adds a fading-to-transparent border on the image
+ *
+ * Values are four dimensions, or two, or one (CSS style)
+ * They represent the border thickness top, right, bottom and left.
+ * These values can either be in an array, or a space separated string.
+ * Each value can be in pixels (with or without 'px'), or percentage (of the source image)
+ *
+ * See {@link image_crop} for valid formats
+ *
+ * Note that the dimensions of the picture will not be increased by the borders' thickness
+ *
+ * Default value is null (no border)
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_border_transparent;
+
+ /**
+ * Adds a multi-color frame on the outer of the image
+ *
+ * Value is an integer. Two values are possible for now:
+ * 1 for flat border, meaning that the frame is mirrored horizontally and vertically
+ * 2 for crossed border, meaning that the frame will be inversed, as in a bevel effect
+ *
+ * The frame will be composed of colored lines set in {@link image_frame_colors}
+ *
+ * Note that the dimensions of the picture will be increased by the borders' thickness
+ *
+ * Default value is null (no frame)
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_frame;
+
+ /**
+ * Sets the colors used to draw a frame
+ *
+ * Values is a list of n colors in hexadecimal format.
+ * These values can either be in an array, or a space separated string.
+ *
+ * The colors are listed in the following order: from the outset of the image to its center
+ *
+ * For instance, are valid:
+ *
+ * $foo->image_frame_colors = '#FFFFFF #999999 #666666 #000000';
+ * $foo->image_frame_colors = array('#FFFFFF', '#999999', '#666666', '#000000');
+ *
+ *
+ * This setting is used only if {@link image_frame} is set
+ *
+ * Default value is '#FFFFFF #999999 #666666 #000000'
+ *
+ * @access public
+ * @var string OR array;
+ */
+ var $image_frame_colors;
+
+ /**
+ * Sets the opacity for the frame
+ *
+ * Value is a percentage, as an integer between 0 (transparent) and 100 (opaque)
+ *
+ * Unless used with {@link image_frame}, this setting has no effect
+ *
+ * Default value is 100
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_frame_opacity;
+
+ /**
+ * Adds a watermark on the image
+ *
+ * Value is a local image filename, relative or absolute. GIF, JPG, BMP, WEBP and PNG are supported, as well as PNG and WEBP alpha.
+ *
+ * If set, this setting allow the use of all other settings starting with image_watermark_
+ *
+ * Default value is null
+ *
+ * @access public
+ * @var string;
+ */
+ var $image_watermark;
+
+ /**
+ * Sets the watermarkposition within the image
+ *
+ * Value is one or two out of 'TBLR' (top, bottom, left, right)
+ *
+ * The positions are as following: TL T TR
+ * L R
+ * BL B BR
+ *
+ * Default value is null (centered, horizontal and vertical)
+ *
+ * Note that is {@link image_watermark_x} and {@link image_watermark_y} are used, this setting has no effect
+ *
+ * @access public
+ * @var string;
+ */
+ var $image_watermark_position;
+
+ /**
+ * Sets the watermark absolute X position within the image
+ *
+ * Value is in pixels, representing the distance between the top of the image and the watermark
+ * If a negative value is used, it will represent the distance between the bottom of the image and the watermark
+ *
+ * Default value is null (so {@link image_watermark_position} is used)
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_watermark_x;
+
+ /**
+ * Sets the twatermark absolute Y position within the image
+ *
+ * Value is in pixels, representing the distance between the left of the image and the watermark
+ * If a negative value is used, it will represent the distance between the right of the image and the watermark
+ *
+ * Default value is null (so {@link image_watermark_position} is used)
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_watermark_y;
+
+ /**
+ * Prevents the watermark to be resized up if it is smaller than the image
+ *
+ * If the watermark if smaller than the destination image, taking in account the desired watermark position
+ * then it will be resized up to fill in the image (minus the {@link image_watermark_x} or {@link image_watermark_y} values)
+ *
+ * If you don't want your watermark to be resized in any way, then
+ * set {@link image_watermark_no_zoom_in} and {@link image_watermark_no_zoom_out} to true
+ * If you want your watermark to be resized up or doan to fill in the image better, then
+ * set {@link image_watermark_no_zoom_in} and {@link image_watermark_no_zoom_out} to false
+ *
+ * Default value is true (so the watermark will not be resized up, which is the behaviour most people expect)
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_watermark_no_zoom_in;
+
+ /**
+ * Prevents the watermark to be resized down if it is bigger than the image
+ *
+ * If the watermark if bigger than the destination image, taking in account the desired watermark position
+ * then it will be resized down to fit in the image (minus the {@link image_watermark_x} or {@link image_watermark_y} values)
+ *
+ * If you don't want your watermark to be resized in any way, then
+ * set {@link image_watermark_no_zoom_in} and {@link image_watermark_no_zoom_out} to true
+ * If you want your watermark to be resized up or doan to fill in the image better, then
+ * set {@link image_watermark_no_zoom_in} and {@link image_watermark_no_zoom_out} to false
+ *
+ * Default value is false (so the watermark may be shrinked to fit in the image)
+ *
+ * @access public
+ * @var integer
+ */
+ var $image_watermark_no_zoom_out;
+
+ /**
+ * List of MIME types per extension
+ *
+ * @access private
+ * @var array
+ */
+ var $mime_types;
+
+ /**
+ * Allowed MIME types
+ *
+ * Default is a selection of safe mime-types, but you might want to change it
+ *
+ * Simple wildcards are allowed, such as image/* or application/*
+ * If there is only one MIME type allowed, then it can be a string instead of an array
+ *
+ * @access public
+ * @var array OR string
+ */
+ var $allowed;
+
+ /**
+ * Forbidden MIME types
+ *
+ * Default is a selection of safe mime-types, but you might want to change it
+ * To only check for forbidden MIME types, and allow everything else, set {@link allowed} to array('* / *') without the spaces
+ *
+ * Simple wildcards are allowed, such as image/* or application/*
+ * If there is only one MIME type forbidden, then it can be a string instead of an array
+ *
+ * @access public
+ * @var array OR string
+ */
+ var $forbidden;
+
+ /**
+ * Blacklisted file extensions
+ *
+ * List of blacklisted extensions, that are enforced if {@link no_script} is true
+ *
+ * @access public
+ * @var array
+ */
+ var $blacklist;
+
+
+ /**
+ * Array of translated error messages
+ *
+ * By default, the language is english (en_GB)
+ * Translations can be in separate files, in a lang/ subdirectory
+ *
+ * @access public
+ * @var array
+ */
+ var $translation;
+
+ /**
+ * Language selected for the translations
+ *
+ * By default, the language is english ("en_GB")
+ *
+ * @access public
+ * @var array
+ */
+ var $lang;
+
+ /**
+ * Init or re-init all the processing variables to their default values
+ *
+ * This function is called in the constructor, and after each call of {@link process}
+ *
+ * @access private
+ */
+ function init() {
+
+ // overiddable variables
+ $this->file_new_name_body = null; // replace the name body
+ $this->file_name_body_add = null; // append to the name body
+ $this->file_name_body_pre = null; // prepend to the name body
+ $this->file_new_name_ext = null; // replace the file extension
+ $this->file_safe_name = true; // format safely the filename
+ $this->file_force_extension = true; // forces extension if there isn't one
+ $this->file_overwrite = false; // allows overwritting if the file already exists
+ $this->file_auto_rename = true; // auto-rename if the file already exists
+ $this->dir_auto_create = true; // auto-creates directory if missing
+ $this->dir_auto_chmod = true; // auto-chmod directory if not writeable
+ $this->dir_chmod = 0755; // default chmod to use
+
+ $this->no_script = true; // turns scripts into test files
+ $this->mime_check = true; // checks the mime type against the allowed list
+
+ // these are the different MIME detection methods. if one of these method doesn't work on your
+ // system, you can deactivate it here; just set it to false
+ $this->mime_fileinfo = true; // MIME detection with Fileinfo PECL extension
+ $this->mime_file = true; // MIME detection with UNIX file() command
+ $this->mime_magic = true; // MIME detection with mime_magic (mime_content_type())
+ $this->mime_getimagesize = true; // MIME detection with getimagesize()
+
+ // get the default max size from php.ini
+ $this->file_max_size_raw = trim(ini_get('upload_max_filesize'));
+ $this->file_max_size = $this->getsize($this->file_max_size_raw);
+
+ $this->image_resize = false; // resize the image
+ $this->image_convert = ''; // convert. values :''; 'png'; 'jpeg'; 'gif'; 'bmp'; 'webp'
+
+ $this->image_x = 150;
+ $this->image_y = 150;
+ $this->image_ratio = false; // keeps aspect ratio within x and y dimensions
+ $this->image_ratio_crop = false; // keeps aspect ratio within x and y dimensions, filling the space
+ $this->image_ratio_fill = false; // keeps aspect ratio within x and y dimensions, fitting the image in the space
+ $this->image_ratio_pixels = false; // keeps aspect ratio, calculating x and y to reach the number of pixels
+ $this->image_ratio_x = false; // calculate the $image_x if true
+ $this->image_ratio_y = false; // calculate the $image_y if true
+ $this->image_ratio_no_zoom_in = false;
+ $this->image_ratio_no_zoom_out = false;
+ $this->image_no_enlarging = false;
+ $this->image_no_shrinking = false;
+
+ $this->png_compression = null;
+ $this->webp_quality = 85;
+ $this->jpeg_quality = 85;
+ $this->jpeg_size = null;
+ $this->image_interlace = false;
+ $this->image_is_transparent = false;
+ $this->image_transparent_color = null;
+ $this->image_background_color = null;
+ $this->image_default_color = '#ffffff';
+ $this->image_is_palette = false;
+
+ $this->image_max_width = null;
+ $this->image_max_height = null;
+ $this->image_max_pixels = null;
+ $this->image_max_ratio = null;
+ $this->image_min_width = null;
+ $this->image_min_height = null;
+ $this->image_min_pixels = null;
+ $this->image_min_ratio = null;
+
+ $this->image_brightness = null;
+ $this->image_contrast = null;
+ $this->image_opacity = null;
+ $this->image_threshold = null;
+ $this->image_tint_color = null;
+ $this->image_overlay_color = null;
+ $this->image_overlay_opacity = null;
+ $this->image_negative = false;
+ $this->image_greyscale = false;
+ $this->image_pixelate = null;
+ $this->image_unsharp = false;
+ $this->image_unsharp_amount = 80;
+ $this->image_unsharp_radius = 0.5;
+ $this->image_unsharp_threshold = 1;
+
+ $this->image_text = null;
+ $this->image_text_direction = null;
+ $this->image_text_color = '#FFFFFF';
+ $this->image_text_opacity = 100;
+ $this->image_text_background = null;
+ $this->image_text_background_opacity = 100;
+ $this->image_text_font = 5;
+ $this->image_text_size = 16;
+ $this->image_text_angle = null;
+ $this->image_text_x = null;
+ $this->image_text_y = null;
+ $this->image_text_position = null;
+ $this->image_text_padding = 0;
+ $this->image_text_padding_x = null;
+ $this->image_text_padding_y = null;
+ $this->image_text_alignment = 'C';
+ $this->image_text_line_spacing = 0;
+
+ $this->image_reflection_height = null;
+ $this->image_reflection_space = 2;
+ $this->image_reflection_opacity = 60;
+
+ $this->image_watermark = null;
+ $this->image_watermark_x = null;
+ $this->image_watermark_y = null;
+ $this->image_watermark_position = null;
+ $this->image_watermark_no_zoom_in = true;
+ $this->image_watermark_no_zoom_out = false;
+
+ $this->image_flip = null;
+ $this->image_auto_rotate = true;
+ $this->image_rotate = null;
+ $this->image_crop = null;
+ $this->image_precrop = null;
+
+ $this->image_bevel = null;
+ $this->image_bevel_color1 = '#FFFFFF';
+ $this->image_bevel_color2 = '#000000';
+ $this->image_border = null;
+ $this->image_border_color = '#FFFFFF';
+ $this->image_border_opacity = 100;
+ $this->image_border_transparent = null;
+ $this->image_frame = null;
+ $this->image_frame_colors = '#FFFFFF #999999 #666666 #000000';
+ $this->image_frame_opacity = 100;
+
+ $this->forbidden = array();
+ $this->allowed = array(
+ 'application/arj',
+ 'application/excel',
+ 'application/gnutar',
+ 'application/mspowerpoint',
+ 'application/msword',
+ 'application/octet-stream',
+ 'application/onenote',
+ 'application/pdf',
+ 'application/plain',
+ 'application/postscript',
+ 'application/powerpoint',
+ 'application/rar',
+ 'application/rtf',
+ 'application/vnd.ms-excel',
+ 'application/vnd.ms-excel.addin.macroEnabled.12',
+ 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
+ 'application/vnd.ms-excel.sheet.macroEnabled.12',
+ 'application/vnd.ms-excel.template.macroEnabled.12',
+ 'application/vnd.ms-office',
+ 'application/vnd.ms-officetheme',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.ms-powerpoint.addin.macroEnabled.12',
+ 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
+ 'application/vnd.ms-powerpoint.slide.macroEnabled.12',
+ 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
+ 'application/vnd.ms-powerpoint.template.macroEnabled.12',
+ 'application/vnd.ms-word',
+ 'application/vnd.ms-word.document.macroEnabled.12',
+ 'application/vnd.ms-word.template.macroEnabled.12',
+ 'application/vnd.oasis.opendocument.chart',
+ 'application/vnd.oasis.opendocument.database',
+ 'application/vnd.oasis.opendocument.formula',
+ 'application/vnd.oasis.opendocument.graphics',
+ 'application/vnd.oasis.opendocument.graphics-template',
+ 'application/vnd.oasis.opendocument.image',
+ 'application/vnd.oasis.opendocument.presentation',
+ 'application/vnd.oasis.opendocument.presentation-template',
+ 'application/vnd.oasis.opendocument.spreadsheet',
+ 'application/vnd.oasis.opendocument.spreadsheet-template',
+ 'application/vnd.oasis.opendocument.text',
+ 'application/vnd.oasis.opendocument.text-master',
+ 'application/vnd.oasis.opendocument.text-template',
+ 'application/vnd.oasis.opendocument.text-web',
+ 'application/vnd.openofficeorg.extension',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'application/vnd.openxmlformats-officedocument.presentationml.slide',
+ 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
+ 'application/vnd.openxmlformats-officedocument.presentationml.template',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
+ 'application/vocaltec-media-file',
+ 'application/wordperfect',
+ 'application/haansoftxlsx',
+ 'application/x-bittorrent',
+ 'application/x-bzip',
+ 'application/x-bzip2',
+ 'application/x-compressed',
+ 'application/x-excel',
+ 'application/x-gzip',
+ 'application/x-latex',
+ 'application/x-midi',
+ 'application/xml',
+ 'application/x-msexcel',
+ 'application/x-rar',
+ 'application/x-rar-compressed',
+ 'application/x-rtf',
+ 'application/x-shockwave-flash',
+ 'application/x-sit',
+ 'application/x-stuffit',
+ 'application/x-troff-msvideo',
+ 'application/x-zip',
+ 'application/x-zip-compressed',
+ 'application/zip',
+ 'audio/*',
+ 'image/*',
+ 'multipart/x-gzip',
+ 'multipart/x-zip',
+ 'text/plain',
+ 'text/rtf',
+ 'text/richtext',
+ 'text/xml',
+ 'video/*',
+ 'text/csv',
+ 'text/x-c',
+ 'text/x-csv',
+ 'text/comma-separated-values',
+ 'text/x-comma-separated-values',
+ 'application/csv',
+ 'application/x-csv',
+ );
+
+ $this->mime_types = array(
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'jpe' => 'image/jpeg',
+ 'gif' => 'image/gif',
+ 'webp' => 'image/webp',
+ 'png' => 'image/png',
+ 'bmp' => 'image/bmp',
+ 'flif' => 'image/flif',
+ 'flv' => 'video/x-flv',
+ 'js' => 'application/x-javascript',
+ 'json' => 'application/json',
+ 'tiff' => 'image/tiff',
+ 'css' => 'text/css',
+ 'xml' => 'application/xml',
+ 'doc' => 'application/msword',
+ 'xls' => 'application/vnd.ms-excel',
+ 'xlt' => 'application/vnd.ms-excel',
+ 'xlm' => 'application/vnd.ms-excel',
+ 'xld' => 'application/vnd.ms-excel',
+ 'xla' => 'application/vnd.ms-excel',
+ 'xlc' => 'application/vnd.ms-excel',
+ 'xlw' => 'application/vnd.ms-excel',
+ 'xll' => 'application/vnd.ms-excel',
+ 'ppt' => 'application/vnd.ms-powerpoint',
+ 'pps' => 'application/vnd.ms-powerpoint',
+ 'rtf' => 'application/rtf',
+ 'pdf' => 'application/pdf',
+ 'html' => 'text/html',
+ 'htm' => 'text/html',
+ 'php' => 'text/html',
+ 'txt' => 'text/plain',
+ 'mpeg' => 'video/mpeg',
+ 'mpg' => 'video/mpeg',
+ 'mpe' => 'video/mpeg',
+ 'mp3' => 'audio/mpeg3',
+ 'wav' => 'audio/wav',
+ 'aiff' => 'audio/aiff',
+ 'aif' => 'audio/aiff',
+ 'avi' => 'video/msvideo',
+ 'wmv' => 'video/x-ms-wmv',
+ 'mov' => 'video/quicktime',
+ 'zip' => 'application/zip',
+ 'tar' => 'application/x-tar',
+ 'swf' => 'application/x-shockwave-flash',
+ 'odt' => 'application/vnd.oasis.opendocument.text',
+ 'ott' => 'application/vnd.oasis.opendocument.text-template',
+ 'oth' => 'application/vnd.oasis.opendocument.text-web',
+ 'odm' => 'application/vnd.oasis.opendocument.text-master',
+ 'odg' => 'application/vnd.oasis.opendocument.graphics',
+ 'otg' => 'application/vnd.oasis.opendocument.graphics-template',
+ 'odp' => 'application/vnd.oasis.opendocument.presentation',
+ 'otp' => 'application/vnd.oasis.opendocument.presentation-template',
+ 'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
+ 'ots' => 'application/vnd.oasis.opendocument.spreadsheet-template',
+ 'odc' => 'application/vnd.oasis.opendocument.chart',
+ 'odf' => 'application/vnd.oasis.opendocument.formula',
+ 'odb' => 'application/vnd.oasis.opendocument.database',
+ 'odi' => 'application/vnd.oasis.opendocument.image',
+ 'oxt' => 'application/vnd.openofficeorg.extension',
+ 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'docm' => 'application/vnd.ms-word.document.macroEnabled.12',
+ 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
+ 'dotm' => 'application/vnd.ms-word.template.macroEnabled.12',
+ 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',
+ 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
+ 'xltm' => 'application/vnd.ms-excel.template.macroEnabled.12',
+ 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
+ 'xlam' => 'application/vnd.ms-excel.addin.macroEnabled.12',
+ 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'pptm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
+ 'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
+ 'ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
+ 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template',
+ 'potm' => 'application/vnd.ms-powerpoint.template.macroEnabled.12',
+ 'ppam' => 'application/vnd.ms-powerpoint.addin.macroEnabled.12',
+ 'sldx' => 'application/vnd.openxmlformats-officedocument.presentationml.slide',
+ 'sldm' => 'application/vnd.ms-powerpoint.slide.macroEnabled.12',
+ 'thmx' => 'application/vnd.ms-officetheme',
+ 'onetoc' => 'application/onenote',
+ 'onetoc2' => 'application/onenote',
+ 'onetmp' => 'application/onenote',
+ 'onepkg' => 'application/onenote',
+ 'csv' => 'text/csv',
+ );
+
+ $this->blacklist = array(
+ 'php',
+ 'php7',
+ 'php6',
+ 'php5',
+ 'php4',
+ 'php3',
+ 'phtml',
+ 'pht',
+ 'phpt',
+ 'phtm',
+ 'phps',
+ 'inc',
+ 'pl',
+ 'py',
+ 'cgi',
+ 'asp',
+ 'js',
+ 'sh',
+ 'phar',
+ );
+
+ }
+
+ /**
+ * Constructor, for PHP5+
+ */
+ function __construct($file, $lang = 'en_GB') {
+ $this->upload($file, $lang);
+ }
+
+ /**
+ * Constructor, for PHP4. Checks if the file has been uploaded
+ *
+ * The constructor takes $_FILES['form_field'] array as argument
+ * where form_field is the form field name
+ *
+ * The constructor will check if the file has been uploaded in its temporary location, and
+ * accordingly will set {@link uploaded} (and {@link error} is an error occurred)
+ *
+ * If the file has been uploaded, the constructor will populate all the variables holding the upload
+ * information (none of the processing class variables are used here).
+ * You can have access to information about the file (name, size, MIME type...).
+ *
+ *
+ * Alternatively, you can set the first argument to be a local filename (string)
+ * This allows processing of a local file, as if the file was uploaded
+ *
+ * The optional second argument allows you to set the language for the error messages
+ *
+ * @access private
+ * @param array $file $_FILES['form_field']
+ * or string $file Local filename
+ * @param string $lang Optional language code
+ */
+ function upload($file, $lang = 'en_GB') {
+
+ $this->version = '05/10/2021';
+
+ $this->file_src_name = '';
+ $this->file_src_name_body = '';
+ $this->file_src_name_ext = '';
+ $this->file_src_mime = '';
+ $this->file_src_size = '';
+ $this->file_src_error = '';
+ $this->file_src_pathname = '';
+ $this->file_src_temp = '';
+
+ $this->file_dst_path = '';
+ $this->file_dst_name = '';
+ $this->file_dst_name_body = '';
+ $this->file_dst_name_ext = '';
+ $this->file_dst_pathname = '';
+
+ $this->image_src_x = null;
+ $this->image_src_y = null;
+ $this->image_src_bits = null;
+ $this->image_src_type = null;
+ $this->image_src_pixels = null;
+ $this->image_dst_x = 0;
+ $this->image_dst_y = 0;
+ $this->image_dst_type = '';
+
+ $this->uploaded = true;
+ $this->no_upload_check = false;
+ $this->processed = false;
+ $this->error = '';
+ $this->log = '';
+ $this->allowed = array();
+ $this->forbidden = array();
+ $this->file_is_image = false;
+ $this->init();
+ $info = null;
+ $mime_from_browser = null;
+
+ // sets default language
+ $this->translation = array();
+ $this->translation['file_error'] = 'File error. Please try again.';
+ $this->translation['local_file_missing'] = 'Local file doesn\'t exist.';
+ $this->translation['local_file_not_readable'] = 'Local file is not readable.';
+ $this->translation['uploaded_too_big_ini'] = 'File upload error (the uploaded file exceeds the upload_max_filesize directive in php.ini).';
+ $this->translation['uploaded_too_big_html'] = 'File upload error (the uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the html form).';
+ $this->translation['uploaded_partial'] = 'File upload error (the uploaded file was only partially uploaded).';
+ $this->translation['uploaded_missing'] = 'File upload error (no file was uploaded).';
+ $this->translation['uploaded_no_tmp_dir'] = 'File upload error (missing a temporary folder).';
+ $this->translation['uploaded_cant_write'] = 'File upload error (failed to write file to disk).';
+ $this->translation['uploaded_err_extension'] = 'File upload error (file upload stopped by extension).';
+ $this->translation['uploaded_unknown'] = 'File upload error (unknown error code).';
+ $this->translation['try_again'] = 'File upload error. Please try again.';
+ $this->translation['file_too_big'] = 'File too big.';
+ $this->translation['no_mime'] = 'MIME type can\'t be detected.';
+ $this->translation['incorrect_file'] = 'Incorrect type of file.';
+ $this->translation['image_too_wide'] = 'Image too wide.';
+ $this->translation['image_too_narrow'] = 'Image too narrow.';
+ $this->translation['image_too_high'] = 'Image too tall.';
+ $this->translation['image_too_short'] = 'Image too short.';
+ $this->translation['ratio_too_high'] = 'Image ratio too high (image too wide).';
+ $this->translation['ratio_too_low'] = 'Image ratio too low (image too high).';
+ $this->translation['too_many_pixels'] = 'Image has too many pixels.';
+ $this->translation['not_enough_pixels'] = 'Image has not enough pixels.';
+ $this->translation['file_not_uploaded'] = 'File not uploaded. Can\'t carry on a process.';
+ $this->translation['already_exists'] = '%s already exists. Please change the file name.';
+ $this->translation['temp_file_missing'] = 'No correct temp source file. Can\'t carry on a process.';
+ $this->translation['source_missing'] = 'No correct uploaded source file. Can\'t carry on a process.';
+ $this->translation['destination_dir'] = 'Destination directory can\'t be created. Can\'t carry on a process.';
+ $this->translation['destination_dir_missing'] = 'Destination directory doesn\'t exist. Can\'t carry on a process.';
+ $this->translation['destination_path_not_dir'] = 'Destination path is not a directory. Can\'t carry on a process.';
+ $this->translation['destination_dir_write'] = 'Destination directory can\'t be made writeable. Can\'t carry on a process.';
+ $this->translation['destination_path_write'] = 'Destination path is not a writeable. Can\'t carry on a process.';
+ $this->translation['temp_file'] = 'Can\'t create the temporary file. Can\'t carry on a process.';
+ $this->translation['source_not_readable'] = 'Source file is not readable. Can\'t carry on a process.';
+ $this->translation['no_create_support'] = 'No create from %s support.';
+ $this->translation['create_error'] = 'Error in creating %s image from source.';
+ $this->translation['source_invalid'] = 'Can\'t read image source. Not an image?.';
+ $this->translation['gd_missing'] = 'GD doesn\'t seem to be present.';
+ $this->translation['watermark_no_create_support'] = 'No create from %s support, can\'t read watermark.';
+ $this->translation['watermark_create_error'] = 'No %s read support, can\'t create watermark.';
+ $this->translation['watermark_invalid'] = 'Unknown image format, can\'t read watermark.';
+ $this->translation['file_create'] = 'No %s create support.';
+ $this->translation['no_conversion_type'] = 'No conversion type defined.';
+ $this->translation['copy_failed'] = 'Error copying file on the server. copy() failed.';
+ $this->translation['reading_failed'] = 'Error reading the file.';
+
+ // determines the language
+ $this->lang = $lang;
+ if ($this->lang != 'en_GB' && file_exists(dirname(__FILE__).'/lang') && file_exists(dirname(__FILE__).'/lang/class.upload.' . $lang . '.php')) {
+ $translation = null;
+ include(dirname(__FILE__).'/lang/class.upload.' . $lang . '.php');
+ if (is_array($translation)) {
+ $this->translation = array_merge($this->translation, $translation);
+ } else {
+ $this->lang = 'en_GB';
+ }
+ }
+
+
+ // determines the supported MIME types, and matching image format
+ $this->image_supported = array();
+ if ($this->gdversion()) {
+ if (imagetypes() & IMG_GIF) {
+ $this->image_supported['image/gif'] = 'gif';
+ }
+ if (imagetypes() & IMG_JPG) {
+ $this->image_supported['image/jpg'] = 'jpg';
+ $this->image_supported['image/jpeg'] = 'jpg';
+ $this->image_supported['image/pjpeg'] = 'jpg';
+ }
+ if (imagetypes() & IMG_PNG) {
+ $this->image_supported['image/png'] = 'png';
+ $this->image_supported['image/x-png'] = 'png';
+ }
+ if (imagetypes() & IMG_WEBP) {
+ $this->image_supported['image/webp'] = 'webp';
+ $this->image_supported['image/x-webp'] = 'webp';
+ }
+ if (imagetypes() & IMG_WBMP) {
+ $this->image_supported['image/bmp'] = 'bmp';
+ $this->image_supported['image/x-ms-bmp'] = 'bmp';
+ $this->image_supported['image/x-windows-bmp'] = 'bmp';
+ }
+ }
+
+ // display some system information
+ if (empty($this->log)) {
+ $this->log .= 'system information
+
+ | Avatar | +Name | +Blurb | +Location / Last Seen | +
|---|---|---|---|
| =$row->username?> | +=Polygon::FilterText($row->blurb)?> | +Attributes?>>=$Status->Text?> | +|
| + | Group | +Description | +Members | +
| =Polygon::FilterText($row->name)?> | +=Polygon::FilterText($row->description)?> | +=$row->MemberCount?> | +
No results matched your search query
+Pages > 1) { ?> + +buildFooter(); ?> diff --git a/catalog.php b/catalog.php new file mode 100644 index 0000000..2415237 --- /dev/null +++ b/catalog.php @@ -0,0 +1,249 @@ + ["name" => "All Categories", "price" => true], + 3 => ["name" => "Clothing", "subcategories" => [8, 11, 2, 12], "price" => true], + 4 => ["name" => "Body Parts", "subcategories" => [17, 18], "price" => true], + 5 => ["name" => "Gear", "type" => 19, "price" => true], + 6 => ["name" => "Models", "type" => 10, "price" => false], + 8 => ["name" => "Decals", "type" => 13, "price" => false], + 9 => ["name" => "Audio", "type" => 3, "price" => false], +]; + +$sorts = +[ + 0 => "sales DESC", + 1 => "updated DESC", + 2 => "price DESC", + 3 => "price" +]; + +function getUrl($strings) +{ + global $cat, $subcat; + $url = "?"; + if($subcat) $url .= "Subcategory=".$subcat."&"; + $url .= "CurrencyType=%currency%&SortType=%sort%&Category=".$cat; + + return str_replace(["%currency%", "%sort%"], $strings, $url); +} + +$cat = $_GET['Category'] ?? 3; +$subcat = $_GET['Subcategory'] ?? false; +$keyword = $_GET['Keyword'] ?? ""; +$currency = $_GET['CurrencyType'] ?? 0; +$sort = $_GET['SortType'] ?? 1; +$page = $_GET['PageNumber'] ?? 1; + +if($cat == 3 && !isset($_GET['Category'])) + $subcat = 8; + +if(!isset($cats[$cat])) + die(header("Location: /catalog")); + +if($subcat && ($cat == 1 || isset($cats[$cat]["type"]) || !is_numeric($subcat) || !in_array($subcat, $cats[$cat]["subcategories"]))) + die(header("Location: /catalog?Category=".$cat)); + +if(!in_array($currency, [0, 1, 2])) + die(header("Location: ".getUrl([0, $sort]))); + +if(!isset($sorts[$sort])) + die(header("Location: ".getUrl([$currency, 0]))); + +$queryparam = ""; +$type = $subcat ?: $cats[$cat]["type"] ?? 2; + +// adding "is not null" fetches the item even if the price is 0 +$unavailable = isset($_GET['IncludeNotForSale']) && $_GET['IncludeNotForSale'] == "true"; +if($unavailable) $queryparam .= "IS NOT NULL "; + +// process query parameters for the item type +$queryparam .= "AND type"; +if($cat == 1) $queryparam .= " IN (2, 3, 8, 10, 11, 12, 13, 17, 18, 19)"; +elseif(isset($cats[$cat]["type"]) || $subcat) $queryparam .= " = ".($cats[$cat]["type"] ?? $subcat); +else $queryparam .= " IN (".implode(", ", $cats[$cat]["subcategories"]).")"; + +// process query parameters for the item price +$queryparam .= " AND price"; +if(is_numeric($currency) && $currency == 0) $queryparam .= " IS NOT NULL"; +elseif($currency == 2) $queryparam .= " = 0"; + +// get the number of assets matching the query +$results = Database::singleton()->run( + "SELECT COUNT(*) FROM assets WHERE type != 1 AND name LIKE :keywd AND approved != 2 AND sale $queryparam", + [":keywd" => "%{$keyword}%"] +)->fetchColumn(); + +$pagination = Pagination($page, $results, 18); + +$query = Database::singleton()->run( + "SELECT assets.*, users.username FROM assets + INNER JOIN users ON creator = users.id + WHERE type != 1 AND name LIKE :keywd AND approved != 2 AND sale $queryparam + ORDER BY ".$sorts[$sort]." LIMIT 18 OFFSET :offset", + [":keywd" => "%{$keyword}%", ":offset" => $pagination->Offset] +); + +$pageBuilder = new PageBuilder(["title" => "Avatar Items, Virtual Avatars, Virtual Goods"]); +$pageBuilder->addResource("polygonScripts", "/js/polygon/catalog.js"); +$pageBuilder->buildHeader(); +?> + +=plural(Catalog::GetTypeByNum($type))?>
+ $cat_data) { ?> + + +Showing =number_format($pagination->Offset+1)?> - =number_format($pagination->Offset+$query->rowCount())?> of =number_format($results)?> results
+No results matched your criteria
+ +=Polygon::FilterText($item->name)?>
+ sale) { ?>=$item->price ? ' '.number_format($item->price):'Free'?>
+Creator: =$item->username?>
+Updated: =timeSince($item->updated)?>
+Sales: =number_format($item->Sales)?>
+`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Remove the bottom border in Firefox 39-.\n// 5. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-original-title] { // 1\n text-decoration: underline; // 2\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n border-bottom: 0; // 4\n text-decoration-skip-ink: none; // 5\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // Undo browser default\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: $font-weight-bolder; // Add the correct font weight in Chrome, Edge, and Safari\n}\n\nsmall {\n @include font-size(80%); // Add the correct font size in all browsers\n}\n\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n//\n\nsub,\nsup {\n position: relative;\n @include font-size(75%);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n//\n// Links\n//\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n background-color: transparent; // Remove the gray background on active links in IE 10.\n\n @include hover() {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n color: inherit;\n text-decoration: none;\n\n @include hover() {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n//\n// Code\n//\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-monospace;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\npre {\n // Remove browser default top margin\n margin-top: 0;\n // Reset browser default of `1em` to use `rem`s\n margin-bottom: 1rem;\n // Don't allow content to break outside\n overflow: auto;\n // Disable auto-hiding scrollbar in IE & legacy Edge to avoid overlap,\n // making it impossible to interact with the content\n -ms-overflow-style: scrollbar;\n}\n\n\n//\n// Figures\n//\n\nfigure {\n // Apply a consistent margin strategy (matches our type styles).\n margin: 0 0 1rem;\n}\n\n\n//\n// Images and content\n//\n\nimg {\n vertical-align: middle;\n border-style: none; // Remove the border on images inside links in IE 10-.\n}\n\nsvg {\n // Workaround for the SVG overflow bug in IE10/11 is still required.\n // See https://github.com/twbs/bootstrap/issues/26878\n overflow: hidden;\n vertical-align: middle;\n}\n\n\n//\n// Tables\n//\n\ntable {\n border-collapse: collapse; // Prevent double borders\n}\n\ncaption {\n padding-top: $table-cell-padding;\n padding-bottom: $table-cell-padding;\n color: $table-caption-color;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n // Matches default `