diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..170b3bd --- /dev/null +++ b/.env.example @@ -0,0 +1,78 @@ +APP_NAME=Docket +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +# Single-user SQLite app: the DB_HOST/PORT/etc. block is unused. +DB_CONNECTION=sqlite + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="docket@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +VITE_APP_NAME="${APP_NAME}" + +# Docket --------------------------------------------------------------- + +# The single owner account (there is no registration flow). The installer +# (php artisan docket:install, or /install in a browser) fills in the +# name/email and creates the account directly, so you normally never edit +# these by hand. They are the fallbacks used if the database is ever +# re-seeded from scratch. +DOCKET_USER_PASSWORD= +# DOCKET_USER_NAME="Docket Owner" +# DOCKET_USER_EMAIL=owner@example.com + +# User-Agent sent when fetching saved pages. Some sites block unknown +# agents; override it here if one you read often does. +# DOCKET_USER_AGENT= + +# Off-site backup bucket (S3-compatible: Backblaze B2 or Cloudflare R2). +# R2 endpoint looks like https://.r2.cloudflarestorage.com +# B2 endpoint looks like https://s3..backblazeb2.com +BACKUP_ACCESS_KEY_ID= +BACKUP_SECRET_ACCESS_KEY= +BACKUP_BUCKET= +BACKUP_ENDPOINT= +BACKUP_REGION=auto +# Failure alerts go here; falls back to DOCKET_USER_EMAIL when unset. +# BACKUP_NOTIFY_EMAIL= +# Optional: encrypt backup archives with this password (keep a copy in +# your password manager too: no password, no restore). Leave commented +# out to disable encryption; an empty value breaks archive verification. +# BACKUP_ARCHIVE_PASSWORD= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66233b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +*.log +.DS_Store +# Ignore all dot-folders (editor/tool configs), except important ones. +.*/ +!/.github +!/.styleci.yml +/.playwright-mcp +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/auth.json +/database/*.sqlite +/database/*.sqlite-journal +/node_modules +/storage/app/tmp +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..8c6715a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3ac9fb1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ajay D'Souza (Beyond Develop Ltd) + +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..d6ce812 --- /dev/null +++ b/README.md @@ -0,0 +1,280 @@ +# Docket + +A single-user read-later app you can self-host: a replacement for Pocket, +which Mozilla shut down in July 2025. Save a link from the browser, the +Android share sheet, an iOS Shortcut or the API; Docket fetches the +article, strips it down to the text and images, and keeps its own copy so +it survives link rot. Open source under the [MIT licence](LICENSE). + +Laravel 13 · PHP 8.3 · SQLite · Breeze (Blade) · Tailwind · Alpine. + +## What it does + +- **Saving links** four ways: a bookmarklet, the PWA share target + (Android "Share → Docket"), `POST /api/items` with a personal API token + (for an iOS Shortcut, extensions, scripts), or pasting a URL into the + app. +- **Extraction**: a queued job fetches the page and runs it through + [readability.php](https://github.com/fivefilters/readability.php). It + stores the title, author, site name, excerpt, lead image, clean HTML, + plain text, word count, reading time, language and published date. + Inline images are downloaded to private storage and the article's `src` + attributes are rewritten to point at them. The HTML is sanitised against + an allow-list before it is stored. Failures are recorded on the item + with a readable reason, and you can retry. +- **Organising**: the unread list is the home page; then archive, + favourites, tags (many-to-many, case-insensitive) and delete. Lists sort + by newest, oldest or longest, and every list has bulk actions. +- **Search**: SQLite FTS5 over title, site name and article text, ranked + with title hits first. Triggers keep the index in step with every write. +- **Reader**: serif or sans, adjustable size, light, sepia or dark; + remembered per browser. Your scroll position is saved as you read and + restored when you come back, on any device. +- **Pocket import**: upload Pocket's export CSV, or use + `php artisan docket:import-pocket`. Dates, tags and unread/archive status + are kept; extraction is queued at a gentle rate. Re-running it is safe. +- **Export**: CSV in Pocket's shape, and a full JSON export including the + article text and HTML. + +Saving a link that is already there works the way Pocket did: no +duplicate, the item moves back to the top of the unread list, and any new +tags are added. Links are de-duplicated on a normalised form of the URL +(lowercased host, no fragment, no `utm_*`/`fbclid`-style tracking +parameters, sorted query). + +## Saving links + +### Bookmarklet + +**Settings → Bookmarklet**: drag the button to your bookmarks bar. On any +page, click it; a small window confirms the save and closes itself. If +your session has expired it asks you to log in first, then saves. + +### Android: Share → Docket + +Open Docket in Chrome on the phone and choose **Install app** (or **Add to +Home screen**). Docket then shows up in the share sheet of every app. +Sharing works out where the link is: most apps put it in the shared text, +some in the URL field. HTTPS is required for the install. + +### iOS Shortcut + +1. In Docket, **Settings → API tokens**: create a token (for example + "iPhone") and copy it. It is shown once. +2. In the Shortcuts app, create a shortcut called **Save to Docket**. In + its details, turn on **Show in Share Sheet** and set it to receive + **URLs** (and Safari web pages). +3. Add **Get Contents of URL**: + - URL: `https://your-domain/api/items` + - Method: **POST** + - Headers: `Authorization` = `Bearer `, and + `Accept` = `application/json` + - Request Body: **JSON**, with one field: `url` (Text) = **Shortcut + Input**. Add `tags` (Text, comma-separated) if you want a fixed tag. +4. Optionally add **Show Notification** with "Saved to Docket". + +Share any page or link to **Save to Docket**. If the shortcut reports a +401, the token is wrong or has been revoked. + +### API + +```sh +curl -X POST https://your-domain/api/items \ + -H "Authorization: Bearer $DOCKET_TOKEN" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/article", "title": "Optional", "tags": ["later", "long reads"]}' +``` + +`tags` may be an array or a comma-separated string. The response is `201` +for a new item and `200` when the link was already saved, with the item +(including `web_url`, the reader link) in `data`. Invalid input returns +`422` with Laravel's usual `errors` object. The API is limited to 60 +requests a minute. Tokens are Laravel Sanctum personal access tokens; only +a hash is stored, and revoking one on the Settings page stops it at once. + +## Coming from Pocket + +Pocket's export is a ZIP with one or more CSV files (`part_000000.csv`, +…) with the columns `title, url, time_added, tags, status`. Either upload +each CSV on **Import & export**, or: + +```sh +php artisan docket:import-pocket /path/to/part_000000.csv +``` + +Each link keeps its saved date, its tags (Pocket separates them with `|`) +and its status (`archive` becomes archived). Links Pocket never parsed +come through with the URL as the title; Docket leaves those blank for the +extractor to fill in. Rows that are not http(s) links are skipped and +counted. + +The import is idempotent. Links already in Docket, from an earlier import +or saved since, are matched on their normalised URL and only gain any tags +they were missing; nothing is duplicated, re-dated or fetched again. + +Extraction for new items is queued with a gap between each job (10 seconds +by default; **Settings → Import spacing**), so a few thousand links take a +few hours to fill in rather than hammering every site at once. Keep the +queue worker running until the queue drains. Anything that fails (dead +links are common in an old Pocket library) is marked on the item with the +reason. **Tools → Fetch failed and pending items** (or `php artisan +docket:refetch`) tries them all again. + +## Installation + +Requirements: PHP 8.3+ with `pdo_sqlite` (SQLite built with FTS5, which is +standard on current Debian, Ubuntu and Homebrew), `mbstring`, `fileinfo`, +`dom` and `xml`; `intl` and `zip` recommended. Composer and Node 20+. + +Both installers do the same work: create `.env`, generate the app key, +create the SQLite database, run the migrations and create the single owner +account. There is no registration flow. + +### Web installer (recommended on a server) + +```sh +git clone https://github.com/WebberZone/TheDocket.git docket +cd docket +composer install --no-dev +npm ci && npm run build +``` + +Point your web server at `public/`, then open +**`https://your-domain/install`**. The wizard checks the server +requirements, then asks for the site URL, your name, email and password, +and installs. It only exists while Docket has no user account: once your +login is created, `/install` permanently redirects to the login screen. + +### Console installer + +```sh +git clone https://github.com/WebberZone/TheDocket.git docket +cd docket +composer setup # deps + frontend build + guided php artisan docket:install +``` + +The console installer is safe to re-run (`--force`) and never deletes data. +For scripted installs pass the answers as flags: + +```sh +php artisan docket:install --url=https://docket.example.com \ + --name="Jane Doe" --email=jane@example.com --password=... \ + --no-interaction # omit --password to have one generated and shown once +``` + +Then, for local use: + +```sh +php artisan serve +php artisan queue:work # fetches and extracts saved pages +``` + +Development server (app + queue + logs + Vite): `composer dev`. + +## Deployment (any small VPS) + +1. Nginx + PHP 8.3-FPM, a standard Laravel vhost with the root at + `public/`. HTTPS (Let's Encrypt via `certbot --nginx -d your-domain`) is + required for the PWA install and share target. +2. Visit `https://your-domain/install` (or run `php artisan docket:install`) + with the real production URL. Then in `.env` set `APP_ENV=production`, + `APP_DEBUG=false`, `BACKUP_*` (below) and a real mailer (`MAIL_*`) so + backup-failure emails arrive. +3. `php artisan config:cache && php artisan route:cache && php artisan view:cache`. +4. Queue worker under systemd: + + ```ini + [Service] + ExecStart=/usr/bin/php /var/www/docket/artisan queue:work --tries=3 --timeout=300 --max-time=3600 + Restart=always + User=www-data + ``` + +5. One cron entry drives the scheduler (backups, cleanup, monitor): + + ``` + * * * * * cd /var/www/docket && php artisan schedule:run >> /dev/null 2>&1 + ``` + +Without a worker, set `QUEUE_CONNECTION=sync` and pages are fetched during +the save request instead: slower saves, and a Pocket import will take as +long as every fetch put together. + +## Backups (spatie/laravel-backup) + +Daily at 02:00 server time: the SQLite database is dumped, zipped with +`storage/app/private` (the saved article images), verified and shipped to +the `offsite` disk, an S3-compatible bucket with a different provider from +the server (Backblaze B2 or Cloudflare R2). `backup:monitor` emails +`BACKUP_NOTIFY_EMAIL` (or the owner's address) if the newest backup is +more than a day old. + +``` +BACKUP_ACCESS_KEY_ID=... +BACKUP_SECRET_ACCESS_KEY=... +BACKUP_BUCKET=docket-backups +BACKUP_ENDPOINT=https://.r2.cloudflarestorage.com +BACKUP_REGION=auto +``` + +Test a restore early: + +```sh +php artisan backup:run +# download the newest zip from the bucket, then: +unzip Docket/….zip -d /tmp/restore +mv /tmp/restore/db-dumps/database.sqlite database/database.sqlite +rsync -a /tmp/restore/…/storage/app/private/ storage/app/private/ +php artisan migrate:status +``` + +Open a saved article and check its images load. + +## Security notes + +Every saved link is someone else's input, so fetching is treated as +hostile: + +- **SSRF**: Docket resolves each hostname itself and refuses to connect if + any address is loopback, private, link-local (including cloud metadata + at `169.254.169.254`), carrier-grade NAT, multicast or reserved, + including IPv6 forms that embed one. The connection is then pinned to + the address that was checked, so a second DNS answer cannot swap one in. + Redirects are followed by hand and every hop is checked. Responses are + capped (5 MB pages, 5 MB images by default), with connect and total + timeouts and at most five redirects. Inline images go through the same + client. If the server sends outbound traffic through an HTTP proxy, the + proxy does its own DNS lookup, so the pinning cannot apply. +- **Stored HTML** is sanitised with an allow-list + (symfony/html-sanitizer): no scripts, styles, frames, forms, embeds, + inline SVG, event handlers or `javascript:` links. Downloaded images are + checked by content, not by the server's `Content-Type`, and only raster + formats are stored; SVG stays remote. Images are served only to the + logged-in owner, with `nosniff`. +- The bookmarklet and share target are `GET` requests, because that is + how they arrive. A third-party page could link you to `/save?url=…`; the + worst that does is add a link to your own list. + +## Development + +```sh +composer test # php artisan test +composer lint # Pint +composer stan # PHPStan (Larastan) +``` + +The feature tests never touch the network: `Http::preventStrayRequests()` +is on, and DNS goes through a fake resolver (`tests/Support`). The Pocket +importer test uses `tests/Fixtures/pocket_export.csv`; the extraction tests +use `tests/Fixtures/article.html`. + +Config lives in `config/docket.php` (env vars use the `DOCKET_` prefix); +the knobs on the Settings page override it from the database. + +## Not in version 1 + +Highlights and notes, a wallabag-compatible API, saving by email, RSS +feeds, and multiple users. The schema leaves room for them: notes and +highlights hang off `items.id`, and multi-user means adding `user_id` and +widening the unique key to `(user_id, normalized_url)`. diff --git a/app/Console/Commands/DocketImportPocketCommand.php b/app/Console/Commands/DocketImportPocketCommand.php new file mode 100644 index 0000000..4b2736c --- /dev/null +++ b/app/Console/Commands/DocketImportPocketCommand.php @@ -0,0 +1,39 @@ +argument('file'); + + try { + $result = $importer->import($file); + } catch (RuntimeException $e) { + $this->components->error($e->getMessage()); + + return SymfonyCommand::FAILURE; + } + + $this->components->info($result->summary()); + + if ($result->queued > 0) { + $this->components->bulletList([ + 'Extraction runs on the queue: keep php artisan queue:work running until it finishes.', + ]); + } + + return SymfonyCommand::SUCCESS; + } +} diff --git a/app/Console/Commands/DocketInstallCommand.php b/app/Console/Commands/DocketInstallCommand.php new file mode 100644 index 0000000..63bfb18 --- /dev/null +++ b/app/Console/Commands/DocketInstallCommand.php @@ -0,0 +1,187 @@ +components->info('Installing Docket.'); + + $problems = $installer->blockingProblems(); + + if ($problems !== []) { + foreach ($problems as $problem) { + $this->components->error($problem); + } + + return SymfonyCommand::FAILURE; + } + + if ($installer->isInstalled() && ! $this->option('force')) { + $this->components->warn( + 'Docket already has a user account. Re-run with --force to run the installer anyway (existing data is never deleted).' + ); + + return SymfonyCommand::FAILURE; + } + + if ($installer->ensureEnvFile()) { + $this->components->task('creating .env from .env.example', fn (): bool => true); + } + + if ($installer->ensureAppKey()) { + $this->components->task('generating application key', fn (): bool => true); + } + + $url = $this->option('url') + ?? $this->promptOr(fn (): string => text( + label: 'What URL will Docket be served from?', + default: 'http://localhost:8000', + required: true, + ), 'http://localhost:8000'); + + $name = $this->option('name') + ?? $this->promptOr(fn (): string => text( + label: 'Your name', + default: 'Docket Owner', + required: true, + ), 'Docket Owner'); + + $email = $this->option('email') + ?? $this->promptOr(fn (): string => text( + label: 'Your email (used to log in)', + default: 'owner@example.com', + required: true, + validate: fn (string $value): ?string => filter_var($value, FILTER_VALIDATE_EMAIL) + ? null + : 'Enter a valid email address.', + ), 'owner@example.com'); + + $plainPassword = $this->option('password') ?? $this->askForPassword(); + + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->components->error("'{$email}' is not a valid email address."); + + return SymfonyCommand::FAILURE; + } + + if (strlen($plainPassword) < 8) { + $this->components->error('The password must be at least 8 characters.'); + + return SymfonyCommand::FAILURE; + } + + // Persist the identity to .env so a later `db:seed` recreates the + // same account rather than the config/docket.php fallback owner. + // The password is deliberately NOT written to .env: it is hashed + // straight into the database below. + $installer->writeEnvValues([ + 'APP_URL' => $url, + 'DOCKET_USER_NAME' => $name, + 'DOCKET_USER_EMAIL' => $email, + ]); + $this->components->task('.env updated (APP_URL, owner name/email)', fn (): bool => true); + + $installer->ensureSqliteDatabase(); + + $this->components->task('running database migrations', fn (): bool => $installer->migrate()); + + if ($installer->ownerExists($email)) { + $this->components->warn("An account for {$email} already exists — its password was left unchanged."); + } else { + $this->components->task( + "creating owner account for {$email}", + function () use ($installer, $name, $email, $plainPassword): bool { + $installer->createOwner($name, $email, $plainPassword); + + return true; + } + ); + } + + $this->printNextSteps($url, $email); + + return SymfonyCommand::SUCCESS; + } + + protected function askForPassword(): string + { + if (! $this->input->isInteractive()) { + $this->generatedPassword = Str::password(20); + + return $this->generatedPassword; + } + + return password( + label: 'Choose a login password', + required: true, + validate: fn (string $value): ?string => strlen($value) >= 8 + ? null + : 'The password must be at least 8 characters.', + ); + } + + /** + * Run the given prompt when the session is interactive; otherwise fall + * back to the default so --no-interaction installs never hang. + * + * @param callable(): string $prompt + */ + protected function promptOr(callable $prompt, string $default): string + { + return $this->input->isInteractive() ? $prompt() : $default; + } + + protected function printNextSteps(string $url, string $email): void + { + $this->newLine(); + $this->components->info('Docket is installed.'); + + if ($this->generatedPassword !== null) { + $this->components->warn( + "Generated login password (shown once, store it now): {$this->generatedPassword}" + ); + } + + $steps = [ + "Log in at {$url} as {$email}.", + ]; + + if (! file_exists(public_path('build/manifest.json'))) { + $steps[] = 'Build the frontend: npm install && npm run build'; + } + + $steps[] = 'Serve the app: point your web server at public/ (or run: php artisan serve)'; + $steps[] = 'Run a queue worker to fetch saved pages: php artisan queue:work (use systemd/supervisor in production)'; + $steps[] = 'Add the scheduler cron: * * * * * cd /path/to/docket && php artisan schedule:run >> /dev/null 2>&1'; + $steps[] = 'Optional off-site backups: fill in the BACKUP_* keys in .env (S3-compatible bucket)'; + $steps[] = 'Coming from Pocket? Import your export: php artisan docket:import-pocket /path/to/part_000000.csv'; + + $this->components->bulletList($steps); + $this->newLine(); + } +} diff --git a/app/Console/Commands/DocketOptimizeCommand.php b/app/Console/Commands/DocketOptimizeCommand.php new file mode 100644 index 0000000..bbc5d98 --- /dev/null +++ b/app/Console/Commands/DocketOptimizeCommand.php @@ -0,0 +1,49 @@ +components->info('Caching framework routes, views and events.'); + + $tasks = [ + 'events' => 'event:cache', + 'routes' => 'route:cache', + 'views' => 'view:cache', + ]; + + $failed = false; + + foreach ($tasks as $description => $command) { + $success = true; + + $this->components->task( + $description, + function () use ($command, &$success): void { + $success = Artisan::call($command) === 0; + } + ); + + if (! $success) { + $failed = true; + } + } + + $this->newLine(); + + return $failed ? SymfonyCommand::FAILURE : SymfonyCommand::SUCCESS; + } +} diff --git a/app/Console/Commands/DocketRefetchCommand.php b/app/Console/Commands/DocketRefetchCommand.php new file mode 100644 index 0000000..8573dcf --- /dev/null +++ b/app/Console/Commands/DocketRefetchCommand.php @@ -0,0 +1,65 @@ +argument('item') !== null) { + $item = Item::query()->find($this->argument('item')); + + if ($item === null) { + $this->components->error("Item {$this->argument('item')} not found."); + + return SymfonyCommand::FAILURE; + } + + $this->queue($item); + $this->components->info("Queued item {$item->id} ({$item->url})."); + + return SymfonyCommand::SUCCESS; + } + + $query = Item::query()->when( + ! $this->option('all'), + fn (Builder $query) => $query->whereIn('fetch_status', [FetchStatus::Failed->value, FetchStatus::Pending->value]) + ); + + $spacing = max(0, (int) settings('import.spacing_seconds')); + $count = 0; + + foreach ($query->orderBy('id')->lazy() as $item) { + $this->queue($item, $count * $spacing); + $count++; + } + + $this->components->info("Queued {$count} item(s) for extraction."); + + return SymfonyCommand::SUCCESS; + } + + protected function queue(Item $item, int $delaySeconds = 0): void + { + $item->forceFill([ + 'fetch_status' => FetchStatus::Pending, + 'fetch_error' => null, + 'fetch_attempts' => 0, + ])->save(); + + FetchItemContent::dispatch($item)->delay(now()->addSeconds($delaySeconds)); + } +} diff --git a/app/Enums/FetchStatus.php b/app/Enums/FetchStatus.php new file mode 100644 index 0000000..1b1b8c0 --- /dev/null +++ b/app/Enums/FetchStatus.php @@ -0,0 +1,10 @@ + 'unread', + self::Archived => 'archive', + }; + } + + public static function fromPocket(string $value): self + { + return in_array(strtolower(trim($value)), ['archive', 'archived', 'read'], true) + ? self::Archived + : self::Unread; + } +} diff --git a/app/Exceptions/FetchFailedException.php b/app/Exceptions/FetchFailedException.php new file mode 100644 index 0000000..4c2437e --- /dev/null +++ b/app/Exceptions/FetchFailedException.php @@ -0,0 +1,26 @@ +validate([ + 'url' => ['required', 'string', 'max:2048'], + 'title' => ['nullable', 'string', 'max:1000'], + 'tags' => ['nullable'], + 'tags.*' => ['string', 'max:100'], + ]); + + $tags = $data['tags'] ?? null; + + if ($tags !== null && ! is_string($tags) && ! is_array($tags)) { + throw ValidationException::withMessages(['tags' => 'Tags must be a list or a comma-separated string.']); + } + + try { + $result = $saver->save($data['url'], $data['title'] ?? null, $tags, ItemSource::Api); + } catch (InvalidUrlException $e) { + throw ValidationException::withMessages(['url' => $e->getMessage()]); + } + + return response()->json( + ['data' => $this->present($result->item->load('tags')), 'created' => $result->created], + $result->created ? 201 : 200 + ); + } + + /** + * @return array + */ + protected function present(Item $item): array + { + return [ + 'id' => $item->id, + 'url' => $item->url, + 'title' => $item->title, + 'status' => $item->status->value, + 'is_favourite' => $item->is_favourite, + 'fetch_status' => $item->fetch_status->value, + 'tags' => $item->tags->map(fn (Tag $tag): string => $tag->name)->values()->all(), + 'added_at' => $item->added_at?->toIso8601String(), + 'web_url' => route('items.show', $item), + ]; + } +} diff --git a/app/Http/Controllers/ApiTokenController.php b/app/Http/Controllers/ApiTokenController.php new file mode 100644 index 0000000..a602cfd --- /dev/null +++ b/app/Http/Controllers/ApiTokenController.php @@ -0,0 +1,33 @@ +validate([ + 'name' => ['required', 'string', 'max:100'], + ]); + + $token = $request->user()->createToken($data['name']); + + return redirect()->route('settings.edit') + ->with('status', "Token \"{$data['name']}\" created. Copy it now: it will not be shown again.") + ->with('plain_token', $token->plainTextToken); + } + + public function destroy(Request $request, int $token): RedirectResponse + { + $request->user()->tokens()->whereKey($token)->delete(); + + return redirect()->route('settings.edit')->with('status', 'Token revoked.'); + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..23c9ad6 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,47 @@ +authenticate(); + + $request->session()->regenerate(); + + return redirect()->intended(route('items.index', absolute: false)); + } + + /** + * Destroy an authenticated session. + */ + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php new file mode 100644 index 0000000..260a29f --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -0,0 +1,40 @@ +validate([ + 'email' => $request->user()->email, + 'password' => $request->password, + ])) { + throw ValidationException::withMessages([ + 'password' => __('auth.password'), + ]); + } + + $request->session()->put('auth.password_confirmed_at', time()); + + return redirect()->intended(route('items.index', absolute: false)); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 0000000..6a1feb3 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,24 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('items.index', absolute: false)); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'A new verification link has been sent to your email address.'); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 0000000..5dc85e7 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,21 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(route('items.index', absolute: false)) + : view('auth.verify-email'); + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..4cabf9a --- /dev/null +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,63 @@ + $request]); + } + + /** + * Handle an incoming new password request. + * + * @throws ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + // Here we will attempt to reset the user's password. If it is successful we + // will update the password on an actual user model and persist it to the + // database. Otherwise we will parse the error and return the response. + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function (User $user) use ($request) { + $user->forceFill([ + 'password' => Hash::make($request->password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + // If the password was successfully reset, we will redirect the user back to + // the application's home authenticated view. If there is an error we can + // redirect them back to where they came from with their error message. + return $status == Password::PASSWORD_RESET + ? redirect()->route('login')->with('status', 'Password has been reset successfully.') + : back()->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php new file mode 100644 index 0000000..96ba09d --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,30 @@ +validateWithBag('updatePassword', [ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]); + + $request->user()->update([ + 'password' => Hash::make($validated['password']), + ]); + + return Redirect::route('profile.edit')->with('status', 'Password updated successfully.'); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..5c6d90b --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,45 @@ +validate([ + 'email' => ['required', 'email'], + ]); + + // We will send the password reset link to this user. Once we have attempted + // to send the link, we will examine the response then see the message we + // need to show to the user. Finally, we'll send out a proper response. + $status = Password::sendResetLink( + $request->only('email') + ); + + return $status == Password::RESET_LINK_SENT + ? back()->with('status', 'Password reset link sent to your email.') + : back()->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 0000000..4669b62 --- /dev/null +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,30 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('items.index', absolute: false).'?verified=1'); + } + + /** @var MustVerifyEmail $user */ + $user = $request->user(); + if ($user->markEmailAsVerified()) { + event(new Verified($user)); + } + + return redirect()->intended(route('items.index', absolute: false).'?verified=1'); + } +} diff --git a/app/Http/Controllers/BulkItemController.php b/app/Http/Controllers/BulkItemController.php new file mode 100644 index 0000000..e2fe718 --- /dev/null +++ b/app/Http/Controllers/BulkItemController.php @@ -0,0 +1,90 @@ + 'Archive', + 'unarchive' => 'Move to unread', + 'favourite' => 'Favourite', + 'unfavourite' => 'Unfavourite', + 'tag' => 'Add tags', + 'retry' => 'Fetch again', + 'delete' => 'Delete', + ]; + + public function __invoke(Request $request): RedirectResponse + { + $data = $request->validate([ + 'action' => ['required', Rule::in(array_keys(self::ACTIONS))], + 'ids' => ['required', 'array', 'max:1000'], + 'ids.*' => ['integer'], + 'tags' => ['nullable', 'string', 'max:1000', 'required_if:action,tag'], + ], [ + 'ids.required' => 'Tick at least one item first.', + 'tags.required_if' => 'Enter the tags to add.', + ]); + + $items = Item::query()->whereIn('id', $data['ids'])->get(); + $tags = Tag::parseList($data['tags'] ?? null); + + foreach ($items as $item) { + switch ($data['action']) { + case 'archive': + $item->archive(); + break; + case 'unarchive': + $item->unarchive(); + break; + case 'favourite': + $item->setFavourite(true); + break; + case 'unfavourite': + $item->setFavourite(false); + break; + case 'tag': + $item->attachTagNames($tags); + break; + case 'retry': + $this->retry($item); + break; + case 'delete': + // One by one, so each item's stored images go too. + $item->delete(); + break; + } + } + + $count = $items->count(); + + return back()->with('status', sprintf( + '%s: %d %s.', + self::ACTIONS[$data['action']], + $count, + $count === 1 ? 'item' : 'items' + )); + } + + protected function retry(Item $item): void + { + $item->forceFill([ + 'fetch_status' => FetchStatus::Pending, + 'fetch_error' => null, + 'fetch_attempts' => 0, + ])->save(); + + FetchItemContent::dispatch($item); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +streamDownload( + function () use ($exporter): void { + $handle = fopen('php://output', 'w'); + + if ($handle !== false) { + $exporter->writeCsv($handle); + fclose($handle); + } + }, + 'docket-export-'.now()->format('Y-m-d').'.csv', + ['Content-Type' => 'text/csv; charset=UTF-8'] + ); + } + + /** + * Everything, including the extracted articles. + */ + public function json(Exporter $exporter): StreamedResponse + { + return response()->streamDownload( + function () use ($exporter): void { + $handle = fopen('php://output', 'w'); + + if ($handle !== false) { + $exporter->writeJson($handle); + fclose($handle); + } + }, + 'docket-export-'.now()->format('Y-m-d').'.json', + ['Content-Type' => 'application/json; charset=UTF-8'] + ); + } +} diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php new file mode 100644 index 0000000..8be5154 --- /dev/null +++ b/app/Http/Controllers/ImportController.php @@ -0,0 +1,48 @@ + Item::query()->count(), + 'maxKb' => (int) config('docket.import.max_kb'), + ]); + } + + public function store(Request $request, PocketImporter $importer): RedirectResponse + { + $request->validate([ + 'file' => ['required', 'file', 'mimes:csv,txt', 'max:'.(int) config('docket.import.max_kb')], + ], [ + 'file.mimes' => 'Upload the .csv file from Pocket\'s export.', + ]); + + $file = $request->file('file'); + + if (! $file instanceof UploadedFile) { + return back()->withErrors(['file' => 'Upload one CSV file.']); + } + + try { + $result = $importer->import((string) $file->getRealPath()); + } catch (RuntimeException $e) { + return back()->withErrors(['file' => $e->getMessage()]); + } + + return redirect()->route('import.index')->with('status', $result->summary()); + } +} diff --git a/app/Http/Controllers/InstallController.php b/app/Http/Controllers/InstallController.php new file mode 100644 index 0000000..a361b56 --- /dev/null +++ b/app/Http/Controllers/InstallController.php @@ -0,0 +1,115 @@ + $this->installer->requirements(), + 'canProceed' => $this->installer->blockingProblems() === [], + ]); + } + + /** + * Step 2: the setup form (site URL + owner account). + */ + public function form(Request $request): View|RedirectResponse + { + if ($this->installer->blockingProblems() !== []) { + return redirect()->route('install.requirements'); + } + + return view('install.setup', [ + 'input' => ['url' => $request->getSchemeAndHttpHost()], + 'errors' => new MessageBag(), + 'installError' => null, + ]); + } + + /** + * Step 3: perform the installation and show the closing checklist. + */ + public function store(Request $request): View|RedirectResponse|Response + { + if ($this->installer->blockingProblems() !== []) { + return redirect()->route('install.requirements'); + } + + $validator = Validator::make($request->all(), [ + 'url' => ['required', 'string', 'url', 'max:255'], + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + + $safeInput = $request->except(['password', 'password_confirmation']); + + if ($validator->fails()) { + return response()->view('install.setup', [ + 'input' => $safeInput, + 'errors' => $validator->errors(), + 'installError' => null, + ], 422); + } + + $data = $validator->validated(); + + try { + $this->installer->ensureEnvFile(); + $this->installer->ensureAppKey(); + + // The password is deliberately NOT written to .env: it is + // hashed straight into the database below. + $this->installer->writeEnvValues([ + 'APP_URL' => $data['url'], + 'DOCKET_USER_NAME' => $data['name'], + 'DOCKET_USER_EMAIL' => $data['email'], + ]); + + $this->installer->ensureSqliteDatabase(); + + if (! $this->installer->migrate()) { + throw new RuntimeException('Database migrations failed — check storage/logs/laravel.log.'); + } + + $this->installer->createOwner($data['name'], $data['email'], $data['password']); + } catch (Throwable $e) { + report($e); + + return response()->view('install.setup', [ + 'input' => $safeInput, + 'errors' => new MessageBag(), + 'installError' => $e->getMessage(), + ], 500); + } + + return view('install.complete', [ + 'url' => $data['url'], + 'email' => $data['email'], + 'assetsBuilt' => file_exists(public_path('build/manifest.json')), + ]); + } +} diff --git a/app/Http/Controllers/ItemController.php b/app/Http/Controllers/ItemController.php new file mode 100644 index 0000000..5af9f95 --- /dev/null +++ b/app/Http/Controllers/ItemController.php @@ -0,0 +1,147 @@ + 'Unread', + 'archive' => 'Archive', + 'favourites' => 'Favourites', + 'search' => 'Search', + 'tag' => 'Tagged', + ]; + + /** + * Every list: unread (the home page), archive, favourites, a tag, or + * search results. ?sort= picks newest/oldest/longest; search results + * default to relevance. + */ + public function index(Request $request, string $view = 'unread'): View + { + return $this->list($request, $view); + } + + /** + * One tag's items. A separate action because route defaults and bound + * parameters reach the controller positionally, so one signature can't + * serve both. + */ + public function tagged(Request $request, Tag $tag): View + { + return $this->list($request, 'tag', $tag); + } + + protected function list(Request $request, string $view, ?Tag $tag = null): View + { + $view = array_key_exists($view, self::VIEWS) ? $view : 'unread'; + $q = trim($request->string('q')->toString()); + $sort = $request->query('sort'); + $sort = is_string($sort) && array_key_exists($sort, Item::SORTS) ? $sort : null; + + $query = Item::query()->with('tags'); + + if ($view === 'archive') { + $query->archived(); + } elseif ($view === 'favourites') { + $query->favourites(); + } elseif ($view === 'tag' && $tag !== null) { + $query->tagged($tag); + } elseif ($view !== 'search') { + $query->unread(); + } + + if ($q !== '') { + $query->search($q, $sort === null); + } + + if ($q === '' || $sort !== null) { + $query->sorted($sort); + } + + return view('items.index', [ + 'items' => $query->paginate(max(1, (int) settings('per_page')))->withQueryString(), + 'view' => $view, + 'tag' => $tag, + 'q' => $q, + 'sort' => $sort ?? ($q !== '' ? 'relevance' : 'newest'), + 'heading' => $view === 'tag' && $tag !== null ? $tag->name : self::VIEWS[$view], + ]); + } + + /** + * Save a pasted link. + */ + public function store(Request $request, ItemSaver $saver): RedirectResponse + { + $data = $request->validate([ + 'url' => ['required', 'string', 'max:2048'], + 'title' => ['nullable', 'string', 'max:1000'], + 'tags' => ['nullable', 'string', 'max:1000'], + ]); + + try { + $result = $saver->save($data['url'], $data['title'] ?? null, $data['tags'] ?? null, ItemSource::Web); + } catch (InvalidUrlException $e) { + return back()->withErrors(['url' => $e->getMessage()])->withInput(); + } + + return back()->with('status', $result->message()); + } + + /** + * The reader. + */ + public function show(Item $item): View + { + $item->load('tags'); + $item->forceFill(['last_read_at' => now()])->saveQuietly(); + + return view('items.show', ['item' => $item]); + } + + /** + * Edit the title and tags. + */ + public function update(Request $request, Item $item): RedirectResponse + { + $data = $request->validate([ + 'title' => ['nullable', 'string', 'max:1000'], + 'tags' => ['nullable', 'string', 'max:1000'], + ]); + + if ($request->has('title')) { + $title = trim((string) ($data['title'] ?? '')); + $item->title = $title !== '' ? $title : null; + $item->save(); + } + + if ($request->has('tags')) { + $item->syncTagNames(Tag::parseList($data['tags'] ?? null)); + } + + return back()->with('status', 'Saved your changes.'); + } + + public function destroy(Request $request, Item $item): RedirectResponse + { + $fromReader = url()->previous() === route('items.show', $item); + + $item->delete(); + + return ($fromReader ? redirect()->route('items.index') : back()) + ->with('status', 'Deleted.'); + } +} diff --git a/app/Http/Controllers/ItemImageController.php b/app/Http/Controllers/ItemImageController.php new file mode 100644 index 0000000..7912887 --- /dev/null +++ b/app/Http/Controllers/ItemImageController.php @@ -0,0 +1,31 @@ +storageDirectory().'/'.$filename; + $disk = Storage::disk(Item::DISK); + + abort_unless($disk->exists($path), 404); + + return $disk->response($path, $filename, [ + 'Content-Type' => ImageLocaliser::mimeFor($filename), + // Filenames are content-addressed by source URL, so they never + // change under the same name. + 'Cache-Control' => 'private, max-age=31536000, immutable', + 'Content-Security-Policy' => "default-src 'none'", + ]); + } +} diff --git a/app/Http/Controllers/ItemStateController.php b/app/Http/Controllers/ItemStateController.php new file mode 100644 index 0000000..c6ec1f8 --- /dev/null +++ b/app/Http/Controllers/ItemStateController.php @@ -0,0 +1,96 @@ +archive(); + + // Archiving from the reader means "done with this one": go back to + // the list rather than staying on the article. + if ($request->input('then') === 'list') { + return redirect()->route('items.index')->with('status', 'Archived.'); + } + + return $this->respond($request, $item, 'Archived.'); + } + + public function unarchive(Request $request, Item $item): JsonResponse|RedirectResponse + { + $item->unarchive(); + + return $this->respond($request, $item, 'Moved back to unread.'); + } + + public function favourite(Request $request, Item $item): JsonResponse|RedirectResponse + { + $item->setFavourite(true); + + return $this->respond($request, $item, 'Added to favourites.'); + } + + public function unfavourite(Request $request, Item $item): JsonResponse|RedirectResponse + { + $item->setFavourite(false); + + return $this->respond($request, $item, 'Removed from favourites.'); + } + + public function retry(Request $request, Item $item): JsonResponse|RedirectResponse + { + $item->forceFill([ + 'fetch_status' => FetchStatus::Pending, + 'fetch_error' => null, + 'fetch_attempts' => 0, + ])->save(); + + FetchItemContent::dispatch($item); + + return $this->respond($request, $item, 'Fetching the article again.'); + } + + /** + * Reader scroll position, sent while reading and on leaving the page. + */ + public function progress(Request $request, Item $item): Response + { + $data = $request->validate([ + 'progress' => ['required', 'numeric', 'min:0', 'max:1'], + ]); + + $item->forceFill([ + 'read_progress' => round((float) $data['progress'], 4), + 'last_read_at' => now(), + ])->saveQuietly(); + + return response()->noContent(); + } + + protected function respond(Request $request, Item $item, string $message): JsonResponse|RedirectResponse + { + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'status' => $item->status->value, + 'is_favourite' => $item->is_favourite, + 'fetch_status' => $item->fetch_status->value, + ]); + } + + return back()->with('status', $message); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..13021f5 --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,38 @@ + $request->user(), + ]); + } + + /** + * Update the user's profile information. + */ + public function update(ProfileUpdateRequest $request): RedirectResponse + { + $request->user()->fill($request->validated()); + + if ($request->user()->isDirty('email')) { + $request->user()->email_verified_at = null; + } + + $request->user()->save(); + + return Redirect::route('profile.edit')->with('status', 'Profile updated successfully.'); + } +} diff --git a/app/Http/Controllers/SaveController.php b/app/Http/Controllers/SaveController.php new file mode 100644 index 0000000..7b1d1ef --- /dev/null +++ b/app/Http/Controllers/SaveController.php @@ -0,0 +1,74 @@ +string('url')->toString()); + + try { + $result = $saver->save($url, $request->string('title')->toString(), null, ItemSource::Bookmarklet); + } catch (InvalidUrlException $e) { + return response()->view('save.done', ['result' => null, 'error' => $e->getMessage()], 422); + } + + return response()->view('save.done', ['result' => $result, 'error' => null]); + } + + public function share(Request $request, ItemSaver $saver): RedirectResponse + { + $url = $this->sharedUrl( + $request->string('url')->toString(), + $request->string('text')->toString(), + $request->string('title')->toString(), + ); + + if ($url === null) { + return redirect()->route('items.index')->with('error', 'Nothing to save: the share did not include a link.'); + } + + try { + $result = $saver->save($url, $request->string('title')->toString(), null, ItemSource::Share); + } catch (InvalidUrlException $e) { + return redirect()->route('items.index')->with('error', $e->getMessage()); + } + + return redirect()->route('items.index')->with('status', $result->message()); + } + + /** + * Android apps disagree on where the link goes: some fill "url", most + * put it in "text" (often after a headline), a few in "title". + */ + protected function sharedUrl(string ...$fields): ?string + { + foreach ($fields as $field) { + if (preg_match('#https?://[^\s<>"]+#i', $field, $match)) { + return rtrim($match[0], '.,;:!?)\'"'); + } + } + + return null; + } +} diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php new file mode 100644 index 0000000..7500d2c --- /dev/null +++ b/app/Http/Controllers/SettingsController.php @@ -0,0 +1,76 @@ + Settings::fields(), + 'settings' => $this->settings, + 'bookmarklet' => self::bookmarklet(route('save.bookmarklet')), + 'tokens' => $request->user()->tokens()->latest()->get(), + 'plainToken' => $request->session()->get('plain_token'), + 'apiUrl' => route('api.items.store'), + // Read-only values displayed for context (not editable here). + 'info' => [ + 'Environment' => config('app.env'), + 'App URL' => config('app.url'), + 'Debug mode' => config('app.debug') ? 'On — should be off in production' : 'Off', + 'Database' => config('database.default'), + 'Queue driver' => config('queue.default').(config('queue.default') === 'sync' ? ' — pages are fetched during the save request' : ''), + 'Filesystem disk' => config('filesystems.default'), + 'Mail driver' => config('mail.default'), + 'Fetch User-Agent' => config('docket.fetch.user_agent'), + 'Off-site backups' => config('filesystems.disks.offsite.bucket') + ? config('filesystems.disks.offsite.bucket').' @ '.config('filesystems.disks.offsite.endpoint') + : 'Not configured', + 'Backup failure notifications' => config('backup.notifications.mail.to'), + ], + ]); + } + + /** + * Persist the editable overrides. + */ + public function update(SettingsRequest $request): RedirectResponse + { + foreach (array_keys(Settings::fields()) as $key) { + $this->settings->set($key, $request->input(Settings::formKey($key))); + } + + return Redirect::route('settings.edit')->with('status', 'Settings updated successfully.'); + } + + /** + * The bookmarklet: opens a small window on /save with the current + * page's URL and title, or navigates there if a popup blocker + * interferes. + */ + public static function bookmarklet(string $saveUrl): string + { + $js = sprintf( + "(function(){var u=%s+'?url='+encodeURIComponent(location.href)+'&title='+encodeURIComponent(document.title);" + ."if(!window.open(u,'docket','width=440,height=320,menubar=no,toolbar=no')){location.href=u;}})();", + json_encode($saveUrl, JSON_UNESCAPED_SLASHES) + ); + + return 'javascript:'.$js; + } +} diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php new file mode 100644 index 0000000..3f97a7b --- /dev/null +++ b/app/Http/Controllers/TagController.php @@ -0,0 +1,63 @@ + Tag::query()->withCount('items')->orderBy('name')->get(), + ]); + } + + /** + * Rename a tag. Renaming onto another tag's name merges the two. + */ + public function update(Request $request, Tag $tag): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:'.Tag::MAX_LENGTH], + ]); + + $name = Tag::cleanName($data['name']); + + if ($name === '') { + return back()->withErrors(['name' => 'Enter a tag name.']); + } + + $target = Tag::query() + ->where('slug', Tag::slugFor($name)) + ->whereKeyNot($tag->id) + ->first(); + + if ($target === null) { + $tag->update(['name' => $name, 'slug' => Tag::slugFor($name)]); + + return redirect()->route('tags.index')->with('status', "Renamed to \"{$name}\"."); + } + + DB::transaction(function () use ($tag, $target): void { + $target->items()->syncWithoutDetaching($tag->items()->pluck('items.id')->all()); + $tag->delete(); + }); + + return redirect()->route('tags.index')->with('status', "Merged into \"{$target->name}\"."); + } + + /** + * Delete a tag. The items keep everything else. + */ + public function destroy(Tag $tag): RedirectResponse + { + $tag->delete(); + + return redirect()->route('tags.index')->with('status', "Deleted the tag \"{$tag->name}\"."); + } +} diff --git a/app/Http/Controllers/ToolsController.php b/app/Http/Controllers/ToolsController.php new file mode 100644 index 0000000..bde78f2 --- /dev/null +++ b/app/Http/Controllers/ToolsController.php @@ -0,0 +1,123 @@ +}> + */ + public static function tools(): array + { + return [ + 'clear-cache' => [ + 'label' => 'Clear cache', + 'description' => 'Flush every cache — config, routes, views, events, and the application cache (optimize:clear). The safe first step when the app behaves oddly after a change.', + 'command' => 'optimize:clear', + 'params' => [], + ], + 'optimize' => [ + 'label' => 'Rebuild caches', + 'description' => 'Re-cache events, routes and views for faster requests (docket:optimize). Run after deploying; config cache is skipped because it must be rebuilt from the command line in the correct environment.', + 'command' => 'docket:optimize', + 'params' => [], + ], + 'migrate' => [ + 'label' => 'Run migrations', + 'description' => 'Apply any pending database migrations (migrate --force). Safe to run repeatedly — it does nothing when the schema is already up to date.', + 'command' => 'migrate', + 'params' => ['--force' => true], + ], + 'queue-restart' => [ + 'label' => 'Restart queue worker', + 'description' => 'Signal the queue worker to restart after it finishes its current job (queue:restart). Needed after deploying code changes — workers keep the old code in memory until restarted.', + 'command' => 'queue:restart', + 'params' => [], + ], + 'failed-jobs' => [ + 'label' => 'List failed jobs', + 'description' => 'Show jobs that crashed outright (queue:failed). Ordinary fetch failures are recorded on the item instead and do not appear here.', + 'command' => 'queue:failed', + 'params' => [], + ], + 'retry-failed' => [ + 'label' => 'Retry failed jobs', + 'description' => 'Push every failed job back onto the queue (queue:retry all).', + 'command' => 'queue:retry', + 'params' => ['id' => ['all']], + ], + 'refetch' => [ + 'label' => 'Fetch failed and pending items', + 'description' => 'Queue extraction again for every item that failed or is still waiting (docket:refetch) — e.g. links saved while the queue worker was down.', + 'command' => 'docket:refetch', + 'params' => [], + ], + 'backup-run' => [ + 'label' => 'Run backup now', + 'description' => 'Take an off-site backup immediately (backup:run) instead of waiting for the nightly scheduled one. Can take a minute — the saved images are zipped and uploaded.', + 'command' => 'backup:run', + 'params' => [], + ], + 'backup-list' => [ + 'label' => 'List backups', + 'description' => 'Show existing backups per destination with sizes and dates (backup:list).', + 'command' => 'backup:list', + 'params' => [], + ], + 'schedule-list' => [ + 'label' => 'List scheduled tasks', + 'description' => 'Show everything on the scheduler and when it next runs (schedule:list) — the nightly backup, cleanup and monitor.', + 'command' => 'schedule:list', + 'params' => [], + ], + ]; + } + + public function index(): View + { + return view('tools.index', ['tools' => self::tools()]); + } + + /** + * Run one whitelisted tool and flash its console output back to the page. + */ + public function run(string $tool): RedirectResponse + { + $definition = self::tools()[$tool] ?? abort(404); + + if ($tool === 'optimize') { + $binary = dirname(PHP_BINARY).'/'.preg_replace('/-fpm$/', '', basename(PHP_BINARY)); + $process = new Process([$binary, 'artisan', $definition['command']], base_path()); + $process->run(); + $exitCode = $process->getExitCode() ?? 1; + $output = trim($process->getOutput()); + if ($output === '') { + $output = trim($process->getErrorOutput()) ?: '(no output)'; + } + } else { + $exitCode = Artisan::call($definition['command'], $definition['params']); + $output = trim(Artisan::output()); + } + + return redirect('/tools')->with([ + 'tool_ran' => $definition['label'], + 'tool_exit_code' => $exitCode, + 'tool_output' => $output !== '' ? $output : '(no output)', + ]); + } +} diff --git a/app/Http/Middleware/EnsureNotInstalled.php b/app/Http/Middleware/EnsureNotInstalled.php new file mode 100644 index 0000000..b6620ad --- /dev/null +++ b/app/Http/Middleware/EnsureNotInstalled.php @@ -0,0 +1,32 @@ +installer->isInstalled()) { + return redirect()->route('login'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..397921e --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,36 @@ +headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('Referrer-Policy', 'same-origin'); + + return $response; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..711e0a1 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,86 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + /** + * Attempt to authenticate the request's credentials. + * + * @throws ValidationException + */ + public function authenticate(): void + { + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.failed'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + /** + * Ensure the login request is not rate limited. + * + * @throws ValidationException + */ + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + /** + * Get the rate limiting throttle key for the request. + */ + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php new file mode 100644 index 0000000..e2202dd --- /dev/null +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'lowercase', + 'email', + 'max:255', + Rule::unique(User::class)->ignore($this->user()->id), + ], + ]; + } +} diff --git a/app/Http/Requests/SettingsRequest.php b/app/Http/Requests/SettingsRequest.php new file mode 100644 index 0000000..1f1ad57 --- /dev/null +++ b/app/Http/Requests/SettingsRequest.php @@ -0,0 +1,39 @@ + $field) { + // Keyed by the form field name, not the dotted config path — + // see Settings::formKey(). + $rules[Settings::formKey($key)] = $field['rules']; + } + + return $rules; + } + + public function attributes(): array + { + return collect(Settings::fields()) + ->mapWithKeys(fn ($field, $key) => [Settings::formKey($key) => strtolower($field['label'])]) + ->all(); + } +} diff --git a/app/Jobs/FetchItemContent.php b/app/Jobs/FetchItemContent.php new file mode 100644 index 0000000..0005c9c --- /dev/null +++ b/app/Jobs/FetchItemContent.php @@ -0,0 +1,180 @@ + + */ + public function backoff(): array + { + $backoff = settings('fetch.backoff'); + + return is_array($backoff) && $backoff !== [] ? array_values(array_map('intval', $backoff)) : [30]; + } + + public function handle( + SafeHttpClient $http, + ArticleExtractor $extractor, + HtmlSanitiser $sanitiser, + ImageLocaliser $images, + ): void { + $item = $this->item; + $item->forceFill(['fetch_attempts' => $item->fetch_attempts + 1])->saveQuietly(); + + try { + $response = $http->get( + $item->url, + max(1, (int) settings('fetch.max_kb')) * 1024, + 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5' + ); + + if (! $response->isHtml()) { + throw FetchFailedException::permanent( + "This link is {$response->contentType}, not a web page, so there is no article to extract. The original link still works." + ); + } + + $article = $extractor->extract($response->body, $response->url, $response->charset); + } catch (FetchFailedException $e) { + if ($e->permanent || $this->isFinalAttempt()) { + $this->recordFailure($e->getMessage()); + + return; + } + + throw $e; + } + + $html = $article->html !== null ? $sanitiser->sanitise($article->html) : ''; + + $item->forceFill([ + 'resolved_url' => $response->url !== $item->url ? $response->url : null, + 'author' => $article->author, + 'site_name' => $article->siteName, + 'lead_image_url' => $article->leadImageUrl, + 'language' => $article->language, + 'published_at' => $article->publishedAt, + ]); + + if ($this->shouldReplaceTitle($item) && $article->title !== null) { + $item->title = $article->title; + } + + if (trim(strip_tags($html, '')) === '') { + $item->forceFill(['excerpt' => $article->excerpt])->save(); + $this->recordFailure('Could not find any article text on this page. The original link still works.'); + + return; + } + + $html = $images->localise($item, $html); + $text = HtmlSanitiser::toText($html); + $words = HtmlSanitiser::wordCount($text); + $wordsPerMinute = max(1, (int) settings('words_per_minute')); + + $item->forceFill([ + 'content_html' => $html, + 'content_text' => $text, + 'excerpt' => $article->excerpt ?? Str::limit(str_replace("\n", ' ', $text), 280), + 'word_count' => $words, + 'reading_time' => $words > 0 ? (int) max(1, ceil($words / $wordsPerMinute)) : 0, + 'lead_image_path' => $article->leadImageUrl !== null + ? $images->store($item, $article->leadImageUrl) + : null, + 'fetch_status' => FetchStatus::Fetched, + 'fetch_error' => null, + 'fetched_at' => now(), + ])->save(); + } + + /** + * Something unexpected (not a FetchFailedException) used up the last + * attempt. Record it so the item doesn't sit at "pending" for ever. + */ + public function failed(?Throwable $exception): void + { + if ($exception !== null) { + report($exception); + } + + $this->recordFailure('Extraction failed unexpectedly. Try again, or open the original link.'); + } + + protected function recordFailure(string $message): void + { + $this->item->forceFill([ + 'fetch_status' => FetchStatus::Failed, + 'fetch_error' => mb_substr($message, 0, 1000), + ])->save(); + } + + /** + * A sync (or directly invoked) job has no retries to fall back on. + */ + protected function isFinalAttempt(): bool + { + return $this->job === null + || $this->job instanceof SyncJob + || $this->attempts() >= $this->tries(); + } + + /** + * The extracted title wins over a missing one, a bare URL, or the + * browser tab title a bookmarklet or share sheet sent along (which + * usually carries the site name too). A title typed in by hand, or + * one already replaced by an earlier fetch, is kept. + */ + protected function shouldReplaceTitle(Item $item): bool + { + $title = trim((string) $item->title); + + return $title === '' + || $title === $item->url + || (in_array($item->source, [ItemSource::Bookmarklet, ItemSource::Share], true) && $item->fetched_at === null); + } +} diff --git a/app/Models/Item.php b/app/Models/Item.php new file mode 100644 index 0000000..e64d53a --- /dev/null +++ b/app/Models/Item.php @@ -0,0 +1,341 @@ + $tags + */ +class Item extends Model +{ + /** @use HasFactory */ + use HasFactory; + + /** + * The disk article images are stored on. It is the private local disk: + * images are only ever served through the authenticated image route. + */ + public const DISK = 'local'; + + /** + * Sort orders the lists offer, keyed by their query-string value. + */ + public const SORTS = [ + 'newest' => 'Newest', + 'oldest' => 'Oldest', + 'longest' => 'Longest', + ]; + + protected $fillable = [ + 'url', + 'normalized_url', + 'resolved_url', + 'title', + 'author', + 'site_name', + 'excerpt', + 'lead_image_url', + 'lead_image_path', + 'content_html', + 'content_text', + 'word_count', + 'reading_time', + 'language', + 'published_at', + 'status', + 'archived_at', + 'is_favourite', + 'favourited_at', + 'read_progress', + 'last_read_at', + 'source', + 'fetch_status', + 'fetch_error', + 'fetch_attempts', + 'fetched_at', + 'added_at', + ]; + + protected $attributes = [ + 'status' => 'unread', + 'source' => 'web', + 'fetch_status' => 'pending', + 'is_favourite' => false, + 'read_progress' => 0, + 'word_count' => 0, + 'reading_time' => 0, + 'fetch_attempts' => 0, + ]; + + protected function casts(): array + { + return [ + 'status' => ItemStatus::class, + 'source' => ItemSource::class, + 'fetch_status' => FetchStatus::class, + 'is_favourite' => 'boolean', + 'read_progress' => 'float', + 'word_count' => 'integer', + 'reading_time' => 'integer', + 'fetch_attempts' => 'integer', + 'published_at' => 'datetime', + 'archived_at' => 'datetime', + 'favourited_at' => 'datetime', + 'last_read_at' => 'datetime', + 'fetched_at' => 'datetime', + 'added_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (Item $item): void { + $item->added_at ??= now(); + }); + + // Stored images go with the item. Bulk deletes therefore load the + // models and delete them one by one rather than via the query + // builder, which would skip this. + static::deleted(function (Item $item): void { + Storage::disk(self::DISK)->deleteDirectory($item->storageDirectory()); + }); + } + + /** + * @return BelongsToMany + */ + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class)->orderBy('name'); + } + + /** + * Where this item's downloaded images live on the private disk. + */ + public function storageDirectory(): string + { + return 'items/'.$this->id; + } + + /** + * The title to show: the extracted or given title, else the host. + */ + public function displayTitle(): string + { + $title = trim((string) $this->title); + + return $title !== '' ? $title : ($this->host() ?: $this->url); + } + + /** + * The host shown under the title, without a leading "www.". + */ + public function host(): string + { + $host = (string) parse_url($this->resolved_url ?: $this->url, PHP_URL_HOST); + + return (string) preg_replace('/^www\./i', '', $host); + } + + /** + * The link to the original page. + */ + public function originalUrl(): string + { + return $this->resolved_url ?: $this->url; + } + + public function isArchived(): bool + { + return $this->status === ItemStatus::Archived; + } + + public function hasArticle(): bool + { + return $this->fetch_status === FetchStatus::Fetched && trim((string) $this->content_html) !== ''; + } + + public function archive(): void + { + $this->forceFill([ + 'status' => ItemStatus::Archived, + 'archived_at' => now(), + ])->save(); + } + + public function unarchive(): void + { + $this->forceFill([ + 'status' => ItemStatus::Unread, + 'archived_at' => null, + ])->save(); + } + + public function setFavourite(bool $favourite): void + { + $this->forceFill([ + 'is_favourite' => $favourite, + 'favourited_at' => $favourite ? now() : null, + ])->save(); + } + + /** + * Add tags by name, creating any that don't exist yet. Existing tags on + * the item are kept. + * + * @param iterable $names + */ + public function attachTagNames(iterable $names): void + { + $ids = Tag::idsForNames($names); + + if ($ids !== []) { + $this->tags()->syncWithoutDetaching($ids); + } + } + + /** + * Replace the item's tags with exactly these names. + * + * @param iterable $names + */ + public function syncTagNames(iterable $names): void + { + $this->tags()->sync(Tag::idsForNames($names)); + } + + /** + * @param Builder $query + */ + public function scopeUnread(Builder $query): void + { + $query->where('status', ItemStatus::Unread->value); + } + + /** + * @param Builder $query + */ + public function scopeArchived(Builder $query): void + { + $query->where('status', ItemStatus::Archived->value); + } + + /** + * @param Builder $query + */ + public function scopeFavourites(Builder $query): void + { + $query->where('is_favourite', true); + } + + /** + * @param Builder $query + */ + public function scopeTagged(Builder $query, Tag $tag): void + { + $query->whereHas('tags', fn (Builder $tags) => $tags->whereKey($tag->getKey())); + } + + /** + * Restrict to items matching a full-text search, ranked by relevance + * (title hits count most, then the site name, then the body). + * + * @param Builder $query + */ + public function scopeSearch(Builder $query, string $term, bool $orderByRelevance = true): void + { + $match = self::ftsQuery($term); + + if ($match === null) { + $query->whereRaw('0 = 1'); + + return; + } + + $matches = DB::table('items_fts') + ->selectRaw('rowid AS fts_rowid, bm25(items_fts, 10.0, 5.0, 1.0) AS fts_rank') + ->whereRaw('items_fts MATCH ?', [$match]); + + $query->joinSub($matches, 'fts', 'fts.fts_rowid', '=', 'items.id') + ->select('items.*'); + + if ($orderByRelevance) { + $query->orderBy('fts.fts_rank'); + } + } + + /** + * @param Builder $query + */ + public function scopeSorted(Builder $query, ?string $sort): void + { + if ($sort === 'oldest') { + $query->orderBy('items.added_at')->orderBy('items.id'); + } elseif ($sort === 'longest') { + $query->orderByDesc('items.word_count')->orderByDesc('items.id'); + } else { + $query->orderByDesc('items.added_at')->orderByDesc('items.id'); + } + } + + /** + * Turn free text into a safe FTS5 query: every word becomes a quoted + * prefix term, so FTS operators and stray quotes in the input can never + * produce a syntax error. Words are ANDed together. + */ + public static function ftsQuery(string $term): ?string + { + $words = preg_split('/\s+/u', trim(str_replace('"', ' ', $term)), -1, PREG_SPLIT_NO_EMPTY) ?: []; + + $terms = array_map( + static fn (string $word): string => '"'.$word.'"*', + array_slice($words, 0, 12) + ); + + return $terms === [] ? null : implode(' ', $terms); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..04008ff --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,22 @@ + 'array', + ]; + } +} diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 0000000..f879b19 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,118 @@ + */ + use HasFactory; + + public const MAX_LENGTH = 100; + + protected $fillable = ['name', 'slug']; + + public function getRouteKeyName(): string + { + return 'slug'; + } + + /** + * @return BelongsToMany + */ + public function items(): BelongsToMany + { + return $this->belongsToMany(Item::class); + } + + /** + * Tidy a tag name: trim, collapse whitespace, and strip the separators + * (commas and Pocket's pipes) that would split it on the way back in. + */ + public static function cleanName(string $name): string + { + $name = str_replace([',', '|'], ' ', $name); + $name = trim((string) preg_replace('/\s+/u', ' ', $name)); + + return mb_substr($name, 0, self::MAX_LENGTH); + } + + /** + * The case-insensitive identity of a tag name. Not Str::slug(): that + * transliterates to ASCII and would turn a non-Latin tag into nothing. + */ + public static function slugFor(string $name): string + { + return mb_strtolower(str_replace(' ', '-', self::cleanName($name))); + } + + /** + * Split a comma-separated (or array) tag list into clean, unique names. + * + * @param string|iterable|null $tags + * @return list + */ + public static function parseList(string|iterable|null $tags): array + { + if ($tags === null) { + return []; + } + + $raw = is_string($tags) ? explode(',', $tags) : $tags; + + $names = []; + foreach ($raw as $name) { + if (! is_scalar($name)) { + continue; + } + + $clean = self::cleanName((string) $name); + + if ($clean !== '') { + $names[self::slugFor($clean)] ??= $clean; + } + } + + return array_values($names); + } + + /** + * Find or create a tag for each name and return their IDs. + * + * @param iterable $names + * @return list + */ + public static function idsForNames(iterable $names): array + { + $ids = []; + + foreach ($names as $name) { + $name = self::cleanName($name); + + if ($name === '') { + continue; + } + + $tag = self::query()->firstOrCreate( + ['slug' => self::slugFor($name)], + ['name' => $name], + ); + + $ids[$tag->id] = $tag->id; + } + + return array_values($ids); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..4e71ebe --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,33 @@ + */ + use HasApiTokens, HasFactory, Notifiable; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..4141f2d --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,40 @@ +app->singleton(HostResolver::class); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + // Production deployments are HTTPS-only (the PWA install and the + // share target require it); forcing the scheme keeps every + // generated URL (redirects, pagination, asset links) on HTTPS even + // if a proxy hop speaks plain HTTP. + if ($this->app->isProduction()) { + URL::forceScheme('https'); + } + + RateLimiter::for('api', function (Request $request): Limit { + return Limit::perMinute(60)->by($request->user()?->getAuthIdentifier() ?: $request->ip()); + }); + } +} diff --git a/app/Services/Exporter.php b/app/Services/Exporter.php new file mode 100644 index 0000000..c4e383a --- /dev/null +++ b/app/Services/Exporter.php @@ -0,0 +1,114 @@ +items() as $item) { + fputcsv($handle, [ + $item->title ?? '', + $item->url, + (string) ($item->added_at?->getTimestamp() ?? ''), + $item->tags->pluck('name')->implode('|'), + $item->status->pocketName(), + ], ',', '"', ''); + } + } + + /** + * Streamed one item at a time so a large library never has to fit in + * memory as a single array. + * + * @param resource $handle + */ + public function writeJson($handle): void + { + $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE; + + fwrite($handle, '{"format":"docket","version":1,"exported_at":'.json_encode(now()->toIso8601String()).',"items":['); + + $first = true; + + foreach ($this->items() as $item) { + fwrite($handle, ($first ? "\n" : ",\n").json_encode($this->itemToArray($item), $flags)); + $first = false; + } + + fwrite($handle, "\n]}\n"); + } + + /** + * @return array + */ + public function itemToArray(Item $item): array + { + $date = fn (?Carbon $value): ?string => $value?->toIso8601String(); + + return [ + 'id' => $item->id, + 'url' => $item->url, + 'normalized_url' => $item->normalized_url, + 'resolved_url' => $item->resolved_url, + 'title' => $item->title, + 'author' => $item->author, + 'site_name' => $item->site_name, + 'excerpt' => $item->excerpt, + 'lead_image_url' => $item->lead_image_url, + 'language' => $item->language, + 'published_at' => $date($item->published_at), + 'status' => $item->status->value, + 'is_favourite' => $item->is_favourite, + 'tags' => $item->tags->map(fn (Tag $tag): string => $tag->name)->values()->all(), + 'word_count' => $item->word_count, + 'reading_time' => $item->reading_time, + 'read_progress' => $item->read_progress, + 'source' => $item->source->value, + 'fetch_status' => $item->fetch_status->value, + 'fetch_error' => $item->fetch_error, + 'added_at' => $date($item->added_at), + 'archived_at' => $date($item->archived_at), + 'favourited_at' => $date($item->favourited_at), + 'last_read_at' => $date($item->last_read_at), + 'fetched_at' => $date($item->fetched_at), + 'content_text' => $item->content_text, + 'content_html' => $item->content_html, + ]; + } + + /** + * Oldest first, so an export re-imported in order keeps its sequence. + * + * @return LazyCollection + */ + protected function items(): LazyCollection + { + return Item::query() + ->with('tags') + ->orderBy('added_at') + ->orderBy('id') + ->lazy(200); + } +} diff --git a/app/Services/Extraction/ArticleExtractor.php b/app/Services/Extraction/ArticleExtractor.php new file mode 100644 index 0000000..0487475 --- /dev/null +++ b/app/Services/Extraction/ArticleExtractor.php @@ -0,0 +1,274 @@ +toUtf8($html, $charset); + $meta = $this->metadata($html); + + $readability = new Readability( + (new Configuration()) + ->setFixRelativeURLs(true) + ->setOriginalURL($url) + ); + + try { + $readability->parse($html); + $content = $readability->getContent(); + } catch (ParseException) { + $content = null; + } + + $title = $this->clean($readability->getTitle()) ?? $meta['title']; + + return new ExtractedArticle( + title: $title !== null ? mb_substr($title, 0, 1000) : null, + author: $this->limit($this->clean($readability->getAuthor()) ?? $meta['author']), + siteName: $this->limit($this->clean($readability->getSiteName()) ?? $meta['site_name']), + excerpt: $this->clean($readability->getExcerpt()) ?? $meta['description'], + leadImageUrl: $this->absoluteHttpUrl($this->clean($readability->getImage()) ?? $meta['image'], $url), + html: $content !== null && trim(strip_tags($content, '')) !== '' ? $content : null, + language: $meta['language'], + publishedAt: $meta['published_at'], + ); + } + + /** + * Convert the page to UTF-8 using the HTTP charset, else a + * charset, else assume UTF-8 (falling back to Windows-1252 when the + * bytes are not valid UTF-8, which covers most legacy pages). + */ + public function toUtf8(string $html, ?string $charset): string + { + if ($charset === null && preg_match('/]+charset\s*=\s*["\']?\s*([a-z0-9_\-:.]+)/i', substr($html, 0, 4096), $match)) { + $charset = $match[1]; + } + + $charset = strtolower(trim((string) $charset)); + + if ($charset !== '' && ! in_array($charset, ['utf-8', 'utf8'], true)) { + $encodings = array_map('strtolower', mb_list_encodings()); + + if (in_array($charset, $encodings, true)) { + return (string) mb_convert_encoding($html, 'UTF-8', $charset); + } + } + + if (! mb_check_encoding($html, 'UTF-8')) { + return (string) mb_convert_encoding($html, 'UTF-8', 'Windows-1252'); + } + + return $html; + } + + /** + * Metadata from , and JSON-LD. + * + * @return array{title: string|null, author: string|null, site_name: string|null, description: string|null, image: string|null, language: string|null, published_at: CarbonImmutable|null} + */ + protected function metadata(string $html): array + { + $dom = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $dom->loadHTML(''.$html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + $xpath = new DOMXPath($dom); + $meta = fn (string ...$names): ?string => $this->firstMeta($xpath, $names); + + $jsonLd = $this->jsonLd($xpath); + + $titleNode = $this->first($xpath, '//title'); + + $language = $this->attribute($xpath, '//html/@lang') + ?? $meta('og:locale', 'content-language', 'language', 'dc.language'); + + return [ + 'title' => $meta('og:title', 'twitter:title') ?? $this->clean($titleNode?->textContent), + 'author' => $meta('author', 'article:author', 'dc.creator', 'parsely-author', 'sailthru.author'), + 'site_name' => $meta('og:site_name', 'application-name'), + 'description' => $meta('og:description', 'description', 'twitter:description'), + 'image' => $meta('og:image', 'og:image:url', 'twitter:image', 'twitter:image:src'), + 'language' => $this->normaliseLanguage($language), + 'published_at' => $this->parseDate( + $meta('article:published_time', 'og:published_time', 'datepublished', 'date', 'dc.date', 'dc.date.issued', 'pubdate', 'parsely-pub-date', 'sailthru.date') + ?? ($jsonLd['datePublished'] ?? null) + ?? $this->attribute($xpath, '//time[@datetime][1]/@datetime') + ), + ]; + } + + /** + * @param list $names + */ + protected function firstMeta(DOMXPath $xpath, array $names): ?string + { + $found = []; + + foreach ($xpath->query('//meta[@content]') ?: [] as $node) { + if (! $node instanceof DOMElement) { + continue; + } + + $key = strtolower($node->getAttribute('property') ?: $node->getAttribute('name') ?: $node->getAttribute('itemprop') ?: $node->getAttribute('http-equiv')); + + if ($key === '') { + continue; + } + + $found[$key] ??= $this->clean($node->getAttribute('content')); + } + + foreach ($names as $name) { + if (($found[$name] ?? null) !== null) { + return $found[$name]; + } + } + + return null; + } + + protected function attribute(DOMXPath $xpath, string $query): ?string + { + return $this->clean($this->first($xpath, $query)?->nodeValue); + } + + protected function first(DOMXPath $xpath, string $query): ?DOMNode + { + $nodes = $xpath->query($query); + + return $nodes === false ? null : $nodes->item(0); + } + + /** + * The first JSON-LD object that looks like an article. + * + * @return array + */ + protected function jsonLd(DOMXPath $xpath): array + { + foreach ($xpath->query('//script[@type="application/ld+json"]') ?: [] as $node) { + $data = json_decode((string) $node->textContent, true); + + if (! is_array($data)) { + continue; + } + + $candidates = array_is_list($data) ? $data : [$data]; + + if (isset($data['@graph']) && is_array($data['@graph'])) { + $candidates = array_merge($candidates, $data['@graph']); + } + + foreach ($candidates as $candidate) { + if (is_array($candidate) && isset($candidate['datePublished']) && is_string($candidate['datePublished'])) { + return $candidate; + } + } + } + + return []; + } + + protected function parseDate(?string $value): ?CarbonImmutable + { + if ($value === null) { + return null; + } + + try { + $date = CarbonImmutable::parse($value); + } catch (Throwable) { + return null; + } + + // Guard against junk ("0000-00-00", far-future placeholders). + if ($date->year < 1990 || $date->isAfter(now()->addDay())) { + return null; + } + + return $date; + } + + /** + * "en_GB" and "EN-gb" become "en-GB"; anything that isn't a plausible + * BCP 47 tag is dropped. + */ + protected function normaliseLanguage(?string $language): ?string + { + if ($language === null) { + return null; + } + + $language = str_replace('_', '-', trim(explode(',', $language)[0])); + + if (! preg_match('/^([a-z]{2,3})(?:-([a-z0-9]{2,8}))?/i', $language, $match)) { + return null; + } + + $tag = strtolower($match[1]); + + if (isset($match[2])) { + $tag .= '-'.(strlen($match[2]) === 2 ? strtoupper($match[2]) : $match[2]); + } + + return $tag; + } + + protected function absoluteHttpUrl(?string $url, string $base): ?string + { + if ($url === null) { + return null; + } + + if (str_starts_with($url, '//')) { + $url = (parse_url($base, PHP_URL_SCHEME) ?: 'https').':'.$url; + } elseif (! preg_match('#^https?://#i', $url)) { + if (! str_starts_with($url, '/')) { + return null; + } + + $url = parse_url($base, PHP_URL_SCHEME).'://'.parse_url($base, PHP_URL_HOST) + .(parse_url($base, PHP_URL_PORT) ? ':'.parse_url($base, PHP_URL_PORT) : '') + .$url; + } + + return strlen($url) <= 2048 ? $url : null; + } + + protected function clean(?string $value): ?string + { + if ($value === null) { + return null; + } + + $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $value = trim((string) preg_replace('/\s+/u', ' ', $value)); + + return $value === '' ? null : $value; + } + + protected function limit(?string $value): ?string + { + return $value === null ? null : mb_substr($value, 0, 255); + } +} diff --git a/app/Services/Extraction/ExtractedArticle.php b/app/Services/Extraction/ExtractedArticle.php new file mode 100644 index 0000000..7ce7696 --- /dev/null +++ b/app/Services/Extraction/ExtractedArticle.php @@ -0,0 +1,23 @@ +contentType === '' + || in_array($this->contentType, ['text/html', 'application/xhtml+xml'], true); + } +} diff --git a/app/Services/Fetching/HostResolver.php b/app/Services/Fetching/HostResolver.php new file mode 100644 index 0000000..51db4c7 --- /dev/null +++ b/app/Services/Fetching/HostResolver.php @@ -0,0 +1,48 @@ + + */ + public function resolve(string $host): array + { + $host = trim($host, '[]'); + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return [$host]; + } + + $addresses = []; + + $records = @dns_get_record($host, DNS_A | DNS_AAAA); + + foreach (is_array($records) ? $records : [] as $record) { + if (isset($record['ip'])) { + $addresses[] = (string) $record['ip']; + } + + if (isset($record['ipv6'])) { + $addresses[] = (string) $record['ipv6']; + } + } + + // gethostbynamel() also reads /etc/hosts and understands numeric + // forms such as "2130706433" or "0x7f.1", which dns_get_record() + // does not, so those still get vetted (and refused). + $fallback = @gethostbynamel($host); + + if (is_array($fallback)) { + $addresses = array_merge($addresses, $fallback); + } + + return array_values(array_unique($addresses)); + } +} diff --git a/app/Services/Fetching/IpGuard.php b/app/Services/Fetching/IpGuard.php new file mode 100644 index 0000000..eace071 --- /dev/null +++ b/app/Services/Fetching/IpGuard.php @@ -0,0 +1,103 @@ +vet($url); + + $response = $this->send($url, $host, $port, $address, $accept); + $status = $response->status(); + $location = $response->header('Location'); + + if ($status >= 300 && $status < 400 && $location !== '') { + if ($hop >= $maxRedirects) { + throw FetchFailedException::permanent("Gave up after more than {$maxRedirects} redirects."); + } + + $url = (string) UriResolver::resolve(Utils::uriFor($url), Utils::uriFor($location)); + + continue; + } + + if ($status >= 400) { + throw new FetchFailedException( + "The site responded with HTTP {$status}.", + permanent: $status !== 408 && $status !== 429 && $status < 500, + ); + } + + [$contentType, $charset] = $this->parseContentType($response->header('Content-Type')); + + $length = $response->header('Content-Length'); + if (is_numeric($length) && (int) $length > $maxBytes) { + throw FetchFailedException::permanent($this->tooLargeMessage($maxBytes)); + } + + return new FetchedResponse( + url: $url, + status: $status, + contentType: $contentType, + charset: $charset, + body: $this->readBody($response, $maxBytes), + ); + } + } + + /** + * Check the URL's scheme and that every address its host resolves to + * is public. Returns the host, port and the vetted address to pin. + * + * @return array{0: string, 1: int, 2: string} + * + * @throws FetchFailedException + */ + public function vet(string $url): array + { + $parts = parse_url($url); + $scheme = strtolower((string) ($parts['scheme'] ?? '')); + $host = (string) ($parts['host'] ?? ''); + + if (! in_array($scheme, ['http', 'https'], true) || $host === '') { + throw new UnsafeUrlException('Only http and https links can be fetched.'); + } + + $port = isset($parts['port']) ? (int) $parts['port'] : ($scheme === 'https' ? 443 : 80); + $addresses = $this->resolver->resolve($host); + + if ($addresses === []) { + throw new FetchFailedException("Could not find the server for {$host}."); + } + + foreach ($addresses as $address) { + if (! IpGuard::isPublic($address)) { + throw new UnsafeUrlException("Refused to fetch {$host}: it points at a private or local network address."); + } + } + + return [trim($host, '[]'), $port, $addresses[0]]; + } + + /** + * @throws FetchFailedException + */ + protected function send(string $url, string $host, int $port, string $address, string $accept): Response + { + $pinned = str_contains($address, ':') ? "[{$address}]" : $address; + + // An IP-literal URL has nothing to resolve, so nothing to pin. + $curl = filter_var($host, FILTER_VALIDATE_IP) === false + ? [CURLOPT_RESOLVE => ["{$host}:{$port}:{$pinned}"]] + : []; + + try { + return Http::withHeaders([ + 'User-Agent' => (string) config('docket.fetch.user_agent'), + 'Accept' => $accept, + 'Accept-Language' => 'en-GB,en;q=0.9,*;q=0.5', + ]) + ->timeout((int) settings('fetch.timeout')) + ->connectTimeout((int) config('docket.fetch.connect_timeout', 10)) + ->withoutRedirecting() + ->withOptions([ + 'stream' => true, + 'protocols' => ['http', 'https'], + 'curl' => $curl, + ]) + ->get($url); + } catch (ConnectionException $e) { + throw new FetchFailedException('Could not connect: '.$this->shortError($e->getMessage())); + } + } + + /** + * Read the body in chunks, giving up as soon as it passes the cap. + * + * @throws FetchFailedException + */ + protected function readBody(Response $response, int $maxBytes): string + { + $stream = $response->toPsrResponse()->getBody(); + $body = ''; + + try { + while (! $stream->eof()) { + $body .= $stream->read(65536); + + if (strlen($body) > $maxBytes) { + throw FetchFailedException::permanent($this->tooLargeMessage($maxBytes)); + } + } + } finally { + $stream->close(); + } + + return $body; + } + + /** + * @return array{0: string, 1: string|null} the bare MIME type and charset + */ + protected function parseContentType(string $header): array + { + $parts = array_map('trim', explode(';', strtolower($header))); + $charset = null; + + foreach (array_slice($parts, 1) as $parameter) { + if (str_starts_with($parameter, 'charset=')) { + $charset = trim(substr($parameter, 8), "\"' "); + } + } + + return [$parts[0], $charset ?: null]; + } + + protected function tooLargeMessage(int $maxBytes): string + { + return 'The page is larger than the '.number_format($maxBytes / 1024).' KB limit.'; + } + + protected function shortError(string $message): string + { + // cURL errors carry the whole URL and a docs link; the first + // sentence is the useful part. + $message = (string) preg_replace('/\s*\(see https?:\/\/[^)]+\)/', '', $message); + + return mb_strimwidth(trim($message), 0, 200, '…'); + } +} diff --git a/app/Services/HtmlSanitiser.php b/app/Services/HtmlSanitiser.php new file mode 100644 index 0000000..b9ceaf0 --- /dev/null +++ b/app/Services/HtmlSanitiser.php @@ -0,0 +1,154 @@ + ['href', 'title'], + 'abbr' => ['title'], + 'b' => [], + 'blockquote' => ['cite'], + 'br' => [], + 'caption' => [], + 'cite' => [], + 'code' => [], + 'dd' => [], + 'del' => [], + 'details' => [], + 'dl' => [], + 'dt' => [], + 'em' => [], + 'figcaption' => [], + 'figure' => [], + 'h1' => [], + 'h2' => [], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'hr' => [], + 'i' => [], + 'img' => ['src', 'alt', 'title', 'width', 'height'], + 'ins' => [], + 'kbd' => [], + 'li' => [], + 'mark' => [], + 'ol' => ['start', 'reversed'], + 'p' => [], + 'pre' => [], + 'q' => ['cite'], + 's' => [], + 'samp' => [], + 'small' => [], + 'strong' => [], + 'sub' => [], + 'summary' => [], + 'sup' => [], + 'table' => [], + 'tbody' => [], + 'td' => ['colspan', 'rowspan'], + 'tfoot' => [], + 'th' => ['colspan', 'rowspan', 'scope'], + 'thead' => [], + 'time' => ['datetime'], + 'tr' => [], + 'u' => [], + 'ul' => [], + 'var' => [], + ]; + + /** + * Removed together with their contents. + */ + public const DROPPED = [ + 'applet', 'audio', 'base', 'button', 'canvas', 'dialog', 'embed', + 'form', 'frame', 'frameset', 'head', 'iframe', 'input', 'link', + 'map', 'math', 'meta', 'nav', 'noscript', 'object', 'option', + 'param', 'script', 'select', 'source', 'style', 'svg', 'template', + 'textarea', 'title', 'track', 'video', + ]; + + protected ?HtmlSanitizer $sanitizer = null; + + public function sanitise(string $html): string + { + $this->sanitizer ??= new HtmlSanitizer($this->config()); + + return trim($this->sanitizer->sanitize($html)); + } + + /** + * Plain text from article HTML, with block elements on their own lines, + * for the search index, word count and excerpt. + */ + public static function toText(string $html): string + { + $html = (string) preg_replace('/<(br|hr)\b[^>]*>/i', "\n", $html); + $html = (string) preg_replace( + '/<\/(p|div|h[1-6]|li|dt|dd|blockquote|pre|tr|figure|figcaption|table|section|article|ul|ol)>/i', + "$0\n", + $html + ); + + $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = (string) preg_replace('/[ \t\x{00A0}]+/u', ' ', $text); + $text = (string) preg_replace('/ *\n */', "\n", $text); + $text = (string) preg_replace('/\n{3,}/', "\n\n", $text); + + return trim($text); + } + + /** + * Words in a block of text. Counts runs of letters and digits (with + * inner apostrophes), which works for any script that uses spaces. + */ + public static function wordCount(string $text): int + { + return (int) preg_match_all("/[\\p{L}\\p{N}]+(?:['’][\\p{L}]+)*/u", $text); + } + + protected function config(): HtmlSanitizerConfig + { + $config = (new HtmlSanitizerConfig()) + // Unknown elements are unwrapped, keeping their text. + ->defaultAction(HtmlSanitizerAction::Block) + // The page was already capped at download; the sanitiser's own + // 20 KB default would truncate most articles. + ->withMaxInputLength(-1) + ->allowLinkSchemes(['http', 'https', 'mailto']) + ->allowMediaSchemes(['http', 'https']) + ->allowRelativeLinks() + // Localised images point at this app's own image route. + ->allowRelativeMedias() + ->forceAttribute('a', 'rel', 'noopener noreferrer nofollow') + ->forceAttribute('img', 'loading', 'lazy'); + + foreach (self::ALLOWED as $element => $attributes) { + $config = $config->allowElement($element, $attributes); + } + + foreach (self::DROPPED as $element) { + $config = $config->dropElement($element); + } + + return $config; + } +} diff --git a/app/Services/ImageLocaliser.php b/app/Services/ImageLocaliser.php new file mode 100644 index 0000000..39cce59 --- /dev/null +++ b/app/Services/ImageLocaliser.php @@ -0,0 +1,146 @@ + 'jpg', + 'image/pjpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/avif' => 'avif', + ]; + + /** + * Matches a stored image filename (and nothing else) for the route. + */ + public const FILENAME_PATTERN = '[a-f0-9]{40}\.(?:jpg|png|gif|webp|avif)'; + + public function __construct(protected SafeHttpClient $http) {} + + /** + * Rewrite every in already-sanitised article HTML. + */ + public function localise(Item $item, string $html): string + { + $limit = (int) settings('images.max_per_item'); + + if (! config('docket.images.enabled') || $limit < 1 || ! str_contains($html, ' true]); + $dom = $html5->loadHTML('
'.$html.'
'); + $matches = (new DOMXPath($dom))->query('//*[@id="docket-article"]'); + $root = $matches === false ? null : $matches->item(0); + + if (! $root instanceof DOMElement) { + return $html; + } + + $stored = []; + + foreach (iterator_to_array($root->getElementsByTagName('img')) as $img) { + $src = $img->getAttribute('src'); + + if (! preg_match('#^https?://#i', $src)) { + continue; + } + + if (! array_key_exists($src, $stored)) { + $stored[$src] = count(array_filter($stored)) < $limit + ? $this->store($item, $src) + : null; + } + + if ($stored[$src] !== null) { + $img->setAttribute('src', $this->url($item, $stored[$src])); + } + } + + return $html5->saveHTML($root->childNodes); + } + + /** + * Download one image into the item's directory. + * + * @return string|null the stored filename, or null if it was not stored + */ + public function store(Item $item, string $url): ?string + { + try { + $response = $this->http->get($url, (int) config('docket.images.max_kb') * 1024, 'image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.1'); + } catch (FetchFailedException $e) { + Log::debug('Image not stored', ['item' => $item->id, 'url' => $url, 'reason' => $e->getMessage()]); + + return null; + } + + // Trust the bytes, not the header: a server can label anything + // image/png. + $sniffed = (new \finfo(FILEINFO_MIME_TYPE))->buffer($response->body); + $extension = self::TYPES[$sniffed] ?? null; + + if ($extension === null) { + return null; + } + + $filename = sha1($url).'.'.$extension; + + Storage::disk(Item::DISK)->put($item->storageDirectory().'/'.$filename, $response->body); + + return $filename; + } + + /** + * The app-relative URL an image is served from. Relative, so stored + * articles survive a change of domain or scheme. + */ + public function url(Item $item, string $filename): string + { + return route('items.image', ['item' => $item, 'filename' => $filename], false); + } + + /** + * MIME type for a stored filename, for serving it back. + */ + public static function mimeFor(string $filename): string + { + $extension = pathinfo($filename, PATHINFO_EXTENSION); + + return match ($extension) { + 'jpg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + default => 'application/octet-stream', + }; + } +} diff --git a/app/Services/ImportResult.php b/app/Services/ImportResult.php new file mode 100644 index 0000000..f5c3e9a --- /dev/null +++ b/app/Services/ImportResult.php @@ -0,0 +1,26 @@ +created, + $this->created === 1 ? 'link' : 'links', + $this->existing, + $this->invalid, + $this->queued, + ); + } +} diff --git a/app/Services/Installer.php b/app/Services/Installer.php new file mode 100644 index 0000000..966ba64 --- /dev/null +++ b/app/Services/Installer.php @@ -0,0 +1,286 @@ +exists(); + } catch (Throwable) { + // No database yet — definitely a fresh install. + return false; + } + } + + /** + * Environment checks for the requirements screen. Blocking entries + * must pass before installation can proceed; the rest are advisory. + * + * @return list + */ + public function requirements(): array + { + $envPath = $this->app->environmentFilePath(); + $envWritable = file_exists($envPath) + ? is_writable($envPath) + : is_writable($this->app->environmentPath()); + + $checks = [ + [ + 'label' => 'PHP 8.3 or newer (running '.PHP_VERSION.')', + 'ok' => version_compare(PHP_VERSION, '8.3.0', '>='), + 'blocking' => true, + 'hint' => 'Upgrade PHP to 8.3+.', + ], + ]; + + foreach (['pdo_sqlite', 'mbstring', 'fileinfo', 'dom', 'xml'] as $extension) { + $checks[] = [ + 'label' => "PHP extension: {$extension}", + 'ok' => extension_loaded($extension), + 'blocking' => true, + 'hint' => "Install/enable the {$extension} PHP extension.", + ]; + } + + $checks[] = [ + 'label' => 'SQLite with full-text search (FTS5)', + 'ok' => $this->hasFts5(), + 'blocking' => true, + 'hint' => 'Search needs an SQLite build with FTS5 (standard on current Debian, Ubuntu and Homebrew PHP).', + ]; + + $writable = [ + '.env file location' => $envWritable, + 'storage/ directory' => is_writable($this->app->storagePath()), + 'bootstrap/cache/ directory' => is_writable($this->app->bootstrapPath('cache')), + 'database/ directory' => is_writable(dirname($this->sqlitePath() ?? database_path('database.sqlite'))), + ]; + + foreach ($writable as $label => $ok) { + $checks[] = [ + 'label' => "Writable: {$label}", + 'ok' => $ok, + 'blocking' => true, + 'hint' => 'Fix ownership/permissions for the web server user (e.g. chown -R www-data).', + ]; + } + + $checks[] = [ + 'label' => 'Frontend assets built (public/build)', + 'ok' => file_exists(public_path('build/manifest.json')), + 'blocking' => false, + 'hint' => 'Run: npm install && npm run build', + ]; + + $checks[] = [ + 'label' => 'PHP extension: intl (used to normalise international domain names)', + 'ok' => extension_loaded('intl'), + 'blocking' => false, + 'hint' => 'Install the intl PHP extension; without it, non-ASCII domains are compared as typed.', + ]; + + $checks[] = [ + 'label' => 'PHP extension: zip (used by off-site backups)', + 'ok' => extension_loaded('zip'), + 'blocking' => false, + 'hint' => 'Install the zip PHP extension to enable backups.', + ]; + + return $checks; + } + + /** + * Human-readable descriptions of every failing blocking requirement. + * + * @return list + */ + public function blockingProblems(): array + { + $problems = []; + + foreach ($this->requirements() as $check) { + if ($check['blocking'] && ! $check['ok']) { + $problems[] = $check['label'].' — '.$check['hint']; + } + } + + return $problems; + } + + /** + * @return bool whether a new .env was created (false = already existed) + */ + public function ensureEnvFile(): bool + { + $envPath = $this->app->environmentFilePath(); + + if (file_exists($envPath)) { + return false; + } + + $example = $this->app->environmentPath().DIRECTORY_SEPARATOR.'.env.example'; + + if (! copy($example, $envPath)) { + throw new \RuntimeException("Could not create {$envPath} from .env.example."); + } + + return true; + } + + /** + * Writes a fresh APP_KEY directly rather than calling key:generate, + * whose replacement regex anchors on the key the app booted with — + * which is empty/stale when .env was only just created above. + * + * @return bool whether a key was generated (false = already present) + */ + public function ensureAppKey(): bool + { + $contents = (string) file_get_contents($this->app->environmentFilePath()); + + if (preg_match('/^APP_KEY=\S+/m', $contents)) { + return false; + } + + $key = 'base64:'.base64_encode(random_bytes(32)); + + $this->writeEnvValues(['APP_KEY' => $key]); + + if (! config('app.key')) { + config(['app.key' => $key]); + } + + return true; + } + + /** + * Set keys in the .env file, replacing existing (or commented-out) + * lines and appending anything not present. + * + * @param array $values + */ + public function writeEnvValues(array $values): void + { + $path = $this->app->environmentFilePath(); + $contents = (string) file_get_contents($path); + + foreach ($values as $key => $value) { + $line = $key.'='.$this->formatEnvValue($value); + $pattern = '/^#?\h*'.preg_quote($key, '/').'=.*$/m'; + + if (preg_match($pattern, $contents)) { + $contents = (string) preg_replace($pattern, $line, $contents, 1); + } else { + $contents = rtrim($contents, "\n")."\n".$line."\n"; + } + } + + file_put_contents($path, $contents); + } + + /** + * The default SQLite database lives at database/database.sqlite; create + * it (and its directory) so the first migrate has somewhere to run. + */ + public function ensureSqliteDatabase(): void + { + $path = $this->sqlitePath(); + + if ($path === null || file_exists($path)) { + return; + } + + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + + touch($path); + } + + public function migrate(): bool + { + return Artisan::call('migrate', ['--force' => true]) === 0; + } + + public function ownerExists(string $email): bool + { + return User::query()->where('email', $email)->exists(); + } + + public function createOwner(string $name, string $email, string $plainPassword): User + { + // The User model's 'hashed' cast hashes the plain password. + return User::query()->create([ + 'name' => $name, + 'email' => $email, + 'password' => $plainPassword, + ]); + } + + /** + * Whether this PHP's SQLite library was compiled with FTS5, which the + * items_fts search index needs. + */ + protected function hasFts5(): bool + { + if (! extension_loaded('pdo_sqlite')) { + return false; + } + + try { + $pdo = new PDO('sqlite::memory:'); + $pdo->exec('CREATE VIRTUAL TABLE fts5_probe USING fts5(body)'); + + return true; + } catch (Throwable) { + return false; + } + } + + /** + * Path of the SQLite database file, or null when the default + * connection is not file-backed SQLite (e.g. :memory: in tests). + */ + protected function sqlitePath(): ?string + { + if (config('database.default') !== 'sqlite') { + return null; + } + + $path = config('database.connections.sqlite.database'); + + return is_string($path) && $path !== ':memory:' ? $path : null; + } + + protected function formatEnvValue(string $value): string + { + if ($value === '' || preg_match('/^[A-Za-z0-9_.\-\/:@]+$/', $value)) { + return $value; + } + + return '"'.str_replace(['\\', '"'], ['\\\\', '\"'], $value).'"'; + } +} diff --git a/app/Services/ItemSaver.php b/app/Services/ItemSaver.php new file mode 100644 index 0000000..22af468 --- /dev/null +++ b/app/Services/ItemSaver.php @@ -0,0 +1,95 @@ +|null $tags + * + * @throws InvalidUrlException + */ + public function save(string $url, ?string $title = null, string|iterable|null $tags = [], ItemSource $source = ItemSource::Web): SaveResult + { + $url = $this->normaliser->clean($url); + $normalised = $this->normaliser->normalise($url); + $title = $this->cleanTitle($title); + $tagNames = Tag::parseList($tags); + + $existing = Item::query()->where('normalized_url', $normalised)->first(); + + if ($existing !== null) { + return $this->resave($existing, $title, $tagNames); + } + + try { + $item = Item::query()->create([ + 'url' => $url, + 'normalized_url' => $normalised, + 'title' => $title, + 'source' => $source, + ]); + } catch (UniqueConstraintViolationException) { + // Saved twice at the same moment (a double-tapped share sheet). + return $this->resave( + Item::query()->where('normalized_url', $normalised)->firstOrFail(), + $title, + $tagNames + ); + } + + $item->attachTagNames($tagNames); + + FetchItemContent::dispatch($item); + + return new SaveResult($item, true); + } + + /** + * @param list $tagNames + */ + protected function resave(Item $item, ?string $title, array $tagNames): SaveResult + { + $item->forceFill([ + 'status' => ItemStatus::Unread, + 'archived_at' => null, + 'added_at' => now(), + ]); + + if ($title !== null && trim((string) $item->title) === '') { + $item->title = $title; + } + + $item->save(); + $item->attachTagNames($tagNames); + + return new SaveResult($item, false); + } + + protected function cleanTitle(?string $title): ?string + { + $title = trim((string) preg_replace('/\s+/u', ' ', (string) $title)); + + return $title === '' ? null : mb_substr($title, 0, 1000); + } +} diff --git a/app/Services/PocketImporter.php b/app/Services/PocketImporter.php new file mode 100644 index 0000000..0bef9e4 --- /dev/null +++ b/app/Services/PocketImporter.php @@ -0,0 +1,191 @@ +importFrom($handle); + } finally { + fclose($handle); + } + } + + /** + * @param resource $handle + */ + protected function importFrom($handle): ImportResult + { + $columns = $this->columns(fgetcsv($handle, null, ',', '"', '') ?: []); + + if (! isset($columns['url'])) { + throw new RuntimeException('That file has no "url" column. Upload the CSV from Pocket\'s export (title, url, time_added, tags, status).'); + } + + $result = new ImportResult(); + $spacing = max(0, (int) settings('import.spacing_seconds')); + $seen = []; + $chunk = []; + + while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) { + if ($row === [null]) { + continue; + } + + $chunk[] = $row; + + if (count($chunk) >= self::CHUNK) { + $this->importChunk($chunk, $columns, $result, $seen, $spacing); + $chunk = []; + } + } + + if ($chunk !== []) { + $this->importChunk($chunk, $columns, $result, $seen, $spacing); + } + + return $result; + } + + /** + * @param list> $rows + * @param array $columns + * @param array $seen + */ + protected function importChunk(array $rows, array $columns, ImportResult $result, array &$seen, int $spacing): void + { + /** @var list $created */ + $created = DB::transaction(function () use ($rows, $columns, $result, &$seen): array { + $created = []; + + foreach ($rows as $row) { + $value = fn (string $column): string => trim((string) ($row[$columns[$column] ?? -1] ?? '')); + + try { + $url = $this->normaliser->clean($value('url')); + $normalised = $this->normaliser->normalise($url); + } catch (InvalidUrlException) { + $result->invalid++; + + continue; + } + + $tags = Tag::parseList(explode('|', $value('tags'))); + + // Listed twice in the same file, or already in Docket: only + // the tags are merged, so a re-run converges on the same state. + $existing = isset($seen[$normalised]) + ? Item::query()->find($seen[$normalised]) + : Item::query()->where('normalized_url', $normalised)->first(); + + if ($existing !== null) { + $existing->attachTagNames($tags); + $seen[$normalised] = $existing->id; + $result->existing++; + + continue; + } + + $added = $this->timestamp($value('time_added')); + $status = ItemStatus::fromPocket($value('status')); + $title = $value('title'); + + $item = Item::query()->create([ + 'url' => $url, + 'normalized_url' => $normalised, + // Pocket uses the URL as the title for links it never + // managed to parse; leave those for the extractor. + 'title' => $title !== '' && $title !== $url ? mb_substr($title, 0, 1000) : null, + 'source' => ItemSource::Pocket, + 'status' => $status, + 'archived_at' => $status === ItemStatus::Archived ? $added : null, + 'added_at' => $added, + ]); + + $item->attachTagNames($tags); + $seen[$normalised] = $item->id; + $created[] = $item; + $result->created++; + } + + return $created; + }); + + // Queued after the commit, so a sync queue never fetches inside + // the transaction. + foreach ($created as $item) { + FetchItemContent::dispatch($item)->delay(now()->addSeconds($result->queued * $spacing)); + $result->queued++; + } + } + + /** + * Map header names to column positions, ignoring case, whitespace and + * a UTF-8 byte-order mark. + * + * @param array $header + * @return array + */ + protected function columns(array $header): array + { + $columns = []; + + foreach ($header as $index => $name) { + $name = strtolower(trim(str_replace("\u{FEFF}", '', (string) $name))); + + if ($name !== '') { + $columns[$name] ??= $index; + } + } + + return $columns; + } + + protected function timestamp(string $value): Carbon + { + if (ctype_digit($value) && (int) $value > 0) { + return Carbon::createFromTimestamp((int) $value); + } + + return now(); + } +} diff --git a/app/Services/SaveResult.php b/app/Services/SaveResult.php new file mode 100644 index 0000000..4431d96 --- /dev/null +++ b/app/Services/SaveResult.php @@ -0,0 +1,20 @@ +created + ? 'Saved. The article is being fetched.' + : 'Already saved; moved it back to the top of your list.'; + } +} diff --git a/app/Services/Settings.php b/app/Services/Settings.php new file mode 100644 index 0000000..2dbc208 --- /dev/null +++ b/app/Services/Settings.php @@ -0,0 +1,182 @@ + trimmed string + * - int -> integer + * - list_int -> array of positive integers (retry backoff seconds) + * - rules: Laravel validation rules for the form input + * + * @return array}> + */ + public static function fields(): array + { + return [ + 'words_per_minute' => [ + 'label' => 'Reading speed (words per minute)', + 'help' => 'Used to estimate reading time. Applies to articles fetched after the change.', + 'type' => 'int', + 'rules' => ['required', 'integer', 'min:50', 'max:2000'], + ], + 'per_page' => [ + 'label' => 'Items per page', + 'help' => 'How many items the lists show before paginating.', + 'type' => 'int', + 'rules' => ['required', 'integer', 'min:5', 'max:200'], + ], + 'fetch.timeout' => [ + 'label' => 'Fetch timeout (seconds)', + 'help' => 'How long to wait for a page to download before giving up.', + 'type' => 'int', + 'rules' => ['required', 'integer', 'min:2', 'max:120'], + ], + 'fetch.max_kb' => [ + 'label' => 'Largest page fetched (KB)', + 'help' => 'Downloads larger than this are abandoned and the item is marked as failed.', + 'type' => 'int', + 'rules' => ['required', 'integer', 'min:64', 'max:51200'], + ], + 'fetch.tries' => [ + 'label' => 'Fetch attempts', + 'help' => 'How many times the queued fetch job tries a page before recording a failure.', + 'type' => 'int', + 'rules' => ['required', 'integer', 'min:1', 'max:10'], + ], + 'fetch.backoff' => [ + 'label' => 'Fetch backoff (seconds)', + 'help' => 'Comma-separated seconds to wait between each attempt.', + 'type' => 'list_int', + 'rules' => ['required', 'string', 'max:255', 'regex:/^\s*\d+(\s*,\s*\d+)*\s*$/'], + ], + 'images.max_per_item' => [ + 'label' => 'Images saved per article', + 'help' => 'Inline images downloaded so articles survive link rot. Set to 0 to keep every image remote.', + 'type' => 'int', + 'rules' => ['required', 'integer', 'min:0', 'max:500'], + ], + 'import.spacing_seconds' => [ + 'label' => 'Import spacing (seconds)', + 'help' => 'Gap between extraction jobs queued by a Pocket import, so a large import fetches gently.', + 'type' => 'int', + 'rules' => ['required', 'integer', 'min:0', 'max:3600'], + ], + ]; + } + + /** + * The HTML form field name for a settings key. PHP converts dots to + * underscores in submitted form data, so a field named after the + * dotted config path ("fetch.timeout") would arrive mangled and the + * dotted validation rules would never see it. The form, rules and + * controller all address the field by this underscore name instead. + */ + public static function formKey(string $key): string + { + return str_replace('.', '_', $key); + } + + /** + * Read one setting: DB override if present, else the config default. + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->all()[$key] ?? $default ?? config("docket.$key"); + } + + /** + * The full merged map of editable settings (DB override over config). + * + * @return array + */ + public function all(): array + { + return Cache::rememberForever( + self::CACHE_KEY, + function () { + $rows = Setting::query()->pluck('value', 'key')->all(); + + $merged = []; + foreach (array_keys(self::fields()) as $key) { + $merged[$key] = $rows[$key] ?? config("docket.$key"); + } + + return $merged; + } + ); + } + + /** + * Persist one override (cast to the field's type) and bust the cache. + */ + public function set(string $key, mixed $value): void + { + Setting::updateOrCreate( + ['key' => $key], + ['value' => $this->cast($key, $value)] + ); + + Cache::forget(self::CACHE_KEY); + } + + /** + * Format a stored value for a form input (arrays become comma strings). + */ + public function forInput(string $key): string + { + $value = $this->all()[$key] ?? null; + + if (is_array($value)) { + return implode(', ', $value); + } + + return (string) $value; + } + + /** + * Cast a raw form input to the storage type declared in self::fields(). + */ + protected function cast(string $key, mixed $value): mixed + { + $type = self::fields()[$key]['type'] ?? 'string'; + + return match ($type) { + 'int' => (int) $value, + 'list_int' => array_values( + array_filter( + array_map( + static fn ($v) => (int) trim((string) $v), + explode(',', (string) $value) + ), + static fn ($v) => $v > 0 + ) + ), + default => trim((string) $value), + }; + } +} diff --git a/app/Services/UrlNormaliser.php b/app/Services/UrlNormaliser.php new file mode 100644 index 0000000..1da9f5f --- /dev/null +++ b/app/Services/UrlNormaliser.php @@ -0,0 +1,172 @@ + self::MAX_LENGTH) { + throw new InvalidUrlException('That URL is too long.'); + } + + $parts = parse_url($url); + + if ($parts === false || empty($parts['host'])) { + throw new InvalidUrlException('That does not look like a valid URL.'); + } + + $scheme = strtolower($parts['scheme'] ?? ''); + + if (! in_array($scheme, ['http', 'https'], true)) { + throw new InvalidUrlException('Only http and https links can be saved.'); + } + + if (preg_match('/[\s<>"\x00-\x1f]/', $parts['host'])) { + throw new InvalidUrlException('That does not look like a valid URL.'); + } + + return $url; + } + + /** + * The de-duplication key for a URL. + * + * @throws InvalidUrlException + */ + public function normalise(string $url): string + { + $parts = (array) parse_url($this->clean($url)); + + $scheme = strtolower((string) $parts['scheme']); + $host = $this->normaliseHost((string) $parts['host']); + + $port = isset($parts['port']) ? (int) $parts['port'] : null; + if (($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) { + $port = null; + } + + $path = (string) ($parts['path'] ?? ''); + $path = $path === '' ? '/' : $this->normalisePercentEncoding($path); + + $query = $this->normaliseQuery((string) ($parts['query'] ?? '')); + + return $scheme.'://'.$host + .($port !== null ? ':'.$port : '') + .$path + .($query !== '' ? '?'.$query : ''); + } + + protected function normaliseHost(string $host): string + { + $host = rtrim(strtolower($host), '.'); + + if (preg_match('/[^\x20-\x7e]/', $host) && function_exists('idn_to_ascii')) { + $ascii = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); + + if (is_string($ascii) && $ascii !== '') { + $host = $ascii; + } + } + + return $host; + } + + protected function normaliseQuery(string $query): string + { + if ($query === '') { + return ''; + } + + $pairs = []; + + foreach (explode('&', $query) as $pair) { + if ($pair === '') { + continue; + } + + [$key] = explode('=', $pair, 2); + + if ($this->isTrackingParameter(strtolower(rawurldecode($key)))) { + continue; + } + + $pairs[] = $this->normalisePercentEncoding($pair); + } + + // Stable sort by the whole pair keeps repeated keys (a=1&a=2) in a + // predictable order without merging them. + sort($pairs, SORT_STRING); + + return implode('&', $pairs); + } + + protected function isTrackingParameter(string $key): bool + { + foreach (self::TRACKING_PARAMETERS as $parameter) { + if (str_ends_with($parameter, '_') ? str_starts_with($key, $parameter) : $key === $parameter) { + return true; + } + } + + return false; + } + + /** + * Uppercase percent-escapes ("%2f" and "%2F" are the same byte). + */ + protected function normalisePercentEncoding(string $value): string + { + return (string) preg_replace_callback( + '/%[0-9a-f]{2}/i', + static fn (array $match): string => strtoupper($match[0]), + $value + ); + } +} diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php new file mode 100644 index 0000000..de0d46f --- /dev/null +++ b/app/View/Components/AppLayout.php @@ -0,0 +1,17 @@ + Settings instance + * settings('fetch.timeout') -> DB override or config('docket.fetch.timeout') + * settings('foo', 20) -> override, else 20 + */ +function settings(?string $key = null, mixed $default = null): mixed +{ + $service = app(Settings::class); + + if ($key === null) { + return $service; + } + + return $service->get($key, $default); +} diff --git a/artisan b/artisan new file mode 100755 index 0000000..c35e31d --- /dev/null +++ b/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..3b46fac --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,35 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + then: function (): void { + // The web installer runs before the app is usable — the + // database (and with it the session store) may not exist yet — + // so its routes deliberately skip the 'web' middleware group. + Route::middleware([EnsureNotInstalled::class, SecurityHeaders::class]) + ->group(base_path('routes/install.php')); + }, + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->web(append: SecurityHeaders::class); + $middleware->api(append: SecurityHeaders::class); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // The API always answers in JSON; elsewhere, the reader's fetch() + // calls ask for it with an Accept header. + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*') || $request->expectsJson(), + ); + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.401.2", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "b1af9196bd0f94bcebdb6588a150dd85dc240758" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b1af9196bd0f94bcebdb6588a150dd85dc240758", + "reference": "b1af9196bd0f94bcebdb6588a150dd85dc240758", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "mtdowling/jmespath.php": "^2.9.1", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.401.2" + }, + "time": "2026-10-14T18:09:34+00:00" + }, + { + "name": "brick/math", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-09-12T10:28:18+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+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": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2026-10-01T06:59:29+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fivefilters/readability.php", + "version": "v3.3.3", + "source": { + "type": "git", + "url": "https://github.com/fivefilters/readability.php.git", + "reference": "e2ee7b9e49eae89ac7ed2c74b15718100a73b4c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fivefilters/readability.php/zipball/e2ee7b9e49eae89ac7ed2c74b15718100a73b4c8", + "reference": "e2ee7b9e49eae89ac7ed2c74b15718100a73b4c8", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "league/uri": "^7.0", + "masterminds/html5": "^2.0", + "php": ">=8.1", + "psr/log": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "monolog/monolog": "^3.0", + "phpunit/phpunit": "^10.0 || ^11.0" + }, + "suggest": { + "monolog/monolog": "Allow logging debug information" + }, + "type": "library", + "autoload": { + "psr-4": { + "fivefilters\\Readability\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andres Rey", + "email": "andreskrey@gmail.com", + "role": "Original Developer" + }, + { + "name": "Keyvan Minoukadeh", + "email": "keyvan@fivefilters.org", + "homepage": "https://www.fivefilters.org", + "role": "Developer/Maintainer" + } + ], + "description": "A PHP port of Readability.js", + "homepage": "https://github.com/fivefilters/readability.php", + "keywords": [ + "html", + "readability" + ], + "support": { + "issues": "https://github.com/fivefilters/readability.php/issues", + "source": "https://github.com/fivefilters/readability.php/tree/v3.3.3" + }, + "time": "2025-04-26T23:45:37+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b920e5fcaf81d761b8fabe600ca90999beaefdb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b920e5fcaf81d761b8fabe600ca90999beaefdb7", + "reference": "b920e5fcaf81d761b8fabe600ca90999beaefdb7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^3.1", + "guzzlehttp/psr7": "^3.2", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.1", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-10-18T13:38:43+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "094d0ec418eb3b1411ea271a180ec95cae6ae933" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/094d0ec418eb3b1411ea271a180ec95cae6ae933", + "reference": "094d0ec418eb3b1411ea271a180ec95cae6ae933", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-10-18T12:48:16+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "cdc139396207a5eba10c242d241fb014cb098390" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/cdc139396207a5eba10c242d241fb014cb098390", + "reference": "cdc139396207a5eba10c242d241fb014cb098390", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-10-17T23:57:19+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7a466ad606491eb6528c717482f7cca77f1851f3", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34", + "uri-template/tests": "1.0.2" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-08-24T17:13:02+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.37.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "685f8d6c2ab4547287500bc80ccdf2ca12635109" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/685f8d6c2ab4547287500bc80ccdf2ca12635109", + "reference": "685f8d6c2ab4547287500bc80ccdf2ca12635109", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", + "laravel/prompts": "^0.3.25", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.10", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/image": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "intervention/image": "^4.2", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.6.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "2.3.1", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "2.6.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/mercure": "^0.8.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0", + "web-token/jwt-library": "^4.1" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.2).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).", + "web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-10-16T04:59:27+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.26", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "source": "https://github.com/laravel/prompts/tree/v0.3.26" + }, + "time": "2026-10-09T14:45:13+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-09-22T14:32:34+00:00" + }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, + { + "name": "league/commonmark", + "version": "2.10.4", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "40821b7606d8d946bac57dbd2290076c722e3a65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/40821b7606d8d946bac57dbd2290076c722e3a65", + "reference": "40821b7606d8d946bac57dbd2290076c722e3a65", + "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-10-17T19:59:50+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": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "b03780cb97585ee7e48977f40ed599b33b751634" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/b03780cb97585ee7e48977f40ed599b33b751634", + "reference": "b03780cb97585ee7e48977f40ed599b33b751634", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.371.5", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.3" + }, + "time": "2026-08-08T16:19:23+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "fcc872a3d872aa13f935e0cd1887b9abb06da07f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/fcc872a3d872aa13f935e0cd1887b9abb06da07f", + "reference": "fcc872a3d872aa13f935e0cd1887b9abb06da07f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/uri-interfaces": "^7.9.0", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.9.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-16T15:19:33+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58", + "reference": "eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.9.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-16T15:19:33+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.11.0" + }, + "time": "2026-08-18T06:18:41+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.12.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.12.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-29T19:21:12+00:00" + }, + { + "name": "mtdowling/jmespath.php", + "version": "2.9.2", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.52" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.9-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" + }, + "time": "2026-07-06T18:56:19+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.15.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "217d38553209bdb44e01590d9e9a2e4df8354cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/217d38553209bdb44e01590d9e9a2e4df8354cce", + "reference": "217d38553209bdb44e01590d9e9a2e4df8354cce", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-time": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^3.0@dev", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-10-18T18:27:53+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": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "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.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+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": "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/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+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": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.4" + }, + "time": "2026-09-16T11:39:30+00:00" + }, + { + "name": "spatie/db-dumper", + "version": "4.1.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/db-dumper.git", + "reference": "f8f2785574de84aa4642a4df8b59a6dd578dd5d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/db-dumper/zipball/f8f2785574de84aa4642a4df8b59a6dd578dd5d3", + "reference": "f8f2785574de84aa4642a4df8b59a6dd578dd5d3", + "shasum": "" + }, + "require": { + "php": "^8.3", + "symfony/process": "^7.0|^8.0" + }, + "require-dev": { + "pestphp/pest": "^4.0", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\DbDumper\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Dump databases", + "homepage": "https://github.com/spatie/db-dumper", + "keywords": [ + "database", + "db-dumper", + "dump", + "mysqldump", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/db-dumper/tree/4.1.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-04T11:43:33+00:00" + }, + { + "name": "spatie/laravel-backup", + "version": "10.3.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-backup.git", + "reference": "1ab5c2211481d5f962d118d0d35c40b9abf5b3f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-backup/zipball/1ab5c2211481d5f962d118d0d35c40b9abf5b3f0", + "reference": "1ab5c2211481d5f962d118d0d35c40b9abf5b3f0", + "shasum": "" + }, + "require": { + "ext-zip": "^1.14.0", + "illuminate/console": "^12.40|^13.0", + "illuminate/contracts": "^12.40|^13.0", + "illuminate/events": "^12.40|^13.0", + "illuminate/filesystem": "^12.40|^13.0", + "illuminate/notifications": "^12.40|^13.0", + "illuminate/support": "^12.40|^13.0", + "league/flysystem": "^3.30.2", + "php": "^8.3", + "spatie/db-dumper": "^4.0", + "spatie/laravel-package-tools": "^1.92.7", + "spatie/laravel-signal-aware-command": "^2.1", + "spatie/temporary-directory": "^2.3", + "symfony/console": "^7.3.6|^8.0", + "symfony/finder": "^7.3.5|^8.0" + }, + "require-dev": { + "composer-runtime-api": "^2.0", + "ext-pcntl": "*", + "larastan/larastan": "^3.8", + "laravel/slack-notification-channel": "^3.7", + "league/flysystem-aws-s3-v3": "^3.30.1", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^10.8|^11.0", + "pestphp/pest": "^4.1.5", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.8", + "rector/rector": "^2.2.8" + }, + "suggest": { + "laravel/slack-notification-channel": "Required for sending notifications via Slack" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Backup\\BackupServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Helpers/functions.php" + ], + "psr-4": { + "Spatie\\Backup\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A Laravel package to backup your application", + "homepage": "https://github.com/spatie/laravel-backup", + "keywords": [ + "backup", + "database", + "laravel-backup", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-backup/issues", + "source": "https://github.com/spatie/laravel-backup/tree/10.3.3" + }, + "funding": [ + { + "url": "https://github.com/sponsors/spatie", + "type": "github" + }, + { + "url": "https://spatie.be/open-source/support-us", + "type": "other" + } + ], + "time": "2026-09-14T12:01:22+00:00" + }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/7aeef26bd8d4909bec744dbf47aee7d1395588c1", + "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.3" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-09-28T07:16:50+00:00" + }, + { + "name": "spatie/laravel-signal-aware-command", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-signal-aware-command.git", + "reference": "54dcc1efd152bfb3eb0faf56a5fc28569b864b5d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-signal-aware-command/zipball/54dcc1efd152bfb3eb0faf56a5fc28569b864b5d", + "reference": "54dcc1efd152bfb3eb0faf56a5fc28569b864b5d", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0|^12.0|^13.0", + "php": "^8.2", + "spatie/laravel-package-tools": "^1.4.3", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "brianium/paratest": "^6.2|^7.0", + "ext-pcntl": "*", + "nunomaduro/collision": "^5.3|^6.0|^7.0|^8.0", + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest-plugin-laravel": "^1.3|^2.0|^3.0", + "phpunit/phpunit": "^9.5|^10|^11", + "spatie/laravel-ray": "^1.17" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Signal": "Spatie\\SignalAwareCommand\\Facades\\Signal" + }, + "providers": [ + "Spatie\\SignalAwareCommand\\SignalAwareCommandServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\SignalAwareCommand\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Handle signals in artisan commands", + "homepage": "https://github.com/spatie/laravel-signal-aware-command", + "keywords": [ + "laravel", + "laravel-signal-aware-command", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-signal-aware-command/issues", + "source": "https://github.com/spatie/laravel-signal-aware-command/tree/2.1.2" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-22T08:16:31+00:00" + }, + { + "name": "spatie/temporary-directory", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/32cbb9645b28839cf4f476708e99a2c70e6802c9", + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\TemporaryDirectory\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", + "keywords": [ + "php", + "spatie", + "temporary-directory" + ], + "support": { + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.4.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-06-22T07:55:44+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "07437f612dca89c1894c1db04491002b1e798f03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/07437f612dca89c1894c1db04491002b1e798f03", + "reference": "07437f612dca89c1894c1db04491002b1e798f03", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "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": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.21" + }, + "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-10-12T14:31:36+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/21f1bc2296f7ff6043241a8dcbc09559b3732820", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.21" + }, + "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-10-12T18:45:38+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" + }, + "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-08-23T10:03:40+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/error-handler", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.20" + }, + "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-09-22T18:56:40+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "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-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "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": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-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/filesystem", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.21" + }, + "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-10-06T20:05:19+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.20" + }, + "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-09-26T06:31:10+00:00" + }, + { + "name": "symfony/html-sanitizer", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "0b7605debddc90c7c9d4e9c1b6eb520a248f26dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/0b7605debddc90c7c9d4e9c1b6eb520a248f26dc", + "reference": "0b7605debddc90c7c9d4e9c1b6eb520a248f26dc", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "masterminds/html5": "^2.7.2", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], + "support": { + "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.20" + }, + "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-09-28T05:21:23+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.21" + }, + "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-10-08T16:56:12+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "4779fce7dedacb8801b2b61c703ef99159502331" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/4779fce7dedacb8801b2b61c703ef99159502331", + "reference": "4779fce7dedacb8801b2b61c703ef99159502331", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.21" + }, + "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-10-14T06:44:08+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.19" + }, + "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-09-15T06:01:15+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.19" + }, + "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-09-04T10:45:44+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/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" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.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-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.43.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-09-23T14:40:04+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.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-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.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-09-24T15:19:04+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "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.43.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-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "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\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.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-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "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\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.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-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "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\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.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-09-23T22:01:09+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.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-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/8840df45b24f33a49dc419fa18dc0057231ae1d8", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8", + "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\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.43.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-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-time", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-time.git", + "reference": "ac9f1ed802dd58f37e720388130c088434aa09fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-time/zipball/ac9f1ed802dd58f37e720388130c088434aa09fc", + "reference": "ac9f1ed802dd58f37e720388130c088434aa09fc", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Time\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Philippe Nyamagana Butera", + "email": "nyamsprod@gmail.com" + }, + { + "name": "Tim Düsterhus", + "email": "tim@tideways-gmbh.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Time API added to PHP 8.6", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "duration", + "polyfill", + "portable", + "shim", + "time" + ], + "support": { + "source": "https://github.com/symfony/polyfill-time/tree/v1.42.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-08-07T13:08:13+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.43.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-09-24T05:20:52+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.21" + }, + "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-10-08T16:56:12+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/7a344cf871532d081875bfdd18f3dff8dca36c5b", + "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.21" + }, + "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-10-11T18:40:59+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "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": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "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": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.21" + }, + "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-10-03T15:15:30+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.17" + }, + "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-08-21T17:40:08+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "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": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "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": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-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/uid", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/3e706a245b5343ea93be9cafe84525bfb54e60fc", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.21" + }, + "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-10-12T14:31:36+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/55d06b02f49eca9bff61828548988e6f8763e8a6", + "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "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": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.21" + }, + "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-10-13T05:48:33+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/phpdotenv/phpdotenv.git", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.2", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-08-24T18:07:49+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "composer/semver", + "version": "3.5.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.5.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-10-05T13:12:12+00:00" + }, + { + "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": "filp/whoops", + "version": "2.18.7", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "50670ba239926c50f2073e868f6672f775cd57ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/50670ba239926c50f2073e868f6672f775cd57ec", + "reference": "50670ba239926c50f2073e868f6672f775cd57ec", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58", + "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.7" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2026-10-18T13:17:38+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.13.0", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "24ee69a7705ffc7c2cd8a3c96a5f498a4c2de5e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/24ee69a7705ffc7c2cd8a3c96a5f498a4c2de5e3", + "reference": "24ee69a7705ffc7c2cd8a3c96a5f498a4c2de5e3", + "shasum": "" + }, + "require": { + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", + "php": "^8.2", + "phpstan/phpstan": "^2.3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.3.0" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.13.0" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-10-07T14:14:28+00:00" + }, + { + "name": "laravel/agent-detector", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "2e4f1e00f75afa82ce2ade999c6e72b3c81d03c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/2e4f1e00f75afa82ce2ade999c6e72b3c81d03c5", + "reference": "2e4f1e00f75afa82ce2ade999c6e72b3c81d03c5", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-10-05T14:40:01+00:00" + }, + { + "name": "laravel/boost", + "version": "v2.10.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "75c269a8a53e931c7252e7cd562e1a209356a7c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/75c269a8a53e931c7252e7cd562e1a209356a7c6", + "reference": "75c269a8a53e931c7252e7cd562e1a209356a7c6", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9|^8.0", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0|^1.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^1.0.0", + "php": "^8.2" + }, + "require-dev": { + "jasonmccreary/double": "^1.0", + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2026-10-07T19:07:19+00:00" + }, + { + "name": "laravel/breeze", + "version": "v2.4.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/breeze.git", + "reference": "4f20e7b2cc8d25daa85d8647241a89c8e0930305" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/breeze/zipball/4f20e7b2cc8d25daa85d8647241a89c8e0930305", + "reference": "4f20e7b2cc8d25daa85d8647241a89c8e0930305", + "shasum": "" + }, + "require": { + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/filesystem": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "illuminate/validation": "^11.0|^12.0|^13.0", + "php": "^8.2.0", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^11.0|^12.0|^13.0", + "orchestra/testbench-core": "^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Breeze\\BreezeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Breeze\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/breeze/issues", + "source": "https://github.com/laravel/breeze" + }, + "time": "2026-05-14T16:54:25+00:00" + }, + { + "name": "laravel/mcp", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "26c7803551028653beab49af0e672ae38607b772" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/26c7803551028653beab49af0e672ae38607b772", + "reference": "26c7803551028653beab49af0e672ae38607b772", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "jasonmccreary/double": "^1.0", + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-10-11T20:52:46+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "620424f1ce744d96937fb6be60a94793e79b67ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/620424f1ce744d96937fb6be60a94793e79b67ad", + "reference": "620424f1ce744d96937fb6be60a94793e79b67ad", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.2", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.30.5", + "orchestra/testbench": "^10.11.0 || ^11.2.0", + "pestphp/pest": "^4.7.8 || ^5.1.4", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.2", + "phpstan/phpstan": "^2.2.13", + "rector/rector": "^2.6.6", + "symfony/process": "^7.4.13 || ^8.1.6", + "symfony/var-dumper": "^7.4.15 || ^8.1.6" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "rector", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-09-07T14:48:06+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.32.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "006bd7825eb01daf92959d060b89cd9514fe4253" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/006bd7825eb01daf92959d060b89cd9514fe4253", + "reference": "006bd7825eb01daf92959d060b89cd9514fe4253", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.3.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.25", + "illuminate/view": "^13.31.0", + "larastan/larastan": "^3.11.0", + "laravel-zero/framework": "^13.0.0", + "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.24", + "mockery/mockery": "^1.6.15", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^4.7.8" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-09-10T10:47:55+00:00" + }, + { + "name": "laravel/roster", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "shasum": "" + }, + "require": { + "composer/semver": "^3.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" + }, + "require-dev": { + "laravel/pint": "^1.29", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2026-07-18T17:53:15+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+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": "nunomaduro/collision", + "version": "v8.9.5", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-07-15T19:09:14+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": "phpstan/phpstan", + "version": "2.3.1", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-10-08T21:09:11+00:00" + }, + { + "name": "phpstan/phpstan-mockery", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-mockery.git", + "reference": "89a949d0ac64298e88b7c7fa00caee565c198394" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-mockery/zipball/89a949d0ac64298e88b7c7fa00caee565c198394", + "reference": "89a949d0ac64298e88b7c7fa00caee565c198394", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.0" + }, + "require-dev": { + "mockery/mockery": "^1.6.11", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan Mockery extension", + "support": { + "issues": "https://github.com/phpstan/phpstan-mockery/issues", + "source": "https://github.com/phpstan/phpstan-mockery/tree/2.0.0" + }, + "time": "2024-10-14T03:18:12+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7440368d94738d54f2c1de24dbd3871793bbb0dd", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.9.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0.1", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "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": "12.5.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/12.5.8" + }, + "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/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-10-03T14:03:50+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" + }, + "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": "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/6.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/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "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": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "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", + "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/5.0.1" + }, + "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/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-10-03T04:51:29+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.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", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.40", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "76bd3198f16aeb375a4d5f3626471445aed0966e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/76bd3198f16aeb375a4d5f3626471445aed0966e", + "reference": "76bd3198f16aeb375a4d5f3626471445aed0966e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.8", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.1", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.1", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.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/12.5.40" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-16T05:55:07+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.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 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/4.2.1" + }, + "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/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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": "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/7.1.8" + }, + "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-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.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", + "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/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-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/7.0.1" + }, + "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/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.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/8.1.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/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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": "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/7.0.3" + }, + "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": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.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/8.0.3" + }, + "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/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "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 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/4.0.1" + }, + "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/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.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": "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", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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/7.0.1" + }, + "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": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "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": "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", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.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/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/9e935a16187e14f39b0e79910dabd614972cdb26", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "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": "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", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.1" + }, + "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/version", + "type": "tidelift" + } + ], + "time": "2026-10-04T05:50:08+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "044907929c7999814378be22da34722c7a86bad4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/044907929c7999814378be22da34722c7a86bad4", + "reference": "044907929c7999814378be22da34722c7a86bad4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.21" + }, + "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-10-12T12:34:30+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "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/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.3", + "ext-dom": "*", + "ext-mbstring": "*" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3.6" + }, + "plugin-api-version": "2.6.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..423eed5 --- /dev/null +++ b/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/backup.php b/config/backup.php new file mode 100644 index 0000000..025ee79 --- /dev/null +++ b/config/backup.php @@ -0,0 +1,387 @@ + [ + /* + * The name of this application. You can use this name to monitor + * the backups. + */ + 'name' => env('APP_NAME', 'laravel-backup'), + + 'source' => [ + 'files' => [ + /* + * The list of directories and files that will be included in the backup. + */ + // Code lives in git; the irreplaceable data is the DB dump + // (added automatically) and the article images saved under + // storage/app/private. + 'include' => [ + storage_path('app/private'), + ], + + /* + * These directories and files will be excluded from the backup. + * + * Directories used by the backup process will automatically be excluded. + */ + 'exclude' => [], + + /* + * Determines if symlinks should be followed. + */ + 'follow_links' => false, + + /* + * Determines if it should avoid unreadable folders. + */ + 'ignore_unreadable_directories' => false, + + /* + * This path is used to make directories in resulting zip-file relative + * Set to `null` to include complete absolute path + * Example: base_path() + */ + 'relative_path' => null, + ], + + /* + * The names of the connections to the databases that should be backed up + * MySQL, PostgreSQL, SQLite and Mongo databases are supported. + * + * The content of the database dump may be customized for each connection + * by adding a 'dump' key to the connection settings in config/database.php. + * E.g. + * 'mysql' => [ + * ... + * 'dump' => [ + * 'exclude_tables' => [ + * 'table_to_exclude_from_backup', + * 'another_table_to_exclude' + * ] + * ], + * ], + * + * If you are using only InnoDB tables on a MySQL server, you can + * also supply the useSingleTransaction option to avoid table locking. + * + * E.g. + * 'mysql' => [ + * ... + * 'dump' => [ + * 'useSingleTransaction' => true, + * ], + * ], + * + * For a complete list of available customization options, see https://github.com/spatie/db-dumper + */ + 'databases' => [ + env('DB_CONNECTION', 'mysql'), + ], + ], + + /* + * The database dump can be compressed to decrease disk space usage. + * + * Out of the box Laravel-backup supplies + * Spatie\DbDumper\Compressors\GzipCompressor::class. + * + * You can also create custom compressor. More info on that here: + * https://github.com/spatie/db-dumper#using-compression + * + * If you do not want any compressor at all, set it to null. + */ + 'database_dump_compressor' => null, + + /* + * If specified, the database dumped file name will contain a timestamp (e.g.: 'Y-m-d-H-i-s'). + */ + 'database_dump_file_timestamp_format' => null, + + /* + * The base of the dump filename, either 'database' or 'connection' + * + * If 'database' (default), the dumped filename will contain the database name. + * If 'connection', the dumped filename will contain the connection name. + */ + 'database_dump_filename_base' => 'database', + + /* + * The file extension used for the database dump files. + * + * If not specified, the file extension will be .archive for MongoDB and .sql for all other databases + * The file extension should be specified without a leading . + */ + 'database_dump_file_extension' => '', + + 'destination' => [ + /* + * The compression algorithm to be used for creating the zip archive. + * + * If backing up only database, you may choose gzip compression for db dump and no compression at zip. + * + * Some common algorithms are listed below: + * ZipArchive::CM_STORE (no compression at all; set 0 as compression level) + * ZipArchive::CM_DEFAULT + * ZipArchive::CM_DEFLATE + * ZipArchive::CM_BZIP2 + * ZipArchive::CM_XZ + * + * For more check https://www.php.net/manual/zip.constants.php and confirm it's supported by your system. + */ + 'compression_method' => ZipArchive::CM_DEFAULT, + + /* + * The compression level corresponding to the used algorithm; an integer between 0 and 9. + * + * Check supported levels for the chosen algorithm, usually 1 means the fastest and weakest compression, + * while 9 the slowest and strongest one. + * + * Setting of 0 for some algorithms may switch to the strongest compression. + */ + 'compression_level' => 9, + + /* + * The filename prefix used for the backup zip file. + */ + 'filename_prefix' => '', + + /* + * The disk names on which the backups will be stored. + */ + // An S3-compatible bucket on a different provider from the + // server (Backblaze B2 / Cloudflare R2). See the 'offsite' disk + // in config/filesystems.php. + 'disks' => [ + 'offsite', + ], + + /* + * Determines whether to allow backups to continue when some targets fail instead of failing completely. + */ + 'continue_on_failure' => false, + ], + + /* + * The directory where the temporary files will be stored. + */ + 'temporary_directory' => storage_path('app/backup-temp'), + + /* + * The password to be used for archive encryption. + * Set to `null` to disable encryption. + */ + 'password' => env('BACKUP_ARCHIVE_PASSWORD'), + + /* + * The encryption algorithm to be used for archive encryption. + * Set to 'none' to disable encryption. + * + * Supported: 'none', 'default', 'aes128', 'aes192', 'aes256' + * + * When set to 'default', we'll use AES-256 if available on your system. + */ + 'encryption' => 'default', + + /* + * After creating the zip, verify it can be opened and contains files. + * Recommended for critical backups but adds a small overhead. + */ + 'verify_backup' => true, + + /* + * The number of attempts, in case the backup command encounters an exception + */ + 'tries' => 1, + + /* + * The number of seconds to wait before attempting a new backup if the previous try failed + * Set to `0` for none + */ + 'retry_delay' => 0, + ], + + /* + * You can get notified when specific events occur. Out of the box you can use 'mail' and 'slack'. + * For Slack you need to install laravel/slack-notification-channel. + * + * You can also use your own notification classes, just make sure the class is named after one of + * the `Spatie\Backup\Notifications\Notifications` classes. + */ + 'notifications' => [ + 'notifications' => [ + // Email only when something needs attention; silence on success. + BackupHasFailedNotification::class => ['mail'], + UnhealthyBackupWasFoundNotification::class => ['mail'], + CleanupHasFailedNotification::class => ['mail'], + BackupWasSuccessfulNotification::class => [], + HealthyBackupWasFoundNotification::class => [], + CleanupWasSuccessfulNotification::class => [], + ], + + /* + * Here you can specify the notifiable to which the notifications should be sent. The default + * notifiable will use the variables specified in this config file. + */ + 'notifiable' => Notifiable::class, + + 'mail' => [ + // Falls back to the owner account's email when no dedicated + // backup-notification address is configured. + 'to' => env('BACKUP_NOTIFY_EMAIL') ?: (env('DOCKET_USER_EMAIL') ?: 'owner@example.com'), + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + ], + + 'slack' => [ + 'webhook_url' => '', + + /* + * If this is set to null the default channel of the webhook will be used. + */ + 'channel' => null, + + 'username' => null, + + 'icon' => null, + ], + + 'discord' => [ + 'webhook_url' => '', + + /* + * If this is an empty string, the name field on the webhook will be used. + */ + 'username' => '', + + /* + * If this is an empty string, the avatar on the webhook will be used. + */ + 'avatar_url' => '', + ], + + /* + * A generic webhook channel that POSTs JSON to a URL. + * Useful for Mattermost, Microsoft Teams, or custom integrations. + */ + 'webhook' => [ + 'url' => '', + ], + ], + + /* + * The log channel used for backup activity messages. + * + * Set to a channel name defined in config/logging.php to use that channel. + * Set to false to disable backup logging entirely. + * Set to null to use the default log channel. + */ + 'log_channel' => null, + + /* + * Here you can specify which backups should be monitored. + * If a backup does not meet the specified requirements the + * UnHealthyBackupWasFound event will be fired. + */ + 'monitor_backups' => [ + [ + 'name' => env('APP_NAME', 'laravel-backup'), + 'disks' => ['offsite'], + 'health_checks' => [ + MaximumAgeInDays::class => 1, + MaximumStorageInMegabytes::class => 5000, + ], + ], + + /* + [ + 'name' => 'name of the second app', + 'disks' => ['local', 's3'], + 'health_checks' => [ + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumAgeInDays::class => 1, + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumStorageInMegabytes::class => 5000, + ], + ], + */ + ], + + 'cleanup' => [ + /* + * The strategy that will be used to cleanup old backups. The default strategy + * will keep all backups for a certain amount of days. After that period only + * a daily backup will be kept. After that period only weekly backups will + * be kept and so on. + * + * No matter how you configure it the default strategy will never + * delete the newest backup. + */ + 'strategy' => DefaultStrategy::class, + + 'default_strategy' => [ + /* + * The number of days for which backups must be kept. + */ + 'keep_all_backups_for_days' => 7, + + /* + * After the "keep_all_backups_for_days" period is over, the most recent backup + * of that day will be kept. Older backups within the same day will be removed. + * If you create backups only once a day, no backups will be removed yet. + */ + 'keep_daily_backups_for_days' => 14, + + /* + * After the "keep_daily_backups_for_days" period is over, the most recent backup + * of that week will be kept. Older backups within the same week will be removed. + * If you create backups only once a week, no backups will be removed yet. + */ + 'keep_weekly_backups_for_weeks' => 8, + + /* + * After the "keep_weekly_backups_for_weeks" period is over, the most recent backup + * of that month will be kept. Older backups within the same month will be removed. + */ + 'keep_monthly_backups_for_months' => 24, + + /* + * After the "keep_monthly_backups_for_months" period is over, the most recent backup + * of that year will be kept. Older backups within the same year will be removed. + */ + 'keep_yearly_backups_for_years' => 7, + + /* + * After cleaning up the backups remove the oldest backup until + * this amount of megabytes has been reached. + * Set null for unlimited size. + */ + 'delete_oldest_backups_when_using_more_megabytes_than' => 5000, + ], + + /* + * The number of attempts, in case the cleanup command encounters an exception + */ + 'tries' => 1, + + /* + * The number of seconds to wait before attempting a new cleanup if the previous try failed + * Set to `0` for none + */ + 'retry_delay' => 0, + ], + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..d7eec61 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,136 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "storage", "octane", + | "session", "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'storage' => [ + 'driver' => 'storage', + 'disk' => env('CACHE_STORAGE_DISK'), + 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + + /* + |-------------------------------------------------------------------------- + | Serializable Classes + |-------------------------------------------------------------------------- + | + | This value determines the classes that can be unserialized from cache + | storage. By default, no PHP classes will be unserialized from your + | cache to prevent gadget chain attacks if your APP_KEY is leaked. + | + */ + + 'serializable_classes' => false, + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..abbb88e --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/docket.php b/config/docket.php new file mode 100644 index 0000000..b67ffae --- /dev/null +++ b/config/docket.php @@ -0,0 +1,89 @@ + [ + // ?: not the env() default param: a present-but-empty line in .env + // (e.g. DOCKET_USER_NAME=) would otherwise seed an empty string. + 'name' => env('DOCKET_USER_NAME') ?: 'Docket Owner', + 'email' => env('DOCKET_USER_EMAIL') ?: 'owner@example.com', + 'password' => env('DOCKET_USER_PASSWORD') ?: 'change-me-on-first-login', + ], + + /* + | Fetching saved pages. Every request goes through App\Services\SafeHttpClient, + | which refuses private, loopback and link-local addresses after DNS + | resolution, follows redirects itself (re-checking each hop) and stops + | reading once a body passes the size cap. + */ + 'fetch' => [ + 'user_agent' => env( + 'DOCKET_USER_AGENT', + 'Mozilla/5.0 (compatible; Docket/1.0; +https://github.com/WebberZone/TheDocket)' + ), + // Seconds. + 'timeout' => 20, + 'connect_timeout' => 10, + 'max_redirects' => 5, + // Largest HTML page read, in kilobytes (5 MB). + 'max_kb' => 5120, + // Queued job retries; backoff is the seconds to wait between tries. + 'tries' => 3, + 'backoff' => [30, 300], + ], + + /* + | Inline images are downloaded to private storage so saved articles + | survive link rot. SVG is never stored locally: it can carry scripts and + | the copies are served from this origin. + */ + 'images' => [ + 'enabled' => true, + // Per image, in kilobytes (5 MB). + 'max_kb' => 5120, + // Images per article; the rest keep their remote src. + 'max_per_item' => 40, + ], + + /* + | Reading time is word count divided by this, rounded up. + */ + 'words_per_minute' => 230, + + /* + | Items per page on the lists. + */ + 'per_page' => 30, + + /* + | Pocket import: extraction jobs are spaced this many seconds apart so a + | few thousand saved links don't hammer the sites (or the queue worker) + | all at once. + */ + 'import' => [ + 'spacing_seconds' => 10, + // Upload cap for the CSV, in kilobytes (20 MB). + 'max_kb' => 20480, + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..092456a --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,94 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + // Daily backup destination. Keep it with a different provider from + // the server: Backblaze B2 or Cloudflare R2 are both S3-compatible, + // only the endpoint differs. Set BACKUP_* in .env. + 'offsite' => [ + 'driver' => 's3', + 'key' => env('BACKUP_ACCESS_KEY_ID'), + 'secret' => env('BACKUP_SECRET_ACCESS_KEY'), + 'region' => env('BACKUP_REGION', 'auto'), + 'bucket' => env('BACKUP_BUCKET'), + 'endpoint' => env('BACKUP_ENDPOINT'), + 'use_path_style_endpoint' => true, + 'throw' => true, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..6a90eb8 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..e176ff5 --- /dev/null +++ b/config/session.php @@ -0,0 +1,236 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + // Production deployments are HTTPS-only, so the session cookie there + // defaults to Secure (never sent over plain HTTP) without needing + // SESSION_SECURE_COOKIE in .env; local dev stays unaffected. + 'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV') === 'production'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + + /* + |-------------------------------------------------------------------------- + | Session Serialization + |-------------------------------------------------------------------------- + | + | This value controls the serialization strategy for session data, which + | is JSON by default. Setting this to "php" allows the storage of PHP + | objects in the session but can make an application vulnerable to + | "gadget chain" serialization attacks if the APP_KEY is leaked. + | + | Supported: "json", "php" + | + */ + + 'serialization' => 'json', + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/ItemFactory.php b/database/factories/ItemFactory.php new file mode 100644 index 0000000..fd2fb20 --- /dev/null +++ b/database/factories/ItemFactory.php @@ -0,0 +1,76 @@ + + */ +class ItemFactory extends Factory +{ + public function definition(): array + { + $url = 'https://'.fake()->unique()->domainName().'/'.fake()->slug(); + + return [ + 'url' => $url, + 'normalized_url' => app(UrlNormaliser::class)->normalise($url), + 'title' => fake()->sentence(6), + 'status' => ItemStatus::Unread, + 'fetch_status' => FetchStatus::Pending, + 'added_at' => fake()->dateTimeBetween('-1 year'), + ]; + } + + /** + * An item whose article has been fetched and extracted. + */ + public function fetched(?string $text = null): static + { + return $this->state(function () use ($text): array { + $text ??= fake()->paragraphs(4, true); + $words = str_word_count($text); + + return [ + 'site_name' => fake()->company(), + 'excerpt' => mb_substr($text, 0, 160), + 'content_html' => '

