#!/bin/bash
# The terminal command of a self-hosted server. openbot-server-setup links it to
# /usr/local/bin/openbot. It talks to the running OpenBot over the control socket in
# src/main/server-mode.ts, and to systemd for the service.
#
# The socket is in /run/openbot, which only the service user can enter. Run this as that user or
# with sudo. In the Docker image (`mode` is `container`) there is no systemd: Docker starts, stops
# and replaces OpenBot, and only the account commands work.
set -euo pipefail
# shellcheck source=openbot-hosted-update
source "$(dirname "$(readlink -f "$0")")/openbot-hosted-update"
umask 077

SOCKET=/run/openbot/control.sock
UNIT=openbot.service

usage() {
  cat <<'EOF'
Usage: openbot <command>

  status                         Show the account and the server.
  login [<email>] [--name <name>]
                                 Sign in with a code that OpenBot sends to your email.
                                 The server then shows in the server list of that account.
                                 Its name is "OpenBot" when you give no name.
  login --claim [--name <name>]  Sign in with a one-time claim that the operator made for
                                 the owner's email. No code is needed. The claim comes from
                                 standard input, or a hidden prompt in a terminal.
  mobile-connect                 Show a QR code that connects the OpenBot phone app to this
                                 account. It works for one sign-in and expires after two minutes.
  logout                         Sign out. The server stops until the next sign-in.
  name <name>                    Change the server name.
  logs [-f]                      Show the OpenBot log.
  start | stop | restart         Control the OpenBot service (sudo).
  update                         Install the newest release now (sudo).
  uninstall                      Sign out and remove OpenBot. The data stays (sudo).
  version                        Show the installed version.
EOF
}

service_user() {
  cat "$INSTALL/hosted/service-user" 2>/dev/null || echo openbot
}

in_container() {
  [ -r "$INSTALL/hosted/mode" ] && [ "$(<"$INSTALL/hosted/mode")" = container ]
}

# Stops a command that Docker does in the image.
require_host() {
  in_container || return 0
  fail "In a container, $1. See https://github.com/nightly-labs/openbot/blob/main/docs/docker.md"
}

require_root() {
  [ "$(id -u)" -eq 0 ] || fail "Run this with sudo: sudo openbot $*"
}

# Stops with the command that shows the log, which is not the same in a container.
not_answering() {
  if in_container; then
    fail "OpenBot does not answer. Read the log on the host with: docker logs <container>"
  fi
  fail "OpenBot does not answer. Read the log with: openbot logs"
}

# Waits for the socket after a start. A user that cannot enter /run/openbot never sees it.
wait_for_socket() {
  local second
  for ((second = 0; second < 30; second++)); do
    [ -S "$SOCKET" ] && return 0
    in_container || systemctl is-active --quiet "$UNIT" || fail "OpenBot is not running. Start it with: sudo openbot start"
    if [ -d /run/openbot ] && [ ! -x /run/openbot ]; then
      fail "Run this with sudo, or as the user $(service_user)."
    fi
    sleep 1
  done
  not_answering
}

# Percent-encodes each byte. printf is a shell builtin, so the value is in no process arguments.
form_value() {
  printf '%s' "$1" | od -An -tx1 -v | tr -d ' \n' | sed 's/../%&/g'
}

# control <method> <path> [<name>=<value> ...]. Sets `response` to the answer lines and a last
# `status=<HTTP status>` line. The body goes to curl on standard input: other users can read the
# arguments of a process in /proc, and a value can be the sign-in code.
control() {
  local method=$1 path=$2 field body="" arguments=()
  shift 2
  for field in "$@"; do body+="${body:+&}${field%%=*}=$(form_value "${field#*=}")"; done
  if [ "$#" -gt 0 ]; then
    arguments=(--header 'content-type: application/x-www-form-urlencoded' --data-binary @-)
  fi
  wait_for_socket
  response=$(printf '%s' "$body" | curl --silent --show-error --max-time 120 --unix-socket "$SOCKET" \
    -X "$method" "${arguments[@]}" --write-out 'status=%{http_code}\n' "http://openbot$path") ||
    not_answering
}

value() {
  sed -n "s/^$1=//p" <<<"$response" | head -n 1
}

# Prints the error of a failed answer and returns 1, or returns 0 for a good answer.
check_answer() {
  [ "$(value status)" = 200 ] && return 0
  local message
  message=$(value message)
  if [ -z "$message" ]; then
    case $(value error) in
      invalid_email) message="Enter a valid email address." ;;
      invalid_request) message="Enter the sign-in code or the claim." ;;
      invalid_name) message="Enter a server name." ;;
      signed_in) message="This server is signed in. To use another account, run: openbot logout" ;;
      account_signed_out | account_code_sent) message="Sign in first with: openbot login" ;;
      account_error | account_loading) message="OpenBot could not check the account yet. Run: openbot status" ;;
      session_not_storable) message="This server cannot store the session securely. The claim was not used." ;;
      *) message="OpenBot refused the request ($(value error))." ;;
    esac
  fi
  echo "$message" >&2
  return 1
}

