fix(debyltech-cloud): external signers, proxy trust, light-only mail

- Enable LibreSign's email identify method (click-to-sign, no account
  creation), remove the stamp background and collect signer metadata.
  On Skudak these were only ever set in the admin UI. Without the email
  method a fresh instance answers "No signers." for any outside address.
  The verify script now asserts it.
- Store add_footer=true explicitly. The code already defaults to it, but
  the 14.2 admin page shows unset as unchecked.
- trusted_proxies = the container's own address, read per deploy. Behind
  rootless port forwarding every request arrives from it, so without this
  X-Forwarded-For was ignored and every client shared one IP for
  brute-force throttling.
- maintenance_window_start and default_phone_region, which clears the setup
  warnings.
- Mail declares color-scheme "light only" so Apple Mail's dark mode doesn't
  repaint the white ground and bury the black wordmark (asserted in verify).
- Idempotency: redis image fully qualified (docker.io/library/...), the
  debyltechmail copy owned by the mapped www-data uid, and theming
  compared before setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bastian de Byl
2026-09-28 18:08:41 -04:00
co-authored by Claude Opus 5.5
parent fa5bbf8e54
commit 9ce9610965
3 changed files with 106 additions and 0 deletions
@@ -188,6 +188,21 @@ class DebyltechEMailTemplate extends EMailTemplate {
'font-size:26px;font-weight:300;letter-spacing:-0.02em', 'font-size:26px;font-weight:300;letter-spacing:-0.02em',
$this->heading, $this->heading,
); );
// Opt out of mail-client dark mode. Without this Apple Mail repaints
// the white ground charcoal on its own, and the black wordmark all but
// disappears. Swapping to a white logo under prefers-color-scheme is
// NOT a fix: with Apple Mail's "Use dark backgrounds for messages" off,
// the query still matches while the ground stays white, leaving a
// white-on-white logo. This mail is designed light; render it light.
$this->head = str_replace(
'</head>',
'<meta name="color-scheme" content="light only">'
. '<meta name="supported-color-schemes" content="light only">'
. '<style type="text/css">:root{color-scheme:light only;supported-color-schemes:light only}</style>'
. '</head>',
$this->head,
);
} }
/** /**
@@ -370,6 +370,58 @@
register: debyltech_enum_fullmatch register: debyltech_enum_fullmatch
changed_when: "'is now set to' in debyltech_enum_fullmatch.stdout" changed_when: "'is now set to' in debyltech_enum_fullmatch.stdout"
# Settings Skudak only ever had from clicks in the LibreSign admin page, never
# in git -- a fresh instance without them cannot invite anyone by address:
#
# identify_methods The EMAIL identification method. Without it the signer
# search only lists accounts, so an outside address
# returns a bare "No signers." -- the whole point of this
# instance. clickToSign (no emailed code) and
# can_create_account=false, as on Skudak: the emailed link
# is the identity check, and customers never get accounts.
# signature_background_type=deleted
# Drops the LibreSign logo watermark from behind the
# stamp, so with GRAPHIC_ONLY the stamp is the drawn mark
# and nothing else.
# collect_metadata Records signer IP/user agent alongside each signature.
- name: set libresign signer identification and stamp settings in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign {{ item.k }} --value={{ item.v | quote }}
register: debyltech_libresign_settings
changed_when: "'is now set to' in debyltech_libresign_settings.stdout"
loop:
- k: identify_methods
v: >-
{{ [{'name': 'email', 'friendly_name': 'Email', 'enabled': true,
'mandatory': true,
'signatureMethods': {
'clickToSign': {'name': 'clickToSign', 'enabled': true},
'emailToken': {'name': 'emailToken', 'enabled': false}},
'can_create_account': false,
'test_url': '/index.php/settings/admin/mailtest',
'signatureMethodEnabled': 'clickToSign'}] | to_json }}
- {k: signature_background_type, v: deleted}
- {k: collect_metadata, v: "1"}
loop_control:
label: "{{ item.k }}"
# The validation footer ("Digitally signed by ... Validate in <url>") is WANTED
# -- only its QR code goes, below. FooterHandler defaults add_footer to true
# when unset, but the 14.2 admin page renders unset as UNCHECKED, inviting
# someone to "correct" it into actually turning the footer off. Stored
# explicitly so the page shows what the code does.
- name: keep libresign validation footer text in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign add_footer --value=1 --type=boolean
register: libresign_footer
changed_when: "'is now set to' in libresign_footer.stdout"
- name: drop libresign validation QR code from signed-PDF footer in debyltech-cloud - name: drop libresign validation QR code from signed-PDF footer in debyltech-cloud
become: true become: true
become_user: "{{ podman_user }}" become_user: "{{ podman_user }}"
@@ -444,6 +496,23 @@
register: debyltechmail_enable register: debyltechmail_enable
changed_when: "'already enabled' not in debyltechmail_enable.stdout" changed_when: "'already enabled' not in debyltechmail_enable.stdout"
# Behind rootless podman's port forwarder, every request -- Caddy's included --
# reaches Apache FROM THE CONTAINER'S OWN ADDRESS on `shared`, not from the
# host. Unless exactly that address is a trusted proxy, Nextcloud ignores the
# X-Forwarded-For header Caddy sends, so every client looks like one IP:
# brute-force throttling then penalises everyone at once. Read per deploy
# because the address is assigned at container creation, and deploys are the
# only thing that recreate it.
- name: read debyltech-cloud container address
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman inspect debyltech-cloud
--format "{{ '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' }}"
register: debyltech_cloud_ip
changed_when: false
failed_when: debyltech_cloud_ip.stdout is not match('^[0-9.]+$')
# System config, set only when it differs so a clean re-deploy reports no # System config, set only when it differs so a clean re-deploy reports no
# changes (Skudak's equivalents report changed on every run). Values are # changes (Skudak's equivalents report changed on every run). Values are
# single-quoted into the shell, so the backslashes in mail_template_class pass # single-quoted into the shell, so the backslashes in mail_template_class pass
@@ -467,6 +536,10 @@
loop: loop:
- {k: overwrite.cli.url, v: "https://{{ cloud_debyltech_server_name }}"} - {k: overwrite.cli.url, v: "https://{{ cloud_debyltech_server_name }}"}
- {k: overwriteprotocol, v: https} - {k: overwriteprotocol, v: https}
- {k: trusted_proxies 0, v: "{{ debyltech_cloud_ip.stdout }}"}
# Hour in UTC: 05:00 UTC is 01:00/00:00 Eastern, ahead of the 04:15 backup.
- {k: maintenance_window_start, v: "5", t: integer}
- {k: default_phone_region, v: US}
- {k: loglevel, v: "2", t: integer} - {k: loglevel, v: "2", t: integer}
- {k: log_rotate_size, v: "10485760", t: integer} - {k: log_rotate_size, v: "10485760", t: integer}
- {k: mail_template_class, v: "OCA\\Debyltechmail\\Mail\\DebyltechEMailTemplate"} - {k: mail_template_class, v: "OCA\\Debyltechmail\\Mail\\DebyltechEMailTemplate"}
@@ -72,6 +72,10 @@ if (!str_contains($text, 'official document-signing request')) {
if (!str_contains($html, 'debyltech.com/legal/privacy') || !str_contains($html, 'debyltech.com/legal/tos')) { if (!str_contains($html, 'debyltech.com/legal/privacy') || !str_contains($html, 'debyltech.com/legal/tos')) {
$failures[] = 'privacy/terms links missing from footer'; $failures[] = 'privacy/terms links missing from footer';
} }
if (!str_contains($html, 'content="light only"')) {
$failures[] = 'color-scheme "light only" meta missing -- dark-mode mail clients will repaint '
. 'the ground and bury the black wordmark (did upstream rename </head> in $head?)';
}
if (preg_match('/[»«]/u', $html)) { if (preg_match('/[»«]/u', $html)) {
$failures[] = 'German guillemets survived into the body'; $failures[] = 'German guillemets survived into the body';
} }
@@ -131,6 +135,20 @@ if ($appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_
. 'unset or "yes"; it is NOT the knob for the account-owned-email problem.'; . 'unset or "yes"; it is NOT the knob for the account-owned-email problem.';
} }
// Outside signers are invited by address; without an enabled email identify
// method the signer search returns "No signers." for any non-account email.
$methods = json_decode($appConfig->getValueString('libresign', 'identify_methods', '[]'), true) ?: [];
$emailOn = false;
foreach ($methods as $m) {
if (($m['name'] ?? '') === 'email' && !empty($m['enabled'])) {
$emailOn = true;
}
}
if (!$emailOn) {
$failures[] = 'libresign email identify method is not enabled -- outside addresses '
. 'cannot be added as signers ("No signers.")';
}
$identDocs = $appConfig->getValueString('libresign', 'identification_documents', ''); $identDocs = $appConfig->getValueString('libresign', 'identification_documents', '');
if ($identDocs !== '0') { if ($identDocs !== '0') {
$failures[] = 'libresign identification_documents is "' . $identDocs $failures[] = 'libresign identification_documents is "' . $identDocs