Bash script to backup calendars and addressbooks from a local ownCloud/Nextcloud installation
https://codeberg.org/BernieO/calcardbackup
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2342 lines
122 KiB

  1. #!/usr/bin/env bash
  2. # calcardbackup - extracts ownCloud/Nextcloud calendars and addressbooks
  3. # Copyright (C) 2017-2020 Bernhard Ostertag
  4. #
  5. # Source: https://codeberg.org/BernieO/calcardbackup
  6. #
  7. # This program is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU Affero General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU Affero General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU Affero General Public License
  18. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. #######################################################
  20. #######################################################
  21. ## ##
  22. ## Don't touch anything below unless you know ##
  23. ## exactly what you are doing! ##
  24. ## ##
  25. #######################################################
  26. #######################################################
  27. set -euo pipefail
  28. # calcardbackup version and origin repository:
  29. version="1.2.0 (20.02.2020), AGPL-3.0"
  30. origin_repository="https://codeberg.org/BernieO/calcardbackup"
  31. ###
  32. ### BEGIN: FUNCTION DEFINITIONS
  33. ###
  34. _output() {
  35. # prints output, but only if not in batch mode (option -b|--batch)
  36. # use || instead of && so that function returns true in any case - corrected in ver. 0.1.2:
  37. [[ ${mode:-} == "batch" ]] || "$@"
  38. }
  39. remove_trailing_slashes() {
  40. # removes trailing slashes from a string passed as ${1} to this function
  41. local slashes_removed
  42. slashes_removed="${1}"
  43. while [[ ${slashes_removed} =~ /$ ]]; do
  44. slashes_removed="${slashes_removed%/}"
  45. done
  46. printf '%s\n' "${slashes_removed}"
  47. }
  48. get_absolute_path_ng() {
  49. # checks whether path is readable (if path is file) or executable (if path is directory) and
  50. # returns the absolute path to a given file- or directory-path (which may be relative or absolute)
  51. # arguments:
  52. # ${1} = path to file or directory (relative or absolute)
  53. local path in_config_file absolute_path
  54. # assign short text variable if calcardbackup runs with config-file:
  55. [[ -n ${config_file:-} && ${1} != "${config_file:-}" ]] && in_config_file=" in config-file"
  56. # remove trailing slashes from path:
  57. path="$(remove_trailing_slashes "${1}")"
  58. # turn path into absolute path (approach is different, if directory or file):
  59. if [[ -d ${path} ]]; then
  60. # make sure directory is executable (being able to 'cd' into directory):
  61. [[ -x ${path} ]] || error_exit "ERROR: can't open '${1}'. Check given path${in_config_file:-}!" "I need to be able to read the full path!"
  62. absolute_path="$( cd "${path}" && pwd )"
  63. elif [[ -f ${path} ]]; then
  64. # make sure file is readable (then there will not be an error, when changing into directory containing the file):
  65. [[ -r ${path} ]] || error_exit "ERROR: can't read '${1}'. Check given path${in_config_file:-}!" "I need to be able to read the full path!"
  66. # we need to change directory, if $path contains a directory (this is the case if $path contains a slash):
  67. [[ ${path} == */* ]] && cd "${path%/*}"
  68. absolute_path="$(pwd)/${path##*/}"
  69. # switching back to $working_dir not necessary, because this function is always run in a subshell
  70. else
  71. # print error and exit, if path is not readable:
  72. error_exit "ERROR: can't read '${1}'. Check given path${in_config_file:-}!" "I need to be able to read the full path!"
  73. fi
  74. # return absolute path:
  75. printf '%s\n' "${absolute_path}"
  76. }
  77. check_readable_file() {
  78. # checks, if a given path is a regular and readable file.
  79. # arguments: ${1} is path to file, ${2} is short verbal explanation of the file for error message.
  80. # $1 needs to be a regular file AND readable (not OR like in versions < 0.4.2-2)
  81. [[ -f ${1} && -r ${1} ]] || error_exit "ERROR: Can't read ${2}" "${1} needs to be a regular file and I need to be able to read it."
  82. }
  83. print_header() {
  84. # prints header with timestamp and version number
  85. _output printf '\n%s\n' "+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++"
  86. _output printf '%s\n' "+"
  87. _output printf '%s\n' "+ $(date) --> START calcardbackup ver. ${version}"
  88. }
  89. set_required_paths() {
  90. # sets required paths for script to work
  91. # store current directory, because we have to change dir later and need to be able to come back to where we were:
  92. working_dir="$(pwd)"
  93. # get path to scripts dir:
  94. script_dir="$(get_absolute_path_ng "${BASH_SOURCE[0]}")"
  95. script_dir="${script_dir%/*}"
  96. }
  97. load_default_values() {
  98. # assigns default-values as configuration (which will be overwritten later with values from config-file or options passed from command line).
  99. nextcloud_url="https://www.my_nextcloud.net"
  100. trustful_certificate="yes"
  101. users_file=""
  102. date_extension="-%Y-%m-%d"
  103. keep_days_like_time_machine="0"
  104. delete_backups_older_than="0"
  105. compress="yes"
  106. compression_method="tar.gz"
  107. encrypt_backup="no"
  108. gpg_passphrase="1234"
  109. backup_addressbooks="yes"
  110. backup_calendars="yes"
  111. include_shares="no"
  112. snap="no"
  113. fetch_from_database="yes"
  114. one_file_per_component="no"
  115. }
  116. read_config_file() {
  117. # reads calcardbackups config file (if passed with option -c or in case script is run with no option at all or only option -b)
  118. # absolute path to config file:
  119. config_file="$(get_absolute_path_ng "${config_file}")"
  120. config_file_dir="${config_file%/*}"
  121. _output printf '%s\n' "+ Using configuration file ${config_file}, ignoring all other command line options."
  122. load_default_values # if more options have been passed to command: ignore all options except for -b
  123. # check config file for correct syntax (only variable declarations, comments and empty lines allowed)
  124. regex='^([[:space:]]*|[[:space:]]*[a-z_]+="[^"]*"[[:space:]]*)(#.*)?$'
  125. while read -r line; do
  126. [[ ! ${line} =~ ${regex} ]] && error_exit "ERROR: invalid config file! Only variable-declarations, comments and empty lines allowed." "Invalid line: '${line}'" "Edit configuration file and run sript again."
  127. done <"${config_file}"
  128. # config file is readable and has correct syntax. So let's include it now (and overwrite default values):
  129. # shellcheck source=examples/calcardbackup.conf.example
  130. . "${config_file}"
  131. # make sure $nextcloud_path is set - otherwise print error and exit:
  132. [[ -z ${nextcloud_path:-} ]] && error_exit "ERROR: calcardbackup needs path to ownCloud/Nextcloud to be configured." "Check value of variable 'nextcloud_path' in ${config_file}"
  133. # change given paths, if they are relative to $config_file_dir:
  134. [[ ${nextcloud_path} == /* ]] || nextcloud_path="${config_file_dir}/${nextcloud_path}"
  135. if [[ -n ${users_file:-} ]]; then
  136. [[ ${users_file:-} == /* ]] || users_file="${config_file_dir}/${users_file}"
  137. fi
  138. if [[ -n ${backupfolder:-} ]]; then
  139. [[ ${backupfolder:-} == /* ]] || backupfolder="${config_file_dir}/${backupfolder}"
  140. fi
  141. }
  142. preparations() {
  143. # checks for required packages, reads config-file and validates user given values:
  144. _output printf '%s\n' "+ Checking dependencies and preparing..."
  145. # if option -c|--configfile or nothing at all or only -b is given - read config file (and overwrite default values):
  146. [[ -n ${config_file:-} ]] && read_config_file
  147. # if neither configfile nor path to ownCloud/Nextcloud is given - error and exit:
  148. [[ -z ${config_file:-} && -z ${nextcloud_path:-} ]] && error_exit "ERROR: calcardbackup needs path to ownCloud/Nextcloud as first argument!"
  149. # resolve $nextcloud_path to absolute path and check readability of ownCloud/Nextcloud directory
  150. nextcloud_path="$(get_absolute_path_ng "${nextcloud_path}")"
  151. # remove trailing slashes from $nextcloud_url:
  152. nextcloud_url="$(remove_trailing_slashes "${nextcloud_url}")"
  153. # location of ownClouds/Nextclouds config.php:
  154. # take account of env-variables ${NEXTCLOUD_CONFIG_DIR} and ${OWNCLOUD_CONFIG_DIR}
  155. # for ${NEXTCLOUD_CONFIG_DIR} see: https://github.com/nextcloud/server/issues/300
  156. # for ${OWNCLOUD_CONFIG_DIR} see: https://github.com/owncloud/core/pull/27874
  157. if [[ -n ${NEXTCLOUD_CONFIG_DIR:-} && -n ${OWNCLOUD_CONFIG_DIR:-} ]]; then
  158. # if both environment variables are set: error and exit, because this script does not know which one to use:
  159. error_exit "ERROR: both environment variables are set: NEXTCLOUD_CONFIG_DIR and OWNCLOUD_CONFIG_DIR" "Unset one of the two and run script again."
  160. elif [[ -z ${NEXTCLOUD_CONFIG_DIR:-} && -z ${OWNCLOUD_CONFIG_DIR:-} ]]; then
  161. # if both environment variables are empty or unset - use the standard as path to config.php:
  162. configphp="${nextcloud_path}/config/config.php"
  163. else
  164. # if ${NEXTCLOUD_CONFIG_DIR} is not empty, use it as pathname to config directory:
  165. [[ -n ${NEXTCLOUD_CONFIG_DIR:-} ]] && configphp="${NEXTCLOUD_CONFIG_DIR}"
  166. # if ${OWNCLOUD_CONFIG_DIR} is not empty, use it as pathname to config directory:
  167. [[ -n ${OWNCLOUD_CONFIG_DIR:-} ]] && configphp="${OWNCLOUD_CONFIG_DIR}"
  168. # remove trailing slashes from pathname to config directory:
  169. configphp="$(remove_trailing_slashes "${configphp}")"
  170. # add filename (config.php) to pathname:
  171. configphp="${configphp}/config.php"
  172. fi
  173. # check $include_shares for valid value (could only be invalid when using config-file):
  174. if [[ ! ${include_shares} =~ ^(yes|no)$ ]]; then
  175. _output printf '%s\n' "-- WARNING: Parameter 'include_shares' is not valid (only \"yes\" or \"no\" allowed)."
  176. _output printf '%s\n' "-- WARNING: Using default value instead: include_shares=\"no\""
  177. include_shares="no"
  178. fi
  179. # check $fetch_from_database for valid value (could only be invalid when using config-file):
  180. if [[ ! ${fetch_from_database} =~ ^(yes|no)$ ]]; then
  181. _output printf '%s\n' "-- WARNING: Parameter 'fetch_from_database' is not valid (only \"yes\" or \"no\" allowed)."
  182. _output printf '%s\n' "-- WARNING: Using default value instead: fetch_from_database=\"yes\""
  183. fetch_from_database="yes"
  184. fi
  185. # print warning when using deprecated cli option -g|--get-via-http or value fetch_from_database="no" in config file:
  186. if [[ ${fetch_from_database} == "no" ]]; then
  187. [[ -z ${config_file:-} ]] && _output printf '%s\n' "-- WARNING: You are using deprecated option '-g|--get-via-http'" "-- WARNING: For security and performance reasons it is not recommended to use this deprecated option!" "-- WARNING: This option might be removed in a future release of calcardbackup!"
  188. [[ -n ${config_file:-} ]] && _output printf '%s\n' "-- WARNING: You are using deprecated value fetch_from_database=\"no\"" "-- WARNING: For security and performance reasons it is recommended to use the default: fetch_from_database=\"yes\"" "-- WARNING: The possibility to set this value to \"no\" might be removed in a future release of calcardbackup!"
  189. fi
  190. # check for package curl
  191. command -v curl > /dev/null || curl_installed="no"
  192. if [[ ${curl_installed:-} == "no" ]]; then
  193. # cURL is required to get calendars/addressbooks via http(s) --> error and exit, if fetch_from_database="no":
  194. if [[ ${fetch_from_database} == "no" ]]; then
  195. [[ -z ${config_file:-} ]] && error_exit "ERROR: Package 'curl' is needed for running this script with option -g." "Run script again without option '-g' or install cURL with 'apt-get install curl'."
  196. [[ -n ${config_file:-} ]] && error_exit "ERROR: Package 'curl' is needed for running this script with 'fetch_from_database=\"no\"'." "Run script again with 'fetch_from_database=\"yes\"' or install cURL with 'apt-get install curl'."
  197. fi
  198. # for certain server versions the vendor (ownCloud/Nextcloud) can't be detected, if cURL is missing:
  199. _output printf '%s\n' "+ cURL not installed - vendor detection might fail."
  200. else
  201. curl_installed="yes"
  202. # this is only needed, if curl is installed:
  203. # check $trustful_certificate for valid value (could only be invalid when using config-file):
  204. if [[ ! ${trustful_certificate} =~ ^(yes|no)$ ]]; then
  205. _output printf '%s\n' "-- NOTICE: Parameter 'trustful_certificate' is not valid (allowed is only \"yes\" or \"no\")."
  206. _output printf '%s\n' "-- NOTICE: Using default value instead: trustful_certificate=\"yes\""
  207. trustful_certificate="yes"
  208. fi
  209. # assign insecure option for curl (depending on value given by user):
  210. [[ ${trustful_certificate} == "yes" ]] && trust=""
  211. [[ ${trustful_certificate} == "no" ]] && trust="--insecure"
  212. fi
  213. # variable $trustful_certificate is not needed anymore:
  214. unset -v trustful_certificate
  215. # check, if grep is installed on this machine (grep is not a requirement, but speeds up the check, if addressbooks conatin any vCard 2.1)
  216. # assume grep is installed and change to no, if it is not installed:
  217. grep_installed="yes"
  218. command -v grep > /dev/null || grep_installed="no"
  219. # if $users_file is unset or empty and we fetch everything from database - do a complete backup:
  220. if [[ -z ${users_file:-} ]]; then
  221. if [[ ${fetch_from_database} == "yes" ]]; then
  222. _output printf '%s\n' "+ no usersfile given:"
  223. _output printf '%s\n' "+ - will fetch all available items from database"
  224. if [[ ${include_shares} == "yes" ]]; then
  225. _output printf '%s\n' "+ - will not backup shared items additionally"
  226. include_shares="no"
  227. fi
  228. complete_backup_from_database="yes"
  229. elif [[ ${fetch_from_database} == "no" ]]; then
  230. [[ -z ${config_file:-} ]] && error_exit "file with usernames and according passwords must be passed to this script when using option '-g'!"
  231. [[ -n ${config_file:-} ]] && error_exit "file with usernames and according passwords not configured!" "Declare path in configuration file under 'users_file'"
  232. fi
  233. fi
  234. # if $users_file is set: check for file with usernames (+passwords)
  235. if [[ -n ${users_file:-} ]]; then
  236. if [[ ${fetch_from_database} == "yes" ]]; then
  237. # either $users_file is a file, readable and converted to an absolute path OR do a complete backup from database:
  238. users_file="$(get_absolute_path_ng "${users_file}" 2>/dev/null)" || complete_backup_from_database="yes"
  239. if [[ ${complete_backup_from_database:-} == "yes" ]]; then
  240. # file with usernames and passwords is not readable:
  241. _output printf '%s\n' "+ file with usernames not readable:"
  242. _output printf '%s\n' "+ - will fetch all available items from database"
  243. # users_file needs to be unset, because it is not readable:
  244. unset -v users_file
  245. if [[ ${include_shares} == "yes" ]]; then
  246. # don't include shared items when doing a complete backup - will backup everything anyway:
  247. _output printf '%s\n' "+ - will not backup shared items additionally"
  248. include_shares="no"
  249. fi
  250. fi
  251. elif [[ ${fetch_from_database} == "no" ]]; then
  252. # check if users.txt is a file and readable and error and exit if not, because we need that file when not fetching things from database
  253. # (this is also done when converting the path to an absolute path, but check_readable_file stays here because of nicer output):
  254. check_readable_file "${users_file}" "file with usernames and passwords."
  255. # get absolute path to file with usernames and passwords:
  256. users_file="$(get_absolute_path_ng "${users_file}")"
  257. fi
  258. fi
  259. # if $backupfolder is empty or not set: set it to 'backups/' in script's dir
  260. [[ -z ${backupfolder:-} ]] && backupfolder="${script_dir}/backups/"
  261. # create backup folder (needs to be done before resolving to absolute path, because path can't be resolved if not existing):
  262. mkdir -p "${backupfolder}" || error_exit "ERROR: Backupfolder could not be created."
  263. # get absolute path to backupfolder:
  264. backupfolder="$(get_absolute_path_ng "${backupfolder}")"
  265. # check, if I am able to write files into backupfolder (could happen, if backupfolder was existing and was created from a another user):
  266. [[ -w ${backupfolder} ]] || error_exit "ERROR: can't write files into '${backupfolder}'." "I need to be able to have the according permissions for the directory (+wx). Check given path!"
  267. # make sure the configured date format in $date_extension is valid:
  268. if ! date +"${date_extension}" 1>/dev/null 2>&1; then
  269. [[ -z ${config_file:-} ]] && _output printf '%s\n' "-- NOTICE: Argument to option '-d|--date' has no valid format (check 'man date' for valid formats)."
  270. [[ -n ${config_file:-} ]] && _output printf '%s\n' "-- NOTICE: Parameter 'date_extension' has no valid format (check 'man date' for valid formats)."
  271. _output printf '%s\n' "-- NOTICE: Using default value instead: \"-%Y-%m-%d\""
  272. date_extension="-%Y-%m-%d"
  273. fi
  274. # make sure $keep_days_like_time_machine is a positive integer and set it to the default (0) if not:
  275. if [[ ! ${keep_days_like_time_machine:-} =~ ^[0-9]+$ ]]; then
  276. [[ -z ${config_file:-} ]] && _output printf '%s\n' "-- NOTICE: Argument to option '-ltm/--like-time-machine' is not a positive number."
  277. [[ -n ${config_file:-} ]] && _output printf '%s\n' "-- NOTICE: Parameter 'keep_days_like_time_machine' is not a positive number."
  278. _output printf '%s\n' "-- NOTICE: Using default value of 0 instead (keeping everything)."
  279. keep_days_like_time_machine="0"
  280. fi
  281. # make sure $delete_backups_older_than is a positive integer and set it to default (0) if not:
  282. if [[ ! ${delete_backups_older_than:-} =~ ^[0-9]+$ ]]; then
  283. [[ -z ${config_file:-} ]] && _output printf '%s\n' "-- NOTICE: Argument to option '-r/--remove' is not a positive number."
  284. [[ -n ${config_file:-} ]] && _output printf '%s\n' "-- NOTICE: Parameter 'delete_backups_older_than' is not a positive number."
  285. _output printf '%s\n' "-- NOTICE: Using default value of 0 instead (not deleting anything)."
  286. delete_backups_older_than="0"
  287. fi
  288. # check $compress for valid value (could only be invalid when using config-file):
  289. if [[ ! ${compress:-} =~ ^(yes|no)$ ]]; then
  290. _output printf '%s\n' "-- NOTICE: Parameter 'compress' is not valid (only \"yes\" or \"no\" allowed)."
  291. _output printf '%s\n' "-- NOTICE: Using default value instead: compress=\"yes\""
  292. compress="yes"
  293. fi
  294. # check $encrypt_backup for valid value (could only be invalid when using config-file):
  295. if [[ ! ${encrypt_backup:-} =~ ^(yes|no)$ ]]; then
  296. _output printf '%s\n' "-- WARNING: Parameter 'encrypt_backup' is not valid (only \"yes\" or \"no\" allowed)."
  297. _output printf '%s\n' "-- WARNING: Using default value instead: encrypt_backup=\"no\""
  298. encrypt_backup="no"
  299. elif [[ ${encrypt_backup:-} == "yes" ]]; then
  300. # check for package gpg (if installed, prefer gpg2 over gpg)::
  301. command -v gpg > /dev/null && gpgcommand="gpg"
  302. command -v gpg2 > /dev/null && gpgcommand="gpg2"
  303. [[ -z ${gpgcommand:-} ]] && error_exit "ERROR: You chose to encrypt your backup." "Therefore package 'gnupg' is needed. Install with 'apt-get install gnupg' and run script again"
  304. if [[ ${compress:-} == "no" ]]; then
  305. _output printf '%s\n' "-- WARNING: Can't encrypt uncompressed backup."
  306. _output printf '%s\n' "-- WARNING: Changing from uncompress to compress backup"
  307. compress="yes"
  308. fi
  309. if [[ -z ${config_file:-} ]]; then
  310. # get passphrase from on commandline given file:
  311. check_readable_file "${passphrase_file}" "file with passphrase."
  312. # read first line from on commandline given file and use that as passphrase:
  313. read -r gpg_passphrase <"${passphrase_file}"
  314. # variable $passphrase_file is not needed anymore:
  315. unset -v passphrase_file
  316. fi
  317. # error and exit, if backup shall be encrypted, but passphrase is on example value (1234) or empty:
  318. if [[ ${gpg_passphrase:-} =~ ^(1234|)$ ]]; then
  319. [[ -z ${config_file:-} ]] && error_exit "ERROR: Passphrase is on insecure example value (1234)." "Change passphrase in file with passphrase and run sript again."
  320. [[ -n ${config_file:-} ]] && error_exit "ERROR: Passphrase is on insecure example value (1234)." "Change 'gpg_passphrase' in configuration file and run sript again."
  321. fi
  322. fi
  323. # check value $compression_method (this needs to be done after encryption-check, because config may have changed
  324. # there from compress="no" to compress="yes"):
  325. if [[ ${compression_method:-} == "zip" && ${compress} == "yes" ]]; then
  326. # check for package zip:
  327. command -v zip > /dev/null || error_exit "ERROR: You chose to compress backed up files with 'zip' instead of 'tar.gz'." "Therefore package 'zip' is needed. Install with 'apt-get install zip' and run script again"
  328. elif [[ ${compression_method:-} != "tar.gz" && "${compress}" == "yes" ]]; then
  329. # if $compression_method is set to something else than "zip" or "tar.gz" - use "tar.gz":
  330. _output printf '%s\n' "-- NOTICE: Parameter 'compression_method' is set to unsupported format. Using default value 'tar.gz'."
  331. compression_method="tar.gz"
  332. fi
  333. # check $snap for valid value (could only be invalid when using config-file):
  334. if [[ ! ${snap:-} =~ ^(yes|no)$ ]]; then
  335. _output printf '%s\n' "-- WARNING: Parameter 'snap' is not valid (only \"yes\" or \"no\" allowed)."
  336. _output printf '%s\n' "-- WARNING: Using default value instead: snap=\"no\""
  337. snap="no"
  338. fi
  339. # make sure nextcloud.mysql-client is available when configured to use nextcloud-snap:
  340. if [[ ${snap} == "yes" ]]; then
  341. command -v nextcloud.mysql-client > /dev/null || error_exit "ERROR: nextcloud-snaps cli-utility 'nextcloud.mysql-client' not found!" "To work with nextcloud-snap this script needs to be invoked with sudo (even when root)." "If the script has been invoked with sudo: are you sure you are using nextcloud-snap?"
  342. fi
  343. # check $one_file_per_component for valid value (could only be invalid when using config-file):
  344. if [[ ! ${one_file_per_component:-} =~ ^(yes|no)$ ]]; then
  345. _output printf '%s\n' "-- NOTICE: Parameter 'one_file_per_component' is not valid (only \"yes\" or \"no\" allowed)."
  346. _output printf '%s\n' "-- NOTICE: Using default value instead: one_file_per_component=\"no\""
  347. one_file_per_component="no"
  348. fi
  349. # option -one|--one-file-per-component is only possible when fetching things from database:
  350. if [[ ${one_file_per_component:-} == "yes" && "${fetch_from_database:-}" == "no" ]]; then
  351. [[ -z ${config_file:-} ]] && _output printf '%s\n' "-- NOTICE: ignoring option '-one|--one-file-per-component', because getting files via http(s)!"
  352. [[ -n ${config_file:-} ]] && _output printf '%s\n' "-- NOTICE: ignoring parameter 'one_file_per_component', because getting files via http(s)!"
  353. one_file_per_component="no"
  354. fi
  355. }
  356. getvalue_from_configphp() {
  357. # reads a line with a given paramter from config.php and returns the parameters value
  358. # arguments: ${1} is parameter which value should be returned
  359. local regex line
  360. # Notice: multi-line comments (/* ... */) are not recognized, if no marker at beginning of line. The first occurence of ${1} will be returned.
  361. # This might be a problem: because wrong value might be returned, if value is also present in a multi-line comment before the actual declaration in config.php!
  362. regex="^[[:space:]]*'${1}'.*"
  363. while read -r line; do
  364. [[ ${line} =~ ${regex} ]] && break
  365. line=""
  366. done <"${configphp}"
  367. # if parameter was found ($line is not empty) - manipulate $line to only return value and nothing else:
  368. if [[ ${line:-} != "" ]]; then
  369. line=${line%\'*} # throw away end including last '
  370. line=${line##*\'} # throw away beginning including last '
  371. else
  372. # if parameter was not found in config.php: set line to dummy value, which may be tested against:
  373. line="value is unset"
  374. fi
  375. printf '%s\n' "${line}"
  376. }
  377. is_trusted_domain() {
  378. # checks, if to this function passed URL is present in trusted_domains in config.php
  379. # arguments: ${1} is URL which needs to be checked:
  380. local given_domain regex looping_trusted_domains line result
  381. # get domain from passed URL:
  382. given_domain="${1#*//}" # cut http(s):// from the beginning of URL
  383. given_domain="${given_domain%%/*}" # cut eventuelly existing paths from end of URL
  384. # look for trusted_domains:
  385. regex="^[[:space:]]*'trusted_domains' =>.*"
  386. looping_trusted_domains=0
  387. while read -r line; do
  388. case ${looping_trusted_domains} in
  389. 0)
  390. # continue with next line, if line is not declaration of trusted_domains array:
  391. [[ ! ${line} =~ ${regex} ]] && continue
  392. # line is declaration of trusted_domains array
  393. # make sure to go to next step on next run:
  394. looping_trusted_domains=1
  395. # change regex to catch end of trusted_domains:
  396. regex='\),'
  397. continue
  398. ;;
  399. 1)
  400. # don’t loop any further, if regex matches end of trusted_domains array:
  401. [[ ${line} =~ ${regex} ]] && break
  402. line="${line%\'*}" # throw away end including last '
  403. line="${line#*\'}" # throw away beginning including first '
  404. if [[ ${line} == "${given_domain}" ]]; then
  405. # set result to true, if passed URL matches this trusted domain:
  406. result="true"
  407. break # passed URL is a trusted_domain, so no need to loop any further
  408. fi
  409. continue
  410. ;;
  411. esac
  412. done <"${configphp}"
  413. # return "true", if passed URL is a trusted_domain, otherwise return "false":
  414. printf '%s\n' "${result:-false}"
  415. }
  416. read_config_php() {
  417. # reads config.php and assigns different paramters we need to know
  418. # check whether Nextclouds config-file is readable:
  419. check_readable_file "${configphp}" "configuration file of your Own-/Nextcloud installation."
  420. # get dbtableprefix for prefix string of table names:
  421. dbtableprefix="$(getvalue_from_configphp "dbtableprefix")"
  422. # set default dbtableprefix if unset in config.php:
  423. [[ ${dbtableprefix:-} == "value is unset" ]] && dbtableprefix="oc_"
  424. # get version of ownCloud/Nextcloud and configure correct dav-endpoint:
  425. version_config_php="$(getvalue_from_configphp "version")"
  426. if [[ ${version_config_php} == "value is unset" ]]; then
  427. # version not found in config.php: treat as a version >= 9.0 and print info.
  428. # The exact version doesn't matter, because all versions >= 9.0 behave the same regarding calendar+addressbook data
  429. # (as well as all versions <= 8.2 behave the same regarding calendar+addressbook data)
  430. # version 9.1.0 has the advantage that calcardbackup then can't find out, whether it is ownCloud/Nextcloud, if curl is not working:
  431. _output printf '%s\n' "-- INFO: version information not found in config.php - treating as ownCloud/Nextcloud >= 9.0" "-- INFO: if you are using ownCloud <= 8.2, you need to give version information in config.php"
  432. version_config_php="9.1.0"
  433. fi
  434. mainversion=${version_config_php%%.*} # $mainversion is only first number
  435. minimumversion="5"
  436. davchangeversion="9"
  437. # error and exit, if $mainversion < 5 (because ownCloud < 5.0 is not supported by calcardbackup):
  438. [[ ${mainversion} -lt "${minimumversion}" ]] && error_exit "ERROR: This script only works with versions >= 5.0." "You are using ownCloud ${version_config_php}."
  439. if [[ ${mainversion} -ge "${davchangeversion}" ]]; then
  440. # Values for ownCloud/Nextcloud >= 9.0:
  441. caldav="dav"
  442. carddav="dav"
  443. table_calendars="${dbtableprefix}calendars"
  444. table_addressbooks="${dbtableprefix}addressbooks"
  445. row_principaluri="principaluri"
  446. if [[ ${include_shares} == "yes" ]]; then
  447. table_shares="${dbtableprefix}dav_shares"
  448. row_share_principaluri="principaluri"
  449. row_share_type="type"
  450. row_share_resourceid="resourceid"
  451. fi
  452. if [[ ${fetch_from_database} == "yes" ]]; then
  453. table_cards="${dbtableprefix}cards"
  454. table_calendarobjects="${dbtableprefix}calendarobjects"
  455. fi
  456. # next line is only needed for addressbook-export via http(s) from ownCloud/Nextcloud >= 9.0:
  457. extra_users="users/"
  458. else
  459. # Values for ownCloud < 9.0:
  460. caldav="caldav"
  461. carddav="carddav"
  462. table_calendars="${dbtableprefix}clndr_calendars"
  463. table_addressbooks="${dbtableprefix}contacts_addressbooks"
  464. row_principaluri="userid"
  465. if [[ ${include_shares} == "yes" ]]; then
  466. table_shares="${dbtableprefix}share"
  467. row_share_principaluri="share_with"
  468. row_share_type="item_type"
  469. row_share_type_group="share_type" # needed for ownCloud < 9.0 to identify, if shared with group (and not with user)
  470. row_share_resourceid="item_source"
  471. fi
  472. if [[ ${fetch_from_database} == "yes" ]]; then
  473. # for some unknown reason shellcheck complains that the next two variables appear unused (but they are used later on!):
  474. # shellcheck disable=SC2034
  475. table_cards="${dbtableprefix}contacts_cards"
  476. # shellcheck disable=SC2034
  477. table_calendarobjects="${dbtableprefix}clndr_objects"
  478. fi
  479. # must be empty to get addressbooks via http(s) for ownCloud < 9.0:
  480. extra_users=""
  481. fi
  482. # those values are the same for ownCloud < 9.0 and ownCloud/Nextcloud >= 9.0
  483. row_id="id"
  484. row_uri="uri"
  485. row_displayname="displayname"
  486. row_calendarcolor="calendarcolor"
  487. table_group_user="${dbtableprefix}group_user"
  488. # for calendarsubscriptions:
  489. table_calendarsubscriptions="${dbtableprefix}calendarsubscriptions"
  490. row_source="source"
  491. # get ownClouds/Nextclouds URL from config.php (overwrite.cli.url), compare to user given url ($nextcloud_url) and notice/error, if something is not as expected:
  492. local nextcloud_url_default="https://www.my_nextcloud.net"
  493. nextcloud_url_overwrite="$(getvalue_from_configphp "overwrite.cli.url")"
  494. # remove trailing slashes from $nextcloud_url_overwrite:
  495. nextcloud_url_overwrite="$(remove_trailing_slashes "${nextcloud_url_overwrite}")"
  496. # nextcloud_url and nextcloud_url_overwrite are both set and they are different:
  497. if [[ ${nextcloud_url} != "${nextcloud_url_default}" && ${nextcloud_url} != "${nextcloud_url_overwrite}" && ${nextcloud_url_overwrite} != "value is unset" ]]; then
  498. # use given URL but print notice, if $nextcloud_url has been configured to something other than default or overwrite.cli.url:
  499. # (last conditional expression in if statement above: make sure overwrite.cli.url is present and not empty):
  500. _output printf '%s\n' "-- NOTICE: Configured URL differs from 'overwrite.cli.url' in config.php:"
  501. _output printf '%s\n' "-- '${nextcloud_url_overwrite}/' ==> detected in ${configphp}"
  502. _output printf '%s' "-- '${nextcloud_url}/' ==> "
  503. [[ -z ${config_file:-} ]] && _output printf '%s\n' "given with option -a|--address"
  504. [[ -n ${config_file:-} ]] && _output printf '%s\n' "found in config file ${config_file}"
  505. # test, if given URL is in trusted_domains array in config.php:
  506. if [[ $(is_trusted_domain "${nextcloud_url}") == "true" ]]; then
  507. _output printf '%s\n' "-- ==> using second one, because found in 'trusted_domains' (config.php)."
  508. else
  509. nextcloud_url="${nextcloud_url#*//}"
  510. nextcloud_url="${nextcloud_url%%/*}"
  511. _output printf '%s\n' "-- ==> using first one, because '${nextcloud_url}' does not belong to 'trusted_domains' (config.php)"
  512. # use overwrite.cli.url as URL, because given URL is not a trusted domain:
  513. nextcloud_url="${nextcloud_url_overwrite}"
  514. fi
  515. # nextcloud_url and nextcloud_url_overwrite are both unset:
  516. elif [[ ${nextcloud_url} == "${nextcloud_url_default}" && "${nextcloud_url_overwrite}" == "value is unset" ]]; then
  517. if [[ ${fetch_from_database} == "no" ]]; then
  518. # we want do download addressbooks/calendars - therefore the URL to ownCloud/Nextcloud server is required.
  519. # Error, if $nextcloud_url is on default value and config.php contains no overwrite.cli.url (or is empty)
  520. [[ -z ${config_file:-} ]] && error_exit "ERROR: Can't retrieve url from ${configphp}" "Either don't use option '-g' or use option '-a' to pass URL to the script."
  521. [[ -n ${config_file:-} ]] && error_exit "ERROR: Can't retrieve url from ${configphp}" "Either use 'fetch_from_database=\"yes\"' or give URL to 'nextcloud_url' in config file."
  522. else
  523. # if we fetch everything from database, we do not need the url of the ownCloud/Nextcloud server,
  524. # so avoid requests for ownCloud/Nextclouds in this case unknown URL with curl_installed="no"
  525. _output printf '%s\n' "+ Can't retrieve url from ${configphp}"
  526. _output printf '%s\n' "+ Continuing anyway, because fetching everything from database."
  527. # For this scenario this variable name is a bit misleading, but the script needs to act as if curl wasn't installed:
  528. curl_installed="no"
  529. fi
  530. # nextcloud_url is set and nextcloud_url_overwrite is unset:
  531. elif [[ ${nextcloud_url} != "${nextcloud_url_default}" && ${nextcloud_url_overwrite} == "value is unset" ]]; then
  532. # if overwrite.cli.uri is unset and a domain has been passed to the script:
  533. # check, if passed URL belongs to a trusted_domain in config.php:
  534. if [[ $(is_trusted_domain "${nextcloud_url}") == "false" ]]; then
  535. if [[ ${fetch_from_database} == "no" ]]; then
  536. # we want to download addressbooks/calendars - therefore a trusted domain URL to ownCloud/Nextcloud server is required.
  537. # Error, if $nextcloud_url is configured, but not in trusted domains:
  538. [[ -z ${config_file:-} ]] && error_exit "ERROR: configured URL '${nextcloud_url}' does not belong to 'trusted_domains' (config.php)!" "Check URL passed with option -a to this script or don't use option '-g'."
  539. [[ -n ${config_file:-} ]] && error_exit "ERROR: configured URL '${nextcloud_url}' does not belong to 'trusted_domains' (config.php)!" "Check value 'nextcloud_url' or use 'fetch_from_database=\"yes\"'."
  540. fi
  541. # print notice, if given URL is not in trusted_domains array in config.php:
  542. _output printf '%s\n' "-- NOTICE: given URL '${nextcloud_url}' does not belong to 'trusted_domains' (config.php)"
  543. _output printf '%s\n' "+ Continuing without given URL, because fetching everything from database."
  544. # For this scenario this variable name is a bit misleading, but the script needs to act as if curl wasn't installed:
  545. curl_installed="no"
  546. fi
  547. # nextcloud_url is unset and nextcloud_url_overwrite is set:
  548. elif [[ ${nextcloud_url} == "${nextcloud_url_default}" && ${nextcloud_url_overwrite} != "value is unset" ]]; then
  549. # if overwrite.cli.url is found (and $nextcloud_url is on default value): use it as $nextcloud_url
  550. nextcloud_url="${nextcloud_url_overwrite}"
  551. fi
  552. # Print url which will be used only, if curl is installed (use || instead of && to not return false!):
  553. [[ ${curl_installed} == "no" ]] || _output printf '%s\n' "+ Using URL: ${nextcloud_url}"
  554. }
  555. read_status_php() {
  556. # checks for installation of ownCloud/Nextcloud by requesting $nextcloud_url/status.php and
  557. # checks, if version number of $nextcloud_url/status.php correlates with version number of config.php
  558. # declare local variables:
  559. local status_php regex line
  560. # store output of status.php in variable OR store curls exit code if it returns an empty string (which is in case of an error):
  561. status_php="$(curl -s ${trust} "${nextcloud_url}/status.php")" || cerror=$?
  562. # Output of status.php of the different ownCloud/Nextcloud versions:
  563. # ownCloud 5.0.9 {"installed":"true","version":"5.0.38","versionstring":"5.0.19","edition":""}
  564. # ownCloud 6.0.9 {"installed":"true","version":"6.0.9.2","versionstring":"6.0.9","edition":""}
  565. # ownCloud 7.0.15 {"installed":"true","version":"7.0.15.2","versionstring":"7.0.15","edition":""}
  566. # ownCloud 8.0.16 {"installed":true,"maintenance":false,"version":"8.0.16.3","versionstring":"8.0.16","edition":""}
  567. # ownCloud 8.1.12 {"installed":true,"maintenance":false,"version":"8.1.12.2","versionstring":"8.1.12","edition":""}
  568. # ownCloud 8.2.10 {"installed":true,"maintenance":false,"version":"8.2.10.2","versionstring":"8.2.10","edition":""}
  569. # ownCloud 9.0.8 {"installed":true,"maintenance":false,"version":"9.0.8.2","versionstring":"9.0.8","edition":""}
  570. # ownCloud 9.1.4 {"installed":true,"maintenance":false,"version":"9.1.4.2","versionstring":"9.1.4","edition":""}
  571. # ownCloud 10.0.0 {"installed":"true","maintenance":"false","needsDbUpgrade":"false","version":"10.0.0.12","versionstring":"10.0.0","edition":"Community","productname":"ownCloud"}
  572. # options in config.php introduced with ownCloud 10.0.0/10.0.6 generate a different status.php output:
  573. # version.hide {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"","versionstring":"","edition":"","productname":""}
  574. # show_server_hostname {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"10.0.6.1","versionstring":"10.0.6","edition":"Community","productname":"ownCloud","hostname":"Hostname"}
  575. # ownCloud 10.1.0 {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"10.1.0.4","versionstring":"10.1.0","edition":"Community","productname":"ownCloud"}
  576. # Nextcloud 9.0.57 {"installed":true,"maintenance":false,"version":"9.0.57.2","versionstring":"9.0.57","edition":""}
  577. # Nextcloud 10.0.4 {"installed":true,"maintenance":false,"version":"9.1.4.2","versionstring":"10.0.4","edition":""}
  578. # Nextcloud 11.0.2 {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"11.0.2.7","versionstring":"11.0.2","edition":"","productname":"Nextcloud"}
  579. # Nextcloud 12.0.0 {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"12.0.0.29","versionstring":"12.0.0","edition":"","productname":"Nextcloud"}
  580. # Nextcloud 13.0.0 {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"13.0.0.14","versionstring":"13.0.0","edition":"","productname":"Nextcloud"}
  581. # Nextcloud 14.0.0 {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"14.0.0.19","versionstring":"14.0.0","edition":"","productname":"Nextcloud"}
  582. # Nextcloud 15.0.0 {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"15.0.0.10","versionstring":"15.0.0","edition":"","productname":"Nextcloud"}
  583. # Nextcloud 16.0.0 {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"16.0.0.9","versionstring":"16.0.0","edition":"","productname":"Nextcloud"}
  584. # Nextcloud 17.0.0 {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"17.0.0.9","versionstring":"17.0.0","edition":"","productname":"Nextcloud","extendedSupport":false}
  585. # Nextcloud 18.0.0 {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"18.0.0.10","versionstring":"18.0.0","edition":"","productname":"Nextcloud","extendedSupport":false}
  586. # regex matching versions mentioned above (tested with regex101.com):
  587. # ownCloud 10 changes: version(string) maybe hidden or hostname maybe present (see version.hide, show_server_hostname in config.php)
  588. # Nextcloud 17 adds parameter "extendedSupport" (see https://github.com/nextcloud/server/pull/15922)
  589. regex='^\{"installed":"?(true|false)"?,("maintenance":"?(true|false)"?,("needsDbUpgrade":"?(true|false)"?,)?)?"version":"[^"]*","versionstring":"[^"]*","edition":"[^"]*"(,"productname":"[^"]*")?(,"hostname":"[^"]*")?(,"extendedSupport":(true|false))?\}$'
  590. # check status.php for valid installation of ownCloud/Nextcloud:
  591. case ${fetch_from_database} in
  592. yes )
  593. if [[ ! ${status_php} =~ ${regex} || -n ${cerror:-} ]]; then
  594. # if we fetch everything from database, this script can run anyway, even if ownCloud/Nextcloud is not online,
  595. # in this case no further requests to ownCloud/Nextclouds unusable URL with curl_installed="no":
  596. [[ -n ${cerror:-} ]] && _output printf '%s\n' "+ can't read status.php (cURL code '${cerror}')."
  597. [[ -z ${cerror:-} ]] && _output printf '%s\n' "+ no valid status.php found at ${nextcloud_url}."
  598. # For this scenario this variable name is a bit misleading, but the script needs to act as if curl wasn't installed:
  599. curl_installed="no"
  600. fi
  601. ;;
  602. no )
  603. # perform some additional checks, because we are going to get calendars/addressbooks via https(s) from the server:
  604. if [[ ! ${status_php} =~ ${regex} || -n ${cerror:-} ]]; then
  605. # if error code is set and we don't fetch things from database, call function to examine
  606. # curls exit code and print according message to stderr:
  607. [[ -n ${cerror:-} ]] && curl_error
  608. if [[ -z ${cerror:-} ]]; then
  609. # if no valid status.php was found, print according message to stderr and exit:
  610. [[ -z ${config_file:-} ]] && error_exit "ERROR: No ownCloud/Nextcloud Installation found at ${nextcloud_url} !" "Try to run script again without using option '-g|--get-via-http'"
  611. [[ -n ${config_file:-} ]] && error_exit "ERROR: No ownCloud/Nextcloud Installation found at ${nextcloud_url} !" "Try to run script again using 'fetch_from_database=\"yes\"' in config file."
  612. fi
  613. fi
  614. # get version from status.php:
  615. version_status_php=${status_php#*version\":\"}
  616. version_status_php=${version_status_php%%\"*}
  617. # get versionstring from status_php (needed for proper versioning (ownloud 5.0 has in version also only 3 numbers)):
  618. versionstring_status_php=${status_php#*versionstring\":\"}
  619. versionstring_status_php=${versionstring_status_php%%\"*}
  620. # check for missing version in status.php due to option in config.php introduced with ownCloud 10.0 ('version.hide' => true,):
  621. [[ -z ${version_status_php:-} && -z ${versionstring_status_php:-} ]] && {
  622. # print notice that version number is hidden (empty):
  623. _output printf '%s\n' "-- NOTICE: version number hidden in status.php."
  624. }
  625. # check for "needsDbUpgrade":true in status.php and exit if ownCloud/Nextcloud needs a DbUpgrade:
  626. regex='"needsDbUpgrade":"?true"?'
  627. if [[ ${status_php} =~ ${regex} ]]; then
  628. [[ -z ${config_file:-} ]] && error_exit "ERROR: ${nextcloud_url}/status.php reports \"needsDbUpgrade\":true" "You need to upgrade your Installation before running this script again!" "Or run script again without option '-g|--get-via-http'"
  629. [[ -n ${config_file:-} ]] && error_exit "ERROR: ${nextcloud_url}/status.php reports \"needsDbUpgrade\":true" "You need to upgrade your Installation before running this script again!" "Or run script again with the default value 'fetch-from-database=\"yes\"' in config file '${config_file}'."
  630. fi
  631. # check whether version of config.php is the same than version of status.php:
  632. if [[ ${version_status_php} != "${version_config_php}" ]]; then
  633. if [[ -n ${version_status_php:-} && -n "${versionstring_status_php:-}" ]]; then
  634. [[ -z ${config_file:-} ]] && error_exit "ERROR: different versions detected:" "Version ${versionstring_status_php} found at ${nextcloud_url}" "Version ${version_config_php} found at ${nextcloud_path}" "check given path and given url (option -a)"
  635. [[ -n ${config_file:-} ]] && error_exit "ERROR: different versions detected:" "Version ${versionstring_status_php} found at ${nextcloud_url}" "Version ${version_config_php} found at ${nextcloud_path}" "check configured path and url in config file '${config_file}'"
  636. fi
  637. fi
  638. # check for installed=true in status.php and exit if ownCloud/Nextcloud report installed:false:
  639. regex='"installed":"?true"?'
  640. [[ ${status_php} =~ ${regex} ]] || error_exit "ERROR: ${nextcloud_url}/status.php does not contain \"installed\":\"true\"!" "You need to check your Installation!"
  641. # check for maintenance=false for versions >= 8.0 in status.php and exit if ownCloud/Nextcloud in maintenance mode:
  642. if [[ ${mainversion} -ge 8 ]]; then
  643. regex='"maintenance":"?true"?'
  644. if [[ ${status_php} =~ ${regex} ]]; then
  645. [[ -z ${config_file:-} ]] && error_exit "ERROR: Installation is in maintenance mode!" "Disable maintenance mode with command \"sudo -u www-data php ${nextcloud_path}/occ maintenance:mode --off\" and run script again" "Another possibility is to not use option '-g|--get-via-http'."
  646. [[ -n ${config_file:-} ]] && error_exit "ERROR: Installation is in maintenance mode!" "Disable maintenance mode with command \"sudo -u www-data php ${nextcloud_path}/occ maintenance:mode --off\" and run script again" "More recommended is to set the default 'fetch_from_database=\"yes\"' in the configuration file."
  647. fi
  648. fi
  649. ;;
  650. esac
  651. }
  652. detect_vendor() {
  653. # vendor detection (detect whether installation is Nextcloud or ownCloud):
  654. local full_version server_version
  655. # set productname to dummy value, will be changed later in this function (if vendor can be detected):
  656. productname="Server"
  657. [[ ${fetch_from_database} == "yes" ]] && full_version="${version_config_php}"
  658. [[ ${fetch_from_database} == "no" ]] && full_version="${version_status_php}"
  659. # find out major, minor and patch version numbers and assign server_version (major.minor.patch):
  660. main_version="${full_version%%.*}" # first field is mainversion (overwritten, because better take version from status.php for option -g)
  661. minor_version="${full_version#*.}" # cut first number (first number is no minor version)
  662. patch_version="${minor_version#*.}" # cut first number again (first number is no patch version)
  663. patch_version="${patch_version%%.*}" # cut everything from the end to only have patch versionnumber in $patchversion
  664. minor_version="${minor_version%%.*}" # cut everything from the end to only have minor versionnumber in $minorversion
  665. server_version="${main_version}.${minor_version}.${patch_version}"
  666. # only for owncloud 5.0 use versionstring from status.php (if possible), because versionstring uses "official" patch version number
  667. # (for ownCloud >= 6.0 patch version number is the same in version and versionstring):
  668. [[ ${fetch_from_database} == "no" && "${main_version}" == "5" ]] && server_version="${versionstring_status_php}"
  669. # there is no Nextcloud < 9.0:
  670. [[ ${main_version} -lt 9 ]] && productname="ownCloud"
  671. # there is no ownCloud > 10 yet (needs to be changed once ownCloud 11.0 is released):
  672. [[ ${main_version} -gt 10 ]] && productname="Nextcloud"
  673. # only ownCloud >= 10.0 is able to hide server version in status.php (version.hide in config.php)
  674. if [[ ${fetch_from_database} == "no" && -z "${full_version:-}" ]]; then
  675. productname="ownCloud"
  676. server_version="(hidden version)"
  677. fi
  678. # try to detect vendor from user documentation (only if curl is installed):
  679. if [[ ${productname} == "Server" && "${curl_installed}" == "yes" ]]; then
  680. regex='([Nn]extcloud|own[Cc]loud)'
  681. line=""
  682. while read -r line; do
  683. [[ ${line} =~ ${regex} ]] && break
  684. line=""
  685. done <<<"$(curl -s ${trust} "${nextcloud_url}/core/doc/user/index.html")"
  686. if [[ ${line} =~ [Nn]extcloud ]]; then
  687. productname="Nextcloud"
  688. # set server_version for Nextcloud 10 (reports itself as version 9.1):
  689. if [[ "${main_version}.${minor_version}" == "9.1" ]]; then
  690. main_version="10"
  691. minor_version="0"
  692. server_version="${main_version}.${minor_version}.${patch_version}"
  693. fi
  694. elif [[ ${line} =~ own[Cc]loud ]]; then
  695. productname="ownCloud"
  696. fi
  697. fi
  698. # if vendor still could not be detected (e.g. missing curl), try to detect vendor through 'version' in config.php/status.php:
  699. if [[ ${productname} == "Server" ]]; then
  700. case "${main_version}.${minor_version}" in
  701. 9.0 )
  702. # Nextcloud 9.0 has a patch version equal or greater than 50:
  703. [[ ${patch_version} -lt 50 ]] && productname="ownCloud"
  704. [[ ${patch_version} -ge 50 ]] && productname="Nextcloud"
  705. ;;
  706. 9.1 )
  707. if [[ ${fetch_from_database} == "yes" ]]; then
  708. if [[ ${patch_version} -gt 6 ]]; then
  709. # Nextcloud 10.0.6 (reports itself as 9.1.6 in config.php) is the latest version of Nextcloud 10.0, so this must be ownCloud:
  710. productname="ownCloud"
  711. else
  712. # Nextcloud 10.0s version in config.php is 9.1.X, as well as ownCloud 9.1 - can't detect vendor then:
  713. _output printf '%s\n' "+ can't detect vendor"
  714. # use $fullversion for output
  715. server_version="${full_version}"
  716. fi
  717. else
  718. # running with option -g|--get-via-http - check versionstring from status.php:
  719. if [[ ${versionstring_status_php%%.*} == "10" ]]; then
  720. # Nextcloud 10.0 reports itself as 9.1 in version and as 10.0 in versionstring from status.php:
  721. productname="Nextcloud"
  722. # use versionstring from status.php as version information (will be 10.0.X for Nextcloud 10.0):
  723. server_version="${versionstring_status_php}"
  724. elif [[ ${versionstring_status_php%%.*} == "9" ]]; then
  725. # if both (version and versionstring) from status.php are beginning with 9 then this is ownCloud:
  726. productname="ownCloud"
  727. fi
  728. fi
  729. ;;
  730. 10* )
  731. # there is no Nextcloud 10.0 (in version from config.php/status.php, because Nextcloud 10.0 reports as Nextcloud 9.1.x):
  732. productname="ownCloud"
  733. ;;
  734. * )
  735. # vendor can't be detected (productname is already set to generic value "Server"):
  736. _output printf '%s\n' "+ can't detect vendor."
  737. # use $fullversion for output
  738. server_version="${full_version}"
  739. ;;
  740. esac
  741. fi
  742. _output printf '%s\n' "+ ${productname} ${server_version} detected."
  743. }
  744. get_database_details() {
  745. # reads database configuration from config.php and assigns variable $dbcommand which is used to issue database queries later on
  746. # read type of database being used (SQLite or MySQL/MariaDB or PostgreSQL)
  747. dbtype="$(getvalue_from_configphp "dbtype")"
  748. [[ ${dbtype} == "value is unset" ]] && dbtype="sqlite3" # use default if unset in config.php
  749. case ${dbtype} in
  750. mysql|pgsql)
  751. dbname="$(getvalue_from_configphp "dbname")"
  752. dbhost="$(getvalue_from_configphp "dbhost")"
  753. # use localhost as dbhost if unset in config.php
  754. # though (as per documentation) there is no default for dbhost, chances are quite high, that the database listens there:
  755. [[ ${dbhost} == "value is unset" ]] && dbhost="localhost"
  756. # check whether dbhost contains socket or portnumber and store it for mysql/psql commands:
  757. if [[ ${dbhost} =~ ^.+:/.+$ ]]; then
  758. # dbhost contains socket after hostname (e.g. 'localhost:/var/run/mysqld/mysqld.sock'):
  759. [[ ${dbtype} == "mysql" ]] && {
  760. dbprotocol="socket = ${dbhost#*:}"
  761. dbhost="${dbhost%%:*}"
  762. }
  763. [[ ${dbtype} == "pgsql" ]] && {
  764. # for postgresql just use socket as hostname:
  765. dbprotocol=""
  766. dbhost="--host=${dbhost#*:}"
  767. }
  768. elif [[ ${dbhost} =~ ^.+:[[:digit:]]+$ ]]; then
  769. # dbhost contains portnumber after hostname (e.g. '127.0.0.1:3306'):
  770. [[ ${dbtype} == "mysql" ]] && {
  771. # split up $dbhost into port and host for MySQL/MariaDB:
  772. dbprotocol="port = ${dbhost#*:}"
  773. dbhost="${dbhost%%:*}"
  774. }
  775. [[ ${dbtype} == "pgsql" ]] && {
  776. # split up $dbhost into port and host for PostgreSQL:
  777. dbprotocol="--port=${dbhost#*:}"
  778. dbhost="--host=${dbhost%%:*}"
  779. }
  780. else
  781. # dbhost contains only hostname - use empty value to tell mysql to use default value:
  782. dbprotocol=""
  783. # make sure to also pass --host assignment to psql, if dbhost contains neither socket nor port:
  784. [[ ${dbtype} == "pgsql" ]] && dbhost="--host=${dbhost}"
  785. fi
  786. # retrieve username and password for database access from config.php:
  787. dbuser="$(getvalue_from_configphp "dbuser")"
  788. dbpassword="$(getvalue_from_configphp "dbpassword")"
  789. # create command $dbcommand for database queries depending on database found in config.php:
  790. [[ ${dbtype} == "mysql" ]] && {
  791. if [[ ${snap} == "no" ]]; then
  792. # check if mysql|mariadb command line client is installed
  793. # ( no need to check for nextcloud-snaps included cli-utility 'nextcloud.mysql-client', because already checked in function preparations() ):
  794. command -v mysql > /dev/null || error_exit "ERROR: ${productname} database is MySQL/MariaDB. Therefore this script requires package 'mysql-client' or 'mariadb-client'." "Install according package and run script again"
  795. fi
  796. # set database command for MySQL:
  797. # mysql>=5.6 throws a warning when using password on command line interface: https://bugs.mysql.com/bug.php?id=66546
  798. # avoiding that with option --defaults-extra-file and printf as suggested by Dave James Miller: https://stackoverflow.com/questions/20751352/suppress-warning-messages-using-mysql-from-within-terminal-but-password-written/20854048#comment42372603_22933056
  799. # ATTENTION: variable $dbcommand will be passed to command "eval" later. So better take care when adding new stuff to $dbcommand!
  800. case ${snap} in
  801. no )
  802. # shellcheck disable=SC2016
  803. dbcommand='mysql --defaults-extra-file=<(printf "[client]\nuser = %s\npassword = \"%s\"\nhost = %s\n%s" "${dbuser}" "${dbpassword}" "${dbhost}" "${dbprotocol}") "${dbname}" -sre '
  804. ;;
  805. yes )
  806. # shellcheck disable=SC2016
  807. dbcommand='nextcloud.mysql-client --defaults-extra-file=<(printf "[client]\nuser = %s\npassword = \"%s\"\nhost = %s\n%s" "${dbuser}" "${dbpassword}" "${dbhost}" "${dbprotocol}") "${dbname}" -sre '
  808. ;;
  809. esac
  810. # store type of database for output of script:
  811. database="MySQL/MariaDB"
  812. }
  813. [[ ${dbtype} == "pgsql" ]] && {
  814. # check if postgresql command line client is installed:
  815. command -v psql > /dev/null || error_exit "ERROR: ${productname} database is PostgreSQL. Therefore this script requires package 'postgresql-client'." "Install with 'apt-get install postgresql-client' and run script again"
  816. # set database command for PostgreSQL:
  817. # ATTENTION: variable $dbcommand will be passed to command "eval" later. So better take care when adding new stuff to $dbcommand!
  818. # actually no need to quote $dbhost and $dbprotocol here, because they can't contain spaces. Saves some lines of code (v0.7.2-22), but
  819. # quoted both values again in v0.8.10-9 to make shellcheck happy!
  820. # if $dbhost contains a socket, this will not be treated as a network connection by psql and this error may occur:
  821. # 'psql: FATAL: Peer authentication failed for user "USERNAME"'
  822. # see https://stackoverflow.com/a/26183931 and https://stackoverflow.com/a/21889759 to solve this error message.
  823. # shellcheck disable=SC2016
  824. dbcommand='PGPASSWORD="${dbpassword}" psql "${dbhost}" "${dbprotocol}" -U "${dbuser}" -d "${dbname}" -Aqtc '
  825. # store type of database for output of script:
  826. database="PostgreSQL"
  827. }
  828. ;;
  829. sqlite3)
  830. # check if command-line-interface of sqlite3 is installed:
  831. command -v sqlite3 > /dev/null || error_exit "ERROR: ${productname} database is sqlite3. Therefore this script requires package 'sqlite3'." "Install with 'apt-get install sqlite3' and run script again"
  832. datadirectory="$(getvalue_from_configphp "datadirectory")" # has to be absolute path for working Own-/Nextcloud, so no need to get absolute path
  833. [[ ${datadirectory} == "value is unset" ]] && datadirectory="${nextcloud_path}/data" # use default if unset in config.php
  834. sqlite3_database="${datadirectory}/owncloud.db"
  835. # use options '-list' and '-separator "|"' for sqlite3 command, to override eventually existing user-specific config-file '~/.sqlite3rc':
  836. # ATTENTION: variable $dbcommand will be passed to command "eval" later. So better take care when adding new stuff to $dbcommand!
  837. # shellcheck disable=SC2016
  838. dbcommand='sqlite3 -list -separator "|" "${sqlite3_database}" '
  839. # store type of database for output of script:
  840. database="SQLite3"
  841. # check if sqlite3 database file is readable:
  842. check_readable_file "${sqlite3_database}" "${productname}s SQLite3 database."
  843. ;;
  844. *)
  845. # print error and exit, if used dbtype is not supported by calcardbackup:
  846. error_exit "ERROR: Unsupported Database type: ${dbtype}" "Only MySQL/MariaDB, SQLite3 and PostgreSQL are supported."
  847. ;;
  848. esac
  849. _output printf '%s\n' "+ Database of chosen ${productname} installation is ${database}."
  850. }
  851. read_users_txt() {
  852. # reads file with user credentials and assigns usernames and (if run with option -g) passwords to separate arrays
  853. local line
  854. # read file with user credentials line by line
  855. # (ownCloud and Nextcloud don‘t allow colons to be part of a username - that is why calcardbackup uses a colon as separator):
  856. while read -r line; do
  857. # don't process empty lines:
  858. [[ ${line:-} == "" ]] && continue
  859. # only store password in array, when we are going to get calendars/addressbooks via http(s)-request:
  860. if [[ ${fetch_from_database} == "no" ]]; then
  861. if [[ ${line#*:} == "" ]]; then
  862. # continue with next line when using -g and no password for that user found in file with usernames and passwords:
  863. _output printf '%s\n' "-- WARNING: skipping user '${line%%:*}' because of empty password!"
  864. continue
  865. fi
  866. # otherwise keep end of line from first colon on as password:
  867. pass+=("${line#*:}")
  868. fi
  869. # keep beginning of line until first colon as username:
  870. user+=("${line%%:*}")
  871. done < "${users_file}"
  872. }
  873. create_backup_subfolder() {
  874. # creates subfolder of backupfolder with date extension to store files
  875. day="$(date +"${date_extension}")"
  876. backupfolder_day="${backupfolder}/calcardbackup${day}"
  877. # store path to backup (depending on config, this value might get changed later to path to compressed- or encrypted-backup):
  878. path_to_backup="${backupfolder_day}"
  879. mkdir -p "${backupfolder_day}"
  880. }
  881. query_database() {
  882. # gets details of calendars/addressbooks from database
  883. # see next lines for description of arguments ${1} and ${2}
  884. table="${1}" # ${table_calendars}, ${table_calendarsubscriptions} or ${table_addressbooks}
  885. item="${2}" # "calendar", "calendarsubscription" or "addressbook", just a verbal description, needed for output messages
  886. # declare local variables:
  887. local check_table fields regex db_query line
  888. _output printf '%s\n' "+ Looking for ${item}s in your ${productname}:"
  889. # check if table exists:
  890. # for checking tables with MySQL: option -r is not needed but an additional option -s is used to produce less output:
  891. [[ ${dbtype} == "mysql" && "${snap}" == "no" ]] && check_table="$(mysql --defaults-extra-file=<(printf '[client]\nuser = %s\npassword = \"%s\"\nhost = %s\n%s' "${dbuser}" "${dbpassword}" "${dbhost}" "${dbprotocol}") "${dbname}" -sse "SHOW TABLES LIKE '${table}';")"
  892. [[ ${dbtype} == "mysql" && "${snap}" == "yes" ]] && check_table="$(nextcloud.mysql-client --defaults-extra-file=<(printf '[client]\nuser = %s\npassword = \"%s\"\nhost = %s\n%s' "${dbuser}" "${dbpassword}" "${dbhost}" "${dbprotocol}") "${dbname}" -sse "SHOW TABLES LIKE '${table}';")"
  893. [[ ${dbtype} == "sqlite3" ]] && check_table="$(sqlite3 "${sqlite3_database}" "SELECT name FROM sqlite_master WHERE type='table' AND name='${table}';")"
  894. # this check is a bit more complicated for PostgreSQL:
  895. if [[ ${dbtype} == "pgsql" ]]; then
  896. # first let us assume that the desired table exists (variable needs to be set for check later):
  897. check_table="table ${table} exists"
  898. # then querie for the first line in that table, suppress any output and set ${check_table} to empty string, if psql throws an error:
  899. PGPASSWORD="${dbpassword}" psql ${dbhost} ${dbprotocol} -U "${dbuser}" -d "${dbname}" -Aqtc "SELECT * FROM ${table} LIMIT 1;" 1>/dev/null 2>&1 || check_table=""
  900. fi
  901. if [[ ${check_table:-} == "" ]]; then
  902. if [[ ${item} != "calendarsubscription" ]]; then
  903. # print notice, if table doesn't exist (unnecessary for calendarsubscriptions):
  904. _output printf '%s\n' "-- NOTICE: table '${table}' containing ${item}s does not exist in database."
  905. _output printf '%s\n' "-- NOTICE: Looks like your ${productname} did not create any ${item}s yet."
  906. fi
  907. else
  908. # table exists
  909. # prepare database query:
  910. # fields to be read from database table (have displayname as last field to be able to fix issue #17):
  911. fields="${row_principaluri}, ${row_uri}, ${row_displayname}"
  912. # regex for testing correct syntax of fields (in case a field contains a linebreak with eventually following characters):
  913. regex='.+\|.+\|.+'
  914. # adjust regex for ownCloud/Nextcloud >= 9.0
  915. [[ ${mainversion} -ge "${davchangeversion}" ]] && regex="principals(\\/(system|users))?\\/${regex}"
  916. # If we want to get addressbooks from the database (instead of downloading from ownCloud/Nextcloud) or
  917. # to be able to include shares we also need to get id of item (needs to be compared to $share_resourceid from $table_shares):
  918. if [[ ${include_shares} == "yes" || ${fetch_from_database} == "yes" ]]; then
  919. fields="${row_id}, ${fields}"
  920. # adjust regex:
  921. regex="[[:digit:]]+\\|${regex}"
  922. fi
  923. if [[ ${item} == "calendar" && ${fetch_from_database} == "yes" ]]; then
  924. fields="COALESCE(${row_calendarcolor},'NULL'), ${fields}"
  925. # adjust regex (changed to support CSS3 color names according to RFC 7986 (see also issue #20)):
  926. regex="[^|]+\\|${regex}"
  927. fi
  928. if [[ ${item} == "calendarsubscription" ]]; then
  929. fields="${row_id}, ${row_principaluri}, ${row_source}, ${row_displayname}"
  930. # adjust regex (calendarsubscriptions are only supported by ownCloud/Nextcloud >= 9.0):
  931. regex='[[:digit:]]+\|principals(\/(system|users))?\/.+\|.+\|.+'
  932. fi
  933. # add beginning and end of line to regex:
  934. regex="^${regex}\$"
  935. # adjust output format of MySQL/MariaDB/PostgreSQL to match output of SQLite3 (field|field|field):
  936. [[ ${dbtype} != "sqlite3" ]] && fields="CONCAT_WS('|', ${fields})"
  937. # ATTENTION: variable $db_query will be passed to command "eval" later. So better take care when adding new stuff to $db_query!
  938. # shellcheck disable=SC2016
  939. db_query='"SELECT ${fields} FROM ${table} ORDER BY id;"'
  940. # read required fields from table and assign values to arrays:
  941. while read -r line; do
  942. # if $line doesn't match $regex, continue with next line
  943. # (increase robustness against faulty displayname entries (displayname is only field that contains user generated input)):
  944. [[ ! ${line} =~ ${regex} ]] && continue
  945. if [[ ${item} == "calendar" && ${fetch_from_database} == "yes" ]]; then
  946. calendarcolor+=("${line%%|*}") # store calendarcolor (first field) in array
  947. line="${line#*|}" # cut first field (calendarcolor)
  948. fi
  949. if [[ ${include_shares} == "yes" || ${fetch_from_database} == "yes" || ${item} == "calendarsubscription" ]]; then
  950. id+=("${line%%|*}") # store id (first field) in array
  951. line="${line#*|}" # cut first field (id)
  952. fi
  953. # for the next two lines see: https://github.com/nextcloud/server/pull/13573
  954. line="${line/principals\/}" # at first only remove "principals/" from line to get username for legacy installs
  955. line="${line/users\/}" # then also remove "users/" from line to get username for non-legacy installs
  956. principal+=("${line%%|*}") # store principal (=username, is first field) in array
  957. line="${line#*|}" # cut first field (principal)
  958. if [[ ${item} == "calendarsubscription" ]]; then
  959. source_url+=("${line%%|*}") # store URL to source (URL to calendarsubscription, is now first field) in array
  960. else
  961. uri+=("${line%%|*}") # store uri (is now first field) in array
  962. fi
  963. line="${line##*|}" # separate last field (contains displayname)
  964. # delete eventually existing trailing white space characters (fix issue #17) and store displayname in array:
  965. displayname+=("${line%[[:space:]]}")
  966. done < <(eval "${dbcommand}" "${db_query}")
  967. fi
  968. }
  969. include_shares() {
  970. # looks for shared items and adds them to arrays created in function query_database()
  971. # database only needs to be queried once for table with infos about shares ($table_shares)
  972. # (because same table for shared calendars and shared addressbooks):
  973. if [[ -z ${read_shared:-} ]]; then
  974. read_shared="yes"
  975. # check if table $table_shares exists:
  976. # for checking tables with MySQL: option -r is not needed but an additional option -s is used to produce less output:
  977. [[ ${dbtype} == "mysql" && ${snap} == "no" ]] && check_table="$(mysql --defaults-extra-file=<(printf '[client]\nuser = %s\npassword = \"%s\"\nhost = %s\n%s' "${dbuser}" "${dbpassword}" "${dbhost}" "${dbprotocol}") "${dbname}" -sse "SHOW TABLES LIKE '${table_shares}';")"
  978. [[ ${dbtype} == "mysql" && ${snap} == "yes" ]] && check_table="$(nextcloud.mysql-client --defaults-extra-file=<(printf '[client]\nuser = %s\npassword = \"%s\"\nhost = %s\n%s' "${dbuser}" "${dbpassword}" "${dbhost}" "${dbprotocol}") "${dbname}" -sse "SHOW TABLES LIKE '${table_shares}';")"
  979. [[ ${dbtype} == "sqlite3" ]] && check_table="$(sqlite3 "${sqlite3_database}" "SELECT name FROM sqlite_master WHERE type='table' AND name='${table_shares}';")"
  980. # this check is a bit more complicated for PostgreSQL:
  981. if [[ ${dbtype} == "pgsql" ]]; then
  982. # first let us assume that the desired table exists (variable needs to be set for check later):
  983. check_table="table ${table_shares} exists"
  984. # then querie for the first line in that table, suppress any output and set ${check_table} to empty string, if psql throws an error:
  985. PGPASSWORD="${dbpassword}" psql ${dbhost} ${dbprotocol} -U "${dbuser}" -d "${dbname}" -Aqtc "SELECT * FROM ${table_shares} LIMIT 1;" 1>/dev/null 2>&1 || check_table=""
  986. fi
  987. if [[ ${check_table:-} == "" ]]; then
  988. # print notice, if $table_shares doesn't exist:
  989. _output printf '%s\n' "-- NOTICE: table '${table_shares}' containing shared calendars/addressbooks does not exist in database."
  990. _output printf '%s\n' "-- NOTICE: Looks like there are no shared calenders/addressbooks in your ${productname}."
  991. else
  992. # table with info about shares ($table_shares) exists - read required fields from table and assign values to share_arrays
  993. # prepare database query
  994. # fields to be read from database table with shares:
  995. fields="${row_share_principaluri}, ${row_share_type}, ${row_share_resourceid}"
  996. # we also need to get share type to identify group-shares for ownCloud < 9.0:
  997. [[ ${mainversion} -lt "${davchangeversion}" ]] && fields="${row_share_type_group}, ${fields}"
  998. # adjust output format of MySQL/MariaDB/PostgreSQL to match output of SQLite3 (field|field|field):
  999. [[ ${dbtype} != "sqlite3" ]] && fields="CONCAT_WS('|', ${fields})"
  1000. # ATTENTION: variable $db_query will be passed to command "eval" later. So better take care when adding new stuff to $db_query!
  1001. # shellcheck disable=SC2016
  1002. db_query='"SELECT ${fields} FROM ${table_shares} ORDER BY id;"'
  1003. # read required fields from table and assign values to share-arrays:
  1004. while read -r line; do
  1005. # don't process empty lines (PostgreSQL returns an empty line as last line after the result):
  1006. [[ ${line:-} == "" ]] && continue
  1007. if [[ ${mainversion} -lt "${davchangeversion}" ]]; then
  1008. # for ownCloud < 9.0: store share_type_group (first field) in array
  1009. # (share_type_group will be 0 for user-share, 1 for group-share, 3 for public-share):
  1010. share_type_group+=("${line%%|*}") # first field contains share-type_group
  1011. line="${line#*|}" # cut first field (share_type_group) from array
  1012. fi
  1013. # for the next two lines see: https://github.com/nextcloud/server/pull/13573
  1014. line="${line/principals\/}" # at first only remove "principals/" from line to get username for legacy installs
  1015. line="${line/users\/}" # then also remove "users/" from line to get username for non-legacy installs ("groups/" stays there for groupcheck later)
  1016. share_principal+=("${line%%|*}") # store share_principal (is now first field) in array
  1017. line="${line#*|}" # cut first field (share-principal)
  1018. share_type+=("${line%%|*}") # store share_type (is now first field) in array
  1019. share_resourceid+=("${line##*|}") # store share_resourceid (last field) in array
  1020. done < <(eval "${dbcommand}" "${db_query}")
  1021. # we also need to get info about group members to be able to backup calendars/addressbooks which are shared to groups:
  1022. # check if table group_user ($table_group_user) exists:
  1023. # for checking tables with MySQL: option -r is not needed but an additional option -s is used to produce less output:
  1024. [[ ${dbtype} == "mysql" && ${snap} == "no" ]] && check_table="$(mysql --defaults-extra-file=<(printf '[client]\nuser = %s\npassword = \"%s\"\nhost = %s\n%s' "${dbuser}" "${dbpassword}" "${dbhost}" "${dbprotocol}") "${dbname}" -sse "SHOW TABLES LIKE '${table_group_user}';")"
  1025. [[ ${dbtype} == "mysql" && ${snap} == "yes" ]] && check_table="$(nextcloud.mysql-client --defaults-extra-file=<(printf '[client]\nuser = %s\npassword = \"%s\"\nhost = %s\n%s' "${dbuser}" "${dbpassword}" "${dbhost}" "${dbprotocol}") "${dbname}" -sse "SHOW TABLES LIKE '${table_group_user}';")"
  1026. [[ ${dbtype} == "sqlite3" ]] && check_table="$(sqlite3 "${sqlite3_database}" "SELECT name FROM sqlite_master WHERE type='table' AND name='${table_group_user}';")"
  1027. # this check is a bit more complicated for PostgreSQL:
  1028. if [[ ${dbtype} == "pgsql" ]]; then
  1029. # first let us assume that the desired table exists (variable needs to be set for check later):
  1030. check_table="table ${table_group_user} exists"
  1031. # then querie for the first line in that table, suppress any output and set ${check_table} to empty string, if psql throws an error:
  1032. PGPASSWORD="${dbpassword}" psql ${dbhost} ${dbprotocol} -U "${dbuser}" -d "${dbname}" -Aqtc "SELECT * FROM ${table_group_user} LIMIT 1;" 1>/dev/null 2>&1 || check_table=""
  1033. fi
  1034. if [[ ${check_table:-} != "" ]]; then
  1035. # table group_user exists -> read values in array grouplist:
  1036. # fields to be read from table group_user:
  1037. fields="gid, uid"
  1038. # adjust output format of MySQL/MariaDB/PostgreSQL to match output of SQLite3 (field|field|field):
  1039. [[ ${dbtype} != "sqlite3" ]] && fields="CONCAT_WS('|', ${fields})"
  1040. # ATTENTION: variable $db_query will be passed to command "eval" later. So better take care when adding new stuff to $db_query!
  1041. # shellcheck disable=SC2016
  1042. db_query='"SELECT ${fields} FROM ${table_group_user} ORDER BY gid;"'
  1043. # read values from database and create array with list of groups and their users:
  1044. while read -r line; do
  1045. # don't process empty lines (PostgreSQL returns an empty line as last line after the result):
  1046. [[ ${line:-} == "" ]] && continue
  1047. grouplist+=("${line}")
  1048. done < <(eval "${dbcommand}" "${db_query}")
  1049. # no need for else section with error-message, because: if there is no group list - there are no groups, which is not a problem
  1050. fi
  1051. fi
  1052. fi
  1053. # print notice, if no results were found (table $table_shares is empty) and return from this function (includ_shares):
  1054. if [[ -z ${share_principal:-} ]]; then
  1055. _output printf '%s\n' "-- NOTICE: Table '${table_shares}' is empty. There are no shared ${item}s in your ${productname}."
  1056. return
  1057. fi
  1058. # declare local arrays and variables:
  1059. local -a temp_principal temp_displayname temp_uri temp_id temp_calendarcolor
  1060. local -i i s g
  1061. # look for matches of share_resourceid of $table_shares and calendar/addressbook-ids and store values in a temporary array:
  1062. # notice: share_resourceid doesn't get compared with id from same table ($table_shares), but
  1063. # with id from table containing calendars/addressbooks ($table_calendars/$table_addressbooks)
  1064. for (( i=0; i<${#id[@]}; i++ )); do # go through calender/addressbook-ids
  1065. for (( s=0; s<${#share_resourceid[@]}; s++ )); do # go through share_resourceid
  1066. # if share_resourceid matches calendar/addressbook-id, we need to add it to the temporary 'shares to be backed up'-arrays:
  1067. if [[ ${share_resourceid[${s}]} == "${id[${i}]}" && ${share_type[${s}]} == "${item}" ]]; then
  1068. # make sure, principal of share is not a group (group will be treated below)
  1069. # first expression in next line is for ownCloud/Nextcloud >= 9.0, the second one (after &&) is for ownCloud < 9.0:
  1070. if [[ ! ${share_principal[${s}]} =~ groups/ && ! ${share_type_group[${s}]:-} =~ ^1$ ]]; then
  1071. # add identified dav-share (shared with a user!) to the temporary 'shares to be backed up'-arrays:
  1072. temp_principal+=("${share_principal[${s}]}")
  1073. temp_uri+=("${uri[${i}]}_shared_by_${principal[${i}]}")
  1074. temp_displayname+=("${displayname[${i}]}_shared_by_${principal[${i}]}")
  1075. temp_id+=("${share_resourceid[${s}]}")
  1076. # in case it is a calendar - also add calendarcolor of original item to temp array with shared items:
  1077. [[ ${item} == "calendar" && ${fetch_from_database} == "yes" ]] && temp_calendarcolor+=("${calendarcolor[${i}]}")
  1078. else
  1079. # if share-principal is a group:
  1080. # go through list with groups and their users and
  1081. # add item to the temporary 'shares to be backed up'-arrays for all users of that very group:
  1082. for g in "${!grouplist[@]}"; do
  1083. if [[ ${share_principal[${s}]#*groups/} == "${grouplist[${g}]%|*}" ]]; then
  1084. # if item owner is member of group:
  1085. # do not add item to 'shares to be backed up'-arrays for owner, instead continue with next element
  1086. # (owner is excluded here, because owners item will be downloaded anyway: it is already in the array with items to be downloaded):
  1087. [[ ${grouplist[${g}]#*|} == "${principal[${i}]}" ]] && continue
  1088. # add details of item shared with group to temporary 'shares to be backed up'-arrays:
  1089. temp_principal+=("${grouplist[${g}]#*|}")
  1090. temp_uri+=("${uri[${i}]}_shared_by_${principal[${i}]}")
  1091. temp_displayname+=("${displayname[${i}]}_shared_by_${principal[${i}]}")
  1092. temp_id+=("${share_resourceid[${s}]}")
  1093. # in case it is a calendar - also add calendarcolor of original item to temp array with shared items:
  1094. [[ ${item} == "calendar" && ${fetch_from_database} == "yes" ]] && temp_calendarcolor+=("${calendarcolor[${i}]}")
  1095. fi
  1096. done
  1097. fi
  1098. fi
  1099. done
  1100. done
  1101. # make sure, that temporary arrays are only read if not empty. This can be the case, if there
  1102. # are shared calendars, but no shared addressbooks (or vice versa) (v0.6.1):
  1103. if [[ -n ${temp_principal:-} ]]; then
  1104. # add values of temporary 'shares to be backed up'-arrays to arrays which will later be used to download the files:
  1105. for (( i=0; i<${#temp_principal[@]}; i++ )); do
  1106. principal+=("${temp_principal[${i}]}")
  1107. uri+=("${temp_uri[${i}]}")
  1108. displayname+=("${temp_displayname[${i}]}")
  1109. id+=("${temp_id[${i}]}")
  1110. [[ ${item} == "calendar" && ${fetch_from_database} == "yes" ]] && calendarcolor+=("${temp_calendarcolor[${i}]}")
  1111. done
  1112. fi
  1113. # unset arrays with infos about shares (from $table_shares), after second run with addressbooks or if addressbooks shall
  1114. # not be backed up (it is the same data for calendars + addressbooks, so make sure, we do not need it again before unsetting):
  1115. if [[ ${item} == "addressbook" || ${backup_addressbooks} == "no" ]]; then
  1116. unset -v share_type_group share_principal share_type share_resourceid grouplist
  1117. fi
  1118. }
  1119. get_complete_or_one_file_from_db() {
  1120. # checks for option -one|--one-file-per-component and runs according functions to backup calendars/addressbooks
  1121. case ${one_file_per_component:-no} in
  1122. no )
  1123. # backup complete calendars/addressbooks from database (default):
  1124. _output printf '%s' "+ Saving ${item} ${filename} (from db)..."
  1125. [[ ${item} == "calendar" ]] && create_calendar_from_db
  1126. [[ ${item} == "addressbook" ]] && create_addressbook_from_db
  1127. # in case an empty addressbook was found - this function will create an empty vcf file for us:
  1128. check_for_valid_icsvcf
  1129. ;;
  1130. yes )
  1131. # backup only one file per component (option -one|--one-file-per-component):
  1132. _output printf '%s' "+ Saving ${item} ${filename%.*} (one file per comp.)..."
  1133. create_one_file_per_component_from_db
  1134. if [[ ${component_found:-no} == "yes" ]]; then
  1135. _output printf '%s\n' "...success!"
  1136. else
  1137. _output printf '%s\n' "...empty ${item}!"
  1138. fi
  1139. # no need to create empty items or to check for a valid header, as we want
  1140. # to leave the output of the database as it is with option -one|--one-file-per-component
  1141. ;;
  1142. esac
  1143. # print warning, if vCards in version 2.1 were found:
  1144. print_vcard21_warning
  1145. }
  1146. print_vcard21_warning() {
  1147. # print warning, if vCards in version 2.1 were found:
  1148. local plural
  1149. if [[ ${item} == "addressbook" && ${vcard_twodotone:-0} -gt 0 ]]; then
  1150. [[ ${vcard_twodotone} -gt 1 ]] && plural="s"
  1151. _output printf '%s\n' "-- WARNING: ${item} ${filename} contains ${vcard_twodotone:-0} vCard${plural:-} 2.1"
  1152. fi
  1153. }
  1154. get_icsvcf_files() {
  1155. # looks for matches between array with usernames and arrays created by database-queries and then creates/downloads addressbooks/calendars
  1156. # see next lines for description of arguments:
  1157. local item="${1}" # items to be created/downloaded: "addressbook" or "calendar"
  1158. local item_extension="${2}" # filename extension: ".vcf" or ".ics"
  1159. local item_header="${3}" # valid vcf/ics-header (first line of vcf/ics-file): "BEGIN:VCARD" or "BEGIN:VCALENDAR"
  1160. local calcarddav="${4}" # dav-endpoint: "dav" for ownCloud/Nextcloud >= 9.0; "carddav"/"caldav" for ownCloud < 9.0
  1161. local ex_users="${5}" # needed for download of addressbooks from ownCloud/Nextcloud >= 9.0: "users/" (or empty string for ownCloud < 9.0)
  1162. local -i exported i z index
  1163. local line filename item_name file_received convert
  1164. # check whether database returned 0 entries (meaning there is not a single calendar/addressbook in the database):
  1165. if [[ -z ${uri:-} ]]; then
  1166. _output printf '%s\n' "-- INFO: Couldn't find a single ${item} in your ${productname}!"
  1167. # unset arrays (to not include calendardata for addressbook export):
  1168. unset -v id principal uri displayname already_saved_as calendarcolor
  1169. # there is not a single item to export, let's return from this function and proceed:
  1170. return
  1171. fi
  1172. # counter for exported items:
  1173. # (to be able to print message that there is no valid user in users.txt, if no calendar/addressbook was exported)
  1174. exported=0
  1175. # set convert variable for PostgreSQL if we want to get addressbooks directly from database:
  1176. if [[ ${dbtype} == "pgsql" && ${fetch_from_database} == "yes" ]]; then
  1177. [[ ${item} == "addressbook" ]] && convert="CONVERT_FROM(carddata,'UTF8')"
  1178. # shellcheck disable=SC2034
  1179. [[ ${item} == "calendar" ]] && convert="CONVERT_FROM(calendardata,'UTF8')"
  1180. fi
  1181. # loop through array with addressbooks/calendars:
  1182. for (( i=0; i<${#uri[@]}; i++ )); do
  1183. # initialize counter for vCards in version 2.1:
  1184. [[ ${item} == "addressbook" ]] && vcard_twodotone=0
  1185. if [[ ${complete_backup_from_database:-} == "yes" ]]; then
  1186. # exclude birthday calendar (contact_birthdays), because it is an automatically created calendar from ownCloud/Nextcloud
  1187. # and also exclude system-addressbook (system addressbook can't be restored, when backed up with calcardbackup!):
  1188. if [[ ${uri[${i}]} != "contact_birthdays" && ${principal[${i}]} != "system/system" ]]; then
  1189. # create filename like: username-(calendar-/addressbook-)name.(ics|vcf):
  1190. filename="${principal[${i}]}-${displayname[${i}]}${item_extension}"
  1191. # remove funky characters from filename:
  1192. filename="${filename//[\/\\*? ]/_}"
  1193. # create addressbook or calendar by reading data directly from database (default, option -f | --fetch-from-database):
  1194. get_complete_or_one_file_from_db
  1195. # increase counter $exported, because at least one item has been downloaded successfully (even if it was an empty addressbook):
  1196. exported=$((exported + 1))
  1197. fi
  1198. # continue for-loop with next item:
  1199. continue
  1200. fi
  1201. # loop through array with usernames given in file with users credentials (users.txt):
  1202. # (this for-loop will never be run, if $complete_backup_from_database=="yes")
  1203. for index in "${!user[@]}"; do
  1204. # compare prinicpal with username. If there is a match, we found an item to be downloaded
  1205. # (exclude birthday calendar (contact_birthdays), because it is an automatically created calendar from ownCloud/Nextcloud):
  1206. if [[ ${principal[${i}]} == "${user[${index}]}" && ${uri[${i}]} != "contact_birthdays" ]]; then
  1207. # create filename like: username-(calendar-/addressbook-)name.(ics|vcf):
  1208. filename="${principal[${i}]}-${displayname[${i}]}${item_extension}"
  1209. # remove funky characters from filename:
  1210. filename="${filename//[\/\\*? ]/_}"
  1211. # only if shared items shall be included: check if item was already downloaded and break if yes or mark as downloaded:
  1212. if [[ ${include_shares} == "yes" ]]; then
  1213. # increase readability: id of current item is used as index ($z) for array already_saved_as which stores filenames of downloaded items:
  1214. z=${id[${i}]}
  1215. # check if there is already an entry for that item, meaning that it was already downloaded:
  1216. if [[ ${already_saved_as[${z}]:-} != "" ]]; then
  1217. # create name of item for message (use $filename without (.ics/.vcf):
  1218. item_name="${filename%.*}"
  1219. # print message that item is already downloaded and break the for-loop going through usernames to continue with next addressbook/calendar:
  1220. _output printf '%s\n' "+ Skipping ${item} '${item_name}': already saved as '${already_saved_as[${z}]}'." && break
  1221. fi
  1222. # item has not yet been downloaded, but it will be downloaded, so let's add it to the arraay $already_saved_as:
  1223. already_saved_as[${z}]="${filename}"
  1224. fi
  1225. if [[ ${fetch_from_database} == "yes" ]]; then
  1226. # create addressbook or calendar by reading data directly from database (default, option -f | --fetch-from-database):
  1227. get_complete_or_one_file_from_db
  1228. else
  1229. # download item with curl (command uses export link provided by contacts/calendar app from ownCloud/Nextcloud):
  1230. _output printf '%s' "+ Saving ${item} ${filename}..."
  1231. # replace spaces in username or addressbook(/calendar)name with %20 (url encoding, to work more reliable with webserver)
  1232. # (OR (in case curl exits with error): store exit code of curl to be able to react on error in separate function):
  1233. curl -s ${trust} -o "${backupfolder_day}/${filename}" -u "${user[${index}]}":"${pass[${index}]}" "${nextcloud_url}/remote.php/${calcarddav}/${item}s/${ex_users}${user[${index}]// /%20}/${uri[${i}]// /%20}?export" || cerror=$?
  1234. # if error code is set, call function to examine curls exit code and print according message to stderr:
  1235. [[ -n ${cerror:-} ]] && curl_error
  1236. # check whether first line of saved file is a valid iCalendar/vCard header
  1237. # in case an empty addressbook was found - this function will create an empty vcf file for us:
  1238. check_for_valid_icsvcf
  1239. # check for vcard 2.1 in backed up addressbook:
  1240. check_for_vcard21 "${backupfolder_day}/${filename}"
  1241. # print warning, if vCards in version 2.1 were found:
  1242. print_vcard21_warning
  1243. fi
  1244. # increase counter $exported, because at least one item has been downloaded successfully (even if it was an empty addressbook):
  1245. exported=$((exported + 1))
  1246. break # break the for-loop going through usernames to continue with next addressbook/calendar.
  1247. fi
  1248. done
  1249. done
  1250. # there is no matching Nextcloud user, if there are calendars/addressbooks in database table but nothing has been exported:
  1251. # print notice, if there are calendars/addressbooks in database but nothing has been exported:
  1252. [[ ${exported} == 0 ]] && _output printf '%s\n' "+ no ${item}s found for users given in '${users_file}'."
  1253. # unset arrays (to not include calendardata for calendarsubscriptions or addressbook export):
  1254. unset -v id principal uri displayname already_saved_as calendarcolor
  1255. }
  1256. unset_user_array() {
  1257. # unsets array with usernames (and eventually also array with passwords)
  1258. # this array is needed for calendars, calendarsubscriptions + addressbooks, so: before calling this function make sure, that
  1259. # we do not need it again before unsetting):
  1260. [[ ${fetch_from_database} == "no" ]] && unset -v pass
  1261. unset -v user
  1262. }
  1263. create_calendar_from_db() {
  1264. # create iCalendar (.ics file) by reading calendardata directly from database (option -f | --fetch-from-database):
  1265. # according to: https://tools.ietf.org/html/rfc5545
  1266. # here: create an iCalendar object from the components stored in the database (default)
  1267. local line vtime component ifs_original calendartype
  1268. local -a tz_temp tz_data tz_ids
  1269. # an iCalender file consists of calendar properties and calendar components.
  1270. # Nextcloud/ownCloud store every event/todo completely as an iCalender item in a single table cell in table [PREFIX]calendarobjects.
  1271. # We need to get those events, ignore the calendar properties (calcardbackup generates them separately from table [PREFIX]calendars)
  1272. # and collect the components. Every single event contains the according timezone-components. They are unique for each timezone,
  1273. # so we only need to collect each timezone once.
  1274. # ignore cached objects of webcal calendars (calendarsubscriptions) for Nextcloud >= 15.0.0
  1275. # ( Nextcloud >= 15.0.0 caches objects of calendarsubscriptions also in table calendarobjects. calendarids of calendarsubscriptions
  1276. # might match calendarids of regular calendars. So we need to check for calendartype=0 of calendarobjects (calendarsubscriptions
  1277. # have calendartype=1 for Nextcloud >= 15.0.0) (see https://github.com/nextcloud/server/pull/10059) )
  1278. if [[ ${productname} == "Nextcloud" && ${main_version} -ge 15 ]]; then
  1279. # shellcheck disable=SC2034
  1280. calendartype="AND calendartype=0"
  1281. fi
  1282. # prepare database query to collect all events from according calendarid (variable convert is needed for PostgreSQL):
  1283. # shellcheck disable=SC2016
  1284. db_query='"SELECT ${convert:-calendardata} FROM ${table_calendarobjects} WHERE calendarid=${id[${i}]} ${calendartype:-};"'
  1285. # print generic iCalendar properties (BEGIN, VERSION, PRODID, CALSCALE) (according to RFC5545 each line has to end with CR+LF):
  1286. printf '%s\r\n' "BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:${origin_repository} v${version%% *}" "CALSCALE:GREGORIAN" > "${backupfolder_day}/${filename}"
  1287. # also calendarname and calendarcolor (if existent in database):
  1288. printf '%s\r\n' "X-WR-CALNAME:${displayname[${i}]}" >> "${backupfolder_day}/${filename}"
  1289. if [[ ${calendarcolor[${i}]} != "NULL" ]]; then
  1290. printf '%s\r\n' "X-APPLE-CALENDAR-COLOR:${calendarcolor[${i}]}" >> "${backupfolder_day}/${filename}"
  1291. fi
  1292. # property names and property parameters are case insensitive, so let's switch to nocasematch for comparing strings:
  1293. shopt -s nocasematch
  1294. # store IFS (Intenal Field Separator) and set it to the null string (to prevent 'read' from stripping leading and trailing whitespace from the line)
  1295. # of course it is much more performant to do this outside of the loop (instead of 'while IFS= read -r line; do' as suggested often):
  1296. ifs_original="${IFS}"
  1297. IFS=
  1298. # prepare variables:
  1299. # set vtime indicator to 0 (see below. No VTIMEZONE component detected):
  1300. vtime=0
  1301. # fill first array element with dummy value to later be able to avoid check for unset array (changed in 0.7.0-11)):
  1302. tz_ids[0]="dummy"
  1303. # read calendardata line by line and collect components:
  1304. while read -r line; do
  1305. # vtime: is indicator for VTIMEZONE
  1306. # vtime=0 means: no VTIMEZONE component (yet) detected
  1307. # vtime=1 means: loop is within VTIMEZONE component, lines are collected to temp array tz_temp
  1308. # vtime=2 means: loop is within VTIMEZONE component, but VTIMEZONE was already collected before so this time it will be ignored
  1309. # component: is indicator for an iCalendar component
  1310. # component unset: not looping inside an iCalendar component
  1311. # component defined: components value is name of iCalendar component (e.g. VEVENT, VTODO, VJOURNAL, ..., iana-component or x-component which must also not be ignored)
  1312. case ${vtime} in
  1313. 2 )
  1314. # looping within VTIMEZONE and VTIMEZONE was already collected before and VTIMEZONE doesn't end yet: ignore line and continue with next line:
  1315. [[ ${line} != END:VTI* ]] && continue
  1316. ;;
  1317. 0 )
  1318. # looping within an iCalendar component which is not a VTIMEZONE (vtime==0) and that has not ended yet: print line to ics-file and continue with next line:
  1319. [[ ${line} != END:* && -n ${component:-} ]] && {
  1320. # (remove eventually existing CR and add CRLF (carriage-return + linefeed) according to RFC5545
  1321. # with parameter expansion "${line%[[:space:]]}")
  1322. printf '%s\r\n' "${line%[[:space:]]}" >> "${backupfolder_day}/${filename}"
  1323. continue
  1324. }
  1325. ;;
  1326. esac
  1327. case ${line} in
  1328. BEGIN:VCAL* )
  1329. # we need to filter out BEGIN:VCALENDAR to catch beginnings of components (it's not needed anyway):
  1330. continue
  1331. ;;
  1332. BEGIN:* )
  1333. # something is beginning here!
  1334. # it might be an alarm-component which resides inside an VEVENT or VTODO component (see RFC 5545, 3.6.6)
  1335. # or an AVAILABLE-component, which resides within an VAVAILABILITY component (see RFC 7953, 3.1)
  1336. # if $component is unset: an iCalendar component begins with this line, so store component name in component variable:
  1337. [[ -z ${component:-} ]] && component="${line#*:}"
  1338. # if this is the beginning of a VTIMEZONE component, set vtime indicator to 1
  1339. # (we are now within a VTIMEZONE, but don't know yet whether this VTIMEZONE has been already collected):
  1340. [[ ${component} == VTI* ]] && vtime=1
  1341. ;;
  1342. END:${component:-} )
  1343. # something is ending here!
  1344. # if line ends an iCalendar component: unset the component-variable:
  1345. unset -v component
  1346. # additionally reset vtime indicator (to 0) and continue with next line if we are ending an VTIMEZONE component that was collected before:
  1347. [[ ${vtime} -eq 2 ]] && { vtime=0; continue; }
  1348. ;;
  1349. * )
  1350. # not looping in an iCalendar component and line doesn't begin a component: ignore line and continue with next line:
  1351. [[ -z ${component:-} ]] && continue
  1352. ;;
  1353. esac
  1354. # BEGIN VTIMEZONE collect and compare:
  1355. # if we are looping through a VTIMEZONE, we need to check whether we collected the VTIMEZONE with this TZID already:
  1356. if [[ ${vtime} -eq 1 ]]; then
  1357. # does this line define an timezone-id (TZID)?
  1358. if [[ ${line} == TZID:* ]]; then
  1359. # check whether TZID is already stored in $tz_ids (check expanded array against line):
  1360. if [[ ${tz_ids[*]} == *${line}* ]]; then
  1361. # match: TZID is already present in array: remember that timezone is already saved (set vtime indicator to 2):
  1362. vtime=2
  1363. # unset temporary array (filled with partially collected timezone-data):
  1364. unset -v tz_temp
  1365. # current VTIMEZONE component is already collected, so let's continue with the next line from database:
  1366. continue
  1367. fi
  1368. # save timezone_id, since it is not yet in the array for comparing timezone_ids:
  1369. tz_ids+=("${line}")
  1370. fi
  1371. # add line to temporary tz-array:
  1372. tz_temp+=("${line}")
  1373. # check for END:VTIMEZONE:
  1374. if [[ ${line} == END:VTI* ]]; then
  1375. # transfer VTIMEZONE from the temporary array to the array containing all collected VTIMEZONE data:
  1376. tz_data+=("${tz_temp[@]%[[:space:]]}")
  1377. # reset vtime indicator to 0:
  1378. vtime=0
  1379. # unset tz_temp, so there wont be any old data when collecting the next found timezone:
  1380. unset -v tz_temp
  1381. fi
  1382. # line was already stored in tz_temp array, let us get the next line from database:
  1383. continue
  1384. fi
  1385. # END VTIMEZONE collect and compare
  1386. # output line (remove eventually existing CR and add CRLF according to RFC5545):
  1387. printf '%s\r\n' "${line%[[:space:]]}" >> "${backupfolder_day}/${filename}"
  1388. done< <(eval "${dbcommand}" "${db_query}")
  1389. # reset IFS to its original value (was set to null string before to prevent 'read' from stripping leading and trailing whitespace from the line):
  1390. IFS="${ifs_original}"
  1391. # at this point of stage all components (except for VTIMEZONES) are saved in $backupfolder_day/$filename
  1392. # save the VTIMEZONE components at the end of the iCalendar object $backupfolder_day/$filename by expanding the array:
  1393. if [[ -n ${tz_data:-} ]]; then
  1394. printf '%s\r\n' "${tz_data[@]}" >> "${backupfolder_day}/${filename}"
  1395. fi
  1396. # important: add END:VCALENDAR as last line to the iCalendar file:
  1397. printf '%s\r\n' "END:VCALENDAR" >> "${backupfolder_day}/${filename}"
  1398. # reset matching case sensitive:
  1399. shopt -u nocasematch
  1400. }
  1401. create_one_file_per_component_from_db() {
  1402. # option -one|--one-file-per-component
  1403. # Create one file per calendar/addressbook component by reading data directly from the database. This is a direct dump
  1404. # of the components stored in the database. The data stored in the database will not be modified (except for adding CR+LF
  1405. # at the end of the lines according to RFC5545/6350). Each backed up file will contain just one component.
  1406. local fields calendartype db_query first_line_regex line component valarm uid
  1407. unset component_found
  1408. # property names and property parameters are case insensitive, so let's switch to nocasematch for comparing strings:
  1409. shopt -s nocasematch
  1410. # store IFS (Intenal Field Separator) and set it to the null string (to prevent 'read' from stripping leading and trailing whitespace from the line),
  1411. # because we need to keep space characters at the beginning of the line!
  1412. # of course it is much more performant to do this outside of the loop (instead of 'while IFS= read -r line; do' as suggested often):
  1413. ifs_original="${IFS}"
  1414. IFS=
  1415. # delete extension (.ics/.vcf) from filename, because we need to add the UID:
  1416. filename="${filename%.*}"
  1417. # prepare database query:
  1418. # fields to be read from database table (id is needed, to identify new database row!):
  1419. [[ ${item} == "calendar" ]] && fields="id, ${convert:-calendardata}"
  1420. [[ ${item} == "addressbook" ]] && fields="id, ${convert:-carddata}"
  1421. # adjust output format of MySQL/MariaDB/PostgreSQL to match output of SQLite3 (field|field|field):
  1422. [[ ${dbtype} != "sqlite3" ]] && fields="CONCAT_WS('|', ${fields})"
  1423. case ${item} in
  1424. calendar )
  1425. # ignore cached objects of webcal calendars (calendarsubscriptions) for Nextcloud >= 15.0.0
  1426. # ( Nextcloud >= 15.0.0 caches objects of calendarsubscriptions also in table calendarobjects. calendarids of calendarsubscriptions
  1427. # might match calendarids of regular calendars. So we need to check for calendartype=0 of calendarobjects (calendarsubscriptions
  1428. # have calendartype=1 for Nextcloud >= 15.0.0) (see https://github.com/nextcloud/server/pull/10059) )
  1429. if [[ ${productname} == "Nextcloud" && ${main_version} -ge 15 ]]; then
  1430. # shellcheck disable=SC2034
  1431. calendartype="AND calendartype=0"
  1432. fi
  1433. # prepare database query to collect all events from according calendarid (variable convert is needed for PostgreSQL):
  1434. # ATTENTION: variable $db_query will be passed to command "eval" later. So better take care when adding new stuff to $db_query!
  1435. # shellcheck disable=SC2016
  1436. db_query='"SELECT ${fields} FROM ${table_calendarobjects} WHERE calendarid=${id[$i]} ${calendartype:-};"'
  1437. ;;
  1438. addressbook )
  1439. # prepare database query to collect all cards belonging to addressbookid (variable convert is needed for PostgreSQL):
  1440. # ATTENTION: variable $db_query will be passed to command "eval" later. So better take care when adding new stuff to $db_query!
  1441. # shellcheck disable=SC2016
  1442. db_query='"SELECT ${fields} FROM ${table_cards} WHERE addressbookid=${id[$i]};"'
  1443. ;;
  1444. esac
  1445. # regex to identify first line of database row (should be: [ID]|BEGIN:VCALENDAR or [ID]|BEGIN:VCARD, but also faulty entries are identified)
  1446. first_line_regex='^[[:digit:]]+\|.*$'
  1447. while read -r line; do
  1448. # Remove potentially existing carriage return from end of line:
  1449. line="${line%[[:space:]]}"
  1450. # check whether $line contains also "ID|" (this is the only way to find out that a new database row starts):
  1451. if [[ ${line} =~ ${first_line_regex} ]]; then
  1452. # a new database row starts here: this should be the first line of a component.
  1453. # we need to check here for a possibly already from the previous row filled $component array and not at the and
  1454. # of the loop, because only now we know, that the previous database row was completely read.
  1455. # if array $component is already filled (this is not the case for the first returned row): write component to backup file:
  1456. if [[ -n ${component:-} ]]; then
  1457. # write array $component to backup file:
  1458. printf '%s\r\n' "${component[@]}" > "${backupfolder_day}/${filename}_${uid}${item_extension}"
  1459. # clear array $component and $valarm (in case there was no end-tag) for content of the next database row:
  1460. unset -v component valarm
  1461. fi
  1462. # to be sure to not overwrite anything: store ID as $uid, in case there is no UID in the component (which NEVER should be the case!):
  1463. uid="${line%%|*}"
  1464. # store $line (without "ID|" in array $component:
  1465. component+=("${line#*|}")
  1466. else
  1467. # We want to use the UID of the component/card as part of the filename. The only place where all the different ownCloud/Nextcloud
  1468. # versions store the UID of the component is in the component itself. That is why we need to parse the component for the UID with
  1469. # the following case statement. Get the UID of the component/card, which will be used as part of the filename:
  1470. case ${line} in
  1471. # we need to check vor VALARM, because some clients also seem to add an UID to VALARMs, which does not seem to be compliant to
  1472. # RFC5545. With option -one, we only want to backup a copy of what is in the database without manipulation
  1473. # (a short form with wild card (VA*) would speed up the processs, but is not possible here because of VAVALABILITY):
  1474. BEGIN:VALARM )
  1475. valarm=1
  1476. ;;
  1477. END:VALARM )
  1478. valarm=0
  1479. ;;
  1480. UID:* )
  1481. # store UID only, if it is not a VALARM-UID:
  1482. [[ ${valarm:-0} -eq 1 ]] || uid="${line#UID:}"
  1483. ;;
  1484. VERSION:2.1* )
  1485. # increase counter, if a vCards in version 2.1 is found to be able to print a warning if > 0:
  1486. vcard_twodotone=$((vcard_twodotone + 1))
  1487. ;;
  1488. esac
  1489. # store $line in component-array:
  1490. component+=("${line}")
  1491. fi
  1492. done< <(eval "${dbcommand}" "${db_query}")
  1493. # after last returned line from database
  1494. # we need to write array $component to backup file:
  1495. if [[ -n ${component:-} ]]; then
  1496. # write array $component to backup file:
  1497. printf '%s\r\n' "${component[@]}" > "${backupfolder_day}/${filename}_${uid}${item_extension}"
  1498. # set marker, that at least one component for that calendar/addressbook was found
  1499. component_found="yes"
  1500. fi
  1501. # NOTE: as with option -one|--one-file-per-componentl we only do want to reflect the components found in the database,
  1502. # there is no need to create empty calendars/addressbooks without any components (like to be done when invoked
  1503. # without option -o|--one-file-per-component
  1504. # reset IFS to its original value (was set to null string before to prevent 'read' from stripping leading and trailing whitespace from the line):
  1505. IFS="${ifs_original}"
  1506. # reset matching case sensitive:
  1507. shopt -u nocasematch
  1508. }
  1509. create_calendarsubscription_files() {
  1510. # creates textfiles with URLs to subscribed calendars
  1511. local i index filename exported match
  1512. # counter for exported items:
  1513. exported=0
  1514. for (( i=0; i<${#id[@]}; i++ )); do # go through array with calendarsubscriptions
  1515. if [[ -n ${users_file:-} ]]; then
  1516. # in case match-variable was set in a previous run of this for loop,
  1517. # unset match-variable (will be set, if principal matches username):
  1518. unset -v match
  1519. # loop through array with usernames given in file with users credentials (users.txt):
  1520. for index in "${!user[@]}"; do
  1521. # compare prinicpal with username. If there is a match --> assign variable:
  1522. [[ ${principal[${i}]} == "${user[${index}]}" ]] && match="yes"
  1523. [[ -n ${match:-} ]] && break
  1524. done
  1525. fi
  1526. if [[ -n ${match:-} || ${complete_backup_from_database:-} == "yes" ]]; then
  1527. # create filename like: username-(calendar-/addressbook-)name.webcal:
  1528. filename="${principal[${i}]}-${displayname[${i}]}.webcal"
  1529. # remove funky characters from filename:
  1530. filename="${filename//[\/\\*? ]/_}"
  1531. # create file with URL to subscribed calendar:
  1532. _output printf '%s' "+ Saving ${item} ${filename}..."
  1533. printf '%s\r\n' "${source_url[${i}]}" > "${backupfolder_day}/${filename}"
  1534. _output printf '%s\n' "...success!"
  1535. # increase counter $exported:
  1536. exported=$((exported + 1))
  1537. fi
  1538. done
  1539. # print notice, if there are calendarsubscriptions in database but nothing has been exported:
  1540. [[ ${exported} == 0 ]] && _output printf '%s\n' "+ no calendarsubscriptions found for users given in '${users_file}'."
  1541. # unset arrays (to not include data of calendarsubscriptions for addressbook export):
  1542. unset -v id principal displayname source_url
  1543. }
  1544. create_addressbook_from_db() {
  1545. # create vCard addressbook (.vcf file) by reading cards directly from database (option -f | --fetch-from-database):
  1546. # prepare database query to collect all cards belonging to addressbookid (variable convert is needed for PostgreSQL):
  1547. # shellcheck disable=SC2016
  1548. db_query='"SELECT ${convert:-carddata} FROM ${table_cards} WHERE addressbookid=${id[${i}]};"'
  1549. # this is much less complex than iCalendar: a vCard addressbook is just single vCards stuck together one after the other.
  1550. # so we just need to pipe the output of the column 'carddata' from table [PREFIX]cards with id of the according addressbook
  1551. # to the addressbook-backup-file. For more details see: https://tools.ietf.org/html/rfc6350:
  1552. # store IFS (Intenal Field Separator) and set it to the null string (to prevent 'read' from stripping leading and trailing whitespace from the line),
  1553. # because we need to keep space characters at the beginning of the line!
  1554. # of course it is much more performant to do this outside of the loop (instead of 'while IFS= read -r line; do' as suggested often):
  1555. ifs_original="${IFS}"
  1556. IFS=
  1557. # empty a possibly existing backup file from a previous run:
  1558. printf '%s' "" > "${backupfolder_day}/${filename}"
  1559. # We do need a loop though, to be able to add CR+LF at the end of lines accdording to RFC6350:
  1560. while read -r line; do
  1561. # write $line to backup file (remove possibly existing CR at the end of the line and add CR+LF according to RFC6350):
  1562. printf '%s\r\n' "${line%[[:space:]]}" >> "${backupfolder_day}/${filename}"
  1563. done< <(eval "${dbcommand}" "${db_query}")
  1564. # check for vcard 2.1 in backed up addressbook:
  1565. check_for_vcard21 "${backupfolder_day}/${filename}"
  1566. }
  1567. check_for_vcard21() {
  1568. # checks, if a saved addressbookfile contains any vCards 2.1
  1569. # Arguments:
  1570. # $1 : path to file to check
  1571. case ${grep_installed} in
  1572. yes )
  1573. # grep is fastest, so use grep, if installed (use & at end of command to return true in any case):
  1574. vcard_twodotone=$(grep -c '^VERSION:2\.1' "${1}" &)
  1575. ;;
  1576. no )
  1577. # grep is not installed, so we need to read the whole file line by line and check manually (much slower than grep):
  1578. while read -r line; do
  1579. case ${line} in
  1580. VERSION:2.1* )
  1581. vcard_twodotone=$((vcard_twodotone + 1))
  1582. ;;
  1583. esac
  1584. done < "${1}"
  1585. ;;
  1586. esac
  1587. }
  1588. check_for_valid_icsvcf() {
  1589. # checks whether saved file has a valid iCalendar/vCard header (checking first line should be enough for our purpose)
  1590. # (this function is only executed, if option one-file-per-component is set to "no")
  1591. # an empty addressbook results in an empty file (or no file for curl < 7.42.0), so we need to check whether we received a file that is not empty:
  1592. file_received="yes" # let's say for now that a file has been downloaded and change it below if no addressbook has been exported.
  1593. # check for addressbook and whether no file was received (for curl < 7.42.0) or file received has size of 0 (meaning empty file):
  1594. if [[ ${item} == "addressbook" && ! -s "${backupfolder_day}/${filename}" ]]; then
  1595. # touch file to create empty file, if it was not created by curl on successful download of empty addressbook (which is the case for curl < 7.42.0)
  1596. # (this is also the case, if empty addressbook was fetched from database):
  1597. touch "${backupfolder_day}/${filename}"
  1598. _output printf '%s\n' "...empty addressbook!"
  1599. file_received="no" # because we have received an empty file and thus do not need to check whether it is a valid ics-/vcf-file
  1600. fi
  1601. if [[ ${file_received} == "yes" && ${fetch_from_database:-} == "yes" ]]; then
  1602. # no need to check for valid ics/vcf, if exporting directly from database (default option -f|--fetch-from-database):
  1603. _output printf '%s\n' "...success!"
  1604. elif [[ ${file_received} == "yes" && ${fetch_from_database} == "no" ]]; then
  1605. # check for valid ics-/vcf-header in received file (this actually only makes sense for deprecated option -g|--get-via-http):
  1606. read -r line <"${backupfolder_day}/${filename}" # reads first line of received file
  1607. if [[ ! ${line} =~ ${item_header} ]]; then # no valid file, if first line of file doesn't match item_header
  1608. # attach "-ERROR.txt" to filename:
  1609. mv "${backupfolder_day}/${filename}" "${backupfolder_day}/${filename}-ERROR.txt"
  1610. _output printf '%s\n' "" # newline is needed for error-message to start on a separate line. Increases readability.
  1611. # print error message and exit:
  1612. error_exit "ERROR: the saved file is not a valid ${item}-file. Something went wrong." "My guess: either wrong configured URL to ${productname} or a wrong combination of username/password in users.txt." "You may want to check the saved file for any hints what went wrong: ${backupfolder_day}/${filename}-ERROR.txt"
  1613. fi
  1614. _output printf '%s\n' "...success!"
  1615. fi
  1616. }
  1617. get_calendars() {
  1618. # gets calendars and saves them in backup-folder. Have a look at used functions for more detailed explanations.
  1619. query_database "${table_calendars}" "calendar"
  1620. # run include_shares only, if configured and calendars found in database:
  1621. [[ ${include_shares} == "yes" && -n ${id:-} ]] && include_shares
  1622. get_icsvcf_files "calendar" ".ics" "BEGIN:VCALENDAR" "${caldav}" ""
  1623. # get calendarsubscriptions for ownCloud/Nextcloud >= 9.0 (older versions do not support calendarsubscriptions):
  1624. if [[ ${mainversion} -ge "${davchangeversion}" ]]; then
  1625. query_database "${table_calendarsubscriptions}" "calendarsubscription"
  1626. if [[ -n ${source_url:-} ]]; then
  1627. # create files with calendarsubscriptions, if table calendarsubscriptions is not empty:
  1628. create_calendarsubscription_files
  1629. else
  1630. # print info, that there are no calendarsubscriptions in the installation, (if table calendarsubscriptions is empty):
  1631. _output printf '%s\n' "+ No calendarsubscriptions found."
  1632. fi
  1633. else
  1634. # ownCloud < 9.0 has no support for calendarsubscriptions
  1635. _output printf '%s\n' "+ skipping calendarsubscriptions, because ownCloud < 9.0 does not support them."
  1636. fi
  1637. # if set: unset array with usernames (and eventually passwords), if we do not plan to get addressbooks as well:
  1638. if [[ -n ${user:-} ]]; then
  1639. [[ ${backup_addressbooks} == "yes" ]] || unset_user_array
  1640. fi
  1641. }
  1642. get_addressbooks() {
  1643. # gets addressbooks and saves them in backup-folder. Have a look at used functions for more detailed explanations.
  1644. query_database "${table_addressbooks}" "addressbook"
  1645. # run include_shares only, if configured and addressbooks found in database:
  1646. [[ ${include_shares} == "yes" && -n ${id:-} ]] && include_shares
  1647. get_icsvcf_files "addressbook" ".vcf" "BEGIN:VCARD" "${carddav}" "${extra_users}"
  1648. # unset array with usernames (and eventually also passwords) if run with option -u:
  1649. [[ -z ${user:-} ]] || unset_user_array
  1650. }
  1651. check_for_backup_files() {
  1652. # checks for files in backup-directory and prints warning, if there are no files (meaning absolutely nothing has been created/downloaded).
  1653. local backupfolder_content
  1654. backupfolder_content="$(ls -A "${backupfolder_day}")"
  1655. if [[ -z ${backupfolder_content} ]]; then
  1656. printf '%s\n' "-- WARNING: No files in backup directory - meaning no backup created !!" >&2
  1657. backup_files_present="no"
  1658. rm -r "${backupfolder_day}" # remove empty backup folder
  1659. else
  1660. backup_files_present="yes"
  1661. fi
  1662. }
  1663. pack_it() {
  1664. # compresses backup
  1665. _output printf '%s\n' "+ Compressing backup as *.${compression_method} file. Be patient - this may take a while."
  1666. # change to backupfolder to make sure there won't be any funky paths in compressed file:
  1667. cd "${backupfolder}"
  1668. # compress backup using the configured method (zip or tar.gz):
  1669. if [[ ${compression_method} == "zip" ]]; then
  1670. # use zip to compress folder with backed up files:
  1671. zip -r -q "calcardbackup${day}.zip" "calcardbackup${day}" || error_exit "ERROR: Compressing the files produced an error. See lines right above."
  1672. else
  1673. # use tar.gz to compress folder with backed up files:
  1674. tar -czf "calcardbackup${day}.tar.gz" "calcardbackup${day}" || error_exit "ERROR: Compressing the files produced an error. See lines right above."
  1675. fi
  1676. # switch back to original directory:
  1677. cd "${working_dir}"
  1678. # delete folder with uncompressed ics/vcf-files:
  1679. rm -r "${backupfolder_day}"
  1680. # path to backup needs to be stored in case backup shall be encrypted or calcardbackup is running in batch-mode (-b):
  1681. path_to_backup="${backupfolder}/calcardbackup${day}.${compression_method}"
  1682. _output printf '%s\n' "+ Backup successfully compressed!"
  1683. # if backup shall NOT be encrypted, print path to backup:
  1684. # (use || instead of && so that function returns true in any case - corrected in ver. 0.1.2)
  1685. [[ ${encrypt_backup} == "yes" ]] || _output printf '%s\n' "+ Find your compressed backup here: ${path_to_backup}"
  1686. }
  1687. gpg_encrypt_backup() {
  1688. # encrypts compressed backup
  1689. # compose path to encrypted backup by adding ".gpg" to the path/filename of compressed backup:
  1690. local path_encrypted_backup="${path_to_backup}.gpg"
  1691. # encrypt compressed backup file:
  1692. ${gpgcommand} --passphrase-file <(printf '%s\n' "${gpg_passphrase}") --cipher-algo AES256 --batch --no-tty --yes --output "${path_encrypted_backup}" -c "${path_to_backup}" || error_exit "ERROR: Encrypting the compressed backup did not work!" "Check messages above."
  1693. # command to decrypt: gpg --passphrase-file <(printf '%s\n' "${gpg_passphrase}") --batch --output decrypted_output_file --decrypt encrypted_file
  1694. # delete passphrase from memory:
  1695. unset -v gpg_passphrase
  1696. # delete compressed backup
  1697. rm "${path_to_backup}"
  1698. # path_to_backup has changed and needs to be stored, in case calcardbackup is running in batch-mode (-b):
  1699. path_to_backup="${path_encrypted_backup}"
  1700. _output printf '%s\n' "+ Backup successfully encrypted!"
  1701. _output printf '%s\n' "+ Find your encrypted backup here: ${path_to_backup}"
  1702. }
  1703. set_mtime_command() {
  1704. # creates command to retrieve last modification time of file depending on Operating System (needed for deleting old backups)
  1705. # *BSD, Darwin, Minix use different options to stat than Linux or SunOS
  1706. case $(uname) in
  1707. *BSD|Darwin|Minix )
  1708. # this is for BSD (FreeBSD, OpenBSD, NetBSD, DragonflyBSD), Darwin (Mac OS X) and Minix
  1709. mtime_cmd="stat"
  1710. mtime_pre_options="-f %m"
  1711. ;;
  1712. * ) # Linux|GNU|SunOS and others
  1713. # this is for Linux, GNU Hurd and successors of OpenSolaris (OpenIndiana, SmartOS, OmniOSce)
  1714. if command -v stat > /dev/null; then
  1715. mtime_cmd="stat"
  1716. mtime_pre_options="-c %Y"
  1717. else
  1718. # OpenIndiana (illumos) doesn't have command 'stat', so we need to use 'date -r [FILE] +%s' instead:
  1719. mtime_cmd="date"
  1720. mtime_pre_options="-r"
  1721. mtime_post_options="+%s"
  1722. fi
  1723. ;;
  1724. esac
  1725. }
  1726. unset_mtime_command() {
  1727. # unsets variables used for command to retrieve last modification time of file (set in set_mtime_command)
  1728. unset -v mtime_cmd mtime_pre_options mtime_post_options
  1729. }
  1730. keep_like_time_machine() {
  1731. # keeps old backups like apples timemachine:
  1732. # - keeps daily backups for the last $keep_days_like_time_machine days
  1733. # - keeps weekly backups for the time before (backups created on mondays will be kept)
  1734. local old i last_modified deleted
  1735. # set options depending on OS:
  1736. set_mtime_command
  1737. _output printf '%s\n' "+ deleting old backups like time machine (keep daily for the last ${keep_days_like_time_machine} days; weekly before):"
  1738. # get current unix timestamp:
  1739. old=$(date +%s)
  1740. # substract days from today (1 day = 86400 seconds) - backups older than this which are not created on a monday will be deleted:
  1741. old=$(( old - ( keep_days_like_time_machine * 86400 ) ))
  1742. # loop through files in backup directory:
  1743. for i in "${backupfolder}"/calcardbackup*; do
  1744. # get unix timestamp of last modification of this backup:
  1745. last_modified="$(${mtime_cmd} ${mtime_pre_options} "${i}" ${mtime_post_options:-})"
  1746. # check, if backup is old enough to be deleted and has not been created on a monday:
  1747. if [[ ${last_modified} -lt ${old} && $(printf '%(%u)T' "${last_modified}") -ne 1 ]]; then
  1748. # print filename and remove backup:
  1749. _output printf '%s\n' "+ - ${i}"
  1750. rm -rf "${i}"
  1751. # set $deleted to 1, so we know that at least one file has been deleted:
  1752. deleted=1
  1753. fi
  1754. done
  1755. # print notice, if nothing has been deleted:
  1756. [[ ${deleted:-0} -eq 1 ]] || _output printf '%s\n' "+ --> no backups found to be deleted like time machine."
  1757. # unset options, if not needed anymore:
  1758. [[ ${delete_backups_older_than} -gt 0 ]] || unset_mtime_command
  1759. }
  1760. delete_old_backups() {
  1761. # deletes backups older than the configured amount of days ($delete_backups_older_than)
  1762. local old i last_modified deleted
  1763. _output printf '%s\n' "+ deleting backups older than ${delete_backups_older_than} days:"
  1764. # get current unix timestamp:
  1765. old=$(date +%s)
  1766. # substract days from today (1 day = 86400 seconds) - backups older than this will be deleted:
  1767. old=$(( old - ( delete_backups_older_than * 86400 ) ))
  1768. # set options depending on OS, if not already set (in function keep_like_time_machine())
  1769. [[ ${keep_days_like_time_machine} -gt 0 ]] || set_mtime_command
  1770. # loop through files in backup directory:
  1771. for i in "${backupfolder}"/calcardbackup*; do
  1772. # get unix timestamp of last modification of this backup:
  1773. last_modified="$(${mtime_cmd} ${mtime_pre_options} "${i}" ${mtime_post_options:-})"
  1774. # check, if modification time is old enough for backup to be deleted:
  1775. if [[ ${last_modified} -lt ${old} ]]; then
  1776. # print filename and remove backup:
  1777. _output printf '%s\n' "+ - ${i}"
  1778. rm -rf "${i}"
  1779. # set $deleted to 1, so we know that at least one file has been deleted:
  1780. deleted=1
  1781. fi
  1782. done
  1783. unset_mtime_command
  1784. # print notice, if nothing has been deleted:
  1785. [[ ${deleted:-0} -eq 1 ]] || _output printf '%s\n' "+ --> no backups older than ${delete_backups_older_than} days found to delete."
  1786. }
  1787. finish() {
  1788. # last function to be executed. This is the end of calcardbackup
  1789. # prints END-message or, if in batch-mode (-b), only path to backup:
  1790. # print timestamp and END-message:
  1791. _output printf '%s\n' "+ $(date) --> END calcardbackup"
  1792. _output printf '%s\n' "+"
  1793. _output printf '%s\n' "+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++"
  1794. _output printf '%s\n'
  1795. # if running in batch mode and there are backed up files - print path to backup:
  1796. [[ ${mode:-} == "batch" && ${backup_files_present} == "yes" ]] && printf '%s\n' "${path_to_backup}"
  1797. # exit without error, because script catches errors and exits on error with error-code:
  1798. exit 0
  1799. }
  1800. curl_error() {
  1801. # prints to curls exit code according message
  1802. # ${cerror} is curls exit code
  1803. if [[ ${cerror} -eq 6 ]]; then
  1804. # curl error 6: could not resolve host:
  1805. if [[ ${nextcloud_url} == "${nextcloud_url_overwrite}" ]]; then
  1806. problem="Either that host is temporarily unavailable or 'overwrite.cli.url' in ${configphp} is wrong."
  1807. else
  1808. problem="Either that host is temporarily unavailable or given url is wrong."
  1809. fi
  1810. error_exit "ERROR: Curl error 6: could not resolve host \"${nextcloud_url}\"" "${problem}"
  1811. elif [[ ${cerror} -eq 60 ]]; then
  1812. # curl error 60: cannot authenticate certificate (probably self-signed certificate):
  1813. [[ -z ${config_file:-} ]] && error_exit "ERROR: Curl error 60: cannot authenticate peer certificate with known CA certificates." "You need to use option -s|--selfsigned"
  1814. [[ -n ${config_file:-} ]] && error_exit "ERROR: Curl error 60: cannot authenticate peer certificate with known CA certificates." "You need to configure trustful_certificate=\"no\" in config file."
  1815. fi
  1816. # error message for any other (not 6 nor 60) curl error:
  1817. error_exit "ERROR: Curl cannot get the requested file. This can have various reasons." "For clarification lookup Curl Error number ${cerror}"
  1818. }
  1819. error_exit() {
  1820. # prints error message and exits script
  1821. # if any arguments are passed, they all will be printed as error message to stderr
  1822. printf '%s\n' "-- calcardbackup: ERROR --" >&2
  1823. # print all arguments that have been passed to this function as error message to stderr:
  1824. for message in "$@"; do
  1825. printf '%s\n' "-- ${message}" >&2
  1826. done
  1827. printf '%s\n' "-- calcardbackup: Exiting." >&2
  1828. # exit with error code 64, because I read somewhere that 1 is reserved.
  1829. # Not so sure about that anymore though, but error code stays 64 for backwards compatibility:
  1830. exit 64
  1831. }
  1832. print_help() {
  1833. # prints short help text
  1834. _output printf '%s\n' "+ Bash script to backup calendars and addressbooks from a local ownCloud/Nextcloud installation."
  1835. _output printf '%s\n' "+"
  1836. _output printf '%s\n' "+ Usage: ./calcardbackup [DIRECTORY] [option [argument]] [option [argument]] [option [argument]] ..."
  1837. _output printf '%s\n' "+ Find more details in attached file 'README.md' or visit '${origin_repository}'"
  1838. _output printf '%s\n' "+"
  1839. _output printf '%s\n' "+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++"
  1840. _output printf '%s\n'
  1841. }
  1842. check_argument() {
  1843. # checks for argument, if an option passed on command line needs an argument
  1844. # if option was last on command line and no argument is following:
  1845. [[ -z ${1:-} ]] && error_exit "Option '${option}' requires an additional argument."
  1846. # if option was not last on command line, but an option is following instead of an argument:
  1847. # (use || instead of && so that function returns true in any case - corrected in ver. 0.4.2-3)
  1848. [[ ! ${1} =~ ${options_regex} ]] || error_exit "Invalid argument for option '${option}'"
  1849. }
  1850. ###
  1851. ### END: FUNCTION DEFINITIONS
  1852. ###
  1853. # as very first action check for option -b|--batch and set mode accordingly
  1854. # (needed already here to supress printing of header when using option -b|--batch):
  1855. for (( i=1; i <= ${#}; i++ )); do
  1856. [[ ${!i} =~ ^(-b|--batch)$ ]] && mode="batch"
  1857. done
  1858. print_header
  1859. set_required_paths
  1860. load_default_values
  1861. ###
  1862. ### BEGIN: parse command line for options/arguments
  1863. ###
  1864. # if no option or only option -b/--batch given: configure script to read config file from script_dir
  1865. [[ ${#} -eq 0 || ( ${#} -eq 1 && ${1} =~ ^(-b|--batch)$ ) ]] && config_file="${script_dir}/calcardbackup.conf"
  1866. # regex matching all available options:
  1867. options_regex='^-(([a-i]|ltm|n(a|c)|o(ne)?|p|r|s|u|x|z)|-(address|batch|configfile|date|encrypt|fetch-from-database|get-via-http|help|include-shares|like-time-machine|no-(addressbooks|calendars)|output|one-file-per-component|snap|remove|selfsigned|usersfile|uncompressed|zip))$'
  1868. # check whether first given argument is an available option for this script:
  1869. if [[ ${#} -gt 0 && ! ${1} =~ ${options_regex} ]]; then
  1870. # use first argument as path to ownCloud/Nextcloud, since it does not match any of the available options for this script:
  1871. nextcloud_path="${1}"
  1872. shift
  1873. fi
  1874. # read options and their arguments and store them in according variables:
  1875. while [[ ${#} -gt 0 ]]
  1876. do
  1877. # store option for error message, in case an invalid argument for this option is provided
  1878. # (needed because of possibly required shift to get argument for option)
  1879. option="${1}"
  1880. case ${1} in
  1881. -a | --address )
  1882. shift
  1883. check_argument "${1:-}"
  1884. nextcloud_url="${1}"
  1885. ;;
  1886. -b | --batch )
  1887. # this variable is already set (as very first action of this script)
  1888. # no need to set it again:
  1889. : # mode="batch"
  1890. ;;
  1891. -c | --configfile )
  1892. shift
  1893. check_argument "${1:-}"
  1894. config_file="${1}"
  1895. ;;
  1896. -d | --date )
  1897. shift
  1898. check_argument "${1:-}"
  1899. date_extension="${1}"
  1900. ;;
  1901. -e | --encrypt )
  1902. shift
  1903. check_argument "${1:-}"
  1904. encrypt_backup="yes"
  1905. passphrase_file="${1}"
  1906. ;;
  1907. -f | --fetch-from-database )
  1908. # option -f is the default for calcardbackup >= 0.8.0
  1909. fetch_from_database="yes"
  1910. # make sure -f overrides -g, if both (-f and -g) are given (see option -g below):
  1911. f=1
  1912. ;;
  1913. -g | --get-via-http )
  1914. # option -g is deprecated and not recommended anymore!
  1915. # this used to be the default for calcardbackup <= v0.7.2
  1916. # make sure -f overrides -g, if both (-f and -g) are given (see above):
  1917. [[ ${f:-} -eq 1 ]] || fetch_from_database="no"
  1918. ;;
  1919. -h | --help )
  1920. print_help
  1921. exit 0
  1922. ;;
  1923. -i | --include-shares )
  1924. include_shares="yes"
  1925. ;;
  1926. -ltm | --like-time-machine )
  1927. shift
  1928. check_argument "${1:-}"
  1929. keep_days_like_time_machine="${1}"
  1930. ;;
  1931. -na | --no-addressbooks )
  1932. backup_addressbooks="no"
  1933. ;;
  1934. -nc | --no-calendars )
  1935. backup_calendars="no"
  1936. ;;
  1937. -o | --output )
  1938. shift
  1939. check_argument "${1:-}"
  1940. backupfolder="${1}"
  1941. ;;
  1942. -one | --one-file-per-component )
  1943. one_file_per_component="yes"
  1944. ;;
  1945. -p | --snap )
  1946. snap="yes"
  1947. ;;
  1948. -r | --remove )
  1949. shift
  1950. check_argument "${1:-}"
  1951. delete_backups_older_than="${1}"
  1952. ;;
  1953. -s | --selfsigned )
  1954. trustful_certificate="no"
  1955. ;;
  1956. -u | --usersfile )
  1957. shift
  1958. check_argument "${1:-}"
  1959. users_file="${1}"
  1960. ;;
  1961. -x | --uncompressed )
  1962. compress="no"
  1963. ;;
  1964. -z | --zip )
  1965. compression_method="zip"
  1966. ;;
  1967. * )
  1968. _output printf '%s\n' "-- WARNING! Unrecognized option: ${1}"
  1969. ;;
  1970. esac
  1971. shift
  1972. done
  1973. # unset variables not needed anymore:
  1974. unset -v options_regex f
  1975. ###
  1976. ### END: parse command line for options/arguments
  1977. ###
  1978. preparations
  1979. read_config_php
  1980. [[ ${curl_installed} == "yes" ]] && read_status_php
  1981. detect_vendor
  1982. get_database_details
  1983. # only read file with user credentials, if we are not doing a complete backup from database:
  1984. [[ ${complete_backup_from_database:-} == "yes" ]] || read_users_txt
  1985. create_backup_subfolder
  1986. if [[ ${backup_calendars} != "no" ]]
  1987. then get_calendars
  1988. else _output printf '%s\n' "+ Not backing up calenders as configured."
  1989. fi
  1990. if [[ ${backup_addressbooks} != "no" ]]
  1991. then get_addressbooks
  1992. else _output printf '%s\n' "+ Not backing up addressbooks as configured."
  1993. fi
  1994. check_for_backup_files
  1995. if [[ ${backup_files_present} == "yes" ]]; then
  1996. if [[ ${compress} == "no" ]]
  1997. then _output printf '%s\n' "+ Find your uncompressed backup in folder ${backupfolder_day}/"
  1998. else pack_it
  1999. fi
  2000. [[ ${encrypt_backup} == "yes" ]] && gpg_encrypt_backup
  2001. fi
  2002. [[ ${keep_days_like_time_machine} -gt 0 ]] && keep_like_time_machine
  2003. [[ ${delete_backups_older_than} -gt 0 ]] && delete_old_backups
  2004. finish