show_status() {
  local account server name message staged limit
  account=$(value account)
  server=$(value server)
  name=$(value server_name)
  message=$(value server_message)
  echo "OpenBot $(value version)"
  case $account in
    signed_in) echo "Account: $(value email)" ;;
    signed_out) echo "Account: signed out. Sign in with: openbot login" ;;
    code_sent) echo "Account: waiting for a sign-in code. Run: openbot login" ;;
    error) echo "Account: the account server does not answer. $(value account_message)" ;;
    *) echo "Account: $account" ;;
  esac
  if [ -n "$name" ]; then echo "Server: $name ($server)"; else echo "Server: $server"; fi
  case $server in error | unconfigured) if [ -n "$message" ]; then echo "  $message"; fi ;; esac
  # The limit is the smaller of the machine memory and the limit of the unit or the container.
  limit=$(value memory_limit)
  if [ -n "$limit" ]; then
    echo "Memory: $(($(value memory_available) / 1048576)) MiB free of $((limit / 1048576)) MiB ($(value memory_level))"
    turns=$(value turn_limit)
    if [ "$turns" = unlimited ]; then echo "Turns at the same time: no limit"; else echo "Turns at the same time: at most $turns"; fi
  fi
  staged=$(staged_version)
  if [ -n "$staged" ]; then echo "OpenBot $staged is ready to install. Run: sudo openbot update"; fi
}

status_command() {
  control GET /v1/status
  check_answer || exit 1
  show_status
}

login_command() {
  local email="" name="" claim="" with_claim="" challenge code attempt second
  while [ "$#" -gt 0 ]; do
    case $1 in
      --name)
        [ "$#" -ge 2 ] || fail "Add a name after --name."
        name=$2
        shift 2
        ;;
      --claim)
        with_claim=1
        shift
        ;;
      -*) fail "Unknown option: $1" ;;
      *)
        email=$1
        shift
        ;;
    esac
  done
  control GET /v1/status
  check_answer || exit 1
  if [ "$(value account)" = signed_in ]; then
    show_status
    return 0
  fi
  if [ -n "$with_claim" ]; then
    [ -z "$email" ] || fail "The claim names the account. Give no email with --claim."
    # Never an argument: other users can read the arguments of a process. It works once.
    if [ -t 0 ]; then read -rsp "Claim: " claim && echo; else read -r claim || true; fi
    [ -n "$claim" ] || fail "Give the claim on standard input."
    control POST /v1/login/claim "claim=$claim" ${name:+"name=$name"}
    check_answer || exit 1
    email=$(value email)
  else
    [ -t 0 ] || fail "Run openbot login in a terminal."
    if [ -z "$email" ]; then read -rp "Email: " email; fi
    # With the name, OpenBot refuses a name that it cannot publish before it sends a code.
    control POST /v1/login/start "email=$email" ${name:+"name=$name"}
    check_answer || exit 1
    challenge=$(value challenge)
    echo "OpenBot sent a sign-in code to $email."
    for attempt in 1 2 3; do
      read -rp "Code: " code
      control POST /v1/login/verify "challenge=$challenge" "code=$code" ${name:+"name=$name"}
      if check_answer; then break; fi
      [ "$attempt" -lt 3 ] || exit 1
    done
  fi
  echo "Signed in as $(value email). Publishing the server..."
  for ((second = 0; second < 90; second += 2)); do
    control GET /v1/status
    case $(value server) in online | error) break ;; esac
    # Publishing failed before the host started. The start retry tries again every minute.
    if [ "$(value server)" = unconfigured ] && [ -n "$(value server_message)" ]; then break; fi
    sleep 2
  done
  show_status
  if [ "$(value server)" = online ]; then
    echo "Sign in to OpenBot with $email on your computer or https://openbot.run/app to use this server."
    echo "To use it from your phone, run: openbot mobile-connect"
  fi
}

mobile_connect_command() {
  local expires_at
  control POST /v1/mobile-connect
  check_answer || exit 1
  expires_at=$(value expires_at)
  # Arithmetic runs a command in an array index, so only digits go into it.
  [[ $expires_at =~ ^[0-9]+$ ]] || fail "OpenBot sent an invalid Mobile Connect code."
  value qr | base64 -d
  echo "In the OpenBot phone app, select Scan QR code and scan this code."
  echo "It works for one sign-in and expires in $((expires_at / 1000 - $(date +%s))) seconds. Run the command again for a new code."
}

logs_command() {
  require_host "read the log on the host with: docker logs <container>"
  if [ "${1:-}" = -f ]; then exec journalctl --unit "$UNIT" --follow --lines 100; fi
  exec journalctl --unit "$UNIT" --lines 200 --no-pager
}