'.e($text).'

', + 'content_text' => $text, + 'word_count' => $words, + 'reading_time' => max(1, (int) ceil($words / 230)), + 'fetch_status' => FetchStatus::Fetched, + 'fetched_at' => now(), + ]; + }); + } + + public function archived(): static + { + return $this->state(fn (): array => [ + 'status' => ItemStatus::Archived, + 'archived_at' => now(), + ]); + } + + public function favourite(): static + { + return $this->state(fn (): array => [ + 'is_favourite' => true, + 'favourited_at' => now(), + ]); + } + + public function failed(string $error = 'Connection timed out'): static + { + return $this->state(fn (): array => [ + 'fetch_status' => FetchStatus::Failed, + 'fetch_error' => $error, + 'fetch_attempts' => 1, + ]); + } +} diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php new file mode 100644 index 0000000..5be18f0 --- /dev/null +++ b/database/factories/TagFactory.php @@ -0,0 +1,22 @@ + + */ +class TagFactory extends Factory +{ + public function definition(): array + { + $name = fake()->unique()->word(); + + return [ + 'name' => $name, + 'slug' => Tag::slugFor($name), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..da1a044 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..865b8e8 --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..f4ba4b8 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_07_04_000001_create_settings_table.php b/database/migrations/2026_07_04_000001_create_settings_table.php new file mode 100644 index 0000000..a9816f3 --- /dev/null +++ b/database/migrations/2026_07_04_000001_create_settings_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('key')->unique(); + $table->json('value')->nullable(); + $table->timestamps(); + } + ); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/migrations/2026_10_19_000001_create_personal_access_tokens_table.php b/database/migrations/2026_10_19_000001_create_personal_access_tokens_table.php new file mode 100644 index 0000000..8683db8 --- /dev/null +++ b/database/migrations/2026_10_19_000001_create_personal_access_tokens_table.php @@ -0,0 +1,30 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_10_19_000002_create_items_table.php b/database/migrations/2026_10_19_000002_create_items_table.php new file mode 100644 index 0000000..44488c2 --- /dev/null +++ b/database/migrations/2026_10_19_000002_create_items_table.php @@ -0,0 +1,79 @@ +id(); + + // The URL as given, and its normalised form (lowercased host, + // no fragment or tracking parameters, sorted query). The + // normalised form is the de-duplication key. + $table->text('url'); + $table->string('normalized_url', 2048)->unique(); + // Where the fetch actually ended up after redirects. + $table->text('resolved_url')->nullable(); + + $table->string('title', 1024)->nullable(); + $table->string('author')->nullable(); + $table->string('site_name')->nullable(); + $table->text('excerpt')->nullable(); + $table->text('lead_image_url')->nullable(); + // Local copy of the lead image on the private disk. + $table->string('lead_image_path')->nullable(); + + // Sanitised article HTML (inline images rewritten to local + // copies) and its plain-text twin, which feeds the search index. + $table->longText('content_html')->nullable(); + $table->longText('content_text')->nullable(); + $table->unsignedInteger('word_count')->default(0); + // Minutes. + $table->unsignedInteger('reading_time')->default(0); + $table->string('language', 35)->nullable(); + $table->timestamp('published_at')->nullable(); + + // unread | archived + $table->string('status', 16)->default('unread'); + $table->timestamp('archived_at')->nullable(); + $table->boolean('is_favourite')->default(false); + $table->timestamp('favourited_at')->nullable(); + + // Reader scroll position, 0.0–1.0. + $table->float('read_progress')->default(0); + $table->timestamp('last_read_at')->nullable(); + + // web | bookmarklet | share | api | pocket + $table->string('source', 16)->default('web'); + + // pending | fetched | failed + $table->string('fetch_status', 16)->default('pending'); + $table->text('fetch_error')->nullable(); + $table->unsignedInteger('fetch_attempts')->default(0); + $table->timestamp('fetched_at')->nullable(); + + // When the link was saved. Separate from created_at so a Pocket + // import keeps the original date while created_at records when + // the row arrived here. + $table->timestamp('added_at')->nullable(); + $table->timestamps(); + + $table->index(['status', 'added_at']); + $table->index(['is_favourite', 'added_at']); + $table->index('word_count'); + $table->index('fetch_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('items'); + } +}; diff --git a/database/migrations/2026_10_19_000003_create_tags_tables.php b/database/migrations/2026_10_19_000003_create_tags_tables.php new file mode 100644 index 0000000..f5f69f2 --- /dev/null +++ b/database/migrations/2026_10_19_000003_create_tags_tables.php @@ -0,0 +1,35 @@ +id(); + $table->string('name', 100); + $table->string('slug', 120)->unique(); + $table->timestamps(); + }); + + Schema::create('item_tag', function (Blueprint $table) { + $table->foreignId('item_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); + + $table->primary(['item_id', 'tag_id']); + $table->index('tag_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('item_tag'); + Schema::dropIfExists('tags'); + } +}; diff --git a/database/migrations/2026_10_19_000004_create_items_fts_table.php b/database/migrations/2026_10_19_000004_create_items_fts_table.php new file mode 100644 index 0000000..d741566 --- /dev/null +++ b/database/migrations/2026_10_19_000004_create_items_fts_table.php @@ -0,0 +1,63 @@ + config('docket.user.email')], + [ + 'name' => config('docket.user.name'), + 'password' => Hash::make(config('docket.user.password')), + ] + ); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..02e2d3c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1809 @@ +{ + "name": "TheDocket", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/vite": "^4.3.3", + "alpinejs": "^3.16.2", + "concurrently": "^10.0.5", + "laravel-vite-plugin": "^3.2", + "tailwindcss": "^4.3.3", + "vite": "^8.2.2" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", + "dev": true, + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.17.4.tgz", + "integrity": "sha512-N2gsr+58XVUt7OAPKnUGiKPDtkd+xbSGTae8C8IJxeRdkkhBJPIautkJ+qcwb7lcjJ/QK3/CPVIbN20JPvtK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.5.40" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.6.tgz", + "integrity": "sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.12.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz", + "integrity": "sha512-xSxY9Gzeb/eancd8WeK09piAFP+a6i5QIBqNCKNv9L0Eq6wziwzSem7F1GvMSrtjMh5F/QVKFxn8t9naGOA66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "tinyglobby": "^0.2.12", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "fontaine": "^0.8.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "fontaine": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..add6229 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/vite": "^4.3.3", + "alpinejs": "^3.16.2", + "concurrently": "^10.0.5", + "laravel-vite-plugin": "^3.2", + "tailwindcss": "^4.3.3", + "vite": "^8.2.2" + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..d846c99 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,46 @@ + + + PHPCS rules for the Docket Laravel project, aligned with the Laravel Pint preset. + + + + + + + + + + + + + + + + + + + */tests/* + + + + app + config + database + routes + tests + + + bootstrap/cache/* + storage/* + vendor/* + public/build/* + public/hot + *.blade.php + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..d40ff8c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,21 @@ +includes: + - vendor/larastan/larastan/extension.neon + - vendor/phpstan/phpstan-mockery/extension.neon + +parameters: + paths: + - app + - database + - routes + - tests + + # Level 5 adds return type checks, property type checks, and stricter + # dead-code and unreachable-code analysis on top of level 2. + level: 5 + + excludePaths: + - storage + - bootstrap/cache + - vendor + - public/build (?) + - public/hot (?) diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..5fa6288 --- /dev/null +++ b/pint.json @@ -0,0 +1,10 @@ +{ + "preset": "laravel", + "rules": { + "new_with_parentheses": true + }, + "exclude": [ + "bootstrap/cache", + "storage" + ] +} \ No newline at end of file diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..c5375b5 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..ffb4a9d Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..c0adcba Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..1ba0ebd Binary files /dev/null and b/public/icons/icon-maskable-512.png differ diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..5e46d82 --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..f576e2d --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,40 @@ +{ + "name": "Docket", + "short_name": "Docket", + "description": "Save links now, read them later.", + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#0f766e", + "share_target": { + "action": "/share", + "method": "GET", + "params": { + "title": "title", + "text": "text", + "url": "url" + } + }, + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..5e84ee0 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,19 @@ + + + + + + Docket — offline + + + +
+

You’re offline

+

Docket needs a connection. Anything you were sharing hasn’t been saved yet — share it again when you’re back online.

+
+ + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..e60710a --- /dev/null +++ b/public/sw.js @@ -0,0 +1,29 @@ +// Minimal service worker: enough for installability. Everything behind +// auth is useless offline, so requests just pass through to the network, +// with a tiny cached fallback page for offline navigations. +const CACHE = 'docket-v1'; +const OFFLINE_URL = '/offline.html'; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.add(OFFLINE_URL)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request).catch(() => caches.match(OFFLINE_URL)) + ); + } +}); diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..4789264 --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,199 @@ +@import 'tailwindcss'; + +@plugin '@tailwindcss/forms'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../views'; + +@theme { + --font-sans: Figtree, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; + --font-serif: Charter, 'Bitstream Charter', 'Sitka Text', Cambria, Georgia, serif; +} + +/* Alpine: hide x-cloak elements until Alpine boots. */ +[x-cloak] { + display: none !important; +} + +/* + * Reader. Theme, font family and size are set on by the inline + * script in items/show.blade.php (before first paint) and kept in step by + * the Alpine reader component, so they are per browser, not per account. + */ +.reader { + --reader-bg: #ffffff; + --reader-fg: #1e293b; + --reader-muted: #64748b; + --reader-rule: #e2e8f0; + --reader-link: #0f766e; + --reader-code-bg: #f1f5f9; + --reader-bar-bg: rgb(255 255 255 / 0.92); + --reader-size: 20px; + --reader-font: var(--font-serif); + + background: var(--reader-bg); + color: var(--reader-fg); +} + +.reader.theme-sepia { + --reader-bg: #f6efe0; + --reader-fg: #4a3b2a; + --reader-muted: #7b6852; + --reader-rule: #e4d8bf; + --reader-link: #8a4b14; + --reader-code-bg: #eee3cb; + --reader-bar-bg: rgb(246 239 224 / 0.92); +} + +.reader.theme-dark { + --reader-bg: #121417; + --reader-fg: #d6d9de; + --reader-muted: #8b929c; + --reader-rule: #2a2f36; + --reader-link: #5eead4; + --reader-code-bg: #1e2228; + --reader-bar-bg: rgb(18 20 23 / 0.92); +} + +.reader.font-family-sans { + --reader-font: var(--font-sans); +} + +.reader-muted { + color: var(--reader-muted); +} + +.reader-bar { + background: var(--reader-bar-bg); + border-color: var(--reader-rule); +} + +.reader-panel { + background: var(--reader-bg); + border-color: var(--reader-rule); +} + +.reader-title { + font-family: var(--reader-font); +} + +.reader-content { + font-family: var(--reader-font); + font-size: var(--reader-size); + line-height: 1.7; + overflow-wrap: break-word; +} + +.reader-content > * + * { + margin-top: 1.1em; +} + +.reader-content h1, +.reader-content h2, +.reader-content h3, +.reader-content h4, +.reader-content h5, +.reader-content h6 { + font-weight: 700; + line-height: 1.3; + margin-top: 1.6em; +} + +.reader-content h1 { font-size: 1.6em; } +.reader-content h2 { font-size: 1.35em; } +.reader-content h3 { font-size: 1.15em; } +.reader-content h4, +.reader-content h5, +.reader-content h6 { font-size: 1em; } + +.reader-content a { + color: var(--reader-link); + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.reader-content img { + display: block; + height: auto; + max-width: 100%; + margin-left: auto; + margin-right: auto; + border-radius: 0.25rem; +} + +.reader-content figure { + margin-left: 0; + margin-right: 0; +} + +.reader-content figcaption { + color: var(--reader-muted); + font-family: var(--font-sans); + font-size: 0.8em; + margin-top: 0.5em; + text-align: center; +} + +.reader-content blockquote { + border-left: 3px solid var(--reader-rule); + color: var(--reader-muted); + font-style: italic; + padding-left: 1em; +} + +.reader-content ul, +.reader-content ol { + padding-left: 1.5em; +} + +.reader-content ul { list-style: disc; } +.reader-content ol { list-style: decimal; } + +.reader-content li + li { + margin-top: 0.35em; +} + +.reader-content pre, +.reader-content code, +.reader-content kbd, +.reader-content samp { + background: var(--reader-code-bg); + border-radius: 0.25rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; +} + +.reader-content code { + padding: 0.1em 0.3em; +} + +.reader-content pre { + line-height: 1.5; + overflow-x: auto; + padding: 1em; +} + +.reader-content pre code { + background: none; + padding: 0; +} + +.reader-content hr { + border: 0; + border-top: 1px solid var(--reader-rule); +} + +.reader-content table { + border-collapse: collapse; + display: block; + font-size: 0.85em; + overflow-x: auto; +} + +.reader-content th, +.reader-content td { + border: 1px solid var(--reader-rule); + padding: 0.4em 0.6em; + text-align: left; +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..590035b --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1,8 @@ +import Alpine from 'alpinejs'; +import reader from './reader'; + +window.Alpine = Alpine; + +Alpine.data('reader', reader); + +Alpine.start(); diff --git a/resources/js/reader.js b/resources/js/reader.js new file mode 100644 index 0000000..d2132a0 --- /dev/null +++ b/resources/js/reader.js @@ -0,0 +1,120 @@ +/** + * The reader page: appearance preferences (kept in localStorage, so they + * are per browser) and reading progress (sent to the server, so it + * follows you between devices). + */ +export const STORAGE_KEY = 'docket.reader'; +export const DEFAULTS = { theme: 'light', family: 'serif', size: 20 }; +export const SIZES = { min: 14, max: 32, step: 2 }; + +export function loadPreferences() { + try { + const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '{}'); + + return { ...DEFAULTS, ...saved }; + } catch { + return { ...DEFAULTS }; + } +} + +/** + * Apply preferences to . Also called from an inline script in the + * page head so the right theme is there before first paint. + */ +export function applyPreferences(prefs, root = document.documentElement) { + root.classList.remove('theme-light', 'theme-sepia', 'theme-dark', 'font-family-serif', 'font-family-sans'); + root.classList.add(`theme-${prefs.theme}`, `font-family-${prefs.family}`); + root.style.setProperty('--reader-size', `${prefs.size}px`); +} + +export default (config) => ({ + prefs: loadPreferences(), + panel: false, + progressUrl: config.progressUrl, + savedProgress: Number(config.progress) || 0, + lastSent: null, + timer: null, + + init() { + applyPreferences(this.prefs); + + this.$watch('prefs', (prefs) => { + applyPreferences(prefs); + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } catch { + // Private browsing or storage full: the choice lasts until reload. + } + }); + + // Restore the reading position once images have had a chance to + // lay out, unless the article was barely started or finished. + if (this.savedProgress > 0.01 && this.savedProgress < 0.99) { + const restore = () => window.scrollTo({ top: this.savedProgress * this.scrollable(), behavior: 'instant' }); + if (document.readyState === 'complete') { + restore(); + } else { + window.addEventListener('load', restore, { once: true }); + } + } + + this.lastSent = this.savedProgress; + + window.addEventListener('scroll', () => this.schedule(), { passive: true }); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.send(true); + } + }); + window.addEventListener('pagehide', () => this.send(true)); + }, + + setSize(delta) { + const size = this.prefs.size + delta * SIZES.step; + this.prefs.size = Math.min(SIZES.max, Math.max(SIZES.min, size)); + }, + + scrollable() { + return Math.max(1, document.documentElement.scrollHeight - window.innerHeight); + }, + + progress() { + return Math.min(1, Math.max(0, window.scrollY / this.scrollable())); + }, + + schedule() { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.send(false), 1500); + }, + + send(leaving) { + const progress = Math.round(this.progress() * 10000) / 10000; + + if (this.lastSent !== null && Math.abs(progress - this.lastSent) < 0.005) { + return; + } + + this.lastSent = progress; + + const token = document.querySelector('meta[name="csrf-token"]')?.content ?? ''; + const body = new FormData(); + body.append('_token', token); + body.append('progress', String(progress)); + + // sendBeacon survives the page being closed; fetch is used + // otherwise so failures don't go unnoticed in the console. + if (leaving && navigator.sendBeacon) { + navigator.sendBeacon(this.progressUrl, body); + + return; + } + + fetch(this.progressUrl, { + method: 'POST', + body, + credentials: 'same-origin', + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }).catch(() => {}); + }, +}); diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php new file mode 100644 index 0000000..633596d --- /dev/null +++ b/resources/views/auth/confirm-password.blade.php @@ -0,0 +1,27 @@ + +
+ {{ __('This is a secure area of the application. Please confirm your password before continuing.') }} +
+ +
+ @csrf + + +
+ + + + + +
+ +
+ + {{ __('Confirm') }} + +
+
+
diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..cf78ba1 --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,23 @@ + +
+ {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} +
+ +
+ @csrf + + +
+ + + +
+ +
+ + {{ __('Email Password Reset Link') }} + +
+
+
\ No newline at end of file diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..55dc24f --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,45 @@ + +
+ @csrf + + +
+ + + +
+ + +
+ + + + + +
+ + +
+ +
+ +
+ @if (Route::has('password.request')) + + {{ __('Forgot your password?') }} + + @endif + + + {{ __('Log in') }} + +
+
+
\ No newline at end of file diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..a6494cc --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,39 @@ + +
+ @csrf + + + + + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + +
+ +
+ + {{ __('Reset Password') }} + +
+
+
diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php new file mode 100644 index 0000000..3843739 --- /dev/null +++ b/resources/views/auth/verify-email.blade.php @@ -0,0 +1,26 @@ + +
+ {{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }} +
+ +
+
+ @csrf + +
+ + {{ __('Resend Verification Email') }} + +
+
+ +
+ @csrf + + +
+
+
\ No newline at end of file diff --git a/resources/views/components/alert.blade.php b/resources/views/components/alert.blade.php new file mode 100644 index 0000000..5519b8a --- /dev/null +++ b/resources/views/components/alert.blade.php @@ -0,0 +1,24 @@ +@props(['type' => 'success', 'message' => null]) + +@if ($message || session('status')) + @php + $alertMessage = $message ?? session('status'); + $alertType = $type; + + // Auto-detect type from message content. + if (! $message && session('status')) { + $status = session('status'); + if (str_contains(strtolower($status), 'error') || str_contains(strtolower($status), 'failed')) { + $alertType = 'error'; + } + } + @endphp + +
+

+ {{ $alertMessage }} +

+
+@endif \ No newline at end of file diff --git a/resources/views/components/application-logo.blade.php b/resources/views/components/application-logo.blade.php new file mode 100644 index 0000000..e5e1528 --- /dev/null +++ b/resources/views/components/application-logo.blade.php @@ -0,0 +1,6 @@ +{{-- Docket mark: a bookmark ribbon, matching the PWA icon. --}} + + + + + diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php new file mode 100644 index 0000000..c4bd6e2 --- /dev/null +++ b/resources/views/components/auth-session-status.blade.php @@ -0,0 +1,7 @@ +@props(['status']) + +@if ($status) +
merge(['class' => 'font-medium text-sm text-green-600']) }}> + {{ $status }} +
+@endif diff --git a/resources/views/components/danger-button.blade.php b/resources/views/components/danger-button.blade.php new file mode 100644 index 0000000..d17d288 --- /dev/null +++ b/resources/views/components/danger-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/dropdown-link.blade.php b/resources/views/components/dropdown-link.blade.php new file mode 100644 index 0000000..38ba97f --- /dev/null +++ b/resources/views/components/dropdown-link.blade.php @@ -0,0 +1 @@ +merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-slate-700 hover:bg-slate-100 focus:outline-none focus:bg-slate-100 transition duration-150 ease-in-out']) }}>{{ $slot }} diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php new file mode 100644 index 0000000..a46f7c8 --- /dev/null +++ b/resources/views/components/dropdown.blade.php @@ -0,0 +1,35 @@ +@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white']) + +@php +$alignmentClasses = match ($align) { + 'left' => 'ltr:origin-top-left rtl:origin-top-right start-0', + 'top' => 'origin-top', + default => 'ltr:origin-top-right rtl:origin-top-left end-0', +}; + +$width = match ($width) { + '48' => 'w-48', + default => $width, +}; +@endphp + +
+
+ {{ $trigger }} +
+ + +
diff --git a/resources/views/components/input-error.blade.php b/resources/views/components/input-error.blade.php new file mode 100644 index 0000000..9e6da21 --- /dev/null +++ b/resources/views/components/input-error.blade.php @@ -0,0 +1,9 @@ +@props(['messages']) + +@if ($messages) +
    merge(['class' => 'text-sm text-red-600 space-y-1']) }}> + @foreach ((array) $messages as $message) +
  • {{ $message }}
  • + @endforeach +
