#!/bin/bash
# Copyright [2016-2019] Syncwerk GmbH
set -e
# syncwerk trixie dedicated setup/reconcile delegation
if [ "${1:-}" = "setup" ] || [ "${1:-}" = "reconcile-superadmin" ] || [ "${1:-}" = "reconcile-admin-rbac" ] ; then
  _syncwerk_admin_cmd="$1"
  shift
  _syncwerk_admin_dir="$(cd -- "$(dirname -- "$0")" && pwd -P)"
  if [ -x "${_syncwerk_admin_dir}/syncwerk-server-admin-setup" ] ; then
    exec "${_syncwerk_admin_dir}/syncwerk-server-admin-setup" "${_syncwerk_admin_cmd}" "$@"
  fi
  if [ -x "/usr/sbin/syncwerk-server-admin-setup" ] ; then
    exec /usr/sbin/syncwerk-server-admin-setup "${_syncwerk_admin_cmd}" "$@"
  fi
  echo "syncwerk-server-admin-setup not found or not executable" >&2
  exit 127
fi


# Export variables
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PYTHONPATH=/usr/share/python/syncwerk/restapi/lib/python2.7/site-packages
export CONFIG_DIR=/etc/syncwerk
export CCNET_CONF_DIR=${CONFIG_DIR}
export SYNCWERK_CONF_DIR=${CONFIG_DIR}
export SYNCWERK_CENTRAL_CONF_DIR=${CONFIG_DIR}
export RESTAPI_DIR=/usr/share/python/syncwerk/restapi
export DJANGO_SETTINGS_MODULE=restapi.settings
export RESTAPI_LOG_DIR=/var/log/syncwerk
export TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
export OBJECT_STORAGE_PATH="/var/lib/syncwerk"
export ZIPPING_COMMAND="pigz --fast"
export DIST="$(lsb_release -sc)"
export DISTID="$(lsb_release -si)"
export DEBIAN_FRONTEND=noninteractive
export LIBEVENT_ROOT="/usr/lib/syncwerk/libevent"

if [ -z "${SYNCWERK_HOSTNAME}" ] ; then
  export HOSTNAME="$(hostname -f)"
else
  export HOSTNAME="${SYNCWERK_HOSTNAME}"
fi


function setup-user {
id -u syncwerk > /dev/null || adduser --system --gecos "syncwerk" syncwerk --home /usr/share/syncwerk
}


function purge {
cat <<EOF

  USE WITH EXTREME CAUTION

  Proceeding WILL permanently DELETE data WITHOUT further NOTICE

  Purge Syncwerk Server on dedicated machine. Don't use on servers
  with other shared services. ALL databases, stored files and
  configurations will get permantly deleted. This script is only
  intended for development purposes where we need to quickly purge
  Syncwerk server installations and perform a fresh reinstallation.

  Hit return to proceed or CTRL-C to abort.

EOF
read dummy
WAIT=7 ; while [ ${WAIT} -gt 0 ] ; do printf "\r  You have %2d second(s) to change your mind. Hit CTRL-C to abort." ${WAIT} ; sleep 1 ; ((WAIT--)) ; done ; echo
syncwerk-server stop
pkill -SIGKILL -f "mysqld" || true
apt purge $(dpkg -l "*syncwerk*" | grep ii | awk '{ print $2 }' | xargs) -y
for TYPE in d f ; do find / -iname "*syncwerk*" -type ${TYPE} | while read LINE ; do rm -rf ${LINE} ; done ; done
apt purge $(dpkg -l "*mariadb*"  | grep ii | awk '{ print $2 }' | xargs) -y
rm -rf /etc/nginx/conf.d/syncwerk.conf
pkill -SIGKILL -f "nginx" || true
apt purge nginx -y
apt-get autoremove -y
aptitude purge ~c -y
rm -rf /var/lib/mysql/ /etc/mysql/
apt-get update
}


# Install python tools
function install-python-tools {
command pip  > /dev/null 2>&1 || (echo "Installing latest Python pip 2.7" ; curl -s https://syncwerk.io/python2.7/get-pip.py | python2.7)
echo "Installing virtualenv 16.7.9" ; pip install virtualenv==16.7.9
echo "Installing virtualenv-tools 1.0.0" ; pip install virtualenv-tools==1.0

}


# Setup virtualenv
function setup-virtualenv {
#   Creating MySQL symlink for Python MySQL module to build on jessie and xenial
if [ ${DIST} = jessie ] || [ ${DIST} = xenial ] ; then
  ln -sf /usr/bin/mariadb_config /usr/bin/mysql_config
fi
virtualenv --python=python2.7 /usr/share/python/syncwerk/restapi
if ! md5sum --check /tmp/syncwerk_restapi_requirements_txt.md5 > /dev/null 2>&1 ; then
  echo "Python requirements changed. Updating.."
  /usr/share/python/syncwerk/restapi/bin/python2.7 /usr/share/python/syncwerk/restapi/bin/pip freeze | xargs pip uninstall -y || true
  /usr/share/python/syncwerk/restapi/bin/python2.7 /usr/share/python/syncwerk/restapi/bin/pip install -r /usr/share/python/syncwerk/restapi/requirements.txt
else
  echo "Python requirements have not changed. Skipping update.."
fi
# Delete md5 file created during package system preinst phase 
if [ -f /tmp/syncwerk_restapi_requirements_txt.md5 ] ; then 
  rm /tmp/syncwerk_restapi_requirements_txt.md5
fi
}


# Install Let's Encrypt certificate
function setup-letsencrypt-certificate-usage {
cat <<EOF
Usage:
  $(basename "${0}") setup-letsencrypt-certificate [OPTIONS]

Options:
  --domain DOMAIN        Public DNS name for this Syncwerk server. Default: ${HOSTNAME}
  --email ADDRESS       Registration and expiry-notification address for Let's Encrypt.
  --no-email            Register without an email address.
  --staging             Use the Let's Encrypt staging CA for validation tests.
  --dry-run             Validate renewal flow without replacing the active certificate.
  --force-renewal       Force certificate renewal even if the current certificate is valid.
  --no-restart-nginx    Validate the nginx configuration but do not reload/restart nginx.
  -h, --help            Show this help.

The command uses certbot's webroot authenticator and serves HTTP-01 challenges
through nginx from /var/lib/syncwerk/acme-challenges. Port 80 must be reachable
from the public internet and DOMAIN must resolve to this server.
EOF
}

function setup-letsencrypt-certificate {
local le_domain="${HOSTNAME}"
local le_email="${LETSENCRYPT_EMAIL:-}"
local le_staging=0
local le_dry_run=0
local le_force_renewal=0
local le_restart_nginx=1
local nginx_conf="/etc/nginx/conf.d/syncwerk.conf"
local acme_webroot="/var/lib/syncwerk/acme-challenges"
local old_pythonpath="${PYTHONPATH:-}"

while [ "$#" -gt 0 ] ; do
  case "$1" in
    --domain)
      [ -n "${2:-}" ] || { echo "Missing value for --domain" >&2 ; return 2 ; }
      le_domain="$2"
      shift 2
      ;;
    --email)
      [ -n "${2:-}" ] || { echo "Missing value for --email" >&2 ; return 2 ; }
      le_email="$2"
      shift 2
      ;;
    --no-email)
      le_email=""
      shift
      ;;
    --staging)
      le_staging=1
      shift
      ;;
    --dry-run)
      le_dry_run=1
      shift
      ;;
    --force-renewal)
      le_force_renewal=1
      shift
      ;;
    --no-restart-nginx)
      le_restart_nginx=0
      shift
      ;;
    -h|--help)
      setup-letsencrypt-certificate-usage
      return 0
      ;;
    *)
      echo "Unknown option for setup-letsencrypt-certificate: $1" >&2
      setup-letsencrypt-certificate-usage
      return 2
      ;;
  esac
done

if [ "$(id -u)" -ne 0 ] ; then
  echo "setup-letsencrypt-certificate must be run as root." >&2
  return 1
fi
if [[ "$le_domain" =~ ^[0-9]+(\.[0-9]+){3}$ ]] || [[ "$le_domain" = *:* ]] ; then
  echo "Let's Encrypt cannot issue certificates for IP addresses. Use a public DNS name." >&2
  return 2
fi
if [ ! -f "$nginx_conf" ] ; then
  echo "${nginx_conf} not found. Run syncwerk-server-admin setup before enabling Let's Encrypt." >&2
  return 1
fi
if ! command -v nginx >/dev/null 2>&1 ; then
  echo "nginx is required for the Syncwerk Let's Encrypt webroot flow." >&2
  return 1
fi
if ! command -v certbot >/dev/null 2>&1 ; then
  if command -v apt-get >/dev/null 2>&1 ; then
    echo "Installing certbot from Debian repositories..."
    apt-get update
    apt-get install -y certbot
  else
    echo "certbot is not installed. Install the Debian certbot package and retry." >&2
    return 127
  fi
fi

unset PYTHONPATH
install -d -m 0755 "$acme_webroot"

python3 - "$nginx_conf" "$acme_webroot" <<'PY_ACME_NGINX'
from pathlib import Path
import sys

conf = Path(sys.argv[1])
acme_root = sys.argv[2].rstrip("/")
text = conf.read_text()

http_return = "    return 301 https://$host$request_uri;\n"
http_return_alt = "    return 301 https://$http_host$request_uri;\n"
http_location = "    location / {\n        return 301 https://$host$request_uri;\n    }\n"
http_location_alt = "    location / {\n        return 301 https://$http_host$request_uri;\n    }\n"

if http_return in text:
    text = text.replace(http_return, http_location, 1)
elif http_return_alt in text:
    text = text.replace(http_return_alt, http_location_alt, 1)

http_acme = (
    "    # syncwerk-acme-challenge: allow Let's Encrypt HTTP-01 before HTTPS redirect\n"
    "    location ^~ /.well-known/acme-challenge/ {\n"
    f"        root {acme_root};\n"
    "        default_type text/plain;\n"
    "        try_files $uri =404;\n"
    "    }\n\n"
)
https_acme = (
    "    # syncwerk-acme-challenge: also serve HTTP-01 files on HTTPS if a CA follows redirects\n"
    "    location ^~ /.well-known/acme-challenge/ {\n"
    f"        root {acme_root};\n"
    "        default_type text/plain;\n"
    "        try_files $uri =404;\n"
    "    }\n\n"
)

if "allow Let's Encrypt HTTP-01 before HTTPS redirect" not in text:
    for marker in (http_location, http_location_alt):
        if marker in text:
            text = text.replace(marker, http_acme + marker, 1)
            break
    else:
        raise SystemExit("Could not add HTTP ACME location; HTTP redirect location not found")

https_marker = "    # syncwerk-dotfile-hardening: never serve SCM or dotfile paths via SPA fallback\n"
if "also serve HTTP-01 files on HTTPS" not in text:
    if https_marker not in text:
        raise SystemExit("Could not add HTTPS ACME location; dotfile hardening marker not found")
    text = text.replace(https_marker, https_acme + https_marker, 1)

conf.write_text(text)
PY_ACME_NGINX

nginx -t
systemctl reload nginx.service >/dev/null 2>&1 || service nginx reload

local certbot_args=(certonly --webroot -w "$acme_webroot" --agree-tos --non-interactive --preferred-challenges http-01 -d "$le_domain")
if [ -n "$le_email" ] ; then
  certbot_args+=(--email "$le_email")
else
  certbot_args+=(--register-unsafely-without-email)
  echo "No Let's Encrypt email address configured; registering without expiry notifications."
fi
[ "$le_staging" -eq 1 ] && certbot_args+=(--staging)
[ "$le_dry_run" -eq 1 ] && certbot_args+=(--dry-run)
[ "$le_force_renewal" -eq 1 ] && certbot_args+=(--force-renewal)

certbot "${certbot_args[@]}"

if [ "$le_dry_run" -eq 1 ] ; then
  echo "Let's Encrypt dry-run completed. The active nginx certificate was not changed."
  [ -n "$old_pythonpath" ] && export PYTHONPATH="$old_pythonpath"
  return 0
fi

local live_dir="/etc/letsencrypt/live/${le_domain}"
local fullchain="${live_dir}/fullchain.pem"
local privkey="${live_dir}/privkey.pem"
if [ ! -f "$fullchain" ] || [ ! -f "$privkey" ] ; then
  echo "Certificate files not found in ${live_dir} after certbot completed." >&2
  [ -n "$old_pythonpath" ] && export PYTHONPATH="$old_pythonpath"
  return 1
fi

sed -i -E "s#^[[:space:]]*ssl_certificate[[:space:]]+.*;#    ssl_certificate ${fullchain};#" "$nginx_conf"
sed -i -E "s#^[[:space:]]*ssl_certificate_key[[:space:]]+.*;#    ssl_certificate_key ${privkey};#" "$nginx_conf"

install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/syncwerk-nginx-reload <<'EOF_RENEWAL_HOOK'
#!/bin/sh
set -e
nginx -t >/dev/null
systemctl reload nginx.service >/dev/null 2>&1 || service nginx reload
EOF_RENEWAL_HOOK
chmod +x /etc/letsencrypt/renewal-hooks/deploy/syncwerk-nginx-reload

nginx -t
if [ "$le_restart_nginx" -eq 1 ] ; then
  systemctl reload nginx.service >/dev/null 2>&1 || service nginx reload
else
  echo "nginx reload skipped because --no-restart-nginx was used."
fi
echo "Let's Encrypt certificate for ${le_domain} is active in ${nginx_conf}."
[ -n "$old_pythonpath" ] && export PYTHONPATH="$old_pythonpath"
}


# Install ONLYOFFICE Document Server - Don't call during initial setup since it will break due to dpkg locks
function reconcile-onlyoffice-documentserver-jwt {
local jwt_file="$1"
local local_json="$2"

PYTHONPATH= python3 - "${jwt_file}" "${local_json}" "${TIMESTAMP}" <<'PY'
import hashlib
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path

jwt_file = Path(sys.argv[1])
local_json = Path(sys.argv[2])
timestamp = sys.argv[3]

try:
    secret = jwt_file.read_text(encoding='utf-8').strip('\r\n')
except OSError as exc:
    print('ERROR: unable to read ONLYOFFICE JWT secret file: %s' % exc, file=sys.stderr)
    sys.exit(1)

if not secret:
    print('ERROR: ONLYOFFICE JWT secret file is empty', file=sys.stderr)
    sys.exit(1)

if local_json.exists():
    try:
        data = json.loads(local_json.read_text(encoding='utf-8'))
    except Exception as exc:
        print('ERROR: unable to parse ONLYOFFICE local.json: %s' % exc, file=sys.stderr)
        sys.exit(1)
    if not isinstance(data, dict):
        print('ERROR: ONLYOFFICE local.json root is not an object', file=sys.stderr)
        sys.exit(1)
else:
    data = {}

before = json.dumps(data, sort_keys=True, separators=(',', ':'))

def object_at(root, keys):
    current = root
    for key in keys:
        value = current.get(key)
        if not isinstance(value, dict):
            value = {}
            current[key] = value
        current = value
    return current

coauthoring = object_at(data, ['services', 'CoAuthoring'])
for direction in ('inbox', 'outbox', 'session'):
    object_at(coauthoring, ['secret', direction])['string'] = secret

token_enable = object_at(coauthoring, ['token', 'enable'])
token_enable['browser'] = True
request_enable = object_at(token_enable, ['request'])
request_enable['inbox'] = True
request_enable['outbox'] = True

after = json.dumps(data, sort_keys=True, separators=(',', ':'))
changed = before != after
if changed:
    local_json.parent.mkdir(parents=True, exist_ok=True)
    uid = os.getuid()
    gid = os.getgid()
    mode = 0o640
    if local_json.exists():
        stat = local_json.stat()
        uid = stat.st_uid
        gid = stat.st_gid
        mode = stat.st_mode & 0o777
        backup = local_json.with_name(local_json.name + '.' + timestamp + '.syncwerk-jwt.bak')
        shutil.copy2(str(local_json), str(backup))
        os.chown(str(backup), uid, gid)

    handle = tempfile.NamedTemporaryFile(
        'w',
        encoding='utf-8',
        dir=str(local_json.parent),
        prefix=local_json.name + '.',
        suffix='.tmp',
        delete=False,
    )
    tmp_name = handle.name
    try:
        with handle:
            json.dump(data, handle, indent=2, sort_keys=True)
            handle.write('\n')
        os.chown(tmp_name, uid, gid)
        os.chmod(tmp_name, mode)
        os.replace(tmp_name, str(local_json))
    except Exception:
        try:
            os.unlink(tmp_name)
        except OSError:
            pass
        raise

secret_hash = hashlib.sha256(secret.encode('utf-8')).hexdigest()[:16]
print(
    'ONLYOFFICE JWT local.json reconciliation: changed=%s secret_length=%d secret_sha256_16=%s local_json=%s'
    % ('true' if changed else 'false', len(secret), secret_hash, local_json)
)
PY
}

function restart-onlyoffice-documentserver-services {
local unit
for unit in ds-docservice.service ds-converter.service ; do
  if systemctl list-unit-files "$unit" >/dev/null 2>&1 ; then
    systemctl restart "$unit"
  else
    echo "ONLYOFFICE service ${unit} not found; skipping restart." >&2
  fi
done
}

