#!/bin/sh
# Guided FlamePOS installation for Debian and Ubuntu.
#
# The package takes its answers from environment variables, which suits a script and
# suits nobody standing at a keyboard. This asks the same questions the Windows setup
# asks, on the terminal line, and then installs. It offers the private addresses this
# computer actually has, because an address it does not have is the one mistake that
# stops a store server part-way through installing.
#
#   sudo sh flamepos-install.sh [path-to.deb]
#
# Set FLAMEPOS_INSTALL_DRY_RUN=1 to print the installation it would run and stop.
set -eu

PACKAGE=${1:-}
DRY_RUN=${FLAMEPOS_INSTALL_DRY_RUN:-}
CONFIG_FILE=/etc/flamepos/flamepos.config.properties
RULE="--------------------------------------------------------------"

say() { printf '%s\n' "$*"; }
fail() { printf '%s\n' "$*" >&2; exit 2; }

usage() {
    cat <<'USAGE'
FlamePOS guided installation for Debian and Ubuntu.

  sudo sh flamepos-install.sh [PACKAGE.deb]

Asks what the Windows setup asks, then installs the package:

  * which role this computer has - one till on its own, the store server that
    holds the database, or a till joining an existing server
  * for a store server, which of this computer's private addresses the other
    tills will reach it at, and which network may reach the database
  * for a server or a till joining one, the enrolment passphrase
  * for a computer that already holds FlamePOS, whether to upgrade it or start
    again with an empty installation

With no PACKAGE it takes the FlamePOS package in the current directory, and
offers to download it if there is none. A downloaded package is checked against
the checksum published beside it before anything is installed.

  -h, --help    show this and stop

Environment:
  FLAMEPOS_INSTALL_DRY_RUN=1   print the installation it would run, and stop.
                               Changes nothing and does not need root.
  FLAMEPOS_DOWNLOAD_BASE=URL   where to fetch the package from, for a shop with
                               its own mirror. Default: the FlamePOS download page.

Everything it asks can also be given directly to apt, for a scripted
installation. See the FlamePOS documentation, or:

  sudo env FLAMEPOS_DEPLOYMENT_ROLE=STANDALONE apt install ./PACKAGE.deb

After installing on a computer with no desktop, finish setup with:

  sudo flamepos-setup
USAGE
}

case "${1:-}" in
    -h|--help|help) usage; exit 0 ;;
esac

ask() {
    # ask <prompt> <default>
    printf '%s' "$1"
    [ -n "${2:-}" ] && printf ' [%s]' "$2"
    printf ': '
    read -r REPLY || fail "Installation was cancelled."
    [ -n "$REPLY" ] || REPLY=${2:-}
}

ask_secret() {
    printf '%s: ' "$1"
    stty -echo 2>/dev/null || true
    read -r REPLY || { stty echo 2>/dev/null || true; fail "Installation was cancelled."; }
    stty echo 2>/dev/null || true
    printf '\n'
}