+@endif diff --git a/resources/views/components/input-label.blade.php b/resources/views/components/input-label.blade.php new file mode 100644 index 0000000..77184fd --- /dev/null +++ b/resources/views/components/input-label.blade.php @@ -0,0 +1,5 @@ +@props(['value']) + + diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php new file mode 100644 index 0000000..b919fff --- /dev/null +++ b/resources/views/components/modal.blade.php @@ -0,0 +1,78 @@ +@props([ + 'name', + 'show' => false, + 'maxWidth' => '2xl' +]) + +@php +$maxWidth = [ + 'sm' => 'sm:max-w-sm', + 'md' => 'sm:max-w-md', + 'lg' => 'sm:max-w-lg', + 'xl' => 'sm:max-w-xl', + '2xl' => 'sm:max-w-2xl', +][$maxWidth]; +@endphp + +
+
+
+
+ +
+ {{ $slot }} +
+
diff --git a/resources/views/components/nav-link.blade.php b/resources/views/components/nav-link.blade.php new file mode 100644 index 0000000..ff05cd6 --- /dev/null +++ b/resources/views/components/nav-link.blade.php @@ -0,0 +1,11 @@ +@props(['active']) + +@php +$classes = ($active ?? false) + ? 'inline-flex items-center px-1 pt-1 border-b-2 border-teal-400 text-sm font-medium leading-5 text-slate-900 focus:outline-none focus:border-teal-700 transition duration-150 ease-in-out' + : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-slate-500 hover:text-slate-700 hover:border-slate-300 focus:outline-none focus:text-slate-700 focus:border-slate-300 transition duration-150 ease-in-out'; +@endphp + +merge(['class' => $classes]) }}> + {{ $slot }} + diff --git a/resources/views/components/primary-button.blade.php b/resources/views/components/primary-button.blade.php new file mode 100644 index 0000000..b66c5b8 --- /dev/null +++ b/resources/views/components/primary-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/responsive-nav-link.blade.php b/resources/views/components/responsive-nav-link.blade.php new file mode 100644 index 0000000..bbe598c --- /dev/null +++ b/resources/views/components/responsive-nav-link.blade.php @@ -0,0 +1,11 @@ +@props(['active']) + +@php +$classes = ($active ?? false) + ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-teal-400 text-start text-base font-medium text-teal-700 bg-teal-50 focus:outline-none focus:text-teal-800 focus:bg-teal-100 focus:border-teal-700 transition duration-150 ease-in-out' + : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-slate-600 hover:text-slate-800 hover:bg-slate-50 hover:border-slate-300 focus:outline-none focus:text-slate-800 focus:bg-slate-50 focus:border-slate-300 transition duration-150 ease-in-out'; +@endphp + +merge(['class' => $classes]) }}> + {{ $slot }} + diff --git a/resources/views/components/secondary-button.blade.php b/resources/views/components/secondary-button.blade.php new file mode 100644 index 0000000..5bad3cd --- /dev/null +++ b/resources/views/components/secondary-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/sidebar-link.blade.php b/resources/views/components/sidebar-link.blade.php new file mode 100644 index 0000000..bd21d0e --- /dev/null +++ b/resources/views/components/sidebar-link.blade.php @@ -0,0 +1,11 @@ +@props(['href', 'active' => false]) + +merge([ + 'class' => ($active + ? 'bg-slate-800 text-white ' + : 'text-slate-400 hover:bg-slate-800/60 hover:text-white ') + .'group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors', +]) }}> + {{ $icon ?? '' }} + {{ $slot }} + diff --git a/resources/views/components/text-input.blade.php b/resources/views/components/text-input.blade.php new file mode 100644 index 0000000..5e9690a --- /dev/null +++ b/resources/views/components/text-input.blade.php @@ -0,0 +1,3 @@ +@props(['disabled' => false]) + +merge(['class' => 'rounded-lg border-slate-300 shadow-sm focus:border-teal-500 focus:ring-teal-500 placeholder:text-slate-400']) }}> diff --git a/resources/views/import/index.blade.php b/resources/views/import/index.blade.php new file mode 100644 index 0000000..c7fef07 --- /dev/null +++ b/resources/views/import/index.blade.php @@ -0,0 +1,48 @@ + + +

