# frozen_string_literal: true require "yaml" require "zip" class ZippedSiteStructure attr_reader :zip def initialize(path, create: false) @zip = Zip::File.open(path, create) @uploads = {} end def close @zip.close end def set(name, data) @zip.get_output_stream("#{name}.json") { |file| file.write(data.to_json) } end def get(name) data = @zip.get_input_stream("#{name}.json").read JSON.parse(data) end def set_upload(upload_or_id_or_url) return nil if upload_or_id_or_url.blank? if Integer === upload_or_id_or_url upload = Upload.find_by(id: upload_or_id_or_url) elsif String === upload_or_id_or_url upload = Upload.get_from_url(upload_or_id_or_url) elsif Upload === upload_or_id_or_url upload = upload_or_id_or_url end if !upload STDERR.puts "ERROR: Could not find upload #{upload_or_id_or_url.inspect}" return nil end if @uploads[upload.id].present? puts " - Already exported upload #{upload_or_id_or_url} to #{@uploads[upload.id][:path]}" return @uploads[upload.id] end local_path = upload.local? ? Discourse.store.path_for(upload) : Discourse.store.download(upload).path zip_path = File.join("uploads", File.basename(local_path)) zip_path = get_unique_path(zip_path) puts " - Exporting upload #{upload_or_id_or_url} to #{zip_path}" @zip.add(zip_path, local_path) @uploads[upload.id] ||= { filename: upload.original_filename, path: zip_path } end def get_upload(upload, opts = {}) return nil if upload.blank? if @uploads[upload["path"]].present? puts " - Already imported upload #{upload["filename"]} from #{upload["path"]}" return @uploads[upload["path"]] end puts " - Importing upload #{upload["filename"]} from #{upload["path"]}" tempfile = Tempfile.new(upload["filename"], binmode: true) tempfile.write(@zip.get_input_stream(upload["path"]).read) tempfile.rewind @uploads[upload["path"]] ||= UploadCreator.new(tempfile, upload["filename"], opts).create_for( Discourse::SYSTEM_USER_ID, ) end private def get_unique_path(path) return path if @zip.find_entry(path).blank? extname = File.extname(path) basename = File.basename(path, extname) dirname = File.dirname(path) i = 0 loop do i += 1 path = File.join(dirname, "#{basename}_#{i}#{extname}") return path if @zip.find_entry(path).blank? end end end desc "Exports site structure (settings, groups, categories, tags, themes, etc) to a ZIP file" task "site:export_structure", [:zip_path] => :environment do |task, args| if args[:zip_path].blank? STDERR.puts "ERROR: rake site:export_structure[]" exit 1 elsif File.exist?(args[:zip_path]) STDERR.puts "ERROR: File '#{args[:zip_path]}' already exists" exit 2 end data = ZippedSiteStructure.new(args[:zip_path], create: true) puts puts "Exporting site settings" puts settings = {} SiteSetting .all_settings(include_hidden: true) .each do |site_setting| next if site_setting[:default] == site_setting[:value] puts "- Site setting #{site_setting[:setting]} -> #{site_setting[:value].inspect}" settings[site_setting[:setting]] = if site_setting[:type] == "upload" data.set_upload(site_setting[:value]) else site_setting[:value] end end data.set("site_settings", settings) puts puts "Exporting users" puts users = [] User .real .where(admin: true) .each do |u| puts "- User #{u.username}" users << { username: u.username, name: u.name, email: u.email, active: u.active, admin: u.admin, } end data.set("users", users) puts puts "Exporting groups" puts groups = [] Group .where(automatic: false) .each do |g| puts "- Group #{g.name}" groups << { name: g.name, automatic_membership_email_domains: g.automatic_membership_email_domains, primary_group: g.primary_group, title: g.title, grant_trust_level: g.grant_trust_level, incoming_email: g.incoming_email, has_messages: g.has_messages, flair_bg_color: g.flair_bg_color, flair_color: g.flair_color, bio_raw: g.bio_raw, allow_membership_requests: g.allow_membership_requests, full_name: g.full_name, default_notification_level: g.default_notification_level, visibility_level: g.visibility_level, public_exit: g.public_exit, public_admission: g.public_admission, membership_request_template: g.membership_request_template, messageable_level: g.messageable_level, mentionable_level: g.mentionable_level, publish_read_state: g.publish_read_state, members_visibility_level: g.members_visibility_level, flair_icon: g.flair_icon, flair_upload_id: data.set_upload(g.flair_upload_id), allow_unknown_sender_topic_replies: g.allow_unknown_sender_topic_replies, } end data.set("groups", groups) puts puts "Exporting categories" puts categories = [] Category.find_each do |c| puts "- Category #{c.name} (#{c.slug})" categories << { name: c.name, color: c.color, slug: c.slug, description: c.description, text_color: c.text_color, read_restricted: c.read_restricted, auto_close_hours: c.auto_close_hours, parent_category: c.parent_category&.slug, position: c.position, email_in: c.email_in, email_in_allow_strangers: c.email_in_allow_strangers, allow_badges: c.allow_badges, auto_close_based_on_last_post: c.auto_close_based_on_last_post, topic_template: c.topic_template, sort_order: c.sort_order, sort_ascending: c.sort_ascending, uploaded_logo_id: data.set_upload(c.uploaded_logo_id), uploaded_logo_dark_id: data.set_upload(c.uploaded_logo_dark_id), uploaded_background_id: data.set_upload(c.uploaded_background_id), uploaded_background_dark_id: data.set_upload(c.uploaded_background_dark_id), topic_featured_link_allowed: c.topic_featured_link_allowed, all_topics_wiki: c.all_topics_wiki, show_subcategory_list: c.show_subcategory_list, default_view: c.default_view, subcategory_list_style: c.subcategory_list_style, default_top_period: c.default_top_period, mailinglist_mirror: c.mailinglist_mirror, minimum_required_tags: c.minimum_required_tags, navigate_to_first_post_after_read: c.navigate_to_first_post_after_read, search_priority: c.search_priority, allow_global_tags: c.allow_global_tags, read_only_banner: c.read_only_banner, default_list_filter: c.default_list_filter, permissions: c.permissions_params, } end data.set("categories", categories) puts puts "Exporting tag groups" puts tag_groups = [] TagGroup.all.each do |tg| puts "- Tag group #{tg.name}" tag_groups << { name: tg.name, tag_names: tg.tags.map(&:name) } end data.set("tag_groups", tag_groups) puts puts "Exporting tags" puts tags = [] Tag.find_each do |t| puts "- Tag #{t.name}" tag = { name: t.name } tag[:target_tag] = t.target_tag.name if t.target_tag.present? tags << tag end data.set("tags", tags) puts puts "Exporting themes and theme components" puts themes = [] Theme.find_each do |theme| puts "- Theme #{theme.name}" if theme.remote_theme.present? themes << { name: theme.name, url: theme.remote_theme.remote_url, private_key: theme.remote_theme.private_key, branch: theme.remote_theme.branch, } else exporter = ThemeStore::ZipExporter.new(theme) file_path = exporter.package_filename file_zip_path = File.join("themes", File.basename(file_path)) data.zip.add(file_zip_path, file_path) themes << { name: theme.name, filename: File.basename(file_path), path: file_zip_path } end end data.set("themes", themes) puts puts "Exporting theme settings" puts theme_settings = [] ThemeSetting.find_each do |theme_setting| puts "- Theme setting #{theme_setting.name} -> #{theme_setting.value}" value = if theme_setting.data_type == ThemeSetting.types[:upload] data.set_upload(theme_setting.value) else theme_setting.value end theme_settings << { name: theme_setting.name, data_type: theme_setting.data_type, value: value, theme: theme_setting.theme.name, } end data.set("theme_settings", theme_settings) puts puts "Done" puts data.close end desc "Imports site structure from a ZIP file exported by site:export_structure" task "site:import_structure", [:zip_path] => :environment do |task, args| if args[:zip_path].blank? STDERR.puts "ERROR: rake site:import_structure[]" exit 1 elsif !File.exist?(args[:zip_path]) STDERR.puts "ERROR: File '#{args[:zip_path]}' does not exist" exit 2 end data = ZippedSiteStructure.new(args[:zip_path]) puts puts "Importing site settings" puts settings = data.get("site_settings") imported_settings = Set.new 3.times.each do |try| puts "Loading site settings (try ##{try})" settings.each do |key, value| next if imported_settings.include?(key) begin if SiteSetting.type_supervisor.get_type(key) == :upload value = data.get_upload(value, for_site_setting: true) end if SiteSetting.public_send(key) != value puts "- Site setting #{key} -> #{value}" SiteSetting.set_and_log(key, value) end imported_settings << key rescue => e next if try < 2 STDERR.puts "ERROR: Cannot set #{key} to #{value}" puts e.backtrace end end end puts puts "Importing users" puts data .get("users") .each do |u| puts "- User #{u["username"]}" begin user = User.find_or_initialize_by(username: u.delete("username")) user.update!(u) rescue => e STDERR.puts "ERROR: Cannot import user: #{e.message}" puts e.backtrace end end puts puts "Importing groups" puts data .get("groups") .each do |g| puts "- Group #{g["name"]}" begin group = Group.find_or_initialize_by(name: g.delete("name")) group.update!(g) rescue => e STDERR.puts "ERROR: Cannot import group: #{e.message}" puts e.backtrace end end puts puts "Importing categories" puts data .get("categories") .each do |c| puts "- Category #{c["name"]} (#{c["slug"]})" begin category = Category.find_or_initialize_by(slug: c.delete("slug")) category.user ||= Discourse.system_user category.parent_category = Category.find_by(slug: c.delete("parent_category")) category.permissions = c.delete("permissions") category.update!(c) rescue => e STDERR.puts "ERROR: Cannot import category: #{e.message}" puts e.backtrace end end puts puts "Importing tag groups" puts data .get("tag_groups") .each do |tg| puts "- Tag group #{tg["name"]}" tag_group = TagGroup.find_or_initialize_by(name: tg.delete("name")) tag_group.update!(tg) end puts puts "Importing tags" puts data .get("tags") .each do |t| puts "- Tag #{t["name"]}" if t["target_tag"].present? begin t["target_tag"] = Tag.find_or_create_by!(name: t.delete("target_tag")) rescue => e STDERR.puts "ERROR: Cannot import target tag: #{e.message}" puts e.backtrace end end begin tag = Tag.find_or_initialize_by(name: t.delete("name")) tag.update!(t) rescue => e STDERR.puts "ERROR: Cannot import tag: #{e.message}" puts e.backtrace end end puts puts "Importing themes and theme components" puts data .get("themes") .each do |t| puts "- Theme #{t["name"]}" begin if t["url"].present? next if Theme.find_by(name: t["name"]).present? RemoteTheme.import_theme( t["url"], Discourse.system_user, private_key: t["private_key"], branch: t["branch"], ) elsif t["filename"].present? tempfile = Tempfile.new(t["filename"], binmode: true) tempfile.write(data.zip.get_input_stream(t["path"]).read) tempfile.flush RemoteTheme.update_zipped_theme( tempfile.path, t["filename"], user: Discourse.system_user, theme_id: Theme.find_by(name: t["name"])&.id, ) end rescue => e STDERR.puts "ERROR: Cannot import theme: #{e.message}" puts e.backtrace end end puts puts "Importing theme settings" puts data .get("theme_settings") .each do |ts| puts "- Theme setting #{ts["name"]} -> #{ts["value"]}" begin if ts["data_type"] == ThemeSetting.types[:upload] ts["value"] = data.get_upload(ts["value"], for_theme: true) end ThemeSetting.find_or_initialize_by( name: ts["name"], theme: Theme.find_by(name: ts["theme"]), ).update!(data_type: ts["data_type"], value: ts["value"]) rescue => e STDERR.puts "ERROR: Cannot import theme setting: #{e.message}" puts e.backtrace end end puts puts "Done" puts data.close end