#!/usr/bin/env bash copypartyConfigFile="/etc/copyparty/copyparty.conf" copypartyPort="3923" copyparty_installed() { pacman -Q copyparty &> /dev/null } ufw_installed() { pacman -Q ufw &> /dev/null } valid_ipv4_subnet() { local subnetValue="$1" [[ "$subnetValue" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]] || return 1 awk -F '[./]' ' { for (octetIndex = 1; octetIndex <= 4; octetIndex++) { if ($octetIndex < 0 || $octetIndex > 255) { exit 1 } } exit 0 } ' <<< "$subnetValue" } detect_private_subnets() { ip -o -4 addr show up scope global | awk ' { split($4, parts, "/") split(parts[1], octets, ".") if (octets[1] == 10) { subnet = octets[1] "." octets[2] "." octets[3] ".0/24" } else if (octets[1] == 192 && octets[2] == 168) { subnet = octets[1] "." octets[2] "." octets[3] ".0/24" } else if (octets[1] == 172 && octets[2] >= 16 && octets[2] <= 31) { subnet = octets[1] "." octets[2] "." octets[3] ".0/24" } else { next } if (!seen[subnet]++) { print subnet } } ' } detect_first_private_ipv4() { ip -o -4 addr show up scope global | awk ' { split($4, parts, "/") split(parts[1], octets, ".") if (octets[1] == 10 || (octets[1] == 192 && octets[2] == 168) || (octets[1] == 172 && octets[2] >= 16 && octets[2] <= 31)) { print parts[1] exit 0 } } ' } show_copyparty_address() { local lanAddress="" if ! copyparty_installed; then msgbox "Copyparty is not installed." return 1 fi lanAddress="$(detect_first_private_ipv4)" if [[ -z "$lanAddress" ]]; then msgbox "Copyparty is installed, but no private IPv4 address could be detected automatically." return 1 fi msgbox "Copyparty should be available at http://${lanAddress}:${copypartyPort}" return 0 } choose_lan_subnet() { local detectedSubnet="" local subnetChoice="" detectedSubnet="$(detect_private_subnets | head -n 1)" subnetChoice="$(inputbox "Confirm the LAN subnet for Copyparty access." "${detectedSubnet:-192.168.1.0/24}")" || return 1 if valid_ipv4_subnet "$subnetChoice"; then printf '%s\n' "$subnetChoice" return 0 fi msgbox "Enter a valid IPv4 subnet such as 192.168.1.0/24." return 1 } install_copyparty() { local packageList=( copyparty ffmpeg cfssl python-mutagen python-pillow python-pyvips libkeyfinder python-pyopenssl python-pyzmq python-argon2-cffi ) if copyparty_installed; then return 0 fi if ! install_package "${packageList[@]}"; then msgbox "Failed to install Copyparty and its optional feature packages." return 1 fi msgbox "Copyparty and its optional feature packages are installed." return 0 } list_local_users() { awk -F ':' ' $3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ { print $1 } ' /etc/passwd | sort } choose_copyparty_user() { local userEntries=() local userName="" local selectedUser="" while IFS= read -r userName; do [[ -n "$userName" ]] && userEntries+=("$userName") done < <(list_local_users) if [[ "${#userEntries[@]}" -eq 0 ]]; then msgbox "No regular local users were found." return 1 fi selectedUser="$(menulist --prompt "Please select the user Copyparty should be run as." "${userEntries[@]}")" || return 1 printf '%s\n' "$selectedUser" return 0 } account_name_exists() { local accountsFile="$1" local username="$2" awk -F ':' -v username="$username" ' /^[[:space:]]+[A-Za-z0-9._-]+:/ { accountName = $1 gsub(/^[[:space:]]+/, "", accountName) if (accountName == username) { exit 0 } } END { exit 1 } ' "$accountsFile" } collect_copyparty_accounts() { local tempAccountsFile="" local username="" local password="" local addAnother="No" tempAccountsFile="$(mktemp)" while true; do username="$(inputbox "Enter a Copyparty username.")" || { rm -f "$tempAccountsFile" return 1 } if [[ -z "$username" || "$username" =~ [[:space:]:] ]]; then msgbox "Usernames cannot be blank and cannot contain spaces or colons." continue fi if account_name_exists "$tempAccountsFile" "$username"; then msgbox "That Copyparty username already exists in this configuration." continue fi password="$(passwordbox "Enter the password for ${username}.")" || { rm -f "$tempAccountsFile" return 1 } if [[ -z "$password" ]]; then msgbox "Passwords cannot be blank." continue fi printf ' %s: %s\n' "$username" "$password" >> "$tempAccountsFile" addAnother="$(yesno "Add another Copyparty account?")" if [[ "$addAnother" != "Yes" ]]; then break fi done printf '%s\n' "$tempAccountsFile" return 0 } choose_share_mode() { local shareMode="" shareMode="$(menulist "Private read-write" "Private read-only" "Public read-only")" || return 1 printf '%s\n' "$shareMode" return 0 } append_share_config() { local outputFile="$1" local sharePath="$2" local urlPath="$3" local shareMode="$4" local usernamesCsv="$5" { printf '\n[%s]\n' "$urlPath" printf ' %s\n' "$sharePath" printf ' accs:\n' case "$shareMode" in "Private read-write") printf ' rwmd: %s\n' "$usernamesCsv" printf ' flags:\n' printf ' daw\n' ;; "Private read-only") printf ' r: %s\n' "$usernamesCsv" ;; "Public read-only") printf ' r: *\n' ;; esac } >> "$outputFile" } share_url_exists() { local sharesFile="$1" local urlPath="$2" awk -v urlPath="$urlPath" ' /^\[[^]]+\]$/ { sectionName = $0 sub(/^\[/, "", sectionName) sub(/\]$/, "", sectionName) if (sectionName == urlPath) { exit 0 } } END { exit 1 } ' "$sharesFile" } service_user_can_read_share() { local serviceUser="$1" local sharePath="$2" # `sudoFlags` is initialized by the main launcher before sourcing this file. # shellcheck disable=SC2154 sudo "${sudoFlags[@]}" -u "$serviceUser" test -r "$sharePath" && sudo "${sudoFlags[@]}" -u "$serviceUser" test -x "$sharePath" } service_user_can_write_share() { local serviceUser="$1" local sharePath="$2" # `sudoFlags` is initialized by the main launcher before sourcing this file. # shellcheck disable=SC2154 sudo "${sudoFlags[@]}" -u "$serviceUser" test -w "$sharePath" && sudo "${sudoFlags[@]}" -u "$serviceUser" test -x "$sharePath" } collect_copyparty_shares() { local usernamesCsv="$1" local serviceUser="$2" local tempSharesFile="" local sharePath="" local urlPath="" local shareMode="" local addAnother="No" tempSharesFile="$(mktemp)" while true; do sharePath="$(inputbox "Enter the local path to share.")" || { rm -f "$tempSharesFile" return 1 } if [[ -z "$sharePath" ]]; then msgbox "A local path is required." continue fi # `sudoFlags` is initialized by the main launcher before sourcing this file. # shellcheck disable=SC2154 if ! sudo "${sudoFlags[@]}" test -d "$sharePath"; then msgbox "The path ${sharePath} does not exist or is not a directory." continue fi if ! service_user_can_read_share "$serviceUser" "$sharePath"; then msgbox "The selected service user ${serviceUser} cannot read ${sharePath}. Adjust permissions or choose another path." continue fi urlPath="$(inputbox "Enter the Copyparty URL path for this share, for example /files." "/files")" || { rm -f "$tempSharesFile" return 1 } if [[ ! "$urlPath" =~ ^/[^[:space:]]*$ ]]; then msgbox "Enter a URL path that starts with / and contains no spaces." continue fi if share_url_exists "$tempSharesFile" "$urlPath"; then msgbox "That Copyparty URL path is already defined in this configuration." continue fi shareMode="$(choose_share_mode)" || { rm -f "$tempSharesFile" return 1 } if [[ "$shareMode" == "Private read-write" ]] && ! service_user_can_write_share "$serviceUser" "$sharePath"; then msgbox "The selected service user ${serviceUser} cannot write to ${sharePath}. Choose another path or adjust permissions." continue fi append_share_config "$tempSharesFile" "$sharePath" "$urlPath" "$shareMode" "$usernamesCsv" addAnother="$(yesno "Add another Copyparty share?")" if [[ "$addAnother" != "Yes" ]]; then break fi done printf '%s\n' "$tempSharesFile" return 0 } join_account_names() { local accountsFile="$1" awk -F ':' ' BEGIN { first = 1 } /^[[:space:]]+[A-Za-z0-9._-]+:/ { gsub(/^[[:space:]]+/, "", $1) if (!first) { printf ", " } printf "%s", $1 first = 0 } END { printf "\n" } ' "$accountsFile" } write_copyparty_config() { local serviceUser="$1" local accountsFile="$2" local sharesFile="$3" local tempConfigFile="" local serviceGroup="" tempConfigFile="$(mktemp)" serviceGroup="$(id -gn "$serviceUser")" { printf '[global]\n' printf ' i: 0.0.0.0\n' printf ' p: %s\n' "$copypartyPort" printf ' e2dsa\n' printf '\n[accounts]\n' cat "$accountsFile" cat "$sharesFile" } > "$tempConfigFile" # `sudoFlags` is initialized by the main launcher before sourcing this file. # shellcheck disable=SC2154 if ! sudo "${sudoFlags[@]}" install -d -m 755 /etc/copyparty; then rm -f "$tempConfigFile" msgbox "Failed to create /etc/copyparty." return 1 fi # shellcheck disable=SC2154 if ! sudo "${sudoFlags[@]}" install -o root -g "$serviceGroup" -m 640 "$tempConfigFile" "$copypartyConfigFile"; then rm -f "$tempConfigFile" msgbox "Failed to write ${copypartyConfigFile}." return 1 fi rm -f "$tempConfigFile" return 0 } enable_copyparty_service() { local serviceUser="$1" local unitName="copyparty@${serviceUser}" local configuredUnit="" # `sudoFlags` is initialized by the main launcher before sourcing this file. # shellcheck disable=SC2154 while IFS= read -r configuredUnit; do [[ -z "$configuredUnit" || "$configuredUnit" == "$unitName" ]] && continue sudo "${sudoFlags[@]}" systemctl disable --now "$configuredUnit" &> /dev/null || true done < <( sudo "${sudoFlags[@]}" find /etc/systemd/system -type l -name 'copyparty@*.service' -printf '%f\n' 2> /dev/null | sort -u ) if ! sudo "${sudoFlags[@]}" systemctl enable "$unitName"; then msgbox "Copyparty was configured, but the service failed to enable." return 1 fi if sudo "${sudoFlags[@]}" systemctl is-active --quiet "$unitName"; then if ! sudo "${sudoFlags[@]}" systemctl restart "$unitName"; then msgbox "Copyparty was configured, but the running service failed to restart." return 1 fi return 0 fi if ! sudo "${sudoFlags[@]}" systemctl start "$unitName"; then msgbox "Copyparty was configured, but the service failed to start." return 1 fi return 0 } configure_copyparty_firewall() { local lanSubnet="" if ! ufw_installed; then msgbox "ufw is not installed, so Copyparty will listen on ${copypartyPort} without helper-managed LAN restrictions. Install and configure ufw if you need local-only enforcement." return 0 fi if [[ "$(yesno "ufw is installed. Allow LAN-only access to Copyparty on port ${copypartyPort}?")" != "Yes" ]]; then return 0 fi lanSubnet="$(choose_lan_subnet)" || return 1 # `sudoFlags` is initialized by the main launcher before sourcing this file. # shellcheck disable=SC2154 if ! sudo "${sudoFlags[@]}" ufw allow from "$lanSubnet" to any port "$copypartyPort" proto tcp; then msgbox "Failed to allow Copyparty access for ${lanSubnet}." return 1 fi msgbox "Copyparty is allowed on ${copypartyPort}/tcp for ${lanSubnet}." return 0 } install_copyparty_flow() { local serviceUser="" local unitName="" local accountsFile="" local usernamesCsv="" local sharesFile="" local lanAddress="" serviceUser="$(choose_copyparty_user)" || return 1 unitName="copyparty@${serviceUser}.service" install_copyparty || return 1 accountsFile="$(collect_copyparty_accounts)" || return 1 usernamesCsv="$(join_account_names "$accountsFile")" sharesFile="$(collect_copyparty_shares "$usernamesCsv" "$serviceUser")" || { rm -f "$accountsFile" return 1 } write_copyparty_config "$serviceUser" "$accountsFile" "$sharesFile" || { rm -f "$accountsFile" "$sharesFile" return 1 } rm -f "$accountsFile" "$sharesFile" enable_copyparty_service "$serviceUser" || return 1 configure_copyparty_firewall || return 1 lanAddress="$(detect_first_private_ipv4)" if [[ -n "$lanAddress" ]]; then msgbox "Copyparty is configured. Enabled unit: ${unitName}. It should now be available at http://${lanAddress}:${copypartyPort}. For advanced tuning, edit ${copypartyConfigFile} as root." else msgbox "Copyparty is configured. Enabled unit: ${unitName}. For advanced tuning, edit ${copypartyConfigFile} as root." fi } case "${1:-install}" in install) install_copyparty_flow ;; show-address) show_copyparty_address ;; *) msgbox "Unknown Copyparty action: ${1}" return 1 ;; esac