function setup-onlyoffice {
local ds_port=9090
local ds_nginx_conf="/etc/onlyoffice/documentserver/nginx/ds.conf"
local ds_local_json="/etc/onlyoffice/documentserver/local.json"
local nginx_conf="/etc/nginx/conf.d/syncwerk.conf"
local jwt_file="/etc/syncwerk/onlyoffice-jwt.secret"
local jwt_secret

if [ "$(id -u)" -ne 0 ] ; then
  echo "setup-onlyoffice must be run as root." >&2
  return 1
fi

install -d -m 0750 /etc/syncwerk
if [ -s "$jwt_file" ] ; then
  jwt_secret="$(tr -d '\n\r' < "$jwt_file")"
else
  jwt_secret="$(openssl rand -hex 32)"
  umask 0177
  printf '%s\n' "$jwt_secret" > "$jwt_file"
  umask 0022
fi

if [ ! -f /usr/share/keyrings/onlyoffice.gpg ] ; then
  mkdir -p -m 700 ~/.gnupg
  curl -fsSL https://download.onlyoffice.com/GPG-KEY-ONLYOFFICE \
    | gpg --no-default-keyring --keyring gnupg-ring:/tmp/onlyoffice.gpg --import
  chmod 0644 /tmp/onlyoffice.gpg
  chown root:root /tmp/onlyoffice.gpg
  mv /tmp/onlyoffice.gpg /usr/share/keyrings/onlyoffice.gpg
fi
cat > /etc/apt/sources.list.d/onlyoffice.list <<EOF
deb [signed-by=/usr/share/keyrings/onlyoffice.gpg] https://download.onlyoffice.com/repo/debian squeeze main
EOF

debconf-set-selections <<EOF
onlyoffice-documentserver onlyoffice/ds-port select ${ds_port}
onlyoffice-documentserver onlyoffice/jwt-enabled boolean true
onlyoffice-documentserver onlyoffice/jwt-secret password ${jwt_secret}
msttcorefonts msttcorefonts/accepted-mscorefonts-eula boolean true
EOF

apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y onlyoffice-documentserver

reconcile-onlyoffice-documentserver-jwt "$jwt_file" "$ds_local_json"

if [ -f "$ds_nginx_conf" ] ; then
  sed -i -E "s#listen[[:space:]]+0\\.0\\.0\\.0:${ds_port};#listen 127.0.0.1:${ds_port};#" "$ds_nginx_conf"
  sed -i -E "s#listen[[:space:]]+\\[::\\]:${ds_port}[[:space:]]+default_server;#listen [::1]:${ds_port};#" "$ds_nginx_conf"
fi

if [ -f "$nginx_conf" ] && ! grep -q 'location /onlyofficeds/' "$nginx_conf" ; then
  local tmp_conf
  tmp_conf="$(mktemp)"
  awk '
    function print_location() {
      print "    location = /onlyofficeds {"
      print "        return 404;"
      print "    }"
      print ""
      print "    location = /onlyofficeds/ {"
      print "        return 404;"
      print "    }"
      print ""
      print "    location = /onlyofficeds/welcome {"
      print "        return 404;"
      print "    }"
      print ""
      print "    location ^~ /onlyofficeds/welcome/ {"
      print "        return 404;"
      print "    }"
      print ""
      print "    location = /onlyofficeds/example {"
      print "        return 404;"
      print "    }"
      print ""
      print "    location ^~ /onlyofficeds/example/ {"
      print "        return 404;"
      print "    }"
      print ""
      print "    location ~ ^/onlyofficeds/(?<oo_version>[0-9]+\\.[0-9]+\\.[0-9]+[.-][A-Za-z0-9]+)/(?<oo_editor>web-apps/apps/(documenteditor|spreadsheeteditor|presentationeditor|visioeditor)/main)/index_loader\\.html$ {"
      print "        proxy_pass http://127.0.0.1:9090/$oo_version/$oo_editor/index.html$is_args$args;"
      print "        proxy_http_version 1.1;"
      print "        client_max_body_size 100M;"
      print "        proxy_read_timeout 3600s;"
      print "        proxy_connect_timeout 3600s;"
      print "        proxy_send_timeout 3600s;"
      print "        proxy_set_header Upgrade $http_upgrade;"
      print "        proxy_set_header Connection \"upgrade\";"
      print "        proxy_set_header Host $http_host;"
      print "        proxy_set_header X-Forwarded-Host $http_host/onlyofficeds;"
      print "        proxy_set_header X-Forwarded-Proto https;"
      print "        proxy_set_header X-Forwarded-For $remote_addr;"
      print "        access_log /var/log/syncwerk/onlyoffice.log;"
      print "        error_log /var/log/syncwerk/onlyoffice.log;"
      print "    }"
      print ""
      print "    location /onlyofficeds/ {"
      print "        proxy_pass http://127.0.0.1:9090/;"
      print "        proxy_http_version 1.1;"
      print "        client_max_body_size 100M;"
      print "        proxy_read_timeout 3600s;"
      print "        proxy_connect_timeout 3600s;"
      print "        proxy_send_timeout 3600s;"
      print "        proxy_set_header Upgrade $http_upgrade;"
      print "        proxy_set_header Connection \"upgrade\";"
      print "        proxy_set_header Host $http_host;"
      print "        proxy_set_header X-Forwarded-Host $http_host/onlyofficeds;"
      print "        proxy_set_header X-Forwarded-Proto https;"
      print "        proxy_set_header X-Forwarded-For $remote_addr;"
      print "        access_log /var/log/syncwerk/onlyoffice.log;"
      print "        error_log /var/log/syncwerk/onlyoffice.log;"
      print "    }"
      print ""
    }
    /^[[:space:]]*server[[:space:]]*{/ {
      in_server=1
      is_https=0
      depth=1
      print
      next
    }
    in_server {
      line=$0
      if (line ~ /^[[:space:]]*listen[[:space:]]+(\[::\]:)?443([[:space:];]|$)/) {
        is_https=1
      }
      tmp=line
      opens=gsub(/\{/, "{", tmp)
      tmp=line
      closes=gsub(/\}/, "}", tmp)
      if (is_https && depth + opens - closes == 0 && !inserted) {
        print_location()
        inserted=1
      }
      print
      depth += opens - closes
      if (depth == 0) {
        in_server=0
        is_https=0
      }
      next
    }
    { print }
    END {
      if (!inserted) {
        exit 3
      }
    }
  ' "$nginx_conf" > "$tmp_conf"
  if ! grep -q 'location /onlyofficeds/' "$tmp_conf" ; then
    rm -f "$tmp_conf"
    echo "Could not add /onlyofficeds/ location to ${nginx_conf}; HTTPS server block not found." >&2
    return 1
  fi
  install -m 0644 "$tmp_conf" "$nginx_conf"
  rm -f "$tmp_conf"
fi

sed -i '/# Syncwerk ONLYOFFICE managed block begin/,/# Syncwerk ONLYOFFICE managed block end/d' /etc/syncwerk/restapi_settings.py
cat >> /etc/syncwerk/restapi_settings.py <<EOF

# Syncwerk ONLYOFFICE managed block begin
ENABLE_ONLYOFFICE = True
VERIFY_ONLYOFFICE_CERTIFICATE = False
ONLYOFFICE_APIJS_URL = '/onlyofficeds/web-apps/apps/api/documents/api.js'
ONLYOFFICE_JWT_SECRET = '${jwt_secret}'
ONLYOFFICE_FILE_EXTENSION = ('doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'odt', 'fodt', 'odp', 'fodp', 'ods', 'fods', 'csv')
ONLYOFFICE_EDIT_FILE_EXTENSION = ('doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'odt', 'fodt', 'odp', 'fodp', 'ods', 'fods', 'csv')
# Syncwerk ONLYOFFICE managed block end
EOF

nginx -t
restart-onlyoffice-documentserver-services
systemctl restart nginx.service >/dev/null 2>&1 || service nginx restart
systemctl restart syncwerk-server.service

if dpkg-query -W -f='${Version}' onlyoffice-documentserver 2>/dev/null | awk -F'[-.]' '{ exit !($1 > 9 || ($1 == 9 && $2 >= 4)) }' ; then
  echo "ONLYOFFICE Docs 9.4 or newer installed; the Community edition no longer has the historical 20-connection limit."
else
  echo "Installed ONLYOFFICE version may still contain the historical Community connection limit. No license-limit patch was applied."
fi
}


function reconcile-onlyoffice-usage {
cat <<EOF
Usage:
  $(basename "${0}") reconcile-onlyoffice --check [--json]
  $(basename "${0}") reconcile-onlyoffice --apply [--json]

Modes:
  --check   Inspect ONLYOFFICE package, Syncwerk config and local health without changing the system.
  --apply   Run setup-onlyoffice explicitly, then emit the reconciliation status.
  --json    Emit machine-readable JSON. Secrets are never included.

Notes:
  --apply may install packages and restart services. Do not run it from package
  maintainer scripts or while dpkg/apt locks are active.
EOF
}


function reconcile-onlyoffice-status {
local mode="${1:-check}"
local output="${2:-text}"
local apply_rc="${3:-0}"

PYTHONPATH= python3 - "${mode}" "${output}" "${apply_rc}" <<'PY'
import hashlib
import json
import os
import re
import subprocess
import sys
import urllib.request

mode = sys.argv[1]
output = sys.argv[2]
try:
    apply_rc = int(sys.argv[3])
except Exception:
    apply_rc = 0

KEYRING = os.environ.get('SYNCWERK_ONLYOFFICE_KEYRING_PATH', '/usr/share/keyrings/onlyoffice.gpg')
APT_SOURCE = os.environ.get('SYNCWERK_ONLYOFFICE_APT_SOURCE_PATH', '/etc/apt/sources.list.d/onlyoffice.list')
JWT_FILE = os.environ.get('SYNCWERK_ONLYOFFICE_JWT_FILE', '/etc/syncwerk/onlyoffice-jwt.secret')
NGINX_CONF = os.environ.get('SYNCWERK_ONLYOFFICE_NGINX_CONF', '/etc/nginx/conf.d/syncwerk.conf')
LOCAL_JSON = os.environ.get('SYNCWERK_ONLYOFFICE_LOCAL_JSON', '/etc/onlyoffice/documentserver/local.json')
RESTAPI_SETTINGS = os.environ.get('SYNCWERK_ONLYOFFICE_RESTAPI_SETTINGS', '/etc/syncwerk/restapi_settings.py')
HEALTH_URL = os.environ.get('SYNCWERK_ONLYOFFICE_HEALTH_URL', 'http://127.0.0.1:9090/healthcheck')
DOCSERVICE_UNIT = os.environ.get('SYNCWERK_ONLYOFFICE_DOCSERVICE_UNIT', 'ds-docservice.service')
CONVERTER_UNIT = os.environ.get('SYNCWERK_ONLYOFFICE_CONVERTER_UNIT', 'ds-converter.service')

findings = []

def add_finding(code, severity, message, recommendation):
    findings.append({
        'code': code,
        'severity': severity,
        'message': message,
        'recommendation': recommendation,
    })

def run(cmd, timeout=5):
    try:
        return subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=timeout,
            check=False,
        )
    except FileNotFoundError:
        class Missing:
            returncode = 127
            stdout = ''
            stderr = 'command not found'
        return Missing()
    except subprocess.TimeoutExpired:
        class Timeout:
            returncode = 124
            stdout = ''
            stderr = 'timeout'
        return Timeout()

def read_text(path):
    try:
        with open(path, 'r', encoding='utf-8', errors='replace') as handle:
            return handle.read()
    except OSError:
        return ''

def file_present(path, require_nonempty=False):
    if not os.path.isfile(path):
        return False
    if require_nonempty:
        try:
            return os.path.getsize(path) > 0
        except OSError:
            return False
    return True

def secret_metadata(path):
    secret = read_text(path).strip('\r\n')
    if not secret:
        return {'present': False, 'length': 0, 'sha256_16': ''}
    return {
        'present': True,
        'length': len(secret),
        'sha256_16': hashlib.sha256(secret.encode('utf-8')).hexdigest()[:16],
    }

def object_value(root, keys):
    current = root
    for key in keys:
        if not isinstance(current, dict) or key not in current:
            return None
        current = current[key]
    return current

def documentserver_jwt_status(path, syncwerk_secret):
    state = {
        'path': path,
        'present': file_present(path),
        'valid_json': False,
        'secret_fields_configured': False,
        'secret_fields_consistent': False,
        'secret_fields_match_syncwerk': False,
        'token_flags_enabled': False,
        'secret_field_lengths': {},
    }
    if not state['present']:
        return state

    try:
        data = json.loads(read_text(path))
    except Exception:
        state['parse_error'] = 'invalid_json'
        return state
    if not isinstance(data, dict):
        state['parse_error'] = 'root_not_object'
        return state

    state['valid_json'] = True
    secret_paths = {
        'inbox': ['services', 'CoAuthoring', 'secret', 'inbox', 'string'],
        'outbox': ['services', 'CoAuthoring', 'secret', 'outbox', 'string'],
        'session': ['services', 'CoAuthoring', 'secret', 'session', 'string'],
    }
    secret_values = {}
    for name, keys in secret_paths.items():
        value = object_value(data, keys)
        secret_values[name] = value
        state['secret_field_lengths'][name] = len(value) if isinstance(value, str) else 0

    configured_values = [value for value in secret_values.values() if isinstance(value, str) and value]
    state['secret_fields_configured'] = len(configured_values) == len(secret_paths)
    state['secret_fields_consistent'] = state['secret_fields_configured'] and len(set(configured_values)) == 1
    state['secret_fields_match_syncwerk'] = bool(syncwerk_secret) and all(
        value == syncwerk_secret for value in secret_values.values()
    )

    token_flags = {
        'browser': ['services', 'CoAuthoring', 'token', 'enable', 'browser'],
        'request_inbox': ['services', 'CoAuthoring', 'token', 'enable', 'request', 'inbox'],
        'request_outbox': ['services', 'CoAuthoring', 'token', 'enable', 'request', 'outbox'],
    }
    state['token_flags'] = {
        name: object_value(data, keys) is True for name, keys in token_flags.items()
    }
    state['token_flags_enabled'] = all(state['token_flags'].values())
    return state

def dpkg_package_state():
    result = run(['dpkg-query', '-W', '-f=${db:Status-Abbrev}\t${Version}\n', 'onlyoffice-documentserver'])
    if result.returncode != 0 or not result.stdout.strip():
        return {'state': 'absent', 'installed': False, 'residual_config': False, 'version': ''}
    first = result.stdout.strip().splitlines()[0]
    parts = first.split('\t', 1)
    abbrev = parts[0].strip()
    version = parts[1].strip() if len(parts) > 1 else ''
    installed = abbrev.startswith('ii')
    residual_config = abbrev.startswith('rc')
    if installed:
        state = 'installed'
    elif residual_config:
        state = 'residual_config'
    else:
        state = 'not_installed'
    return {'state': state, 'installed': installed, 'residual_config': residual_config, 'version': version}

def legacy_version(version):
    numbers = [int(value) for value in re.findall(r'\d+', version)[:2]]
    if not numbers:
        return False
    major = numbers[0]
    minor = numbers[1] if len(numbers) > 1 else 0
    return major < 9 or (major == 9 and minor < 4)

def systemd_state(unit):
    result = run(['systemctl', 'is-active', unit], timeout=3)
    state_lines = (result.stdout or result.stderr).strip().splitlines()
    state = state_lines[0] if state_lines else 'unknown'
    return {'active': result.returncode == 0 and state == 'active', 'state': state}

def local_health():
    try:
        with urllib.request.urlopen(HEALTH_URL, timeout=3) as response:
            body = response.read(200).decode('utf-8', errors='replace').strip()
            return {'ok': 200 <= response.status < 300, 'http_status': response.status, 'body': body[:32]}
    except Exception as exc:
        return {'ok': False, 'http_status': None, 'body': '', 'error': exc.__class__.__name__}

package = dpkg_package_state()
settings_text = read_text(RESTAPI_SETTINGS)
nginx_text = read_text(NGINX_CONF)

restapi_settings_present = bool(settings_text)
restapi_enable_onlyoffice = bool(re.search(r'^\s*ENABLE_ONLYOFFICE\s*=\s*True\b', settings_text, re.M))
restapi_managed_block = '# Syncwerk ONLYOFFICE managed block begin' in settings_text
restapi_jwt_configured = bool(re.search(r'^\s*ONLYOFFICE_JWT_SECRET\s*=', settings_text, re.M))
restapi_apijs_configured = '/onlyofficeds/' in settings_text
nginx_proxy_present = 'location /onlyofficeds/' in nginx_text or 'location = /onlyofficeds' in nginx_text
jwt_secret = read_text(JWT_FILE).strip('\r\n')
jwt_secret_state = secret_metadata(JWT_FILE)
documentserver_jwt = documentserver_jwt_status(LOCAL_JSON, jwt_secret)

checks = {
    'package': package,
    'keyring': {'path': KEYRING, 'present': file_present(KEYRING)},
    'apt_source': {'path': APT_SOURCE, 'present': file_present(APT_SOURCE)},
    'jwt_secret_file': {'path': JWT_FILE, 'present': file_present(JWT_FILE, require_nonempty=True), 'metadata': jwt_secret_state},
    'documentserver_jwt': documentserver_jwt,
    'nginx_proxy': {'path': NGINX_CONF, 'present': nginx_proxy_present},
    'restapi_settings': {
        'path': RESTAPI_SETTINGS,
        'present': restapi_settings_present,
        'enable_onlyoffice': restapi_enable_onlyoffice,
        'managed_block': restapi_managed_block,
        'jwt_configured': restapi_jwt_configured,
        'apijs_configured': restapi_apijs_configured,
    },
    'services': {
        'ds_docservice': systemd_state(DOCSERVICE_UNIT),
        'ds_converter': systemd_state(CONVERTER_UNIT),
    },
    'local_health': local_health(),
}

provider_artifacts_present = any([
    package['state'] != 'absent',
    checks['keyring']['present'],
    checks['apt_source']['present'],
    checks['jwt_secret_file']['present'],
    documentserver_jwt['present'],
    nginx_proxy_present,
    restapi_enable_onlyoffice,
    restapi_managed_block,
])

if apply_rc:
    add_finding(
        'onlyoffice_apply_failed',
        'critical',
        'The explicit ONLYOFFICE repair command failed.',
        'Inspect the command output, apt/dpkg state and service logs before retrying.',
    )

if package['residual_config']:
    add_finding(
        'onlyoffice_package_residual_config',
        'critical',
        'onlyoffice-documentserver exists only as residual dpkg configuration.',
        'Run reconcile-onlyoffice --apply after apt/dpkg locks are clear if ONLYOFFICE should be active.',
    )
elif provider_artifacts_present and not package['installed']:
    add_finding(
        'onlyoffice_package_missing',
        'critical',
        'Syncwerk or ONLYOFFICE artifacts exist, but onlyoffice-documentserver is not installed.',
        'Install/reconcile ONLYOFFICE explicitly, or disable the provider state in Syncwerk configuration.',
    )

if package['installed'] and legacy_version(package['version']):
    add_finding(
        'onlyoffice_legacy_version',
        'warning',
        'Installed ONLYOFFICE version is older than the current supported 9.4 baseline.',
        'Plan an explicit provider upgrade and verify document open/save workflows.',
    )

if provider_artifacts_present or package['installed']:
    if not checks['keyring']['present']:
        add_finding('onlyoffice_keyring_missing', 'warning', 'ONLYOFFICE apt keyring is missing.', 'Recreate the official ONLYOFFICE apt source through reconcile-onlyoffice --apply.')
    if not checks['apt_source']['present']:
        add_finding('onlyoffice_apt_source_missing', 'warning', 'ONLYOFFICE apt source file is missing.', 'Recreate the official ONLYOFFICE apt source through reconcile-onlyoffice --apply.')
    if not checks['jwt_secret_file']['present']:
        add_finding('onlyoffice_jwt_secret_missing', 'critical', 'ONLYOFFICE JWT secret file is missing or empty.', 'Generate a new JWT secret and render the managed RESTAPI block via reconcile-onlyoffice --apply.')
    if not documentserver_jwt['present']:
        add_finding('onlyoffice_documentserver_jwt_missing', 'critical', 'ONLYOFFICE DocumentServer local.json is missing.', 'Render DocumentServer JWT settings via reconcile-onlyoffice --apply.')
    elif not documentserver_jwt['valid_json']:
        add_finding('onlyoffice_documentserver_jwt_invalid_json', 'critical', 'ONLYOFFICE DocumentServer local.json is not valid JSON.', 'Repair local.json manually before applying automated JWT reconciliation.')
    elif documentserver_jwt['valid_json']:
        if checks['jwt_secret_file']['present'] and not documentserver_jwt['secret_fields_match_syncwerk']:
            add_finding('onlyoffice_documentserver_jwt_secret_mismatch', 'critical', 'ONLYOFFICE DocumentServer JWT secrets do not match the Syncwerk JWT source.', 'Run reconcile-onlyoffice --apply after apt/dpkg locks are clear.')
        if not documentserver_jwt['token_flags_enabled']:
            add_finding('onlyoffice_documentserver_jwt_token_flags_disabled', 'critical', 'ONLYOFFICE DocumentServer JWT token flags are not fully enabled.', 'Run reconcile-onlyoffice --apply after apt/dpkg locks are clear.')
    if not nginx_proxy_present:
        add_finding('onlyoffice_nginx_proxy_missing', 'critical', 'Syncwerk nginx /onlyofficeds/ proxy block is missing.', 'Render the Syncwerk nginx ONLYOFFICE block and validate nginx configuration.')
    if restapi_enable_onlyoffice and not restapi_jwt_configured:
        add_finding('onlyoffice_restapi_jwt_missing', 'critical', 'RESTAPI enables ONLYOFFICE without a configured JWT secret.', 'Render the managed RESTAPI ONLYOFFICE block via reconcile-onlyoffice --apply.')
    if restapi_enable_onlyoffice and not restapi_apijs_configured:
        add_finding('onlyoffice_restapi_apijs_missing', 'critical', 'RESTAPI enables ONLYOFFICE without the /onlyofficeds/ API JS URL.', 'Render the managed RESTAPI ONLYOFFICE block via reconcile-onlyoffice --apply.')
    if package['installed'] and not checks['services']['ds_docservice']['active']:
        add_finding('onlyoffice_docservice_inactive', 'critical', 'ONLYOFFICE ds-docservice is not active.', 'Inspect ds-docservice logs and restart ONLYOFFICE after configuration repair.')
    if package['installed'] and not checks['services']['ds_converter']['active']:
        add_finding('onlyoffice_converter_inactive', 'critical', 'ONLYOFFICE ds-converter is not active.', 'Inspect ds-converter logs and restart ONLYOFFICE after configuration repair.')
    if package['installed'] and not checks['local_health']['ok']:
        add_finding('onlyoffice_local_health_failed', 'critical', 'Local ONLYOFFICE healthcheck failed.', 'Repair provider services before advertising ONLYOFFICE as usable.')

if restapi_enable_onlyoffice and any(f['severity'] == 'critical' for f in findings):
    add_finding(
        'onlyoffice_advertised_while_unhealthy',
        'critical',
        'RESTAPI currently advertises ONLYOFFICE while provider checks are not healthy.',
        'Keep the user-facing feature disabled until package, JWT, proxy and health checks are consistent.',
    )

if not provider_artifacts_present:
    status = 'not_configured'
elif any(f['severity'] == 'critical' for f in findings):
    status = 'needs_action'
elif findings:
    status = 'degraded'
else:
    status = 'ok'

recommendation = {
    'ok': 'No action required.',
    'not_configured': 'ONLYOFFICE is not configured on this host.',
    'degraded': 'Review warnings before treating ONLYOFFICE as production-ready.',
    'needs_action': 'Run syncwerk-server-admin reconcile-onlyoffice --apply only after apt/dpkg locks are clear, or disable the provider state intentionally.',
}.get(status, 'Review reconciliation findings.')

result = {
    'component': 'onlyoffice',
    'mode': mode,
    'mutating': mode == 'apply',
    'status': status,
    'checks': checks,
    'findings': findings,
    'recommendation': recommendation,
}

if output == 'json':
    print(json.dumps(result, sort_keys=True, indent=2))
else:
    print('ONLYOFFICE reconciliation status: %s' % status)
    for finding in findings:
        print('- [{severity}] {code}: {message}'.format(**finding))
    print('Recommendation: %s' % recommendation)

sys.exit(0 if status in ('ok', 'not_configured') and apply_rc == 0 else 1)
PY
}


function reconcile-onlyoffice {
local mode=""
local output="text"
local apply_rc=0

while [ "$#" -gt 0 ] ; do
  case "$1" in
    --check)
      if [ -n "${mode}" ] ; then
        echo "Use only one reconcile-onlyoffice mode." >&2
        return 2
      fi
      mode="check"
      shift
      ;;
    --apply)
      if [ -n "${mode}" ] ; then
        echo "Use only one reconcile-onlyoffice mode." >&2
        return 2
      fi
      mode="apply"
      shift
      ;;
    --json)
      output="json"
      shift
      ;;
    -h|--help)
      reconcile-onlyoffice-usage
      return 0
      ;;
    *)
      echo "Unknown option for reconcile-onlyoffice: $1" >&2
      reconcile-onlyoffice-usage >&2
      return 2
      ;;
  esac
