Phase 0: in-place decoupling before WarpEngine extraction

- FileService/DownloadService: lazy container-path resolution instead of
  class-load-time realpath (boot no longer requires /softwares to exist)
- downloadCount/totalDownloads: single grouped query passed as Blueprinter
  option instead of per-release association counts (fixes N+1)
- admin Images: Member coupling replaced with ImageUsage owner registry
  (precursor of the WarpEngine.config.image_owners hook)
- Image: UPLOAD_PATH constant replaced with call-time upload_path accessor
- proper test environment (config/environments/test.rb, softwares_test DB,
  hosts.clear) — suite previously ran against the development DB
- spec fixes: case-insensitive uniqueness matchers (MySQL ai_ci collation),
  DB-cascade has_many expectations, PlatformLink::SUPPORTED_PLATFORMS
- JSON baselines of /api/software and /api/builds for post-extraction diffing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:39:20 +02:00
co-authored by Claude Fable 5
parent 0f13c19fc8
commit bf921c589d
19 changed files with 851 additions and 32 deletions
+6 -5
View File
@@ -3,13 +3,14 @@ ActiveAdmin.register Image do
menu priority: 5, label: "🖼️ Images"
used_ids = -> { SoftwareImage.distinct.pluck(:image_id) + ImageUsage.used_image_ids }
scope :all, default: true
scope("In use") { |scope| scope.where(id: SoftwareImage.select(:image_id)).or(scope.where(id: Member.where.not(image_id: nil).select(:image_id))) }
scope("Orphan") { |scope| scope.where.not(id: SoftwareImage.select(:image_id)).where.not(id: Member.where.not(image_id: nil).select(:image_id)) }
scope("In use") { |scope| scope.where(id: used_ids.call) }
scope("Orphan") { |scope| scope.where.not(id: used_ids.call) }
batch_action :delete_orphans, confirm: "Delete all selected orphan images and their files?" do |ids|
in_use_ids = SoftwareImage.where(image_id: ids).pluck(:image_id) + Member.where(image_id: ids).pluck(:image_id)
orphan_ids = ids.map(&:to_i) - in_use_ids
orphan_ids = ids.map(&:to_i) - used_ids.call
Image.where(id: orphan_ids).find_each do |img|
File.delete(img.file_path) if File.exist?(img.file_path)
img.destroy
@@ -30,7 +31,7 @@ ActiveAdmin.register Image do
column(:usage) do |img|
uses = []
uses << "#{img.software_images.size} software(s)" if img.software_images.any?
uses << "member" if Member.where(image_id: img.id).exists?
uses.concat(ImageUsage.usage_labels_for(img))
uses.any? ? uses.join(", ") : status_tag("orphan", class: "warning")
end
column :created_at
+6 -3
View File
@@ -1,5 +1,8 @@
class Image < ApplicationRecord
UPLOAD_PATH = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
# Híváskor kiértékelve, hogy a path ne class-load-kor rögzüljön.
def self.upload_path
ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
end
has_many :software_images, dependent: :restrict_with_error
@@ -14,13 +17,13 @@ class Image < ApplicationRecord
end
def file_path
File.join(UPLOAD_PATH, filename.to_s)
File.join(self.class.upload_path, filename.to_s)
end
private
def process_upload
FileUtils.mkdir_p(UPLOAD_PATH)
FileUtils.mkdir_p(self.class.upload_path)
self.original_filename = file_upload.original_filename
self.content_type = file_upload.content_type.presence || "application/octet-stream"
ext = File.extname(file_upload.original_filename)
@@ -26,5 +26,8 @@ class ReleaseSerializer < Blueprinter::Base
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) { |r| r.association(:downloads).loaded? ? r.downloads.size : r.downloads.count }
field(:downloadCount) do |r, opts|
counts = opts[:download_counts]
counts ? counts.fetch(r.id, 0) : r.downloads.count
end
end
@@ -1,7 +1,7 @@
class SoftwareDetailSerializer < Blueprinter::Base
field(:software) { |sw, _| SoftwareSerializer.render_as_hash(sw) }
field(:releases) { |_, opts| ReleaseSerializer.render_as_hash(opts[:releases]) }
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest]) : nil }
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable]) : nil }
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
+12 -5
View File
@@ -1,15 +1,22 @@
class DownloadService
CONTAINER_BASE = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
BASE_PATH = Pathname.new(CONTAINER_BASE).realpath
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
full_path = BASE_PATH.join(sanitized).realpath
return nil unless full_path.to_s.start_with?(BASE_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 = ReleaseAsset.find_by(path: File.join(CONTAINER_BASE, sanitized)) ||
asset = ReleaseAsset.find_by(path: File.join(self.class.container_base, sanitized)) ||
ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
Download.create!(
+10 -3
View File
@@ -1,8 +1,11 @@
class FileService
BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath
# 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)
full_path = base_path.join(input.path.to_s)
return FileResultDto.not_found unless safe_path?(full_path)
if File.directory?(full_path)
@@ -20,7 +23,11 @@ class FileService
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)
File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(base_path.to_s)
end
end
+30
View File
@@ -0,0 +1,30 @@
# A katalóguson kívüli modellek regisztrálják ide, hogy mely Image rekordokat
# használják. A leendő WarpEngine.config.image_owners hook elődje: az admin
# Images oldal In use/Orphan logikája ezen keresztül marad domain-független.
#
# Owner kontraktus:
# image_ids: -> { Array<Integer> } — az owner által használt image id-k
# usage_label: ->(image) { String vagy nil } — megjelenítendő címke, ha használja
module ImageUsage
Owner = Struct.new(:label, :image_ids, :usage_label, keyword_init: true)
def self.owners
@owners ||= []
end
def self.reset!
@owners = []
end
def self.register(label:, image_ids:, usage_label:)
owners << Owner.new(label:, image_ids:, usage_label:)
end
def self.used_image_ids
owners.flat_map { |o| o.image_ids.call }
end
def self.usage_labels_for(image)
owners.filter_map { |o| o.usage_label.call(image) }
end
end
@@ -8,7 +8,7 @@ class SoftwareHighlightedService
.first
return nil unless software
releases = Release.includes(:downloads, :release_assets).where(software_id: software.id).to_a
build_response(software, releases)
releases = Release.includes(:release_assets).where(software_id: software.id).to_a
build_response(software, releases, download_counts_for(releases.map(&:id)))
end
end
@@ -1,18 +1,25 @@
module SoftwareResponseBuilder
private
def build_response(software, releases)
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| r.association(:downloads).loaded? ? r.downloads.size : 0 }
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
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?
Download.where(release_id: release_ids).group(:release_id).count
end
end
+3 -2
View File
@@ -2,7 +2,8 @@ class SoftwareService
include SoftwareResponseBuilder
def index
softwares = Software.includes(releases: [ :downloads, :release_assets ]).includes(:external_links, :software_images).all
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a) } }
softwares = 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