workshop_core.sh 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. #!/bin/bash
  2. # LinuxGSM workshop_core.sh module
  3. # Author: Daniel Gibbs
  4. # Contributors: https://linuxgsm.com/contrib
  5. # Website: https://linuxgsm.com
  6. # Description: Downloads and installs Steam Workshop mods for game servers that cannot download them.
  7. moduleselfname="$(basename "$(readlink -f "${BASH_SOURCE[0]}")")"
  8. # Installed mods are named ${workshopprefix}<id> in the directory the game loads them from.
  9. if [ "${shortname}" == "squad" ]; then
  10. workshopdir="${systemdir}/Plugins/Mods"
  11. workshopprefix=""
  12. else
  13. workshopdir="${serverfiles}/workshop"
  14. workshopprefix="@"
  15. fi
  16. workshopdldir="${tmpdir}/workshop"
  17. workshopmodlist="${datadir}/${selfname}-workshop.txt"
  18. workshopapi="https://api.steampowered.com/ISteamRemoteStorage"
  19. workshopapimax="100"
  20. workshopretries="5"
  21. # Converts workshopmods into an ordered list of IDs. Accepts space, comma or semicolon separators.
  22. fn_workshop_config_ids() {
  23. workshopconfigids=()
  24. local id
  25. for id in ${workshopmods//[,;]/ }; do
  26. if [[ "${id}" =~ ^[0-9]+$ ]]; then
  27. workshopconfigids+=("${id}")
  28. else
  29. fn_print_warn_nl "Ignoring invalid Workshop ID: ${id}"
  30. fn_script_log_warn "Ignoring invalid Workshop ID: ${id}"
  31. fi
  32. done
  33. }
  34. # Queries the Steam Web API. Usage: fn_workshop_api <GetCollectionDetails|GetPublishedFileDetails> <id>...
  35. fn_workshop_api() {
  36. local method="$1"
  37. shift
  38. local countparam="itemcount"
  39. if [ "${method}" == "GetCollectionDetails" ]; then
  40. countparam="collectioncount"
  41. fi
  42. local postdata=("--data-urlencode" "${countparam}=$#")
  43. local index=0
  44. local id
  45. for id in "$@"; do
  46. postdata+=("--data-urlencode" "publishedfileids[${index}]=${id}")
  47. index=$((index + 1))
  48. done
  49. curl -sSL --connect-timeout 10 --max-time 60 "${postdata[@]}" "${workshopapi}/${method}/v1/"
  50. }
  51. # Records which IDs are collections and their children, in batches.
  52. fn_workshop_get_collections() {
  53. local ids=("$@")
  54. local start response
  55. for ((start = 0; start < ${#ids[@]}; start += workshopapimax)); do
  56. if ! response="$(fn_workshop_api GetCollectionDetails "${ids[@]:start:workshopapimax}")" || ! jq -e '.response' <<< "${response}" > /dev/null 2>&1; then
  57. fn_print_fail_nl "Checking for update: ${remotelocation}: Unable to get collection details from Steam"
  58. fn_script_log_fail "Checking for update: ${remotelocation}: Unable to get collection details from Steam"
  59. core_exit.sh
  60. fi
  61. local id result children
  62. while IFS=$'\t' read -r id result children; do
  63. workshopchecked["${id}"]=1
  64. if [ "${result}" == "1" ]; then
  65. workshopchildren["${id}"]="${children}"
  66. fi
  67. done < <(jq -r '.response.collectiondetails[] | [.publishedfileid, (.result | tostring), ([.children[]? | "\(.publishedfileid):\(.filetype)"] | join(" "))] | @tsv' <<< "${response}")
  68. done
  69. }
  70. # Adds an ID to workshopitems, expanding collections in their sort order.
  71. fn_workshop_expand() {
  72. local id="$1"
  73. local depth="$2"
  74. if [ -n "${workshopseen[${id}]}" ]; then
  75. return
  76. fi
  77. workshopseen["${id}"]=1
  78. if [ -n "${workshopchildren[${id}]+set}" ]; then
  79. if [ "${depth}" -gt 3 ]; then
  80. fn_print_warn_nl "Skipping Workshop collection ${id}: collections are nested too deeply"
  81. fn_script_log_warn "Skipping Workshop collection ${id}: collections are nested too deeply"
  82. return
  83. fi
  84. local child
  85. for child in ${workshopchildren[${id}]}; do
  86. fn_workshop_expand "${child%%:*}" $((depth + 1))
  87. done
  88. else
  89. workshopitems+=("${id}")
  90. fi
  91. }
  92. # Builds workshopitems: the ordered list of Workshop items to install, with collections expanded.
  93. fn_workshop_resolve() {
  94. declare -gA workshopchecked=()
  95. declare -gA workshopchildren=()
  96. declare -gA workshopseen=()
  97. workshopitems=()
  98. fn_workshop_config_ids
  99. if [ "${#workshopconfigids[@]}" -eq 0 ]; then
  100. return
  101. fi
  102. local tocheck=("${workshopconfigids[@]}")
  103. local round id child
  104. for ((round = 0; round < 4 && ${#tocheck[@]} > 0; round++)); do
  105. fn_workshop_get_collections "${tocheck[@]}"
  106. local nested=()
  107. for id in "${tocheck[@]}"; do
  108. for child in ${workshopchildren[${id}]}; do
  109. if [ "${child##*:}" == "2" ] && [ -z "${workshopchecked[${child%%:*}]}" ]; then
  110. nested+=("${child%%:*}")
  111. fi
  112. done
  113. done
  114. tocheck=("${nested[@]}")
  115. done
  116. for id in "${workshopconfigids[@]}"; do
  117. fn_workshop_expand "${id}" 0
  118. done
  119. }
  120. # Gets title, game appid and last updated time for each item in workshopitems.
  121. fn_workshop_get_details() {
  122. declare -gA workshoptitle=()
  123. declare -gA workshopappid=()
  124. declare -gA workshoptimeupdated=()
  125. local validitems=()
  126. local start response
  127. for ((start = 0; start < ${#workshopitems[@]}; start += workshopapimax)); do
  128. if ! response="$(fn_workshop_api GetPublishedFileDetails "${workshopitems[@]:start:workshopapimax}")" || ! jq -e '.response' <<< "${response}" > /dev/null 2>&1; then
  129. fn_print_fail_nl "Checking for update: ${remotelocation}: Unable to get Workshop item details from Steam"
  130. fn_script_log_fail "Checking for update: ${remotelocation}: Unable to get Workshop item details from Steam"
  131. core_exit.sh
  132. fi
  133. local id result appid timeupdated title
  134. while IFS=$'\t' read -r id result appid timeupdated title; do
  135. if [ "${result}" != "1" ] || [ -z "${timeupdated}" ]; then
  136. fn_print_warn_nl "Skipping Workshop item ${id}: not found, or not public"
  137. fn_script_log_warn "Skipping Workshop item ${id}: not found, or not public"
  138. continue
  139. fi
  140. workshoptitle["${id}"]="${title}"
  141. workshopappid["${id}"]="${appid}"
  142. workshoptimeupdated["${id}"]="${timeupdated}"
  143. done < <(jq -r '.response.publishedfiledetails[] | [.publishedfileid, (.result | tostring), (.consumer_app_id // "" | tostring), (.time_updated // "" | tostring), (.title // "" | gsub("[\t\n\r]"; " "))] | @tsv' <<< "${response}")
  144. done
  145. local item
  146. for item in "${workshopitems[@]}"; do
  147. if [ -n "${workshoptimeupdated[${item}]}" ]; then
  148. validitems+=("${item}")
  149. fi
  150. done
  151. workshopitems=("${validitems[@]}")
  152. }
  153. # Reads a value from an installed mod's state file. Usage: fn_workshop_state <id> <key>
  154. fn_workshop_state() {
  155. local statefile="${workshopdir}/${workshopprefix}${1}/.lgsm-workshop"
  156. if [ -f "${statefile}" ]; then
  157. sed -n "s/^${2}=//p" "${statefile}"
  158. fi
  159. }
  160. # Works out which mods need downloading and which installed mods are no longer used.
  161. fn_workshop_compare() {
  162. workshopupdates=()
  163. workshopremovals=()
  164. local item
  165. for item in "${workshopitems[@]}"; do
  166. if [ "$(fn_workshop_state "${item}" timeupdated)" != "${workshoptimeupdated[${item}]}" ]; then
  167. workshopupdates+=("${item}")
  168. fi
  169. done
  170. if [ -d "${workshopdir}" ]; then
  171. # Mods used by any instance sharing these serverfiles are kept.
  172. local inuse=" ${workshopitems[*]} "
  173. local listfile
  174. for listfile in "${datadir}"/*-workshop.txt; do
  175. if [ -f "${listfile}" ] && [ "${listfile}" != "${workshopmodlist}" ]; then
  176. inuse+="$(grep -v '^#' "${listfile}" | tr '\n' ' ') "
  177. fi
  178. done
  179. local statefile moddir
  180. for statefile in "${workshopdir}"/*/.lgsm-workshop; do
  181. if [ -f "${statefile}" ]; then
  182. moddir="${statefile%/*}"
  183. moddir="${moddir##*/}"
  184. item="${moddir#"${workshopprefix}"}"
  185. if [[ "${inuse}" != *" ${item} "* ]]; then
  186. workshopremovals+=("${item}")
  187. fi
  188. fi
  189. done
  190. fi
  191. }
  192. # Downloads Workshop items with a single SteamCMD login. Failed items are retried.
  193. fn_workshop_download() {
  194. local pending=("$@")
  195. local attempt=0
  196. local item params
  197. steamcmdlog="${lgsmlogdir}/${selfname}-steamcmd.log"
  198. rm -rf "${workshopdldir:?}"
  199. mkdir -p "${workshopdldir}"
  200. if [ -d "${steamcmddir}" ]; then
  201. cd "${steamcmddir}" || exit
  202. fi
  203. unbuffercommand=()
  204. if [ "$(command -v unbuffer 2> /dev/null)" ]; then
  205. unbuffercommand=("unbuffer")
  206. fi
  207. while [ "${#pending[@]}" -gt 0 ] && [ "${attempt}" -lt "${workshopretries}" ]; do
  208. attempt=$((attempt + 1))
  209. params=(+force_install_dir "${workshopdldir}" +login "${steamuser}" "${steampass}")
  210. for item in "${pending[@]}"; do
  211. params+=(+workshop_download_item "${workshopappid[${item}]}" "${item}")
  212. done
  213. params+=(+quit)
  214. rm -f "${steamcmdlog:?}"
  215. "${unbuffercommand[@]}" "${steamcmdcommand}" "${params[@]}" | uniq | tee -a "${lgsmlog}" "${steamcmdlog}"
  216. if grep -q "FAILED (" "${steamcmdlog}" || grep -q "Two-factor code mismatch" "${steamcmdlog}"; then
  217. fn_print_failure_nl "${commandaction} ${selfname}: ${remotelocation}: Steam login failed"
  218. fn_print_nl "Check steamuser and steampass in ${configdirserver}"
  219. fn_script_log_fail "${commandaction} ${selfname}: ${remotelocation}: Steam login failed"
  220. core_exit.sh
  221. fi
  222. local failed=()
  223. for item in "${pending[@]}"; do
  224. if [ -z "$(find "${workshopdldir}/steamapps/workshop/content/${workshopappid[${item}]}/${item}" -mindepth 1 -print -quit 2> /dev/null)" ]; then
  225. failed+=("${item}")
  226. fi
  227. done
  228. pending=("${failed[@]}")
  229. if [ "${#pending[@]}" -gt 0 ] && [ "${attempt}" -lt "${workshopretries}" ]; then
  230. fn_print_error2_nl "${commandaction} ${selfname}: ${remotelocation}: ${#pending[@]} item(s) did not download, retrying"
  231. fn_script_log_error "${commandaction} ${selfname}: ${remotelocation}: ${#pending[@]} item(s) did not download, retrying"
  232. fi
  233. done
  234. workshopfailed=("${pending[@]}")
  235. if [ "${#workshopfailed[@]}" -gt 0 ]; then
  236. fn_print_failure_nl "${commandaction} ${selfname}: ${remotelocation}: ${#workshopfailed[@]} item(s) failed to download"
  237. for item in "${workshopfailed[@]}"; do
  238. fn_print_nl "* ${workshoptitle[${item}]} (${item})"
  239. fn_script_log_fail "${commandaction} ${selfname}: ${remotelocation}: ${workshoptitle[${item}]} (${item}) failed to download"
  240. done
  241. fn_print_nl "The Steam account must own the game for its Workshop items to download"
  242. fi
  243. }
  244. # Renames files and directories in a mod to lowercase, deepest first.
  245. fn_workshop_lowercase() {
  246. local path name lower
  247. while IFS= read -r -d '' path; do
  248. name="${path##*/}"
  249. lower="${name,,}"
  250. if [ -e "${path%/*}/${lower}" ]; then
  251. fn_script_log_warn "Lowercase name already exists, not renaming: ${path}"
  252. else
  253. mv "${path}" "${path%/*}/${lower}"
  254. fi
  255. done < <(find "$1" -mindepth 1 -depth -name '*[[:upper:]]*' -print0)
  256. }
  257. # Removes the BattlEye keys a mod installed, unless another installed mod also uses them.
  258. fn_workshop_remove_keys() {
  259. local item="$1"
  260. local key statefile
  261. for key in $(fn_workshop_state "${item}" keys); do
  262. local shared=""
  263. for statefile in "${workshopdir}"/*/.lgsm-workshop; do
  264. if [ "${statefile}" != "${workshopdir}/${workshopprefix}${item}/.lgsm-workshop" ] && [[ " $(sed -n 's/^keys=//p' "${statefile}" 2> /dev/null) " == *" ${key} "* ]]; then
  265. shared=1
  266. fi
  267. done
  268. if [ -z "${shared}" ]; then
  269. rm -f "${serverfiles:?}/keys/${key}"
  270. fi
  271. done
  272. }
  273. # Removes the links a mod added to the Starbound mods directory.
  274. fn_workshop_remove_links() {
  275. local link
  276. for link in $(fn_workshop_state "$1" links); do
  277. rm -f "${serverfiles:?}/mods/${link}"
  278. done
  279. }
  280. # Moves a downloaded item into the game's mod directory and prepares it for the game.
  281. fn_workshop_install() {
  282. local item="$1"
  283. local src="${workshopdldir}/steamapps/workshop/content/${workshopappid[${item}]}/${item}"
  284. local dst="${workshopdir}/${workshopprefix}${item}"
  285. local keys=()
  286. local links=()
  287. local key pak
  288. fn_print_dots "Installing ${workshoptitle[${item}]} (${item})"
  289. if [ -d "${dst}" ]; then
  290. fn_workshop_remove_keys "${item}"
  291. fn_workshop_remove_links "${item}"
  292. rm -rf "${dst:?}"
  293. fi
  294. mkdir -p "${workshopdir}"
  295. if ! mv "${src}" "${dst}"; then
  296. fn_print_fail_nl "Installing ${workshoptitle[${item}]} (${item})"
  297. fn_script_log_fail "Installing ${workshoptitle[${item}]} (${item})"
  298. core_exit.sh
  299. fi
  300. if [ "${shortname}" == "arma3" ] || [ "${shortname}" == "dayz" ]; then
  301. fn_workshop_lowercase "${dst}"
  302. mkdir -p "${serverfiles}/keys"
  303. while IFS= read -r -d '' key; do
  304. cp -f "${key}" "${serverfiles}/keys/"
  305. keys+=("${key##*/}")
  306. done < <(find "${dst}" -type f -name '*.bikey' -print0)
  307. elif [ "${shortname}" == "sb" ]; then
  308. mkdir -p "${serverfiles}/mods"
  309. while IFS= read -r -d '' pak; do
  310. ln -sfn "../workshop/${workshopprefix}${item}/${pak##*/}" "${serverfiles}/mods/workshop-${item}-${pak##*/}"
  311. links+=("workshop-${item}-${pak##*/}")
  312. done < <(find "${dst}" -maxdepth 1 -type f -name '*.pak' -print0)
  313. fi
  314. {
  315. echo "title=${workshoptitle[${item}]}"
  316. echo "timeupdated=${workshoptimeupdated[${item}]}"
  317. echo "keys=${keys[*]}"
  318. echo "links=${links[*]}"
  319. } > "${dst}/.lgsm-workshop"
  320. fn_print_ok_nl "Installing ${workshoptitle[${item}]} (${item})"
  321. fn_script_log_pass "Installing ${workshoptitle[${item}]} (${item})"
  322. }
  323. # Removes an installed mod that is no longer in workshopmods.
  324. fn_workshop_remove() {
  325. local item="$1"
  326. local title
  327. title="$(fn_workshop_state "${item}" title)"
  328. fn_print_dots "Removing ${title} (${item})"
  329. fn_workshop_remove_keys "${item}"
  330. fn_workshop_remove_links "${item}"
  331. rm -rf "${workshopdir:?}/${workshopprefix}${item}"
  332. fn_print_ok_nl "Removing ${title} (${item})"
  333. fn_script_log_pass "Removing ${title} (${item})"
  334. }
  335. # Installs downloaded items and removes unused ones. Downloads happen first, so the server only stops for this step.
  336. fn_workshop_apply() {
  337. local item
  338. for item in "${workshopinstalls[@]}"; do
  339. fn_workshop_install "${item}"
  340. done
  341. for item in "${workshopremovals[@]}"; do
  342. fn_workshop_remove "${item}"
  343. done
  344. rm -rf "${workshopdldir:?}"
  345. }
  346. # Saves the resolved mod order so start can build the mod list without querying Steam.
  347. fn_workshop_save_list() {
  348. if [ "${#workshopitems[@]}" -eq 0 ]; then
  349. rm -f "${workshopmodlist}"
  350. return
  351. fi
  352. {
  353. echo "# workshopmods=${workshopmods}"
  354. printf '%s\n' "${workshopitems[@]}"
  355. } > "${workshopmodlist}"
  356. }
  357. # Checks for Workshop mod updates and, for update commands, applies them.
  358. fn_workshop_update() {
  359. remotelocation="Steam Workshop"
  360. fn_print_dots "Checking for update: ${remotelocation}"
  361. fn_workshop_resolve
  362. if [ "${#workshopitems[@]}" -gt 0 ]; then
  363. fn_workshop_get_details
  364. fi
  365. fn_workshop_compare
  366. fn_print_ok_nl "Checking for update: ${remotelocation}"
  367. fn_script_log_info "Checking for update: ${remotelocation}"
  368. local item
  369. if [ "${#workshopupdates[@]}" -eq 0 ] && [ "${#workshopremovals[@]}" -eq 0 ]; then
  370. fn_print "\n"
  371. fn_print_nl "${bold}${underline}No update${default} available"
  372. fn_print_nl "* Workshop mods: ${#workshopitems[@]}"
  373. fn_print "\n"
  374. fn_script_log_info "No update available: ${remotelocation}"
  375. if [ "${commandname}" != "CHECK-UPDATE" ]; then
  376. fn_workshop_save_list
  377. fi
  378. return
  379. fi
  380. fn_print "\n"
  381. fn_print_nl "${bold}${underline}Update${default} available"
  382. for item in "${workshopupdates[@]}"; do
  383. if [ -d "${workshopdir}/${workshopprefix}${item}" ]; then
  384. fn_print_nl "* Update: ${workshoptitle[${item}]} (${item})"
  385. else
  386. fn_print_nl "* Install: ${workshoptitle[${item}]} (${item})"
  387. fi
  388. fn_script_log_info "Update available: ${workshoptitle[${item}]} (${item})"
  389. done
  390. for item in "${workshopremovals[@]}"; do
  391. fn_print_nl "* Remove: $(fn_workshop_state "${item}" title) (${item})"
  392. fn_script_log_info "Remove: $(fn_workshop_state "${item}" title) (${item})"
  393. done
  394. fn_print "\n"
  395. if [ "${commandname}" == "CHECK-UPDATE" ]; then
  396. return
  397. fi
  398. fn_workshop_save_list
  399. date '+%s' > "${lockdir:?}/update.lock"
  400. workshopinstalls=()
  401. workshopfailed=()
  402. if [ "${#workshopupdates[@]}" -gt 0 ]; then
  403. fn_print_start_nl "${remotelocation}"
  404. fn_workshop_download "${workshopupdates[@]}"
  405. for item in "${workshopupdates[@]}"; do
  406. if [[ " ${workshopfailed[*]} " != *" ${item} "* ]]; then
  407. workshopinstalls+=("${item}")
  408. fi
  409. done
  410. fi
  411. if [ "${#workshopinstalls[@]}" -eq 0 ] && [ "${#workshopremovals[@]}" -eq 0 ]; then
  412. rm -rf "${workshopdldir:?}"
  413. return
  414. fi
  415. check_status.sh
  416. if [ "${status}" == "0" ]; then
  417. fn_workshop_apply
  418. else
  419. fn_print_restart_warning
  420. exitbypass=1
  421. command_stop.sh
  422. fn_firstcommand_reset
  423. fn_workshop_apply
  424. exitbypass=1
  425. command_start.sh
  426. fn_firstcommand_reset
  427. fi
  428. unset exitbypass
  429. }
  430. # Adds installed Workshop mods to the mods start parameter, in workshopmods order.
  431. fn_workshop_mods_startparameters() {
  432. if [ -n "${workshopmodsadded}" ]; then
  433. return
  434. fi
  435. workshopmodsadded=1
  436. local items=()
  437. if [ -f "${workshopmodlist}" ]; then
  438. mapfile -t items < <(grep -v '^#' "${workshopmodlist}")
  439. if [ "$(sed -n 's/^# workshopmods=//p' "${workshopmodlist}")" != "${workshopmods}" ]; then
  440. fn_print_warn_nl "workshopmods has changed. Run ./${selfname} workshop-update to apply it"
  441. fn_script_log_warn "workshopmods has changed. Run ./${selfname} workshop-update to apply it"
  442. fi
  443. else
  444. fn_workshop_config_ids
  445. items=("${workshopconfigids[@]}")
  446. fi
  447. local workshopmodsparam=""
  448. local missing=0
  449. local item
  450. for item in "${items[@]}"; do
  451. if [ -d "${workshopdir}/${workshopprefix}${item}" ]; then
  452. workshopmodsparam+="${workshopmodsparam:+\\;}workshop/${workshopprefix}${item}"
  453. else
  454. missing=$((missing + 1))
  455. fi
  456. done
  457. if [ "${missing}" -gt 0 ]; then
  458. fn_print_warn_nl "${missing} Workshop mod(s) not installed. Run ./${selfname} workshop-update to install them"
  459. fn_script_log_warn "${missing} Workshop mod(s) not installed"
  460. fi
  461. if [ -n "${workshopmodsparam}" ]; then
  462. mods="${mods:+${mods}\\;}${workshopmodsparam}"
  463. fi
  464. }