{{ __('Import & export') }}

+
+ +
+
+
+

{{ __('Import from Pocket') }}

+

+ {{ __('Upload the CSV from Pocket\'s export (columns: title, url, time_added, tags, status). Each link keeps its saved date, tags and unread or archived status. Importing the same file again is safe: links already here are skipped, apart from gaining any tags they were missing.') }} +

+

+ {{ __('Articles are fetched in the background, one every :seconds seconds, so a big import takes a while to fill in. Keep the queue worker running.', ['seconds' => settings('import.spacing_seconds')]) }} +

+ +
+ @csrf +
+ + +

{{ __('Up to :size MB. Command-line alternative: php artisan docket:import-pocket path/to/file.csv', ['size' => round($maxKb / 1024)]) }}

+ +
+ {{ __('Import') }} +
+
+ +
+

{{ __('Export') }}

+

+ {{ trans_choice('Your library has :count item.|Your library has :count items.', $itemCount) }} +

+
    +
  • + {{ __('Download CSV') }} + — {{ __('the same columns as Pocket\'s export, so it imports back into Docket or anything that reads Pocket files.') }} +
  • +
  • + {{ __('Download JSON') }} + — {{ __('everything, including the saved article text and HTML.') }} +
  • +
+
+
+
+
diff --git a/resources/views/install/complete.blade.php b/resources/views/install/complete.blade.php new file mode 100644 index 0000000..ea43a5d --- /dev/null +++ b/resources/views/install/complete.blade.php @@ -0,0 +1,42 @@ +@extends('install.layout') + +@section('content') +

