commit beaf0e1f378cb2d597b72bf0f6514abfc641425e Author: deepseek Date: Sun Aug 23 07:10:30 2026 +0000 Toolvana: production-ready media tools platform (CodeIgniter 4) - 134-tool registry with programmatic SEO (unique titles/H1/descriptions, JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter) - DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD), Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG) - Security: SSRF guard, MIME validation, rate limits, API-key auth, bcrypt admin login, security headers - Admin panel: dashboard, tools/categories/guides CRUD, SEO audit, analytics, job inspector with retry, system health, feature flags - Docker deployment (nginx + web/api FPM pools + scalable workers), PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..8faf7f0 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,29 @@ +# Docker deployment environment (copy values, never commit real secrets) +CI_ENVIRONMENT = production + +app.baseURL = 'https://your-domain.example/' +app.forceGlobalSecureRequests = true +app.indexPage = '' + +database.default.hostname = db +database.default.database = toolvana +database.default.username = toolvana +database.default.password = toolvana +database.default.DBDriver = MySQLi + +# generate once: php spark key:generate +encryption.key = + +session.driver = CodeIgniter\Session\Handlers\FileHandler +session.savePath = /app/writable/session + +site.name = 'Toolvana' +# bcrypt hash of the admin password +site.adminPassword = +site.retentionHours = 2 +site.maxUploadMb = 512 +site.ytDlpEnabled = false + +log.threshold = 4 +cache.handler = redis +cache.redis.host = redis diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f86f94 --- /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 + +# ------------------------- +# Toolvana runtime data +# ------------------------- +.env.docker +/writable/media/* +/writable/incoming/* +/writable/og/* +!/writable/media/index.html +!/writable/incoming/index.html +!/writable/og/index.html +/.phpunit.cache/ 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..6994ed7 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# Toolvana — Your free online media toolbox + +A production-ready, SEO-first all-in-one media tools platform built on **CodeIgniter 4.7** and **PHP 8.2**: YouTube tools, video/audio/image/GIF converters, PDF utilities, QR generation, programmatic SEO at scale (134+ root-level tool pages), a durable job queue with FFmpeg workers, anonymous analytics, and a complete admin panel. + +## Feature overview + +| Area | What ships | +|---|---| +| **Tools** | 134 seeded tools across YouTube / video / audio / image / GIF / PDF / utility categories; URL-kind, upload-kind and text-kind flows | +| **Processing** | MySQL-backed queue (`SELECT … FOR UPDATE SKIP LOCKED`), driver architecture: `Ffmpeg`, `Images` (GD), `Pdf` (qpdf/Ghostscript/poppler), `Youtube` (thumbnails via i.ytimg.com; yt-dlp optional & policy-gated), `Qr` (server-side PNG) | +| **Security** | SSRF guard for URL fetches, extension **and** real-MIME validation, UUID storage names outside the web root, per-session/IP hourly rate limits, API-key auth with hashed lookup, admin bcrypt login, strict security headers, canonical 301 enforcement | +| **SEO** | Unique titles/H1s/descriptions generated per format pair, JSON-LD (WebApplication, BreadcrumbList, FAQPage, HowTo, Article), auto sitemap index split at 5 000 URLs, robots.txt blocking processing endpoints, noindex on search/API/admin, hreflang scaffolding, OG images rendered by GD | +| **Admin** (`/admin`) | Overview dashboard, tools CRUD with pre-publish SEO gate, categories, guides (Markdown), automated **SEO audit** across every page, analytics dashboard (success-rate per tool), job inspector with retry, system health (binaries/storage/worker heartbeat), feature-flag settings | +| **API v1** | `/api/v1/tools`, `/api/v1/jobs` (+ legacy `/api/*`), Bearer-token auth, per-key hourly quotas | +| **PWA** | Web manifest, offline-capable service worker, offline shell | +| **Privacy** | Anonymous session-hash analytics only, automatic media expiry (`media:cleanup`), DMCA/privacy/cookies pages | + +## Requirements + +- PHP 8.1+ (8.2 recommended) with `intl`, `mbstring`, `zip`, `pdo_mysql`, `gd`, `exif` +- MariaDB/MySQL 10.6+ +- FFmpeg, ImageMagick (`convert`), poppler-utils (`pdftoppm`, `pdftotext`), qpdf, Ghostscript +- Optional: `yt-dlp` (disabled by policy until enabled in Admin → Settings) +- Composer 2 + +## Quick start (development) + +```bash +composer install +cp env .env # then edit DB credentials +php spark key:generate + +# create schema + seed registry/content +php spark migrate --all +php spark db:seed InitialSeeder + +# run +php spark serve # http://127.0.0.1:8080 (falls back to 8081/8082 if busy) + +# process jobs (keep one or more running) +php spark queue:work --loop # add --sleep=2 to idle politely + +# expire finished jobs + files (cron every 5 min in production) +php spark media:cleanup +``` + +Sign in to the admin panel at `/admin/login`. Set the password hash in `.env`: + +```bash +php -r 'echo password_hash("your-password", PASSWORD_DEFAULT), "\n";' +# .env → site.adminPassword = '$2y$10$…' +``` + +## Production deployment (Docker) + +```bash +cp .env.docker.example .env.docker # fill in secrets: encryption.key, site.adminPassword, DB password +docker compose up -d --build +docker compose exec web php spark migrate --all +docker compose exec web php spark db:seed InitialSeeder +``` + +Topology (`docker-compose.yml`): + +``` +browser ── nginx ──► web (PHP-FPM, page rendering) + └──► api (PHP-FPM, /api + processing endpoints isolated) +workers × N (php spark queue:work --loop) ← scale replicas freely +scheduler (media:cleanup every 5 minutes) +db (MariaDB 11 + healthcheck) +``` + +- The single `docker/Dockerfile` image contains PHP-FPM **and** the full media binary stack, so web/worker versions can never drift. +- Media lives on named volumes (`media-storage`, `incoming-storage`); point them at shared NFS/EFS when running workers on separate hosts. +- Put TLS termination at your edge/load balancer and set `app.forceGlobalSecureRequests = true`; nginx config includes HSTS-ready headers. +- Recommended cron alternative to the scheduler container: `*/5 * * * * docker compose exec -T scheduler php spark media:cleanup`. + +### Production checklist + +- [ ] `CI_ENVIRONMENT = production`, strong `encryption.key` (`php spark key:generate`) +- [ ] `site.adminPassword` set to a fresh bcrypt hash +- [ ] `site.ytDlpEnabled` left `false` unless legal review is done +- [ ] Review retention: `site.retentionHours` (default 2 h) matches your privacy policy +- [ ] Point `/sitemap.xml` at Search Console; confirm canonical host in `.env` (`app.baseURL`) +- [ ] Run Admin → **SEO Audit** and clear critical issues before launch + +## Architecture notes + +``` +app/ +├── Config/Site.php # brand, binaries map, limits, policy flags +├── Controllers/ # Tools, Directory, Guides, Media (upload/start/download), Api/*, Admin/* +├── Libraries/ +│ ├── Pipeline.php # enqueue guards + driver routing (by operation/format family) +│ ├── Pipeline/{Ffmpeg,Images,Pdf,Youtube,Qr}.php +│ ├── CatalogBuilder.php # generates the 134-tool registry with unique SEO copy +│ ├── Seo.php # head tags, canonical, JSON-LD graphs +│ ├── UrlGuard.php # SSRF protection for remote fetches +│ ├── ToolSearch.php # weighted token search incl. aliases ("yt mp3") +│ └── RelatedTools.php # internal-linking engine (format/category scoring) +├── Commands/ # queue:work, media:cleanup +└── Database/Migrations/ # categories, tools, guides, jobs, analytics_events, api_keys +``` + +**Why a DB queue?** Zero extra infrastructure, transactional claims via `SKIP LOCKED`, trivial horizontal scaling of identical worker containers, and a built-in audit trail surfaced in the admin Jobs screen. + +**Canonical URL policy:** every non-canonical variant (uppercase, trailing slash, doubled slashes) 301-redirects before routing — including unmatched routes — via a *required* filter. + +## Testing + +```bash +./vendor/bin/phpunit # 19 tests, ~1 100 assertions +``` + +Suites: +- `tests/unit/CatalogTest.php` — catalog integrity (unique slugs, complete SEO fields, format-specific titles), YouTube ID extraction, SSRF guard behaviour. +- `tests/feature/SeoPagesTest.php` — the **SEO contract**: canonical/OG/Twitter tags, JSON-LD types present in first response, sitemaps contain only indexable URLs, robots.txt blocks processing endpoints, 301 canonicalisation, helpful 404s. + +## Useful commands + +```bash +php spark queue:work [--loop] [--sleep=N] [--max-jobs=N] # process jobs +php spark media:cleanup # expire jobs/files past retention +php spark migrate --all && php spark db:seed InitialSeeder # rebuild registry (editorial fields preserved) +``` 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/MediaCleanup.php b/app/Commands/MediaCleanup.php new file mode 100644 index 0000000..915454a --- /dev/null +++ b/app/Commands/MediaCleanup.php @@ -0,0 +1,58 @@ +findExpired(), ...$jobs->findOldFailed()] as $job) { + foreach (['input_file' => Pipeline::incomingDir(), 'output_file' => Pipeline::storageDir()] as $field => $dir) { + if (! empty($job[$field])) { + $path = rtrim((string) $dir, '/') . '/' . basename((string) $job[$field]); + if (is_file($path)) { + $freed += (int) @filesize($path); + @unlink($path); + } + } + } + $jobs->delete($job['id']); + ++$deleted; + } + + // orphaned files with no row (worker crash leftovers) + $cutoff = time() - max(2, config('Site')->retentionHours) * 3600 - 600; + foreach ([Pipeline::incomingDir(), Pipeline::storageDir()] as $dir) { + foreach (glob(rtrim($dir, '/') . '/*') ?: [] as $file) { + if (is_file($file) && filemtime($file) < $cutoff) { + $freed += (int) @filesize($file); + @unlink($file); + } + } + } + + CLI::write("Cleanup: {$deleted} job row(s) removed, " . number_format($freed / 1048576, 1) . ' MB freed.', 'yellow'); + } +} diff --git a/app/Commands/QueueWork.php b/app/Commands/QueueWork.php new file mode 100644 index 0000000..7627d5a --- /dev/null +++ b/app/Commands/QueueWork.php @@ -0,0 +1,93 @@ + 'Keep running until stopped', + '--sleep' => 'Seconds between polls when idle (default 2)', + '--max-jobs' => 'Exit after N successful jobs', + '--stale' => 'Also reap jobs stuck in processing beyond max runtime', + ]; + + public function run(array $params): void + { + $loop = array_key_exists('loop', $params) || CLI::getOption('loop'); + $sleep = (int) ($params['sleep'] ?? 2); + $maxJobs = isset($params['max-jobs']) ? (int) $params['max-jobs'] : 0; + $jobs = model(JobModel::class); + $done = 0; + + do { + $job = null; + + // claim with retry: SKIP LOCKED can race under contention + try { + $job = $jobs->claimNext(); + } catch (\Throwable $e) { + log_message('error', 'worker claim failed: {m}', ['m' => $e->getMessage()]); + usleep(500_000); + } + + if ($job === null) { + if (! $loop) { + break; + } + sleep(max(1, $sleep)); + continue; + } + + CLI::write("Processing job {$job['id']} [{$job['operation']}]", 'green'); + self::heartbeat(); + service('pipeline')->process($job); + ++$done; + + if ($done % 10 === 0) { + self::reapStale($jobs); // periodic housekeeping inside long-running workers + } + + if ($maxJobs > 0 && $done >= $maxJobs) { + break; + } + } while (true); + + CLI::write("Worker finished after {$done} job(s).", 'yellow'); + } + + /** Touch a cache key so the admin dashboard can see workers are alive. */ + public static function heartbeat(): void + { + try { + cache()->save('tv_worker_heartbeat', gmdate('H:i:s'), 120); + } catch (\Throwable) { + // never let telemetry break processing + } + } + + /** Fail jobs whose worker died mid-processing. */ + public static function reapStale(JobModel $jobs): void + { + foreach ($jobs->findStale((int) config('Site')->maxProcessingSeconds) as $stale) { + Pipeline::failJob($stale['id'], 'Processing timed out.'); + } + } +} diff --git a/app/Common.php b/app/Common.php new file mode 100644 index 0000000..39fc71f --- /dev/null +++ b/app/Common.php @@ -0,0 +1,97 @@ + '', + 'film' => '', + 'music' => '', + 'headphones'=> '', + 'image' => '', + 'sparkles' => '', + 'filetext' => '', + 'tool' => '', + 'download' => '', + 'video' => '', + 'search' => '', + 'info' => '', + 'text' => '', + 'users' => '', + 'link' => '', + 'code' => '', + 'clock' => '', + 'qr' => '', + 'shield' => '', + 'key' => '', + 'palette' => '', + 'file' => '', + 'folder' => '', + 'phone' => '', + 'list' => '', + 'swap' => '', + 'layers' => '', + 'star' => '', + 'arrow' => '', + 'menu' => '', + ]; + + $d = $paths[$name] ?? $paths['file']; + + return ''; + } +} + +if (! function_exists('textPlaceholder')) { + function textPlaceholder(string $operation): string + { + return match ($operation) { + 'hash_text' => 'Paste any text to hash…', + 'json_format' => '{"paste":"your JSON here"}', + 'url_encode' => 'Text to encode…', + 'url_decode' => 'Text%20to%20decode…', + 'uuid_gen' => '', + 'timestamp_convert' => '1735689600 or 2025-01-01 12:00:00', + 'color_convert' => '#7c3aed', + 'yt_url_analyze' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'yt_embed' => 'Paste a YouTube URL to build an embed code…', + 'yt_timestamp' => 'Paste a YouTube URL and set the time below…', + default => 'Type or paste text here…', + }; + } +} + +if (! function_exists('format_bytes')) { + function format_bytes(int|float|null $bytes, int $precision = 1): string + { + $bytes = max(0, (int) $bytes); + if ($bytes < 1024) { + return $bytes . ' B'; + } + foreach (['KB', 'MB', 'GB', 'TB'] as $unit) { + $bytes /= 1024; + if ($bytes < 1024) { + return round((float) $bytes, $precision) . ' ' . $unit; + } + } + + return round((float) $bytes, $precision) . ' PB'; + } +} diff --git a/app/Config/App.php b/app/Config/App.php new file mode 100644 index 0000000..d353ef2 --- /dev/null +++ b/app/Config/App.php @@ -0,0 +1,202 @@ + + */ + public array $allowedHostnames = []; + + /** + * -------------------------------------------------------------------------- + * Index File + * -------------------------------------------------------------------------- + * + * Typically, this will be your `index.php` file, unless you've renamed it to + * something else. If you have configured your web server to remove this file + * from your site URIs, set this variable to an empty string. + */ + public string $indexPage = ''; + + /** + * -------------------------------------------------------------------------- + * URI PROTOCOL + * -------------------------------------------------------------------------- + * + * This item determines which server global should be used to retrieve the + * URI string. The default setting of 'REQUEST_URI' works for most servers. + * If your links do not seem to work, try one of the other delicious flavors: + * + * 'REQUEST_URI': Uses $_SERVER['REQUEST_URI'] + * 'QUERY_STRING': Uses $_SERVER['QUERY_STRING'] + * 'PATH_INFO': Uses $_SERVER['PATH_INFO'] + * + * WARNING: If you set this to 'PATH_INFO', URIs will always be URL-decoded! + */ + public string $uriProtocol = 'REQUEST_URI'; + + /* + |-------------------------------------------------------------------------- + | Allowed URL Characters + |-------------------------------------------------------------------------- + | + | This lets you specify which characters are permitted within your URLs. + | When someone tries to submit a URL with disallowed characters they will + | get a warning message. + | + | As a security measure you are STRONGLY encouraged to restrict URLs to + | as few characters as possible. + | + | By default, only these are allowed: `a-z 0-9~%.:_-` + | + | Set an empty string to allow all characters -- but only if you are insane. + | + | The configured value is actually a regular expression character group + | and it will be used as: '/\A[]+\z/iu' + | + | DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!! + | + */ + public string $permittedURIChars = 'a-z 0-9~%.:_\-'; + + /** + * -------------------------------------------------------------------------- + * Default Locale + * -------------------------------------------------------------------------- + * + * The Locale roughly represents the language and location that your visitor + * is viewing the site from. It affects the language strings and other + * strings (like currency markers, numbers, etc), that your program + * should run under for this request. + */ + public string $defaultLocale = 'en'; + + /** + * -------------------------------------------------------------------------- + * Negotiate Locale + * -------------------------------------------------------------------------- + * + * If true, the current Request object will automatically determine the + * language to use based on the value of the Accept-Language header. + * + * If false, no automatic detection will be performed. + */ + public bool $negotiateLocale = false; + + /** + * -------------------------------------------------------------------------- + * Supported Locales + * -------------------------------------------------------------------------- + * + * If $negotiateLocale is true, this array lists the locales supported + * by the application in descending order of priority. If no match is + * found, the first locale will be used. + * + * IncomingRequest::setLocale() also uses this list. + * + * @var list + */ + public array $supportedLocales = ['en']; + + /** + * -------------------------------------------------------------------------- + * Application Timezone + * -------------------------------------------------------------------------- + * + * The default timezone that will be used in your application to display + * dates with the date helper, and can be retrieved through app_timezone() + * + * @see https://www.php.net/manual/en/timezones.php for list of timezones + * supported by PHP. + */ + public string $appTimezone = 'UTC'; + + /** + * -------------------------------------------------------------------------- + * Default Character Set + * -------------------------------------------------------------------------- + * + * This determines which character set is used by default in various methods + * that require a character set to be provided. + * + * @see http://php.net/htmlspecialchars for a list of supported charsets. + */ + public string $charset = 'UTF-8'; + + /** + * -------------------------------------------------------------------------- + * Force Global Secure Requests + * -------------------------------------------------------------------------- + * + * If true, this will force every request made to this application to be + * made via a secure connection (HTTPS). If the incoming request is not + * secure, the user will be redirected to a secure version of the page + * and the HTTP Strict Transport Security (HSTS) header will be set. + */ + public bool $forceGlobalSecureRequests = false; + + /** + * -------------------------------------------------------------------------- + * Reverse Proxy IPs + * -------------------------------------------------------------------------- + * + * If your server is behind a reverse proxy, you must whitelist the proxy + * IP addresses from which CodeIgniter should trust headers such as + * X-Forwarded-For or Client-IP in order to properly identify + * the visitor's IP address. + * + * You need to set a proxy IP address or IP address with subnets and + * the HTTP header for the client IP address. + * + * Here are some examples: + * [ + * '10.0.1.200' => 'X-Forwarded-For', + * '192.168.5.0/24' => 'X-Real-IP', + * ] + * + * @var array + */ + public array $proxyIPs = []; + + /** + * -------------------------------------------------------------------------- + * Content Security Policy + * -------------------------------------------------------------------------- + * + * Enables the Response's Content Secure Policy to restrict the sources that + * can be used for images, scripts, CSS files, audio, video, etc. If enabled, + * the Response object will populate default values for the policy from the + * `ContentSecurityPolicy.php` file. Controllers can always add to those + * restrictions at run time. + * + * For a better understanding of CSP, see these documents: + * + * @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/ + * @see http://www.w3.org/TR/CSP/ + */ + public bool $CSPEnabled = false; +} diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php new file mode 100644 index 0000000..9a92824 --- /dev/null +++ b/app/Config/Autoload.php @@ -0,0 +1,92 @@ +|string> + */ + public $psr4 = [ + APP_NAMESPACE => APPPATH, + ]; + + /** + * ------------------------------------------------------------------- + * Class Map + * ------------------------------------------------------------------- + * The class map provides a map of class names and their exact + * location on the drive. Classes loaded in this manner will have + * slightly faster performance because they will not have to be + * searched for within one or more directories as they would if they + * were being autoloaded through a namespace. + * + * Prototype: + * $classmap = [ + * 'MyClass' => '/path/to/class/file.php' + * ]; + * + * @var array + */ + public $classmap = []; + + /** + * ------------------------------------------------------------------- + * Files + * ------------------------------------------------------------------- + * The files array provides a list of paths to __non-class__ files + * that will be autoloaded. This can be useful for bootstrap operations + * or for loading functions. + * + * Prototype: + * $files = [ + * '/path/to/my/file.php', + * ]; + * + * @var list + */ + public $files = []; + + /** + * ------------------------------------------------------------------- + * Helpers + * ------------------------------------------------------------------- + * Prototype: + * $helpers = [ + * 'form', + * ]; + * + * @var list + */ + public $helpers = []; +} diff --git a/app/Config/Boot/development.php b/app/Config/Boot/development.php new file mode 100644 index 0000000..a868447 --- /dev/null +++ b/app/Config/Boot/development.php @@ -0,0 +1,34 @@ + + * + * @see https://www.php.net/manual/en/curl.constants.php#constant.curl-lock-data-connect + */ + public array $shareConnectionOptions = [ + CURL_LOCK_DATA_CONNECT, + CURL_LOCK_DATA_DNS, + ]; + + /** + * -------------------------------------------------------------------------- + * CURLRequest Share Options + * -------------------------------------------------------------------------- + * + * Whether share options between requests or not. + * + * If true, all the options won't be reset between requests. + * It may cause an error request with unnecessary headers. + */ + public bool $shareOptions = false; +} diff --git a/app/Config/Cache.php b/app/Config/Cache.php new file mode 100644 index 0000000..38ac541 --- /dev/null +++ b/app/Config/Cache.php @@ -0,0 +1,198 @@ + WRITEPATH . 'cache/', + 'mode' => 0640, + ]; + + /** + * ------------------------------------------------------------------------- + * Memcached settings + * ------------------------------------------------------------------------- + * + * Your Memcached servers can be specified below, if you are using + * the Memcached drivers. + * + * @see https://codeigniter.com/user_guide/libraries/caching.html#memcached + * + * @var array{host?: string, port?: int, weight?: int, raw?: bool} + */ + public array $memcached = [ + 'host' => '127.0.0.1', + 'port' => 11211, + 'weight' => 1, + 'raw' => false, + ]; + + /** + * ------------------------------------------------------------------------- + * Redis settings + * ------------------------------------------------------------------------- + * + * Your Redis server can be specified below, if you are using + * the Redis or Predis drivers. + * + * @var array{ + * host?: string, + * password?: string|null, + * port?: int, + * timeout?: int, + * async?: bool, + * persistent?: bool, + * database?: int + * } + */ + public array $redis = [ + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'timeout' => 0, + 'async' => false, // specific to Predis and ignored by the native Redis extension + 'persistent' => false, + 'database' => 0, + ]; + + /** + * -------------------------------------------------------------------------- + * Available Cache Handlers + * -------------------------------------------------------------------------- + * + * This is an array of cache engine alias' and class names. Only engines + * that are listed here are allowed to be used. + * + * @var array> + */ + public array $validHandlers = [ + 'apcu' => ApcuHandler::class, + 'dummy' => DummyHandler::class, + 'file' => FileHandler::class, + 'memcached' => MemcachedHandler::class, + 'predis' => PredisHandler::class, + 'redis' => RedisHandler::class, + 'wincache' => WincacheHandler::class, + ]; + + /** + * -------------------------------------------------------------------------- + * Web Page Caching: Cache Include Query String + * -------------------------------------------------------------------------- + * + * Whether to take the URL query string into consideration when generating + * output cache files. Valid options are: + * + * false = Disabled + * true = Enabled, take all query parameters into account. + * Please be aware that this may result in numerous cache + * files generated for the same page over and over again. + * ['q'] = Enabled, but only take into account the specified list + * of query parameters. + * + * @var bool|list + */ + public $cacheQueryString = false; + + /** + * -------------------------------------------------------------------------- + * Web Page Caching: Cache Status Codes + * -------------------------------------------------------------------------- + * + * HTTP status codes that are allowed to be cached. Only responses with + * these status codes will be cached by the PageCache filter. + * + * Default: [] - Cache all status codes (backward compatible) + * + * Recommended: [200] - Only cache successful responses + * + * You can also use status codes like: + * [200, 404, 410] - Cache successful responses and specific error codes + * [200, 201, 202, 203, 204] - All 2xx successful responses + * + * WARNING: Using [] may cache temporary error pages (404, 500, etc). + * Consider restricting to [200] for production applications to avoid + * caching errors that should be temporary. + * + * @var list + */ + public array $cacheStatusCodes = []; +} diff --git a/app/Config/Constants.php b/app/Config/Constants.php new file mode 100644 index 0000000..fb56bb1 --- /dev/null +++ b/app/Config/Constants.php @@ -0,0 +1,79 @@ +|string|null + */ + public $defaultSrc; + + /** + * Lists allowed scripts' URLs. + * + * @var list|string + */ + public $scriptSrc = 'self'; + + /** + * Specifies valid sources for JavaScript + + + + +
+
+ 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/errors/http.php b/app/Views/errors/http.php new file mode 100644 index 0000000..ffd66fc --- /dev/null +++ b/app/Views/errors/http.php @@ -0,0 +1,6 @@ +
+ +

