Customers are admin-created accounts in per-customer groups. They should
read what staff share with them and nothing else. Checked against a test
customer in the UI, and by running the sharee and contacts-menu search
services as that user.
- shareapi_exclude_groups=allow, list [admin]: only staff can share. Exclude
mode ("yes") only disables sharing for users whose groups are ALL
excluded, so it never catches a customer in their own group.
- User/group autocomplete off. By default a customer typing "bas" found the
owner's account. Staff share by exact group name; LibreSign signers are
found by email.
- New shares default to View only (shareapi_default_permissions=1).
- files default_quota 0 B, so customers get no personal storage. Staff in
cloud_debyltech_staff_users are exempted (skipped if not yet created).
- No "Leon Green" sample contact in new address books.
- The verify script fails the deploy if any isolation setting drifts.
- README: staff and customer onboarding checklist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
221 lines
10 KiB
Django/Jinja
221 lines
10 KiB
Django/Jinja
<?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 (!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)) {
|
|
$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.';
|
|
}
|
|
|
|
// 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.")';
|
|
}
|
|
|
|
// Customer isolation (see the policy task in containers/debyltech/cloud.yml).
|
|
// A stray click in Settings > Sharing can undo any of these, and each one
|
|
// quietly re-exposes customers to one another or lets them share onward.
|
|
$isolation = [
|
|
'shareapi_exclude_groups' => 'allow',
|
|
'shareapi_exclude_groups_list' => json_encode(['{{ cloud_debyltech_staff_group }}']),
|
|
'shareapi_allow_share_dialog_user_enumeration' => 'no',
|
|
'shareapi_default_permissions' => '1',
|
|
];
|
|
foreach ($isolation as $key => $want) {
|
|
$have = \OC::$server->get(\OCP\IConfig::class)->getAppValue('core', $key, '<unset>');
|
|
if ($have !== $want) {
|
|
$failures[] = "core $key is \"$have\" -- expected \"$want\" (customer isolation)";
|
|
}
|
|
}
|
|
|
|
$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";
|