mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Let an administrator change the public address after setup
Setup asked for it once and nothing could answer it again. An install restored from another machine's backup therefore kept the old machine's address: browsers were sent there for live updates and the stream indicator stayed red, with an environment variable in the compose file as the only remedy. Admin → Address edits the same value setup writes. The change is saved to the generated file and takes a restart to apply. Making it live was left out on purpose: the entrypoint exports that file into every container's environment at start, and telling a stale export from a deliberate pin needs more machinery than a restart costs. The page shows the address in use beside the saved one whenever they differ, and offers the web container's restart after every save. One thing fell out. A hub address on file that is only the previous public address with the hub's path on it is dropped on save, so it is derived from the new address again; a hub address of its own is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0507c1e716
commit
be3b27cf32
18 files changed
+546
-7
No files matched your search
Vendored
BIN
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: install/configuration.md sha1:c07a21499b2262504fc78db0a38abc2686e150c5 -->
|
||||
<!-- translated-from: install/configuration.md sha1:4891178bed3970f53bdec5785209ab33bbd47f59 -->
|
||||
# Konfigurationsreferenz
|
||||
|
||||
Jede Umgebungsvariable, die plMail liest, was sie bewirkt, welchen Vorgabewert sie hat und was
|
||||
@@ -44,7 +44,7 @@ Das sind die Variablen aus `.env`, in der Reihenfolge, in der sie dort stehen.
|
||||
| `APP_SHARE_DIR` | Nichts. Kein Code in diesem Repository liest sie. | `var/share` | Nein | Nichts. Sie bleibt bestehen, weil das Entfernen einer Variablen eine Änderung an `.env` wäre; behandle sie als wirkungslos. |
|
||||
| `APP_SECRETS_FILE` | Pfad zur Datei der erzeugten Geheimnisse, relativ zum Projektstammverzeichnis, sofern er nicht mit `/` beginnt. | `var/secrets/generated.env` | Ja | Zeigt ein Dienst dorthin, wo die anderen nicht hinsehen, erzeugt er seinen eigenen Verschlüsselungsschlüssel. `EncryptionKeyProbe` bemerkt das beim Start und verweigert den Start des Servers. |
|
||||
| `APP_STORAGE_DIR` | Wurzel für alles, was plMail als Datei schreibt, relativ zum Projektstammverzeichnis: `attachments/`, `raw/`, `uploads/` (Avatare liegen unter `uploads/avatars/`). | `var` | Ja | Anhangpfade werden relativ zum Projektstammverzeichnis in der Datenbank gespeichert; eine Änderung auf einer Installation, in der bereits Mail liegt, macht daher jede vorhandene Datei unauffindbar. |
|
||||
| `APP_PUBLIC_URL` | Die Adresse, unter der plMail von außen erreicht wird — die, die Google und Microsoft zurückrufen. | leer; wird auf dem Einrichtungsbildschirm abgefragt und in die erzeugte Datei geschrieben | Für Push: ja | Leer, `http://` oder ein Loopback-Host, und Kalender-Push sowie Graph-Mail-Push registrieren sich überhaupt nicht. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
|
||||
| `APP_PUBLIC_URL` | Die Adresse, unter der plMail von außen erreicht wird — die, die Google und Microsoft zurückrufen. | leer; wird auf dem Einrichtungsbildschirm abgefragt und in die erzeugte Datei geschrieben. Später unter Admin → Adresse änderbar, was einen Neustart braucht | Für Push: ja | Leer, `http://` oder ein Loopback-Host, und Kalender-Push sowie Graph-Mail-Push registrieren sich überhaupt nicht. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
|
||||
| `APP_DEFAULT_TIMEZONE` | Die Uhr, die jemandem angezeigt wird, der in den Einstellungen nie eine gewählt hat. | `Europe/Berlin` (zugleich der Parameter `app.fallback_timezone`, der greift, wenn die Variable fehlt) | Nein | Eine Installation mit falscher Zone zeigt allen diesen Personen Zeiten, die um ihren eigenen Versatz plausibel falsch sind. Zeitstempel werden in UTC gespeichert und nur zur Anzeige umgerechnet, eine Korrektur rendert also neu, statt umzuschreiben. Nicht zu verwechseln mit der PHP-Zeitzone des Containers, die in `frankenphp/conf.d/10-app.ini` fest auf `UTC` steht. |
|
||||
| `DEFAULT_URI` | Basis-URI, die der Router beim Erzeugen von URLs außerhalb einer HTTP-Anfrage verwendet. | `http://localhost` | Nein | Links in Mail, die aus einem Konsolenbefehl entsteht, zeigen auf localhost. Push-Rückrufe verwenden das nicht — sie verwenden `APP_PUBLIC_URL`. |
|
||||
| `DATABASE_URL` | Doctrine-Verbindung. | `postgresql://app@database:5432/app?serverVersion=18&charset=utf8` — bewusst ohne Zugangsdaten | Ja, aber zusammengesetzt | Eine DSN **ohne** Passwort wird als "niemand hat eine Datenbank konfiguriert" gewertet, und das erzeugte `POSTGRES_PASSWORD` wird eingesetzt. Eine DSN mit Passwort wird als Absicht gewertet und unangetastet gelassen — ein falsches Passwort hier wird also wörtlich verwendet, und Postgres lehnt die Verbindung ab. Sie ist nicht leer, weil Doctrine den Treiber aus dem Schema liest und eine leere DSN das Aufwärmen des Caches beim Image-Build mit "could not find driver" scheitern lässt. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: install/platforms.md sha1:727c18329d917d9982252fd27e182991b46a96e2 -->
|
||||
<!-- translated-from: install/platforms.md sha1:a5d607be3fca4c06dcb8499899db2014cb726ef3 -->
|
||||
# Plattform-Hinweise
|
||||
|
||||
plMail führt überall dieselben Container aus, deshalb geht es auf dieser Seite fast nur um die
|
||||
|
||||
@@ -40,7 +40,7 @@ These are the variables in `.env`, in the order they appear there.
|
||||
| `APP_SHARE_DIR` | Nothing. No code in this repository reads it. | `var/share` | No | Nothing. Left in place because removing a variable is a change to `.env`; treat it as inert. |
|
||||
| `APP_SECRETS_FILE` | Path to the generated secrets file, relative to the project root unless it starts with `/`. | `var/secrets/generated.env` | Yes | Point one service somewhere the others cannot see and it mints its own encryption key. `EncryptionKeyProbe` catches that at boot and refuses to start the server. |
|
||||
| `APP_STORAGE_DIR` | Root for everything plMail writes as a file, relative to the project root: `attachments/`, `raw/`, `uploads/` (avatars live under `uploads/avatars/`). | `var` | Yes | Attachment paths are stored in the database relative to the project root, so changing this on an install that already holds mail orphans every existing file. |
|
||||
| `APP_PUBLIC_URL` | The address plMail is reached at from outside — what Google and Microsoft call back to. | blank; asked for on the setup screen and written to the generated file | For push, yes | Blank, `http://`, or a loopback host and calendar push and Graph mail push refuse to register at all. See [Behind a reverse proxy](reverse-proxy.md). |
|
||||
| `APP_PUBLIC_URL` | The address plMail is reached at from outside — what Google and Microsoft call back to. | blank; asked for on the setup screen and written to the generated file. Changed afterwards under Admin → Address, which takes a restart to apply | For push, yes | Blank, `http://`, or a loopback host and calendar push and Graph mail push refuse to register at all. See [Behind a reverse proxy](reverse-proxy.md). |
|
||||
| `APP_DEFAULT_TIMEZONE` | The clock a user who has never picked one in Settings is shown. | `Europe/Berlin` (also the `app.fallback_timezone` parameter, used when the variable is absent) | No | An install left on the wrong zone shows every such user times that are plausibly wrong by their own offset. Timestamps are stored in UTC and only converted for display, so fixing it re-renders rather than rewrites. Not the container's PHP timezone, which is pinned to `UTC` in `frankenphp/conf.d/10-app.ini`. |
|
||||
| `DEFAULT_URI` | Base URI the router uses when generating URLs outside an HTTP request. | `http://localhost` | No | Links in mail generated from a console command point at localhost. Push callbacks do not use this — they use `APP_PUBLIC_URL`. |
|
||||
| `DATABASE_URL` | Doctrine connection. | `postgresql://app@database:5432/app?serverVersion=18&charset=utf8` — deliberately credential-less | Yes, but assembled | A DSN that carries **no** password is treated as "nobody configured a database", and the generated `POSTGRES_PASSWORD` is spliced in. A DSN that carries one is taken as intent and left alone — so a wrong password here is used verbatim and Postgres refuses the connection. It is not blank because Doctrine reads the driver out of the scheme, and a blank DSN fails the image build's cache warmup with "could not find driver". |
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -32,7 +32,7 @@ final class AdminDashboardController extends AbstractController
|
||||
{
|
||||
use ChecksCsrf;
|
||||
|
||||
private const array SECTIONS = ['system', 'database', 'logs', 'insight-reports', 'integrations', 'push', 'ai', 'users', 'backup', 'updates', 'reset'];
|
||||
private const array SECTIONS = ['system', 'database', 'logs', 'insight-reports', 'address', 'integrations', 'push', 'ai', 'users', 'backup', 'updates', 'reset'];
|
||||
private const int LOGS_PER_PAGE = 100;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Form\Admin\PublicUrlType;
|
||||
use App\Service\Setup\PublicUrlSetting;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Admin → Address: where plMail is reached from outside, after setup.
|
||||
*
|
||||
* The setup screen asks for this once and there was no way to answer it again.
|
||||
* That left an install restored from another machine's backup telling every
|
||||
* browser to subscribe to live updates at the old machine's address, with the
|
||||
* only remedy an environment variable in the compose file.
|
||||
*
|
||||
* One action for GET and POST and a Turbo Frame of its own, as Admin → Push
|
||||
* does it and for the reasons recorded there.
|
||||
*
|
||||
* Saving does not restart anything. The frame says a restart is needed and
|
||||
* offers the web container's, which is the one that can be done from here; the
|
||||
* rest of the stack is the operator's to restart, as it is after a restored
|
||||
* backup. PublicUrlSetting records why the change is not applied live.
|
||||
*/
|
||||
#[Route('/admin/address', name: 'app_admin_address_')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
final class PublicAddressController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PublicUrlSetting $publicUrl,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'settings', methods: ['GET', 'POST'])]
|
||||
public function settings(Request $request): Response
|
||||
{
|
||||
// Prefilled with what is on file rather than what is in force: the
|
||||
// form edits the file, and after a save that is still waiting for its
|
||||
// restart the two differ.
|
||||
$form = $this->createForm(PublicUrlType::class, [
|
||||
'publicUrl' => $this->publicUrl->stored() ?? $this->publicUrl->current(),
|
||||
], [
|
||||
// Explicit for the reason PushSettingsController gives: inside a
|
||||
// Turbo Frame a form with no action posts to the document URL.
|
||||
'action' => $this->generateUrl('app_admin_address_settings'),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$saved = false;
|
||||
|
||||
if (true === $form->isSubmitted() && true === $form->isValid()) {
|
||||
$this->publicUrl->save((string) $form->get('publicUrl')->getData());
|
||||
|
||||
$saved = true;
|
||||
}
|
||||
|
||||
return $this->render('admin/address/_frame.html.twig', [
|
||||
'form' => $form,
|
||||
'saved' => $saved,
|
||||
'current' => $this->publicUrl->current(),
|
||||
'stored' => $this->publicUrl->stored(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin;
|
||||
|
||||
use App\Form\PasswordManagerIgnore;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\Url;
|
||||
|
||||
/**
|
||||
* The public address, on its own — the field the setup screen asks for among
|
||||
* the first administrator's details, for the administrator who needs to change
|
||||
* it afterwards.
|
||||
*
|
||||
* The same rules as there, deliberately: FirstAdminType explains why the TLD is
|
||||
* not required, and an address setup accepted must not be one this form then
|
||||
* refuses to save back.
|
||||
*/
|
||||
final class PublicUrlType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('publicUrl', UrlType::class, [
|
||||
'label' => 'admin.address.field.public_url',
|
||||
'help' => 'admin.address.field.public_url_help',
|
||||
'default_protocol' => null,
|
||||
'constraints' => [new NotBlank(), new Url(requireTld: false)],
|
||||
// A server address, not an account of any kind.
|
||||
'attr' => PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,23 @@ use Throwable;
|
||||
* Stored in the generated-config file, the one place a running container can
|
||||
* write that every other service reads. An APP_PUBLIC_URL supplied through the
|
||||
* environment still wins, so a deployment that sets it is untouched.
|
||||
*
|
||||
* Changeable after setup, from Admin → Address: an install gets a domain, moves
|
||||
* behind a different proxy, or is restored from a backup taken somewhere else,
|
||||
* and the address in that backup is the old machine's. A change takes a restart
|
||||
* to reach processes that are already running. Making it live was considered
|
||||
* and left out — the entrypoint exports this file into every container's
|
||||
* environment at start, and telling a stale export from a deliberate pin needs
|
||||
* more machinery than a restart costs.
|
||||
*/
|
||||
final readonly class PublicUrlSetting
|
||||
{
|
||||
/**
|
||||
* Where the hub answers on the app's own origin — the suffix
|
||||
* config/bootstrap_generated_secrets.php derives MERCURE_PUBLIC_URL with.
|
||||
*/
|
||||
private const string HUB_PATH = '/.well-known/mercure';
|
||||
|
||||
public function __construct(
|
||||
private GeneratedSecretsFile $config,
|
||||
private WorkerRestartSignal $workerRestart,
|
||||
@@ -33,7 +47,21 @@ final readonly class PublicUrlSetting
|
||||
|
||||
public function save(string $url): void
|
||||
{
|
||||
$this->config->set('APP_PUBLIC_URL', rtrim(trim($url), '/'));
|
||||
$url = rtrim(trim($url), '/');
|
||||
|
||||
// A hub address on file that is only the previous public address with
|
||||
// the hub's path on it was never a decision of its own — a restored
|
||||
// backup carries one. Left in place it keeps browsers subscribing at
|
||||
// the address that was just replaced, and live updates stay dead while
|
||||
// everything else has moved. One that points anywhere else is somebody's
|
||||
// separate hub, and stays.
|
||||
$previous = $this->stored();
|
||||
|
||||
if (null !== $previous && $previous.self::HUB_PATH === trim($this->config->read()['MERCURE_PUBLIC_URL'] ?? '')) {
|
||||
$this->config->remove(['MERCURE_PUBLIC_URL']);
|
||||
}
|
||||
|
||||
$this->config->set('APP_PUBLIC_URL', $url);
|
||||
|
||||
// The web process picks the new value up on the next request; the
|
||||
// workers are long-running and would otherwise hold the old one until
|
||||
@@ -72,6 +100,22 @@ final readonly class PublicUrlSetting
|
||||
return '' === $stored ? null : rtrim($stored, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* What is on file, whatever the running processes were started with.
|
||||
*
|
||||
* Differs from current() in exactly two situations, and Admin → Address
|
||||
* shows both values because it cannot tell them apart: a saved address
|
||||
* still waiting for its restart, since the entrypoint exports the file into
|
||||
* the environment once, at container start; or an APP_PUBLIC_URL set in the
|
||||
* compose file, which wins over the file for good.
|
||||
*/
|
||||
public function stored(): ?string
|
||||
{
|
||||
$stored = trim($this->config->read()['APP_PUBLIC_URL'] ?? '');
|
||||
|
||||
return '' === $stored ? null : rtrim($stored, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* The address this request arrived on, as the best guess to offer during
|
||||
* setup. Correct whenever plMail is reached the same way its users reach
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,112 @@
|
||||
{% import '_partials/_card_chrome.html.twig' as chrome %}
|
||||
{#
|
||||
admin/address/_frame.html.twig — Admin → Address.
|
||||
|
||||
Accepts:
|
||||
form — App\Form\Admin\PublicUrlType
|
||||
saved — true immediately after a successful save
|
||||
current — the address the running processes use, or null when there is none
|
||||
stored — the address on file, or null when nothing was ever saved
|
||||
|
||||
**Two addresses, shown side by side whenever they differ.** `current` is what
|
||||
this process was started with and `stored` is what the next start will read.
|
||||
They part ways after a save that has not been restarted yet, and also when
|
||||
APP_PUBLIC_URL is set in the compose file, which wins over the file for good.
|
||||
The page cannot tell those apart, so it names both and lets the administrator
|
||||
recognise their own situation rather than guessing at one.
|
||||
|
||||
The restart button is the System panel's own, posting to the same action with
|
||||
the same token. It restarts the web container only, which is what brings live
|
||||
updates to the new address; the note beside it says what it does not reach.
|
||||
|
||||
No auto-refresh wraps this frame — it is configuration, and a poll would empty
|
||||
the field mid-word.
|
||||
#}
|
||||
{# Layout classes belong on the placeholder in admin/index.html.twig, not
|
||||
here: Turbo transplants this frame's CHILDREN into the frame already on
|
||||
the page and never copies its attributes, so a class written here reaches
|
||||
nothing. See tests/Infrastructure/Templating/TurboFrameClassesTest.php. #}
|
||||
<turbo-frame id="admin-address">
|
||||
|
||||
{% set pending = stored is not null and stored != current %}
|
||||
|
||||
<div class="{{ chrome.shell() }}">
|
||||
|
||||
<div class="flex items-center gap-2.5 {{ chrome.band() }}">
|
||||
<i class="fa-solid fa-globe text-xs text-ink-faint" aria-hidden="true"></i>
|
||||
<h2 class="text-sm font-semibold text-ink">{{ 'admin.address.heading'|trans }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="{{ chrome.body() }} space-y-4">
|
||||
|
||||
{% if saved %}
|
||||
<div role="status" class="flex items-start gap-2.5 rounded-lg bg-emerald-500/10 px-3.5 py-3 text-sm text-emerald-500">
|
||||
<i class="fa-solid fa-circle-check mt-0.5 shrink-0" aria-hidden="true"></i>
|
||||
<p class="leading-snug">{{ 'admin.address.saved'|trans }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-sm text-ink-soft leading-relaxed">{{ 'admin.address.intro'|trans }}</p>
|
||||
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
|
||||
<dt class="text-ink-faint">{{ 'admin.address.in_use'|trans }}</dt>
|
||||
<dd class="font-mono text-ink-soft break-all">{{ current ?? 'admin.address.none'|trans }}</dd>
|
||||
{% if pending %}
|
||||
<dt class="text-ink-faint">{{ 'admin.address.on_file'|trans }}</dt>
|
||||
<dd class="font-mono text-ink-soft break-all">{{ stored }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if pending %}
|
||||
<div role="status" class="rounded-lg bg-amber-500/10 px-3.5 py-3 text-sm text-amber-500">
|
||||
<p class="leading-snug">{{ 'admin.address.pending'|trans }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# After a save as well as while one is pending. The two are not
|
||||
the same moment: a process that reads the file on every call
|
||||
shows the new address at once, and the hub address the layout
|
||||
hands to browsers is still the one this kernel booted with. #}
|
||||
{% if saved or pending %}
|
||||
<div>
|
||||
<p class="text-xs text-ink-faint leading-relaxed">{{ 'admin.address.restart_help'|trans }}</p>
|
||||
|
||||
{# data-turbo="false", as on the System panel: the answer
|
||||
is a full page that outlives the process serving it,
|
||||
not something to swap into this frame. #}
|
||||
<form method="post" action="{{ path('app_admin_system_restart_app') }}" data-turbo="false" class="mt-2">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('admin_restart_app') }}">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium btn-quiet hover:bg-hover transition-colors cursor-pointer">
|
||||
<i class="fa-solid fa-power-off" aria-hidden="true"></i>
|
||||
{{ 'admin.maintenance.restart_app'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ form_start(form, { attr: { 'data-turbo-frame': 'admin-address' } }) }}
|
||||
<div class="space-y-4">
|
||||
{{ form_widget(form) }}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-sm font-medium
|
||||
text-accent-ink bg-accent hover:bg-accent-strong transition-colors cursor-pointer">
|
||||
<i class="fa-solid fa-floppy-disk text-xs" aria-hidden="true"></i>
|
||||
{{ 'admin.address.save'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# px-4, the house inset: this note sits outside the card and has to
|
||||
begin where the card's own content begins, not at its own figure. #}
|
||||
<p class="px-4 text-xs text-ink-faint leading-relaxed">
|
||||
<i class="fa-solid fa-circle-info mr-1" aria-hidden="true"></i>
|
||||
{{ 'admin.address.follow_up'|trans }}
|
||||
</p>
|
||||
|
||||
</turbo-frame>
|
||||
@@ -77,7 +77,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if section not in ['logs', 'insight-reports', 'integrations', 'push', 'ai', 'backup', 'updates', 'reset'] %}
|
||||
{% if section not in ['logs', 'insight-reports', 'address', 'integrations', 'push', 'ai', 'backup', 'updates', 'reset'] %}
|
||||
<span class="ml-auto text-xs text-ink-faint">{{ 'admin.refresh_hint'|trans }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -131,6 +131,14 @@
|
||||
aria-label="{{ 'admin.insight_reports.pending'|trans({ '%count%': pendingInsightReports }) }}">{{ pendingInsightReports }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{# Before integrations, because those are
|
||||
configured against it: the redirect address
|
||||
each provider is given is built from this. #}
|
||||
<a href="{{ path('app_admin_dashboard', { section: 'address' }) }}"
|
||||
class="{{ section == 'address' ? navActive : navInactive }}">
|
||||
<i class="fa-solid fa-globe w-4 {{ section == 'address' ? 'text-ink-faint' : '' }}" aria-hidden="true"></i>
|
||||
{{ 'admin.nav.address'|trans }}
|
||||
</a>
|
||||
<a href="{{ path('app_admin_dashboard', { section: 'integrations' }) }}"
|
||||
class="{{ section == 'integrations' ? navActive : navInactive }}">
|
||||
<i class="fa-solid fa-plug w-4 {{ section == 'integrations' ? 'text-ink-faint' : '' }}" aria-hidden="true"></i>
|
||||
@@ -240,6 +248,20 @@
|
||||
</div>
|
||||
</turbo-frame>
|
||||
|
||||
{% elseif section == 'address' %}
|
||||
|
||||
{# No auto-refresh: configuration, and
|
||||
a poll would empty the field
|
||||
mid-word. #}
|
||||
<turbo-frame id="admin-address"
|
||||
class="block space-y-3"
|
||||
src="{{ path('app_admin_address_settings') }}"
|
||||
loading="lazy">
|
||||
<div class="py-10 text-center text-sm text-ink-faint">
|
||||
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
|
||||
</div>
|
||||
</turbo-frame>
|
||||
|
||||
{% elseif section == 'integrations' %}
|
||||
|
||||
{# No auto-refresh: this is configuration, not
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\User\User;
|
||||
use App\Infrastructure\Setup\GeneratedSecretsFile;
|
||||
use App\Repository\User\UserRepository;
|
||||
use App\Service\Monitoring\WorkerRestartSignal;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* The public address can be changed after setup, and a change is written to the
|
||||
* file rather than only acknowledged.
|
||||
*
|
||||
* Setup asked for it once and nothing could answer it again, so an install
|
||||
* restored from another machine's backup kept sending browsers to that
|
||||
* machine's address for live updates. The claim worth pinning is the write: a
|
||||
* form that re-renders with the new value in its field and a green notice looks
|
||||
* finished whether or not anything reached the file the next start reads.
|
||||
*/
|
||||
final class PublicAddressControllerTest extends WebTestCase
|
||||
{
|
||||
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
|
||||
|
||||
private KernelBrowser $client;
|
||||
private Connection $connection;
|
||||
private GeneratedSecretsFile $config;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient();
|
||||
|
||||
$container = static::getContainer();
|
||||
$this->connection = $container->get(Connection::class);
|
||||
$this->config = $container->get(GeneratedSecretsFile::class);
|
||||
|
||||
$admin = $container->get(UserRepository::class)->findOneBy(['email' => self::ADMIN_EMAIL]);
|
||||
|
||||
if (false === $admin instanceof User) {
|
||||
self::markTestSkipped('run `app:test:seed-user --admin` first');
|
||||
}
|
||||
|
||||
$this->client->loginUser($admin);
|
||||
|
||||
// Outside the transaction, for the reason InstallEmptyInstallTest
|
||||
// gives: saving nudges the workers through a cache pool that creates
|
||||
// its table on first write.
|
||||
$container->get(WorkerRestartSignal::class)->request();
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
// The file is shared by the whole suite, and an address left in it
|
||||
// would be the public URL of every test that runs afterwards.
|
||||
$this->config->remove(['APP_PUBLIC_URL']);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testASavedAddressReachesTheFile(): void
|
||||
{
|
||||
$crawler = $this->client->request('GET', '/admin/address');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$this->client->submit($crawler->filter('form[name="public_url"]')->form([
|
||||
'public_url[publicUrl]' => 'https://mail.example.test/',
|
||||
]));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('https://mail.example.test', $this->config->read()['APP_PUBLIC_URL'] ?? null);
|
||||
self::assertSelectorExists('turbo-frame#admin-address [role="status"]');
|
||||
|
||||
// Offered straight away, not only once the two addresses are seen to
|
||||
// differ: the hub address browsers are handed is fixed when the kernel
|
||||
// boots, so a save is never complete without it.
|
||||
self::assertSelectorExists('turbo-frame#admin-address form[action="/admin/system/restart-app"]');
|
||||
}
|
||||
|
||||
public function testSomethingThatIsNotAnAddressIsRefusedRatherThanStored(): void
|
||||
{
|
||||
$crawler = $this->client->request('GET', '/admin/address');
|
||||
|
||||
$this->client->submit($crawler->filter('form[name="public_url"]')->form([
|
||||
'public_url[publicUrl]' => 'not an address',
|
||||
]));
|
||||
|
||||
self::assertArrayNotHasKey('APP_PUBLIC_URL', $this->config->read());
|
||||
}
|
||||
|
||||
public function testTheSectionIsReachableFromTheAdminNavigation(): void
|
||||
{
|
||||
$crawler = $this->client->request('GET', '/admin?section=address');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(1, $crawler->filter('turbo-frame#admin-address[src="/admin/address"]'));
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,53 @@ final class PublicUrlSettingTest extends TestCase
|
||||
self::assertNull($this->setting()->current());
|
||||
}
|
||||
|
||||
public function testASavedAddressIsOnFileWhileTheOldOneIsStillInForce(): void
|
||||
{
|
||||
// What a running container looks like after a change in Admin →
|
||||
// Address: the entrypoint exported the old address at start, and the
|
||||
// environment wins until the next one.
|
||||
file_put_contents($this->secretsFile, "APP_PUBLIC_URL=https://old.example.com\n");
|
||||
$_SERVER['APP_PUBLIC_URL'] = 'https://old.example.com';
|
||||
|
||||
$setting = $this->setting();
|
||||
$setting->save('https://new.example.com/');
|
||||
|
||||
self::assertSame('https://old.example.com', $setting->current(), 'the running process keeps what it started with');
|
||||
self::assertSame('https://new.example.com', $setting->stored(), 'the next start reads the new one');
|
||||
}
|
||||
|
||||
public function testAHubAddressThatOnlyFollowedTheOldAddressIsDropped(): void
|
||||
{
|
||||
// A restored backup carries one. Left on file it would keep every
|
||||
// browser subscribing at the address that was just replaced.
|
||||
file_put_contents(
|
||||
$this->secretsFile,
|
||||
"APP_PUBLIC_URL=https://old.example.com\nMERCURE_PUBLIC_URL=https://old.example.com/.well-known/mercure\nMERCURE_JWT_SECRET=kept\n",
|
||||
);
|
||||
|
||||
$this->setting()->save('https://new.example.com');
|
||||
|
||||
$stored = (new GeneratedSecretsFile($this->secretsFile))->read();
|
||||
|
||||
self::assertArrayNotHasKey('MERCURE_PUBLIC_URL', $stored, 'so that it is derived from the new address again');
|
||||
self::assertSame('kept', $stored['MERCURE_JWT_SECRET'] ?? null, 'nothing else on file is touched');
|
||||
}
|
||||
|
||||
public function testAHubAddressOfItsOwnSurvivesAChange(): void
|
||||
{
|
||||
file_put_contents(
|
||||
$this->secretsFile,
|
||||
"APP_PUBLIC_URL=https://old.example.com\nMERCURE_PUBLIC_URL=https://hub.example.net/.well-known/mercure\n",
|
||||
);
|
||||
|
||||
$this->setting()->save('https://new.example.com');
|
||||
|
||||
self::assertSame(
|
||||
'https://hub.example.net/.well-known/mercure',
|
||||
(new GeneratedSecretsFile($this->secretsFile))->read()['MERCURE_PUBLIC_URL'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
private function setting(): PublicUrlSetting
|
||||
{
|
||||
return new PublicUrlSetting(
|
||||
|
||||
@@ -58,6 +58,41 @@ admin:
|
||||
last_activity: 'Letzte Aktivität'
|
||||
messages: 'Nachrichten'
|
||||
threads: 'Konversationen'
|
||||
address:
|
||||
field:
|
||||
public_url: Öffentliche Adresse
|
||||
public_url_help: >-
|
||||
Unter dieser Adresse ist plMail von außen erreichbar, mit http:// oder
|
||||
https:// davor und ohne Pfad. Gmail und Outlook schicken
|
||||
Push-Benachrichtigungen dorthin zurück, dafür muss sie aus dem Internet
|
||||
über https erreichbar sein.
|
||||
follow_up: >-
|
||||
Was woanders eingetragen ist, zieht nicht von selbst mit: die
|
||||
Weiterleitungsadressen in den Konsolen von Google und Microsoft, ein
|
||||
Pub/Sub-Push-Endpunkt für Gmail und die Serveradresse in Mail- und
|
||||
Kalender-Apps. Browser-Benachrichtigungen musst du auf jedem Gerät neu
|
||||
einschalten.
|
||||
heading: Öffentliche Adresse
|
||||
in_use: Derzeit verwendet
|
||||
intro: >-
|
||||
Aus dieser Adresse baut plMail seine eigenen Links: wohin sich Browser für
|
||||
Live-Aktualisierungen verbinden und wohin Google und Microsoft
|
||||
zurückrufen sollen. Ändere sie, wenn die Installation auf eine andere
|
||||
Domain, einen anderen Port oder hinter einen anderen Proxy zieht, oder
|
||||
nachdem du eine Sicherung von einer anderen Maschine eingespielt hast.
|
||||
none: keine
|
||||
on_file: Gespeichert
|
||||
pending: >-
|
||||
Die gespeicherte Adresse ist nicht die verwendete. Eine Änderung greift,
|
||||
sobald die Container neu starten. Weichen die beiden nach einem Neustart
|
||||
immer noch voneinander ab, ist APP_PUBLIC_URL in der Compose-Datei
|
||||
gesetzt, und das hat Vorrang.
|
||||
restart_help: >-
|
||||
Für Live-Aktualisierungen reicht es, plMail hier neu zu starten. Starte
|
||||
zusätzlich den ganzen Stack neu, damit die Worker Push unter der neuen
|
||||
Adresse anmelden.
|
||||
save: Adresse speichern
|
||||
saved: 'Gespeichert — greift beim nächsten Neustart.'
|
||||
ago: 'her'
|
||||
ai:
|
||||
backfill:
|
||||
@@ -653,6 +688,7 @@ admin:
|
||||
page: 'Seite'
|
||||
refresh: 'Aktualisieren'
|
||||
nav:
|
||||
address: Adresse
|
||||
ai: KI
|
||||
backup: Sicherung
|
||||
database: Datenbank
|
||||
|
||||
@@ -58,6 +58,37 @@ admin:
|
||||
last_activity: 'Last activity'
|
||||
messages: 'Messages'
|
||||
threads: 'Threads'
|
||||
address:
|
||||
field:
|
||||
public_url: Public address
|
||||
public_url_help: >-
|
||||
Where people reach plMail from outside, with http:// or https:// in
|
||||
front and no path. Gmail and Outlook send push notifications back to
|
||||
it, so for those it has to be reachable from the internet over https.
|
||||
follow_up: >-
|
||||
Things registered elsewhere do not follow by themselves: the redirect
|
||||
addresses in the Google and Microsoft consoles, a Gmail Pub/Sub push
|
||||
endpoint, and the server address in mail and calendar apps. Browser
|
||||
notifications have to be switched on again on each device.
|
||||
heading: Public address
|
||||
in_use: In use now
|
||||
intro: >-
|
||||
The address plMail builds its own links from: where browsers connect for
|
||||
live updates, and where Google and Microsoft are told to call back.
|
||||
Change it when the install moves to another domain, port or proxy, or
|
||||
after restoring a backup taken on a different machine.
|
||||
none: none
|
||||
on_file: Saved
|
||||
pending: >-
|
||||
The saved address is not the one in use. A change takes effect when the
|
||||
containers start again. If the two still differ after a restart,
|
||||
APP_PUBLIC_URL is set in the compose file, and that setting wins over
|
||||
this one.
|
||||
restart_help: >-
|
||||
Restarting plMail here is enough for live updates. Restart the whole
|
||||
stack as well, so the workers register push at the new address.
|
||||
save: Save address
|
||||
saved: 'Saved — it takes effect at the next restart.'
|
||||
ago: 'ago'
|
||||
ai:
|
||||
backfill:
|
||||
@@ -646,6 +677,7 @@ admin:
|
||||
page: 'Page'
|
||||
refresh: 'Refresh'
|
||||
nav:
|
||||
address: Address
|
||||
ai: AI
|
||||
backup: Backup
|
||||
database: Database
|
||||
|
||||
@@ -58,6 +58,38 @@ admin:
|
||||
last_activity: 'Last sighted'
|
||||
messages: 'Bottles'
|
||||
threads: 'Yarns'
|
||||
address:
|
||||
field:
|
||||
public_url: Home port
|
||||
public_url_help: >-
|
||||
Where folk hail plMail from the open sea, with http:// or https://
|
||||
afore it an' no path. Gmail an' Outlook send their push signals
|
||||
back to it, so for those she must be reachable from the wide
|
||||
internet over https.
|
||||
follow_up: >-
|
||||
What be written down elsewhere follows not by itself: the redirect
|
||||
addresses in the Google an' Microsoft consoles, a Gmail Pub/Sub push
|
||||
endpoint, an' the server address in mail an' almanac apps. Browser
|
||||
signals must be hoisted anew on every device.
|
||||
heading: Home port
|
||||
in_use: Flyin' now
|
||||
intro: >-
|
||||
The address plMail builds her own links from: where browsers moor for
|
||||
live tidings, an' where Google an' Microsoft be told to call back.
|
||||
Change her when the ship moves to another domain, port or proxy, or
|
||||
after restorin' ship's papers taken aboard another vessel.
|
||||
none: none
|
||||
on_file: Logged
|
||||
pending: >-
|
||||
The logged address be not the one flyin'. A change takes hold when
|
||||
the containers start anew. If the two still differ after a rousin',
|
||||
APP_PUBLIC_URL be set in the compose file, an' that order outranks
|
||||
this one.
|
||||
restart_help: >-
|
||||
Rousin' plMail here be enough for live tidings. Rouse the whole stack
|
||||
besides, so the hands register push at the new address.
|
||||
save: Log the address
|
||||
saved: 'Logged — she takes hold at the next rousin''.'
|
||||
ago: 'ago'
|
||||
ai:
|
||||
backfill:
|
||||
@@ -583,6 +615,7 @@ admin:
|
||||
page: 'Page'
|
||||
refresh: 'Refresh'
|
||||
nav:
|
||||
address: Home port
|
||||
ai: Oracle
|
||||
backup: "Ship's papers"
|
||||
database: Database
|
||||
|
||||
Reference in new issue
Block a user