Compare commits
1 Commits
f4c2e4ed2f
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b71b88923 |
13
.env.example
13
.env.example
@@ -53,6 +53,7 @@ REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Local/CI: log or array. Production transactional email: MAIL_MAILER=resend
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
@@ -62,10 +63,22 @@ MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# Required when MAIL_MAILER=resend
|
||||
RESEND_API_KEY=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
# Cloudflare R2 (production object storage). Set FILESYSTEM_DISK=r2 in production.
|
||||
# R2_URL must be the custom domain mapped to the bucket (e.g. https://media.example.com).
|
||||
# R2_ENDPOINT example: https://<account_id>.r2.cloudflarestorage.com
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=
|
||||
R2_ENDPOINT=
|
||||
R2_URL=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
24
README.md
24
README.md
@@ -82,13 +82,33 @@ docker exec amare-postgres psql -U amare -d amare -c "CREATE DATABASE amare_test
|
||||
|
||||
## Armazenamento de mídia
|
||||
|
||||
Uploads de conteúdo usam o disco `public`. Crie o symlink antes de servir arquivos localmente:
|
||||
Uploads de conteúdo usam o disco `public` em desenvolvimento. Crie o symlink antes de servir arquivos localmente:
|
||||
|
||||
```bash
|
||||
php artisan storage:link
|
||||
```
|
||||
|
||||
Em produção, configure `FILESYSTEM_DISK=s3` no `.env`.
|
||||
Em produção, configure `FILESYSTEM_DISK=r2` (Cloudflare R2). O disco legado `s3` continua suportado se necessário.
|
||||
|
||||
## Provedores de produção
|
||||
|
||||
Produção usa **Resend** (e-mail transacional) e **Cloudflare R2** (mídia pública via domínio customizado). Local e CI permanecem com defaults seguros (`MAIL_MAILER=log`/`array`, disco `public`).
|
||||
|
||||
### Checklist de variáveis (produção)
|
||||
|
||||
| Variável | Valor |
|
||||
|---|---|
|
||||
| `MAIL_MAILER` | `resend` |
|
||||
| `RESEND_API_KEY` | API key Resend |
|
||||
| `MAIL_FROM_ADDRESS` / `MAIL_FROM_NAME` | Remetente verificado no Resend |
|
||||
| `FILESYSTEM_DISK` | `r2` |
|
||||
| `R2_ACCESS_KEY_ID` | Access key do token R2 |
|
||||
| `R2_SECRET_ACCESS_KEY` | Secret do token R2 |
|
||||
| `R2_BUCKET` | Nome do bucket |
|
||||
| `R2_ENDPOINT` | `https://<account_id>.r2.cloudflarestorage.com` |
|
||||
| `R2_URL` | Domínio customizado público (ex.: `https://media.example.com`) |
|
||||
|
||||
Infra manual (fora do app): criar bucket R2, token API, mapear domínio customizado ao bucket, criar API key Resend e verificar domínio de envio.
|
||||
|
||||
## Credenciais de desenvolvimento
|
||||
|
||||
|
||||
@@ -86,6 +86,10 @@ final class PublicImageUploadRules
|
||||
|
||||
public static function disk(): string
|
||||
{
|
||||
return config('filesystems.default') === 's3' ? 's3' : 'public';
|
||||
return match (config('filesystems.default')) {
|
||||
'r2' => 'r2',
|
||||
's3' => 's3',
|
||||
default => 'public',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"filament/filament": "^5.0",
|
||||
"intervention/image": "^3.0",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/tinker": "^3.0"
|
||||
"laravel/tinker": "^3.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.35",
|
||||
"resend/resend-php": "^1.7"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
404
composer.lock
generated
404
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "b7cd97df13085158d9ecb6ad877a600f",
|
||||
"content-hash": "cf50a933bd18a3ac949ec7aa0b4a1d57",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -71,6 +71,157 @@
|
||||
},
|
||||
"time": "2026-06-20T14:30:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.389.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "09bbb4023a14316ff2c5e568301a382f7e3465f5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/09bbb4023a14316ff2c5e568301a382f7e3465f5",
|
||||
"reference": "09bbb4023a14316ff2c5e568301a382f7e3465f5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
|
||||
"guzzlehttp/promises": "^2.0.3 || ^3.0",
|
||||
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
|
||||
"mtdowling/jmespath.php": "^2.9.1",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0",
|
||||
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-pcntl": "To use client-side monitoring",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "https://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.389.3"
|
||||
},
|
||||
"time": "2026-07-29T18:09:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "blade-ui-kit/blade-heroicons",
|
||||
"version": "2.7.0",
|
||||
@@ -3012,6 +3163,61 @@
|
||||
},
|
||||
"time": "2026-07-06T14:42:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.35.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4",
|
||||
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.371.5",
|
||||
"league/flysystem": "^3.10.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\AwsS3V3\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"aws",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2"
|
||||
},
|
||||
"time": "2026-07-01T23:25:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.31.0",
|
||||
@@ -3562,6 +3768,72 @@
|
||||
],
|
||||
"time": "2026-01-02T08:56:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.9.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
|
||||
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.52"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
|
||||
},
|
||||
"time": "2026-07-06T18:56:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.13.1",
|
||||
@@ -5089,6 +5361,65 @@
|
||||
},
|
||||
"time": "2026-06-18T03:57:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "resend/resend-php",
|
||||
"version": "v1.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/resend/resend-php.git",
|
||||
"reference": "9516207c4f6ff210c64b193e31074e925dd6193a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/resend/resend-php/zipball/9516207c4f6ff210c64b193e31074e925dd6193a",
|
||||
"reference": "9516207c4f6ff210c64b193e31074e925dd6193a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
|
||||
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
|
||||
"php": "^8.1.0",
|
||||
"psr/http-client": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.13",
|
||||
"mockery/mockery": "^1.6",
|
||||
"pestphp/pest": "^1.0|^2.0|^3.0|^4.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Resend.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Resend\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Resend and contributors",
|
||||
"homepage": "https://github.com/resend/resend-php/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Resend PHP library.",
|
||||
"homepage": "https://resend.com/",
|
||||
"keywords": [
|
||||
"api",
|
||||
"client",
|
||||
"php",
|
||||
"resend",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/resend/resend-php/issues",
|
||||
"source": "https://github.com/resend/resend-php/tree/v1.7.0"
|
||||
},
|
||||
"time": "2026-07-27T21:14:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "ryangjchandler/blade-capture-directive",
|
||||
"version": "v1.1.1",
|
||||
@@ -5994,6 +6325,77 @@
|
||||
],
|
||||
"time": "2026-06-05T06:23:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "17856b7a222664a26a5ea1cb06ee0721c2438217"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217",
|
||||
"reference": "17856b7a222664a26a5ea1cb06ee0721c2438217",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4.1",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/process": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Filesystem\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-22T15:42:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v8.1.1",
|
||||
|
||||
@@ -62,6 +62,20 @@ return [
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'r2' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('R2_ACCESS_KEY_ID'),
|
||||
'secret' => env('R2_SECRET_ACCESS_KEY'),
|
||||
'region' => 'auto',
|
||||
'bucket' => env('R2_BUCKET'),
|
||||
'url' => env('R2_URL'),
|
||||
'endpoint' => env('R2_ENDPOINT'),
|
||||
'use_path_style_endpoint' => true,
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
2
openspec/changes/add-production-providers/.openspec.yaml
Normal file
2
openspec/changes/add-production-providers/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-29
|
||||
80
openspec/changes/add-production-providers/design.md
Normal file
80
openspec/changes/add-production-providers/design.md
Normal file
@@ -0,0 +1,80 @@
|
||||
## Context
|
||||
|
||||
Fase 0–1 entregaram CMS com uploads de mídia via `PublicImageUploadRules`, que hoje seleciona disco `public` localmente e `s3` quando `FILESYSTEM_DISK=s3`. Laravel 13 já inclui mailer `resend` em [`config/mail.php`](../../config/mail.php) e chave em [`config/services.php`](../../config/services.php), mas faltam dependências (`resend/resend-php`, `league/flysystem-aws-s3-v3`) e configuração explícita para Cloudflare R2.
|
||||
|
||||
Produção usará **Resend** para e-mail transacional e **Cloudflare R2** para mídia pública via domínio customizado. Local e CI continuam com `MAIL_MAILER=log`/`array` e disco `public`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Resend como transporte nativo Laravel em produção (`MAIL_MAILER=resend`, `RESEND_API_KEY`).
|
||||
- Disco dedicado `r2` com variáveis explícitas (`R2_*`) e URL pública via `R2_URL` (domínio customizado).
|
||||
- Seleção de disco de mídia alinhada a `FILESYSTEM_DISK=r2`.
|
||||
- Documentação de env vars e testes automatizados de config/seleção (sem chamadas live).
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Templates de e-mail, filas de notificação de leads, ou fluxos CRM.
|
||||
- Buckets privados, signed URLs, ou automação de DNS/CDN.
|
||||
- Mudança de queue driver ou provisionamento Cloudflare via código.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Resend via transporte nativo Laravel
|
||||
|
||||
**Decisão:** usar mailer `resend` já presente em `config/mail.php` + pacote `resend/resend-php`. Credencial em `config/services.php` → `RESEND_API_KEY`.
|
||||
|
||||
**Alternativa rejeitada:** SMTP genérico — menos idiomático; perde integração nativa Laravel 13.
|
||||
|
||||
### 2. Disco dedicado `r2` (não reutilizar nome `s3`)
|
||||
|
||||
**Decisão:** adicionar disco `r2` em `config/filesystems.php` com driver `s3`, endpoint R2 (`https://<account_id>.r2.cloudflarestorage.com`), `use_path_style_endpoint=true`, bucket e credenciais via `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_ENDPOINT`, `R2_URL` (domínio público customizado).
|
||||
|
||||
**Alternativa rejeitada:** reutilizar disco `s3` com vars `AWS_*` — funciona tecnicamente, mas obscurece provedor real e conflita com futuro uso de AWS S3.
|
||||
|
||||
### 3. `FILESYSTEM_DISK=r2` em produção
|
||||
|
||||
**Decisão:** default disk de produção = `r2`. Local/test = `local` ou `public`. `PublicImageUploadRules::disk()` retorna `r2` quando default é `r2`, `s3` quando default é `s3`, senão `public`.
|
||||
|
||||
**Alternativa rejeitada:** mapear `r2` para disco `s3` internamente — confunde operadores e quebra intenção explícita do env.
|
||||
|
||||
### 4. URLs públicas via domínio customizado
|
||||
|
||||
**Decisão:** `R2_URL` aponta para domínio customizado (ex.: `https://media.example.com`) mapeado ao bucket via Cloudflare. Disco `r2` define `url => env('R2_URL')` e `visibility => public`.
|
||||
|
||||
**Alternativa rejeitada:** URL `r2.dev` gerenciada — menos controle de marca e SEO; usuário escolheu custom domain.
|
||||
|
||||
### 5. Testes sem integração live
|
||||
|
||||
**Decisão:** testes usam `Mail::fake`, `Storage::fake('r2')`, e asserts de config (`config('mail.default')`, `config('filesystems.disks.r2')`, `PublicImageUploadRules::disk()`). Nenhuma chamada HTTP a Resend/R2 no CI.
|
||||
|
||||
### 6. Defaults seguros por ambiente
|
||||
|
||||
**Decisão:**
|
||||
|
||||
| Ambiente | `MAIL_MAILER` | `FILESYSTEM_DISK` | Disco de upload CMS |
|
||||
|---|---|---|---|
|
||||
| Local dev | `log` | `local` | `public` |
|
||||
| Testes (phpunit) | `array` | (unset → local) | `public` |
|
||||
| Produção | `resend` | `r2` | `r2` |
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[R2 credentials missing in prod]** → app boot ok, upload fails at runtime; document required vars in `.env.example` and README; config test asserts disk definition exists.
|
||||
- **[Custom domain not configured]** → broken public image URLs; `R2_URL` documented as required for production media.
|
||||
- **[Resend API key missing]** → mail send fails; lead creation must not depend on mail (future phases per SPEC).
|
||||
- **[Package version drift]** → pin compatible versions in `composer.json`; run `composer security-audit` in CI.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Merge change; run `composer install` in production image build.
|
||||
2. Create R2 bucket, API token, and custom domain in Cloudflare dashboard (manual).
|
||||
3. Create Resend API key and verify sending domain.
|
||||
4. Set production env: `MAIL_MAILER=resend`, `RESEND_API_KEY`, `FILESYSTEM_DISK=r2`, `R2_*`, `R2_URL`, `MAIL_FROM_*`.
|
||||
5. Deploy; upload test image via Filament; verify public URL resolves.
|
||||
6. **Rollback:** revert env to `MAIL_MAILER=log`, `FILESYSTEM_DISK=public`; existing DB paths remain valid for local disk until re-upload.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- _(none — custom domain confirmed; Resend as sole production mail provider for this change)_
|
||||
43
openspec/changes/add-production-providers/proposal.md
Normal file
43
openspec/changes/add-production-providers/proposal.md
Normal file
@@ -0,0 +1,43 @@
|
||||
## Why
|
||||
|
||||
The application already supports CMS media uploads and will soon send transactional email (lead notifications, password reset), but production still lacks concrete provider wiring. SPEC §9.1 and §15.4 require S3-compatible object storage in production and transactional email when configured. Resend and Cloudflare R2 are the chosen providers; this change adds the configuration, dependencies, and selection rules so production can send mail and persist public media outside the container filesystem.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add **Resend** as the production mail transport via Laravel's native `resend` mailer and `RESEND_API_KEY`.
|
||||
- Add a dedicated **`r2` filesystem disk** for Cloudflare R2 (S3-compatible API) with explicit R2 env vars and public URLs served through a **custom domain** (`R2_URL`).
|
||||
- Install required dependencies: `resend/resend-php` and `league/flysystem-aws-s3-v3`.
|
||||
- Update `.env.example`, README, and deployment guidance with production vs local/test defaults.
|
||||
- Fix media disk selection in `PublicImageUploadRules` so `FILESYSTEM_DISK=r2` uses the R2 disk instead of falling back to local `public`.
|
||||
- Add automated config and media-disk selection tests; no live calls to Resend or R2 in CI.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Conforme [SPEC.md §4.2](../../SPEC.md), **não** fazem parte desta change:
|
||||
|
||||
- Lead notification emails, briefing confirmation flows, or CRM email templates (future phases).
|
||||
- Private/signed URL access to media; production media is public via custom domain.
|
||||
- Cloudflare Workers, CDN provisioning, or DNS automation (manual infra setup).
|
||||
- Redis, queue driver changes, or FrankenPHP worker mode.
|
||||
- Replacing local `public` disk behavior for development and tests.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `transactional-email`: Resend-backed transactional mail transport with safe local/test defaults and production configuration via environment variables (SPEC §9.1, §15.4, §12.1–12.2).
|
||||
- `object-storage`: Cloudflare R2 object storage via dedicated `r2` disk, custom-domain public URLs, and environment-driven disk selection (SPEC §6.4, §9.1, §15.4).
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `content-media`: extend disk selection so production `FILESYSTEM_DISK=r2` stores and serves public content images from R2 instead of local `public`.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Dependencies**: `resend/resend-php`, `league/flysystem-aws-s3-v3` in `composer.json`.
|
||||
- **Config**: `config/mail.php`, `config/services.php`, `config/filesystems.php`, `.env.example`.
|
||||
- **Application**: `app/Support/PublicImageUploadRules.php` disk selection logic.
|
||||
- **Tests**: new Pest feature/unit tests for mail and filesystem config; media disk selection regression test.
|
||||
- **Docs**: `README.md` production provider section.
|
||||
- **Infra (manual)**: Resend API key, R2 bucket, R2 API token, and custom domain mapped to the bucket.
|
||||
- **No breaking change** for local development: defaults remain `MAIL_MAILER=log` and `FILESYSTEM_DISK=local`/`public`.
|
||||
@@ -0,0 +1,20 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: CMS public image uploads select production object storage disk
|
||||
|
||||
Public content image uploads (Filament FileUpload via `PublicImageUploadRules`) MUST store files on the disk matching the configured default filesystem (SPEC §6.4). When `FILESYSTEM_DISK` is `r2`, uploads MUST use the `r2` disk. When `FILESYSTEM_DISK` is `s3`, uploads MUST use the `s3` disk. Otherwise uploads MUST use the local `public` disk.
|
||||
|
||||
#### Scenario: Production R2 disk is used for CMS uploads
|
||||
|
||||
- **WHEN** `FILESYSTEM_DISK=r2`
|
||||
- **THEN** `PublicImageUploadRules::disk()` MUST return `r2`
|
||||
|
||||
#### Scenario: Legacy S3 default still supported
|
||||
|
||||
- **WHEN** `FILESYSTEM_DISK=s3`
|
||||
- **THEN** `PublicImageUploadRules::disk()` MUST return `s3`
|
||||
|
||||
#### Scenario: Local development uses public disk
|
||||
|
||||
- **WHEN** `FILESYSTEM_DISK` is `local`, unset, or any value other than `r2`/`s3`
|
||||
- **THEN** `PublicImageUploadRules::disk()` MUST return `public`
|
||||
@@ -0,0 +1,44 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Cloudflare R2 is available as dedicated filesystem disk
|
||||
|
||||
The system SHALL provide an `r2` filesystem disk using Laravel's S3-compatible driver pointed at Cloudflare R2 (SPEC §6.4, §9.1, §15.4). Configuration MUST use explicit `R2_*` environment variables: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_ENDPOINT`, and `R2_URL`.
|
||||
|
||||
#### Scenario: R2 disk is defined in filesystem config
|
||||
|
||||
- **WHEN** the application boots
|
||||
- **THEN** `config('filesystems.disks.r2')` MUST exist
|
||||
- **AND** the disk driver MUST be `s3`
|
||||
- **AND** credentials MUST resolve from `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`
|
||||
|
||||
#### Scenario: R2 endpoint uses path-style addressing
|
||||
|
||||
- **WHEN** the `r2` disk is configured
|
||||
- **THEN** `use_path_style_endpoint` MUST be `true`
|
||||
- **AND** `endpoint` MUST resolve from `R2_ENDPOINT`
|
||||
|
||||
#### Scenario: Public URLs use custom domain
|
||||
|
||||
- **WHEN** a file is stored on the `r2` disk with public visibility
|
||||
- **THEN** generated URLs MUST use `R2_URL` as base
|
||||
- **AND** MUST NOT require signed URLs for browser-facing media
|
||||
|
||||
### Requirement: Production default filesystem disk is R2
|
||||
|
||||
Production deployments MUST set `FILESYSTEM_DISK=r2` so application code using the default disk stores files on R2.
|
||||
|
||||
#### Scenario: Default disk resolves to R2 in production
|
||||
|
||||
- **WHEN** `FILESYSTEM_DISK=r2` is set
|
||||
- **THEN** `config('filesystems.default')` MUST be `r2`
|
||||
|
||||
#### Scenario: Local development keeps local disk
|
||||
|
||||
- **WHEN** `FILESYSTEM_DISK` is unset or set to `local`/`public` in development
|
||||
- **THEN** the default disk MUST NOT require R2 credentials to boot
|
||||
|
||||
#### Scenario: Tests do not call R2
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** tests MUST use `Storage::fake('r2')` or local fakes
|
||||
- **AND** no live HTTP request to Cloudflare R2 MUST occur during CI
|
||||
@@ -0,0 +1,32 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Production transactional email uses Resend
|
||||
|
||||
The system SHALL support Resend as the production mail transport via Laravel's native `resend` mailer (SPEC §9.1, §15.4). The Resend API key MUST be read from `RESEND_API_KEY` through `config/services.php`. Production deployments MUST set `MAIL_MAILER=resend` when transactional email is enabled.
|
||||
|
||||
#### Scenario: Resend mailer is configured
|
||||
|
||||
- **WHEN** the application boots with valid configuration
|
||||
- **THEN** a `resend` mailer MUST exist in `config/mail.php`
|
||||
- **AND** `config('services.resend.key')` MUST resolve from `RESEND_API_KEY`
|
||||
|
||||
#### Scenario: Local development uses safe mail default
|
||||
|
||||
- **WHEN** `APP_ENV` is `local` and `MAIL_MAILER` is unset
|
||||
- **THEN** the default mailer MUST be `log`
|
||||
- **AND** no outbound email MUST be sent to external providers
|
||||
|
||||
#### Scenario: Tests do not call Resend
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** `MAIL_MAILER` MUST be `array` (or tests MUST use `Mail::fake`)
|
||||
- **AND** no live HTTP request to Resend MUST occur during CI
|
||||
|
||||
### Requirement: Global from address is configurable
|
||||
|
||||
The system SHALL read the global sender address and name from `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME` (SPEC §15.4).
|
||||
|
||||
#### Scenario: From address applied to outbound mail
|
||||
|
||||
- **WHEN** the application sends mail through the configured mailer
|
||||
- **THEN** the message MUST use the configured from address and name
|
||||
33
openspec/changes/add-production-providers/tasks.md
Normal file
33
openspec/changes/add-production-providers/tasks.md
Normal file
@@ -0,0 +1,33 @@
|
||||
## 1. Dependencies
|
||||
|
||||
- [x] 1.1 Add `resend/resend-php` to `composer.json` require
|
||||
- [x] 1.2 Add `league/flysystem-aws-s3-v3` to `composer.json` require
|
||||
- [x] 1.3 Run `composer update resend/resend-php league/flysystem-aws-s3-v3 --with-all-dependencies` and commit lockfile
|
||||
|
||||
## 2. Transactional email (transactional-email)
|
||||
|
||||
- [x] 2.1 Confirm `config/mail.php` resend mailer and `config/services.php` `RESEND_API_KEY` binding
|
||||
- [x] 2.2 Add `RESEND_API_KEY` and production mail vars to `.env.example` with safe local defaults (`MAIL_MAILER=log`)
|
||||
- [x] 2.3 Write feature test asserting resend mailer config and services key resolution
|
||||
|
||||
## 3. Object storage (object-storage)
|
||||
|
||||
- [x] 3.1 Add dedicated `r2` disk to `config/filesystems.php` with `R2_*` env vars, path-style endpoint, and `R2_URL`
|
||||
- [x] 3.2 Add R2 env vars to `.env.example` with comments for custom domain setup
|
||||
- [x] 3.3 Write feature test asserting `r2` disk config (driver, endpoint, path-style, url from `R2_URL`)
|
||||
|
||||
## 4. CMS media disk selection (content-media)
|
||||
|
||||
- [x] 4.1 Update `PublicImageUploadRules::disk()` to return `r2` when `FILESYSTEM_DISK=r2`, keep `s3` and `public` fallbacks
|
||||
- [x] 4.2 Write unit/feature test for disk selection matrix: `r2`, `s3`, local/unset → `public`
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [x] 5.1 Update `README.md` with production provider section (Resend + R2 + custom domain)
|
||||
- [x] 5.2 Document production env checklist: `MAIL_MAILER=resend`, `FILESYSTEM_DISK=r2`, required secrets
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- [x] 6.1 Run `composer pint:check` and `composer phpstan`
|
||||
- [x] 6.2 Run `composer test:unit` and `composer test:feature` for new provider tests
|
||||
- [x] 6.3 Run `composer quality` and fix any failures
|
||||
59
tests/Feature/ProductionProviders/R2DiskConfigTest.php
Normal file
59
tests/Feature/ProductionProviders/R2DiskConfigTest.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
afterEach(function (): void {
|
||||
foreach ([
|
||||
'R2_ACCESS_KEY_ID',
|
||||
'R2_SECRET_ACCESS_KEY',
|
||||
'R2_BUCKET',
|
||||
'R2_ENDPOINT',
|
||||
'R2_URL',
|
||||
] as $key) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
}
|
||||
});
|
||||
|
||||
it('defines an r2 disk with the s3 driver and path-style endpoint', function (): void {
|
||||
$disk = config('filesystems.disks.r2');
|
||||
|
||||
expect($disk)->toBeArray()
|
||||
->and($disk['driver'])->toBe('s3')
|
||||
->and($disk['use_path_style_endpoint'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('resolves r2 credentials endpoint bucket and url from R2 env vars', function (): void {
|
||||
putenv('R2_ACCESS_KEY_ID=r2-key');
|
||||
putenv('R2_SECRET_ACCESS_KEY=r2-secret');
|
||||
putenv('R2_BUCKET=amare-media');
|
||||
putenv('R2_ENDPOINT=https://accountid.r2.cloudflarestorage.com');
|
||||
putenv('R2_URL=https://media.example.com');
|
||||
$_ENV['R2_ACCESS_KEY_ID'] = 'r2-key';
|
||||
$_ENV['R2_SECRET_ACCESS_KEY'] = 'r2-secret';
|
||||
$_ENV['R2_BUCKET'] = 'amare-media';
|
||||
$_ENV['R2_ENDPOINT'] = 'https://accountid.r2.cloudflarestorage.com';
|
||||
$_ENV['R2_URL'] = 'https://media.example.com';
|
||||
$_SERVER['R2_ACCESS_KEY_ID'] = 'r2-key';
|
||||
$_SERVER['R2_SECRET_ACCESS_KEY'] = 'r2-secret';
|
||||
$_SERVER['R2_BUCKET'] = 'amare-media';
|
||||
$_SERVER['R2_ENDPOINT'] = 'https://accountid.r2.cloudflarestorage.com';
|
||||
$_SERVER['R2_URL'] = 'https://media.example.com';
|
||||
|
||||
$filesystems = require config_path('filesystems.php');
|
||||
$disk = $filesystems['disks']['r2'];
|
||||
|
||||
expect($disk['key'])->toBe('r2-key')
|
||||
->and($disk['secret'])->toBe('r2-secret')
|
||||
->and($disk['bucket'])->toBe('amare-media')
|
||||
->and($disk['endpoint'])->toBe('https://accountid.r2.cloudflarestorage.com')
|
||||
->and($disk['url'])->toBe('https://media.example.com')
|
||||
->and($disk['use_path_style_endpoint'])->toBeTrue()
|
||||
->and($disk['visibility'])->toBe('public');
|
||||
});
|
||||
|
||||
it('uses r2 as the default disk when FILESYSTEM_DISK is r2', function (): void {
|
||||
config(['filesystems.default' => 'r2']);
|
||||
|
||||
expect(config('filesystems.default'))->toBe('r2');
|
||||
});
|
||||
28
tests/Feature/ProductionProviders/ResendMailConfigTest.php
Normal file
28
tests/Feature/ProductionProviders/ResendMailConfigTest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
afterEach(function (): void {
|
||||
putenv('RESEND_API_KEY');
|
||||
unset($_ENV['RESEND_API_KEY'], $_SERVER['RESEND_API_KEY']);
|
||||
});
|
||||
|
||||
it('defines the resend mailer transport', function (): void {
|
||||
expect(config('mail.mailers.resend.transport'))->toBe('resend');
|
||||
});
|
||||
|
||||
it('resolves the resend api key from RESEND_API_KEY', function (): void {
|
||||
putenv('RESEND_API_KEY=test-resend-key');
|
||||
$_ENV['RESEND_API_KEY'] = 'test-resend-key';
|
||||
$_SERVER['RESEND_API_KEY'] = 'test-resend-key';
|
||||
|
||||
$services = require config_path('services.php');
|
||||
|
||||
expect($services['resend']['key'])->toBe('test-resend-key');
|
||||
});
|
||||
|
||||
it('defaults the mailer to log in config when MAIL_MAILER is unset', function (): void {
|
||||
$contents = file_get_contents(config_path('mail.php'));
|
||||
|
||||
expect($contents)->toContain("env('MAIL_MAILER', 'log')");
|
||||
});
|
||||
37
tests/Unit/PublicImageUploadRulesDiskTest.php
Normal file
37
tests/Unit/PublicImageUploadRulesDiskTest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Support\PublicImageUploadRules;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PublicImageUploadRulesDiskTest extends TestCase
|
||||
{
|
||||
public function test_uses_r2_disk_when_default_filesystem_is_r2(): void
|
||||
{
|
||||
config(['filesystems.default' => 'r2']);
|
||||
|
||||
$this->assertSame('r2', PublicImageUploadRules::disk());
|
||||
}
|
||||
|
||||
public function test_uses_s3_disk_when_default_filesystem_is_s3(): void
|
||||
{
|
||||
config(['filesystems.default' => 's3']);
|
||||
|
||||
$this->assertSame('s3', PublicImageUploadRules::disk());
|
||||
}
|
||||
|
||||
public function test_uses_public_disk_for_local_or_unset_default(): void
|
||||
{
|
||||
config(['filesystems.default' => 'local']);
|
||||
$this->assertSame('public', PublicImageUploadRules::disk());
|
||||
|
||||
config(['filesystems.default' => 'public']);
|
||||
$this->assertSame('public', PublicImageUploadRules::disk());
|
||||
|
||||
config(['filesystems.default' => null]);
|
||||
$this->assertSame('public', PublicImageUploadRules::disk());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user