feat(debyltech-cloud): add cloud.debyltech.com Nextcloud
A de Byl Technologies LLC Nextcloud cloned from the Skudak instance: LibreSign signing for people without an account, registration off (admin-created accounts only), no Group Folders. DNS is a terraform-managed ALIAS to fulfillr.debyltech.com. - containers/debyltech/cloud.yml: nextcloud/mariadb/redis on port 8091. It installs unattended on the first deploy, sends mail through SES as noreply@debyltech.com, and re-asserts the Skudak LibreSign settings. - files/debyltechmail: skudakmail rebranded, with a new black-and-white wordmark and white web-UI logos. - LibreSign is pinned to 14.2.2 from the GitHub release (sha256-checked) rather than `occ app:install`. The app store served a same-day 14.2.3 whose tarball has no binary-signature metadata. 14.2.x also doesn't create its own download dirs, so they're pre-created. - The backup runs nightly at 04:15 to TrueNAS /mnt/glacier/debyltechcloud and reaches personal iDrive via the "iDrive E2 Backup" task; the TrueNAS side excludes /debyltechcloud/_backup/config/**. - Fix the libresign:configure:check gate in both instances: '\berror\b' becomes a backspace in Jinja and never matched, so a check reporting three errors passed clean. Now '\\berror\\b'. - vault: cloud_debyltech_* secrets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0eca63d4b7
commit
fa5bbf8e54
@@ -60,7 +60,7 @@ ansible/
|
||||
Containers are organized in `ansible/roles/podman/tasks/containers/`:
|
||||
- `base/` - Core infrastructure containers (Caddy web server, AWS DDNS)
|
||||
- `home/` - Home-specific services (Home Assistant, PartKeepr, Immich photos, Nextcloud, Redis)
|
||||
- `debyltech/` - Personal/business services (Fulfillr)
|
||||
- `debyltech/` - Personal/business services (Fulfillr, Nextcloud at cloud.debyltech.com - cloned from the Skudak instance, backs up to personal iDrive via TrueNAS)
|
||||
- `skudak/` - Additional services (BookStack wiki, Nextcloud)
|
||||
|
||||
### Security Model
|
||||
|
||||
@@ -39,9 +39,13 @@ before you need it, and record the date you last did.
|
||||
|
||||
Dumps live on the host at `/var/backups/nextcloud/<name>/db/<name>-YYYYMMDD.sql.gz`
|
||||
and on TrueNAS at `<remote_path>/_backup/db/`. TrueNAS in turn cloud-syncs
|
||||
`/mnt/glacier/skudakcloud` to Skudak's own iDrive e2 bucket, so a third copy
|
||||
exists there — but restoring from it means going through the TrueNAS console,
|
||||
not this host.
|
||||
each dataset offsite, so a third copy exists there — but restoring from it
|
||||
means going through the TrueNAS console, not this host:
|
||||
|
||||
| Dataset | Offsite |
|
||||
|---|---|
|
||||
| `skudakcloud`, `skudakapps`, `skudakgit` | Skudak's own iDrive e2 bucket (excluded from the personal task) |
|
||||
| `nextcloud`, `gitea`, `debyltechcloud` | Personal iDrive e2 bucket, via the "iDrive E2 Backup" task over `/mnt/glacier` |
|
||||
|
||||
Verify the dump before trusting it:
|
||||
|
||||
@@ -102,23 +106,25 @@ The signing CA lives in the data tree at
|
||||
brings it back with everything else. After restoring, confirm it:
|
||||
|
||||
```bash
|
||||
sudo -H -u podman bash -c 'cd; podman exec -u www-data skudak-cloud php occ libresign:configure:check'
|
||||
sudo -H -u podman bash -c 'cd; podman exec -u www-data <skudak-cloud|debyltech-cloud> php occ libresign:configure:check'
|
||||
```
|
||||
|
||||
Every check must report `success`. If `openssl-configure` reports an error, the
|
||||
`certificate_engine` / `config_path` app config is pointing somewhere without a
|
||||
CA — see the guarded generate task in `tasks/containers/skudak/cloud.yml`.
|
||||
CA — see the guarded generate task in `tasks/containers/{skudak,debyltech}/cloud.yml`.
|
||||
**Do not** simply re-run `libresign:configure:openssl` on a restored instance
|
||||
without understanding why: it mints a *new* root CA and invalidates the trust
|
||||
chain on every document already signed under the old one.
|
||||
|
||||
## LibreSign
|
||||
|
||||
Deployed on `skudak-cloud` only. LibreSign 14.1.0 requires Nextcloud server
|
||||
`>=34.0.0,<35.0.0`, which the pinned `nextcloud:34.0.2-apache` satisfies. If the
|
||||
Nextcloud tag is bumped to 35, LibreSign must be held or upgraded in step — the
|
||||
two instances are pinned independently in `tasks/main.yml`, so `skudak-cloud`
|
||||
can lag `cloud` if needed.
|
||||
Deployed on `skudak-cloud` and `debyltech-cloud`. LibreSign 14.1.0 requires
|
||||
Nextcloud server `>=34.0.0,<35.0.0`, which the pinned `nextcloud:34.0.3-apache`
|
||||
satisfies. If the Nextcloud tag is bumped to 35, LibreSign must be held or
|
||||
upgraded in step — each instance is pinned independently in `tasks/main.yml`,
|
||||
so the LibreSign instances can lag `cloud` if needed. The branding apps
|
||||
(`files/skudakmail`, `files/debyltechmail`) pin `max-version="34"` too and
|
||||
must be bumped alongside.
|
||||
|
||||
Dependency split, which drives what survives a container recreate:
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ bookstack_path: "{{ podman_volumes }}/bookstack"
|
||||
cam2ip_path: "{{ podman_volumes }}/cam2ip"
|
||||
cloud_path: "{{ podman_volumes }}/cloud"
|
||||
cloud_skudak_path: "{{ podman_volumes }}/skudakcloud"
|
||||
cloud_debyltech_path: "{{ podman_volumes }}/debyltechcloud"
|
||||
debyltech_path: "{{ podman_volumes }}/debyltech"
|
||||
# drone_path: removed - Drone CI decommissioned
|
||||
factorio_path: "{{ podman_volumes }}/factorio"
|
||||
@@ -218,6 +219,29 @@ libresign_skudak_cert_c: US
|
||||
libresign_skudak_cert_st: New Hampshire
|
||||
libresign_skudak_cert_l: Newbury
|
||||
|
||||
# de Byl Technologies Nextcloud (containers/debyltech/cloud.yml). DNS is a
|
||||
# Route53 ALIAS to fulfillr.debyltech.com, managed in ~/src/debyltech/terraform
|
||||
# (aws/cloud.tf), so no awsddns container of its own.
|
||||
cloud_debyltech_server_name: cloud.debyltech.com
|
||||
# debyltech-com $primary-color (copper). Drives the web UI theming; the mail
|
||||
# CTA carries the same value as a constant in files/debyltechmail.
|
||||
theming_debyltech_primary: "#bc804d"
|
||||
# Login/header background. Dark site ink rather than copper so the white
|
||||
# wordmark and mark shipped in files/debyltechmail/img stay legible on it.
|
||||
theming_debyltech_background: "#0a1a2b"
|
||||
# LibreSign root CA identity: the issuer on every signed document. Same caveat
|
||||
# as the Skudak block above -- changing these does not re-issue the CA.
|
||||
libresign_debyltech_cert_cn: de Byl Technologies LLC
|
||||
libresign_debyltech_cert_o: de Byl Technologies LLC
|
||||
libresign_debyltech_cert_c: US
|
||||
libresign_debyltech_cert_st: New Hampshire
|
||||
libresign_debyltech_cert_l: Newbury
|
||||
# Outbound mail via AWS SES SMTP as noreply@debyltech.com. The IAM user is
|
||||
# NextcloudSMTP in the terraform repo; its SMTP username/password are
|
||||
# cloud_debyltech_smtp_user / cloud_debyltech_smtp_pass in the vault.
|
||||
cloud_debyltech_smtp_host: email-smtp.us-east-1.amazonaws.com
|
||||
cloud_debyltech_smtp_port: 465
|
||||
|
||||
|
||||
# Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security
|
||||
|
||||
@@ -284,6 +308,7 @@ caddy_log_names:
|
||||
- graylog
|
||||
- cloud
|
||||
- cloud-skudak
|
||||
- cloud-debyltech
|
||||
- gitea-debyl
|
||||
- gitea-skudak
|
||||
- fulfillr
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0"?>
|
||||
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||
<id>debyltechmail</id>
|
||||
<name>de Byl Tech Customisations</name>
|
||||
<summary>de Byl Technologies-branded email templates and UI overrides for Nextcloud and LibreSign</summary>
|
||||
<description><![CDATA[
|
||||
Restyles outgoing Nextcloud and LibreSign mail to match the de Byl
|
||||
Technologies brand (~/src/debyltech/debyltech-com). Cloned from the Skudak
|
||||
instance's skudakmail app. Two supported extension points, no core
|
||||
patch and no LibreSign fork:
|
||||
|
||||
1. `OCA\Debyltechmail\Mail\DebyltechEMailTemplate` extends Nextcloud's EMailTemplate
|
||||
and is wired in via the `mail_template_class` system config value, which
|
||||
Nextcloud checks in `lib/private/Mail/Mailer.php::createEMailTemplate()`.
|
||||
It owns layout, typography, subject rewriting, button labels and the
|
||||
footer LibreSign never adds.
|
||||
|
||||
2. `OCA\Debyltechmail\Listener\DebyltechMailListener` listens on
|
||||
`OCP\Mail\Events\BeforeMessageSent` to embed the wordmark as an inline
|
||||
(cid:) MIME part, so the logo survives the remote-image blocking that
|
||||
Apple Mail, Gmail and Outlook apply by default. This cannot be done from
|
||||
the template class, which has no reference to the message.
|
||||
|
||||
3. `OCA\Debyltechmail\Listener\DebyltechStyleListener` listens on
|
||||
`OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent` and adds
|
||||
css/libresign-mobile.css, which fixes the LibreSign public signing page
|
||||
being clipped at the bottom on iOS Safari. Serving it from here rather
|
||||
than patching LibreSign keeps the app's integrity signature intact and
|
||||
survives app updates, which wipe the app directory.
|
||||
|
||||
The app has no routes, no UI, no settings and no database tables. The id
|
||||
`debyltechmail` is referenced by the `mail_template_class` system config;
|
||||
its scope is instance-wide customisation, not mail alone.
|
||||
]]></description>
|
||||
<version>1.0.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author>de Byl Technologies LLC</author>
|
||||
<namespace>Debyltechmail</namespace>
|
||||
<category>customization</category>
|
||||
<dependencies>
|
||||
<nextcloud min-version="34" max-version="34"/>
|
||||
</dependencies>
|
||||
</info>
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Mobile fix for the LibreSign public signing page.
|
||||
*
|
||||
* PROBLEM: src/ExternalApp.vue sets `height: 100vh` on `html body #content`
|
||||
* and again on `#app-sidebar` under `@media (max-width: 512px)`. iOS Safari
|
||||
* resolves 100vh against the LARGE viewport -- as though the browser chrome
|
||||
* were hidden -- so the element extends behind the bottom toolbar and the
|
||||
* signing action bar is clipped off-screen. The built `external` chunk uses
|
||||
* 100vh seven times and dvh/svh/safe-area zero times.
|
||||
*
|
||||
* WHY NOT safe-area-inset: the page's viewport meta is
|
||||
* `width=device-width, initial-scale=1.0, minimum-scale=1.0` with no
|
||||
* `viewport-fit=cover`, so env(safe-area-inset-bottom) resolves to 0 here.
|
||||
*
|
||||
* WHY dvh: the dynamic viewport unit tracks the chrome as it shows and hides,
|
||||
* which is exactly the behaviour wanted. Browsers without dvh support drop the
|
||||
* declaration entirely and keep LibreSign's own 100vh -- so this degrades to
|
||||
* today's behaviour rather than to something broken. No @supports needed.
|
||||
*
|
||||
* SCOPING IS LOad-BEARING. `#content` and `#app-sidebar` are Nextcloud-wide
|
||||
* IDs used throughout the authenticated UI. Every rule below is scoped to
|
||||
* `#body-public` + `.app-public`, which the public signing page sets:
|
||||
* <body id="body-public" class="layout-base">
|
||||
* <div id="content" class="app-public" role="main">
|
||||
* Widening these selectors would restyle the whole instance.
|
||||
*
|
||||
* UPSTREAM: patched at source in src/ExternalApp.vue (lines 34 and 46) and
|
||||
* submitted to LibreSign. Once that lands and this instance runs a release
|
||||
* containing it, this file can be deleted.
|
||||
*/
|
||||
|
||||
#body-public #content.app-public {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
@media (max-width: 512px) {
|
||||
#body-public #app-sidebar {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Debyltechmail\AppInfo;
|
||||
|
||||
use OCA\Debyltechmail\Listener\DebyltechMailListener;
|
||||
use OCA\Debyltechmail\Listener\DebyltechStyleListener;
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
|
||||
use OCP\Mail\Events\BeforeMessageSent;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'debyltechmail';
|
||||
|
||||
public function __construct(array $urlParams = []) {
|
||||
parent::__construct(self::APP_ID, $urlParams);
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {
|
||||
// BeforeMessageSent fires in Mailer::send() (lib/private/Mail/Mailer.php:186),
|
||||
// AFTER useTemplate() has flattened the template into subject/plain/html on
|
||||
// the message, and BEFORE setRecipients() and the Symfony transport. That
|
||||
// window is the only place an inline (cid:) logo can be attached -- see the
|
||||
// listener for why the template class alone cannot do it.
|
||||
$context->registerEventListener(BeforeMessageSent::class, DebyltechMailListener::class);
|
||||
|
||||
// BeforeTemplateRenderedEvent is dispatched from
|
||||
// lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php:35 and
|
||||
// lib/private/Template/TemplateManager.php:82 -- the latter covers public
|
||||
// (unauthenticated) pages, which is the case that matters here since the
|
||||
// LibreSign signing page is a #[PublicPage].
|
||||
$context->registerEventListener(BeforeTemplateRenderedEvent::class, DebyltechStyleListener::class);
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Embeds the de Byl Technologies wordmark as an inline (cid:) MIME part.
|
||||
*
|
||||
* WHY A LISTENER AND NOT THE TEMPLATE CLASS: Apple Mail, Gmail and Outlook all
|
||||
* block remote images by default, and Apple Mail draws its own placeholder box
|
||||
* rather than styled alt text -- so no amount of styling in the HTML rescues a
|
||||
* remote <img>. The fix is a cid: reference backed by an inline MIME part, and
|
||||
* that part must be attached to the MESSAGE. An IEMailTemplate subclass has no
|
||||
* reference to the message, so it physically cannot do this; the template emits
|
||||
* the <img>, this listener supplies the bytes and rewrites the src.
|
||||
*
|
||||
* BeforeMessageSent is the sanctioned hook -- "Emitted before a system mail is
|
||||
* sent. It can be used to alter the message." (lib/public/Mail/Events/
|
||||
* BeforeMessageSent.php). It fires at lib/private/Mail/Mailer.php:186, after
|
||||
* useTemplate() has already rendered subject/plain/html onto the message and
|
||||
* before setRecipients() and the transport, so a body rewrite here takes
|
||||
* effect. No core patch, no LibreSign fork.
|
||||
*
|
||||
* FAILURE POSTURE: every step is defensive. If the asset is missing, the body
|
||||
* is not ours, or anything throws, the listener leaves the message untouched
|
||||
* and mail still goes out with a remote <img> -- degraded, never blocked. Mail
|
||||
* that carries signature requests must not fail to send because branding
|
||||
* broke.
|
||||
*/
|
||||
|
||||
namespace OCA\Debyltechmail\Listener;
|
||||
|
||||
use OC\Mail\Message;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Mail\Events\BeforeMessageSent;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/** @template-implements IEventListener<BeforeMessageSent> */
|
||||
class DebyltechMailListener implements IEventListener {
|
||||
/** Must match DebyltechEMailTemplate::LOGO_PATH. */
|
||||
private const LOGO_PATH_FRAGMENT = '/custom_apps/debyltechmail/img/debyltech-wordmark.png';
|
||||
|
||||
/** Content-ID. Symfony emits this as <debyltech-wordmark.png>. */
|
||||
private const CID = 'debyltech-wordmark.png';
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof BeforeMessageSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->embedWordmark($event->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
// Never let branding break delivery of a signature request.
|
||||
$this->logger->warning('debyltechmail: inline logo embed skipped', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function embedWordmark(\OCP\Mail\IMessage $message): void {
|
||||
// Mailer::send() guards `instanceof Message` before dispatching this
|
||||
// event, so the concrete type is guaranteed -- but getSymfonyEmail()
|
||||
// is not on the interface, so narrow explicitly rather than assume.
|
||||
if (!$message instanceof Message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = $message->getSymfonyEmail();
|
||||
$html = $email->getHtmlBody();
|
||||
if (!is_string($html) || $html === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only touch mail that actually renders our wordmark. Anything else --
|
||||
// password resets, share notifications, other apps -- passes through.
|
||||
if (!str_contains($html, self::LOGO_PATH_FRAGMENT)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$asset = $this->assetPath();
|
||||
if ($asset === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bytes = @file_get_contents($asset);
|
||||
if ($bytes === false || $bytes === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rewrite the absolute URL to a cid: reference. Matched on the path
|
||||
// fragment with an optional query string so a cachebuster or a change
|
||||
// of host still resolves.
|
||||
$rewritten = preg_replace(
|
||||
'#https?://[^"\']*' . preg_quote(self::LOGO_PATH_FRAGMENT, '#') . '(\?[^"\']*)?#',
|
||||
'cid:' . self::CID,
|
||||
$html,
|
||||
);
|
||||
|
||||
if (!is_string($rewritten) || $rewritten === $html) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email->embed($bytes, self::CID, 'image/png');
|
||||
$message->setHtmlBody($rewritten);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves img/debyltech-wordmark.png relative to this file, so the app works
|
||||
* from whatever apps directory Nextcloud has it in.
|
||||
*/
|
||||
private function assetPath(): ?string {
|
||||
$path = dirname(__DIR__, 2) . '/img/debyltech-wordmark.png';
|
||||
return is_readable($path) ? $path : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Injects de Byl Tech's CSS overrides into rendered Nextcloud pages.
|
||||
*
|
||||
* Currently one override: the LibreSign public signing page clips its bottom
|
||||
* action bar on iOS Safari, because ExternalApp.vue sizes #content to 100vh and
|
||||
* Safari resolves that against the large viewport (chrome hidden). See
|
||||
* css/libresign-mobile.css for the full reasoning.
|
||||
*
|
||||
* WHY A LISTENER RATHER THAN PATCHING LIBRESIGN: an app-store app carries
|
||||
* appinfo/signature.json, so editing a single byte of it raises INVALID_HASH in
|
||||
* the admin security check, and an app update wipes the directory outright
|
||||
* (Installer::downloadApp() calls Files::rmdirr on it). A stylesheet served
|
||||
* from our own app survives both, and survives Nextcloud upgrades.
|
||||
*
|
||||
* The stylesheet itself is tightly scoped to #body-public / .app-public. This
|
||||
* listener is deliberately NOT scoped further -- adding a stylesheet is
|
||||
* idempotent and cheap, and gating on which app is rendering would couple this
|
||||
* to LibreSign's route structure for no benefit. The CSS decides where it
|
||||
* applies; this only decides that it is available.
|
||||
*/
|
||||
|
||||
namespace OCA\Debyltechmail\Listener;
|
||||
|
||||
use OCA\Debyltechmail\AppInfo\Application;
|
||||
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Util;
|
||||
|
||||
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
|
||||
class DebyltechStyleListener implements IEventListener {
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof BeforeTemplateRenderedEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Never let a styling concern break page rendering. A signing page that
|
||||
// loads unstyled is recoverable; one that 500s is not.
|
||||
try {
|
||||
Util::addStyle(Application::APP_ID, 'libresign-mobile');
|
||||
} catch (\Throwable $e) {
|
||||
// Intentionally swallowed -- no logger dependency is worth adding
|
||||
// for a stylesheet, and a failure here has no user-visible effect
|
||||
// beyond the override not applying.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* de Byl Technologies-branded email template. Cloned from the Skudak
|
||||
* instance's skudakmail app (roles/podman/files/skudakmail); keep fixes to
|
||||
* the shared mechanics in sync between the two.
|
||||
*
|
||||
* Wired in via the `mail_template_class` system config value, which Nextcloud
|
||||
* checks in lib/private/Mail/Mailer.php::createEMailTemplate(). That is a
|
||||
* supported extension point -- core is not patched, so Nextcloud upgrades do
|
||||
* not clobber this.
|
||||
*
|
||||
* WHY THIS EXISTS AT ALL: LibreSign's outgoing mail is generic open-source
|
||||
* boilerplate -- subject "LibreSign: There is a file for you to sign", heading
|
||||
* "File to sign", button "Sign »filename«", and NO footer whatsoever (it never
|
||||
* calls addFooter(); verified: zero hits for addFooter in custom_apps/libresign).
|
||||
* That mail carries customer agreements to signers, so it needs to read as an
|
||||
* official de Byl Technologies LLC communication.
|
||||
*
|
||||
* DESIGN INTENT (palette from ~/src/debyltech/debyltech-com, theme
|
||||
* assets/scss/_variables.scss):
|
||||
* - Light ground, near-black text, NO coloured header band, and a pure
|
||||
* black-and-white wordmark. Transactional mail from Stripe/Linear/DocuSign
|
||||
* is likewise restrained, and a band leaves an ugly empty slab when the
|
||||
* logo is blocked (see LOGO note).
|
||||
* - $primary-color copper #bc804d on the CTA button only -- the one place
|
||||
* this template spends colour.
|
||||
* - Open Sans, matching the site's $primary-font, with the stock stack as
|
||||
* fallback.
|
||||
*
|
||||
* LOGO: served from this app's own img/ directory rather than the theming app.
|
||||
* Two reasons. (1) The theming logo is white-on-transparent because the web UI
|
||||
* and login page are dark; a white mark is invisible on this template's white
|
||||
* ground. (2) Decoupling means restyling mail can never disturb the web UI.
|
||||
* /custom_apps/<app>/img/<file> is served publicly without auth (verified).
|
||||
*
|
||||
* Note that remote images are blocked by default in Apple Mail, Gmail and
|
||||
* Outlook, and Apple Mail renders its own placeholder box rather than styled
|
||||
* alt text -- so alt styling cannot rescue it. Surviving that requires a CID
|
||||
* inline part via IMessage::attachInline(), which lives on the MESSAGE and is
|
||||
* unreachable from a template subclass. Mitigated instead by dropping the
|
||||
* band: a blocked logo now leaves plain white space, not a black slab.
|
||||
*
|
||||
* IMPLEMENTATION NOTE: font restyling is done by string-substitution against
|
||||
* the PARENT's own markup rather than by redefining it. Those properties are
|
||||
* large inline-CSS blobs with positional sprintf placeholders; copying them
|
||||
* wholesale would mean re-auditing every placeholder on every upgrade, and a
|
||||
* mismatch renders broken mail. Substitution degrades safely -- if upstream
|
||||
* changes markup the replacements no-op and mail still sends, just unstyled.
|
||||
* The header IS replaced wholesale, deliberately, because "no band" cannot be
|
||||
* expressed as a substitution; its placeholder order is documented at its
|
||||
* definition and must be kept in sync with upstream.
|
||||
*/
|
||||
|
||||
namespace OCA\Debyltechmail\Mail;
|
||||
|
||||
use OC\Mail\EMailTemplate;
|
||||
|
||||
class DebyltechEMailTemplate extends EMailTemplate {
|
||||
/** Stock Nextcloud font stack, replaced wholesale. Must match exactly. */
|
||||
private const STOCK_FONTS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif";
|
||||
|
||||
/** $primary-font, with the stock stack retained as fallback. */
|
||||
private const BRAND_FONTS = "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif";
|
||||
|
||||
private const ACCENT = '#BC804D'; // $primary-color (copper)
|
||||
private const ON_ACCENT = '#FFFFFF';
|
||||
private const INK = '#0A0A0A';
|
||||
private const MUTED = '#525252';
|
||||
private const FAINT = '#A3A3A3';
|
||||
private const RULE = '#E5E5E5';
|
||||
|
||||
private const ENTITY = 'de Byl Technologies LLC';
|
||||
private const SITE = 'https://debyltech.com';
|
||||
private const LOGO_PATH = '/custom_apps/debyltechmail/img/debyltech-wordmark.png';
|
||||
|
||||
/** Displayed width in px. The asset is 600px wide for retina. */
|
||||
private const LOGO_DISPLAY_WIDTH = 190;
|
||||
|
||||
/**
|
||||
* LibreSign's l10n wraps document names in German guillemets -- "Sign
|
||||
* »contract«" -- regardless of locale. Mapped to US curly quotes, matching
|
||||
* the ``...'' convention in the LaTeX document templates.
|
||||
*/
|
||||
private const QUOTE_MAP = ['»' => "\u{201C}", '«' => "\u{201D}"];
|
||||
|
||||
/**
|
||||
* LibreSign subject -> de Byl Tech subject. Keys are the exact English msgids
|
||||
* from custom_apps/libresign/lib/Service/MailService.php (lines 51, 87,
|
||||
* 121, 150, 172). Anything unmatched passes through untouched, so an
|
||||
* upstream string change degrades to the original subject rather than a
|
||||
* blank one.
|
||||
*/
|
||||
private const SUBJECT_MAP = [
|
||||
'LibreSign: There is a file for you to sign' => 'Document for your signature',
|
||||
'LibreSign: Changes into a file for you to sign' => 'Updated document for your signature',
|
||||
'LibreSign: A file has been signed' => 'A document has been signed',
|
||||
'LibreSign: A signature request has been canceled' => 'Signature request cancelled',
|
||||
'LibreSign: Code to sign file' => 'Your signing verification code',
|
||||
];
|
||||
|
||||
/**
|
||||
* LibreSign heading -> de Byl Tech heading. Exact English msgids from
|
||||
* MailService.php lines 53/89, 123, 152.
|
||||
*/
|
||||
private const HEADING_MAP = [
|
||||
'File to sign' => 'Review and sign',
|
||||
'File signed' => 'Document signed',
|
||||
'Signature request canceled' => 'Signature request cancelled',
|
||||
];
|
||||
|
||||
/**
|
||||
* LibreSign body copy -> de Byl Tech body copy (MailService.php lines 60, 96,
|
||||
* 174). Only the strings with NO %s interpolation are mapped; the two that
|
||||
* carry a name or filename (lines 125, 154) arrive already substituted and
|
||||
* so cannot be matched exactly -- they pass through unchanged.
|
||||
*/
|
||||
private const BODY_MAP = [
|
||||
'There is a document for you to sign. Access the link below:'
|
||||
=> 'de Byl Technologies LLC has sent you a document that requires your signature. Review it and sign using the link below.',
|
||||
'Changes have been made in a file that you have to sign. Access the link below:'
|
||||
=> 'A document awaiting your signature has been updated by de Byl Technologies LLC. Review the current version and sign using the link below.',
|
||||
'Use this code to sign the document:'
|
||||
=> 'Use this verification code to complete your signature:',
|
||||
];
|
||||
|
||||
/**
|
||||
* Template properties carrying the font stack. Listed explicitly rather
|
||||
* than discovered reflectively so an upstream rename fails loudly in
|
||||
* testing instead of silently skipping a block.
|
||||
*/
|
||||
private const STYLED_PARTS = [
|
||||
'head', 'tail', 'heading', 'bodyBegin', 'bodyText',
|
||||
'listBegin', 'listItem', 'listEnd', 'buttonGroup', 'button',
|
||||
'bodyEnd', 'footer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Own flag, deliberately NOT the parent's $footerAdded.
|
||||
*
|
||||
* Message::useTemplate() (lib/private/Mail/Message.php:289-296) calls
|
||||
* renderText() at :291 BEFORE renderHtml() at :293, and renderText() sets
|
||||
* $footerAdded = true. Guarding footer injection on !$footerAdded therefore
|
||||
* never fires on the real send path -- the footer silently vanished from
|
||||
* every mail while a renderHtml()-only test passed. Both renderers below
|
||||
* call inject() and this flag makes the second call inert.
|
||||
*/
|
||||
private bool $brandFooterInjected = false;
|
||||
|
||||
public function __construct(
|
||||
\OCP\Defaults $themingDefaults,
|
||||
\OCP\IURLGenerator $urlGenerator,
|
||||
\OCP\L10N\IFactory $l10nFactory,
|
||||
?int $logoWidth,
|
||||
?int $logoHeight,
|
||||
string $emailId,
|
||||
array $data,
|
||||
) {
|
||||
$this->applyBrandStyling();
|
||||
|
||||
// Must run AFTER the substitutions: the parent constructor copies
|
||||
// $this->head into $htmlBody as its first act, so restyling head
|
||||
// afterwards would leave the already-emitted copy untouched.
|
||||
parent::__construct(
|
||||
$themingDefaults,
|
||||
$urlGenerator,
|
||||
$l10nFactory,
|
||||
$logoWidth,
|
||||
$logoHeight,
|
||||
$emailId,
|
||||
$data,
|
||||
);
|
||||
}
|
||||
|
||||
private function applyBrandStyling(): void {
|
||||
foreach (self::STYLED_PARTS as $part) {
|
||||
if (!property_exists($this, $part)) {
|
||||
continue;
|
||||
}
|
||||
$this->$part = str_replace(self::STOCK_FONTS, self::BRAND_FONTS, $this->$part);
|
||||
}
|
||||
|
||||
// Light, tightly tracked headings, carried over from the Skudak template.
|
||||
$this->heading = str_replace(
|
||||
'font-size:24px;font-weight:400',
|
||||
'font-size:26px;font-weight:300;letter-spacing:-0.02em',
|
||||
$this->heading,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites LibreSign's subjects. Called by LibreSign on the TEMPLATE
|
||||
* (MailService.php:51 etc.), not on the message, which is what makes this
|
||||
* interceptable at all -- Message::useTemplate() later pulls the result via
|
||||
* renderSubject(). Prefixed with the entity so the sender is unambiguous in
|
||||
* an inbox list.
|
||||
*/
|
||||
public function setSubject(string $subject): void {
|
||||
$mapped = self::SUBJECT_MAP[$subject] ?? null;
|
||||
parent::setSubject(
|
||||
$mapped === null ? $subject : self::ENTITY . ' — ' . $mapped,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stock header wholesale: no coloured band, wordmark centred
|
||||
* on white.
|
||||
*
|
||||
* Does NOT use the parent's $header property or its placeholder order --
|
||||
* this is independent markup, so upstream changes to $header cannot break
|
||||
* it (and equally cannot improve it). $logoWidth/$logoHeight from the
|
||||
* Mailer are ignored on purpose: they are clamped to MAX_LOGO_SIZE = 105
|
||||
* (lib/private/Mail/Mailer.php:60), which is too small for a wordmark to
|
||||
* be legible.
|
||||
*/
|
||||
public function addHeader(): void {
|
||||
if ($this->headerAdded) {
|
||||
return;
|
||||
}
|
||||
$this->headerAdded = true;
|
||||
|
||||
$logoUrl = $this->urlGenerator->getAbsoluteURL(self::LOGO_PATH);
|
||||
$alt = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8');
|
||||
$w = self::LOGO_DISPLAY_WIDTH;
|
||||
$fonts = self::BRAND_FONTS;
|
||||
$ink = self::INK;
|
||||
|
||||
$this->htmlBody .= <<<HTML
|
||||
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:left;vertical-align:top;width:100%">
|
||||
<tbody><tr style="padding:0;text-align:left;vertical-align:top">
|
||||
<td align="center" style="border-collapse:collapse!important;margin:0;padding:40px 30px 28px 30px;text-align:center;vertical-align:top">
|
||||
<img src="{$logoUrl}" alt="{$alt}" width="{$w}" style="-ms-interpolation-mode:bicubic;border:0;clear:both;display:block;margin:0 auto;outline:0;text-decoration:none;width:{$w}px;max-width:{$w}px;height:auto;color:{$ink};font-family:{$fonts};font-size:22px;font-weight:300;letter-spacing:-0.02em"/>
|
||||
</td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both renderers inject the footer -- see $brandFooterInjected.
|
||||
*
|
||||
* Mirrors the parent's own guard structure (renderHtml at
|
||||
* lib/private/Mail/EMailTemplate.php:643, renderText at :656): close the
|
||||
* body, append $tail, flip $footerAdded. The brand block goes in before
|
||||
* $tail.
|
||||
*/
|
||||
public function renderHtml(): string {
|
||||
$this->injectBrandFooter();
|
||||
return parent::renderHtml();
|
||||
}
|
||||
|
||||
public function renderText(): string {
|
||||
$this->injectBrandFooter();
|
||||
return parent::renderText();
|
||||
}
|
||||
|
||||
private function injectBrandFooter(): void {
|
||||
if ($this->brandFooterInjected || $this->footerAdded) {
|
||||
return;
|
||||
}
|
||||
$this->brandFooterInjected = true;
|
||||
|
||||
// Close the body ourselves so the footer lands INSIDE the layout
|
||||
// rather than after it. The parent's render methods are then a no-op
|
||||
// for body closing and only append $tail.
|
||||
$this->ensureBodyIsClosed();
|
||||
$this->htmlBody .= $this->brandFooterHtml();
|
||||
$this->plainBody .= $this->brandFooterText();
|
||||
}
|
||||
|
||||
private function brandFooterHtml(): string {
|
||||
$year = date('Y');
|
||||
$entity = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8');
|
||||
$fonts = self::BRAND_FONTS;
|
||||
$site = self::SITE;
|
||||
[$muted, $faint, $rule, $ink] = [self::MUTED, self::FAINT, self::RULE, self::INK];
|
||||
|
||||
// Table-based and fully inline-styled: <style> blocks, flex and grid
|
||||
// are stripped or unsupported across Outlook and most webmail.
|
||||
return <<<HTML
|
||||
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:left;vertical-align:top;width:100%">
|
||||
<tbody><tr style="padding:0;text-align:left;vertical-align:top">
|
||||
<td align="center" style="border-collapse:collapse!important;margin:0;padding:0 30px 44px 30px;text-align:center;vertical-align:top">
|
||||
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:center;width:100%;max-width:550px">
|
||||
<tbody>
|
||||
<tr><td style="border-collapse:collapse!important;border-top:1px solid {$rule};font-size:0;line-height:0;height:1px;margin:0;padding:0"> </td></tr>
|
||||
<tr><td align="center" style="border-collapse:collapse!important;color:{$muted};font-family:{$fonts};font-size:13px;font-weight:400;line-height:1.6;margin:0;padding:22px 0 0 0;text-align:center">
|
||||
This is an official document-signing request from <strong style="color:{$ink};font-weight:600">{$entity}</strong>.<br/>
|
||||
Nothing is signed unless you open the document and complete it yourself. If you were not expecting this, you can safely ignore it.
|
||||
</td></tr>
|
||||
<tr><td align="center" style="border-collapse:collapse!important;color:{$muted};font-family:{$fonts};font-size:13px;font-weight:400;line-height:1.6;margin:0;padding:16px 0 0 0;text-align:center">
|
||||
<a href="{$site}/legal/privacy" style="color:{$muted};text-decoration:underline">Privacy Policy</a>
|
||||
 · 
|
||||
<a href="{$site}/legal/tos" style="color:{$muted};text-decoration:underline">Terms of Use</a>
|
||||
 · 
|
||||
<a href="{$site}" style="color:{$muted};text-decoration:underline">debyltech.com</a>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="border-collapse:collapse!important;color:{$faint};font-family:{$fonts};font-size:12px;font-weight:400;line-height:1.6;margin:0;padding:16px 0 0 0;text-align:center">
|
||||
© {$year} {$entity}. All rights reserved.<br/>
|
||||
Automated message — please do not reply to this address.
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function brandFooterText(): string {
|
||||
$year = date('Y');
|
||||
$entity = self::ENTITY;
|
||||
$site = self::SITE;
|
||||
|
||||
return <<<TEXT
|
||||
|
||||
--
|
||||
This is an official document-signing request from {$entity}.
|
||||
Nothing is signed unless you open the document and complete it yourself.
|
||||
If you were not expecting this, you can safely ignore it.
|
||||
|
||||
Privacy Policy: {$site}/legal/privacy
|
||||
Terms of Use: {$site}/legal/tos
|
||||
|
||||
© {$year} {$entity}. All rights reserved.
|
||||
Automated message — please do not reply to this address.
|
||||
|
||||
TEXT;
|
||||
}
|
||||
|
||||
private function tidyQuotes(string $text): string {
|
||||
return strtr($text, self::QUOTE_MAP);
|
||||
}
|
||||
|
||||
// Signatures below mirror the parent EXACTLY. $plainTitle/$plainText are
|
||||
// deliberately untyped there (they accept string|bool -- false suppresses
|
||||
// the plain-text variant), and narrowing a parameter type in an override
|
||||
// is a fatal error in PHP.
|
||||
public function addHeading(string $title, $plainTitle = ''): void {
|
||||
$mapped = self::HEADING_MAP[$title] ?? $this->tidyQuotes($title);
|
||||
parent::addHeading(
|
||||
$mapped,
|
||||
is_string($plainTitle) && $plainTitle !== ''
|
||||
? (self::HEADING_MAP[$plainTitle] ?? $this->tidyQuotes($plainTitle))
|
||||
: $plainTitle,
|
||||
);
|
||||
}
|
||||
|
||||
public function addBodyText(string $text, $plainText = ''): void {
|
||||
$mapped = self::BODY_MAP[$text] ?? $this->tidyQuotes($text);
|
||||
parent::addBodyText(
|
||||
$mapped,
|
||||
is_string($plainText) && $plainText !== ''
|
||||
? (self::BODY_MAP[$plainText] ?? $this->tidyQuotes($plainText))
|
||||
: $plainText,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reimplemented for two reasons: the accent colour, and a fixed label.
|
||||
*
|
||||
* LibreSign builds "Sign »%s«" with the raw filename
|
||||
* (MailService.php:64,100). Real documents here are named things like
|
||||
* acme-master-services-agreement-rev3, which makes an ungainly button and
|
||||
* leaks the document name to anyone who sees the inbox preview. Replaced
|
||||
* with a fixed call to action; the document is identified on the landing
|
||||
* page behind the link.
|
||||
*
|
||||
* Mirrors the parent's vsprintf argument order exactly:
|
||||
* [$color, $color, $url, $color, $textColor, $textColor, $text].
|
||||
* Kept in sync with parent::addBodyButton() -- if that changes upstream,
|
||||
* this needs revisiting.
|
||||
*/
|
||||
public function addBodyButton(string $text, string $url, $plainText = ''): void {
|
||||
if ($this->footerAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ensureBodyIsOpened();
|
||||
$this->ensureBodyListClosed();
|
||||
|
||||
$label = $this->buttonLabelFor($text);
|
||||
if ($plainText === '') {
|
||||
$plainText = $label;
|
||||
} elseif (is_string($plainText)) {
|
||||
$plainText = $this->tidyQuotes($plainText);
|
||||
}
|
||||
|
||||
$this->htmlBody .= vsprintf($this->button, [
|
||||
self::ACCENT,
|
||||
self::ACCENT,
|
||||
$url,
|
||||
self::ACCENT,
|
||||
self::ON_ACCENT,
|
||||
self::ON_ACCENT,
|
||||
htmlspecialchars($label, ENT_QUOTES, 'UTF-8'),
|
||||
]);
|
||||
|
||||
if ($plainText !== false) {
|
||||
$this->plainBody .= $plainText . ': ';
|
||||
}
|
||||
$this->plainBody .= $url . PHP_EOL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps LibreSign's filename-bearing labels onto fixed calls to action.
|
||||
* Matched on the stable leading verb rather than the whole string, since
|
||||
* the tail is a filename. Unknown labels pass through with quotes tidied.
|
||||
*/
|
||||
private function buttonLabelFor(string $text): string {
|
||||
if (str_starts_with($text, 'Sign ')) {
|
||||
return 'Review document';
|
||||
}
|
||||
if (str_starts_with($text, 'View signed file')) {
|
||||
return 'View signed document';
|
||||
}
|
||||
return $this->tidyQuotes($text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
---
|
||||
# de Byl Technologies Nextcloud (cloud.debyltech.com).
|
||||
#
|
||||
# Cloned from containers/skudak/cloud.yml, which carries the full reasoning for
|
||||
# nearly every task below -- read the matching comment there before changing
|
||||
# one here. Comments in this file cover only where the two instances differ.
|
||||
#
|
||||
# Differences from Skudak, by design:
|
||||
# - Fresh install: NEXTCLOUD_ADMIN_* makes the first deploy install
|
||||
# unattended, and LibreSign is installed from the app store rather than
|
||||
# assumed present.
|
||||
# - No Group Folders. Registration stays off, matching Skudak's live state:
|
||||
# every account, staff and customer alike, is created by the admin, with
|
||||
# customers in per-customer groups.
|
||||
# - Outbound mail is AWS SES SMTP (noreply@debyltech.com), set here via occ
|
||||
# so it lives in git rather than only in the admin UI.
|
||||
# - Backups go to personal iDrive e2 via TrueNAS -- see the backup include
|
||||
# at the bottom.
|
||||
- name: create required debyltech cloud volumes
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: "{{ podman_subuid.stdout }}"
|
||||
group: "{{ podman_subuid.stdout }}"
|
||||
mode: 0755
|
||||
notify: restorecon podman
|
||||
loop:
|
||||
- "{{ cloud_debyltech_path }}/apps"
|
||||
- "{{ cloud_debyltech_path }}/config"
|
||||
- "{{ cloud_debyltech_path }}/data"
|
||||
- "{{ cloud_debyltech_path }}/mysql"
|
||||
- "{{ cloud_debyltech_path }}/scripts"
|
||||
- "{{ cloud_debyltech_path }}/redis"
|
||||
|
||||
- name: unshare chown the debyltech cloud volumes
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
changed_when: false
|
||||
ansible.builtin.command: |
|
||||
podman unshare chown -R 33:33 {{ cloud_debyltech_path }}/apps {{ cloud_debyltech_path }}/data {{ cloud_debyltech_path }}/config
|
||||
|
||||
- name: flush handlers
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- import_tasks: podman/podman-check.yml
|
||||
vars:
|
||||
container_name: debyltech-cloud-db
|
||||
container_image: "{{ db_image }}"
|
||||
|
||||
- name: create debyltech-cloud-db container
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
containers.podman.podman_container:
|
||||
name: debyltech-cloud-db
|
||||
image: "{{ db_image }}"
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
network:
|
||||
- shared
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: "{{ cloud_debyltech_db_root_pass }}"
|
||||
MYSQL_DATABASE: dtcloud
|
||||
MYSQL_PASSWORD: "{{ cloud_debyltech_db_pass }}"
|
||||
MYSQL_USER: dtcloud
|
||||
volumes:
|
||||
- "{{ cloud_debyltech_path }}/mysql:/var/lib/mysql"
|
||||
|
||||
- name: create systemd startup job for debyltech-cloud-db
|
||||
include_tasks: podman/systemd-generate.yml
|
||||
vars:
|
||||
container_name: debyltech-cloud-db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis: distributed cache + file locking. MUST exist before debyltech-cloud
|
||||
# below -- see the Skudak equivalent for why.
|
||||
- name: template debyltech cloud redis config
|
||||
become: true
|
||||
ansible.builtin.template:
|
||||
src: nextcloud/redis-debyltech.conf.j2
|
||||
dest: "{{ cloud_debyltech_path }}/redis/redis.conf"
|
||||
owner: "{{ podman_subuid.stdout }}"
|
||||
group: "{{ podman_subuid.stdout }}"
|
||||
mode: 0640
|
||||
notify: restorecon podman
|
||||
no_log: true
|
||||
|
||||
- name: flush handlers
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: unshare chown the debyltech redis config to the redis uid
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
changed_when: false
|
||||
ansible.builtin.command: >
|
||||
podman unshare chown 999:1000 {{ cloud_debyltech_path }}/redis/redis.conf
|
||||
|
||||
- import_tasks: podman/podman-check.yml
|
||||
vars:
|
||||
container_name: debyltech-cloud-redis
|
||||
container_image: "{{ redis_image }}"
|
||||
|
||||
- name: create debyltech-cloud-redis container
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
containers.podman.podman_container:
|
||||
name: debyltech-cloud-redis
|
||||
image: "{{ redis_image }}"
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
network:
|
||||
- shared
|
||||
volumes:
|
||||
- "{{ cloud_debyltech_path }}/redis/redis.conf:/etc/redis/redis.conf:ro"
|
||||
command: redis-server /etc/redis/redis.conf
|
||||
|
||||
- name: create systemd startup job for debyltech-cloud-redis
|
||||
include_tasks: podman/systemd-generate.yml
|
||||
vars:
|
||||
container_name: debyltech-cloud-redis
|
||||
|
||||
- import_tasks: podman/podman-check.yml
|
||||
vars:
|
||||
container_name: debyltech-cloud
|
||||
container_image: "{{ image }}"
|
||||
|
||||
- name: create debyltech cloud container
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
containers.podman.podman_container:
|
||||
name: debyltech-cloud
|
||||
image: "{{ image }}"
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
network:
|
||||
- shared
|
||||
env:
|
||||
MYSQL_PASSWORD: "{{ cloud_debyltech_db_pass }}"
|
||||
MYSQL_DATABASE: dtcloud
|
||||
MYSQL_HOST: debyltech-cloud-db
|
||||
MYSQL_USER: dtcloud
|
||||
# Read by the entrypoint ONLY on first start against an empty config
|
||||
# volume, to run the install unattended; ignored on every start after.
|
||||
NEXTCLOUD_ADMIN_USER: admin
|
||||
NEXTCLOUD_ADMIN_PASSWORD: "{{ cloud_debyltech_admin_pass }}"
|
||||
NEXTCLOUD_TRUSTED_DOMAINS: "{{ cloud_debyltech_server_name }}"
|
||||
PHP_MEMORY_LIMIT: 1024M
|
||||
PHP_UPLOAD_LIMIT: 512M
|
||||
LC_ALL: C.UTF-8
|
||||
LANG: C.UTF-8
|
||||
REDIS_HOST: debyltech-cloud-redis
|
||||
REDIS_HOST_PORT: "6379"
|
||||
REDIS_HOST_PASSWORD: "{{ cloud_debyltech_redis_pass }}"
|
||||
volumes:
|
||||
- "{{ cloud_debyltech_path }}/apps:/var/www/html/custom_apps"
|
||||
- "{{ cloud_debyltech_path }}/data:/var/www/html/data"
|
||||
- "{{ cloud_debyltech_path }}/config:/var/www/html/config"
|
||||
ports:
|
||||
- "8091:80"
|
||||
|
||||
- name: create systemd startup job for debyltech-cloud
|
||||
include_tasks: podman/systemd-generate.yml
|
||||
vars:
|
||||
container_name: debyltech-cloud
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LibreSign
|
||||
- name: install libresign runtime dependencies in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command:
|
||||
cmd: >
|
||||
podman exec -u 0 debyltech-cloud
|
||||
sh -c "apt-get update && apt-get install -y --no-install-recommends
|
||||
poppler-utils ghostscript && rm -rf /var/lib/apt/lists/*"
|
||||
register: libresign_deps
|
||||
changed_when: "'is already the newest version' not in libresign_deps.stdout"
|
||||
|
||||
# On the FIRST deploy this also waits out the unattended install, which takes
|
||||
# noticeably longer than a restart -- hence the larger budget than Skudak's.
|
||||
- name: wait for nextcloud to be ready in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ status --output=json
|
||||
register: debyltech_occ_ready
|
||||
# String match, not from_json: before the install finishes occ can print a
|
||||
# plain-text warning ahead of the JSON, and a parse error would abort the
|
||||
# retry loop instead of waiting. Skudak's bare 'installed' check would also
|
||||
# match "installed":false, which is exactly the state being waited out here.
|
||||
until: >-
|
||||
debyltech_occ_ready.rc == 0
|
||||
and '"installed":true' in debyltech_occ_ready.stdout
|
||||
retries: 60
|
||||
delay: 5
|
||||
changed_when: false
|
||||
|
||||
# LibreSign is PINNED (libresign_version / libresign_sha256 in tasks/main.yml)
|
||||
# and installed from the upstream GitHub release, NOT `occ app:install`, which
|
||||
# always takes whatever the app store has that day. On 2026-09-28 that was a
|
||||
# same-day 14.2.3 whose tarball shipped without appinfo/install-*.json -- the
|
||||
# maintainer-signed metadata LibreSign verifies its java/pdftk/jsignpdf
|
||||
# downloads against -- so configure:check failed all three on a clean install.
|
||||
#
|
||||
# Upgrading: bump both pins together (the sha256 is on the GitHub release
|
||||
# asset) and deploy; the tree is replaced and `occ upgrade` runs the app's
|
||||
# migrations. Downgrading is refused below: Nextcloud does not support it, and
|
||||
# the only way back is removing the app, which discards its config and CA.
|
||||
- name: read installed libresign version in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ config:app:get libresign installed_version
|
||||
register: libresign_installed
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: refuse to downgrade libresign in debyltech-cloud
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
LibreSign {{ libresign_installed.stdout }} is installed but the pin is
|
||||
{{ libresign_version }}. Nextcloud cannot downgrade an app in place --
|
||||
raise the pin, or remove the app deliberately if nothing has been signed.
|
||||
when:
|
||||
- libresign_installed.rc == 0
|
||||
- libresign_installed.stdout is version(libresign_version, '>')
|
||||
|
||||
- name: install pinned libresign release in debyltech-cloud
|
||||
when: libresign_installed.rc != 0 or libresign_installed.stdout != libresign_version
|
||||
block:
|
||||
- name: fetch pinned libresign release
|
||||
become: true
|
||||
ansible.builtin.get_url:
|
||||
url: "https://github.com/LibreSign/libresign/releases/download/v{{ libresign_version }}/libresign-v{{ libresign_version }}.tar.gz"
|
||||
dest: "{{ cloud_debyltech_path }}/scripts/libresign-v{{ libresign_version }}.tar.gz"
|
||||
checksum: "sha256:{{ libresign_sha256 }}"
|
||||
mode: 0644
|
||||
|
||||
- name: remove previous libresign app tree
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ cloud_debyltech_path }}/apps/libresign"
|
||||
state: absent
|
||||
|
||||
- name: unpack pinned libresign release into custom_apps
|
||||
become: true
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ cloud_debyltech_path }}/scripts/libresign-v{{ libresign_version }}.tar.gz"
|
||||
dest: "{{ cloud_debyltech_path }}/apps/"
|
||||
remote_src: true
|
||||
# Unpacked as root the files keep the tarball's owners, which lie
|
||||
# outside the podman user's subuid range, so the unshare chown below
|
||||
# is refused. Same two-step as the debyltechmail copy.
|
||||
owner: "{{ podman_subuid.stdout }}"
|
||||
group: "{{ podman_subuid.stdout }}"
|
||||
notify: restorecon podman
|
||||
|
||||
- name: unshare chown the libresign app tree
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
changed_when: false
|
||||
ansible.builtin.command: >
|
||||
podman unshare chown -R 33:33 {{ cloud_debyltech_path }}/apps/libresign
|
||||
|
||||
- name: flush handlers
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
# Only an in-place upgrade needs this; a first install is handled by the
|
||||
# app:enable below.
|
||||
- name: run libresign migrations in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud php occ upgrade
|
||||
when: libresign_installed.rc == 0
|
||||
|
||||
- name: ensure libresign app is enabled in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ app:enable libresign
|
||||
register: libresign_enable
|
||||
changed_when: "'already enabled' not in libresign_enable.stdout"
|
||||
|
||||
# 14.2.x's downloader does not create its own target directories: on a fresh
|
||||
# appdata every java/pdftk download fails with "Directory ... does not exist
|
||||
# for sink value". Creating them first is harmless once they exist.
|
||||
- name: pre-create libresign binary directories in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
changed_when: false
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud sh -c
|
||||
'd=$(ls -d /var/www/html/data/appdata_*/libresign) &&
|
||||
mkdir -p "$d/x86_64/linux/java" "$d/x86_64/pdftk"'
|
||||
|
||||
# "Finished with success" is printed even when every download failed, so this
|
||||
# check only catches the command itself falling over. The real gate is the
|
||||
# configure:check verify task below, which hashes each binary against the
|
||||
# release's signed metadata.
|
||||
- name: install libresign java/pdftk/jsignpdf binaries in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ libresign:install --java --pdftk --jsignpdf
|
||||
register: libresign_install
|
||||
changed_when: false
|
||||
failed_when: "'Finished with success' not in libresign_install.stdout"
|
||||
|
||||
- name: check whether libresign root certificate is configured
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ libresign:configure:check --certificate
|
||||
register: libresign_cert_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
# Guarded: re-running would mint a new root CA and orphan every certificate
|
||||
# already issued. No --ou -- see skudak/cloud.yml.
|
||||
- name: generate libresign root certificate for debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ libresign:configure:openssl
|
||||
--cn="{{ libresign_debyltech_cert_cn }}"
|
||||
-o "{{ libresign_debyltech_cert_o }}"
|
||||
-c "{{ libresign_debyltech_cert_c }}"
|
||||
-s "{{ libresign_debyltech_cert_st }}"
|
||||
-l "{{ libresign_debyltech_cert_l }}"
|
||||
when: "'error' in libresign_cert_check.stdout"
|
||||
changed_when: true
|
||||
|
||||
# Signers are mostly customers WITHOUT an account, reached by emailed
|
||||
# invitation; the ID-document gate would leave them unable to sign at all.
|
||||
- name: relax libresign identification-document gate 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 identification_documents --value=0
|
||||
register: libresign_ident
|
||||
changed_when: "'is now set to' in libresign_ident.stdout"
|
||||
|
||||
# Must be exactly GRAPHIC_ONLY -- see skudak/cloud.yml.
|
||||
- name: use signature-only stamp in debyltech-cloud libresign
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ config:app:set libresign signature_render_mode --value=GRAPHIC_ONLY
|
||||
register: libresign_render
|
||||
changed_when: "'is now set to' in libresign_render.stdout"
|
||||
|
||||
# Lets account-owned emails be added as signers. NEVER set the _email variant
|
||||
# of this key to 'no' -- see skudak/cloud.yml.
|
||||
- name: allow account-owned emails as libresign signers in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ config:app:set core
|
||||
shareapi_restrict_user_enumeration_full_match --value=no
|
||||
register: debyltech_enum_fullmatch
|
||||
changed_when: "'is now set to' in debyltech_enum_fullmatch.stdout"
|
||||
|
||||
- name: drop libresign validation QR code from signed-PDF footer 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 write_qrcode_on_footer
|
||||
--value=0 --type=boolean
|
||||
register: libresign_qr
|
||||
changed_when: "'is now set to' in libresign_qr.stdout"
|
||||
|
||||
- name: verify libresign configuration in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ libresign:configure:check
|
||||
register: libresign_verify
|
||||
changed_when: false
|
||||
# Double backslashes: Jinja unescapes string literals, so a single '\b'
|
||||
# becomes a BACKSPACE character and this could never match -- which is
|
||||
# how a check reporting three errors passed clean on 2026-09-28.
|
||||
failed_when: libresign_verify.stdout is search('\\berror\\b')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background jobs. A fresh install defaults to AJAX mode, which only runs jobs
|
||||
# while someone has the web UI open -- LibreSign's queued signature mail and
|
||||
# every cleanup job would stall. The cloud-cron timer included below drives
|
||||
# cron.php; this tells Nextcloud to expect it.
|
||||
- name: set debyltech-cloud background jobs to cron
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ config:app:set core backgroundjobs_mode --value=cron
|
||||
register: debyltech_bgjobs
|
||||
changed_when: "'is now set to' in debyltech_bgjobs.stdout"
|
||||
|
||||
- name: disable nextcloud signup link in debyltech-cloud config
|
||||
become: true
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ cloud_debyltech_path }}/config/config.php"
|
||||
regexp: "^\\s*'simpleSignUpLink\\.shown'\\s*=>"
|
||||
line: " 'simpleSignUpLink.shown' => false,"
|
||||
insertbefore: '^\);'
|
||||
create: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Branding: debyltechmail, cloned from skudakmail. custom_apps is a persisted
|
||||
# bind mount, so only enabling and config need reasserting.
|
||||
# Owned directly by the HOST uid that rootless podman maps www-data (33) to --
|
||||
# subuid start + 32, since container uid 1 is the first subuid. Skudak copies
|
||||
# as the subuid and then `podman unshare chown`s, which flips ownership back
|
||||
# and forth so the copy reports changed on every run; here that would also
|
||||
# re-import the theming logos below every time.
|
||||
- name: deploy debyltechmail email-template app to debyltech-cloud
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: debyltechmail/
|
||||
dest: "{{ cloud_debyltech_path }}/apps/debyltechmail/"
|
||||
owner: "{{ podman_subuid.stdout | int + 32 }}"
|
||||
group: "{{ podman_subuid.stdout | int + 32 }}"
|
||||
mode: 0644
|
||||
directory_mode: 0755
|
||||
register: debyltechmail_copy
|
||||
notify: restorecon podman
|
||||
|
||||
- name: enable debyltechmail app in debyltech-cloud
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud php occ app:enable debyltechmail
|
||||
register: debyltechmail_enable
|
||||
changed_when: "'already enabled' not in debyltechmail_enable.stdout"
|
||||
|
||||
# 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
|
||||
# single-quoted into the shell, so the backslashes in mail_template_class pass
|
||||
# through literally. overwrite.cli.url is what LibreSign invitation links and
|
||||
# mail asset URLs are built from when sent by a background job.
|
||||
- name: set debyltech-cloud system config
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.shell: |
|
||||
set -o pipefail
|
||||
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
|
||||
cur=$(occ config:system:get {{ item.k }} || true)
|
||||
if [ "$cur" != {{ item.v | quote }} ]; then
|
||||
occ config:system:set {{ item.k }} --value={{ item.v | quote }} --type={{ item.t | default('string') }} >/dev/null
|
||||
echo CHANGED
|
||||
fi
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: debyltech_sysconfig
|
||||
changed_when: "'CHANGED' in debyltech_sysconfig.stdout"
|
||||
loop:
|
||||
- {k: overwrite.cli.url, v: "https://{{ cloud_debyltech_server_name }}"}
|
||||
- {k: overwriteprotocol, v: https}
|
||||
- {k: loglevel, v: "2", t: integer}
|
||||
- {k: log_rotate_size, v: "10485760", t: integer}
|
||||
- {k: mail_template_class, v: "OCA\\Debyltechmail\\Mail\\DebyltechEMailTemplate"}
|
||||
- {k: mail_smtpmode, v: smtp}
|
||||
- {k: mail_smtphost, v: "{{ cloud_debyltech_smtp_host }}"}
|
||||
- {k: mail_smtpport, v: "{{ cloud_debyltech_smtp_port }}", t: integer}
|
||||
- {k: mail_smtpsecure, v: ssl}
|
||||
- {k: mail_smtpauth, v: "true", t: boolean}
|
||||
- {k: mail_from_address, v: noreply}
|
||||
- {k: mail_domain, v: debyltech.com}
|
||||
loop_control:
|
||||
label: "{{ item.k }}"
|
||||
|
||||
- name: set debyltech-cloud SES SMTP credentials
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.shell: |
|
||||
set -o pipefail
|
||||
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
|
||||
cur=$(occ config:system:get {{ item.k }} || true)
|
||||
if [ "$cur" != {{ item.v | quote }} ]; then
|
||||
occ config:system:set {{ item.k }} --value={{ item.v | quote }} >/dev/null
|
||||
echo CHANGED
|
||||
fi
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: debyltech_smtp_creds
|
||||
changed_when: "'CHANGED' in debyltech_smtp_creds.stdout"
|
||||
loop:
|
||||
- {k: mail_smtpname, v: "{{ cloud_debyltech_smtp_user }}"}
|
||||
- {k: mail_smtppassword, v: "{{ cloud_debyltech_smtp_pass }}"}
|
||||
loop_control:
|
||||
label: "{{ item.k }}"
|
||||
no_log: true
|
||||
|
||||
# Compared first: theming:config prints "Updated" even when nothing changed.
|
||||
- name: set debyltech-cloud theming
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.shell: |
|
||||
set -o pipefail
|
||||
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
|
||||
cur=$(occ config:app:get theming {{ item.k }} || true)
|
||||
if [ "$cur" != {{ item.v | quote }} ]; then
|
||||
occ theming:config {{ item.k }} {{ item.v | quote }} >/dev/null
|
||||
echo CHANGED
|
||||
fi
|
||||
args:
|
||||
executable: /bin/bash
|
||||
loop:
|
||||
- {k: name, v: "de Byl Technologies"}
|
||||
- {k: slogan, v: "Hardware, firmware and design services"}
|
||||
- {k: url, v: "https://debyltech.com"}
|
||||
- {k: primary_color, v: "{{ theming_debyltech_primary }}"}
|
||||
- {k: background_color, v: "{{ theming_debyltech_background }}"}
|
||||
register: debyltech_theming
|
||||
changed_when: "'CHANGED' in debyltech_theming.stdout"
|
||||
loop_control:
|
||||
label: "{{ item.k }}"
|
||||
|
||||
# The web UI logos ship inside the debyltechmail app (the white variants; the
|
||||
# ink wordmark is the mail one). theming:config re-imports the file on every
|
||||
# call, so it runs only when the app's files changed or no logo is set yet.
|
||||
# `logo` is the wide wordmark on the login page; `logoheader` is the square
|
||||
# mark in the top bar, where a wordmark would shrink to illegibility.
|
||||
- name: check debyltech-cloud theming logos
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud
|
||||
php occ config:app:get theming {{ item }}Mime
|
||||
loop: [logo, logoheader]
|
||||
register: debyltech_logo_mime
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: set debyltech-cloud theming logos
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: >
|
||||
podman exec -u www-data debyltech-cloud php occ theming:config {{ item.item }}
|
||||
/var/www/html/custom_apps/debyltechmail/img/{{ logo_files[item.item] }}
|
||||
loop: "{{ debyltech_logo_mime.results }}"
|
||||
when: debyltechmail_copy is changed or item.rc != 0 or item.stdout == ''
|
||||
vars:
|
||||
logo_files:
|
||||
logo: debyltech-wordmark-white.png
|
||||
logoheader: debyltech-mark-white.png
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
# Fails the play if branding, the LibreSign settings above, or Redis locking
|
||||
# have silently regressed -- see skudak/cloud.yml.
|
||||
- name: template debyltechmail verification script
|
||||
become: true
|
||||
ansible.builtin.template:
|
||||
src: nextcloud/debyltechmail-verify.php.j2
|
||||
dest: "{{ cloud_debyltech_path }}/scripts/debyltechmail-verify.php"
|
||||
owner: "{{ podman_subuid.stdout }}"
|
||||
group: "{{ podman_subuid.stdout }}"
|
||||
mode: 0644
|
||||
notify: restorecon podman
|
||||
|
||||
- name: verify debyltech mail branding is live
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.shell: >
|
||||
set -o pipefail;
|
||||
podman exec -i -u www-data debyltech-cloud php
|
||||
< {{ cloud_debyltech_path }}/scripts/debyltechmail-verify.php
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: debyltechmail_verify
|
||||
changed_when: false
|
||||
|
||||
- include_tasks: containers/cloud-cron.yml
|
||||
vars:
|
||||
cron_name: debyltech-cloud
|
||||
cron_container: debyltech-cloud
|
||||
cron_script_path: /usr/local/bin/debyltech-cloud-cron.sh
|
||||
|
||||
# BUSINESS data that DELIBERATELY reaches personal storage -- the opposite of
|
||||
# Skudak, and on purpose: de Byl Technologies LLC is the owner's own company.
|
||||
#
|
||||
# Chain: this rsync -> TrueNAS /mnt/glacier/debyltechcloud (05:00 ZFS
|
||||
# snapshot) -> the personal "iDrive E2 Backup" cloud-sync task, which pushes
|
||||
# /mnt/glacier to the personal iDrive e2 bucket. Unlike /skudakcloud/**,
|
||||
# /skudakapps/** and /skudakgit/**, there is NO exclude for /debyltechcloud/**
|
||||
# on that task, and there must not be one -- that inclusion IS the offsite
|
||||
# copy. If that ever changes, give it its own cloud-sync task first.
|
||||
- include_tasks: containers/cloud-backup.yml
|
||||
vars:
|
||||
backup_name: debyltech-cloud
|
||||
data_path: "{{ cloud_debyltech_path }}/data"
|
||||
config_path: "{{ cloud_debyltech_path }}/config"
|
||||
db_container: debyltech-cloud-db
|
||||
ssh_key_path: /etc/ssh/backup_keys/debyltech-cloud
|
||||
ssh_key_content: "{{ cloud_debyltech_backup_ssh_key }}"
|
||||
ssh_user: debyltechcloud
|
||||
remote_path: /mnt/glacier/debyltechcloud
|
||||
script_path: /usr/local/bin/debyltech-cloud-backup.sh
|
||||
# data/ is mode 770 here too; see skudak/cloud.yml.
|
||||
backup_rsync_extra_args: "--chmod=Du=rwx,Dgo=rx"
|
||||
# Between the 04:00 personal and 04:30 Skudak runs, before the 05:00
|
||||
# TrueNAS snapshot.
|
||||
backup_oncalendar: "*-*-* 04:15:00"
|
||||
@@ -405,7 +405,10 @@
|
||||
php occ libresign:configure:check
|
||||
register: libresign_verify
|
||||
changed_when: false
|
||||
failed_when: libresign_verify.stdout is search('\berror\b')
|
||||
# Double backslashes: Jinja unescapes string literals, so a single '\b'
|
||||
# becomes a BACKSPACE character and this could never match -- which is
|
||||
# how a check reporting three errors passed clean on 2026-09-28.
|
||||
failed_when: libresign_verify.stdout is search('\\berror\\b')
|
||||
|
||||
- name: disable nextcloud signup link in config
|
||||
become: true
|
||||
|
||||
@@ -79,6 +79,22 @@
|
||||
image: docker.io/library/nextcloud:34.0.3-apache
|
||||
tags: skudak, skudak-cloud
|
||||
|
||||
# cloud.debyltech.com -- cloned from the Skudak instance above; keep the two
|
||||
# image pins in step. DNS is a terraform-managed ALIAS (see defaults).
|
||||
- import_tasks: containers/debyltech/cloud.yml
|
||||
vars:
|
||||
db_image: docker.io/library/mariadb:10.6
|
||||
# Fully qualified on purpose: podman records `docker.io/library/redis`, and
|
||||
# podman-check compares names literally, so the short `docker.io/redis`
|
||||
# form (as in the Skudak block above) recreates redis on every deploy.
|
||||
redis_image: docker.io/library/redis:8.2-alpine
|
||||
image: docker.io/library/nextcloud:34.0.3-apache
|
||||
# GitHub release asset + its sha256 -- see the pinned-install comment in
|
||||
# the task file for why this is not left to the app store.
|
||||
libresign_version: "14.2.2"
|
||||
libresign_sha256: 8655a4c89f52ca7eaf542d0764d07a7732cb23b39906e7246b37e569ec7a6579
|
||||
tags: debyltech, debyltech-cloud
|
||||
|
||||
- import_tasks: containers/debyltech/fulfillr.yml
|
||||
vars:
|
||||
image: git.debyl.io/debyltech/fulfillr:20260915.0011
|
||||
|
||||
@@ -457,6 +457,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
# de Byl Tech Nextcloud - {{ cloud_debyltech_server_name }}
|
||||
{{ cloud_debyltech_server_name }} {
|
||||
request_body {
|
||||
max_size {{ caddy_max_request_body_mb }}MB
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8091 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote}
|
||||
}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "same-origin"
|
||||
-X-Powered-By
|
||||
}
|
||||
|
||||
# Nextcloud specific redirects
|
||||
redir /.well-known/carddav /remote.php/dav 301
|
||||
redir /.well-known/caldav /remote.php/dav 301
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/cloud-debyltech.log {
|
||||
roll_size {{ caddy_log_roll_size }}
|
||||
roll_keep {{ caddy_log_roll_keep }}
|
||||
roll_keep_for {{ caddy_log_roll_keep_for }}
|
||||
}
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
# Gitea - {{ gitea_debyl_server_name }}
|
||||
{{ gitea_debyl_server_name }} {
|
||||
import common_headers
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
/**
|
||||
* {{ ansible_managed }}
|
||||
*
|
||||
* Post-deploy assertion that de Byl Tech mail branding is actually live.
|
||||
*
|
||||
* WHY THIS EXISTS: DebyltechEMailTemplate extends OC\Mail\EMailTemplate, which is
|
||||
* Nextcloud's PRIVATE namespace -- no API stability guarantee. Two things can
|
||||
* silently switch the branding off:
|
||||
*
|
||||
* 1. A Nextcloud major upgrade. appinfo/info.xml pins max-version, so the app
|
||||
* is auto-disabled as incompatible; Mailer::createEMailTemplate() then
|
||||
* fails its class_exists() check and falls back to the stock template.
|
||||
* Mail still sends -- unbranded. That is the right failure mode, but it is
|
||||
* invisible without this check.
|
||||
* 2. An upstream change to the private base class breaking an override.
|
||||
*
|
||||
* Renders through Message::useTemplate() -- the REAL path -- rather than
|
||||
* calling renderHtml() directly. That distinction is not academic: renderText()
|
||||
* runs first and flips the parent's footerAdded flag, and a renderHtml()-only
|
||||
* test once passed green while live mail shipped with no footer at all.
|
||||
*
|
||||
* Exits non-zero with a diagnostic on any failure, so the Ansible task fails
|
||||
* the play rather than reporting a clean deploy over broken branding.
|
||||
*/
|
||||
|
||||
require_once '/var/www/html/lib/base.php';
|
||||
|
||||
$mailer = \OC::$server->get(\OCP\Mail\IMailer::class);
|
||||
$dispatcher = \OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class);
|
||||
|
||||
// Mirrors MailService::notifyUnsignedUser() (custom_apps/libresign/lib/Service/MailService.php:85-116).
|
||||
$template = $mailer->createEMailTemplate('settings.TestEmail');
|
||||
$template->setSubject('LibreSign: There is a file for you to sign');
|
||||
$template->addHeader();
|
||||
$template->addHeading('File to sign', false);
|
||||
$template->addBodyText('There is a document for you to sign. Access the link below:');
|
||||
$template->addBodyButton('Sign »verify.pdf«', 'https://{{ cloud_debyltech_server_name }}/verify');
|
||||
|
||||
$message = $mailer->createMessage();
|
||||
$message->setTo(['verify@example.invalid' => 'Verify']);
|
||||
$message->useTemplate($template);
|
||||
|
||||
// What Mailer::send() does at lib/private/Mail/Mailer.php:186. Nothing is sent.
|
||||
$dispatcher->dispatchTyped(new \OCP\Mail\Events\BeforeMessageSent($message));
|
||||
|
||||
$html = $message->getSymfonyEmail()->getHtmlBody() ?? '';
|
||||
$text = $message->getPlainBody();
|
||||
$subject = $message->getSubject();
|
||||
|
||||
$inlineNames = [];
|
||||
foreach ($message->getSymfonyEmail()->getAttachments() as $part) {
|
||||
$inlineNames[] = (string)$part->getFilename();
|
||||
}
|
||||
|
||||
$failures = [];
|
||||
|
||||
if (!$template instanceof \OCA\Debyltechmail\Mail\DebyltechEMailTemplate) {
|
||||
$failures[] = 'template class is ' . get_class($template)
|
||||
. ' -- expected DebyltechEMailTemplate. Is the debyltechmail app enabled, and does '
|
||||
. 'appinfo/info.xml still allow this Nextcloud major?';
|
||||
}
|
||||
if (!str_starts_with($subject, 'de Byl Technologies LLC')) {
|
||||
$failures[] = 'subject not rewritten: ' . $subject;
|
||||
}
|
||||
if (!str_contains($html, 'official document-signing request')) {
|
||||
$failures[] = 'HTML footer missing (renderText/renderHtml ordering regression?)';
|
||||
}
|
||||
if (!str_contains($text, 'official document-signing request')) {
|
||||
$failures[] = 'plain-text footer missing';
|
||||
}
|
||||
if (!str_contains($html, 'debyltech.com/legal/privacy') || !str_contains($html, 'debyltech.com/legal/tos')) {
|
||||
$failures[] = 'privacy/terms links missing from footer';
|
||||
}
|
||||
if (preg_match('/[»«]/u', $html)) {
|
||||
$failures[] = 'German guillemets survived into the body';
|
||||
}
|
||||
if (!str_contains($html, 'Review document')) {
|
||||
$failures[] = 'button label not normalised to "Review document"';
|
||||
}
|
||||
if (!str_contains($html, 'cid:debyltech-wordmark.png')) {
|
||||
$failures[] = 'logo is not a cid: reference -- BeforeMessageSent listener did not fire';
|
||||
}
|
||||
if (!in_array('debyltech-wordmark.png', $inlineNames, true)) {
|
||||
$failures[] = 'inline logo MIME part absent (found: ' . (implode(', ', $inlineNames) ?: 'none') . ')';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LibreSign signing settings. These live in oc_appconfig (the database), not on
|
||||
// disk, so they survive container recreation -- but they are re-assertable and
|
||||
// a stray click in the admin UI can change them silently. GRAPHIC in particular
|
||||
// matters: any other mode makes SignatureTextService::getSignatureWidth()
|
||||
// return $current / 2 and stamp a name/date block that duplicates -- and
|
||||
// collides with -- the one our documents already typeset.
|
||||
$appConfig = \OC::$server->get(\OCP\IAppConfig::class);
|
||||
|
||||
// Must be exactly GRAPHIC_ONLY -- SignerElementsService::RENDER_MODE_GRAPHIC_ONLY.
|
||||
// The valid set is DESCRIPTION_ONLY / SIGNAME_AND_DESCRIPTION /
|
||||
// GRAPHIC_AND_DESCRIPTION / GRAPHIC_ONLY. Anything outside it (a bare 'GRAPHIC',
|
||||
// say) is accepted by occ but matches no radio in the admin UI and falls
|
||||
// through to default behaviour, so this asserts membership, not just non-empty.
|
||||
$renderMode = $appConfig->getValueString('libresign', 'signature_render_mode', '');
|
||||
if ($renderMode !== 'GRAPHIC_ONLY') {
|
||||
$failures[] = 'libresign signature_render_mode is "' . $renderMode
|
||||
. '" -- expected GRAPHIC_ONLY (signature only). Any other mode halves the '
|
||||
. 'stamp width and overlays a duplicate name/date block.';
|
||||
}
|
||||
|
||||
// Read with getValueBool, exactly as FooterHandler:158 does -- asserting the
|
||||
// string form would pass on a value the app itself reads as true.
|
||||
if ($appConfig->getValueBool('libresign', 'write_qrcode_on_footer', true) !== false) {
|
||||
$failures[] = 'libresign write_qrcode_on_footer is not false -- the validation '
|
||||
. 'QR block will be stamped on every page and overlaps the document footer. '
|
||||
. '(Was it written without --type=boolean?)';
|
||||
}
|
||||
|
||||
// Signer search for account-owned emails. Both keys are asserted because the
|
||||
// two failure modes are opposite and the second is the more dangerous:
|
||||
// full_match = yes -> account-owned emails silently unselectable
|
||||
// full_match_email = no -> email signer search disabled ENTIRELY
|
||||
// Defaults are 'yes' for both (MailPlugin.php:50-55), so an unset
|
||||
// full_match_email is correct and only an explicit 'no' is a problem.
|
||||
if ($appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') !== 'no') {
|
||||
$failures[] = 'core shareapi_restrict_user_enumeration_full_match is not "no" -- '
|
||||
. 'emails belonging to an existing Nextcloud account cannot be added as '
|
||||
. 'LibreSign signers (MailPlugin.php:163 aborts the search).';
|
||||
}
|
||||
if ($appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes') === 'no') {
|
||||
$failures[] = 'core shareapi_restrict_user_enumeration_full_match_email is "no" -- '
|
||||
. 'this disables email signer search ENTIRELY (MailPlugin.php:67). It must be '
|
||||
. 'unset or "yes"; it is NOT the knob for the account-owned-email problem.';
|
||||
}
|
||||
|
||||
$identDocs = $appConfig->getValueString('libresign', 'identification_documents', '');
|
||||
if ($identDocs !== '0') {
|
||||
$failures[] = 'libresign identification_documents is "' . $identDocs
|
||||
. '" -- expected 0. A non-zero value gates signing behind an ID upload '
|
||||
. 'plus admin approval, and signers see no way to sign.';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redis: distributed cache + transactional file locking.
|
||||
//
|
||||
// These come from the image's config/redis.config.php drop-in, which only
|
||||
// activates when REDIS_HOST is set on the container. If the env var is lost
|
||||
// (a container recreated from a stale spec, say), Nextcloud silently reverts
|
||||
// to DBLockingProvider and every file lock goes back to being a MariaDB write
|
||||
// -- functional, but the stalls come back with no error anywhere.
|
||||
$sysConfig = \OC::$server->get(\OCP\IConfig::class);
|
||||
|
||||
foreach (['memcache.locking', 'memcache.distributed'] as $key) {
|
||||
$value = $sysConfig->getSystemValueString($key, '');
|
||||
if ($value !== '\OC\Memcache\Redis') {
|
||||
$failures[] = $key . ' is "' . $value . '" -- expected \\OC\\Memcache\\Redis. '
|
||||
. 'Is REDIS_HOST still set on the debyltech-cloud container?';
|
||||
}
|
||||
}
|
||||
|
||||
// Prove Redis is actually reachable and authenticating, not merely configured.
|
||||
// A wrong password leaves the config looking perfect while every cache and
|
||||
// lock operation fails at runtime.
|
||||
try {
|
||||
$cacheFactory = \OC::$server->get(\OCP\ICacheFactory::class);
|
||||
if (!$cacheFactory->isAvailable()) {
|
||||
$failures[] = 'distributed cache reports unavailable -- redis unreachable or auth failed';
|
||||
} else {
|
||||
$probe = $cacheFactory->createDistributed('debyltechmail-verify');
|
||||
$probe->set('probe', 'ok', 30);
|
||||
if ($probe->get('probe') !== 'ok') {
|
||||
$failures[] = 'distributed cache round-trip failed (set/get mismatch)';
|
||||
}
|
||||
$probe->remove('probe');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failures[] = 'distributed cache threw: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
if ($failures !== []) {
|
||||
fwrite(STDERR, "debyltechmail branding verification FAILED:\n");
|
||||
foreach ($failures as $f) {
|
||||
fwrite(STDERR, " - $f\n");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "debyltechmail branding OK (subject: $subject)\n";
|
||||
@@ -0,0 +1,40 @@
|
||||
# {{ ansible_managed }}
|
||||
#
|
||||
# Redis for debyltech-cloud: Nextcloud distributed cache + transactional file
|
||||
# locking. Reachable only by container name on the `shared` podman network --
|
||||
# no host port is published.
|
||||
#
|
||||
# The password lives HERE rather than on the command line as
|
||||
# `redis-server --requirepass <pass>`. That is the existing house idiom (see
|
||||
# the deleted container-nosql.yml in git history), but it leaks the secret into
|
||||
# `podman inspect`, into the generated systemd unit under
|
||||
# ~/.config/systemd/user/, and into `ps` for every user on the host. A 0640
|
||||
# config file mounted read-only keeps it out of all three.
|
||||
requirepass {{ cloud_debyltech_redis_pass }}
|
||||
|
||||
# Bind to all interfaces WITHIN the container's network namespace. The
|
||||
# container publishes no port, so this is reachable only from the `shared`
|
||||
# podman network -- not from the host and not from the LAN.
|
||||
bind 0.0.0.0
|
||||
port 6379
|
||||
protected-mode yes
|
||||
|
||||
# NO maxmemory / eviction policy, deliberately.
|
||||
#
|
||||
# Nextcloud puts BOTH the distributed cache and the transactional file locks in
|
||||
# this instance. Cache entries are safely evictable; LOCKS ARE NOT. An
|
||||
# `allkeys-lru` policy under memory pressure can evict a lock that a live
|
||||
# request still believes it holds, which permits concurrent writers to the same
|
||||
# file -- silent corruption rather than a visible error. With no maxmemory,
|
||||
# Redis never evicts. The host has ~14 GiB free of 31 GiB and this instance
|
||||
# holds a few hundred keys, so a cap buys nothing.
|
||||
#
|
||||
# If a cap is ever genuinely needed, use `maxmemory-policy noeviction` so Redis
|
||||
# returns an error instead of silently discarding a lock.
|
||||
|
||||
# No persistence. Locks are ephemeral and TTL-bounded, and the cache is
|
||||
# rebuildable -- there is nothing here worth surviving a restart. Persisting
|
||||
# would be actively worse: a restored RDB could reinstate locks whose owning
|
||||
# request died, blocking files until the TTL expired.
|
||||
save ""
|
||||
appendonly no
|
||||
Binary file not shown.
Reference in New Issue
Block a user