#!/usr/bin/env bash

# librerelease - uploads packages to the repo server and publishes them
#
# Copyright (C) 2010-2012                Joshua Ismael Haase Hernández (xihh) <hahj87@gmail.com>
# Copyright (C) 2010-2013                Nicolás Reynolds <fauno@parabola.nu>
# Copyright (C) 2013                     Michał Masłowski <mtjm@mtjm.eu>
# Copyright (C) 2013-2014,2017-2018,2024 Luke Shumaker <lukeshu@parabola.nu>
# Copyright (C) 2019-2020,2022-2024      Bill Auger <mr.j.spam.me@gmail.com>
#
# For just the create_signature() function:
#   Copyright (C) 2006-2013 Pacman Development Team <pacman-dev@archlinux.org>
#   Copyright (C) 2002-2006 Judd Vinet <jvinet@zeroflux.org>
#   Copyright (C) 2005      Aurelien Foret <orelien@chez.com>
#   Copyright (C) 2006      Miklos Vajna <vmiklos@frugalware.org>
#   Copyright (C) 2005      Christian Hamar <krics@linuxforum.hu>
#   Copyright (C) 2006      Alex Smith <alex@alex-smith.me.uk>
#   Copyright (C) 2006      Andras Voroskoi <voroskoi@frugalware.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This file is part of Parabola Libretools.
#
# Parabola is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Parabola is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Parabola. If not, see <http://www.gnu.org/licenses/>.

# create_signature() is taken from pacman:makepkg, which is GPLv2+,
# so we take the '+' to combine it with our GPLv3+.


set -euE

source "$(librelib conf         )" # LIBREUSER, load_conf()
source "$(librelib messages     )" # setup_traps(), msg(), msg2() error(), print(), prose(), flag()
source "$(librelib notifications)" # notify_release()


declare -ri STAGING_LOCK=8
declare -ra RSYNC_FLAGS=(
	--no-group
	--no-perms
	--copy-links
	--hard-links
	--partial
	--human-readable
	--progress
)
DRY_RUN=''       # main()
UPLOAD_ONLY=''   # main()
TIER0_HOST=''    # main()
TIER0_STAGING='' # main()
TIER0_LOGIN=''   # main()
TIER0_PORT=''    # main()
SSH_CMD=''       # main()
RSYNC_DEST=''    # main()


## helpers ##

lock_staging() {
	lock $STAGING_LOCK "${WORKDIR}/staging.lock" \
	     "Waiting for an exclusive lock on the staging directory ...."
}

unlock_staging() {
	lock_close $STAGING_LOCK
}

list0_files() {
	find -L "${WORKDIR}/staging" -type f -not -name '*.lock' \
	     -exec realpath -z --relative-to="${WORKDIR}/staging" {} + | sort -z
}

# This function is taken almost verbatim from makepkg
create_signature() {
	local filename="$1"
	msg "Signing package..."

	local SIGNWITHKEY=()
	if [[ -n $GPGKEY ]]; then
		SIGNWITHKEY=(-u "${GPGKEY}")
	fi

	if gpg --detach-sign --use-agent "${SIGNWITHKEY[@]}" \
	        --no-armor "$filename" &>/dev/null           ; then
		msg2 "Created signature file %s." "$filename.sig"
		return $EXIT_SUCCESS
	else
		error "Failed to sign package file."
		return $EXIT_FAILURE
	fi
}

sign_packages() {
	IFS=$'\n'
	local files=($(find "${WORKDIR}/staging/" -type f -not -iname '*.sig' -print))
	local file
	for file in "${files[@]}"; do
		if [[ -f "${file}.sig" ]]; then
			msg2 "File signature found, verifying..."

			# Verify that the signature is correct, else remove for re-signing
			if ! gpg --quiet --verify "${file}.sig" >/dev/null 2>&1; then
				error "Failed!  Re-signing..."
				rm -f "${file}.sig"
			fi
		fi

		if ! [[ -f "${file}.sig" ]]; then
			create_signature "$file" || return
		fi
	done
}

# Clean everything if not in dry-run mode
clean_files() (
	local file_list=$1
	local rmcmd

	if [[ -z "$DRY_RUN" ]]; then
		rmcmd=( rm -fv )
	else
		rmcmd=( printf "$(_ "removed '%s' (dry-run)")\n" )
	fi

	msg "Removing files from local staging directory"
	cd "${WORKDIR}/staging"
	xargs -0r -a "$file_list" "${rmcmd[@]}"
	find . -depth -mindepth 1 -type d \
	       -exec rmdir --ignore-fail-on-non-empty -- '{}' +
)


## The different modes ##