+

+

Browse all tools

+
diff --git a/app/Views/errors/rate_limited.php b/app/Views/errors/rate_limited.php new file mode 100644 index 0000000..0d401bd --- /dev/null +++ b/app/Views/errors/rate_limited.php @@ -0,0 +1,6 @@ +
+ 429 +

Too many requests

+

You've hit the rate limit. Please wait a little while before trying again — limits keep the tools fast for everyone.

+

← Back to the tool directory

+
diff --git a/app/Views/errors/tool_not_found.php b/app/Views/errors/tool_not_found.php new file mode 100644 index 0000000..7afe264 --- /dev/null +++ b/app/Views/errors/tool_not_found.php @@ -0,0 +1,24 @@ +
+

Tool not found

+

That page doesn't exist — but the toolbox is right here.

+ +
+
+
+

Popular tools

+
+ + $tool]) ?> + +
+
+
+

Browse categories

+
+ +
+
+
diff --git a/app/Views/guide.php b/app/Views/guide.php new file mode 100644 index 0000000..7014c9a --- /dev/null +++ b/app/Views/guide.php @@ -0,0 +1,37 @@ +
+ + +
+
+ · min read +

+

+
+ + + + +
+

Tools used in this guide

+
+ + $tool]) ?> + +
+
+ + + +
+

Keep reading

+
    + +
  • + +
+
+ +
+
diff --git a/app/Views/guides_index.php b/app/Views/guides_index.php new file mode 100644 index 0000000..b6a82fc --- /dev/null +++ b/app/Views/guides_index.php @@ -0,0 +1,21 @@ +
+ +
+

Guides & tutorials

+

Practical, no-fluff walkthroughs for converting, compressing and editing media. Every guide links the exact tools for the job.

+
+ +
+ +
+
diff --git a/app/Views/home.php b/app/Views/home.php new file mode 100644 index 0000000..24c1d43 --- /dev/null +++ b/app/Views/home.php @@ -0,0 +1,110 @@ +
+ free tools +

Free Online Video, YouTube & Image Tools

+

Fast, simple and free tools for downloading, converting, compressing and editing media online.

+ + +
No signup · No watermarks · Files deleted automatically
+
+ +
+ +
+

Popular tools See all →

+
+ + $tool]) ?> + +
+
+ +
+

Browse by category

+ +
+ +
+

Most used this week

+
+ + $tool]) ?> + +
+
+ +
+

Recently added

+
+ + $tool]) ?> + +
+
+ +
+

Why use our tools?

+
+
+ +

Private by design

+

Files are processed on isolated workers and deleted automatically. Text utilities run entirely in your browser.

+
+
+ +

No installation

+

Everything runs in the browser or on our servers — nothing to download, install or update.

+
+
+ +

Genuinely fast

+