done

mode="${mode:-check}"
case "$mode" in
  check)
    ;;
  apply)
    setup-onlyoffice >&2 || apply_rc=$?
    ;;
  *)
    echo "Invalid reconcile-onlyoffice mode: ${mode}" >&2
    return 2
    ;;
esac

reconcile-onlyoffice-status "${mode}" "${output}" "${apply_rc}"
}


# Ensuring required dirs exist
mkdir -p ${OBJECT_STORAGE_PATH}/template ${OBJECT_STORAGE_PATH}/avatars /var/log/syncwerk

function syncwerk-group {
id -gn syncwerk 2>/dev/null || echo nogroup
}

function syncwerk-runtime-group {
getent group www-data >/dev/null 2>&1 && echo www-data || syncwerk-group
}

function seed-default-avatars {
local GROUP SOURCE
GROUP="$(syncwerk-group)"
SOURCE="/usr/share/syncwerk/webapp/assets/images/placeholder-profile.png"
[ -f "${SOURCE}" ] || return 0
install -d -o syncwerk -g "${GROUP}" -m 0755 /var/lib/syncwerk/avatars /var/lib/syncwerk/avatars/groups
[ -f /var/lib/syncwerk/avatars/default.png ] || install -o syncwerk -g "${GROUP}" -m 0644 "${SOURCE}" /var/lib/syncwerk/avatars/default.png
[ -f /var/lib/syncwerk/avatars/groups/default.png ] || install -o syncwerk -g "${GROUP}" -m 0644 "${SOURCE}" /var/lib/syncwerk/avatars/groups/default.png
}


function start-database {
if [ -f /.dockerenv ] ; then
  service mysql start
fi
}


function create-database {
if [ ! -f "/etc/syncwerk/mysql.txt" ] ; then
SYNCWERK_DB_PW=$(pwgen 14 1)
cat > /etc/syncwerk/mysql.txt <<EOF
[client]
user=syncwerk
password=${SYNCWERK_DB_PW}
EOF
chmod 600 /etc/syncwerk/mysql.txt
mysql -e "CREATE DATABASE IF NOT EXISTS \`syncwerk-ccnet\` CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci;"
mysql -e "CREATE DATABASE IF NOT EXISTS \`syncwerk-server\` CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci;"
mysql -e "CREATE DATABASE IF NOT EXISTS \`syncwerk-restapi\` CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci;"
mysql -e "CREATE USER 'syncwerk'@'localhost' IDENTIFIED BY '${SYNCWERK_DB_PW}';"
mysql -e "GRANT ALL PRIVILEGES ON  \`syncwerk-ccnet\` . * TO  'syncwerk'@'localhost';"
mysql -e "GRANT ALL PRIVILEGES ON  \`syncwerk-server\` . * TO  'syncwerk'@'localhost';"
mysql -e "GRANT ALL PRIVILEGES ON  \`syncwerk-restapi\` . * TO  'syncwerk'@'localhost';"
else
    echo "/etc/syncwerk/mysql.txt exists. Skipping.."
fi
}


