From 4f4ba6db79c8fc07431a2de2e1c96ad04e219614 Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Sat, 18 Jul 2026 10:44:54 +0200 Subject: [PATCH] feat: add in-stack Euro-Office integration (15.1.0) This adds an optional eurooffice instance to the recipe. Contrary to the OnlyOffice integration, which is a separate recipe, I choose this way because I suppose that most of the time the eurooffice instance will only be used by Nextcloud. There seems to be an error with the current EuroOffice official image which fails to initialize their database. For this reason it also includes a Postgres and a RabbitMQ service. Both are initialised upon first usage. --- .env.sample | 7 ++ README.md | 34 ++++++++++ abra.sh | 11 +++- compose.eurooffice.yml | 118 ++++++++++++++++++++++++++++++++++ compose.yml | 2 +- entrypoint.eurooffice.sh.tmpl | 30 +++++++++ eurooffice-createdb.sql | 73 +++++++++++++++++++++ release/15.1.0+34.0.1-fpm | 25 +++++++ 8 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 compose.eurooffice.yml create mode 100644 entrypoint.eurooffice.sh.tmpl create mode 100644 eurooffice-createdb.sql create mode 100644 release/15.1.0+34.0.1-fpm diff --git a/.env.sample b/.env.sample index 3e84dc5..d8854ed 100644 --- a/.env.sample +++ b/.env.sample @@ -69,6 +69,13 @@ DEFAULT_QUOTA="10 GB" # APPS="$APPS onlyoffice" # SECRET_ONLYOFFICE_JWT_VERSION=v1 +# Euro-Office runs its own document server in this stack; EUROOFFICE_DOMAIN +# needs its own DNS record pointing at this host. +# COMPOSE_FILE="$COMPOSE_FILE:compose.eurooffice.yml" +# EUROOFFICE_DOMAIN=eurooffice.example.com +# APPS="$APPS eurooffice" +# SECRET_EUROOFFICE_JWT_VERSION=v1 + # COMPOSE_FILE="$COMPOSE_FILE:compose.bbb.yml" # BBB_URL=https://talk.example.org/bigbluebutton/ # trailing slash! # SECRET_BBB_SECRET_VERSION=v1 diff --git a/README.md b/README.md index b317d3e..68d671f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,40 @@ Then set the onlyoffice JWT secret from the onlyoffice installation: * `abra app secret insert onlyoffice_jwt v1 ` * `abra app cmd app install_onlyoffice` +### Euro-Office Integration + +Euro-Office is the AGPL fork of OnlyOffice that powers "Nextcloud Office" from +Nextcloud 34 onwards. Like OnlyOffice it uses a client-side document-server +architecture, so a separate document server is required — but this overlay runs +that document server **inside the same stack**, so there is no external host to +manage. The browser talks to it directly, so it needs its own public HTTPS +domain (`EUROOFFICE_DOMAIN`) with a DNS record pointing at this host. + +`abra app config ` + +Enable the overlay and set the document server's domain: +``` +COMPOSE_FILE="$COMPOSE_FILE:compose.eurooffice.yml" +EUROOFFICE_DOMAIN=eurooffice.example.com +APPS="$APPS eurooffice" +SECRET_EUROOFFICE_JWT_VERSION=v1 +``` + +The overlay runs the document server with its own Postgres and RabbitMQ services +(the image's bundled Postgres is unreliable). The document server schema is +seeded into that Postgres automatically on first init (see +`eurooffice-createdb.sql`). The Postgres is internal-only and uses trust auth, so +the only secret to manage is the JWT; generate it, deploy, then wire up the +Nextcloud app: + +* `abra app secret generate -a ` +* `abra app deploy ` +* `abra app cmd app install_eurooffice` + +> Note: the document server needs ~4 GB RAM (8 GB for multi-user) and pulls the +> `ghcr.io/euro-office/documentserver` image, which currently only publishes a +> `latest` tag (no semver / Renovate pinning yet). + ### BBB Integration `abra app config ` diff --git a/abra.sh b/abra.sh index af39f71..c21047f 100644 --- a/abra.sh +++ b/abra.sh @@ -6,11 +6,13 @@ export MY_CNF_VERSION=v6 export ENTRYPOINT_VERSION=v3 export ENTRYPOINT_WHITEBOARD_VERSION=v1 export ENTRYPOINT_TALK_VERSION=v1 +export ENTRYPOINT_EUROOFFICE_VERSION=v3 +export EUROOFFICE_CREATEDB_VERSION=v1 export CRONTAB_VERSION=v1 export PG_BACKUP_VERSION=v2 run_occ() { - su -p www-data -s /bin/sh -c "/var/www/html/occ $@" + su -p www-data -s /bin/sh -c "/var/www/html/occ $*" } install_apps() { @@ -83,6 +85,13 @@ install_onlyoffice() { set_app_config onlyoffice customizationForcesave true } +install_eurooffice() { + install_apps eurooffice + set_app_config eurooffice DocumentServerUrl "https://${EUROOFFICE_DOMAIN}" + set_app_config eurooffice jwt_secret "$(cat /run/secrets/eurooffice_jwt)" + set_app_config eurooffice customizationForcesave true +} + install_collabora() { install_apps richdocuments set_app_config richdocuments wopi_url "$COLLABORA_URL" diff --git a/compose.eurooffice.yml b/compose.eurooffice.yml new file mode 100644 index 0000000..c27fd40 --- /dev/null +++ b/compose.eurooffice.yml @@ -0,0 +1,118 @@ +version: "3.8" +services: + app: + secrets: + - eurooffice_jwt + environment: + - EUROOFFICE_DOMAIN + + eurooffice: + image: ghcr.io/euro-office/documentserver:latest + stdin_open: true + depends_on: + - eurooffice-db + - eurooffice-rabbitmq + networks: + - proxy + - internal + environment: + - JWT_ENABLED=true + - JWT_SECRET_FILE=/run/secrets/eurooffice_jwt + # Use external Postgres + RabbitMQ instead of the flaky bundled ones. + # (The all-in-one image ships an uncleanly-shut-down Postgres data dir + # whose crash recovery exceeds pg_ctl's start timeout -> restart loop.) + - DB_TYPE=postgres + - DB_HOST=eurooffice-db + - DB_PORT=5432 + - DB_NAME=eurooffice + - DB_USER=eurooffice + - AMQP_URI=amqp://guest:guest@eurooffice-rabbitmq + volumes: + - eurooffice_data:/var/lib/euro-office + - eurooffice_config:/etc/euro-office + - eurooffice_logs:/var/log/euro-office + - eurooffice_fonts:/usr/share/fonts/custom + secrets: + - eurooffice_jwt + configs: + - source: entrypoint_eurooffice + target: /custom-entrypoint.sh + mode: 555 + entrypoint: /custom-entrypoint.sh + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/healthcheck"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 3m + deploy: + update_config: + failure_action: rollback + order: start-first + labels: + - "traefik.enable=true" + - "traefik.swarm.network=proxy" + - "traefik.http.services.${STACK_NAME}_eurooffice.loadbalancer.server.port=80" + - "traefik.http.routers.${STACK_NAME}_eurooffice.rule=Host(`${EUROOFFICE_DOMAIN}`)" + - "traefik.http.routers.${STACK_NAME}_eurooffice.entrypoints=web-secure" + - "traefik.http.routers.${STACK_NAME}_eurooffice.tls.certresolver=${LETS_ENCRYPT_ENV}" + - "traefik.http.routers.${STACK_NAME}_eurooffice.middlewares=${STACK_NAME}_eurooffice-fwdproto" + - "traefik.http.middlewares.${STACK_NAME}_eurooffice-fwdproto.headers.customRequestHeaders.X-Forwarded-Proto=https" + + eurooffice-db: + image: postgres:16-alpine + networks: + - internal + environment: + - POSTGRES_DB=eurooffice + - POSTGRES_USER=eurooffice + # Internal-only DB holding transient editing state; trust auth on the + # private overlay network avoids managing a Swarm secret for it. + - POSTGRES_HOST_AUTH_METHOD=trust + volumes: + - eurooffice_db:/var/lib/postgresql/data + configs: + # Seed the document server schema on first init. The all-in-one image only + # creates its schema in the *bundled* Postgres; with an external DB the + # docservice starts against an empty DB, errors on missing task_result / + # doc_changes, never binds its port, and gets healthcheck-killed in a loop. + - source: eurooffice_createdb + target: /docker-entrypoint-initdb.d/createdb.sql + healthcheck: + test: ["CMD", "pg_isready", "-U", "eurooffice"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 1m + + eurooffice-rabbitmq: + image: rabbitmq:4.3.2 + networks: + - internal + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 10s + retries: 10 + start_period: 1m + +secrets: + eurooffice_jwt: + external: true + name: ${STACK_NAME}_eurooffice_jwt_${SECRET_EUROOFFICE_JWT_VERSION} + +volumes: + eurooffice_data: + eurooffice_config: + eurooffice_logs: + eurooffice_fonts: + eurooffice_db: + +configs: + entrypoint_eurooffice: + name: ${STACK_NAME}_entrypoint_eurooffice_${ENTRYPOINT_EUROOFFICE_VERSION} + file: entrypoint.eurooffice.sh.tmpl + template_driver: golang + eurooffice_createdb: + name: ${STACK_NAME}_eurooffice_createdb_${EUROOFFICE_CREATEDB_VERSION} + file: eurooffice-createdb.sql diff --git a/compose.yml b/compose.yml index b5c7d53..6284d67 100644 --- a/compose.yml +++ b/compose.yml @@ -95,7 +95,7 @@ services: failure_action: rollback order: start-first labels: - - "coop-cloud.${STACK_NAME}.version=15.0.0+34.0.1-fpm" + - "coop-cloud.${STACK_NAME}.version=15.1.0+34.0.1-fpm" - "coop-cloud.${STACK_NAME}.timeout=${TIMEOUT}" - "backupbot.backup=${ENABLE_BACKUPS:-true}" - "backupbot.backup.volumes.redis=false" diff --git a/entrypoint.eurooffice.sh.tmpl b/entrypoint.eurooffice.sh.tmpl new file mode 100644 index 0000000..70bc27e --- /dev/null +++ b/entrypoint.eurooffice.sh.tmpl @@ -0,0 +1,30 @@ +#!/bin/bash + +set -eu + +# Read a Swarm secret file (_FILE) into the plain env var the +# Euro-Office document server expects, then hand off to its own entrypoint. +file_env() { + local var="$1" + local fileVar="${var}_FILE" + local def="${2:-}" + + if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then + echo >&2 "error: both $var and $fileVar are set (but are exclusive)" + exit 1 + fi + + local val="$def" + if [ "${!var:-}" ]; then + val="${!var}" + elif [ "${!fileVar:-}" ]; then + val="$(< "${!fileVar}")" + fi + + export "$var"="$val" + unset "$fileVar" +} + +file_env "JWT_SECRET" + +exec /entrypoint.sh diff --git a/eurooffice-createdb.sql b/eurooffice-createdb.sql new file mode 100644 index 0000000..2c1cf1c --- /dev/null +++ b/eurooffice-createdb.sql @@ -0,0 +1,73 @@ +-- +-- Create schema onlyoffice +-- + +-- CREATE DATABASE onlyoffice ENCODING = 'UTF8' CONNECTION LIMIT = -1; + +-- ---------------------------- +-- Table structure for doc_changes +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "doc_changes" ( +"tenant" varchar(255) COLLATE "default" NOT NULL, +"id" varchar(255) COLLATE "default" NOT NULL, +"change_id" int4 NOT NULL, +"user_id" varchar(255) COLLATE "default" NOT NULL, +"user_id_original" varchar(255) COLLATE "default" NOT NULL, +"user_name" varchar(255) COLLATE "default" NOT NULL, +"change_data" text COLLATE "default" NOT NULL, +"change_date" timestamp without time zone NOT NULL, +PRIMARY KEY ("tenant", "id", "change_id") +) +WITH (OIDS=FALSE); + +-- ---------------------------- +-- Table structure for task_result +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "task_result" ( +"tenant" varchar(255) COLLATE "default" NOT NULL, +"id" varchar(255) COLLATE "default" NOT NULL, +"status" int2 NOT NULL, +"status_info" int4 NOT NULL, +"created_at" timestamp without time zone DEFAULT NOW(), +"last_open_date" timestamp without time zone NOT NULL, +"user_index" int4 NOT NULL DEFAULT 1, +"change_id" int4 NOT NULL DEFAULT 0, +"callback" text COLLATE "default" NOT NULL, +"baseurl" text COLLATE "default" NOT NULL, +"password" text COLLATE "default" NULL, +"additional" text COLLATE "default" NULL, +PRIMARY KEY ("tenant", "id") +) +WITH (OIDS=FALSE); + +CREATE OR REPLACE FUNCTION merge_db(_tenant varchar(255), _id varchar(255), _status int2, _status_info int4, _last_open_date timestamp without time zone, _user_index int4, _change_id int4, _callback text, _baseurl text, OUT isupdate char(5), OUT userindex int4) AS +$$ +DECLARE + t_var "task_result"."user_index"%TYPE; +BEGIN + LOOP + -- first try to update the key + -- note that "a" must be unique + IF ((_callback <> '') IS TRUE) AND ((_baseurl <> '') IS TRUE) THEN + UPDATE "task_result" SET last_open_date=_last_open_date, user_index=user_index+1,callback=_callback,baseurl=_baseurl WHERE tenant = _tenant AND id = _id RETURNING user_index into userindex; + ELSE + UPDATE "task_result" SET last_open_date=_last_open_date, user_index=user_index+1 WHERE tenant = _tenant AND id = _id RETURNING user_index into userindex; + END IF; + IF found THEN + isupdate := 'true'; + RETURN; + END IF; + -- not there, so try to insert the key + -- if someone else inserts the same key concurrently, + -- we could get a unique-key failure + BEGIN + INSERT INTO "task_result"(tenant, id, status, status_info, last_open_date, user_index, change_id, callback, baseurl) VALUES(_tenant, _id, _status, _status_info, _last_open_date, _user_index, _change_id, _callback, _baseurl) RETURNING user_index into userindex; + isupdate := 'false'; + RETURN; + EXCEPTION WHEN unique_violation THEN + -- do nothing, and loop to try the UPDATE again + END; + END LOOP; +END; +$$ +LANGUAGE plpgsql; diff --git a/release/15.1.0+34.0.1-fpm b/release/15.1.0+34.0.1-fpm new file mode 100644 index 0000000..017d2b1 --- /dev/null +++ b/release/15.1.0+34.0.1-fpm @@ -0,0 +1,25 @@ +Adds an optional Euro-Office integration (compose.eurooffice.yml). + +Euro-Office is the AGPL fork of OnlyOffice that powers "Nextcloud Office" from +Nextcloud 34 onwards. This overlay runs the Euro-Office document server inside +the stack, so there is no external document server to manage. + +This change is additive: existing installs are unaffected unless you opt in. + +To enable it (`abra app config `): +- COMPOSE_FILE="$COMPOSE_FILE:compose.eurooffice.yml" +- EUROOFFICE_DOMAIN=eurooffice.example.com +- APPS="$APPS eurooffice" +- SECRET_EUROOFFICE_JWT_VERSION=v1 + +Then: +- Create a DNS record for EUROOFFICE_DOMAIN pointing at this host (the browser + talks to the document server directly over HTTPS). +- `abra app secret generate -a ` +- `abra app deploy ` +- `abra app cmd app install_eurooffice` + +Notes: +- The document server needs ~4 GB RAM (8 GB recommended for multi-user). +- The `ghcr.io/euro-office/documentserver` image currently only publishes a + `latest` tag (no semver pinning yet), so it is not tracked by Renovate.