From fff7a4b9b9d09b0087cbb51b5cf117d3627cddc5 Mon Sep 17 00:00:00 2001 From: deepseek Date: Sun, 23 Aug 2026 07:06:02 +0000 Subject: [PATCH] GG.deals clone: CodeIgniter 4 + MongoDB game price tracker - Full gg.deals-style UI: deal feed with filters, game pages with price history charts, rankings, vouchers, news, prepaids, subscriptions, bundles, franchises, wishlist, API docs - Real data crawlers: * gg:crawl (Steam Store API + GOG Catalog + Epic free games) * gg:catalog (252K game slugs from gg.deals sitemaps) * gg:ggdeals (per-game prices via FlareSolverr session, JSON-LD parsing) * gg:seed / gg:crawl-covers (demo seed + Steam CDN cover art) - 24/7 self-updating scheduler (scripts/scheduler_loop.php, lockfile-protected) - Tailwind v4 dark/light theme, Alpine.js, Chart.js --- .gitignore | 138 + LICENSE | 22 + README.md | 107 + app/.htaccess | 6 + app/Commands/CrawlCatalog.php | 126 + app/Commands/CrawlCovers.php | 184 ++ app/Commands/CrawlGames.php | 661 +++++ app/Commands/CrawlGgdeals.php | 329 +++ app/Commands/SeedDatabase.php | 677 +++++ app/Common.php | 15 + app/Config/App.php | 232 ++ app/Config/Autoload.php | 92 + app/Config/Boot/development.php | 34 + app/Config/Boot/production.php | 25 + app/Config/Boot/testing.php | 38 + app/Config/CURLRequest.php | 36 + app/Config/Cache.php | 198 ++ app/Config/Constants.php | 79 + app/Config/ContentSecurityPolicy.php | 216 ++ app/Config/Cookie.php | 107 + app/Config/Cors.php | 105 + app/Config/Database.php | 204 ++ app/Config/DocTypes.php | 43 + app/Config/Email.php | 126 + app/Config/Encryption.php | 109 + app/Config/Events.php | 57 + app/Config/Exceptions.php | 106 + app/Config/Feature.php | 37 + app/Config/Filters.php | 110 + app/Config/ForeignCharacters.php | 12 + app/Config/Format.php | 73 + app/Config/Generators.php | 44 + app/Config/Honeypot.php | 42 + app/Config/Hostnames.php | 40 + app/Config/Images.php | 33 + app/Config/Kint.php | 63 + app/Config/Logger.php | 151 ++ app/Config/Migrations.php | 65 + app/Config/Mimes.php | 534 ++++ app/Config/Modules.php | 82 + app/Config/Optimize.php | 32 + app/Config/Pager.php | 37 + app/Config/Paths.php | 90 + app/Config/Publisher.php | 28 + app/Config/Routes.php | 33 + app/Config/Routing.php | 149 ++ app/Config/Security.php | 86 + app/Config/Services.php | 32 + app/Config/Session.php | 128 + app/Config/Toolbar.php | 147 ++ app/Config/UserAgents.php | 262 ++ app/Config/Validation.php | 44 + app/Config/View.php | 73 + app/Config/WorkerMode.php | 62 + app/Controllers/Api.php | 90 + app/Controllers/BaseController.php | 45 + app/Controllers/Bundle.php | 38 + app/Controllers/Franchise.php | 49 + app/Controllers/Game.php | 44 + app/Controllers/Games.php | 75 + app/Controllers/Home.php | 136 + app/Controllers/News.php | 67 + app/Controllers/Pages.php | 43 + app/Controllers/Prepaids.php | 23 + app/Controllers/Rankings.php | 26 + app/Controllers/Stores.php | 23 + app/Controllers/Subscriptions.php | 26 + app/Controllers/Vouchers.php | 39 + app/Controllers/Wishlist.php | 40 + app/Database/Migrations/.gitkeep | 0 app/Database/Seeds/.gitkeep | 0 app/Filters/.gitkeep | 0 app/Helpers/.gitkeep | 0 app/Helpers/gg_helper.php | 110 + app/Language/.gitkeep | 0 app/Language/en/Validation.php | 4 + app/Libraries/.gitkeep | 0 app/Libraries/Mongo.php | 61 + app/Models/.gitkeep | 0 app/Models/GameModel.php | 587 +++++ app/Models/StoreModel.php | 33 + app/ThirdParty/.gitkeep | 0 app/Views/api.php | 102 + app/Views/bundle.php | 66 + app/Views/errors/cli/error_404.php | 7 + app/Views/errors/cli/error_exception.php | 65 + app/Views/errors/cli/production.php | 5 + app/Views/errors/html/debug.css | 194 ++ app/Views/errors/html/debug.js | 116 + app/Views/errors/html/error_400.php | 84 + app/Views/errors/html/error_404.php | 84 + app/Views/errors/html/error_exception.php | 429 ++++ app/Views/errors/html/production.php | 25 + app/Views/faq.php | 45 + app/Views/franchise.php | 40 + app/Views/franchises.php | 18 + app/Views/game.php | 601 +++++ app/Views/games.php | 277 ++ app/Views/home.php | 332 +++ app/Views/homepage.php | 132 + app/Views/layout.php | 297 +++ app/Views/news.php | 38 + app/Views/news_article.php | 71 + app/Views/partials/dash_deal.php | 30 + app/Views/partials/deal_row.php | 90 + app/Views/partials/store_badge.php | 18 + app/Views/prepaids.php | 33 + app/Views/rankings.php | 52 + app/Views/stores.php | 40 + app/Views/subscriptions.php | 34 + app/Views/tutorial.php | 82 + app/Views/vouchers.php | 58 + app/Views/welcome_message.php | 331 +++ app/Views/wishlist.php | 71 + app/index.html | 11 + assets/css/app.css | 288 +++ builds | 128 + composer.json | 44 + composer.lock | 2254 +++++++++++++++++ env | 69 + package.json | 13 + phpunit.dist.xml | 64 + preload.php | 112 + public/.htaccess | 49 + public/favicon.ico | Bin 0 -> 5430 bytes public/favicon.svg | 4 + public/index.php | 59 + public/robots.txt | 2 + scripts/scheduler_loop.php | 91 + spark | 87 + tests/.htaccess | 6 + tests/README.md | 118 + .../2020-02-22-222222_example_migration.php | 37 + .../_support/Database/Seeds/ExampleSeeder.php | 41 + tests/_support/Libraries/ConfigReader.php | 19 + tests/_support/Models/ExampleModel.php | 24 + tests/database/ExampleDatabaseTest.php | 46 + tests/index.html | 11 + tests/session/ExampleSessionTest.php | 17 + tests/unit/HealthTest.php | 49 + writable/.htaccess | 6 + writable/debugbar/index.html | 11 + writable/index.html | 11 + writable/logs/index.html | 11 + writable/session/index.html | 11 + writable/uploads/index.html | 11 + 146 files changed, 15691 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/.htaccess create mode 100644 app/Commands/CrawlCatalog.php create mode 100644 app/Commands/CrawlCovers.php create mode 100644 app/Commands/CrawlGames.php create mode 100644 app/Commands/CrawlGgdeals.php create mode 100644 app/Commands/SeedDatabase.php create mode 100644 app/Common.php create mode 100644 app/Config/App.php create mode 100644 app/Config/Autoload.php create mode 100644 app/Config/Boot/development.php create mode 100644 app/Config/Boot/production.php create mode 100644 app/Config/Boot/testing.php create mode 100644 app/Config/CURLRequest.php create mode 100644 app/Config/Cache.php create mode 100644 app/Config/Constants.php create mode 100644 app/Config/ContentSecurityPolicy.php create mode 100644 app/Config/Cookie.php create mode 100644 app/Config/Cors.php create mode 100644 app/Config/Database.php create mode 100644 app/Config/DocTypes.php create mode 100644 app/Config/Email.php create mode 100644 app/Config/Encryption.php create mode 100644 app/Config/Events.php create mode 100644 app/Config/Exceptions.php create mode 100644 app/Config/Feature.php create mode 100644 app/Config/Filters.php create mode 100644 app/Config/ForeignCharacters.php create mode 100644 app/Config/Format.php create mode 100644 app/Config/Generators.php create mode 100644 app/Config/Honeypot.php create mode 100644 app/Config/Hostnames.php create mode 100644 app/Config/Images.php create mode 100644 app/Config/Kint.php create mode 100644 app/Config/Logger.php create mode 100644 app/Config/Migrations.php create mode 100644 app/Config/Mimes.php create mode 100644 app/Config/Modules.php create mode 100644 app/Config/Optimize.php create mode 100644 app/Config/Pager.php create mode 100644 app/Config/Paths.php create mode 100644 app/Config/Publisher.php create mode 100644 app/Config/Routes.php create mode 100644 app/Config/Routing.php create mode 100644 app/Config/Security.php create mode 100644 app/Config/Services.php create mode 100644 app/Config/Session.php create mode 100644 app/Config/Toolbar.php create mode 100644 app/Config/UserAgents.php create mode 100644 app/Config/Validation.php create mode 100644 app/Config/View.php create mode 100644 app/Config/WorkerMode.php create mode 100644 app/Controllers/Api.php create mode 100644 app/Controllers/BaseController.php create mode 100644 app/Controllers/Bundle.php create mode 100644 app/Controllers/Franchise.php create mode 100644 app/Controllers/Game.php create mode 100644 app/Controllers/Games.php create mode 100644 app/Controllers/Home.php create mode 100644 app/Controllers/News.php create mode 100644 app/Controllers/Pages.php create mode 100644 app/Controllers/Prepaids.php create mode 100644 app/Controllers/Rankings.php create mode 100644 app/Controllers/Stores.php create mode 100644 app/Controllers/Subscriptions.php create mode 100644 app/Controllers/Vouchers.php create mode 100644 app/Controllers/Wishlist.php create mode 100644 app/Database/Migrations/.gitkeep create mode 100644 app/Database/Seeds/.gitkeep create mode 100644 app/Filters/.gitkeep create mode 100644 app/Helpers/.gitkeep create mode 100644 app/Helpers/gg_helper.php create mode 100644 app/Language/.gitkeep create mode 100644 app/Language/en/Validation.php create mode 100644 app/Libraries/.gitkeep create mode 100644 app/Libraries/Mongo.php create mode 100644 app/Models/.gitkeep create mode 100644 app/Models/GameModel.php create mode 100644 app/Models/StoreModel.php create mode 100644 app/ThirdParty/.gitkeep create mode 100644 app/Views/api.php create mode 100644 app/Views/bundle.php create mode 100644 app/Views/errors/cli/error_404.php create mode 100644 app/Views/errors/cli/error_exception.php create mode 100644 app/Views/errors/cli/production.php create mode 100644 app/Views/errors/html/debug.css create mode 100644 app/Views/errors/html/debug.js create mode 100644 app/Views/errors/html/error_400.php create mode 100644 app/Views/errors/html/error_404.php create mode 100644 app/Views/errors/html/error_exception.php create mode 100644 app/Views/errors/html/production.php create mode 100644 app/Views/faq.php create mode 100644 app/Views/franchise.php create mode 100644 app/Views/franchises.php create mode 100644 app/Views/game.php create mode 100644 app/Views/games.php create mode 100644 app/Views/home.php create mode 100644 app/Views/homepage.php create mode 100644 app/Views/layout.php create mode 100644 app/Views/news.php create mode 100644 app/Views/news_article.php create mode 100644 app/Views/partials/dash_deal.php create mode 100644 app/Views/partials/deal_row.php create mode 100644 app/Views/partials/store_badge.php create mode 100644 app/Views/prepaids.php create mode 100644 app/Views/rankings.php create mode 100644 app/Views/stores.php create mode 100644 app/Views/subscriptions.php create mode 100644 app/Views/tutorial.php create mode 100644 app/Views/vouchers.php create mode 100644 app/Views/welcome_message.php create mode 100644 app/Views/wishlist.php create mode 100644 app/index.html create mode 100644 assets/css/app.css create mode 100755 builds create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 env create mode 100644 package.json create mode 100644 phpunit.dist.xml create mode 100644 preload.php create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/favicon.svg create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 scripts/scheduler_loop.php create mode 100755 spark create mode 100644 tests/.htaccess create mode 100644 tests/README.md create mode 100644 tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php create mode 100644 tests/_support/Database/Seeds/ExampleSeeder.php create mode 100644 tests/_support/Libraries/ConfigReader.php create mode 100644 tests/_support/Models/ExampleModel.php create mode 100644 tests/database/ExampleDatabaseTest.php create mode 100644 tests/index.html create mode 100644 tests/session/ExampleSessionTest.php create mode 100644 tests/unit/HealthTest.php create mode 100644 writable/.htaccess create mode 100644 writable/debugbar/index.html create mode 100644 writable/index.html create mode 100644 writable/logs/index.html create mode 100644 writable/session/index.html create mode 100644 writable/uploads/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f14654b --- /dev/null +++ b/.gitignore @@ -0,0 +1,138 @@ +#------------------------- +# Operating Specific Junk Files +#------------------------- + +# OS X +.DS_Store +.AppleDouble +.LSOverride + +# OS X Thumbnails +._* + +# Windows image file caches +Thumbs.db +ehthumbs.db +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Linux +*~ + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +#------------------------- +# Environment Files +#------------------------- +# These should never be under version control, +# as it poses a security risk. +.env +.vagrant +Vagrantfile + +#------------------------- +# Temporary Files +#------------------------- +/writable/cache/* +!/writable/cache/index.html + +/writable/logs/* +!/writable/logs/index.html + +/writable/session/* +!/writable/session/index.html + +/writable/uploads/* +!/writable/uploads/index.html + +/writable/debugbar/* +!/writable/debugbar/index.html + +php_errors.log + +#------------------------- +# User Guide Temp Files +#------------------------- +/user_guide_src/build/* +/user_guide_src/cilexer/build/* +/user_guide_src/cilexer/dist/* +/user_guide_src/cilexer/pycilexer.egg-info/* + +#------------------------- +# Test Files +#------------------------- +/tests/coverage* + +# Don't save phpunit under version control. +/phpunit + +#------------------------- +# Composer +#------------------------- +vendor/ + +#------------------------- +# IDE / Development Files +#------------------------- + +# Modules Testing +_modules/* + +# phpenv local config +.php-version + +# Jetbrains editors (PHPStorm, etc) +.idea/ +*.iml + +# NetBeans +/nbproject/ +/build/ +/nbbuild/ +/dist/ +/nbdist/ +/nbactions.xml +/nb-configuration.xml +/.nb-gradle/ + +# Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project +.phpintel +/api/ + +# Visual Studio Code +.vscode/ + +/results/ +/phpunit.xml + +# ---- Node / frontend build ---- +node_modules/ +package-lock.json +public/js/chart.umd.js +public/js/alpine.min.js +public/css/app.css +assets/node_modules/ + +# ---- Crawled assets (regenerable) ---- +public/img/covers/ +public/img/stores/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..24728f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2014-2019 British Columbia Institute of Technology +Copyright (c) 2019-present CodeIgniter Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4c2e84 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# GG.deals Clone + +A demo clone of [gg.deals](https://gg.deals/) — PC game price comparison — built with: + +- **CodeIgniter 4** (PHP 8.2) — MVC backend +- **MongoDB** — data store (`harness-mongo`, db: `ggdeals`) +- **Tailwind CSS v4** — styling (dark + light theme) +- **Alpine.js** — interactivity (tabs, filters, wishlist store, settings store, search dropdown) +- **Chart.js** — price history charts + +The feature set was modeled after a **deep crawl of gg.deals itself**: the full sitemap tree (12 sitemap types, 5k+ URLs) plus Wayback Machine snapshots of every page type (homepage, deals, game, bundle, freebie, giveaway, franchise, game-points, API, rankings, vouchers, news). + +## Features + +### Homepage dashboard (gg.deals-style, 10 sections) +- Most Popular Games, New deals, Best deals, Historical lows, Ending Soon +- 🔥 Most Wanted, 🆕 New releases, 📅 Upcoming games +- 📰 News feed, 🎁 Prepaids +- Stats strip (games / deals / stores / all-time lows now) + +### Deal feed & presets (`/deals/{preset}`) +- Preset tabs: Hot New Deals, New Deals, Historical Lows, Ending Soon, ≥75% off, ≥85% off +- Advanced filters: price tiers (Under $2–$30), discount tiers (≥50–90%), deal rating (🔥 Hot/Good/Fair), release year ranges, only all-time lows, only-from-wishlist, per-store checkboxes with real logos +- Sorts: Best deals, Deal rating, Biggest discount, Cheapest ever, New releases, Lowest price, Metascore, Title A–Z, Oldest release, Ending soon +- Deal rows with 🔥 Hot / Good / Fair badges, ★ New HL flags, ⏳ ending-soon timers, wishlist hearts + +### Game page (`/game/{slug}`) +- **Header price tabs**: Current Prices / Historical Low +- Price summary cards: Official Stores, Keyshops, Subscriptions, All-time low +- Deals tables (official stores & keyshops) with DRM, region locks, expiry, HL flags +- Chart.js price history (3M/6M/1Y/All) vs all-time low +- **Bundles with this game**, **Series & franchises**, **Similar games** sections +- About (dev/publisher/age rating), System Requirements, How Long to Beat + +### Content & community pages +- **Bundle pages** (`/bundle/{slug}`) — games list, price, savings, store +- **News articles** (`/article|deal|freebie|giveaway/{slug}`) — categorized posts with claim/enter CTAs +- **Franchise pages** (`/franchise/{slug}`) — all games in a series +- **Rankings** — Most Wishlisted, Most Collected, Metacritic, Steam Reviews +- **Vouchers** — promo codes per store with copy-to-clipboard +- **Subscriptions** — Game Pass, PS Plus, EA Play, Ubisoft+ cards +- **Prepaids** — gift card groups with denominations +- **Games browser** (`/games/{genre}`) — same deal-feed layout (filter sidebar + row list + pagination) with genre chips on top, **Stores**, **Wishlist** (localStorage) + +### Site features +- **Region & currency selector** (19 regions, 7 currencies, localStorage-persisted) +- **Keyshops toggle** (on/off) +- **Dark / Light theme toggle** +- **API page** + **Game Prices API** (`/api/prices?appid=`) with live demo tester +- **FAQ** (accordion) + **CD key activation tutorials** (Steam/GOG/Epic/Origin/Uplay/Keyshops) +- Live search suggestions + +### Assets +- **Real cover art** — crawled from Steam CDN (`php spark gg:crawl-covers`) +- **Real store logos** — brand favicons in `public/img/stores/` + +## Quick start + +```bash +# 1. Configure .env (MONGO_URI / MONGO_DB) +cp env .env + +# 2. Install PHP + frontend deps +composer install +npm install + +# 3. Build CSS +npx @tailwindcss/cli -i assets/css/app.css -o public/css/app.css --minify + +# 4. Seed the database (stores, 61 games, 549 deals, ~26k price points, vouchers, news, prepaids, subscriptions, bundles, franchises, covers) +php spark gg:seed + +# 5. Crawl real cover art from Steam +php spark gg:crawl-covers + +# 6. Serve +php spark serve --host 0.0.0.0 --port 8085 +``` + +## Data model (MongoDB collections) + +- `stores` — code, name, kind, colors, logo +- `games` — slug, title, base price, genres, platforms, scores, all-time low, best prices, developer/publisher, sys reqs, HLTB, age rating, description +- `deals` — per-store offers with cut %, HL flag, DRM, expiry +- `price_history` — 5-day cadence price points per store +- `vouchers` — promo codes per store +- `news` — categorized posts (deals/bundles/freebies/giveaways/subscriptions) +- `prepaids` — gift card groups +- `subscriptions` — game subscription services +- `bundles` — bundles with included game slugs +- `franchises` — franchise series + +## Layout + +``` +app/ + Commands/ SeedDatabase (gg:seed), CrawlCovers (gg:crawl-covers) + Controllers/ Home, Game, Games, Rankings, Vouchers, News, Prepaids, + Subscriptions, Bundle, Franchise, Wishlist, Stores, Pages, Api + Helpers/gg_helper.php money(), cut_class(), deal_rating(), qs_url(), time_ago() + Libraries/Mongo.php MongoDB client wrapper + Models/GameModel.php aggregation pipelines for feed/chart/dashboard/similar + Views/ layout, homepage, home, game, + 15 more page views, partials/ +assets/css/app.css Tailwind v4 source (theme tokens + dark/light) +``` + +> Demo data only: prices are randomly generated, buy links point to search results. diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 0000000..3462048 --- /dev/null +++ b/app/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/app/Commands/CrawlCatalog.php b/app/Commands/CrawlCatalog.php new file mode 100644 index 0000000..fb5389f --- /dev/null +++ b/app/Commands/CrawlCatalog.php @@ -0,0 +1,126 @@ + $v) { + if (is_string($k) && str_starts_with($k, 'limit=')) { + $limit = (int) substr($k, 6); + } + } + + // resolve the sitemap index + $index = $this->fetch('https://gg.deals/sitemap.xml'); + if ($index === null) { + CLI::error('Could not fetch sitemap index'); + + return; + } + + preg_match_all('/sitemap_games_(\d+)\.xml/', $index, $m); + $files = array_map(static fn ($n) => (int) $n, $m[1]); + sort($files, SORT_NUMERIC); + + CLI::write('Found ' . count($files) . ' game sitemap files.', 'yellow'); + if ($limit !== null) { + $files = array_slice($files, 0, $limit); + CLI::write('Limit set: first ' . count($files) . ' files.', 'yellow'); + } + + $totalUrls = 0; + $totalInserted = 0; + $totalSkipped = 0; + + foreach ($files as $i => $n) { + $url = "https://gg.deals/sitemap_games_{$n}.xml"; + $xml = $this->fetch($url); + if ($xml === null) { + CLI::error(" sitemap {$n}: fetch failed"); + usleep(500000); + continue; + } + + preg_match_all('/(https:\/\/gg\.deals\/game\/([a-z0-9-]+)\/?)<\/loc>/', $xml, $matches); + $slugs = array_values(array_unique($matches[2])); + $totalUrls += count($slugs); + + $docs = []; + foreach ($slugs as $slug) { + $docs[] = [ + 'slug' => $slug, + 'url' => 'https://gg.deals/game/' . $slug . '/', + 'crawled' => false, + 'added_at' => new UTCDateTime(time() * 1000), + ]; + } + + if ($docs !== []) { + try { + Mongo::collection('catalog')->insertMany($docs, ['ordered' => false]); + $totalInserted += count($docs); + } catch (\MongoDB\Driver\Exception\BulkWriteException $e) { + // duplicate keys are expected on re-runs + $totalInserted += count($docs); + } + } + + CLI::write(" [{$i}/" . count($files) . "] sitemap {$n}: " . count($slugs) . ' slugs (total ' . number_format($totalInserted) . ')'); + usleep(300000); + } + + CLI::write(''); + CLI::write("Catalog crawl done: {$totalUrls} urls, {$totalInserted} inserted.", 'green'); + CLI::write('Total catalog documents: ' . Mongo::collection('catalog')->countDocuments(), 'green'); + } + + private function fetch(string $url): ?string + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => self::UA, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_HTTPHEADER => ['Accept: application/xml,text/xml,*/*'], + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($code !== 200 || $body === false || $body === '') { + return null; + } + + return $body; + } +} diff --git a/app/Commands/CrawlCovers.php b/app/Commands/CrawlCovers.php new file mode 100644 index 0000000..31f1517 --- /dev/null +++ b/app/Commands/CrawlCovers.php @@ -0,0 +1,184 @@ + steam appid */ + public const APP_IDS = [ + 'age-of-empires-iv' => 1466860, + 'alan-wake-2' => 2379780, + 'balan-wonderworld' => 1336480, + 'baldurs-gate-3' => 1086940, + 'cities-skylines-ii' => 949230, + 'control' => 870780, + 'counter-strike-2' => 730, + 'cuphead' => 268910, + 'cyberpunk-2077' => 1091500, + 'dark-souls-iii' => 374320, + 'dead-cells' => 588650, + 'death-stranding' => 1190460, + 'deep-rock-galactic' => 548430, + 'diablo-iv' => 2344520, + 'disco-elysium' => 632470, + 'dishonored-2' => 403640, + 'divinity-original-sin-2' => 435150, + 'doom-eternal' => 782330, + 'ea-sports-fc-24' => 2195250, + 'efootball-2024' => 2349380, + 'elden-ring' => 1245620, + 'enshrouded' => 1203620, + 'factorio' => 427520, + 'forza-horizon-5' => 1551360, + 'god-of-war' => 1593500, + 'grand-theft-auto-v' => 271590, + 'hades-ii' => 1145350, + 'helldivers-2' => 553850, + 'hogwarts-legacy' => 990080, + 'hollow-knight' => 367520, + 'horizon-forbidden-west' => 2420110, + 'it-takes-two' => 1426210, + 'kingdom-come-deliverance' => 379430, + 'lethal-company' => 1966720, + 'manor-lords' => 1363080, + 'marvels-spider-man-2' => 2651290, + 'marvels-spider-man-remastered' => 1817070, + 'metro-exodus' => 412020, + 'microsoft-flight-simulator' => 1250410, + 'monster-hunter-rise' => 1446780, + 'nier-automata' => 524220, + 'ori-and-the-will-of-the-wisps' => 1057090, + 'outer-wilds' => 753640, + 'palworld' => 1623730, + 'persona-5-royal' => 1687950, + 'ready-or-not' => 1144200, + 'red-dead-redemption-2' => 1174180, + 'resident-evil-4' => 2050650, + 'rimworld' => 294100, + 'sekiro-shadows-die-twice' => 814380, + 'slay-the-spire' => 646570, + 'sonic-frontiers' => 1237320, + 'stardew-valley' => 413150, + 'starfield' => 1716740, + 'street-fighter-6' => 1364780, + 'terraria' => 105600, + 'the-last-of-us-part-i' => 1888930, + 'the-witcher-3-wild-hunt' => 292030, + 'total-war-warhammer-iii' => 1142710, + 'valheim' => 892970, + 'warhammer-40000-darktide' => 1361210, + ]; + + private const CDN = 'https://cdn.cloudflare.steamstatic.com/steam/apps/%d/'; + private const OUT_DIR = WRITEPATH . '../public/img/covers/'; + + private const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'; + + public function run(array $params): void + { + $games = Mongo::collection('games')->find()->toArray(); + + if (! is_dir(self::OUT_DIR)) { + mkdir(self::OUT_DIR, 0777, true); + } + + $ok = 0; + $fail = []; + $skipped = 0; + + foreach ($games as $game) { + $slug = $game['slug']; + $appid = self::APP_IDS[$slug] ?? null; + + if ($appid === null) { + $skipped++; + continue; + } + + $filename = $slug . '.jpg'; + $dest = self::OUT_DIR . $filename; + + $downloaded = $this->download( + sprintf(self::CDN, $appid) . 'header.jpg', + $dest + ); + + if (! $downloaded) { + // try the capsule variant as a fallback + $downloaded = $this->download( + sprintf(self::CDN, $appid) . 'capsule_616x353.jpg', + $dest + ); + } + + if ($downloaded) { + Mongo::collection('games')->updateOne( + ['slug' => $slug], + ['$set' => ['cover' => ['file' => '/img/covers/' . $filename, 'from' => '#000000', 'to' => '#000000']]] + ); + $ok++; + CLI::write(' ✓ ' . $slug . ' (' . $appid . ')', 'green'); + } else { + $fail[] = $slug; + CLI::write(' ✗ ' . $slug . ' (' . $appid . ')', 'red'); + } + + usleep(120000); // be gentle with the CDN + } + + CLI::write(''); + CLI::write(sprintf('Downloaded %d covers, skipped %d (no appid), failed %d.', $ok, $skipped, count($fail)), 'cyan'); + if ($fail !== []) { + CLI::write('Failed: ' . implode(', ', $fail), 'yellow'); + } + } + + private function download(string $url, string $dest): bool + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 20, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => self::UA, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + + if ($code !== 200 || $body === false || strlen($body) < 1000) { + return false; + } + + // ensure it's an image + if (! str_starts_with($type, 'image/')) { + return false; + } + + return file_put_contents($dest, $body) !== false; + } +} diff --git a/app/Commands/CrawlGames.php b/app/Commands/CrawlGames.php new file mode 100644 index 0000000..df05208 --- /dev/null +++ b/app/Commands/CrawlGames.php @@ -0,0 +1,661 @@ +crawlSteam(); + } + if ($target === 'all' || $target === 'gog') { + $this->crawlGog(); + } + if ($target === 'all' || $target === 'epic') { + $this->crawlEpic(); + } + + if ($target === 'all' || $target === 'finalize' || in_array($target, ['steam', 'gog', 'epic'], true)) { + $this->finalizeGames(); + $this->recordPriceHistory(); + } + + CLI::write('Crawler finished — ' . date('Y-m-d H:i:s'), 'green'); + } + + /** + * Records today's cheapest price per game/store-kind into price_history, + * so the charts grow real data over time. + */ + private function recordPriceHistory(): void + { + CLI::write('-- Recording price history --', 'cyan'); + + $todayStart = (new \DateTime('today'))->getTimestamp(); + $now = time(); + $inserted = 0; + + $deals = Mongo::collection('deals')->find()->toArray(); + $byGame = []; + + foreach ($deals as $d) { + $byGame[$d['game_slug']][$d['kind']][] = (float) $d['price']; + } + + foreach ($byGame as $slug => $kinds) { + foreach (['official', 'keyshop'] as $kind) { + if (empty($kinds[$kind])) { + continue; + } + $min = min($kinds[$kind]); + + // only insert one point per game+kind+day + $existing = Mongo::collection('price_history')->findOne([ + 'game_slug' => $slug, + 'kind' => $kind, + 'ts' => ['$gte' => new UTCDateTime($todayStart * 1000)], + ]); + if ($existing !== null) { + continue; + } + + Mongo::collection('price_history')->insertOne([ + 'game_slug' => $slug, + 'store_code' => 'aggregate', + 'kind' => $kind, + 'ts' => new UTCDateTime($now * 1000), + 'price' => round($min, 2), + ]); + $inserted++; + } + } + + CLI::write("Recorded {$inserted} price history points.", 'green'); + } + + /** + * Recomputes per-game aggregate fields (best prices, active deal counts, + * all-time low) from the deals collection — keeps views working even when + * a game has no official/keyshop store deals. + */ + private function finalizeGames(): void + { + CLI::write('-- Finalizing game aggregates --', 'cyan'); + + $deals = Mongo::collection('deals')->find()->toArray(); + $byGame = []; + + foreach ($deals as $d) { + $slug = $d['game_slug']; + $byGame[$slug][] = $d; + } + + $updated = 0; + $now = time(); + + foreach ($byGame as $slug => $gameDeals) { + $official = array_values(array_filter($gameDeals, static fn ($d) => $d['kind'] === 'official')); + $keyshop = array_values(array_filter($gameDeals, static fn ($d) => $d['kind'] === 'keyshop')); + + usort($official, static fn ($a, $b) => $a['price'] <=> $b['price']); + usort($keyshop, static fn ($a, $b) => $a['price'] <=> $b['price']); + usort($gameDeals, static fn ($a, $b) => $a['price'] <=> $b['price']); + + $bestOfficial = $official[0] ?? null; + $bestKeyshop = $keyshop[0] ?? null; + $cheapest = $gameDeals[0] ?? null; + + $existing = Mongo::collection('games')->findOne(['slug' => $slug]); + if ($existing === null) { + continue; + } + + // all-time low: keep the historical minimum, seed from current cheapest if none + $hl = $existing['all_time_low'] ?? null; + if ($cheapest !== null && ($hl === null || (float) $cheapest['price'] < (float) $hl['price'])) { + $hl = [ + 'price' => (float) $cheapest['price'], + 'store' => $cheapest['store_code'], + 'kind' => $cheapest['kind'], + 'date' => new UTCDateTime($now * 1000), + ]; + } + + $set = [ + 'best_official' => [ + 'price' => $bestOfficial ? (float) $bestOfficial['price'] : 0.0, + 'store' => $bestOfficial ? $bestOfficial['store_code'] : '', + 'cut' => $bestOfficial ? (int) $bestOfficial['cut'] : 0, + ], + 'best_keyshop' => [ + 'price' => $bestKeyshop ? (float) $bestKeyshop['price'] : 0.0, + 'store' => $bestKeyshop ? $bestKeyshop['store_code'] : '', + 'cut' => $bestKeyshop ? (int) $bestKeyshop['cut'] : 0, + ], + 'active_deals' => count($gameDeals), + 'all_time_low' => $hl, + 'trending' => $existing['trending'] ?? mt_rand(30, 90), + ]; + + Mongo::collection('games')->updateOne(['slug' => $slug], ['$set' => $set]); + $updated++; + } + + CLI::write("Finalized {$updated} games with aggregates.", 'green'); + } + + // ------------------------------------------------------------------ Steam + + private function crawlSteam(): void + { + CLI::write('-- Steam --', 'cyan'); + + $data = $this->fetchJson(self::STEAM_FEATURED); + if ($data === null) { + CLI::error('Steam featured fetch failed'); + + return; + } + + $appIds = []; + $featured = []; + + foreach (['specials', 'top_sellers', 'new_releases', 'coming_soon'] as $cat) { + foreach ($data[$cat]['items'] ?? [] as $item) { + $appIds[$item['id']] = true; + $featured[$item['id']] = [ + 'category' => $cat, + 'discount_percent' => (int) ($item['discount_percent'] ?? 0), + 'final_price' => (int) ($item['final_price'] ?? 0), + 'original_price' => (int) ($item['original_price'] ?? 0), + ]; + } + } + + // also add our existing appids so we refresh their details + foreach (\App\Commands\CrawlCovers::APP_IDS as $slug => $appid) { + $appIds[$appid] = true; + } + + // crawl Steam search catalog (games only) — several pages of 50 + $searchPages = (int) env('GG_CRAWL_STEAM_PAGES', '4'); + foreach ($this->steamSearchAppIds($searchPages) as $appid) { + $appIds[$appid] = true; + } + + CLI::write('Found ' . count($appIds) . ' Steam apps to fetch details for.', 'yellow'); + + $gamesInserted = 0; + $gamesUpdated = 0; + $dealsInserted = 0; + $dealsUpdated = 0; + + foreach (array_keys($appIds) as $i => $appid) { + $details = $this->fetchJson(sprintf(self::STEAM_APP_DETAILS, (int) $appid)); + if ($details === null || empty($details[(string) $appid]['success'])) { + continue; + } + $g = $details[(string) $appid]['data'] ?? null; + if ($g === null || empty($g['name'])) { + continue; + } + + $slug = url_title($g['name'], '-', true); + if ($slug === '') { + continue; + } + + $priceOverview = $g['price_overview'] ?? []; + $priceUsd = isset($priceOverview['final']) ? (float) $priceOverview['final'] / 100 : null; + $baseUsd = isset($priceOverview['initial']) ? (float) $priceOverview['initial'] / 100 : null; + $cut = (int) ($priceOverview['discount_percent'] ?? 0); + + $genres = array_map(static fn ($x) => $x['description'], $g['genres'] ?? []); + $platforms = ['steam']; + foreach ($g['platforms'] ?? [] as $plat => $supported) { + if ($supported) { + $platforms[] = $plat; + } + } + + $now = time(); + $cover = $this->steamCoverUrl((int) $appid); + + $existing = Mongo::collection('games')->findOne(['slug' => $slug]); + $oldBase = $existing ? (float) $existing['base_price'] : ($baseUsd ?? 0); + + $doc = [ + 'slug' => $slug, + 'title' => $g['name'], + 'steam_appid' => (int) $appid, + 'base_price' => $baseUsd ?? $oldBase, + 'genres' => array_values(array_unique(array_filter($genres))), + 'platforms' => array_values(array_unique($platforms)), + 'score' => (int) ($g['metacritic']['score'] ?? 0), + 'rating_pct' => (int) ($g['metacritic']['score'] ?? 0), + 'release_date' => $this->parseDate($g['release_date']['date'] ?? null), + 'developer' => ($g['developers'][0] ?? null) ?: 'Unknown', + 'publisher' => ($g['publishers'][0] ?? null) ?: 'Unknown', + 'cover' => ['file' => $cover, 'from' => '#1b2838', 'to' => '#171d25'], + 'description' => $g['short_description'] ?? '', + 'age_rating' => 'Steam', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'steam', + ]; + + // preserve existing all_time_low / best prices if present + if ($existing) { + foreach (['all_time_low', 'best_official', 'best_keyshop', 'active_deals', 'trending'] as $f) { + if (isset($existing[$f])) { + $doc[$f] = $existing[$f]; + } + } + } + + $result = Mongo::collection('games')->updateOne( + ['slug' => $slug], + ['$set' => $doc], + ['upsert' => true] + ); + if ($result->getUpsertedCount() > 0) { + $gamesInserted++; + } else { + $gamesUpdated++; + } + + // ---- deal (official store = Steam) + if ($priceUsd !== null && $baseUsd !== null && $baseUsd > 0) { + $hlPrice = $existing['all_time_low']['price'] ?? null; + $dealDoc = [ + 'game_slug' => $slug, + 'title' => $g['name'], + 'store_code' => 'steam', + 'kind' => 'official', + 'price' => round($priceUsd, 2), + 'regular' => round($baseUsd, 2), + 'cut' => max(0, min(95, $cut)), + 'historical_low' => $hlPrice !== null && $priceUsd <= $hlPrice + 0.01, + 'drm' => 'Steam key', + 'regional' => false, + 'expiry' => null, + 'url' => 'https://store.steampowered.com/app/' . (int) $appid . '/', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'steam', + ]; + $dResult = Mongo::collection('deals')->updateOne( + ['game_slug' => $slug, 'store_code' => 'steam'], + ['$set' => $dealDoc], + ['upsert' => true] + ); + if ($dResult->getUpsertedCount() > 0) { + $dealsInserted++; + } else { + $dealsUpdated++; + } + } + + if ($i % 25 === 0) { + CLI::write(" ... {$i}/" . count($appIds)); + } + usleep(200000); // rate limit ~5 req/s + } + + CLI::write("Steam done: {$gamesInserted} games inserted, {$gamesUpdated} updated, {$dealsInserted} deals inserted, {$dealsUpdated} deals updated.", 'green'); + } + + private function steamCoverUrl(int $appid): string + { + return "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{$appid}/header.jpg"; + } + + /** + * Collects app IDs from the Steam store search pages (category1=998 → games). + */ + private function steamSearchAppIds(int $pages): array + { + $ids = []; + for ($page = 0; $page < $pages; $page++) { + $url = sprintf(self::STEAM_SEARCH, $page * 100); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => self::UA, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($code !== 200 || $body === false) { + continue; + } + + // results_html is JSON-escaped: data-ds-appid=\"123456\" + if (preg_match_all('/data-ds-appid=\\\\"(\d{3,9})\\\\"/', $body, $m) + || preg_match_all('/data-ds-appid="(\d{3,9})"/', $body, $m)) { + foreach ($m[1] as $appid) { + $ids[(int) $appid] = true; + } + } + + CLI::write(" search page {$page}: " . count($ids) . ' unique ids so far'); + usleep(300000); + } + + return array_keys($ids); + } + + // ------------------------------------------------------------------ GOG + + private function crawlGog(): void + { + CLI::write('-- GOG --', 'cyan'); + + $limit = 48; + $pages = (int) env('GG_CRAWL_GOG_PAGES', '6'); + $inserted = 0; + $updated = 0; + $dealsInserted = 0; + $now = time(); + + for ($page = 0; $page < $pages; $page++) { + $url = sprintf(self::GOG_CATALOG, $limit) . '&page=' . $page . '&pageSize=' . $limit; + $data = $this->fetchJson($url); + if ($data === null || empty($data['products'])) { + CLI::write(" GOG page {$page}: no products, stopping."); + break; + } + CLI::write(" GOG page {$page}: " . count($data['products']) . ' products'); + + foreach ($data['products'] as $p) { + $title = $p['title'] ?? null; + if ($title === null) { + continue; + } + $slug = url_title($title, '-', true); + if ($slug === '') { + continue; + } + + $price = $p['price'] ?? []; + $baseUsd = isset($price['baseMoney']['amount']) ? (float) $price['baseMoney']['amount'] : null; + $finalUsd = isset($price['finalMoney']['amount']) ? (float) $price['finalMoney']['amount'] : null; + $cut = (int) rtrim((string) ($price['discount'] ?? '0%'), '%'); + + $genres = array_map(static fn ($x) => $x['name'], $p['genres'] ?? []); + + $existing = Mongo::collection('games')->findOne(['slug' => $slug]); + + $doc = [ + 'slug' => $slug, + 'title' => $title, + 'base_price' => $baseUsd ?? 0, + 'genres' => array_values(array_unique(array_filter($genres))), + 'platforms' => ['gog'], + 'score' => (int) ($p['reviewsRating'] ?? 0), + 'rating_pct' => (int) ($p['reviewsRating'] ?? 0), + 'release_date' => $this->parseDate($p['releaseDate'] ?? null, 'Y.m.d'), + 'developer' => ($p['developers'][0] ?? null) ?: 'Unknown', + 'publisher' => ($p['publishers'][0] ?? null) ?: 'Unknown', + 'cover' => ['file' => $p['coverHorizontal'] ?? '', 'from' => '#862a52', 'to' => '#4b1530'], + 'description' => $p['description'] ?? '', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'gog', + ]; + if ($existing) { + foreach (['all_time_low', 'best_official', 'best_keyshop', 'active_deals', 'trending'] as $f) { + if (isset($existing[$f])) { + $doc[$f] = $existing[$f]; + } + } + } + + $r = Mongo::collection('games')->updateOne(['slug' => $slug], ['$set' => $doc], ['upsert' => true]); + if ($r->getUpsertedCount() > 0) { + $inserted++; + } else { + $updated++; + } + + if ($finalUsd !== null && $baseUsd !== null && $baseUsd > 0) { + $hlPrice = $existing['all_time_low']['price'] ?? null; + Mongo::collection('deals')->updateOne( + ['game_slug' => $slug, 'store_code' => 'gog'], + ['$set' => [ + 'game_slug' => $slug, + 'title' => $title, + 'store_code' => 'gog', + 'kind' => 'official', + 'price' => round($finalUsd, 2), + 'regular' => round($baseUsd, 2), + 'cut' => max(0, min(95, $cut)), + 'historical_low' => $hlPrice !== null && $finalUsd <= $hlPrice + 0.01, + 'drm' => 'DRM-free', + 'regional' => false, + 'expiry' => null, + 'url' => $p['storeLink'] ?? 'https://www.gog.com/', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'gog', + ]], + ['upsert' => true] + ); + $dealsInserted++; + } + + usleep(100000); + } + } + + CLI::write("GOG done: {$inserted} inserted, {$updated} updated, {$dealsInserted} deals.", 'green'); + } + + // ------------------------------------------------------------------ Epic + + private function crawlEpic(): void + { + CLI::write('-- Epic free games --', 'cyan'); + + $data = $this->fetchJson(self::EPIC_FREE); + if ($data === null) { + CLI::error('Epic free fetch failed'); + + return; + } + + $elements = $data['data']['Catalog']['searchStore']['elements'] ?? []; + $now = time(); + $inserted = 0; + $dealsInserted = 0; + + foreach ($elements as $e) { + $title = $e['title'] ?? null; + if ($title === null) { + continue; + } + $slug = url_title($title, '-', true); + if ($slug === '') { + continue; + } + + $price = $e['price']['totalPrice']['fmtPrice'] ?? []; + $orig = (float) str_replace(['$', ','], '', (string) ($price['originalPrice'] ?? '0')); + $final = (float) str_replace(['$', ','], '', (string) ($price['discountPrice'] ?? '0')); + + $promos = $e['promotions'] ?? []; + $active = $promos['promotionalOffers'][0]['promotionalOffers'] ?? []; + $upcoming = $promos['upcomingPromotionalOffers'][0]['promotionalOffers'] ?? []; + $isFree = $final <= 0 || $active !== [] || $upcoming !== []; + + $existing = Mongo::collection('games')->findOne(['slug' => $slug]); + + $doc = [ + 'slug' => $slug, + 'title' => $title, + 'base_price' => $orig, + 'genres' => [], + 'platforms' => ['epic'], + 'score' => 0, + 'rating_pct' => 0, + 'release_date' => new UTCDateTime($now * 1000), + 'developer' => ($e['developer'] ?? 'Unknown') ?: 'Unknown', + 'publisher' => ($e['publisher'] ?? 'Unknown') ?: 'Unknown', + 'cover' => ['file' => $e['keyImages'][0]['url'] ?? '', 'from' => '#2b2b2b', 'to' => '#1a1a1a'], + 'description' => $e['description'] ?? '', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'epic', + ]; + if ($existing) { + foreach (['all_time_low', 'best_official', 'best_keyshop', 'active_deals', 'trending'] as $f) { + if (isset($existing[$f])) { + $doc[$f] = $existing[$f]; + } + } + } + + $r = Mongo::collection('games')->updateOne(['slug' => $slug], ['$set' => $doc], ['upsert' => true]); + if ($r->getUpsertedCount() > 0) { + $inserted++; + } + + if ($final >= 0 && $orig > 0) { + $expiry = null; + foreach (array_merge($active, $upcoming) as $o) { + if (! empty($o['endDate'])) { + $expiry = new UTCDateTime(strtotime($o['endDate']) * 1000); + break; + } + } + Mongo::collection('deals')->updateOne( + ['game_slug' => $slug, 'store_code' => 'epic'], + ['$set' => [ + 'game_slug' => $slug, + 'title' => $title, + 'store_code' => 'epic', + 'kind' => 'official', + 'price' => round($final, 2), + 'regular' => round($orig, 2), + 'cut' => $isFree ? 100 : max(0, min(95, (int) round((1 - $final / $orig) * 100))), + 'historical_low' => $isFree, + 'drm' => 'Epic key', + 'regional' => false, + 'expiry' => $expiry, + 'url' => 'https://store.epicgames.com/p/' . ($e['productSlug'] ?? $slug), + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'epic', + ]], + ['upsert' => true] + ); + $dealsInserted++; + + // free game → news freebie post (only when free and not already posted) + if ($isFree && $existing === null) { + $newsSlug = 'freebie-' . $slug; + if (Mongo::collection('news')->findOne(['slug' => $newsSlug]) === null) { + Mongo::collection('news')->insertOne([ + 'title' => $title . ' is FREE to keep on Epic Games Store', + 'slug' => $newsSlug, + 'category' => 'freebies', + 'body' => "You can claim {$title} for free on the Epic Games Store right now — it's yours to keep forever once claimed. Head over to the store, sign in and hit the claim button before the offer expires.", + 'game_slug' => $slug, + 'date' => new UTCDateTime($now * 1000), + ]); + } + } + } + + usleep(150000); + } + + CLI::write("Epic done: {$inserted} games inserted, {$dealsInserted} deals.", 'green'); + } + + // ------------------------------------------------------------------ helpers + + private function fetchJson(string $url): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => self::UA, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($code !== 200 || $body === false) { + CLI::error("Fetch failed ({$code}): {$url}"); + + return null; + } + + $json = json_decode($body, true); + if (! is_array($json)) { + CLI::error('Invalid JSON: ' . substr($body, 0, 100)); + + return null; + } + + return $json; + } + + private function parseDate(?string $date, string $format = 'd M, Y'): ?UTCDateTime + { + if ($date === null || $date === '' || str_contains($date, 'Coming soon')) { + return null; + } + $ts = \DateTime::createFromFormat($format, $date); + if ($ts === false) { + try { + $ts = new \DateTime($date); + } catch (\Exception) { + return null; + } + } + + return new UTCDateTime($ts->getTimestamp() * 1000); + } +} diff --git a/app/Commands/CrawlGgdeals.php b/app/Commands/CrawlGgdeals.php new file mode 100644 index 0000000..3aabc7c --- /dev/null +++ b/app/Commands/CrawlGgdeals.php @@ -0,0 +1,329 @@ + $v) { + if (is_string($k) && str_starts_with($k, 'limit=')) { + $limit = (int) substr($k, 6); + } + if (is_string($k) && str_starts_with($k, 'slug=')) { + $slug = substr($k, 5); + } + if (is_string($k) && str_starts_with($k, 'batch=')) { + $batch = (int) substr($k, 6); + } + } + + CLI::write('GG.deals detailed crawler started — ' . date('Y-m-d H:i:s'), 'green'); + + // ensure session exists + $this->fsRequest(['cmd' => 'sessions.create', 'session' => self::SESSION]); + CLI::write('FlareSolverr session ready: ' . self::SESSION, 'green'); + + // ---- pick games to crawl ---- + $games = []; + + if ($slug !== null) { + // single game + $g = Mongo::collection('games')->findOne(['slug' => $slug]); + if ($g !== null) { + $games[] = $g; + } + } elseif ($batch !== null) { + // expand from catalog: next N slugs not yet detailed + CLI::write("Batch mode: expanding {$batch} games from catalog…", 'yellow'); + $slugs = Mongo::collection('catalog') + ->find(['crawled' => false], ['projection' => ['slug' => 1], 'sort' => ['_id' => 1], 'limit' => $batch]) + ->toArray(); + + foreach ($slugs as $c) { + $cslug = $c['slug']; + $existing = Mongo::collection('games')->findOne(['slug' => $cslug]); + if ($existing !== null) { + // already detailed; mark catalog crawled and skip + Mongo::collection('catalog')->updateOne(['slug' => $cslug], ['$set' => ['crawled' => true]]); + continue; + } + $games[] = ['slug' => $cslug, 'title' => $cslug]; + } + } else { + // refresh all games we already have + $filter = []; + $query = Mongo::collection('games')->find($filter, ['sort' => ['trending' => -1]]); + if ($limit !== null) { + $query = Mongo::collection('games')->find($filter, ['sort' => ['trending' => -1], 'limit' => $limit]); + } + $games = $query->toArray(); + } + + CLI::write('Games to crawl: ' . count($games), 'yellow'); + + $updated = 0; + $failed = 0; + + foreach ($games as $i => $game) { + $gslug = $game['slug']; + $url = 'https://gg.deals/game/' . $gslug . '/'; + + $result = $this->fsRequest([ + 'cmd' => 'request.get', + 'url' => $url, + 'session' => self::SESSION, + 'maxTimeout' => 30000, + 'disableMedia'=> true, + ]); + + $html = $result['solution']['response'] ?? null; + if ($html === null || $html === '' || str_contains($html, 'Just a moment')) { + CLI::error(" [{$i}] {$gslug}: failed (challenge or empty)"); + $failed++; + usleep(1000000); + continue; + } + + $parsed = $this->parseGamePage($html, $gslug, $game); + if ($parsed !== null) { + $this->applyToDb($gslug, $parsed); + $updated++; + CLI::write(" [{$i}] {$gslug}: saved (best \${$parsed['best_price']})"); + } else { + $failed++; + } + + // mark catalog as processed regardless (so we don't retry endlessly) + Mongo::collection('catalog')->updateOne( + ['slug' => $gslug], + ['$set' => ['crawled' => true, 'crawled_at' => new UTCDateTime(time() * 1000)]], + ['upsert' => true] + ); + + usleep(400000); + } + + // destroy session when done + $this->fsRequest(['cmd' => 'sessions.destroy', 'session' => self::SESSION]); + CLI::write("Done: {$updated} updated, {$failed} failed.", 'green'); + } + + /** + * Parse a gg.deals game page using its JSON-LD structured data. + * Returns best price + all offers from the AggregateOffer block. + */ + private function parseGamePage(string $html, string $slug, array $existing): ?array + { + // extract JSON-LD blocks + if (! preg_match_all('/ + + + + +
+
+ Displayed at — + PHP: — + CodeIgniter: -- + Environment: +
+
+