Docket is installed

+

+ Your account {{ $email }} is ready and the installer + is now disabled. A few things to finish on the server: +

+ +
    + @unless ($assetsBuilt) +
  1. + Build the frontend assets: + npm install && npm run build +
  2. + @endunless +
  3. + Run a queue worker to fetch and extract saved pages: + php artisan queue:work + (under systemd or supervisor in production). +
  4. +
  5. + Add one cron entry for the scheduler (backups & cleanup): + * * * * * cd /path/to/docket && php artisan schedule:run >> /dev/null 2>&1 +
  6. +
  7. + Optional off-site backups: fill in the BACKUP_* keys + in .env (any S3-compatible bucket). +
  8. +
  9. + In production, cache the config once .env is final: + php artisan config:cache +
  10. +
+ +
+ {{-- Relative path: this page renders in the same request that + created .env, so the URL generator may still think it is in + production and force https. --}} + Log in to Docket +
+@endsection diff --git a/resources/views/install/layout.blade.php b/resources/views/install/layout.blade.php new file mode 100644 index 0000000..856f9ca --- /dev/null +++ b/resources/views/install/layout.blade.php @@ -0,0 +1,93 @@ +{{-- + Standalone layout for the web installer. Deliberately self-contained: + no Vite/app.css (the frontend may not be built yet), no session, no + components from the authenticated app. +--}} + + + + + + + Install Docket + + + +
+
+

Docket

+

Self-hosted read-later

+
+
+ @yield('content') +
+
Open source under the MIT licence
+
+ + diff --git a/resources/views/install/requirements.blade.php b/resources/views/install/requirements.blade.php new file mode 100644 index 0000000..eca6afb --- /dev/null +++ b/resources/views/install/requirements.blade.php @@ -0,0 +1,47 @@ +@extends('install.layout') + +@section('content') +

Welcome to the Docket installer

+

+ This wizard checks your server, creates the configuration file and + database, and sets up your login. It only works while Docket is not + yet installed — once your account exists it disables itself. +

+ +
    + @foreach ($checks as $check) +
  • + @if ($check['ok']) + Pass + @elseif ($check['blocking']) + Fail + @else + Later + @endif + + {{ $check['label'] }} + @unless ($check['ok']) + {{ $check['hint'] }} + @endunless + +
  • + @endforeach +
+ + @if ($canProceed) +

+ Items marked Later are optional and can be sorted + out after installation. +

+
+ {{-- Relative path, not route(): before .env exists the app + defaults to APP_ENV=production and forces https URLs, + which breaks installs served over plain http. --}} + Continue +
+ @else + + @endif +@endsection diff --git a/resources/views/install/setup.blade.php b/resources/views/install/setup.blade.php new file mode 100644 index 0000000..feb3d5c --- /dev/null +++ b/resources/views/install/setup.blade.php @@ -0,0 +1,56 @@ +@extends('install.layout') + +@section('content') +