function update-database {
# Ensuring paths in virtualenv are valid
virtualenv-tools --reinitialize /usr/share/python/syncwerk/restapi

# Ensuring sql_mode is set
grep -q sql_mode /etc/syncwerk/restapi_settings.py || sed -i 's/storage_engine=INNODB/storage_engine=INNODB, sql_mode=STRICT_TRANS_TABLES/' /etc/syncwerk/restapi_settings.py

echo "Upgrading restapi database"
mysql --force syncwerk-restapi < /usr/share/python/syncwerk/restapi/sql/latest-restapi.sql

# Ensuring institutions are renamed
mysql --force syncwerk-restapi <<EOF  >& /dev/null
RENAME TABLE \`syncwerk-restapi\`.\`institutions_institution\` TO \`syncwerk-restapi\`.\`tenants_tenant\`;
RENAME TABLE \`syncwerk-restapi\`.\`institutions_institutionadmin\` TO \`syncwerk-restapi\`.\`tenants_tenantadmin\`;
RENAME TABLE \`syncwerk-restapi\`.\`institutions_institutionquota\` TO \`syncwerk-restapi\`.\`tenants_tenantquota\`;
ALTER TABLE \`tenants_tenantadmin\` CHANGE \`institution_id\` \`tenant_id\` INT(11) NOT NULL;
ALTER TABLE \`tenants_tenantquota\` CHANGE \`institution_id\` \`tenant_id\` INT(11) NOT NULL;
ALTER TABLE \`profile_profile\` CHANGE \`institution\` \`tenant\` VARCHAR(225) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL;
UPDATE \`django_migrations\` SET \`app\` = Replace(app, 'institution', 'tenant') WHERE \`app\` like "%institution%";
UPDATE \`django_migrations\` SET \`name\` = Replace(name, 'institution', 'tenant') WHERE \`name\` like "%institution%";
UPDATE \`auth_permission\` SET \`name\` = Replace(name, 'institution', 'tenant') WHERE \`name\` like "%institution%";
UPDATE \`auth_permission\` SET \`codename\` = Replace(codename, 'institution', 'tenant') WHERE \`name\` like "%institution%";
EOF

# Ensuring shared repo table is updated
mysql --force syncwerk-server <<EOF  >& /dev/null
ALTER TABLE \`syncwerk-server\`.\`SharedRepo\` ADD \`allow_view_history\` BOOLEAN DEFAULT True;
ALTER TABLE \`syncwerk-server\`.\`SharedRepo\` ADD \`allow_view_snapshot\` BOOLEAN DEFAULT False;
ALTER TABLE \`syncwerk-server\`.\`SharedRepo\` ADD \`allow_restore_snapshot\` BOOLEAN DEFAULT False;
EOF

# Upgrading API3 related tables
/usr/sbin/syncwerk-server-manage-py migrate api3 > /dev/null 2>&1 || true

# Ensuring django site id is set. Otherwise public user registration fails..
mysql -e "INSERT INTO \`syncwerk-restapi\`.\`django_site\` VALUES (1,'example.com','example.com');" || true

# Ensuring api2_tokenv2 is migrated to api3_tokenv2
mysql -e "INSERT INTO \`syncwerk-restapi\`.\`api3_tokenv2\` SELECT * FROM \`syncwerk-restapi\`.\`api2_tokenv2\` AS \`tmp\` WHERE NOT EXISTS ( SELECT * FROM \`syncwerk-restapi\`.\`api3_tokenv2\` WHERE \`syncwerk-restapi\`.\`api3_tokenv2\`.user = \`tmp\`.user AND \`syncwerk-restapi\`.\`api3_tokenv2\`.device_id = \`tmp\`.device_id );" || true

# Check if the database layout has changed. Requires latest-restapi.sql to include all recent changes at any time
if ! md5sum --check /tmp/syncwerk_restapi_sql.md5 > /dev/null 2>&1 ; then
  echo "Reinitialising restapi database schema. This may take a while.."
  for i in $(/usr/sbin/syncwerk-server-manage-py migrate --fake 2> /dev/null | grep "Apply all migrations" | sed 's/Apply all migrations://g; s/, / /g' | xargs) ; do
    /usr/sbin/syncwerk-server-manage-py migrate "${i}" > /dev/null 2>&1 || /usr/sbin/syncwerk-server-manage-py migrate "${i}" --fake > /dev/null 2>&1
  done
else
  echo "Restapi database schema has not changed. Skipping update.."
fi
# Delete md5 file created during package system preinst phase 
if [ -f /tmp/syncwerk_restapi_sql.md5 ] ; then 
  rm /tmp/syncwerk_restapi_sql.md5
fi

# Fixing typo issue introduce at https://gitlab.syncwerk.com/development/free/syncwerk-server-restapi/-/commit/278b925d217bf2cc75b9117462641774b1f1dc9c
mysql -e "ALTER TABLE \`syncwerk-restapi\`.\`AuditLog\` CHANGE COLUMN \`recepient\` \`recipient\` longtext;" > /dev/null 2>&1 || true

echo "Upgrading ccnet database"
mysql --force syncwerk-ccnet < /usr/share/python/syncwerk/restapi/sql/latest-ccnet.sql > /dev/null 2>&1

# Ensuring language column is present
mysql -e "alter table \`syncwerk-ccnet\`.\`EmailUser\` add \`language\` varchar(255) after \`passwd\`;" > /dev/null 2>&1 || true
mysql -e "alter table \`syncwerk-ccnet\`.\`LDAPUsers\` add \`language\` varchar(255) after \`password\`;" > /dev/null 2>&1 || true 

# Ensuring ctime column is present
mysql -e "alter table \`syncwerk-ccnet\`.\`LDAPUsers\` add \`ctime\` BIGINT after \`is_active\`;" > /dev/null 2>&1 || true 

echo "Upgrading server database"
mysql --force syncwerk-server < /usr/share/python/syncwerk/restapi/sql/latest-server.sql > /dev/null 2>&1
}


function get-database-credentials {
CCNET_DB=$(grep "DB.*=.*" /etc/syncwerk/ccnet.conf | awk '{ print $3 }')
RESTAPI_DB=$(grep "'NAME'.*:.*" /etc/syncwerk/restapi_settings.py | awk -F"'" '{ print $4 }')
SERVER_DB=$(grep -i "db_name" /etc/syncwerk/server.conf | awk '{ print $3 }')
}


function unique-database-names {
local db
local -A seen
SYNCWERK_BACKUP_DATABASES=()

for db in "$@" ; do
  [ -n "${db}" ] || continue
  if [ -z "${seen[${db}]+x}" ] ; then
    seen["${db}"]=1
    SYNCWERK_BACKUP_DATABASES+=("${db}")
  fi
done
}


function database-exists {
local db="${1:-}"
[ -n "${db}" ] || return 1
mysqlshow "${db}" > /dev/null 2>&1
}


function update-translations {
sync
# Updating translations.  Run from the package-managed RESTAPI runtime so
# Django does not scan operator working directories such as /root.
(
  cd /usr/share/python/syncwerk/restapi
  for lang in en de ; do
    nice -n 19 ./manage.py compilemessages --verbosity 3 --locale "${lang}" || true
  done
)
}


function update-static-files {
sync
# Updateing static files
nice -n 19 /usr/share/python/syncwerk/restapi/manage.py collectstatic --no-input --verbosity 3 || true
}


function syncwerk-webapp-csp-header {
cat <<'EOF'
    # syncwerk-webapp-csp: keep public branding/custom CSS resource loading same-origin bound
    add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' wss://$host ws://$host; media-src 'self' data: blob:; frame-src 'self'; worker-src 'self' blob:; manifest-src 'self'" always;
    add_header X-Syncwerk-Content-Security-Policy "branding-resources-same-origin" always;
EOF
}


function help {
cat <<EOF

Name:
  syncwerk-server-admin - A Syncwerk server setup and administration tool

Usage:
  $(basename ${0}) [OPTIONS] [ARGS]

Commands:
  support                         Show how to receive official support
  setup                           Automatically setup production ready Syncwerk server
  update-translations             Update translations (May take very long)
  update-static-files             Update static files (collectstatic)
  start                           Start all Syncwerk server services
  stop                            Stop all Syncwerk server services
  restart                         Restart all Syncwerk server services
  fix-permissions                 Fix file permissions and ownership of Syncwerk server components
  report                          Create report for support requests
  fsck                            Check Syncwerk block storage data
  mount [MOUNTPOINT]              Mount unencrypted folders locally in read only mode
  umount                          Unmount locally mounted folders
  gc                              Run garabage collection to delete admin trash and expired file versions
  change-email-address            Safely change a user account email address; supports --dry-run
  backup-databases                Backup Syncwerk server databases and save to ${OBJECT_STORAGE_PATH}/backup/
  backup-object-storage           Backup Syncwerk server object storage and save to ${OBJECT_STORAGE_PATH}/backup/
  backup-configurations           Backup Syncwerk server configurations and save to ${OBJECT_STORAGE_PATH}/backup/
  backup-server-applications      Backup installed Syncwerk server application and save to ${OBJECT_STORAGE_PATH}/backup/
  backup-all                      Backup Syncwerk server configuration, databases, object-storage and save to ${OBJECT_STORAGE_PATH}/backup/
  setup-onlyoffice                Install ONLYOFFICE for file preview and editing
  reconcile-onlyoffice            Check or explicitly repair ONLYOFFICE provider drift
  reconcile-legacy-upgrade        Check or repair legacy Debian-upgrade drift
  harden-nginx-dotfiles           Block dotfiles and SCM paths before the webapp fallback
  harden-nginx-webapp-csp         Add Syncwerk webapp CSP for branding/custom CSS resources
  setup-clamav-virus-scanner      Install and setup ClamAV virus scanner 
  reconcile-clamav-virus-scanner  Ensure configured ClamAV scanner runtime is ready
  run-virus-scanner               Runs virus scanner manually
  setup-letsencrypt-certificate   Activate Let's Encrypt TLS using nginx webroot validation.
                                  Run with --help to show domain, email, staging and dry-run options.
  configure-ldap                  Configure Syncwerk LDAP/AD authentication in /etc/syncwerk/ccnet.conf
  test-ldap                       Validate the LDAP section in /etc/syncwerk/ccnet.conf with ldapsearch
  setup-samba-ad-dc               Install Samba based Active Directory for "$(hostname -d | sed 's/^/DC=/ ; s/\./,DC=/g')"
  create-file-list                Create a list of all files in unencrypted folders


SUPPORT:
  For support contact us at support@syncwerk.com or visit https://www.syncwerk.com

EOF
}


function reconcile-legacy-upgrade {
local helper="${SYNCWERK_LEGACY_UPGRADE_RECONCILER:-}"
local script_dir=""

if [ -z "${helper}" ] ; then
  script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
  if [ -x "${script_dir}/../libexec/syncwerk-server/reconcile_legacy_upgrade.py" ] ; then
    helper="${script_dir}/../libexec/syncwerk-server/reconcile_legacy_upgrade.py"
  else
    helper="/usr/libexec/syncwerk-server/reconcile_legacy_upgrade.py"
  fi
fi

if [ ! -x "${helper}" ] ; then
  echo "syncwerk legacy-upgrade reconciler not found or not executable: ${helper}" >&2
  return 127
fi

PYTHONPATH= /usr/bin/python3 "${helper}" "$@"
}


function harden-nginx-dotfiles {
local nginx_conf="/etc/nginx/conf.d/syncwerk.conf"
local tmp_conf
local seafhttp_changed=0

if [ ! -f "${nginx_conf}" ] ; then
  echo "${nginx_conf} not found. Run syncwerk-server-admin setup first." >&2
  return 1
fi

tmp_conf="$(mktemp)"
sed -E 's#^([[:space:]]*)location[[:space:]]+/seafhttp([[:space:]]*\{)#\1location ^~ /seafhttp\2#' \
  "${nginx_conf}" > "${tmp_conf}"
if ! cmp -s "${nginx_conf}" "${tmp_conf}" ; then
  seafhttp_changed=1
fi

if grep -q 'syncwerk-dotfile-hardening' "${tmp_conf}" ; then
  if [ "${seafhttp_changed}" -eq 1 ] ; then
    install -m 0644 "${tmp_conf}" "${nginx_conf}"
    rm -f "${tmp_conf}"
    nginx -t
    systemctl reload nginx.service >/dev/null 2>&1 || service nginx reload
    echo "Syncwerk nginx seafhttp dotfile priority updated in ${nginx_conf}."
  else
    rm -f "${tmp_conf}"
    echo "Syncwerk nginx dotfile hardening already present."
  fi
  return 0
fi

awk '
  BEGIN { inserted = 0 }
  /^[[:space:]]*location[[:space:]]+\/[[:space:]]*\{/ && inserted == 0 {
    print "    # syncwerk-dotfile-hardening: never serve SCM or dotfile paths via SPA fallback"
    print "    location ~ /\\.(?!well-known/acme-challenge(?:/|$)) {"
    print "        return 404;"
    print "        access_log off;"
    print "        log_not_found off;"
    print "    }"
    print ""
    inserted = 1
  }
  { print }
  END { if (inserted == 0) exit 1 }
' "${tmp_conf}" > "${tmp_conf}.new" || {
  rm -f "${tmp_conf}" "${tmp_conf}.new"
  echo "Could not add Syncwerk nginx dotfile hardening; webapp location block not found." >&2
  return 1
}
mv "${tmp_conf}.new" "${tmp_conf}"

install -m 0644 "${tmp_conf}" "${nginx_conf}"
rm -f "${tmp_conf}"
nginx -t
systemctl reload nginx.service >/dev/null 2>&1 || service nginx reload
echo "Syncwerk nginx dotfile hardening installed in ${nginx_conf}."
}


function harden-nginx-webapp-csp {
local nginx_conf="/etc/nginx/conf.d/syncwerk.conf"
local tmp_conf

if [ ! -f "${nginx_conf}" ] ; then
  echo "${nginx_conf} not found. Run syncwerk-server-admin setup first." >&2
  return 1
fi

if grep -q 'syncwerk-webapp-csp' "${nginx_conf}" ; then
  echo "Syncwerk nginx webapp CSP already present."
  return 0
fi

if grep -q 'Content-Security-Policy' "${nginx_conf}" ; then
  echo "Content-Security-Policy already present in ${nginx_conf}; leaving existing policy unchanged." >&2
  return 0
fi

tmp_conf="$(mktemp)"
awk -v block="$(syncwerk-webapp-csp-header)" '
  BEGIN { inserted = 0 }
  /^[[:space:]]*try_files[[:space:]].*\/index\.html;[[:space:]]*$/ && inserted == 0 {
    print block
    inserted = 1
  }
  { print }
  END { if (inserted == 0) exit 1 }
' "${nginx_conf}" > "${tmp_conf}" || {
  rm -f "${tmp_conf}"
  echo "Could not add Syncwerk nginx webapp CSP; webapp fallback not found." >&2
  return 1
}

install -m 0644 "${tmp_conf}" "${nginx_conf}"
rm -f "${tmp_conf}"
nginx -t
systemctl reload nginx.service >/dev/null 2>&1 || service nginx reload
echo "Syncwerk nginx webapp CSP installed in ${nginx_conf}."
}


function setup-nginx {
if [ -z "${SYNCWERK_HOSTNAME}" ] ; then
  export HOSTNAME="$(hostname -f)"
else
  export HOSTNAME="${SYNCWERK_HOSTNAME}"
fi

echo "Setup NGINX proxy for Syncwerk services"
if [ ! -f "/etc/nginx/conf.d/syncwerk.conf" ] ; then
mkdir -p /etc/nginx/conf.d/
cat > /etc/nginx/conf.d/syncwerk.conf <<'EOF'
upstream syncwerk-server-restapi {
  server unix:/run/syncwerk/restapi.sock fail_timeout=0;
}

# Required for ONLYOFFICE
map $http_x_forwarded_proto $the_scheme {
        default $http_x_forwarded_proto;
        "" $scheme;
    }

map $http_x_forwarded_host $the_host {
        default $http_x_forwarded_host;
        "" $host;
    }

map $http_upgrade $proxy_connection {
        default upgrade;
        "" close;
    }
    
server {
  listen [::]:80 ipv6only=off;
  server_name  "";

  # syncwerk-acme-challenge: allow Let's Encrypt HTTP-01 before HTTPS redirect
  location ^~ /.well-known/acme-challenge/ {
    root /var/lib/syncwerk/acme-challenges;
    default_type text/plain;
    try_files $uri =404;
  }

  location / {
    return 301 https://$http_host$request_uri;
  }
}

server {
  listen [::]:443 ipv6only=off http2;
  server_name  "";
  ssl on;
  ssl_certificate /etc/nginx/ssl/syncwerk.crt;
  ssl_certificate_key /etc/nginx/ssl/syncwerk.key;

  proxy_set_header X-Forwarded-For $remote_addr;
  proxy_max_temp_file_size 0;

  # syncwerk-acme-challenge: also serve HTTP-01 files on HTTPS if a CA follows redirects
  location ^~ /.well-known/acme-challenge/ {
    root /var/lib/syncwerk/acme-challenges;
    default_type text/plain;
    try_files $uri =404;
  }

  # syncwerk-dotfile-hardening: never serve SCM or dotfile paths via SPA fallback
  location ~ /\.(?!well-known/acme-challenge(?:/|$)) {
    return 404;
    access_log off;
    log_not_found off;
  }

  location / {
    # syncwerk-webapp-csp: keep public branding/custom CSS resource loading same-origin bound
    add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' wss://$host ws://$host; media-src 'self' data: blob:; frame-src 'self'; worker-src 'self' blob:; manifest-src 'self'" always;
    add_header X-Syncwerk-Content-Security-Policy "branding-resources-same-origin" always;
    try_files $uri $uri/ /index.html;
    root /usr/share/syncwerk/webapp/;
    access_log      /var/log/syncwerk/webapp.log;
    error_log       /var/log/syncwerk/webapp.log;
  }

  location /notification/list {
    return 301 /notifications;
  }

  location ^~ /seafhttp {
    rewrite ^/seafhttp(.*)$ $1 break;
    proxy_pass http://127.0.0.1:8082;
    client_max_body_size 0;
    proxy_connect_timeout  36000s;
    proxy_read_timeout  36000s;
    proxy_send_timeout  36000s;
    proxy_request_buffering off;
  }

  location /api2 {
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Host $http_host;
    proxy_redirect off;
    proxy_pass http://syncwerk-server-restapi;
    client_max_body_size 0;
    access_log      /var/log/syncwerk/restapi-proxy.log;
    error_log       /var/log/syncwerk/restapi-proxy.log;
  }

  location /api/v2.1 {
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Host $http_host;
    proxy_redirect off;
    proxy_pass http://syncwerk-server-restapi;
    client_max_body_size 0;
    proxy_connect_timeout  120s;
    proxy_read_timeout  120s;
    proxy_send_timeout  120s;
    access_log      /var/log/syncwerk/restapi-proxy.log;
    error_log       /var/log/syncwerk/restapi-proxy.log;
  }

  location /api3 {
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Host $http_host;
    proxy_redirect off;
    proxy_pass http://syncwerk-server-restapi;
    client_max_body_size 0;
    proxy_connect_timeout  120s;
    proxy_read_timeout  120s;
    proxy_send_timeout  120s;
    access_log      /var/log/syncwerk/restapi-proxy.log;
    error_log       /var/log/syncwerk/restapi-proxy.log;
  }

  location /wikis {
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Host $http_host;
    proxy_redirect off;
    proxy_pass http://syncwerk-server-restapi;
    client_max_body_size 0;
    proxy_connect_timeout  120s;
    proxy_read_timeout  120s;
    proxy_send_timeout  120s;
    access_log      /var/log/syncwerk/restapi-proxy.log;
    error_log       /var/log/syncwerk/restapi-proxy.log;
  }


  location /media {
    root /usr/share/python/syncwerk/restapi/;
  }

  location /client-login {
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Host $http_host;
    proxy_redirect off;
    proxy_pass http://syncwerk-server-restapi;
    client_max_body_size 0;
    access_log      /var/log/syncwerk/restapi-proxy.log;
    error_log       /var/log/syncwerk/restapi-proxy.log;
  }
  
  location /webdav {
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Host $http_host;
    proxy_redirect off;
    proxy_pass http://127.0.0.1:8080;
    proxy_intercept_errors on;
    error_page 502 503 504 =404 /syncwerk-webdav-unavailable;
    proxy_request_buffering off;
    proxy_buffering off;
    client_max_body_size 0;
    proxy_connect_timeout 120s;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    access_log      /var/log/syncwerk/webdav.log;
    error_log       /var/log/syncwerk/webdav.log;
  }

  location = /syncwerk-webdav-unavailable {
    internal;
    return 404;
  }
  
  location = /onlyofficeds {
    return 404;
  }

  location = /onlyofficeds/ {
    return 404;
  }

  location = /onlyofficeds/welcome {
    return 404;
  }

  location ^~ /onlyofficeds/welcome/ {
    return 404;
  }

  location = /onlyofficeds/example {
    return 404;
  }

  location ^~ /onlyofficeds/example/ {
    return 404;
  }

  location ~ ^/onlyofficeds/(?<oo_version>[0-9]+\.[0-9]+\.[0-9]+[.-][A-Za-z0-9]+)/(?<oo_editor>web-apps/apps/(documenteditor|spreadsheeteditor|presentationeditor|visioeditor)/main)/index_loader\.html$ {
    proxy_pass http://127.0.0.1:9090/$oo_version/$oo_editor/index.html$is_args$args;
    proxy_http_version 1.1;
    client_max_body_size 100M;
    proxy_read_timeout 3600s;
    proxy_connect_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $proxy_connection;
    proxy_set_header X-Forwarded-Host $the_host/onlyofficeds;
    proxy_set_header X-Forwarded-Proto $the_scheme;
    proxy_set_header X-Forwarded-For $remote_addr;
    access_log      /var/log/syncwerk/onlyoffice.log;
    error_log       /var/log/syncwerk/onlyoffice.log;
  }

  location /onlyofficeds/ {
    proxy_pass http://127.0.0.1:9090/;
    proxy_http_version 1.1;
    client_max_body_size 100M;
    proxy_read_timeout 3600s;
    proxy_connect_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $proxy_connection;
    proxy_set_header X-Forwarded-Host $the_host/onlyofficeds;
    proxy_set_header X-Forwarded-Proto $the_scheme;
    proxy_set_header X-Forwarded-For $remote_addr;
    access_log      /var/log/syncwerk/onlyoffice.log;
    error_log       /var/log/syncwerk/onlyoffice.log;
  }
  
}
EOF
sed -i "s/HOSTNAME/${HOSTNAME}/g" /etc/nginx/conf.d/syncwerk.conf
else
    echo "/etc/nginx/conf.d/syncwerk.conf exists. Skipping.."
fi
echo


echo "Setting up self-signed SSL certificate for Syncwerk services"
if [ ! -f "/etc/nginx/ssl/syncwerk.key" ] ; then
mkdir -p /etc/nginx/ssl
openssl genrsa -out /etc/nginx/ssl/syncwerk.key 4096
cat > /etc/nginx/ssl/syncwerk.cnf <<EOF
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
prompt = no
[req_distinguished_name]
countryName = DE
stateOrProvinceName = Bayern
localityName = Wiesentheid
organizationName = Syncwerk GmbH
organizationalUnitName = WebApp
emailAddress = support@syncwerk.com
# Must be last for Syncwerk client to validate...
commonName = ${HOSTNAME}
[v3_ca]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
basicConstraints = CA:TRUE
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = $(hostname -f)
DNS.3 = $(hostname -s)
EOF
openssl req -new -x509 -key /etc/nginx/ssl/syncwerk.key -out /etc/nginx/ssl/syncwerk.crt -days 10950 -config /etc/nginx/ssl/syncwerk.cnf -sha256
else
    echo "/etc/nginx/ssl/syncwerk.key exists. Skipping.."
fi
echo


echo "Setting up NGINX proxy for Syncwerk services"
echo

echo "Backing up old /etc/nginx/nginx.conf"
cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.syncwerk-backup.$(date +"%Y-%m-%d_%H-%M-%S")
echo

echo "Deleting redundant backups"
fdupes /etc/nginx/ | grep "nginx.conf.syncwerk-backup" | xargs rm
echo

echo "Creating optimized nginx.conf"
cat > /etc/nginx/nginx.conf <<ENDOFFILE
worker_processes auto;
events {
  worker_connections 8096;
  multi_accept on;
  use epoll;
}
pid /var/run/nginx.pid;
worker_rlimit_nofile 40000;
http {
  server_tokens off;
  server_names_hash_bucket_size 128;
  client_max_body_size 50M;
  include /etc/nginx/mime.types;
  default_type application/octet-stream;
  log_format main '\$remote_addr - \$remote_user [\$time_local] "\$request" '
  '\$status \$body_bytes_sent "\$http_referer" '
  '"\$http_user_agent" "\$http_x_forwarded_for"';
  access_log /var/log/nginx/access.log main;
  error_log /var/log/nginx/error.log warn;
  sendfile on;
  tcp_nopush on;
  tcp_nodelay on;
  client_body_timeout 12;
  client_header_timeout 12;
  keepalive_timeout 15;
  send_timeout 10;
  gzip on;
  gzip_vary on;
  gzip_proxied expired no-cache no-store private auth any;
  gzip_comp_level 9;
  gzip_min_length 10240;
  gzip_buffers 16 8k;
  gzip_http_version 1.1;
  gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/xml font/woff2;
  gzip_disable "MSIE [1-6].";
  include /etc/nginx/conf.d/*.conf;
  map \$scheme \$php_https {
    default off;
    https on;
  }
  include perfect-forward-secrecy.conf;
}
ENDOFFILE
echo


echo "Setting up perfect forward secrecy"
if [ ! -f "/etc/nginx/perfect-forward-secrecy.conf" ] ; then
openssl dhparam -dsaparam -out /etc/nginx/dh4096.pem 4096
cat > /etc/nginx/perfect-forward-secrecy.conf <<'EOF'
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_ciphers "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA";
ssl_dhparam dh4096.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
EOF
echo
else
    echo "/etc/nginx/ssl/syncwerk.key exists. Skipping.."
fi


# Ensuring log dir exists
mkdir -p /var/log/syncwerk/
echo


OS=$(lsb_release -sc)
if [ ${OS} = jessie ] ; then
  echo "Removing HTTP/2 extension"
  sed -i 's/http2//g' /etc/nginx/conf.d/syncwerk.conf
  echo
fi


echo "Restarting NGINX"
service nginx restart
}


function setup-ccnet-conf {
SERVERID="$(cat /dev/urandom | tr -dc "a-fA-F0-9" | fold -w 40 | head -n 1)"
DBUSER="$(grep user /etc/syncwerk/mysql.txt | awk -F"=" '{ print $2}')"
DBPASS="$(grep password /etc/syncwerk/mysql.txt | awk -F"=" '{ print $2}')"
DBNAME="syncwerk-ccnet"
sed -i "s/SERVERID/${SERVERID}/g" /etc/syncwerk/ccnet.conf
sed -i "s/HOSTNAME/${HOSTNAME}/g" /etc/syncwerk/ccnet.conf
sed -i "s/DBUSER/${DBUSER}/g" /etc/syncwerk/ccnet.conf
sed -i "s/DBPASS/${DBPASS}/g" /etc/syncwerk/ccnet.conf
sed -i "s/DBNAME/${DBNAME}/g" /etc/syncwerk/ccnet.conf
}


function setup-my-key-peer {
if [ ! -f "/etc/syncwerk/mykey.peer" ] ; then
openssl genrsa -out /etc/syncwerk/mykey.peer 2048
chmod 600 /etc/syncwerk/mykey.peer
else
    echo "/etc/syncwerk/mykey.peer exists. Skipping.."
fi
}


function setup-ccnet-database {
# Setup EmailUser and LDAPUsers tables so syncwerk-server binary is satisfied and will not abort. CCNET will create the other missing tables during startup.
mysql -e "CREATE TABLE IF NOT EXISTS \`syncwerk-ccnet\`.\`EmailUser\` ( \`id\` int(11) NOT NULL AUTO_INCREMENT, \`email\` varchar(255) DEFAULT NULL, \`passwd\` varchar(256) DEFAULT NULL, \`is_staff\` tinyint(1) NOT NULL, \`is_active\` tinyint(1) NOT NULL, \`ctime\` bigint(20) DEFAULT NULL, PRIMARY KEY (\`id\`), UNIQUE KEY \`email\` (\`email\`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;"
mysql -e "CREATE TABLE IF NOT EXISTS \`syncwerk-ccnet\`.\`LDAPUsers\` ( \`id\` bigint(20) NOT NULL AUTO_INCREMENT, \`email\` varchar(255) NOT NULL, \`password\` varchar(255) NOT NULL, \`language\` varchar(255) DEFAULT NULL, \`is_staff\` tinyint(1) NOT NULL, \`is_active\` tinyint(1) NOT NULL, \`ctime\` bigint(20) DEFAULT NULL, \`extra_attrs\` text DEFAULT NULL, \`reference_id\` varchar(255) DEFAULT NULL, PRIMARY KEY (\`id\`), UNIQUE KEY \`email\` (\`email\`), UNIQUE KEY \`reference_id\` (\`reference_id\`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;"
}


function normalize-ccnet-auth-collations {
mysql -e "ALTER TABLE \`syncwerk-ccnet\`.\`EmailUser\` CONVERT TO CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci;"
mysql -e "ALTER TABLE \`syncwerk-ccnet\`.\`LDAPUsers\` CONVERT TO CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci;"
mysql -e "ALTER TABLE \`syncwerk-ccnet\`.\`UserRole\` CONVERT TO CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci;" >/dev/null 2>&1 || true
}


function setup-file-server-conf {
DBUSER="$(grep user /etc/syncwerk/mysql.txt | awk -F"=" '{ print $2}')"
DBPASS="$(grep password /etc/syncwerk/mysql.txt | awk -F"=" '{ print $2}')"
DBNAME="syncwerk-server"
sed -i "s/DBUSER/${DBUSER}/g" /etc/syncwerk/server.conf
sed -i "s/DBPASS/${DBPASS}/g" /etc/syncwerk/server.conf
sed -i "s/DBNAME/${DBNAME}/g" /etc/syncwerk/server.conf
}


function setup-gunicorn-conf {
local active_gunicorn_conf="/etc/syncwerk/gunicorn.conf.py"
local legacy_gunicorn_conf="/etc/syncwerk/gunicorn.conf"

if [ -f "${active_gunicorn_conf}" ] ; then
    echo "${active_gunicorn_conf} exists. Skipping.."
    return 0
fi

if [ -f "${legacy_gunicorn_conf}" ] ; then
    cp -a "${legacy_gunicorn_conf}" "${active_gunicorn_conf}"
    echo "Copied legacy ${legacy_gunicorn_conf} to ${active_gunicorn_conf}. Legacy file left in place for rollback."
else
cat > "${active_gunicorn_conf}" <<EOF
import multiprocessing
daemon = False
workers = multiprocessing.cpu_count()
threads = 5
timeout = 1200
pid = "/run/syncwerk/restapi.pid"
bind = "unix:/run/syncwerk/restapi.sock"
umask = 0o007
EOF
fi

if ! grep -Eq '^[[:space:]]*umask[[:space:]]*=' "${active_gunicorn_conf}" ; then
    cp -a "${active_gunicorn_conf}" "${active_gunicorn_conf}.${TIMESTAMP}.bak"
    printf '\numask = 0o007\n' >>"${active_gunicorn_conf}"
fi

chown syncwerk:syncwerk "${active_gunicorn_conf}"
chmod 0640 "${active_gunicorn_conf}"
}


function setup-restapi-settings-py {
SECRETKEY="$(</dev/urandom tr -dc 'A-Za-z0-9!#$%&\''()*+,-.:;<=>?@[\]^_`{|}~' | head -c 50  ; echo)"
APIDBNAME="syncwerk-restapi"
CCNETDBNAME="syncwerk-ccnet"
SERVERDBNAME="syncwerk-server"
DBUSER="$(grep user /etc/syncwerk/mysql.txt | awk -F"=" '{ print $2}')"
DBPASS="$(grep password /etc/syncwerk/mysql.txt | awk -F"=" '{ print $2}')"
sed -i "s/SECRETKEY/${SECRETKEY}/g" /etc/syncwerk/restapi_settings.py
sed -i "s/APIDBNAME/${APIDBNAME}/g" /etc/syncwerk/restapi_settings.py
sed -i "s/CCNETDBNAME/${CCNETDBNAME}/g" /etc/syncwerk/restapi_settings.py
sed -i "s/SERVERDBNAME/${SERVERDBNAME}/g" /etc/syncwerk/restapi_settings.py
sed -i "s/DBUSER/${DBUSER}/g" /etc/syncwerk/restapi_settings.py
sed -i "s/DBPASS/${DBPASS}/g" /etc/syncwerk/restapi_settings.py
sed -i "s/HOSTNAME/${HOSTNAME}/g" /etc/syncwerk/restapi_settings.py
sed -i 's/default_admin/superadmin/g' /etc/syncwerk/restapi_settings.py
}