# Where the published package lives. Overridable so a shop with its own mirror, or a
# technician testing a build, can point this somewhere else.
DOWNLOAD_BASE=${FLAMEPOS_DOWNLOAD_BASE:-https://flamepos.quasarailab.com/downloads}
GATE_BASE=${FLAMEPOS_GATE_BASE:-https://flamepos.quasarailab.com/get}

# The package is by invitation: one code, one download. Pass it as FLAMEPOS_CODE, and if
# it is not set this asks, because a till being set up in a shop has somebody standing in
# front of it who was given a code.
INVITATION=${FLAMEPOS_CODE:-}

fetch() {
    # $1 url, $2 destination. Whichever of the two tools this machine has.
    if command -v wget >/dev/null 2>&1; then
        wget -q --show-progress -O "$2" "$1"
    elif command -v curl >/dev/null 2>&1; then
        curl -fSL --progress-bar -o "$2" "$1"
    else
        fail "Neither wget nor curl is installed, so the package cannot be downloaded.
Download it yourself from $DOWNLOAD_BASE and run this again in the same folder."
    fi
}

download_package() {
    manifest=$(mktemp) || fail "Could not create a temporary file."
    fetch "$DOWNLOAD_BASE/manifest.json" "$manifest" >/dev/null 2>&1 ||
        fail "Could not reach $DOWNLOAD_BASE. Check this computer's internet connection,
or download the package yourself and run this again in the same folder."
    name=$(sed -n 's/.*"linux"[^}]*"file"[^"]*"\([^"]*\)".*/\1/p' "$manifest" | head -1)
    want=$(sed -n 's/.*"linux"[^}]*"sha256"[^"]*"\([^"]*\)".*/\1/p' "$manifest" | head -1)
    rm -f "$manifest"
    [ -n "$name" ] && [ -n "$want" ] ||
        fail "The download page did not say which package to fetch. Download it yourself
from $DOWNLOAD_BASE and run this again in the same folder."

    if [ -z "$INVITATION" ]; then
        say "Downloading FlamePOS needs the invitation code you were given."
        ask "Invitation code"
        INVITATION=$REPLY
    fi
    [ -n "$INVITATION" ] || fail "No invitation code, so there is nothing to download.
Ask us for one: the2.71828@gmail.com"

    # One request, because the code is spent by the request that redeems it: asking the
    # gate to check it first and then asking again to download would find it already
    # used. curl is given the redirect to follow by hand so a refusal can be read out;
    # wget follows it itself.
    say "Downloading $name ..."
    if command -v curl >/dev/null 2>&1; then
        refusal=$(mktemp) || fail "Could not create a temporary file."
        signed=$(curl -s -o "$refusal" -w "%{redirect_url}" \
            "$GATE_BASE/redeem?file=linux&code=$INVITATION")
        if [ -z "$signed" ]; then
            message=$(tr -d "\r" < "$refusal" | head -3)
            rm -f "$refusal"
            fail "$message
Ask us for a code: the2.71828@gmail.com"
        fi
        rm -f "$refusal"
        fetch "$signed" "./$name" || fail "The package could not be downloaded."
    else
        fetch "$GATE_BASE/redeem?file=linux&code=$INVITATION" "./$name" ||
            fail "The invitation code was not accepted, or the download failed. Each code
is good for one download. Ask us for another: the2.71828@gmail.com"
    fi

    # Checked before anything is installed: a package that arrived truncated would
    # otherwise fail somewhere less obvious, with a shop's database half migrated.
    got=$(sha256sum "./$name" | cut -d" " -f1)
    if [ "$got" != "$want" ]; then
        rm -f "./$name"
        fail "The downloaded package does not match the published checksum, so it has
been deleted. Try again, and if it happens twice, tell us: $DOWNLOAD_BASE"
    fi
    say "Downloaded and checked against the published checksum."
    say ""
    PACKAGE=./$name
}

if [ -z "$PACKAGE" ]; then
    PACKAGE=$(ls -1 ./flamepos_*_amd64*.deb 2>/dev/null | head -1 || true)
fi
if [ -z "$PACKAGE" ] || [ ! -f "$PACKAGE" ]; then
    if [ -n "$DRY_RUN" ]; then
        say "No package here; a real run would download it from $DOWNLOAD_BASE."
        say ""
        usage
        exit 2
    fi
    say "No FlamePOS package in this folder."
    ask "Download it now from flamepos.quasarailab.com? (y/n)" "y"
    case "$REPLY" in
        [Nn]*)
            say ""
            usage
            exit 2
            ;;
    esac
    say ""
    download_package
fi
# Asked for after it is clear there is something to install, so somebody who simply
# ran it to see what it does is told that, rather than told to find sudo first.
# A dry run changes nothing, so it does not need to be root.
[ -n "$DRY_RUN" ] || [ "$(id -u)" -eq 0 ] || fail "Run this with sudo: sudo sh $0 $PACKAGE"
case "$PACKAGE" in
    /*) ;;
    *) PACKAGE=$(CDPATH= cd -- "$(dirname -- "$PACKAGE")" && pwd)/$(basename -- "$PACKAGE") ;;
esac

say "$RULE"
say "FlamePOS installation"
say "$RULE"
say "Package: $PACKAGE"
say ""

# An installation already here keeps its role, and needs none of these questions
# answered again: the address, the network and the enrolment material are on the
# machine already.
EXISTING=
if [ -f "$CONFIG_FILE" ]; then
    EXISTING=$(sed -n 's/^[[:space:]]*deployment_role[[:space:]]*=[[:space:]]*//p' "$CONFIG_FILE" 2>/dev/null | tail -1)
    [ -n "${EXISTING:-}" ] || EXISTING=STANDALONE
    say "This computer already holds a FlamePOS $EXISTING installation."
    say ""
    say "  1) Upgrade it     keeps this shop's database, settings and records"
    say "  2) Start again    a new, empty installation on this computer"
    say ""
    CHOICE=
    while [ -z "$CHOICE" ]; do
        ask "Choose 1 or 2" "1"
        case "$REPLY" in
            1) CHOICE=upgrade ;;
            2) CHOICE=replace ;;
            *) say "  Enter 1 or 2." ;;
        esac
    done

    if [ "$CHOICE" = upgrade ]; then
        say ""
        say "Upgrading. Nothing is asked again: the role, the network and the"
        say "enrolment material are already on this computer."
        say ""
        say "Installing..."
        [ -n "$DRY_RUN" ] && { say "apt install --reinstall $PACKAGE"; exit 0; }
        exec apt install --reinstall "$PACKAGE"
    fi

    # Starting again is how a machine changes role, and how a shop that has finished
    # with its old records starts clean. It is also the one answer here that can cost
    # somebody their trading history, so it is spelled out and typed out in full.
    say ""
    say "$RULE"
    say "  Starting again means this shop's FlamePOS database is set aside:"
    say "    products, staff, customers, sales and fiscal records."
    say ""
    say "  They are moved to a dated folder beside the current one, not deleted,"
    say "  and the new installation begins empty."
    say ""
    say "  If this computer is trading, stop and take a backup first."
    say "$RULE"
    ask "Type REPLACE to start again, or anything else to stop" ""
    if [ "$REPLY" != "REPLACE" ]; then
        say "Nothing has been changed."
        exit 0
    fi
    REPLACE_EXISTING=yes
    say ""