Set up Docket

+

+ Docket is a single-user app: this creates the one account you will + log in with (there is no registration screen). +

+ + @if ($installError) + + @endif + + {{-- No @csrf: these routes run before the session store exists and are + disabled entirely once the owner account has been created. + Relative action, not route(): before .env exists the app defaults + to APP_ENV=production and forces https URLs, which breaks installs + served over plain http. --}} +
+ + + @if ($errors->has('url')) +

{{ $errors->first('url') }}

+ @endif + + + + @if ($errors->has('name')) +

{{ $errors->first('name') }}

+ @endif + + + + @if ($errors->has('email')) +

{{ $errors->first('email') }}

+ @endif + + + + @if ($errors->has('password')) +

{{ $errors->first('password') }}

+ @endif + + + + +
+ +
+
+@endsection diff --git a/resources/views/items/index.blade.php b/resources/views/items/index.blade.php new file mode 100644 index 0000000..2d52ef5 --- /dev/null +++ b/resources/views/items/index.blade.php @@ -0,0 +1,133 @@ + + +
+
+

+ @if ($view === 'tag') + {{ __('Tag:') }} + @endif + {{ $heading }} +

+

+ @if ($q !== '') + {{ trans_choice(':count result for “:q”|:count results for “:q”', $items->total(), ['q' => $q]) }} + @else + {{ trans_choice(':count item|:count items', $items->total()) }} + @endif +

+
+ +
+ + + + + + {{ __('Go') }} +
+
+
+ +
+
+ + {{-- Save a link --}} +
+ @csrf +
+
+ + +
+
+ + +
+ {{ __('Save') }} +
+ + + + @if ($items->isEmpty()) +
+ @if ($q !== '') + {{ __('Nothing matches that search.') }} + @elseif ($view === 'unread') + {{ __('Nothing to read. Save a link above, use the bookmarklet, or share to Docket from your phone.') }} + @elseif ($view === 'archive') + {{ __('Nothing archived yet.') }} + @elseif ($view === 'favourites') + {{ __('No favourites yet.') }} + @else + {{ __('Nothing here.') }} + @endif +
+ @else +
+ + {{-- Bulk actions: the checkboxes below join this form via form="bulk". --}} +
+ @csrf + + + +
+ + +
    + @foreach ($items as $item) + @include('items.partials.row', ['item' => $item]) + @endforeach +