Lightweight pages, no ad bloat, no fake buttons. Tools start working the moment you arrive.

+
+
+
+ +
+

How it works

+
    +
  1. Paste or upload Drop a file onto the upload area, or paste a link — whatever the tool asks for.
  2. +
  3. Choose your output Pick format and quality when it matters; sensible defaults are preselected.
  4. +
  5. Download the result Processing happens on dedicated servers with live progress, then you save the file.
  6. +
+
+ + [ + ['q' => 'Are these tools really free?', 'a' => 'Yes. Every tool on ' . config('Site')->name . ' is free to use without an account. Heavy processing is rate-limited to keep the service fast for everyone.'], + ['q' => 'What can I do with these online media tools?', 'a' => 'Convert videos between MP4, WebM, MOV and more, extract audio as MP3 or WAV, compress oversized files, convert and optimize images, merge PDFs, generate QR codes and much more — ' . $toolCount . ' tools in total.'], + ['q' => 'Do you store my files?', 'a' => 'Only temporarily. Uploads and results live in temporary storage for a short retention window (typically two hours) and are then deleted automatically along with all job metadata links.'], + ['q' => 'Can I convert videos without installing software?', 'a' => 'That is the whole point — every conversion runs server-side through FFmpeg, so your device only needs a browser.'], + ['q' => 'Is it legal to download YouTube videos?', 'a' => 'It depends on the content and your jurisdiction: your own uploads and properly licensed material are fine; commercial content generally requires permission from the rights holder or YouTube Premium\'s official offline mode. Our documentation explains how we handle platform terms.'], + ]]) ?> + +
+

Latest guides All guides →

+ +
+ +
diff --git a/app/Views/layout.php b/app/Views/layout.php new file mode 100644 index 0000000..c0e2d80 --- /dev/null +++ b/app/Views/layout.php @@ -0,0 +1,87 @@ + + + + + +<?= $seo->renderTitle() ?> + +renderHead() ?> + + + + + + + + + + + +
+ +
+ + + + + + + + + diff --git a/app/Views/partials/admin_nav.php b/app/Views/partials/admin_nav.php new file mode 100644 index 0000000..e21d772 --- /dev/null +++ b/app/Views/partials/admin_nav.php @@ -0,0 +1,13 @@ + diff --git a/app/Views/partials/faq.php b/app/Views/partials/faq.php new file mode 100644 index 0000000..7938a75 --- /dev/null +++ b/app/Views/partials/faq.php @@ -0,0 +1,18 @@ +, zero JS. + * Expected vars: $faqs list of ['q'=>,'a'=>] + */ +if (empty($faqs)) { + return; +} +?> +
+

Frequently asked questions

+ +
+ +

+
+ +
diff --git a/app/Views/partials/tool_card.php b/app/Views/partials/tool_card.php new file mode 100644 index 0000000..b7a7608 --- /dev/null +++ b/app/Views/partials/tool_card.php @@ -0,0 +1,11 @@ + + + +

+

110 ? '…' : '')) ?>

+
diff --git a/app/Views/partials/tools_grid.php b/app/Views/partials/tools_grid.php new file mode 100644 index 0000000..fc451e1 --- /dev/null +++ b/app/Views/partials/tools_grid.php @@ -0,0 +1,11 @@ + + +
+

All tools →

+
+ + $tool]) ?> + +
+
+ diff --git a/app/Views/search.php b/app/Views/search.php new file mode 100644 index 0000000..fa98df4 --- /dev/null +++ b/app/Views/search.php @@ -0,0 +1,39 @@ +
+
+

Search tools

+ +

Results for “” — match.

+ +
+ + + + +
+ + $tool]) ?> + +
+ +

No tool matched. Try “youtube”, “compress”, or one of these:

+ + +
+

Popular right now

+
+ + $tool]) ?> + +
+
+ +
+

Categories

+
+ +
+
+
diff --git a/app/Views/static/contact.php b/app/Views/static/contact.php new file mode 100644 index 0000000..7503efe --- /dev/null +++ b/app/Views/static/contact.php @@ -0,0 +1,13 @@ +
+
+

Contact

+

Reach us at hello@example.com — replace with your production inbox before launch. Typical response time is two business days.

+

Common topics

+
    +
  • Abuse reports — include URLs and timestamps.
  • +
  • DMCA / copyright — see the DMCA policy for notice requirements.
  • +
  • API access — tell us your expected volume.
  • +
  • Bug reports — the tool page URL plus what you expected vs. saw helps enormously.
  • +
+
+
diff --git a/app/Views/static/cookies.php b/app/Views/static/cookies.php new file mode 100644 index 0000000..a9d8736 --- /dev/null +++ b/app/Views/static/cookies.php @@ -0,0 +1,12 @@ +
+
+

Cookie Policy

+

Last updated:

+

name) ?> sets exactly one cookie:

+ + + +
NamePurposeLifetime
tv_sessionLinks your uploaded file to its processing job so you can download results. Contains no personal data.Session (deleted when you close the browser)
+

No analytics cookies, advertising cookies, or third-party trackers are used. Because the only cookie is strictly functional, no consent banner is required.

+
+
diff --git a/app/Views/static/dmca.php b/app/Views/static/dmca.php new file mode 100644 index 0000000..0ab4d9b --- /dev/null +++ b/app/Views/static/dmca.php @@ -0,0 +1,18 @@ +
+
+

DMCA & Copyright Policy

+

name) ?> respects intellectual property rights. We respond promptly to valid notices under the DMCA and comparable laws.

+ +

Filing a notice

+

Send takedown requests through the contact page, including:

+
    +
  1. Identificationof the copyrighted work you believe is infringed.
  2. +
  3. The URL on our site allegedly hosting infringing material.
  4. +
  5. Your contact details and a good-faith statement of authority.
  6. +
+

Valid notices are actioned within two business days.

+ +

Design of this service

+

This platform does not host a public library of user files: uploads are private to the uploading session and expire automatically. Tools that interact with third-party platforms are designed around applicable platform terms and can be disabled by operators.

+
+
diff --git a/app/Views/static/privacy.php b/app/Views/static/privacy.php new file mode 100644 index 0000000..fe70563 --- /dev/null +++ b/app/Views/static/privacy.php @@ -0,0 +1,31 @@ +
+
+

Privacy Policy

+

Last updated:

+ +

name) ?> is built to need as little of your data as possible. This page explains exactly what is stored and what is not.

+ +

Files you process

+
    +
  • Uploads and generated results live in temporary storage only for processing.
  • +
  • They are deleted automatically after a short retention window (two hours by default), or sooner once you download them and the job expires.
  • +
  • Files are stored under random UUID names, outside the web root, and never shared with other users or third parties.
  • +
+ +

What we log

+
    +
  • Anonymous usage events: which tools are viewed and used, output formats chosen, success/failure counts. Session and IP values are one-way salted hashes — we cannot reverse them to identify anyone.
  • +
  • Search terms, in aggregate, to find missing tools.
  • +
  • No advertising cookies, no cross-site trackers, no fingerprinting scripts.
  • +
+ +

Cookies

+

One functional session cookie keeps your upload/result association while you use a tool (see the cookie policy). No consent banner is needed because no tracking cookies exist.

+ +

Third-party requests

+

YouTube metadata lookups query YouTube's public oEmbed endpoint with your pasted URL. Thumbnail downloads fetch directly from Google's image CDN. Nothing else about you is sent anywhere.

+ +

Data removal

+

Because files expire quickly and logs are anonymous hashes, there is generally nothing to delete. For any question, use the contact page.

+
+
diff --git a/app/Views/static/terms.php b/app/Views/static/terms.php new file mode 100644 index 0000000..6aefe83 --- /dev/null +++ b/app/Views/static/terms.php @@ -0,0 +1,27 @@ +
+
+

Terms of Service

+

Last updated:

+ +

Acceptable use

+

name) ?> provides media utilities. You agree to use them only for content you own or are permitted to process, and never to:

+
    +
  • circumvent DRM or technical protection measures;
  • +
  • access private or paid content without authorization;
  • +
  • infringe copyright or other rights of third parties;
  • +
  • upload malware or attempt to abuse the infrastructure.
  • +
+ +

Third-party platforms

+

For platforms such as YouTube, downloading capability is provided for your own uploads and properly licensed material. You are responsible for complying with that platform's terms and applicable law. Features may be disabled by operators where required.

+ +

No warranty

+

Services are provided "as is" without warranties of any kind. Processing results depend on input quality; always keep originals of important files.

+ +

Rate limits

+

To keep the service fast for everyone, fair-use limits apply per IP address and session. Automated access requires an API token with its own quota.

+ +

Contact

+

Questions about these terms: see the contact page.

+
+
diff --git a/app/Views/tool.php b/app/Views/tool.php new file mode 100644 index 0000000..0f1f239 --- /dev/null +++ b/app/Views/tool.php @@ -0,0 +1,248 @@ + 'text-tool', + $kind === 'url' => 'url-tool', + default => 'upload-tool', +}; +?> + +
+ + + +
+

+

+
+ +
+ + +
+ Stream downloading is disabled on this server.
+ The operator has not enabled video/audio downloads (platform terms require explicit opt-in). + Metadata tools like the thumbnail downloader and + metadata viewer work normally. +
+ +
+ This tool is temporarily unavailable on this server.
+ A required processing component is missing. Please check back later. +
+ +
+ +
+ + +
+ Works with watch links, Shorts and share links. +
+ +
+ +
+ + + +
+ + +
+
+
+
+
+
+ +
+ +
+
+
+ + +
+
+ + Drop your file here + or click to browse — + + · up to maxUploadMb) ?> MB + + +
+ + + +
+ Advanced options +
+ 1 && in_array($tool['operation'], ['convert', 'extract_audio'], true)): ?> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+
+ + +
+ + + +
+ + +
+

How to use the

+
    + +
  1. + +
+
+ + + +
+

Supported formats

+ +

Input

+
+ + +

Output

+
+ +
+ + + is_array($tool['faqs']) ? $tool['faqs'] : []]) ?> + + +
+

Related guides