function setup-services {
local RUNGROUP
RUNGROUP="$(syncwerk-runtime-group)"
# Ensuring required dirs exist
mkdir -p ${OBJECT_STORAGE_PATH}/template ${OBJECT_STORAGE_PATH}/avatars /var/log/syncwerk /run/syncwerk

# Ensuring correct ownerships is set
chown -R syncwerk:nogroup /etc/syncwerk /usr/share/python/syncwerk /usr/share/syncwerk /var/log/syncwerk
chown syncwerk:"${RUNGROUP}" /run/syncwerk
chmod 2750 /run/syncwerk
ps aux | grep "chown -R syncwerk:nogroup /var/lib/syncwerk" | grep -vq grep || (nice -n 19 chown -R syncwerk:nogroup /var/lib/syncwerk &)

if [[ ! $(tr -d '\0' < /proc/1/environ) == *container* ]] ; then
echo "Setting up systemd autostart"
cat > /etc/systemd/system/syncwerk-server.service <<'EOF'
[Unit]
Description=Syncwerk Server
After=network.target mysql.service
[Service]
Type=simple
ExecStart=/usr/bin/syncwerk-server start --foreground
ExecStop=/usr/bin/syncwerk-server stop
[Install]
WantedBy=multi-user.target
EOF
systemctl enable syncwerk-server
else
echo "Setting up sysv autostart"
cat > /etc/init.d/syncwerk-server <<'EOF'
#!/bin/bash
### BEGIN INIT INFO
# Provides:          syncwerk-server
# Required-Start:    $remote_fs $syslog mysql
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Syncwerk server
# Description:       Start Syncwerk server
### END INIT INFO
# Author: Alexander Jackson <alexander.jackson@syncwerk.com>
#
case "$1" in
  start)
    /usr/bin/syncwerk-server start
    ;;
  restart)
    /usr/bin/syncwerk-server stop
    /usr/bin/syncwerk-server start
    ;;
  stop)
    /usr/bin/syncwerk-server stop
    ;;
  *)
    echo "Usage: /etc/init.d/syncwerk-server {start|stop|restart}"
    exit 1
    ;;
esac
EOF
chmod +x /etc/init.d/syncwerk-server
update-rc.d syncwerk-server defaults
fi

if [ -f /.dockerenv ] ; then
cat > /etc/init.d/syncwerk-server <<'EOF'
#!/bin/bash
### BEGIN INIT INFO
# Provides:          syncwerk-server
# Required-Start:    $remote_fs $syslog mysql
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Syncwerk server
# Description:       Start Syncwerk server
### END INIT INFO
# Author: Alexander Jackson <alexander.jackson@syncwerk.com>
#
case "$1" in
  start)
    /usr/bin/syncwerk-server start
    ;;
  restart)
    /usr/bin/syncwerk-server stop
    /usr/bin/syncwerk-server start
    ;;
  stop)
    /usr/bin/syncwerk-server stop
    ;;
  status)
    if PID=$(pgrep -f /usr/bin/syncwerk-server)
    then
      echo "syncwerk-server is running with PID ${PID}"
    else
      echo "syncwerk-server is not running"
    fi
    ;;
  *)
    echo "Usage: /etc/init.d/syncwerk-server {start|stop|restart|status}"
    exit 1
    ;;
esac

EOF
chmod +x /etc/init.d/syncwerk-server
update-rc.d syncwerk-server defaults
fi
}


function migrate-avatars {
GROUP="$(syncwerk-group)"
install -d -o syncwerk -g "${GROUP}" -m 0755 /var/lib/syncwerk/avatars
chmod 0711 /var/lib/syncwerk
if [ -L /usr/share/python/syncwerk/restapi/media/avatars ] ; then
  echo "Avatars seem migrated. Ensuring permissions and defaults."
else
  if [ -e /usr/share/python/syncwerk/restapi/media/avatars/ ] ; then
    mv /usr/share/python/syncwerk/restapi/media/avatars/* /var/lib/syncwerk/avatars/
    rm -rf /usr/share/python/syncwerk/restapi/media/avatars
  fi
  ln -s /var/lib/syncwerk/avatars /usr/share/python/syncwerk/restapi/media/avatars
fi
chown -R syncwerk:"${GROUP}" /var/lib/syncwerk/avatars
chown -h syncwerk:"${GROUP}" /usr/share/python/syncwerk/restapi/media/avatars
chmod 0755 /var/lib/syncwerk/avatars
seed-default-avatars
}


function fix-permissions {
local RUNGROUP
RUNGROUP="$(syncwerk-runtime-group)"
# Ensuring required dirs exist
mkdir -p ${OBJECT_STORAGE_PATH}/template ${OBJECT_STORAGE_PATH}/avatars /var/log/syncwerk /run/syncwerk

echo "Ensuring correct ownership is set. Running chown in background..."
chown -R syncwerk:nogroup /etc/syncwerk /usr/share/python/syncwerk /usr/share/syncwerk /var/log/syncwerk
chown syncwerk:"${RUNGROUP}" /run/syncwerk
chmod 2750 /run/syncwerk
ps aux | grep "chown -R syncwerk:nogroup /var/lib/syncwerk" | grep -vq grep || (nice -n 19 chown -R syncwerk:nogroup /var/lib/syncwerk &)
chmod 0711 ${OBJECT_STORAGE_PATH}
chmod 0755 ${OBJECT_STORAGE_PATH}/avatars
seed-default-avatars
}


function stop {
syncwerk-server stop
}


function start {
if [ ! -f /.dockerenv ] ; then
  systemctl start syncwerk-server.service
else
  service syncwerk-server start
fi
command supervisorctl start all > /dev/null 2>&1 || true
}


function restart {
stop
start
}


function fsck {
sudo -u syncwerk bash -c "syncwerk-server-fsck -c /etc/syncwerk -d /var/lib/syncwerk -F /etc/syncwerk ${1} ${2} ${3} ${4} ${5} ${6} ${7} ${8} | tee -a /var/log/syncwerk/fsck.log"
}


function mount {
touch /var/log/syncwerk/fuse.log
chown syncwerk:nogroup /var/log/syncwerk/fuse.log
syncwerk-server-fuse -c /etc/syncwerk/ -F /etc/syncwerk/ -d ${OBJECT_STORAGE_PATH} -l /var/log/syncwerk/fuse.log ${1}
}


function umount {
pkill -SIGTERM -f "syncwerk-server-fuse" || true
}


function gc {
sudo -u syncwerk bash -c "syncwerk-server-gc -c /etc/syncwerk -d /var/lib/syncwerk -F /etc/syncwerk ${1} ${2} ${3} ${4} ${5} ${6} ${7} ${8} | tee -a /var/log/syncwerk/gc.log"
}


function backup-databases {
get-database-credentials
unique-database-names "${CCNET_DB:-}" "${RESTAPI_DB:-}" "${SERVER_DB:-}"
if [ "${#SYNCWERK_BACKUP_DATABASES[@]}" -gt 0 ] ; then
  local db
  for db in "${SYNCWERK_BACKUP_DATABASES[@]}" ; do
    if ! database-exists "${db}" ; then
      echo "Could not backup databases. They probably don't exist yet. Skipping.."
      return 0
    fi
  done

  echo "Backing up current databases"
  mkdir -p "${OBJECT_STORAGE_PATH}/backup"
  mysqldump --databases "${SYNCWERK_BACKUP_DATABASES[@]}" | pigz > "${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-databases.sql.gz"
  echo "Calculating sha256sum for databases backup file"
  pv "${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-databases.sql.gz" | sha256sum > "${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-databases.sql.gz.sha256.txt"
else
  echo "Databases not configured. Skipping backup.."
fi
}


function backup-object-storage {
AVAILABLE_SPACE_ON_BACKUP_DRIVE=$(df --output=avail -m ${OBJECT_STORAGE_PATH}/backup/ | tail -1)
TOTAL_SPACE_USED_FOR_BLOCK_STORAGE=$(du --total --summarize --block-size=1M ${OBJECT_STORAGE_PATH}/fs ${OBJECT_STORAGE_PATH}/storage ${OBJECT_STORAGE_PATH}/commits | tail -1 | cut -f1)
AVAILABLE_SPACE_AFTER_BACKUP=$(( AVAILABLE_SPACE_ON_BACKUP_DRIVE - TOTAL_SPACE_USED_FOR_BLOCK_STORAGE ))
if [[ ${AVAILABLE_SPACE_AFTER_BACKUP} -gt 2000 ]] ; then
  (( ${AVAILABLE_SPACE_AFTER_BACKUP} >= $(( AVAILABLE_SPACE_ON_BACKUP_DRIVE - $(( TOTAL_SPACE_USED_FOR_BLOCK_STORAGE * 3 / 2 )) )) )) || (echo "Not enough backup space available. You need to free up at least $(( AVAILABLE_SPACE_ON_BACKUP_DRIVE - $(( TOTAL_SPACE_USED_FOR_BLOCK_STORAGE * 3 / 2 )))) MB of disk space at ${OBJECT_STORAGE_PATH}/backup/" | tee ${OBJECT_STORAGE_PATH}/${TIMESTAMP}_syncwerk-object-storage-backup-not-possible-due-to-insufficient-backup-space.txt ; df -h >> ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-object-storage-backup-not-possible-due-to-insufficient-backup-space.txt ; exit)
  echo "Backing up object storage"
  mkdir -p ${OBJECT_STORAGE_PATH}/backup
  tar --exclude="${OBJECT_STORAGE_PATH}/backup" -cf - ${OBJECT_STORAGE_PATH} | pv -s $(du -sb ${OBJECT_STORAGE_PATH} | awk '{print $1}') | ${ZIPPING_COMMAND} > ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-object-storage.gz
  #tar --exclude="${OBJECT_STORAGE_PATH}/backup" -cf - ${OBJECT_STORAGE_PATH} | pv -s $(du -sb ${OBJECT_STORAGE_PATH} | awk '{print $1}') | ${ZIPPING_COMMAND} | gpg --batch --symmetric --passphrase MySecretPassword1 --cipher-algo aes256 -o backup.tar.gz.gpg
  echo "Calculating sha256sum for object storage backup file"
  pv ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-object-storage.gz | sha256sum > ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-object-storage.gz.sha256.txt
else
  echo "Not enough backup space available. You need to free at least 2000 MB of disk space on ${OBJECT_STORAGE_PATH}/backup/. Then try again." | tee ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-object-storage-backup-not-possible-due-to-insufficient-backup-space.txt ; df -h >> ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-object-storage-backup-not-possible-due-to-insufficient-backup-space.txt
fi
}


function backup-configurations {
echo "Backing up configuration"
mkdir -p ${OBJECT_STORAGE_PATH}/backup
tar -cf - /etc/syncwerk/ /etc/nginx/ /etc/mysql/ | ${ZIPPING_COMMAND} > ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-configurations.gz
echo "Calculating sha256sum for configurations backup file"
pv ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-configurations.gz | sha256sum > ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-configurations.gz.sha256.txt
}


function backup-server-applications {
echo "Backing up server application"
mkdir -p ${OBJECT_STORAGE_PATH}/backup
tar -cf - /usr/bin/syncwerk-server* /usr/sbin/syncwerk-server* /usr/include/syncwerk /usr/share/python/syncwerk /usr/share/syncwerk | pv -s $(du --total --summarize --block-size=1M /usr/bin/syncwerk-server* /usr/sbin/syncwerk-server* /usr/include/syncwerk /usr/share/python/syncwerk /usr/share/syncwerk | tail -1 | cut -f1) | ${ZIPPING_COMMAND} > ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-server-applications.gz
pv ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-server-applications.gz | sha256sum > ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_syncwerk-server-applications.gz.sha256.txt
}


function backup-all {
backup-configurations
backup-server-applications
backup-databases
backup-object-storage
echo "Here are your backups"
ls -ahl ${OBJECT_STORAGE_PATH}/backup/${TIMESTAMP}_*
}


function create-admin {
if [ ! -f "/etc/syncwerk/admin.txt" ] ; then
echo "Creating Syncwerk admin user"
ADMIN_USER=admin@${HOSTNAME}
ADMIN_PASSWORD=$(pwgen 14 1)
cat > /etc/syncwerk/admin.txt <<EOF
{
  "email": "${ADMIN_USER}",
  "password": "${ADMIN_PASSWORD}"
}
EOF
/usr/share/python/syncwerk/restapi/bin/python2.7 /usr/share/python/syncwerk/restapi/restapi/create-admin.py
echo "Saving Syncwerk admin credentials to /etc/syncwerk/admin.txt"
cat > /etc/syncwerk/admin.txt <<EOF
Mail = ${ADMIN_USER}
Pass = ${ADMIN_PASSWORD}
EOF
chmod 600 /etc/syncwerk/admin.txt
chown syncwerk:nogroup /etc/syncwerk/admin.txt
echo
fi
}


function report_redact_stream {
sed -E \
  -e '/-----BEGIN [A-Z ]*PRIVATE KEY-----/,/-----END [A-Z ]*PRIVATE KEY-----/c\[REDACTED private key block]' \
  -e 's#([a-z][a-z0-9+.-]*://)[^/@[:space:]]+:[^/@[:space:]]+@#\1[REDACTED]@#Ig' \
  -e "/[\"']?[A-Za-z0-9_.-]*(password|passwd|secret|token|api[_-]?key|private[_-]?key|jwt|cookie|session|credential|authorization|access[_-]?key|bindpw|bind[_-]?password|dbpass)[A-Za-z0-9_.-]*[\"']?[[:space:]]*[:=][[:space:]]*/I s/.*/[REDACTED sensitive assignment line]/" \
  -e 's#(--[A-Za-z0-9_.-]*(password|passwd|secret|token|api[_-]?key|private[_-]?key|jwt|cookie|session|credential|authorization|access[_-]?key|bindpw|bind[_-]?password|dbpass)[A-Za-z0-9_.-]*=)[^[:space:]]+#\1[REDACTED]#Ig' \
  -e 's#(--[A-Za-z0-9_.-]*(password|passwd|secret|token|api[_-]?key|private[_-]?key|jwt|cookie|session|credential|authorization|access[_-]?key|bindpw|bind[_-]?password|dbpass)[A-Za-z0-9_.-]*[[:space:]]+)[^[:space:]]+#\1[REDACTED]#Ig' \
  -e 's#("[^"]*(password|passwd|secret|token|api[_-]?key|private[_-]?key|jwt|cookie|session|credential|authorization|access[_-]?key|bindpw|bind[_-]?password|dbpass)[^"]*"[[:space:]]*:[[:space:]]*)("[^"]*"|[^,}[:space:]]+)#\1"[REDACTED]"#Ig' \
  -e '/^[[:space:]]*[#;]?[[:space:]]*[A-Za-z0-9_.-]*(password|passwd|secret|token|api[_-]?key|private[_-]?key|jwt|cookie|session|credential|authorization|access[_-]?key|bindpw|bind[_-]?password|dbpass)[^=:]*[=:]/I s/.*/[REDACTED sensitive configuration line]/' \
  -e '/^[[:space:]]*(Authorization|Cookie|Set-Cookie|X-Auth-Token|X-Api-Key|X-Access-Token|X-Session-Token)[[:space:]]*:/I s/.*/[REDACTED sensitive header line]/'
}


