Compare commits

...
48 Commits
Author SHA1 Message Date
Bastian de BylandClaude Fable 5.1 bc423a1c7c chore(gitea-actions): bump ESP-IDF CI image to v5.5.1
CI Images / Plan (push) Successful in 35s
CI Images / Build ci (push) Successful in 7m53s
CI Images / Build espidf (push) Successful in 18m27s
CI Images / Build platformio (push) Successful in 25m48s
esp32-vi-can-obd2 needs the IDF 5.5 TWAI node driver. ci-images.yml publishes
gitbot/gitea-ci-espidf:v5.5.1 from this pin; the v5.4.1 tag stays in the
registry for esp-mg-tpms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:08:58 -04:00
Bastian de BylandClaude Opus 5.5 4b826a0008 chore(gregtime): bump to 3.21.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:08:22 -04:00
Bastian de BylandClaude Opus 5.5 4220a2190d chore(gregtime): bump to 3.21.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:42:30 -04:00
Bastian de BylandClaude Opus 5.5 867b3038c8 chore(gregtime): bump to 3.20.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:09:39 -04:00
Bastian de BylandClaude Opus 5.5 ecb85e96d0 chore(gregtime): bump to 3.20.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:42:43 -04:00
Bastian de BylandClaude Opus 5.5 135c26369f chore(gregtime): bump to 3.19.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:27:07 -04:00
Bastian de BylandClaude Opus 5.5 bcebdc19cc chore(gregtime): bump to 3.19.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:36:55 -04:00
Bastian de BylandClaude Opus 5.5 a38cb080da feat(hass): hook the living room desk lamp into the light schedule
switch.desk_lamp (EP10) is an on/off switch, so the area-targeted
light.* actions skip it. It now follows light.living_room explicitly:
on at sunset unless TV mode is on, on when the TV goes off in the
evening, and off with the living room at 23:30, the 01:00 sweep and
when the TV goes off late at night.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:21:09 -04:00
Bastian de BylandClaude Opus 5.5 3d03332aaa fix(hass): drop dead device-based automations, use entity ids
The five "Lights - 01/02/03/04/10" automations had no triggers left and
were superseded by the sunset/evening-ramp/lights-out/sweep automations;
nothing references them. "Driveway String Lights Off" and "Lights - 00 -
Morning" referenced device ids that no longer exist in the device
registry (they only worked because HA resolved the entity registry id),
so they now target switch.driveway_string_lights and
light.bathroom_hallway directly. Ids and aliases are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:58:01 -04:00
Bastian de BylandClaude Opus 5.5 f575f8f62d chore(hass): add one-off playbook to repoint tplink entries at IoT VLAN
The IoT WiFi moved from Default (192.168.1.x) to the IoT VLAN
(192.168.2.x) with fixed IPs reserved in UniFi. HA 2026.9.3's tplink
reconfigure flow ignores the entered host and reconnects to the stored
one, so this edits data.host in .storage/core.config_entries with HA
stopped, matching entries by MAC and refusing to write unless all 9
match. Backs up core.config_entries first and always restarts hass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:45:59 -04:00
Bastian de BylandClaude Opus 5.5 339d4b150c chore(gregtime): bump to 3.19.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:46:04 -04:00
Bastian de BylandClaude Fable 5.1 0bd6c6e3fe chore(fulfillr): bump prod and dev image to 20260929.1624 (SCRUM-208 activity feed)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:40:08 -04:00
Bastian de BylandClaude Fable 5.1 4759ee5909 chore(fulfillr): bump prod and dev image to 20260929.1553 (SCRUM-207, SCRUM-208)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:04:00 -04:00
Bastian de BylandClaude Fable 5.1 a55e7b7c3d chore(fulfillr): bump prod image to 20260929.1412 (nav badges, SCRUM-206)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 10:23:53 -04:00
Bastian de BylandClaude Fable 5.1 554d3acb6e chore(fulfillr-dev): bump image to 20260929.1412 (nav badges, SCRUM-206)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 10:22:05 -04:00
Bastian de BylandClaude Fable 5.1 69d2c2de92 chore(fulfillr): bump prod and dev image to 20260929.0203 (project invoicing, SCRUM-205)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 09:39:39 -04:00
Bastian de BylandClaude Fable 5.1 72c1006b3e chore(fulfillr-dev): bump image to 20260929.0015 (project invoicing, SCRUM-202)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 20:23:29 -04:00
Bastian de BylandClaude Opus 5.5 1f399cee91 chore(vault): rotate gitea registry token to write:package
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:53:46 -04:00
Bastian de BylandClaude Opus 5.5 0cc4c1460e feat(debyltech-cloud): limit customers to Files, Activity and signing
Nothing was group-restricted, so customers saw Dashboard, Photos, Office
and the rest, plus Nextcloud's first-run and promo apps.