fi

say "How will this computer be used?"
say ""
say "  1) Standalone POS       one till, its own private database (recommended)"
say "  2) Store Server + POS   this computer holds the database other tills use"
say "  3) POS Terminal Only    a till that joins an existing Store Server"
say ""
ROLE=
while [ -z "$ROLE" ]; do
    ask "Choose 1, 2 or 3" "1"
    case "$REPLY" in
        1) ROLE=STANDALONE ;;
        2) ROLE=SERVER ;;
        3) ROLE=TERMINAL ;;
        *) say "  Enter 1, 2 or 3." ;;
    esac
done
say ""
say "Selected: $ROLE"
say ""

SERVER_ADDRESS=
NETWORK_CIDR=
ENROLLMENT_PROFILE=
PASSPHRASE=
REPLACE_EXISTING=${REPLACE_EXISTING:-}

if [ "$ROLE" = SERVER ]; then
    # Only private addresses: a store database is never put on a public one. The
    # mesh VPN range 100.64-127.x is what Tailscale hands out and is never routable
    # on the internet.
    CANDIDATES=$(ip -4 -o addr show 2>/dev/null | awk '
        {
            split($4, parts, "/")
            address = parts[1]
            prefix = parts[2]
            if (address ~ /^10\./ || address ~ /^192\.168\./ ||
                address ~ /^172\.(1[6-9]|2[0-9]|3[01])\./ ||
                address ~ /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./) {
                print $2 "|" address "|" prefix
            }
        }')
    [ -n "$CANDIDATES" ] || say "No private address was found on this computer."
    say "Which address will the other tills reach this computer at?"
    say ""
    INDEX=0
    echo "$CANDIDATES" | while IFS='|' read -r device address prefix; do
        [ -n "$address" ] || continue
        INDEX=$((INDEX + 1))
        printf '  %d) %-15s on %s\n' "$INDEX" "$address" "$device"
    done
    say ""
    while [ -z "$SERVER_ADDRESS" ]; do
        ask "Choose a number, or type an address" "1"
        case "$REPLY" in
            [0-9]|[0-9][0-9]) CHOSEN=$(echo "$CANDIDATES" | sed -n "${REPLY}p") ;;
            *) CHOSEN="|$REPLY|" ;;
        esac
        SERVER_ADDRESS=$(printf '%s' "$CHOSEN" | cut -d'|' -f2)
        PREFIX=$(printf '%s' "$CHOSEN" | cut -d'|' -f3)
        [ -n "$SERVER_ADDRESS" ] || say "  Choose one of the numbers, or type an address."
    done

    # A sensible allowed network for the address chosen, which the operator can change.
    case "$SERVER_ADDRESS" in
        100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*)
            # Tailnet peers do not share a smaller subnet than the whole range.
            SUGGESTED=100.64.0.0/10 ;;
        *)
            SUGGESTED=$(printf '%s' "$SERVER_ADDRESS" |
                awk -F. -v prefix="${PREFIX:-24}" '{
                    if (prefix == "" || prefix > 24) { prefix = 24 }
                    if (prefix >= 24)      { printf "%s.%s.%s.0/%s", $1, $2, $3, prefix }
                    else if (prefix >= 16) { printf "%s.%s.0.0/%s", $1, $2, prefix }
                    else                   { printf "%s.0.0.0/%s", $1, prefix }
                }') ;;
    esac
    say ""
    say "Which network may reach the store database?"
    ask "Allowed network in CIDR notation" "$SUGGESTED"
    NETWORK_CIDR=$REPLY

    say ""
    say "Each till joins this server with an enrolment passphrase."
    say "It is not stored anywhere, so keep it somewhere safe."
    while [ -z "$PASSPHRASE" ]; do
        ask_secret "Enrolment passphrase (10 characters or more)"
        FIRST=$REPLY
        if [ "${#FIRST}" -lt 10 ]; then
            say "  It must be at least 10 characters."
            continue
        fi
        ask_secret "Confirm the passphrase"
        if [ "$FIRST" != "$REPLY" ]; then
            say "  The two passphrases are different."
            continue
        fi
        PASSPHRASE=$FIRST
    done