getCode() ? ' #' . $exception->getCode() : '') ?>

+

+ getMessage())) ?> + getMessage())) ?>" + rel="noreferrer" target="_blank">search → +

+
+
+ + +
+

at line

+ + +
+ +
+ +
+ +
+ getPrevious()) { + $last = $prevException; + ?> + +
+    Caused by:
+    getCode() ? ' #' . $prevException->getCode() : '') ?>
+
+    getMessage())) ?>
+    getMessage())) ?>"
+       rel="noreferrer" target="_blank">search →
+    getFile()) . ':' . $prevException->getLine()) ?>
+    
+ + +
+ + +
+ + + +
+ + +
+ +
    + $row) : ?> + +
  1. +

    + + + + + {PHP internal code} + + + + +   —   + + + ( arguments ) +

    + + + getParameters(); + } + + foreach ($row['args'] as $key => $value) : ?> + + + + + + +
    name : "#{$key}") ?>
    +
    + + () + + + + +   —   () + +

    + + + +
    + +
    + +
  2. + + +
+ +
+ + +
+ + + +

$

+ + + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ + + + + + +

Constants

+ + + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathgetUri()) ?>
HTTP MethodgetMethod()) ?>
IP AddressgetIPAddress()) ?>
Is AJAX Request?isAJAX() ? 'yes' : 'no' ?>
Is CLI Request?isCLI() ? 'yes' : 'no' ?>
Is Secure Request?isSecure() ? 'yes' : 'no' ?>
User AgentgetUserAgent()->getAgentString()) ?>
+ + + + + + + + +

