From d575a4efc54b02be5337bc00ddc80f3e3695f537 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 12 Sep 2024 15:48:27 -0400 Subject: [PATCH] moved api from monorepo --- _data/games.json | 960 ++++++++++++++++++++++++++++++++++ _data/users.json | 34 ++ app.js | 57 ++ bin/www.js | 86 +++ bun.lockb | Bin 0 -> 108016 bytes config/db.js | 18 + controllers/adminGames.js | 135 +++++ controllers/auth.js | 223 ++++++++ controllers/games.js | 238 +++++++++ controllers/tags.js | 64 +++ controllers/users.js | 79 +++ middleware/advancedResults.js | 240 +++++++++ middleware/async.js | 4 + middleware/auth.js | 48 ++ middleware/error.js | 31 ++ models/Game.js | 104 ++++ models/User.js | 95 ++++ package.json | 41 ++ routes/adminGames.js | 20 + routes/auth.js | 17 + routes/games.js | 20 + routes/tags.js | 35 ++ routes/users.js | 28 + scripts/adminUser.js | 49 ++ scripts/scraper.js | 175 +++++++ seeder.js | 53 ++ utils/errorResponse.js | 8 + utils/getTag.js | 12 + utils/sendEmail.js | 30 ++ 29 files changed, 2904 insertions(+) create mode 100644 _data/games.json create mode 100644 _data/users.json create mode 100644 app.js create mode 100644 bin/www.js create mode 100755 bun.lockb create mode 100644 config/db.js create mode 100644 controllers/adminGames.js create mode 100644 controllers/auth.js create mode 100644 controllers/games.js create mode 100644 controllers/tags.js create mode 100644 controllers/users.js create mode 100644 middleware/advancedResults.js create mode 100644 middleware/async.js create mode 100644 middleware/auth.js create mode 100644 middleware/error.js create mode 100644 models/Game.js create mode 100644 models/User.js create mode 100644 package.json create mode 100644 routes/adminGames.js create mode 100644 routes/auth.js create mode 100644 routes/games.js create mode 100644 routes/tags.js create mode 100644 routes/users.js create mode 100644 scripts/adminUser.js create mode 100644 scripts/scraper.js create mode 100644 seeder.js create mode 100644 utils/errorResponse.js create mode 100644 utils/getTag.js create mode 100644 utils/sendEmail.js diff --git a/_data/games.json b/_data/games.json new file mode 100644 index 0000000..73e1019 --- /dev/null +++ b/_data/games.json @@ -0,0 +1,960 @@ +[ + { + "os": { + "windows": true, + "mac": false, + "linux": false + }, + "systemRequirements": { + "windows": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + }, + "mac": { + "minimum": "", + "recommended": "" + }, + "linux": { + "minimum": "", + "recommended": "" + } + }, + "_id": "6143fb8c9d57ff50939bef40", + "title": "I Am Fish", + "series": "", + "steamId": "1472560", + "frontImage": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/header.jpg?t=1631811463", + "screenshots": [ + { + "id": 0, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_448c5e50d0f663660dceb92279f0480b1a55806b.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_448c5e50d0f663660dceb92279f0480b1a55806b.600x338.jpg?t=1631811463" + }, + { + "id": 1, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_30de2e5d50bcb4a0460f5ef6858019665b9bc8d9.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_30de2e5d50bcb4a0460f5ef6858019665b9bc8d9.600x338.jpg?t=1631811463" + }, + { + "id": 2, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_0480024d03a454dbd12178b35b5b10e8619c83e1.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_0480024d03a454dbd12178b35b5b10e8619c83e1.600x338.jpg?t=1631811463" + }, + { + "id": 3, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_1afa229bd43a56ddc114983fb6e2c824f1dd3c3d.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_1afa229bd43a56ddc114983fb6e2c824f1dd3c3d.600x338.jpg?t=1631811463" + }, + { + "id": 4, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_839c9b1028afb8226febb1e258260d3f92c7e125.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_839c9b1028afb8226febb1e258260d3f92c7e125.600x338.jpg?t=1631811463" + }, + { + "id": 5, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_fd9aa604a0c61a0ed5f86da7e1ca70e72f27aa6b.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_fd9aa604a0c61a0ed5f86da7e1ca70e72f27aa6b.600x338.jpg?t=1631811463" + }, + { + "id": 6, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_858e3528167517a8b2f49d9efd1691b2ee0c6681.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_858e3528167517a8b2f49d9efd1691b2ee0c6681.600x338.jpg?t=1631811463" + }, + { + "id": 7, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_4be849859bec3bcdf49bd6af15f4036db0a5e8cb.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_4be849859bec3bcdf49bd6af15f4036db0a5e8cb.600x338.jpg?t=1631811463" + }, + { + "id": 8, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_ba1ad6a5f9ae0625c8d65d5d59eaecfaeed00ffa.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_ba1ad6a5f9ae0625c8d65d5d59eaecfaeed00ffa.600x338.jpg?t=1631811463" + }, + { + "id": 9, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_043e2dfcedd39c3d2a46526054036bc406c275bb.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_043e2dfcedd39c3d2a46526054036bc406c275bb.600x338.jpg?t=1631811463" + }, + { + "id": 10, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_1d6e4efe8e674ec1bcc0cea7f2781b0e8ee106f7.1920x1080.jpg?t=1631811463", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1472560/ss_1d6e4efe8e674ec1bcc0cea7f2781b0e8ee106f7.600x338.jpg?t=1631811463" + } + ], + "genre": [ + "Adventure", + "Simulation" + ], + "store": [ + "Steam" + ], + "playStatus": "Never Played", + "wine": "Not Tested", + "controller": "Full Controller Support", + "developer": [ + "Bossa Studios" + ], + "publisher": [ + "Curve Digital" + ], + "releaseDate": "Sep 16, 2021", + "shortDesc": "I Am Fish is a charming, physics-based adventure starring four intrepid fish friends, forcibly separated from their home in a pet shop fish tank. Swim, fly, roll and chomp your way to the open ocean in a bid for freedom and to re-unite once again.", + "reviews": "“One of the most unique, compelling titles I’ve had the luxury of playing recently... The best game that Bossa Studios has made to date... I really hope there’s a sequel.”
8.5 – PC Invasion

“Immediately fun... One of those games that anyone can play... Home to some genius moments that you won't want to miss.”
Rock Paper Shotgun

“I really enjoyed it... I cried tears of joy... Kept me laughing and shaking my head in disbelief.”
7.0 – IGN
", + "summary": "

I Am Fish is a charming, physics-based adventure starring four intrepid fish friends, forcibly separated from their home in a pet shop fish tank. Over the course of the game you join them as they swim, fly, roll and chomp their way to the open ocean from the far-flung corners of Barnardshire (the smallest county in England) in their bid for freedom and to re-unite once again.




FINTASTIC FRIENDS
Meet our heroes! Goldfish - cheerful, brave, and adventurous, a natural born swimmer! Pufferfish – a little slow but kind-hearted who can also puff up into a ball and roll across land. Piranha - wild, chaotic, loud, unpredictable, and loves to bite - obviously. Flying Fish – a little aloof at times but a real softy at heart, with the ability to glide through the air! These plucky heroes will leave no bowl unturned, putting their heart and shoal into the mission to re-unite!




NO NEED TO BE A BRAIN STURGEON
Swim, roll, glide, chomp, flip flop, inflate, fly and bite your way through enthralling challenges. A simple, intuitive control scheme leaves no excuses should your fish perish leaving you feeling very gill-ty.




THINK OUTSIDE THE BOWL
With its idyllic coastline and quaint villages Barnardshire might seem like a perfect slice of quintessential tranquility, but to our aquatic adventurers it’s alive with the very real threats presented by crossing roads, traversing rooftops, dodging deep fat fryers, avoiding wildlife including the cantankerous locals, not to mention fragile fishbowls and the very real problem that fish can’t breathe out of water.




LEAVE NO FISH BEHIND
We won’t dwell on grizzly fish death, but should your fish fall too far in whatever make-shift fishbowl it finds itself in, run out of air or generally flounder you will be popped back to the most recent checkpoint to refine your approach.




SWIM TO FREEDOM
Our four fin-tastic friends will be hitching rides in all manner of ad hoc water carriers including jars, mop buckets on wheels and the occasional pint glass. Cheers! Navigating in each make-shift aquatic vehicle poses its own unique challenge, but don’t worry, the relative freedom of open water is never too far away with inviting fountains, swimming pools and err, sewers full of hazardous materials to splash around in, before reaching the final goal – the shimmering, open ocean.




BE CAREFUL YOU DON’T FLOUNDER
Whilst I Am Fish may look like a piece of hake to play, actshoally the controls are deliberately designed to be challenging and with an additional layer of added Bossa Style controls, it won’t be like shooting fish in a barrel. Even if you are a dab-handed player, you might find yourself in deep waters - we dare you to dive in!




Whale and Dolphin Conservation
WDC is the leading global charity dedicated to the protection of whales and dolphins. With a vision of a world where every whale and dolphin is safe and free, WDC fights for the survival of these beautiful creatures by ending captivity, stopping whaling, preventing deaths in nets and creating healthy seas. The equivalent of £0.125 GBP from each copy will be donated to WDC, Whale and Dolphin Conservation. UK registered charity no. 1014705.", + "soundtrack": "No", + "intel": "No", + "createdBy": "6143fb7244277443078d7d44", + "accessedBy": [ + { + "user": "6143fb7244277443078d7d44", + "soundtrack": "No", + "store": [ + "Steam" + ], + "playStatus": "Never Played", + "rating": 5 + } + ], + "createDate": "2021-09-17T02:21:01.667Z", + "lastUpdateDate": "2021-09-17T02:21:01.667Z" + }, + { + "os": { + "windows": true, + "mac": false, + "linux": false + }, + "systemRequirements": { + "windows": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + }, + "mac": { + "minimum": "", + "recommended": "" + }, + "linux": { + "minimum": "", + "recommended": "" + } + }, + "_id": "6143fba39d57ff50939bef41", + "title": "STORY OF SEASONS: Pioneers of Olive Town", + "series": "", + "steamId": "1392960", + "frontImage": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/header.jpg?t=1631749858", + "screenshots": [ + { + "id": 0, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_2f4235399f9b09f648e5cb4e617c12c0a445a550.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_2f4235399f9b09f648e5cb4e617c12c0a445a550.600x338.jpg?t=1631749858" + }, + { + "id": 1, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_444b300288436ec1276c0afebbe3ba0166edc52d.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_444b300288436ec1276c0afebbe3ba0166edc52d.600x338.jpg?t=1631749858" + }, + { + "id": 2, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_ee1ccb62083c6b90f115640e450f5d46a32c86d7.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_ee1ccb62083c6b90f115640e450f5d46a32c86d7.600x338.jpg?t=1631749858" + }, + { + "id": 3, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_7bbbfdac759ab21c20dbbff4f46e84d396e06664.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_7bbbfdac759ab21c20dbbff4f46e84d396e06664.600x338.jpg?t=1631749858" + }, + { + "id": 4, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_85f92a0ba308253a767b9f24744cc5969e270816.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_85f92a0ba308253a767b9f24744cc5969e270816.600x338.jpg?t=1631749858" + }, + { + "id": 5, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_0118d0ecbd8aa7d3c31eb18067d6af46e429b8f4.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_0118d0ecbd8aa7d3c31eb18067d6af46e429b8f4.600x338.jpg?t=1631749858" + }, + { + "id": 6, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_fbcbaaf87cce9490a525eef27bd7be8a81163024.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_fbcbaaf87cce9490a525eef27bd7be8a81163024.600x338.jpg?t=1631749858" + }, + { + "id": 7, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_efd758b3a731a7c15d1a9bc960babc6b3b30c2cb.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_efd758b3a731a7c15d1a9bc960babc6b3b30c2cb.600x338.jpg?t=1631749858" + }, + { + "id": 8, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_95688d934cd8d8b5dff0dcbe957fb7b98b81fb08.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_95688d934cd8d8b5dff0dcbe957fb7b98b81fb08.600x338.jpg?t=1631749858" + }, + { + "id": 9, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_90532bfbfa8568ead58d2787dc3a66b294ec57a8.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_90532bfbfa8568ead58d2787dc3a66b294ec57a8.600x338.jpg?t=1631749858" + }, + { + "id": 10, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_447461dc39cbce0e1f16760948d4408cf658dc79.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_447461dc39cbce0e1f16760948d4408cf658dc79.600x338.jpg?t=1631749858" + }, + { + "id": 11, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_3c3837973b5beded3bd9c8ad1b0ff200057943fd.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_3c3837973b5beded3bd9c8ad1b0ff200057943fd.600x338.jpg?t=1631749858" + }, + { + "id": 12, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_74c9e3b5a52344ee6f28c624410043d11b00c22f.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_74c9e3b5a52344ee6f28c624410043d11b00c22f.600x338.jpg?t=1631749858" + }, + { + "id": 13, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_7eee35894df1d32db1ace312578d9a8190570c55.1920x1080.jpg?t=1631749858", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1392960/ss_7eee35894df1d32db1ace312578d9a8190570c55.600x338.jpg?t=1631749858" + } + ], + "genre": [ + "Casual", + "RPG", + "Simulation" + ], + "wine": "Not Tested", + "controller": "Partial Controller Support", + "developer": [ + "Marvelous Inc." + ], + "publisher": [ + "XSEED Games", + "Marvelous USA, Inc.", + "Marvelous" + ], + "releaseDate": "Sep 15, 2021", + "shortDesc": "Welcome to Olive Town, a peaceful community established by your trailblazing grandfather and his friends. Now that you've taken over his farm, it's your job to carry on his legacy. Plant crops, raise animals, build relationships, and get to know the residents of your new home!", + "reviews": "", + "summary": "

Build Your Farm From the Ground Up!


Welcome to Olive Town, a peaceful community established by your trailblazing grandfather and his friends. Now that you've taken over his farm, it's your job to carry on his legacy.

Plant crops, raise animals, build relationships, and get to know the residents of your new home in this brand-new entry in the STORY OF SEASONS series!



Cultivate Your Farm, Cultivate Your Town
Tame the wilderness and build your farm from the ground up! Gather and process materials to fulfill requests and improve Olive Town's infrastructure, upgrade tools, or commission new outfits and accessories.

A Farm of Endless Possibilities
Clear the land, repair old facilities, and place new ones wherever you see fit. Level up your farming skills and craft a variety of decorations and facilities, from fences and automatic feeders for livestock to sprinklers for crops!

New Adventures Off the Beaten Path
Finding Earth Sprites while exploring your farmland may lead you to mysterious, fantastical lands such as gardens where the seasons never change, an island in the sky, or even the inside of a volcano!

There's Always Something Going on in Olive Town!
Participate in local festivals and watch the town come to life! Get to know your neighbors better with over 200 unique events, and you may even find love with a special someone!

", + "intel": "No", + "createdBy": "6143fb7244277443078d7d44", + "accessedBy": [ + { + "user": "6143fb7244277443078d7d44", + "soundtrack": "No", + "store": [ + "Steam" + ], + "playStatus": "Never Played" + } + ], + "createDate": "2021-09-17T02:21:23.962Z", + "lastUpdateDate": "2021-09-17T02:21:23.962Z" + }, + { + "os": { + "windows": true, + "mac": false, + "linux": false + }, + "systemRequirements": { + "windows": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + }, + "mac": { + "minimum": "", + "recommended": "" + }, + "linux": { + "minimum": "", + "recommended": "" + } + }, + "_id": "6143fbb19d57ff50939bef42", + "title": "The Artful Escape", + "series": "", + "steamId": "1122680", + "frontImage": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/header.jpg?t=1631203144", + "screenshots": [ + { + "id": 0, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_ddbd9919d189a909af3cc874714b8f01a6872da8.1920x1080.jpg?t=1631203144", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_ddbd9919d189a909af3cc874714b8f01a6872da8.600x338.jpg?t=1631203144" + }, + { + "id": 1, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_8ba7c3cc7b4a2210b3c4a823b9b956427ddb6e51.1920x1080.jpg?t=1631203144", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_8ba7c3cc7b4a2210b3c4a823b9b956427ddb6e51.600x338.jpg?t=1631203144" + }, + { + "id": 2, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_96f03593dadecbc8a63b59bd667d3b6fab8ea7fb.1920x1080.jpg?t=1631203144", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_96f03593dadecbc8a63b59bd667d3b6fab8ea7fb.600x338.jpg?t=1631203144" + }, + { + "id": 3, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_ab8d9cc3efe4d51ac4387feca4cd856869c2b65e.1920x1080.jpg?t=1631203144", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_ab8d9cc3efe4d51ac4387feca4cd856869c2b65e.600x338.jpg?t=1631203144" + }, + { + "id": 4, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_999deed56165d386911ce17c520da9d2355ca6d7.1920x1080.jpg?t=1631203144", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_999deed56165d386911ce17c520da9d2355ca6d7.600x338.jpg?t=1631203144" + }, + { + "id": 5, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_6f06e1d46d986e1601a3dc214273955d9d754b07.1920x1080.jpg?t=1631203144", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_6f06e1d46d986e1601a3dc214273955d9d754b07.600x338.jpg?t=1631203144" + }, + { + "id": 6, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_d0550df3b2b649fff4f74203ec389a76ea9eca05.1920x1080.jpg?t=1631203144", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_d0550df3b2b649fff4f74203ec389a76ea9eca05.600x338.jpg?t=1631203144" + }, + { + "id": 7, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_9972926970943a7679fb3fe278e1a0abfd044651.1920x1080.jpg?t=1631203144", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_9972926970943a7679fb3fe278e1a0abfd044651.600x338.jpg?t=1631203144" + }, + { + "id": 8, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_c96f8ade705102a89f019bb7b9c8ff248af5cb8a.1920x1080.jpg?t=1631203144", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1122680/ss_c96f8ade705102a89f019bb7b9c8ff248af5cb8a.600x338.jpg?t=1631203144" + } + ], + "genre": [ + "Action", + "Adventure", + "Indie" + ], + "wine": "Not Tested", + "controller": "Full Controller Support", + "developer": [ + "Beethoven and Dinosaur" + ], + "publisher": [ + "Annapurna Interactive" + ], + "releaseDate": "Sep 9, 2021", + "shortDesc": "A teenage guitar prodigy sets out on a psychedelic journey to inspire his stage persona and confront the legacy of a dead folk legend. Starring voice performances by Michael Johnston, Caroline Kinley, Lena Headey, Jason Schwartzman, Mark Strong, and Carl Weathers.", + "reviews": "", + "summary": "On the eve of his first performance, Francis Vendetti struggles with the legacy of a dead folk legend and the cosmic wanderings of his own imagination.

In an attempt to escape the musical legacy of his uncle, a teenage guitar prodigy embarks on a psychedelic journey to inspire his new stage persona, searching for who he isn’t in an adventure spanning stolen opera houses, melodic alien landscapes, and the impossible depths of the Cosmic Extraordinary.

Starring voice performances by Michael Johnston, Caroline Kinley, Lena Headey, Jason Schwartzman, Mark Strong, and Carl Weathers.

KEY FEATURES:
* A story about great expectations, towering legacies, aliens, folk music, guitar solos, making stuff up, and living your dreams like memories.
* Musical jams. They’re visceral. They traverse dimensions.
* Craft your own stage persona from the sci-fi beginnings of your backstory to the trim on your moonboots.
* Converse, consult and chill with all manner of beings: disenchanted publicans, nostalgic villagers, lumbering alien wildlife, and reality-defying behemoths.
* Shred, soar and dance across the multiverse. Traverse landscapes made of sound -- composed by your movement -- as if the world itself were an instrument.", + "intel": "No", + "createdBy": "6143fb7244277443078d7d44", + "accessedBy": [ + { + "user": "6143fb7244277443078d7d44", + "soundtrack": "No", + "store": [ + "Steam" + ], + "playStatus": "Never Played" + } + ], + "createDate": "2021-09-17T02:21:38.590Z", + "lastUpdateDate": "2021-09-17T02:21:38.590Z" + }, + { + "os": { + "windows": true, + "mac": true, + "linux": true + }, + "systemRequirements": { + "windows": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + }, + "mac": { + "minimum": "Minimum:
", + "recommended": "" + }, + "linux": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + } + }, + "_id": "6143fbb69d57ff50939bef43", + "title": "Life is Strange - Episode 3", + "series": "Life is Strange", + "steamId": "329910", + "frontImage": "https://cdn.akamai.steamstatic.com/steam/apps/329910/header.jpg?t=1521537755", + "screenshots": [ + { + "id": 0, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_0e9be011d0a997c4e6ed09291bce9a625530762e.1920x1080.jpg?t=1521537755", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_0e9be011d0a997c4e6ed09291bce9a625530762e.600x338.jpg?t=1521537755" + }, + { + "id": 1, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_82c36037ed7ed0037f71a285f5eb50ed754c6bd6.1920x1080.jpg?t=1521537755", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_82c36037ed7ed0037f71a285f5eb50ed754c6bd6.600x338.jpg?t=1521537755" + }, + { + "id": 2, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_a055d8315a360382645b6766490ee7911cae9fb2.1920x1080.jpg?t=1521537755", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_a055d8315a360382645b6766490ee7911cae9fb2.600x338.jpg?t=1521537755" + }, + { + "id": 3, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_8cba744ebebd36ffa6cd1c38973f1216a46fc02a.1920x1080.jpg?t=1521537755", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_8cba744ebebd36ffa6cd1c38973f1216a46fc02a.600x338.jpg?t=1521537755" + }, + { + "id": 4, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_827b3e27ff69e0b4dd2a36ff14ddae4fbb0363e4.1920x1080.jpg?t=1521537755", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/329910/ss_827b3e27ff69e0b4dd2a36ff14ddae4fbb0363e4.600x338.jpg?t=1521537755" + } + ], + "genre": [ + "Action", + "Adventure" + ], + "wine": "Not Tested", + "controller": "Full Controller Support", + "developer": [ + "DONTNOD ENTERTAINMENT", + "Feral Interactive (Mac)", + "Feral Interactive (Linux)" + ], + "publisher": [ + "Square Enix", + "Feral interactive (Mac)", + "Feral Interactive (Linux)" + ], + "releaseDate": "", + "shortDesc": "Max and Chloe’s investigation into Rachel Amber’s disappearance lead them to break into Blackwell Academy after dark, searching for answers. It’s here they discover that Rachel kept many secrets and was not the person Chloe thought she knew. Max meanwhile discovers she has a new power that brings with it some devastating consequences.", + "reviews": "", + "summary": "Max and Chloe’s investigation into Rachel Amber’s disappearance lead them to break into Blackwell Academy after dark, searching for answers. It’s here they discover that Rachel kept many secrets and was not the person Chloe thought she knew. Max meanwhile discovers she has a new power that brings with it some devastating consequences.

Life Is Strange Chaos Theory is part three of a five part series that sets out to revolutionise story based choice and consequence games by allowing the player to rewind time and affect the past, present and future.

Features
", + "intel": "No", + "createdBy": "6143fb7244277443078d7d44", + "accessedBy": [ + { + "user": "6143fb7244277443078d7d44", + "soundtrack": "No", + "store": [ + "Steam" + ], + "playStatus": "Never Played" + } + ], + "createDate": "2021-09-17T02:21:43.249Z", + "lastUpdateDate": "2021-09-17T02:21:43.249Z" + }, + { + "os": { + "windows": true, + "mac": true, + "linux": true + }, + "systemRequirements": { + "windows": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + }, + "mac": { + "minimum": "Minimum:
", + "recommended": "" + }, + "linux": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + } + }, + "_id": "6143fbbc9d57ff50939bef44", + "title": "Life is Strange - Episode 1", + "series": "Life is Strange", + "steamId": "319630", + "frontImage": "https://cdn.akamai.steamstatic.com/steam/apps/319630/header.jpg?t=1592488448", + "screenshots": [ + { + "id": 0, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_d74ebed52e05e7c22937e53fcf6c7bf1de70ada1.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_d74ebed52e05e7c22937e53fcf6c7bf1de70ada1.600x338.jpg?t=1592488448" + }, + { + "id": 1, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_f0cbce81ce638fca6fa8154d6b5f0178e67eb87f.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_f0cbce81ce638fca6fa8154d6b5f0178e67eb87f.600x338.jpg?t=1592488448" + }, + { + "id": 2, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_2abb901703c73f9230d0ad42846c29d263825807.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_2abb901703c73f9230d0ad42846c29d263825807.600x338.jpg?t=1592488448" + }, + { + "id": 3, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_f071f2da3d45953de69f00e05c6e333954ecdf26.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_f071f2da3d45953de69f00e05c6e333954ecdf26.600x338.jpg?t=1592488448" + }, + { + "id": 4, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_a856ba29b6a4eeb12cea337d6804d3a177c86e1c.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_a856ba29b6a4eeb12cea337d6804d3a177c86e1c.600x338.jpg?t=1592488448" + }, + { + "id": 5, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_351f0026c4ca89eef429a095750814aaf6b5ebc0.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_351f0026c4ca89eef429a095750814aaf6b5ebc0.600x338.jpg?t=1592488448" + }, + { + "id": 6, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_8df8236403f5aad45eeedd33d2bd545e45435b39.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_8df8236403f5aad45eeedd33d2bd545e45435b39.600x338.jpg?t=1592488448" + }, + { + "id": 7, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_d0648fc4ed2aa5b671e5bf11819df99b72a219ff.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_d0648fc4ed2aa5b671e5bf11819df99b72a219ff.600x338.jpg?t=1592488448" + }, + { + "id": 8, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_dc4879bb7a8305411f089fc4fb9a605d1881a862.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_dc4879bb7a8305411f089fc4fb9a605d1881a862.600x338.jpg?t=1592488448" + }, + { + "id": 9, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_bf32315f4ee26c933967b8b5189baa90281fb7c6.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_bf32315f4ee26c933967b8b5189baa90281fb7c6.600x338.jpg?t=1592488448" + }, + { + "id": 10, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_bd2379094bf433c9376ba5047ab54c3a601b74ef.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_bd2379094bf433c9376ba5047ab54c3a601b74ef.600x338.jpg?t=1592488448" + }, + { + "id": 11, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_0e9be011d0a997c4e6ed09291bce9a625530762e.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_0e9be011d0a997c4e6ed09291bce9a625530762e.600x338.jpg?t=1592488448" + }, + { + "id": 12, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_a055d8315a360382645b6766490ee7911cae9fb2.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_a055d8315a360382645b6766490ee7911cae9fb2.600x338.jpg?t=1592488448" + }, + { + "id": 13, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_8cba744ebebd36ffa6cd1c38973f1216a46fc02a.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_8cba744ebebd36ffa6cd1c38973f1216a46fc02a.600x338.jpg?t=1592488448" + }, + { + "id": 14, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_827b3e27ff69e0b4dd2a36ff14ddae4fbb0363e4.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_827b3e27ff69e0b4dd2a36ff14ddae4fbb0363e4.600x338.jpg?t=1592488448" + }, + { + "id": 15, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_82c36037ed7ed0037f71a285f5eb50ed754c6bd6.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_82c36037ed7ed0037f71a285f5eb50ed754c6bd6.600x338.jpg?t=1592488448" + }, + { + "id": 16, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_08f0871f59f213497304739ee675bae9f2b77883.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_08f0871f59f213497304739ee675bae9f2b77883.600x338.jpg?t=1592488448" + }, + { + "id": 17, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_4bb34c58857d28cf10de6b9c08c956d8d3b190f0.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_4bb34c58857d28cf10de6b9c08c956d8d3b190f0.600x338.jpg?t=1592488448" + }, + { + "id": 18, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_4b6de0ccb365a09b49f0d7ac9feecb299422142c.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_4b6de0ccb365a09b49f0d7ac9feecb299422142c.600x338.jpg?t=1592488448" + }, + { + "id": 19, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_e96d229f90e743bb8855a417b81ee229fc83e96d.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_e96d229f90e743bb8855a417b81ee229fc83e96d.600x338.jpg?t=1592488448" + }, + { + "id": 20, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_582d8b31d940aa4ab675382be36090fd8b9ed903.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_582d8b31d940aa4ab675382be36090fd8b9ed903.600x338.jpg?t=1592488448" + }, + { + "id": 21, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_d6ab80f511623688f0497fc1b85137f453279bc7.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_d6ab80f511623688f0497fc1b85137f453279bc7.600x338.jpg?t=1592488448" + }, + { + "id": 22, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_2f717a78e3777d04363c45490e4a26a235c2af8a.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_2f717a78e3777d04363c45490e4a26a235c2af8a.600x338.jpg?t=1592488448" + }, + { + "id": 23, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_8146f6c8fb9afaabbdd49b7bafe3aebdf6dc9053.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_8146f6c8fb9afaabbdd49b7bafe3aebdf6dc9053.600x338.jpg?t=1592488448" + }, + { + "id": 24, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_f7dbabdf90d2f5c558bdceeac52be468027a2896.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_f7dbabdf90d2f5c558bdceeac52be468027a2896.600x338.jpg?t=1592488448" + }, + { + "id": 25, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_c170fbe46876e8ee01b711eab94f628c33b977d2.1920x1080.jpg?t=1592488448", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/319630/ss_c170fbe46876e8ee01b711eab94f628c33b977d2.600x338.jpg?t=1592488448" + } + ], + "genre": [ + "Action", + "Adventure" + ], + "wine": "Not Tested", + "controller": "Full Controller Support", + "developer": [ + "DONTNOD Entertainment", + "Feral Interactive (Mac)", + "Feral Interactive (Linux)" + ], + "publisher": [ + "Square Enix", + "Feral interactive (Mac)", + "Feral Interactive (Linux)" + ], + "releaseDate": "Jan 29, 2015", + "shortDesc": "Episode 1 now FREE! Life is Strange is an award-winning and critically acclaimed episodic adventure game that allows the player to rewind time and affect the past, present and future.", + "reviews": "“The climax of Episode Two is one of the most compelling — and devastating — things I’ve ever experienced in a game, because it’s so real, so understandable. Dontnod nails it.”
8.5/10 – Polygon

“Essential”
9/10 – GamesTM

“Best episodic adventure game out there.”
5/5 – Blogcritics
", + "summary": "
Episode 1 now FREE!

Life is Strange is an award-winning and critically acclaimed episodic adventure game that allows the player to rewind time and affect the past, present and future.

Follow the story of Max Caulfield, a photography senior who discovers she can rewind time while saving her best friend Chloe Price.
The pair soon find themselves investigating the mysterious disappearance of fellow student Rachel Amber, uncovering a dark side to life in Arcadia Bay. Meanwhile, Max must quickly learn that changing the past can sometimes lead to a devastating future.

Key Features:


", + "intel": "No", + "createdBy": "6143fb7244277443078d7d44", + "accessedBy": [ + { + "user": "6143fb7244277443078d7d44", + "soundtrack": "No", + "store": [ + "Steam" + ], + "playStatus": "Never Played" + } + ], + "createDate": "2021-09-17T02:21:49.127Z", + "lastUpdateDate": "2021-09-17T02:21:49.127Z" + }, + { + "os": { + "windows": true, + "mac": true, + "linux": true + }, + "systemRequirements": { + "windows": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + }, + "mac": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + }, + "linux": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + } + }, + "_id": "6143fbc99d57ff50939bef45", + "title": "Sid Meier’s Civilization® VI", + "series": "Sid Meier's Civilization", + "steamId": "289070", + "frontImage": "https://cdn.akamai.steamstatic.com/steam/apps/289070/header.jpg?t=1631817221", + "screenshots": [ + { + "id": 0, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_36c63ebeb006b246cb740fdafeb41bb20e3b330d.1920x1080.jpg?t=1631817221", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_36c63ebeb006b246cb740fdafeb41bb20e3b330d.600x338.jpg?t=1631817221" + }, + { + "id": 1, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_cf53258cb8c4d283e52cf8dce3edf8656f83adc6.1920x1080.jpg?t=1631817221", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_cf53258cb8c4d283e52cf8dce3edf8656f83adc6.600x338.jpg?t=1631817221" + }, + { + "id": 2, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_f501156a69223131ee8b12452f3003698334e964.1920x1080.jpg?t=1631817221", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_f501156a69223131ee8b12452f3003698334e964.600x338.jpg?t=1631817221" + }, + { + "id": 3, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_2be9153a2633e671c283e2dbcec64e2e4543f66f.1920x1080.jpg?t=1631817221", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_2be9153a2633e671c283e2dbcec64e2e4543f66f.600x338.jpg?t=1631817221" + }, + { + "id": 4, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_a4b07a0fbdd09e35b5ec3a4726239b884f1f1f7d.1920x1080.jpg?t=1631817221", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_a4b07a0fbdd09e35b5ec3a4726239b884f1f1f7d.600x338.jpg?t=1631817221" + }, + { + "id": 5, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_fd6bbe6791ee8ab68f8a91455fa3c25b4dd9bca7.1920x1080.jpg?t=1631817221", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/289070/ss_fd6bbe6791ee8ab68f8a91455fa3c25b4dd9bca7.600x338.jpg?t=1631817221" + } + ], + "genre": [ + "Strategy" + ], + "wine": "Not Tested", + "controller": "No Controller Support", + "developer": [ + "Firaxis Games", + "Aspyr (Mac)", + "Aspyr (Linux)" + ], + "publisher": [ + "2K", + "Aspyr (Mac)", + "Aspyr (Linux)" + ], + "releaseDate": "Oct 20, 2016", + "shortDesc": "Civilization VI offers new ways to interact with your world, expand your empire across the map, advance your culture, and compete against history’s greatest leaders to build a civilization that will stand the test of time. Play as one of 20 historical leaders including Roosevelt (America) and Victoria (England).", + "reviews": "“I’ll never need another Civ game in my life besides this one”
93 / 100 – PC Gamer

“Possibly the biggest and deepest game in the series' 25-year history.”
9.4 / 10 – IGN

“One of the most rewarding 4X experiences to date”
9.5 / 10 – Game Informer
", + "summary": "
Originally created by legendary game designer Sid Meier, Civilization is a turn-based strategy game in which you attempt to build an empire to stand the test of time. Become Ruler of the World by establishing and leading a civilization from the Stone Age to the Information Age. Wage war, conduct diplomacy, advance your culture, and go head-to-head with history’s greatest leaders as you attempt to build the greatest civilization the world has ever known.

Civilization VI offers new ways to engage with your world: cities now physically expand across the map, active research in technology and culture unlocks new potential, and competing leaders will pursue their own agendas based on their historical traits as you race for one of five ways to achieve victory in the game.

", + "intel": "No", + "createdBy": "61876dddcbf9050cf108a658", + "accessedBy": [ + { + "user": "61876dddcbf9050cf108a658", + "store": [ + "Steam" + ], + "playStatus": "Never Played", + "soundtrack": "No" + } + ], + "createDate": "2021-09-17T02:22:02.347Z", + "lastUpdateDate": "2021-09-17T02:22:02.347Z" + }, + { + "os": { + "windows": true, + "mac": false, + "linux": false + }, + "systemRequirements": { + "windows": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + }, + "mac": { + "minimum": "", + "recommended": "" + }, + "linux": { + "minimum": "", + "recommended": "" + } + }, + "_id": "614674967beedada7a335484", + "title": "DEATHLOOP", + "series": "", + "steamId": "1252330", + "frontImage": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/header.jpg?t=1631723461", + "screenshots": [ + { + "id": 0, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_3701b17b1907b1db4c7cd226875e5be7425b4187.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_3701b17b1907b1db4c7cd226875e5be7425b4187.600x338.jpg?t=1631723461" + }, + { + "id": 1, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_fdabbcbfcd0684a8c27d7d34a489d309cb739fe4.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_fdabbcbfcd0684a8c27d7d34a489d309cb739fe4.600x338.jpg?t=1631723461" + }, + { + "id": 2, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_dfa0f824063ae4d2b89aaf893070238f51c2a10d.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_dfa0f824063ae4d2b89aaf893070238f51c2a10d.600x338.jpg?t=1631723461" + }, + { + "id": 3, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_400dcbac5adb1973496cab8539cbfcff5e57c262.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_400dcbac5adb1973496cab8539cbfcff5e57c262.600x338.jpg?t=1631723461" + }, + { + "id": 4, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_7dcb3b38463527e9b4ff79e08bb90506bef48668.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_7dcb3b38463527e9b4ff79e08bb90506bef48668.600x338.jpg?t=1631723461" + }, + { + "id": 5, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_fe103526b4a8ea25533de7ab9597892786222f98.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_fe103526b4a8ea25533de7ab9597892786222f98.600x338.jpg?t=1631723461" + }, + { + "id": 6, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_b870f4114030f0b9c64154ca20cf4eecbf5d54fa.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_b870f4114030f0b9c64154ca20cf4eecbf5d54fa.600x338.jpg?t=1631723461" + }, + { + "id": 7, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_b1e15dc9d723ced88108654ceefb0fbb6a684081.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_b1e15dc9d723ced88108654ceefb0fbb6a684081.600x338.jpg?t=1631723461" + }, + { + "id": 8, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_238c2fc57b844026d216cb446ddad62a7f041551.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_238c2fc57b844026d216cb446ddad62a7f041551.600x338.jpg?t=1631723461" + }, + { + "id": 9, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_0f52ec120227b3810ff8566c30a70b04513c44a1.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_0f52ec120227b3810ff8566c30a70b04513c44a1.600x338.jpg?t=1631723461" + }, + { + "id": 10, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_4e90b2abfceca33f96fce415580aab5eb96e47fd.1920x1080.jpg?t=1631723461", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1252330/ss_4e90b2abfceca33f96fce415580aab5eb96e47fd.600x338.jpg?t=1631723461" + } + ], + "genre": [ + "Action" + ], + "wine": "Not Tested", + "controller": "Full Controller Support", + "developer": [ + "Arkane Studios" + ], + "publisher": [ + "Bethesda Softworks" + ], + "releaseDate": "Sep 13, 2021", + "shortDesc": "DEATHLOOP is a next-gen FPS from Arkane Lyon, the award-winning studio behind Dishonored. In DEATHLOOP, two rival assassins are trapped in a mysterious timeloop on the island of Blackreef, doomed to repeat the same day for eternity.", + "reviews": "", + "summary": "
DEATHLOOP is a next-gen first person shooter from Arkane Lyon, the award-winning studio behind Dishonored. In DEATHLOOP, two rival assassins are trapped in a mysterious timeloop on the island of Blackreef, doomed to repeat the same day for eternity. As Colt, the only chance for escape is to end the cycle by assassinating eight key targets before the day resets. Learn from each cycle - try new paths, gather intel, and find new weapons and abilities. Do whatever it takes to break the loop.

If At First You Don’t Succeed... Die, Die Again

Every new loop is an opportunity to change things up. Use the knowledge you gain from each attempt to change up your playstyle, stealthily sneaking through levels or barreling into the fight, guns-blazing. In each loop you’ll discover new secrets, gather intel on your targets as well as the island of Blackreef, and expand your arsenal. Armed with a host of otherworldly abilities and savage weaponry, you’ll utilize every tool at your command to execute takedowns that are as striking as they are devastating. Customize your loadout wisely to survive this deadly game of hunter vs hunted.

SINGLE PLAYER GAMEPLAY INJECTED WITH DEADLY MULTIPLAYER

Are you the hero or the villain? You’ll experience DEATHLOOP’s main story as Colt, hunting down targets across the island of Blackreef to break the loop and earn your freedom. All the while, you’ll be hunted by your rival Julianna, who can be controlled by another player. So if you’re feeling devious, you, too, can step into Julianna’s stylish sneakers and invade another player’s campaign to kill Colt. The multiplayer experience is completely optional, and players can choose to have Julianna controlled by AI within their campaign.

The island Of Blackreef – Paradise Or Prison

Arkane is renowned for magnificently artistic worlds with multiple pathways and emergent gameplay. DEATHLOOP will present a stunning, retro-future, 60s-inspired environment, that feels like a character within itself. While Blackreef may be a stylish wonderland, for Colt it is his prison, a world ruled by decadence where death has no meaning, and delinquents party forever while keeping him captive.
", + "intel": "No", + "createdBy": "6143fb7244277443078d7d44", + "accessedBy": [ + { + "user": "61876dddcbf9050cf108a658", + "playStatus": "Never Played", + "soundtrack": "No", + "store": [ + "Steam", "Humble Bundle" + ] + }, + { + "user": "6143fb7244277443078d7d44", + "playStatus": "Playing", + "soundtrack": "Yes", + "store": [ + "Steam", "GOG" + ] + } + ], + "createDate": "2021-09-18T23:21:58.649Z", + "lastUpdateDate": "2021-09-18T23:21:58.649Z" + }, + { + "os": { + "windows": true, + "mac": false, + "linux": false + }, + "systemRequirements": { + "windows": { + "minimum": "Minimum:
", + "recommended": "Recommended:
" + }, + "mac": { + "minimum": "", + "recommended": "" + }, + "linux": { + "minimum": "", + "recommended": "" + } + }, + "_id": "614fc6bd22da13b9bbcddcf8", + "title": "Yakuza: Like a Dragon", + "series": "Yakuza", + "steamId": "1235140", + "frontImage": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/header.jpg?t=1632304741", + "screenshots": [ + { + "id": 0, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_3672df00523861cd37b0f969d80604003ba14fd4.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_3672df00523861cd37b0f969d80604003ba14fd4.600x338.jpg?t=1632304741" + }, + { + "id": 1, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_9cecfb713527a480f607bbde54c01763b18bf354.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_9cecfb713527a480f607bbde54c01763b18bf354.600x338.jpg?t=1632304741" + }, + { + "id": 2, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_d8bcb8c72368ec09506d3a60d42ff2a1901e39f7.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_d8bcb8c72368ec09506d3a60d42ff2a1901e39f7.600x338.jpg?t=1632304741" + }, + { + "id": 3, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_23d607ec8ceaca26edc88b6445d92ddcd111fea4.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_23d607ec8ceaca26edc88b6445d92ddcd111fea4.600x338.jpg?t=1632304741" + }, + { + "id": 4, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_66151ac4259635b4f90e7945706498dacf6e98c1.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_66151ac4259635b4f90e7945706498dacf6e98c1.600x338.jpg?t=1632304741" + }, + { + "id": 5, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_b7336fc083ba36c6073c25659fe23075234b179a.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_b7336fc083ba36c6073c25659fe23075234b179a.600x338.jpg?t=1632304741" + }, + { + "id": 6, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_8562245f8ca68976ffb5404023d148fb0cbc13e9.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_8562245f8ca68976ffb5404023d148fb0cbc13e9.600x338.jpg?t=1632304741" + }, + { + "id": 7, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_fe349bb52fa4a8243ba8d9b861bb52c3a4f4b5b0.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_fe349bb52fa4a8243ba8d9b861bb52c3a4f4b5b0.600x338.jpg?t=1632304741" + }, + { + "id": 8, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_a46dfebb4726aa042c76636131f8bbaa1a079fb9.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_a46dfebb4726aa042c76636131f8bbaa1a079fb9.600x338.jpg?t=1632304741" + }, + { + "id": 9, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_5a17b24cc9582e458a3db0f5f9ecc3f956ded072.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_5a17b24cc9582e458a3db0f5f9ecc3f956ded072.600x338.jpg?t=1632304741" + }, + { + "id": 10, + "full": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_d3fe630add22247a497f0c2d22e568df66a39f48.1920x1080.jpg?t=1632304741", + "thumbnail": "https://cdn.akamai.steamstatic.com/steam/apps/1235140/ss_d3fe630add22247a497f0c2d22e568df66a39f48.600x338.jpg?t=1632304741" + } + ], + "genre": [ + "Action", + "Adventure", + "RPG" + ], + "wine": "Not Tested", + "controller": "Full Controller Support", + "developer": [ + "Ryu Ga Gotoku Studio" + ], + "publisher": [ + "SEGA" + ], + "releaseDate": "Nov 10, 2020", + "shortDesc": "Become Ichiban Kasuga, a low-ranking yakuza grunt left on the brink of death by the man he trusted most. Take up your legendary bat and get ready to crack some underworld skulls in dynamic RPG combat set against the backdrop of modern-day Japan.", + "reviews": "", + "summary": "


Yakuza: Like a Dragon’s Hero Edition includes a selection of the game’s DLC, Job Set, and Management Mode Set.

Yakuza: Like a Dragon’s Legendary Hero Edition includes ALL of the game’s DLC. This DLC adds a wide variety of in-game bonus content, including the Job Set, which unlocks the ‘Devil Rocker’ and ‘Matriarch’ Jobs, as well as the Management Mode Set, Crafting Set, Karaoke Set, Ultimate Costume Set, and Stat Boost Set.

RISE LIKE A DRAGON



Ichiban Kasuga, a low-ranking grunt of a low-ranking yakuza family in Tokyo, faces an 18-year prison sentence after taking the fall for a crime he didn't commit. Never losing faith, he loyally serves his time and returns to society to discover that no one was waiting for him on the outside, and his clan has been destroyed by the man he respected most.
Ichiban sets out to discover the truth behind his family's betrayal and take his life back, drawing a ragtag group of society’s outcasts to his side: Adachi, a rogue cop, Nanba, a homeless ex-nurse, and Saeko, a hostess on a mission. Together, they are drawn into a conflict brewing beneath the surface in Yokohama and must rise to become the heroes they never expected to be.

LEVEL UP FROM UNDERDOG TO DRAGON IN DYNAMIC RPG COMBAT



Experience dynamic RPG combat like none other. Switch between 19 unique Jobs ranging from Bodyguard to Musician, using the battlefield as your weapon. Take up bats, umbrellas, bikes, signs, and everything else at your disposal to clean up the streets!

ENTER THE UNDERWORLD PLAYGROUND



When you're not busy bashing heads, relax by hitting up the local arcade for some classic SEGA games, compete with locals in a no holds barred go-kart race around Yokohama, complete 50 unique substories, or just take in the scenery of a modern-day Japanese city. There’s always something new around the corner.", + "intel": "Yes", + "createdBy": "6143fb7244277443078d7d44", + "accessedBy": [ + { + "user": "61876dddcbf9050cf108a658", + "soundtrack": "No", + "store": [ + "Steam" + ], + "playStatus": "Playing" + } + ], + "createDate": "2021-09-26T01:02:53.899Z", + "lastUpdateDate": "2021-09-26T01:02:53.899Z" + } +] \ No newline at end of file diff --git a/_data/users.json b/_data/users.json new file mode 100644 index 0000000..230b0b5 --- /dev/null +++ b/_data/users.json @@ -0,0 +1,34 @@ +[ + { + "_id": "613eb7adcbb6b9eb52b40d72", + "name": "Admin", + "email": "admin@gamesdatabase.com", + "password": "123456", + "displayName": "Administrator", + "role": "admin" + }, + { + "_id": "6188775eb0af643c865b28fb", + "name": "User", + "email": "user@gamesdatabase.com", + "password": "123456", + "displayName": "User", + "role": "user" + }, + { + "_id": "6143fb7244277443078d7d44", + "name": "John", + "email": "jokeefe@fastmail.com", + "password": "123456", + "displayName": "LinuxHG", + "role": "user" + }, + { + "_id": "61876dddcbf9050cf108a658", + "name": "John O'Keefe", + "email": "nymusicman@gmail.com", + "password": "123456", + "displayName": "John", + "role": "admin" + } +] \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..c8708d5 --- /dev/null +++ b/app.js @@ -0,0 +1,57 @@ +import 'dotenv/config' +import 'colors' +import express from 'express' +import cors from 'cors' +import helmet from 'helmet' +import cookieParser from 'cookie-parser' +import mongoSanitize from 'express-mongo-sanitize' +import xss from 'xss-clean' +import rateLimit from 'express-rate-limit' +import hpp from 'hpp' +import morgan from 'morgan' +import errorHandler from './middleware/error.js' + + + +import games from './routes/games.js' +import adminGames from './routes/adminGames.js' +import tags from './routes/tags.js' +import auth from './routes/auth.js' +import users from './routes/users.js' +import createAdmin from './scripts/adminUser.js' +import connectDB from './config/db.js' + +connectDB().then(x => x) + +const app = express() + +const whitelist = ['http://localhost:3000', 'http://localhost:5173','https://games.linuxhg.com', 'http://localhost:8000'] +const corsOptions = { + origin: (origin, callback) => { + if (whitelist.indexOf(origin) !== -1 || !origin) { + callback(null, true) + } else { + callback(new Error('Not allowed by CORS')) + } + }, +} + +const limiter = rateLimit({ + windowMs: 10 * 60 * 1000, // 10 minutes + max: 100 +}) + +app.use(express.json(), cookieParser(), morgan('dev'), mongoSanitize(), helmet(), xss(), limiter, hpp(), cors(corsOptions)) + +app.use('/api/admin/games', adminGames) +app.use('/api/games', games) +app.use('/api/tags', tags) +app.use('/api/auth', auth) +app.use('/api/admin/users', users) + +app.use(errorHandler) + + +createAdmin() + +export default app diff --git a/bin/www.js b/bin/www.js new file mode 100644 index 0000000..13c1765 --- /dev/null +++ b/bin/www.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +import app from '../app.js' +import debug from 'debug' +import http from 'http' + +/** + * Normalize a port into a number, string, or false. + */ + +const normalizePort = (val) => { + const port = parseInt(val, 10) + + if (isNaN(port)) { + // named pipe + return val + } + + if (port >= 0) { + // port number + return port + } + + return false +} + +/** + * Event listener for HTTP server "error" event. + */ + +const onError = (error) => { + if (error.syscall !== 'listen') { + throw error + } + + const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges') + process.exit(1) + break + case 'EADDRINUSE': + console.error(bind + ' is already in use') + process.exit(1) + break + default: + throw error + } +} + +/** + * Event listener for HTTP server "listening" event. + */ + +const onListening = () => { + const addr = server.address() + const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port + debug('Listening on ' + bind) +} + +/** + * Get port from environment and store in Express. + */ + +const port = normalizePort(Bun.env.PORT || '5000') +app.set('port', port) + +/** + * Create HTTP server. + */ + +const server = http.createServer(app) + +/** + * Listen on provided port, on all network interfaces. + */ + +server.listen(port) +server.on('error', onError) +server.on('listening', onListening) diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..57d303dcd318957e02b211f494c50aab5a67efc5 GIT binary patch literal 108016 zcmeFZc|4U{{|3CVWoR&D9y1d%W*#$S2pKX|WF9h)MT96rQihVEGG(5MkfJ0}DM?94 zlqgftK$3SY_Il3!JMVKk)ceQ#$MgN1+qKua*L8irYrNOJ_CE1&i3R%miP^h(i8*-% zZL$yaq6C+yhqr^fvzv#LsI#}9r(J+(kT@kV4u=z%*t}`x%TtA6N`KWG#SywvoX>g4 z@3{Eg)@q8{zHpkwHjfCD!r@4k0Y|=+!u_>LG!e}A z^!9S`_V#!3cXJMa%1GbSH_*v1`2X$K)5k8r6_$fMM|)fr5TOG)Rv>8TuMFTifc~H( zVW=V?0n=_iP#uSh2Ibj7nsNQIe;a_X9|HWOW#E0EK(Am2Z%3y!AfE>0yMUmX0fI71 z?PBlm4O$C_bGP$za`MFCiooY3e@>o3PJW<|KqtZWBLWEHnE-!ae6Ik)^ds;G_G1G9 z%L)1W4=gtg3_+6oA9I_}K;7 z1=#ro+xfY`$nJyAg??NB!hU09T8<-FLT;%&8VL8V5YQDg8Z1{vKx2U5R)jV{=yV@-iEI=CGF9GFXzBq^n_Tzxya{4&|xj{bE1B1oY+tD9K z1JbblND1)=dil8dfXD|yd~m!wcze6Mf%G$whV^X%2-~LyAZ%AZy91*3-j2cj!pr_# zMV8Ci2M0L8$cjKY7?+)cgOg8yKh8#UxtybS09e04|9kr_Re)m4ayc!)f#s232XBw1 z5@0ysKo$P8?x1xBttYdfvY<>vfWL>EJt+H0Vp-<}KrnP8Y5{HnC?~Z%J`w@KdC3Nl z4WOAc4hM!>L=h+l+b0sB5I{QuZ(u792c}L0mHcvh*#m^>`vAdEj98Fc*7aAw;dnv% z6-a|28KEn?eE*)}G8O}b{%(N#plQJRf50EEKNmq7#vcw4#_0iYBR~s)aGtrSET?Y) zg!K^u`4CU5;&5P!MXXU<*55`b9|rQ7K)x;T59>(-@?jd?p9A?YJxIX7K)0oF+yc^2 zFApG?A`!;{LVgH98h|1IVVvs#!Z^Qx)t3=qH$d2a6#&6dj5q@j@(uum?O+cO)&up= zl=kxY^4h*!PKQw5S!db*c7Tv43J~hVi7(e*8T1EiUp9a+z8@eT#?eQ>a)4lnMYs{t zQacFs0SK1Z2qJ^!cJlxT>yrV>!FFahTwcGxZ1f8B#NmiR8ny#^ZrBGpJHvi*b8>Q$ z-71aqHd>xnV08!p&t*RrO2JyPp{}|IE!V#qsFkY=u77BG@?}+f{&`QMS6MQfOXMo( zRcv1EJAUt#;lYyEVhz0Ab{Ql_*+o3rHz*$mQ&V;GvxSePHHiB^X*T`2Yfjv*tLLzF zU6$eSTZSa=>i)&&Vy48S=_(!mOprSlxEht&bmn#b!6A}8e2S{yQcO$ z*jqrL<1l5t#Lyu1HgZ)tEvIaI62?8$Cxc@WtS4Nh6Dzjekn`|KjKWC|vfOXUt!jQ& z-F;H$j>}pDA$zY^T)kp80ybPD+vR7~{LM;@If;jCj3+03tQsdtGhR&yww(ywz)1Y; zpoMXlf%SCT;DKo8cUz1Cw^C*fzKkY&BenJG*P7dI+^p|RSS$F&qa)cju~zw&_!lDsOPnIY7gFH;t)7?s@L!SH}Z&h4d7h2VbODM{5YF z*vH9kAhpiseSbt~*zObj)yndmw72B9G&Q%)ecf?Jiz_{7B58 zg_tYp{6(4ffyLD`6(3XU8KznDvqmm{lchuLY0e-2Ve?VDelJEbrMcQKGC``zAu4k_ z@pZbiYAoOF$PLmPWZzIGl=UaHB&}UXCOXL4^f}s{=+gm}DW_W=r?$|X`@qOF(B;B; zTg~xIraQ0yz8~zTW@FXGh*HvQ9@u@pb2s#k-Sow}yh0Gvy7t%6}dU zQf+JbJoT*4F{PMg_T<=5k!-}|ys}G&V`$8N-Zq)VtSydCTVqI*1$z~Hdo`ztriU8> zZgsCc7F9HuO|#%ZRN=#|`I4P`kmnW!cinEHj9%?;7kx^+C8|6o9WIPJ?%vg}bW3xc zO~$5oBIcY-g9VN68jCsK_8Z@Gn_#yocPoDOaAO0*hIeioSgMWvw})=bvbPz`H#dG@ zXh&UjubW4S>}YVzu`kv;6BhjMZ;rGOX1rW&?aU+)ZP#^4a_6q6)K-RKP1(%% z@6g%%@+zyXrILGMS=6EX`Qq@dddJ5ei}B`g%xDG2fuLRfCMk zFN2F?+i0|2xz*DKxvsmp4SRUl?$?%nbG%)!ZPU|foxk_@$L27fjP8d|r_(B)vxIU@ zt3PL}&B^kS>yyzbD=I$3hsKSlUW=CR7H&MHL zv*@aK36f-dcm(9+$5S~;t(iW#2!rGX4De-*zeHLHO+O?`S(?Q=gE|z#U=Nwjj z^}=|hc-YkIN;=)4&(Gzw_6g0YGMRl7hA)G@d-*hM-oHm|_syqx z-bAah^n-$;=g;2|u2BiDR7iQ6`&Pnqf|S*ECMkLk!_IyU^}c@N?}Z}wpN*GxY}7g^ zz4vfL4d0Qra+T|jW=fmp)^k_Wt6ln7(7LtR;QBkyXd`kjrKt4XUAM?IN9Jl%{Yc&4 zjjElRJyf|jIsDMGNk*0%&nUE_h)m+1mvLC?QEFb|<4rGR$wq$FPU$cSx;ybr9X3@> zSu61ktvhI)LF>t+RpPa((W>*>7yFB{8S7_z{2a9Tz8QDWq#H1CduM1ly1XgP&nuph z(;y=H`js1hPxSg=LuAV(viPF3DZlaT&d65>`-Kjgr;|S|c{-diP-ysUafe#{PJY(B zh%FBK4vxvn#C;3a!}05i)*B4i$bSBuTB#csfAmdAM0T*4g&*;yK^j_f*N*;R+OfMO zOr1krw4)U=R_`ogt~hYQ=Ic|+~bgc0W`cB>myMMm6A(5R%8 z?^LQf&G(Y8!<4QpK*V;)Ijr_|hxCybKP>8`-WOih+Aqqi^XR2&;o9Is)0&TK6~^&3 zR?{SOG&5^_Q%;fFJ&HZGvn;78fn1%|Rw+wl!;#WGo_4OrX#-}C4AWmkq>sEjZ}jml z)$Jm5-}Jx>#q!M)qI8s;Tlb2bQed6jq1(a7@gT93^ZP!-y2GD0-hJ~rdLXSiLV2K7 ztr|QWyw66x=FPUMZTRBSA@6gv{FB)yK2^WSF5km=*M82dje5Q9 zM$r#@NqrhwScYmYR}?ZflM^W)bjX^Hebag-gcG8DI*)KwPbsWdXqcPs?1 zzZGXgMIJUreCFNx_DWfrvBUyK7JEA9bOYbCQT&r|<+9^Pq=TAla?2lz*0IZ6yV~j( zlWaz7*K~8?8*`uHTaIRu=z+&hxl!gu>4_184}R+3i({O3E$0_~PLn}{o*Q>{H4_9w zZl8?a75LMHQ*+kBBm?(z>ihjZ8*!5>!gVKzaDQgR1o}sSU~3&gx=K03X9IyK06wI{ znDFq!3Jme>010e8BOn*%k*$CWiy;0*Km=R7CH@+a53iLN;y(j?CBTO~@P30^X*(c3 z4fvo-;KMqA*M$`s;yZ#5u>Dc}S40Md5dReT0ON;oLyeUh;tzliu>W8h@>i;X_{=0Y z9C!s-ihrfPVL8M%Ch!5Q*f*A+2Z*TtFdyoy#E}0%K+q)Af2CZgkNA9Gz$5-j{Gu|5 z?*{m={Q#_Z>_H90&m!>Q_+4q=Vfj6P4_-l+`fsIU7t0p~0}r-8@{QtxWqxI-oEzZ7 z`47{-)BgE@kH+8cjNbvk2d`>N{(lwk-|wOLsX)U^68LBh`Fq*_mPUMaK!WohrT<&m z|CWc!djdXef0Rc3@ZUJtJXF34@L~IdW>^|OC?8w)zkNpKrUBm^@R47TT#+HZF<9{7 z`h)tOd`0ZP#{W6M2e0}|{$UK@>}W-X{LcYCY(GRo^+4%=Ld4$+0@lRTALaiO59OnB z!GI5+Utk(88EOYq?w=6x>i{41Ka72~@qYn)IDbGhEUiCC_n-Wtd{j;pJT$?PNdz_F z#%lfB06rT3kh|LY#{)h%Vp|%&D7KX#ioXHy;rxZ|4}GJ4_%{v2|M<7~QT|H#Y+%ua z{$Y6_wPHhl3;`dGA2@$OSFXDM>A{521pL*E9T+6??+W z>#$No{O^E|)?YY=S3CaX)-C&o{f;zO5(6rG5b)9XT?r4BLHu06w+Hoy>6NaXPy_L~ z*5h!>fDf8sRfhOJfWHIqVcV_75dQ|?!{_Hp`yKf}{BgjC>kmw?Mg#GA!J)ba;KQ*G z^H4tW^G}G%MF76(AJqRT;KTUA8SroQ-&*kH7HmH__J3#mD*`@T|6qL}f3@S^8}QWt zAGY7`@cF>PkJeu}hEThLkH0ij&I<71{sYdx)vQ5?p9c7F{egUFtF3=O;P1rnS2_og ze^GGQ|0nC8Kj7jM535I^L? zJd_X1{K`s{Syb9kN95zAH6?e@BiCp#5Vv7m)al1e**B)`VD=qw*GekUz6Y; z#q@8n!~3W#1^Dt0>wmTLM;-9t_(hs4odd|fH{gS>ESJU)Tsy#UT#+GuJ>bLVH%!B} z<5>~=ulmno{DXCP#fJQgF)WWCNL*>%VOhlY1bp=V0+u59UF?bs@pAwl#Qod*k8Z$+ z{SVs??EzL20P-&lzGRjHeDD~={m%OF9PrfvAND(pVWn+|{BL6V>-+@ettS4xfDhLX z`0R$hVGJuV-`m1@P4XAL*^u{~GXP1IUN|p~h~*@=HIV4*n5a?0QhkI zf_#*RE%#5KQQ2g`NB#FZ_3sD#ZG`$mUn?C$$iEQBU+XvIqILA2;zs$1?*#ZTewdH) zP?=vLDw_lN*!i9z9ymli2!4za~SbI1O9eQ z`>mF*$i2M&g4d9x^PAQ3{Q)2D|6#ksJs^q;eEg-Ma@IV{`!85`c%l4Xk}>5@06ttl zAQu|K<LDG#8(D#SZVGa(4kA&VR^9dH=@6^5+2`?tdU3#=hG4rTCYBmkaxDrDr!-{~my^hN(Xs zLn|><*ABqfBJjac{;K1r7g*kZp!P%k@NY37{|5p8Px}8F;A8uLwf;W>K0H4`yw&y} zhu~lJhZ;y{rS-Q3d~E!vT~^9J2l#ORfp}#9MkL%vV6XSIg%VSw8;+R2+;Q94D>FkpBR{S0ng`zES?a ziJ)>-fDfPFaNYVH{yV_m2KbPRu7C3l?<4<$qJPal7z6oA$QZsW;KTI`(%{%#?fRPw z_*(%Vd=0k5g>_hoq4;|MAMT%E8pgiTIe_>K;Nh_i@L}KoPWxFB_>c=VRy%*s0X|$m z0CkDK+WJ2t@R5J0vl2t~Cl&+$k0tPr`fauCw;%B30Uy?T-AWu2${_zafDh{r+YTCv zV>$KDe-XbM@X`AZJpW&9`)vS&XAhzN=_@4zKgj=2iNDUTU>l(Pe~x_l4k{-OCO_Oi z!1PM%4iyl8Kj51X_%MI9&)*w>Z;RnmK#>&~@~;QJywJq(S3CX-0N?Bn_&)&O62o6@ z{C;5Y?Zxm{yZ$`_d}Y9geRuXZ#?M-?`1$_<|1jX&5%|9|e!l^}@gMl#1SbDBz(?a@ z1w&`42r3@}_&fiAKM44Sf4~<5lMg$7R~vsC;KTJ3)n~Q*MZmYg@K-y2trh=o{Z~8x zU;T~0+W1Al!yh~UR?CkDd`C?ER~!E*;KTg`Z2z?@bq>m*{$dA{cQ=8*is}0w{sF+Z z#l*kT5Rm_Fz}Nl*K0A2%*2BcV(q{qwn z%2fcqIi~$q`}|>7`@i$&-~6Kc$iF+_!}dq*j`C5te?nBQ8t}2}-)iG0RsZYx`8(}r z1^8J1{}k`v^HKbTfRDx>EVJ7E+j?IpVDZ&ECMpv zf-jvvYygFc{)zCne8iXBzI=WF=iX`z@nZoWwIBM|u2!Oh`2Bzn*T3KOAE|@GSrGU# zE3G^T5cz)r_;CO6yZ$A0|1AC^fDf8r$^Y-XKePe98-f2j^H&l)JRJVO|7pNS>&NfJ z{}sdko%k*EakxW&5Pv7&|4ICYJN_DfzY~8Q;DaIf+v`7z!C(6~IPcc1Wb$Fge;D9{ zE#z;H|9ZgxlkuwnULN84hbP3e+Wkue;DaN8rS%V1eYN~5z(?!H@AThV@bH8C7Z^Vo zp$;&Pe>1fHC;~o=AI1&&D_z16{~+L_?{8qA#bX+osFdoo7ONK?556+yH?wc;(hW`)L5+Cek zmkc5-2lm!W_A8_WC2W_A{SiWc_5>Y>u)mzZ1?%q&E@)VU{pP-u`)h={p5TJ$4KAqX z11@O4LO7=bz_k`!;oyS&NN_>>6~efq!3F&s1sAm6LFg}LiM@(y&|g0xjYU}g6(JuYY>(Fj90CY!B?#y9 z`=!#qM(FPYxZw9TU%>_A`VKCbKMyWw{|#YW3rlzZFT(sqLOw*8CW2yrLzqPj{=hgX z0KzmS_ygw?6+kM0959oB@FOSq1NFE7!gBloVY>pZs zgykXuk^_t*+>Zwcbx#6>^+_h=rvQY!(*#Td2+L&Lpg#Bj<5bEy%2+QpQ z2>sgw3&K3O7l0Quh*0mp?FZm}m=C4?w=aOL zvAj?C-@f42`vKTaaNhvO&HwfVur>a-FZkcSV0oSW-@ahE9saj3`2X9!;3w5jxDojO z*obh;x>#xackB82u4yKH$!7bPh8s>rCe&^AFPu7Nl@?<}S0zO1n5#J8h2P?kDJmrA z!ANg;(u+U3+WGPR%CDPi87!?c>p+Z1m$1!QiW)yeExg0{MN92P;b#^KQ!@-dcnks^ z1ldj=FA>}s^o6^`X`lANmM7}kQdv8S&sxk!Kiebjd7M1vd>2jpA<6yZx)@!!=R$@T zmgToo9hgh#v%f}Dyy5ab#-O(}%xkH4W-gXlD+sAca+SHB6C-H|A3t7kd`iZ2(0-OP zyj3=yvY3p4>#Nav_zXnx!o3%dYs2pO=J*^=&d`%KKT4sughmnm2~{$%8J8eqR(O~>(g(x)V3v2Fc4k#9k^lg9M@ zpSi(wKnUr=cLQYjvH3HB9uDe`idFYWg*_O~)C^aWO2rtDZos#4%DscRDd5IvI zMv@;j(G$^4LUl(I&Ry$liVBhppHCUg8)&7-u|(&@OBByT+ai@3(_}*Po!J|%uCw14 zn7UD4u2sGDgLg#Jm21W+*=M7j`om|{AM7d=!^NGwTg-E;XE}h0luzHsX3togFcr61_B=$P<(qJJvgbs3k29xS zlWxP%*kLZ+u0VzxwOna4w}vd(9Y)P;o*no^OWy1Vgpe+Lmqv#7TgTV9U{6neG}9== z`XG%0r-Z>s=@mcw4b6QMGQ=VlVe?;3oQgkb#FVgj-{inuoih3sg9U>JyQ_~CpDvJ8 z9|1y07oJTZ!^>4}T%%IaF~$PkEqCdydXrXMYF ztK>6rZ0nsCnd6^kKE(hbq)UxN0pi)W3P;i&Dv$AS%u`^IFASrtR&2=nb}dxmb2Y2F zYddEFquZMI7Oy@gDLkfp>&gdHgijD!hw@s=+F1D2I!yj14O7Duj*+U~VE}M`boF9K`{lWNe&qT5F0<1Lqw%LTL zQVM;ayO~(+RmGPBgiycHBT<0(sJeTjKQmk93ixAjxc%-dr%KJ@;*O=f{dt|Z{rR`h z*pTjd)r@)0?L`8u4|&P);R-uzTB)m2HOoz+@x%|vvFkbmR@Y-o^O+i5*Yw98BtN_K zMD4$)SKfT(XXA7H(!S%DbPcwz&!G(Qn-_}G>3$SoDk%5vhvBUtBK2Lbsm$)|6?7*P z$HdEs)ipd9((yBP=c%W)9~y@*vIVtC@sAEPU%I`PElgEB+5O6ob{|Ddyy$!d%Hv1(&=9Sa&?t4j^Wo&Pj9#&h zy;Bc^@>7arNZc+&kcujFPaoE6IU7LPHh4Dsg!R@On>=|jJ#A%pT7CZitSvtpK89pPMMW{eAn4K*GKm8Q@-7C=^g3mCq!vF zTW9Jo=E&~mAdUBp+%vZO7#N)Y8NcjMkYM^+oZD1FivkdXs4{7XckKFVMLWl*Ye` zzOZv*ql8U;biZ19^|)}txkAHU~GW^~5ChlDK1}@ufyk>sPvOdpqHu`yc+MS%nVR3SC)#aD(QiV&NPmZX`iC2^2_n=gYQlBfDnpz6A}f8*S&Hc*Ry`i;Mwum zX6K*EqQkn9hb39pvbJXkmgq^+3YS*&kMPCpQ9OM0FiqThB9h#9It*$P7s_Vnc ze_hoBN6)sN@^-ULd5CiN zp2^c#eMqa{(KHpK%Zt^GBN6aRj}~qY&20X`mQ=$0SmVk?3BNV#cJYtDlM^^^rNu6@ zUp%$TO8iq-h2hNm_*YNco$U12kng09N)_e32fwRA@xrrFWO!xzqa1Hj`(IC}rA%c! zSLAEssImN9lTxN|+h6I&c!=6uPkLzK_Pd)tabG3UFKVZ(UZ39 zGpZU-h51U)RwX=XJQt|he6xP5l6Fil5JK?^AW?w$_-)4O@=7rqG-xAtHTzbxgqtoh z@>KUc5T7z6ChNqkk)~#!xdZaWMHgVwc zi7(r12S%;R=9+@pKDc>G@P2d{9`>TYv~$S$)E*&yOuU=1y2(EVkJo8lF{R zbcM0HPFslb;}@M{ipJjP^QZiLlJTxHHMEIUTY14^BkgfsYPN?C0(zpYH8vY!JCd(S z^GM~GJ&)s2bF7TeHu&_~62^%7O$4hu>742!YB2dhVa`c&Uy%oqh^LInxvi#r%*45@ zKWj47`bCCMc=4^fbftdZ@w1m|cV2m3Hsy9a@)Lva0rASLUOSAgC|37dtX{{#(30<` z(mX8ueaBr{Htfv{>S!KS+0NLwrd8Lz`RZhC1xwv2OM#c$G}vz%m5ce6EU>p!#D1aM zd%Vg58}AmZuI1$ho1vDj^~O)MveLY(t?}7&U(F?3D8pu$4P->(&ZJVc$+U{|3>Vv{ zZzbuGacmV@PxB7{_Jx+Sd!d}W12$eUtnTj5J*g*mduuXP%Lvpqz0Rs$yPJhqTXEaO z^Mx(l^r07Hu7nm3e^79Ks#fDS-Fvi0_jW>vX$DJA{b`}k17W4gn0A1B6l8eK+#hT^ zc3eItF(kMy&Xj2;*x>#4*d5tUq*UeG7nud_90?RW(a%O)JY~yil=etj0iz3lr+^HfR_Pr|H0`E1{+VIcR@90rUIT= zOZ%9A-jy`8_85mHe`TijINjx5d~YpAR~oC!(6yyo)BH%-oiBzg?y^3c46Zyn>%1dX zb3=}`oWz*dHRrSUV>@TK4vKx3Hfnoz{MCL{vbp!W=MoqU73!4S%Azs4@OK%=@OLG) zZhS{Nn0JylEw%l!&Rc1hYgSAHueX_D#X5#L=}+e>hwn-7zh^b#UwZgO=<2M~X1IvRGZRw?u<+QTMt`s)~Pz zROKz?>Cjk@*rv()q!{cdJ#xV8!Ti;qJ}-T~+EN`9WP4kl^K|FbhlghEO&j+r9da_b z`xK*#euo0(@skq1T2>7Ss>R8rZyj{f*QJdsdQZHjHae*NwV?3pY0ibsthtqO@-b`! zT1OL<_fajfXLhRJ88AHCW+C!S;3M zx4v5Y@+RHQY8Rb-YT*wL<)=t_Tnyk&So|U>*F9@W`D27}&NWVCWCjzj0#?_DZ6uRQ zFX3G3SVeHpM&hwk3Hz^H8#7>F2zu*VP_eu8(}SOOt|m@bm;^2>h)I|nX8$((Wnc|$ z80$<$U9R-jPK++x3nRnpHYiQxKB8>2E+y5UPL@#IYUehS<2YbudVwo;fXehxL>%9t zGUfE3F&#J+zVa+mQl~Pp!gSQ#N7x?=P*B72IEU`%X?%*XO!<(lt>S zUAP7z!)Ni$xOo(k-+rqk+J1wEMd`rj&9~&w&5X&-Qd=srMzzgnUkm!CA-%@7F%fSy z^o?Rv`qnXpaot$Q}8^z}88yOiCDL3hyJ^Z39j#bgbC`>)>k1ORV z>#n^fBW}i!z7q(cc-4?7Ks=d>^Yx5ty5^7BHPye}Rr9Ah$H5_{Wuv3?LPu1sL{Zwg zQa)M3<_-6Uf?Trv>irzY^nIBJY<@Dd+LVVrUD$~|FHy(p?(y;c;NqwyQJR`etMEnR zr}>8y@tICn4`-sc51Uy66-ze!RJ=a|~}${N%sj# zKhq9=ONq)2vefONF>I-V?^dWCG_bm5nVmU?_kDXVgY%Eb9A9;A_le5Wx>oY~Pd0b$ zE6l5MoiaKbeCbol28{!EjARF+-q()Ud!DYHKVq}r%YN<%_Pj(Bt6Rj~y~w0VZIX(+ za5Q!!Ye@UY&-j`9hUVjkX7b8AMPqzrdR~7g+fw$ubwcJAr6l8-CckpSaQ)qMN6+<* z9Vl1B#H)qX%`D*SXAt)2{hma@!5i;6+G+LRfsY91*M`3Dtpip=pY_;Ysj4UI&|EKw zChm+P&b*+%#UWUpsN3zGrBdZ?dhByk8>`#SViv2!zR0YAQ#dJ(Z<+q4Z1(-FpW)M| zS7Yz#2qfC8WduLn<-e2l(7;UxuefC1rYXIWe6{D?dWY{lwV+Xe&mPpi@N5bhzVEi^ zTj^HmqKmqB?}sM2Yu#`x3zi|-_?(G$v8{Dxu=7TIL9&2hYO^X&%tNo3qQECxXKTFc z8E#oxum@)3A27HBa?|f5tCFf%J-nMs%SRb=7{-)Ph>rrZmEnG zkvDQ04-2tNpZ})7!S*ofLD4!>LimfR+lJ<5RofeQy~c-QH6NEMsq7?! zV*tggheQG5x09%Sxj3(KrRu}AwLB6#PG=oXyE)X9FsG=gZ-l17F^;xTG|69;{)$@@V^bj=)yfVGW^EmJNv4O^B?KO+w3`de)fYZkNmsQ z&1Jfh&g)*0Y6sZ;Xx?pP6}YbA<*=N1adN^Anm6_fK{`!uy&aCW-;Scl2SO;`9Y_=) zK4ree?Xar7dCeZB1j^4H@<8<2HtsHM$-yC|VC1jo9KQw!VVSkQy zFvC_`vWiN&bf>r_0g)%tt#4jrX6D$t_mx~88KBtpg!jt$V>O{+x-YdUVmrTYQb{jYXYo|9 z#`#TSbdCO_3fjR@M#W>Uu3h@>T>Q2YQssFg%F5K$$_7q$-b0TYcJ#1Cnx}Mb zXc%NKM-8$ko~O+wYmM%<#EGbEjiI`>3h? z#;R$eDtTQG4RTIr*i71Z%kE1pFD_N%XA^VYhr4nm2hN` zL0Pkkh*=B!-d!i|KE7)6;OMno%p+?2i;OONyiX>1!?}RsHO1;mHf^$ZQIAQia=OUI zE4H=xXj}5ZmXWfL4|U7m6&46nY(;M+DGk^b_#D%6fe%Jr?PW#Wubf#uUyd*c&oWC;{NH!95R8|#xJk^ zT)k(LQu9?#rQIFRw|O2(WfX~Eelv#A-G$Y4d(+!2pJC_Spj;GLaWTeD-G`!gR&-DQ z1!}*k+w$Yng5{4Nna}Ph!QG`(8~iA7hn{s&@kLGQtOxarM-!x*;}JLsf`J$Z}YZD`cwvAD%|TGFXmYjTG+ zsaulGh&%ia3$+9IP5yrj|Jbo%&$Ibg)O02~4YE^K?r!R5-v9U}cS(_}xU%H1kfy?| zyjtg@g)|Y4EOb1#$lt6JVrXHIHp}8lc_`&j8SI16wfv7N7-wSMv^&@jb%rwNmv$xg zXv!J%pYFotWm`CF#6EvR8ZP^-l=Wl?(M$F*$>6E8cd5?27k;vyG{mIFFpw#!JM$xU z|6qmHgNv?@&1Lz==enf4&nhktk!60YcG~

wIsTV~+b8JLAd6iyqf$Uaj74_7(`Cc7S^nWcZ??oi=8jJMF)Ugqs+LC|Rs! z8oJMNfk|-ms>%6B_^yh*8z0Cj?hp#%^>5?!UpS*HY0A)e_=DWLg}hED33W>NT?o>( zL81WhkA*Iej5SmU)9%@{Q1JZ13F|uyv)hE)^nyfQxSrm^MkT9R+dCOh#&W=#+_$`w zSw$%Ewm5}{)ljYRr~-{m8rUU!Z=k@3fk+zV;u~hXWt$uDH%fC8 z4_0%;V07Wx2{OENPVlK))2YRI2N>4sZXfeXG_99Acy!>}#ydvJVKV9GU7qbpqQQ^a zP4xts_|0ZcQl`C(wF?-Yy#2|O+q#PE91ue7V2?xr;zK<&c^WG!ynV~QIb5l87V}J$ zkRD=Z!%1G}rg^iexK-*>MWZt1ymqsOu4Gr#*B+wtT60@-(o9WQ5pxE~DV-mFma*DTaV=hr+Z#o zV_%9WNPf8J#gd9=w!1O0f5&Fi{n-733s!e~qm|2oI0@~s`Vc0`y+2Nhz4c;UU%I7J z=)&V>_1h5_4l}xy(tBPMcG*zH6zCJgyRBu0-lla{k49+nY>gh}I3`|KtnT8X(%D_f z0-Su!A|C^AL_3ndO7TT@dtcy6e7NprazR~0(opKx662fh$!&7)`gZZ>(yXhs=sQKc znI=zrr_vtm^UV#byFKnPk=ZR%wJ@r~;VcG|e2eqe$2$BsbLr%JlhD%Wyx?N6;h3`A zb;<4a)^!WM&S}jPvEAEJ)=s>U=$*P$qJZ6}?#JrZl&~IoNo?~`>dLE*$WUGToC)yq z7FJ6gIIpRE<-x*(sZsytVN0GADW)CVvAX4j zMm{u^PQg0iexdca{2_z~@A+4DzkTdY><=RxhTP?X8f@ zlin%!jc98Z=?`7(_bnb+-S&#G5^go4-Mxu?8s4t^1&!NnZ`fWlT5rr|)pyK?$~Q!& zZ;y2<@r8iwt~!a?h|!}@hZ!=j^^&TI#M(Hf($Zq$^~CCG(GaV&&c{!E@;*mt_kfgr zvm0H{_{pC^@@7wj8l+Uu3G5@)8rbTbuCU%`q-E{vqz!5J7YsuOXEqJAY^TWL6T;|v zVRZw>8~WCs=`XF=dTl%R8|FOOO$qMDP3I%U=Fj^kg~s#f_nJ&i~0|u zv*P88BYh{HD=XmF*x*FeFuLAY-F-hSXWZF*OH21pUOV0s)n=qOdouHc6y22{oKwtQ z`;v~|-1}O9{}JC$rVl5r?uAeYPj}jE@H9B(a7W>^a?Ycd7+tuRK!%TusK}|~8WwG( z;x`ga`+k(SN;1gw6Ga@uWihWp4JZDOf$5g-*O)h7;9kt*;*yFR44vjFQ(WVju=mpO zBEN%~KnSf5@SPSJem$KqRRP5VF;XusxvTH)WCh$j##giJMsTfw&&^vG2PE%_4T+7{ zYqMQeoWA%L{GUG9_dBIv5H^YlGVHeggtX34EC5(M1ScT*&Jb9u;UfC(B2cH9gb^sJk4S& z?`+tYUP$^x&-3kpN6qJx->1jzdQ%GDF;Tn+u)4}DAs?ng$5N9E-rE+6ab1_5GmY2~ zac{7E4S8+sKA|F7iZt%Bx(Hp$9~6iCOsJA11rHxvH&Uh$_VwhMp=5n_jP60K?z4(y z8om&f#q3_=seG0LRGfu6s(hQ~L+dP`lvC5y%=KPf!}h)Nx`XpX+_pCdPF}oF8mt_i zUx;fOADNhN6UXkWgR#2&iJ1b1#OZ>Qk3uPQMc*3_Uq0~ZbJ*wMq`=p=&o+dVno;gQ zbxZK@!Fne7hzf^+z~gqiV`2mG8pQYFQ=*;6V=?iDV08z`wBw%p6{p;O`iN0Pv(7Lj zS#$lz6Fz)g4qVqgbG05UzA?-1d4K9jUxvEZ7AL<~CyC3WvP5pyIGHsbwcRd?eGb9z zB$45pR76D{9@%v~c2ibMjk+CWCw(lVc)Lr^K6M^h)>lMjnXD6xcwR1v8;4bb*Phy0 zDYE5eQKb9bUb)eXZjE-LfLh*}!xbmL%@@25WRcMqEt8+N+o@J^nkaHwAWSmE0y;|`aHq8)lS z9-m@f`hQ(fyoa&6BQxV-1;>=TJARrZJk!q(G2hhaV|FQR=2@P&Y41Ikp+ix^9|KA6 zQq$tON^F=H)^Oc_MI_~i-{HC6vSfXILLnyJaI7x=$$86~{wKp!ZnciqTF#xSTdsfB zY?BMl{=9pkui(zd{Y2MvTgB?ajAGkKuAU{nYJZsq7hRyuxU>8~tjkO{_Ir^CtZr_L z>5aIJ&jwQH-0vt9vCFslJ|WFbCckd*l4TlqBkWTIamhly)Ke#0NzZyRj!2S4#U0o5 z7tN1tbl}Z03Evuxi8m6fyVpHw-HUD$!SS6p#nK8pscOWHS+CNP^Le~GT9=|66)e8q z|I98~-cHGxgdZ(bZ(p`M_hy~hGUC0k;NNdT@#-r^_Xt+EDDP_RXeZrRk&1-P>!&5+ z*De}HQ{SRXq)lyLBogggfBMyQnWsXT(V=&u(n1U*jQO|t=f~%It<(Q56GT*Wfvx`&PBxo`gtiPUWc+M_PJK<`mXP?j7 zR|T8gn;O49ppz%H2xpcL-cM42J=cJ3f()N!<)6I1S(N(?`d?989eL^alyF{SgB zrY41+RruWkZ%j!{Erz=SnMr#f8nk`3+3ks z`$WAvGrzqXua`k82gF6ZJf(to72cG6;>rENzpVnk>xLHtze7u(C zS9l_#vahdDoty=GUINb)k>M#tE-2EN{y5_&n_l(!>A=1XwB)oQ`hrqj3QkWcbIwpV zGuaw-xs4keukE~0b1bqtto2)HXuge@f$gM#<W!IHDv{1)^s#+bR;bg;N;%6dQ-mkhDLDG(a$nTD&x6hT>v|3~N@V+MZ{r;V zLa2RDAW?w$;?s}szdTBPB<7Thy?FeKZryTG){$)-GK+%+YmCkNZyw)UDRYn3&C$a7 zTLMQ1LxDwrj{sLuR0i$D2VcVl=0=QeB33tYSS-k}Z*KIe_%o6u7V~ampU9bP`N7_1 zy71`g`_FQ}KXNir)EQTQpmzS7sKOSkKJWrX3HfZ~N~ z5HftYi?^bd+2}~l1TzEg{#VCjX2i+w=3jg!_&vBPsH;LmJ zg9&BdOYh8|Gqu~1d(hQB20}nV^yS_5eAq17e&DN`U0JZb zw!!huB^ce)Slu&bBH~r4>ouOfiR~{ndNgo7?}ETkmDEoc%6I8$f;uI~H_uzOWSs5{ zYxGcUAL>vey7-!RyOrscgwi6)?%@~27~L~i-HvS4F~d(ebSak(wIx8Ia`oN*K4_aoylgH2Q7bIj_ABAHJ4<+^6>W zrk;$vs?4hdqvvO_>*QG^3K0KR!tr{1UgMs#Y9)TtaYspqjMVSBZZy2#=*}5Z(NscN zoxL%2wmIr5uGK5_fGvqaXZD87V|4>KJ8Mt8$9;iPpn=9iI#xHXO^yA{ei^SD5BIpZ zY;Wkgd1Yse_rsSgwRv)u)UxKE7H@pAKWOtIjM>Ndw8}avqc2yxxx@ml?{zhai0M5< z-;2?OXKBdrK7!Yy!h6asi?(gKqFc`)wQDN-*v*gq;scg5#_`?CVU-2%-qG2$b#}?u zH;}z@vYR3vtyAQ1;U_N+xBYa-ts4lTc+VqIfcSUAea03Or%6aNZEqG$jW=kO_c`{z z_|_F&{!OWlNt&#l{(78b|6F&{%Vxt1nhmqey~6P#TN3#z0xt!wNg~JYA2P7ImzA!0 z#+qCi`MBGpLiurC|D8kNnV(u#_E?*pS0hhN2K?qq)!uYy$oa>9QeHDLBF5@anekVx zGyApmaj)1WgfQ{KcV%RFHQdbUtiEXh`j3fIp4k=xl!gTz! zuUdRp#BeG5Q;n+GD@}HL;zWbY6-2rG#su3(vHf-di2}siWww|KE#|ax3Z}c9KKy!$ zr8bJ^y1Z}})ATu4cM&G`qKK`uog}ZT6-V2WpVoA+zVE0#`b{~|W_Ou>-;dXq%Yg=J z--}q?Ot}wXx3p+C>jnCh4@PuY3cT~Vrgy&GDZpBYINr2z^Ic~1zU?`SW;ph@UhVVx zrsd6>;|xUU^EF0IYGpcoNj(y7 z*LYq$YTBTo&u|DmH(S%<`8r+MZ_JwmA8NSh z$qKh8Q&rjD7sYpJQe{idOzPQO4micrDXCm}FKSn}^;qw4r_SU{KUrInW*2G?-};*m zlQ<06yeTub)uvtp8Ytd8tnQEQc_Wdh_i}66XbnAH(@(t$DZeM&`zGfjt}bXmTxa37 z8YRcRi|a3i9x=W1WF}jzrx=MU%+a5B6xL-H!^H^tW zu{cT}93M{{bAT$4{(Qd!=eqnOZz_ntQpIoz$e60kzD)8dJ93Z3%x(5vbc|l?!Sh8J zUHF|kGQ4tx=uw$#=l`Gfz5^hNWoZ|1O_&wMEQ(o{92EgEXHmg`qRXgT;rfaIYy1KeL%uFNeaN@#H8`pT6(e_Cdwn zG9OO1f*{@w8+f5OioUeA*dR^Weqr@*C(rQOCe6DEg#$j}!BXyJL73 z($+ZPrKihwuLCPC#ocIh^!@$>@3?x`YI~foRzx6|#(?}!WcK6ugVOG;GonMw_%E_N zxO-M#shLl^+Rm@~dsThVr0AVGr|l+H5C!%;wBvVdgYO&1b(!;`;WCM*$8g`tg$>vH z)rKHmU$P1R6Ez%t(_-<~Om&-WUxLDS9!fM_b?oBSLv_vf4|t&Vz4T*cce4?aKD{EB zm45F$e5C)5T6L5sUT5(k}Sg?ZX~>R&_sBbor^yx7~NV@DJ`$!oPm|d9UA&o$dEwZS3HuYcs3a zN#33;l6rjLt`+re&bjWo%iVXf`ps!tE8_K~n92V{LB_{t`49H0ajeexSIu6|tFWz= zB4O>+N;jelFE4B`;*f8t<=@<-r4PkcoYT(s;LU~I9MVQ653nn{_p`c(>{q9NZV<%F z-O3BaQ8egL<1RJpj$Y{A!E4)ygGXyFEO%hLfyc9T8_S(uUfC-kc1p!oKQHgUJT5Kl zN%FKlUAu0tlzHpT5m}3yy{e6!G-BN@fn18k{7+=lVPBMSyT=X7oUh%Ye;w|Vr>(~)NdaubAdt*a&Y zo2NH7c|Lr^(q5ew-@Dr6T9?jGLaG;eJiJ3Oce~CXmPm%Z`|a|u#`T-~7S$g*+`aOX z?wiIfDIsloMiP6X3ymRpJM0w7jag$f{!_|QUk7FCu6vhiczlVebgAX1-{mqYUEXtP z!{KW^z4{N|VsrNK@%D*HEvxKE8&G9YhkE4_#(VB>dh1u4al-XJszd%KGH7E{>-K}n zJ)ZpBvZ`mvO7C}vcbi-;-MeSQn%|uY6@DGjqqN71E$%1RWt`evN8KXY=VoG>$f?$& z`OS{5@!x1?eI0^$eRuOhaTKjtQ1i~-s1Jwh&GsASWtY6NzJbxgR144a6Amu&i|53X zn~zato*Erj_fij;ck2V44Yw9)ds}gkrz()a+aXaX_mk!7M}_x?#kUQb@@lp! zHm+TlZeb%Ij_==d-nsUzZaJQ|u6L?WtvO{A?Yb6EG@1H+kNe>&gKW1vD4h1Br;SU* z&UAs?eL}fY$DCO?_`>VIjvIVQF&Dt z`mE>|QNd;YgriEkb+0=bb(amFIr)jbp+N3_q1-M#XNFpcyMDRr7&Fsz)z;!QJxwx9 z>TDlVqv~C+?@o?uI_+DP68*bE^H&4f#qViwD$?%D?YrfMKZ$NIwAbcKg_k7>p zy`MSnOSA8FL)u@uAuqc4ZSiLZ`kJ;rP%9?Qt$O7z-j`aWxp^*oV|~}`*7MTXWvEU4m7xK<*)-+=o6#T?f3E z{tSEGS=DOh$z=!o)f)7@V+oP}t2tfDJ8nF9!~NX>8<(_@$e4o{`$ZPoxUclvTi@Py z@%n5S7#`jDb1#8hvI+ka6{%r6d;7Dco{7FGWvx4S75eZfsCpyUebMr+HAVY;%N^k@>hHsrPfY5bu(fGI(41$M(;IKetatC;$X&(l>h(+ys^|AAY8dHLK0!-iaNoHIMEbDv^|9qZN~E$S_vbZ+JF zbtfx5>Gz{b$;3}_?@WBfx6SW-dQ#Z_`jv);qdJ*Q7RWs;l>2jRkmcx?ZC_Wq*XvVq zlC0{_x(Q_r7k55UHZF?#aM`a6~Bgg6csfJKKOu&EL%Ss&AEkY48TIw0!+;4-CW0dX1RxFz3e3S-Xs7-cJ+M z>2vJo-mdr3_t|0#sc(hj3tM(K?%9mm0&j<-Lb*S0o_@RjMR}ji`x{Q$liq&lxIH_q zD*G+6XjyF9Pp6ve)_eIZdwuWe?GKj4TC{5G=rW*rtMAf{o~7#VIcManj`r>%kb6uh z_oiEC`H$Vr+Ey5~Eux9Jw2I=TLytNeTyEZXwjO@?;OR``CEa7<{pF=jTGvkKJYHEV z{A`c0>EmL~Z5Y<8=7|c$h3j0$g>q|pM^C!+`Nx+s>js|K*>%BYqbB zroq9;HdX6wT0ErR$u>JX%sp82`ikB=OQc;>xw%d<4H@*J!a-k^K;IKWxedld8V1J) z8+(3NHt&%+=Rn!Aw<>I~n-}aqwDsBg{S;enSPZZaT;RTJ%f^Z|x@`BXe5TZ21F!ZI zty*5)An?eHN}~mGsn6$sqUd@H?s{x38T0YT%Y;%FmwL(5?%qoAGo)OA5>Q%LF<-ga(PZo`H?U7b} z?FIig#ygf@C_m>{|L4jqu3znbr$nXR`LT2P$myMStm|P=ta#Gtu*@Sn2Cms1_U%rwNb4t!${m|= z$3N=9=xx_;?0h@MQ*7+}yz=Yc9cBpUn>6;}f1-M+uRBiK6cIPl#N~#kyK7g+-Wksx z1g%agGi>o}@uyL;&P$p){axGAA+^iIZ-t|hGp^b%YI^Tub&r*E2bLb_v{Pu`bG%R- zMY2P!9#kJ0H|KTKiJLK#_ElQXOzGjnRhdXZA zf4jgv`4xfg|Un_m=_ zth+Mtg59x@s%@)GDEiZXR;`9ZH~$JS9MLn@)x`XBh2_%s)@i0Mjmqw?6y?)&%&kFR zO)D6c4lQ&#bNS+lzXftH3FX#|w7+!9v+DTa2Uqp!V%}ob&5IvynDtP#^IF@~uEp&1&EJ}w`4l$b*Unm|F+T&B+_J)RtB+^YLvtFXrlTO2p{>Ubu#{ql*?7REmh zY~C`${hG4RRHMu)`$F6{?4Gx(#iec^-xT$4+PJvT&uFcZ|A{iZk6f+L`o*G0VdsXP zc^Y+8^m<6h=I5Ie%b%RpO)|XDw;^%AA|f+xEmelBi%$4C>}s##Mcv&`9PaT z#>94pAl?qwc%e9oqJkwgn}1nYYTo=Z9|a7k}Et%&@$q|u?;W^bP#I(=z; z7k8Hi&R#!TC&w5>j*g1(PTT6{U8PO6@-NOt3FOjTga3)%znxLC%3aC+OE;6J1zwqG zmQ?Oj!nyWk-jDu!divceY0`+oZMM%>9v!?%G3-jY^W{B%jNh5|E$nOXx_a|xTsgdC z3Iy@`-r$AeC>j$rZe#G51)rofu5_L2(b{Hcg^->eThd*pF8lc-esXNt>d!jGB&kQW zZ!Djcyk@4nbc<;<&cwZM_c^{?*!8XN%!KO?H-&P)T(P)bbegZ#gx)>I#oY>8zVb-v zrS)DJw0tcees*{L2ANSU=eAD0QN8@WH?v(#d-m`jFe+n><>C=mg~tDV_rk7WLk0TY z63VrGIMeN7(TW>K*(JSd8%6gp)=6_SE*OH_c>juaPHw-fXfRA5g1E zVJAiV%u?S1zogdm*gs|9HDz+h(ZhdLztVN>-W?GiKgGsB?d)!HUDyxZ5z4iiYjS2l z;JMAk4ptwO8tv5JgUiO*y&5GCwC!9*e)7z%6v_JWv28aDn{>8+`%-&b?*2Wu!lt!x z8wR$Oq*t8UX#1_V0(~=ta!+J8UfU|~A9M!Z1oU7_5fc7=OADdu-zz~FZQ zw?cX?^C(rR)AcDS$-A0=cxGo-=VXJ2UPGR4k(Qa2{;g)WN7BZxOO9L_J!00#NqIE{?4*c-x`d&pj=?9y~HCw)X1K{nswY zB6?J>9Tgs*`f^aWp%p*2xLI=4`g)cwi|;=c$h|L=>(uRQ#LBwH>%X>4DEw?!Z(U%u(nGQv3d zKq$BS*A82)+--|~^?lL0$GY|}794pRBULpiw(&#jJr{oTef}sTY*NSW&NH7Yr}v-v z?EdbwWz{Y;%9yHt@%Ia_fy2Bb1o}P{%5Cu}V$n!d>NwBq7aA3@@?LEpAG*ZhqhIhz zi#6?=E}a&%w^-Oa$ER0ab`L#XbaRufNBT6nptx0NwAYmG6D(^FFD+a*dL)#)>XxZ% zlep~xt^YoJ^SN^SGv6l%CVIYUP-boy-+fJ_59G(+7M1o*n4kzbYW(im#6^RiBuzD) zeeUb@b*s%+WxAhwEzp;2&HqG0Cm!*8xaiw3Un{3EtuMacviAMxkl1zJ>94}#O}stt zX37F8dz2~E)%^62lNl9iwO_s`W?N9@m?ATJz8!RIRrf-3A&8GVPk5m?iriG4Ur)9F z-XPNNV{%H^5tCleNE;ia833N<8lc3-ykzPxx%*J4&p zzTKZ^T>a&Tsq4qMN6cRQ=|XfL^O6S1~=NsyJ)VQHjay!aF8a+;w`K!Gy0)B_39+ z+P~nqRTmgldOU+KH`) ze?OE_ar^4Hl3~RJv@vBo&amNiJd_x?3CIxqGHuAahq-V(T;~R&at~_sM9f4f( zW&S71m@X4lsUz){QS#E_sXt8ZhHRad8qmMyt4-eJnj55iF1FXO&8hb-y)vhczwB_* zHvHM@Zq=9cah>vN&b5hu{mxAO3PHTSnY>UOMM>Vb%$@3YuMn5$IdoG@=z5>F(N1GO z?ru^oDloF{9kUrGPs{i(E^(vZ10VB?CSx>kU*a)ChZ8=>3{ zbu&KQ`1WP`=8P9#&;F|1TGXVfkM%FtMXxg-KHIT*LKVeW!V^-Mo{xPPy{`0uu+&}BhUJnU$E6H*tQ^_5N&Treik7n)-n3nNv&*f<*H5ZY#K`x1 z|CcwDR+e|^(raq8xO4B;%#f!Gr^~=ONv*B554`%ebI^XGrKKq>3#Ho z`9-Jb+LL#T_ft>W^;>rAOWLm*eQnyu(;dsn@%Yq>z*PeQpPyWh81QK7-ujuCsl|MkuM zbm{2_cO|qOvSUN++D(PZEi;*Y;zO&VJ6DJ{*Khu$`~pvx2GvTHk9S*aT~2)}y~g^1 zeFSnp3*|nr>oN7Lb3&(Gug1i`o?tmRE_ziF`_$1@JN9T@YItC=JKn!7L>Ggne3jOT z%e3A-u71%D33o=@$ZD2YVOD>^hTrr|i1&vtLb(lYb$l;hE2&trU!TJteddmjDU{iK zvSrc`lkt6Q$CbT&t53DKxG!lF;vDC8p7nRc=_&nYK1ps|_<+Hc;?8e8E7kT8$fdT& z|3v2YD-sU&RGzsry~qWhM(vu2ht3jxz1GR_h5cgl2Tmu;*KS;MWJs@8XWqG8Sii|` z*Aa^vqw60Yv@81Ai(l>ufA3X85U($-{qaB1sY^f4jUU@{?EJv;^>&K4Op4mle(L?z z^Sv9yWVW5=)7-VOONl!{wa%n|IP)!fXxGgyZHK#wRenH6ZN<* z?!RCDa<29&Zbzdx!X~inw_%Ta%Gz#~@7Qst ztHIPZb3%6}$Gr=;aID>T-?H`UsbelVG&1~kc0|t@S=ihD+pas*70CT5l&c>DOlehYdt(Qq#!mC9 zT}+sFBlYiTr+*3C#V?`UwpJ0wm#RE&5kG5joo6e4G**_yrScGqOW z{4a;EZggpEQ2gfhN!y3GUz-(J+{AD7OxsVpoNU(G^%-%-r(559UCy2!;aKCcGAWHRd{%61axcurq@sFksTXfD9yfD5KlQJK zl?Ti>6+Y3VU5VFc25vdj=<<1ssPNCWU$*yo>RtSKut2V%P_99IUw^MB@9vcM-Z`;^ zRos*NQ^tIVPC8KN-KWegiL>g*H5t}@wQ=x+ zzRP+G%ZF1x80mlq#9h z0xwmTHZX{#eE)BxkuDKRnOZHEDW7&VFu)X@(S`KM?@zFFnO~NI`wA>jV1WV)6j-3Z z0tFT*ut0$Y3M^1yfdUH@SfIcH1r{i8DW#V3aFtpT8fqD;kPZrzhsrE_DrGV^Gh1sjm3*j75olp%Z-zgH z8x*bxG!|Io*KhVmNwhx~iKOp9=qJ3U{eS33dujEj_x86Oda4z{=wK8N3t!|FWHmqNA*SZL-j%SCwr59 z$)03CvKQHh>_O$H@>2PzJj`BF$c*;DqW!8`0BeA?0QuBzH z$=}Jp$)Cx8$zLl0ra)z&3Q!fO22=-X0Cyng25=L&1*`(b0n>qrfH&X;bO$;CjR9)I z)MhOJF<=Q;0oH&mUXbrRh+5+u> z_CN<97#I&!2b`h*Ea)={=mAh0r#4M(_zsW(bj5vMKoZU)fQ7&=U^TD-m;#If>H`yT z-)zX4O!9Ev6Yv3g0~7-&&Ta&46EGj}1Ka=$z!IRCOEH#WDusrZNV5U%11Eu0U^%b? zumy-FL{D7b1z$R_0N4tw0;U2FfcwBzKmuJ7x!Ci8Djh$4F^K$_^xK9bwfoILJWvmy zGK~bN9Z-9qc0uiy^dY;F54Ho^0@OBZ166@?0L8JwfCzwEOn*gjj^bKTz!)eClmtou ze5@;t^HN+o9r^PzIIj#;0xAL(fbxI|UHt*E`T*Ty1yG$e0=S_-wgdTi6M*Ve4A=wK0AFWzIH$Hq?cWn<0eArJ0Qp06z!hi) zQ2tGURzORj4bYl9r)w%>CxF^_2cSLB9q0mh0el=7jPnRU0eAx;KrbKwkOKaI1Q-bT z0)2r#fDh0cpnC@Zen3B|lW{%?m|$)|cEf1D2d12$pYAfMR)YyzmfTL7}_ zcHkF4Z6pC`0PFy$Y;}No0MV@g3t%VE2>1wO0^fiSz6DK!57O;#fKM0lzCijD;4$zBcnCZI=-&IlJ>V{I z0=NQX0C#}fz%Aega2>b`kp33|%KI8X`J4wxFDlPD;4F{=oCb~qM}cI3Y;l-79^sCZ zc8oix{K~REB@zk5mrwAz^#r+qSlLo%7!O+{fp`+w>#y%$dg9;~QucR~`d=d;Ns- zf3?m2scnewGeElYKKTXb)P^aI+7G2u`$z`J5559CjjnlL5Y~InGXBJU|LJmmN1mkL z4=!)26VjW~egib#r2$<-npEJowI_QtcJ;M&>>ufjl@`aWIj>Fjm6r0jzXZD6*;ak0 z*WcaTrI?|gt+l12rFGVB8@K$dyC*c+Hyso^OKVFzb{y9bJeebVxX&$N^Vx`{IADaM z2vsOm7-h|BSGnOJSv?~|8%ul0V#iXYz|(3)y}FCGR9(eV96^yX92hlLJ$Siz>#>HA zW^3te326Z`|HvTRF=MQO{k^JZPJm)>X=iB*ib5?5AA*t1j(&4S4ZHjr6dOm7Yz$=4 z*d;`zG7xt-(R#3@qNb7INP9~gXE1f=N*Zu@gQr>AyLRAlhH^IS7^@O=OY$|ZekO4X z%42J3%V>?F(5N}HYyj^8B?h_EFXhDkPn|uswLm@& ztPOnN=wzMsabaEWtaNNl(rhiAaBp8DW=<1Z=4$=wqh2gXVt7y@ss%=vLA?2@gT@zP z%CbBl82Bp`q5cw;%vzFuePsWAD>)m0Bvq-z_~T0I#G78du6*6&!Ic!0vs^yEuUPdk zdb-Gs(~a@but>E$RMq9t!%DO3?7j^OwI9^N74T3=tyLF~_4wue6%?vdP_%0>xe&Ym z&cxHbf0&!QzUO$L!2*ub-Y@9s*wvyl3_JtNaU@`Z5%zq~=^c2W;-&E9+Uz9Tf6j)PhVV4wNfZ>asIqCQj@BDwy$JGPOY% zHE#UY>^gRN_tlw*2VBE4*jR+kr}j-NzXpD_pHQoZU*TT4N*olb@HdDW7x>t+Yor5H zQl{qG6=mzRmeGAbuh*}kHB;Mg->IOKf!2NgeqDIUomUM(K?5Quy$4EZP7aH|jCH6pGf?5t166pU>RhF& zQ9Ymm?B+xotOSn9zXy-B`So2^$kZ86;Es%ajV0 zNoeJAOSVLPG%-BR-J5}974Xb|9T;)!zG$L};bo-Y|5#PHBq9QK>tsBw`~2Oz_d=Qz zS_umD1!U6tb)Ok~c0Jt{SC)Lo#@X4@x)0o)d`Gc+P5Qb{_dh~wt`FV{3dQ)u*`bHM z0-tpNh4XlM1X&L&F45w?Yuv=-2b^PDfPr}2?sjR?asRi8pm6Q92)gESpscFB9X%;k+03XB&qY88#;2_ywVfkVWesDQtM= z$vyNXJS8NGZNk;ouY9unvy%y<0b|i^FdEry_xtT{{yI?U9Vj$%fCfoOE(=O<;qS{G zpG9tBc$iVmd5*HF)YLH(@AX1H;IXC-=nf}sXqCtAcEzjr)$qImg?wkl#r+W%ONY1~nH??y8n)uO?qv({TJ)hi>oR6^b@L-uzu2z*>{M@v1MtTq%dtqt=JD8JVVw78^ zEID+RC20UIQ1A#*9jTJ3?o~VTVca0Yw=54-GiU)G^6T5vPRuS*vZz>7o=`=AM1?Sx ze1Ayur7m648C%<++1sjPB4lE@%0M;XM~zuuCefIJ1|l}L2J0%a_2b`X{pjtFXF5~l zV_e5z2Po7QtPJNa@Jo?k{5F!j*VdldK~{`7di403w?9v;4JQp87`4k_P(`&ZE&i=d zz&@wY}CkN1Iq3!%S7M@R$K1~C$45c&Mf7x&|x zVqbqJttrUc7+_!HNI58#8;p~rudwX~3Po5LE(p>{>(h?ES6T+hsL$d|8YmAB5JyHB zlzuEPU3IsY3KWPUp23`M+vJ-rpRJHo2^6Y27&{h}ayUG^?R8TAs)ip!Vfvrhpuj+} zmG5o%`|;nKYH>V}whk1kpOSt{%pP0#2q+00LRGQ-1^^bE(-JSa6__{0#5qWv)Pr=HuiGTS>(EIg;-&1N@m zg2HuL+O5+DL5FJYWk6r=+pfv2z=McP*6{)bS;cNv*z5{@EnNl*7x{ugAzRm|bI&}v zc%6NqaQ)m?j?&j~$;~#Om(%!wG=N3#R%7$=@k;3;DZA?=M?pR>I7-{$6;}HD-CGX| zMI=xn#CVi0QOmBHyA~ZEGjRe#VQLN(d6-;n@UeflX+KqG8iT@3W+KFD>b4C=_iAFg zZg>AchQdhGUJF{v0}WcC!=f>)ciPLkLltHFK$;`#q!U3QyLGWTvZ~6NqZE}nt@F%> z(OP>xsgQ>5B-Vd$-R2%8%Ahs!o>;3UYwL@><5SyzSpJ9^^Dz?!ZLQwcMp=v1TDhcl zP0y#}m|oM48Q07+VgT_@XTltmdQGR=tgYW18xgVO>e)2LLm2DC;9U^8N^S6N!S*Xvhu3C%XNIDE z6d;jGWf5wXL3(o0R!_br{>9X`ouxBoyLDI(8P~N~xa-(O=$&bJP5r`NP{vBNA!EeQgv!GWME(iFpeX!YP>2J(f?+46+= zTJ=4YCPGZ)`_n+Ilh6qNdvf`S#|J8wvgJWGd8|3`x|M@8ivC@04EYuwk%W9mHL^7p zHi8uaDuag?%gx&AIN%kd0fpW?W^K0Y0L6gWSvFD`sx15W-gjU7&0=`$Xu?nj`Oqk! z#E_s-mot7fA>EiMlYuoT6k)@Ttsd!q=Ni^2Mv`()=(DI}#LqN(Y+%^Z_gFtbcg{qY zrko9mJgt4UvvJ8COg_x$Huu=YfgSeZ?<0gWvrxx#Pn)ja`t&EG0pdTN406)qhFcVV zbo4W3ZSXe4@;@890GUdyRKys}2%mlaSj+>ALwHa86Rov-{Ga$W{}%uP%_m%aRc5pi z>BdYO^B9xUkPj}Z!>_gboAxoR_H~MZ_1P9&s?wLfZ>HTHfjG)Vq|KmEq;U6pH{@H( z<(Stq6nNrpP^i~@xvlw1lil9zngbIvIivxM5C9(1phoqdqh`lHp}8g(5W+wq?=8Ay_F40> zk7-Samr8mzmM$#iHnj;y9*lGaZBWSmht1mQf9DIOTAx^Zv(SMuu2E zpwO$MH`8td?Xx!REqFfiqT1H1Qu95=?IzspW1HLlfS#^qR6kgBK!2*;ZrUTcQW7Qh zR|LdF+xHt8ySLsL%}SQ`IGWp{?kM%cutfj%>{>e%s@@LlH!v4WZBTXd82Dw*C_^A9RD~;OA z^C#M6(K$ug{=@X{opPy(6{@uyr0LIrmPdq5pGc78XwmI$5k_-(ck&_k>^kB>SwVMRbKCXd+ ziFItbMV%|xQ7pj-pP3z^EN?-f9`L$S+NR!TFKErogW;oURjAw_KGbIU?f6xu2hg%W zVa9LTDS1j`7KOC)REMKhhXzHb#4pHHzN8+If+A*>TC~O(R(n3qkX9bjoLd?ie)?1g z9>Pe&BtyFfGr&XZd?&^G?o_M8{PWZ|Bo}r_q?p@bO?i zC#{0`dB4IDwwV9$lI`I>4d+O6{uJhYm( z<-THZ`TDQXPWgOFqD4{^oALgE=PpkXo)z#E3sC5Z&C6?L5@*d>kJ%wlX%7lX+iW@U zUAyi@)^UD~S{n!oS*K?}rFS1K>@jv>S}-U>K`94{pO<3c*c)})Xed)To@24iGfsKz zzOSLI1%;yWhYH6Z+#Q#(KtnkJ3KkAyXKbnUZ9tcOBQ=zJppfsF7}s*>XE6)w6ubss zIZBVHYt6PbekImW%ArLP&yBdQZ;qEa9M4hU*IMEmtl0=W6qR3!_V#&K$b?_JMM<@{ z5>N1uhqNd^&~>FnX@PaL`)DtY=SQ!7cF#7hR;#;`rvum+~V` zOZ$O;CZb2DTF5a9fW}%p5fm-{25EVE$RGFU+NEu3#*PA(kKA}}T;1XR8XKGhg=}5Q z?p-6d7C-lBDB3OhCzL<2fxjXOfAa}%n2nA-G_B{!L&R0uoxkCZ^iI`#UT3txw z<29nopTq<0B^}|;Mv5)IVUl341sHiV)Ksvto$b${HOBKUJL*Cwa#qAJ`c8QamFrij^%H} z5N^7gyXHW#wM9+jp*5bp(Bl{FJmGEG8Rfb}^W_tY6@IMAN4vBXNS1{;~T%0re)@e zvA^~;p+D&t{^Y61|K?{O|0(PI_t@Z1eDqH|QTubi|C9!Q($8ss5~=;!;lFp?->wro za=19E`PQjp?1yO9;|rb$;G21@chb1(f8!H@f?o5_R`dQO9%#4qTC^V%c*qgIHQUaL z_tn72n|pyrdldVV6%519?24x2o}H(Z6X*2g^1=K<`;&(}HDK1Bw6Bk9*G>C+;-9Qj zgrYq3T&BF_$f2K~tK6cv!#?r+6Rq=@eQ<9k{K;%84{86xT;@L&$&I?Ou_}05eesoY zOYrQF9}O17ckUL{poh2t|-4|$kU67DZi`UgkKVu#7Ay%5Ky`Nd znRJj2aas3JG5Gt%^d7B^9y{>6RDh8`MFBAG(ZdJ*m>n^|$DfMnO?6&*m`e#~MiWtj z4%3BUX9;SJI!d7_Gb@!d0OQ1V`t=cH!tx6eU<(?}>23(fU!XQCLy!O&`t8E+p8;{3+vq`uNx#UBJbjpzdft7s?EgMlKagBJnL(I2v%cToo(}z-h2d z5}>;+Yi@#E%?0BZwtC7T$cts>bIw-{@L$e=l|Q8t>Gfjz&H+4h$X6{fUraCz*p&zz z6ft!BMwkk>hpF-*{C@g1aC`aK%IkeHEdlTcJ{L8wXLx zBIt0K>SaQ17@Q?cqLv2hP%i5p63E|=2w;s*70LOO5~)m#zEBdbk}GueR5|V>i)H6! z9e}Z$za&%=E|mp{8SSKsaC|q7^9UV!YHosL%>}wQ>fm(OdG_o`vuye2t37bL%z&9Y zLo}d02#_-pWYKEcgGPt`nwwY=90z#eZ^%yS|Nq#;}tjtefO=~F&JwG`15uR z@JA_Va{_R2Br#kq9K!|pz8FI@j4Q?XIM#!whN#e=&*Z^J4sr0UM55^J`yHFmlcB`6eMHD$XZ6S!rRE; z0xl@1zhh-K3nD#e(;ze(e5XY9qzXgu%SQ|r+(tqKmuSc|Akgh8@(6IrBl6X4WYuq8 zu0utqxD8*+)3uNT?M?x0xH%7PaTdjRo#sqO#c6IrPMQlbyFn1i#e78`5K6fZ!SDfY zr)xTp`(_fGO9H}?{vj1@P6Tc`Fy5oj=KRJ);NeeU9sR?vR>{;DDXA5-GNA}mD==ij_z9yNUB(mM2EoG1 ze3gjbF#x>$DWj1-D`!`hxU*@Gyz?<2;dW~SH@|TklefNBh+S>Lf`2hUrp{Ei<+(5T z!NHwDH`)k=*urE1a*3GIm9$j{bEa!3p}7g-G#AhslMQ_?z}(M{)G05Fp4iRY^yn|p zkuue2jMD3m5p2W)BYQ$U0Xh z;J~aLi?f!bnVA>c!RUw)%m#Ik%pAq+-gG24KqkdPJ1y-6$<$&gHh_{TX@yv(L|Bq5 zVP*Uy#maXKpMZ61T7n9dh0_W>nEdIlhhb90b#hVi0CjLwuv{7}4pRg~hRWD1nVEyG zhA6x(OHr0f%-;kTw!3V ze=Z|*ezJ?8Eig5xFZVoK6a?j&{*NSPHClnRtTZOr>htCt(ExICq%ivEi`d*omf+^j zD15@QXi4loiC8gE2Qy1G2sJIeKaKGNMTB%Z=5FR^dUiT1#TPtE1S8VUXvQ^ zpJC7pADE#DW74b~vh*16>sF8K9i5faMxeY?W$DQRX_GUMcz_zT-$e9r(bdRp` zuzX%d%%_-K>umb?xFQI2hevQjbBR(Zi4g~e(lkAsHbFzDroN-c7+IKOFmK;|~FV8X&`i1(`}J!5-N3x(IqrNhtT^JWQqx!f+f^4A?Lr z$67p|aHtjRuqy&H-{=^zBp?9VgyTk52dFVbhKCjswOqFDZBZ3!1bQCc}swvymMQ+jKjU&^llgULr^R>o~feiE~jx zhjnQRi(HwREVGlP9&MRDGr_SF&;dbX%m(1|K0%wG|ztJ4wOg$x8&j3x9{K~`oQFOxCD zsc$X$F z8dL;@YTccD);$oGbq+nSS*o5AWMxC;=d;Y$z%_fPLZ!&o%TSEf`vM@hfph(mQF$M6 zH?yTJ*@LzO#@ZD5q3$S@ZCDA;Y}c@*{y4^M%}>1SCjH>T&is^bxGYG4S4+4znzg*G zp}7enG#4-={YgLF+OvNM5Pa+jy?Ug736=ihAXw-CqtdfwWWsfZ5*3Yz>@#mHOO?XU@x?gpCw(_1(e*Mg0pX`r?L`oDfm+jJnKcQ(4}tYxE16w1^Pq%iqGFgl03YmNvWAs7g-XqRX_2La zfE=m_!W(UQQSjtEl6kul%XVt^1>(GJ!ynI6it*-bxH^kU>u7i>IDmabn>|@`d$y9$ zJHrf{fX?&Z5Tp*y;|9J|d61McJn+gR9ri(=#+5^m7uGeUgZOl&>E~4rL`Jm& zqb#P7{N1cDe0oQ(U-OBK`Ph&8vM14$hAmK*$vJIU76DyoFoqZ)3zS5Ls#Wwh1U?ku zd{4-p{f=A$vRQ?#VRfLhu81NO9*KL=LbCXnn8b`Pvlo&1Y*ZYm#HVW7pYv&B;WOag z4kIbK^TC69b`Hbdm5*$4a3Gf)DVj|3A&oHsV@zIM=0YQbFCDEy%jt_3`py$BLQ{`G zIX+Lp3VuHF(Z!COb&)l?}{p1*GOkVWcs`9iD|z zNZW9P0-C1j=C^nn4!&5Llz(ol9J=u$b0q(Bx!Fz4HE~{goiN&m=t0(tz%AZHRIk7?3a30GUu$0xKGwGJ~9 z8H&*Xua3~C1^HW_5ai4clCzhOPyN#(QbVRU64)LgyX@>VCI`JUS8kk{dtLz1xfB19 z)>)Geh{`(8p9FqN%*G51x7fKAGnIj~ypn|zMjaKaZyuJ1biM=phtqjRNxr&iaX5Rz zKh;;@S(@1)q~@9aF9^?L!kaypJZ|_GvSVl7*>hmi{sp0e*?xA(0>VELpMC8C9}6?9 zMYIlq4`8V0U?*ebwcM>#*E+l~`vNE)1L=Rpz&NfzI&@$hDtq>t6$H!~`I)K?MRGI; z0TVBuCZD_@l-1Tj|4WNPRkN?xXbh)mRC!oWF0--8vEIU0L1aV(#xp80EsU{g*^9uv z7953FBNS2jE_gOBzU0{wQFJ?gCl7sMwd+dd&s_qI3*}RCcKbkR_JsV)V?I)6pP68B z4}!RF*>kM)=~GL~x9+r>M~^8;ZXORHI4ZPd{!F629+vMD@)E^OjI@!MEb{5M+RB2J zW-aBQHE6-Zs6RCp9vW%M0F-}+k#au5(vn-w+x~Ic@_q(Kx#dj}W{}mG1>G7wG}bfe z;(a4G7qZQrD6}R&EX{g}Q#{08R~2#tCgJVKLwKnf;_$L%Loj6HW|#Das5BoLQr!>_?4tjZnXD?}V}+V&;9L|zXcn}Etb2uZ5HJXDv#J6m zSdov;+D=WSl#+iCbiYxRgAc0bNWpso^ooh@@j5C4pH5T7F(-IppN|PlUS7r)27+>z zQ0QekYD4(_Kl7)4^{CD+y&^|$rIx;mXI_ioR# { + mongoose.set('strictQuery', true) + const conn = await mongoose.connect(MONGO_URI) + + console.log( + `MongoDB Connected to ${ + conn.connection.host + } using ${conn.connection.name.toUpperCase()} database`.cyan.underline.bold + ) +} + +export default connectDB diff --git a/controllers/adminGames.js b/controllers/adminGames.js new file mode 100644 index 0000000..8c04545 --- /dev/null +++ b/controllers/adminGames.js @@ -0,0 +1,135 @@ +import Game from '../models/Game.js' +import steamScraper from '../scripts/scraper.js' +import asyncHandler from '../middleware/async.js' +import ErrorResponse from '../utils/errorResponse.js' + +const checkForHexRegExp = new RegExp('^[0-9a-fA-F]{24}$') +const checkForTwelveRegExp = new RegExp('^[0-9a-fA-F]{12}$') +import {decode, isValid} from 'js-base64' + +/** + * games.js + * + * @description :: Server-side logic for managing games. + */ +/** + * gameController.list() + */ +export const list = asyncHandler(async (req, res, next) => { + return res.status(200).json(res.advancedResults) +}) + +/** + * gameController.show() + */ +export const show = asyncHandler(async (req, res, next) => { + const { id } = req.params + const gameId = + id === id.match(checkForTwelveRegExp) || id.match(checkForHexRegExp) + ? '_id' + : 'steamId' + const game = await Game.findOne({ [gameId]: id }).select('-__v') + + return res.status(200).json({ + success: true, + data: game, + }) +}) + +/** + * gameController.create() + */ +export const create = asyncHandler(async (req, res, next) => { + let oldGame + req.body.steamId.length > 0 + ? (oldGame = await Game.findOne({ steamId: req.body.steamId })) + : (oldGame = await Game.findOne({ title: req.body.title })) + + if (oldGame) + return next( + new ErrorResponse(`The game ${oldGame.title} already exists`, 400) + ) + + req.body.createdBy = req.user.id + req.body.lastModifiedBy = req.user.id + req.body.accessedBy[0].user = req.user.id + const data = + req.body.scrape === true ? await steamScraper(req.body) : req.body + + if (req.body.scrape === false) { + if (req.body.shortDesc && isValid(req.body.shortDesc)) req.body.shortDesc = decode(req.body.shortDesc) + if (req.body.reviews && isValid(req.body.reviews)) req.body.reviews = decode(req.body.reviews) + if (req.body.summary && isValid(req.body.summary)) req.body.summary = decode(req.body.summary) + isValid(req.body.systemRequirements?.windows?.minimum) ? req.body.systemRequirements.windows.minimum = decode(req.body.systemRequirements.windows.minimum) : '' + isValid(req.body.systemRequirements?.windows?.recommended) ? req.body.systemRequirements.windows.recommended = decode(req.body.systemRequirements.windows.recommended) : '' + isValid(req.body.systemRequirements?.mac?.minimum) ? req.body.systemRequirements.mac.minimum = decode(req.body.systemRequirements.mac.minimum) : '' + isValid(req.body.systemRequirements?.mac?.recommended) ? req.body.systemRequirements.mac.recommended = decode(req.body.systemRequirements.mac.recommended) : '' + isValid(req.body.systemRequirements?.linux?.minimum) ? req.body.systemRequirements.linux.minimum = decode(req.body.systemRequirements.linux.minimum) : '' + isValid(req.body.systemRequirements?.linux?.recommended) ? req.body.systemRequirements.linux.recommended = decode(req.body.systemRequirements.linux.recommended) : '' + } + + const game = await Game.create(data) + + res.status(200).json({ + success: true, + data: game, + }) +}) + +/** + * gameController.update() + */ +export const update = asyncHandler(async (req, res, next) => { + const { id } = req.params + + const gameId = + id === id.match(checkForTwelveRegExp) || id.match(checkForHexRegExp) + ? '_id' + : 'steamId' + + let game = await Game.findOne({ [gameId]: id }) + + if (!game) + return next( + ErrorResponse(`A game with the id of ${id} does not exist`, 401), + ) + + + req.body.lastModifiedBy = req.user.id + if (req.body.shortDesc && isValid(req.body.shortDesc)) req.body.shortDesc = decode(req.body.shortDesc) + if (req.body.reviews && isValid(req.body.reviews)) req.body.reviews = decode(req.body.reviews) + if (req.body.summary && isValid(req.body.summary)) req.body.summary = decode(req.body.summary) + isValid(req.body.systemRequirements?.windows?.minimum) ? req.body.systemRequirements.windows.minimum = decode(req.body.systemRequirements.windows.minimum) : '' + isValid(req.body.systemRequirements?.windows?.recommended) ? req.body.systemRequirements.windows.recommended = decode(req.body.systemRequirements.windows.recommended) : '' + isValid(req.body.systemRequirements?.mac?.minimum) ? req.body.systemRequirements.mac.minimum = decode(req.body.systemRequirements.mac.minimum) : '' + isValid(req.body.systemRequirements?.mac?.recommended) ? req.body.systemRequirements.mac.recommended = decode(req.body.systemRequirements.mac.recommended) : '' + isValid(req.body.systemRequirements?.linux?.minimum) ? req.body.systemRequirements.linux.minimum = decode(req.body.systemRequirements.linux.minimum) : '' + isValid(req.body.systemRequirements?.linux?.recommended) ? req.body.systemRequirements.linux.recommended = decode(req.body.systemRequirements.linux.recommended) : '' + + const data = + req.body.scrape === true ? await steamScraper(req.body) : req.body + + game = await Game.findOneAndUpdate({ [gameId]: id }, data, { + new: true, + runValidators: true, + }) + + res.status(200).json({ + success: true, + data: game, + }) +}) + +/** + * gameController.remove() + */ +export const remove = asyncHandler(async (req, res, next) => { + const { id } = req.params + const gameId = + id === id.match(checkForTwelveRegExp) || id.match(checkForHexRegExp) + ? '_id' + : 'steamId' + + await Game.findOneAndDelete({ [gameId]: id }) + res.status(200).json({ success: true, data: {} }) +}) diff --git a/controllers/auth.js b/controllers/auth.js new file mode 100644 index 0000000..07cf0d8 --- /dev/null +++ b/controllers/auth.js @@ -0,0 +1,223 @@ +// noinspection SpellCheckingInspection + +import User from '../models/User.js' +import asyncHandler from '../middleware/async.js' +import ErrorResponse from '../utils/errorResponse.js' +import sendEmail from '../utils/sendEmail.js' +import crypto from 'crypto' + +// @desc Register user +// @route POST /api/v1/auth/register +// @access Public +export const register = asyncHandler(async (req, res, next) => { + const { name, displayName, email, password, role } = req.body + + //Create user + const user = await User.create({ + name, + displayName, + email, + password, + role, + gamesCreated: [], + gamesToAccess: [], + }) + + sendTokenResponse(user, 200, res) +}) + +// @desc Login user +// @route POST /api/v1/auth/login +// @access Public +export const login = asyncHandler(async (req, res, next) => { + const { email, password } = req.body + + // Validate email & password + if (!email || !password) + return next(new ErrorResponse('Please provide an email and password', 400)) + + // Check for user + const user = await User.findOne({ email }).select('+password') + + if (!user) return next(new ErrorResponse('Invalid credentials', 401)) + + // Check is password matches + const isMatched = await user.matchPassword(password) + + if (!isMatched) return next(new ErrorResponse('Invalid credentials', 401)) + + sendTokenResponse(user, 200, res) +}) + +// @desc Logout User / Clear Cookie +// @route GET /api/v1/auth/logout +// @access Private +export const logout = asyncHandler(async (req, res, next) => { + res.cookie('token', 'none', { + expires: new Date(Date.now() + 10 * 1000), + httpOnly: true, + }) + + res.status(200).json({ + success: true, + data: {}, + }) +}) + +// @desc Get current logged-in user +// @route POST /api/v1/auth/me +// @access Private +export const getMe = asyncHandler(async (req, res, next) => { + const user = await User.findById(req.user.id) + + res.status(200).json({ + success: true, + data: user, + }) +}) + +// @desc Update user details +// @route PUT /api/v1/auth/updatedetails +// @access Private +export const updateDetails = asyncHandler(async (req, res, next) => { + const fieldsToUpdate = { + name: req.body.name, + displayName: req.body.displayName, + email: req.body.email, + role: req.body.role, + updateDate: Date.now(), + } + const user = await User.findByIdAndUpdate(req.user.id, fieldsToUpdate, { + new: true, + runValidators: true, + }) + + res.status(200).json({ + success: true, + data: user, + }) +}) + +// @desc Update password +// @route PUT /api/v1/auth/updatepassword +// @access Private +export const updatePassword = asyncHandler(async (req, res, next) => { + const user = await User.findById(req.user.id).select('+password') + + // Check current password + if (!(await user.matchPassword(req.body.currentPassword))) + return next(new ErrorResponse('Password is incorrect', 401)) + + user.password = req.body.newPassword + + await user.save() + + sendTokenResponse(user, 200, res) +}) + +// @desc Forgot password +// @route POST /api/v1/auth/forgotpassword +// @access Public +export const forgotPassword = asyncHandler(async (req, res, next) => { + const user = await User.findOne({ email: req.body.email }) + + if (!user) { + console.error('user does not exist') + return res.status(200).json({ + success: true, + data: 'Email has been sent', + }) + } + + // Get reset token + const resetToken = await user.getResetPasswordToken() + + await user.save({ validateBeforeSave: false }) + + // Create reset url + let resetUrl + let message + if (req.get('Referrer') === undefined) { + const protocol = req.secure ? 'https' : 'http' + const host = await req.get('host') + resetUrl = `${protocol}://${host}/api/auth/resetpassword/${resetToken}` + message = { + text: `You are receiving this email because you (or someone else) has requested the reset of a password. Please make a PUT request to: \n\n${resetUrl}`, + html: `

You are receiving this email because you (or someone else) has requested the reset of a password.
Please make a PUT request to:

${resetUrl}

` + } + } else { + resetUrl = `${req.get('Referrer')}resetpassword/${resetToken}` + message = { + text: `You are receiving this email because you (or someone else) has requested the reset of a password. \nPlease visit the below URL to continue this request. \n\n${resetUrl}`, + html: `

You are receiving this email because you (or someone else) has requested the reset of a password.
Please visit the below URL to continue this request.

${resetUrl}

`, + } + } + + try { + await sendEmail({ + email: user.email, + subject: 'Games Database Password Reset Token', + message, + }) + + res.status(200).json({ + success: true, + data: 'Email has been sent', + }) + } catch (err) { + console.log(err) + user.resetPasswordToken = undefined + user.resetPasswordExpire = undefined + + await user.save({ validateBeforeSave: false }) + + return next(new ErrorResponse('Email could not be sent', 500)) + } +}) + +// @desc Reset password +// @route PUT /api/v1/auth/resetpassword/:resettoken +// @access Public +export const resetPassword = asyncHandler(async (req, res, next) => { + // Get hashed token + const resetPasswordToken = crypto + .createHash('sha256') + .update(req.params.resettoken) + .digest('hex') + + const user = await User.findOne({ + resetPasswordToken, + resetPasswordExpire: { $gt: Date.now() }, + }) + + if (!user) return next(new ErrorResponse('Invalid token', 400)) + + // Set new password + user.password = req.body.password + user.resetPasswordToken = undefined + user.resetPasswordExpire = undefined + + await user.save() + + sendTokenResponse(user, 200, res) +}) + +// Get token from model, create cookie and send response +const sendTokenResponse = (user, statusCode, res) => { + // Create token + const token = user.getSignedJwtToken() + + const options = { + expires: new Date( + Date.now() + 24 * 60 * 60 * 1000, + ), + httpOnly: true, + } + + if (Bun.env.NODE_ENV === 'production') options.secure = true + + res.status(statusCode).cookie('token', token, options).json({ + success: true, + token, + }) +} diff --git a/controllers/games.js b/controllers/games.js new file mode 100644 index 0000000..28eda3a --- /dev/null +++ b/controllers/games.js @@ -0,0 +1,238 @@ +// noinspection DuplicatedCode + +import Game from '../models/Game.js' +import steamScraper from '../scripts/scraper.js' +import asyncHandler from '../middleware/async.js' +import ErrorResponse from '../utils/errorResponse.js' +import {decode, isValid} from "js-base64"; + +const checkForHexRegExp = new RegExp('^[0-9a-fA-F]{24}$') +const checkForTwelveRegExp = new RegExp('^[0-9a-fA-F]{12}$') +/** + * games.js + * + * @description :: Server-side logic for managing games. + */ + + +/** + * gameController.list() + */ +export const list = asyncHandler(async (req, res, next) => { + const data = res.advancedResults.data + if (data[0]?.accessedBy) { + for (let i = 0; i < data.length; i++) { + for (let x = 0; x < data[i].accessedBy.length; x++) { + if (data[i].accessedBy[x].user.toString() !== req.user.id) + data[i].accessedBy.splice(x, 1) + } + } + } + return res.status(200).json(res.advancedResults) +}) + +/** + * gameController.show() + */ +export const show = asyncHandler(async (req, res, next) => { + const {id} = req.params + + const gameId = + id === id.match(checkForTwelveRegExp) || id.match(checkForHexRegExp) + ? '_id' + : 'steamId' + + const game = await Game.findOne({[gameId]: id}) + + if (!game) + return next( + new ErrorResponse(`Game not found with id of ${req.params.id}`, 404), + ) + + if ( + !game.accessedBy + .map((x) => x.user.toString() === req.user.id) + .includes(true) + ) + return next( + new ErrorResponse( + `You do not have permission to access Game ID ${req.params.id}`, + 401, + ), + ) + + const userGame = await Game.findOne({[gameId]: id}).select( + '-createdBy -__v', + ) + + for (let i = 0; i < userGame.accessedBy.length; i++) { + if (userGame.accessedBy[i].user.toString() !== req.user.id) + userGame.accessedBy.splice(i, 1) + } + + res.status(200).json({success: true, data: userGame}) +}) + +/** + * gameController.create() + */ +export const create = asyncHandler(async (req, res, next) => { + let oldGame + + if (req.body.shortDesc && isValid(req.body.shortDesc)) req.body.shortDesc = decode(req.body.shortDesc) + if (req.body.reviews && isValid(req.body.reviews)) req.body.reviews = decode(req.body.reviews) + if (req.body.summary && isValid(req.body.summary)) req.body.summary = decode(req.body.summary) + isValid(req.body.systemRequirements?.windows?.minimum) ? req.body.systemRequirements.windows.minimum = decode(req.body.systemRequirements.windows.minimum) : '' + isValid(req.body.systemRequirements?.windows?.recommended) ? req.body.systemRequirements.windows.recommended = decode(req.body.systemRequirements.windows.recommended) : '' + isValid(req.body.systemRequirements?.mac?.minimum) ? req.body.systemRequirements.mac.minimum = decode(req.body.systemRequirements.mac.minimum) : '' + isValid(req.body.systemRequirements?.mac?.recommended) ? req.body.systemRequirements.mac.recommended = decode(req.body.systemRequirements.mac.recommended) : '' + isValid(req.body.systemRequirements?.linux?.minimum) ? req.body.systemRequirements.linux.minimum = decode(req.body.systemRequirements.linux.minimum) : '' + isValid(req.body.systemRequirements?.linux?.recommended) ? req.body.systemRequirements.linux.recommended = decode(req.body.systemRequirements.linux.recommended) : '' + + req.body.steamId.length > 0 + ? (oldGame = await Game.findOne({ + steamId: req.body.steamId, + 'accessedBy.user': req.user.id, + })) + : (oldGame = await Game.findOne({ + title: req.body.title, + 'accessedBy.user': req.user.id, + })) + + if (oldGame) + return next( + new ErrorResponse(`The game ${oldGame.title} already exists in users account.`, 400) + ) + + req.body.steamId.length > 0 + ? (oldGame = await Game.findOne({steamId: req.body.steamId})) + : (oldGame = await Game.findOne({title: req.body.title})) + + if (oldGame) { + oldGame.accessedBy.push({ + user: req.user.id, + store: req.body.accessedBy[0].store, + playStatus: req.body.accessedBy[0].playStatus, + soundtrack: req.body.accessedBy[0].soundtrack, + rating: req.body.accessedBy[0].rating, + }) + + oldGame.lastModifiedBy = req.user.id + + await oldGame.save() + return res.status(200).json({ + success: true, + data: oldGame, + }) + } + + req.body.createdBy = req.user.id + req.body.lastModifiedBy = req.user.id + req.body.accessedBy[0].user = req.user.id + const data = + req.body.scrape === true ? await steamScraper(req.body) : req.body + + const game = await Game.create(data) + + res.status(200).json({ + success: true, + data: game, + }) +}) + +/** + * gameController.update() + */ +export const update = asyncHandler(async (req, res, next) => { + const {id} = req.params + + const gameId = + id === id.match(checkForTwelveRegExp) || id.match(checkForHexRegExp) + ? '_id' + : 'steamId' + + if (req.body.shortDesc && isValid(req.body.shortDesc)) req.body.shortDesc = decode(req.body.shortDesc) + if (req.body.reviews && isValid(req.body.reviews)) req.body.reviews = decode(req.body.reviews) + if (req.body.summary && isValid(req.body.summary)) req.body.summary = decode(req.body.summary) + isValid(req.body.systemRequirements?.windows?.minimum) ? req.body.systemRequirements.windows.minimum = decode(req.body.systemRequirements.windows.minimum) : '' + isValid(req.body.systemRequirements?.windows?.recommended) ? req.body.systemRequirements.windows.recommended = decode(req.body.systemRequirements.windows.recommended) : '' + isValid(req.body.systemRequirements?.mac?.minimum) ? req.body.systemRequirements.mac.minimum = decode(req.body.systemRequirements.mac.minimum) : '' + isValid(req.body.systemRequirements?.mac?.recommended) ? req.body.systemRequirements.mac.recommended = decode(req.body.systemRequirements.mac.recommended) : '' + isValid(req.body.systemRequirements?.linux?.minimum) ? req.body.systemRequirements.linux.minimum = decode(req.body.systemRequirements.linux.minimum) : '' + isValid(req.body.systemRequirements?.linux?.recommended) ? req.body.systemRequirements.linux.recommended = decode(req.body.systemRequirements.linux.recommended) : '' + + let game = await Game.findOne({ + [gameId]: id, + 'accessedBy.user': req.user.id, + }) + + if (!game) + return next( + new ErrorResponse(`A game with the id of ${id} does not exist`, 401), + ) + + game = await Game.findOneAndUpdate( + {[gameId]: id, 'accessedBy.user': req.user.id}, + { + $set: { + series: req.body.series, + intel: req.body.intel, + genre: req.body.genre, + wine: req.body.wine, + lastModifiedBy: req.user.id, + 'accessedBy.$.store': req.body.accessedBy[0].store, + 'accessedBy.$.playStatus': req.body.accessedBy[0].playStatus, + 'accessedBy.$.soundtrack': req.body.accessedBy[0].soundtrack, + 'accessedBy.$.rating': req.body.accessedBy[0].rating, + }, + }, + {new: true, runValidators: true}, + ) + + res.status(200).json({ + success: true, + data: game, + }) +}) + +/** + * gameController.remove() + */ +export const remove = asyncHandler(async (req, res, next) => { + const {id} = req.params + + const gameId = + id === id.match(checkForTwelveRegExp) || id.match(checkForHexRegExp) + ? '_id' + : 'steamId' + + let game = await Game.findOne({ + [gameId]: id, + 'accessedBy.user': req.user.id, + }) + + if (!game) + return next( + new ErrorResponse(`A game with the id of ${id} does not exist`, 401), + ) + + if (game.accessedBy.length > 1) { + await Game.findOneAndUpdate( + {[gameId]: id, 'accessedBy.user': req.user.id}, + { + $pull: { + accessedBy: {user: req.user.id}, + }, + }, + {new: true, runValidators: true}, + ) + } else { + await Game.findOneAndDelete({[gameId]: id}) + } + + + res.status(200).json({ + success: true, + data: {}, + }) +}) diff --git a/controllers/tags.js b/controllers/tags.js new file mode 100644 index 0000000..b2d8993 --- /dev/null +++ b/controllers/tags.js @@ -0,0 +1,64 @@ +import getTag from '../utils/getTag.js' +import asyncHandler from '../middleware/async.js' + +/** + * gameController.tagList() + * Finds all genres used distinctly + */ +export const genreList = asyncHandler(async (req, res, next) => { + const genre = await getTag('genre') + + return res.status(200).json({ + success: true, + genre, + }) +}) + +export const seriesList = asyncHandler(async (req, res, next) => { + const series = await getTag('series') + + return res.status(200).json({ + success: true, + series, + }) +}) + +export const storeList = asyncHandler(async (req, res, next) => { + const store = await getTag('accessedBy.store') + + return res.status(200).json({ + success: true, + store, + }) +}) + +export const developerList = asyncHandler(async (req, res, next) => { + const developer = await getTag('developer') + + return res.status(200).json({ + success: true, + developer, + }) +}) + +export const publisherList = asyncHandler(async (req, res, next) => { + const publisher = await getTag('publisher') + + return res.status(200).json({ + success: true, + publisher, + }) +}) + +export const tags = async (req, res, next) => { + const genre = await getTag('genre') + const series = await getTag('series') + const store = await getTag('accessedBy.store') + const developer = await getTag('developer') + const publisher = await getTag('publisher') + const tags = { genre, series, store, developer, publisher } + return res.status(200).json({ + success: true, + data: tags, + }) +} diff --git a/controllers/users.js b/controllers/users.js new file mode 100644 index 0000000..16d4215 --- /dev/null +++ b/controllers/users.js @@ -0,0 +1,79 @@ +import User from '../models/User.js' +import asyncHandler from '../middleware/async.js' +import ErrorResponse from '../utils/errorResponse.js' + +// @desc Get all users +// @route GET /api/admin/users +// @access Private/Admin +export const getUsers = asyncHandler(async (req, res, next) => { + res.status(200).json({ + success: true, + data: res.advancedResults, + }) +}) + +// @desc Get single user +// @route GET /api/admin/users/:id +// @access Private/Admin +export const getUser = asyncHandler(async (req, res, next) => { + console.log('reached route.') + const user = await User.findById(req.params.id) + + res.status(200).json({ + success: true, + data: user, + }) +}) + +// @desc Create user +// @route POST /api/admin/users +// @access Private/Admin +export const createUser = asyncHandler(async (req, res, next) => { + const user = await User.create(req.body) + + res.status(201).json({ + success: true, + data: user, + }) +}) + +// @desc Update user +// @route PUT /api/admin/users/:id +// @access Private/Admin +export const updateUser = asyncHandler(async (req, res, next) => { + const { password, ...updateFields } = req.body + + let user = await User.findById(req.params.id).select('+password') + + if (!user) + return next(new ErrorResponse('User does not exist in database', 404)) + + await user.updateOne(updateFields, { + runValidators: true, + }) + + if (password) { + user.password = password + await user.save() + user = await User.findById(req.params.id).select('+password') + } else { + user = await User.findById(req.params.id) + } + + res.status(200).json({ + success: true, + data: user, + }) +}) + +// @desc Delete user +// @route DELETE /api/admin/users/:id +// @access Private/Admin +export const deleteUser = asyncHandler(async (req, res, next) => { + await User.findByIdAndDelete(req.params.id) + + res.status(200).json({ + success: true, + data: {}, + }) +}) diff --git a/middleware/advancedResults.js b/middleware/advancedResults.js new file mode 100644 index 0000000..c1a19ae --- /dev/null +++ b/middleware/advancedResults.js @@ -0,0 +1,240 @@ +const advancedResults = (model, populate) => async (req, res, next) => { + if (req.originalUrl.indexOf('admin') === -1) + req.query = { + ...req.query, + $and: [{accessedBy: {$elemMatch: {user: req.user.id}}}], + } + + + if (req.query.search === undefined) req.query.search = '' + if (req.query.search) + req.query = {...req.query, $text: {$search: req.query.search}} + + const filterAndInGame = ['genre', 'os', 'developer', 'publisher', 'series', 'intel', 'wine', 'controller', 'store', 'soundtrack', 'playStatus', 'rating', 'ratinggte', 'ratinglte', 'steamRating', 'steamRatinggte', 'steamRatinglte'] + + filterAndInGame.map((filter) => { + if (req.query[filter] === undefined) req.query[filter] = '' + if (req.query[filter]) { + let newFilter + if (filter === 'os') { + newFilter = req.query[filter].split(',').reduce((prev, curr, index, arr) => { + const key = `${[filter]}.${arr[index]}`.toLowerCase() + prev.push({[key]: true}) + return prev + }, []) + } else if (filter === 'store' || filter === 'soundtrack' || filter === 'playStatus') { + newFilter = req.query[filter].split(',').reduce((prev, curr) => { + prev.push({accessedBy: {$elemMatch: {[filter]: curr}}}) + return prev + }, []) + } else if (filter === 'rating') { + if (Object.hasOwn(req.query[filter], 'gte')) { + newFilter = req.query[filter]['gte'].split(',').reduce((prev, curr) => { + prev.push({accessedBy: {$elemMatch: {rating: {$gte: Number(curr)}}}}) + return prev + }, []) + req.query.rating = `gte${req.query[filter]['gte']}` + } else if (Object.hasOwn(req.query[filter], 'lte')) { + newFilter = req.query[filter]['lte'].split(',').reduce((prev, curr) => { + prev.push({accessedBy: {$elemMatch: {rating: {$lte: Number(curr)}}}}) + return prev + }, []) + req.query.rating = `lte${req.query[filter]['lte']}` + } else { + newFilter = req.query[filter].split(',').reduce((prev, curr) => { + prev.push({accessedBy: {$elemMatch: {rating: Number(curr)}}}) + return prev + }, []) + } + } else if (filter === 'ratinggte') { + newFilter = req.query[filter].split(',').reduce((prev, curr) => { + prev.push({accessedBy: {$elemMatch: {rating: {$gte: Number(curr)}}}}) + return prev + }, []) + req.query.rating = req.query.ratinggte + } else if (filter === 'ratinglte') { + newFilter = req.query[filter].split(',').reduce((prev, curr) => { + prev.push({accessedBy: {$elemMatch: {rating: {$lte: Number(curr)}}}}) + return prev + }, []) + req.query.rating = req.query.ratinglte + } else if (filter === 'steamRating') { + if (Object.hasOwn(req.query[filter], 'gte')) { + newFilter = req.query[filter]['gte'].split(',').reduce((prev, curr) => { + prev.push({steamRating: {$gte: Number(curr)}}) + return prev + }, []) + req.query.steamRating = `gte${req.query[filter]['gte']}` + } else if (Object.hasOwn(req.query[filter], 'lte')) { + newFilter = req.query[filter]['lte'].split(',').reduce((prev, curr) => { + prev.push({steamRating: {$lte: Number(curr)}}) + return prev + }, []) + req.query.steamRating = `lte${req.query[filter]['lte']}` + } else { + newFilter = req.query[filter].split(',').reduce((prev, curr) => { + prev.push({steamRating: Number(curr)}) + return prev + }, []) + } + } else if (filter === 'steamRatinggte') { + newFilter = req.query[filter].split(',').reduce((prev, curr) => { + prev.push({steamRating: {$gte: Number(curr)}}) + return prev + }, []) + req.query.steamRating = req.query.steamRatinggte + } else if (filter === 'steamRatinglte') { + newFilter = req.query[filter].split(',').reduce((prev, curr) => { + prev.push({steamRating: {$lte: Number(curr)}}) + return prev + }, []) + req.query.steamRating = req.query.steamRatinglte + } else { + newFilter = req.query[filter].split(',').reduce((prev, curr) => { + prev.push({[filter]: curr}) + return prev + }, []) + } + newFilter.forEach(element => req.query.$and.push(element)) + } + }) + + let query + +// Copy req.query + const reqQuery = {...req.query} + +// Fields to exclude + const removeFields = [ + 'select', + 'series', + 'developer', + 'publisher', + 'sort', + 'limit', + 'page', + 'search', + 'os', + 'playStatus', + 'store', + 'soundtrack', + 'genre', + 'controller', + 'rating', + 'ratinggte', + 'ratinglte', + 'steamRating', + 'steamRatinggte', + 'steamRatinglte', + ] + +// Loop over removeFields and delete them from reqQuery + removeFields.forEach((param) => delete reqQuery[param]) + + filterAndInGame.forEach((param) => { + if (req.query[param] === '') delete reqQuery[param] + }) + +// Create query String + let queryStr = JSON.stringify(reqQuery) + +// Create operators like gt, gte, etc... + queryStr = queryStr.replace( + /\b(gt|gte|lt|lte|in|all)\b/g, + (match) => `$${match}`, + ) + + queryStr = queryStr.replace(/\$\$/g, '$') + + const total = await model.countDocuments(reqQuery) + +// Finding resource + query = model.find(JSON.parse(queryStr)) + +// Select Fields + if (req.query.select) { + const fields = req.query.select.split(',').join(' ') + query = query.select(fields) + } + +// Sort + if (req.query.sort) { + const sortBy = req.query.sort.split(',').join(' ') + query = query.sort(sortBy) + } else { + query = query.sort('series title') + } + +// Pagination + let page = parseInt(req.query.page, 10) || 1 + const limit = parseInt(req.query.limit, 10) || 10 + +// This block figures out how many total pages there will be and if +// the client is calling a page larger than the total pages + let pages + if (limit === 1) pages = total + else if (total === 0) pages = 1 + else if (total % limit === 0) pages = (total - (total % limit)) / limit + else pages = (total - (total % limit)) / limit + 1 + if (page > pages) page = pages + + const startIndex = (page - 1) * limit + const endIndex = page * limit + + query = query.skip(startIndex).limit(limit) + + if (populate) { + query = query.populate(populate) + } + +// Executing query + const results = await query + +// Pagination result + const pagination = {} + + if (endIndex < total) { + pagination.next = { + page: page + 1, + } + } + + if (startIndex > 0) { + pagination.prev = { + page: page - 1, + } + } + + res.advancedResults = { + success: true, + searchParams: req.query.search, + count: { + gamesPerPage: results.length, + totalGames: total, + currentPage: page, + pages, + limit, + }, + filters: { + series: req.query.series, + playStatus: req.query.playStatus, + genre: req.query.genre, + os: req.query.os, + store: req.query.store, + developer: req.query.developer, + controller: req.query.controller, + publisher: req.query.publisher, + soundtrack: req.query.soundtrack, + intel: req.query.intel, + wine: req.query.wine, + rating: req.query.rating, + steamRating: req.query.steamRating, + }, + pagination, + data: results, + } + + next() +} + +export default advancedResults diff --git a/middleware/async.js b/middleware/async.js new file mode 100644 index 0000000..dbea241 --- /dev/null +++ b/middleware/async.js @@ -0,0 +1,4 @@ +const asyncHandler = fn => (req, res, next) => + Promise.resolve(fn(req, res, next)).catch(next) + +export default asyncHandler diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..b357e97 --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,48 @@ +import jwt from 'jsonwebtoken' +import asyncHandler from './async.js' +import ErrorResponse from '../utils/errorResponse.js' +import User from '../models/User.js' + +// Protect route +export const protect = asyncHandler(async (req, res, next) => { + let token + + // Set token from header + if ( + req.headers.authorization && + req.headers.authorization.startsWith('Bearer') + ) + token = req.headers.authorization.split(' ')[1] + // Set token from cookie + // else if (req.cookies.token) token = req.cookies.token + + // Make sure token exists + if (!token) + return next(new ErrorResponse('Not authorized to access this route', 401)) + + try { + // Verify token + const decoded = jwt.verify(token, Bun.env.ACCESS_TOKEN_SECRET) + + req.user = await User.findById(decoded.id) + + next() + } catch (err) { + return next(new ErrorResponse('Not authorized to access this route', 401)) + } +}) + +// Grants access to specific roles +export const authorize = (...roles) => { + return (req, res, next) => { + if (!roles.includes(req.user.role)) { + return next( + new ErrorResponse( + `User role ${req.user.role} is not authorized to access this route.`, + 403 + ) + ) + } + next() + } +} diff --git a/middleware/error.js b/middleware/error.js new file mode 100644 index 0000000..3696aa5 --- /dev/null +++ b/middleware/error.js @@ -0,0 +1,31 @@ +import ErrorResponse from '../utils/errorResponse.js' + +const errorHandler = (err, req, res, next) => { + let error = { ...err } + error.message = err.message + console.log(err.stack.red) + + //Mongoose bad ObjectId + if (err.name === 'CastError') { + const message = `Resource not found${typeof(err.value) === 'string' ? ` with id of ${err.value}` : ''}` + error = new ErrorResponse(message, 404) + } + + //Mongoose duplicate key + if (err.code === 11000) { + const message = `Duplicate field value entered` + error = new ErrorResponse(message, 400) + } + + //Mongoose validation error + if(err.name === 'ValidationError') { + const message = Object.values(err.errors).map(val => val.message) + error = new ErrorResponse(message, 400) + } + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || 'Server Error', + }) +} + +export default errorHandler diff --git a/models/Game.js b/models/Game.js new file mode 100644 index 0000000..4fcd401 --- /dev/null +++ b/models/Game.js @@ -0,0 +1,104 @@ +import mongoose from 'mongoose' +import slugify from 'slugify' + +const {Schema, model} = mongoose + +const GameSchema = new Schema( + { + title: {type: String, trim: true, required: true}, + series: {type: String, trim: true}, + steamId: { + type: String, + index: {unique: true, partialFilterExpression: {steamId: {$exists: true, $gt: 0, $type: String}}} + }, + slug: String, + frontImage: {type: String, required: true}, + screenshots: Array, + genre: {type: Array, sparse: true}, + os: { + windows: {type: Boolean}, + mac: {type: Boolean}, + linux: {type: Boolean}, + android: {type: Boolean}, + ios: {type: Boolean} + }, + wine: {type: String, enum: ['Not Tested', 'Yes', 'No']}, + controller: { + type: String, + enum: [ + 'No Controller Support', + 'Partial Controller Support', + 'Full Controller Support', + ], + }, + developer: {type: Array, sparse: true}, + publisher: {type: Array, sparse: true}, + releaseDate: {type: String}, + shortDesc: {type: String}, + reviews: String, + summary: String, + intel: {type: String, enum: ['Not Tested', 'Yes', 'No']}, + systemRequirements: { + windows: { + minimum: String, + recommended: String, + }, + mac: { + minimum: String, + recommended: String, + }, + linux: { + minimum: String, + recommended: String, + }, + }, + steamRating: {type: Number, min: 0, max: 10, default: 0}, + createdBy: { + type: Schema.ObjectId, + ref: 'User', + required: true, + }, + accessedBy: [ + { + user: { + type: Schema.ObjectId, + ref: 'User', + required: true, + }, + store: {type: Array, required: true}, + playStatus: { + type: String, + enum: ['Never Played', 'Up Next', 'Playing', 'Finished', 'Will Not Play'], + default: 'Never Played', + }, + soundtrack: {type: String, enum: ['Yes', 'No'], default: 'No'}, + rating: {type: Number, min: 0, max: 10, default: 0} + }, + ], + lastModifiedBy: { + type: Schema.ObjectId, + ref: 'User', + required: true, + }, + }, + {timestamps: {createdAt: 'createDate', updatedAt: 'lastUpdateDate'}} +) + +GameSchema.pre('save', function (next) { + this.slug = slugify(this.title, {lower: true}) + next() +}) + +GameSchema.index({accessedBy: {user: 1}}, {unique: true}) + +GameSchema.index({ + title: 'text', + series: 'text', + steamId: 'text', + genre: 'text', + developer: 'text', + publisher: 'text', + slug: 'text', +}) + +export default model('Game', GameSchema) diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..d76bf9c --- /dev/null +++ b/models/User.js @@ -0,0 +1,95 @@ +import mongoose from 'mongoose' +const { Schema } = mongoose +import gravatar from 'gravatar' +// import crypt from 'argon2' +import jwt from 'jsonwebtoken' +import crypto from 'crypto' + +const UserSchema = new Schema({ + name: { + type: String, + trim: true, + required: [true, 'Please add a name'], + }, + email: { + type: String, + required: [true, 'Please enter a valid email address'], + match: [ + /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/, + 'Please add a valid email', + ], + unique: true, + }, + password: { + type: String, + required: [true, 'Please enter a password with a minimum of 6 characters'], + minlength: 6, + select: false + }, + avatar: { + type: String, + }, + displayName: { + type: String, + required: [true, 'Please add a name to show with your icon'], + unique: true, + }, + createDate: { + type: Date, + default: Date.now, + }, + updateDate: Date, + role: { + type: String, + enum: ['user', 'admin'], + default: 'user' + }, + resetPasswordToken: String, + resetPasswordExpire: Date, +}) + +UserSchema.pre('save', async function (next) { + if(!this.isModified('password')) next() + + this.password = await Bun.password.hash(this.password) +}) + +UserSchema.pre('save', async function(next) { + if(!this.isModified('email')) next() + this.avatar = await gravatar.url(this.email, { + s: '200', + r: 'pg', + d: 'retro', + }) +}) + +// Sign JWT and return +UserSchema.methods.getSignedJwtToken = function () { + return jwt.sign({ id: this._id }, Bun.env.ACCESS_TOKEN_SECRET, { + expiresIn: Bun.env.JWT_EXPIRE, + }) +} + +// Match user entered password to hashed password in database +UserSchema.methods.matchPassword = async function (enteredPassword) { + return await Bun.password.verify(enteredPassword, this.password) +} + +// Generate and hash password token +UserSchema.methods.getResetPasswordToken = async function () { + // Generate token + const resetToken = crypto.randomBytes(20).toString('hex') + + // Hash token and set to resetPasswordToken field + this.resetPasswordToken = crypto + .createHash('sha256') + .update(resetToken) + .digest('hex') + + // Set expire + this.resetPasswordExpire = Date.now() + 10 * 60 * 1000 + + return resetToken +} + +export default mongoose.model('User', UserSchema) diff --git a/package.json b/package.json new file mode 100644 index 0000000..2137c36 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "games-database-api", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "bun run --hot ./bin/www.js", + "startProd": "bun ./bin/www.js" + }, + "type": "module", + "dependencies": { + "@imranbarbhuiya/mongoose-fuzzy-searching": "^3.0.5", + "colors": "1.4.0", + "cookie-parser": "1.4.6", + "cors": "^2.8.5", + "debug": "^4.3.2", + "dotenv": "^16.3.1", + "express": "~4.18.2", + "express-jwt": "^8.4.1", + "express-mongo-sanitize": "2.2.0", + "express-rate-limit": "5.5.1", + "express-validator": "^7.0.1", + "gravatar": "^1.8.2", + "helmet": "^7.0.0", + "hpp": "0.2.3", + "http-errors": "^2.0.0", + "js-base64": "^3.7.5", + "jsonwebtoken": "^8.5.1", + "mongoose": "^7.5.1", + "mongoose-partial-search": "^1.0.6", + "morgan": "1.10.0", + "node-html-parser": "^6.1.9", + "nodemailer": "6.9.5", + "slugify": "1.6.6", + "xss-clean": "0.1.4", + "yn": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^20.6.0", + "prettier": "^3.0.3" + } +} diff --git a/routes/adminGames.js b/routes/adminGames.js new file mode 100644 index 0000000..f5dca83 --- /dev/null +++ b/routes/adminGames.js @@ -0,0 +1,20 @@ +import express from 'express' +const router = express.Router() +import { list, show, create, update, remove } from '../controllers/adminGames.js' +import { protect, authorize } from '../middleware/auth.js' +import advancedResults from '../middleware/advancedResults.js' +import Game from '../models/Game.js' + +router.use(protect) +router.use(authorize('admin')) + +router.route('/') + .get(advancedResults(Game), list) + .post(create) + +router.route('/:id') + .get(show) + .put(update) + .delete(remove) + +export default router diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..a2e2866 --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,17 @@ +// noinspection SpellCheckingInspection + +import express from 'express' +const router = express.Router() +import { register, login, getMe, forgotPassword, resetPassword, updateDetails, updatePassword, logout } from '../controllers/auth.js' +import { protect } from '../middleware/auth.js' + +router.post('/register', register) +router.post('/login', login) +router.get('/logout', logout) +router.get('/me', protect, getMe) +router.put('/updatedetails', protect, updateDetails) +router.post('/forgotpassword', forgotPassword) +router.put('/resetpassword/:resettoken', resetPassword) +router.put('/updatepassword', protect, updatePassword) + +export default router diff --git a/routes/games.js b/routes/games.js new file mode 100644 index 0000000..c43618d --- /dev/null +++ b/routes/games.js @@ -0,0 +1,20 @@ +import express from 'express' +const router = express.Router() +import { list, show, create, update, remove } from '../controllers/games.js' +import { protect, authorize } from '../middleware/auth.js' +import advancedResults from '../middleware/advancedResults.js' +import Game from '../models/Game.js' + +router.use(protect) +router.use(authorize('user', 'admin')) + +router.route('/') + .get(advancedResults(Game), list) + .post(create) + +router.route('/:id') + .get(show) + .put(update) + .delete(remove) + +export default router diff --git a/routes/tags.js b/routes/tags.js new file mode 100644 index 0000000..456e6b1 --- /dev/null +++ b/routes/tags.js @@ -0,0 +1,35 @@ +import express from 'express' +const router = express.Router() +import { genreList, seriesList, storeList, developerList, publisherList, tags } from '../controllers/tags.js' + +/* + * GET + */ +router.get('/genres', genreList) + +/* + * GET + */ +router.get('/series', seriesList) + +/* + * GET + */ +router.get('/store', storeList) + +/* + * GET + */ +router.get('/developer', developerList) + +/* + * GET + */ +router.get('/publisher', publisherList) + +/* + * GET + */ +router.get('/', tags) + +export default router \ No newline at end of file diff --git a/routes/users.js b/routes/users.js new file mode 100644 index 0000000..8248c48 --- /dev/null +++ b/routes/users.js @@ -0,0 +1,28 @@ +import express from 'express' +const router = express.Router({ mergeParams: true }) +import User from '../models/User.js' +import { + getUsers, + getUser, + createUser, + updateUser, + deleteUser, +} from '../controllers/users.js' +import advancedResults from '../middleware/advancedResults.js' +import { protect, authorize } from '../middleware/auth.js' + +router.use(protect) +router.use(authorize('admin')) + +router + .route('/') + .get(advancedResults(User), getUsers) + .post(createUser) + +router + .route('/:id') + .get(getUser) + .put(updateUser) + .delete(deleteUser) + +export default router diff --git a/scripts/adminUser.js b/scripts/adminUser.js new file mode 100644 index 0000000..03499ee --- /dev/null +++ b/scripts/adminUser.js @@ -0,0 +1,49 @@ +// noinspection SpellCheckingInspection + +import UserModel from '../models/User.js' +// import Bun.password from 'argon2' +import gravatar from 'gravatar' + +const createAdmin = async () => { + try { + const hash = await Bun.password.hash('game$databaseadmin1') + + const oldUser = await UserModel.findOne({ + email: 'admin@gamesdatabase.com', + }) + + if (oldUser) { + return + } + + const avatar = gravatar.url('admin@gamesdatabase.com', { + s: '200', + r: 'pg', + d: 'retro', + }) + + const user = new UserModel({ + name: 'Admin', + email: 'admin@gamesdatabase.com', + password: hash, + displayName: 'Administrator', + avatar, + role: 'admin', + gamesCreated: [], + gamesToAccess: [], + }) + + await user.save((err) => { + console.log('Admin user successfully created') + if (err) { + return console.error('Could not save admin user') + } + }) + } catch (err) { + return console.error( + 'Server Error: Could not create admin user' + err.message + ) + } +} + +export default createAdmin diff --git a/scripts/scraper.js b/scripts/scraper.js new file mode 100644 index 0000000..05a337d --- /dev/null +++ b/scripts/scraper.js @@ -0,0 +1,175 @@ +import {parse} from "node-html-parser"; + +const steamScraper = async (resData) => { + const {_id, steamId, series, wine, intel} = resData + const {playStatus, soundtrack} = resData.accessedBy[0] + if (steamId === null || steamId === '') { + return 'Please enter a valid Steam Id' + } + const steamApiUrl = `https://store.steampowered.com/api/appdetails/?appids=${steamId}` + const steamRatingApiUrl = `https://store.steampowered.com/appreviews/${steamId}?json=1` + const steamWebUrl = `https://store.steampowered.com/app/${steamId}` + try { + const apiResponse = await fetch(steamApiUrl, { + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US', + Connection: 'keep-alive', + Host: + 'store.steampowered.com', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0', + }, + }), + steamRatingApiResponse = await fetch(steamRatingApiUrl, { + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US', + Connection: 'keep-alive', + Cookie: 'birthtime=470682001;path=/;domain=store.steampowered.com', + Host: + 'store.steampowered.com', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0', + }, + }), + webResponse = await fetch(steamWebUrl, { + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US', + Connection: 'keep-alive', + Cookie: 'birthtime=470682001;path=/;domain=store.steampowered.com', + Host: + 'store.steampowered.com', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0', + }, + }), + apiAnswer = await apiResponse.json(), + steamRatingApiAnswer = await steamRatingApiResponse.json(), + webAnswer = await webResponse.text(), + success = apiAnswer[steamId]['success'] + if (success === true) { + const data = apiAnswer[steamId]['data'] + const steamRating = steamRatingApiAnswer['success'] === 1 ? + steamRatingApiAnswer['query_summary']['review_score'] : + 0 + const webData = parse(webAnswer) + + const screenshots = data.screenshots?.map((screenshot) => ({ + id: screenshot.id, + full: screenshot.path_full, + thumbnail: screenshot.path_thumbnail, + })) || [], + steamGenres = data.genres?.map((genre) => genre.description) || [], + tags = webData.querySelectorAll('.app_tag').map(x => { + let tag = x.rawText.trim() !== '+' && x.rawText.trim() + if (typeof tag == 'string' && tag.includes("&")) tag = tag.replace("&", "&") + return tag + }), + manualGenres = resData.genre || [], + genre = [...new Set([...steamGenres, ...manualGenres, ...tags])].filter(x => x !== "" & x !== false), + filterController = data.categories + ? data.categories.filter((x) => { + if (x.id === 28 || x.id === 18) { + return x + } + }) + : [], + controller = + filterController.length === 1 + ? filterController[0].description + .split(' ') + .map(word => word[0].toUpperCase() + word.substring(1).toLowerCase()) + .join(' ') + : 'No Controller Support', + store = resData.accessedBy[0].store.length > 0 ? resData.accessedBy[0].store : ['Steam'], + createdBy = resData.createdBy || '', + lastModifiedBy = resData.lastModifiedBy || '', + user = resData.accessedBy[0].user || '', + reviews = resData.reviews || '', + rating = resData.rating || 0, + json = { + _id, + steamId, + title: data.name, + series, + frontImage: data.header_image, + screenshots, + genre, + os: data.platforms, + wine, + controller, + developer: data.developers, + publisher: data.publishers, + releaseDate: data.release_date.date, + shortDesc: data.short_description, + reviews, + summary: data.about_the_game, + steamRating, + intel, + systemRequirements: { + windows: { + minimum: '', + recommended: '', + }, + mac: { + minimum: '', + recommended: '', + }, + linux: { + minimum: '', + recommended: '', + }, + }, + createdBy, + lastModifiedBy, + accessedBy: [ + { + user, + store, + playStatus, + soundtrack, + }, + ], + rating, + } + + if (data.platforms.windows) { + json.systemRequirements.windows.minimum = + data.pc_requirements.minimum || '' + json.systemRequirements.windows.recommended = + data.pc_requirements.recommended || '' + } + if (data.platforms.mac) { + json.systemRequirements.mac.minimum = + data.mac_requirements.minimum || '' + json.systemRequirements.mac.recommended = + data.mac_requirements.recommended || '' + } + if (data.platforms.linux) { + json.systemRequirements.linux.minimum = + data.linux_requirements.minimum || '' + json.systemRequirements.linux.recommended = + data.linux_requirements.recommended || '' + } + return json + } else { + return success + } + } catch (error) { + console.error(error) + return error + } +} + +export default steamScraper diff --git a/seeder.js b/seeder.js new file mode 100644 index 0000000..b2cd57d --- /dev/null +++ b/seeder.js @@ -0,0 +1,53 @@ +import fs from 'fs' +import mongoose from 'mongoose' +import 'colors' +import dotenv from 'dotenv' + +dotenv.config() + +import Game from './models/Game.js' +import User from './models/User.js' + +mongoose.connect(Bun.env.MONGO_URI) + +const games = JSON.parse( + fs.readFileSync(`${__dirname}/_data/games.json`, 'utf-8') +) + +const users = JSON.parse( + fs.readFileSync(`${__dirname}/_data/users.json`, 'utf-8') +) + +const importData = async () => { + try { + await Game.create(games) + await User.create(users) + console.log('Data Imported...'.green.inverse) + process.exit() + } catch (err) { + console.error(err) + } +} + +const deleteData = async () => { + try { + await Game.deleteMany() + await User.deleteMany() + console.log('Data Destroyed...'.red.inverse) + process.exit() + } catch (err) { + console.error(err) + } +} + +if (process.argv[2] === '-i') { + importData() +} else if (process.argv[2] === '-d') { + deleteData() +} else { + console.log( + 'Please use the "-i" flag for importing or the "-d" flag for deleting'.red + .bold + ) + process.exit() +} diff --git a/utils/errorResponse.js b/utils/errorResponse.js new file mode 100644 index 0000000..70acd02 --- /dev/null +++ b/utils/errorResponse.js @@ -0,0 +1,8 @@ +class ErrorResponse extends Error { + constructor(message, statusCode) { + super(message) + this.statusCode = statusCode + } +} + +export default ErrorResponse \ No newline at end of file diff --git a/utils/getTag.js b/utils/getTag.js new file mode 100644 index 0000000..6ef4980 --- /dev/null +++ b/utils/getTag.js @@ -0,0 +1,12 @@ +import Game from '../models/Game.js' + +const getTag = async (tag) => { + const fullTags = await Game.find().distinct(tag) + let tags = [] + for (let i = 0; i < fullTags.length; i++) { + if (fullTags[i] !== '') tags.push(fullTags[i]) + } + return tags +} + +export default getTag \ No newline at end of file diff --git a/utils/sendEmail.js b/utils/sendEmail.js new file mode 100644 index 0000000..4d3a0c1 --- /dev/null +++ b/utils/sendEmail.js @@ -0,0 +1,30 @@ +import nodemailer from 'nodemailer' +import yn from 'yn' + +const sendEmail = async options => { + + const transporter = nodemailer.createTransport({ + host: Bun.env.SMTP_HOST, + port: Bun.env.SMTP_PORT, + secure: yn(Bun.env.SECURE), // true for 465, false for other ports + auth: { + user: Bun.env.SMTP_USER, // generated ethereal user + pass: Bun.env.SMTP_PASSWORD, // generated ethereal password + }, + }) + + // send mail with defined transport object + const message = { + from: `${Bun.env.FROM_NAME} <${Bun.env.FROM_EMAIL}>`, // sender address + to: options.email, // list of receivers + subject: options.subject, // Subject line + html: options.message.html, // HTML body + text: options.message.text, // plain text body + } + + const info = await transporter.sendMail(message).catch((err) => console.error(err)) + + console.log('Message sent: %s', info.messageId) +} + +export default sendEmail