fi

if [ "$ROLE" = TERMINAL ]; then
    say "This till joins an existing Store Server."
    say "Copy the .fpos file the server created onto this computer first."
    say ""
    while [ -z "$ENROLLMENT_PROFILE" ]; do
        ask "Path to the .fpos enrolment profile" ""
        if [ -f "$REPLY" ]; then
            ENROLLMENT_PROFILE=$REPLY
        else
            say "  No file there. Check the path and try again."
        fi
    done
    say ""
    while [ -z "$PASSPHRASE" ]; do
        ask_secret "Enrolment passphrase from the Store Server"
        if [ "${#REPLY}" -lt 10 ]; then
            say "  It must be at least 10 characters."
            continue
        fi
        PASSPHRASE=$REPLY
    done
fi

say ""
say "$RULE"
say "  Role       $ROLE"
[ -n "$SERVER_ADDRESS" ] && say "  Address    $SERVER_ADDRESS"
[ -n "$NETWORK_CIDR" ] &&   say "  Network    $NETWORK_CIDR"
[ -n "$ENROLLMENT_PROFILE" ] && say "  Profile    $ENROLLMENT_PROFILE"
[ -n "$PASSPHRASE" ] &&     say "  Passphrase (kept out of sight)"
[ -n "$REPLACE_EXISTING" ] && say "  The existing installation will be set aside"
say "$RULE"
ask "Install with these settings? (y/n)" "y"
case "$REPLY" in
    [Yy]*) ;;
    *) say "Nothing has been changed."; exit 0 ;;
esac

say ""
say "Installing..."
if [ -n "$DRY_RUN" ]; then
    say "FLAMEPOS_DEPLOYMENT_ROLE=$ROLE"
    [ -n "$SERVER_ADDRESS" ] && say "FLAMEPOS_SERVER_ADDRESS=$SERVER_ADDRESS"
    [ -n "$NETWORK_CIDR" ] && say "FLAMEPOS_NETWORK_CIDR=$NETWORK_CIDR"
    [ -n "$ENROLLMENT_PROFILE" ] && say "FLAMEPOS_ENROLLMENT_PROFILE=$ENROLLMENT_PROFILE"
    [ -n "$PASSPHRASE" ] && say "FLAMEPOS_ENROLLMENT_PASSPHRASE=(set)"
    [ -n "$REPLACE_EXISTING" ] && say "FLAMEPOS_REPLACE_EXISTING=yes"
    say "apt install --reinstall $PACKAGE"
    exit 0
fi

FLAMEPOS_DEPLOYMENT_ROLE=$ROLE \
FLAMEPOS_SERVER_ADDRESS=$SERVER_ADDRESS \
FLAMEPOS_NETWORK_CIDR=$NETWORK_CIDR \
FLAMEPOS_ENROLLMENT_PROFILE=$ENROLLMENT_PROFILE \
FLAMEPOS_ENROLLMENT_PASSPHRASE=$PASSPHRASE \
FLAMEPOS_REPLACE_EXISTING=$REPLACE_EXISTING \
    apt install --reinstall "$PACKAGE"
