From df0b051ee6b1bbfc85addfa9ab79171a4da39994 Mon Sep 17 00:00:00 2001 From: sindoring Date: Mon, 27 Jul 2026 15:42:54 +0400 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20+=20=D0=BC=D0=B5=D0=B9=D0=BB=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +- .env.mail.example | 10 + .gitignore | 3 +- README.md | 7 + app/Http/Controllers/Auth/LoginController.php | 1 + app/Http/Controllers/CabinetController.php | 1 + .../Concerns/BuildsPortalProps.php | 1 + app/Http/Controllers/LandingController.php | 2 + app/Http/Controllers/LegalController.php | 8 + compose.mail.yaml | 28 + config/moonwell.php | 3 +- docs/mail-server.md | 136 ++ resources/css/moonwell.css | 21 + resources/js/Components/LegalDocument.vue | 8 +- resources/js/Components/SiteFooter.vue | 3 +- resources/js/Components/SiteHeader.vue | 6 +- .../js/Pages/Landing/Components/Footer.vue | 10 +- .../js/Pages/Landing/Components/Join.vue | 10 +- resources/js/Pages/Legal/Privacy.vue | 107 +- resources/js/Pages/Legal/Rules.vue | 33 + resources/js/Pages/Legal/offerContent.js | 6 +- resources/js/Pages/Legal/privacyContent.js | 1108 +++++++++++++++++ resources/js/Pages/Legal/rulesContent.js | 391 ++++++ routes/web.php | 1 + scripts/mail-server.sh | 150 +++ .../Feature/InertiaPublicPagesFeatureTest.php | 24 +- tests/Feature/LandingPageTest.php | 6 +- 27 files changed, 1969 insertions(+), 118 deletions(-) create mode 100644 .env.mail.example create mode 100644 compose.mail.yaml create mode 100644 docs/mail-server.md create mode 100644 resources/js/Pages/Legal/Rules.vue create mode 100644 resources/js/Pages/Legal/privacyContent.js create mode 100644 resources/js/Pages/Legal/rulesContent.js create mode 100755 scripts/mail-server.sh diff --git a/.env.example b/.env.example index e0b4407..a024a70 100644 --- a/.env.example +++ b/.env.example @@ -60,7 +60,8 @@ MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" MOONWELL_OFFER_PUBLISHED=true -MOONWELL_PRIVACY_PUBLISHED=false +MOONWELL_PRIVACY_PUBLISHED=true +MOONWELL_RULES_PUBLISHED=true MOONWELL_LEGAL_OPERATOR="Администрация проекта MoonWell" MOONWELL_LEGAL_EMAIL="support@moon-well.online" MOONWELL_LEGAL_EFFECTIVE_DATE="26 июля 2026 года" diff --git a/.env.mail.example b/.env.mail.example new file mode 100644 index 0000000..9a0936a --- /dev/null +++ b/.env.mail.example @@ -0,0 +1,10 @@ +# Public mail-server identity. +MAIL_HOSTNAME=mail.moon-well.online +MAIL_DOMAIN=moon-well.online +MAIL_PUBLIC_URL=https://mail.moon-well.online + +# Public listeners. Leave 0.0.0.0 on a production mail host. +MAIL_BIND_IP=0.0.0.0 + +# The setup UI is bound to 127.0.0.1 only. +MAIL_SETUP_PORT=8081 diff --git a/.gitignore b/.gitignore index ab2213f..7628482 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.log .DS_Store .env +.env.mail .env.backup .env.production .phpactor.json @@ -22,4 +23,4 @@ Homestead.json Homestead.yaml Thumbs.db -/public/appcast.xml \ No newline at end of file +/public/appcast.xml diff --git a/README.md b/README.md index e67f22e..acb326c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ cp .env.example .env Сайт будет доступен на `http://localhost:8080`. +## Почта техподдержки + +Для собственного ящика `support@moon-well.online` подготовлен лёгкий +Stalwart-сервер с SMTP/IMAP, TLS, DKIM и антиспамом. Инструкция по первому +запуску, DNS и подключению Laravel находится в +[`docs/mail-server.md`](docs/mail-server.md). + ## Деплой на Ubuntu Скрипт деплоя лежит в `scripts/deploy-ubuntu.sh`. Он рассчитан на сервер, где уже установлены: diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 99c7be5..24b69e7 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -31,6 +31,7 @@ class LoginController extends Controller 'login_store' => route('login.store'), 'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null, 'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null, + 'rules' => config('moonwell.legal.rules_published') ? route('legal.rules') : null, ], 'flash' => [ 'status' => $request->session()->get('status'), diff --git a/app/Http/Controllers/CabinetController.php b/app/Http/Controllers/CabinetController.php index dd7e593..8e8e52b 100644 --- a/app/Http/Controllers/CabinetController.php +++ b/app/Http/Controllers/CabinetController.php @@ -126,6 +126,7 @@ class CabinetController extends Controller 'deposit' => route('cabinet.balance.deposit'), 'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null, 'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null, + 'rules' => config('moonwell.legal.rules_published') ? route('legal.rules') : null, ]; } } diff --git a/app/Http/Controllers/Concerns/BuildsPortalProps.php b/app/Http/Controllers/Concerns/BuildsPortalProps.php index fead310..7338a43 100644 --- a/app/Http/Controllers/Concerns/BuildsPortalProps.php +++ b/app/Http/Controllers/Concerns/BuildsPortalProps.php @@ -33,6 +33,7 @@ trait BuildsPortalProps 'logout' => route('logout'), 'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null, 'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null, + 'rules' => config('moonwell.legal.rules_published') ? route('legal.rules') : null, 'admin_accounts' => route('admin.accounts.index'), 'admin_balances' => route('admin.balances.index'), 'admin_shop' => route('admin.shop.index'), diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index f2af0c8..46b5ebb 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -84,8 +84,10 @@ class LandingController extends Controller 'legalUrls' => [ 'offer_published' => (bool) config('moonwell.legal.offer_published'), 'privacy_published' => (bool) config('moonwell.legal.privacy_published'), + 'rules_published' => (bool) config('moonwell.legal.rules_published'), 'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null, 'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null, + 'rules' => config('moonwell.legal.rules_published') ? route('legal.rules') : null, ], 'inviteRequired' => (bool) config('moonwell.registration.require_invite_code'), 'csrfToken' => csrf_token(), diff --git a/app/Http/Controllers/LegalController.php b/app/Http/Controllers/LegalController.php index 4efd61f..8175fd8 100644 --- a/app/Http/Controllers/LegalController.php +++ b/app/Http/Controllers/LegalController.php @@ -22,6 +22,13 @@ class LegalController extends Controller return Inertia::render('Legal/Privacy', $this->pageData($request)); } + public function rules(Request $request): Response + { + abort_unless(config('moonwell.legal.rules_published'), 404); + + return Inertia::render('Legal/Rules', $this->pageData($request)); + } + /** * @return array */ @@ -48,6 +55,7 @@ class LegalController extends Controller 'logout' => route('logout'), 'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null, 'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null, + 'rules' => config('moonwell.legal.rules_published') ? route('legal.rules') : null, ], 'legal' => [ 'operator_name' => config('moonwell.legal.operator_name'), diff --git a/compose.mail.yaml b/compose.mail.yaml new file mode 100644 index 0000000..c10c98e --- /dev/null +++ b/compose.mail.yaml @@ -0,0 +1,28 @@ +services: + stalwart: + image: 'stalwartlabs/stalwart:v0.16' + restart: unless-stopped + profiles: + - mail + environment: + STALWART_PUBLIC_URL: '${MAIL_PUBLIC_URL:-https://mail.moon-well.online}' + ports: + - '${MAIL_BIND_IP:-0.0.0.0}:25:25' + - '${MAIL_BIND_IP:-0.0.0.0}:465:465' + - '${MAIL_BIND_IP:-0.0.0.0}:587:587' + - '${MAIL_BIND_IP:-0.0.0.0}:993:993' + # Initial setup UI. Keep it on loopback and use an SSH tunnel. + - '127.0.0.1:${MAIL_SETUP_PORT:-8081}:8080' + volumes: + - 'stalwart-config:/etc/stalwart' + - 'stalwart-data:/var/lib/stalwart' + networks: + default: + aliases: + - '${MAIL_HOSTNAME:-mail.moon-well.online}' + +volumes: + stalwart-config: + driver: local + stalwart-data: + driver: local diff --git a/config/moonwell.php b/config/moonwell.php index 09681f2..c980d7e 100644 --- a/config/moonwell.php +++ b/config/moonwell.php @@ -21,7 +21,8 @@ return [ 'legal' => [ 'offer_published' => (bool) env('MOONWELL_OFFER_PUBLISHED', true), - 'privacy_published' => (bool) env('MOONWELL_PRIVACY_PUBLISHED', false), + 'privacy_published' => (bool) env('MOONWELL_PRIVACY_PUBLISHED', true), + 'rules_published' => (bool) env('MOONWELL_RULES_PUBLISHED', true), 'operator_name' => env('MOONWELL_LEGAL_OPERATOR', 'Администрация проекта MoonWell'), 'contact_email' => env('MOONWELL_LEGAL_EMAIL', 'support@moon-well.online'), 'effective_date' => env('MOONWELL_LEGAL_EFFECTIVE_DATE', '26 июля 2026 года'), diff --git a/docs/mail-server.md b/docs/mail-server.md new file mode 100644 index 0000000..be90687 --- /dev/null +++ b/docs/mail-server.md @@ -0,0 +1,136 @@ +# Почта техподдержки + +Для `support@moon-well.online` используется отдельный контейнер +[Stalwart Mail Server](https://stalw.art/). Он предоставляет SMTP, IMAP, +антиспам, DKIM и административный интерфейс. Данные и конфигурация хранятся +в именованных Docker volumes и переживают пересоздание контейнера. + +## Что требуется от сервера + +- постоянный публичный IPv4-адрес; +- возможность принимать TCP-соединения на порты `25`, `465`, `587`, `993`; +- разрешённый исходящий TCP-порт `25`; +- PTR/rDNS для IP со значением `mail.moon-well.online`; +- доступ к управлению DNS-зоной `moon-well.online`. + +Без корректного PTR исходящие сообщения часто попадают в спам. Некоторые +хостинги блокируют исходящий порт 25 — это нужно проверить до настройки. + +## Первый запуск + +```bash +./scripts/mail-server.sh init +``` + +Проверь значения в `.env.mail`. Затем создай DNS-запись: + +```text +mail.moon-well.online. A <публичный IPv4 сервера> +``` + +Запусти сервер: + +```bash +./scripts/mail-server.sh up +./scripts/mail-server.sh credentials +``` + +Административный HTTP-порт намеренно доступен только на loopback-интерфейсе. +Открой туннель со своего компьютера: + +```bash +ssh -L 8081:127.0.0.1:8081 <пользователь>@<сервер> +``` + +После этого открой `http://127.0.0.1:8081/admin` и войди с временными +реквизитами из команды `credentials`. + +## Мастер Stalwart + +Используй следующие значения: + +- Server hostname: `mail.moon-well.online` +- Default email domain: `moon-well.online` +- Automatically obtain TLS certificate: включено +- Generate email signing keys: включено +- Storage: локальный RocksDB +- Directory: Internal Directory +- Logging: Console + +Если DNS размещён у поддерживаемого Stalwart провайдера, подключи его в +последнем шаге. Тогда сертификат выпускается через DNS-01 без занятия +публичного порта 443. Иначе понадобится отдельно передать серверу TLS-сертификат +или временно предоставить ему порт 443 для ACME-проверки. + +Сохрани постоянные реквизиты администратора, показанные мастером, и +перезапусти контейнер: + +```bash +./scripts/mail-server.sh restart +``` + +Создай обычный пользовательский аккаунт `support@moon-well.online`. +Административный аккаунт нельзя использовать в почтовом клиенте. + +## DNS после мастера + +В панели Stalwart открой: + +`Management → Domains → Domains → moon-well.online → View DNS Zone file` + +Перенеси предложенные записи в DNS-панель. В обязательный минимум входят: + +- MX; +- SPF; +- DKIM; +- DMARC; +- записи автоконфигурации, которые предложит Stalwart. + +Значение DKIM нужно брать только из созданной установки: ключ генерируется +индивидуально. Не копируй DKIM из примеров. + +## Настройка почтового клиента + +```text +Адрес: support@moon-well.online +IMAP-сервер: mail.moon-well.online +IMAP-порт: 993 +Шифрование IMAP: SSL/TLS +SMTP-сервер: mail.moon-well.online +SMTP-порт: 465 +Шифрование SMTP: SSL/TLS +SMTP-аутентификация: включена +Логин: support@moon-well.online +``` + +## Подключение Laravel + +После создания ящика укажи в production `.env`: + +```dotenv +MAIL_MAILER=smtp +MAIL_SCHEME=smtps +MAIL_HOST=mail.moon-well.online +MAIL_PORT=465 +MAIL_USERNAME=support@moon-well.online +MAIL_PASSWORD=<пароль ящика> +MAIL_FROM_ADDRESS=support@moon-well.online +MAIL_FROM_NAME="${APP_NAME}" +``` + +Затем сбрось и пересобери кэш конфигурации: + +```bash +docker compose exec -T laravel.test php artisan optimize:clear +docker compose exec -T laravel.test php artisan config:cache +``` + +## Резервное копирование + +В бэкап должны попадать оба именованных volume: + +- `stalwart-config`; +- `stalwart-data`. + +Перед восстановлением используй ту же minor-версию образа Stalwart, что указана +в `compose.mail.yaml`. diff --git a/resources/css/moonwell.css b/resources/css/moonwell.css index bd48638..524c44a 100644 --- a/resources/css/moonwell.css +++ b/resources/css/moonwell.css @@ -1861,6 +1861,19 @@ body[data-particles="false"] #particles { display: none; } border-bottom: 2px solid var(--bg-0); transform: rotate(45deg); } +.join-check-content { + display: grid; + gap: 7px; +} +.join-legal-links { + display: flex; + flex-wrap: wrap; + gap: 7px 18px; +} +.join-legal-links a { + display: inline-flex; + align-items: center; +} .join-check a { color: var(--moon); text-decoration: underline; text-decoration-color: rgba(127,184,212,0.3); } .join-check a:hover { text-decoration-color: var(--moon); } @@ -2859,6 +2872,14 @@ body[data-particles="false"] #particles { display: none; } color: var(--moon); } .legal-document h2 { margin: 0 0 20px; font-size: clamp(27px, 4vw, 38px); } +.legal-document .legal-subheading { + margin: 30px 0 14px; + font-family: var(--f-serif); + font-size: clamp(20px, 2.5vw, 25px); + font-weight: 500; + line-height: 1.35; + color: var(--ivory); +} .legal-document p, .legal-document li { font-family: var(--f-serif); diff --git a/resources/js/Components/LegalDocument.vue b/resources/js/Components/LegalDocument.vue index 2299550..c31426a 100644 --- a/resources/js/Components/LegalDocument.vue +++ b/resources/js/Components/LegalDocument.vue @@ -3,7 +3,8 @@ defineProps({ eyebrow: { type: String, required: true }, title: { type: String, required: true }, description: { type: String, required: true }, - effectiveDate: { type: String, required: true }, + effectiveDate: { type: String, default: '' }, + effectiveDateLabel: { type: String, default: 'Дата вступления в силу' }, intro: { type: Array, default: () => [] }, sections: { type: Array, required: true }, }); @@ -15,8 +16,8 @@ defineProps({ {{ eyebrow }}

{{ title }}

{{ description }}

-