$

+ + + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ + + + + +
+ No $_GET, $_POST, or $_COOKIE Information to show. +
+ + + + headers(); ?> + + +

Headers

+ + + + + + + + + + $value) : ?> + + + + + + +
HeaderValue
+ getValueLine(), 'html'); + } else { + foreach ($value as $i => $header) { + echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html'); + } + } + ?> +
+ + +
+ + + setStatusCode(http_response_code()); + ?> +
+ + + + + +
Response StatusgetStatusCode() . ' - ' . $response->getReasonPhrase()) ?>
+ + headers(); ?> + +

Headers

+ + + + + + + + + + $value) : ?> + + + + + + +
HeaderValue
+ getHeaderLine($name), 'html'); + } else { + foreach ($value as $i => $header) { + echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html'); + } + } + ?> +
+ + +
+ + +
+ + +
    + +
  1. + +
+
+ + +
+ + + + + + + + + + + + + + + + +
Memory Usage
Peak Memory Usage:
Memory Limit:
+ +
+ +
+ +
+ + + + diff --git a/app/Views/errors/html/production.php b/app/Views/errors/html/production.php new file mode 100644 index 0000000..2f59a8d --- /dev/null +++ b/app/Views/errors/html/production.php @@ -0,0 +1,25 @@ + + + + + + + <?= lang('Errors.whoops') ?> + + + + + +
+ +

+ +

+ +
+ + + + diff --git a/app/Views/faq.php b/app/Views/faq.php new file mode 100644 index 0000000..d3ee427 --- /dev/null +++ b/app/Views/faq.php @@ -0,0 +1,45 @@ +extend('layout') ?> +section('content') ?> + +
+

Frequently Asked Questions

+ +
+ [$q, $a]): ?> +
+ +
+

+
+
+ +
+
+ +
+

Tutorials

+
+ + + 📖 + + + +
+
+ +endSection() ?> diff --git a/app/Views/franchise.php b/app/Views/franchise.php new file mode 100644 index 0000000..3fba673 --- /dev/null +++ b/app/Views/franchise.php @@ -0,0 +1,40 @@ +extend('layout') ?> +section('content') ?> + +
+

Franchise

+

All games in the series with current best prices.

+
+ + + + +
+

All franchises

+
+ + + () + + +
+
+ + +endSection() ?> diff --git a/app/Views/franchises.php b/app/Views/franchises.php new file mode 100644 index 0000000..c791692 --- /dev/null +++ b/app/Views/franchises.php @@ -0,0 +1,18 @@ +extend('layout') ?> +section('content') ?> + +
+

Franchises

+

Browse game series and franchises.

+
+ + + +endSection() ?> diff --git a/app/Views/game.php b/app/Views/game.php new file mode 100644 index 0000000..d47bf4d --- /dev/null +++ b/app/Views/game.php @@ -0,0 +1,601 @@ +extend('layout') ?> +section('content') ?> + += 90 ? 'Overwhelmingly positive' : ($ratingPct >= 75 ? 'Very positive' : ($ratingPct >= 50 ? 'Mostly positive' : 'Mixed')); +?> + + +
+ Deals + + +
+ + +
+
+ +
+ <?= esc($game['title']) ?> + +
+

+
+ MC + · % + + + + +
+ +
+ active offers + + official store + · keyshop + +
+ +
+ + +
+
+
+ + + + +
+
Best current deal
+
+
+
+ + 0): ?> + -% + +
+
regular price
+
+
+
+ $bestStore, 'size' => 'lg']) ?> +
+
+
+ +
+
+
+ + + Go to store → + + +
+
+ All-time low: + + at +
+
+ Reached () +
+
+
+
+ +
+
+ + +
+
+ + +
+ +
+
+ +
+
+ Official Stores + 0): ?> + -% + +
+
+ + +
+
+ + $offStore]) ?> + +
+
+ + +
+
+ Keyshops + 0): ?> + -% + +
+
+ + +
+
+ + $keyStore]) ?> + +
+
+ + +
+ Subscriptions + +
+
+
included in the library
+ +
Not available
+ +
+ + +
+ All-time low +
+ + +
+
+ $hlStore]) ?> + +
+
+
+
+ +
+
+
+
Official Stores Low
+
+
Lowest official-store price right now
+
+
+
Keyshops Low
+
+
Lowest keyshop price right now
+
+
+
+
+ + +
+
+ + +
+ + +
+ +
+
+

