wite
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
APP_NAME=Tarnax
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
APP_LOCALE=en
|
||||
LOG_CHANNEL=stack
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=tarnax
|
||||
DB_USERNAME=tarnax
|
||||
DB_PASSWORD=
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
CACHE_STORE=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS=hello@example.com
|
||||
MAIL_FROM_NAME=Tarnax
|
||||
SUPPORT_EMAIL=
|
||||
PADDLE_ENVIRONMENT=sandbox
|
||||
PADDLE_API_KEY=
|
||||
PADDLE_CLIENT_TOKEN=
|
||||
PADDLE_PRICE_ID=
|
||||
PADDLE_WEBHOOK_SECRET=
|
||||
APPLE_STORE_URL=
|
||||
APPLE_BUNDLE_ID=
|
||||
APPLE_PRODUCT_ID=
|
||||
APPLE_ENVIRONMENT=Sandbox
|
||||
APPLE_ROOT_CERTIFICATE=
|
||||
@@ -0,0 +1,11 @@
|
||||
/.env
|
||||
/.env.*
|
||||
!/.env.example
|
||||
/vendor
|
||||
/node_modules
|
||||
/public/hot
|
||||
/storage/*.key
|
||||
/storage/logs/*
|
||||
/database/*.sqlite*
|
||||
/bootstrap/cache/*.php
|
||||
.DS_Store
|
||||
@@ -0,0 +1 @@
|
||||
{"version":2,"defects":{"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":7,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":7,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":7,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":7,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":7},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\CommerceTest::test_public_pages_and_authentication_views_render":0.021,"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":0.012,"Tests\\Feature\\CommerceTest::test_verified_payment_issues_only_one_license_on_retries":0.009,"Tests\\Feature\\CommerceTest::test_invalid_signature_and_stale_replay_never_grant_licenses":0.002,"Tests\\Feature\\CommerceTest::test_wrong_product_does_not_grant_license":0.001,"Tests\\Feature\\CommerceTest::test_unknown_customer_rolls_back_event_for_retry":0.002,"Tests\\Feature\\CommerceTest::test_activation_limits_validation_and_deactivation":0.005,"Tests\\Feature\\CommerceTest::test_refund_revokes_license_and_prevents_regrant":0.003,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":0.017,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":0.003,"Tests\\Feature\\CommerceTest::test_downloads_require_license_signature_and_matching_customer":0.01,"Tests\\Feature\\CommerceTest::test_support_is_scoped_and_does_not_accept_another_ticket":0.007,"Tests\\Feature\\CommerceTest::test_customer_can_create_support_ticket":0.005,"Tests\\Feature\\CommerceTest::test_admin_requires_role_and_confirmed_two_factor":0.004,"Tests\\Feature\\CommerceTest::test_admin_can_edit_cms_and_mutations_are_audited":0.004,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":0.001,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":0.002,"Tests\\Feature\\CommerceTest::test_account_support_and_security_render_for_verified_customer":0.004,"Tests\\Feature\\CommerceTest::test_erasure_revokes_access_and_anonymises_profile":0.006,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.001}}
|
||||
@@ -0,0 +1,129 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application running on PHP 8.5. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||
|
||||
Before relying on a package's API, confirm its installed version:
|
||||
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||
- JS packages: check `package.json` for the installed versions.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If a frontend change doesn't show in the UI or you get a "Unable to locate file in Vite manifest" error, run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
## Project Rules
|
||||
|
||||
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists, including path-scoped framework guidelines under `.ai/rules/boost`. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||
|
||||
## Tinker
|
||||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||
- Use array shape type definitions in PHPDoc blocks.
|
||||
|
||||
=== deployments rules ===
|
||||
|
||||
# Deployment
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||
|
||||
## APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allows you to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
|
||||
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
# PHPUnit
|
||||
|
||||
- This project uses PHPUnit. Create tests with `php artisan make:test --phpunit {name}`.
|
||||
- Do not include the test suite directory in `{name}`. Use `SomeFeatureTest`, not `Feature/SomeFeatureTest`.
|
||||
- Read the `testing-best-practices` skill for guidance on coverage, naming, structure, dependency isolation, and review.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the narrowest set of tests that covers the change. Pass a file path or `--filter=testName` to `php artisan test --compact`.
|
||||
- Rerun a test after each change to it.
|
||||
- Run `vendor/bin/phpunit` to call the test runner directly. It accepts the same file path and `--filter=testName` arguments.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
@@ -0,0 +1,129 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application running on PHP 8.5. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||
|
||||
Before relying on a package's API, confirm its installed version:
|
||||
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||
- JS packages: check `package.json` for the installed versions.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If a frontend change doesn't show in the UI or you get a "Unable to locate file in Vite manifest" error, run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
## Project Rules
|
||||
|
||||
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists, including path-scoped framework guidelines under `.ai/rules/boost`. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||
|
||||
## Tinker
|
||||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||
- Use array shape type definitions in PHPDoc blocks.
|
||||
|
||||
=== deployments rules ===
|
||||
|
||||
# Deployment
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||
|
||||
## APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allows you to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
|
||||
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
# PHPUnit
|
||||
|
||||
- This project uses PHPUnit. Create tests with `php artisan make:test --phpunit {name}`.
|
||||
- Do not include the test suite directory in `{name}`. Use `SomeFeatureTest`, not `Feature/SomeFeatureTest`.
|
||||
- Read the `testing-best-practices` skill for guidance on coverage, naming, structure, dependency isolation, and review.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the narrowest set of tests that covers the change. Pass a file path or `--filter=testName` to `php artisan test --compact`.
|
||||
- Rerun a test after each change to it.
|
||||
- Run `vendor/bin/phpunit` to call the test runner directly. It accepts the same file path and `--filter=testName` arguments.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
@@ -0,0 +1,58 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||
|
||||
## Agentic Development
|
||||
|
||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string', 'current_password:web'],
|
||||
'password' => $this->passwordRules(),
|
||||
], [
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given verified user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AppleNotificationController extends Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Services\AppleVerifier;
|
||||
class CommerceController extends Controller {
|
||||
public function home() {return view('home',['features'=>DB::table('pages')->where('published',true)->where('slug','features')->first()]);}
|
||||
public function page(string $slug) {return view('page',['page'=>DB::table('pages')->where('slug',$slug)->where('published',true)->firstOrFail()]);}
|
||||
public function releases() {return view('releases',['releases'=>DB::table('releases')->where('published',true)->latest()->paginate(15)]);}
|
||||
public function account(Request $r) {
|
||||
if(!$r->user()->account_token){$r->user()->forceFill(['account_token'=>(string)Str::uuid()])->save();}
|
||||
return view('account',['licenses'=>DB::table('licenses')->where('user_id',$r->user()->id)->get(),'purchases'=>DB::table('purchases')->where('user_id',$r->user()->id)->latest()->get(),'releases'=>DB::table('releases')->where('published',true)->latest()->get()]);
|
||||
}
|
||||
public function downloadLink(Request $r,int $release) {
|
||||
abort_unless(DB::table('licenses')->where('user_id',$r->user()->id)->where('status','active')->exists(),403);
|
||||
abort_unless(DB::table('releases')->where('id',$release)->where('published',true)->exists(),404);
|
||||
return redirect(URL::temporarySignedRoute('download.file',now()->addMinutes(5),['release'=>$release,'user'=>$r->user()->id]));
|
||||
}
|
||||
public function download(Request $r,int $release,int $user) {
|
||||
abort_unless($r->user()->id===$user && DB::table('licenses')->where('user_id',$user)->where('status','active')->exists(),403);
|
||||
$file=DB::table('releases')->where('id',$release)->where('published',true)->firstOrFail();abort_unless(Storage::disk('local')->exists($file->path),404);
|
||||
return Storage::disk('local')->download($file->path,'Tarnax-'.$file->version.'.'.pathinfo($file->path,PATHINFO_EXTENSION));
|
||||
}
|
||||
public function consent(Request $r) {
|
||||
$v=$r->validate(['analytics'=>'required|boolean','marketing'=>'required|boolean']);$visitor=$r->cookie('tarnax_visitor');if(!Str::isUuid($visitor??'')){$visitor=(string)Str::uuid();}
|
||||
DB::table('consents')->insert([...$v,'visitor_id'=>$visitor,'user_id'=>$r->user()?->id,'policy_version'=>config('tarnax.consent_version'),'created_at'=>now(),'updated_at'=>now()]);
|
||||
return response()->json(['saved'=>true])->cookie('tarnax_visitor',$visitor,525600,'/',null,$r->isSecure(),true,false,'lax')->cookie('tarnax_consent',json_encode([...$v,'version'=>config('tarnax.consent_version')]),525600,'/',null,$r->isSecure(),false,false,'lax');
|
||||
}
|
||||
public function export(Request $r) {
|
||||
$data=['profile'=>$r->user()->only('name','email','created_at'),'purchases'=>DB::table('purchases')->where('user_id',$r->user()->id)->get(),'support'=>DB::table('ticket_messages')->where('user_id',$r->user()->id)->get(),'consent'=>DB::table('consents')->where('user_id',$r->user()->id)->get(),'privacy_requests'=>DB::table('privacy_requests')->where('user_id',$r->user()->id)->get()];
|
||||
return response()->streamDownload(fn()=>print(json_encode($data,JSON_PRETTY_PRINT)),'tarnax-personal-data.json',['Content-Type'=>'application/json']);
|
||||
}
|
||||
public function deletion(Request $r) {
|
||||
DB::table('privacy_requests')->updateOrInsert(['user_id'=>$r->user()->id,'type'=>'deletion','status'=>'pending'],['created_at'=>now(),'updated_at'=>now()]);
|
||||
return back()->with('status','Deletion requested. Support will review retained billing records and respond within the applicable deadline.');
|
||||
}
|
||||
public function apple(Request $r,AppleVerifier $verifier) {
|
||||
$v=$r->validate(['signed_transaction'=>'required|string|max:25000']);$claims=$verifier->verify($v['signed_transaction'],$r->user()->account_token??'');
|
||||
DB::transaction(function()use($r,$claims){$id='apple:'.$claims['transactionId'];$purchase=DB::table('purchases')->where('transaction_id',$id)->lockForUpdate()->first();abort_if($purchase && $purchase->user_id!==$r->user()->id,403);if(!$purchase){DB::table('purchases')->insert(['user_id'=>$r->user()->id,'provider'=>'apple','transaction_id'=>$id,'status'=>'verified','created_at'=>now(),'updated_at'=>now()]);}});
|
||||
return back()->with('status','Apple purchase verified. App Store entitlements remain separate from direct licenses.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Services\LicenseService;
|
||||
class LicenseController extends Controller {
|
||||
public function activate(Request $r,LicenseService $service): JsonResponse {$v=$r->validate(['license_key'=>'required|string|max:100','device_id'=>'required|string|min:16|max:255']);return response()->json($service->activate($v['license_key'],$v['device_id']));}
|
||||
public function validateLicense(Request $r,LicenseService $service): JsonResponse {return response()->json($service->check($r->bearerToken()??''));}
|
||||
public function deactivate(Request $r,LicenseService $service): JsonResponse {return response()->json($service->check($r->bearerToken()??'',true));}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
use App\Models\User;
|
||||
use App\Services\LicenseService;
|
||||
use App\Notifications\AccountUpdate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
class PaddleController extends Controller {
|
||||
public function checkout(Request $r): JsonResponse {
|
||||
$config=config('tarnax.paddle');abort_unless($config['api_key'] && $config['price_id'] && $config['client_token'],503,'Checkout is awaiting merchant configuration.');
|
||||
$user=$r->user();if(!$user->account_token){$user->forceFill(['account_token'=>(string)Str::uuid()])->save();}
|
||||
$base=$config['environment']==='production'?'https://api.paddle.com':'https://sandbox-api.paddle.com';
|
||||
$response=Http::withToken($config['api_key'])->timeout(15)->post($base.'/transactions',['items'=>[['price_id'=>$config['price_id'],'quantity'=>1]],'collection_mode'=>'automatic','custom_data'=>['account_token'=>$user->account_token]]);
|
||||
abort_unless($response->successful(),502,'Payment provider unavailable.');
|
||||
return response()->json(['transaction_id'=>$response->json('data.id')]);
|
||||
}
|
||||
public function webhook(Request $r,LicenseService $licenses): JsonResponse {
|
||||
$secret=config('tarnax.paddle.webhook_secret');abort_unless($secret,503);
|
||||
$parts=[];foreach(explode(';',$r->header('Paddle-Signature','')) as $part){$pair=explode('=',trim($part),2);if(count($pair)===2){$parts[$pair[0]][]=$pair[1];}}
|
||||
$ts=$parts['ts'][0]??'';abort_unless(ctype_digit($ts) && abs(time()-(int)$ts)<=300,401);
|
||||
$expected=hash_hmac('sha256',$ts.':'.$r->getContent(),$secret);$valid=false;foreach($parts['h1']??[] as $signature){$valid=$valid||hash_equals($expected,$signature);}abort_unless($valid,401);
|
||||
$event=$r->validate(['event_id'=>'required|string|max:100','event_type'=>'required|string|max:100','occurred_at'=>'required|date','data'=>'required|array']);
|
||||
DB::transaction(function()use($event,$licenses){
|
||||
$inserted=DB::table('webhook_events')->insertOrIgnore(['id'=>$event['event_id'],'type'=>$event['event_type'],'processed_at'=>now()]);if(!$inserted){return;}
|
||||
$d=$event['data'];$type=$event['event_type'];$at=Carbon::parse($event['occurred_at']);
|
||||
if($type==='transaction.completed'){
|
||||
abort_unless(($d['status']??'')==='completed',422);
|
||||
$matching=collect($d['items']??[])->contains(fn($i)=>($i['price']['id']??null)===config('tarnax.paddle.price_id') && ($i['quantity']??0)>=1);if(!$matching){return;}
|
||||
$user=User::where('account_token',$d['custom_data']['account_token']??'')->first();abort_unless($user,422,'Unknown customer.');
|
||||
$purchase=DB::table('purchases')->where('transaction_id',$d['id'])->lockForUpdate()->first();
|
||||
if($purchase && ($purchase->status==='refunded' || Carbon::parse($purchase->provider_updated_at)->gte($at))){return;}
|
||||
$id=$purchase?->id??DB::table('purchases')->insertGetId(['user_id'=>$user->id,'provider'=>'paddle','transaction_id'=>$d['id'],'subscription_id'=>$d['subscription_id']??null,'status'=>'completed','total'=>(int)($d['details']['totals']['grand_total']??0),'currency'=>$d['currency_code']??'USD','provider_updated_at'=>$at,'created_at'=>now(),'updated_at'=>now()]);
|
||||
$licenses->issue($user->id,$id);
|
||||
$user->notify(new AccountUpdate('Your Tarnax license is ready','Your purchase has been confirmed. Your license and downloads are in your account.',url('/account')));
|
||||
}
|
||||
if($type==='adjustment.updated' || $type==='adjustment.created'){
|
||||
if(($d['status']??'')!=='approved' || !in_array($d['action']??'',['refund','chargeback','chargeback_warning'])){return;}
|
||||
$purchase=DB::table('purchases')->where('transaction_id',$d['transaction_id']??'')->lockForUpdate()->first();
|
||||
if($purchase){DB::table('purchases')->where('id',$purchase->id)->update(['status'=>'refunded','provider_updated_at'=>$at,'updated_at'=>now()]);DB::table('licenses')->where('purchase_id',$purchase->id)->update(['status'=>'revoked','updated_at'=>now()]);}
|
||||
}
|
||||
if(in_array($type,['subscription.canceled','subscription.paused','subscription.past_due'])){
|
||||
$purchases=DB::table('purchases')->where('subscription_id',$d['id']??'')->where('provider_updated_at','<',$at)->get();
|
||||
foreach($purchases as $purchase){DB::table('licenses')->where('purchase_id',$purchase->id)->update(['status'=>'revoked','updated_at'=>now()]);DB::table('purchases')->where('id',$purchase->id)->update(['status'=>$d['status'],'provider_updated_at'=>$at]);}
|
||||
}
|
||||
},3);
|
||||
return response()->json(['received'=>true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Http\Middleware;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
class RequireAdmin {
|
||||
public function handle(Request $request,Closure $next): Response {
|
||||
abort_unless($request->user()?->is_admin,403);
|
||||
if(!$request->user()->two_factor_confirmed_at){return redirect('/account/security')->with('status','Enable and confirm two-factor authentication before entering administration.');}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
namespace App\Livewire\Admin;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
class Cms extends Component {
|
||||
use WithFileUploads;
|
||||
public string $section='pages';public array $form=[];public $upload;
|
||||
#[Locked] public ?int $editing=null;
|
||||
private const TABLES=['pages','releases','users','purchases','licenses','tickets','privacy_requests','consents','audit_logs','settings'];
|
||||
public function boot(): void {abort_unless(auth()->user()?->is_admin && auth()->user()->two_factor_confirmed_at,403);}
|
||||
private function section(): string {abort_unless(in_array($this->section,self::TABLES),404);return $this->section;}
|
||||
public function newRecord(): void {$this->reset('form','editing','upload');}
|
||||
public function updatedSection(): void {$this->reset('form','editing','upload');}
|
||||
private function audit(string $action,string $resource): void {DB::table('audit_logs')->insert(['user_id'=>auth()->id(),'action'=>$action,'resource'=>$resource,'created_at'=>now(),'updated_at'=>now()]);}
|
||||
public function edit(int $id): void {
|
||||
$section=$this->section();abort_unless(in_array($section,['pages','releases']),403);
|
||||
$row=DB::table($section)->find($id);abort_unless($row,404);$this->editing=$id;
|
||||
$this->form=array_intersect_key((array)$row,array_flip($section==='pages'?['slug','title','body','description','published']:['version','channel','platform','notes','published']));
|
||||
}
|
||||
public function save(): void {
|
||||
$section=$this->section();abort_unless(in_array($section,['pages','releases']),403);
|
||||
if($section==='pages'){$data=$this->validate(['form.slug'=>'required|alpha_dash|max:100|unique:pages,slug,'.($this->editing??'NULL'),'form.title'=>'required|string|max:200','form.body'=>'required|string|max:100000','form.description'=>'nullable|string|max:255','form.published'=>'boolean'])['form'];}
|
||||
else {
|
||||
$data=$this->validate(['form.version'=>'required|regex:/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$/|max:50|unique:releases,version,'.($this->editing??'NULL'),'form.channel'=>'required|in:stable,beta','form.platform'=>'required|string|max:50','form.notes'=>'required|string|max:20000','form.published'=>'boolean'])['form'];
|
||||
$this->validate(['upload'=>($this->editing?'nullable':'required').'|file|extensions:zip,dmg,pkg|max:1048576']);
|
||||
if($this->upload){$path=$this->upload->store('releases','local');$data+=['path'=>$path,'sha256'=>hash_file('sha256',Storage::disk('local')->path($path)),'bytes'=>Storage::disk('local')->size($path)];}
|
||||
}
|
||||
$data['published']=$data['published']??false;$data['updated_at']=now();
|
||||
if($this->editing){DB::table($section)->where('id',$this->editing)->update($data);$id=$this->editing;}else{$data['created_at']=now();$id=DB::table($section)->insertGetId($data);}
|
||||
$this->audit('saved',$section.':'.$id);$this->reset('form','editing','upload');session()->flash('status','Saved successfully.');
|
||||
}
|
||||
public function revoke(int $id): void {abort_unless($this->section()==='licenses',403);DB::table('licenses')->where('id',$id)->update(['status'=>'revoked','updated_at'=>now()]);$this->audit('revoked','license:'.$id);}
|
||||
public function saveSettings(): void {
|
||||
abort_unless($this->section()==='settings',403);$v=$this->validate(['form.headline'=>'required|string|max:120','form.tagline'=>'required|string|max:500','form.price_label'=>'required|string|max:100'])['form'];foreach($v as $key=>$value){DB::table('settings')->updateOrInsert(['key'=>$key],['value'=>$value]);}$this->audit('saved','settings');session()->flash('status','Settings saved.');
|
||||
}
|
||||
public function fulfillDeletion(int $id): void {
|
||||
abort_unless($this->section()==='privacy_requests',403);
|
||||
DB::transaction(function()use($id){$request=DB::table('privacy_requests')->where('id',$id)->lockForUpdate()->first();abort_unless($request && $request->type==='deletion' && $request->status==='pending',422);abort_if($request->user_id===auth()->id(),422,'Cannot erase your own administrator account.');
|
||||
$user=\App\Models\User::findOrFail($request->user_id);abort_if($user->is_admin,422,'Remove administrator access before erasure.');
|
||||
DB::table('licenses')->where('user_id',$user->id)->update(['status'=>'revoked','updated_at'=>now()]);DB::table('activations')->whereIn('license_id',DB::table('licenses')->where('user_id',$user->id)->select('id'))->delete();
|
||||
DB::table('tickets')->where('user_id',$user->id)->delete();DB::table('ticket_messages')->where('user_id',$user->id)->delete();DB::table('consents')->where('user_id',$user->id)->delete();DB::table('sessions')->where('user_id',$user->id)->delete();DB::table('password_reset_tokens')->where('email',$user->email)->delete();
|
||||
$user->forceFill(['name'=>'Deleted customer','email'=>'deleted-'.$user->id.'@invalid.example','password'=>bin2hex(random_bytes(32)),'remember_token'=>null,'account_token'=>null,'two_factor_secret'=>null,'two_factor_recovery_codes'=>null])->save();
|
||||
DB::table('privacy_requests')->where('id',$id)->update(['status'=>'fulfilled','updated_at'=>now()]);$this->audit('privacy erasure fulfilled; billing records retained','user:'.$user->id);
|
||||
});
|
||||
}
|
||||
public function render() {
|
||||
$section=$this->section();$query=DB::table($section);if($section==='users'){$query->select('id','name','email','is_admin','email_verified_at');}elseif($section==='licenses'){$query->select('id','user_id','purchase_id','status','device_limit');}
|
||||
return view('livewire.admin.cms',['sections'=>self::TABLES,'rows'=>$query->orderBy($section==='settings'?'key':'id','desc')->limit(100)->get()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
namespace App\Livewire;
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\User;
|
||||
use App\Notifications\AccountUpdate;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
class SupportDesk extends Component {
|
||||
public string $subject='';public string $body='';public string $reply='';
|
||||
#[Locked] public ?int $ticketId=null;
|
||||
private function ticket() { $ticket=DB::table('tickets')->find($this->ticketId);abort_unless($ticket && (auth()->user()->is_admin || $ticket->user_id===auth()->id()),403);return $ticket; }
|
||||
public function select(int $id): void {$this->ticketId=$id;$this->ticket();}
|
||||
public function create(): void {
|
||||
$this->validate(['subject'=>'required|string|max:200','body'=>'required|string|min:10|max:10000']);
|
||||
$this->ticketId=DB::transaction(function(){ $id=DB::table('tickets')->insertGetId(['user_id'=>auth()->id(),'subject'=>$this->subject,'status'=>'open','created_at'=>now(),'updated_at'=>now()]);DB::table('ticket_messages')->insert(['ticket_id'=>$id,'user_id'=>auth()->id(),'body'=>$this->body,'created_at'=>now(),'updated_at'=>now()]);return $id;});
|
||||
if(config('tarnax.support_email')){Notification::route('mail',config('tarnax.support_email'))->notify(new AccountUpdate('New Tarnax support ticket',$this->subject,url('/support')));}
|
||||
$this->reset('subject','body');
|
||||
}
|
||||
public function send(): void {
|
||||
$ticket=$this->ticket();$this->validate(['reply'=>'required|string|min:2|max:10000']);
|
||||
DB::table('ticket_messages')->insert(['ticket_id'=>$ticket->id,'user_id'=>auth()->id(),'body'=>$this->reply,'created_at'=>now(),'updated_at'=>now()]);
|
||||
DB::table('tickets')->where('id',$ticket->id)->update(['status'=>'open','updated_at'=>now()]);
|
||||
$notification=new AccountUpdate('Your Tarnax support ticket has a reply','Sign in to read the reply.',url('/support'));
|
||||
if(auth()->user()->is_admin){User::findOrFail($ticket->user_id)->notify($notification);}elseif(config('tarnax.support_email')){Notification::route('mail',config('tarnax.support_email'))->notify($notification);}
|
||||
$this->reply='';
|
||||
}
|
||||
public function close(): void {$ticket=$this->ticket();DB::table('tickets')->where('id',$ticket->id)->update(['status'=>'closed','updated_at'=>now()]);}
|
||||
public function render() {
|
||||
$query=DB::table('tickets');if(!auth()->user()->is_admin){$query->where('user_id',auth()->id());}
|
||||
$messages=collect();if($this->ticketId){$this->ticket();$messages=DB::table('ticket_messages')->join('users','users.id','=','ticket_messages.user_id')->where('ticket_id',$this->ticketId)->select('ticket_messages.*','users.name')->orderBy('ticket_messages.id')->get();}
|
||||
return view('livewire.support-desk',['tickets'=>$query->latest('updated_at')->limit(100)->get(),'messages'=>$messages]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Notifications;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
class AccountUpdate extends Notification implements ShouldQueue {
|
||||
use Queueable;
|
||||
public function __construct(public string $subject, public string $message, public string $url) { $this->afterCommit(); }
|
||||
public function via(object $notifiable): array {return ['mail'];}
|
||||
public function toMail(object $notifiable): MailMessage {return (new MailMessage)->subject($this->subject)->line($this->message)->action('Open your account',$this->url);}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class QueuedResetPassword extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->line('The introduction to the notification.')
|
||||
->action('Notification Action', url('/'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class QueuedVerifyEmail extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->line('The introduction to the notification.')
|
||||
->action('Notification Action', url('/'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\RateLimiter::for('license', fn ($request) => \Illuminate\Cache\RateLimiting\Limit::perMinute(30)->by($request->ip()));
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Fortify::loginView(fn () => view('auth.form', ['mode'=>'login']));
|
||||
Fortify::registerView(fn () => view('auth.form', ['mode'=>'register']));
|
||||
Fortify::requestPasswordResetLinkView(fn () => view('auth.form', ['mode'=>'forgot-password']));
|
||||
Fortify::resetPasswordView(fn ($request) => view('auth.form', ['mode'=>'reset-password', 'request'=>$request]));
|
||||
Fortify::verifyEmailView(fn () => view('auth.verify'));
|
||||
Fortify::confirmPasswordView(fn () => view('auth.form', ['mode'=>'confirm-password']));
|
||||
Fortify::twoFactorChallengeView(fn () => view('auth.form', ['mode'=>'two-factor-challenge']));
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
RateLimiter::for('passkeys', function (Request $request) {
|
||||
$credentialId = $request->input('credential.id');
|
||||
|
||||
return Limit::perMinute(10)->by(
|
||||
($credentialId ?: $request->session()->getId()).'|'.$request->ip()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
/** Verifies StoreKit 2 transaction JWS against a locally pinned Apple root. */
|
||||
class AppleVerifier {
|
||||
private function decode(string $data): string { $result=base64_decode(strtr($data,'-_','+/'),true);abort_unless($result!==false,422);return $result; }
|
||||
private function pem(string $cert): string {return "-----BEGIN CERTIFICATE-----\n".chunk_split($cert,64,"\n")."-----END CERTIFICATE-----\n";}
|
||||
public function verify(string $jws,string $accountToken): array {
|
||||
$rootPath=config('tarnax.apple.root_certificate');abort_unless($rootPath && is_file($rootPath),503,'Apple verification is not configured.');
|
||||
$parts=explode('.',$jws);abort_unless(count($parts)===3,422);
|
||||
$header=json_decode($this->decode($parts[0]),true,512,JSON_THROW_ON_ERROR);
|
||||
abort_unless(($header['alg']??'')==='ES256' && count($header['x5c']??[])>=2 && count($header['x5c'])<=3,422);
|
||||
$certs=array_map(fn($c)=>$this->pem($c),$header['x5c']);$root=file_get_contents($rootPath);
|
||||
foreach($certs as $index=>$cert){
|
||||
$parsed=openssl_x509_parse($cert);abort_unless($parsed && $parsed['validFrom_time_t']<=time() && $parsed['validTo_time_t']>=time(),422);
|
||||
$issuer=$certs[$index+1]??$root;
|
||||
abort_unless(openssl_x509_verify($cert,openssl_pkey_get_public($issuer))===1,422,'Invalid Apple certificate chain.');
|
||||
}
|
||||
$leaf=openssl_x509_parse($certs[0]);abort_unless(isset($leaf['extensions']['1.2.840.113635.100.6.11.1']),422,'Not an Apple transaction signing certificate.');
|
||||
$sig=$this->decode($parts[2]);abort_unless(strlen($sig)===64,422);
|
||||
$der='';foreach([substr($sig,0,32),substr($sig,32)] as $integer){$integer=ltrim($integer,"\0");if($integer==='' || (ord($integer[0])&128)){$integer="\0".$integer;}$der.="\x02".chr(strlen($integer)).$integer;}
|
||||
$der="\x30".chr(strlen($der)).$der;
|
||||
abort_unless(openssl_verify($parts[0].'.'.$parts[1],$der,$certs[0],OPENSSL_ALGO_SHA256)===1,422,'Invalid transaction signature.');
|
||||
$claims=json_decode($this->decode($parts[1]),true,512,JSON_THROW_ON_ERROR);
|
||||
abort_unless(config('tarnax.apple.bundle_id') && config('tarnax.apple.product_id'),503);
|
||||
abort_unless(($claims['bundleId']??'')===config('tarnax.apple.bundle_id') && ($claims['productId']??'')===config('tarnax.apple.product_id') && ($claims['environment']??'')===config('tarnax.apple.environment') && strtolower($claims['appAccountToken']??'')===strtolower($accountToken),422,'Transaction does not match this account or product.');
|
||||
abort_if(isset($claims['revocationDate']) || (($claims['expiresDate']??PHP_INT_MAX)<time()*1000),403,'Purchase is no longer valid.');
|
||||
abort_unless(isset($claims['transactionId'],$claims['signedDate']) && $claims['signedDate']<=time()*1000+300000 && $claims['signedDate']>=time()*1000-86400000,422,'A recent signed transaction is required.');
|
||||
return $claims;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Str;
|
||||
class LicenseService {
|
||||
public function issue(int $userId,int $purchaseId): void {
|
||||
if(DB::table('licenses')->where('purchase_id',$purchaseId)->exists()){return;}
|
||||
$key='TNX-'.Str::upper(bin2hex(random_bytes(20)));
|
||||
DB::table('licenses')->insert(['user_id'=>$userId,'purchase_id'=>$purchaseId,'key_hash'=>hash('sha256',$key),'key_encrypted'=>Crypt::encryptString($key),'status'=>'active','device_limit'=>2,'created_at'=>now(),'updated_at'=>now()]);
|
||||
}
|
||||
public function activate(string $key,string $device): array {
|
||||
return DB::transaction(function()use($key,$device){
|
||||
$license=DB::table('licenses')->where('key_hash',hash('sha256',$key))->lockForUpdate()->first();
|
||||
abort_unless($license && $license->status==='active',403,'Invalid or revoked license.');
|
||||
$deviceHash=hash('sha256',$device);$existing=DB::table('activations')->where('license_id',$license->id)->where('device_hash',$deviceHash)->first();
|
||||
abort_if(!$existing && DB::table('activations')->where('license_id',$license->id)->count()>=$license->device_limit,409,'Device limit reached.');
|
||||
$token=bin2hex(random_bytes(32));
|
||||
DB::table('activations')->updateOrInsert(['license_id'=>$license->id,'device_hash'=>$deviceHash],['token_hash'=>hash('sha256',$token),'last_seen_at'=>now(),'created_at'=>now(),'updated_at'=>now()]);
|
||||
return ['activation_token'=>$token,'valid'=>true,'check_again_in'=>86400];
|
||||
},3);
|
||||
}
|
||||
public function check(string $token,bool $deactivate=false): array {
|
||||
return DB::transaction(function()use($token,$deactivate){
|
||||
$activation=DB::table('activations')->where('token_hash',hash('sha256',$token))->first();
|
||||
abort_unless($activation,403,'Invalid activation.');
|
||||
$license=DB::table('licenses')->where('id',$activation->license_id)->lockForUpdate()->first();
|
||||
if($deactivate){DB::table('activations')->where('id',$activation->id)->delete();return ['deactivated'=>true];}
|
||||
abort_unless($license->status==='active',403,'License revoked.');
|
||||
DB::table('activations')->where('id',$activation->id)->update(['last_seen_at'=>now()]);
|
||||
return ['valid'=>true,'check_again_in'=>86400];
|
||||
},3);
|
||||
}
|
||||
}
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 860 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"guidelines": true
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
|
||||
);
|
||||
})->create();
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
];
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/fortify": "^1.41",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "^4.4",
|
||||
"predis/predis": "^3.7"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^2.10",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install --ignore-scripts",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan dev"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
Generated
+10447
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => 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", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'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),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => 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/locks'),
|
||||
],
|
||||
|
||||
'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,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => 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),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => 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,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Fortify will use while
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Password Broker
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which password broker Fortify can use when a user
|
||||
| is resetting their password. This configured value should match one
|
||||
| of your password brokers setup in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => 'users',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Username / Email
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines which model attribute should be considered as your
|
||||
| application's "username" field. Typically, this might be the email
|
||||
| address of the users but you are free to change this value here.
|
||||
|
|
||||
| Out of the box, Fortify expects forgot password and reset password
|
||||
| requests to have a field named 'email'. If the application uses
|
||||
| another name for the field you may define it below as needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'username' => 'email',
|
||||
|
||||
'email' => 'email',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Lowercase Usernames
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines whether usernames should be lowercased before saving
|
||||
| them in the database, as some database system string fields are case
|
||||
| sensitive. You may disable this for your application if necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'lowercase_usernames' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Home Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the path where users will get redirected during
|
||||
| authentication or password reset when the operations are successful
|
||||
| and the user is authenticated. You are free to change this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/account',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Prefix / Subdomain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which prefix Fortify will assign to all the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| subdomain under which all of the Fortify routes will be available.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => '',
|
||||
|
||||
'domain' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which middleware Fortify will assign to the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| these middleware but typically this provided default is preferred.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Fortify will throttle logins to five requests per minute for
|
||||
| every email and IP address combination. However, if you would like to
|
||||
| specify a custom rate limiter to call then you may specify it here.
|
||||
|
|
||||
*/
|
||||
|
||||
'limiters' => [
|
||||
'login' => 'login',
|
||||
'two-factor' => 'two-factor',
|
||||
'passkeys' => 'passkeys',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register View Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify if the routes returning views should be disabled as
|
||||
| you may not need them when building your own application. This may be
|
||||
| especially true if you're writing a custom single-page application.
|
||||
|
|
||||
*/
|
||||
|
||||
'views' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passkeys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These settings configure Fortify's passkey (WebAuthn) support. Passkeys
|
||||
| allow users to sign in without needing to remember credentials since
|
||||
| they use public-key cryptography - making them immune to breaches.
|
||||
|
|
||||
*/
|
||||
|
||||
'passkeys' => [
|
||||
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||
'allowed_origins' => [config('app.url')],
|
||||
'timeout' => 60000,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Features
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some of the Fortify features are optional. You may disable the features
|
||||
| by removing them from this array. You're free to only remove some of
|
||||
| these features or you can even remove all of these if you need to.
|
||||
|
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
Features::registration(),
|
||||
Features::resetPasswords(),
|
||||
Features::emailVerification(),
|
||||
Features::updateProfileInformation(),
|
||||
Features::updatePasswords(),
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
// 'window' => 0,
|
||||
]),
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => 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", "monthly", "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'),
|
||||
'max_files' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'monthly' => [
|
||||
'driver' => 'monthly',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'max_files' => 3,
|
||||
'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'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => 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')),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => 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',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Resend, Postmark, AWS, and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'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'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => 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.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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',
|
||||
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'paddle' => ['environment'=>env('PADDLE_ENVIRONMENT','sandbox'),'api_key'=>env('PADDLE_API_KEY'),'client_token'=>env('PADDLE_CLIENT_TOKEN'),'price_id'=>env('PADDLE_PRICE_ID'),'webhook_secret'=>env('PADDLE_WEBHOOK_SECRET')],
|
||||
'apple'=>['url'=>env('APPLE_STORE_URL'),'bundle_id'=>env('APPLE_BUNDLE_ID'),'product_id'=>env('APPLE_PRODUCT_ID'),'environment'=>env('APPLE_ENVIRONMENT','Sandbox'),'root_certificate'=>env('APPLE_ROOT_CERTIFICATE')],
|
||||
'support_email'=>env('SUPPORT_EMAIL'),'consent_version'=>'2026-11-04',
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->text('two_factor_secret')
|
||||
->after('password')
|
||||
->nullable();
|
||||
|
||||
$table->text('two_factor_recovery_codes')
|
||||
->after('two_factor_secret')
|
||||
->nullable();
|
||||
|
||||
$table->timestamp('two_factor_confirmed_at')
|
||||
->after('two_factor_recovery_codes')
|
||||
->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_confirmed_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Laravel\Passkeys\Passkeys;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('passkeys', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignIdFor(Passkeys::userModel(), 'user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('credential_id')->unique();
|
||||
$table->json('credential');
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('passkeys');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::table('users',function(Blueprint $t){$t->boolean('is_admin')->default(false);$t->uuid('account_token')->nullable()->unique();});
|
||||
Schema::create('pages',function(Blueprint $t){$t->id();$t->string('slug')->unique();$t->string('title');$t->text('body');$t->string('description')->nullable();$t->boolean('published')->default(false);$t->timestamps();});
|
||||
Schema::create('settings',function(Blueprint $t){$t->string('key')->primary();$t->text('value')->nullable();});
|
||||
Schema::create('releases',function(Blueprint $t){$t->id();$t->string('version')->unique();$t->string('channel')->default('stable');$t->string('platform')->default('macOS');$t->text('notes');$t->string('path');$t->string('sha256',64);$t->unsignedBigInteger('bytes');$t->boolean('published')->default(false);$t->timestamps();});
|
||||
Schema::create('purchases',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->restrictOnDelete();$t->string('provider');$t->string('transaction_id')->unique();$t->string('subscription_id')->nullable()->index();$t->string('status');$t->unsignedBigInteger('total')->default(0);$t->string('currency',3)->default('USD');$t->timestamp('provider_updated_at')->nullable();$t->timestamps();});
|
||||
Schema::create('licenses',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->foreignId('purchase_id')->unique()->constrained()->cascadeOnDelete();$t->string('key_hash',64)->unique();$t->text('key_encrypted');$t->string('status')->default('active');$t->unsignedInteger('device_limit')->default(2);$t->timestamps();});
|
||||
Schema::create('activations',function(Blueprint $t){$t->id();$t->foreignId('license_id')->constrained()->cascadeOnDelete();$t->string('device_hash',64);$t->string('token_hash',64)->unique();$t->timestamp('last_seen_at');$t->timestamps();$t->unique(['license_id','device_hash']);});
|
||||
Schema::create('webhook_events',function(Blueprint $t){$t->string('id')->primary();$t->string('type');$t->timestamp('processed_at');});
|
||||
Schema::create('tickets',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->string('subject');$t->string('status')->default('open');$t->timestamps();});
|
||||
Schema::create('ticket_messages',function(Blueprint $t){$t->id();$t->foreignId('ticket_id')->constrained()->cascadeOnDelete();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->text('body');$t->timestamps();});
|
||||
Schema::create('consents',function(Blueprint $t){$t->id();$t->uuid('visitor_id')->index();$t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();$t->string('policy_version');$t->boolean('analytics');$t->boolean('marketing');$t->timestamps();});
|
||||
Schema::create('privacy_requests',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->string('type');$t->string('status')->default('pending');$t->timestamps();});
|
||||
Schema::create('audit_logs',function(Blueprint $t){$t->id();$t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();$t->string('action');$t->string('resource');$t->timestamps();});
|
||||
}
|
||||
public function down(): void {foreach(['audit_logs','privacy_requests','consents','ticket_messages','tickets','webhook_events','activations','licenses','purchases','releases','settings','pages'] as $table){Schema::dropIfExists($table);}Schema::table('users',function(Blueprint $t){$t->dropColumn(['is_admin','account_token']);});}
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
namespace Database\Seeders;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
class DatabaseSeeder extends Seeder {
|
||||
public function run(): void {
|
||||
$pages=[
|
||||
'features'=>['Built around your server','Tarnax is a native remote for your own qBittorrent server. Monitor transfers, organise your library and control file priorities. Requires the qBittorrent Web UI API and a trusted HTTPS connection. Credentials are managed by the native app.'],
|
||||
'privacy'=>['Privacy notice — template','DRAFT — replace and review before publishing.\n\nController: [legal entity, address, contact]. We process account details, purchase references, support messages, device identifier hashes and consent choices to deliver software, secure accounts and provide support. Specify the lawful basis for each purpose.\n\nPaddle processes direct payments as merchant of record. Apple handles App Store purchases. Identify hosting, mail and other processors, international transfers and safeguards. We do not receive payment card details.\n\nSet retention periods for accounts, support, security and billing records. Explain access, correction, erasure, restriction, portability, objection and complaint rights, and name the supervisory authority. Use your account to export data or request deletion. Specify response deadlines and contact.'],
|
||||
'terms'=>['Terms of use — template','DRAFT — legal review required.\n\nProvider: [legal name and address]. Describe supported software, system requirements, price, tax, license scope, device limit, permitted uses, updates, support and termination. Use only content you are authorised to access and share.\n\nDirect purchases are processed by Paddle. App Store purchases are subject to Apple’s applicable terms. Distribution entitlements are separate. State applicable consumer rights, warranties, liability limits, governing law and dispute process without excluding mandatory rights.'],
|
||||
'refunds'=>['Refund policy — template','DRAFT — legal review required.\n\nFor direct purchases contact [support address] with your transaction reference. State the applicable refund period, eligibility and process in agreement with Paddle merchant terms and mandatory consumer rights. Explain digital-content cancellation consent where required. Approved refunds revoke direct licenses. App Store refunds are requested through Apple.'],
|
||||
'cookies'=>['Cookie policy — template','DRAFT — review cookie inventory and retention periods.\n\nEssential session and CSRF cookies protect your account. Tarnax preference and visitor cookies remember choices and record consent for up to one year. Optional analytics and marketing are disabled by default. No analytics or advertising scripts ship in this project. Change or withdraw choices at any time using Cookie preferences. Document any trackers before enabling them.']
|
||||
];
|
||||
foreach($pages as $slug=>[$title,$body]){DB::table('pages')->insertOrIgnore(['slug'=>$slug,'title'=>$title,'body'=>str_replace('\\n',"\n",$body),'description'=>$title,'published'=>true,'created_at'=>now(),'updated_at'=>now()]);}
|
||||
foreach(['headline'=>'Your server. Your control.','tagline'=>'A native iPhone and iPad remote for your own qBittorrent server. Follow live transfers, organise your library and manage the details from wherever you are.','price_label'=>'Pricing coming soon'] as $key=>$value){DB::table('settings')->insertOrIgnore(compact('key','value'));}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Tarnax — native remote control for your qBittorrent server. Support and privacy information."><title>Your server. Your control. · Tarnax</title><link rel="icon" href="assets/favicon.png"><link rel="apple-touch-icon" href="assets/apple-touch-icon.png"><link rel="stylesheet" href="style.css"></head><body><header><a class="brand" href="index.html"><img src="assets/icon.png" alt="" width="44" height="44">Tarnax</a><nav aria-label="Main navigation"><a href="support.html">Support</a><a href="privacy.html">Privacy</a><a href="terms.html">Usage</a></nav></header><main><section class="hero"><img src="assets/icon.png" alt="Tarnax app icon"><p class="eyebrow">Remote for qBittorrent</p><h1>Your server.<br><strong>Your control.</strong></h1><p>A native iPhone and iPad remote for your own qBittorrent server. Follow live transfers, organise your library and manage the details from wherever you are.</p><a class="button" href="support.html">Get support</a></section><section class="cards"><article class="card"><h2>See the flow</h2><p>Colourful upload and download graphs, live speeds and the server space you need to know about.</p></article><article class="card"><h2>Manage the details</h2><p>Search and filter your transfers, control file priorities and edit your server’s preferences.</p></article><article class="card"><h2>Your connection</h2><p>Trusted HTTPS, device Keychain credentials and optional Face ID or device authentication.</p></article></section><p class="note">Requires your own qBittorrent server with its Web UI API enabled and a trusted HTTPS connection. Torrent contents stay on your server. Use only content you are authorised to access and share.</p></main><footer>© 2026 Mark Gregory · <a href="mailto:mark.gregory@abbeysoftware.com">mark.gregory@abbeysoftware.com</a><p>Independent software. Not affiliated with or endorsed by the qBittorrent project.</p></footer></body></html>
|
||||
Generated
+2229
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@laravel/multiplex": "^0.4.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env force="true" name="APP_ENV" value="testing"/>
|
||||
<env force="true" name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env force="true" name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env force="true" name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env force="true" name="CACHE_STORE" value="array"/>
|
||||
<env force="true" name="DB_CONNECTION" value="sqlite"/>
|
||||
<env force="true" name="DB_DATABASE" value=":memory:"/>
|
||||
<env force="true" name="DB_URL" value=""/>
|
||||
<env force="true" name="MAIL_MAILER" value="array"/>
|
||||
<env force="true" name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env force="true" name="SESSION_DRIVER" value="array"/>
|
||||
<env force="true" name="PULSE_ENABLED" value="false"/>
|
||||
<env force="true" name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env force="true" name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
@@ -1,21 +0,0 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Tarnax — native remote control for your qBittorrent server. Support and privacy information."><title>Privacy · Tarnax</title><link rel="icon" href="assets/favicon.png"><link rel="apple-touch-icon" href="assets/apple-touch-icon.png"><link rel="stylesheet" href="style.css"></head><body><header><a class="brand" href="index.html"><img src="assets/icon.png" alt="" width="44" height="44">Tarnax</a><nav aria-label="Main navigation"><a href="support.html">Support</a><a href="privacy.html">Privacy</a><a href="terms.html">Usage</a></nav></header><main><h1>Tarnax privacy policy</h1>
|
||||
<p>Effective date: 4 November 2026</p>
|
||||
<p>Tarnax is developed by Mark Gregory. Contact: mark.gregory@abbeysoftware.com.</p>
|
||||
<h2>Your connection</h2>
|
||||
<p>Tarnax is a remote control for a qBittorrent server that you configure. It sends your server address, authentication information, API requests, torrent links or files that you choose to add, and management commands directly to that server over HTTPS. Reverse-proxy credentials, if configured, are sent to the proxy serving that connection. The server and proxy operators may retain access logs, IP addresses, account information or transfer data under their own policies. You are responsible for choosing a server you trust and having permission to use it.</p>
|
||||
<p>Tarnax does not provide a hosted torrent service. It does not send your library or credentials to a developer-operated service unless you explicitly choose a server operated by the developer. The released app has no preset production server.</p>
|
||||
<h2>Information on your device</h2>
|
||||
<p>Saved connection credentials are kept in the device Keychain with device-only, when-unlocked protection. The app-lock preference and navigation state are stored locally. Session cookies use a private temporary network session and are discarded when the connection closes. Transfer-history samples are held in memory for the current session and are cleared when the server connection is replaced or disconnected.</p>
|
||||
<p>When you select a .torrent file, Tarnax reads it to upload it to your chosen server. Torrent contents remain on the server; Tarnax does not download those contents to your iPhone or iPad.</p>
|
||||
<h2>Authentication</h2>
|
||||
<p>Face ID, Touch ID where supported, and device passcode authentication are handled by Apple's LocalAuthentication services. Tarnax receives an authentication result. It does not receive or store biometric templates, facial images or your device passcode.</p>
|
||||
<h2>Tracking and analytics</h2>
|
||||
<p>Tarnax includes no advertising, tracking SDKs or third-party analytics services. It does not request advertising identifiers, contacts, photos, microphone, camera or location access. Apple may process App Store, payment or diagnostic information according to your Apple settings and Apple's policies. Information collected independently by your selected server is governed by that server operator.</p>
|
||||
<h2>Support correspondence</h2>
|
||||
<p>If you email support, we receive the information you choose to send and use it to respond, investigate the issue and maintain the support correspondence. Email providers process those messages as part of delivering the service. Do not send passwords, private torrent links or sensitive server data. Ask us to delete support correspondence by emailing the address above; we will consider the request subject to applicable retention obligations and legitimate record-keeping needs.</p>
|
||||
<h2>Your choices</h2>
|
||||
<p>Use Settings → Forget saved credentials to remove Tarnax's saved Keychain credentials and disconnect. Disconnecting also discards the session history. Deleting the app removes its local app data, but iOS may retain Keychain items; use the Forget action before deleting the app if you want to remove those credentials. Server-side accounts, files, logs and torrent data must be managed with your server operator.</p>
|
||||
<h2>Children and changes</h2>
|
||||
<p>Tarnax is a general-purpose server utility and is not designed specifically for children. We do not knowingly seek personal information from children. This policy may be updated if the app or its data practices change. The effective date above identifies the current version.</p>
|
||||
<h2>Contact</h2>
|
||||
<p>mark.gregory@abbeysoftware.com</p><h2>This website</h2><p>These supplied pages contain no analytics scripts, advertising or cookies. The hosting provider you use may process technical request logs for operating and securing the site. Confirm the final hosting arrangement before publishing this policy.</p></main><footer>© 2026 Mark Gregory · <a href="mailto:mark.gregory@abbeysoftware.com">mark.gregory@abbeysoftware.com</a><p>Independent software. Not affiliated with or endorsed by the qBittorrent project.</p></footer></body></html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
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]
|
||||
</IfModule>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,9 @@
|
||||
@import 'tailwindcss';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
@theme { --font-sans: ui-sans-serif, system-ui, sans-serif; }
|
||||
@layer base { body { @apply bg-slate-950 text-slate-200 antialiased; } h1 { @apply text-4xl md:text-6xl font-semibold tracking-tight text-white; } h2 { @apply text-2xl font-semibold text-white mb-4; } h3 { @apply text-lg font-semibold text-white; } input:not([type=checkbox]), textarea, select { @apply w-full rounded-xl border border-slate-700 bg-slate-900 p-3 text-white; } label { @apply block text-sm text-slate-300 mb-2; } button, a { @apply focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-cyan-300; } }
|
||||
@layer components { .button { @apply inline-flex items-center justify-center rounded-xl bg-cyan-300 px-5 py-3 font-semibold text-slate-950 hover:bg-cyan-200 transition cursor-pointer; } .secondary { @apply inline-flex items-center justify-center rounded-xl border border-slate-700 px-5 py-3 text-slate-100 hover:bg-slate-800 cursor-pointer; } .card { @apply rounded-2xl border border-slate-800 bg-slate-900/70 p-6; } .muted { @apply text-slate-400; } .eyebrow { @apply text-xs uppercase tracking-[.25em] text-cyan-300 font-semibold; } .prose-copy { @apply whitespace-pre-wrap leading-8 text-slate-300; } }
|
||||
[x-cloak] {display:none!important}
|
||||
@@ -0,0 +1 @@
|
||||
//
|
||||
@@ -0,0 +1 @@
|
||||
<x-layout title="Your account · Tarnax"><p class="eyebrow mb-4">Your Tarnax</p><h1 class="mb-8">Welcome, {{ auth()->user()->name }}.</h1><div class="flex flex-wrap gap-3 mb-8"><a class="secondary" href="{{ route('security') }}">Account security</a><a class="secondary" href="{{ route('support') }}">Get support</a><a class="secondary" href="{{ route('privacy.export') }}">Export personal data</a></div><div class="grid md:grid-cols-2 gap-6"><section class="card"><h2>Direct licenses</h2>@forelse($licenses as $license)<div class="border-t border-slate-700 py-4"><p class="text-cyan-200 mb-2">{{ ucfirst($license->status) }} · {{ $license->device_limit }} devices</p><code class="text-sm break-all">{{ \Illuminate\Support\Facades\Crypt::decryptString($license->key_encrypted) }}</code><p class="text-xs muted mt-2">Keep your license key private.</p></div>@empty<p class="muted">Your license appears here after a verified direct purchase.</p>@endforelse</section><section class="card"><h2>Your downloads</h2>@forelse($releases as $release)<a class="block border-t border-slate-700 py-4" href="{{ route('download.link',$release->id) }}">{{ $release->version }} · {{ $release->platform }} ↗</a>@empty<p class="muted">No direct releases yet.</p>@endforelse</section><section class="card"><h2>Purchases</h2>@forelse($purchases as $purchase)<p class="py-3 border-t border-slate-700">{{ ucfirst($purchase->provider) }} · {{ $purchase->status }} · {{ $purchase->currency }} {{ number_format($purchase->total/100,2) }}</p>@empty<p class="muted">No purchases yet.</p>@endforelse</section><section class="card"><h2>App Store verification</h2><p class="muted text-sm mb-4">Your app can associate its StoreKit purchase using this account token. Apple purchases do not grant a direct license.</p><code class="text-xs break-all">{{ auth()->user()->account_token }}</code><details class="mt-4"><summary>Verify a signed StoreKit transaction</summary><form method="post" action="{{ route('apple.verify') }}" class="space-y-3 mt-3">@csrf<label>Signed transaction<textarea name="signed_transaction" required></textarea></label><button class="secondary">Verify purchase</button></form></details></section></div><section class="card mt-8"><h2>Privacy requests</h2><p class="muted mb-4">Request account erasure. Billing records required for legal retention may be retained; support reviews each request.</p><form method="post" action="{{ route('privacy.delete') }}">@csrf<button class="secondary">Request account deletion</button></form></section></x-layout>
|
||||
@@ -0,0 +1 @@
|
||||
<x-layout title="Admin · Tarnax"><p class="eyebrow mb-4">Tarnax / admin</p><h1 class="mb-8">Your control room.</h1><livewire:admin.cms /></x-layout>
|
||||
@@ -0,0 +1,9 @@
|
||||
<x-layout title="Account · Tarnax"><div class="max-w-md mx-auto card"><p class="eyebrow mb-4">Your Tarnax account</p><h2>{{ ucwords(str_replace('-',' ',$mode)) }}</h2><form method="post" action="{{ ['login'=>'/login','register'=>'/register','forgot-password'=>'/forgot-password','reset-password'=>'/reset-password','confirm-password'=>'/user/confirm-password','two-factor-challenge'=>'/two-factor-challenge'][$mode] }}" class="space-y-4">@csrf
|
||||
@if($mode==='register')<label>Name<input name="name" value="{{ old('name') }}" required autocomplete="name"></label>@endif
|
||||
@if(in_array($mode,['login','register','forgot-password','reset-password']))<label>Email<input type="email" name="email" value="{{ old('email',$request->email??'') }}" required autocomplete="email"></label>@endif
|
||||
@if(in_array($mode,['login','register','reset-password','confirm-password']))<label>Password<input type="password" name="password" required autocomplete="{{ in_array($mode,['register','reset-password'])?'new-password':'current-password' }}"></label>@endif
|
||||
@if(in_array($mode,['register','reset-password']))<label>Confirm password<input type="password" name="password_confirmation" required autocomplete="new-password"></label>@endif
|
||||
@if($mode==='reset-password')<input type="hidden" name="token" value="{{ $request->route('token') }}">@endif
|
||||
@if($mode==='two-factor-challenge')<label>Authenticator code<input name="code" inputmode="numeric" autocomplete="one-time-code"></label><label>Or recovery code<input name="recovery_code"></label>@endif
|
||||
@if($mode==='register')<p class="muted text-sm">By registering, you agree to the <a class="underline" href="{{ route('page','terms') }}">terms</a>. Read our <a class="underline" href="{{ route('page','privacy') }}">privacy notice</a>.</p>@endif
|
||||
<button class="button w-full">Continue →</button></form><div class="flex gap-4 text-sm mt-5"><a href="/login">Sign in</a><a href="/register">Register</a><a href="/forgot-password">Reset password</a></div></div></x-layout>
|
||||
@@ -0,0 +1 @@
|
||||
<x-layout><div class="card max-w-xl mx-auto"><h2>Verify your email</h2><p class="muted mb-5">Open the link we sent you to activate your account.</p><form method="post" action="/email/verification-notification">@csrf<button class="button">Resend verification email</button></form></div></x-layout>
|
||||
@@ -0,0 +1,7 @@
|
||||
@props(['title'=>'Tarnax — Your server. Your control.','description'=>'A native remote for your qBittorrent server.'])
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"><meta name="description" content="{{ $description }}"><title>{{ $title }}</title><link rel="icon" href="/brand/favicon.png">@vite(['resources/css/app.css','resources/js/app.js']) @livewireStyles</head>
|
||||
<body><header class="border-b border-slate-800"><div class="max-w-7xl mx-auto px-6 py-5 flex flex-wrap items-center justify-between gap-5"><a href="{{ route('home') }}" class="flex items-center gap-3 text-xl font-bold text-white"><img src="/brand/icon.png" width="36" height="36" class="rounded-xl" alt="">tarnax<span class="text-xs font-normal text-slate-500">/ software, considered.</span></a><nav aria-label="Main navigation" class="flex flex-wrap gap-6 text-sm"><a href="{{ route('releases') }}">Downloads</a><a href="{{ route('page','features') }}">Features</a><a href="{{ route('support') }}">Support</a>@auth<a href="{{ route('account') }}">My account</a>@if(auth()->user()->is_admin)<a href="{{ route('admin') }}">Admin</a>@endif<form method="post" action="/logout">@csrf<button>Sign out</button></form>@else<a href="/login">Sign in ↗</a>@endauth</nav></div></header>
|
||||
<main class="max-w-7xl mx-auto px-6 py-12 min-h-[70vh]">@if(session('status'))<div role="status" class="card mb-6 text-cyan-200">{{ session('status') }}</div>@endif @if($errors->any())<div role="alert" class="card mb-6 text-rose-300">{{ $errors->first() }}</div>@endif {{ $slot }}</main>
|
||||
<footer class="border-t border-slate-800"><div class="max-w-7xl mx-auto p-6 flex flex-wrap gap-5 text-sm text-slate-400"><span class="mr-auto">© {{ date('Y') }} Tarnax · Independent software</span>@foreach(['privacy','terms','refunds','cookies'] as $slug)<a href="{{ route('page',$slug) }}">{{ ucfirst($slug) }}</a>@endforeach<button onclick="window.dispatchEvent(new Event('cookie-preferences'))">Cookie preferences</button></div><p class="max-w-7xl mx-auto px-6 pb-6 text-xs text-slate-500">Requires your own qBittorrent server. Use only content you are authorised to access and share. Not affiliated with the qBittorrent project.</p></footer>
|
||||
<div x-data="{open: !document.cookie.includes('tarnax_consent='), analytics:false, marketing:false, error:'', async save(a,m){try{let r=await fetch('{{ route('consent') }}',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-TOKEN':document.querySelector('meta[name=csrf-token]').content,'Accept':'application/json'},body:JSON.stringify({analytics:a,marketing:m})});if(!r.ok)throw Error();this.open=false;window.dispatchEvent(new Event('consent-changed'));}catch(e){this.error='Could not save preferences. Please try again.'}}}" @cookie-preferences.window="open=true" x-show="open" x-cloak role="dialog" aria-label="Cookie preferences" class="fixed bottom-4 left-4 right-4 md:left-auto md:w-[520px] card shadow-2xl z-50"><h3>Your privacy, your choice</h3><p class="muted text-sm my-3">Essential cookies keep your account secure. Optional cookies stay off until you choose them. No optional trackers are installed in this build.</p><label><input type="checkbox" x-model="analytics"> Analytics</label><label><input type="checkbox" x-model="marketing"> Marketing</label><div class="flex flex-wrap gap-2 mt-4"><button class="secondary" @click="save(false,false)">Reject optional</button><button class="secondary" @click="save(analytics,marketing)">Save choices</button><button class="button" @click="save(true,true)">Accept all</button></div><p x-text="error" role="alert"></p></div>
|
||||
@livewireScripts</body></html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<x-layout>
|
||||
@php($settings=\Illuminate\Support\Facades\DB::table('settings')->pluck('value','key'))
|
||||
<section class="grid lg:grid-cols-2 gap-16 items-center py-12 md:py-20"><div><div class="eyebrow mb-6">Built for your connection</div><h1 class="leading-[1.05]">{{ $settings['headline'] ?? 'Your server. Your control.' }}</h1><p class="text-lg text-slate-400 leading-8 my-7 max-w-xl">{{ $settings['tagline'] ?? 'A native remote for your qBittorrent server.' }}</p><div class="flex flex-wrap gap-3">@if(config('tarnax.apple.url'))<a class="button" href="{{ config('tarnax.apple.url') }}" rel="noopener">Download on the App Store ↗</a>@endif<a href="#direct" class="secondary">Explore direct purchase →</a></div><p class="text-xs text-slate-500 mt-5">Your server. Your files. A clearer view.</p></div><div class="relative rounded-3xl border border-slate-700 bg-gradient-to-br from-cyan-950 via-slate-900 to-indigo-950 p-8 shadow-2xl"><div class="flex items-center gap-3 mb-10"><img src="/brand/icon.png" class="rounded-2xl" width="56" height="56" alt="Tarnax icon"><div><h3>Tarnax</h3><p class="text-sm muted">A little closer to your server.</p></div><span class="ml-auto rounded-full bg-cyan-400/10 text-cyan-200 text-xs px-3 py-1">Interface preview</span></div><div class="grid grid-cols-2 gap-4"><div class="card"><span class="text-xs muted">DOWNLOAD</span><p class="text-3xl text-white mt-2">42.8 <span class="text-sm muted">MB/s</span></p></div><div class="card"><span class="text-xs muted">UPLOAD</span><p class="text-3xl text-white mt-2">8.2 <span class="text-sm muted">MB/s</span></p></div></div><svg viewBox="0 0 400 120" class="w-full my-6" role="img" aria-label="Illustrative transfer activity"><path d="M0 105L30 80L60 90L90 40L120 60L150 25L180 50L210 15L240 40L270 20L300 45L330 10L360 30L400 15" fill="none" stroke="#67e8f9" stroke-width="3"/><path d="M0 115L30 110L60 100L90 110L120 80L150 90L180 65L210 85L240 70L270 90L300 60L330 80L360 55L400 70" fill="none" stroke="#818cf8" stroke-width="2"/></svg><div class="flex justify-between text-sm muted border-t border-slate-700 pt-5"><span>Everything in view.</span><span>Designed with care ↗</span></div></div></section>
|
||||
<section class="grid md:grid-cols-3 gap-5 my-12">@foreach([['01 / SEE THE FLOW','A clearer picture','Live transfer activity, speeds and server space, thoughtfully presented.'],['02 / MANAGE THE DETAILS','Everything in its place','Search, filter and organise transfers. Fine-tune file priorities and server preferences.'],['03 / YOUR CONNECTION','Made for your server','Connect to your own qBittorrent Web UI over trusted HTTPS. Your content stays on your server.']] as [$label,$title,$body])<article class="card"><p class="eyebrow mb-8">{{ $label }}</p><h3>{{ $title }}</h3><p class="muted mt-3 leading-7">{{ $body }}</p></article>@endforeach</section>
|
||||
<section id="direct" class="card my-20 grid md:grid-cols-2 gap-10 p-10"><div><p class="eyebrow mb-4">Two ways to make it yours</p><h2>Choose your distribution.</h2><p class="muted leading-7">App Store purchases are managed by Apple. Direct purchases use Paddle and include a separate license for supported direct-distribution builds. Check release availability before purchasing.</p></div><div><h3>Direct license</h3><p class="text-3xl text-white my-4">{{ $settings['price_label'] ?? 'Pricing coming soon' }}</p><p class="muted text-sm mb-5">Two device activations · Account downloads · Personal support</p>@auth<button class="button" x-data @click="window.startTarnaxCheckout($el)">Buy securely with Paddle →</button>@else<a class="button" href="/register">Create an account to purchase →</a>@endauth<p id="checkout-status" role="status" class="mt-3 text-sm muted"></p></div></section>
|
||||
@if(config('tarnax.paddle.client_token'))<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script><script>window.addEventListener('load',()=>{ @if(config('tarnax.paddle.environment')!=='production') Paddle.Environment.set('sandbox'); @endif Paddle.Initialize({token:@json(config('tarnax.paddle.client_token'))}); });</script>@endif
|
||||
<script>window.startTarnaxCheckout=async function(button){button.disabled=true;let status=document.getElementById('checkout-status');try{let r=await fetch(@json(route('checkout')),{method:'POST',headers:{'X-CSRF-TOKEN':document.querySelector('meta[name=csrf-token]').content,'Accept':'application/json'}});let data=await r.json();if(!r.ok)throw Error(data.message||'Checkout unavailable');if(!window.Paddle)throw Error('Checkout is awaiting configuration.');Paddle.Checkout.open({transactionId:data.transaction_id});status.textContent='Complete payment in the secure checkout. Your account updates after confirmation.';}catch(e){status.textContent=e.message;}finally{button.disabled=false;}}</script>
|
||||
</x-layout>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div><div class="flex flex-wrap gap-4 mb-6"><label class="min-w-56">Manage<select wire:model.live="section">@foreach($sections as $name)<option value="{{ $name }}">{{ ucwords(str_replace('_',' ',$name)) }}</option>@endforeach</select></label><div class="card flex-1 text-sm muted">Protected by administrator access and confirmed 2FA. Showing the latest 100 records per section. Mutations are audited.</div></div>@if(session('status'))<p role="status" class="text-cyan-200 mb-4">{{ session('status') }}</p>@endif @if($errors->any())<p role="alert" class="text-rose-300 mb-4">{{ $errors->first() }}</p>@endif
|
||||
@if(in_array($section,['pages','releases']))<form wire:submit="save" class="card grid md:grid-cols-2 gap-4 mb-8"><h2>{{ $editing?'Edit':'Create' }} {{ rtrim($section,'s') }}</h2>@foreach($section==='pages'?['slug','title','description']:['version','channel','platform'] as $field)<label>{{ ucfirst($field) }}<input wire:model="form.{{ $field }}"></label>@endforeach<label class="md:col-span-2">{{ $section==='pages'?'Body (plain text, safe by default)':'Release notes' }}<textarea rows="6" wire:model="form.{{ $section==='pages'?'body':'notes' }}"></textarea></label>@if($section==='releases')<label>Private binary (ZIP, DMG or PKG)<input type="file" wire:model="upload" accept=".zip,.dmg,.pkg"></label>@endif<label><input type="checkbox" wire:model="form.published"> Published</label><button class="button" wire:loading.attr="disabled">Save</button><button type="button" class="secondary" wire:click="newRecord">New record</button></form>@endif
|
||||
@if($section==='settings')<form wire:submit="saveSettings" class="card mb-8 space-y-4"><h2>Storefront settings</h2>@foreach(['headline','tagline','price_label'] as $field)<label>{{ ucwords(str_replace('_',' ',$field)) }}<input wire:model="form.{{ $field }}" required></label>@endforeach<button class="button">Save settings</button></form>@endif
|
||||
<div class="card overflow-x-auto"><table class="w-full text-left text-sm"><thead><tr>@if($rows->isNotEmpty())@foreach(array_keys((array)$rows->first()) as $key)<th class="p-3 text-slate-400">{{ $key }}</th>@endforeach<th>Actions</th>@endif</tr></thead><tbody>@forelse($rows as $row)<tr class="border-t border-slate-800">@foreach((array)$row as $key=>$value)<td class="p-3 max-w-64 break-words">{{ \Illuminate\Support\Str::limit((string)$value,160) }}</td>@endforeach<td class="p-3">@if(in_array($section,['pages','releases']))<button class="text-cyan-200" wire:click="edit({{ $row->id }})">Edit</button>@elseif($section==='licenses')<button class="text-rose-200" wire:confirm="Revoke this license?" wire:click="revoke({{ $row->id }})">Revoke</button>@elseif($section==='privacy_requests' && $row->status==='pending')<button wire:confirm="Erase customer profile and support data, revoke licenses, and retain billing records?" wire:click="fulfillDeletion({{ $row->id }})">Fulfill deletion</button>@endif</td></tr>@empty<tr><td class="p-5 muted">No records yet.</td></tr>@endforelse</tbody></table></div></div>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="grid lg:grid-cols-3 gap-6"><section class="card"><h2>Start a conversation</h2><form wire:submit="create" class="space-y-4"><label>Subject<input wire:model="subject" required maxlength="200"></label><label>How can we help?<textarea wire:model="body" rows="5" required></textarea></label><button class="button" wire:loading.attr="disabled">Create ticket</button></form><h3 class="mt-8">Your tickets</h3>@foreach($tickets as $ticket)<button wire:click="select({{ $ticket->id }})" class="block text-left w-full py-4 border-b border-slate-700">#{{ $ticket->id }} {{ $ticket->subject }}<span class="block text-xs muted">{{ $ticket->status }}</span></button>@endforeach</section><section class="card lg:col-span-2">@if($errors->any())<p role="alert" class="text-rose-300 mb-4">{{ $errors->first() }}</p>@endif @if($ticketId)<h2>Ticket #{{ $ticketId }}</h2>@foreach($messages as $message)<article class="border-b border-slate-700 py-5"><p class="text-sm text-cyan-200">{{ $message->name }} · {{ $message->created_at }}</p><p class="prose-copy mt-2">{{ $message->body }}</p></article>@endforeach<form wire:submit="send" class="space-y-4 mt-6"><label>Reply<textarea wire:model="reply" required rows="4"></textarea></label><button class="button" wire:loading.attr="disabled">Send reply</button><button type="button" wire:click="close" class="secondary">Close ticket</button></form>@else<p class="muted">Choose a conversation or create a ticket. Please never send passwords or license keys.</p>@endif</section></div>
|
||||
@@ -0,0 +1 @@
|
||||
<x-layout :title="$page->title.' · Tarnax'" :description="$page->description ?? ''"><article class="max-w-3xl mx-auto"><p class="eyebrow mb-5">Tarnax / {{ $page->slug }}</p><h1 class="mb-10">{{ $page->title }}</h1><div class="prose-copy">{{ $page->body }}</div></article></x-layout>
|
||||
@@ -0,0 +1 @@
|
||||
<x-layout title="Releases · Tarnax"><p class="eyebrow mb-5">Always moving forward</p><h1 class="mb-5">Downloads & releases</h1><p class="muted mb-10">Direct builds require an active direct license. App Store builds update through Apple.</p><div class="space-y-5">@forelse($releases as $release)<article class="card"><div class="flex justify-between gap-4"><h2>Tarnax {{ $release->version }}</h2><span class="muted">{{ $release->platform }} · {{ $release->channel }}</span></div><p class="prose-copy mb-5">{{ $release->notes }}</p><a class="button" href="{{ route('download.link',$release->id) }}">Download →</a><p class="text-xs muted mt-4 break-all">SHA-256: {{ $release->sha256 }}</p></article>@empty<div class="card">No direct builds have been published yet.</div>@endforelse</div><div class="mt-6">{{ $releases->links() }}</div></x-layout>
|
||||
@@ -0,0 +1 @@
|
||||
<x-layout title="Security · Tarnax"><div class="max-w-2xl space-y-6"><h1 class="mb-8">Account security</h1><section class="card"><h2>Two-factor authentication</h2>@if(!auth()->user()->two_factor_secret)<form method="post" action="/user/two-factor-authentication">@csrf<button class="button">Enable authenticator</button></form>@else<div class="bg-white p-4 inline-block rounded-xl">{!! auth()->user()->twoFactorQrCodeSvg() !!}</div>@if(!auth()->user()->two_factor_confirmed_at)<form method="post" action="/user/confirmed-two-factor-authentication" class="space-y-3 mt-5">@csrf<label>Authenticator code<input name="code" required autocomplete="one-time-code"></label><button class="button">Confirm setup</button></form>@else<p class="mt-4 text-cyan-200">Two-factor authentication is active.</p>@endif<details class="mt-4"><summary>Recovery codes — store securely</summary><pre class="mt-3 text-sm">{{ implode("\n",auth()->user()->recoveryCodes()) }}</pre></details><form method="post" action="/user/two-factor-authentication" class="mt-5">@csrf @method('DELETE')<button class="secondary">Disable two-factor authentication</button></form>@endif</section><section class="card"><h2>Change password</h2><form method="post" action="/user/password" class="space-y-4">@csrf @method('PUT')<label>Current password<input type="password" name="current_password" required></label><label>New password<input type="password" name="password" required></label><label>Confirm new password<input type="password" name="password_confirmation" required></label><button class="button">Update password</button></form></section></div></x-layout>
|
||||
@@ -0,0 +1 @@
|
||||
<x-layout title="Support · Tarnax"><p class="eyebrow mb-4">Tarnax / support</p><h1 class="mb-8">Let’s get it sorted.</h1><livewire:support-desk /></x-layout>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\LicenseController;
|
||||
use App\Http\Controllers\PaddleController;
|
||||
Route::post('/webhooks/paddle',[PaddleController::class,'webhook'])->middleware('throttle:120,1');
|
||||
Route::prefix('v1/licenses')->middleware('throttle:license')->group(function(){
|
||||
Route::post('activate',[LicenseController::class,'activate']);
|
||||
Route::post('validate',[LicenseController::class,'validateLicense']);
|
||||
Route::post('deactivate',[LicenseController::class,'deactivate']);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use App\Models\User;
|
||||
Artisan::command('tarnax:admin {email}',function(){
|
||||
$user=User::where('email',$this->argument('email'))->first();if(!$user){$this->error('Register and verify this account first.');return 1;}
|
||||
if(!$user->hasVerifiedEmail()){$this->error('Verify the email address first.');return 1;}
|
||||
$user->forceFill(['is_admin'=>true])->save();$this->info('Administrator granted. Confirm 2FA in account security before opening /admin.');return 0;
|
||||
})->purpose('Promote a verified customer to administrator');
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\CommerceController as Shop;
|
||||
use App\Http\Controllers\PaddleController;
|
||||
Route::get('/',[Shop::class,'home'])->name('home');
|
||||
Route::get('/pages/{slug}',[Shop::class,'page'])->name('page');
|
||||
Route::get('/releases',[Shop::class,'releases'])->name('releases');
|
||||
Route::post('/consent',[Shop::class,'consent'])->middleware('throttle:30,1')->name('consent');
|
||||
Route::middleware(['auth','verified'])->group(function(){
|
||||
Route::get('/account',[Shop::class,'account'])->name('account');
|
||||
Route::view('/account/security','security')->name('security');
|
||||
Route::get('/support',fn()=>view('support'))->name('support');
|
||||
Route::post('/checkout',[PaddleController::class,'checkout'])->middleware('throttle:10,1')->name('checkout');
|
||||
Route::post('/account/apple',[Shop::class,'apple'])->middleware('throttle:10,1')->name('apple.verify');
|
||||
Route::get('/downloads/{release}',[Shop::class,'downloadLink'])->name('download.link');
|
||||
Route::get('/download/{release}/{user}',[Shop::class,'download'])->middleware('signed')->name('download.file');
|
||||
Route::get('/account/export',[Shop::class,'export'])->middleware('password.confirm')->name('privacy.export');
|
||||
Route::post('/account/deletion',[Shop::class,'deletion'])->middleware('password.confirm')->name('privacy.delete');
|
||||
Route::get('/admin',fn()=>view('admin'))->middleware([\App\Http\Middleware\RequireAdmin::class,'password.confirm'])->name('admin');
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!private/
|
||||
!public/
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,10 @@
|
||||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
lsp-*.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!data/
|
||||
!locks/
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1 +0,0 @@
|
||||
*{box-sizing:border-box}html{color-scheme:dark}body{margin:0;background:#070b1b;color:#edf1fc;font:18px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header,main,footer{max-width:1060px;margin:auto;padding:30px clamp(20px,5vw,64px)}header{display:flex;justify-content:space-between;align-items:center;gap:20px;border-bottom:1px solid #242e4a}.brand{display:flex;align-items:center;gap:14px;color:white;font-size:25px;font-weight:700;text-decoration:none}.brand img{border-radius:10px}nav{display:flex;gap:24px}a{color:#a9bdff;text-underline-offset:4px}a:hover{color:#fff}a:focus-visible{outline:3px solid #25c7ce;outline-offset:6px}h1{font-size:clamp(36px,6vw,64px);line-height:1.12;letter-spacing:-1.8px;margin:30px 0}h2{font-size:26px;line-height:1.3;margin-top:45px}p,li{max-width:76ch;color:#c8d2e8}.eyebrow{color:#67e3dd;font-size:14px;font-weight:700;letter-spacing:2px;text-transform:uppercase}.hero{padding:30px 0 50px}.hero img{width:140px;border-radius:32px}.hero strong{color:#c0c9ff}.button{display:inline-block;background:#7763ff;color:white;text-decoration:none;padding:12px 23px;border-radius:12px;font-weight:650}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px}.card{background:#111a31;border:1px solid #253253;border-radius:20px;padding:25px}.card h2{margin-top:0}.note{padding:20px 25px;border-left:3px solid #20bdc4;background:#101b2d}footer{font-size:14px;border-top:1px solid #242e4a;margin-top:40px}footer p{font-size:13px}details{border-bottom:1px solid #253253;padding:20px 0}summary{cursor:pointer;font-weight:650;color:white}@media(max-width:600px){header{align-items:flex-start;flex-direction:column}nav{font-size:16px}body{font-size:17px}}
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Tarnax — native remote control for your qBittorrent server. Support and privacy information."><title>Support · Tarnax</title><link rel="icon" href="assets/favicon.png"><link rel="apple-touch-icon" href="assets/apple-touch-icon.png"><link rel="stylesheet" href="style.css"></head><body><header><a class="brand" href="index.html"><img src="assets/icon.png" alt="" width="44" height="44">Tarnax</a><nav aria-label="Main navigation"><a href="support.html">Support</a><a href="privacy.html">Privacy</a><a href="terms.html">Usage</a></nav></header><main><h1>How can we help?</h1><p>For Tarnax support, email <a href="mailto:mark.gregory@abbeysoftware.com">mark.gregory@abbeysoftware.com</a>. Please include the Tarnax version/build, iPhone or iPad model, iOS version, qBittorrent version, and what happened.</p><p class="note">Never send passwords, private magnet links or sensitive server contents. Redact identifying information from screenshots.</p><h2>Quick answers</h2><details><summary>What do I need?</summary><p>An existing qBittorrent server, Web UI API access, valid credentials and a trusted HTTPS endpoint. Tarnax does not provide the server, content catalogue or a VPN.</p></details><details><summary>Why does my connection fail?</summary><p>Check the HTTPS hostname, certificate chain and reverse-proxy subpath. HTTP 401 usually means proxy authentication; HTTP 403 may indicate session expiry, access rules or server protection. Use Disconnect/change server to reconnect after correcting the configuration.</p></details><details><summary>Does Tarnax download to my phone?</summary><p>No. It sends commands and selected .torrent files to your server. Torrent contents remain on that server.</p></details><details><summary>Why does the graph start empty?</summary><p>History records transfer samples while Tarnax is active and unlocked. It is session history, not a download-history database. Wait for a few updates; background pauses appear as gaps.</p></details><details><summary>How do I retry Face ID?</summary><p>Normal unlocking happens automatically. If authentication is cancelled or interrupted, tap Unlock to retry using Face ID or the device passcode fallback. Check that the app is allowed to use Face ID in iOS Settings.</p></details><details><summary>How do I remove saved credentials?</summary><p>Open Tarnax Settings and choose Forget saved credentials. iOS may retain Keychain data after deleting an app, so use this action before uninstalling if you want to remove the saved connection.</p></details><details><summary>Can I edit every server option?</summary><p>Tarnax exposes preferences returned by your server. Available settings and commands depend on qBittorrent’s version and configuration. Network, authentication and storage changes may interrupt access or move data.</p></details><details><summary>Why don’t transfers update in the background?</summary><p>This version refreshes while the app is active and unlocked. iOS background execution is not requested.</p></details><details><summary>Where is the privacy policy?</summary><p>Open Privacy in the navigation above, or Settings → Privacy policy inside Tarnax.</p></details></main><footer>© 2026 Mark Gregory · <a href="mailto:mark.gregory@abbeysoftware.com">mark.gregory@abbeysoftware.com</a><p>Independent software. Not affiliated with or endorsed by the qBittorrent project.</p></footer></body></html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user