- Disable for everyone: firstrunwizard, recommendations, related_resources,
  weather_status, survey_client, support, app_api, contactsinteraction,
  photos. A refused disable now fails the play (occ exits 0 on "can't be
  disabled").
- Restrict dashboard and office to staff. defaultapp=dashboard,files so
  staff land on the dashboard and customers fall through to Files.
- libresign groups_request_sign pinned to staff and asserted in verify.
  LibreSign itself is deliberately NOT group-restricted: that also blocks
  anonymous requests and would break public signing links.
- profile.enabled=false; lookup_server="" (lookup_server_connector
  can't be disabled).
- README: what customers can open.

Checked as a probe customer: apps=files,activity,libresign,text,viewer,
lands in Files, can't request signatures; staff land on Dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:48:39 -04:00
Bastian de BylandClaude Opus 5.5 c4fe506860 feat(debyltech-cloud): lock customers to read-only, no discovery
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>
2026-09-28 19:10:47 -04:00
Bastian de BylandClaude Opus 5.5 be50798096 fix(ci-images): static build matrix for Gitea
CI Images / Plan (push) Successful in 29s
CI Images / Build ci (push) Failing after 1m8s
CI Images / Build espidf (push) Failing after 1m16s
CI Images / Build platformio (push) Failing after 1m30s
Gitea expands a job's matrix when the run is created, before plan has any
outputs, so fromJSON(needs.plan.outputs.matrix) collapsed to a single empty
"Build ${{ matrix.key }}" job and nothing was ever built. The matrix is now
the fixed list of image keys; plan emits every image's spec with a build flag
and each matrix job looks its own entry up, no-opping when it wasn't picked.

Also document that both registry tokens need write:package -- the vault token
was read-only, so the gitea_ci_build_local bootstrap failed its push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:00:35 -04:00
Bastian de BylandClaude Opus 5.5 8701ada7e2 feat(debyltech-cloud): plain login background, empty homes for new accounts
- theming background -> backgroundColor, dropping the stock image for the
  navy theming colour.
- skeletondirectory/templatedirectory set to "" so customer accounts start
  empty instead of getting Nextcloud's sample Manual, intro video and
  Templates folder.
- The system-config compare now tells an unset key apart from an empty
  value. Both print nothing, so an intentionally empty setting was
  silently skipped.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:08:41 -04:00
Bastian de BylandClaude Opus 5.5 fa5bbf8e54 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>
2026-09-28 17:04:04 -04:00
bastian 0eca63d4b7 Merge pull request 'fix(gitea-actions): serve CI images from the Gitea registry' (#12) from feat/ci-images-registry into master
CI Images / Plan (push) Failing after 1s
CI Images / Build ${{ matrix.key }} (push) Skipped
2026-09-28 12:19:57 -04:00
Bastian de BylandClaude Opus 5.5 73c552303b docs(claude): work directly on master, no branches or PRs
CI Images / Plan (pull_request) Failing after 2s
CI Images / Build ${{ matrix.key }} (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:53:14 -04:00
Bastian de BylandClaude Opus 5.5 5d6aa187cc chore(vault): update secrets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:51:45 -04:00
Bastian de BylandClaude Opus 5.5 1852af5fc9 chore: bump gregtime to 3.19.0, rsvp to 1.0.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:51:45 -04:00
Bastian de BylandClaude Opus 5.5 f674f61b8d fix(hass): don't fire on-off automations when a device reconnects
CI Images / Plan (pull_request) Failing after 2s
CI Images / Build ${{ matrix.key }} (pull_request) Skipped
The Bedroom Light HS200 dropped off Wi-Fi for 5 s at 03:01 and came back
reporting "on"; the Bedroom On device trigger treated unavailable -> on as
someone flipping the switch and lit the bedroom Hue lamps at 100%.

Bedroom On/Off and TV On/Off now use state triggers with not_from
unavailable/unknown, so reconnects and HA restarts no longer count as a
flip. The driveway's switch-reconnect catch-up is dropped for the same
reason (it would undo a manual off); the HA-restart catch-up stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:32:24 -04:00
Bastian de BylandClaude Opus 5.5 fd985e014c fix(hass): driveway lights ignore TV mode, ramped evening dimming, bump to 2026.9.3
CI Images / Plan (pull_request) Failing after 2s
CI Images / Build ${{ matrix.key }} (pull_request) Skipped
The sunset automation required TV mode off, so an afternoon of TV skipped
the driveway string lights entirely (Sep 22 and 23) - not an outage. The
driveway now has its own sunset -1h automation, with catch-up on restart
or switch reconnect before 23:00.

Evening brightness lives in one script (evening_lights_apply) that blends
between the old step levels; a 5-minute ramp from 20:30 eases lights that
are on and leaves alone any a person has changed by hand. The Dining Hall
no longer bumps to 50% at 21:30.

TV off after 23:30 now only turns off the living room glow instead of
bringing the whole house back to full brightness, and TV on/off leave the
lights alone in daylight. Lights-out moves to 23:30, and a 01:00 sweep
catches anything switched back on at the wall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:42:33 -04:00
Bastian de BylandClaude Fable 5.1 15a8ec693e chore(gitea): bump git.skudak.com to 1.27.3
CI Images / Plan (pull_request) Successful in 37s
CI Images / Build ${{ matrix.key }} (pull_request) Failing after 58s
Same security fixes as git.debyl.io, deployed and verified after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:10:07 -04:00
Bastian de BylandClaude Fable 5.1 64850995ec chore(gitea): bump git.debyl.io to 1.27.3, pin the two instances separately
1.26.1 -> 1.27.3 picks up the security fixes in 1.27.0 through 1.27.3.
The image was one shared variable, so the two instances could only move
together; split it into gitea_debyl_image / gitea_skudak_image and tag
the debyl tasks gitea-debyl so each can be upgraded and verified on its
own. Skudak stays on 1.26.1 in this commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:06:31 -04:00
Bastian de BylandClaude Opus 5 ba0936bc8d chore(gregtime): bump to 3.18.4
The daily quote keeps a ledger of what it has posted and re-rolls ZenQuotes
until it finds something the channel has not read, with the header framing and
the offline fallback pool drawn against that same ledger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:30:07 -04:00
Bastian de BylandClaude Opus 5 d0e76bd6cf feat(gitea-actions): build the CI job images in Gitea CI
The runner's job images were built by ansible into localhost/ only, so the
nightly CI prune deleted them and every idle stretch ended with CI failing in
under a second on `docker pull localhost/gitea-ci:latest` until someone re-ran
the role and waited out a rebuild. The previous commit moved them to the Gitea
registry; this moves the *build* off the deploy path entirely.

- .gitea/workflows/ci-images.yml builds files/Containerfile.* and pushes to
  git.debyl.io/gitbot/. Per-image change detection, so an ESP-IDF pin bump does
  not rebuild the other two; weekly schedule for base-image updates; a
  workflow_dispatch selector. PRs build under a throwaway :pr-<n> tag and drop
  it -- the build lands in the live runner's store, and act_runner will not
  re-pull a tag it already has, so a PR using the real tag would hand every
  later job on this host an unmerged image.
- The Containerfiles stop being ansible templates: their version vars are now
  --build-arg, read by the workflow out of the same defaults/main.yml the role
  interpolates, so CI and ansible build the same bytes from one set of pins.
- LABEL io.debyl.ci-base moves into each Containerfile so neither builder can
  forget the prune exemption; the workflow re-checks it before pushing.
- roles/gitea-actions pulls instead of building. gitea_ci_build_local=true
  restores the local build+push for seeding a cold registry or when CI is
  down -- the workflow that builds gitea-ci runs in gitea-ci.
- Lint .gitea/ alongside ansible/, and document the flow in the role README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:10:43 -04:00
Bastian de Byl a52209ff6a Merge branch 'master' into feat/ci-images-registry 2026-09-21 11:02:55 -04:00
Bastian de BylandClaude Opus 5 56d74660b8 chore(rsvp): bump to 1.0.5
Nights are checkboxes again: a household ticks every night that works, which
also says which nights do not. At least one tick (or "Any of these works")
is required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:42:58 -04:00
bastian 306e43e700 Merge pull request 'SCRUM-196: fulfillr 20260915.0011 (GA4 users over the exact window)' (#13) from scrum-196/fulfillr-users-window into master 2026-09-14 20:34:12 -04:00
Bastian de BylandClaude Opus 5 355a0739ff SCRUM-196: fulfillr 20260915.0011 (GA4 users over the exact window)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:26:02 -04:00
Bastian de Byl f7f4d903a0 Merge remote-tracking branch 'origin/master' into feat/ci-images-registry 2026-09-14 19:57:33 -04:00
bastian f0e2fae900 Merge pull request 'SCRUM-196: GA4 analytics config for fulfillr Traffic & funnel tab' (#11) from scrum-196/fulfillr-ga4-analytics into master 2026-09-14 17:59:21 -04:00
Bastian de BylandClaude Opus 5 75e257077e SCRUM-196: fulfillr 20260914.2149 (embedded tzdata for GA4)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:55:15 -04:00
Bastian de BylandClaude Opus 5 80edc84586 SCRUM-196: fulfillr 20260914.2103 (funnel + GA4 traffic endpoints)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:10:08 -04:00
Bastian de BylandClaude Opus 5 42e6f5271d fix(gitea-actions): serve CI images from the Gitea registry
The CI job images only existed under localhost/ in the gitea-runner store,
and the nightly CI prune deletes any image older than 48h that no container
holds. After every idle stretch CI failed in 0-1s pulling
localhost/gitea-ci:latest until the role was re-run and the images rebuilt.

- Build under git.debyl.io/gitbot/..., push after every run, and pull from the
  registry instead of rebuilding when the Containerfile is unchanged.
- Log gitea-runner in via ~/.docker/config.json, which both act_runner (job
  image pulls) and podman read.
- Label the base images io.debyl.ci-base and skip that label in the CI prune;
  its `until` counts from build time, so a re-pulled image would otherwise be
  deleted again the next night.

Workflows pinning `container: image: localhost/gitea-ci-*` must move to the
registry paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:40:57 -04:00
Bastian de BylandClaude Opus 5 e174e259eb SCRUM-196: Read GA4 key from fulfillr_ga4_credentials_json
Match the vault variable name holding the service-account key file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:21:04 -04:00
Bastian de BylandClaude Opus 5 e681a46b78 SCRUM-196: GA4 analytics config for fulfillr Traffic & funnel tab
Render an analytics block (property 353859448 + service-account key) into the
fulfillr dev and prod configs once fulfillr_ga4_credentials is in the vault.
Without the vault var the block is omitted and the portal reports GA as not
connected. Remember to restart the container after deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:02:38 -04:00
Bastian de BylandClaude Opus 5 d9ab55f05d chore(rsvp): bump to 1.0.4
Compacts the admin invite table so it fits without clipping: short token link
with Copy/Msg, status as an emoji, Qty, allergens/notes behind popovers, and
relative update times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 10:16:39 -04:00
Bastian de BylandClaude Opus 5 1c5d3b020a chore(rsvp): bump to 1.0.3
Fixes the admin invite table clipping its Edit/Revoke column, and adds
default-headcount people estimates to the invited/awaiting summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:36:08 -04:00
Bastian de BylandClaude Opus 5 1b9fccd779 chore(rsvp): bump to 1.0.2
Adds the anonymized "Who's coming" card for guests who have answered, and a
message-template copy button on the admin invite table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:20:57 -04:00
39 changed files with 3020 additions and 686 deletions
+273
View File
@@ -0,0 +1,273 @@
---
# Builds the Gitea Actions job images and publishes them to the Gitea container
# registry, so the runner can re-pull one the nightly podman prune removed
# instead of waiting for a human to re-run `make deploy TAGS=gitea-actions`.
#
# Source of truth is ansible/roles/gitea-actions: files/Containerfile.* for the
# image contents, defaults/main.yml for the version pins and the registry path.
# This workflow reads those vars rather than repeating them. roles/gitea-actions
# then only pulls what lands here (gitea_ci_build_local is the escape hatch for
# seeding an empty namespace, since the job below runs *in* gitea-ci).
#
# `docker build` here talks to the gitea-runner user's rootless podman socket,
# mounted into every job container by roles/gitea-actions (config.yaml.j2), so
# the build happens in the same image store the runner pulls from and the layer
# cache survives between runs. That also means a build writes tags the live
# runner will use -- which is why pull requests build under a throwaway
# :pr-<n> tag and delete it again.
name: CI Images
on:
push:
branches: [master]
paths:
- ansible/roles/gitea-actions/files/Containerfile.*
- ansible/roles/gitea-actions/defaults/main.yml
- .gitea/workflows/ci-images.yml
pull_request:
branches: [master]
paths:
- ansible/roles/gitea-actions/files/Containerfile.*
- ansible/roles/gitea-actions/defaults/main.yml
- .gitea/workflows/ci-images.yml
workflow_dispatch:
inputs:
image:
description: Which image to rebuild
type: choice
options: [all, ci, espidf, platformio]
default: all
schedule:
# Weekly rebuild so base-image security updates land without a commit.
# Sunday 04:00, after the 02:00 podman prune has finished.
- cron: "0 4 * * 0"
env:
DEFAULTS: ansible/roles/gitea-actions/defaults/main.yml
CONTEXT: ansible/roles/gitea-actions/files
REGISTRY: git.debyl.io
# Not a secret: the same namespace is in defaults/main.yml. It must be the
# owner of REGISTRY_TOKEN -- Gitea authorises a package push by the token's
# user, not by the path, so pushing to gitbot/ means logging in as gitbot.
REGISTRY_USER: gitbot
KEEP_LABEL: io.debyl.ci-base
# One publisher at a time. Two runs pushing :latest concurrently would leave the
# registry holding whichever finished last, which need not be the newest commit.
concurrency:
group: ci-images
cancel-in-progress: false
jobs:
plan:
name: Plan
runs-on: fedora
outputs:
images: ${{ steps.plan.outputs.images }}
any: ${{ steps.plan.outputs.any }}
steps:
- uses: actions/checkout@v4
with:
# Full history so the change detection below can diff against the
# pushed-from commit / the PR base.
fetch-depth: 0
- name: Decide which images to build
id: plan
env:
EVENT: ${{ github.event_name }}
SELECTED: ${{ github.event.inputs.image }}
BEFORE: ${{ github.event.before }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, subprocess, sys, yaml
defaults = yaml.safe_load(open(os.environ["DEFAULTS"]))
ctx = os.environ["CONTEXT"]
reg, ns = os.environ["REGISTRY"], os.environ["REGISTRY_USER"]
# Mirrors gitea_ci_images in defaults/main.yml. The tags are rebuilt
# from the same version vars the role interpolates, so a pin bump in
# that file moves the image tag here and in ansible together.
images = [
{
"key": "ci",
"containerfile": "Containerfile.ci",
"tag": f"{reg}/{ns}/gitea-ci:latest",
"build_args": "",
},
{
"key": "espidf",
"containerfile": "Containerfile.espidf",
"tag": f"{reg}/{ns}/gitea-ci-espidf:{defaults['esp_idf_version']}",
"build_args": f"ESP_IDF_VERSION={defaults['esp_idf_version']}",
},
{
"key": "platformio",
"containerfile": "Containerfile.platformio",
"tag": f"{reg}/{ns}/gitea-ci-platformio:{defaults['pio_espressif32_version']}",
"build_args": (
f"PLATFORMIO_CORE_VERSION={defaults['platformio_core_version']} "
f"PIO_ESPRESSIF32_VERSION={defaults['pio_espressif32_version']}"
),
},
]
event = os.environ["EVENT"]
def changed_files(base):
"""Paths touched since `base`, or None if the diff is not usable."""
if not base or set(base) == {"0"}:
return None
try:
out = subprocess.run(
["git", "diff", "--name-only", f"{base}...HEAD"],
capture_output=True, text=True, check=True,
).stdout
except subprocess.CalledProcessError:
# Force push, shallow clone, first push of a branch: fall back
# to building everything rather than silently skipping a real
# change.
return None
return set(out.split())
if event == "workflow_dispatch":
selected = os.environ.get("SELECTED") or "all"
picked = images if selected == "all" else [i for i in images if i["key"] == selected]
elif event == "schedule":
picked = images
else:
base = os.environ["PR_BASE"] if event == "pull_request" else os.environ["BEFORE"]
touched = changed_files(base)
if touched is None:
picked = images
else:
# defaults/main.yml holds every pin, so a change there could
# retag any image; the workflow file itself changes how all of
# them are built. Either one rebuilds the lot.
wide = {os.environ["DEFAULTS"], ".gitea/workflows/ci-images.yml"}
if touched & wide:
picked = images
else:
picked = [i for i in images if f"{ctx}/{i['containerfile']}" in touched]
# Every image, keyed by matrix.key, each flagged build or skip. The
# build job's matrix is static (see there), so it needs the full set
# to look its own entry up in, not just the picked ones.
picked_keys = {i["key"] for i in picked}
specs = {i["key"]: {**i, "build": i["key"] in picked_keys} for i in images}
print(f"images={json.dumps(specs)}")
print(f"any={'true' if picked else 'false'}")
print("building: " + (", ".join(i["tag"] for i in picked) or "nothing"), file=sys.stderr)
PY
build:
name: Build ${{ matrix.key }}
needs: plan
if: needs.plan.outputs.any == 'true'
runs-on: fedora
strategy:
# One image failing must not cancel the others: they are independent, and
# a half-published set is what this whole workflow exists to avoid.
fail-fast: false
# Static on purpose. Gitea expands the matrix when the run is created,
# before plan has produced any outputs, so a
# fromJSON(needs.plan.outputs.*) matrix collapses to one empty job. Each
# entry instead looks its spec up in plan's output and no-ops its steps
# when plan did not pick it. Keys must match `images` in plan.
matrix:
key: [ci, espidf, platformio]
steps:
- name: Look up the ${{ matrix.key }} image spec
id: spec
env:
IMAGES: ${{ needs.plan.outputs.images }}
KEY: ${{ matrix.key }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os
spec = json.loads(os.environ["IMAGES"])[os.environ["KEY"]]
for k in ("containerfile", "tag", "build_args"):
print(f"{k}={spec[k]}")
print(f"build={'true' if spec['build'] else 'false'}")
PY
- uses: actions/checkout@v4
if: steps.spec.outputs.build == 'true'
- name: Log in to the Gitea Container Registry
if: steps.spec.outputs.build == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
# The build lands in the live runner's image store, and act_runner will
# not re-pull a tag it already has locally. Tagging a PR build with the
# real tag would therefore hand every later job on this host an unmerged
# image, so PRs get a throwaway tag that the cleanup step removes.
- name: Resolve build tag
id: tag
if: steps.spec.outputs.build == 'true'
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "image=${{ steps.spec.outputs.tag }}-pr${{ github.event.number }}" >> "$GITHUB_OUTPUT"
else
echo "image=${{ steps.spec.outputs.tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Build ${{ matrix.key }}
if: steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
BUILD_ARGS: ${{ steps.spec.outputs.build_args }}
run: |
set -euo pipefail
args=()
for a in $BUILD_ARGS; do args+=(--build-arg "$a"); done
# --pull so a scheduled run actually picks up a refreshed base image;
# without it an unchanged FROM line just hits the local layer cache.
docker build --pull \
"${args[@]}" \
-t "$IMAGE" \
-f "$CONTEXT/${{ steps.spec.outputs.containerfile }}" \
"$CONTEXT"
- name: Verify the prune-exemption label survived the build
if: steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: |
set -euo pipefail
# roles/podman's nightly prune keeps an image only if it carries this
# label (podman_prune_ci_keep_label). Publishing one without it would
# quietly restore the nightly-deletion behaviour this replaced, and
# nothing would notice until CI failed on a Monday morning.
got=$(docker inspect -f "{{ index .Config.Labels \"$KEEP_LABEL\" }}" "$IMAGE")
test "$got" = "true" || {
echo "::error::$IMAGE is missing LABEL $KEEP_LABEL=true"
exit 1
}
- name: Push ${{ matrix.key }}
if: github.event_name != 'pull_request' && steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: |
set -euo pipefail
docker push "$IMAGE"
echo "Pushed: $IMAGE"
# Always, including on failure: the throwaway tag carries the keep label,
# so the nightly prune will not reclaim it and a few skipped cleanups add
# up to gigabytes in the runner's store.
- name: Drop the pull-request image
if: always() && github.event_name == 'pull_request' && steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: docker rmi -f "$IMAGE" || true
+19 -1
View File
@@ -28,6 +28,11 @@ The project uses Python virtualenv for dependency management:
- Makefile automatically creates `.venv/` and installs dependencies
- Vault password is sourced from password manager via `.pass.sh`
### Git Workflow
- Work directly on `master` - no feature branches and no pull requests in this repo.
- Commit to `master` and push to `origin/master` when asked; don't create a branch first.
- Pushing to `master` also triggers `.gitea/workflows/ci-images.yml` (see Gitea Actions CI images below), which only rebuilds images whose inputs changed.
## Architecture
### Directory Structure
@@ -45,6 +50,7 @@ ansible/
│ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically)
│ ├── github-actions/# CI/CD runner setup
│ ├── labelprint/ # 4x6 label print proxy (Raspberry Pi, CUPS/TSPL)
│ ├── gitea-actions/ # Gitea Actions runners + CI job images (see its README)
│ └── pihole/ # DNS filtering
└── vars/
└── vault.yml # Encrypted secrets
@@ -54,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
@@ -90,6 +96,7 @@ Tasks are tagged by service/component for selective deployment:
- `ddns` - Dynamic DNS tasks
- ~~`drone` - CI/CD tasks (decommissioned)~~
- `hass` - Home Assistant tasks
- `gitea-actions` - Gitea Actions runners and their CI job images
- Common infrastructure tags like `common`, `ssl`
## Configuration Files
@@ -122,6 +129,17 @@ Tasks are tagged by service/component for selective deployment:
- Falls back to its own rescue Wi-Fi AP at 192.168.4.1 when the home SSID is
unreachable
### Gitea Actions CI images
The runner's job images (`gitea-ci`, `gitea-ci-espidf`, `gitea-ci-platformio`)
are built by `.gitea/workflows/ci-images.yml` and published to the Gitea
container registry under `git.debyl.io/gitbot/`. The `gitea-actions` role only
pulls them - do NOT add build steps back to it. To change an image, edit
`ansible/roles/gitea-actions/files/Containerfile.*` (or a version pin in that
role's `defaults/main.yml`) and push to master; CI rebuilds only what changed.
See `ansible/roles/gitea-actions/README.md` for the registry rationale, the
prune-exemption label, and the bootstrap path when CI itself cannot build.
### Remote SSH Commands for Service Users
The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH:
+3 -1
View File
@@ -54,7 +54,9 @@ ${VAULT_FILE}: ${VAULT_PASS_FILE}
touch $@
# Linting
YAML_FILES=$(shell find ansible/ -name '*.yml' -not -name '*vault*')
# .gitea/workflows is linted too: the CI-image workflow is as much part of the
# deployment as the roles it publishes for.
YAML_FILES=$(shell find ansible/ .gitea/ -name '*.yml' -not -name '*vault*')
SKIP_FILE=./.lint-vars.sh
# Targets
@@ -0,0 +1,50 @@
"""Point HA's 9 TP-Link (Kasa) config entries at their new IoT VLAN addresses.
Run on galactica as the podman user while HA is STOPPED:
python3 /tmp/fix_tplink.py # dry run, prints what would change
python3 /tmp/fix_tplink.py apply # writes the change (refuses unless all 9 match)
Only data.host is changed; entries are matched by MAC (unique_id).
"""
import json
import os
import re
import sys
path = "/home/podman/.local/share/volumes/hass/config/.storage/core.config_entries"
new = {
"54af970a5d01": "192.168.2.220", # Printer Plug KP115
"788cb56955b4": "192.168.2.146", # Driveway String Lights HS200
"2887ba1bd687": "192.168.2.199", # Kitchen Wall Light KS230
"5ce9319dc5d6": "192.168.2.38", # Kitchen Lights HS220
"5ce9319da5e0": "192.168.2.200", # Dining Hall HS220
"5ce9319dd193": "192.168.2.159", # Dining Room HS220
"5ce931a23a6e": "192.168.2.61", # Bathroom Hallway HS220
"f0a731771227": "192.168.2.55", # Stair Light KS230
"788cb5694e4a": "192.168.2.252", # Bedroom Light HS200
}
apply = len(sys.argv) > 1 and sys.argv[1] == "apply"
with open(path) as f:
d = json.load(f)
n = 0
for e in d["data"]["entries"]:
if e.get("domain") != "tplink":
continue
mac = re.sub(r"[^0-9a-f]", "", (e.get("unique_id") or "").lower())
ip = new.get(mac)
print(f'{e["title"]:<36} host={e["data"].get("host")} -> {ip}')
if ip:
e["data"]["host"] = ip
n += 1
print("matched", n, "of 9")
if apply:
if n != 9:
sys.exit("refusing to write: not all 9 matched")
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(d, f, indent=4, ensure_ascii=False)
os.replace(tmp, path)
print("written")
+72
View File
@@ -0,0 +1,72 @@
---
# One-off: repoint Home Assistant's TP-Link (Kasa) config entries at their new
# addresses after the IoT WiFi moved from the Default network to the IoT VLAN
# (192.168.1.x -> 192.168.2.x, fixed IPs reserved in UniFi).
#
# HA 2026.9.3's tplink "Reconfigure" flow ignores the host entered in the form
# and reconnects to the stored one, so it cannot be used to change the address.
# Instead this edits data.host in .storage/core.config_entries while HA is
# stopped. Entries are matched by MAC (unique_id); nothing else is touched.
#
# Run:
# .venv/bin/ansible-playbook -i ansible/inventories/home/hosts.yml \
# ansible/oneoff/hass-tplink-rehost.yml
- name: Repoint HA tplink entries at the IoT VLAN
hosts: home.debyl.io
gather_facts: false
become: true
become_user: podman
vars:
hass_storage: /home/podman/.local/share/volumes/hass/config/.storage
hass_entries: "{{ hass_storage }}/core.config_entries"
tasks:
- name: Get podman uid for systemctl --user
ansible.builtin.command: id -u
register: podman_uid
changed_when: false
- name: Dry run - all 9 tplink entries must match before HA is stopped
ansible.builtin.script:
cmd: files/hass_tplink_rehost.py
executable: python3
register: dry_run
changed_when: false
failed_when: "'matched 9 of 9' not in dry_run.stdout"
- name: Show dry run
ansible.builtin.debug:
var: dry_run.stdout_lines
- name: Back up core.config_entries
ansible.builtin.copy:
src: "{{ hass_entries }}"
dest: "{{ hass_entries }}.bak-iot-vlan-20261001"
remote_src: true
mode: preserve
force: false
- name: Stop HA, rewrite hosts, start HA
environment:
XDG_RUNTIME_DIR: "/run/user/{{ podman_uid.stdout }}"
block:
- name: Stop hass
ansible.builtin.systemd:
name: hass.service
state: stopped
scope: user
- name: Rewrite tplink hosts
ansible.builtin.script:
cmd: files/hass_tplink_rehost.py apply
executable: python3
register: applied
- name: Show result
ansible.builtin.debug:
var: applied.stdout_lines
always:
- name: Start hass
ansible.builtin.systemd:
name: hass.service
state: started
scope: user
+3 -1
View File
@@ -4,9 +4,11 @@ git_home: "/srv/{{ git_user }}"
# Gitea configuration
gitea_debyl_server_name: git.debyl.io
gitea_image: docker.gitea.com/gitea:1.26.1
# Pinned per instance so one can be upgraded (and verified) before the other.
gitea_debyl_image: docker.gitea.com/gitea:1.27.3
gitea_db_image: docker.io/library/postgres:14-alpine
# Skudak Gitea configuration
gitea_skudak_server_name: git.skudak.com
gitea_skudak_ssh_port: 2222
gitea_skudak_image: docker.gitea.com/gitea:1.27.3
+1 -1
View File
@@ -43,7 +43,7 @@
become_user: "{{ git_user }}"
containers.podman.podman_container:
name: gitea-skudak
image: "{{ gitea_image }}"
image: "{{ gitea_skudak_image }}"
pod: gitea-skudak-pod
restart_policy: on-failure:3
log_driver: journald
+6 -6
View File
@@ -10,7 +10,7 @@
state: started
ports:
- "3100:3000"
tags: gitea
tags: gitea, gitea-debyl
# PostgreSQL container in pod
- name: create gitea-debyl-postgres container
@@ -28,7 +28,7 @@
POSTGRES_PASSWORD: "{{ gitea_debyl_db_pass }}"
volumes:
- "{{ git_home }}/volumes/gitea/psql:/var/lib/postgresql/data"
tags: gitea
tags: gitea, gitea-debyl
# Gitea container in pod
- name: create gitea-debyl container
@@ -36,7 +36,7 @@
become_user: "{{ git_user }}"
containers.podman.podman_container:
name: gitea-debyl
image: "{{ gitea_image }}"
image: "{{ gitea_debyl_image }}"
pod: gitea-debyl-pod
restart_policy: on-failure:3
log_driver: journald
@@ -66,7 +66,7 @@
volumes:
- "{{ git_home }}/volumes/gitea/data:/data"
- /etc/localtime:/etc/localtime:ro
tags: gitea
tags: gitea, gitea-debyl
# Generate systemd service for the pod
- name: create systemd job for gitea-debyl-pod
@@ -80,7 +80,7 @@
args:
chdir: "{{ git_home }}"
changed_when: false
tags: gitea
tags: gitea, gitea-debyl
- name: enable gitea-debyl-pod service
become: true
@@ -91,4 +91,4 @@
enabled: true
state: started
scope: user
tags: gitea
tags: gitea, gitea-debyl
+96
View File
@@ -0,0 +1,96 @@
# gitea-actions
Runs the Gitea Actions runners on `home.debyl.io`. One `act_runner` process per
Gitea instance (`git.debyl.io`, `git.skudak.com`), both as the `gitea-runner`
user, both backed by the same rootless podman image store.
## CI job images
Jobs do not run on the host. Each one gets an ephemeral container from one of
three images:
| `runs-on` / `container:` | Image | Used by |
| --- | --- | --- |
| `fedora`, `ubuntu-latest`, `ubuntu-22.04` | `git.debyl.io/gitbot/gitea-ci:latest` | Go / node / web jobs, `docker build` |
| `container: image:` | `git.debyl.io/gitbot/gitea-ci-espidf:<esp_idf_version>` | esp-mg-tpms, skudak/esp32-stm32-vcu |
| `container: image:` | `git.debyl.io/gitbot/gitea-ci-platformio:<pio_espressif32_version>` | skudak/esp32-web-interface |
**This role does not build them.** `.gitea/workflows/ci-images.yml` builds
`files/Containerfile.*` and pushes to the Gitea registry; the role logs
`gitea-runner` in and pulls. Version pins live in `defaults/main.yml` and are
read by both the role and the workflow, so a bump moves the image tag in one
place.
### Why the registry
The images used to exist only as `localhost/gitea-ci*` in the runner's store.
The nightly prune (`roles/podman`, `podman_prune_ci_until: 48h`) deletes any
CI-user image older than that which no container holds, so after an idle
weekend every job failed in under a second on `docker pull
localhost/gitea-ci:latest`, and the only fix was re-running this role and
waiting out a full rebuild.
Two things now keep that from happening:
- **A registry copy.** `force_pull` stays `false`, which in act_runner means
*pull only when missing* — so a present image is never re-fetched, and a
pruned one is restored by the next job without anyone noticing.
- **A prune exemption.** Each Containerfile declares
`LABEL io.debyl.ci-base="true"`, and the prune skips that label
(`podman_prune_ci_keep_label`). Its `until` counts from build time, not pull
time, so without this a re-pulled image would be deleted again the same night
— a 7.8 GB ESP-IDF download every single day.
The label is declared in the Containerfile rather than passed as `--label` so
neither builder can omit it; the workflow re-checks it with `docker inspect`
before pushing.
### Authentication
Both the role and act_runner read `/home/gitea-runner/.docker/config.json`.
act_runner uses it for the job-image pull it performs when a label's image is
missing; podman falls back to the same file. The role writes it from
`gitea_registry_username` / `gitea_registry_token` (vault), so one login covers
both. The `skudak` runner pulls from `git.debyl.io` too — same host, same user,
same file.
The workflow pushes with a `REGISTRY_TOKEN` secret on `bastian/deploy_home`,
belonging to the same `gitbot` user: Gitea authorises a package push by the
token's owner, not by the path, so pushing to `gitbot/` means logging in as
`gitbot`.
Both tokens need the `write:package` scope, not just `read:package`: the
workflow pushes with `REGISTRY_TOKEN`, and the `gitea_ci_build_local` bootstrap
below pushes with the vault token. A read-only token logs in and pulls fine but
fails the push with `authentication required` (Gitea logs `reqPackageAccess`).
### Rebuilding
Normally nothing to do — edit a `files/Containerfile.*` or a version pin, push
to `master`, and the workflow rebuilds only the affected images. It also
rebuilds everything weekly so base-image updates land without a commit, and
takes a `workflow_dispatch` with an image selector.
Pull requests build but do not push, under a throwaway `:pr-<n>` tag that is
deleted afterwards. The build runs in the live runner's image store, so a PR
tagged with the real name would hand every later job on this host an unmerged
image.
### Bootstrap / CI is down
The workflow that builds `gitea-ci` runs *in* `gitea-ci`, so a registry that has
never held it cannot bootstrap itself. Build on the host instead:
```sh
make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true"
```
That builds all three from the same Containerfiles and pushes them. One run is
enough even on a cold registry: `tasks/main.yml` imports `images.yml` before
`runner.yml`, so the images are published before the runner labels are flipped
to point at them.
The alternative first-time path is to merge the workflow and dispatch it while
the deployed labels still say `localhost/` — the job then builds inside the old
local image and seeds the registry — then run a plain
`make deploy TAGS=gitea-actions` to switch the labels over.
+57 -7
View File
@@ -22,20 +22,70 @@ act_runner_bin: /usr/local/bin/act_runner
act_runner_config_dir: /etc/act_runner
act_runner_work_dir: /var/lib/act_runner
# Job container images (built locally into the gitea-runner rootless image
# store by tasks/images.yml; never pulled — force_pull is false).
gitea_ci_image: localhost/gitea-ci:latest
# Job container images, served from the Gitea container registry.
#
# They used to live only under localhost/, built by this role. The nightly
# podman prune (roles/podman: podman_prune_ci_until) deletes any CI-user image
# older than 48h that no container is using, so every idle weekend CI failed in
# 0-1s on `docker pull localhost/gitea-ci:latest` until someone re-ran the role
# and waited out a full rebuild.
#
# Now .gitea/workflows/ci-images.yml builds them from files/Containerfile.* and
# pushes them here, and this role only pulls. A pruned image is re-pulled by the
# next job on its own (force_pull stays false, which means "pull only when
# missing", so a present image is never re-fetched).
#
# Workflows that pin `container: image:` must use these registry paths too
# (esp-mg-tpms, skudak/esp32-stm32-vcu, skudak/esp32-web-interface).
gitea_ci_registry: git.debyl.io
# Namespace = the owner of gitea_registry_username / gitea_registry_token (vault).
gitea_ci_registry_namespace: gitbot
gitea_ci_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci:latest"
# ESP-IDF firmware image tag tracks the upstream espressif/idf release we build from.
esp_idf_version: v5.4.1
gitea_ci_espidf_image: "localhost/gitea-ci-espidf:{{ esp_idf_version }}"
esp_idf_version: v5.5.1
gitea_ci_espidf_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-espidf:{{ esp_idf_version }}"
# PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface).
# Tag tracks the pre-baked espressif32 platform version; both pins match the
# hardware-validated local build.
platformio_core_version: "6.1.19"
pio_espressif32_version: "7.0.1"
gitea_ci_platformio_image: "localhost/gitea-ci-platformio:{{ pio_espressif32_version }}"
gitea_ci_platformio_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-platformio:{{ pio_espressif32_version }}"
# Default labels for every runner — map runs-on values to the local CI image.
# Registry credentials for the gitea-runner user. The Docker-format path is read
# by both act_runner (to authenticate job image pulls) and podman (as its
# fallback auth file), so one login covers the runner and this role.
gitea_ci_registry_authfile: "{{ gitea_runner_home }}/.docker/config.json"
# The images this role keeps present on the runner. `build_args` is a literal
# podman-build argument string (podman_image has no structured build-arg
# option) and is only used by the gitea_ci_build_local fallback below -- the
# workflow passes the same --build-arg values, read out of the version vars
# above, so there is one source of truth for the pins.
gitea_ci_images:
- image: "{{ gitea_ci_image }}"
containerfile: Containerfile.ci
build_args: ""
- image: "{{ gitea_ci_espidf_image }}"
containerfile: Containerfile.espidf
build_args: "--build-arg ESP_IDF_VERSION={{ esp_idf_version }}"
- image: "{{ gitea_ci_platformio_image }}"
containerfile: Containerfile.platformio
build_args: >-
--build-arg PLATFORMIO_CORE_VERSION={{ platformio_core_version }}
--build-arg PIO_ESPRESSIF32_VERSION={{ pio_espressif32_version }}
# Escape hatch: build the images on the host and push them, instead of pulling
# what CI published. Needed to seed a brand-new registry namespace, and when CI
# itself is down -- the workflow that builds gitea-ci runs *in* gitea-ci, so a
# registry that has never held it cannot bootstrap itself.
#
# make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true"
#
# Off by default: a plain deploy should never sit through a 15-minute ESP-IDF
# rebuild, and two publishers racing on the same tag is worth avoiding.
gitea_ci_build_local: false
# Default labels for every runner — map runs-on values to the registry CI image.
# Firmware jobs opt into the ESP-IDF image per-job via `container:` in their workflow.
gitea_runner_labels:
- "fedora:docker://{{ gitea_ci_image }}"
@@ -1,8 +1,18 @@
# Default Gitea Actions job image (managed by ansible: roles/gitea-actions).
# Covers Go/web/node jobs plus `docker build` (talks to the mounted rootless
# podman socket). Go toolchains are provided per-job by actions/setup-go.
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result (see gitea_ci_build_local for the local-build fallback).
# A plain Containerfile, not a template, so CI and ansible build the same bytes.
FROM node:20-bookworm-slim
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls a gigabyte.
LABEL io.debyl.ci-base="true"
ARG DOCKER_CLI_VERSION=27.3.1
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -14,7 +14,19 @@
# the release aborts *after* the firmware and version.json are already live —
# clients get the new build while the tag, Gitea release and protocol manifest
# are never written. Keep it installed.
FROM espressif/idf:{{ esp_idf_version }}
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result. ESP_IDF_VERSION is a build arg rather than an ansible
# template var so CI and ansible build the same bytes -- its value is read from
# esp_idf_version in roles/gitea-actions/defaults/main.yml by both.
ARG ESP_IDF_VERSION
FROM espressif/idf:${ESP_IDF_VERSION}
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls 7.8 GB.
LABEL io.debyl.ci-base="true"
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates unzip jq python3-yaml python3-jinja2 \
@@ -8,8 +8,23 @@
# was validated on hardware — bump pio_espressif32_version /
# platformio_core_version in defaults/main.yml to upgrade (the image tag
# tracks the platform version).
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result. The pins are build args rather than ansible template
# vars so CI and ansible build the same bytes -- their values are read from
# platformio_core_version / pio_espressif32_version in
# roles/gitea-actions/defaults/main.yml by both.
FROM python:3.12-slim-bookworm
ARG PLATFORMIO_CORE_VERSION
ARG PIO_ESPRESSIF32_VERSION
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls a gigabyte.
LABEL io.debyl.ci-base="true"
ENV PLATFORMIO_CORE_DIR=/opt/platformio
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -18,12 +33,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir platformio=={{ platformio_core_version }}
RUN pip install --no-cache-dir platformio==${PLATFORMIO_CORE_VERSION}
# Seed project mirroring the real projects' platformio.ini so `pio pkg install`
# pulls the platform + toolchain + framework packages into the core dir.
# %s + a quoted argument, not ${...} inside the single-quoted format string:
# RUN is `sh -c`, and sh does not expand inside single quotes, so an inlined
# ${PIO_ESPRESSIF32_VERSION} would be written to platformio.ini literally.
RUN mkdir -p /tmp/seed/src \
&& printf '[env:seed]\nplatform = espressif32@{{ pio_espressif32_version }}\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' > /tmp/seed/platformio.ini \
&& printf '[env:seed]\nplatform = espressif32@%s\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' "${PIO_ESPRESSIF32_VERSION}" > /tmp/seed/platformio.ini \
&& pio pkg install -d /tmp/seed \
&& pio pkg install -d /tmp/seed --tool platformio/tool-mkspiffs \
&& rm -rf /tmp/seed \
+70 -55
View File
@@ -1,4 +1,51 @@
---
# CI job images. .gitea/workflows/ci-images.yml builds files/Containerfile.*
# and pushes them to the Gitea registry; this role only logs the runner in and
# makes sure the images are present, so a plain deploy never waits on a build.
#
# Set gitea_ci_build_local=true to build and push from here instead -- see the
# comment on that variable in defaults/main.yml.
- name: create gitea-runner registry auth directory
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.file:
path: "{{ gitea_ci_registry_authfile | dirname }}"
state: directory
mode: "0700"
tags: gitea-actions
# Docker-format path on purpose: act_runner reads ~/.docker/config.json to
# authenticate the job-image pull it does when a label's image is missing, and
# podman falls back to the same file. One login covers both.
- name: log gitea-runner in to the Gitea container registry
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_login:
registry: "{{ gitea_ci_registry }}"
username: "{{ gitea_registry_username }}"
password: "{{ gitea_registry_token }}"
authfile: "{{ gitea_ci_registry_authfile }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
no_log: true
tags: gitea-actions
- name: pull CI images from the registry
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ item.image }}"
auth_file: "{{ gitea_ci_registry_authfile }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
loop: "{{ gitea_ci_images }}"
loop_control:
label: "{{ item.image }}"
when: not (gitea_ci_build_local | bool)
tags: gitea-actions
# --- local build fallback (gitea_ci_build_local=true) ------------------------
# Only reached when seeding a new namespace or when CI cannot build for us.
- name: create CI image build directory
become: true
become_user: "{{ gitea_runner_user }}"
@@ -6,73 +53,41 @@
path: "{{ gitea_runner_home }}/ci-images"
state: directory
mode: "0755"
when: gitea_ci_build_local | bool
tags: gitea-actions
- name: stage default CI Containerfile
# copy, not template: these are plain Containerfiles that CI builds verbatim.
# Versions come in as --build-arg from the same defaults/main.yml the workflow
# reads, so neither builder can drift from the other.
- name: stage CI Containerfiles
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.template:
src: Containerfile.ci
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.ci"
ansible.builtin.copy:
src: "{{ item.containerfile }}"
dest: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
mode: "0644"
register: ci_containerfile
loop: "{{ gitea_ci_images }}"
loop_control:
label: "{{ item.containerfile }}"
when: gitea_ci_build_local | bool
tags: gitea-actions
- name: stage ESP-IDF CI Containerfile
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.template:
src: Containerfile.espidf.j2
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.espidf"
mode: "0644"
register: espidf_containerfile
tags: gitea-actions
- name: build default CI image ({{ gitea_ci_image }})
- name: build and push CI images
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ gitea_ci_image }}"
name: "{{ item.image }}"
path: "{{ gitea_runner_home }}/ci-images"
build:
file: "{{ gitea_runner_home }}/ci-images/Containerfile.ci"
force: "{{ ci_containerfile is changed }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
tags: gitea-actions
- name: stage PlatformIO CI Containerfile
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.template:
src: Containerfile.platformio.j2
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.platformio"
mode: "0644"
register: platformio_containerfile
tags: gitea-actions
- name: build ESP-IDF CI image ({{ gitea_ci_espidf_image }})
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ gitea_ci_espidf_image }}"
path: "{{ gitea_runner_home }}/ci-images"
build:
file: "{{ gitea_runner_home }}/ci-images/Containerfile.espidf"
force: "{{ espidf_containerfile is changed }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
tags: gitea-actions
- name: build PlatformIO CI image ({{ gitea_ci_platformio_image }})
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ gitea_ci_platformio_image }}"
path: "{{ gitea_runner_home }}/ci-images"
build:
file: "{{ gitea_runner_home }}/ci-images/Containerfile.platformio"
force: "{{ platformio_containerfile is changed }}"
file: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
extra_args: "{{ item.build_args }}"
force: true
push: true
auth_file: "{{ gitea_ci_registry_authfile }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
loop: "{{ gitea_ci_images }}"
loop_control:
label: "{{ item.image }}"
when: gitea_ci_build_local | bool
tags: gitea-actions
+56 -10
View File
@@ -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:
@@ -148,6 +154,46 @@ LibreSign setup failures and buys nothing at this scale.
(LibreSign issue #4872).
## cloud.debyltech.com accounts
Registration is off, so every account is created by an admin in
Settings → Accounts. The isolation policy in
`tasks/containers/debyltech/cloud.yml` is enforced on every deploy and checked
by the verify script.
**Staff**: add to the `admin` group (the only group allowed to share) and to
`cloud_debyltech_staff_users` in `defaults/main.yml`, so the next deploy
exempts them from the 0 B default quota. Until then, set the account's quota
to *Unlimited* by hand.
**Customer**:
1. Create a group per customer, e.g. `Customer - Acme`. Use a consistent
prefix: autocomplete is off, so you share by typing the **exact** group
name.
2. Create the account with only their email filled in and no password, in
that group. Nextcloud sends a branded welcome email with a set-password
link.
3. Share a folder (for example `Customers/Acme`) with the group. It defaults
to **View only**; choose *Allow editing* only if they should upload.
What a customer gets:
- read-only access to what's shared with them
- no personal storage (0 B quota)
- no sharing or public links
- no view of other accounts or groups: the share search and contacts menu
return nothing
What they can open: Files, Activity, and LibreSign for signing only.
Dashboard and Office are staff-only. Promotional or directory-style apps
(first-run wizard, recommendations, weather, Photos, contacts interaction,
lookup server, ...) are disabled for everyone. Only staff can *request*
signatures.
Signature requests to customers need no account: use LibreSign with their
email address. LibreSign stays enabled for all accounts on purpose.
Restricting an app to a group also blocks visitors who aren't logged in,
which would break the public signing links.
## Logging
Every container runs with `log_driver=journald`, so container stdout lands in
+44
View File
@@ -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,37 @@ 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
# Staff vs customers (see "customer isolation" in containers/debyltech/cloud.yml).
# Only this group may share; everyone else -- customers -- can only read what
# is shared with them. Staff accounts are exempted from the 0 B default quota.
# Accounts listed here that do not exist yet are skipped, not created.
cloud_debyltech_staff_group: admin
cloud_debyltech_staff_users:
- admin
- bastian@debyltech.com
# Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security
@@ -284,6 +316,7 @@ caddy_log_names:
- graylog
- cloud
- cloud-skudak
- cloud-debyltech
- gitea-debyl
- gitea-skudak
- fulfillr
@@ -311,6 +344,14 @@ podman_prune_ci_users:
- gitea-runner
- actions-runner
podman_prune_ci_until: 48h
# The CI base images declare LABEL io.debyl.ci-base="true" in
# roles/gitea-actions/files/Containerfile.* (and .gitea/workflows/ci-images.yml
# verifies it before publishing -- keep all three in sync). Images carrying it
# are skipped below: `until` counts from build time and not pull time, so
# without the exemption a re-pulled image would be deleted again the next
# night, a 7.8 GB ESP-IDF re-download after every idle day. Superseded tags
# (e.g. after an esp_idf_version bump) survive too; remove those by hand.
podman_prune_ci_keep_label: io.debyl.ci-base
# Daily rather than weekly: CI turns over many images a day, and a week of that
# is what let the store reach 113 GB between runs.
@@ -326,3 +367,6 @@ cifs_watchdog_mounts:
- "{{ photos_path }}/storage"
- "{{ photos_path }}/immich"
# GA4 property for the fulfillr portal Traffic & funnel tab (SCRUM-196, non-secret).
# Shared by dev and prod. The service-account key `fulfillr_ga4_credentials_json` lives in the vault.
fulfillr_ga4_property_id: "353859448"
@@ -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,436 @@
<?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,
);
// Opt out of mail-client dark mode. Without this Apple Mail repaints
// the white ground charcoal on its own, and the black wordmark all but
// disappears. Swapping to a white logo under prefers-color-scheme is
// NOT a fix: with Apple Mail's "Use dark backgrounds for messages" off,
// the query still matches while the ground stays white, leaving a
// white-on-white logo. This mail is designed light; render it light.
$this->head = str_replace(
'</head>',
'<meta name="color-scheme" content="light only">'
. '<meta name="supported-color-schemes" content="light only">'
. '<style type="text/css">:root{color-scheme:light only;supported-color-schemes:light only}</style>'
. '</head>',
$this->head,
);
}
/**
* 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">&#xA0;</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>
&#160;&#183;&#160;
<a href="{$site}/legal/tos" style="color:{$muted};text-decoration:underline">Terms of Use</a>
&#160;&#183;&#160;
<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">
&copy; {$year} {$entity}. All rights reserved.<br/>
Automated message &mdash; 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);
}
}
+204 -592
View File
@@ -1,237 +1,78 @@
- id: '1649042328061'
alias: Lights - 01 - On
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
domain: switch
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 100
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 100
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 75
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 100
mode: single
- id: '1707432903086'
alias: Driveway String Lights Off
description: ''
trigger:
- platform: time
triggers:
- trigger: time
at: '23:00:00'
condition: []
action:
- type: turn_off
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
domain: switch
mode: single
- id: '1707433130493'
alias: Lights - 02 - Early Dim
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 50
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 50
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 50
mode: single
- id: '1707433185560'
alias: Lights - 03 - Mid Dim
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 25
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 25
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 25
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 25
mode: single
- id: '1707433226166'
alias: Lights - 04 - Late Dim
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 1
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 1
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 10
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 10
- action: light.turn_on
metadata: {}
data:
brightness_pct: 1
- action: switch.turn_off
target:
area_id: bedroom
enabled: false
mode: single
- id: '1711218890065'
alias: Lights - 10 - Off
description: ''
triggers: []
conditions: []
actions:
- type: turn_off
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
- type: turn_off
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
- type: turn_off
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
- type: turn_off
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
- type: turn_off
device_id: 03eb359bf2344a58bebfe1e9c5bcfadd
entity_id: a30b2da3cd80a5b4c927e1608b91eb65
domain: light
- type: turn_off
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
entity_id: switch.driveway_string_lights
mode: single
- id: '1739912161794'
alias: Lights - 00 - Morning
description: ''
triggers:
- trigger: time
at: 09:00:00
at: '09:00:00'
conditions: []
actions:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 100
- action: light.turn_on
data:
brightness_pct: 100
target:
entity_id: light.bathroom_hallway
mode: single
- id: '1762116115638'
alias: Light - TV On
description: ''
description: TV mode on; once it's dark, dim the living room and turn off the
lights that glare on the TV
triggers:
- type: turned_on
device_id: 18a9bb7a2a32be4371da447767ef50a9
entity_id: c05688f2610e27e2d86380e2945ceae5
domain: remote
trigger: device
- trigger: state
entity_id: remote.samsung_tv
to: 'on'
not_from:
- unavailable
- unknown
conditions: []
actions:
- action: input_boolean.turn_on
target:
entity_id: input_boolean.tv_mode
- action: light.turn_on
metadata: {}
data:
brightness_pct: 5
target:
area_id: living_room
- type: turn_off
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
- type: turn_off
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
- type: turn_off
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
- if:
- condition: or
conditions:
- condition: sun
after: sunset
after_offset: "-01:00:00"
- condition: sun
before: sunrise
then:
- action: light.turn_on
data:
brightness_pct: 5
target:
area_id: living_room
- action: light.turn_off
target:
entity_id:
- light.kitchen_wall_light
- light.dining_hall
- light.bathroom_hallway
mode: single
- id: new_tv_off
alias: Light - TV Off - Restore
description: Restores appropriate lighting level when TV turns off based on time
description: Evening (sunset -1h to 23:30) brings the lights back to the
scheduled level; late night (23:30 to sunrise) only turns off the TV glow;
daytime leaves the lights alone
triggers:
- type: turned_off
device_id: 18a9bb7a2a32be4371da447767ef50a9
entity_id: c05688f2610e27e2d86380e2945ceae5
domain: remote
trigger: device
- trigger: state
entity_id: remote.samsung_tv
to: 'off'
not_from:
- unavailable
- unknown
conditions: []
actions:
- action: input_boolean.turn_off
@@ -239,409 +80,179 @@
entity_id: input_boolean.tv_mode
- choose:
- conditions:
- condition: sun
after: sunset
after_offset: "-01:00:00"
- condition: time
after: '22:30:00'
before: '23:45:00'
before: '23:30:00'
sequence:
# Late dim levels
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 1
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 1
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 10
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 10
- action: script.evening_lights_apply
data:
apply: force
- action: switch.turn_on
target:
entity_id: switch.desk_lamp
- conditions:
- condition: time
after: '21:30:00'
before: '22:30:00'
- condition: or
conditions:
- condition: time
after: '23:30:00'
- condition: sun
before: sunrise
sequence:
# Mid dim levels
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 25
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 25
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 25
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 25
- conditions:
- condition: time
after: '21:00:00'
before: '21:30:00'
sequence:
# Early dim levels
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 50
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 50
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 50
default:
# Full brightness (before 21:00 after sunset)
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 100
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 100
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 75
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 100
- action: light.turn_off
target:
area_id: living_room
- action: switch.turn_off
target:
entity_id: switch.desk_lamp
mode: single
- id: 'sunset_lights_on'
alias: Lights - Sunset On
description: Turn on lights 1 hour before sunset
- id: driveway_lights_on
alias: Driveway String Lights On
description: On 1 hour before sunset whether or not the TV is on. Also catches
up if Home Assistant restarts before the 23:00 off
triggers:
- trigger: sun
event: sunset
offset: "-01:00:00"
id: sunset
- trigger: homeassistant
event: start
conditions:
- condition: state
entity_id: input_boolean.tv_mode
entity_id: switch.driveway_string_lights
state: 'off'
- condition: or
conditions:
- condition: trigger
id: sunset
- condition: and
conditions:
- condition: sun
after: sunset
after_offset: "-01:00:00"
- condition: time
before: '23:00:00'
actions:
- type: turn_on
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
domain: switch
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 100
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 100
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 75
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 100
- action: switch.turn_on
target:
entity_id: switch.driveway_string_lights
mode: single
- id: 'evening_dim_2100'
alias: Lights - Evening Dim (21:00)
description: Dim lights at 21:00 - only affects lights that are ON
- id: 'sunset_lights_on'
alias: Lights - Sunset On
description: Turn on lights 1 hour before sunset. If the TV is on, the living
room gets the TV glow and the lights that glare on the TV stay off
triggers:
- trigger: time
at: "21:00:00"
conditions:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
actions:
- if:
- condition: device
device_id: 03a12d2360d9954aed19c2449070725a
domain: light
entity_id: 7c1e7db73799cc3f90948b5118596985
type: is_on
then:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
domain: light
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
type: is_on
then:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: 3f7f65571d9bb0833433996f1f6725bd
domain: light
entity_id: 7407afe14783543252c666d5ff7c5d5c
type: is_on
then:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
domain: light
entity_id: 81c486d682afcc94e98e377475cc92fc
type: is_on
then:
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 50
mode: single
- id: 'mid_dim_2130'
alias: Lights - Mid Dim (21:30)
description: Dim lights at 21:30 - only affects lights that are ON
triggers:
- trigger: time
at: "21:30:00"
conditions:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
actions:
- if:
- condition: device
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
domain: light
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
type: is_on
then:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 03a12d2360d9954aed19c2449070725a
domain: light
entity_id: 7c1e7db73799cc3f90948b5118596985
type: is_on
then:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 800eddbeeda071225f181a14cb9527e0
domain: light
entity_id: 521a92ddd8be76c7eddfc544f81f6020
type: is_on
then:
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: 3f7f65571d9bb0833433996f1f6725bd
domain: light
entity_id: 7407afe14783543252c666d5ff7c5d5c
type: is_on
then:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
domain: light
entity_id: 81c486d682afcc94e98e377475cc92fc
type: is_on
then:
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 25
mode: single
- id: 'late_dim_2230'
alias: Lights - Late Dim (22:30)
description: Dim lights at 22:30 - only affects lights that are ON
triggers:
- trigger: time
at: "22:30:00"
conditions:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
actions:
- if:
- condition: device
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
domain: light
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
type: is_on
then:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 1
- if:
- condition: device
device_id: 03a12d2360d9954aed19c2449070725a
domain: light
entity_id: 7c1e7db73799cc3f90948b5118596985
type: is_on
then:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 1
- if:
- condition: device
device_id: 800eddbeeda071225f181a14cb9527e0
domain: light
entity_id: 521a92ddd8be76c7eddfc544f81f6020
type: is_on
then:
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 3f7f65571d9bb0833433996f1f6725bd
domain: light
entity_id: 7407afe14783543252c666d5ff7c5d5c
type: is_on
then:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 10
- if:
- condition: device
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
domain: light
entity_id: 81c486d682afcc94e98e377475cc92fc
type: is_on
then:
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 10
mode: single
- id: 'lights_out_2345'
alias: Lights - Out (23:45)
description: Turn off all lights at 23:45
triggers:
- trigger: time
at: "23:45:00"
- trigger: sun
event: sunset
offset: "-01:00:00"
conditions: []
actions:
- type: turn_off
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
- type: turn_off
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
- type: turn_off
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
- type: turn_off
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
- type: turn_off
device_id: 03eb359bf2344a58bebfe1e9c5bcfadd
entity_id: a30b2da3cd80a5b4c927e1608b91eb65
domain: light
- type: turn_off
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
- action: script.evening_lights_apply
data:
apply: force
- if:
- condition: state
entity_id: input_boolean.tv_mode
state: 'on'
then:
- action: light.turn_on
data:
brightness_pct: 5
target:
area_id: living_room
else:
- action: switch.turn_on
target:
entity_id: switch.desk_lamp
mode: single
- id: evening_dim_ramp
alias: Lights - Evening Dim Ramp
description: Every 5 minutes from 20:30 to 23:30, ease the lights that are on
toward the schedule in script.evening_lights_apply
triggers:
- trigger: time_pattern
minutes: /5
conditions:
- condition: time
after: '20:30:00'
before: '23:30:00'
actions:
- action: script.evening_lights_apply
data:
apply: ramp
mode: single
- id: 'lights_out_2345'
alias: Lights - Out (23:30)
description: Turn off all lights at 23:30. While the TV is on the living room
is left as it is
triggers:
- trigger: time
at: "23:30:00"
conditions: []
actions:
- action: light.turn_off
target:
entity_id:
- light.kitchen_lights
- light.kitchen_wall_light
- light.dining_hall
- light.dining_room
- light.bathroom_hallway
- if:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
then:
- action: light.turn_off
target:
entity_id: light.living_room
- action: switch.turn_off
target:
entity_id: switch.desk_lamp
mode: single
- id: lights_sweep_0100
alias: Lights - Sweep (01:00)
description: Catch anything turned back on after lights-out. While the TV is
on the living room is left as it is
triggers:
- trigger: time
at: "01:00:00"
conditions: []
actions:
- action: light.turn_off
target:
entity_id:
- light.kitchen_lights
- light.kitchen_wall_light
- light.dining_hall
- light.dining_room
- light.bathroom_hallway
- action: switch.turn_off
target:
entity_id: switch.driveway_string_lights
- if:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
then:
- action: light.turn_off
target:
entity_id: light.living_room
- action: switch.turn_off
target:
entity_id: switch.desk_lamp
mode: single
- id: '1768862300896'
alias: Bedroom On
description: ''
triggers:
- type: turned_on
device_id: afb9734fe9b187ab6881a64d24e1c2f5
entity_id: 27efa149b9ebb388e7c21ba89e671b42
domain: switch
trigger: device
- trigger: state
entity_id: switch.bedroom_light
to: 'on'
not_from:
- unavailable
- unknown
conditions: []
actions:
- action: light.turn_on
@@ -655,11 +266,12 @@
alias: Bedroom Off
description: ''
triggers:
- type: turned_off
device_id: afb9734fe9b187ab6881a64d24e1c2f5
entity_id: 27efa149b9ebb388e7c21ba89e671b42
domain: switch
trigger: device
- trigger: state
entity_id: switch.bedroom_light
to: 'off'
not_from:
- unavailable
- unknown
conditions: []
actions:
- action: light.turn_off
@@ -23,6 +23,7 @@ homeassistant:
media: /share
automation: !include automations.yaml
script: !include scripts.yaml
input_boolean:
tv_mode:
@@ -0,0 +1,76 @@
evening_lights_apply:
alias: Evening Lights - Apply Schedule
description: >-
Sets each evening light to its scheduled brightness for the current time,
blending linearly between the points in `schedule`. apply=force turns the
lights on (sunset, TV off); apply=ramp only eases lights that are already
on, and leaves alone any light someone has changed by hand. While TV mode
is on the living room and the lights that glare on the TV are left alone.
mode: queued
fields:
apply:
description: "force: turn lights on at the target. ramp: only adjust lights that are already on."
example: ramp
selector:
select:
options:
- force
- ramp
variables:
# [minute of day, brightness %] - 1230 = 20:30, 1260 = 21:00,
# 1290 = 21:30, 1350 = 22:30. Before the first point a light sits at the
# first value; after the last it holds the last value until lights-out.
schedule:
light.kitchen_lights: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]]
light.kitchen_wall_light: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]]
light.bathroom_hallway: [[1230, 75], [1260, 50], [1290, 25], [1350, 10]]
light.living_room: [[1230, 100], [1260, 50], [1290, 25], [1350, 10]]
light.dining_hall: [[1290, 25], [1350, 15]]
tv_mode_lights:
- light.living_room
- light.kitchen_wall_light
- light.dining_hall
- light.bathroom_hallway
apply_mode: "{{ apply | default('ramp') }}"
sequence:
- repeat:
for_each: "{{ schedule.keys() | list }}"
sequence:
- variables:
light: "{{ repeat.item }}"
# [target now, target 5 minutes ago - what the last ramp tick set]
levels: >-
{%- macro at(pts, t) -%}
{%- if t <= pts[0][0] -%}{{ pts[0][1] }}
{%- elif t >= pts[-1][0] -%}{{ pts[-1][1] }}
{%- else -%}
{%- for i in range(pts | length - 1) if pts[i][0] <= t < pts[i + 1][0] -%}
{{ (pts[i][1] + (pts[i + 1][1] - pts[i][1]) * (t - pts[i][0]) / (pts[i + 1][0] - pts[i][0])) | round(0) | int }}
{%- endfor -%}
{%- endif -%}
{%- endmacro -%}
{%- set t = now().hour * 60 + now().minute -%}
{{ [at(schedule[repeat.item], t) | int, at(schedule[repeat.item], t - 5) | int] }}
current: "{{ ((state_attr(repeat.item, 'brightness') or 0) / 2.55) | round(0) | int }}"
skip: "{{ is_state('input_boolean.tv_mode', 'on') and repeat.item in tv_mode_lights }}"
- choose:
- conditions: "{{ not skip and apply_mode == 'force' }}"
sequence:
- action: light.turn_on
target:
entity_id: "{{ light }}"
data:
brightness_pct: "{{ levels[0] }}"
transition: 2
# A light more than 10 points off the last tick was set by hand; the
# biggest scheduled change in 5 minutes is ~8
- conditions: >-
{{ not skip and apply_mode == 'ramp' and is_state(light, 'on')
and (current - levels[1]) | abs <= 10 and current != levels[0] }}
sequence:
- action: light.turn_on
target:
entity_id: "{{ light }}"
data:
brightness_pct: "{{ levels[0] }}"
transition: 60
@@ -0,0 +1,855 @@
---
# 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"
# Settings Skudak only ever had from clicks in the LibreSign admin page, never
# in git -- a fresh instance without them cannot invite anyone by address:
#
# identify_methods The EMAIL identification method. Without it the signer
# search only lists accounts, so an outside address
# returns a bare "No signers." -- the whole point of this
# instance. clickToSign (no emailed code) and
# can_create_account=false, as on Skudak: the emailed link
# is the identity check, and customers never get accounts.
# signature_background_type=deleted
# Drops the LibreSign logo watermark from behind the
# stamp, so with GRAPHIC_ONLY the stamp is the drawn mark
# and nothing else.
# collect_metadata Records signer IP/user agent alongside each signature.
- name: set libresign signer identification and stamp settings in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign {{ item.k }} --value={{ item.v | quote }}
register: debyltech_libresign_settings
changed_when: "'is now set to' in debyltech_libresign_settings.stdout"
loop:
- k: identify_methods
v: >-
{{ [{'name': 'email', 'friendly_name': 'Email', 'enabled': true,
'mandatory': true,
'signatureMethods': {
'clickToSign': {'name': 'clickToSign', 'enabled': true},
'emailToken': {'name': 'emailToken', 'enabled': false}},
'can_create_account': false,
'test_url': '/index.php/settings/admin/mailtest',
'signatureMethodEnabled': 'clickToSign'}] | to_json }}
- {k: signature_background_type, v: deleted}
- {k: collect_metadata, v: "1"}
loop_control:
label: "{{ item.k }}"
# The validation footer ("Digitally signed by ... Validate in <url>") is WANTED
# -- only its QR code goes, below. FooterHandler defaults add_footer to true
# when unset, but the 14.2 admin page renders unset as UNCHECKED, inviting
# someone to "correct" it into actually turning the footer off. Stored
# explicitly so the page shows what the code does.
- name: keep libresign validation footer text in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign add_footer --value=1 --type=boolean
register: libresign_footer
changed_when: "'is now set to' in libresign_footer.stdout"
- name: drop libresign validation QR code from signed-PDF footer in debyltech-cloud
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
# ---------------------------------------------------------------------------
# Customer isolation. Customers are admin-created accounts in a per-customer
# group, and must only READ what staff share with them -- not upload, not
# share onward, and not discover that other customers exist. Verified
# 2026-09-28 against a test customer, both in the UI and with the sharee and
# contacts-menu search services run as that user.
#
# shareapi_exclude_groups=allow + list=[staff]
# Only staff may share. NOT "yes" (exclude mode): that only disables
# sharing for users whose groups are ALL excluded, so a customer in
# their own per-customer group would never be caught by it.
# shareapi_allow_share_dialog_user_enumeration=no
# No partial-match browsing of accounts or groups, for anyone. By
# default a customer typing "bas" found the owner's account. Staff
# share to a customer group by typing its exact name; LibreSign signers
# are found by email and are unaffected.
# shareapi_default_permissions=1
# New shares default to View only; tick "Allow editing" per share to
# let a customer upload.
# files default_quota=0 B
# No personal storage, so no "+ New" in a customer's own home. Uploads
# into a share granted editing count against the OWNER's quota and still
# work. Staff are exempted by the next task.
# dav enableDefaultContact=false
# No "Leon Green" sample contact in new address books.
- name: set debyltech-cloud customer isolation policy
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set {{ item.app }} {{ item.k }} --value={{ item.v | quote }}
{{ ('--type=' ~ item.t) if item.t is defined else '' }}
register: debyltech_isolation
changed_when: "'is now set to' in debyltech_isolation.stdout"
loop:
- {app: core, k: shareapi_exclude_groups, v: allow}
- {app: core, k: shareapi_exclude_groups_list, v: "{{ [cloud_debyltech_staff_group] | to_json }}"}
- {app: core, k: shareapi_allow_share_dialog_user_enumeration, v: "no"}
- {app: core, k: shareapi_default_permissions, v: "1"}
- {app: files, k: default_quota, v: "0 B"}
- {app: dav, k: enableDefaultContact, v: "0", t: boolean}
loop_control:
label: "{{ item.app }}.{{ item.k }}"
- name: exempt debyltech-cloud staff from the zero default quota
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
occ user:info {{ item | quote }} >/dev/null 2>&1 || exit 0
cur=$(occ user:setting {{ item | quote }} files quota) || cur='<unset>'
if [ "$cur" != none ]; then
occ user:setting {{ item | quote }} files quota none
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_staff_quota
changed_when: "'CHANGED' in debyltech_staff_quota.stdout"
loop: "{{ cloud_debyltech_staff_users }}"
# What a customer can reach. Nextcloud had nothing group-restricted, so every
# customer saw Dashboard, Photos, Office and the rest in the app menu.
#
# Disabled outright -- promos, prompts, or directory-ish features a business
# file-and-signing portal has no use for (contactsinteraction silently adds
# whoever shares with you to your address book; app_api only produces a
# setup warning here). lookup_server_connector cannot be disabled (occ refuses)
# -- the empty `lookup_server` system value below switches it off instead.
#
# Restricted to staff: dashboard and office. `defaultapp` below lists
# dashboard first so staff land there and customers fall through to Files.
#
# NOT restricted: libresign. A group restriction is enforced for anonymous
# requests too (AppManager::checkAppForUser returns false for no user), so it
# would break the public signing links sent to outside signers and to any
# customer already logged in. Who may AUTHOR requests is LibreSign's own
# groups_request_sign, pinned to staff below.
- name: disable unneeded apps in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
if occ app:list --output=json | python3 -c 'import json,sys; sys.exit(0 if sys.argv[1] in json.load(sys.stdin)["enabled"] else 1)' {{ item | quote }}; then
occ app:disable {{ item | quote }}
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_app_disable
changed_when: "'CHANGED' in debyltech_app_disable.stdout"
# occ exits 0 even when it refuses ("can't be disabled").
failed_when: debyltech_app_disable.rc != 0 or "can't be disabled" in debyltech_app_disable.stdout
loop:
- firstrunwizard
- recommendations
- related_resources
- weather_status
- survey_client
- support
- app_api
- contactsinteraction
- photos
- name: restrict staff-only apps in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
want={{ [cloud_debyltech_staff_group] | to_json | quote }}
if [ "$(occ config:app:get {{ item | quote }} enabled || true)" != "$want" ]; then
occ app:enable --groups {{ cloud_debyltech_staff_group | quote }} {{ item | quote }} >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_app_restrict
changed_when: "'CHANGED' in debyltech_app_restrict.stdout"
loop:
- dashboard
- office
- name: pin who may request libresign signatures 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 groups_request_sign
--value={{ [cloud_debyltech_staff_group] | to_json | quote }}
register: debyltech_request_sign
changed_when: "'is now set to' in debyltech_request_sign.stdout"
# ---------------------------------------------------------------------------
# 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"
# Behind rootless podman's port forwarder, every request -- Caddy's included --
# reaches Apache FROM THE CONTAINER'S OWN ADDRESS on `shared`, not from the
# host. Unless exactly that address is a trusted proxy, Nextcloud ignores the
# X-Forwarded-For header Caddy sends, so every client looks like one IP:
# brute-force throttling then penalises everyone at once. Read per deploy
# because the address is assigned at container creation, and deploys are the
# only thing that recreate it.
- name: read debyltech-cloud container address
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman inspect debyltech-cloud
--format "{{ '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' }}"
register: debyltech_cloud_ip
changed_when: false
failed_when: debyltech_cloud_ip.stdout is not match('^[0-9.]+$')
# System config, set only when it differs so a clean re-deploy reports no
# 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 "$@"; }
# An UNSET key prints nothing and exits 1 -- indistinguishable from ""
# by output alone, which would skip setting an intentionally empty value.
cur=$(occ config:system:get {{ item.k }}) || cur='<unset>'
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: trusted_proxies 0, v: "{{ debyltech_cloud_ip.stdout }}"}
# Hour in UTC: 05:00 UTC is 01:00/00:00 Eastern, ahead of the 04:15 backup.
- {k: maintenance_window_start, v: "5", t: integer}
- {k: default_phone_region, v: US}
# New accounts -- customers above all -- start with an empty home rather
# than Nextcloud's sample Manual/intro video/Readme and Templates folder.
- {k: skeletondirectory, v: ""}
- {k: templatedirectory, v: ""}
# First ENABLED app wins: staff get the dashboard, and customers -- who
# cannot open it (restricted below) -- fall through to Files.
- {k: defaultapp, v: "dashboard,files"}
# No per-account profile pages.
- {k: profile.enabled, v: "false", t: boolean}
# Never query or publish to the global lookup server (lookup.nextcloud.com).
- {k: lookup_server, v: ""}
- {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.
# Plain theming_debyltech_background instead of Nextcloud's stock blue-shapes
# image. `background backgroundColor` is a special case in UpdateConfig.php
# (absent from --help) that drops the image and sets backgroundMime, exactly
# what the admin UI's "remove background image" does.
- name: use a plain colour login background in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
if [ "$(occ config:app:get theming backgroundMime || true)" != backgroundColor ]; then
occ theming:config background backgroundColor >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_background
changed_when: "'CHANGED' in debyltech_background.stdout"
- 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"
@@ -25,6 +25,7 @@
loop:
- configuration.yaml
- automations.yaml
- scripts.yaml
- name: flush handlers
ansible.builtin.meta: flush_handlers
@@ -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
+21 -5
View File
@@ -39,7 +39,7 @@
- import_tasks: containers/home/hass.yml
vars:
image: ghcr.io/home-assistant/home-assistant:2026.8.3
image: ghcr.io/home-assistant/home-assistant:2026.9.3
tags: hass
- import_tasks: containers/home/partsy.yml
@@ -79,15 +79,31 @@
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:20260827.2009
image: git.debyl.io/debyltech/fulfillr:20260929.1624
tags: debyltech, fulfillr
# Staging back-office (fulfillr-dev.debyltech.com) — same image, staging Turso config.
- import_tasks: containers/debyltech/fulfillr-dev.yml
vars:
image: git.debyl.io/debyltech/fulfillr:20260827.2009
image: git.debyl.io/debyltech/fulfillr:20260929.1624
tags: debyltech, fulfillr-dev
- import_tasks: containers/debyltech/uptime-kuma.yml
@@ -123,14 +139,14 @@
- import_tasks: containers/home/gregtime.yml
vars:
image: localhost/greg-time-bot:3.17.3
image: localhost/greg-time-bot:3.21.1
tags: gregtime
# Built and loaded by `make deploy-remote` in ~/src/rsvp-debylio; bump this to
# the VERSION it loaded. The Caddy vhost ships with the caddy-config tag.
- import_tasks: containers/home/rsvp.yml
vars:
image: localhost/rsvpd:1.0.1
image: localhost/rsvpd:1.0.7
tags: rsvp
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken
@@ -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
@@ -53,5 +53,13 @@
"recovery": {
"schedule_name": "cart-recovery-dev",
"schedule_group": "default"
}{%- if fulfillr_ga4_credentials_json is defined %},
{# GA4 Data API for the portal Traffic & funnel tab (SCRUM-196). Renders only once the
service-account key `fulfillr_ga4_credentials_json` (the key JSON, as a mapping or string)
is in the vault; without it the traffic endpoint reports configured:false. #}
"analytics": {
"ga4_property_id": "{{ fulfillr_ga4_property_id }}",
"ga4_credentials": {{ (fulfillr_ga4_credentials_json if fulfillr_ga4_credentials_json is mapping else (fulfillr_ga4_credentials_json | from_json)) | to_json }}
}
{%- endif %}
}
@@ -53,5 +53,13 @@
"recovery": {
"schedule_name": "cart-recovery-prod",
"schedule_group": "default"
}{%- if fulfillr_ga4_credentials_json is defined %},
{# GA4 Data API for the portal Traffic & funnel tab (SCRUM-196). Renders only once the
service-account key `fulfillr_ga4_credentials_json` (the key JSON, as a mapping or string)
is in the vault; without it the traffic endpoint reports configured:false. #}
"analytics": {
"ga4_property_id": "{{ fulfillr_ga4_property_id }}",
"ga4_credentials": {{ (fulfillr_ga4_credentials_json if fulfillr_ga4_credentials_json is mapping else (fulfillr_ga4_credentials_json | from_json)) | to_json }}
}
{%- endif %}
}
@@ -0,0 +1,228 @@
<?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)";
}
}
// Only staff may AUTHOR signature requests (LibreSign cannot be group-
// restricted without breaking its public signing links).
$requesters = json_decode($appConfig->getValueString('libresign', 'groups_request_sign', ''), true);
if ($requesters !== ['{{ cloud_debyltech_staff_group }}']) {
$failures[] = 'libresign groups_request_sign is ' . json_encode($requesters)
. ' -- expected only the staff group, or customers could send signature requests';
}
$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
@@ -43,9 +43,10 @@ run() {
exec podman "$@"' _ "$@"
}
# prune_user <user> <until> <prune_containers: yes|no>
# prune_user <user> <until> <prune_containers: yes|no> [image prune filter...]
prune_user() {
local u=$1 keep=$2 do_containers=$3
shift 3
local before after img vol con
if ! id "$u" >/dev/null 2>&1; then
@@ -66,7 +67,7 @@ prune_user() {
con=$(run "$u" container prune -f --filter "until=$keep" 2>&1 | tail -1)
fi
img=$(run "$u" image prune -af --filter "until=$keep" 2>&1 | tail -1)
img=$(run "$u" image prune -af --filter "until=$keep" "$@" 2>&1 | tail -1)
vol=$(run "$u" volume prune -f 2>&1 | tail -1)
after=$(run "$u" system df --format '{{ '{{' }}.Size{{ '}}' }}' 2>/dev/null | head -1)
@@ -80,7 +81,19 @@ for u in {{ podman_prune_users | join(' ') }}; do
done
for u in {{ podman_prune_ci_users | join(' ') }}; do
prune_user "$u" "{{ podman_prune_ci_until }}" yes
# CI base images (gitea-ci, -espidf, -platformio) carry the keep label: they
# are rebuilt or re-pulled from the registry only when missing, so pruning
# them just forces a multi-GB re-download on the next job.
prune_user "$u" "{{ podman_prune_ci_until }}" yes --filter "label!={{ podman_prune_ci_keep_label }}"
# ...but the label is inherited by every build of those images, including the
# one a rebuild supersedes. That copy loses its tag and becomes dangling, and
# the label filter above would keep it forever -- a multi-GB leak per weekly
# rebuild, ESP-IDF alone being several GB. Without -a, `image prune` removes
# only dangling images, so it can ignore the label without touching the live
# tagged ones.
dangling=$(run "$u" image prune -f --filter "until={{ podman_prune_ci_until }}" 2>&1 | tail -1)
log "user=$u dangling_prune=${dangling:-none}"
done
log "status=ok"
Binary file not shown.