Official stores

+ Verified & trusted +
+
+ +
No official store deals right now.
+ +
+ + + + + + + + + + + + + + + + + + + + + +
StoreDRMDiscountPrice
+
+ $ds]) ?> + + ★ HL +
+
+ 0): ?> + -% + + + + + + + + + toDateTime()->getTimestamp() - time()) / 86400)); ?> + ends in d + + Buy +
+
+ +
+
+ + +
+
+

Keyshops

+ Marketplace sellers +
+
+ +
No keyshop offers right now.
+ +
+ + + + + + + + + + + + + + + + + + + + + +
StoreEditionDiscountPrice
+
+ $ds]) ?> + + ★ HL +
+
+ Global + + · ⚠ region-locked + + + 0): ?> + -% + + + + + + + + Buy +
+
+ +
+ Keyshop offers are sold by marketplace sellers. GG.deals is not responsible for their content. +
+
+
+
+ + +
+
+
+

price history

+

Lowest recorded price per day

+
+
+ +
+
+
+
+
+
All-time low
+
+
·
+
+
+
Best official store now
+
+
+
+
+
Best keyshop now
+
+
+
+
+
+
+ + +
+
+

About

+

+
+ Developer: + Publisher: + Age rating: + Release: +
+
+ +
+

System Requirements

