#!/bin/bash
# Cleitext Toolkit: the steps that need root.
#
# Plesk runs this file as root through pm_ApiCli::callSbin(). It does only the
# few things the domain's user cannot do (PostgreSQL roles and databases as
# the postgres account, a read-only scan of the webspaces, whether Passenger
# is installed, and the upgrade of the extension itself with a package it
# downloaded and checked), and it trusts nothing it is given:
# - every argument is checked against a strict pattern before use;
# - folders must be inside a webspace under /var/www/vhosts;
# - a database is only prepared or dropped when the extension found it in
#   Plesk under the domain's subscription (XML API), or when the domain's own
#   Cleitext configuration names it; superusers' databases are never touched.
# Plesk itself is only reached through its documented command line
# (`plesk bin database`, and `plesk bin database-server` when the
# administrator asks the Toolkit to set up a PostgreSQL administrator for
# Plesk); its database is never read.
# Passwords come in environment variables, never on the command line.
#
# Each command prints one JSON object; on failure {"error":{"code","message"}}
# and the exit code 1.
#
# Copyright (C) 2026 Cleitext contributors
# SPDX-License-Identifier: GPL-3.0-or-later

set -euo pipefail
export LC_ALL=C
umask 077

PLESK=/usr/sbin/plesk
[ -x "$PLESK" ] || PLESK=/usr/local/psa/bin/plesk
VHOSTS=/var/www/vhosts
PG_SERVER=localhost:5432

# --- output -----------------------------------------------------------------------