+ +
+ + + + $conversions, 'title' => 'Related conversions']) ?> + + + $related, 'title' => 'Related tools']) ?> + +
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/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/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..5fcce58 --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ +{ + "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", + "chillerlan/php-qrcode": "^5.0", + "codeigniter4/framework": "^4.7", + "league/commonmark": "^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..b9c2364 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2819 @@ +{ + "_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": "a6111be3483e4e10097c1f802009c0cd", + "packages": [ + { + "name": "chillerlan/php-qrcode", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/7b66282572fc14075c0507d74d9837dab25b38d6", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.6 || ^3.2.1", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "chillerlan/php-authenticator": "^4.3.1 || ^5.2.1", + "ext-fileinfo": "*", + "phan/phan": "^5.5.2", + "phpcompatibility/php-compatibility": "10.x-dev", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^9.6", + "setasign/fpdf": "^1.8.2", + "slevomat/coding-standard": "^8.23.0", + "squizlabs/php_codesniffer": "^4.0.0" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output.", + "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase/qrcode-generator" + }, + { + "name": "ZXing Authors", + "homepage": "https://github.com/zxing/zxing" + }, + { + "name": "Ashot Khanamiryan", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + } + ], + "description": "A QR Code generator and reader with a user-friendly API. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qr-reader", + "qrcode", + "qrcode-generator", + "qrcode-reader" + ], + "support": { + "docs": "https://php-qrcode.readthedocs.io", + "issues": "https://github.com/chillerlan/php-qrcode/issues", + "source": "https://github.com/chillerlan/php-qrcode" + }, + "funding": [ + { + "url": "https://ko-fi.com/codemasher", + "type": "Ko-Fi" + } + ], + "time": "2025-11-23T23:51:44+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/a0a487cbf5344f721eb504bf0f59bada40c381b7", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "phan/phan": "^5.5.2", + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpunit/phpunit": "^10.5", + "slevomat/coding-standard": "^8.22", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container.", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "Settings", + "configuration", + "container", + "helper", + "property hook" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2026-03-20T21:10:52+00:00" + }, + { + "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": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+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": "league/commonmark", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.11-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-08-11T16:06:25+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c54350438cd6914616f790a49cb424605f421562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.6" + }, + "time": "2026-08-16T21:58:41+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+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/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": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "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\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.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-04-10T16:19:22+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": "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/content/guides/how-to-compress-video.md b/content/guides/how-to-compress-video.md new file mode 100644 index 0000000..3f08503 --- /dev/null +++ b/content/guides/how-to-compress-video.md @@ -0,0 +1,57 @@ +--- +slug: how-to-compress-video +title: "How to Compress Video Files Without Losing Quality" +excerpt: "Why video files get huge, which settings actually shrink them, and how to hit email and upload limits every time." +seo_title: "How to Compress Video Online – Reduce File Size Guide" +seo_description: "Learn how to compress large video files online: CRF quality explained, resolution and codec advice, and how to fit under upload limits." +date: "2025-11-16" +tools: ["video-compressor", "video-resizer", "mp4-to-webm"] +--- + +A ten-minute phone recording can easily exceed 1 GB. Compression fixes that — but doing it *well* means understanding which knob actually turns the size. + +## What makes video files large? + +Three factors multiply together: + +1. **Resolution** — four times the pixels means roughly four times the data. +2. **Bitrate / quality target** — how generously each frame is described. +3. **Codec efficiency** — H.264, HEVC and VP9 pack the same visual content very differently. + +Duration scales everything linearly, but you rarely want to cut footage just to save space. + +## The one setting that matters: CRF + +Modern encoders use a *constant rate factor* (CRF) — a quality target rather than a fixed bitrate. Lower numbers mean better quality and bigger files: + +| CRF | Result | +|---|---| +| 18 | Visually identical to source | +| 23 | Default; excellent for most content | +| 28 | Noticeably smaller; fine for screen recordings | +| 33+ | Aggressive; acceptable for talking-head clips | + +Our [Video Compressor](/video-compressor) exposes this as a simple "smaller / balanced / sharper" choice and maps it internally to sane CRF values. + +## Practical recipe by goal + +- **Under a 25 MB email limit** — compress at 28 CRF, then [resize](/video-resizer) to 720p if still over. +- **Faster web playback** — convert MP4 to [WebM](/mp4-to-webm); VP9 typically saves another 20–30%. +- **Archive copy** — 20 CRF, keep original resolution, accept the larger file. + +## Common mistakes + +- Compressing an already-compressed file repeatedly: each generation softens detail. Keep the original master. +- Dropping resolution when the problem is bitrate: a sharp 720p beats a mushy 1080p on phones. +- Ignoring audio: stereo 320 kbps audio in a voice memo is wasted megabytes. + +## FAQ + +**How much smaller can I expect?** +Screen recordings often shrink 60–80%. Camera footage with heavy motion compresses less — motion is expensive to describe. + +**Will compression re-encode my audio?** +Yes, to efficient AAC — transparent for almost all content. + +**Is there a file-size cap?** +Uploads are capped per tool (the site default is generous; see the limits shown on each tool page). diff --git a/content/guides/how-to-convert-webp-to-jpg.md b/content/guides/how-to-convert-webp-to-jpg.md new file mode 100644 index 0000000..503fe23 --- /dev/null +++ b/content/guides/how-to-convert-webp-to-jpg.md @@ -0,0 +1,49 @@ +--- +slug: how-to-convert-webp-to-jpg +title: "How to Convert WebP to JPG (and When You Should)" +excerpt: "WebP images fail in older software and some workflows. Here is how to convert them to JPG or PNG quickly, plus when to convert the other way." +seo_title: "How to Convert WebP to JPG – Free Online Converter Guide" +seo_description: "Convert WebP images to JPG online in seconds. Learn why sites save WebP, which software struggles with it, and how to batch-fix downloads." +date: "2025-11-21" +tools: ["webp-to-jpg", "jpg-to-webp", "webp-to-png"] +--- + +Save an image from a website lately? Chances are it arrived as `.webp` — and then refused to open in your photo printer, CMS or ancient image viewer. Converting takes seconds once you know why this happens. + +## Why everything is WebP now + +Google designed WebP to make pages faster: identical-looking images are typically 25–35% smaller than JPEG. Browsers adopted it enthusiastically, so servers now serve it by default. The catch is that plenty of non-browser software — older Office versions, some printers, certain editing tools — never caught up. + +## Convert in three steps + +1. Open [WebP to JPG](/webp-to-jpg). +2. Drop your files onto the upload area. +3. Download the JPGs — quality is preserved at 85%, indistinguishable from the source for photos. + +Need transparency preserved? Use [WebP to PNG](/webp-to-png) instead; JPG has no alpha channel, so transparent areas would flatten to white. + +## Should you ever go back *to* WebP? + +If you manage a website: yes. Serving [JPG as WebP](/jpg-to-webp) is free performance — same pixels, smaller transfer, better Core Web Vitals. Keep JPG masters, generate WebP for delivery. + +## Quick decision table + +| Situation | Best format | +|---|---| +| Opening in old software | JPG | +| Photo with no transparency needs | JPG | +| Logo / graphic with transparency | PNG | +| Website images you serve yourself | WebP | + +## Troubleshooting + +- **"File could not be read as an image"** — some sites deliver AVIF with a `.webp` extension. Our converter reads actual file content, not just names, so these still work. +- **Colors look slightly different after conversion** — that is wide-gamut color profiles being flattened; acceptable for almost all uses. + +## FAQ + +**Does converting reduce quality?** +One conversion at high quality (85+) is visually lossless for photographs. Repeated re-saves compound loss, so keep originals. + +**Can I convert many at once?** +Yes — drop multiple files; each converts independently. diff --git a/content/guides/how-to-convert-youtube-to-mp3.md b/content/guides/how-to-convert-youtube-to-mp3.md new file mode 100644 index 0000000..34cccba --- /dev/null +++ b/content/guides/how-to-convert-youtube-to-mp3.md @@ -0,0 +1,50 @@ +--- +slug: how-to-convert-youtube-to-mp3 +title: "How to Convert YouTube to MP3 (the Right Way)" +excerpt: "Everything about turning YouTube videos into MP3 audio — bitrate choices, quality expectations, legal context and troubleshooting." +seo_title: "How to Convert YouTube to MP3 – Complete 2026 Guide" +seo_description: "Step-by-step guide to converting YouTube videos to MP3 audio, including bitrate advice, quality tips and what the law says." +date: "2025-11-09" +tools: ["youtube-to-mp3", "youtube-to-wav", "youtube-to-m4a"] +--- + +Converting a YouTube video to MP3 is the fastest way to keep lectures, podcasts and interviews in your pocket. This guide explains how the process works, which settings matter, and where the legal lines sit. + +## The short version + +1. Copy the video link. +2. Open [YouTube to MP3](/youtube-to-mp3) and paste it. +3. Press Convert and wait for the audio track to be extracted. +4. Download the finished MP3. + +That is genuinely all there is to it on our side — but a few decisions along the way change your result noticeably. + +## Which bitrate should you pick? + +MP3 bitrate trades file size against fidelity: + +- **128 kbps** — fine for spoken word; audiobooks and lectures sound identical to higher rates here. +- **192 kbps** — a sensible middle ground for music with modest demands. +- **320 kbps** — maximum MP3 quality; overkill for speech, right for archival music rips. + +One honest caveat: YouTube itself serves lossy audio streams (usually AAC at roughly 128–160 kbps). Converting to 320 kbps cannot add detail that was never there — it just avoids *further* degradation. If you want mathematically untouched audio, use [YouTube to WAV](/youtube-to-wav) instead. + +## Is it legal? + +Context decides. Ripping your own uploads, or material explicitly released under Creative Commons, is unproblematic. Extracting audio from commercial music you have not licensed conflicts with platform terms in most jurisdictions — the same rules that apply to video downloading. When in doubt, ask the rights holder or use official offline features. + +## Common failure causes + +| Symptom | Likely cause | Fix | +|---|---|---| +| "Invalid YouTube URL" | Truncated or decorated link | Paste the plain `watch?v=` URL | +| "Video could not be processed" | Private/deleted video | Check the video plays while logged out | +| Audio starts mid-video | Timestamped share link | Remove the `?t=` parameter | + +## FAQ + +**Why does my file start with silence?** +Some videos embed intro padding. Trim it afterwards with the [audio trimmer](/audio-trimmer). + +**M4A instead?** +Yes — [YouTube to M4A](/youtube-to-m4a) skips one re-encoding step because YouTube's source streams are already AAC-based, so quality stays marginally better at similar sizes. diff --git a/content/guides/how-to-download-youtube-videos.md b/content/guides/how-to-download-youtube-videos.md new file mode 100644 index 0000000..2a02de3 --- /dev/null +++ b/content/guides/how-to-download-youtube-videos.md @@ -0,0 +1,56 @@ +--- +slug: how-to-download-youtube-videos +title: "How to Download YouTube Videos Safely and Legally" +excerpt: "A practical walkthrough of saving YouTube videos for offline use — which tools to use, what the platform rules say, and how to avoid sketchy sites." +seo_title: "How to Download YouTube Videos – Safe & Legal Guide" +seo_description: "Learn how to download YouTube videos safely: step-by-step instructions, legal considerations, quality options and common pitfalls explained." +date: "2025-11-02" +tools: ["youtube-downloader", "youtube-video-downloader", "youtube-thumbnail-downloader"] +--- + +Saving a YouTube video for offline use is a reasonable thing to want — long flights, unreliable connections, archiving your own uploads, or keeping a copy of Creative Commons material you are licensed to reuse. This guide covers how to do it cleanly and within the rules. + +## What YouTube's terms actually say + +YouTube's Terms of Service allow offline viewing through their official **YouTube Premium** download feature inside the mobile apps. Downloading outside that mechanism can conflict with the platform's terms, so it matters *what* you download and *why*: + +- **Your own uploads** — always fine; you own them. +- **Creative Commons licensed videos** — allowed under the license terms. +- **Anything else** — get permission from the rights holder first. + +We designed Toolvana around this reality: video download capability ships disabled by default and every operator has to make an explicit, reviewed decision to enable it. The tools that never touch restricted content — [thumbnails](/youtube-thumbnail-downloader), [metadata](/youtube-metadata-viewer), [transcripts analysis](/youtube-transcript) — work out of the box. + +## Step by step: saving a video you're allowed to keep + +1. Copy the video URL from your browser's address bar. +2. Open the [YouTube Downloader](/youtube-downloader). +3. Paste the link and press Convert. +4. Pick the resolution you actually need — 720p looks fine on most laptops and downloads far faster than 1080p. +5. Save the MP4 file when processing finishes. + +## Choosing a quality level + +| Resolution | Typical use | Approximate size per minute | +|---|---|---| +| 360p | Reference only | 5–15 MB | +| 720p | Laptops, tablets | 25–60 MB | +| 1080p | Large screens | 50–120 MB | + +Higher resolution costs time and bandwidth without visible benefit on small screens. When in doubt, start at 720p. + +## Common problems and fixes + +- *"Video unavailable"* — the video may be private, age-restricted or region-blocked. Private videos cannot be downloaded at all. +- *"This tool is disabled"* — the server operator has not enabled stream downloading. Metadata and thumbnail tools continue working. +- Slow progress on long videos — resolution and duration drive processing time. A two-hour lecture at 1080p takes considerably longer than a three-minute clip. + +## Frequently asked questions + +**Is it legal to download YouTube videos?** +It depends entirely on the content and jurisdiction. Your own uploads and properly licensed material are safe; commercial content generally requires permission or Premium's official offline mode. + +**Can I download just the audio?** +Yes — see our [YouTube to MP3 converter](/youtube-to-mp3), which extracts audio tracks in several formats. + +**Do downloads expire?** +Files produced here live for a short retention window (a couple of hours by default) and then vanish from our storage permanently. diff --git a/content/guides/how-to-extract-audio-from-video.md b/content/guides/how-to-extract-audio-from-video.md new file mode 100644 index 0000000..df0485a --- /dev/null +++ b/content/guides/how-to-extract-audio-from-video.md @@ -0,0 +1,48 @@ +--- +slug: how-to-extract-audio-from-video +title: "How to Extract Audio from Any Video File" +excerpt: "Pull clean MP3, WAV or M4A soundtracks out of MP4, MOV and MKV files online — with format advice and quality expectations." +seo_title: "How to Extract Audio from Video Online – Free Guide" +seo_description: "Learn how to extract audio from video files online. MP3 vs WAV vs M4A output explained, plus tips for best sound quality." +date: "2025-12-12" +tools: ["extract-audio-from-video", "mp4-to-mp3", "video-to-gif"] +--- + +Interview recorded on your phone? Lecture saved as a video? The soundtrack is often the only part you need. Extracting it takes one upload. + +## Three steps + +1. Open [Extract Audio from Video](/extract-audio-from-video). +2. Upload the video — MP4, MOV, MKV and WebM all work. +3. Choose an output format and download the audio file. + +For the common case of YouTube-sourced content, our [MP4 to MP3 converter](/mp4-to-mp3) does the same job with format shortcuts on the front page. + +## Which output format? + +| Format | Best for | File size | +|---|---|---| +| **MP3** | Universal playback, podcasts | Small | +| **M4A/AAC** | Modern devices, better quality per MB | Smaller | +| **WAV** | Editing in Audition/Audacity, sampling | Large (uncompressed) | +| **FLAC** | Archival copies of music videos | Large (lossless) | + +Default to MP3 at 192 kbps unless you have a specific reason not to — it plays everywhere and sounds indistinguishable from the source for spoken word. + +## What actually happens technically + +The extraction demuxes the audio stream from the container and re-encodes it to your chosen codec. Two consequences worth knowing: + +1. **Quality cannot improve.** If the source carries 128 kbps AAC, that is your ceiling. Choose matching bitrates rather than inflating numbers. +2. **Video tracks are discarded**, so files shrink dramatically — an hour-long lecture drops from hundreds of megabytes to tens. + +## Common questions + +**Can I extract just part of the audio?** +Yes — trim the video first with the [video cutter](/video-cutter), then extract; or extract everything and cut the audio with the [audio cutter](/audio-cutter). + +**Why is my extracted audio silent?** +Some videos genuinely carry no audio track (timelapses, some dashcam loops). Our tool detects this and reports it instead of producing an empty file. + +**Does it work with multiple audio tracks?** +MKV files sometimes hold several language tracks; the primary track is extracted by default. diff --git a/content/guides/how-to-merge-pdf-files.md b/content/guides/how-to-merge-pdf-files.md new file mode 100644 index 0000000..a13ae9a --- /dev/null +++ b/content/guides/how-to-merge-pdf-files.md @@ -0,0 +1,51 @@ +--- +slug: how-to-merge-pdf-files +title: "How to Merge PDF Files Into One Document" +excerpt: "Combine scans, contracts and statements into a single ordered PDF — plus page-order tricks and privacy notes." +seo_title: "How to Merge PDF Files Online – Free & Private" +seo_description: "Merge multiple PDF files into one document online for free. Reorder pages, keep quality, and learn how private the process really is." +date: "2025-12-18" +tools: ["pdf-merger", "pdf-splitter", "pdf-page-extractor"] +--- + +Merging PDFs is the most common document chore there is: scanned pages that arrived separately, a contract split across three emails, statements needing one combined archive. Here is the fast path. + +## Merging in four steps + +1. Open the [PDF Merger](/pdf-merger). +2. Upload your first file — then add the rest; upload order becomes page order. +3. Press Merge. +4. Download the combined document. + +Files are stitched with qpdf, a battle-tested open-source engine. Output keeps original page quality — no re-compression unless you ask for it separately. + +## Getting page order right + +The most common mistake: uploading files alphabetically when the intended order differs. Two fixes: + +- **Rename before uploading** (`01-scan.pdf`, `02-scan.pdf` ...). +- Or merge first, then reorder by extracting page ranges with the [PDF Page Extractor](/pdf-page-extractor) and re-merging. + +## When to split instead of merge + +Sometimes the opposite job is needed. The [PDF Splitter](/pdf-splitter) breaks a document into single-page files (zipped), which suits: + +- Distributing individual chapters +- Pulling one signed page from a packet + +## Privacy notes worth knowing + +- Merged documents exist on our workers only during processing and expire automatically afterwards. +- Merging never adds identifying metadata — but source files may carry some. Run [PDF Metadata Remover](/pdf-metadata-remover) on sensitive output. +- Nothing you merge is readable by other users; jobs are isolated per session. + +## FAQ + +**Is there a page or file limit?** +Practical limits come from total upload size rather than file count — dozens of typical documents merge fine. + +**Do bookmarks and form fields survive?** +Bookmarks flatten during merging (page content is preserved perfectly). Interactive form fields survive when they live on the pages being merged. + +**Why does my merged file look rotated?** +Individual scans often carry inconsistent rotation flags. Rotate pages at the source scanner, or rasterize-and-rebuild via [PDF to JPG](/pdf-to-jpg) then [JPG to PDF](/jpg-to-pdf). diff --git a/content/guides/how-to-reduce-video-size.md b/content/guides/how-to-reduce-video-size.md new file mode 100644 index 0000000..51a07a1 --- /dev/null +++ b/content/guides/how-to-reduce-video-size.md @@ -0,0 +1,61 @@ +--- +slug: how-to-reduce-video-size +title: "How to Reduce Video Size for Email, Discord and Uploads" +excerpt: "Platform size limits decoded — email attachments, Discord uploads, WhatsApp and Loom caps — with the exact settings to hit each one." +seo_title: "How to Reduce Video Size – Hit Every Upload Limit" +seo_description: "Shrink videos under email, Discord and WhatsApp limits. Resolution, bitrate and codec settings that cut file size without wrecking quality." +date: "2025-11-28" +tools: ["video-compressor", "video-cutter", "video-resolution-converter"] +--- + +Every platform has a ceiling. Here are the current common limits and the fastest way to get under each. + +## The limits that matter in 2026 + +| Platform | Limit | Notes | +|---|---|---| +| Gmail / Outlook | 25 MB attachment | Drives most compression needs | +| Discord (free) | 10 MB per file | Brutal for video | +| WhatsApp | 16 MB (status), 2 GB (document share) | Document route is generous | +| Telegram | 2 GB free | Rarely a constraint | +| Loom free tier | 5 minutes / 25 MB-ish effective | Compress or trim | + +## Strategy 1: trim before you compress + +The cheapest megabytes are the ones you delete. Cutting a rambling intro from a screen recording saves proportionally more than any codec trick. Use the [Video Trimmer](/video-trimmer) first — it copies streams when possible so quality stays untouched. + +## Strategy 2: match resolution to destination + +Nobody needs 4K inside an email: + +- **Chat apps** — 720p is plenty on phones. +- **Email previews** — even 540p reads fine. +- **Documentation embeds** — 1080p keeps text crisp. + +Our [resolution converter](/video-resolution-converter) scales while preserving aspect ratio automatically. + +## Strategy 3: compress last + +With content trimmed and resolution matched, run the [Video Compressor](/video-compressor): + +- **Balanced preset** handles most cases. +- **Smaller** preset suits static screen recordings — they compress spectacularly. +- **Sharper** preset only when fine text must stay readable. + +## Worked example + +A 340 MB, 8-minute 4K screen recording destined for email: + +1. Trim to 6:40 of actual content → ~280 MB +2. Scale to 1080p → ~95 MB +3. Compress balanced → **18 MB** ✓ under the 25 MB limit + +Total time: about three minutes including processing. + +## FAQ + +**Why does my compressed video look blocky during motion?** +The quality target is fighting fast movement. Step one preset sharper, or reduce resolution instead — smaller frames hide artifacts better than starved bitrates. + +**Does compressing remove metadata?** +Re-encoding drops most container metadata naturally. For guaranteed stripping, run [Remove Metadata](/remove-metadata-from-video) afterwards. diff --git a/content/guides/mp3-vs-m4a-which-is-better.md b/content/guides/mp3-vs-m4a-which-is-better.md new file mode 100644 index 0000000..223bbd2 --- /dev/null +++ b/content/guides/mp3-vs-m4a-which-is-better.md @@ -0,0 +1,59 @@ +--- +slug: mp3-vs-m4a-which-is-better +title: "MP3 vs M4A: Which Audio Format Should You Use?" +excerpt: "Two dominant lossy audio formats, one honest comparison — compatibility, quality per megabyte, and the use cases where each wins." +seo_title: "MP3 vs M4A – Which Audio Format Is Better in 2026?" +seo_description: "MP3 or M4A? Compare sound quality, file size, device support and streaming use to pick the right audio format for your library." +date: "2025-12-05" +tools: ["mp3-to-m4a", "m4a-to-mp3", "audio-converter"] +--- + +The MP3-vs-M4A question usually appears at the worst moment: an export dialog asking you to choose. Here is the honest breakdown. + +## The 30-second answer + +- **Maximum compatibility** (old cars, ancient hi-fi, random hotel radios) → **MP3** +- **Modern devices, smaller files, Apple ecosystem** → **M4A** + +Both are lossy formats — they discard audio information humans mostly cannot hear. Neither "loses quality" audibly at sensible bitrates. + +## Quality per megabyte + +M4A (which carries AAC audio) was designed as MP3's successor: + +| Bitrate | Verdict | +|---|---| +| 128 kbps AAC | ≈ transparent for most listeners | +| 128 kbps MP3 | Audible softness on complex music | +| 192 kbps MP3 | Matches 128 kbps AAC roughly | +| 320 kbps MP3 | Overkill except archival | + +Rule of thumb: at equal bitrate, M4A sounds a notch better; at equal quality, M4A is 25–30% smaller. + +## Compatibility reality check + +MP3's superpower is ubiquity — it decodes on everything with a speaker, including things older than your career. M4A plays on every phone and computer sold this decade, every current car system, all major consoles. What it *doesn't* play on: that 2003 aftermarket head unit. + +## When each format wins + +**Choose MP3 when:** +- The file must survive contact with unknown hardware. +- You email audio to non-technical people. +- Target platforms demand it (some podcast directories historically did). + +**Choose M4A when:** +- Files live on modern phones and computers. +- Storage space matters (offline lecture libraries). +- You are in the Apple ecosystem (iTunes/Apple Music native). + +## Converting between them + +Already committed to the wrong format? [M4A to MP3](/m4a-to-mp3) and [MP3 to M4A](/mp3-to-m4a) conversions take seconds. One warning applies both directions: converting lossy→lossy always re-encodes, so keep original masters of anything precious. + +## FAQ + +**Is FLAC better than both?** +FLAC is lossless — different category entirely. Use it for archiving masters; use MP3/M4A for everyday listening. Our [FLAC converter](/flac-to-mp3) bridges the worlds. + +**What about WAV?** +Uncompressed production format. Huge files, no tagging niceties — an editing intermediate, not a listening format. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b1ff7b9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,88 @@ +# --------------------------------------------------------------------------- +# Toolvana production topology. +# +# browser -> nginx -> php-fpm (web) stateless, page-cached +# -> api (php-fpm) /api/v1 traffic isolation +# +# queue: postgres/mysql durable jobs; workers scale horizontally: +# worker-1 .. worker-N (spark queue:work --loop) +# scheduler (media:cleanup every 5 min + stale reaper) +# +# Replace the DB image or add Redis freely — the app only needs a +# PDO-compatible database and the file cache driver works everywhere. +# --------------------------------------------------------------------------- + +x-app-common: &app-common + build: + context: . + dockerfile: docker/Dockerfile + restart: unless-stopped + env_file: .env.docker + volumes: + - media-storage:/app/writable/media + - incoming-storage:/app/writable/incoming + - session-storage:/app/writable/session + - og-cache:/app/writable/og + +services: + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" # mount certs + full server block for TLS in production + volumes: + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ./public:/app/public:ro + - ./writable/og:/app/writable/og:ro + depends_on: + - web + - api + + web: + <<: *app-common + environment: + TV_ROLE: web + + # Dedicated pool for developer API + processing endpoints so large + # conversions never starve page rendering. + api: + <<: *app-common + environment: + TV_ROLE: api + + worker-1: + <<: *app-common + command: ["php", "spark", "queue:work", "--loop", "--sleep", "2"] + deploy: + replicas: 3 # worker-1..worker-N: scale to taste + + scheduler: + <<: *app-common + command: > + bash -c "while true; do + php spark media:cleanup >> writable/logs/scheduler.log 2>&1; + sleep 300; + done" + + db: + image: mariadb:11 + restart: unless-stopped + environment: + MARIADB_DATABASE: toolvana + MARIADB_USER: toolvana + MARIADB_PASSWORD: ${DB_PASSWORD:-toolvana} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root-secret} + volumes: + - db-data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + retries: 5 + +volumes: + db-data: + media-storage: + incoming-storage: + session-storage: + og-cache: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..72eb01e --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,44 @@ +# --------------------------------------------------------------------------- +# Toolvana web/api image: PHP-FPM 8.2 with all media processing binaries. +# The same image serves the web tier and runs workers/scheduler — services +# differ only by command, keeping builds small and versions identical. +# --------------------------------------------------------------------------- + +FROM php:8.2-fpm-alpine AS base + +RUN apk add --no-cache bash icu-libs libzip oniguruma libpng jpegoptim + +# --- PHP extensions required by CodeIgniter 4 + app ------------------------ +RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev oniguruma-dev libpng-dev \ + && docker-php-ext-install intl mbstring zip pdo_mysql mysqli opcache pcntl exif \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del .build-deps + +# --- Media processing stack ------------------------------------------------- +RUN apk add --no-cache ffmpeg imagemagick poppler-utils qpdf ghostscript zip unzip curl \ + && wget -q https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp \ + && chmod 755 /usr/local/bin/yt-dlp || true + +# --- Composer deps ----------------------------------------------------------- +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +WORKDIR /app +COPY composer.json composer.lock ./ +RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction + +COPY . . +RUN composer dump-autoload -o \ + && chown -R www-data:www-data writable \ + && chmod -R ug+rwX writable + +# --- PHP production defaults -------------------------------------------------- +RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini \ + && { echo "opcache.enable=1"; \ + echo "opcache.validate_timestamps=0"; \ + echo "opcache.memory_consumption=192"; \ + echo "realpath_cache_size=4096K"; \ + echo "memory_limit=512M"; } > /usr/local/etc/php/conf.d/toolvana.ini + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..03ed063 --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,41 @@ +# Toolvana — nginx reverse proxy + static asset server. + +server { + listen 80; + server_name _; + root /app/public; + index index.php; + + # security headers (TLS terminates here in production; enable HSTS after cert) + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + gzip on; + gzip_comp_level 5; + gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml; + # brotli: enable with the ngx_brotli module where available + # brotli on; brotli_types text/plain text/css application/javascript application/json image/svg+xml; + + # --- static assets: immutable caching ------------------------------------ + location ~* \.(css|js|svg|png|jpg|webp|woff2?|ico)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.php$is_args$args; + } + + # --- PHP-FPM to the web/api upstreams -------------------------------------- + location ~ \.php$ { + try_files $uri =404; + include fastcgi_params; + fastcgi_pass web:9000; + fastcgi_read_timeout 120s; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } +} + +# HTTP/3 note: run behind Cloudflare or a QUIC-enabled edge for h3. 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/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/assets/css/app.css b/public/assets/css/app.css new file mode 100644 index 0000000..7a061ac --- /dev/null +++ b/public/assets/css/app.css @@ -0,0 +1,196 @@ +/* ========================================================================== + Toolvana design system — single stylesheet, no framework, no webfonts. + System font stack keeps LCP fast and CLS at zero. + ========================================================================== */ + +:root { + --brand-50:#f5f3ff; --brand-100:#ede9fe; --brand-500:#7c3aed; --brand-600:#6d28d9; + --brand-700:#5b21b6; --brand-900:#2e1065; + --ink-900:#111827; --ink-700:#374151; --ink-500:#6b7280; --ink-300:#d1d5db; + --paper:#ffffff; --bg:#fafafa; --ok:#059669; --err:#dc2626; --warn:#d97706; + --radius:14px; --radius-sm:9px; + --shadow-sm:0 1px 2px rgb(17 24 39 / .06),0 1px 3px rgb(17 24 39 / .08); + --shadow-md:0 4px 12px rgb(17 24 39 / .10); + --maxw:1080px; + font-size:16px; +} + +* { box-sizing:border-box; } +html { -webkit-text-size-adjust:100%; scroll-behavior:smooth; } +body { + margin:0; background:var(--bg); color:var(--ink-900); + font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; + line-height:1.6; text-rendering:optimizeLegibility; +} +img,svg,video { max-width:100%; height:auto; } + +.icon { width:1.25em; height:1.25em; vertical-align:-.22em; flex:none; } + +a { color:var(--brand-600); text-decoration:none; } +a:hover { text-decoration:underline; } + +:focus-visible { outline:3px solid var(--brand-500); outline-offset:2px; border-radius:4px; } + +.skip-link{position:absolute;left:-9999px;top:0;background:#fff;padding:.6rem 1rem;z-index:99;border-radius:0 0 var(--radius-sm) 0} +.skip-link:focus{left:0} + +/* ---------- header ---------- */ +.site-header{background:var(--paper);border-bottom:1px solid #eee;position:sticky;top:0;z-index:40} +.site-header .inner{max-width:var(--maxw);margin:0 auto;display:flex;align-items:center;gap:1.2rem;padding:.65rem 1.25rem} +.logo{display:flex;align-items:center;gap:.55rem;font-weight:800;font-size:1.18rem;color:var(--ink-900);letter-spacing:-.02em} +.logo:hover{text-decoration:none} +.logo-mark{width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,var(--brand-600),#a855f7);display:inline-flex;align-items:center;justify-content:center;color:#fff} +.main-nav{margin-left:auto;display:flex;align-items:center;gap:1rem} +.main-nav a{color:var(--ink-700);font-weight:550;font-size:.95rem;padding:.35rem .15rem} +.nav-toggle{display:none;margin-left:auto;background:none;border:0;color:var(--ink-700);padding:.4rem;cursor:pointer} + +/* ---------- hero ---------- */ +.hero{background:linear-gradient(160deg,var(--brand-700) 0%,#7c3aed 60%,#9333ea 100%);color:#fff;padding:3.4rem 1.25rem 4.2rem;text-align:center} +.hero h1{font-size:clamp(1.8rem,4.5vw,2.9rem);line-height:1.15;margin:.4rem auto 0;font-weight:850;letter-spacing:-.03em;max-width:820px} +.hero .tagline{font-size:clamp(1rem,2vw,1.2rem);opacity:.93;max-width:600px;margin:1rem auto 1.8rem} +.hero-search{display:flex;max-width:560px;margin:0 auto;background:#fff;border-radius:999px;padding:.3rem;box-shadow:var(--shadow-md)} +.hero-search input[type=search]{flex:1;border:0;font-size:1.02rem;padding:.72rem .5rem .72rem 1.15rem;min-width:0;background:none;color:var(--ink-900)} +.hero-search input:focus{outline:none} +.hero-search button{border:0;background:var(--ink-900);color:#fff;border-radius:999px;padding:.68rem 1.5rem;font-weight:650;cursor:pointer;font-size:.97rem} +.hero-stats{margin-top:1.6rem;font-size:.92rem;opacity:.85} + +/* ---------- layout blocks ---------- */ +.wrap{max-width:var(--maxw);margin:0 auto;padding:0 1.25rem} +section.block{padding:2.6rem 0} +section.block.tight{padding:1.6rem 0} +h2.section-title{font-size:1.45rem;font-weight:800;letter-spacing:-.02em;margin:0 0 1.2rem;display:flex;align-items:center;gap:.6rem} +h2.section-title .see-all{margin-left:auto;font-size:.9rem;font-weight:600} + +/* ---------- cards ---------- */ +.grid{display:grid;gap:1rem} +.grid.cols-4{grid-template-columns:repeat(4,1fr)} +.grid.cols-3{grid-template-columns:repeat(3,1fr)} +.grid.cols-2{grid-template-columns:repeat(2,1fr)} + +.card{background:var(--paper);border-radius:var(--radius);padding:1.15rem 1.2rem;box-shadow:var(--shadow-sm);transition:transform .12s ease,box-shadow .12s ease;display:block;color:inherit} +a.card:hover{transform:translateY(-2px);box-shadow:var(--shadow-md);text-decoration:none} +.card h3,.card h2{margin:0 0 .3rem;font-size:1.02rem;font-weight:700;letter-spacing:-.01em} +.card p{margin:0;color:var(--ink-500);font-size:.88rem;line-height:1.5} +.card .icon-wrap{width:42px;height:42px;border-radius:11px;background:var(--brand-50);color:var(--brand-600);display:flex;align-items:center;justify-content:center;margin-bottom:.75rem} +.card .icon-wrap .icon{width:22px;height:22px} +.badge{display:inline-block;font-size:.68rem;font-weight:750;letter-spacing:.06em;text-transform:uppercase;background:var(--brand-50);color:var(--brand-700);padding:.18rem .55rem;border-radius:999px} +.meta-line{font-size:.78rem;color:var(--ink-500);margin-top:.55rem} + +.cat-card{display:flex;flex-direction:column;align-items:flex-start;gap:.4rem} +.cat-card .count{font-size:.8rem;color:var(--ink-500)} + +/* ---------- tool page ---------- */ +.tool-head{margin:2.2rem 0 1rem} +.tool-head h1{font-size:clamp(1.6rem,4vw,2.3rem);font-weight:830;letter-spacing:-.03em;margin:.35rem 0 .4rem;line-height:1.18} +.breadcrumbs ol{list-style:none;display:flex;flex-wrap:wrap;gap:.45rem;margin:1.4rem 0 0;padding:0;font-size:.85rem;color:var(--ink-500)} +.breadcrumbs li+li::before{content:"→";margin-right:.45rem;color:var(--ink-300)} +.breadcrumbs a{color:var(--ink-500)} +.breadcrumbs [aria-current]{color:var(--ink-900);font-weight:600} + +.tool-panel{background:var(--paper);border-radius:18px;box-shadow:var(--shadow-sm);padding:1.8rem;margin-top:1.2rem} +.url-form{display:flex;flex-direction:column;gap:.85rem} +.url-row{display:flex;gap:.7rem} +.input-lg{flex:1;border:2px solid var(--ink-300);border-radius:12px;padding:.95rem 1.05rem;font-size:1.04rem;min-width:0;transition:border-color .15s} +.input-lg:focus{outline:none;border-color:var(--brand-500)} +.btn-primary{border:0;background:linear-gradient(135deg,var(--brand-600),#9333ea);color:#fff;font-size:1.05rem;font-weight:700;padding:.95rem 2rem;border-radius:12px;cursor:pointer;min-height:54px;white-space:nowrap} +.btn-primary:hover{filter:brightness(1.06)} +.btn-primary[disabled]{opacity:.6;cursor:not-allowed} +.hint{font-size:.83rem;color:var(--ink-500)} + +.dropzone{border:2.5px dashed var(--ink-300);border-radius:16px;padding:2.6rem 1.4rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#fcfcff} +.dropzone:hover,.dropzone.dragover{border-color:var(--brand-500);background:var(--brand-50)} +.dropzone .icon{width:38px;height:38px;color:var(--brand-600)} +.dropzone strong{display:block;font-size:1.05rem;margin:.6rem 0 .2rem} +.dropzone span{font-size:.86rem;color:var(--ink-500)} +.file-chip{display:flex;align-items:center;gap:.75rem;background:var(--brand-50);border-radius:11px;padding:.7rem .9rem;font-size:.9rem} +.file-chip .name{font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.file-chip button{margin-left:auto;border:0;background:none;color:var(--err);cursor:pointer;font-size:.82rem;font-weight:650} + +.status-flow{margin-top:1rem} +.progress-track{height:9px;border-radius:999px;background:#eceefc;overflow:hidden} +.progress-fill{height:100%;width:0;background:linear-gradient(90deg,var(--brand-500),#a855f7);border-radius:999px;transition:width .35s ease} +.stages{list-style:none;display:flex;justify-content:space-between;padding:0;margin:.7rem 0 0;font-size:.76rem;color:var(--ink-500)} +.stages li.done{color:var(--ok);font-weight:650} +.stages li.active{color:var(--brand-700);font-weight:700} + +.result-panel{margin-top:1.1rem;border:1px solid #e5e7eb;border-radius:14px;padding:1.1rem;display:none} +.result-panel.show{display:block} +.result-meta{display:grid;grid-template-columns:auto 1fr auto auto;gap:.4rem 1.1rem;font-size:.92rem;align-items:center} +.result-actions{margin-top:.9rem;display:flex;gap:.7rem;flex-wrap:wrap} +.notice{border-left:4px solid var(--warn);background:#fffbeb;padding:.8rem 1rem;border-radius:0 10px 10px 0;font-size:.92rem;margin-top:1rem} +.notice.error{border-color:var(--err);background:#fef2f2} + +details.advanced{margin-top:1rem;border:1px solid #e8e8f0;border-radius:11px;padding:.75rem 1rem} +details.advanced summary{cursor:pointer;font-weight:650;font-size:.92rem;color:var(--ink-700)} +.opt-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.9rem;padding-top:.9rem} +.opt-grid label{display:block;font-size:.8rem;font-weight:650;color:var(--ink-700);margin-bottom:.25rem} +.opt-grid select,.opt-grid input[type=number],.opt-grid input[type=text]{width:100%;border:1.5px solid var(--ink-300);border-radius:9px;padding:.5rem .6rem;font-size:.92rem} + +.faq details{background:var(--paper);border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);margin-bottom:.65rem} +.faq summary{cursor:pointer;font-weight:650;padding:.9rem 1.1rem;list-style:none;display:flex;justify-content:space-between;align-items:center;gap:1rem} +.faq summary::-webkit-details-marker{display:none} +.faq summary::after{content:"+";font-size:1.3rem;color:var(--brand-600);line-height:1} +.faq details[open] summary::after{content:"–"} +.faq .answer{padding:0 1.1rem .95rem;color:var(--ink-700);font-size:.94rem} + +.steps{counter-reset:step;list-style:none;padding:0;margin:0;display:grid;gap:.8rem} +.steps li{counter-increment:step;background:var(--paper);border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);padding:1rem 1.1rem 1rem 3.4rem;position:relative;font-size:.94rem} +.steps li strong{display:block} +.steps li::before{content:counter(step);position:absolute;left:.95rem;top:1rem;width:26px;height:26px;border-radius:50%;background:var(--brand-600);color:#fff;font-weight:750;display:flex;align-items:center;justify-content:center;font-size:.85rem} + +.chips{display:flex;flex-wrap:wrap;gap:.45rem;margin:.4rem 0} +.chip{background:var(--paper);border:1px solid #e5e7eb;border-radius:999px;padding:.28rem .8rem;font-size:.84rem;font-weight:600;color:var(--ink-700)} +.chip:hover{border-color:var(--brand-500);color:var(--brand-600);text-decoration:none} + +.prose{max-width:74ch} +.prose h2{font-size:1.3rem;margin:2rem 0 .7rem;letter-spacing:-.02em} +.prose p,.prose ul,.prose ol{color:var(--ink-700);font-size:.98rem} +.prose table{border-collapse:collapse;width:100%;font-size:.9rem;margin:1rem 0} +.prose th,.prose td{border:1px solid #e5e7eb;padding:.5rem .7rem;text-align:left} +.prose th{background:var(--brand-50)} +.prose code{background:#f3f0ff;padding:.1rem .35rem;border-radius:5px;font-size:.88em} + +/* ---------- utility/text tools ---------- */ +.io-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem} +textarea.tool-area{width:100%;min-height:190px;border:2px solid var(--ink-300);border-radius:12px;padding:.9rem;font:inherit;font-size:.95rem;resize:vertical} +textarea.tool-area:focus{outline:none;border-color:var(--brand-500)} + +/* ---------- footer ---------- */ +.site-footer{background:var(--paper);border-top:1px solid #eee;margin-top:3rem;padding:2.4rem 0 2rem;font-size:.9rem} +.footer-cols{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:2rem} +.footer-cols h4{margin:.2rem 0 .7rem;font-size:.82rem;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-500)} +.footer-cols ul{list-style:none;padding:0;margin:0;display:grid;gap:.4rem} +.footer-bottom{display:flex;justify-content:space-between;gap:1rem;flex-wrap:wrap;color:var(--ink-500);font-size:.82rem;margin-top:2rem;padding-top:1.2rem;border-top:1px solid #f0f0f0} + +/* ---------- errors / misc ---------- */ +.center-page{max-width:640px;margin:4rem auto;padding:0 1.25rem;text-align:left} +.error-hero{text-align:center;padding:3.5rem 0 1rem} +.error-hero code.big{font-size:3.4rem;font-weight:850;color:var(--brand-200,#ddd6fe);letter-spacing:-.05em} + +@media (max-width:900px){ + .grid.cols-4{grid-template-columns:repeat(2,1fr)} + .grid.cols-3{grid-template-columns:repeat(2,1fr)} + .footer-cols{grid-template-columns:1fr 1fr} + .io-grid{grid-template-columns:1fr} +} +@media (max-width:620px){ + .main-nav{ + display:none;position:absolute;top:100%;left:0;right:0;background:var(--paper); + flex-direction:column;align-items:flex-start;padding:1rem 1.4rem;border-bottom:1px solid #eee;gap:.8rem; + box-shadow:var(--shadow-md) + } + .main-nav.open{display:flex} + .nav-toggle{display:inline-flex} + .url-row{flex-direction:column} + .btn-primary{width:100%} + .grid.cols-2,.grid.cols-3,.grid.cols-4{grid-template-columns:1fr} + .hero-search{flex-direction:row} + .result-meta{grid-template-columns:1fr auto} + .stages{flex-wrap:wrap;gap:.3rem;justify-content:flex-start} + .tool-panel{padding:1.2rem} +} + +@media (prefers-reduced-motion:no-preference){ + .progress-fill{animation:pulse 1.4s ease-in-out infinite alternate} + @keyframes pulse{from{opacity:1}to{opacity:.75}} +} diff --git a/public/assets/js/app.js b/public/assets/js/app.js new file mode 100644 index 0000000..f8bbd17 --- /dev/null +++ b/public/assets/js/app.js @@ -0,0 +1,58 @@ +/* Toolvana global JS — tiny, dependency-free. */ +(function () { + 'use strict'; + + // mobile nav + var toggle = document.getElementById('nav-toggle'); + var nav = document.getElementById('main-nav'); + if (toggle && nav) { + toggle.addEventListener('click', function () { + var open = nav.classList.toggle('open'); + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + } + + // search suggest (progressive enhancement only) + var input = document.querySelector('[data-suggest]'); + if (input && 'fetch' in window) { + var box = null; + function closeBox() { if (box) { box.remove(); box = null; } } + input.addEventListener('input', function () { + var q = input.value.trim(); + if (q.length < 2) { closeBox(); return; } + fetch('/api/suggest?q=' + encodeURIComponent(q)) + .then(function (r) { return r.json(); }) + .then(function (data) { + closeBox(); + if (!data.results || !data.results.length) return; + box = document.createElement('div'); + box.className = 'suggest'; + box.setAttribute('role', 'listbox'); + data.results.forEach(function (r) { + var a = document.createElement('a'); + a.href = r.url; + a.textContent = r.name; + a.setAttribute('role', 'option'); + a.style.cssText = 'display:block;padding:.55rem 1.1rem;color:var(--ink-900);font-size:.95rem;text-decoration:none'; + a.onmouseenter = function () { a.style.background = 'var(--brand-50)'; }; + a.onmouseleave = function () { a.style.background = 'none'; }; + box.appendChild(a); + }); + Object.assign(box.style, { + position: 'absolute', top: '100%', left: 0, right: 0, background: '#fff', + borderRadius: '16px', boxShadow: 'var(--shadow-md)', zIndex: 60, overflow: 'hidden' + }); + input.parentElement.style.position = 'relative'; + input.parentElement.appendChild(box); + }).catch(function () {}); + }); + document.addEventListener('click', function (e) { + if (box && !box.contains(e.target) && e.target !== input) closeBox(); + }); + } + + // service worker (PWA shell caching) + if ('serviceWorker' in navigator && location.protocol === 'https:') { + navigator.serviceWorker.register('/service-worker.js').catch(function () {}); + } +})(); diff --git a/public/assets/js/text-tool.js b/public/assets/js/text-tool.js new file mode 100644 index 0000000..6b4e6c4 --- /dev/null +++ b/public/assets/js/text-tool.js @@ -0,0 +1,214 @@ +/* Client-side text tools — everything computed locally, zero requests. */ +(function () { + 'use strict'; + + var form = document.getElementById('tool-form'); + if (!form) return; + var op = form.dataset.client; + var input = document.getElementById('tool-input'); + var out = document.getElementById('client-result'); + + function render(text, extra) { + out.innerHTML = ''; + var pre = document.createElement('textarea'); + pre.className = 'tool-area'; + pre.readOnly = true; + pre.value = text; + out.appendChild(pre); + if (extra) { + var p = document.createElement('p'); + p.className = 'hint'; + p.textContent = extra; + out.appendChild(p); + } + var copy = document.createElement('button'); + copy.className = 'btn-primary'; + copy.type = 'button'; + copy.style.cssText = 'margin-top:.7rem;padding:.6rem 1.4rem;font-size:.92rem'; + copy.textContent = 'Copy result'; + copy.addEventListener('click', function () { + navigator.clipboard.writeText(pre.value).then(function () { copy.textContent = 'Copied!'; setTimeout(function(){copy.textContent='Copy result';},1500); }); + }); + out.appendChild(copy); + out.classList.add('show'); + } + + form.addEventListener('submit', function (e) { + e.preventDefault(); + var v = input.value; + + // Server-rendered tools go through the job queue like uploads. + if (op === 'qr_generate') { + runQrJob(v); + return; + } + + switch (op) { + case 'hash_text': { + Promise.all(['SHA-256', 'SHA-512', 'SHA-1'].map(function (alg) { + return crypto.subtle.digest(alg, new TextEncoder().encode(v)).then(function (buf) { + return [alg, Array.from(new Uint8Array(buf)).map(function (b) { return b.toString(16).padStart(2, '0'); }).join('')]; + }); + })).then(function (pairs) { + var md5 = sparklessMd5(v); + render(pairs.map(function (p) { return p[0] + '\n' + p[1]; }).join('\n\n') + '\n\nMD5\n' + md5); + }); + break; + } + case 'uuid_gen': { + var lines = []; + for (var i = 0; i < 10; i++) lines.push(crypto.randomUUID()); + render(lines.join('\n')); + break; + } + case 'json_format': { + try { + var obj = JSON.parse(v); + render(JSON.stringify(obj, null, 2)); + } catch (err) { + render('// JSON error: ' + err.message + '\n\n' + String(err).split('\n')[1] || ''); + } + break; + } + case 'url_encode': + try { render(encodeURIComponent(v)); } catch (e) { render(''); } + break; + case 'url_decode': + try { render(decodeURIComponent(v.replace(/\+/g, ' '))); } catch (err) { showErrorBox('Invalid percent-encoding.'); } + break; + case 'timestamp_convert': { + var n = Number(v.trim()); + var ms = !isNaN(n) && String(Math.trunc(n)).length <= 13 ? n : Date.parse(v); + if (isNaN(ms)) { showErrorBox('Could not parse that timestamp.'); break; } + var d = new Date(ms < 1e12 ? ms * 1000 : ms); + render( + 'ISO 8601: ' + d.toISOString() + '\nUTC: ' + d.toUTCString() + + '\nLocal: ' + d.toLocaleString() + '\nUnix seconds: ' + Math.floor(d.getTime() / 1000) + ); + break; + } + case 'color_convert': { + var hex = v.trim().replace('#', ''); + if (!/^[0-9a-f]{6}$/i.test(hex)) { showErrorBox('Enter a hex color like #7c3aed'); break; } + var r = parseInt(hex.slice(0, 2), 16), g = parseInt(hex.slice(2, 4), 16), b = parseInt(hex.slice(4, 6), 16); + var hsl = rgbToHsl(r, g, b); + render('HEX: #' + hex.toUpperCase() + '\nRGB: rgb(' + r + ', ' + g + ', ' + b + ')\nHSL: hsl(' + hsl[0] + ', ' + hsl[1] + '%, ' + hsl[2] + '%)'); + break; + } + default: + render('This tool runs in your browser. Output appears here.'); + } + }); + + function showErrorBox(msg) { + out.innerHTML = '

' + msg + '

'; + out.classList.add('show'); + } + + function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + var max = Math.max(r, g, b), min = Math.min(r, g, b), h = 0, s = 0, l = (max + min) / 2; + if (max !== min) { + var dd = max - min; + s = l > 0.5 ? dd / (2 - max - min) : dd / (max + min); + h = max === r ? ((g - b) / dd + (g < b ? 6 : 0)) : max === g ? (b - r) / dd + 2 : (r - g) / dd + 4; + h *= 60; + } + return [Math.round(h), Math.round(s * 100), Math.round(l * 100)]; + } + + /* compact MD5 implementation (public-domain algorithm, RFC 1321) */ + function sparklessMd5(str) { + function rl(n, c) { return (n << c) | (n >>> (32 - c)); } + function add(a, b) { return (((a >> 16) + (b >> 16) + (((a & 65535) + (b & 65535)) >> 16)) << 16) | (((a & 65535) + (b & 65535)) & 65535); } + function ff(a, b, c, d, x, s, t) { return add(rl(add(add(a, (b & c) | (~b & d)), add(x, t)), s), b); } + function gg(a, b, c, d, x, s, t) { return add(rl(add(add(a, (b & d) | (c & ~d)), add(x, t)), s), b); } + function hh(a, b, c, d, x, s, t) { return add(rl(add(add(a, b ^ c ^ d), add(x, t)), s), b); } + function ii(a, b, c, d, x, s, t) { return add(rl(add(add(a, c ^ (b | ~d)), add(x, t)), s), b); } + + var utf8 = unescape(encodeURIComponent(str)); + var n = utf8.length; + var words = []; + for (var i = 0; i < n; i++) words[i >> 2] = (words[i >> 2] || 0) | (utf8.charCodeAt(i) << ((i % 4) << 3)); + words[n >> 2] = (words[n >> 2] || 0) | (0x80 << ((n % 4) << 3)); + var len = (((n + 8) >> 6) + 1) * 16; + for (i = n + 8; i < len; i++) words[i] = words[i] || 0; + words[len - 2] = n * 8; + + var a = 1732584193, b = -271733879, c = -1732584194, d = 271733878; + for (i = 0; i < len; i += 16) { + var oa = a, ob = b, oc = c, od = d; + a=ff(a,b,c,d,words[i],7,-680876936); d=ff(d,a,b,c,words[i+1],12,-389564586); c=ff(c,d,a,b,words[i+2],17,606105819); b=ff(b,c,d,a,words[i+3],22,-1044525330); + a=ff(a,b,c,d,words[i+4],7,-176418897); d=ff(d,a,b,c,words[i+5],12,1200080426); c=ff(c,d,a,b,words[i+6],17,-1473231341); b=ff(b,c,d,a,words[i+7],22,-45705983); + a=ff(a,b,c,d,words[i+8],7,1770035416); d=ff(d,a,b,c,words[i+9],12,-1958414417); c=ff(c,d,a,b,words[i+10],17,-42063); b=ff(b,c,d,a,words[i+11],22,-1990404162); + a=ff(a,b,c,d,words[i+12],7,1804603682); d=ff(d,a,b,c,words[i+13],12,-40341101); c=ff(c,d,a,b,words[i+14],17,-1502002290); b=ff(b,c,d,a,words[i+15],22,1236535329); + a=gg(a,b,c,d,words[i+1],5,-165796510); d=gg(d,a,b,c,words[i+6],9,-1069501632); c=gg(c,d,a,b,words[i+11],14,643717713); b=gg(b,c,d,a,words[i],20,-373897302); + a=gg(a,b,c,d,words[i+5],5,-701558691); d=gg(d,a,b,c,words[i+10],9,38016083); c=gg(c,d,a,b,words[i+15],14,-660478335); b=gg(b,c,d,a,words[i+4],20,-405537848); + a=gg(a,b,c,d,words[i+9],5,568446438); d=gg(d,a,b,c,words[i+14],9,-1019803690); c=gg(c,d,a,b,words[i+3],14,-187363961); b=gg(b,c,d,a,words[i+8],20,1163531501); + a=gg(a,b,c,d,words[i+13],5,-1444681467); d=gg(d,a,b,c,words[i+2],9,-51403784); c=gg(c,d,a,b,words[i+7],14,1735328473); b=gg(b,c,d,a,words[i+12],20,-1926607734); + a=hh(a,b,c,d,words[i+5],4,-378558); d=hh(d,a,b,c,words[i+8],11,-2022574463); c=hh(c,d,a,b,words[i+11],16,1839030562); b=hh(b,c,d,a,words[i+14],23,-35309556); + a=hh(a,b,c,d,words[i+1],4,-1530992060); d=hh(d,a,b,c,words[i+4],11,1272893353); c=hh(c,d,a,b,words[i+7],16,-155497632); b=hh(b,c,d,a,words[i+10],23,-1094730640); + a=hh(a,b,c,d,words[i+13],4,681279174); d=hh(d,a,b,c,words[i],11,-358537222); c=hh(c,d,a,b,words[i+3],16,-722521979); b=hh(b,c,d,a,words[i+6],23,76029189); + a=hh(a,b,c,d,words[i+9],4,-640364487); d=hh(d,a,b,c,words[i+12],11,-421815835); c=hh(c,d,a,b,words[i+15],16,530742520); b=hh(b,c,d,a,words[i+2],23,-995338651); + a=ii(a,b,c,d,words[i],6,-198630844); d=ii(d,a,b,c,words[i+7],10,1126891415); c=ii(c,d,a,b,words[i+14],15,-1416354905); b=ii(b,c,d,a,words[i+5],21,-57434055); + a=ii(a,b,c,d,words[i+12],6,1700485571); d=ii(d,a,b,c,words[i+3],10,-1894986606); c=ii(c,d,a,b,words[i+10],15,-1051523); b=ii(b,c,d,a,words[i+1],21,-2054922799); + a=ii(a,b,c,d,words[i+8],6,1873313359); d=ii(d,a,b,c,words[i+15],10,-30611744); c=ii(c,d,a,b,words[i+6],15,-1560198380); b=ii(b,c,d,a,words[i+13],21,1309151649); + a=ii(a,b,c,d,words[i+4],6,-145523070); d=ii(d,a,b,c,words[i+11],10,-1120210379); c=ii(c,d,a,b,words[i+2],15,718787259); b=ii(b,c,d,a,words[i+9],21,-343485551); + a=add(a,oa); b=add(b,ob); c=add(c,oc); d=add(d,od); + } + function hex(num) { + var s = ''; + for (var j = 0; j < 4; j++) s += ('0' + ((num >> (j * 8)) & 255).toString(16)).slice(-2); + return s; + } + return hex(a) + hex(b) + hex(c) + hex(d); + } + /* QR generator: submit to the processing queue, poll, show PNG. */ + function runQrJob(content) { + if (!content.trim()) { return; } + var sizeSel = document.getElementById('qr-size'); + var lvlSel = document.getElementById('qr-level'); + out.innerHTML = '

Generating…

'; + out.classList.add('show'); + + fetch('/jobs/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tool: 'qr-code-generator', + content: content, + size: sizeSel ? parseInt(sizeSel.value, 10) : 300, + level: lvlSel ? lvlSel.value : 'M' + }) + }).then(function (r) { return r.json(); }).then(function (data) { + if (!data.ok) { throw new Error(data.error || 'Request failed'); } + return pollJob(data.job_id); + }).then(function (job) { + if (job.status !== 'completed') { throw new Error(job.error || 'Generation failed'); } + out.innerHTML = ''; + var img = document.createElement('img'); + img.src = '/preview/' + job.id + '.png'; + img.alt = 'QR code'; + img.width = 280; img.height = 280; + out.appendChild(img); + var dl = document.createElement('a'); + dl.className = 'btn-primary'; + dl.style.cssText = 'display:inline-block;margin-top:.8rem;padding:.6rem 1.4rem;font-size:.92rem;text-decoration:none'; + dl.href = '/download/' + job.id; + dl.textContent = 'Download PNG'; + out.appendChild(dl); + }).catch(function (err) { + out.innerHTML = '

'; + out.querySelector('.notice').textContent = err.message; + }); + } + + function pollJob(id) { + return fetch('/api/jobs/' + id).then(function (r) { return r.json(); }).then(function (d) { + if (d.status === 'queued' || d.status === 'processing') { + return new Promise(function (resolve) { setTimeout(resolve, 700); }).then(function () { return pollJob(id); }); + } + return d; + }); + } +})(); diff --git a/public/assets/js/upload-tool.js b/public/assets/js/upload-tool.js new file mode 100644 index 0000000..058a31f --- /dev/null +++ b/public/assets/js/upload-tool.js @@ -0,0 +1,194 @@ +/* Upload-tool flow: dropzone → upload → job → poll → download. */ +(function () { + 'use strict'; + + var panel = document.getElementById('tool-panel'); + if (!panel) return; + var slug = panel.dataset.tool; + + var form = document.getElementById('tool-form'); + var zone = document.getElementById('dropzone'); + var fileInput = document.getElementById('file-input'); + var chip = document.getElementById('file-chip'); + var chipName = document.getElementById('chip-name'); + var chipSize = document.getElementById('chip-size'); + var chipRemove = document.getElementById('chip-remove'); + var submitBtn = document.getElementById('convert-btn'); + var statusFlow = document.getElementById('status-flow'); + var progressFill = document.getElementById('progress-fill'); + var resultPanel = document.getElementById('result-panel'); + var errorBox = document.getElementById('error-box'); + + var uploadedFile = null; + var pollTimer = null; + var currentJobId = null; + + function fmtSize(bytes) { + var units = ['B', 'KB', 'MB', 'GB'], i = 0, n = bytes; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return (Math.round(n * 10) / 10) + ' ' + units[i]; + } + + function showError(msg) { + errorBox.textContent = msg; + errorBox.hidden = false; + track('tool_failure', {}); + } + + function hideError() { errorBox.hidden = true; } + + function track(name, extra) { + try { + navigator.sendBeacon && + navigator.sendBeacon('/api/events', new Blob([JSON.stringify(Object.assign({ name: name, tool: slug }, extra))], { type: 'application/json' })); + } catch (e) {} + } + + function setStage(stage, progress) { + if (!statusFlow) return; + statusFlow.hidden = false; + document.querySelectorAll('.stages li').forEach(function (li) { + li.classList.toggle('done', li.dataset.stage !== stage); + li.classList.toggle('active', li.dataset.stage === stage); + }); + if (typeof progress === 'number') progressFill.style.width = Math.max(6, progress) + '%'; + } + + function acceptFile(file) { + if (!file) return; + hideError(); + chipName.textContent = file.name; + chipSize.textContent = fmtSize(file.size); + chip.hidden = false; + submitBtn.disabled = false; + } + + // ---- dropzone interactions ---- + ['dragover', 'dragenter'].forEach(function (ev) { + zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('dragover'); }); + }); + ['dragleave', 'drop'].forEach(function (ev) { + zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('dragover'); }); + }); + zone.addEventListener('drop', function (e) { + fileInput.files = e.dataTransfer.files; + acceptFile(fileInput.files[0]); + }); + zone.addEventListener('click', function () { fileInput.click(); }); + zone.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); }); + fileInput.addEventListener('change', function () { acceptFile(fileInput.files[0]); }); + chipRemove.addEventListener('click', function () { + fileInput.value = ''; + chip.hidden = true; + submitBtn.disabled = true; + }); + + // ---- submit → upload → start job ---- + form.addEventListener('submit', function (e) { + e.preventDefault(); + if (!fileInput.files.length || !submitBtn.disabled === false) { + if (!fileInput.files.length) return; + } + submitBtn.disabled = true; + hideError(); + + var fd = new FormData(); + fd.append('tool', slug); + fd.append('file', fileInput.files[0]); + + setStage('preparing', 4); + + fetch('/upload', { method: 'POST', body: fd }) + .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); }) + .then(function (res) { + if (!res.ok) throw new Error(res.body.error || 'Upload failed.'); + uploadedFile = res.body.file; + return startJob(res.body); + }) + .catch(function (err) { + showError(err.message || 'Upload failed. Please check the file and try again.'); + submitBtn.disabled = false; + }); + }); + + function opt(id) { + var el = document.getElementById(id); + return el ? el.value : undefined; + } + + function startJob(uploadInfo) { + var params = {}; + ['format', 'bitrate', 'quality', 'width', 'height', 'start', 'end', + 'x', 'y', 'crop_width', 'crop_height', 'degrees', 'direction', + 'factor', 'fps', 'gain_db', 'level', 'preset', 'pages'].forEach(function (k) { + var v = opt('opt-' + k); + if (v !== undefined && v !== '') params[k] = isNaN(v) || v === '' ? v : Number(v); + }); + + // map option ids to API param names where they differ + if (params.quality !== undefined) params.quality = Number(params.quality); // level select feeds "level" + if (opt('opt-level') !== undefined && opt('opt-level') !== '') { params.level = Number(opt('opt-level')); delete params.quality; } + if (params.gain_db !== undefined) params.gain_db = Number(params.gain_db); + + var fd = new FormData(); + fd.append('tool', slug); + Object.keys(params).forEach(function (k) { fd.append(k, params[k]); }); + + return fetch('/jobs/start', { method: 'POST', body: fd }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.ok) throw new Error(data.error || 'Could not start processing.'); + currentJobId = data.job_id; + track('conversion_format', { format: params.format || (document.getElementById('opt-format') || {}).value || '' }); + poll(); + }); + } + + function poll() { + if (!currentJobId) return; + fetch('/api/jobs/' + currentJobId) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.ok) throw new Error('lost'); + var stageMap = { preparing: 'preparing', fetching: 'processing', processing: 'processing', converting: 'converting' }; + if (data.status === 'completed') { + finish(data); + return; + } + if (data.status === 'failed') { + showError(data.message || 'Processing failed. Please try again.'); + submitBtn.disabled = false; + return; + } + setStage(stageMap[data.stage] || 'processing', data.progress); + pollTimer = setTimeout(poll, 1500); + }) + .catch(function () { pollTimer = setTimeout(poll, 3000); }); + } + + function finish(data) { + setStage('complete', 100); + clearTimeout(pollTimer); + var ext = (data.output_name || '').split('.').pop() || 'file'; + var isMedia = /^(jpg|jpeg|png|webp|gif|mp4|webm|mp3|wav|m4a|pdf)$/.test(ext); + + resultPanel.innerHTML = + '
' + + 'Done' + + '' + escapeHtml(data.output_name || 'result.' + ext) + '' + + '' + fmtSize(data.output_size || 0) + '' + + 'Download' + + '
' + + (isMedia ? '
Result preview
'.replace('' + + 'Done' + + '' + escapeHtml(data.output_name || 'download') + '' + + '' + humanSize(data.output_size || 0) + '' + + 'Download' + + ''; + resultPanel.classList.add('show'); + } + + function humanSize(bytes) { + var u = ['B', 'KB', 'MB', 'GB'], i = 0; + while (bytes >= 1024 && i < u.length - 1) { bytes /= 1024; i++; } + return (Math.round(bytes * 10) / 10) + ' ' + u[i]; + } + + function escapeHtml(s) { + var d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +})(); diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..7ecfce2 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..80107d2 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + 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/offline.html b/public/offline.html new file mode 100644 index 0000000..d0b2580 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,9 @@ +Offline — Toolvana + + + +
+

You're offline

+

The tool directory is available offline, but media processing needs a connection.
Reconnect and pick up where you left off.

+Open tool directory +
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/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/feature/SeoPagesTest.php b/tests/feature/SeoPagesTest.php new file mode 100644 index 0000000..1b4fda4 --- /dev/null +++ b/tests/feature/SeoPagesTest.php @@ -0,0 +1,126 @@ +getMessage() . "\n"); + } + } + + private function get(string $page): \CodeIgniter\Test\TestResponse + { + return $this->call('get', $page); + } + + public function testToolPageRendersFullSeoContract(): void + { + $response = $this->get('/youtube-to-mp3'); + $response->assertStatus(200); + + $html = (string) $response->getBody(); + + // canonical, title, description, OG + $this->assertMatchesRegularExpression('##', $html); + $this->assertMatchesRegularExpression('/[^<]+<\/title>/', $html); + $this->assertStringContainsString('name="description"', $html); + $this->assertStringContainsString('property="og:title"', $html); + $this->assertStringContainsString('name="twitter:card" content="summary_large_image"', $html); + + // structured data: FAQPage + BreadcrumbList + WebApplication + $jsonLd = ''; + if (preg_match('/<script type="application\/ld\+json">(.*?)<\/script>/s', $html, $m)) { + $jsonLd = $m[1]; + } + $this->assertNotSame('', $jsonLd, 'JSON-LD block missing'); + foreach (['BreadcrumbList', 'WebApplication', 'FAQPage'] as $type) { + $this->assertStringContainsString('"' . $type . '"', $jsonLd, "{$type} missing from JSON-LD"); + } + + // content in first response: H1, how-to steps, FAQ questions visible + $this->assertStringContainsString('<h1>', $html); + $this->assertStringContainsString('Frequently asked questions', $html); + $this->assertStringContainsString('Related tools', $html); + $this->assertStringContainsString('breadcrumbs', $html); + + // robots meta allows indexing + $this->assertStringContainsString('content="index, follow', $html); + } + + public function testCategoryAndHomepageAreIndexable(): void + { + foreach (['/', '/youtube-tools', '/tools'] as $page) { + $html = (string) $this->get($page)->getBody(); + $this->assertStringContainsString('rel="canonical"', $html, "canonical missing on {$page}"); + $this->assertStringContainsString('index, follow', $html, "robots meta wrong on {$page}"); + } + } + + public function testSearchIsNoindexed(): void + { + $html = (string) $this->get('/search?q=mp3')->getBody(); + + $this->assertStringContainsString('noindex', $html); + } + + public function testSitemapsContainOnlyCanonicalUrls(): void + { + $index = (string) $this->get('/sitemap.xml')->getBody(); + $this->assertStringContainsString('sitemapindex', $index); + $this->assertStringContainsString('sitemap-tools-1.xml', $index); + $this->assertStringContainsString('sitemap-guides.xml', $index); + + $tools = (string) $this->get('/sitemap-tools-1.xml')->getBody(); + $this->assertStringContainsString('/youtube-to-mp3</loc>', $tools); + $this->assertDoesNotMatchRegularExpression('#<loc>[^<]*/(api/|admin|download/|search\?)#', $tools, 'Non-indexable URL leaked into sitemap'); + + $categories = (string) $this->get('/sitemap-categories.xml')->getBody(); + $this->assertStringContainsString('/youtube-tools</loc>', $categories); + } + + public function testRobotsTxtBlocksProcessingEndpoints(): void + { + $body = (string) $this->get('/robots.txt')->getBody(); + + $this->assertStringContainsString('Disallow: /api/', $body); + $this->assertStringContainsString('Disallow: /download/', $body); + $this->assertStringContainsString('Disallow: /admin', $body); + $this->assertStringContainsString('Sitemap:', $body); + } + + public function testUppercaseUrl301RedirectsToCanonical(): void + { + $this->get('/YouTube-To-MP3')->assertStatus(301); + } + + public function testUnknownSlugReturnsUseful404(): void + { + $result = $this->get('/this-tool-does-not-exist'); + $result->assertStatus(404); + $html = (string) $result->getBody(); + $this->assertStringContainsString('Tool not found', $html); + $this->assertStringContainsString('Popular tools', $html, '404 page must link popular tools'); + } +} 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 @@ +<!DOCTYPE html> +<html> +<head> + <title>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/CatalogTest.php b/tests/unit/CatalogTest.php new file mode 100644 index 0000000..8ddbe6d --- /dev/null +++ b/tests/unit/CatalogTest.php @@ -0,0 +1,97 @@ +assertSame($slugs, array_values(array_unique($slugs)), 'Tool slugs must be unique'); + $this->assertGreaterThan(100, count($slugs)); + } + + public function testEveryToolHasCompleteSeoFields(): void + { + foreach (CatalogBuilder::build() as $tool) { + $this->assertNotSame('', trim((string) $tool['seo_title']), "seo_title empty for {$tool['slug']}"); + $this->assertNotSame('', trim((string) $tool['h1']), "h1 empty for {$tool['slug']}"); + $this->assertGreaterThanOrEqual(20, mb_strlen((string) $tool['seo_description']), "short description for {$tool['slug']}"); + $this->assertNotEmpty($tool['how_to'], "how_to missing for {$tool['slug']}"); + $this->assertNotEmpty($tool['faqs'], "faqs missing for {$tool['slug']}"); + $this->assertLessThanOrEqual(65, mb_strlen((string) $tool['seo_title']) + 0, "title too long for {$tool['slug']}"); + } + } + + public function testConverterTitlesAreFormatSpecific(): void + { + $bySlug = []; + foreach (CatalogBuilder::build() as $tool) { + $bySlug[$tool['slug']] = $tool; + } + + // each converter page must mention its actual formats + foreach (['jpg-to-png', 'mp4-to-webm', 'flac-to-mp3'] as $slug) { + $this->assertArrayHasKey($slug, $bySlug); + $title = (string) $bySlug[$slug]['seo_title']; + [$from, $to] = explode('-to-', $slug); + $this->assertStringContainsString(FormatCatalog::name($from), $title); + $this->assertStringContainsString(FormatCatalog::name($to), $title); + } + } + + public function testYoutubeIdExtraction(): void + { + $cases = [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' => 'dQw4w9WgXcQ', + 'https://youtu.be/dQw4w9WgXcQ?t=42' => 'dQw4w9WgXcQ', + 'https://www.youtube.com/shorts/abcdefghijk' => 'abcdefghijk', + 'https://www.youtube.com/embed/abcdefghijk' => 'abcdefghijk', + 'dQw4w9WgXcQ' => 'dQw4w9WgXcQ', + 'https://evil.com/watch?v=dQw4w9WgXcQ' => null, + 'not a url' => null, + ]; + + foreach ($cases as $input => $expected) { + $this->assertSame($expected, YoutubeLib::extractId($input), "Failed extracting from: {$input}"); + } + } + + public function testUrlGuardBlocksPrivateNetworks(): void + { + $guard = new UrlGuard(); + + $this->expectException(\InvalidArgumentException::class); + $guard->check('http://127.0.0.1/secret'); + } + + public function testUrlGuardBlocksFileScheme(): void + { + $guard = new UrlGuard(); + + $this->expectException(\InvalidArgumentException::class); + $guard->check('file:///etc/passwd'); + } + + public function testIsPublicIp(): void + { + $guard = new UrlGuard(); + + $this->assertFalse($guard->isPublicIp('10.0.0.5')); + $this->assertFalse($guard->isPublicIp('192.168.1.1')); + $this->assertFalse($guard->isPublicIp('169.254.169.254')); // cloud metadata + $this->assertTrue($guard->isPublicIp('142.250.185.78')); + } +} 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/cache/index.html b/writable/cache/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/cache/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + 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.

+ + +