+
+ +
+ {{ $items->links() }} +
+ @endif +
+
+
diff --git a/resources/views/items/partials/row.blade.php b/resources/views/items/partials/row.blade.php new file mode 100644 index 0000000..e4a022e --- /dev/null +++ b/resources/views/items/partials/row.blade.php @@ -0,0 +1,111 @@ +@php + /** @var \App\Models\Item $item */ + $failed = $item->fetch_status === \App\Enums\FetchStatus::Failed; + $pending = $item->fetch_status === \App\Enums\FetchStatus::Pending; +@endphp +
  • +
    + +
    + +
    + + {{ $item->displayTitle() }} + + +
    + {{ $item->site_name ?: $item->host() }} + @if ($item->reading_time > 0) + + {{ trans_choice(':count min|:count min', $item->reading_time) }} + @endif + + + @if ($item->read_progress > 0.02 && $item->read_progress < 0.98) + + {{ __(':percent% read', ['percent' => (int) round($item->read_progress * 100)]) }} + @endif + @if ($pending) + {{ __('Fetching…') }} + @elseif ($failed) + + {{ __('Could not fetch') }} + + @endif + @if ($item->isArchived()) + {{ __('Archived') }} + @endif +
    + + @if ($item->excerpt) +

    {{ $item->excerpt }}

    + @endif + + @if ($failed && $item->fetch_error) +

    {{ $item->fetch_error }}

    + @endif + + @if ($item->tags->isNotEmpty()) +
    + @foreach ($item->tags as $tag) + {{ $tag->name }} + @endforeach +
    + @endif + +
    + @if ($item->isArchived()) +
    + @csrf + @method('delete') + +
    + @else +
    + @csrf + +
    + @endif + +
    + @csrf + @if ($item->is_favourite) + @method('delete') + @endif + +
    + + @if ($failed) +
    + @csrf + +
    + @endif + + {{ __('Original') }} ↗ + +
    + @csrf + @method('delete') + +
    +
    +
    + + @if ($item->lead_image_path) + + @endif +
  • diff --git a/resources/views/items/show.blade.php b/resources/views/items/show.blade.php new file mode 100644 index 0000000..249d5f9 --- /dev/null +++ b/resources/views/items/show.blade.php @@ -0,0 +1,217 @@ +@php + /** @var \App\Models\Item $item */ + $failed = $item->fetch_status === \App\Enums\FetchStatus::Failed; + $pending = $item->fetch_status === \App\Enums\FetchStatus::Pending; +@endphp + + + + + + + + + + {{ $item->displayTitle() }} — {{ config('app.name', 'Docket') }} + + {{-- Appearance before first paint, so a dark-theme reader never flashes white. Mirrors resources/js/reader.js. --}} + + + + + + @include('partials.pwa') + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + + {{-- Top bar --}} +
    +
    + + {{ __('Back') }} + + +
    + @if ($item->isArchived()) +
    + @csrf + @method('delete') + +
    + @else +
    + @csrf + + +
    + @endif + +
    + @csrf + @if ($item->is_favourite) + @method('delete') + @endif + +
    + +
    + + +
    +
    +

    {{ __('Text size') }}

    +
    + + + +
    +
    +
    +

    {{ __('Typeface') }}

    +
    + + +
    +
    +
    +

    {{ __('Theme') }}

    +
    + + + +
    +
    +
    +
    +
    +
    +
    + +
    + @if (session('status')) +

    {{ session('status') }}

    + @endif + +
    +
    +

    {{ $item->displayTitle() }}

    + +

    + + {{ $item->site_name ?: $item->host() }} ↗ + + @if ($item->author) + {{ $item->author }} + @endif + @if ($item->published_at) + + + @endif + @if ($item->reading_time > 0) + {{ trans_choice(':count min read|:count min read', $item->reading_time) }} + @endif +

    + + @if ($item->tags->isNotEmpty()) +

    + @foreach ($item->tags as $tag) + {{ $tag->name }} + @endforeach +

    + @endif +
    + + @if ($item->hasArticle()) +
    + {!! $item->content_html !!} +
    + @else +
    + @if ($pending) +

    {{ __('The article is still being fetched. Refresh in a moment.') }}

    + @elseif ($failed) +

    {{ $item->fetch_error ?: __('The article could not be fetched.') }}

    + @endif + + @if ($item->excerpt) +

    {{ $item->excerpt }}

    + @endif + +
    + {{ __('Open the original') }} ↗ + @if ($failed) +
    + @csrf + +
    + @endif +
    +
    + @endif + +
    +

    + {{ __('Saved :date.', ['date' => $item->added_at?->format('j F Y')]) }} + {{ $item->originalUrl() }} +

    + +
    + {{ __('Edit title and tags') }} +
    + @csrf + @method('patch') +
    + + +
    +
    + + +
    + {{ __('Save') }} +
    + +
    + @csrf + @method('delete') + +
    +
    +
    +
    +
    + + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..afe3fb4 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,162 @@ + + + + + + + + + {{ config('app.name', 'Docket') }} + + + + + + @include('partials.pwa') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + @php + $navigation = [ + ['route' => 'items.index', 'pattern' => ['items.index', 'items.show'], 'label' => 'Unread', + 'count' => \App\Models\Item::query()->unread()->count(), + 'icon' => 'M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25'], + ['route' => 'items.archive', 'pattern' => ['items.archive'], 'label' => 'Archive', + 'icon' => 'm20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z'], + ['route' => 'items.favourites', 'pattern' => ['items.favourites'], 'label' => 'Favourites', + 'icon' => 'M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z'], + ['route' => 'tags.index', 'pattern' => ['tags.*', 'items.tagged'], 'label' => 'Tags', + 'icon' => 'M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z M6 6h.008v.008H6V6Z'], + ['route' => 'import.index', 'pattern' => ['import.*'], 'label' => 'Import & export', + 'icon' => 'M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3'], + ['route' => 'settings.edit', 'pattern' => ['settings.*'], 'label' => 'Settings', + 'icon' => 'M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75'], + ['route' => 'tools.index', 'pattern' => ['tools.*'], 'label' => 'Tools', + 'icon' => 'M21.75 6.75a4.5 4.5 0 0 1-4.5 4.5H11.31l-4.72 4.72a.75.75 0 0 1-1.06 0l-1.06-1.06a.75.75 0 0 1 0-1.06l4.72-4.72V6.75a4.5 4.5 0 0 1 4.5-4.5h1.5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 0 .75.75h1.5a.75.75 0 0 1 .75.75v1.5Z'], + ]; + @endphp + +
    + + {{-- Desktop sidebar --}} + + + {{-- Mobile top bar --}} +
    + + + Docket + + +
    + + {{-- Mobile menu panel --}} +
    + +
    + {{ Auth::user()->name }} +
    + @csrf + +
    +
    +
    + + {{-- Main column --}} +
    + @isset($header) +
    +
    + {{ $header }} +
    +
    + @endisset + +
    + @if (session('status') || session('error')) +
    + @if (session('status')) + + @endif + @if (session('error')) + + @endif +
    + @endif + + {{ $slot }} +
    +
    +
    + + + \ No newline at end of file diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php new file mode 100644 index 0000000..43baf92 --- /dev/null +++ b/resources/views/layouts/guest.blade.php @@ -0,0 +1,40 @@ + + + + + + + + + {{ config('app.name', 'Docket') }} + + + + + + @include('partials.pwa') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + +
    + + + Docket + + +
    + @if (session('status')) + + @endif + + {{ $slot }} +
    + +

    Docket — save now, read later

    +
    + + + \ No newline at end of file diff --git a/resources/views/partials/pwa.blade.php b/resources/views/partials/pwa.blade.php new file mode 100644 index 0000000..0452684 --- /dev/null +++ b/resources/views/partials/pwa.blade.php @@ -0,0 +1,11 @@ +{{-- PWA: manifest + icons + service worker registration. --}} + + + + + + diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php new file mode 100644 index 0000000..423d5df --- /dev/null +++ b/resources/views/profile/edit.blade.php @@ -0,0 +1,23 @@ + + +

    + {{ __('Profile') }} +

    +
    + +
    +
    +
    +
    + @include('profile.partials.update-profile-information-form') +
    +
    + +
    +
    + @include('profile.partials.update-password-form') +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/resources/views/profile/partials/update-password-form.blade.php b/resources/views/profile/partials/update-password-form.blade.php new file mode 100644 index 0000000..6f4d8c4 --- /dev/null +++ b/resources/views/profile/partials/update-password-form.blade.php @@ -0,0 +1,41 @@ +
    +
    +

    + {{ __('Update Password') }} +

    + +

    + {{ __('Ensure your account is using a long, random password to stay secure.') }} +

    +
    + +
    + @csrf + @method('put') + +
    + + + +
    + +
    + + + +
    + +
    + + + +
    + +
    + {{ __('Save') }} +
    +
    +
    \ No newline at end of file diff --git a/resources/views/profile/partials/update-profile-information-form.blade.php b/resources/views/profile/partials/update-profile-information-form.blade.php new file mode 100644 index 0000000..649aa0e --- /dev/null +++ b/resources/views/profile/partials/update-profile-information-form.blade.php @@ -0,0 +1,52 @@ +
    +
    +

    + {{ __('Profile Information') }} +

    + +

    + {{ __("Update your account's profile information and email address.") }} +

    +
    + +
    + @csrf +
    + +
    + @csrf + @method('patch') + +
    + + + +
    + +
    + + + + + @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail()) +
    +

    + {{ __('Your email address is unverified.') }} + + +

    + + +
    + @endif +
    + +
    + {{ __('Save') }} +
    +
    +
    \ No newline at end of file diff --git a/resources/views/save/done.blade.php b/resources/views/save/done.blade.php new file mode 100644 index 0000000..5797bc9 --- /dev/null +++ b/resources/views/save/done.blade.php @@ -0,0 +1,44 @@ +{{-- + The bookmarklet's popup. Small and self-contained; closes itself after + a successful save when it was opened as a popup. +--}} + + + + + + + + {{ $error ? __('Not saved') : __('Saved') }} — {{ config('app.name', 'Docket') }} + @vite(['resources/css/app.css']) + + + +
    + + + @if ($error) +

    {{ __('Not saved') }}

    +

    {{ $error }}

    + @else +

    {{ $result->created ? __('Saved to Docket') : __('Already in Docket') }}

    +

    {{ $result->item->displayTitle() }}

    +

    {{ $result->message() }}

    + @endif + +
    + {{ __('Open Docket') }} + +
    +
    + + @unless ($error) + + @endunless + + + diff --git a/resources/views/settings/edit.blade.php b/resources/views/settings/edit.blade.php new file mode 100644 index 0000000..c9b0e08 --- /dev/null +++ b/resources/views/settings/edit.blade.php @@ -0,0 +1,175 @@ + + +

    + {{ __('Settings') }} +

    +
    + +
    +
    + + {{-- Ways to save links --}} +
    +
    +
    +
    +

    {{ __('Bookmarklet') }}

    +

    + {{ __('Drag this button to your bookmarks bar. Click it on any page to save that page to Docket.') }} +

    +
    +
    + + {{ __('Save to Docket') }} + + {{ __('On a phone, use the share sheet instead (below).') }} +
    +
    + {{ __('Copy the code instead') }} +

    {{ __('Create a bookmark by hand and paste this as its URL:') }}

    + +
    +
    + +
    +
    +

    {{ __('Android: Share → Docket') }}

    +

    + {{ __('Open Docket in Chrome on your phone and choose "Install app" (or "Add to Home screen") from the menu. Docket then appears in the share sheet of every app; sharing a link saves it.') }} +

    +
    +
    + +
    +
    +

    {{ __('API tokens') }}

    +

    + {{ __('For the iOS Shortcut, browser extensions and scripts. Send a POST to :url with a JSON body like {"url": "https://…", "tags": ["later"]} and the header Authorization: Bearer .', ['url' => $apiUrl]) }} +

    +
    + + @if ($plainToken) +
    +

    {{ __('Your new token. Copy it now: it will not be shown again.') }}

    + +
    + @endif + +
    + @csrf +
    + + +
    + {{ __('Create token') }} +
    + + + @if ($tokens->isNotEmpty()) +
      + @foreach ($tokens as $token) +
    • +
      + {{ $token->name }} + + {{ __('created :date', ['date' => $token->created_at?->format('j M Y')]) }}, + {{ $token->last_used_at ? __('last used :when', ['when' => $token->last_used_at->diffForHumans()]) : __('never used') }} + +
      +
      + @csrf + @method('delete') + +
      +
    • + @endforeach +
    + @endif + +
    + {{ __('Setting up the iOS Shortcut') }} +
      +
    1. {{ __('Create a token above and copy it.') }}
    2. +
    3. {{ __('In the Shortcuts app, create a new shortcut. In its details, turn on "Show in Share Sheet" and set it to receive URLs.') }}
    4. +
    5. {{ __('Add the action "Get Contents of URL". Set the URL to :url, the method to POST, and add two headers: Authorization = Bearer followed by a space and your token, and Accept = application/json.', ['url' => $apiUrl]) }}
    6. +
    7. {{ __('Set the request body to JSON, with one text field: url = the Shortcut Input.') }}
    8. +
    9. {{ __('Optionally add "Show Notification" with the text "Saved to Docket". Name it "Save to Docket".') }}
    10. +
    +
    +
    +
    +
    + + {{-- Editable knobs --}} +
    +
    +
    +
    +

    + {{ __('Application settings') }} +

    + +

    + {{ __('Tunable knobs that override the defaults in config/docket.php.') }} +

    +
    + +
    + @csrf + @method('patch') + + @foreach ($fields as $key => $field) + {{-- PHP mangles dots in form data, so the field is named + by the underscore form key, not the dotted config path. --}} + @php($name = \App\Services\Settings::formKey($key)) +
    + + +

    {{ __($field['help']) }}

    + +
    + @endforeach + +
    + {{ __('Save') }} +
    +
    +
    +
    +
    + + {{-- Read-only invariants --}} +
    +
    +
    +
    +

    + {{ __('Info') }} +

    + +

    + {{ __('Host-specific values that live in .env or config/docket.php.') }} +

    +
    + +
    + @foreach ($info as $label => $value) +
    +
    {{ __($label) }}
    +
    {{ $value }}
    +
    + @endforeach +
    +
    +
    +
    + +
    +
    +
    diff --git a/resources/views/tags/index.blade.php b/resources/views/tags/index.blade.php new file mode 100644 index 0000000..69f921b --- /dev/null +++ b/resources/views/tags/index.blade.php @@ -0,0 +1,49 @@ + + +

    {{ __('Tags') }}

    +
    + +
    +
    + + + @if ($tags->isEmpty()) +
    + {{ __('No tags yet. Add them when you save a link, in the reader, or with the bulk actions.') }} +
    + @else +
      + @foreach ($tags as $tag) +
    • +
      + {{ $tag->name }} + {{ trans_choice(':count item|:count items', $tag->items_count) }} +
      + +
      + @csrf + @method('patch') + + + {{ __('Rename') }} +
      + +
      + +
      + @csrf + @method('delete') + +
      +
      +
    • + @endforeach +
    +

    {{ __('Renaming a tag to the name of another one merges them.') }}

    + @endif +
    +
    +
    diff --git a/resources/views/tools/index.blade.php b/resources/views/tools/index.blade.php new file mode 100644 index 0000000..d5196d2 --- /dev/null +++ b/resources/views/tools/index.blade.php @@ -0,0 +1,58 @@ + + +

    + {{ __('Tools') }} +

    +
    + +
    +
    + + {{-- Output from the last tool run, flashed by ToolsController::run() --}} + @if (session('tool_ran')) +
    +
    +

    + {{ session('tool_ran') }} +

    + @if (session('tool_exit_code') === 0) + {{ __('Success') }} + @else + {{ __('Failed (exit :code)', ['code' => session('tool_exit_code')]) }} + @endif +
    +
    {{ session('tool_output') }}
    +
    + @endif + +
    +
    +

    + {{ __('Maintenance tools') }} +

    + +

    + {{ __('Each button runs one artisan command on the server and shows its output above. Everything here is also available from the command line, e.g. php artisan docket:refetch.') }} +

    +
    + +
    + @foreach ($tools as $key => $tool) +
    +

    {{ __($tool['label']) }}

    +

    {{ __($tool['description']) }}

    +
    + @csrf + {{ __('Run') }} +
    +
    + @endforeach +
    +
    + +
    +
    +
    \ No newline at end of file diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..4a64601 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,19 @@ +". Used by the iOS +| Shortcut, and open to browser extensions and anything else later. +| +*/ + +Route::middleware(['auth:sanctum', 'throttle:api'])->group(function (): void { + Route::post('items', [ItemController::class, 'store'])->name('api.items.store'); +}); diff --git a/routes/auth.php b/routes/auth.php new file mode 100644 index 0000000..39e6b57 --- /dev/null +++ b/routes/auth.php @@ -0,0 +1,58 @@ +group( + function () { + Route::get('login', [AuthenticatedSessionController::class, 'create']) + ->name('login'); + + Route::post('login', [AuthenticatedSessionController::class, 'store']); + + Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) + ->name('password.request'); + + Route::post('forgot-password', [PasswordResetLinkController::class, 'store']) + ->name('password.email'); + + Route::get('reset-password/{token}', [NewPasswordController::class, 'create']) + ->name('password.reset'); + + Route::post('reset-password', [NewPasswordController::class, 'store']) + ->name('password.store'); + } +); + +Route::middleware('auth')->group( + function () { + Route::get('verify-email', EmailVerificationPromptController::class) + ->name('verification.notice'); + + Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + + Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) + ->middleware('throttle:6,1') + ->name('verification.send'); + + Route::get('confirm-password', [ConfirmablePasswordController::class, 'show']) + ->name('password.confirm'); + + Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']); + + Route::put('password', [PasswordController::class, 'update'])->name('password.update'); + + Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) + ->name('logout'); + } +); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..f93475e --- /dev/null +++ b/routes/console.php @@ -0,0 +1,19 @@ +> /dev/null 2>&1 +| +| Times are server-local. Prune old backups first, then take the new one, +| then verify the newest backup is fresh enough (emails if not). +*/ + +Schedule::command('backup:clean')->dailyAt('01:30'); +Schedule::command('backup:run')->dailyAt('02:00'); +Schedule::command('backup:monitor')->dailyAt('08:00'); diff --git a/routes/install.php b/routes/install.php new file mode 100644 index 0000000..3fc5002 --- /dev/null +++ b/routes/install.php @@ -0,0 +1,21 @@ +name('install.requirements'); +Route::get('install/setup', [InstallController::class, 'form'])->name('install.form'); +Route::post('install/setup', [InstallController::class, 'store'])->name('install.store'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..20a7b5c --- /dev/null +++ b/routes/web.php @@ -0,0 +1,75 @@ +group( + function () { + // The lists. The route default picks the view (the tag list has + // its own action), and ?sort= / ?q= refine it. + Route::get('/', [ItemController::class, 'index'])->defaults('view', 'unread')->name('items.index'); + Route::get('archive', [ItemController::class, 'index'])->defaults('view', 'archive')->name('items.archive'); + Route::get('favourites', [ItemController::class, 'index'])->defaults('view', 'favourites')->name('items.favourites'); + Route::get('search', [ItemController::class, 'index'])->defaults('view', 'search')->name('items.search'); + Route::get('tags/{tag}', [ItemController::class, 'tagged'])->name('items.tagged'); + + Route::post('items', [ItemController::class, 'store'])->name('items.store'); + Route::post('items/bulk', BulkItemController::class)->name('items.bulk'); + Route::get('items/{item}', [ItemController::class, 'show'])->name('items.show'); + Route::patch('items/{item}', [ItemController::class, 'update'])->name('items.update'); + Route::delete('items/{item}', [ItemController::class, 'destroy'])->name('items.destroy'); + + Route::post('items/{item}/archive', [ItemStateController::class, 'archive'])->name('items.archive.store'); + Route::delete('items/{item}/archive', [ItemStateController::class, 'unarchive'])->name('items.archive.destroy'); + Route::post('items/{item}/favourite', [ItemStateController::class, 'favourite'])->name('items.favourite.store'); + Route::delete('items/{item}/favourite', [ItemStateController::class, 'unfavourite'])->name('items.favourite.destroy'); + Route::post('items/{item}/retry', [ItemStateController::class, 'retry'])->name('items.retry'); + Route::post('items/{item}/progress', [ItemStateController::class, 'progress'])->name('items.progress'); + + Route::get('items/{item}/images/{filename}', ItemImageController::class) + ->where('filename', ImageLocaliser::FILENAME_PATTERN) + ->name('items.image'); + + // Bookmarklet and PWA share target. GET, because both arrive as a + // plain navigation from another page or app. + Route::get('save', [SaveController::class, 'bookmarklet'])->name('save.bookmarklet'); + Route::get('share', [SaveController::class, 'share'])->name('save.share'); + + Route::get('tags', [TagController::class, 'index'])->name('tags.index'); + Route::patch('tags/{tag}', [TagController::class, 'update'])->name('tags.update'); + Route::delete('tags/{tag}', [TagController::class, 'destroy'])->name('tags.destroy'); + + Route::get('import', [ImportController::class, 'index'])->name('import.index'); + Route::post('import', [ImportController::class, 'store'])->name('import.store'); + + Route::get('export/csv', [ExportController::class, 'csv'])->name('export.csv'); + Route::get('export/json', [ExportController::class, 'json'])->name('export.json'); + + Route::get('settings', [SettingsController::class, 'edit'])->name('settings.edit'); + Route::patch('settings', [SettingsController::class, 'update'])->name('settings.update'); + Route::post('settings/tokens', [ApiTokenController::class, 'store'])->name('tokens.store'); + Route::delete('settings/tokens/{token}', [ApiTokenController::class, 'destroy'])->whereNumber('token')->name('tokens.destroy'); + + // Maintenance tools: {tool} is a key into ToolsController::tools(), + // never a raw command; unknown keys 404. + Route::get('tools', [ToolsController::class, 'index'])->name('tools.index'); + Route::post('tools/{tool}', [ToolsController::class, 'run'])->name('tools.run'); + + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); + Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); + } +); + +require __DIR__.'/auth.php'; diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..4325fe5 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,5 @@ +* +!private/ +!public/ +!templates/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 0000000..e1766c2 --- /dev/null +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,54 @@ +get('/login'); + + $response->assertStatus(200); + } + + public function test_users_can_authenticate_using_the_login_screen(): void + { + $user = User::factory()->create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('items.index', absolute: false)); + } + + public function test_users_can_not_authenticate_with_invalid_password(): void + { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); + } + + public function test_users_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $this->assertGuest(); + $response->assertRedirect('/'); + } +} diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..225f730 --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,58 @@ +unverified()->create(); + + $response = $this->actingAs($user)->get('/verify-email'); + + $response->assertStatus(200); + } + + public function test_email_can_be_verified(): void + { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(route('items.index', absolute: false).'?verified=1'); + } + + public function test_email_is_not_verified_with_invalid_hash(): void + { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } +} diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php new file mode 100644 index 0000000..ff85721 --- /dev/null +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -0,0 +1,44 @@ +create(); + + $response = $this->actingAs($user)->get('/confirm-password'); + + $response->assertStatus(200); + } + + public function test_password_can_be_confirmed(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'password', + ]); + + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); + } + + public function test_password_is_not_confirmed_with_invalid_password(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(); + } +} diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 0000000..aa50350 --- /dev/null +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,73 @@ +get('/forgot-password'); + + $response->assertStatus(200); + } + + public function test_reset_password_link_can_be_requested(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_reset_password_screen_can_be_rendered(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $response = $this->get('/reset-password/'.$notification->token); + + $response->assertStatus(200); + + return true; + }); + } + + public function test_password_can_be_reset_with_valid_token(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('login')); + + return true; + }); + } +} diff --git a/tests/Feature/Auth/PasswordUpdateTest.php b/tests/Feature/Auth/PasswordUpdateTest.php new file mode 100644 index 0000000..ca28c6c --- /dev/null +++ b/tests/Feature/Auth/PasswordUpdateTest.php @@ -0,0 +1,51 @@ +create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); + } + + public function test_correct_password_must_be_provided_to_update_password(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasErrorsIn('updatePassword', 'current_password') + ->assertRedirect('/profile'); + } +} diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 0000000..fa7d8bb --- /dev/null +++ b/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,26 @@ +get('/register')->assertNotFound(); + + $this->post( + '/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ] + )->assertNotFound(); + } +} diff --git a/tests/Feature/ImportExport/ExportTest.php b/tests/Feature/ImportExport/ExportTest.php new file mode 100644 index 0000000..fb25ce8 --- /dev/null +++ b/tests/Feature/ImportExport/ExportTest.php @@ -0,0 +1,103 @@ +archived()->create([ + 'title' => 'Comma, "quoted" title', + 'url' => 'https://example.com/a', + 'added_at' => Carbon::createFromTimestamp(1600000000), + ]); + $archived->attachTagNames(['one', 'two']); + Item::factory()->create(['title' => 'Second', 'url' => 'https://example.com/b', 'added_at' => Carbon::createFromTimestamp(1700000000)]); + + $response = $this->actingAs(User::factory()->create())->get('/export/csv'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); + $this->assertStringContainsString('attachment; filename=docket-export-', (string) $response->headers->get('Content-Disposition')); + + $rows = array_map(fn (string $line): array => str_getcsv($line, ',', '"', ''), array_filter(explode("\n", $response->streamedContent()))); + + $this->assertSame(['title', 'url', 'time_added', 'tags', 'status'], $rows[0]); + $this->assertSame(['Comma, "quoted" title', 'https://example.com/a', '1600000000', 'one|two', 'archive'], $rows[1]); + $this->assertSame(['Second', 'https://example.com/b', '1700000000', '', 'unread'], $rows[2]); + } + + public function test_csv_export_imports_back_unchanged(): void + { + Queue::fake(); + $item = Item::factory()->archived()->create(['url' => 'https://example.com/round-trip', 'added_at' => Carbon::createFromTimestamp(1650000000)]); + $item->attachTagNames(['kept']); + + $csv = $this->actingAs(User::factory()->create())->get('/export/csv')->streamedContent(); + + Item::query()->delete(); + + $path = tempnam(sys_get_temp_dir(), 'export'); + file_put_contents($path, $csv); + + try { + app(PocketImporter::class)->import($path); + } finally { + @unlink($path); + } + + $restored = Item::query()->sole(); + $this->assertSame('https://example.com/round-trip', $restored->url); + $this->assertSame(1650000000, $restored->added_at?->getTimestamp()); + $this->assertTrue($restored->isArchived()); + $this->assertSame(['kept'], $restored->tags->pluck('name')->all()); + } + + public function test_json_export_includes_everything(): void + { + $item = Item::factory()->fetched('Body text for the export.')->favourite()->create([ + 'title' => 'Exported', + 'url' => 'https://example.com/json', + 'read_progress' => 0.5, + ]); + $item->attachTagNames(['json']); + + $response = $this->actingAs(User::factory()->create())->get('/export/json'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/json; charset=UTF-8'); + + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame('docket', $data['format']); + $this->assertSame(1, $data['version']); + $this->assertCount(1, $data['items']); + + $exported = $data['items'][0]; + $this->assertSame('Exported', $exported['title']); + $this->assertSame('https://example.com/json', $exported['url']); + $this->assertTrue($exported['is_favourite']); + $this->assertSame(['json'], $exported['tags']); + $this->assertSame('Body text for the export.', $exported['content_text']); + $this->assertSame('

    Body text for the export.

    ', $exported['content_html']); + $this->assertSame(0.5, $exported['read_progress']); + $this->assertSame('unread', $exported['status']); + } + + public function test_an_empty_library_exports_valid_json(): void + { + $content = $this->actingAs(User::factory()->create())->get('/export/json')->streamedContent(); + + $this->assertSame([], json_decode($content, true, flags: JSON_THROW_ON_ERROR)['items']); + } +} diff --git a/tests/Feature/ImportExport/PocketImportTest.php b/tests/Feature/ImportExport/PocketImportTest.php new file mode 100644 index 0000000..0c07431 --- /dev/null +++ b/tests/Feature/ImportExport/PocketImportTest.php @@ -0,0 +1,166 @@ +artisan('docket:import-pocket', ['file' => $this->fixture()]) + ->expectsOutputToContain('Imported 4 new links; 1 already saved; 2 skipped as invalid. 4 queued for extraction.') + ->assertSuccessful(); + + $this->assertSame(4, Item::query()->count()); + + $first = Item::query()->where('normalized_url', 'https://www.example.com/the-web-we-lost')->sole(); + $this->assertSame('The Web We Lost', $first->title); + $this->assertSame(ItemStatus::Archived, $first->status); + $this->assertSame(ItemSource::Pocket, $first->source); + $this->assertSame(1356998400, $first->added_at?->getTimestamp()); + $this->assertSame(1356998400, $first->archived_at?->getTimestamp()); + // Tags from both rows for the same link. + $this->assertSame(['essays', 'history', 'web'], $first->tags->pluck('name')->all()); + + $second = Item::query()->where('url', 'https://blog.example.org/slow-reading')->sole(); + $this->assertSame('Slow Reading, Fast Living', $second->title); + $this->assertSame(ItemStatus::Unread, $second->status); + $this->assertSame(['reading'], $second->tags->pluck('name')->all()); + + // Pocket uses the URL as the title for pages it never parsed. + $this->assertNull(Item::query()->where('url', 'https://news.example.net/untitled-link')->sole()->title); + + $tagged = Item::query()->where('url', 'https://example.co.uk/tags')->sole(); + $this->assertSame(['Laravel', 'PHP'], $tagged->tags->pluck('name')->all()); + } + + public function test_extraction_is_queued_at_a_gentle_rate(): void + { + config(['docket.import.spacing_seconds' => 15]); + + $this->artisan('docket:import-pocket', ['file' => $this->fixture()])->assertSuccessful(); + + $delays = []; + Queue::assertPushed(FetchItemContent::class, function (FetchItemContent $job) use (&$delays): bool { + $delays[] = (int) round(now()->diffInSeconds($job->delay, false)); + + return true; + }); + + sort($delays); + $this->assertSame([0, 15, 30, 45], $delays); + } + + public function test_importing_the_same_file_twice_changes_nothing(): void + { + $this->artisan('docket:import-pocket', ['file' => $this->fixture()])->assertSuccessful(); + + $snapshot = fn (): array => Item::query()->with('tags')->orderBy('id')->get() + ->map(fn (Item $item): array => [ + $item->normalized_url, + $item->title, + $item->status->value, + $item->added_at?->getTimestamp(), + $item->tags->pluck('name')->all(), + ])->all(); + + $before = $snapshot(); + + $this->artisan('docket:import-pocket', ['file' => $this->fixture()]) + ->expectsOutputToContain('Imported 0 new links; 5 already saved; 2 skipped as invalid. 0 queued for extraction.') + ->assertSuccessful(); + + $this->assertSame($before, $snapshot()); + $this->assertSame(6, Tag::query()->count()); + Queue::assertPushed(FetchItemContent::class, 4); + } + + public function test_links_already_saved_keep_their_state_and_gain_tags(): void + { + $existing = Item::factory()->favourite()->create([ + 'url' => 'https://blog.example.org/slow-reading', + 'normalized_url' => 'https://blog.example.org/slow-reading', + 'title' => 'My title', + 'added_at' => now()->subDay(), + ]); + + $this->artisan('docket:import-pocket', ['file' => $this->fixture()])->assertSuccessful(); + + $existing->refresh(); + $this->assertSame('My title', $existing->title); + $this->assertTrue($existing->is_favourite); + $this->assertTrue($existing->added_at->isYesterday()); + $this->assertSame(['reading'], $existing->tags->pluck('name')->all()); + } + + public function test_uploading_the_csv_in_the_browser(): void + { + $file = UploadedFile::fake()->createWithContent('part_000000.csv', (string) file_get_contents($this->fixture())); + + $this->actingAs(User::factory()->create()) + ->post('/import', ['file' => $file]) + ->assertRedirect('/import') + ->assertSessionHas('status', 'Imported 4 new links; 1 already saved; 2 skipped as invalid. 4 queued for extraction.'); + + $this->assertSame(4, Item::query()->count()); + } + + public function test_a_csv_without_a_url_column_is_rejected(): void + { + $file = UploadedFile::fake()->createWithContent('wrong.csv', "name,link\nFoo,https://example.com/\n"); + + $this->actingAs(User::factory()->create()) + ->from('/import') + ->post('/import', ['file' => $file]) + ->assertRedirect('/import') + ->assertSessionHasErrors('file'); + + $this->assertSame(0, Item::query()->count()); + + $this->artisan('docket:import-pocket', ['file' => '/no/such/file.csv'])->assertFailed(); + } + + public function test_a_byte_order_mark_and_header_case_are_tolerated(): void + { + $path = tempnam(sys_get_temp_dir(), 'pocket'); + file_put_contents($path, "\u{FEFF}Title,URL,Time_Added,Tags,Status\nHello,https://example.com/bom,1700000000,,unread\n"); + + try { + $this->artisan('docket:import-pocket', ['file' => $path])->assertSuccessful(); + } finally { + @unlink($path); + } + + $this->assertSame('Hello', Item::query()->sole()->title); + } +} diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php new file mode 100644 index 0000000..c8c4ce6 --- /dev/null +++ b/tests/Feature/InstallCommandTest.php @@ -0,0 +1,145 @@ +envDir = sys_get_temp_dir().'/docket-install-test-'.Str::random(8); + mkdir($this->envDir); + copy(base_path('.env.example'), $this->envDir.'/.env.example'); + + $this->app->useEnvironmentPath($this->envDir); + } + + protected function tearDown(): void + { + @unlink($this->envDir.'/.env'); + @unlink($this->envDir.'/.env.example'); + @rmdir($this->envDir); + + parent::tearDown(); + } + + private function envContents(): string + { + return (string) file_get_contents($this->envDir.'/.env'); + } + + public function test_installs_with_options(): void + { + $this->artisan('docket:install', [ + '--url' => 'https://docket.example.com', + '--name' => 'Jane Doe', + '--email' => 'jane@example.com', + '--password' => 'super-secret-99', + ])->assertSuccessful(); + + $user = User::query()->where('email', 'jane@example.com')->firstOrFail(); + $this->assertSame('Jane Doe', $user->name); + $this->assertTrue(Hash::check('super-secret-99', $user->password)); + + $this->assertTrue(Schema::hasTable('items')); + + $env = $this->envContents(); + $this->assertStringContainsString('APP_URL=https://docket.example.com', $env); + $this->assertStringContainsString('DOCKET_USER_NAME="Jane Doe"', $env); + $this->assertStringContainsString('DOCKET_USER_EMAIL=jane@example.com', $env); + $this->assertMatchesRegularExpression('/^APP_KEY="?base64:/m', $env); + $this->assertStringNotContainsString('super-secret-99', $env); + } + + public function test_non_interactive_install_generates_a_password(): void + { + $this->artisan('docket:install', [ + '--email' => 'owner@example.com', + '--no-interaction' => true, + ])->assertSuccessful(); + + $user = User::query()->where('email', 'owner@example.com')->firstOrFail(); + // The fallback owner password must not be in play. + $this->assertFalse(Hash::check('change-me-on-first-login', $user->password)); + } + + public function test_interactive_install_prompts_for_details(): void + { + $this->artisan('docket:install') + ->expectsQuestion('What URL will Docket be served from?', 'https://docket.example.com') + ->expectsQuestion('Your name', 'Sam Smith') + ->expectsQuestion('Your email (used to log in)', 'sam@example.com') + ->expectsQuestion('Choose a login password', 'another-secret-1') + ->assertSuccessful(); + + $user = User::query()->where('email', 'sam@example.com')->firstOrFail(); + $this->assertTrue(Hash::check('another-secret-1', $user->password)); + } + + public function test_refuses_to_run_when_already_installed(): void + { + User::factory()->create(); + + $this->artisan('docket:install', [ + '--email' => 'jane@example.com', + '--password' => 'super-secret-99', + ])->assertFailed(); + + $this->assertSame(0, User::query()->where('email', 'jane@example.com')->count()); + } + + public function test_force_reruns_without_touching_existing_account(): void + { + $existing = User::factory()->create(['email' => 'jane@example.com']); + $originalPassword = $existing->password; + + $this->artisan('docket:install', [ + '--url' => 'http://localhost', + '--name' => 'Jane Doe', + '--email' => 'jane@example.com', + '--password' => 'a-brand-new-password', + '--force' => true, + ])->assertSuccessful(); + + $this->assertSame(1, User::query()->count()); + $this->assertSame($originalPassword, $existing->refresh()->password); + } + + public function test_rejects_invalid_email(): void + { + $this->artisan('docket:install', [ + '--email' => 'not-an-email', + '--password' => 'super-secret-99', + '--no-interaction' => true, + ])->assertFailed(); + + $this->assertSame(0, User::query()->count()); + } + + public function test_rejects_short_password(): void + { + $this->artisan('docket:install', [ + '--email' => 'jane@example.com', + '--password' => 'short', + '--no-interaction' => true, + ])->assertFailed(); + + $this->assertSame(0, User::query()->count()); + } +} diff --git a/tests/Feature/Items/ApiItemsTest.php b/tests/Feature/Items/ApiItemsTest.php new file mode 100644 index 0000000..ea520a8 --- /dev/null +++ b/tests/Feature/Items/ApiItemsTest.php @@ -0,0 +1,143 @@ +create()->createToken('Shortcut')->plainTextToken; + } + + public function test_requests_without_a_token_are_refused(): void + { + $this->postJson('/api/items', ['url' => 'https://example.com/']) + ->assertUnauthorized(); + + $this->assertSame(0, Item::query()->count()); + } + + public function test_requests_with_a_wrong_token_are_refused(): void + { + $this->token(); + + $this->withToken('1|not-the-right-token') + ->postJson('/api/items', ['url' => 'https://example.com/']) + ->assertUnauthorized(); + + $this->assertSame(0, Item::query()->count()); + } + + public function test_saves_a_link_with_title_and_tags(): void + { + $response = $this->withToken($this->token()) + ->postJson('/api/items', [ + 'url' => 'https://example.com/long-read', + 'title' => 'A long read', + 'tags' => ['weekend', 'essays'], + ]); + + $response->assertCreated() + ->assertJsonPath('created', true) + ->assertJsonPath('data.url', 'https://example.com/long-read') + ->assertJsonPath('data.title', 'A long read') + ->assertJsonPath('data.status', 'unread') + ->assertJsonPath('data.tags', ['essays', 'weekend']); + + $item = Item::query()->sole(); + $this->assertSame(ItemSource::Api, $item->source); + $response->assertJsonPath('data.web_url', route('items.show', $item)); + + Queue::assertPushed(FetchItemContent::class); + } + + public function test_tags_may_be_a_comma_separated_string(): void + { + $this->withToken($this->token()) + ->postJson('/api/items', ['url' => 'https://example.com/', 'tags' => 'one, two,,one']) + ->assertCreated() + ->assertJsonPath('data.tags', ['one', 'two']); + } + + public function test_an_existing_link_returns_200_and_is_not_duplicated(): void + { + $token = $this->token(); + + $this->withToken($token)->postJson('/api/items', ['url' => 'https://example.com/a?utm_medium=share'])->assertCreated(); + + $this->withToken($token) + ->postJson('/api/items', ['url' => 'https://EXAMPLE.com/a']) + ->assertOk() + ->assertJsonPath('created', false); + + $this->assertSame(1, Item::query()->count()); + } + + public function test_invalid_input_gets_a_422_with_errors(): void + { + $token = $this->token(); + + $this->withToken($token)->postJson('/api/items', []) + ->assertUnprocessable() + ->assertJsonValidationErrors('url'); + + $this->withToken($token)->postJson('/api/items', ['url' => 'file:///etc/passwd']) + ->assertUnprocessable() + ->assertJsonValidationErrors('url'); + + $this->withToken($token)->postJson('/api/items', ['url' => 'https://example.com/', 'tags' => 42]) + ->assertUnprocessable() + ->assertJsonValidationErrors('tags'); + + $this->assertSame(0, Item::query()->count()); + } + + public function test_tokens_are_created_and_revoked_from_settings(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/settings/tokens', ['name' => 'iPhone']); + $response->assertRedirect('/settings'); + + $plain = session('plain_token'); + $this->assertIsString($plain); + $this->assertSame(1, $user->tokens()->count()); + + // Shown once, on the page right after creation. + $this->actingAs($user)->get('/settings')->assertSee($plain); + $this->actingAs($user)->get('/settings')->assertDontSee($plain); + + // Drop the logged-in session so only the token can authenticate. + $this->app['auth']->forgetGuards(); + $this->withToken($plain)->postJson('/api/items', ['url' => 'https://example.com/'])->assertCreated(); + + $tokenId = $user->tokens()->sole()->id; + $this->app['auth']->forgetGuards(); + $this->actingAs($user)->delete("/settings/tokens/{$tokenId}")->assertRedirect('/settings'); + + $this->assertSame(0, $user->tokens()->count()); + + $this->app['auth']->forgetGuards(); + $this->withToken($plain)->postJson('/api/items', ['url' => 'https://example.com/other'])->assertUnauthorized(); + } +} diff --git a/tests/Feature/Items/FetchItemContentTest.php b/tests/Feature/Items/FetchItemContentTest.php new file mode 100644 index 0000000..af5cba7 --- /dev/null +++ b/tests/Feature/Items/FetchItemContentTest.php @@ -0,0 +1,315 @@ +create($attributes + [ + 'url' => $url, + 'normalized_url' => app(UrlNormaliser::class)->normalise($url), + 'title' => null, + ]); + } + + private function fakeArticle(): void + { + $png = (string) base64_decode(self::PNG); + + Http::fake([ + self::ARTICLE_URL => Http::response( + (string) file_get_contents(base_path('tests/Fixtures/article.html')), + 200, + ['Content-Type' => 'text/html; charset=utf-8'] + ), + 'https://example.com/images/reading-room.png' => Http::response($png, 200, ['Content-Type' => 'image/png']), + 'https://cdn.example.com/lead.png' => Http::response($png, 200, ['Content-Type' => 'image/png']), + 'https://cdn.example.com/diagram.svg' => Http::response( + '', + 200, + ['Content-Type' => 'image/svg+xml'] + ), + ]); + } + + public function test_extracts_the_article_and_its_metadata(): void + { + $this->fakeArticle(); + $item = $this->item(); + + FetchItemContent::dispatchSync($item); + $item->refresh(); + + $this->assertSame(FetchStatus::Fetched, $item->fetch_status); + $this->assertNull($item->fetch_error); + $this->assertNotNull($item->fetched_at); + $this->assertSame(1, $item->fetch_attempts); + + $this->assertSame('Why slow reading matters', $item->title); + $this->assertSame('The Example Review', $item->site_name); + $this->assertSame('Jane Writer', $item->author); + $this->assertSame('A short case for reading fewer things, more carefully.', $item->excerpt); + $this->assertSame('en-GB', $item->language); + $this->assertSame('2025-03-14', $item->published_at?->toDateString()); + $this->assertSame('https://cdn.example.com/lead.png', $item->lead_image_url); + + $this->assertStringContainsString('Slow reading is the opposite habit', (string) $item->content_html); + $this->assertStringContainsString('Slow reading is the opposite habit', (string) $item->content_text); + $this->assertStringNotContainsString('content_text); + + $this->assertGreaterThan(150, $item->word_count); + $this->assertSame((int) ceil($item->word_count / 230), $item->reading_time); + } + + public function test_stored_html_is_sanitised(): void + { + $this->fakeArticle(); + $item = $this->item(); + + FetchItemContent::dispatchSync($item); + $html = (string) $item->refresh()->content_html; + + foreach (['assertStringNotContainsStringIgnoringCase($unsafe, $html, "Stored HTML still contains {$unsafe}"); + } + + // Safe markup survives, with links hardened. + $this->assertStringContainsString('href="https://www.example.org/further-reading"', $html); + $this->assertStringContainsString('rel="noopener noreferrer nofollow"', $html); + $this->assertStringContainsString('This link should lose its href.', $html); + } + + public function test_inline_images_are_downloaded_and_rewritten(): void + { + $this->fakeArticle(); + $item = $this->item(); + + FetchItemContent::dispatchSync($item); + $item->refresh(); + + $filename = sha1('https://example.com/images/reading-room.png').'.png'; + $path = "items/{$item->id}/{$filename}"; + + Storage::disk(Item::DISK)->assertExists($path); + $this->assertStringContainsString( + 'src="'.route('items.image', ['item' => $item, 'filename' => $filename], false).'"', + (string) $item->content_html + ); + $this->assertStringNotContainsString('src="https://example.com/images/reading-room.png"', (string) $item->content_html); + + // The lead image is kept locally too. + $this->assertSame(sha1('https://cdn.example.com/lead.png').'.png', $item->lead_image_path); + + // SVG is never stored: it could carry scripts and would be served + // from this origin. + foreach (Storage::disk(Item::DISK)->files("items/{$item->id}") as $file) { + $this->assertStringEndsWith('.png', $file); + } + + // And the stored copy is served to the owner. + $this->actingAs(User::factory()->create()) + ->get(route('items.image', ['item' => $item, 'filename' => $filename])) + ->assertOk() + ->assertHeader('Content-Type', 'image/png') + ->assertHeader('X-Content-Type-Options', 'nosniff'); + } + + public function test_images_are_only_served_to_the_owner_and_only_by_exact_name(): void + { + $item = $this->item(); + $filename = sha1('x').'.png'; + Storage::disk(Item::DISK)->put("items/{$item->id}/{$filename}", (string) base64_decode(self::PNG)); + + $this->get(route('items.image', ['item' => $item, 'filename' => $filename]))->assertRedirect('/login'); + + $user = User::factory()->create(); + $this->actingAs($user)->get("/items/{$item->id}/images/".sha1('missing').'.png')->assertNotFound(); + $this->actingAs($user)->get("/items/{$item->id}/images/..%2F..%2F.env")->assertNotFound(); + } + + public function test_deleting_an_item_removes_its_stored_images(): void + { + $this->fakeArticle(); + $item = $this->item(); + FetchItemContent::dispatchSync($item); + + $this->assertNotEmpty(Storage::disk(Item::DISK)->files("items/{$item->id}")); + + $this->actingAs(User::factory()->create())->delete("/items/{$item->id}")->assertRedirect(); + + $this->assertModelMissing($item); + Storage::disk(Item::DISK)->assertMissing("items/{$item->id}"); + } + + public function test_a_page_title_from_the_bookmarklet_is_replaced_by_the_extracted_one(): void + { + $this->fakeArticle(); + $item = $this->item(['title' => 'Why slow reading matters | The Example Review | Home', 'source' => ItemSource::Bookmarklet]); + + FetchItemContent::dispatchSync($item); + + $this->assertSame('Why slow reading matters', $item->refresh()->title); + } + + public function test_a_title_given_by_hand_is_kept(): void + { + $this->fakeArticle(); + $item = $this->item(['title' => 'My own title', 'source' => ItemSource::Web]); + + FetchItemContent::dispatchSync($item); + + $this->assertSame('My own title', $item->refresh()->title); + } + + public function test_a_missing_page_is_recorded_as_a_failure_without_retrying(): void + { + Http::fake([self::ARTICLE_URL => Http::response('Not found', 404)]); + $item = $this->item(); + + FetchItemContent::dispatchSync($item); + $item->refresh(); + + $this->assertSame(FetchStatus::Failed, $item->fetch_status); + $this->assertSame('The site responded with HTTP 404.', $item->fetch_error); + $this->assertSame(1, $item->fetch_attempts); + } + + public function test_a_transient_failure_is_retried_while_attempts_remain(): void + { + Http::fake([self::ARTICLE_URL => Http::response('Busy', 503)]); + $item = $this->item(); + + $queueJob = Mockery::mock(Job::class); + $queueJob->shouldReceive('attempts')->andReturn(1); + + $job = new FetchItemContent($item); + $job->setJob($queueJob); + + try { + app()->call([$job, 'handle']); + $this->fail('A transient failure on the first attempt should be rethrown for the queue to retry.'); + } catch (FetchFailedException $e) { + $this->assertFalse($e->permanent); + } + + $this->assertSame(FetchStatus::Pending, $item->refresh()->fetch_status); + } + + public function test_a_transient_failure_on_the_last_attempt_is_recorded(): void + { + Http::fake([self::ARTICLE_URL => Http::response('Busy', 503)]); + $item = $this->item(); + + FetchItemContent::dispatchSync($item); + $item->refresh(); + + $this->assertSame(FetchStatus::Failed, $item->fetch_status); + $this->assertSame('The site responded with HTTP 503.', $item->fetch_error); + } + + public function test_links_that_are_not_web_pages_are_recorded_as_failures(): void + { + Http::fake([self::ARTICLE_URL => Http::response('%PDF-1.7', 200, ['Content-Type' => 'application/pdf'])]); + $item = $this->item(); + + FetchItemContent::dispatchSync($item); + $item->refresh(); + + $this->assertSame(FetchStatus::Failed, $item->fetch_status); + $this->assertStringContainsString('application/pdf', (string) $item->fetch_error); + } + + public function test_a_page_without_an_article_is_recorded_as_a_failure(): void + { + Http::fake([self::ARTICLE_URL => Http::response('Empty', 200, ['Content-Type' => 'text/html'])]); + $item = $this->item(); + + FetchItemContent::dispatchSync($item); + $item->refresh(); + + $this->assertSame(FetchStatus::Failed, $item->fetch_status); + $this->assertStringContainsString('Could not find any article text', (string) $item->fetch_error); + } + + public function test_a_legacy_charset_is_converted_to_utf8(): void + { + $html = (string) file_get_contents(base_path('tests/Fixtures/article.html')); + $html = str_replace('Most of what we read', 'Café culture: most of what we read', $html); + + Http::fake([ + self::ARTICLE_URL => Http::response( + (string) mb_convert_encoding($html, 'ISO-8859-1', 'UTF-8'), + 200, + ['Content-Type' => 'text/html; charset=ISO-8859-1'] + ), + '*' => Http::response('', 404), + ]); + $item = $this->item(); + + FetchItemContent::dispatchSync($item); + + $this->assertStringContainsString('Café culture', (string) $item->refresh()->content_text); + } + + public function test_failed_items_can_be_retried(): void + { + Queue::fake(); + $item = Item::factory()->failed()->create(); + + $this->actingAs(User::factory()->create()) + ->from('/') + ->post("/items/{$item->id}/retry") + ->assertRedirect('/'); + + $item->refresh(); + $this->assertSame(FetchStatus::Pending, $item->fetch_status); + $this->assertNull($item->fetch_error); + $this->assertSame(0, $item->fetch_attempts); + + Queue::assertPushed(FetchItemContent::class, fn (FetchItemContent $job) => $job->item->is($item)); + } + + public function test_the_job_retries_on_the_configured_schedule(): void + { + config(['docket.fetch.tries' => 4, 'docket.fetch.backoff' => [5, 25]]); + + $job = new FetchItemContent($this->item()); + + $this->assertSame(4, $job->tries()); + $this->assertSame([5, 25], $job->backoff()); + } +} diff --git a/tests/Feature/Items/OrganisingTest.php b/tests/Feature/Items/OrganisingTest.php new file mode 100644 index 0000000..009a551 --- /dev/null +++ b/tests/Feature/Items/OrganisingTest.php @@ -0,0 +1,216 @@ +user = User::factory()->create(); + } + + public function test_the_home_page_is_the_unread_list(): void + { + $unread = Item::factory()->create(['title' => 'Still to read']); + Item::factory()->archived()->create(['title' => 'Done with this']); + + $this->actingAs($this->user)->get('/') + ->assertOk() + ->assertSee($unread->title) + ->assertDontSee('Done with this'); + } + + public function test_archive_and_favourites_lists(): void + { + Item::factory()->create(['title' => 'Unread one']); + Item::factory()->archived()->create(['title' => 'Archived one']); + Item::factory()->archived()->favourite()->create(['title' => 'Archived favourite']); + + $this->actingAs($this->user)->get('/archive') + ->assertSee('Archived one') + ->assertSee('Archived favourite') + ->assertDontSee('Unread one'); + + $this->actingAs($this->user)->get('/favourites') + ->assertSee('Archived favourite') + ->assertDontSee('Archived one') + ->assertDontSee('Unread one'); + } + + public function test_lists_sort_by_newest_oldest_or_longest(): void + { + Item::factory()->create(['title' => 'Piece Bravo', 'added_at' => now()->subDays(2), 'word_count' => 9000]); + Item::factory()->create(['title' => 'Piece Charlie', 'added_at' => now()->subDay(), 'word_count' => 100]); + Item::factory()->create(['title' => 'Piece Alpha', 'added_at' => now()->subDays(3), 'word_count' => 500]); + + $this->actingAs($this->user)->get('/')->assertSeeInOrder(['Piece Charlie', 'Piece Bravo', 'Piece Alpha']); + $this->actingAs($this->user)->get('/?sort=oldest')->assertSeeInOrder(['Piece Alpha', 'Piece Bravo', 'Piece Charlie']); + $this->actingAs($this->user)->get('/?sort=longest')->assertSeeInOrder(['Piece Bravo', 'Piece Alpha', 'Piece Charlie']); + $this->actingAs($this->user)->get('/?sort=nonsense')->assertSeeInOrder(['Piece Charlie', 'Piece Bravo', 'Piece Alpha']); + } + + public function test_archive_unarchive_favourite_and_unfavourite(): void + { + $item = Item::factory()->create(); + + $this->actingAs($this->user)->from('/')->post("/items/{$item->id}/archive")->assertRedirect('/'); + $this->assertSame(ItemStatus::Archived, $item->refresh()->status); + $this->assertNotNull($item->archived_at); + + $this->actingAs($this->user)->from('/archive')->delete("/items/{$item->id}/archive")->assertRedirect('/archive'); + $this->assertSame(ItemStatus::Unread, $item->refresh()->status); + $this->assertNull($item->archived_at); + + $this->actingAs($this->user)->postJson("/items/{$item->id}/favourite") + ->assertOk() + ->assertJsonPath('is_favourite', true); + $this->assertTrue($item->refresh()->is_favourite); + + $this->actingAs($this->user)->deleteJson("/items/{$item->id}/favourite")->assertJsonPath('is_favourite', false); + $this->assertFalse($item->refresh()->is_favourite); + $this->assertNull($item->favourited_at); + } + + public function test_archiving_from_the_reader_returns_to_the_list(): void + { + $item = Item::factory()->create(); + + $this->actingAs($this->user) + ->post("/items/{$item->id}/archive", ['then' => 'list']) + ->assertRedirect(route('items.index')); + } + + public function test_tags_are_added_in_the_reader_and_listed(): void + { + $item = Item::factory()->create(); + + $this->actingAs($this->user) + ->patch("/items/{$item->id}", ['title' => 'Renamed', 'tags' => 'Laravel, php, laravel, ']) + ->assertSessionHasNoErrors(); + + $item->refresh(); + $this->assertSame('Renamed', $item->title); + $this->assertSame(['Laravel', 'php'], $item->tags->pluck('name')->all()); + + $this->actingAs($this->user)->get('/tags/laravel')->assertOk()->assertSee('Renamed'); + $this->actingAs($this->user)->get('/tags')->assertOk()->assertSee('Laravel')->assertSee('1 item'); + + // Clearing the field removes every tag (the tags themselves stay). + $this->actingAs($this->user)->patch("/items/{$item->id}", ['tags' => '']); + $this->assertCount(0, $item->refresh()->tags); + $this->assertSame(2, Tag::query()->count()); + } + + public function test_tags_can_be_renamed_merged_and_deleted(): void + { + $a = Item::factory()->create(); + $b = Item::factory()->create(); + $a->attachTagNames(['js']); + $b->attachTagNames(['JavaScript']); + + $js = Tag::query()->where('slug', 'js')->sole(); + $this->actingAs($this->user)->patch("/tags/{$js->slug}", ['name' => 'javascript'])->assertRedirect('/tags'); + + $this->assertModelMissing($js); + $merged = Tag::query()->where('slug', 'javascript')->sole(); + $this->assertSame(2, $merged->items()->count()); + + $this->actingAs($this->user)->patch("/tags/{$merged->slug}", ['name' => 'Web dev']); + $this->assertSame('Web dev', $merged->refresh()->name); + $this->assertSame('web-dev', $merged->slug); + + $this->actingAs($this->user)->delete('/tags/web-dev')->assertRedirect('/tags'); + $this->assertSame(0, Tag::query()->count()); + $this->assertSame(2, Item::query()->count()); + } + + public function test_bulk_archive_favourite_tag_and_retry(): void + { + Queue::fake(); + $items = Item::factory()->count(3)->failed()->create(); + $ids = $items->pluck('id')->take(2)->all(); + + foreach (['archive', 'favourite', 'retry'] as $action) { + $this->actingAs($this->user)->from('/') + ->post('/items/bulk', ['action' => $action, 'ids' => $ids]) + ->assertRedirect('/') + ->assertSessionHasNoErrors(); + } + + $this->actingAs($this->user)->post('/items/bulk', ['action' => 'tag', 'ids' => $ids, 'tags' => 'batch, weekend']); + + foreach ($items as $index => $item) { + $item->refresh(); + $selected = $index < 2; + + $this->assertSame($selected ? ItemStatus::Archived : ItemStatus::Unread, $item->status); + $this->assertSame($selected, $item->is_favourite); + $this->assertSame($selected ? FetchStatus::Pending : FetchStatus::Failed, $item->fetch_status); + $this->assertSame($selected ? ['batch', 'weekend'] : [], $item->tags->pluck('name')->all()); + } + + Queue::assertPushed(FetchItemContent::class, 2); + + $this->actingAs($this->user)->post('/items/bulk', ['action' => 'unarchive', 'ids' => $ids]); + $this->assertSame(0, Item::query()->archived()->count()); + } + + public function test_bulk_delete_removes_items_and_their_files(): void + { + Storage::fake(Item::DISK); + $doomed = Item::factory()->create(); + $kept = Item::factory()->create(); + Storage::disk(Item::DISK)->put("items/{$doomed->id}/".sha1('a').'.png', 'x'); + + $this->actingAs($this->user) + ->post('/items/bulk', ['action' => 'delete', 'ids' => [$doomed->id]]) + ->assertSessionHas('status', 'Delete: 1 item.'); + + $this->assertModelMissing($doomed); + $this->assertModelExists($kept); + Storage::disk(Item::DISK)->assertMissing("items/{$doomed->id}"); + } + + public function test_bulk_actions_need_a_valid_action_and_a_selection(): void + { + $item = Item::factory()->create(); + + $this->actingAs($this->user)->from('/')->post('/items/bulk', ['action' => 'archive']) + ->assertSessionHasErrors('ids'); + $this->actingAs($this->user)->from('/')->post('/items/bulk', ['action' => 'drop-tables', 'ids' => [$item->id]]) + ->assertSessionHasErrors('action'); + $this->actingAs($this->user)->from('/')->post('/items/bulk', ['action' => 'tag', 'ids' => [$item->id]]) + ->assertSessionHasErrors('tags'); + + $this->assertSame(ItemStatus::Unread, $item->refresh()->status); + } + + public function test_deleting_a_single_item(): void + { + $item = Item::factory()->create(); + + $this->actingAs($this->user)->from('/archive')->delete("/items/{$item->id}")->assertRedirect('/archive'); + $this->assertModelMissing($item); + + // From the reader there is nothing to go back to, so it's the list. + $other = Item::factory()->create(); + $this->actingAs($this->user)->from("/items/{$other->id}")->delete("/items/{$other->id}")->assertRedirect('/'); + } +} diff --git a/tests/Feature/Items/ReaderTest.php b/tests/Feature/Items/ReaderTest.php new file mode 100644 index 0000000..ce2c200 --- /dev/null +++ b/tests/Feature/Items/ReaderTest.php @@ -0,0 +1,61 @@ +fetched('A quiet paragraph about reading.')->create([ + 'title' => 'On reading', + 'url' => 'https://example.com/on-reading', + 'read_progress' => 0.42, + ]); + + $this->actingAs(User::factory()->create()) + ->get("/items/{$item->id}") + ->assertOk() + ->assertSee('On reading') + ->assertSee('

    A quiet paragraph about reading.

    ', false) + ->assertSee('href="https://example.com/on-reading"', false) + ->assertSee('reader({ progressUrl:', false) + ->assertSee('progress: 0.42', false) + ->assertSee('Serif') + ->assertSee('Sepia') + ->assertSee('Dark'); + + $this->assertNotNull($item->refresh()->last_read_at); + } + + public function test_reading_progress_is_saved(): void + { + $item = Item::factory()->fetched()->create(); + $user = User::factory()->create(); + + $this->actingAs($user)->post("/items/{$item->id}/progress", ['progress' => 0.6789])->assertNoContent(); + $this->assertEqualsWithDelta(0.6789, $item->refresh()->read_progress, 0.0001); + + $this->actingAs($user)->post("/items/{$item->id}/progress", ['progress' => 1.5])->assertSessionHasErrors('progress'); + $this->actingAs($user)->postJson("/items/{$item->id}/progress", ['progress' => 'half'])->assertUnprocessable(); + $this->assertEqualsWithDelta(0.6789, $item->refresh()->read_progress, 0.0001); + } + + public function test_an_item_without_an_article_offers_the_original_and_a_retry(): void + { + $item = Item::factory()->failed('The site responded with HTTP 403.')->create(['url' => 'https://example.com/paywalled']); + + $this->actingAs(User::factory()->create()) + ->get("/items/{$item->id}") + ->assertOk() + ->assertSee('The site responded with HTTP 403.') + ->assertSee('Open the original') + ->assertSee('Try again'); + } +} diff --git a/tests/Feature/Items/SaveFromBookmarkletAndShareTest.php b/tests/Feature/Items/SaveFromBookmarkletAndShareTest.php new file mode 100644 index 0000000..b5af08b --- /dev/null +++ b/tests/Feature/Items/SaveFromBookmarkletAndShareTest.php @@ -0,0 +1,116 @@ +actingAs(User::factory()->create()) + ->get('/save?url='.urlencode('https://example.com/article').'&title='.urlencode('An article | Example')) + ->assertOk() + ->assertSee('Saved to Docket') + ->assertSee('An article | Example'); + + $item = Item::query()->sole(); + $this->assertSame(ItemSource::Bookmarklet, $item->source); + $this->assertSame('An article | Example', $item->title); + + Queue::assertPushed(FetchItemContent::class); + } + + public function test_the_bookmarklet_reports_a_link_that_is_already_saved(): void + { + $user = User::factory()->create(); + $this->actingAs($user)->get('/save?url='.urlencode('https://example.com/article')); + + $this->actingAs($user) + ->get('/save?url='.urlencode('https://example.com/article#section-2')) + ->assertOk() + ->assertSee('Already in Docket'); + + $this->assertSame(1, Item::query()->count()); + } + + public function test_the_bookmarklet_rejects_pages_it_cannot_save(): void + { + $this->actingAs(User::factory()->create()) + ->get('/save?url='.urlencode('chrome://settings')) + ->assertStatus(422) + ->assertSee('Not saved'); + + $this->assertSame(0, Item::query()->count()); + } + + public function test_the_bookmarklet_sends_guests_to_log_in_and_back(): void + { + $url = '/save?url='.urlencode('https://example.com/article'); + + $this->get($url)->assertRedirect('/login'); + + $user = User::factory()->create(['password' => 'password']); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']) + ->assertRedirect($url); + } + + public function test_android_share_with_the_link_in_the_text_field(): void + { + // Most Android apps put the link in "text", often after a headline. + $this->actingAs(User::factory()->create()) + ->get('/share?title='.urlencode('Great read').'&text='.urlencode('Have a look at this: https://example.com/story?id=7. Worth it')) + ->assertRedirect('/') + ->assertSessionHas('status', 'Saved. The article is being fetched.'); + + $item = Item::query()->sole(); + $this->assertSame('https://example.com/story?id=7', $item->url); + $this->assertSame(ItemSource::Share, $item->source); + $this->assertSame('Great read', $item->title); + } + + public function test_android_share_with_the_link_in_the_url_field(): void + { + $this->actingAs(User::factory()->create()) + ->get('/share?url='.urlencode('https://example.com/direct')) + ->assertRedirect('/'); + + $this->assertSame('https://example.com/direct', Item::query()->sole()->url); + } + + public function test_android_share_without_a_link_saves_nothing(): void + { + $this->actingAs(User::factory()->create()) + ->get('/share?text='.urlencode('just some words')) + ->assertRedirect('/') + ->assertSessionHas('error'); + + $this->assertSame(0, Item::query()->count()); + } + + public function test_the_manifest_registers_the_share_target(): void + { + $manifest = json_decode((string) file_get_contents(public_path('manifest.webmanifest')), true); + + $this->assertSame('/share', $manifest['share_target']['action']); + $this->assertSame('GET', $manifest['share_target']['method']); + $this->assertSame(['title' => 'title', 'text' => 'text', 'url' => 'url'], $manifest['share_target']['params']); + $this->assertSame(url('/share'), route('save.share')); + } +} diff --git a/tests/Feature/Items/SaveFromWebTest.php b/tests/Feature/Items/SaveFromWebTest.php new file mode 100644 index 0000000..162de73 --- /dev/null +++ b/tests/Feature/Items/SaveFromWebTest.php @@ -0,0 +1,103 @@ +actingAs(User::factory()->create()) + ->from('/') + ->post('/items', ['url' => 'https://example.com/post?utm_source=feed', 'tags' => 'reading, Later']) + ->assertRedirect('/') + ->assertSessionHas('status', 'Saved. The article is being fetched.'); + + $item = Item::query()->sole(); + + $this->assertSame('https://example.com/post?utm_source=feed', $item->url); + $this->assertSame('https://example.com/post', $item->normalized_url); + $this->assertSame(ItemSource::Web, $item->source); + $this->assertSame(ItemStatus::Unread, $item->status); + $this->assertNotNull($item->added_at); + $this->assertSame(['Later', 'reading'], $item->tags->pluck('name')->all()); + + Queue::assertPushed(FetchItemContent::class, fn (FetchItemContent $job) => $job->item->is($item)); + } + + public function test_a_link_without_a_scheme_is_saved_as_https(): void + { + $this->actingAs(User::factory()->create()) + ->post('/items', ['url' => 'example.com/no-scheme']); + + $this->assertSame('https://example.com/no-scheme', Item::query()->sole()->url); + } + + public function test_saving_the_same_link_again_does_not_duplicate_it(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->post('/items', ['url' => 'https://Example.com:443/a/?b=2&a=1#comments']); + + $item = Item::query()->sole(); + $item->archive(); + $item->forceFill(['added_at' => now()->subYear()])->save(); + + $this->actingAs($user) + ->post('/items', ['url' => 'https://example.com/a/?a=1&b=2&fbclid=abc', 'tags' => 'again']) + ->assertSessionHas('status', 'Already saved; moved it back to the top of your list.'); + + $this->assertSame(1, Item::query()->count()); + + $item->refresh(); + $this->assertSame(ItemStatus::Unread, $item->status, 'Saving again moves it back to unread, as Pocket did.'); + $this->assertTrue($item->added_at->isToday()); + $this->assertSame(['again'], $item->tags->pluck('name')->all()); + + Queue::assertPushed(FetchItemContent::class, 1); + } + + public function test_invalid_links_are_rejected_with_a_message(): void + { + $user = User::factory()->create(); + + foreach (['javascript:alert(1)', 'ftp://example.com/file', 'not a url at all', ''] as $url) { + $this->actingAs($user) + ->from('/') + ->post('/items', ['url' => $url]) + ->assertRedirect('/') + ->assertSessionHasErrors('url'); + } + + $this->assertSame(0, Item::query()->count()); + Queue::assertNothingPushed(); + } + + public function test_guests_cannot_save(): void + { + $this->post('/items', ['url' => 'https://example.com/'])->assertRedirect('/login'); + + $this->assertSame(0, Item::query()->count()); + } +} diff --git a/tests/Feature/Items/SearchTest.php b/tests/Feature/Items/SearchTest.php new file mode 100644 index 0000000..6bbd020 --- /dev/null +++ b/tests/Feature/Items/SearchTest.php @@ -0,0 +1,108 @@ +user = User::factory()->create(); + } + + public function test_finds_items_by_title_site_and_article_text(): void + { + Item::factory()->create(['title' => 'Gardening for beginners']); + Item::factory()->fetched('Sourdough needs patience and a warm kitchen.')->create(['title' => 'Bread notes']); + Item::factory()->create(['title' => 'Unrelated', 'site_name' => 'The Allotment Gazette']); + + $this->actingAs($this->user)->get('/search?q=gardening')->assertSee('Gardening for beginners')->assertDontSee('Bread notes'); + $this->actingAs($this->user)->get('/search?q=sourdough')->assertSee('Bread notes')->assertDontSee('Gardening'); + $this->actingAs($this->user)->get('/search?q=allotment')->assertSee('Unrelated'); + } + + public function test_matches_word_prefixes_and_requires_every_word(): void + { + Item::factory()->create(['title' => 'Programming in Laravel']); + Item::factory()->create(['title' => 'Programming in Rust']); + + $this->actingAs($this->user)->get('/search?q=progr') + ->assertSee('Programming in Laravel') + ->assertSee('Programming in Rust'); + + $this->actingAs($this->user)->get('/search?q=programming+laravel') + ->assertSee('Programming in Laravel') + ->assertDontSee('Programming in Rust'); + } + + public function test_ignores_accents_and_case(): void + { + Item::factory()->create(['title' => 'Café Society']); + + $this->actingAs($this->user)->get('/search?q=CAFE')->assertSee('Café Society'); + } + + public function test_the_index_follows_updates_and_deletes(): void + { + $item = Item::factory()->create(['title' => 'Before the edit']); + + $item->update(['title' => 'After the edit']); + $this->actingAs($this->user)->get('/search?q=before')->assertDontSee('After the edit'); + $this->actingAs($this->user)->get('/search?q=after')->assertSee('After the edit'); + + // A bulk query-builder write skips model events; the triggers still see it. + DB::table('items')->where('id', $item->id)->update(['content_text' => 'zeppelin']); + $this->actingAs($this->user)->get('/search?q=zeppelin')->assertSee('After the edit'); + + $item->delete(); + $this->assertSame(0, (int) DB::table('items_fts')->whereRaw('items_fts MATCH ?', ['zeppelin'])->count()); + } + + public function test_search_within_a_list_stays_in_that_list(): void + { + Item::factory()->create(['title' => 'Kettle unread']); + Item::factory()->archived()->create(['title' => 'Kettle archived']); + + $this->actingAs($this->user)->get('/archive?q=kettle') + ->assertSee('Kettle archived') + ->assertDontSee('Kettle unread'); + + $this->actingAs($this->user)->get('/search?q=kettle') + ->assertSee('Kettle archived') + ->assertSee('Kettle unread'); + } + + public function test_title_matches_rank_above_body_matches(): void + { + Item::factory()->fetched('This article mentions owls once, in passing, among many other birds and trees.')->create(['title' => 'Birds of the hedgerow']); + Item::factory()->create(['title' => 'Owls']); + + $this->actingAs($this->user)->get('/search?q=owls')->assertSeeInOrder(['Owls', 'Birds of the hedgerow']); + } + + public function test_awkward_input_never_breaks_the_query(): void + { + Item::factory()->create(['title' => 'C++ for "experts"']); + + foreach (['"', 'AND', 'NOT OR', '*', 'c++', 'experts"', '(', 'title:experts', "'; DROP TABLE items; --"] as $q) { + $this->actingAs($this->user)->get('/search?q='.urlencode($q))->assertOk(); + } + + $this->assertSame(1, Item::query()->count()); + } +} diff --git a/tests/Feature/Items/SsrfProtectionTest.php b/tests/Feature/Items/SsrfProtectionTest.php new file mode 100644 index 0000000..fe209b9 --- /dev/null +++ b/tests/Feature/Items/SsrfProtectionTest.php @@ -0,0 +1,223 @@ + + */ + public static function blockedLiteralUrls(): array + { + return [ + 'IPv4 loopback' => ['http://127.0.0.1/admin'], + 'loopback, other address' => ['http://127.1.2.3:8080/'], + 'IPv6 loopback' => ['http://[::1]/'], + 'IPv4-mapped IPv6 loopback' => ['http://[::ffff:127.0.0.1]/'], + 'private 10/8' => ['http://10.0.0.1/'], + 'private 172.16/12' => ['https://172.16.5.4/'], + 'private 192.168/16' => ['http://192.168.1.1/router'], + 'cloud metadata (link-local)' => ['http://169.254.169.254/latest/meta-data/'], + 'carrier-grade NAT' => ['http://100.64.0.1/'], + 'unspecified' => ['http://0.0.0.0/'], + 'IPv6 unique local' => ['http://[fd00::1]/'], + 'IPv6 link-local' => ['http://[fe80::1]/'], + ]; + } + + #[DataProvider('blockedLiteralUrls')] + public function test_private_and_local_addresses_are_refused(string $url): void + { + Http::fake(); + + try { + $this->client()->get($url, 1024); + $this->fail("{$url} should have been refused."); + } catch (UnsafeUrlException $e) { + $this->assertTrue($e->permanent); + } + + Http::assertNothingSent(); + } + + public function test_hostnames_are_checked_after_dns_resolution(): void + { + Http::fake(); + $this->dns->set('intranet.example.com', '10.1.2.3'); + $this->dns->set('localhost', '127.0.0.1'); + + foreach (['https://intranet.example.com/', 'http://localhost:3000/'] as $url) { + try { + $this->client()->get($url, 1024); + $this->fail("{$url} should have been refused."); + } catch (UnsafeUrlException) { + // Expected. + } + } + + Http::assertNothingSent(); + } + + public function test_a_host_with_any_private_address_is_refused(): void + { + // A public and a private A record: picking the "good" one is not + // enough, because the client cannot guarantee which gets used. + Http::fake(); + $this->dns->set('split.example.com', ['93.184.216.34', '192.168.0.10']); + + $this->expectException(UnsafeUrlException::class); + + $this->client()->get('https://split.example.com/', 1024); + } + + public function test_redirects_into_the_private_network_are_refused(): void + { + $this->dns->set('internal.example.com', '192.168.1.20'); + Http::fake([ + 'https://public.example.com/start' => Http::response('', 302, ['Location' => 'http://internal.example.com/secret']), + '*' => Http::response('should never be fetched'), + ]); + + try { + $this->client()->get('https://public.example.com/start', 1024); + $this->fail('The redirect should have been refused.'); + } catch (UnsafeUrlException) { + // Expected. + } + + Http::assertSentCount(1); + } + + public function test_safe_redirects_are_followed_including_relative_ones(): void + { + Http::fake([ + 'https://public.example.com/old' => Http::response('', 301, ['Location' => '/new']), + 'https://public.example.com/new' => Http::response('

    Moved

    ', 200, ['Content-Type' => 'text/html']), + ]); + + $response = $this->client()->get('https://public.example.com/old', 1024); + + $this->assertSame('https://public.example.com/new', $response->url); + $this->assertSame('

    Moved

    ', $response->body); + } + + public function test_redirect_chains_are_capped(): void + { + config(['docket.fetch.max_redirects' => 2]); + Http::fake(['https://loop.example.com/*' => Http::response('', 302, ['Location' => '/again'])]); + + try { + $this->client()->get('https://loop.example.com/start', 1024); + $this->fail('An endless redirect should give up.'); + } catch (FetchFailedException $e) { + $this->assertStringContainsString('more than 2 redirects', $e->getMessage()); + } + + Http::assertSentCount(3); + } + + public function test_responses_over_the_size_cap_are_abandoned(): void + { + Http::fake([ + 'https://big.example.com/declared' => Http::response(str_repeat('a', 10), 200, ['Content-Length' => '50000']), + 'https://big.example.com/streamed' => Http::response(str_repeat('a', 5000), 200), + ]); + + foreach (['declared', 'streamed'] as $path) { + try { + $this->client()->get("https://big.example.com/{$path}", 1000); + $this->fail("The {$path} oversize response should have been abandoned."); + } catch (FetchFailedException $e) { + $this->assertTrue($e->permanent); + $this->assertStringContainsString('larger than', $e->getMessage()); + } + } + } + + public function test_only_http_and_https_are_fetched(): void + { + Http::fake(); + + foreach (['ftp://example.com/file', 'file:///etc/passwd', 'gopher://example.com/'] as $url) { + try { + $this->client()->get($url, 1024); + $this->fail("{$url} should have been refused."); + } catch (UnsafeUrlException) { + // Expected. + } + } + + Http::assertNothingSent(); + } + + public function test_unresolvable_hosts_fail_cleanly(): void + { + Http::fake(); + $this->dns->set('nowhere.example.com', []); + + $this->expectException(FetchFailedException::class); + $this->expectExceptionMessage('Could not find the server for nowhere.example.com.'); + + $this->client()->get('https://nowhere.example.com/', 1024); + } + + public function test_a_saved_link_to_a_private_address_is_recorded_as_failed(): void + { + Http::fake(); + $this->dns->set('router.home.arpa', '192.168.1.1'); + + $item = Item::factory()->create([ + 'url' => 'http://router.home.arpa/admin', + 'normalized_url' => app(UrlNormaliser::class)->normalise('http://router.home.arpa/admin'), + ]); + + FetchItemContent::dispatchSync($item); + $item->refresh(); + + $this->assertSame(FetchStatus::Failed, $item->fetch_status); + $this->assertStringContainsString('private or local network address', (string) $item->fetch_error); + Http::assertNothingSent(); + } + + public function test_article_images_on_private_addresses_are_left_alone(): void + { + Storage::fake(Item::DISK); + Http::fake(); + $this->dns->set('cdn.internal', '10.20.30.40'); + + $item = Item::factory()->create(); + $html = '

    Text

    '; + + $result = app(ImageLocaliser::class)->localise($item, $html); + + $this->assertStringContainsString('src="http://cdn.internal/pixel.png"', $result); + $this->assertStringContainsString('src="http://127.0.0.1/x.png"', $result); + $this->assertSame([], Storage::disk(Item::DISK)->allFiles()); + Http::assertNothingSent(); + } +} diff --git a/tests/Feature/PageSmokeTest.php b/tests/Feature/PageSmokeTest.php new file mode 100644 index 0000000..745a767 --- /dev/null +++ b/tests/Feature/PageSmokeTest.php @@ -0,0 +1,59 @@ +create(); + $tag = Tag::factory()->create(['name' => 'reading', 'slug' => 'reading']); + + $fetched = Item::factory()->fetched()->favourite()->create(); + $fetched->tags()->attach($tag); + $pending = Item::factory()->create(); + $failed = Item::factory()->failed()->archived()->create(); + + $pages = [ + '/', + '/?sort=oldest', + '/?sort=longest', + '/archive', + '/favourites', + '/search?q=the', + '/search?q=the&sort=newest', + '/search', + '/tags', + '/tags/reading', + "/items/{$fetched->id}", + "/items/{$pending->id}", + "/items/{$failed->id}", + '/import', + '/settings', + '/tools', + '/profile', + ]; + + foreach ($pages as $page) { + $this->actingAs($user)->get($page)->assertOk(); + } + } + + public function test_guests_are_sent_to_the_login_screen(): void + { + foreach (['/', '/archive', '/favourites', '/tags', '/import', '/settings', '/export/csv'] as $page) { + $this->get($page)->assertRedirect('/login'); + } + } +} diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..1dd0e9a --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,63 @@ +create(); + + $response = $this + ->actingAs($user) + ->get('/profile'); + + $response->assertOk(); + } + + public function test_profile_information_can_be_updated(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $user->refresh(); + + $this->assertSame('Test User', $user->name); + $this->assertSame('test@example.com', $user->email); + $this->assertNull($user->email_verified_at); + } + + public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => $user->email, + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertNotNull($user->refresh()->email_verified_at); + } +} diff --git a/tests/Feature/SecurityHeadersTest.php b/tests/Feature/SecurityHeadersTest.php new file mode 100644 index 0000000..336d6ba --- /dev/null +++ b/tests/Feature/SecurityHeadersTest.php @@ -0,0 +1,29 @@ +actingAs(User::factory()->create()) + ->get('/') + ->assertOk() + ->assertHeader('X-Content-Type-Options', 'nosniff') + ->assertHeader('X-Frame-Options', 'SAMEORIGIN') + ->assertHeader('Referrer-Policy', 'same-origin'); + } + + public function test_hardening_headers_are_sent_on_guest_pages_too(): void + { + $this->get('/login') + ->assertOk() + ->assertHeader('X-Content-Type-Options', 'nosniff'); + } +} diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php new file mode 100644 index 0000000..5acc9a9 --- /dev/null +++ b/tests/Feature/SettingsTest.php @@ -0,0 +1,84 @@ + 'docket-backups', + 'filesystems.disks.offsite.endpoint' => 'https://s3.eu-central-003.backblazeb2.com', + ]); + + $response = $this->actingAs(User::factory()->create())->get('/settings'); + + $response->assertOk(); + $response->assertSee('Bookmarklet'); + $response->assertSee('javascript:', false); + $response->assertSee(route('save.bookmarklet'), false); + $response->assertSee('API tokens'); + $response->assertSee('Setting up the iOS Shortcut'); + $response->assertSee('docket-backups @ https://s3.eu-central-003.backblazeb2.com'); + $response->assertDontSee(config('app.key')); + } + + public function test_settings_save_with_the_field_names_a_browser_actually_submits(): void + { + // PHP converts dots to underscores in form data, so the request + // arrives with fetch_timeout etc. The dotted names never reach the + // server. This posts exactly what a browser would deliver. + $this->actingAs(User::factory()->create()) + ->patch('/settings', $this->validSettingsPayload(['fetch_timeout' => '45', 'fetch_backoff' => '5, 50'])) + ->assertSessionHasNoErrors() + ->assertRedirect('/settings'); + + $this->assertSame(45, settings('fetch.timeout')); + $this->assertSame([5, 50], settings('fetch.backoff')); + } + + public function test_out_of_range_values_are_rejected(): void + { + $response = $this->actingAs(User::factory()->create()) + ->from('/settings') + ->patch('/settings', $this->validSettingsPayload(['words_per_minute' => '0', 'fetch_backoff' => 'soon'])); + + $response->assertSessionHasErrors(['words_per_minute', 'fetch_backoff']); + $this->assertNull(Setting::where('key', 'words_per_minute')->first()); + } + + public function test_defaults_come_from_config_until_overridden(): void + { + config(['docket.per_page' => 17]); + + $this->assertSame(17, settings('per_page')); + } + + /** + * Every field the settings form submits, keyed by the underscore form + * names PHP actually delivers, with defaults from config. + * + * @param array $overrides + * @return array + */ + private function validSettingsPayload(array $overrides = []): array + { + return array_merge([ + 'words_per_minute' => (string) config('docket.words_per_minute'), + 'per_page' => (string) config('docket.per_page'), + 'fetch_timeout' => (string) config('docket.fetch.timeout'), + 'fetch_max_kb' => (string) config('docket.fetch.max_kb'), + 'fetch_tries' => (string) config('docket.fetch.tries'), + 'fetch_backoff' => implode(', ', config('docket.fetch.backoff')), + 'images_max_per_item' => (string) config('docket.images.max_per_item'), + 'import_spacing_seconds' => (string) config('docket.import.spacing_seconds'), + ], $overrides); + } +} diff --git a/tests/Feature/ToolsTest.php b/tests/Feature/ToolsTest.php new file mode 100644 index 0000000..8571fa3 --- /dev/null +++ b/tests/Feature/ToolsTest.php @@ -0,0 +1,92 @@ +actingAs(User::factory()->create())->get('/tools'); + + $response->assertOk(); + $response->assertSee('Maintenance tools'); + $response->assertSee('Clear cache'); + $response->assertSee('Fetch failed and pending items'); + $response->assertSee('Run backup now'); + } + + public function test_tools_page_requires_authentication(): void + { + $this->get('/tools')->assertRedirect('/login'); + $this->post('/tools/clear-cache')->assertRedirect('/login'); + } + + public function test_running_a_tool_flashes_its_console_output(): void + { + $response = $this->actingAs(User::factory()->create())->post('/tools/clear-cache'); + + $response->assertRedirect(route('tools.index')); + $response->assertSessionHas('tool_ran', 'Clear cache'); + $response->assertSessionHas('tool_exit_code', 0); + $response->assertSessionHas('tool_output'); + } + + public function test_unknown_tool_returns_404_rather_than_running_anything(): void + { + $this->actingAs(User::factory()->create()) + ->post('/tools/rm-rf-everything') + ->assertNotFound(); + } + + public function test_refetch_tool_queues_failed_and_pending_items_only(): void + { + Queue::fake(); + + $failed = Item::factory()->failed()->create(); + $pending = Item::factory()->create(); + Item::factory()->fetched()->create(); + + $this->actingAs(User::factory()->create()) + ->post('/tools/refetch') + ->assertRedirect(route('tools.index')); + + Queue::assertPushed(FetchItemContent::class, 2); + Queue::assertPushed(FetchItemContent::class, fn (FetchItemContent $job) => $job->item->is($failed)); + Queue::assertPushed(FetchItemContent::class, fn (FetchItemContent $job) => $job->item->is($pending)); + + $this->assertSame(FetchStatus::Pending, $failed->refresh()->fetch_status); + $this->assertNull($failed->fetch_error); + } + + public function test_refetch_command_targets_one_item_or_everything(): void + { + Queue::fake(); + + $item = Item::factory()->fetched()->create(); + Item::factory()->fetched()->create(); + + $this->artisan('docket:refetch', ['item' => $item->id]) + ->expectsOutputToContain("Queued item {$item->id}") + ->assertSuccessful(); + + Queue::assertPushed(FetchItemContent::class, 1); + + $this->artisan('docket:refetch', ['--all' => true]) + ->expectsOutputToContain('Queued 2 item(s)') + ->assertSuccessful(); + + $this->artisan('docket:refetch', ['item' => 999999]) + ->expectsOutputToContain('Item 999999 not found.') + ->assertFailed(); + } +} diff --git a/tests/Feature/WebInstallerTest.php b/tests/Feature/WebInstallerTest.php new file mode 100644 index 0000000..374594f --- /dev/null +++ b/tests/Feature/WebInstallerTest.php @@ -0,0 +1,148 @@ +envDir = sys_get_temp_dir().'/docket-web-install-test-'.Str::random(8); + mkdir($this->envDir); + copy(base_path('.env.example'), $this->envDir.'/.env.example'); + + $this->app->useEnvironmentPath($this->envDir); + } + + protected function tearDown(): void + { + @unlink($this->envDir.'/.env'); + @unlink($this->envDir.'/.env.example'); + @rmdir($this->envDir); + + parent::tearDown(); + } + + /** @return array */ + private function validPayload(): array + { + return [ + 'url' => 'https://docket.example.com', + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'password' => 'super-secret-99', + 'password_confirmation' => 'super-secret-99', + ]; + } + + public function test_requirements_page_renders_when_not_installed(): void + { + $this->get('/install') + ->assertOk() + ->assertSee('Welcome to the Docket installer') + ->assertSee('PHP 8.3 or newer') + ->assertSee('SQLite with full-text search (FTS5)') + ->assertSee('Continue'); + } + + public function test_setup_form_renders_when_not_installed(): void + { + $this->get('/install/setup') + ->assertOk() + ->assertSee('Set up Docket') + ->assertDontSee('Seed starter categories'); + } + + public function test_installs_and_creates_the_owner(): void + { + $response = $this->post('/install/setup', $this->validPayload()); + + $response->assertOk()->assertSee('Docket is installed'); + + $user = User::query()->where('email', 'jane@example.com')->firstOrFail(); + $this->assertSame('Jane Doe', $user->name); + $this->assertTrue(Hash::check('super-secret-99', $user->password)); + + $this->assertSame(1, User::query()->count()); + + $env = (string) file_get_contents($this->envDir.'/.env'); + $this->assertStringContainsString('APP_URL=https://docket.example.com', $env); + $this->assertStringContainsString('DOCKET_USER_NAME="Jane Doe"', $env); + $this->assertStringContainsString('DOCKET_USER_EMAIL=jane@example.com', $env); + $this->assertMatchesRegularExpression('/^APP_KEY="?base64:/m', $env); + $this->assertStringNotContainsString('super-secret-99', $env); + } + + public function test_invalid_email_re_renders_form_with_error(): void + { + $payload = $this->validPayload(); + $payload['email'] = 'not-an-email'; + + $this->post('/install/setup', $payload) + ->assertStatus(422) + ->assertSee('Set up Docket'); + + $this->assertSame(0, User::query()->count()); + } + + public function test_mismatched_password_confirmation_fails(): void + { + $payload = $this->validPayload(); + $payload['password_confirmation'] = 'something-else-9'; + + $this->post('/install/setup', $payload)->assertStatus(422); + + $this->assertSame(0, User::query()->count()); + } + + public function test_installer_disabled_once_installed(): void + { + User::factory()->create(); + + $this->get('/install')->assertRedirect(route('login')); + $this->get('/install/setup')->assertRedirect(route('login')); + $this->post('/install/setup', $this->validPayload())->assertRedirect(route('login')); + + $this->assertSame(0, User::query()->where('email', 'jane@example.com')->count()); + } + + public function test_wizard_links_are_relative_so_forced_https_cannot_break_them(): void + { + // Before .env exists the app defaults to APP_ENV=production, which + // makes AppServiceProvider force https on every generated URL. The + // wizard must therefore use relative paths, or a plain-http install + // (local, or a server before certbot has run) posts into the void. + URL::forceScheme('https'); + + $this->get('/install') + ->assertOk() + ->assertSee('href="/install/setup"', false); + + $this->get('/install/setup') + ->assertOk() + ->assertSee('action="/install/setup"', false); + } + + public function test_install_routes_do_not_require_csrf_or_session(): void + { + // POSTing with no CSRF token must not 419 — these routes run + // outside the 'web' group because no session store exists yet. + $this->post('/install/setup', [])->assertStatus(422); + } +} diff --git a/tests/Fixtures/article.html b/tests/Fixtures/article.html new file mode 100644 index 0000000..9671212 --- /dev/null +++ b/tests/Fixtures/article.html @@ -0,0 +1,37 @@ + + + + + Why slow reading matters | The Example Review + + + + + + + + + + + +
    +
    +

    Why slow reading matters

    + +

    Most of what we read online is skimmed. We open a tab, glance at the first paragraph, and move on, telling ourselves we will come back to it later. The tabs pile up, the reading list grows, and very little of it is ever read with any care.

    +

    Slow reading is the opposite habit. It means choosing fewer things and giving each one your full attention, the way you might read a good novel on a long train journey. It is not about reading slowly for its own sake but about reading in a way that lets ideas settle.

    +
    + A quiet reading room +
    A quiet reading room in the old library.
    +
    +

    A read-later list helps, but only if it is a place you return to. Saving something is a promise to your future self, and the list is only useful when that promise is kept. Archive what you have finished and delete what no longer interests you.

    +

    There is also the question of where the reading happens. A clean page, a comfortable typeface and no adverts make a surprising difference to how long you stay with a piece of writing. This link should lose its href. Further reading is still allowed.

    + + +

    Finally, keep what matters. Links rot and sites disappear, so a reading habit worth having is one that keeps its own copy of the things you valued enough to finish. Docket stores the text and images of each article for exactly that reason.

    + An SVG diagram +
    +
    +
    Copyright The Example Review. All rights reserved.
    + + diff --git a/tests/Fixtures/pocket_export.csv b/tests/Fixtures/pocket_export.csv new file mode 100644 index 0000000..15c7b32 --- /dev/null +++ b/tests/Fixtures/pocket_export.csv @@ -0,0 +1,8 @@ +title,url,time_added,cursor,tags,status +"The Web We Lost",https://www.example.com/the-web-we-lost?utm_source=pocket_saves,1356998400,a1,web|history,archive +"Slow Reading, Fast Living",https://blog.example.org/slow-reading,1704067200,a2,reading,unread +https://news.example.net/untitled-link,https://news.example.net/untitled-link,1717200000,a3,,unread +"Duplicate of the first (different tracking)",https://WWW.EXAMPLE.COM/the-web-we-lost?fbclid=xyz,1356998400,a4,essays,archive +"Not a link",javascript:alert(1),1717200000,a5,,unread +"Empty URL",,1717200000,a6,,unread +"Tagged twice",https://example.co.uk/tags,1720000000,a7,Laravel|laravel|PHP,unread diff --git a/tests/Support/FakeHostResolver.php b/tests/Support/FakeHostResolver.php new file mode 100644 index 0000000..205c5c3 --- /dev/null +++ b/tests/Support/FakeHostResolver.php @@ -0,0 +1,41 @@ +> + */ + protected array $map = []; + + /** + * @param string|list $addresses + */ + public function set(string $host, string|array $addresses): static + { + $this->map[strtolower($host)] = (array) $addresses; + + return $this; + } + + public function resolve(string $host): array + { + $host = strtolower(trim($host, '[]')); + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return [$host]; + } + + return $this->map[$host] ?? [self::PUBLIC_ADDRESS]; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..be42ace --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,26 @@ +dns = new FakeHostResolver(); + $this->app->instance(HostResolver::class, $this->dns); + + Http::preventStrayRequests(); + } +} diff --git a/tests/Unit/IpGuardTest.php b/tests/Unit/IpGuardTest.php new file mode 100644 index 0000000..8dc1f66 --- /dev/null +++ b/tests/Unit/IpGuardTest.php @@ -0,0 +1,53 @@ + + */ + public static function addresses(): array + { + return [ + 'public IPv4' => ['93.184.216.34', true], + 'public IPv4 (DNS resolver)' => ['1.1.1.1', true], + 'public IPv6' => ['2606:4700:4700::1111', true], + 'loopback' => ['127.0.0.1', false], + 'loopback range' => ['127.255.0.9', false], + 'private 10/8' => ['10.0.0.1', false], + 'private 172.16/12' => ['172.31.255.255', false], + 'private 192.168/16' => ['192.168.0.1', false], + 'link-local / metadata' => ['169.254.169.254', false], + 'carrier-grade NAT' => ['100.64.0.1', false], + 'this network' => ['0.0.0.0', false], + 'documentation' => ['192.0.2.10', false], + 'benchmarking' => ['198.18.0.1', false], + 'multicast' => ['224.0.0.251', false], + 'broadcast' => ['255.255.255.255', false], + 'IPv6 loopback' => ['::1', false], + 'IPv6 unspecified' => ['::', false], + 'IPv6 bracketed loopback' => ['[::1]', false], + 'IPv4-mapped loopback' => ['::ffff:127.0.0.1', false], + 'IPv4-mapped private' => ['::ffff:10.0.0.1', false], + 'IPv4-compatible loopback' => ['::127.0.0.1', false], + 'NAT64 of loopback' => ['64:ff9b::7f00:1', false], + '6to4' => ['2002:7f00:1::1', false], + 'unique local' => ['fd12:3456::1', false], + 'IPv6 link-local' => ['fe80::1', false], + 'IPv6 multicast' => ['ff02::1', false], + 'not an address' => ['example.com', false], + 'empty' => ['', false], + ]; + } + + #[DataProvider('addresses')] + public function test_only_public_unicast_addresses_pass(string $address, bool $public): void + { + $this->assertSame($public, IpGuard::isPublic($address)); + } +} diff --git a/tests/Unit/TagNamesTest.php b/tests/Unit/TagNamesTest.php new file mode 100644 index 0000000..b1fe3cd --- /dev/null +++ b/tests/Unit/TagNamesTest.php @@ -0,0 +1,39 @@ +assertSame(['Laravel', 'php', 'long reads'], Tag::parseList(' Laravel, php,,LARAVEL , long reads ')); + $this->assertSame(['one', 'two'], Tag::parseList(['one', ' two ', '', 'ONE', ['nested']])); + $this->assertSame([], Tag::parseList(null)); + $this->assertSame([], Tag::parseList('')); + } + + public function test_slugs_ignore_case_but_keep_non_latin_scripts(): void + { + $this->assertSame('long-reads', Tag::slugFor('Long Reads')); + $this->assertSame('日本語', Tag::slugFor('日本語')); + $this->assertSame('ελληνικά', Tag::slugFor('ΕΛΛΗΝΙΚΆ')); + } + + public function test_pipes_cannot_sneak_into_a_name(): void + { + $this->assertSame('a b', Tag::cleanName('a|b')); + } + + public function test_search_input_becomes_a_safe_fts_query(): void + { + $this->assertSame('"slow"* "reading"*', Item::ftsQuery(' slow reading ')); + $this->assertSame('"c++"* "AND"*', Item::ftsQuery('c++ AND')); + $this->assertSame('"experts"*', Item::ftsQuery('"experts"')); + $this->assertNull(Item::ftsQuery(' ')); + $this->assertNull(Item::ftsQuery('""')); + } +} diff --git a/tests/Unit/UrlNormaliserTest.php b/tests/Unit/UrlNormaliserTest.php new file mode 100644 index 0000000..0e7a24c --- /dev/null +++ b/tests/Unit/UrlNormaliserTest.php @@ -0,0 +1,68 @@ + + */ + public static function equivalentUrls(): array + { + return [ + 'case of scheme and host' => ['HTTPS://Example.COM/Path', 'https://example.com/Path'], + 'default port' => ['https://example.com:443/a', 'https://example.com/a'], + 'default http port' => ['http://example.com:80/a', 'http://example.com/a'], + 'non-default port kept' => ['https://example.com:8443/a', 'https://example.com:8443/a'], + 'fragment' => ['https://example.com/a#comments', 'https://example.com/a'], + 'empty path' => ['https://example.com', 'https://example.com/'], + 'tracking parameters' => ['https://example.com/a?utm_source=x&utm_medium=y&fbclid=z&gclid=1&id=7', 'https://example.com/a?id=7'], + 'only tracking parameters' => ['https://example.com/a?utm_campaign=autumn', 'https://example.com/a'], + 'query order' => ['https://example.com/a?b=2&a=1', 'https://example.com/a?a=1&b=2'], + 'percent-escape case' => ['https://example.com/a%2fb?q=%c3%a9', 'https://example.com/a%2Fb?q=%C3%A9'], + 'trailing dot on host' => ['https://example.com./a', 'https://example.com/a'], + 'user info dropped' => ['https://user:pass@example.com/a', 'https://example.com/a'], + 'missing scheme' => ['example.com/article', 'https://example.com/article'], + 'surrounding whitespace' => [" https://example.com/a \n", 'https://example.com/a'], + 'international domain' => ['https://bücher.example/', 'https://xn--bcher-kva.example/'], + 'trailing slash is significant' => ['https://example.com/a/', 'https://example.com/a/'], + ]; + } + + #[DataProvider('equivalentUrls')] + public function test_normalises(string $input, string $expected): void + { + $this->assertSame($expected, (new UrlNormaliser())->normalise($input)); + } + + /** + * @return array + */ + public static function invalidUrls(): array + { + return [ + 'empty' => [''], + 'javascript' => ['javascript:alert(1)'], + 'data' => ['data:text/html,'], + 'mailto' => ['mailto:someone@example.com'], + 'ftp' => ['ftp://example.com/file'], + 'file' => ['file:///etc/passwd'], + 'no host' => ['https://'], + 'spaces in host' => ['not a url'], + 'too long' => ['https://example.com/'.str_repeat('a', 2100)], + ]; + } + + #[DataProvider('invalidUrls')] + public function test_rejects(string $input): void + { + $this->expectException(InvalidUrlException::class); + + (new UrlNormaliser())->normalise($input); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..29fbfe9 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], +});