function report_write_json_string {
local value="$1"
python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "${value}"
}


function report_capture_command {
local target="$1"
shift
local output="${REPORT_WORKDIR}/${target}"
local rc=0

mkdir -p "$(dirname "${output}")"
{
  echo "Command: $*"
  echo "Timestamp: $(date --iso-8601=seconds)"
  echo
  "$@" 2>&1 || rc=$?
  echo
  echo "Exit-Code: ${rc}"
} | report_redact_stream > "${output}"
return 0
}


function report_collect_package_inventory {
local output="${REPORT_WORKDIR}/packages/dpkg-query-relevant.tsv"
local all_output="${REPORT_WORKDIR}/packages/dpkg-query.tsv"

mkdir -p "$(dirname "${output}")"

{
  printf 'package\tversion\tarchitecture\tstatus\n'
  if command -v dpkg-query >/dev/null 2>&1 ; then
    dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\t${db:Status-Abbrev}\n' 2>&1 \
      | awk -F'\t' 'BEGIN { IGNORECASE=1 } $1 ~ /^(syncwerk|mariadb|mysql|nginx|python3|gunicorn|onlyoffice|ds-|rabbitmq|postgresql|clamav|memcached|redis)/ { print }'
  else
    printf 'dpkg-query\tunavailable\tunknown\tunknown\n'
  fi
} | report_redact_stream > "${output}"

{
  printf 'package\tversion\tarchitecture\tstatus\n'
  if command -v dpkg-query >/dev/null 2>&1 ; then
    dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\t${db:Status-Abbrev}\n' 2>&1
  else
    printf 'dpkg-query\tunavailable\tunknown\tunknown\n'
  fi
} | report_redact_stream > "${all_output}"
}


function report_collect_apt_sources {
local output="${REPORT_WORKDIR}/config/apt-sources-redacted.txt"
mkdir -p "$(dirname "${output}")"
{
  if [ -d /etc/apt ] ; then
    find /etc/apt -type f ! -name '*.gpg' ! -name '*.asc' ! -name '*.key' -print 2>/dev/null | sort | while read -r line ; do
      echo
      echo "Content of ${line}"
      echo "-------------------------------------------------------"
      cat "${line}" 2>&1 || true
    done
  else
    echo "/etc/apt not found"
  fi
} | report_redact_stream > "${output}"
}


function report_collect_syncwerk_config {
local root="/etc/syncwerk"
local output="${REPORT_WORKDIR}/config/etc-syncwerk-file-list.txt"
local source_file
mkdir -p "${REPORT_WORKDIR}/config/etc-syncwerk"

if [ ! -d "${root}" ] ; then
  echo "${root} not found" > "${output}"
  return 0
fi

{
  echo "Only active text configuration files are copied."
  echo "Backup, key, credential and database helper files are intentionally omitted."
  echo
  for source_file in \
    "${root}/ccnet.conf" \
    "${root}/server.conf" \
    "${root}/webdav.conf" \
    "${root}/gunicorn.conf.py" \
    "${root}/gunicorn.conf" \
    "${root}/storage.ini" \
    "${root}/restapi_settings.py"
  do
    [ -f "${source_file}" ] || continue
    ls -l "${source_file}" 2>/dev/null || true
  done
} | report_redact_stream > "${output}"

for source_file in \
  "${root}/ccnet.conf" \
  "${root}/server.conf" \
  "${root}/webdav.conf" \
  "${root}/gunicorn.conf.py" \
  "${root}/gunicorn.conf" \
  "${root}/storage.ini" \
  "${root}/restapi_settings.py"
do
  [ -f "${source_file}" ] || continue
  local rel="${source_file#${root}/}"
  local dest="${REPORT_WORKDIR}/config/etc-syncwerk/${rel}"

  mkdir -p "$(dirname "${dest}")"
  if grep -Iq . "${source_file}" 2>/dev/null || [ ! -s "${source_file}" ] ; then
    report_redact_stream < "${source_file}" > "${dest}"
  else
    echo "[skipped non-text file]" > "${dest}"
  fi
done
}