json_str() {
  local s=$1
  s=${s//\\/\\\\}
  s=${s//\"/\\\"}
  s=${s//$'\n'/\\n}
  s=${s//$'\r'/}
  s=${s//$'\t'/ }
  # Other control characters are dropped.
  s=$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037')
  printf '"%s"' "$s"
}

# Errors go to fd 3, the real stdout, also from inside $(…).
exec 3>&1
fail() {
  printf '{"error":{"code":%s,"message":%s}}\n' "$(json_str "$1")" "$(json_str "$2")" >&3
  exit 1
}

# --- validation -------------------------------------------------------------------

DOMAIN_RE='^[a-z0-9]([a-z0-9-]{0,62})(\.[a-z0-9]([a-z0-9-]{0,62}))+$'
IDENT_RE='^[a-z][a-z0-9_]{2,62}$'

need_domain() {
  [[ ${#1} -le 253 && $1 =~ $DOMAIN_RE ]] || fail invalid_argument "Not a domain name."
}

# Names of databases and users made elsewhere (by hand, other tools) may have
# capitals, e.g. hotelauroraPSGdb: accepted only where they reach Plesk, never SQL.
IDENT_ANY_RE='^[A-Za-z_][A-Za-z0-9_]{0,62}$'
need_ident_any() {
  [[ $1 =~ $IDENT_ANY_RE ]] || fail invalid_argument "Not a valid database or user name: use letters, digits and _."
  case "${1,,}" in
    postgres | pg_* | template0 | template1 | admin | root | psa | apsc | horde | roundcube*)
      fail invalid_argument "Reserved name." ;;
  esac
}

need_ident() {
  [[ $1 =~ $IDENT_RE ]] || fail invalid_argument "Not a valid database or user name: use a-z, 0-9 and _."
  case "$1" in
    postgres | pg_* | template0 | template1 | admin | root | psa | apsc | horde | roundcube*)
      fail invalid_argument "Reserved name." ;;
  esac
}

# need_home <path>: a webspace's home folder (/var/www/vhosts/<name>), printed resolved.
need_home() {
  local real
  real=$(realpath -e -- "$1" 2>/dev/null) || fail not_found "The webspace folder does not exist."
  [[ $real =~ ^$VHOSTS/[a-z0-9][a-z0-9.-]*$ && $real != "$VHOSTS/system" ]] || fail forbidden "Not a webspace folder."
  printf '%s' "$real"
}

# need_base <home> <base>: the installation's folder, inside the webspace, printed resolved.
need_base() {
  local real
  real=$(realpath -e -- "$2" 2>/dev/null) || fail not_found "The folder does not exist."
  case "$real/" in "$1"/*) ;; *) fail forbidden "The folder is outside the domain's webspace." ;; esac
  printf '%s' "$real"
}

# need_not_super <role>: refuses PostgreSQL superusers.
need_not_super() {
  local super
  super=$(cd / && printf '%s\n' "select rolsuper from pg_roles where rolname = :'r';" | pg -v r="$1")
  [ "$super" != t ] || fail forbidden "The role is a superuser: it is not changed."
}

# --- PostgreSQL -------------------------------------------------------------------

# psql as the postgres superuser over the local socket (peer authentication).
# The SQL comes on stdin, so variables (:'x', :"x") are quoted by psql itself.
# It connects to template1: when the PostgreSQL administrator is set in
# Plesk, Plesk puts "local samegroup all password" first in pg_hba.conf,
# which asks postgres for a password on the "postgres" database (a role of
# the same name) but not on template1.
pg() {
  runuser -u postgres -- psql -X -q -At -v ON_ERROR_STOP=1 -d "${PGDB:-template1}" "$@"
}

pg_available() {
  command -v psql >/dev/null 2>&1 && id postgres >/dev/null 2>&1 &&
    (cd / && pg -c 'select 1' >/dev/null 2>&1)
}

need_pg() {
  pg_available || fail pg_unavailable "PostgreSQL is not installed on this server, or its postgres account cannot be used."
}

role_exists() {
  [ "$(cd / && printf '%s\n' "select count(*) from pg_roles where rolname = :'r';" | pg -v r="$1")" != 0 ]
}

db_exists() {
  [ "$(cd / && printf '%s\n' "select count(*) from pg_database where datname = :'d';" | pg -v d="$1")" != 0 ]
}

# --- commands ---------------------------------------------------------------------

# check: what the installation needs on this server that only root can see.
cmd_check() {
  local installed=false pg_local=false pg_version=""
  command -v passenger-config >/dev/null 2>&1 && installed=true
  if pg_available; then
    pg_local=true
    pg_version=$(cd / && pg -c 'show server_version' 2>/dev/null | head -n 1 | cut -d' ' -f1)
  fi
  printf '{"passenger":{"installed":%s},"pg":{"local":%s,"version":%s}}\n' \
    "$installed" "$pg_local" "$(json_str "$pg_version")"
}

# pg-create <subscription> <db> <owner> [<app role>]
# Creates the owner (LOGIN CREATEROLE) and its database as postgres, then
# registers both in Plesk when it can. CT_OWNER_PASSWORD is required;
# CT_APP_PASSWORD also creates the application role (for Cleitext versions
# without `cleitext configure`).
cmd_pg_create() {
  [ $# -ge 3 ] || fail usage "pg-create <subscription> <db> <owner> [<app role>]"
  local subscription=$1 db=$2 owner=$3 app=${4:-}
  need_domain "$subscription"; need_ident "$db"; need_ident "$owner"
  [ -z "$app" ] || need_ident "$app"
  [ -n "${CT_OWNER_PASSWORD:-}" ] || fail usage "CT_OWNER_PASSWORD is not set."
  [[ $CT_OWNER_PASSWORD =~ ^[A-Za-z0-9]{16,128}$ ]] || fail invalid_argument "The password must be 16-128 letters and digits."
  if [ -n "$app" ]; then
    [[ ${CT_APP_PASSWORD:-} =~ ^[A-Za-z0-9]{16,128}$ ]] || fail invalid_argument "CT_APP_PASSWORD must be 16-128 letters and digits."
  fi
  need_pg
  db_exists "$db" && fail exists "A database with this name already exists."
  role_exists "$owner" && fail exists "A database user with this name already exists."
  if [ -n "$app" ] && role_exists "$app"; then fail exists "The application user already exists."; fi

  export CT_OWNER_PASSWORD CT_APP_PASSWORD
  local out
  if ! out=$(cd / && {
    printf '%s\n' '\getenv pw CT_OWNER_PASSWORD' \
      "create role :\"owner\" with login createrole nosuperuser nocreatedb password :'pw';"
    if [ -n "$app" ]; then
      # Created as the owner, so the owner keeps admin rights on it (as `cleitext configure` would).
      printf '%s\n' '\getenv apw CT_APP_PASSWORD' 'set role :"owner";' \
        "create role :\"app\" with login nosuperuser nocreatedb nocreaterole password :'apw';" 'reset role;'
    fi
  } | pg -v owner="$owner" -v app="$app" 2>&1); then
    fail pg "$out"
  fi
  if ! out=$(cd / && printf '%s\n' "create database :\"db\" owner :\"owner\" encoding 'UTF8' template template0;" |
    pg -v db="$db" -v owner="$owner" 2>&1); then
    (cd / && printf '%s\n' 'drop role if exists :"app";' 'drop role if exists :"owner";' |
      pg -v owner="$owner" -v app="${app:-$owner}" >/dev/null 2>&1) || true
    fail pg "$out"
  fi

  # Plesk only lists what it knows: register them, weakly (removing them in
  # Plesk does not drop them; the Toolkit drops them itself).
  local registered=true reg_error=""
  if ! out=$("$PLESK" bin database --register "$db" -domain "$subscription" -type postgresql \
    -server "$PG_SERVER" -weak-relationship true 2>&1); then
    registered=false; reg_error=$out
  elif ! out=$(PSA_PASSWORD="$CT_OWNER_PASSWORD" "$PLESK" bin database --register-dbuser "$owner" \
    -domain "$subscription" -type postgresql -server "$PG_SERVER" -database "$db" -passwd '' \
    -weak-relationship true 2>&1); then
    registered=false; reg_error=$out
  fi
  printf '{"ok":true,"database":%s,"owner":%s,"app":%s,"registered":%s,"registerError":%s}\n' \
    "$(json_str "$db")" "$(json_str "$owner")" "$(json_str "$app")" "$registered" "$(json_str "$reg_error")"
}

# pg-prepare <db> <owner>
# For a database Plesk has just created under the domain's subscription (the
# extension checks that with the XML API): gives its user CREATEROLE and the
# ownership of the database, which Cleitext needs (row level security, one
# role per plugin).
cmd_pg_prepare() {
  [ $# -eq 2 ] || fail usage "pg-prepare <db> <owner>"
  local db=$1 owner=$2
  need_ident "$db"; need_ident "$owner"
  need_pg
  db_exists "$db" || fail not_found "The database does not exist."
  role_exists "$owner" || fail not_found "The database user does not exist."
  need_not_super "$owner"
  local out
  if ! out=$(cd / && printf '%s\n' 'alter role :"owner" createrole;' 'alter database :"db" owner to :"owner";' |
    pg -v owner="$owner" -v db="$db" 2>&1); then
    fail pg "$out"
  fi
  printf '{"ok":true}\n'
}

# pg-drop <home> <db> <registered: 0|1> [<base folder>]
# Drops a Cleitext database with its owner, the application role and the
# plugin roles (the roles the owner administers). Allowed when the extension
# found the database in Plesk under the domain's subscription (registered
# 1; Plesk's record is removed too), or when the domain's Cleitext
# configuration (<base>/cleitext-shared/.env) names it.
cmd_pg_drop() {
  [ $# -ge 3 ] || fail usage "pg-drop <home> <db> <registered: 0|1> [<base folder>]"
  local home db=$2 registered=$3 base=${4:-} allowed=false
  home=$(need_home "$1"); need_ident "$db"
  [[ $registered =~ ^[01]$ ]] || fail invalid_argument "registered must be 0 or 1."
  if [ "$registered" = 1 ]; then
    allowed=true
  elif [ -n "$base" ]; then
    local real env url name
    real=$(need_base "$home" "$base")
    env="$real/cleitext-shared/.env"
    [ -f "$env" ] && [ ! -L "$env" ] || fail not_found "No Cleitext configuration in that folder."
    url=$(grep -m1 '^DATABASE_ADMIN_URL=' "$env" | cut -d= -f2-)
    name=${url##*/}; name=${name%%\?*}
    [ "$name" = "$db" ] && allowed=true
  fi
  $allowed || fail forbidden "This database does not belong to the domain."
  need_pg

  local owner
  owner=$(cd / && printf '%s\n' "select pg_get_userbyid(datdba) from pg_database where datname = :'d';" | pg -v d="$db")
  local roles=""
  if [ -n "$owner" ]; then
    need_ident "$owner"
    local super
    super=$(cd / && printf '%s\n' "select rolsuper from pg_roles where rolname = :'o';" | pg -v o="$owner")
    [ "$super" = f ] || fail forbidden "The database is owned by a superuser: it is not dropped."
    # Roles created by the owner (application role, one per plugin).
    roles=$(cd / && printf '%s\n' "select r.rolname from pg_auth_members m
        join pg_roles r on r.oid = m.roleid
        join pg_roles o on o.oid = m.member
        where o.rolname = :'o' and m.admin_option and not r.rolsuper;" | pg -v o="$owner")
  fi

  local plesk_removed=false out
  # The application may still hold connections (Passenger keeps it a while):
  # no new ones, and the open ones are closed, or Plesk cannot drop it.
  (cd / && printf '%s\n' 'revoke connect on database :"db" from public;' \
    "select pg_terminate_backend(pid) from pg_stat_activity where datname = :'db' and pid <> pg_backend_pid();" |
    pg -v db="$db" >/dev/null 2>&1) || true
  if [ "$registered" = 1 ]; then
    if out=$("$PLESK" bin database --remove "$db" 2>&1); then plesk_removed=true; fi
  fi
  if ! out=$(cd / && printf '%s\n' 'drop database if exists :"db" with (force);' | pg -v db="$db" 2>&1); then
    fail pg "$out"
  fi
  # Still listed in Plesk: now that the database is gone, its record goes.
  if [ "$registered" = 1 ] && ! $plesk_removed; then
    if out=$("$PLESK" bin database --remove "$db" 2>&1); then plesk_removed=true; fi
  fi
  # Plesk gives each database it creates a group role of the same name (for
  # "samegroup" in pg_hba.conf); it goes too, when it is only that.
  local group
  group=$(cd / && printf '%s\n' "select rolname from pg_roles where rolname = :'d' and not rolcanlogin and not rolsuper;" | pg -v d="$db")
  local dropped=() role
  for role in $roles $owner $group; do
    [[ $role =~ $IDENT_RE ]] || continue
    if (cd / && printf '%s\n' 'drop role if exists :"r";' | pg -v r="$role" >/dev/null 2>&1); then
      dropped+=("$(json_str "$role")")
    fi
  done
  local list
  list=$(IFS=,; printf '%s' "${dropped[*]:-}")
  printf '{"ok":true,"pleskRecordRemoved":%s,"droppedRoles":[%s]}\n' "$plesk_removed" "$list"
}

# pg-register <subscription> <home> <db> <owner> <base folder> [user-only]
# Lists an existing Cleitext database and its owner in Plesk (weakly), for a
# database created when Plesk could not register it. Allowed only when the
# domain's Cleitext configuration (<base>/cleitext-shared/.env) names both.
# CT_OWNER_PASSWORD is the owner's password, which Plesk keeps for the user.
cmd_pg_register() {
  [ $# -eq 5 ] || { [ $# -eq 6 ] && [ "$6" = user-only ]; } ||
    fail usage "pg-register <subscription> <home> <db> <owner> <base folder> [user-only]"
  local subscription=$1 home db=$3 owner=$4 base=$5 only=${6:-} real env url
  need_ident_any "$db"; need_ident_any "$owner"; need_domain "$subscription"; home=$(need_home "$2")
  # The owner's password, as set by whoever created the role: printable, no spaces.
  [[ ${CT_OWNER_PASSWORD:-} =~ ^[!-~]{8,256}$ ]] || fail invalid_argument "CT_OWNER_PASSWORD must be 8-256 printable characters without spaces."
  real=$(need_base "$home" "$base")
  env="$real/cleitext-shared/.env"
  [ -f "$env" ] && [ ! -L "$env" ] || fail not_found "No Cleitext configuration in that folder."
  url=$(grep -m1 '^DATABASE_ADMIN_URL=' "$env" | cut -d= -f2-)
  [[ $url == postgres*://"$owner":*@*/"$db" || $url == postgres*://"$owner":*@*/"$db"\?* ]] ||
    fail forbidden "The domain's configuration does not use this database and owner."
  local out
  # user-only: the database is already listed (e.g. by hand), only its owner is missing.
  if [ "$only" != user-only ] && ! out=$("$PLESK" bin database --register "$db" -domain "$subscription" -type postgresql \
    -server "$PG_SERVER" -weak-relationship true 2>&1); then
    fail register "$out"
  fi
  if ! out=$(PSA_PASSWORD="$CT_OWNER_PASSWORD" "$PLESK" bin database --register-dbuser "$owner" \
    -domain "$subscription" -type postgresql -server "$PG_SERVER" -database "$db" -passwd '' \
    -weak-relationship true 2>&1); then
    fail register "$out"
  fi
  printf '{"ok":true,"registered":true}\n'
}

# pg-admin-setup <role> <superuser: 0|1>
# Gives Plesk an administrator for the local PostgreSQL server, when the
# Plesk administrator asks for it (the Toolkit's page or
# `plesk ext cleitext-toolkit --setup-pg-admin`), so that Plesk can create
# and list databases. A server-wide change, never made on its own:
# 1. creates (or, if this helper made it before, resets) the role <role>
#    (plesk_*), LOGIN CREATEDB CREATEROLE, member of pg_read_all_data (for
#    Plesk's backups), SUPERUSER only with superuser=1, and a generated
#    password that never leaves this script: it is not printed, not logged,
#    not on any command line (psql reads it with \getenv, Plesk with
#    PSA_PASSWORD, the login test with PGPASSWORD);
# 2. logs in with it over TCP at localhost:5432, as Plesk does. If
#    pg_hba.conf refuses that, nothing is changed in Plesk, the role it just
#    created is dropped, and the error shows the host rules: pg_hba.conf is
#    not edited;
# 3. plesk bin database-server --update-server localhost:5432 -type
#    postgresql -admin <role> -passwd '' with PSA_PASSWORD. If Plesk refuses,
#    the role it just created is dropped.
cmd_pg_admin_setup() {
  [ $# -eq 2 ] || fail usage "pg-admin-setup <role> <superuser: 0|1>"
  local role=$1 superuser=$2
  [[ $role =~ ^plesk_[a-z0-9_]{2,40}$ ]] || fail invalid_argument "The role must be named plesk_<name> (a-z, 0-9 and _)."
  need_ident "$role"
  [[ $superuser =~ ^[01]$ ]] || fail invalid_argument "superuser must be 0 or 1."
  need_pg

  local marker='Plesk PostgreSQL administrator, made by Cleitext Toolkit'
  local created=true
  if role_exists "$role"; then
    # Only a role this helper made is changed.
    [ "$(cd / && printf '%s\n' "select coalesce(shobj_description(oid, 'pg_authid'), '') from pg_roles where rolname = :'r';" |
      pg -v r="$role")" = "$marker" ] || fail exists "A PostgreSQL role named $role already exists and was not made by Cleitext Toolkit: it is not changed."
    created=false
  fi

  local pw
  pw=$(head -c 96 /dev/urandom | base64 -w0 | tr -dc 'A-Za-z0-9')
  pw=${pw:0:40}
  [ ${#pw} -eq 40 ] || fail internal "Could not generate a password."
  local super_sql=nosuperuser
  [ "$superuser" = 1 ] && super_sql=superuser
  local out verb=alter
  $created && verb=create
  if ! out=$(cd / && export CT_PGADMIN_PASSWORD="$pw" && printf '%s\n' '\getenv pw CT_PGADMIN_PASSWORD' \
    "$verb role :\"r\" with login createdb createrole $super_sql noreplication nobypassrls password :'pw';" \
    "comment on role :\"r\" is :'m';" \
    'grant pg_read_all_data to :"r";' | pg -v r="$role" -v m="$marker" 2>&1); then
    fail pg "${out//$pw/***}"
  fi

  undo() {
    if $created; then
      (cd / && printf '%s\n' 'drop role if exists :"r";' | pg -v r="$role" >/dev/null 2>&1) || true
    fi
  }

  # Plesk connects over TCP with the password: does pg_hba.conf let it?
  if ! out=$(cd / && PGPASSWORD="$pw" PGCONNECT_TIMEOUT=5 runuser -u postgres -- \
    psql -X -q -At -w -h localhost -p "${PG_SERVER##*:}" -U "$role" -d template1 -c 'select 1' 2>&1); then
    local hba rules
    hba=$(cd / && pg -c 'show hba_file' 2>/dev/null || true)
    rules=$(cd / && pg -F ' ' -c "select line_number, type, array_to_string(database, ','), array_to_string(user_name, ','),
        coalesce(address, ''), coalesce(netmask, ''), auth_method from pg_hba_file_rules
        where type like 'host%' order by line_number" 2>/dev/null || true)
    undo
    fail pg_hba "PostgreSQL refuses a password login over TCP for $role at $PG_SERVER, which is how Plesk connects: ${out//$pw/***}
pg_hba.conf ($hba) needs, before any rule that rejects it, lines such as
host all $role 127.0.0.1/32 scram-sha-256
host all $role ::1/128 scram-sha-256
then a reload (systemctl reload postgresql). Cleitext Toolkit does not edit pg_hba.conf. Host rules now (line, type, databases, users, address, mask, method):
$rules"
  fi

  if ! out=$(PSA_PASSWORD="$pw" "$PLESK" bin database-server --update-server "$PG_SERVER" -type postgresql \
    -admin "$role" -passwd '' 2>&1); then
    undo
    fail plesk "Plesk did not accept the administrator: ${out//$pw/***}"
  fi
  pw=""
  printf '{"ok":true,"role":%s,"superuser":%s,"created":%s}\n' "$(json_str "$role")" \
    "$([ "$superuser" = 1 ] && echo true || echo false)" "$created"
}

# scan: the Cleitext installations in the webspaces (read only).
cmd_scan() {
  local first=1 path
  printf '{"paths":['
  # "cleitext" folders and links (the releases layout), with bin/cleitext inside.
  while IFS= read -r -d '' path; do
    case "$path" in */.*) continue ;; "$VHOSTS"/system/*) continue ;; esac
    [ -f "$path/bin/cleitext" ] || continue
    if [ $first = 1 ]; then first=0; else printf ','; fi
    json_str "$path"
  done < <(find "$VHOSTS" -mindepth 2 -maxdepth 5 -name cleitext \( -type d -o -type l \) -print0 2>/dev/null)
  printf ']}\n'
}

# self-upgrade <package> <sha256>: upgrades this extension with a package the
# extension downloaded from cleitext.com and checked. The package must be in
# the extension's own downloads folder and be named like a Toolkit package;
# it is copied where only root can write, checked again against the SHA-256
# (so it cannot be swapped after the check), and must say it is
# cleitext-toolkit. Plesk then upgrades it in the background, in its own
# systemd unit: the extension that asked is being replaced, so it cannot
# wait. Plesk's output goes to the extension's logs/self-upgrade.log.
cmd_self_upgrade() {
  local file=${1:-} sha=${2:-} var
  local real work copy log unit
  # /usr/local/psa is a link to /opt/psa on Debian and Ubuntu.
  var=$(realpath -e /usr/local/psa/var/modules/cleitext-toolkit 2>/dev/null) ||
    fail not_found "The extension's folder does not exist."
  [[ $sha =~ ^[0-9a-f]{64}$ ]] || fail invalid_argument "Not a SHA-256."
  [ ! -L "$file" ] || fail forbidden "The package must not be a link."
  real=$(realpath -e -- "$file" 2>/dev/null) || fail not_found "The package does not exist."
  [[ $real =~ ^$var/downloads/cleitext-toolkit-[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}-[0-9]{1,4}\.zip$ ]] ||
    fail forbidden "Not a Cleitext Toolkit package in the extension's downloads folder."
  [ -f "$real" ] || fail not_found "The package is not a file."
  work=$(mktemp -d /var/tmp/cleitext-toolkit-upgrade.XXXXXX)
  copy="$work/${real##*/}"
  cp -- "$real" "$copy"
  if [ "$(sha256sum -- "$copy" | cut -d' ' -f1)" != "$sha" ]; then
    rm -rf -- "$work"
    fail checksum "The package does not match its SHA-256: nothing was changed."
  fi
  if ! unzip -p -- "$copy" meta.xml 2>/dev/null | grep -q '<id>cleitext-toolkit</id>'; then
    rm -rf -- "$work"
    fail invalid_package "The package is not Cleitext Toolkit."
  fi
  log="$var/logs/self-upgrade.log"
  mkdir -p "$var/logs"
  printf 'Upgrading with %s (sha256 %s) at %s\n' "${real##*/}" "$sha" "$(date -u +%FT%TZ)" >"$log"
  chown --reference="$var/logs" -- "$log" 2>/dev/null || true
  unit="cleitext-toolkit-upgrade-$(date +%s)"
  # $0 plesk, $1 the package, $2 the log, $3 the work folder.
  local script='"$0" bin extension --upgrade "$1" >>"$2" 2>&1; echo "exit code $?" >>"$2"; rm -rf -- "$3"'
  if command -v systemd-run >/dev/null 2>&1; then
    systemd-run --unit="$unit" --collect --quiet --no-block \
      /bin/bash -c "$script" "$PLESK" "$copy" "$log" "$work" >/dev/null 2>&1 ||
      fail upgrade "The upgrade could not be started."
  else
    unit=""
    setsid nohup /bin/bash -c "$script" "$PLESK" "$copy" "$log" "$work" </dev/null >/dev/null 2>&1 &
  fi
  printf '{"started":true,"unit":%s}\n' "$(json_str "$unit")"
}

case "${1:-}" in
  check) shift; cmd_check "$@" ;;
  self-upgrade) shift; cmd_self_upgrade "$@" ;;
  pg-create) shift; cmd_pg_create "$@" ;;
  pg-prepare) shift; cmd_pg_prepare "$@" ;;
  pg-drop) shift; cmd_pg_drop "$@" ;;
  pg-register) shift; cmd_pg_register "$@" ;;
  pg-admin-setup) shift; cmd_pg_admin_setup "$@" ;;
  scan) shift; cmd_scan "$@" ;;
  *) fail usage "Commands: check, self-upgrade, pg-create, pg-prepare, pg-drop, pg-register, pg-admin-setup, scan." ;;
esac
