require 'stringio' require 'zlib' # Initial implementation keeps every file payload and the final ZIP in memory. # Keep this service boundary stable so job/cached export paths can replace it later. class MaterialZipExporter Entry = Struct.new(:path, :data, :mtime, keyword_init: true) class EmptyExportError < StandardError; end class DuplicatePathError < StandardError; end def initialize profile: 'legacy_drive', tag_id: nil @profile = profile.presence || 'legacy_drive' @tag_id = tag_id.presence end def export entries = build_entries raise EmptyExportError if entries.empty? ZipWriter.write(entries) end private def build_entries rows = MaterialExportItem .enabled .includes(material: { file_attachment: :blob }) .joins(:material) .merge(Material.kept) .where(profile: @profile) .where(materials: { file_suppressed_at: nil }) .order(:export_path) rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id entries = rows.filter_map do |item| material = item.material next unless material.file.attached? Entry.new(path: item.export_path, data: material.file.blob.download, mtime: material.updated_at || Time.current) end paths = entries.map(&:path) duplicated = paths.find { |path| paths.count(path) > 1 } raise DuplicatePathError, duplicated if duplicated entries end class ZipWriter VERSION_NEEDED = 20 GP_FLAG = 0x0800 COMPRESSION_STORE = 0 def self.write entries new(entries).write end def initialize entries @entries = entries @central_directory = [] end def write io = StringIO.new(''.b) @entries.each do |entry| write_entry(io, entry) end central_start = io.pos @central_directory.each { |header| io.write(header) } central_size = io.pos - central_start io.write([0x06054b50, 0, 0, @entries.size, @entries.size, central_size, central_start, 0].pack('VvvvvVVv')) io.string end private def write_entry io, entry path = entry.path.b data = entry.data.b crc32 = Zlib.crc32(data) dos_time, dos_date = dos_timestamp(entry.mtime) offset = io.pos local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE, dos_time, dos_date, crc32, data.bytesize, data.bytesize, path.bytesize, 0].pack('VvvvvvVVVvv') io.write(local_header) io.write(path) io.write(data) @central_directory << central_header(path:, crc32:, size: data.bytesize, dos_time:, dos_date:, offset:) end def central_header path:, crc32:, size:, dos_time:, dos_date:, offset: [0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE, dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0, offset].pack('VvvvvvvVVVvvvvvVV') + path end def dos_timestamp time local = time.to_time dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2) dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day [dos_time, dos_date] end end end