diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..119a2bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +log/ +spec/dummy/log/ +spec/dummy/tmp/ +Gemfile.lock diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..7a2cc1a --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c46ae88 --- /dev/null +++ b/Gemfile @@ -0,0 +1,12 @@ +source "https://rubygems.org" + +gemspec + +gem "mysql2", "~> 0.5" + +group :development, :test do + gem "rspec-rails", "~> 7.0" + gem "factory_bot_rails" + gem "shoulda-matchers", "~> 6.0" + gem "debug", platforms: %i[mri windows] +end diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..e8de9d4 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +require "bundler/setup" + +APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__) +load "rails/tasks/engine.rake" + +require "rspec/core/rake_task" +RSpec::Core::RakeTask.new(:spec) +task default: :spec diff --git a/app/dtos/warp_engine/file_result_dto.rb b/app/dtos/warp_engine/file_result_dto.rb new file mode 100644 index 0000000..5cbe69c --- /dev/null +++ b/app/dtos/warp_engine/file_result_dto.rb @@ -0,0 +1,15 @@ +module WarpEngine + class FileResultDto + attr_reader :type, :path, :url + + def initialize(type:, path: nil, url: nil) + @type = type + @path = path + @url = url + end + + def self.file(path) = new(type: :file, path: path) + def self.redirect(url) = new(type: :redirect, url: url) + def self.not_found = new(type: :not_found) + end +end diff --git a/app/dtos/warp_engine/file_show_input_dto.rb b/app/dtos/warp_engine/file_show_input_dto.rb new file mode 100644 index 0000000..e4e6adb --- /dev/null +++ b/app/dtos/warp_engine/file_show_input_dto.rb @@ -0,0 +1,3 @@ +module WarpEngine + FileShowInputDto = Struct.new(:path, keyword_init: true) +end diff --git a/app/dtos/warp_engine/image_show_input_dto.rb b/app/dtos/warp_engine/image_show_input_dto.rb new file mode 100644 index 0000000..65af195 --- /dev/null +++ b/app/dtos/warp_engine/image_show_input_dto.rb @@ -0,0 +1,3 @@ +module WarpEngine + ImageShowInputDto = Struct.new(:id, keyword_init: true) +end diff --git a/app/dtos/warp_engine/update_input_dto.rb b/app/dtos/warp_engine/update_input_dto.rb new file mode 100644 index 0000000..7f64251 --- /dev/null +++ b/app/dtos/warp_engine/update_input_dto.rb @@ -0,0 +1,7 @@ +module WarpEngine + UpdateInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do + def initialize(platform:, name:, version: nil) + super + end + end +end diff --git a/app/serializers/concerns/warp_engine/timestamp_fields.rb b/app/serializers/concerns/warp_engine/timestamp_fields.rb new file mode 100644 index 0000000..0de9ca5 --- /dev/null +++ b/app/serializers/concerns/warp_engine/timestamp_fields.rb @@ -0,0 +1,6 @@ +module WarpEngine + module TimestampFields + GO_ZERO_TIME = "0001-01-01T00:00:00Z" + TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ" + end +end diff --git a/app/serializers/warp_engine/external_link_serializer.rb b/app/serializers/warp_engine/external_link_serializer.rb new file mode 100644 index 0000000..f60b72c --- /dev/null +++ b/app/serializers/warp_engine/external_link_serializer.rb @@ -0,0 +1,13 @@ +module WarpEngine + class ExternalLinkSerializer < Blueprinter::Base + include TimestampFields + + field(:id) { |el| el.id } + field(:createdAt) { |el| el.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:updatedAt) { |el| el.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:deletedAt) { |el| el.deleted_at&.utc&.strftime(TS_FORMAT) } + field(:softwareId) { |el| el.software_id } + field :label + field :url + end +end diff --git a/app/serializers/warp_engine/platform_link_serializer.rb b/app/serializers/warp_engine/platform_link_serializer.rb new file mode 100644 index 0000000..f6ad273 --- /dev/null +++ b/app/serializers/warp_engine/platform_link_serializer.rb @@ -0,0 +1,6 @@ +module WarpEngine + class PlatformLinkSerializer < Blueprinter::Base + field :name + field :url + end +end diff --git a/app/serializers/warp_engine/release_serializer.rb b/app/serializers/warp_engine/release_serializer.rb new file mode 100644 index 0000000..1ad2d3d --- /dev/null +++ b/app/serializers/warp_engine/release_serializer.rb @@ -0,0 +1,39 @@ +module WarpEngine + class ReleaseSerializer < Blueprinter::Base + include TimestampFields + + FILE_PATH_TO = "/file/" + + # A lemezen tárolt asset-útvonalak publikus /file/ URL-lé írása configból. + def self.file_path_from + "#{WarpEngine.config.file_container_path.chomp("/")}/" + end + + def self.assets_of(release) + release.association(:release_assets).loaded? ? release.release_assets : release.release_assets.to_a + end + + def self.asset_path(release, kind) + asset = assets_of(release).find { |a| a.kind == kind } + asset ? asset.path.gsub(file_path_from, FILE_PATH_TO) : "" + end + + field(:id) { |r| r.id } + field(:createdAt) { |r| r.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:updatedAt) { |r| r.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:deletedAt) { |r| r.deleted_at&.utc&.strftime(TS_FORMAT) } + field(:softwareId) { |r| r.software_id } + field :version + field(:cartridgePath) { |r| asset_path(r, "cartridge") } + field(:sourcePath) { |r| asset_path(r, "source") } + field(:htmlFolderPath) { |r| asset_path(r, "html") } + field(:docsFolderPath) { |r| asset_path(r, "docs") } + field(:assets) do |r| + assets_of(r).map { |a| { kind: a.kind, path: a.path.gsub(file_path_from, FILE_PATH_TO) } } + end + field(:downloadCount) do |r, opts| + counts = opts[:download_counts] + counts ? counts.fetch(r.id, 0) : r.downloads.count + end + end +end diff --git a/app/serializers/warp_engine/software_detail_serializer.rb b/app/serializers/warp_engine/software_detail_serializer.rb new file mode 100644 index 0000000..d553be4 --- /dev/null +++ b/app/serializers/warp_engine/software_detail_serializer.rb @@ -0,0 +1,9 @@ +module WarpEngine + class SoftwareDetailSerializer < Blueprinter::Base + field(:software) { |sw, _| SoftwareSerializer.render_as_hash(sw) } + field(:releases) { |_, opts| ReleaseSerializer.render_as_hash(opts[:releases], download_counts: opts[:download_counts]) } + field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil } + field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil } + field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 } + end +end diff --git a/app/serializers/warp_engine/software_serializer.rb b/app/serializers/warp_engine/software_serializer.rb new file mode 100644 index 0000000..069a472 --- /dev/null +++ b/app/serializers/warp_engine/software_serializer.rb @@ -0,0 +1,30 @@ +module WarpEngine + class SoftwareSerializer < Blueprinter::Base + include TimestampFields + + field(:id) { |sw| sw.id } + field(:createdAt) { |sw| sw.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:updatedAt) { |sw| sw.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:deletedAt) { |sw| sw.deleted_at&.utc&.strftime(TS_FORMAT) } + field :name + field :title + field :author + field(:desc) { |sw| sw.desc.to_s } + field(:story) { |sw| sw.story.to_s } + field(:license) { |sw| sw.license.to_s } + field :platform + field :status + field(:highlighted) { |sw| sw.highlighted ? true : false } + field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) } + field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) } + field(:imageUrl) { |sw| + si = sw.software_images.detect(&:is_default?) || sw.software_images.first + si ? "/api/image/#{si.image_id}" : nil + } + field(:images) { |sw| + sw.software_images.map { |si| + { url: "/api/image/#{si.image_id}", isDefault: si.is_default?, position: si.position } + } + } + end +end diff --git a/app/services/concerns/warp_engine/software_updater/archive_extraction.rb b/app/services/concerns/warp_engine/software_updater/archive_extraction.rb new file mode 100644 index 0000000..1c8ec46 --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/archive_extraction.rb @@ -0,0 +1,47 @@ +require "zip" +require "fileutils" + +module WarpEngine + module SoftwareUpdater + module ArchiveExtraction + private + + def base_path + @base_path ||= WarpEngine.config.file_container_path + end + + def full_path(filename) + File.join(base_path, filename) + end + + def dir_exists?(dirname) + File.directory?(full_path(dirname)) + end + + def delete_dir(dirname) + FileUtils.rm_rf(full_path(dirname)) + end + + def create_dir(dirname) + FileUtils.mkdir_p(full_path(dirname)) + end + + def unzip_file(zip_filename, dest_dirname) + dest = full_path(dest_dirname) + Zip::File.open(full_path(zip_filename)) do |zip| + zip.each do |entry| + entry_dest = File.join(dest, entry.name) + FileUtils.mkdir_p(File.dirname(entry_dest)) + entry.extract(entry_dest) { true } + end + end + end + + def extract_zip_to_dir(zip_file, dir_name) + delete_dir(dir_name) if dir_exists?(dir_name) + create_dir(dir_name) + unzip_file(zip_file, dir_name) + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_cartridge.rb b/app/services/concerns/warp_engine/software_updater/builds/build_cartridge.rb new file mode 100644 index 0000000..ff4ada6 --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_cartridge.rb @@ -0,0 +1,24 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildCartridge + extend ActiveSupport::Concern + + included do + register_expected_kind "cartridge" + end + + def cartridge_ext + raise NotImplementedError, "#{self.class} must implement #cartridge_ext" + end + + private + + def cartridge_asset_path(versioned) + path = full_path("#{versioned}#{cartridge_ext}") + { "cartridge" => path } if File.file?(path) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_docs.rb b/app/services/concerns/warp_engine/software_updater/builds/build_docs.rb new file mode 100644 index 0000000..a9f42eb --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_docs.rb @@ -0,0 +1,26 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildDocs + extend ActiveSupport::Concern + + included do + register_expected_kind "docs" + register_prepare_step :prepare_docs + end + + private + + def prepare_docs(versioned) + docs_zip = "#{versioned}-docs.zip" + extract_zip_to_dir(docs_zip, "#{versioned}-docs") if File.file?(full_path(docs_zip)) + end + + def docs_asset_path(versioned) + docs_dir = "#{versioned}-docs" + { "docs" => full_path(docs_dir) } if dir_exists?(docs_dir) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_linux_x64.rb b/app/services/concerns/warp_engine/software_updater/builds/build_linux_x64.rb new file mode 100644 index 0000000..f53406b --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_linux_x64.rb @@ -0,0 +1,20 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildLinuxX64 + extend ActiveSupport::Concern + + included do + register_expected_kind "linux_x64" + end + + private + + def linux_x64_asset_path(versioned) + path = full_path("#{versioned}-linux-x64.zip") + { "linux_x64" => path } if File.file?(path) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_mac_arm64.rb b/app/services/concerns/warp_engine/software_updater/builds/build_mac_arm64.rb new file mode 100644 index 0000000..17d3c55 --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_mac_arm64.rb @@ -0,0 +1,20 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildMacArm64 + extend ActiveSupport::Concern + + included do + register_expected_kind "mac_arm64" + end + + private + + def mac_arm64_asset_path(versioned) + path = full_path("#{versioned}-mac-arm64.zip") + { "mac_arm64" => path } if File.file?(path) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_mac_universal.rb b/app/services/concerns/warp_engine/software_updater/builds/build_mac_universal.rb new file mode 100644 index 0000000..003022a --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_mac_universal.rb @@ -0,0 +1,20 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildMacUniversal + extend ActiveSupport::Concern + + included do + register_expected_kind "mac_universal" + end + + private + + def mac_universal_asset_path(versioned) + path = full_path("#{versioned}-mac-universal.zip") + { "mac_universal" => path } if File.file?(path) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_mac_x64.rb b/app/services/concerns/warp_engine/software_updater/builds/build_mac_x64.rb new file mode 100644 index 0000000..eafa2bb --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_mac_x64.rb @@ -0,0 +1,20 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildMacX64 + extend ActiveSupport::Concern + + included do + register_expected_kind "mac_x64" + end + + private + + def mac_x64_asset_path(versioned) + path = full_path("#{versioned}-mac-x64.zip") + { "mac_x64" => path } if File.file?(path) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_source.rb b/app/services/concerns/warp_engine/software_updater/builds/build_source.rb new file mode 100644 index 0000000..04c8b99 --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_source.rb @@ -0,0 +1,24 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildSource + extend ActiveSupport::Concern + + included do + register_expected_kind "source" + end + + def source_ext + raise NotImplementedError, "#{self.class} must implement #source_ext" + end + + private + + def source_asset_path(versioned) + path = full_path("#{versioned}#{source_ext}") + { "source" => path } if File.file?(path) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_web.rb b/app/services/concerns/warp_engine/software_updater/builds/build_web.rb new file mode 100644 index 0000000..185851c --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_web.rb @@ -0,0 +1,25 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildWeb + extend ActiveSupport::Concern + + included do + register_expected_kind "html" + register_prepare_step :prepare_web + end + + private + + def prepare_web(versioned) + extract_zip_to_dir("#{versioned}.html.zip", versioned) + end + + def html_asset_path(versioned) + path = full_path(versioned) + { "html" => path } if dir_exists?(versioned) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_win_x64.rb b/app/services/concerns/warp_engine/software_updater/builds/build_win_x64.rb new file mode 100644 index 0000000..7ffaeb8 --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_win_x64.rb @@ -0,0 +1,20 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildWinX64 + extend ActiveSupport::Concern + + included do + register_expected_kind "win_x64" + end + + private + + def win_x64_asset_path(versioned) + path = full_path("#{versioned}-win-x64.zip") + { "win_x64" => path } if File.file?(path) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/builds/build_win_x86.rb b/app/services/concerns/warp_engine/software_updater/builds/build_win_x86.rb new file mode 100644 index 0000000..ab1750f --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/builds/build_win_x86.rb @@ -0,0 +1,20 @@ +module WarpEngine + module SoftwareUpdater + module Builds + module BuildWinX86 + extend ActiveSupport::Concern + + included do + register_expected_kind "win_x86" + end + + private + + def win_x86_asset_path(versioned) + path = full_path("#{versioned}-win-x86.zip") + { "win_x86" => path } if File.file?(path) + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/metadata_parsing.rb b/app/services/concerns/warp_engine/software_updater/metadata_parsing.rb new file mode 100644 index 0000000..805cc43 --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/metadata_parsing.rb @@ -0,0 +1,16 @@ +require "json" + +module WarpEngine + module SoftwareUpdater + module MetadataParsing + METADATA_KEYS = %i[name title author desc site repo license].freeze + + private + + def parse_json_metadata(path) + raw = JSON.parse(File.read(path), symbolize_names: true) + raw.slice(*METADATA_KEYS) + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/software_persistence.rb b/app/services/concerns/warp_engine/software_updater/software_persistence.rb new file mode 100644 index 0000000..5fc87e5 --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/software_persistence.rb @@ -0,0 +1,38 @@ +module WarpEngine + module SoftwareUpdater + module SoftwarePersistence + private + + def update_or_create_software(attrs) + software = WarpEngine::Software.unscoped.find_or_initialize_by(name: attrs[:name]) + software.assign_attributes(attrs.except(:name)) + software.deleted_at = nil + software.save! + software + end + + def upsert_external_link(software_id, label, url) + link = WarpEngine::ExternalLink.unscoped.find_or_initialize_by(software_id: software_id, label: label) + link.url = url + link.deleted_at = nil + link.save! + end + + def create_release_if_not_exists(attrs) + existing = WarpEngine::Release.unscoped.find_by(software_id: attrs[:software_id], version: attrs[:version]) + return existing if existing + + WarpEngine::Release.create!(attrs) + end + + def sync_release_assets(release, kind_paths) + kind_paths.each do |kind, path| + asset = WarpEngine::ReleaseAsset.unscoped.find_or_initialize_by(release_id: release.id, kind: kind) + asset.path = path + asset.deleted_at = nil + asset.save! + end + end + end + end +end diff --git a/app/services/concerns/warp_engine/software_updater/updatable.rb b/app/services/concerns/warp_engine/software_updater/updatable.rb new file mode 100644 index 0000000..ce2646a --- /dev/null +++ b/app/services/concerns/warp_engine/software_updater/updatable.rb @@ -0,0 +1,74 @@ +module WarpEngine + module SoftwareUpdater + module Updatable + extend ActiveSupport::Concern + include ArchiveExtraction + include MetadataParsing + include SoftwarePersistence + + class_methods do + def platform(value = nil) + @platform = value if value + @platform ||= name.demodulize.delete_suffix("Service").downcase + end + + def label(value = nil) + @label = value if value + @label ||= platform.titleize + end + + def expected_kinds + @expected_kinds ||= [] + end + + def register_expected_kind(kind) + expected_kinds << kind unless expected_kinds.include?(kind) + end + + def prepare_steps + @prepare_steps ||= [] + end + + def register_prepare_step(method_name) + prepare_steps << method_name unless prepare_steps.include?(method_name) + end + end + + def update(name, version) + versioned = "#{name}-#{version}" + prepare_files(versioned) + + metadata = parse_metadata(versioned) + site_url = metadata.delete(:site) + repo_url = metadata.delete(:repo) + + ActiveRecord::Base.transaction do + software = update_or_create_software(metadata.merge(platform: self.class.platform)) + upsert_external_link(software.id, "Source Code", site_url) if site_url.present? + upsert_external_link(software.id, "Repository", repo_url) if repo_url.present? + + release = create_release_if_not_exists(software_id: software.id, version: version) + sync_release_assets(release, collect_asset_paths(versioned)) + release + end + end + + private + + def prepare_files(versioned) + self.class.prepare_steps.each { |step| send(step, versioned) } + end + + def parse_metadata(versioned) + parse_json_metadata(full_path("#{versioned}.metadata.json")) + end + + def collect_asset_paths(versioned) + self.class.expected_kinds.each_with_object({}) do |kind, hash| + result = send(:"#{kind}_asset_path", versioned) + hash.merge!(result) if result + end + end + end + end +end diff --git a/app/services/warp_engine/builds_service.rb b/app/services/warp_engine/builds_service.rb new file mode 100644 index 0000000..270c41b --- /dev/null +++ b/app/services/warp_engine/builds_service.rb @@ -0,0 +1,39 @@ +module WarpEngine + class BuildsService + def index + platforms = WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash| + service_class = "WarpEngine::SoftwareUpdater::#{platform.camelize}Service".constantize + hash[platform] = { + label: service_class.label, + kinds: service_class.expected_kinds + } + end + + all_kinds = WarpEngine::ReleaseAsset::KINDS + + { platforms: platforms, allKinds: all_kinds } + end + + def show(name) + software = WarpEngine::Software.find_by!(name: name) + service_class = "WarpEngine::SoftwareUpdater::#{software.platform.camelize}Service".constantize + expected = service_class.expected_kinds + + releases = software.releases.includes(:release_assets).order(updated_at: :desc) + + release_data = releases.each_with_object({}) do |release, hash| + actual = release.release_assets.map(&:kind) + hash[release.version] = { + actual: actual, + missing: expected - actual + } + end + + { + platform: software.platform, + expected: expected, + releases: release_data + } + end + end +end diff --git a/app/services/warp_engine/download_service.rb b/app/services/warp_engine/download_service.rb new file mode 100644 index 0000000..f21f473 --- /dev/null +++ b/app/services/warp_engine/download_service.rb @@ -0,0 +1,34 @@ +module WarpEngine + class DownloadService + def self.container_base + WarpEngine.config.file_container_path + end + + # Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem. + def self.base_path + Pathname.new(container_base).realpath + end + + def create(path:, ip:, user_agent:, referer:) + sanitized = path.to_s + base_path = self.class.base_path + full_path = base_path.join(sanitized).realpath + return nil unless full_path.to_s.start_with?(base_path.to_s) + return nil unless File.file?(full_path) + + escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_") + asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, sanitized)) || + WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first + + WarpEngine::Download.create!( + file_path: sanitized, + release: asset&.release, + ip_address: ip, + user_agent: user_agent&.truncate(500), + referer: referer&.truncate(500) + ) + + full_path + end + end +end diff --git a/app/services/warp_engine/file_manager_service.rb b/app/services/warp_engine/file_manager_service.rb new file mode 100644 index 0000000..59e52e7 --- /dev/null +++ b/app/services/warp_engine/file_manager_service.rb @@ -0,0 +1,93 @@ +module WarpEngine + class FileManagerService + # Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem. + def base_path + @base_path ||= Pathname.new(WarpEngine.config.file_container_path) + end + + def list(relative_path = "") + full = safe_path!(relative_path) + raise ArgumentError, "Not a directory" unless full.directory? + + entries = full.children.sort_by { |c| [ c.directory? ? 0 : 1, c.basename.to_s.downcase ] } + entries.map do |child| + stat = child.stat + { + name: child.basename.to_s, + path: child.relative_path_from(base_path).to_s, + type: child.directory? ? :directory : :file, + size: child.directory? ? nil : stat.size, + mtime: stat.mtime + } + end + end + + MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB + + def upload(relative_dir, uploaded_file) + raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE + dir = safe_path!(relative_dir) + raise ArgumentError, "Not a directory" unless dir.directory? + + safe_name = sanitize_name(uploaded_file.original_filename) + target = dir.join(safe_name) + raise ArgumentError, "Path escape" unless target.to_s.start_with?(base_path.to_s) + + IO.copy_stream(uploaded_file.to_io, target.to_s) + target.relative_path_from(base_path).to_s + end + + def delete(relative_path) + full = safe_path!(relative_path) + raise ArgumentError, "Cannot delete root" if full == base_path + + if full.directory? + full.rmdir + else + full.delete + end + end + + def rename(relative_path, new_name) + full = safe_path!(relative_path) + raise ArgumentError, "Cannot rename root" if full == base_path + + safe_name = sanitize_name(new_name) + new_full = full.parent.join(safe_name) + raise ArgumentError, "Path escape" unless new_full.to_s.start_with?(base_path.to_s) + + full.rename(new_full) + new_full.relative_path_from(base_path).to_s + end + + def mkdir(relative_path, folder_name) + parent = safe_path!(relative_path) + raise ArgumentError, "Not a directory" unless parent.directory? + + safe_name = sanitize_name(folder_name) + new_dir = parent.join(safe_name) + raise ArgumentError, "Path escape" unless new_dir.to_s.start_with?(base_path.to_s) + + new_dir.mkdir + new_dir.relative_path_from(base_path).to_s + end + + private + + def safe_path!(relative_path) + cleaned = relative_path.to_s.gsub("..", "").squeeze("/").gsub(%r{^/|/$}, "") + full = base_path.join(cleaned) + resolved = full.exist? ? full.realpath : full.cleanpath + unless resolved.to_s.start_with?(base_path.to_s) + raise ArgumentError, "Path traversal detected" + end + resolved + end + + def sanitize_name(name) + name.to_s.gsub("..", "").gsub("/", "").gsub("\\", "").strip.tap do |n| + raise ArgumentError, "Invalid name" if n.blank? + end + end + end +end diff --git a/app/services/warp_engine/file_service.rb b/app/services/warp_engine/file_service.rb new file mode 100644 index 0000000..d50191e --- /dev/null +++ b/app/services/warp_engine/file_service.rb @@ -0,0 +1,35 @@ +module WarpEngine + class FileService + # Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem. + def self.base_path + Pathname.new(WarpEngine.config.file_container_path).realpath + end + + def show(input) + full_path = base_path.join(input.path.to_s) + return FileResultDto.not_found unless safe_path?(full_path) + + if File.directory?(full_path) + index_path = full_path.join("index.html") + return FileResultDto.not_found unless File.file?(index_path) + return FileResultDto.redirect("/file/#{input.path.to_s.chomp("/")}/index.html") + end + + if File.file?(full_path) + FileResultDto.file(full_path) + else + FileResultDto.not_found + end + end + + private + + def base_path + @base_path ||= self.class.base_path + end + + def safe_path?(path) + File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(base_path.to_s) + end + end +end diff --git a/app/services/warp_engine/image_service.rb b/app/services/warp_engine/image_service.rb new file mode 100644 index 0000000..69be38d --- /dev/null +++ b/app/services/warp_engine/image_service.rb @@ -0,0 +1,7 @@ +module WarpEngine + class ImageService + def show(input) + WarpEngine::Image.find(input.id) + end + end +end diff --git a/app/services/warp_engine/software_highlighted_service.rb b/app/services/warp_engine/software_highlighted_service.rb new file mode 100644 index 0000000..b898109 --- /dev/null +++ b/app/services/warp_engine/software_highlighted_service.rb @@ -0,0 +1,16 @@ +module WarpEngine + class SoftwareHighlightedService + include SoftwareResponseBuilder + + def index + software = WarpEngine::Software.includes(:external_links, :software_images) + .where(highlighted: true) + .order(id: :desc) + .first + return nil unless software + + releases = WarpEngine::Release.includes(:release_assets).where(software_id: software.id).to_a + build_response(software, releases, download_counts_for(releases.map(&:id))) + end + end +end diff --git a/app/services/warp_engine/software_response_builder.rb b/app/services/warp_engine/software_response_builder.rb new file mode 100644 index 0000000..a895ef1 --- /dev/null +++ b/app/services/warp_engine/software_response_builder.rb @@ -0,0 +1,27 @@ +module WarpEngine + module SoftwareResponseBuilder + private + + def build_response(software, releases, download_counts) + sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse + latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first + # web-playable, ha az utolsó (stabil) release-nek van webes assetje + web_playable = latest if latest&.release_assets&.any? { |a| a.kind == "html" } + total_downloads = releases.sum { |r| download_counts.fetch(r.id, 0) } + + SoftwareDetailSerializer.render_as_hash(software, + releases: sorted, + latest: latest, + web_playable: web_playable, + total_downloads: total_downloads, + download_counts: download_counts + ) + end + + # Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett). + def download_counts_for(release_ids) + return {} if release_ids.empty? + WarpEngine::Download.where(release_id: release_ids).group(:release_id).count + end + end +end diff --git a/app/services/warp_engine/software_service.rb b/app/services/warp_engine/software_service.rb new file mode 100644 index 0000000..45b2174 --- /dev/null +++ b/app/services/warp_engine/software_service.rb @@ -0,0 +1,11 @@ +module WarpEngine + class SoftwareService + include SoftwareResponseBuilder + + def index + softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all + counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) }) + { softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } } + end + end +end diff --git a/app/services/warp_engine/software_updater/bevy_service.rb b/app/services/warp_engine/software_updater/bevy_service.rb new file mode 100644 index 0000000..d6a6683 --- /dev/null +++ b/app/services/warp_engine/software_updater/bevy_service.rb @@ -0,0 +1,12 @@ +module WarpEngine + module SoftwareUpdater + class BevyService + include Updatable + include Builds::BuildWeb + include Builds::BuildWinX64 + include Builds::BuildLinuxX64 + + label "Bevy" + end + end +end diff --git a/app/services/warp_engine/software_updater/c64_service.rb b/app/services/warp_engine/software_updater/c64_service.rb new file mode 100644 index 0000000..978422a --- /dev/null +++ b/app/services/warp_engine/software_updater/c64_service.rb @@ -0,0 +1,12 @@ +module WarpEngine + module SoftwareUpdater + class C64Service + include Updatable + include Builds::BuildCartridge + + label "C64" + + def cartridge_ext = ".prg" + end + end +end diff --git a/app/services/warp_engine/software_updater/ebitengine_service.rb b/app/services/warp_engine/software_updater/ebitengine_service.rb new file mode 100644 index 0000000..4109562 --- /dev/null +++ b/app/services/warp_engine/software_updater/ebitengine_service.rb @@ -0,0 +1,15 @@ +module WarpEngine + module SoftwareUpdater + class EbitengineService + include Updatable + include Builds::BuildWeb + include Builds::BuildWinX86 + include Builds::BuildWinX64 + include Builds::BuildLinuxX64 + include Builds::BuildMacX64 + include Builds::BuildMacArm64 + + label "Ebitengine" + end + end +end diff --git a/app/services/warp_engine/software_updater/godot_service.rb b/app/services/warp_engine/software_updater/godot_service.rb new file mode 100644 index 0000000..4a72a55 --- /dev/null +++ b/app/services/warp_engine/software_updater/godot_service.rb @@ -0,0 +1,14 @@ +module WarpEngine + module SoftwareUpdater + class GodotService + include Updatable + include Builds::BuildWeb + include Builds::BuildWinX86 + include Builds::BuildWinX64 + include Builds::BuildLinuxX64 + include Builds::BuildMacUniversal + + label "Godot" + end + end +end diff --git a/app/services/warp_engine/software_updater/love_service.rb b/app/services/warp_engine/software_updater/love_service.rb new file mode 100644 index 0000000..667ce4b --- /dev/null +++ b/app/services/warp_engine/software_updater/love_service.rb @@ -0,0 +1,13 @@ +module WarpEngine + module SoftwareUpdater + class LoveService + include Updatable + include Builds::BuildWeb + include Builds::BuildWinX64 + include Builds::BuildLinuxX64 + include Builds::BuildMacUniversal + + label "LÖVE" + end + end +end diff --git a/app/services/warp_engine/software_updater/phaser_service.rb b/app/services/warp_engine/software_updater/phaser_service.rb new file mode 100644 index 0000000..3beb402 --- /dev/null +++ b/app/services/warp_engine/software_updater/phaser_service.rb @@ -0,0 +1,10 @@ +module WarpEngine + module SoftwareUpdater + class PhaserService + include Updatable + include Builds::BuildWeb + + label "Phaser" + end + end +end diff --git a/app/services/warp_engine/software_updater/tic80_service.rb b/app/services/warp_engine/software_updater/tic80_service.rb new file mode 100644 index 0000000..2eaa6f0 --- /dev/null +++ b/app/services/warp_engine/software_updater/tic80_service.rb @@ -0,0 +1,38 @@ +module WarpEngine + module SoftwareUpdater + class Tic80Service + include Updatable + include Builds::BuildCartridge + include Builds::BuildSource + include Builds::BuildWeb + include Builds::BuildDocs + include Builds::BuildWinX64 + include Builds::BuildLinuxX64 + include Builds::BuildMacX64 + + label "TIC-80" + + def cartridge_ext = ".tic" + def source_ext = ".lua" + + private + + def parse_metadata(versioned) + parse_lua_metadata(full_path("#{versioned}.lua")) + end + + def parse_lua_metadata(source_path) + metadata = {} + File.foreach(source_path) do |line| + break unless line.start_with?("--") + parts = line[2..].split(":", 2) + next if parts.length != 2 + key = parts[0].strip.downcase.to_sym + value = parts[1].strip + metadata[key] = value + end + metadata.slice(*MetadataParsing::METADATA_KEYS) + end + end + end +end diff --git a/app/services/warp_engine/update_service.rb b/app/services/warp_engine/update_service.rb new file mode 100644 index 0000000..5a498ec --- /dev/null +++ b/app/services/warp_engine/update_service.rb @@ -0,0 +1,11 @@ +module WarpEngine + class UpdateService + def update(input) + unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform) + raise ArgumentError, "Unsupported platform: #{input.platform}" + end + + "WarpEngine::SoftwareUpdater::#{input.platform.camelize}Service".constantize.new.update(input.name, input.version) + end + end +end diff --git a/lib/warp_engine.rb b/lib/warp_engine.rb index a396622..8ca83e8 100644 --- a/lib/warp_engine.rb +++ b/lib/warp_engine.rb @@ -1,3 +1,5 @@ +require "blueprinter" + require "warp_engine/version" require "warp_engine/configuration" diff --git a/spec/dummy/Rakefile b/spec/dummy/Rakefile new file mode 100644 index 0000000..7820749 --- /dev/null +++ b/spec/dummy/Rakefile @@ -0,0 +1,2 @@ +require_relative "config/application" +Rails.application.load_tasks diff --git a/spec/dummy/config.ru b/spec/dummy/config.ru new file mode 100644 index 0000000..5c59c6f --- /dev/null +++ b/spec/dummy/config.ru @@ -0,0 +1,4 @@ +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb new file mode 100644 index 0000000..616833d --- /dev/null +++ b/spec/dummy/config/application.rb @@ -0,0 +1,23 @@ +require_relative "boot" + +require "rails" +require "active_model/railtie" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" +require "action_dispatch/railtie" + +Bundler.require(*Rails.groups) +require "warp_engine" + +module Dummy + class Application < Rails::Application + config.load_defaults 8.0 + config.eager_load = false + + config.time_zone = "UTC" + config.active_record.default_timezone = :utc + + config.hosts.clear + end +end diff --git a/spec/dummy/config/boot.rb b/spec/dummy/config/boot.rb new file mode 100644 index 0000000..5624a9d --- /dev/null +++ b/spec/dummy/config/boot.rb @@ -0,0 +1,3 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) + +require "bundler/setup" diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml new file mode 100644 index 0000000..24441b0 --- /dev/null +++ b/spec/dummy/config/database.yml @@ -0,0 +1,8 @@ +test: + adapter: mysql2 + encoding: utf8mb4 + username: <%= ENV.fetch("DB_USER", "root") %> + password: <%= ENV.fetch("DB_PASSWORD", "") %> + host: <%= ENV.fetch("DB_HOST", "mysql") %> + port: <%= ENV.fetch("DB_PORT", "3306") %> + database: warp_engine_test diff --git a/spec/dummy/config/environment.rb b/spec/dummy/config/environment.rb new file mode 100644 index 0000000..73a3979 --- /dev/null +++ b/spec/dummy/config/environment.rb @@ -0,0 +1,3 @@ +require_relative "application" + +Rails.application.initialize! diff --git a/spec/dummy/config/environments/test.rb b/spec/dummy/config/environments/test.rb new file mode 100644 index 0000000..7388880 --- /dev/null +++ b/spec/dummy/config/environments/test.rb @@ -0,0 +1,7 @@ +Rails.application.configure do + config.cache_classes = true + config.consider_all_requests_local = true + config.action_dispatch.show_exceptions = :rescuable + config.active_support.deprecation = :stderr + config.log_level = :warn +end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb new file mode 100644 index 0000000..7de15df --- /dev/null +++ b/spec/dummy/config/routes.rb @@ -0,0 +1,3 @@ +Rails.application.routes.draw do + mount WarpEngine::Engine => "/" +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb new file mode 100644 index 0000000..32f8c5c --- /dev/null +++ b/spec/dummy/db/schema.rb @@ -0,0 +1,108 @@ +# A katalógus-táblák a host schema.rb-vel megegyező definícióval. +ActiveRecord::Schema[8.1].define(version: 1) do + create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", precision: 3 + t.datetime "deleted_at", precision: 3 + t.string "file_path", null: false + t.string "ip_address" + t.string "referer", limit: 500 + t.bigint "release_id", unsigned: true + t.datetime "updated_at", precision: 3 + t.string "user_agent", limit: 500 + t.index ["deleted_at"], name: "idx_downloads_deleted_at" + t.index ["file_path"], name: "index_downloads_on_file_path" + t.index ["release_id"], name: "index_downloads_on_release_id" + end + + create_table "external_links", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", precision: 3 + t.datetime "deleted_at", precision: 3 + t.string "label", limit: 128 + t.bigint "software_id", unsigned: true + t.datetime "updated_at", precision: 3 + t.string "url" + t.index ["deleted_at"], name: "idx_external_links_deleted_at" + t.index ["software_id"], name: "idx_external_links_software_id" + end + + create_table "images", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "content_type", default: "application/octet-stream", null: false + t.datetime "created_at", null: false + t.datetime "deleted_at", precision: 3 + t.string "filename", null: false + t.string "original_filename", null: false + t.datetime "updated_at", null: false + t.index ["deleted_at"], name: "idx_images_deleted_at" + end + + create_table "platform_links", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", precision: 3 + t.datetime "deleted_at", precision: 3 + t.string "name", limit: 128 + t.string "platform", limit: 128 + t.integer "position", default: 0, null: false + t.datetime "updated_at", precision: 3 + t.string "url" + t.index ["deleted_at"], name: "idx_platform_links_deleted_at" + t.index ["platform"], name: "idx_platform_links_platform" + end + + create_table "release_assets", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", precision: 3, null: false + t.datetime "deleted_at", precision: 3 + t.string "kind", limit: 32, null: false + t.string "path", null: false + t.bigint "release_id", null: false, unsigned: true + t.datetime "updated_at", precision: 3, null: false + t.index ["deleted_at"], name: "idx_release_assets_deleted_at" + t.index ["path"], name: "idx_release_assets_path" + t.index ["release_id", "kind"], name: "idx_release_assets_release_kind", unique: true + end + + create_table "releases", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", precision: 3 + t.datetime "deleted_at", precision: 3 + t.bigint "software_id", unsigned: true + t.datetime "updated_at", precision: 3 + t.string "version", limit: 64 + t.index ["deleted_at"], name: "idx_releases_deleted_at" + t.index ["software_id"], name: "idx_releases_software_id" + end + + create_table "software_images", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "image_id", null: false + t.boolean "is_default", default: false, null: false + t.integer "position", default: 0, null: false + t.bigint "software_id", null: false, unsigned: true + t.datetime "updated_at", null: false + t.index ["image_id"], name: "fk_rails_a456fbc278" + t.index ["software_id", "image_id"], name: "index_software_images_on_software_id_and_image_id", unique: true + t.index ["software_id", "position"], name: "index_software_images_on_software_id_and_position" + end + + create_table "softwares", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "author" + t.datetime "created_at", precision: 3 + t.datetime "deleted_at", precision: 3 + t.text "desc" + t.boolean "highlighted", default: false + t.string "license", limit: 128 + t.string "name", limit: 128 + t.string "platform", limit: 128 + t.string "site" + t.string "status", limit: 20, default: "development" + t.text "story" + t.string "title" + t.datetime "updated_at", precision: 3 + t.index ["deleted_at"], name: "idx_softwares_deleted_at" + t.index ["name"], name: "idx_softwares_name", unique: true + end + + add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify + add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade + add_foreign_key "release_assets", "releases", name: "fk_releases_release_assets", on_delete: :cascade + add_foreign_key "releases", "softwares", name: "fk_softwares_releases", on_delete: :cascade + add_foreign_key "software_images", "images" + add_foreign_key "software_images", "softwares", on_delete: :cascade +end diff --git a/spec/factories/downloads.rb b/spec/factories/downloads.rb new file mode 100644 index 0000000..c305277 --- /dev/null +++ b/spec/factories/downloads.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :download, class: "WarpEngine::Download" do + file_path { "/test/file.tic" } + ip_address { "127.0.0.1" } + end +end diff --git a/spec/factories/platform_links.rb b/spec/factories/platform_links.rb new file mode 100644 index 0000000..06b2ec7 --- /dev/null +++ b/spec/factories/platform_links.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :platform_link, class: "WarpEngine::PlatformLink" do + sequence(:name) { |n| "Link #{n}" } + platform { "tic80" } + url { "https://example.com/link" } + position { 0 } + end +end diff --git a/spec/factories/releases.rb b/spec/factories/releases.rb new file mode 100644 index 0000000..6a8bae1 --- /dev/null +++ b/spec/factories/releases.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :release, class: "WarpEngine::Release" do + software + sequence(:version) { |n| "1.0.#{n}" } + end +end diff --git a/spec/factories/softwares.rb b/spec/factories/softwares.rb new file mode 100644 index 0000000..353ceb7 --- /dev/null +++ b/spec/factories/softwares.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :software, class: "WarpEngine::Software" do + sequence(:name) { |n| "game-#{n}" } + title { "Test Game" } + author { "dev" } + platform { "tic80" } + status { "development" } + end +end diff --git a/spec/models/download_spec.rb b/spec/models/download_spec.rb new file mode 100644 index 0000000..aa196a7 --- /dev/null +++ b/spec/models/download_spec.rb @@ -0,0 +1,6 @@ +require "rails_helper" + +RSpec.describe WarpEngine::Download, type: :model do + it { should validate_presence_of(:file_path) } + it { should belong_to(:release).optional } +end diff --git a/spec/models/platform_link_spec.rb b/spec/models/platform_link_spec.rb new file mode 100644 index 0000000..b04ef90 --- /dev/null +++ b/spec/models/platform_link_spec.rb @@ -0,0 +1,52 @@ +require "rails_helper" + +RSpec.describe WarpEngine::PlatformLink, type: :model do + describe "validations" do + it { should validate_presence_of(:name) } + it { should validate_presence_of(:url) } + it { should validate_presence_of(:platform) } + + it "rejects invalid platform" do + link = build(:platform_link, platform: "invalid") + expect(link).not_to be_valid + expect(link.errors[:platform]).to be_present + end + + it "accepts valid platforms" do + WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |p| + link = build(:platform_link, platform: p) + expect(link).to be_valid + end + end + end + + describe "default scope" do + it "excludes soft-deleted records" do + active = create(:platform_link) + create(:platform_link, deleted_at: Time.current) + + expect(WarpEngine::PlatformLink.all).to eq([active]) + end + + it "orders by position" do + second = create(:platform_link, position: 2) + first = create(:platform_link, position: 1) + + expect(WarpEngine::PlatformLink.all).to eq([first, second]) + end + end + + describe ".for_platform" do + it "returns links for given platform only" do + tic80_link = create(:platform_link, platform: "tic80") + create(:platform_link, platform: "love") + + result = WarpEngine::PlatformLink.for_platform("tic80") + expect(result).to eq([tic80_link]) + end + + it "returns empty array for platform without links" do + expect(WarpEngine::PlatformLink.for_platform("godot")).to eq([]) + end + end +end diff --git a/spec/models/release_spec.rb b/spec/models/release_spec.rb new file mode 100644 index 0000000..1b5ebbb --- /dev/null +++ b/spec/models/release_spec.rb @@ -0,0 +1,23 @@ +require "rails_helper" + +RSpec.describe WarpEngine::Release, type: :model do + it { should belong_to(:software) } + it { should have_many(:downloads) } + + describe "version uniqueness" do + let(:software) { create(:software) } + + it "prevents duplicate versions for same software" do + create(:release, software: software, version: "1.0.0") + dup = build(:release, software: software, version: "1.0.0") + expect(dup).not_to be_valid + end + + it "allows same version across different software" do + other_sw = create(:software) + create(:release, software: software, version: "1.0.0") + other = build(:release, software: other_sw, version: "1.0.0") + expect(other).to be_valid + end + end +end diff --git a/spec/models/software_spec.rb b/spec/models/software_spec.rb new file mode 100644 index 0000000..19c348a --- /dev/null +++ b/spec/models/software_spec.rb @@ -0,0 +1,25 @@ +require "rails_helper" + +RSpec.describe WarpEngine::Software, type: :model do + subject { build(:software) } + + it { should validate_presence_of(:name) } + it { should validate_presence_of(:title) } + it { should validate_presence_of(:platform) } + # MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive + it { should validate_uniqueness_of(:name).case_insensitive } + + # a törlést a DB-szintű ON DELETE CASCADE végzi, a modellen nincs dependent opció + it { should have_many(:releases) } + it { should have_many(:external_links) } + it { should have_many(:software_images).dependent(:destroy) } + + describe "default scope" do + it "excludes soft-deleted records" do + active = create(:software) + create(:software, deleted_at: Time.current) + + expect(WarpEngine::Software.all).to eq([active]) + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000..6a43560 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,31 @@ +require "spec_helper" +ENV["RAILS_ENV"] ||= "test" +require_relative "dummy/config/environment" + +abort("The Rails environment is running in production mode!") if Rails.env.production? +require "rspec/rails" + +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end + +# A factory_bot_rails a dummy app gyökerében keresne; a factory-k az engine spec/ alatt élnek +FactoryBot.definition_file_paths = [ File.expand_path("factories", __dir__) ] +FactoryBot.reload + +RSpec.configure do |config| + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! + + config.include FactoryBot::Syntax::Methods +end + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end diff --git a/spec/services/builds_service_spec.rb b/spec/services/builds_service_spec.rb new file mode 100644 index 0000000..a19f529 --- /dev/null +++ b/spec/services/builds_service_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe WarpEngine::BuildsService do + describe "#index" do + subject(:result) { described_class.new.index } + + it "returns all supported platforms with expected kinds" do + expect(result[:platforms]).to have_key("tic80") + expect(result[:platforms]["tic80"][:label]).to eq("TIC-80") + expect(result[:platforms]["tic80"][:kinds]).to include("cartridge", "html") + end + + it "returns c64 with only cartridge" do + expect(result[:platforms]["c64"][:kinds]).to eq(["cartridge"]) + end + + it "returns allKinds matching WarpEngine::ReleaseAsset::KINDS" do + expect(result[:allKinds]).to eq(WarpEngine::ReleaseAsset::KINDS) + end + + it "includes all supported platforms" do + WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |platform| + expect(result[:platforms]).to have_key(platform) + end + end + end + + describe "#show" do + let!(:software) { create(:software, name: "test-game", platform: "love") } + let!(:release) { create(:release, software: software, version: "1.0.0") } + + before do + WarpEngine::ReleaseAsset.create!(release: release, kind: "html", path: "/test/html") + WarpEngine::ReleaseAsset.create!(release: release, kind: "win_x64", path: "/test/win") + end + + it "returns expected and actual kinds per release" do + result = described_class.new.show("test-game") + + expect(result[:platform]).to eq("love") + expect(result[:expected]).to match_array(%w[html win_x64 linux_x64 mac_universal]) + expect(result[:releases]["1.0.0"][:actual]).to match_array(%w[html win_x64]) + expect(result[:releases]["1.0.0"][:missing]).to match_array(%w[linux_x64 mac_universal]) + end + + it "raises RecordNotFound for unknown software" do + expect { described_class.new.show("nonexistent") }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/services/software_highlighted_service_spec.rb b/spec/services/software_highlighted_service_spec.rb new file mode 100644 index 0000000..d19f44b --- /dev/null +++ b/spec/services/software_highlighted_service_spec.rb @@ -0,0 +1,20 @@ +require "rails_helper" + +RSpec.describe WarpEngine::SoftwareHighlightedService do + describe "#index" do + it "returns nil when no highlighted software" do + create(:software, highlighted: false) + + result = described_class.new.index + expect(result).to be_nil + end + + it "returns highlighted software" do + sw = create(:software, highlighted: true, title: "Featured") + + result = described_class.new.index + expect(result).not_to be_nil + expect(result[:software][:title]).to eq("Featured") + end + end +end diff --git a/spec/services/software_updater/tic80_service_spec.rb b/spec/services/software_updater/tic80_service_spec.rb new file mode 100644 index 0000000..4170300 --- /dev/null +++ b/spec/services/software_updater/tic80_service_spec.rb @@ -0,0 +1,78 @@ +require "rails_helper" +require "zip" + +RSpec.describe WarpEngine::SoftwareUpdater::Tic80Service do + let(:tmpdir) { Dir.mktmpdir } + let(:name) { "spectic" } + let(:version) { "9.9" } + let(:versioned) { "#{name}-#{version}" } + + before do + allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir) + + write_zip("#{versioned}.html.zip", "index.html" => "") + File.write(File.join(tmpdir, "#{versioned}.tic"), "TIC!") + File.write(File.join(tmpdir, "#{versioned}.lua"), <<~LUA) + -- title: Spec TIC + -- name: #{name} + -- author: RSpec + -- desc: spec fixture + -- version: #{version} + function TIC() end + LUA + end + + after do + FileUtils.remove_entry(tmpdir) + WarpEngine::Software.unscoped.where(name: name).each do |sw| + WarpEngine::Release.unscoped.where(software_id: sw.id).each do |r| + WarpEngine::ReleaseAsset.unscoped.where(release_id: r.id).delete_all + r.delete + end + WarpEngine::ExternalLink.unscoped.where(software_id: sw.id).delete_all + sw.delete + end + end + + def write_zip(filename, entries) + Zip::File.open(File.join(tmpdir, filename), create: true) do |zip| + entries.each do |entry_name, content| + zip.get_output_stream(entry_name) { |io| io.write(content) } + end + end + end + + def asset_kinds(release) + WarpEngine::ReleaseAsset.unscoped.where(release_id: release.id).pluck(:kind) + end + + it "registers the release without a docs zip" do + release = described_class.new.update(name, version) + + expect(asset_kinds(release)).to match_array(%w[cartridge source html]) + end + + it "registers docs when the docs zip is present" do + write_zip("#{versioned}-docs.zip", "index.html" => "docs") + + release = described_class.new.update(name, version) + + expect(asset_kinds(release)).to include("docs") + end + + it "registers binary assets when slug zips are present" do + write_zip("#{versioned}-win-x64.zip", "game.exe" => "MZ") + write_zip("#{versioned}-mac-x64.zip", "game" => "bin") + + release = described_class.new.update(name, version) + + expect(asset_kinds(release)).to include("win_x64", "mac_x64") + expect(asset_kinds(release)).not_to include("linux_x64") + end + + it "exposes expected_kinds from included Build concerns" do + expect(described_class.expected_kinds).to match_array( + %w[cartridge source html docs win_x64 linux_x64 mac_x64] + ) + end +end diff --git a/spec/services/update_service_spec.rb b/spec/services/update_service_spec.rb new file mode 100644 index 0000000..ca874a9 --- /dev/null +++ b/spec/services/update_service_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +RSpec.describe WarpEngine::UpdateService do + describe "#update" do + it "raises ArgumentError for unsupported platform" do + input = WarpEngine::UpdateInputDto.new(platform: "unknown", name: "game", version: "1.0") + + expect { described_class.new.update(input) }.to raise_error(ArgumentError, /Unsupported platform/) + end + + it "routes to correct platform service" do + input = WarpEngine::UpdateInputDto.new(platform: "tic80", name: "game", version: "1.0") + mock_service = instance_double(WarpEngine::SoftwareUpdater::Tic80Service) + + allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(mock_service) + allow(mock_service).to receive(:update) + + described_class.new.update(input) + + expect(mock_service).to have_received(:update).with("game", "1.0") + end + + it "routes godot platform to GodotService" do + input = WarpEngine::UpdateInputDto.new(platform: "godot", name: "game", version: "1.0") + mock_service = instance_double(WarpEngine::SoftwareUpdater::GodotService) + + allow(WarpEngine::SoftwareUpdater::GodotService).to receive(:new).and_return(mock_service) + allow(mock_service).to receive(:update) + + described_class.new.update(input) + + expect(mock_service).to have_received(:update).with("game", "1.0") + end + + it "routes bevy platform to BevyService" do + input = WarpEngine::UpdateInputDto.new(platform: "bevy", name: "game", version: "1.0") + mock_service = instance_double(WarpEngine::SoftwareUpdater::BevyService) + + allow(WarpEngine::SoftwareUpdater::BevyService).to receive(:new).and_return(mock_service) + allow(mock_service).to receive(:update) + + described_class.new.update(input) + + expect(mock_service).to have_received(:update).with("game", "1.0") + end + + it "routes phaser platform to PhaserService" do + input = WarpEngine::UpdateInputDto.new(platform: "phaser", name: "game", version: "1.0") + mock_service = instance_double(WarpEngine::SoftwareUpdater::PhaserService) + + allow(WarpEngine::SoftwareUpdater::PhaserService).to receive(:new).and_return(mock_service) + allow(mock_service).to receive(:update) + + described_class.new.update(input) + + expect(mock_service).to have_received(:update).with("game", "1.0") + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..fe819e0 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,14 @@ +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.order = :random + Kernel.srand config.seed +end diff --git a/warp_engine.gemspec b/warp_engine.gemspec index 1a1d5c8..2fd85a2 100644 --- a/warp_engine.gemspec +++ b/warp_engine.gemspec @@ -18,4 +18,6 @@ Gem::Specification.new do |spec| spec.files = Dir["{app,config,db,lib}/**/*", "README.md"] spec.add_dependency "rails", ">= 8.0" + spec.add_dependency "blueprinter" + spec.add_dependency "rubyzip", "~> 2.3" end