usage() {
	print "Usage: %s [OPTIONS]" "${0##*/}"
	prose 'Upload packages staged in %s (per `librestage`) to the configured server,
	       and publish them to their respective repositories.' \$WORKDIR/staging
	echo
	prose 'This requires the `gpg` program, configured with your GPG key,
	       and a staging directory (%s), writable by %s.' \$WORKDIR/staging '$LIBREUSER'
	prose '%s is determined at runtime, by %s.
	       %s is normally the local login of the invoking user,
	       but may be over-ridden by setting %s in the environment.' \
	      '$LIBREUSER' /usr/lib/libretools/conf.sh '$LIBREUSER' '$SUDO_USER'
	prose 'By default, %s is assumed to match the hackers.git login
	       for which %s has login credentials for the repo server.
	       If %s does not match the hackers.git login,
	       you must specify %s as the remote login, (eg: in %s)' \
	      '$LIBREUSER' '$LIBREUSER' '$LIBREUSER' '$TIER0_LOGIN'  \
	      \$XDG_CONFIG_HOME/libretools/libretools.conf
	echo
	print "Options:"
	flag '-c' "Clean Local: delete packages in local staging directory"
	flag '-C' "Clean Remote: delete packages in remote staging directory"
	flag '-h' "Help: Show this message"
	flag '-l' "List: list packages but not upload them"
	flag '-n' "Dry-run: don't actually do anything"
	flag '-u' "Upload-only: do not run db-update on the server"
}

list() {
	find "$WORKDIR/staging/" -mindepth 1 -maxdepth 1 -type d -not -empty | sort |
	while read -r path; do
		msg2 "${path##*/}"
		cd "$path"
		find -L . -type f -not -name '*.lock' | sed 's|^\./|     |' | sort
	done
}