function report_collect_logs {
local output="${REPORT_WORKDIR}/logs/tail-redacted.log"
local log_file
mkdir -p "$(dirname "${output}")"
{
  for log_file in /var/log/syncwerk/*.log /var/log/nginx/error.log ; do
    [ -f "${log_file}" ] || continue
    echo
    echo "==> ${log_file} <=="
    tail -n 500 "${log_file}" 2>&1 || true
  done
} | report_redact_stream > "${output}"
}


function report_collect_mariadb_upgrade_status {
local output="${REPORT_WORKDIR}/services/mariadb-system-tables.txt"
local upgrade_cmd=""
local check_output=""
local rc=0

mkdir -p "$(dirname "${output}")"

{
  echo "MariaDB system table upgrade status"
  echo "Timestamp: $(date --iso-8601=seconds)"
  echo

  if command -v mariadb >/dev/null 2>&1 ; then
    echo "MariaDB client version:"
    mariadb --version 2>&1 || true
    echo
  elif command -v mysql >/dev/null 2>&1 ; then
    echo "MySQL/MariaDB client version:"
    mysql --version 2>&1 || true
    echo
  else
    echo "MariaDB/MySQL client not found"
    echo
  fi

  if command -v mariadb-upgrade >/dev/null 2>&1 ; then
    upgrade_cmd="$(command -v mariadb-upgrade)"
  elif command -v mysql_upgrade >/dev/null 2>&1 ; then
    upgrade_cmd="$(command -v mysql_upgrade)"
  fi

  if [ -z "${upgrade_cmd}" ] ; then
    echo "Status: check-unavailable"
    echo "Reason: mariadb-upgrade/mysql_upgrade not found"
    return 0
  fi

  echo "Command: ${upgrade_cmd} --check-if-upgrade-is-needed --silent"
  if check_output="$("${upgrade_cmd}" --check-if-upgrade-is-needed --silent 2>&1)" ; then
    rc=0
  else
    rc=$?
  fi

  [ -z "${check_output}" ] || printf '%s\n' "${check_output}"
  echo "Exit-Code: ${rc}"

  case "${rc}" in
    0)
      echo "Status: upgrade-required"
      echo "Action: create or verify a database backup, run '${upgrade_cmd} --upgrade-system-tables', then rerun the check."
      ;;
    1)
      echo "Status: ok-no-upgrade-required"
      ;;
    *)
      echo "Status: validation-error"
      echo "Action: inspect MariaDB logs and rerun the MariaDB system table upgrade check manually before continuing an upgrade."
      ;;
  esac
} | report_redact_stream > "${output}"
}


function report_secret_guard {
local output="${REPORT_WORKDIR}/security/secret-scan.txt"
local status_json="${REPORT_WORKDIR}/security/redaction-status.json"
local findings
local sentinel
findings="$(mktemp /tmp/syncwerk-server-report-secret-scan.XXXXXX)"
mkdir -p "$(dirname "${output}")"

: > "${findings}"

if [ -n "${SYNCWERK_REPORT_SECRET_SENTINELS:-}" ] ; then
  while IFS= read -r sentinel ; do
    [ -n "${sentinel}" ] || continue
    grep -R -n -I -F -- "${sentinel}" "${REPORT_WORKDIR}" >> "${findings}" || true
  done <<EOF
${SYNCWERK_REPORT_SECRET_SENTINELS}
EOF
fi

grep -R -n -I -E -i -- \
  "-----BEGIN [A-Z ]*PRIVATE KEY-----|['\\\"]?[A-Za-z0-9_.-]*(PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|JWT|COOKIE|SESSION|CREDENTIAL|AUTHORIZATION|ACCESS[_-]?KEY|DBPASS|BINDPW)[A-Za-z0-9_.-]*['\\\"]?[[:space:]]*[:=][[:space:]]*['\\\"]?[^[:space:]#;,}\\\"']+" \
  "${REPORT_WORKDIR}" >> "${findings}" || true

if [ -s "${findings}" ] ; then
  mv "${findings}" "${output}"
  cat > "${status_json}" <<EOF
{
  "status": "failed",
  "created": $(report_write_json_string "$(date --iso-8601=seconds)"),
  "reason": "sensitive-looking data remained in the generated report tree",
  "archive_created": false
}
EOF
  cat <<EOF >&2
Sensitive-looking data was still detected in the generated report tree.
The archive was not created. Review and remove the temporary directory:
${REPORT_WORKDIR}
EOF
  return 1
fi

rm -f "${findings}"
echo "No unredacted sensitive assignment, sentinel or private key block detected." > "${output}"
cat > "${status_json}" <<EOF
{
  "status": "ok",
  "created": $(report_write_json_string "$(date --iso-8601=seconds)"),
  "reason": "no unredacted sensitive assignment, configured sentinel or private key block detected",
  "archive_created": true
}
EOF
return 0
}


function report {
local timestamp
local report_file
local server_id="unknown"

set -o pipefail
timestamp=$(date +"%Y-%m-%d_%H-%M-%S")
REPORT_WORKDIR="$(mktemp -d /tmp/syncwerk-server-report.XXXXXX)"
report_file="/tmp/syncwerk-server-report_${timestamp}.tgz"
umask 077

syncwerk-server show-id >/dev/null 2>&1 && server_id="$(syncwerk-server show-id 2>/dev/null || echo unknown)"

mkdir -p "${REPORT_WORKDIR}/system" "${REPORT_WORKDIR}/services" "${REPORT_WORKDIR}/packages" "${REPORT_WORKDIR}/config" "${REPORT_WORKDIR}/logs" "${REPORT_WORKDIR}/security"

cat > "${REPORT_WORKDIR}/manifest.txt" <<EOF
Syncwerk Server Support Report
Created: $(date --iso-8601=seconds)
Server-ID: ${server_id}
Hostname: $(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)

This report is generated locally and is not uploaded automatically.
Configuration files and logs are copied only in redacted form where possible.
Handle the resulting archive as confidential system diagnostic material.
EOF

cat > "${REPORT_WORKDIR}/manifest.json" <<EOF
{
  "format": "syncwerk-server-support-report",
  "format_version": 2,
  "created": $(report_write_json_string "$(date --iso-8601=seconds)"),
  "server_id": $(report_write_json_string "${server_id}"),
  "hostname": $(report_write_json_string "$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)"),
  "archive": $(report_write_json_string "${report_file}"),
  "automatic_upload": false,
  "confidential": true,
  "redaction_required": true
}
EOF

cat > "${REPORT_WORKDIR}/SUPPORT_NOTICE.txt" <<EOF
This archive contains confidential system diagnostic material.

It is generated locally and is not uploaded automatically.
Submit it only through an approved support channel and only if
security/redaction-status.json reports status "ok".

If security/redaction-status.json is missing or reports "failed", do not
share the archive. Re-run the report generation after fixing the redaction
problem.
EOF

report_capture_command system/os-release.txt cat /etc/os-release
report_capture_command system/debian-version.txt cat /etc/debian_version
report_capture_command system/uname.txt uname -a
report_capture_command system/hostname.txt hostname -A
report_capture_command system/cpuinfo.txt cat /proc/cpuinfo
report_capture_command system/meminfo.txt cat /proc/meminfo
report_capture_command system/filesystems.txt df -hT
report_capture_command system/ip-addresses.txt ip addr show
report_capture_command system/routes.txt ip route show table all
report_capture_command system/resolv-conf.txt cat /etc/resolv.conf
report_capture_command system/hosts.txt cat /etc/hosts
report_capture_command services/systemctl-failed.txt systemctl --failed --no-pager
report_capture_command services/syncwerk-server-status.txt systemctl status syncwerk-server.service --no-pager
report_capture_command services/nginx-test.txt nginx -t
report_capture_command services/process-list.txt ps -eo pid,ppid,user,stat,pcpu,pmem,etime,comm
report_collect_mariadb_upgrade_status
report_collect_package_inventory
report_capture_command packages/dpkg-list.txt dpkg -l

report_collect_apt_sources
report_collect_syncwerk_config
report_collect_logs
report_secret_guard

tar -C "${REPORT_WORKDIR}" -czf "${report_file}" .
chmod 600 "${report_file}"
sha256sum "${report_file}" > "${report_file}.sha256"
chmod 600 "${report_file}.sha256"
rm -rf "${REPORT_WORKDIR}"
unset REPORT_WORKDIR

cat <<EOF

  EN The report has been saved locally:
     ${report_file}
     ${report_file}.sha256

     It was not uploaded automatically. Submit it manually with your error
     description if support requested it.

  DE Der Report wurde lokal gespeichert:
     ${report_file}
     ${report_file}.sha256

     Er wurde nicht automatisch hochgeladen. Übermitteln Sie ihn manuell mit
     Ihrer Fehlerbeschreibung, wenn der Support ihn angefordert hat.

EOF
}


function ldap_usage {
cat <<EOF

Usage:
  $(basename ${0}) configure-ldap --host LDAP_URI --base BASE_DN [OPTIONS]
  $(basename ${0}) test-ldap [CONFIG_FILE]

Options:
  --host URI                  LDAP URI, for example ldap://dc01.example.local/ or ldaps://dc01.example.local/
  --base DN                   Search base, for example CN=Users,DC=example,DC=local
  --bind-dn DN                Bind DN or AD UPN. If omitted, anonymous bind is used.
  --bind-password PASSWORD    Bind password. Prefer SYNCWERK_LDAP_BIND_PASSWORD or --bind-password-file.
  --bind-password-file FILE   Read bind password from FILE.
  --login-attr ATTRIBUTE      Login attribute. Default: mail. Samba AD bootstrap uses sAMAccountName.
  --filter FILTER             Additional LDAP filter fragment, for example memberOf=CN=Syncwerk,CN=Users,DC=example,DC=local
  --follow-referrals BOOL     true or false. Default: false.
  --cache-ttl SECONDS         Cache LDAP DN lookup only. Password binds still hit LDAP. Default: 300, 0 disables.
  --force                     Replace an existing [LDAP] section in /etc/syncwerk/ccnet.conf.
  --no-test                   Write configuration without ldapsearch validation.
  --restart                   Restart syncwerk-server after writing configuration.
  --dry-run                   Print the resulting LDAP section with the password redacted.

Environment:
  SYNCWERK_CCNET_CONF         Override ccnet.conf path. Default: /etc/syncwerk/ccnet.conf

EOF
}

function _syncwerk_ldap_filter_expr {
local login_attr="${1}"
local filter="${2}"

if [ -n "${filter}" ] ; then
  if [[ "${filter}" == \(* ]] ; then
    printf '%s' "${filter}"
  else
    printf '(%s)' "${filter}"
  fi
else
  printf '(%s=*)' "${login_attr}"
fi
}

function _syncwerk_ldap_search_test {
local host="${1}"
local base="${2}"
local bind_dn="${3}"
local bind_password="${4}"
local login_attr="${5}"
local filter="${6}"
local ldap_filter
local passfile=""
local rc=0

if ! command -v ldapsearch > /dev/null 2>&1 ; then
  echo "ldapsearch not found. Install ldap-utils or use --no-test." >&2
  return 1
fi

ldap_filter="$(_syncwerk_ldap_filter_expr "${login_attr}" "${filter}")"

if [ -n "${bind_dn}" ] ; then
  passfile="$(mktemp /tmp/syncwerk-ldap-bind.XXXXXX)"
  chmod 600 "${passfile}"
  printf '%s' "${bind_password}" > "${passfile}"
  ldapsearch -x -LLL -H "${host}" -D "${bind_dn}" -y "${passfile}" -b "${base}" -z 1 "${ldap_filter}" dn "${login_attr}" > /dev/null || rc=$?
  rm -f "${passfile}"
else
  ldapsearch -x -LLL -H "${host}" -b "${base}" -z 1 "${ldap_filter}" dn "${login_attr}" > /dev/null || rc=$?
fi

if [ "${rc}" -ne 0 ] ; then
  echo "LDAP validation failed for host=${host}, base=${base}, filter=${ldap_filter}." >&2
  return "${rc}"
fi
}

function configure-ldap {
local ccnet_conf="${SYNCWERK_CCNET_CONF:-/etc/syncwerk/ccnet.conf}"
local host=""
local base=""
local bind_dn=""
local bind_password="${SYNCWERK_LDAP_BIND_PASSWORD:-}"
local login_attr="mail"
local filter=""
local follow_referrals="false"
local cache_ttl="300"
local force="0"
local test_config="1"
local restart_service="0"
local dry_run="0"
local backup_conf=""

while [ "$#" -gt 0 ] ; do
  case "${1}" in
    --host|--url) host="${2:-}" ; shift 2 ;;
    --base) base="${2:-}" ; shift 2 ;;
    --bind-dn|--user-dn) bind_dn="${2:-}" ; shift 2 ;;
    --bind-password) bind_password="${2:-}" ; shift 2 ;;
    --bind-password-file)
      bind_password="$(tr -d '\r\n' < "${2}")"
      shift 2
      ;;
    --login-attr) login_attr="${2:-}" ; shift 2 ;;
    --filter) filter="${2:-}" ; shift 2 ;;
    --follow-referrals) follow_referrals="${2:-}" ; shift 2 ;;
    --cache-ttl) cache_ttl="${2:-}" ; shift 2 ;;
    --force) force="1" ; shift ;;
    --no-test) test_config="0" ; shift ;;
    --restart) restart_service="1" ; shift ;;
    --dry-run) dry_run="1" ; shift ;;
    -h|--help) ldap_usage ; return 0 ;;
    *)
      echo "Unknown configure-ldap option: ${1}" >&2
      ldap_usage
      return 2
      ;;
  esac
done

if [ -z "${host}" ] || [ -z "${base}" ] ; then
  echo "--host and --base are required." >&2
  ldap_usage
  return 2
fi

if [ -n "${bind_dn}" ] && [ -z "${bind_password}" ] ; then
  echo "--bind-password, --bind-password-file or SYNCWERK_LDAP_BIND_PASSWORD is required when --bind-dn is set." >&2
  return 2
fi

case "${follow_referrals}" in
  true|false|TRUE|FALSE|True|False) ;;
  *)
    echo "--follow-referrals must be true or false." >&2
    return 2
    ;;
esac

if ! [[ "${cache_ttl}" =~ ^[0-9]+$ ]] ; then
  echo "--cache-ttl must be a non-negative integer." >&2
  return 2
fi

if [ "${dry_run}" = "1" ] ; then
cat <<EOF
[LDAP]
HOST = ${host}
BASE = ${base}
EOF
if [ -n "${bind_dn}" ] ; then
cat <<EOF
USER_DN = ${bind_dn}
PASSWORD = ********
EOF
fi
cat <<EOF
LOGIN_ATTR = ${login_attr}
EOF
if [ -n "${filter}" ] ; then
cat <<EOF
FILTER = ${filter}
EOF
fi
cat <<EOF
FOLLOW_REFERRALS = ${follow_referrals}
CACHE_TTL = ${cache_ttl}
EOF
  return 0
fi

if [ ! -f "${ccnet_conf}" ] ; then
  echo "${ccnet_conf} not found." >&2
  return 1
fi

if grep -Eq '^\[LDAP\]' "${ccnet_conf}" && [ "${force}" != "1" ] ; then
  echo "Found existing [LDAP] section in ${ccnet_conf}. Use --force to replace it." >&2
  return 1
fi

if [ "${test_config}" = "1" ] ; then
  _syncwerk_ldap_search_test "${host}" "${base}" "${bind_dn}" "${bind_password}" "${login_attr}" "${filter}"
fi

backup_conf="${ccnet_conf}.${TIMESTAMP}.bak"
cp -a "${ccnet_conf}" "${backup_conf}"
SYNCWERK_LDAP_HOST="${host}" \
SYNCWERK_LDAP_BASE="${base}" \
SYNCWERK_LDAP_BIND_DN="${bind_dn}" \
SYNCWERK_LDAP_BIND_PASSWORD="${bind_password}" \
SYNCWERK_LDAP_LOGIN_ATTR="${login_attr}" \
SYNCWERK_LDAP_FILTER="${filter}" \
SYNCWERK_LDAP_FOLLOW_REFERRALS="${follow_referrals}" \
SYNCWERK_LDAP_CACHE_TTL="${cache_ttl}" \
python3 - "${ccnet_conf}" <<'PY'
import os
import sys
from pathlib import Path

path = Path(sys.argv[1])
text = path.read_text()
lines = text.splitlines()
out = []
skip = False

for line in lines:
    if line.strip().lower() == "[ldap]":
        skip = True
        continue
    if skip and line.startswith("[") and line.strip().endswith("]"):
        skip = False
    if not skip:
        out.append(line)

while out and out[-1] == "":
    out.pop()

section = [
    "",
    "[LDAP]",
    f"HOST = {os.environ['SYNCWERK_LDAP_HOST']}",
    f"BASE = {os.environ['SYNCWERK_LDAP_BASE']}",
]

bind_dn = os.environ.get("SYNCWERK_LDAP_BIND_DN", "")
if bind_dn:
    section.append(f"USER_DN = {bind_dn}")
    section.append(f"PASSWORD = {os.environ.get('SYNCWERK_LDAP_BIND_PASSWORD', '')}")

section.append(f"LOGIN_ATTR = {os.environ['SYNCWERK_LDAP_LOGIN_ATTR']}")

ldap_filter = os.environ.get("SYNCWERK_LDAP_FILTER", "")
if ldap_filter:
    section.append(f"FILTER = {ldap_filter}")

section.append(f"FOLLOW_REFERRALS = {os.environ['SYNCWERK_LDAP_FOLLOW_REFERRALS']}")
section.append(f"CACHE_TTL = {os.environ['SYNCWERK_LDAP_CACHE_TTL']}")
section.append("")

insert_at = len(out)
in_database = False
for idx, line in enumerate(out):
    stripped = line.strip().lower()
    if stripped == "[database]":
        in_database = True
        insert_at = idx + 1
        continue
    if in_database and stripped.startswith("[") and stripped.endswith("]"):
        insert_at = idx
        break
    if in_database:
        insert_at = idx + 1

new_lines = out[:insert_at] + section + out[insert_at:]
while new_lines and new_lines[-1] == "":
    new_lines.pop()

path.write_text("\n".join(new_lines) + "\n")
PY

if id -u syncwerk > /dev/null 2>&1 ; then
  chown "syncwerk:$(id -gn syncwerk)" "${ccnet_conf}" "${backup_conf}"
  chmod 0640 "${ccnet_conf}" "${backup_conf}"
else
  chmod 0600 "${ccnet_conf}" "${backup_conf}"
fi

echo "LDAP configuration written to ${ccnet_conf}. Backup: ${backup_conf}"
echo "DN cache TTL: ${cache_ttl}s. Password validation is not cached."

if [ "${restart_service}" = "1" ] ; then
  systemctl restart syncwerk-server
fi
}

function test-ldap {
local ccnet_conf="${1:-/etc/syncwerk/ccnet.conf}"

python3 - "${ccnet_conf}" <<'PY'
import configparser
import os
import shutil
import subprocess
import sys
import tempfile

conf = sys.argv[1]
if not os.path.exists(conf):
    print(f"{conf} not found.", file=sys.stderr)
    sys.exit(1)

if not shutil.which("ldapsearch"):
    print("ldapsearch not found. Install ldap-utils.", file=sys.stderr)
    sys.exit(1)

cfg = configparser.ConfigParser(strict=False, interpolation=None)
cfg.optionxform = str
cfg.read(conf)

if not cfg.has_section("LDAP"):
    print(f"No [LDAP] section found in {conf}.", file=sys.stderr)
    sys.exit(1)

section = cfg["LDAP"]

def get(name, default=""):
    for key in (name, name.lower(), name.upper()):
        if key in section:
            return section.get(key, default).strip()
    return default

host = get("HOST")
base = get("BASE")
bind_dn = get("USER_DN")
password = get("PASSWORD")
login_attr = get("LOGIN_ATTR", "mail")
ldap_filter = get("FILTER")

if not host or not base:
    print("HOST and BASE are required in [LDAP].", file=sys.stderr)
    sys.exit(1)

if ldap_filter:
    if not ldap_filter.startswith("("):
        ldap_filter = f"({ldap_filter})"
else:
    ldap_filter = f"({login_attr}=*)"

args = ["ldapsearch", "-x", "-LLL", "-H", host, "-b", base, "-z", "1"]
passfile = None

if bind_dn:
    args.extend(["-D", bind_dn])
    if password:
        fd, passfile = tempfile.mkstemp(prefix="syncwerk-ldap-bind.")
        with os.fdopen(fd, "w") as handle:
            handle.write(password)
        os.chmod(passfile, 0o600)
        args.extend(["-y", passfile])
    else:
        print("USER_DN is set but PASSWORD is empty.", file=sys.stderr)
        sys.exit(1)

args.extend([ldap_filter, "dn", login_attr])

try:
    result = subprocess.run(args, check=False)
finally:
    if passfile:
        os.unlink(passfile)

if result.returncode != 0:
    print(f"LDAP validation failed for host={host}, base={base}, filter={ldap_filter}.", file=sys.stderr)
    sys.exit(result.returncode)

print(f"LDAP validation OK: host={host}, base={base}, filter={ldap_filter}, login_attr={login_attr}")
PY
}

function setup-samba-ad-dc {
local dns_domain
local domain
local realm
local adminpass
local base
local usergroup="Syncwerk"
local username="aduser1"
local userpass
local smb_backup

dns_domain="$(hostname -d)"
if [ -z "${dns_domain}" ] ; then
  echo "hostname -d returned an empty domain. Set a proper FQDN before provisioning Samba AD." >&2
  return 1
fi

domain="$(printf '%s' "${dns_domain}" | awk -F '.' '{ print $1 }' | tr '[:lower:]' '[:upper:]')"
realm="$(printf '%s' "${dns_domain}" | tr '[:lower:]' '[:upper:]')"
adminpass="${SYNCWERK_SAMBA_AD_ADMIN_PASSWORD:-}"
base="CN=Users,$(printf '%s' "${dns_domain}" | sed 's/^/DC=/ ; s/\./,DC=/g')"

if [ -f /etc/samba/smb.conf ] && grep -Eiq 'server role *= *active directory domain controller' /etc/samba/smb.conf ; then
  echo "Existing Samba AD DC configuration detected. Provisioning is skipped."
  if [ -z "${adminpass}" ] ; then
    echo "Set SYNCWERK_SAMBA_AD_ADMIN_PASSWORD before configuring Syncwerk against the existing local Samba AD." >&2
    return 1
  fi
else
  if [ -f /etc/samba/smb.conf ] && [ "${SYNCWERK_SAMBA_AD_FORCE:-0}" != "1" ] ; then
    echo "Existing /etc/samba/smb.conf found. Set SYNCWERK_SAMBA_AD_FORCE=1 to replace it for a fresh local AD." >&2
    return 1
  fi

  apt-get update
  apt-get install samba samba-ad-dc samba-dsdb-modules samba-vfs-modules krb5-user winbind smbclient ldap-utils pwgen -y
  systemctl stop smbd nmbd winbind samba-ad-dc || true
  systemctl disable smbd nmbd winbind || true
  systemctl unmask samba-ad-dc

  if [ -f /etc/samba/smb.conf ] ; then
    smb_backup="/etc/samba/smb.conf.${TIMESTAMP}.syncwerk-backup"
    mv /etc/samba/smb.conf "${smb_backup}"
    echo "Moved existing Samba configuration to ${smb_backup}"
  fi

  if [ -z "${adminpass}" ] ; then
    adminpass="$(pwgen -ns 20 1)"
  fi
  samba-tool domain provision --server-role=dc --use-rfc2307 --dns-backend=SAMBA_INTERNAL --realm="${realm}" --domain="${domain}" --adminpass="${adminpass}"
  grep -q "ldap server require strong auth = no" /etc/samba/smb.conf || sed -i '/.*rfc2307.*/a \        ldap server require strong auth = no' /etc/samba/smb.conf

  if [[ -f /etc/krb5.conf || -h /etc/krb5.conf ]] && [ ! -h /etc/krb5.conf ] ; then
    mv /etc/krb5.conf "/etc/krb5.conf.${TIMESTAMP}.syncwerk-backup"
  fi
  if [ ! -e /etc/krb5.conf ] ; then
    ln -s /var/lib/samba/private/krb5.conf /etc/krb5.conf
  fi

  systemctl enable samba-ad-dc
  systemctl start samba-ad-dc
  echo "Waiting 15 seconds for samba-ad-dc to start"
  sleep 15
fi

samba-tool group list | grep -q "^${usergroup}$" || samba-tool group add "${usergroup}"
if ! samba-tool user list | grep -q "^${username}$" ; then
  userpass="$(pwgen -ns 20 1)"
  samba-tool user create "${username}" "${userpass}"
  samba-tool group addmembers "${usergroup}" "${username}"
  echo "Created test AD user: ${username}"
  echo "Password: ${userpass}"
fi

configure-ldap \
  --force \
  --host ldap://127.0.0.1/ \
  --base "${base}" \
  --bind-dn "administrator@${dns_domain}" \
  --bind-password "${adminpass}" \
  --login-attr sAMAccountName \
  --filter "memberOf=CN=${usergroup},${base}" \
  --follow-referrals false \
  --cache-ttl "${SYNCWERK_LDAP_CACHE_TTL:-300}" \
  --restart

echo "Samba AD bind user: administrator@${dns_domain}"
echo "Samba AD administrator password: ${adminpass}"
echo "Syncwerk LDAP login attribute: sAMAccountName"
}


function _syncwerk_python_setting_upsert {
local settings_file="${1}"
local setting_key="${2}"
local setting_value="${3}"
if grep -qE "^${setting_key}[[:space:]]*=" "${settings_file}" ; then
  sed -i -E "s#^${setting_key}[[:space:]]*=.*#${setting_key} = ${setting_value}#" "${settings_file}"
else
  printf '%s = %s\n' "${setting_key}" "${setting_value}" >> "${settings_file}"
fi
}


function _syncwerk_python_setting_is_true {
local settings_file="${1}"
local setting_key="${2}"
grep -Eq "^[[:space:]]*${setting_key}[[:space:]]*=[[:space:]]*True([[:space:]]*(#.*)?)?$" "${settings_file}"
}


function _syncwerk_python_setting_needs_upsert {
local settings_file="${1}"
local setting_key="${2}"
local setting_value="${3}"
! grep -Fxq "${setting_key} = ${setting_value}" "${settings_file}"
}


function _syncwerk_clamav_database_ready {
local db_dir="${1}"
local main_ready=0
local daily_ready=0
local candidate
for candidate in "${db_dir}/main.cvd" "${db_dir}/main.cld" "${db_dir}/main.inc" ; do
  [ -e "${candidate}" ] && main_ready=1
done
for candidate in "${db_dir}/daily.cvd" "${db_dir}/daily.cld" "${db_dir}/daily.inc" ; do
  [ -e "${candidate}" ] && daily_ready=1
done
[ "${main_ready}" = "1" ] && [ "${daily_ready}" = "1" ]
}


function _syncwerk_wait_for_clamav_database {
local db_dir="${1}"
local attempts="${2:-36}"
local delay="${3:-5}"
local i=1
while [ "${i}" -le "${attempts}" ] ; do
  if _syncwerk_clamav_database_ready "${db_dir}" ; then
    return 0
  fi
  sleep "${delay}"
  i=$((i + 1))
done
echo "ClamAV database is not ready in ${db_dir}" >&2
return 1
}


function _syncwerk_wait_for_clamd {
local attempts="${1:-24}"
local delay="${2:-5}"
local i=1
while [ "${i}" -le "${attempts}" ] ; do
  if clamdscan --ping 3:1 > /dev/null 2>&1 ; then
    return 0
  fi
  sleep "${delay}"
  i=$((i + 1))
done
echo "ClamAV daemon is not ready; clamdscan --ping failed" >&2
return 1
}


function _syncwerk_ensure_clamav_runtime_ready {
local db_dir="${1}"
if ! command -v clamdscan > /dev/null 2>&1 ; then
  apt-get update
  apt-get install clamav clamav-daemon -y
fi

systemctl enable --now clamav-freshclam.service
_syncwerk_wait_for_clamav_database "${db_dir}"
systemctl enable --now clamav-daemon.socket
systemctl restart clamav-daemon.service
_syncwerk_wait_for_clamd
}


function _syncwerk_clamav_scan_command_settings_need_update {
local settings_file="${1}"
_syncwerk_python_setting_needs_upsert "${settings_file}" "VIRUS_SCAN_COMMAND" "'/usr/bin/clamdscan --fdpass'" && return 0
_syncwerk_python_setting_needs_upsert "${settings_file}" "VIRUS_SCAN_CHECK_SCAN_COMMAND_READY" "['/usr/bin/clamdscan --ping 3:1']" && return 0
return 1
}


function _syncwerk_clamav_scan_limit_value {
printf '%s\n' "25*1024*1024"
}


function _syncwerk_clamav_scan_limit_settings_need_update {
local settings_file="${1}"
local limit_value
limit_value="$(_syncwerk_clamav_scan_limit_value)"
_syncwerk_python_setting_needs_upsert "${settings_file}" "VIRUS_SCAN_FILE_SIZE_LIMIT" "${limit_value}" && return 0
_syncwerk_python_setting_needs_upsert "${settings_file}" "VIRUS_SCAN_CLAMD_CONFIG_FILE" "'/etc/clamav/clamd.conf'" && return 0
_syncwerk_python_setting_needs_upsert "${settings_file}" "VIRUS_SCAN_REQUIRE_CLAMAV_LIMIT_READBACK" "True" && return 0
return 1
}


function _syncwerk_clamav_scan_settings_need_update {
local settings_file="${1}"
_syncwerk_clamav_scan_command_settings_need_update "${settings_file}" && return 0
_syncwerk_clamav_scan_limit_settings_need_update "${settings_file}" && return 0
return 1
}


function _syncwerk_configure_clamav_scan_command {
local settings_file="${1}"
_syncwerk_python_setting_upsert "${settings_file}" "VIRUS_SCAN_COMMAND" "'/usr/bin/clamdscan --fdpass'"
_syncwerk_python_setting_upsert "${settings_file}" "VIRUS_SCAN_CHECK_SCAN_COMMAND_READY" "['/usr/bin/clamdscan --ping 3:1']"
}


function _syncwerk_configure_clamav_scan_limits {
local settings_file="${1}"
local limit_value
limit_value="$(_syncwerk_clamav_scan_limit_value)"
_syncwerk_python_setting_upsert "${settings_file}" "VIRUS_SCAN_FILE_SIZE_LIMIT" "${limit_value}"
_syncwerk_python_setting_upsert "${settings_file}" "VIRUS_SCAN_CLAMD_CONFIG_FILE" "'/etc/clamav/clamd.conf'"
_syncwerk_python_setting_upsert "${settings_file}" "VIRUS_SCAN_REQUIRE_CLAMAV_LIMIT_READBACK" "True"
}


function _syncwerk_configure_clamav_scan_settings {
local settings_file="${1}"
_syncwerk_configure_clamav_scan_command "${settings_file}"
_syncwerk_configure_clamav_scan_limits "${settings_file}"
}


function _syncwerk_clamav_scanning_requested {
local settings_file="${1}"
local setting_key
for setting_key in \
  ENABLE_VIRUS_SCANNING \
  ENABLE_UPLOAD_LINK_VIRUS_CHECK \
  ENABLE_FILESERVER_UPLOAD_VIRUS_CHECK \
  ENABLE_WEBDAV_UPLOAD_VIRUS_CHECK
do
  if _syncwerk_python_setting_is_true "${settings_file}" "${setting_key}" ; then
    return 0
  fi
done
return 1
}


function setup-clamav-virus-scanner {
local settings_file="${SYNCWERK_CLAMAV_RESTAPI_SETTINGS:-/etc/syncwerk/restapi_settings.py}"
local db_dir="${SYNCWERK_CLAMAV_DB_DIR:-/var/lib/clamav}"

if [ ! -f "${settings_file}" ] ; then
  echo "Syncwerk RESTAPI settings file not found: ${settings_file}" >&2
  return 1
fi

_syncwerk_ensure_clamav_runtime_ready "${db_dir}"

cp -a "${settings_file}" "${settings_file}.bak.${TIMESTAMP}"
_syncwerk_python_setting_upsert "${settings_file}" "ENABLE_VIRUS_SCANNING" "True"
_syncwerk_configure_clamav_scan_settings "${settings_file}"
_syncwerk_python_setting_upsert "${settings_file}" "VIRUS_SCAN_RESULT_INFECTED_CODE" "[1]"
_syncwerk_python_setting_upsert "${settings_file}" "VIRUS_SCAN_RESULT_SAFE_CODE" "[0]"
_syncwerk_python_setting_upsert "${settings_file}" "VIRUS_SCAN_INTERVAL" "60"
_syncwerk_python_setting_upsert "${settings_file}" "VIRUS_SCAN_SKIP_EXT" "['bmp', 'gif', 'ico', 'png', 'jpg', 'mp3', 'mp4', 'wav', 'avi', 'rmvb', 'mkv']"

restart
}


function reconcile-clamav-virus-scanner {
local settings_file="${SYNCWERK_CLAMAV_RESTAPI_SETTINGS:-/etc/syncwerk/restapi_settings.py}"
local db_dir="${SYNCWERK_CLAMAV_DB_DIR:-/var/lib/clamav}"

if [ ! -f "${settings_file}" ] ; then
  echo "Syncwerk RESTAPI settings file not found: ${settings_file}" >&2
  return 1
fi

if ! _syncwerk_clamav_scanning_requested "${settings_file}" ; then
  echo "ClamAV scanner is not enabled; no readiness action required"
  return 0
fi

_syncwerk_ensure_clamav_runtime_ready "${db_dir}"

if _syncwerk_clamav_scan_settings_need_update "${settings_file}" ; then
  cp -a "${settings_file}" "${settings_file}.bak.${TIMESTAMP}.clamav-reconcile"
  _syncwerk_configure_clamav_scan_settings "${settings_file}"
fi

echo "ClamAV scanner readiness reconciled"
}



function run-virus-scanner {
local settings_file="${SYNCWERK_CLAMAV_RESTAPI_SETTINGS:-/etc/syncwerk/restapi_settings.py}"

if [ ! -f "${settings_file}" ] ; then
  echo "Syncwerk RESTAPI settings file not found: ${settings_file}" >&2
  return 1
fi

if ! _syncwerk_python_setting_is_true "${settings_file}" "ENABLE_VIRUS_SCANNING" ; then
  echo "Virus scanner is not enabled. Aborting.." >&2
  return 1
fi

sudo -E -u syncwerk /usr/share/python/syncwerk/restapi/manage.py trigger_virus_scanning
echo "Finished virus scanning"
}


function support {
if  [[ ${LANG} = *"de"* ]] ; then
cat <<EOF

  Anwendung  Syncwerk Server

  Anbieter   Syncwerk GmbH, Wiesentheid (Deutschland)
             https://www.syncwerk.com

  Telefon    +49 (0) 9383 33337-0


  Hilfe      support@syncwerk.com

  Handbuch   https://support.syncwerk.de

EOF
else
cat <<EOF

  Program    Syncwerk Server

  Vendor     Syncwerk GmbH, Wiesentheid (Germany)
             https://www.syncwerk.com

  Phone      +49 (0) 9383 33337-0


  Support    support@syncwerk.com

  Manual     https://support.syncwerk.de

EOF
fi
}


function setup {
stop
backup-databases
install-python-tools
setup-nginx
setup-virtualenv
setup-user
start-database
create-database
setup-ccnet-conf
setup-my-key-peer
setup-ccnet-database
normalize-ccnet-auth-collations
setup-file-server-conf
setup-gunicorn-conf
setup-restapi-settings-py
update-database
normalize-ccnet-auth-collations
setup-services
migrate-avatars
fix-permissions
update-static-files
start
stop
start
support
syncwerk-server start
if [ ! -f "/etc/syncwerk/admin.txt" ] ; then
  sleep 12
  create-admin
fi
}


function manage.py {
sudo -E -u syncwerk /usr/share/python/syncwerk/restapi/manage.py ${1} ${2}
}


function change-email-address {
local CURRENTEMAIL=""
local NEWEMAIL=""
local MODE="--apply"
local ALLOW_UNMAPPED=()
local ALLOW_MISSING_CURRENT_IDENTITY=()

while [[ $# -gt 0 ]] ; do
  case "$1" in
    -c|--current)
      CURRENTEMAIL="${2:-}"
      shift 2
      ;;
    -n|--new)
      NEWEMAIL="${2:-}"
      shift 2
      ;;
    --dry-run)
      MODE="--dry-run"
      shift
      ;;
    --apply)
      MODE="--apply"
      shift
      ;;
    --allow-unmapped)
      ALLOW_UNMAPPED=("--allow-unmapped")
      shift
      ;;
    --allow-missing-current-identity)
      ALLOW_MISSING_CURRENT_IDENTITY=("--allow-missing-current-identity")
      shift
      ;;
    *)
      echo "Unknown option: $1"
      echo "Usage: ${0} change-email-address --current old@email.tld --new new@email.tld [--dry-run|--apply] [--allow-unmapped] [--allow-missing-current-identity]"
      exit 2
      ;;
  esac
