Phase 3: move service layer, serializers and DTOs into WarpEngine + dummy-app test suite

- all catalog services (update/software/highlighted/builds/file/file-manager/
  download/image + SoftwareResponseBuilder), the SoftwareUpdater platform
  services and their concerns, Blueprinter serializers (incl. TimestampFields)
  and the 4 DTOs now live in the engine under WarpEngine::
- constantize dispatch strings use absolute names
  (WarpEngine::SoftwareUpdater::<Platform>Service)
- container paths read from WarpEngine.config everywhere (FileService,
  DownloadService, FileManagerService, ArchiveExtraction, ReleaseSerializer
  path rewriting); FileManagerService base path is now lazy
- engine requires blueprinter itself; gemspec declares blueprinter + rubyzip
- engine test suite: spec/dummy app (mysql warp_engine_test, catalog-only
  schema), rails_helper with engine-local factories; catalog model/service
  specs and factories moved from the host
- host suite keeps TTG specs and loads catalog factories from the engine

Verified: engine suite 40 green, host suite 14 green, /api/software and
/api/builds byte-identical to baselines, admin OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:03:58 +02:00
co-authored by Claude Fable 5
parent 92f10197c1
commit 0dc3cebc14
122 changed files with 1235 additions and 906 deletions
+2
View File
@@ -2,7 +2,9 @@ PATH
remote: ../libs/ruby/warp_engine
specs:
warp_engine (0.1.0)
blueprinter
rails (>= 8.0)
rubyzip (~> 2.3)
GEM
remote: https://rubygems.org/
+7 -7
View File
@@ -2,7 +2,7 @@ ActiveAdmin.register_page "Files" do
menu priority: 6, label: "📁 Files"
content do
service = FileManagerService.new
service = WarpEngine::FileManagerService.new
current_dir = params[:dir].to_s.presence || ""
picker_mode = params[:picker].present?
picker_field = params[:field].to_s
@@ -199,7 +199,7 @@ ActiveAdmin.register_page "Files" do
# JSON API for AJAX file picker
page_action :list, method: :get do
service = FileManagerService.new
service = WarpEngine::FileManagerService.new
dir = params[:dir].to_s.presence || ""
begin
entries = service.list(dir)
@@ -210,7 +210,7 @@ ActiveAdmin.register_page "Files" do
end
page_action :ajax_upload, method: :post do
service = FileManagerService.new
service = WarpEngine::FileManagerService.new
path = service.upload(params[:dir].to_s, params[:file])
render json: { ok: true, path: path }
rescue => e
@@ -218,7 +218,7 @@ ActiveAdmin.register_page "Files" do
end
page_action :upload, method: :post do
service = FileManagerService.new
service = WarpEngine::FileManagerService.new
service.upload(params[:dir].to_s, params[:file])
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "File uploaded."
rescue => e
@@ -226,7 +226,7 @@ ActiveAdmin.register_page "Files" do
end
page_action :mkdir, method: :post do
service = FileManagerService.new
service = WarpEngine::FileManagerService.new
service.mkdir(params[:dir].to_s, params[:name].to_s)
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "Folder created."
rescue => e
@@ -234,7 +234,7 @@ ActiveAdmin.register_page "Files" do
end
page_action :rename, method: :post do
service = FileManagerService.new
service = WarpEngine::FileManagerService.new
service.rename(params[:path].to_s, params[:new_name].to_s)
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "Renamed."
rescue => e
@@ -242,7 +242,7 @@ ActiveAdmin.register_page "Files" do
end
page_action :delete, method: :delete do
service = FileManagerService.new
service = WarpEngine::FileManagerService.new
dir = params[:dir].to_s.presence || begin
d = File.dirname(params[:path].to_s)
d == "." ? "" : d
@@ -1,5 +1,5 @@
class Api::BuildsController < ApiController
def index
render json: BuildsService.new.index
render json: WarpEngine::BuildsService.new.index
end
end
@@ -13,7 +13,7 @@ class Api::DownloadsController < ApiController
path = params[:path]
return render(json: { error: "Path is required" }, status: :bad_request) if path.blank?
full_path = DownloadService.new.create(
full_path = WarpEngine::DownloadService.new.create(
path: path,
ip: request.remote_ip,
user_agent: request.user_agent,
@@ -9,7 +9,7 @@ class Api::ImagesController < ApiController
returns code: 200, desc: "Image binary data"
error code: 404, desc: "Image not found"
def show
image = ImageService.new.show(ImageShowInputDto.new(id: params[:id]))
image = WarpEngine::ImageService.new.show(WarpEngine::ImageShowInputDto.new(id: params[:id]))
send_file image.file_path, type: image.content_type, disposition: "inline"
end
end
@@ -1,5 +1,5 @@
class Api::SoftwareBuildsController < ApiController
def show
render json: BuildsService.new.show(params[:name])
render json: WarpEngine::BuildsService.new.show(params[:name])
end
end
@@ -54,6 +54,6 @@ class Api::SoftwareController < ApiController
end
end
def index
render json: SoftwareService.new.index
render json: WarpEngine::SoftwareService.new.index
end
end
@@ -33,7 +33,7 @@ class Api::SoftwareHighlightedController < ApiController
end
error code: 404, desc: "No highlighted software found"
def index
result = SoftwareHighlightedService.new.index
result = WarpEngine::SoftwareHighlightedService.new.index
if result
render json: result
else
+1 -1
View File
@@ -10,7 +10,7 @@ class FilesController < ApiController
returns code: 301, desc: "Redirect to file URL"
error code: 404, desc: "File not found"
def show
result = FileService.new.show(FileShowInputDto.new(path: params[:path]))
result = WarpEngine::FileService.new.show(WarpEngine::FileShowInputDto.new(path: params[:path]))
case result.type
when :redirect then redirect_to result.url, status: :moved_permanently
when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path)
@@ -26,13 +26,13 @@ class UpdateController < ApiController
return render plain: "Unauthorized", status: :unauthorized unless authorized?
return render plain: "Version not provided", status: :bad_request if params[:version].blank?
input = UpdateInputDto.new(
input = WarpEngine::UpdateInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
UpdateService.new.update(input)
WarpEngine::UpdateService.new.update(input)
render plain: "Updated"
end
-13
View File
@@ -1,13 +0,0 @@
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
-1
View File
@@ -1 +0,0 @@
FileShowInputDto = Struct.new(:path, keyword_init: true)
@@ -1 +0,0 @@
ImageShowInputDto = Struct.new(:id, keyword_init: true)
-5
View File
@@ -1,5 +0,0 @@
UpdateInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do
def initialize(platform:, name:, version: nil)
super
end
end
@@ -1,4 +0,0 @@
module TimestampFields
GO_ZERO_TIME = "0001-01-01T00:00:00Z"
TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ"
end
+1 -1
View File
@@ -1,5 +1,5 @@
class EventSerializer < Blueprinter::Base
include TimestampFields
include WarpEngine::TimestampFields
field :name
field(:date) { |event| event.date.utc.strftime(TS_FORMAT) }
@@ -1,11 +0,0 @@
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
@@ -1,4 +0,0 @@
class PlatformLinkSerializer < Blueprinter::Base
field :name
field :url
end
@@ -1,33 +0,0 @@
class ReleaseSerializer < Blueprinter::Base
include TimestampFields
FILE_PATH_FROM = "/softwares/"
FILE_PATH_TO = "/file/"
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
@@ -1,7 +0,0 @@
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
@@ -1,28 +0,0 @@
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
-37
View File
@@ -1,37 +0,0 @@
class BuildsService
def index
platforms = WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
service_class = "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 = "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
@@ -1,45 +0,0 @@
require "zip"
require "fileutils"
module SoftwareUpdater
module ArchiveExtraction
private
def base_path
@base_path ||= ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
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
@@ -1,22 +0,0 @@
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
@@ -1,24 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,22 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,14 +0,0 @@
require "json"
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
@@ -1,36 +0,0 @@
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
@@ -1,72 +0,0 @@
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
-32
View File
@@ -1,32 +0,0 @@
class DownloadService
def self.container_base
ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
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
@@ -1,88 +0,0 @@
class FileManagerService
BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares"))
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
-33
View File
@@ -1,33 +0,0 @@
class FileService
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
def self.base_path
Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).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
-5
View File
@@ -1,5 +0,0 @@
class ImageService
def show(input)
WarpEngine::Image.find(input.id)
end
end
@@ -1,14 +0,0 @@
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
@@ -1,25 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,10 +0,0 @@
module SoftwareUpdater
class BevyService
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX64
include Builds::BuildLinuxX64
label "Bevy"
end
end
@@ -1,10 +0,0 @@
module SoftwareUpdater
class C64Service
include Updatable
include Builds::BuildCartridge
label "C64"
def cartridge_ext = ".prg"
end
end
@@ -1,13 +0,0 @@
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
@@ -1,12 +0,0 @@
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
@@ -1,11 +0,0 @@
module SoftwareUpdater
class LoveService
include Updatable
include Builds::BuildWeb
include Builds::BuildWinX64
include Builds::BuildLinuxX64
include Builds::BuildMacUniversal
label "LÖVE"
end
end
@@ -1,8 +0,0 @@
module SoftwareUpdater
class PhaserService
include Updatable
include Builds::BuildWeb
label "Phaser"
end
end
@@ -1,36 +0,0 @@
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
-9
View File
@@ -1,9 +0,0 @@
class UpdateService
def update(input)
unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
raise ArgumentError, "Unsupported platform: #{input.platform}"
end
"SoftwareUpdater::#{input.platform.camelize}Service".constantize.new.update(input.name, input.version)
end
end
-6
View File
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :download, class: "WarpEngine::Download" do
file_path { "/test/file.tic" }
ip_address { "127.0.0.1" }
end
end
@@ -1,8 +0,0 @@
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
-6
View File
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :release, class: "WarpEngine::Release" do
software
sequence(:version) { |n| "1.0.#{n}" }
end
end
-9
View File
@@ -1,9 +0,0 @@
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
-6
View File
@@ -1,6 +0,0 @@
require "rails_helper"
RSpec.describe WarpEngine::Download, type: :model do
it { should validate_presence_of(:file_path) }
it { should belong_to(:release).optional }
end
@@ -1,52 +0,0 @@
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
-23
View File
@@ -1,23 +0,0 @@
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
-25
View File
@@ -1,25 +0,0 @@
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
+4
View File
@@ -11,6 +11,10 @@ rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip
end
# A katalógus-factory-k (software, release, download, platform_link) az engine-ben élnek
FactoryBot.definition_file_paths << WarpEngine::Engine.root.join("spec/factories")
FactoryBot.reload
RSpec.configure do |config|
config.fixture_paths = [ Rails.root.join("spec/fixtures") ]
config.use_transactional_fixtures = true
@@ -1,50 +0,0 @@
require "rails_helper"
RSpec.describe 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
@@ -1,20 +0,0 @@
require "rails_helper"
RSpec.describe 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
@@ -1,79 +0,0 @@
require "rails_helper"
require "zip"
RSpec.describe SoftwareUpdater::Tic80Service do
let(:tmpdir) { Dir.mktmpdir }
let(:name) { "spectic" }
let(:version) { "9.9" }
let(:versioned) { "#{name}-#{version}" }
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with("FILE_CONTAINER_PATH", "/softwares").and_return(tmpdir)
write_zip("#{versioned}.html.zip", "index.html" => "<html></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
@@ -1,59 +0,0 @@
require "rails_helper"
RSpec.describe UpdateService do
describe "#update" do
it "raises ArgumentError for unsupported platform" do
input = 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 = UpdateInputDto.new(platform: "tic80", name: "game", version: "1.0")
mock_service = instance_double(SoftwareUpdater::Tic80Service)
allow(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 = UpdateInputDto.new(platform: "godot", name: "game", version: "1.0")
mock_service = instance_double(SoftwareUpdater::GodotService)
allow(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 = UpdateInputDto.new(platform: "bevy", name: "game", version: "1.0")
mock_service = instance_double(SoftwareUpdater::BevyService)
allow(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 = UpdateInputDto.new(platform: "phaser", name: "game", version: "1.0")
mock_service = instance_double(SoftwareUpdater::PhaserService)
allow(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