package.rb 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. class Package < ApplicationModel
  3. @@root = Rails.root.to_s # rubocop:disable Style/ClassVars
  4. =begin
  5. verify if package is installed correctly
  6. package = Package.find(123)
  7. issues = package.verify
  8. returns:
  9. # if no issue exists
  10. nil
  11. # list of issues
  12. {
  13. 'path/to/file' => 'missing',
  14. 'path/to/file' => 'changed',
  15. }
  16. =end
  17. def verify
  18. # get package
  19. json_file = self.class._get_bin(name, version)
  20. package = JSON.parse(json_file)
  21. # verify installed files
  22. issues = {}
  23. package['files'].each do |file|
  24. if !File.exist?(file['location'])
  25. logger.error "File #{file['location']} is missing"
  26. issues[file['location']] = 'missing'
  27. next
  28. end
  29. content_package = Base64.decode64(file['content'])
  30. content_fs = self.class._read_file(file['location'])
  31. next if content_package == content_fs
  32. logger.error "File #{file['location']} is different"
  33. issues[file['location']] = 'changed'
  34. end
  35. return nil if issues.blank?
  36. issues
  37. end
  38. =begin
  39. install all packages located under auto_install/*.zpm
  40. Package.auto_install
  41. =end
  42. def self.auto_install
  43. path = "#{@@root}/auto_install/"
  44. return if !File.exist?(path)
  45. data = []
  46. Dir.foreach(path) do |entry|
  47. if entry.include?('.zpm') && entry !~ %r{^\.}
  48. data.push entry
  49. end
  50. end
  51. data.each do |file|
  52. install(file: "#{path}/#{file}")
  53. end
  54. data
  55. end
  56. =begin
  57. remove all linked files in application
  58. note: will not take down package migrations, use Package.unlink instead
  59. Package.unlink_all
  60. =end
  61. def self.unlink_all
  62. # link files
  63. Dir.glob("#{@@root}/**/*") do |entry|
  64. if File.symlink?(entry)
  65. logger.info "unlink: #{entry}"
  66. File.delete(entry)
  67. end
  68. backup_file = "#{entry}.link_backup"
  69. if File.exist?(backup_file)
  70. logger.info "Restore backup file of #{backup_file} -> #{entry}."
  71. File.rename(backup_file, entry)
  72. end
  73. end
  74. end
  75. # check if zpm is a package source repo
  76. def self._package_base_dir?(package_base_dir)
  77. package = false
  78. Dir.glob("#{package_base_dir}/*.szpm") do |entry|
  79. package = entry.sub(%r{^.*/(.+?)\.szpm$}, '\1')
  80. end
  81. if package == false
  82. raise "Can't link package, '#{package_base_dir}' is no package source directory!"
  83. end
  84. logger.debug { package.inspect }
  85. package
  86. end
  87. =begin
  88. execute migration down + unlink files
  89. Package.unlink('/path/to/src/extension')
  90. =end
  91. def self.unlink(package_base_dir)
  92. # check if zpm is a package source repo
  93. package = _package_base_dir?(package_base_dir)
  94. # migration down
  95. Package::Migration.migrate(package, 'reverse')
  96. # link files
  97. Dir.glob("#{package_base_dir}/**/*") do |entry|
  98. entry = entry.sub('//', '/')
  99. file = entry
  100. file = file.sub(%r{#{package_base_dir}}, '')
  101. dest = "#{@@root}/#{file}"
  102. if File.symlink?(dest.to_s)
  103. logger.info "Unlink file: #{dest}"
  104. File.delete(dest.to_s)
  105. end
  106. backup_file = "#{dest}.link_backup"
  107. if File.exist?(backup_file)
  108. logger.info "Restore backup file of #{backup_file} -> #{dest}."
  109. File.rename(backup_file, dest.to_s)
  110. end
  111. end
  112. end
  113. =begin
  114. link files
  115. Package.link('/path/to/src/extension')
  116. Migrations will not be executed because the the codebase was modified
  117. in the current process and is therefore inconsistent. This must be done
  118. subsequently in a separate step.
  119. =end
  120. def self.link(package_base_dir)
  121. # link files
  122. Dir.glob("#{package_base_dir}/**/*") do |entry|
  123. entry = entry.sub('//', '/')
  124. file = entry
  125. file = file.sub(%r{#{package_base_dir}}, '')
  126. file = file.sub(%r{^/}, '')
  127. # ignore files
  128. if file.start_with?('README')
  129. logger.info "NOTICE: Ignore #{file}"
  130. next
  131. end
  132. # get new file destination
  133. dest = "#{@@root}/#{file}"
  134. if File.directory?(entry.to_s) && !File.exist?(dest.to_s)
  135. logger.info "Create dir: #{dest}"
  136. FileUtils.mkdir_p(dest.to_s)
  137. end
  138. if File.file?(entry.to_s) && (File.file?(dest.to_s) && !File.symlink?(dest.to_s))
  139. backup_file = "#{dest}.link_backup"
  140. if File.exist?(backup_file)
  141. raise "Can't link #{entry} -> #{dest}, destination and .link_backup already exists!"
  142. end
  143. logger.info "Create backup file of #{dest} -> #{backup_file}."
  144. File.rename(dest.to_s, backup_file)
  145. end
  146. if File.file?(entry)
  147. if File.symlink?(dest.to_s)
  148. File.delete(dest.to_s)
  149. end
  150. logger.info "Link file: #{entry} -> #{dest}"
  151. File.symlink(entry.to_s, dest.to_s)
  152. end
  153. end
  154. end
  155. =begin
  156. install zpm package
  157. package = Package.install(file: '/path/to/package.zpm')
  158. or
  159. package = Package.install(string: zpm_as_string)
  160. returns
  161. package # record of newly created package
  162. Migrations will not be executed because the the codebase was modified
  163. in the current process and is therefore inconsistent. This must be done
  164. subsequently in a separate step.
  165. =end
  166. def self.install(data)
  167. if data[:file]
  168. json = _read_file(data[:file], true)
  169. package = JSON.parse(json)
  170. elsif data[:string]
  171. package = JSON.parse(data[:string])
  172. end
  173. # package meta data
  174. meta = {
  175. name: package['name'],
  176. version: package['version'],
  177. vendor: package['vendor'],
  178. url: package['url'],
  179. state: 'uninstalled',
  180. created_by_id: 1,
  181. updated_by_id: 1,
  182. }
  183. # verify if package can get installed
  184. package_db = Package.find_by(name: meta[:name])
  185. if package_db
  186. if !data[:reinstall]
  187. if Gem::Version.new(package_db.version) == Gem::Version.new(meta[:version])
  188. raise "Package '#{meta[:name]}-#{meta[:version]}' already installed!"
  189. end
  190. if Gem::Version.new(package_db.version) > Gem::Version.new(meta[:version])
  191. raise "Newer version (#{package_db.version}) of package '#{meta[:name]}-#{meta[:version]}' already installed!"
  192. end
  193. end
  194. # uninstall files of old package
  195. uninstall(
  196. name: package_db.name,
  197. version: package_db.version,
  198. migration_not_down: true,
  199. reinstall: data[:reinstall],
  200. )
  201. end
  202. Transaction.execute do
  203. # store package
  204. if !data[:reinstall]
  205. package_db = Package.create(meta)
  206. Store.create!(
  207. object: 'Package',
  208. o_id: package_db.id,
  209. data: package.to_json,
  210. filename: "#{meta[:name]}-#{meta[:version]}.zpm",
  211. preferences: {},
  212. created_by_id: UserInfo.current_user_id || 1,
  213. )
  214. end
  215. # write files
  216. package['files'].each do |file|
  217. if !allowed_file_path?(file['location'])
  218. raise "Can't create file, because of not allowed file location: #{file['location']}!"
  219. end
  220. ensure_no_duplicate_files!(package_db.name, file['location'])
  221. permission = file['permission'] || '644'
  222. content = Base64.decode64(file['content'])
  223. _write_file(file['location'], permission, content)
  224. end
  225. # update package state
  226. package_db.reload
  227. package_db.state = 'installed'
  228. package_db.save
  229. end
  230. package_db
  231. end
  232. def self.ensure_no_duplicate_files!(name, location)
  233. all_files.each do |check_package, check_files|
  234. next if check_package == name
  235. next if check_files.exclude?(location)
  236. raise "Can't create file, because file '#{location}' is already provided by package '#{check_package}'!"
  237. end
  238. end
  239. def self.all_files
  240. Auth::RequestCache.fetch_value('Package/all_files') do
  241. Package.all.each_with_object({}) do |package, result|
  242. json_file = Package._get_bin(package.name, package.version)
  243. package_json = JSON.parse(json_file)
  244. result[package.name] = package_json['files'].pluck('location')
  245. end
  246. end
  247. end
  248. def self.app_frontend_files?
  249. Auth::RequestCache.fetch_value('Package/app_frontend_files') do
  250. Package.all_files.values.flatten.any? { |f| f.starts_with?('app/frontend') }
  251. end
  252. end
  253. def self.gem_files?
  254. Dir['Gemfile.local.*'].present?
  255. end
  256. def self.app_package_installation?
  257. File.exist?('/usr/bin/zammad')
  258. end
  259. =begin
  260. reinstall package
  261. package = Package.reinstall(package_name)
  262. returns
  263. package # record of newly created package
  264. =end
  265. def self.reinstall(package_name)
  266. package = Package.find_by(name: package_name)
  267. if !package
  268. raise "No such package '#{package_name}'"
  269. end
  270. file = _get_bin(package.name, package.version)
  271. install(string: file, reinstall: true)
  272. package
  273. end
  274. =begin
  275. uninstall package
  276. package = Package.uninstall(name: 'package', version: '0.1.1')
  277. or
  278. package = Package.uninstall(string: zpm_as_string)
  279. returns
  280. package # record of newly created package
  281. =end
  282. def self.uninstall(data)
  283. if data[:string]
  284. package = JSON.parse(data[:string])
  285. else
  286. json_file = _get_bin(data[:name], data[:version])
  287. package = JSON.parse(json_file)
  288. end
  289. # down migrations
  290. if !data[:migration_not_down]
  291. Package::Migration.migrate(package['name'], 'reverse')
  292. end
  293. record = Package.find_by(
  294. name: package['name'],
  295. version: package['version'],
  296. )
  297. if record.state == 'installed'
  298. package['files'].each do |file|
  299. permission = file['permission'] || '644'
  300. content = Base64.decode64(file['content'])
  301. _delete_file(file['location'], permission, content)
  302. end
  303. end
  304. # delete package
  305. if data[:reinstall]
  306. record.update(state: 'uninstalled')
  307. else
  308. record.destroy
  309. end
  310. record
  311. end
  312. =begin
  313. execute all pending package migrations at once
  314. Package.migration_execute
  315. =end
  316. def self.migration_execute
  317. Package.all.each do |package|
  318. json_file = Package._get_bin(package.name, package.version)
  319. package = JSON.parse(json_file)
  320. Package::Migration.migrate(package['name'])
  321. end
  322. # sync package po files
  323. Translation.sync
  324. end
  325. def self._get_bin(name, version)
  326. package = Package.find_by(
  327. name: name,
  328. version: version,
  329. )
  330. if !package
  331. raise "No such package '#{name}' version '#{version}'"
  332. end
  333. list = Store.list(
  334. object: 'Package',
  335. o_id: package.id,
  336. )
  337. # find file
  338. if !list || !list.first
  339. raise "No such file in storage list #{name} #{version}"
  340. end
  341. if !list.first.content
  342. raise "No such file in storage #{name} #{version}"
  343. end
  344. list.first.content
  345. end
  346. def self._read_file(file, fullpath = false)
  347. location = case fullpath
  348. when false
  349. "#{@@root}/#{file}"
  350. when true
  351. file
  352. else
  353. "#{fullpath}/#{file}"
  354. end
  355. File.binread(location)
  356. end
  357. def self._write_file(file, permission, data)
  358. location = "#{@@root}/#{file}"
  359. # rename existing file if not already the same file
  360. if File.exist?(location)
  361. backup_location = "#{location}.save"
  362. content_fs = _read_file(file)
  363. if content_fs == data && File.exist?(backup_location)
  364. logger.debug { "NOTICE: file '#{location}' already exists, skip install" }
  365. return true
  366. end
  367. logger.info "NOTICE: backup old file '#{location}' to #{backup_location}"
  368. File.rename(location, backup_location)
  369. end
  370. # check if directories need to be created
  371. directories = location.split '/'
  372. (0..(directories.length - 2)).each do |position|
  373. tmp_path = ''
  374. (1..position).each do |count|
  375. tmp_path = "#{tmp_path}/#{directories[count]}"
  376. end
  377. next if tmp_path == ''
  378. next if File.exist?(tmp_path)
  379. Dir.mkdir(tmp_path, 0o755)
  380. end
  381. # install file
  382. logger.info "NOTICE: install '#{location}' (#{permission})"
  383. file = File.new(location, 'wb')
  384. file.write(data)
  385. file.close
  386. File.chmod(permission.to_s.to_i(8), location)
  387. true
  388. end
  389. def self._delete_file(file, _permission, _data)
  390. location = "#{@@root}/#{file}"
  391. # install file
  392. logger.info "NOTICE: uninstall '#{location}'"
  393. FileUtils.rm_rf(location)
  394. # rename existing file
  395. backup_location = "#{location}.save"
  396. if File.exist?(backup_location)
  397. logger.info "NOTICE: restore old file '#{backup_location}' to #{location}"
  398. File.rename(backup_location, location)
  399. end
  400. true
  401. end
  402. def self.allowed_file_path?(file)
  403. file.exclude?('..') && file.exclude?('%2e%2e')
  404. end
  405. private_class_method :allowed_file_path?
  406. end