done

if [ -z "${CURRENTEMAIL}" ] || [ -z "${NEWEMAIL}" ] ; then
  echo "Usage: ${0} change-email-address --current old@email.tld --new new@email.tld [--dry-run|--apply] [--allow-unmapped] [--allow-missing-current-identity]"
  exit 2
fi

if [ "${MODE}" = "--apply" ] ; then
  backup-databases
fi

export PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PYTHONPATH=/usr/share/python/syncwerk/restapi:/usr/share/python/syncwerk
export CONFIG_DIR=/etc/syncwerk
export CCNET_CONF_DIR=/etc/syncwerk
export SYNCWERK_CONF_DIR=/etc/syncwerk
export SYNCWERK_CENTRAL_CONF_DIR=/etc/syncwerk
export RESTAPI_DIR=/usr/share/python/syncwerk/restapi
export DJANGO_SETTINGS_MODULE=restapi.settings
export RESTAPI_LOG_DIR=/var/log/syncwerk
export OBJECT_STORAGE_PATH=/var/lib/syncwerk

cd /usr/share/python/syncwerk/restapi
sudo -E -u syncwerk /usr/bin/python3 manage.py change_user_email \
  "${MODE}" \
  --current "${CURRENTEMAIL}" \
  --new "${NEWEMAIL}" \
  "${ALLOW_UNMAPPED[@]}" \
  "${ALLOW_MISSING_CURRENT_IDENTITY[@]}"
}


function create-file-list {
epoch=$(date +%s)
mkdir -p /mnt/syncwerk-server-mount /var/log/syncwerk/filetree
syncwerk-server-admin umount /mnt/syncwerk-server-mount
syncwerk-server-admin mount /mnt/syncwerk-server-mount
cd /mnt/syncwerk-server-mount
for user in $(ls | xargs) ; do
    printf "Char\tSize\tFile\n" | awk -F'\t' '{printf("%20s %30s   %s \n", $1, $2, $3)}' | tee /var/log/syncwerk/filetree/${epoch}_${user}.log
    printf '%s\n' ------------------------------------------------------------------------------------------------------------------------------------------------------- | tee -a /var/log/syncwerk/filetree/${epoch}_${user}.log
    find ${user}/ -type f | while read file ; do
        filelength=$(echo -n "${file}" | wc -m)
        userlength=$(echo -n "${user}" | wc -m)
        prefixlength=37
        pathlength=$((filelength-userlength-prefixlength))
        filesize=$(du -sb "${file}" | cut -f -1)
        filesizeinmb=$(echo ${filesize} | awk '{ byte =$1 /1024/1024 ; print byte " MB" }')
        printf "${pathlength}\t${filesizeinmb}\t\"${file}\"\n" | awk -F'\t' '{printf("%20s %30s   %s \n", $1, $2, $3)}'
        files=$((files+1))
        echo -n ${files} > /var/log/syncwerk/filetree/${epoch}_${user}_files.txt
    done | sort --numeric-sort | tee -a /var/log/syncwerk/filetree/${epoch}_${user}.log
    totalusage=$(du -sb "${user}" | cut -f -1 | awk '{ byte =$1 /1024/1024 ; print byte " MB" }')
    printf '%s\n' ------------------------------------------------------------------------------------------------------------------------------------------------------- | tee -a /var/log/syncwerk/filetree/${epoch}_${user}.log
    printf "$(if [ -f "/var/log/syncwerk/filetree/${epoch}_${user}_files.txt" ]; then cat /var/log/syncwerk/filetree/${epoch}_${user}_files.txt; fi)\t${totalusage}\t\n" | awk -F'\t' '{printf("%20s %30s   %s \n", $1, $2, $3)}' | tee -a /var/log/syncwerk/filetree/${epoch}_${user}.log
    rm -f /var/log/syncwerk/filetree/${epoch}_${user}_files.txt
    printf '\n\n'
done
echo "Your file list was saved to /var/log/syncwerk/filetree/${epoch}_${user}.log"
}


if [ "${SYNCWERK_ADMIN_NO_MAIN:-0}" != "1" ] ; then
# Get options and execute task
while true ; do
    case "$1" in
        start) start ; break ;;
        stop) stop ; break ;;
        restart) restart ; break ;;
        setup) setup ; break ;;
        update-translations) update-translations ; break ;;
        update-static-files) update-static-files ; break ;;
        purge) purge ; break ;;
        fsck) fsck ${2} ${3} ${4} ${5} ${6} ${7} ${8} ; break ;;
        mount) mount ${2} ; break ;;
        umount) umount ; break ;;
        gc) gc ${2} ${3} ${4} ${5} ${6} ${7} ${8} ; break ;;
        backup-databases) backup-databases ; fix-permissions ; break ;;
        backup-object-storage) backup-object-storage ; fix-permissions ; break ;;
        backup-configurations) backup-configurations ; fix-permissions ; break ;;
        backup-server-applications) backup-server-applications ; fix-permissions ; break ;;
        backup-all) backup-all ; fix-permissions ; break ;;
        fix-permissions) fix-permissions ; break ;;
        setup-onlyoffice) setup-onlyoffice || support ; break ;;
        reconcile-onlyoffice) shift ; reconcile-onlyoffice "$@" ; break ;;
        reconcile-legacy-upgrade) shift ; reconcile-legacy-upgrade "$@" ; break ;;
        harden-nginx-dotfiles) harden-nginx-dotfiles || support ; break ;;
        harden-nginx-webapp-csp) harden-nginx-webapp-csp || support ; break ;;
        setup-letsencrypt-certificate) shift ; setup-letsencrypt-certificate "$@" || support; break ;;
        configure-ldap) shift ; configure-ldap "$@" || support ; break ;;
        test-ldap) test-ldap "${2:-}" || support ; break ;;
        setup-samba-ad-dc) setup-samba-ad-dc || support ; break ;;
        setup-clamav-virus-scanner) setup-clamav-virus-scanner || support ; break ;;
        reconcile-clamav-virus-scanner) reconcile-clamav-virus-scanner || support ; break ;;
        run-virus-scanner) run-virus-scanner || support ; break ;;
        report) report ; break ;;
        support) support ; break ;;
        manage.py) manage.py ${2} ${3} ; break ;;
        change-email-address) shift ; change-email-address "$@" ; break ;;
        create-file-list) create-file-list ; break ;;
        *) help ; break ;;
    esac
done
fi
