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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user