# OpenBot starts again when `apply` fails or a signal stops the update, as in `request`. A copy that
# did not complete is done again first, because a partial release cannot start. The status stays.
# A second signal must not stop the recovery, and the `apply` child ignores it too.
restart_after_failed_update() {
  local status=$?
  trap '' INT TERM HUP
  [ ! -e "$INSTALL/.applying" ] || "$INSTALL/hosted/openbot-hosted-update" apply || true
  if systemctl start "$UNIT"; then
    echo "The update failed. OpenBot $(version_of "$INSTALL/app") runs. Try again with: sudo openbot update" >&2
  else
    echo "The update failed, and OpenBot did not start. Read the log with: openbot logs" >&2
  fi
  exit "$status"
}

update_command() {
  require_host "pull the new image on the host, then make the container again"
  require_root update
  local before after reply
  before=$(version_of "$INSTALL/app")
  "$INSTALL/hosted/openbot-hosted-update" stage
  after=$(staged_version)
  if [ -z "$after" ]; then
    echo "OpenBot $before is the newest release."
    return 0
  fi
  if [ -t 0 ]; then
    read -rp "Install OpenBot $after now? OpenBot stops for about a minute, and running agent turns stop. [y/N] " reply
    [[ $reply == [yY]* ]] || return 0
  fi
  trap restart_after_failed_update EXIT
  trap 'exit 1' INT TERM HUP
  systemctl stop "$UNIT"
  "$INSTALL/hosted/openbot-hosted-update" apply
  trap - EXIT INT TERM HUP
  systemctl start "$UNIT"
  echo "Installed OpenBot $(version_of "$INSTALL/app")."
}

# Removes what openbot-server-setup installed. The home folder of the service user and the keyring in
# /srv/openbot-hosted stay: the keyring has the key of the encrypted data in that home folder, so the
# two go together, and a later install uses them again.
uninstall_command() {
  require_host "run openbot logout, then remove the container on the host. The data stays in its volume"
  require_root uninstall
  local user home reply unit entry
  { [ -r "$INSTALL/hosted/mode" ] && [ "$(<"$INSTALL/hosted/mode")" = self ]; } ||
    fail "This computer is not a self-hosted OpenBot server."
  user=$(service_user)
  if [ -t 0 ]; then
    read -rp "Remove OpenBot from this computer? The data of the user $user stays. [y/N] " reply
    [[ $reply == [yY]* ]] || return 0
  fi
  # The session then stops on the account server too, and the data keeps no session. A stopped
  # service starts for it.
  if ! (systemctl start "$UNIT" && control POST /v1/logout && check_answer); then
    echo "OpenBot did not sign out. Remove the server in the app." >&2
  fi
  # Waits for an update that runs now.
  exec 9>"$LOCK"
  flock 9
  systemctl disable --now --quiet "${UNITS[@]}" 2>/dev/null || true
  for unit in "${UNITS[@]}"; do rm -f "/etc/systemd/system/$unit"; done
  systemctl daemon-reload
  if [ -f /etc/apparmor.d/openbot-hosted ] && command -v apparmor_parser >/dev/null &&
    [ -d /sys/kernel/security/apparmor ]; then
    apparmor_parser --remove /etc/apparmor.d/openbot-hosted 2>/dev/null || true
  fi
  rm -f /etc/apparmor.d/openbot-hosted /usr/local/bin/openbot
  # The desktop .deb also installs into /opt/OpenBot, so only the entries of a server go.
  for entry in app hosted staged staged.ready .applying; do rm -rf "${INSTALL:?}/$entry"; done
  rmdir "$INSTALL" 2>/dev/null || true
  home=$(getent passwd "$user" | cut -d: -f6)
  echo "Removed OpenBot. The data stays in ${home:-the home folder of $user} and /srv/openbot-hosted."
  echo "To install again with this data: the install command, with --user $user."
  echo "To delete the data too: sudo userdel --remove $user && sudo rm -rf /srv/openbot-hosted"
}

command=${1:-}
[ "$#" -eq 0 ] || shift
case $command in
  status) status_command ;;
  login) login_command "$@" ;;
  logout)
    control POST /v1/logout
    check_answer || exit 1
    echo "Signed out. The server is not available until the next sign-in."
    ;;
  name)
    [ "$#" -ge 1 ] || fail "Usage: openbot name <name>"
    control POST /v1/name "name=$*"
    check_answer || exit 1
    show_status
    ;;
  mobile-connect) mobile_connect_command ;;
  logs) logs_command "$@" ;;
  start | stop | restart)
    require_host "run docker $command <container> on the host"
    require_root "$command"
    systemctl "$command" "$UNIT"
    ;;
  update) update_command ;;
  uninstall) uninstall_command ;;
  version) version_of "$INSTALL/app" ;;
  "" | help | --help | -h) usage ;;
  *)
    usage >&2
    exit 1
    ;;
esac