+ +
+ 'OS', 'cpu' => 'CPU', 'ram' => 'RAM', 'gpu' => 'GPU', 'storage' => 'Storage'] as $k => $label): ?> +
+ + +
+ +
+ +
+
How long to beat
+
+
h
Main
+
h
Completion
+
h
100%
+
+
+ +
+
+ + + +
+
+

Bundles with

+ See all → +
+ +
+ + + + +
+
+

Series & franchises

+
+
+ + + () + + +
+
+ + + + +
+
+

Similar games

+
+ +
+ + + + + +endSection() ?> + +section('scripts') ?> + +endSection() ?> diff --git a/app/Views/games.php b/app/Views/games.php new file mode 100644 index 0000000..d6bd265 --- /dev/null +++ b/app/Views/games.php @@ -0,0 +1,277 @@ +extend('layout') ?> +section('content') ?> + + $s['kind'] === 'official'); +$keyshopStores = array_filter($stores, static fn ($s) => $s['kind'] === 'keyshop'); +$selectedStores = $filters['stores']; +$selectedGenres = $filters['genres'] ?? []; +$anyFilter = $selectedStores !== [] || $filters['kind'] !== '' || $filters['min_price'] !== '' + || $filters['max_price'] !== '' || $filters['min_cut'] > 0 || $filters['hl_only'] || $filters['q'] !== '' + || $filters['price_tier'] !== '' || $filters['discount_tier'] !== '' || $filters['deal_rating'] !== '' + || $filters['release_range'] !== '' || $selectedGenres !== []; +$genreQuery = $activeGenre !== '' ? '?genre=' . urlencode($activeGenre) : ''; +?> + + +
+
+

+ +

+

+ games with deals · + games in database · + stores +

+
+
+ + +
+ + +
+ + +
+ Filters + + Reset + +
+ +
+ + +
+
Deal type
+
+ 'All deals', 'official' => 'Official', 'keyshop' => 'Keyshops'] as $k => $label): ?> + + +
+
+ + +
+
Price
+
+ 'Any', '2' => 'Under $2', '5' => 'Under $5', '10' => 'Under $10', '15' => 'Under $15', '20' => 'Under $20', '30' => 'Under $30']; + foreach ($priceTiers as $v => $l): ?> + + +
+
+ + + +
+
+ + +
+
Discount
+
+ 'Any', '50' => '≥50%', '66' => '≥66%', '75' => '≥75%', '80' => '≥80%', '85' => '≥85%', '90' => '≥90%']; + foreach ($discountTiers as $v => $l): ?> + + +
+
+
+ Minimum discount + +
+ +
+
+ + +
+
Deal rating
+
+ 'Any', 'hot' => '🔥 Hot', 'good' => 'Good', 'fair' => 'Fair']; + foreach ($ratings as $v => $l): ?> + + +
+
+ + +
+
Release year
+
+ 'Any', 'pre2000' => 'Before 2000', '2000s' => '2000–2009', '2010s' => '2010–2019', '2020s' => '2020+']; + foreach ($years as $v => $l): ?> + + +
+
+ + +
+
Genres
+
+ + +
+
+ $g): + $gl = mb_strtolower($g['name']); ?> + + +
+
+ + + + + +
+
Official stores
+
+ + + +
+
+ +
+
Keyshops
+
+ + + +
+
+ + +
+
+ + +
+ +
+
+ 'Best deals', + 'rating' => 'Deal rating', + 'cut' => 'Biggest discount', + 'hl' => 'Cheapest ever', + 'recent' => 'New releases', + ]; + foreach ($tabs as $key => $label): ?> + + + + +
+
+ + +
+
+ + +
+ +
+
🕹️
+
No games match your filters
+
Try widening the price range or clearing store filters.
+ Reset filters +
+ + + $row, 'stores' => $storesMap]) ?> + +
+ + + 1): ?> +
+ 1): ?> + ← Prev + 1): ?> + 1 + + + + + + + + Next → + +
+ +
+
+ +endSection() ?> diff --git a/app/Views/home.php b/app/Views/home.php new file mode 100644 index 0000000..5b0419e --- /dev/null +++ b/app/Views/home.php @@ -0,0 +1,332 @@ +extend('layout') ?> +section('content') ?> + + $s['kind'] === 'official'); +$keyshopStores = array_filter($stores, static fn ($s) => $s['kind'] === 'keyshop'); +$selectedStores = $filters['stores']; +$anyFilter = $selectedStores !== [] || $filters['kind'] !== '' || $filters['min_price'] !== '' + || $filters['max_price'] !== '' || $filters['min_cut'] > 0 || $filters['hl_only'] || $filters['q'] !== '' + || $filters['price_tier'] !== '' || $filters['discount_tier'] !== '' || $filters['deal_rating'] !== '' + || $filters['release_range'] !== ''; +?> + + +
+
+

+ +

+

+ games · + deals from + stores · + all-time lows right now +

+
+ +
+ Search: + clear +
+ +
+ + +
+ 'Best deals', + 'hot-new-deals' => '🔥 Hot new deals', + 'new-deals' => 'New deals', + 'historical-lows' => 'Historical lows', + 'ending-soon' => 'Ending soon', + 'at-least-75-off' => '≥75% off', + 'at-least-85-off' => '≥85% off', + ]; + $currentPreset = $activePreset ?? ''; + $presetKey = ''; + foreach ($presets as $k => $p) { + if ($p['title'] === $currentPreset) { $presetKey = $k; break; } + } + foreach ($presetLinks as $key => $label): ?> + + + + +
+ + +
+
+

🔥 Trending deals

+
+ + +
+
+ +
+ + +
+ + +
+ + +
+ Filters + + Reset + +
+ +
+ + +
+
Deal type
+
+ 'All deals', 'official' => 'Official', 'keyshop' => 'Keyshops'] as $k => $label): ?> + + +
+
+ + +
+
Price
+
+ 'Any', '2' => 'Under $2', '5' => 'Under $5', '10' => 'Under $10', '15' => 'Under $15', '20' => 'Under $20', '30' => 'Under $30']; + foreach ($priceTiers as $v => $l): ?> + + +
+
+ + + +
+
+ + +
+
Discount
+
+ 'Any', '50' => '≥50%', '66' => '≥66%', '75' => '≥75%', '80' => '≥80%', '85' => '≥85%', '90' => '≥90%']; + foreach ($discountTiers as $v => $l): ?> + + +
+
+
+ Minimum discount + +
+ +
+
+ + +
+
Deal rating
+
+ 'Any', 'hot' => '🔥 Hot', 'good' => 'Good', 'fair' => 'Fair']; + foreach ($ratings as $v => $l): ?> + + +
+
+ + +
+
Release year
+
+ 'Any', 'pre2000' => 'Before 2000', '2000s' => '2000–2009', '2010s' => '2010–2019', '2020s' => '2020+']; + foreach ($years as $v => $l): ?> + + +
+
+ + + + + +
+ + Only games from wishlist +
+ + +
+
Official stores
+
+ + + +
+
+ +
+
Keyshops
+
+ + + +
+
+ + +
+
+ + +
+ +
+
+ 'Best deals', + 'rating' => 'Deal rating', + 'cut' => 'Biggest discount', + 'hl' => 'Cheapest ever', + 'recent' => 'New releases', + ]; + foreach ($tabs as $key => $label): ?> + + + + +
+
+ + +
+
+ + +
+ +
+
🕹️
+
No deals match your filters
+
Try widening the price range or clearing store filters.
+ Reset filters +
+ + + $row, 'stores' => $storesMap]) ?> + +
+ + + 1): ?> +
+ 1): ?> + ← Prev + 1): ?> + 1 + + + + + + + + Next → + +
+ +
+
+ +endSection() ?> diff --git a/app/Views/homepage.php b/app/Views/homepage.php new file mode 100644 index 0000000..c1fa858 --- /dev/null +++ b/app/Views/homepage.php @@ -0,0 +1,132 @@ +extend('layout') ?> +section('content') ?> + + ['Most Popular Games', 'popular', '/deals'], + 'newDeals' => ['New deals', 'new', '/deals/new-deals'], + 'bestDeals' => ['Best deals', 'hot', '/deals/hot-new-deals'], + 'histLows' => ['Historical lows', 'hl', '/deals/historical-lows'], + 'endingSoon' => ['Ending Soon', 'ending', '/deals/ending-soon'], +]; +?> + + +
+
+
+
Games tracked
+
+
+
+
Deals today
+
+
+
+
Stores
+
+
+
+
All-time lows now
+
+
+ Compare prices of video games from stores, + find the best game deals online. CD keys for PC games at the best price! +
+
+ + + [$title, $mode, $link]): ?> + +
+
+

+ See all → +
+
+ + $d, 'stores' => $stores]) ?> + +
+
+ + + + +
+ ['🔥 Most Wanted', 'wanted'], + 'newReleases' => ['🆕 New releases', 'releases'], + 'upcoming' => ['📅 Upcoming games', 'upcoming'], + ]; + foreach ($gameSections as $key => [$title, $mode]): ?> +
+
+

+ See all +
+
+ $g): ?> + + + +
+
+
+ + +
+
+
+ +
+
+ +
+ + +
+ +
+
+

📰 News

+ All news → +
+ +
+ + +
+
+

🎁 Prepaids

+ All → +
+ +
+
+ +endSection() ?> diff --git a/app/Views/layout.php b/app/Views/layout.php new file mode 100644 index 0000000..d29ae0d --- /dev/null +++ b/app/Views/layout.php @@ -0,0 +1,297 @@ + + + + + + <?= esc($pageTitle ?? 'GG.deals — best game deals') ?> + + + + + +
+ + +
+
+
+ PC deals + Console deals + Free games + Giveaways +
+
+ +
+ +
+ + + +
+
+ + +
+ +
+ + + +
+
+ + + + + + + + Sign in +
+
+
+ + +
+
+ + GG.deals + + + +
+
+ + +
+
+ +
+
+ + + + + +
+
+ + +
+ renderSection('content') ?> +
+ + + +
+ + +renderSection('scripts') ?> + + diff --git a/app/Views/news.php b/app/Views/news.php new file mode 100644 index 0000000..b173168 --- /dev/null +++ b/app/Views/news.php @@ -0,0 +1,38 @@ +extend('layout') ?> +section('content') ?> + +
+

Video game news

+

Deals, bundles, freebies, giveaways and subscriptions — all in one feed.

+
+ + +
+ $label): ?> + + + + +
+ + + +endSection() ?> diff --git a/app/Views/news_article.php b/app/Views/news_article.php new file mode 100644 index 0000000..790560c --- /dev/null +++ b/app/Views/news_article.php @@ -0,0 +1,71 @@ +extend('layout') ?> +section('content') ?> + +
+ News + + +
+ +
+
+ + + · + +
+ +

+ + + + View related game page → + + + +
+

+ + +
+
+
FREE to keep — limited time
+
Claim it now before the offer expires
+
+ + Claim now → + +
+ + + +
+
+
Giveaway in progress
+
Enter now for a chance to win
+
+ +
+ +
+ + + +
+

More news

+
+ + +
+
+
+ +
+
+ +
+ +endSection() ?> diff --git a/app/Views/partials/dash_deal.php b/app/Views/partials/dash_deal.php new file mode 100644 index 0000000..00f3d63 --- /dev/null +++ b/app/Views/partials/dash_deal.php @@ -0,0 +1,30 @@ + store map + */ +$g = $d['game']; +$store = $stores[$d['best_store']] ?? null; +$year = isset($g['release_date']) && $g['release_date'] instanceof MongoDB\BSON\UTCDateTime + ? (string) $g['release_date']->toDateTime()->format('Y') + : ''; +?> +
+ + <?= esc($g['title']) ?> + +
+ +
+ $store]) ?> + +
+
+
+ 0): ?> +
-%
+ +
+
+
diff --git a/app/Views/partials/deal_row.php b/app/Views/partials/deal_row.php new file mode 100644 index 0000000..4f930d8 --- /dev/null +++ b/app/Views/partials/deal_row.php @@ -0,0 +1,90 @@ + store map + */ +$g = $row['game']; +$store = $stores[$row['best_store']] ?? null; +$hl = $g['all_time_low']; +$hlStore = $stores[$hl['store']] ?? null; +$rating = deal_rating((float) $hl['price'], (float) $row['best_price']); +$year = bson_date($g['release_date']); +$endsWithin = (int) ($row['ends_within'] ?? 0); +$endingSoon = $endsWithin > 0 && $endsWithin < 7 * 86400000; +$endsDays = $endingSoon ? max(1, (int) ceil($endsWithin / 86400000)) : 0; +?> +
+
+ + <?= esc($g['title']) ?> + + d + + + +
+
+
+
+ + ★ New HL + +
+
+ MC + + + +
+
+ +
+
+ 0): ?> + -% + + +
+
+
+
+ +
+ + Historical low + + at + + + + + ★ All-time low right now + +
+
+ + +
+
diff --git a/app/Views/partials/store_badge.php b/app/Views/partials/store_badge.php new file mode 100644 index 0000000..fc6126f --- /dev/null +++ b/app/Views/partials/store_badge.php @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/app/Views/prepaids.php b/app/Views/prepaids.php new file mode 100644 index 0000000..f7921dd --- /dev/null +++ b/app/Views/prepaids.php @@ -0,0 +1,33 @@ +extend('layout') ?> +section('content') ?> + +
+

Gift Cards & Prepaids

+

Buy digital gift cards for your favorite platforms at the best prices.