clean_local() {
	lock_staging

	local file_list
	file_list="$(mktemp -t "${0##*/}.XXXXXXXXXX")"
	trap "rm -f -- ${file_list@Q}" EXIT
	list0_files > "$file_list"

	unlock_staging

	clean_files "$file_list"
}

clean_remote() {
	msg "Removing files from remote staging directory"
	${SSH_CMD[*]} "rm -rfv ${TIER0_STAGING@Q}/*"
}

release() {
	local file_list="$(   mktemp -t ${0##*/}_lst.XXXXXXXXXX)"
	local dbupdate_log="$(mktemp -t ${0##*/}_log.XXXXXXXXXX)"
	local mkdir_cmd="mkdir -p -- ${TIER0_STAGING@Q} && cd ${TIER0_STAGING@Q} && xargs -0r mkdir -pv --"
	local dbupdate_cmd="STAGING=${TIER0_STAGING@Q} DBSCRIPTS_CONFIG=${DBSCRIPTS_CONFIG@Q} db-update"
	local upload_size pbotsay_msg pbotsay_cmd

	trap "rm -f -- ${file_list@Q} ${dbupdate_log@Q}" INT RETURN TERM

	lock_staging

	# verify connection and login
	if ! ${SSH_CMD[0]} -fN ${SSH_CMD[*]:1}; then
		error "Connection or login failed."
		return $EXIT_FAILURE
	fi


	## prepare ##

	if [[ -n $HOOKPRERELEASE ]]; then
		msg "Running HOOKPRERELEASE..."
		(
			PS4="   \\[$BOLD\\]\$\\[$ALL_OFF\\] "
			eval -- "set -x; $HOOKPRERELEASE"
		)
	fi

	sign_packages || return $EXIT_FAILURE

	# collect staged files and set permissions for repository-bound files
	list0_files > "$file_list"
	upload_size="$(cd "${WORKDIR}/staging" && du -hc --files0-from="$file_list" | sed -n '$s/\t.*//p')"
	find "${WORKDIR}/staging" -type f -exec chmod 644 {} +
	find "${WORKDIR}/staging" -type d -exec chmod 755 {} +

	unlock_staging

	# prepare remote staging directory tree
	msg "%s to upload" "$upload_size"
	xargs -0r -a "$file_list" dirname -z | ${SSH_CMD[*]} "$mkdir_cmd"


	## upload ##

	msg "Uploading packages..."
	if ! rsync ${DRY_RUN} "${RSYNC_FLAGS[@]}" \
		-e "ssh $SSH_PORT"                      \
		-0 --files-from="$file_list"            \
		"${WORKDIR}/staging"                    \
		"$RSYNC_DEST"
	then
		error "Sync failed, try again"
		return $EXIT_FAILURE
	fi

	clean_files "$file_list"

	if $UPLOAD_ONLY; then
		return $EXIT_SUCCESS
	fi


	## publish ##

	msg "Running db-update on repos"
	(
		# this contraption allows detecting `db-update` exit failure,
		# while logging output both to file and to the local shell in real-time,
		# while preserving (restoring) colors lost in the pipeline
		set -o pipefail ; ${SSH_CMD[*]} "$dbupdate_cmd" | tee "$dbupdate_log" |
		while read line ; do ( [[ "$line" =~ ^==\>    ]] && msg "${line#==\> }" ) ||
		                     ( [[ "$line" =~ ^\ *-\>  ]] && msg2 "${line#*\> }" ) ||
		                     echo "$line" ; done
	)

	if grep -Eq "^==> Updating \[" "$dbupdate_log"; then
		pbotsay_msg=$(release_notification "${TIER0_LOGIN:-${LIBREUSER}}" < "$dbupdate_log")
		pbotsay_cmd="if type pbot-say &>/dev/null ; then pbot-say ${pbotsay_msg@Q} ; fi"

		if [[ -n $HOOKPOSTRELEASE ]]; then
			msg "Running HOOKPOSTRELEASE..."
			(
				PS4="   \\[$BOLD\\]\$\\[$ALL_OFF\\] "
				eval -- "set -x; $HOOKPOSTRELEASE"
			)
		fi

		# notify pbot of the excellent work that we have done today
		msg2 "Notifying pbot:" ; print "     $pbotsay_msg" ;
		${SSH_CMD[*]} "$pbotsay_cmd" &> /dev/null || :
	else
		msg2 "Nothing was published"
	fi
}


## main entry ##

main() {
	# Parse CLI options
	local mode=release            # publish packages to public repo (default)
	UPLOAD_ONLY=false             # upload and publish
	while getopts 'cChlnu' arg; do
		case $arg in
			c) mode=clean_local    ;; # empties local staging area
			C) mode=clean_remote   ;; # empties remote staging area
			h) mode=usage          ;; # print 'Usage' message
			l) mode=list           ;; # pretty-print locally-staged packages
			n) DRY_RUN='--dry-run' ;; # only show what would be done
			u) UPLOAD_ONLY=true    ;; # upload, but do not publish
			*) usage >&2 ; return $EXIT_INVALIDARGUMENT ;;
		esac
	done
	shift $(( OPTIND - 1 ))
	if [[ -w / ]]; then
		error "This program should be run as unprivileged user"
		return $EXIT_NOPERMISSION
	elif (( $# )); then
		usage >&2
		return $EXIT_INVALIDARGUMENT
	elif [[ $mode == usage ]]; then
		usage
		return $EXIT_SUCCESS
	fi

	# source makepkg and libretools configuration files
	# the specified config vars will be used in this script, and so are mandatory
	# optional config vars used in this script, if specified in libretools.conf:
	#   TIER0_LOGIN, TIER0_PORT, TIER0_STAGING, HOOKPRERELEASE, HOOKPOSTRELEASE
	if ! load_conf makepkg.conf GPGKEY; then
		error "The %s config variable is misconfigured." \$GPGKEY
		return $EXIT_NOTCONFIGURED
	elif ! load_conf libretools.conf WORKDIR TIER0_HOST DBSCRIPTS_CONFIG; then
		for var in WORKDIR TIER0_HOST DBSCRIPTS_CONFIG; do
			[[ -n ${!var} ]] || error "The $%s config variable is misconfigured." \$$var
		done
		prose "The format of %s variables may have changed.
		       Merge the %s file, if one is present; and adapt any custom %s to it." \
		      libretools.conf /etc/libretools.conf.pacnew ~/.config/libretools/libretools.conf
		return $EXIT_NOTCONFIGURED
	fi

	# validate/sanitize tier-0 repo URL components
	if [[ "$TIER0_STAGING" == '/~/'* ]]; then
		TIER0_STAGING=${TIER0_STAGING#'/~/'}
	elif [[ "$TIER0_STAGING" == '/~'* ]]; then
		error "Unfortunately, tilde expansion ('~' home directory) is not supported in libretools.conf::TIER0_STAGING"
		return $EXIT_NOTCONFIGURED
	fi

	# finalize state
	readonly DRY_RUN
	readonly UPLOAD_ONLY

	# construct the SSH and rsync destination parameters
	readonly TIER0_LOGIN
	readonly TIER0_HOST
	readonly TIER0_PORT
	readonly TIER0_STAGING=${TIER0_STAGING:-/home/${TIER0_LOGIN:-$LIBREUSER}/staging}
	readonly SSH_URL=${TIER0_LOGIN:+${TIER0_LOGIN}@}${TIER0_HOST}
	readonly SSH_PORT=${TIER0_PORT:+-p $TIER0_PORT}
	readonly SSH_CMD=( ssh ${SSH_PORT} ${SSH_URL} )
	readonly RSYNC_DEST=${SSH_URL}:${TIER0_STAGING%/}/

	# do the requested business
	$mode
}


setup_traps
main "$@"