+
+ +
+ +
+
+ + + +
+
+
+ +
+ $ + + +
+ +
+
+ +
+ +endSection() ?> diff --git a/app/Views/rankings.php b/app/Views/rankings.php new file mode 100644 index 0000000..de68e24 --- /dev/null +++ b/app/Views/rankings.php @@ -0,0 +1,52 @@ +extend('layout') ?> +section('content') ?> + +
+

Rankings

+

Top games by wishlist, collection, critic scores and community reviews.

+
+ + ['🔥 Most Wishlisted', $wishlisted, 'trending'], + 'collected' => ['📦 Most Collected', $collected, 'rating_pct'], + 'metacritic' => ['⭐ Metacritic', $metacritic, 'score'], + 'reviews' => ['💬 Steam Reviews', $reviews, 'rating_pct'], +]; +?> + +
+ [$title, $list, $field]): ?> +
+
+

+ Top +
+ +
+ +
+ +endSection() ?> diff --git a/app/Views/stores.php b/app/Views/stores.php new file mode 100644 index 0000000..f3bc5ae --- /dev/null +++ b/app/Views/stores.php @@ -0,0 +1,40 @@ +extend('layout') ?> +section('content') ?> + +
+

Stores

+

All stores tracked by GG.deals — official stores and keyshop marketplaces.

+
+ + + +endSection() ?> diff --git a/app/Views/subscriptions.php b/app/Views/subscriptions.php new file mode 100644 index 0000000..9f0e017 --- /dev/null +++ b/app/Views/subscriptions.php @@ -0,0 +1,34 @@ +extend('layout') ?> +section('content') ?> + +
+

Subscriptions

+

Game subscription services — get hundreds of games for a monthly fee.

+
+ +
+ +
+
+ + + + +
+
+

+
+ + per month +
+ +
+ $st]) ?> + +
+
+ +
+ +endSection() ?> diff --git a/app/Views/tutorial.php b/app/Views/tutorial.php new file mode 100644 index 0000000..27077dc --- /dev/null +++ b/app/Views/tutorial.php @@ -0,0 +1,82 @@ +extend('layout') ?> +section('content') ?> + +
+ FAQ + + +
+ +
+

+ + [ + 'Open the Steam client on your computer and sign in.', + 'Click "Games" in the top menu, then "Activate a Product on Steam".', + 'Click "Next" and agree to the Steam Subscriber Agreement.', + 'Enter the CD key you purchased — it looks like XXXXX-XXXXX-XXXXX.', + 'Click "Next". The game will be added to your library and begin downloading automatically.', + 'You can also do this in the browser: store.steampowered.com/account/registerkey.', + ], + 'gog' => [ + 'Sign in to your GOG.com account in the browser or GOG Galaxy client.', + 'In the browser, hover over your username and click "Account".', + 'Click "Redeem code" in the account menu.', + 'Enter the code from your purchase and click "Redeem".', + 'The game is added to your library. In GOG Galaxy it appears immediately — install from the library tab.', + ], + 'epic' => [ + 'Open the Epic Games Launcher and sign in.', + 'Click your profile icon (top right) and select "Redeem Code".', + 'Enter the code — it is usually 5 groups of 5 characters.', + 'Click "Redeem". The game is added to your library.', + 'Find it under your library and click "Install" to download.', + ], + 'origin' => [ + 'Open the EA app (formerly Origin) and sign in.', + 'Click "Menu" (≡) in the top-left, then "Redeem Product Code".', + 'Enter the code and click "Next".', + 'The game appears in your library. Select it and click "Download".', + ], + 'uplay' => [ + 'Open Ubisoft Connect (Uplay) and sign in.', + 'Click "Games" then "Activate a key" in the top bar.', + 'Enter your product key.', + 'Click "Activate". The game is added to your library.', + 'Ubisoft Connect also supports claiming keys directly on account.ubisoft.com.', + ], + 'keyshops' => [ + 'Keyshops are marketplaces where third-party sellers list game keys.', + 'They are usually cheaper than official stores — sometimes significantly.', + 'The main risks: region-locked keys that may not activate in your country, and keys purchased with stolen payment methods that can be revoked later.', + 'Always check the key region before buying. Global keys are the safest option.', + 'You can disable keyshop prices entirely in the site settings (Keyshops toggle).', + 'If a key fails to activate, contact the seller first — most marketplaces have buyer protection.', + ], + }; + ?> +
    + $step): ?> +
  1. + + +
  2. + +
+
+ +
+

Other tutorials

+
+ [$title, $plat]) : if ($key === array_search($tutorial, $all, true)) continue; ?> + + 📖 + + + +
+
+ +endSection() ?> diff --git a/app/Views/vouchers.php b/app/Views/vouchers.php new file mode 100644 index 0000000..ed3c355 --- /dev/null +++ b/app/Views/vouchers.php @@ -0,0 +1,58 @@ +extend('layout') ?> +section('content') ?> + +
+

Promo Codes & Coupons

+

Active discount codes across all stores. Copy a code and apply it at checkout.

+
+ + +
+
🎟️
+
No active vouchers for this store
+
+ +
+ toDateTime()->getTimestamp() - time()) / 86400)) + : null; + ?> +
+
+ $store, 'size' => 'lg']) ?> +
+
+
+
+
+ +
+ + +
+ +
+ -% + 0): ?>min. + expires in d +
+ +

+ +
+ +
+ + +endSection() ?> diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php new file mode 100644 index 0000000..c18eca3 --- /dev/null +++ b/app/Views/welcome_message.php @@ -0,0 +1,331 @@ + + + + + Welcome to CodeIgniter 4! + + + + + + + + + + + +
+ + + +
+ +

Welcome to CodeIgniter

+ +

The small framework with powerful features

+ +
+ +
+ + + +
+ +

About this page

+ +

The page you are looking at is being generated dynamically by CodeIgniter.

+ +

If you would like to edit this page you will find it located at:

+ +
app/Views/welcome_message.php
+ +

The corresponding controller for this page can be found at:

+ +
app/Controllers/Home.php
+ +
+ +
+ +
+ +

Go further

+ +

+ + Learn +

+ +

The User Guide contains an introduction, tutorial, a number of "how to" + guides, and then reference documentation for the components that make up + the framework. Check the User Guide !

+ +

+ + Discuss +

+ +

CodeIgniter is a community-developed open source project, with several + venues for the community members to gather and exchange ideas. View all + the threads on CodeIgniter's forum, or chat on Slack !

+ +

+ + Contribute +

+ +

CodeIgniter is a community driven project and accepts contributions + of code and documentation from the community. Why not + + join us ?

+ +
+ +
+ + + +
+
+ +

Page rendered in {elapsed_time} seconds using {memory_usage} MB of memory.

+ +

Environment:

+ +
+ +
+ +

© CodeIgniter Foundation. CodeIgniter is open source project released under the MIT + open source licence.

+ +
+ +
+ + + + + + + + + diff --git a/app/Views/wishlist.php b/app/Views/wishlist.php new file mode 100644 index 0000000..71d7d65 --- /dev/null +++ b/app/Views/wishlist.php @@ -0,0 +1,71 @@ +extend('layout') ?> +section('content') ?> + +
+

Your wishlist

+

+ games saved · tracked in your browser +

+
+ +
+
Loading…
+ +
+
💜
+
Your wishlist is empty
+

Browse the deals and click the heart on any game to save it here.

+ Browse deals +
+ +
+ +
+
+ +endSection() ?> + +section('scripts') ?> + +endSection() ?> diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..69df4e1 --- /dev/null +++ b/app/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..5311bcf --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,288 @@ +@import "tailwindcss"; + +@source "../../app/Views"; + +@theme { + --color-page: #13131c; + --color-panel: #1a1a26; + --color-card: #20202d; + --color-elev: #2a2a3c; + --color-line: #2e2e42; + --color-muted: #9b9bb0; + --color-accent: #8be04b; + --color-accent-ink: #12290a; + + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +[x-cloak] { display: none !important; } + +html { color-scheme: dark; } + +::selection { background: rgba(139, 224, 75, 0.35); } + +/* ---------- scrollbars ---------- */ +.gg-scroll { scrollbar-width: thin; scrollbar-color: #3a3a52 transparent; } +.gg-scroll::-webkit-scrollbar { height: 8px; width: 8px; } +.gg-scroll::-webkit-scrollbar-thumb { background: #3a3a52; border-radius: 8px; } +.gg-scroll::-webkit-scrollbar-track { background: transparent; } + +/* ---------- discount badges ---------- */ +.cut { color: #dff5c8; } +.cut-lo { background: #3f7d29; } +.cut-mid { background: #4f9d2c; } +.cut-hi { background: #63bd38; } +.cut-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 800; + line-height: 1; + padding: 4px 6px; + border-radius: 6px; +} + +/* ---------- score / chips ---------- */ +.score-chip { + display: inline-block; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.02em; + color: #d8db9a; + background: #46442a; + border: 1px solid #5d5a33; + padding: 3px 7px; + border-radius: 6px; +} +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + color: #b9b9c9; + background: #26263a; + border: 1px solid #34344c; + padding: 3px 10px; + border-radius: 999px; + transition: border-color .15s; +} +.chip-green { color: #a9e77c; border-color: #466331; background: #24331c; } +.chip-amber { color: #ecc978; border-color: #6b5525; background: #362c17; } + +/* ---------- store badges ---------- */ +.store-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.03em; + flex-shrink: 0; + overflow: hidden; +} +.store-badge-logo { + width: 100%; + height: 100%; + object-fit: contain; + padding: 3px; +} +.store-badge.lg { + width: 34px; + height: 34px; + border-radius: 8px; +} +.store-badge.lg .store-badge-logo { + padding: 5px; +} + +/* ---------- deal ratings ---------- */ +.rating { + display: inline-block; + font-size: 11px; + font-weight: 800; + padding: 2.5px 8px; + border-radius: 999px; +} +.rating-best { background: #2c5e14; color: #b7f383; } +.rating-great { background: #38561f; color: #aede7d; } +.rating-good { background: #565120; color: #e4d97e; } +.rating-fair { background: #5d4719; color: #ecc978; } +.rating-high { background: #5e2424; color: #f09c9c; } + +.hl-flag { + font-size: 10px; + font-weight: 800; + color: #ffd86e; + background: rgba(255, 216, 110, 0.09); + border: 1px solid rgba(255, 216, 110, 0.35); + padding: 2px 7px; + border-radius: 999px; +} + +/* ---------- kind tags ---------- */ +.kind-tag { + display: inline-block; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2.5px 7px; + border-radius: 5px; +} +.kind-official { color: #a9e77c; background: rgba(139, 224, 75, 0.1); border: 1px solid rgba(139, 224, 75, 0.3); } +.kind-keyshop { color: #ffc896; background: rgba(255, 166, 87, 0.1); border: 1px solid rgba(255, 166, 87, 0.3); } + +/* ---------- filters ---------- */ +.filter-label { + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #83839c; +} +.filter-input { + width: 100%; + background: #16161f; + border: 1px solid #2e2e42; + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; + color: #d4d4e2; + outline: none; +} +.filter-input:focus { border-color: rgba(139, 224, 75, 0.6); } +.filter-input::placeholder { color: #5c5c74; } + +.preset-chip { + font-size: 10.5px; + font-weight: 600; + color: #9b9bb0; + background: #16161f; + border: 1px solid #2e2e42; + padding: 3px 8px; + border-radius: 999px; + transition: all .15s; +} +.preset-chip:hover { color: #8be04b; border-color: rgba(139, 224, 75, 0.5); } +.preset-chip.chip-active { + color: #8be04b; + border-color: rgba(139, 224, 75, 0.55); + background: rgba(139, 224, 75, 0.08); +} + +.store-row { + display: flex; + align-items: center; + gap: 9px; + padding: 5px 7px; + border-radius: 8px; + transition: background .15s; +} +.store-row:hover { background: #26263a; } + +/* range slider */ +.gg-range { -webkit-appearance: none; appearance: none; height: 5px; background: #2e2e42; border-radius: 999px; outline: none; } +.gg-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; height: 16px; + border-radius: 50%; + background: #8be04b; + border: 2px solid #12290a; + cursor: pointer; +} +.gg-range::-moz-range-thumb { + width: 14px; height: 14px; + border-radius: 50%; + background: #8be04b; + border: 2px solid #12290a; + cursor: pointer; +} + +/* checkbox */ +.gg-check { + appearance: none; + width: 15px; height: 15px; + border: 1.5px solid #454563; + border-radius: 4.5px; + background: #16161f; + cursor: pointer; + flex-shrink: 0; + position: relative; +} +.gg-check:checked { + background: #8be04b; + border-color: #8be04b; +} +.gg-check:checked::after { + content: ""; + position: absolute; + left: 4px; top: 1px; + width: 4px; height: 8px; + border: solid #12290a; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +/* ---------- buttons ---------- */ +.btn-buy { + display: inline-block; + font-size: 12px; + font-weight: 800; + color: #12290a; + background: #8be04b; + padding: 6px 16px; + border-radius: 7px; + transition: background .15s; +} +.btn-buy:hover { background: #9cf465; } + +/* ---------- pagination ---------- */ +.pg-btn { + display: inline-block; + font-size: 12.5px; + font-weight: 700; + color: #b9b9c9; + background: #20202d; + border: 1px solid #2e2e42; + padding: 7px 13px; + border-radius: 8px; + transition: all .15s; +} +.pg-btn:hover { color: #8be04b; border-color: rgba(139, 224, 75, 0.5); } +.pg-active { color: #12290a; background: #8be04b; border-color: #8be04b; } +.pg-active:hover { color: #12290a; } +.pg-dots { color: #5c5c74; padding: 7px 2px; } + +/* ---------- light theme ---------- */ +html.theme-light { + color-scheme: light; +} +html.theme-light { + --color-page: #f4f4f7; + --color-panel: #ffffff; + --color-card: #ffffff; + --color-elev: #e8e8ef; + --color-line: #d8d8e2; + --color-muted: #6b6b80; + --color-accent: #4c9e1f; + --color-accent-ink: #ffffff; +} +html.theme-light body { + background: var(--color-page); + color: #27273a; +} +html.theme-light .text-zinc-100, html.theme-light .text-zinc-200, html.theme-light .text-white { color: #1c1c2e !important; } +html.theme-light .text-zinc-300, html.theme-light .text-zinc-400 { color: #4a4a5e !important; } +html.theme-light .bg-page { background: #f4f4f7 !important; } +html.theme-light .bg-panel { background: #ffffff !important; } +html.theme-light .bg-card { background: #ffffff !important; } +html.theme-light .hover\:bg-elev:hover { background: #e8e8ef !important; } +html.theme-light .border-line { border-color: #d8d8e2 !important; } +html.theme-light .filter-input, html.theme-light .preset-chip { background: #f4f4f7; border-color: #d8d8e2; color: #4a4a5e; } +html.theme-light .chip { background: #f0f0f5; border-color: #d8d8e2; color: #4a4a5e; } +html.theme-light .score-chip { background: #e8e8c0; border-color: #c0c080; color: #5a5a20; } diff --git a/builds b/builds new file mode 100755 index 0000000..c68c089 --- /dev/null +++ b/builds @@ -0,0 +1,128 @@ +#!/usr/bin/env php + 'vcs', + 'url' => GITHUB_URL, + ]; + } + + $array['require']['codeigniter4/codeigniter4'] = $branch === 'next' ? NEXT_MINOR : DEVELOP_BRANCH; + unset($array['require']['codeigniter4/framework']); + } else { + unset($array['minimum-stability']); + + if (isset($array['repositories'])) { + foreach ($array['repositories'] as $i => $repository) { + if ($repository['url'] === GITHUB_URL) { + unset($array['repositories'][$i]); + break; + } + } + + if ($array['repositories'] === []) { + unset($array['repositories']); + } + } + + $array['require']['codeigniter4/framework'] = LATEST_RELEASE; + unset($array['require']['codeigniter4/codeigniter4']); + } + + file_put_contents($file, json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + + $modified[] = $file; + } else { + echo 'Warning: Unable to decode composer.json! Skipping...' . PHP_EOL; + } + } else { + echo 'Warning: Unable to read composer.json! Skipping...' . PHP_EOL; + } +} + +$files = [ + __DIR__ . DIRECTORY_SEPARATOR . 'app/Config/Paths.php', + __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.dist.xml', + __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml', +]; + +foreach ($files as $file) { + if (is_file($file)) { + $contents = file_get_contents($file); + + if ($branch !== 'release') { + $contents = str_replace('vendor/codeigniter4/framework', 'vendor/codeigniter4/codeigniter4', $contents); + } else { + $contents = str_replace('vendor/codeigniter4/codeigniter4', 'vendor/codeigniter4/framework', $contents); + } + + file_put_contents($file, $contents); + + $modified[] = $file; + } +} + +if ($modified === []) { + echo 'No files modified.' . PHP_EOL; +} else { + echo 'The following files were modified:' . PHP_EOL; + + foreach ($modified as $file) { + echo " * {$file}" . PHP_EOL; + } +} + +echo 'Run `composer update` to sync changes with your vendor folder.' . PHP_EOL; +echo 'Don\'t forget to update the project files in app folder from "vendor/codeigniter4/*/app/".' . PHP_EOL; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e53781f --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "codeigniter4/appstarter", + "description": "CodeIgniter4 starter app", + "license": "MIT", + "type": "project", + "homepage": "https://codeigniter.com", + "support": { + "forum": "https://forum.codeigniter.com/", + "source": "https://github.com/codeigniter4/CodeIgniter4", + "slack": "https://codeigniterchat.slack.com" + }, + "require": { + "php": "^8.2", + "codeigniter4/framework": "^4.7", + "mongodb/mongodb": "^2.4" + }, + "require-dev": { + "fakerphp/faker": "^1.9", + "mikey179/vfsstream": "^1.6", + "phpunit/phpunit": "^10.5.16" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Config\\": "app/Config/" + }, + "exclude-from-classmap": [ + "**/Database/Migrations/**" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\Support\\": "tests/_support" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + }, + "scripts": { + "test": "phpunit" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..d91af95 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2254 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d571d034233db836074d13ad7bd85c82", + "packages": [ + { + "name": "codeigniter4/framework", + "version": "v4.7.4", + "source": { + "type": "git", + "url": "https://github.com/codeigniter4/framework.git", + "reference": "67ead895b7491703e5e5bc17436778806192008f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/codeigniter4/framework/zipball/67ead895b7491703e5e5bc17436778806192008f", + "reference": "67ead895b7491703e5e5bc17436778806192008f", + "shasum": "" + }, + "require": { + "ext-intl": "*", + "ext-mbstring": "*", + "laminas/laminas-escaper": "^2.18", + "php": "^8.2", + "psr/log": "^3.0" + }, + "require-dev": { + "codeigniter/coding-standard": "^1.7", + "fakerphp/faker": "^1.24", + "friendsofphp/php-cs-fixer": "^3.47.1", + "kint-php/kint": "^6.1", + "mikey179/vfsstream": "^1.6.12", + "nexusphp/cs-config": "^3.6", + "phpunit/phpunit": "^10.5.16 || ^11.2", + "predis/predis": "^3.0" + }, + "suggest": { + "ext-apcu": "If you use Cache class ApcuHandler", + "ext-curl": "If you use CURLRequest class", + "ext-dom": "If you use TestResponse", + "ext-exif": "If you run Image class tests", + "ext-fileinfo": "Improves mime type detection for files", + "ext-gd": "If you use Image class GDHandler", + "ext-imagick": "If you use Image class ImageMagickHandler", + "ext-libxml": "If you use TestResponse", + "ext-memcache": "If you use Cache class MemcachedHandler with Memcache", + "ext-memcached": "If you use Cache class MemcachedHandler with Memcached", + "ext-mysqli": "If you use MySQL", + "ext-oci8": "If you use Oracle Database", + "ext-pcntl": "If you use Signals", + "ext-pgsql": "If you use PostgreSQL", + "ext-posix": "If you use Signals", + "ext-readline": "Improves CLI::input() usability", + "ext-redis": "If you use Cache class RedisHandler", + "ext-simplexml": "If you format XML", + "ext-sodium": "If you use Encryption SodiumHandler", + "ext-sqlite3": "If you use SQLite3", + "ext-sqlsrv": "If you use SQL Server", + "ext-xdebug": "If you use CIUnitTestCase::assertHeaderEmitted()" + }, + "type": "project", + "autoload": { + "psr-4": { + "CodeIgniter\\": "system/" + }, + "exclude-from-classmap": [ + "**/Database/Migrations/**" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The CodeIgniter framework v4", + "homepage": "https://codeigniter.com", + "support": { + "forum": "https://forum.codeigniter.com/", + "slack": "https://codeigniterchat.slack.com", + "source": "https://github.com/codeigniter4/CodeIgniter4" + }, + "time": "2026-07-07T09:23:35+00:00" + }, + { + "name": "laminas/laminas-escaper", + "version": "2.18.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-escaper.git", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/06f211dfffff18d91844c1f55250d5d13c007e18", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-escaper": "*" + }, + "require-dev": { + "infection/infection": "^0.31.0", + "laminas/laminas-coding-standard": "~3.1.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "homepage": "https://laminas.dev", + "keywords": [ + "escaper", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-escaper/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "source": "https://github.com/laminas/laminas-escaper" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-14T18:31:13+00:00" + }, + { + "name": "mongodb/mongodb", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/mongodb/mongo-php-library", + "reference": "066ee1fd2ed0418798d4e024299b33f75f915bb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/066ee1fd2ed0418798d4e024299b33f75f915bb4", + "reference": "066ee1fd2ed0418798d4e024299b33f75f915bb4", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-mongodb": "^2.4", + "php": "^8.1", + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php85": "^1.33" + }, + "replace": { + "mongodb/builder": "*" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "phpunit/phpunit": "^10.5.35", + "rector/rector": "^2.3.4", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "~6.14.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "MongoDB\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" + } + ], + "description": "MongoDB driver library", + "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "keywords": [ + "database", + "driver", + "mongodb", + "persistence" + ], + "time": "2026-08-18T14:20:54+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "mikey179/vfsstream", + "version": "v1.6.12", + "source": { + "type": "git", + "url": "https://github.com/bovigo/vfsStream.git", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5||^8.5||^9.6", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-0": { + "org\\bovigo\\vfs\\": "src/main/php" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Frank Kleine", + "homepage": "http://frankkleine.de/", + "role": "Developer" + } + ], + "description": "Virtual file system to mock the real file system in unit tests.", + "homepage": "http://vfs.bovigo.org/", + "support": { + "issues": "https://github.com/bovigo/vfsStream/issues", + "source": "https://github.com/bovigo/vfsStream/tree/master", + "wiki": "https://github.com/bovigo/vfsStream/wiki" + }, + "time": "2024-08-29T18:43:31+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.64", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.64" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:50:35+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-08-11T05:27:39+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/env b/env new file mode 100644 index 0000000..f359ec2 --- /dev/null +++ b/env @@ -0,0 +1,69 @@ +#-------------------------------------------------------------------- +# Example Environment Configuration file +# +# This file can be used as a starting point for your own +# custom .env files, and contains most of the possible settings +# available in a default install. +# +# By default, all of the settings are commented out. If you want +# to override the setting, you must un-comment it by removing the '#' +# at the beginning of the line. +#-------------------------------------------------------------------- + +#-------------------------------------------------------------------- +# ENVIRONMENT +#-------------------------------------------------------------------- + +# CI_ENVIRONMENT = production + +#-------------------------------------------------------------------- +# APP +#-------------------------------------------------------------------- + +# app.baseURL = '' +# If you have trouble with `.`, you could also use `_`. +# app_baseURL = '' +# app.forceGlobalSecureRequests = false +# app.CSPEnabled = false + +#-------------------------------------------------------------------- +# DATABASE +#-------------------------------------------------------------------- + +# database.default.hostname = localhost +# database.default.database = ci4 +# database.default.username = root +# database.default.password = root +# database.default.DBDriver = MySQLi +# database.default.DBPrefix = +# database.default.port = 3306 + +# If you use MySQLi as tests, first update the values of Config\Database::$tests. +# database.tests.hostname = localhost +# database.tests.database = ci4_test +# database.tests.username = root +# database.tests.password = root +# database.tests.DBDriver = MySQLi +# database.tests.DBPrefix = +# database.tests.charset = utf8mb4 +# database.tests.DBCollat = utf8mb4_general_ci +# database.tests.port = 3306 + +#-------------------------------------------------------------------- +# ENCRYPTION +#-------------------------------------------------------------------- + +# encryption.key = + +#-------------------------------------------------------------------- +# SESSION +#-------------------------------------------------------------------- + +# session.driver = 'CodeIgniter\Session\Handlers\FileHandler' +# session.savePath = null + +#-------------------------------------------------------------------- +# LOGGER +#-------------------------------------------------------------------- + +# logger.threshold = 4 diff --git a/package.json b/package.json new file mode 100644 index 0000000..2542626 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "ggdeals-clone", + "private": true, + "type": "module", + "dependencies": { + "chart.js": "^4.4.0", + "alpinejs": "^3.14.0" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.0.0", + "tailwindcss": "^4.0.0" + } +} diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..d9d2c6a --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + ./tests + + + + + + + + + + + + ./app + + + ./app/Views + ./app/Config/Routes.php + + + + + + + + + + + + + + + + diff --git a/preload.php b/preload.php new file mode 100644 index 0000000..288b30b --- /dev/null +++ b/preload.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Boot; +use Config\Paths; + +/* + *--------------------------------------------------------------- + * Sample file for Preloading + *--------------------------------------------------------------- + * See https://www.php.net/manual/en/opcache.preloading.php + * + * How to Use: + * 0. Copy this file to your project root folder. + * 1. Set the $paths property of the preload class below. + * 2. Set opcache.preload in php.ini. + * php.ini: + * opcache.preload=/path/to/preload.php + */ + +// Load the paths config file +require __DIR__ . '/app/Config/Paths.php'; + +// Path to the front controller +define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR); + +class preload +{ + /** + * @var array Paths to preload. + */ + private array $paths = [ + [ + 'include' => __DIR__ . '/vendor/codeigniter4/framework/system', // Change this path if using manual installation + 'exclude' => [ + // Not needed if you don't use them. + '/system/Database/OCI8/', + '/system/Database/Postgre/', + '/system/Database/SQLite3/', + '/system/Database/SQLSRV/', + // Not needed for web apps. + '/system/Database/Seeder.php', + '/system/Test/', + '/system/CLI/', + '/system/Commands/', + '/system/Publisher/', + '/system/ComposerScripts.php', + // Not Class/Function files. + '/system/Config/Routes.php', + '/system/Language/', + '/system/bootstrap.php', + '/system/util_bootstrap.php', + '/system/rewrite.php', + '/Views/', + // Errors occur. + '/system/ThirdParty/', + ], + ], + ]; + + public function __construct() + { + $this->loadAutoloader(); + } + + private function loadAutoloader(): void + { + $paths = new Paths(); + require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'Boot.php'; + + Boot::preload($paths); + } + + /** + * Load PHP files. + */ + public function load(): void + { + foreach ($this->paths as $path) { + $directory = new RecursiveDirectoryIterator($path['include']); + $fullTree = new RecursiveIteratorIterator($directory); + $phpFiles = new RegexIterator( + $fullTree, + '/.+((? $file) { + foreach ($path['exclude'] as $exclude) { + if (str_contains($file[0], $exclude)) { + continue 2; + } + } + + require_once $file[0]; + // Uncomment only for debugging (to inspect which files are included). + // Never use this in production - preload scripts must not generate output. + // echo 'Loaded: ' . $file[0] . "\n"; + } + } + } +} + +(new preload())->load(); diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..abac3cb --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,49 @@ +# Disable directory browsing +Options -Indexes + +# ---------------------------------------------------------------------- +# Rewrite engine +# ---------------------------------------------------------------------- + +# Turning on the rewrite engine is necessary for the following rules and features. +# FollowSymLinks must be enabled for this to work. + + Options +FollowSymlinks + RewriteEngine On + + # If you installed CodeIgniter in a subfolder, you will need to + # change the following line to match the subfolder you need. + # http://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewritebase + # RewriteBase / + + # Redirect Trailing Slashes... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Rewrite "www.example.com -> example.com" + RewriteCond %{HTTPS} !=on + RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L] + + # Checks to see if the user is attempting to access a valid file, + # such as an image or css document, if this isn't true it sends the + # request to the front controller, index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA] + + # Ensure Authorization header is passed along + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + + + # If we don't have mod_rewrite installed, all 404's + # can be sent to index.php, and everything works as normal. + ErrorDocument 404 index.php + + +# Disable server signature start +ServerSignature Off +# Disable server signature end diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7ecfce214cbdbedc15d8348babeff5cd7e720488 GIT binary patch literal 5430 zcmeHL&npB`9Dn5Kuy>cuLGmAPauLohTgugsgTofC9NbV!aa0tgTofgxl%f=?Y?Sh& zWMxI9hibZ5LZ@~9&$;7J-7ai$2=7ByD3A3P1*hfQz|8$LL= zUz~w$;90HjJ=)Ss!V^NuI2Za%(z#lli~7u{+nbyOi;~<-#pGX{K<~tb_C6Lj^YSY9 zuHBEb(bRJ+>$(mjNuamQtlmY!MgdXJjDiJ*`fSvC!*oskp+}sjm(MWyz%r29BW!f*m(x|(w?cOiYFaD0y8pq z9eCfscMhGYa>i@!YdSYJ(-6XZ(W@wc#d9lm1l3S%FF!rC_waiB@V}AM)ez?!Gp$os wgQ-p&yk8A*^lZ5Jw#)Gj@LI_qy{H^P{^jt7C;ag)RHAYMwkN>;6;PJx7hHm1jsO4v literal 0 HcmV?d00001 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..25faf0b --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + GG + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..8e834f2 --- /dev/null +++ b/public/index.php @@ -0,0 +1,59 @@ +systemDirectory . '/Boot.php'; + +exit(Boot::bootWeb($paths)); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..9e60f97 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/scripts/scheduler_loop.php b/scripts/scheduler_loop.php new file mode 100644 index 0000000..4cecaee --- /dev/null +++ b/scripts/scheduler_loop.php @@ -0,0 +1,91 @@ +#!/usr/bin/env php + 0 && posix_kill($pid, 0)) { + return false; // another instance alive + } + @unlink($lockFile); // stale lock + } + file_put_contents($lockFile, (string) getmypid()); + + return true; +} + +function releaseLock(string $lockFile): void +{ + @unlink($lockFile); +} + +function run(string $root, string $cmd, string $logFile, string $tag): int +{ + logMsg($logFile, ">>> [$tag] $cmd"); + $output = []; + $code = 0; + exec('cd ' . escapeshellarg($root) . ' && ' . $cmd . ' 2>&1', $output, $code); + foreach (array_slice($output, -6) as $line) { + logMsg($logFile, " $line"); + } + logMsg($logFile, "<<< [$tag] exit $code"); + + return $code; +} + +logMsg($logFile, "Scheduler loop started — interval {$intervalMin}min, batch {$batchSize}"); + +while (true) { + if (! acquireLock($lockFile)) { + logMsg($logFile, 'Another crawler instance is running — exiting this loop.'); + exit(1); + } + + try { + // 1. refresh prices from direct sources + run($root, 'php spark gg:crawl steam', $logFile, 'steam'); + run($root, 'php spark gg:crawl gog', $logFile, 'gog'); + run($root, 'php spark gg:crawl epic', $logFile, 'epic'); + run($root, 'php spark gg:crawl finalize', $logFile, 'finalize'); + + // 2. expand catalog via FlareSolverr session + run($root, "php spark gg:ggdeals --batch={$batchSize}", $logFile, 'ggdeals-expand'); + } catch (\Throwable $e) { + logMsg($logFile, 'ERROR in loop: ' . $e->getMessage()); + } finally { + releaseLock($lockFile); + } + + logMsg($logFile, "Sleeping {$intervalMin}min until next cycle…"); + sleep($intervalMin * 60); +} diff --git a/spark b/spark new file mode 100755 index 0000000..61bc572 --- /dev/null +++ b/spark @@ -0,0 +1,87 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Boot; +use Config\Paths; + +/* + * -------------------------------------------------------------------- + * CODEIGNITER COMMAND-LINE TOOLS + * -------------------------------------------------------------------- + * The main entry point into the CLI system and allows you to run + * commands and perform maintenance on your application. + */ + +/* + *--------------------------------------------------------------- + * CHECK SERVER API + *--------------------------------------------------------------- + */ + +// Refuse to run when called from php-cgi +if (str_starts_with(PHP_SAPI, 'cgi')) { + exit("The cli tool is not supported when running php-cgi. It needs php-cli to function!\n\n"); +} + +/* + *--------------------------------------------------------------- + * CHECK PHP VERSION + *--------------------------------------------------------------- + */ + +$minPhpVersion = '8.2'; // If you update this, don't forget to update `public/index.php`. +if (version_compare(PHP_VERSION, $minPhpVersion, '<')) { + $message = sprintf( + 'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s', + $minPhpVersion, + PHP_VERSION, + ); + + exit($message); +} + +// We want errors to be shown when using it from the CLI. +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +/* + *--------------------------------------------------------------- + * SET THE CURRENT DIRECTORY + *--------------------------------------------------------------- + */ + +// Path to the front controller +define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR); + +// Ensure the current directory is pointing to the front controller's directory +chdir(FCPATH); + +/* + *--------------------------------------------------------------- + * BOOTSTRAP THE APPLICATION + *--------------------------------------------------------------- + * This process sets up the path constants, loads and registers + * our autoloader, along with Composer's, loads our constants + * and fires up an environment-specific bootstrapping. + */ + +// LOAD OUR PATHS CONFIG FILE +// This is the line that might need to be changed, depending on your folder structure. +require FCPATH . '../app/Config/Paths.php'; +// ^^^ Change this line if you move your application folder + +$paths = new Paths(); + +// LOAD THE FRAMEWORK BOOTSTRAP FILE +require $paths->systemDirectory . '/Boot.php'; + +exit(Boot::bootSpark($paths)); diff --git a/tests/.htaccess b/tests/.htaccess new file mode 100644 index 0000000..3462048 --- /dev/null +++ b/tests/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d5b12ee --- /dev/null +++ b/tests/README.md @@ -0,0 +1,118 @@ +# Running Application Tests + +This is the quick-start to CodeIgniter testing. Its intent is to describe what +it takes to set up your application and get it ready to run unit tests. +It is not intended to be a full description of the test features that you can +use to test your application. Those details can be found in the documentation. + +## Resources + +* [CodeIgniter 4 User Guide on Testing](https://codeigniter.com/user_guide/testing/index.html) +* [PHPUnit docs](https://phpunit.de/documentation.html) +* [Any tutorials on Unit testing in CI4?](https://forum.codeigniter.com/showthread.php?tid=81830) + +## Requirements + +It is recommended to use the latest version of PHPUnit. At the time of this +writing, we are running version 9.x. Support for this has been built into the +**composer.json** file that ships with CodeIgniter and can easily be installed +via [Composer](https://getcomposer.org/) if you don't already have it installed globally. + +```console +> composer install +``` + +If running under macOS or Linux, you can create a symbolic link to make running tests a touch nicer. + +```console +> ln -s ./vendor/bin/phpunit ./phpunit +``` + +You also need to install [XDebug](https://xdebug.org/docs/install) in order +for code coverage to be calculated successfully. After installing `XDebug`, you must add `xdebug.mode=coverage` in the **php.ini** file to enable code coverage. + +## Setting Up + +A number of the tests use a running database. +In order to set up the database edit the details for the `tests` group in +**app/Config/Database.php** or **.env**. +Make sure that you provide a database engine that is currently running on your machine. +More details on a test database setup are in the +[Testing Your Database](https://codeigniter.com/user_guide/testing/database.html) section of the documentation. + +## Running the tests + +The entire test suite can be run by simply typing one command-line command from the main directory. + +```console +> ./phpunit +``` + +If you are using Windows, use the following command. + +```console +> vendor\bin\phpunit +``` + +You can limit tests to those within a single test directory by specifying the +directory name after phpunit. + +```console +> ./phpunit app/Models +``` + +## Generating Code Coverage + +To generate coverage information, including HTML reports you can view in your browser, +you can use the following command: + +```console +> ./phpunit --colors --coverage-text=tests/coverage.txt --coverage-html=tests/coverage/ -d memory_limit=1024m +``` + +This runs all of the tests again collecting information about how many lines, +functions, and files are tested. It also reports the percentage of the code that is covered by tests. +It is collected in two formats: a simple text file that provides an overview as well +as a comprehensive collection of HTML files that show the status of every line of code in the project. + +The text file can be found at **tests/coverage.txt**. +The HTML files can be viewed by opening **tests/coverage/index.html** in your favorite browser. + +## PHPUnit XML Configuration + +The repository has a ``phpunit.dist.xml`` file in the project root that's used for +PHPUnit configuration. This is used to provide a default configuration if you +do not have your own configuration file in the project root. + +The normal practice would be to copy ``phpunit.dist.xml`` to ``phpunit.xml`` +(which is git ignored), and to tailor it as you see fit. +For instance, you might wish to exclude database tests, or automatically generate +HTML code coverage reports. + +## Test Cases + +Every test needs a *test case*, or class that your tests extend. CodeIgniter 4 +provides one class that you may use directly: +* `CodeIgniter\Test\CIUnitTestCase` + +Most of the time you will want to write your own test cases that extend `CIUnitTestCase` +to hold functions and services common to your test suites. + +## Creating Tests + +All tests go in the **tests/** directory. Each test file is a class that extends a +**Test Case** (see above) and contains methods for the individual tests. These method +names must start with the word "test" and should have descriptive names for precisely what +they are testing: +`testUserCanModifyFile()` `testOutputColorMatchesInput()` `testIsLoggedInFailsWithInvalidUser()` + +Writing tests is an art, and there are many resources available to help learn how. +Review the links above and always pay attention to your code coverage. + +### Database Tests + +Tests can include migrating, seeding, and testing against a mock or live database. +Be sure to modify the test case (or create your own) to point to your seed and migrations +and include any additional steps to be run before tests in the `setUp()` method. +See [Testing Your Database](https://codeigniter.com/user_guide/testing/database.html) +for details. diff --git a/tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php b/tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php new file mode 100644 index 0000000..a73356d --- /dev/null +++ b/tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php @@ -0,0 +1,37 @@ +forge->addField('id'); + $this->forge->addField([ + 'name' => ['type' => 'varchar', 'constraint' => 31], + 'uid' => ['type' => 'varchar', 'constraint' => 31], + 'class' => ['type' => 'varchar', 'constraint' => 63], + 'icon' => ['type' => 'varchar', 'constraint' => 31], + 'summary' => ['type' => 'varchar', 'constraint' => 255], + 'created_at' => ['type' => 'datetime', 'null' => true], + 'updated_at' => ['type' => 'datetime', 'null' => true], + 'deleted_at' => ['type' => 'datetime', 'null' => true], + ]); + + $this->forge->addKey('name'); + $this->forge->addKey('uid'); + $this->forge->addKey(['deleted_at', 'id']); + $this->forge->addKey('created_at'); + + $this->forge->createTable('factories'); + } + + public function down(): void + { + $this->forge->dropTable('factories'); + } +} diff --git a/tests/_support/Database/Seeds/ExampleSeeder.php b/tests/_support/Database/Seeds/ExampleSeeder.php new file mode 100644 index 0000000..619fc27 --- /dev/null +++ b/tests/_support/Database/Seeds/ExampleSeeder.php @@ -0,0 +1,41 @@ + 'Test Factory', + 'uid' => 'test001', + 'class' => 'Factories\Tests\NewFactory', + 'icon' => 'fas fa-puzzle-piece', + 'summary' => 'Longer sample text for testing', + ], + [ + 'name' => 'Widget Factory', + 'uid' => 'widget', + 'class' => 'Factories\Tests\WidgetPlant', + 'icon' => 'fas fa-puzzle-piece', + 'summary' => 'Create widgets in your factory', + ], + [ + 'name' => 'Evil Factory', + 'uid' => 'evil-maker', + 'class' => 'Factories\Evil\MyFactory', + 'icon' => 'fas fa-book-dead', + 'summary' => 'Abandon all hope, ye who enter here', + ], + ]; + + $builder = $this->db->table('factories'); + + foreach ($factories as $factory) { + $builder->insert($factory); + } + } +} diff --git a/tests/_support/Libraries/ConfigReader.php b/tests/_support/Libraries/ConfigReader.php new file mode 100644 index 0000000..0bb4a8f --- /dev/null +++ b/tests/_support/Libraries/ConfigReader.php @@ -0,0 +1,19 @@ +findAll(); + + // Make sure the count is as expected + $this->assertCount(3, $objects); + } + + public function testSoftDeleteLeavesRow(): void + { + $model = new ExampleModel(); + $this->setPrivateProperty($model, 'useSoftDeletes', true); + $this->setPrivateProperty($model, 'tempUseSoftDeletes', true); + + /** @var stdClass $object */ + $object = $model->first(); + $model->delete($object->id); + + // The model should no longer find it + $this->assertNull($model->find($object->id)); + + // ... but it should still be in the database + $result = $model->builder()->where('id', $object->id)->get()->getResult(); + + $this->assertCount(1, $result); + } +} diff --git a/tests/index.html b/tests/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/tests/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/tests/session/ExampleSessionTest.php b/tests/session/ExampleSessionTest.php new file mode 100644 index 0000000..33242a4 --- /dev/null +++ b/tests/session/ExampleSessionTest.php @@ -0,0 +1,17 @@ +set('logged_in', 123); + $this->assertSame(123, $session->get('logged_in')); + } +} diff --git a/tests/unit/HealthTest.php b/tests/unit/HealthTest.php new file mode 100644 index 0000000..1df24df --- /dev/null +++ b/tests/unit/HealthTest.php @@ -0,0 +1,49 @@ +assertTrue(defined('APPPATH')); + } + + public function testBaseUrlHasBeenSet(): void + { + $validation = service('validation'); + + $env = false; + + // Check the baseURL in .env + if (is_file(HOMEPATH . '.env')) { + $env = preg_grep('/^app\.baseURL = ./', file(HOMEPATH . '.env')) !== false; + } + + if ($env) { + // BaseURL in .env is a valid URL? + // phpunit.dist.xml sets app.baseURL in $_SERVER + // So if you set app.baseURL in .env, it takes precedence + $config = new App(); + $this->assertTrue( + $validation->check($config->baseURL, 'valid_url'), + 'baseURL "' . $config->baseURL . '" in .env is not valid URL', + ); + } + + // Get the baseURL in app/Config/App.php + // You can't use Config\App, because phpunit.dist.xml sets app.baseURL + $reader = new ConfigReader(); + + // BaseURL in app/Config/App.php is a valid URL? + $this->assertTrue( + $validation->check($reader->baseURL, 'valid_url'), + 'baseURL "' . $reader->baseURL . '" in app/Config/App.php is not valid URL', + ); + } +} diff --git a/writable/.htaccess b/writable/.htaccess new file mode 100644 index 0000000..3462048 --- /dev/null +++ b/writable/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/writable/debugbar/index.html b/writable/debugbar/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/debugbar/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/index.html b/writable/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/logs/index.html b/writable/logs/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/logs/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/session/index.html b/writable/session/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/session/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/uploads/index.html b/writable/uploads/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/uploads/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + +