diff --git a/apps/api/app/admin/releases.rb b/apps/api/app/admin/releases.rb index d2d0b31..e86ff0a 100644 --- a/apps/api/app/admin/releases.rb +++ b/apps/api/app/admin/releases.rb @@ -10,7 +10,7 @@ ActiveAdmin.register Release do id_column column(:software) { |r| link_to r.software.title, admin_software_path(r.software) } column :version - column :html_folder_path + column(:assets) { |r| r.release_assets.map(&:kind).sort.join(", ") } column :created_at actions only: [ :show ] end @@ -22,14 +22,21 @@ ActiveAdmin.register Release do row :id row(:software) { |r| link_to r.software.title, admin_software_path(r.software) } row :version - row :cartridge_path - row :source_path - row :html_folder_path - row :docs_folder_path row :created_at row :updated_at end + panel "Assets" do + if resource.release_assets.any? + table_for resource.release_assets.order(:kind) do + column :kind + column(:path) { |a| span a.path, style: "font-family:monospace;font-size:12px;" } + end + else + para "No assets for this release.", style: "color:#999;font-style:italic;" + end + end + panel "Downloads by File" do file_stats = Download.where(release_id: resource.id) .group(:file_path) diff --git a/apps/api/app/admin/softwares.rb b/apps/api/app/admin/softwares.rb index 3c13b30..e80a657 100644 --- a/apps/api/app/admin/softwares.rb +++ b/apps/api/app/admin/softwares.rb @@ -3,7 +3,8 @@ ActiveAdmin.register Software do :license, :platform, :status, :highlighted, software_images_attributes: [ :id, :image_id, :is_default, :position, :file_upload, :_destroy ], external_links_attributes: [ :id, :label, :url, :_destroy ], - releases_attributes: [ :id, :version, :html_folder_path, :cartridge_path, :source_path, :docs_folder_path, :web_playable, :_destroy ] + releases_attributes: [ :id, :version, :web_playable, :_destroy, + { release_assets_attributes: [ :id, :kind, :path, :_destroy ] } ] menu priority: 2, label: "🎮 Softwares" @@ -126,11 +127,11 @@ ActiveAdmin.register Software do f.inputs "Releases" do f.has_many :releases, allow_destroy: true, new_record: true do |r| r.input :version - r.input :html_folder_path - r.input :cartridge_path - r.input :source_path - r.input :docs_folder_path r.input :web_playable + r.has_many :release_assets, allow_destroy: true, new_record: true do |ra| + ra.input :kind, as: :select, collection: ReleaseAsset::KINDS, include_blank: false + ra.input :path + end end end @@ -140,7 +141,7 @@ ActiveAdmin.register Software do controller do def scoped_collection - super.includes(:releases, software_images: :image) + super.includes({ releases: :release_assets }, software_images: :image) end def find_resource diff --git a/apps/api/app/assets/javascripts/active_admin.js b/apps/api/app/assets/javascripts/active_admin.js index 0c3ca1e..dc8e787 100644 --- a/apps/api/app/assets/javascripts/active_admin.js +++ b/apps/api/app/assets/javascripts/active_admin.js @@ -1,25 +1,23 @@ //= require active_admin/base (function() { - var PATH_FIELDS = ['html_folder_path', 'cartridge_path', 'source_path', 'docs_folder_path']; + var PATH_INPUT_SELECTOR = 'input[id*="release_assets_attributes"][id$="_path"]:not([data-browse-added])'; var _pickerTarget = null; function addBrowseButtons() { - PATH_FIELDS.forEach(function(fieldName) { - var inputs = document.querySelectorAll('input[id*="' + fieldName + '"]:not([data-browse-added])'); - inputs.forEach(function(input) { - input.setAttribute('data-browse-added', 'true'); - var btn = document.createElement('a'); - btn.href = '#'; - btn.textContent = 'Browse'; - btn.className = 'fm-browse-btn'; - btn.addEventListener('click', function(e) { - e.preventDefault(); - _pickerTarget = input; - openPicker(''); - }); - input.parentNode.insertBefore(btn, input.nextSibling); + var inputs = document.querySelectorAll(PATH_INPUT_SELECTOR); + inputs.forEach(function(input) { + input.setAttribute('data-browse-added', 'true'); + var btn = document.createElement('a'); + btn.href = '#'; + btn.textContent = 'Browse'; + btn.className = 'fm-browse-btn'; + btn.addEventListener('click', function(e) { + e.preventDefault(); + _pickerTarget = input; + openPicker(''); }); + input.parentNode.insertBefore(btn, input.nextSibling); }); } diff --git a/apps/api/app/models/release.rb b/apps/api/app/models/release.rb index 6fc1565..8eeac44 100644 --- a/apps/api/app/models/release.rb +++ b/apps/api/app/models/release.rb @@ -3,6 +3,9 @@ class Release < ApplicationRecord belongs_to :software has_many :downloads + has_many :release_assets + + accepts_nested_attributes_for :release_assets, allow_destroy: true validates :version, presence: true validates :version, uniqueness: { scope: :software_id } @@ -14,16 +17,17 @@ class Release < ApplicationRecord def c64_cannot_be_web_playable return unless software&.platform == "c64" - if html_folder_path.present? || web_playable? + has_html = release_assets.reject(&:marked_for_destruction?).any? { |a| a.kind == "html" } + if has_html || web_playable? errors.add(:base, "C64 releases cannot be web playable (download only)") end end def self.ransackable_attributes(auth_object = nil) - %w[cartridge_path created_at deleted_at docs_folder_path html_folder_path id software_id source_path updated_at version web_playable] + %w[created_at deleted_at id software_id updated_at version web_playable] end def self.ransackable_associations(auth_object = nil) - %w[downloads software] + %w[downloads release_assets software] end end diff --git a/apps/api/app/models/release_asset.rb b/apps/api/app/models/release_asset.rb new file mode 100644 index 0000000..ab9e39e --- /dev/null +++ b/apps/api/app/models/release_asset.rb @@ -0,0 +1,22 @@ +class ReleaseAsset < ApplicationRecord + KINDS = %w[cartridge source html docs + win_x86 win_x64 linux_x86 linux_x64 + mac_x64 mac_arm64 mac_universal].freeze + + belongs_to :release + + enum :kind, KINDS.index_by(&:itself) + + validates :path, presence: true + validates :kind, uniqueness: { scope: :release_id } + + default_scope { where(deleted_at: nil) } + + def self.ransackable_attributes(auth_object = nil) + %w[created_at deleted_at id kind path release_id updated_at] + end + + def self.ransackable_associations(auth_object = nil) + %w[release] + end +end diff --git a/apps/api/app/serializers/release_serializer.rb b/apps/api/app/serializers/release_serializer.rb index 213dcd6..6b0183f 100644 --- a/apps/api/app/serializers/release_serializer.rb +++ b/apps/api/app/serializers/release_serializer.rb @@ -4,15 +4,27 @@ class ReleaseSerializer < Blueprinter::Base 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| r.cartridge_path.blank? ? "" : r.cartridge_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } - field(:sourcePath) { |r| r.source_path.blank? ? "" : r.source_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } - field(:htmlFolderPath) { |r| r.html_folder_path.blank? ? "" : r.html_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } - field(:docsFolderPath) { |r| r.docs_folder_path.blank? ? "" : r.docs_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } + 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) { |r| r.association(:downloads).loaded? ? r.downloads.size : r.downloads.count } end diff --git a/apps/api/app/services/concerns/software_updater/binary_attachment.rb b/apps/api/app/services/concerns/software_updater/binary_attachment.rb new file mode 100644 index 0000000..0e302bb --- /dev/null +++ b/apps/api/app/services/concerns/software_updater/binary_attachment.rb @@ -0,0 +1,23 @@ +module SoftwareUpdater + module BinaryAttachment + # fájlnév slug → ReleaseAsset kind + TARGET_KINDS = { + "win-x86" => "win_x86", + "win-x64" => "win_x64", + "linux-x86" => "linux_x86", + "linux-x64" => "linux_x64", + "mac-x64" => "mac_x64", + "mac-arm64" => "mac_arm64", + "mac-universal" => "mac_universal" + }.freeze + + private + + def binary_asset_paths(versioned) + TARGET_KINDS.each_with_object({}) do |(slug, kind), assets| + filename = "#{versioned}-#{slug}.zip" + assets[kind] = full_path(filename) if File.file?(full_path(filename)) + end + end + end +end diff --git a/apps/api/app/services/concerns/software_updater/software_persistence.rb b/apps/api/app/services/concerns/software_updater/software_persistence.rb index 2f3e06d..b22ffb2 100644 --- a/apps/api/app/services/concerns/software_updater/software_persistence.rb +++ b/apps/api/app/services/concerns/software_updater/software_persistence.rb @@ -23,5 +23,14 @@ module SoftwareUpdater Release.create!(attrs) end + + def sync_release_assets(release, kind_paths) + kind_paths.each do |kind, path| + asset = 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 diff --git a/apps/api/app/services/concerns/software_updater/updatable.rb b/apps/api/app/services/concerns/software_updater/updatable.rb index a1fcdd2..abd6a5b 100644 --- a/apps/api/app/services/concerns/software_updater/updatable.rb +++ b/apps/api/app/services/concerns/software_updater/updatable.rb @@ -4,6 +4,7 @@ module SoftwareUpdater include ArchiveExtraction include MetadataParsing include SoftwarePersistence + include BinaryAttachment class_methods do def platform(value = nil) @@ -25,9 +26,9 @@ module SoftwareUpdater 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? - create_release_if_not_exists( - { software_id: software.id, version: version }.merge(release_paths(versioned)) - ) + release = create_release_if_not_exists(software_id: software.id, version: version) + sync_release_assets(release, asset_paths(versioned).merge(binary_asset_paths(versioned))) + release end end @@ -41,8 +42,8 @@ module SoftwareUpdater parse_json_metadata(full_path("#{versioned}.metadata.json")) end - def release_paths(versioned) - { html_folder_path: full_path(versioned) } + def asset_paths(versioned) + { "html" => full_path(versioned) } end end end diff --git a/apps/api/app/services/download_service.rb b/apps/api/app/services/download_service.rb index fe00f97..d9f6f1e 100644 --- a/apps/api/app/services/download_service.rb +++ b/apps/api/app/services/download_service.rb @@ -1,5 +1,6 @@ class DownloadService - BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath + CONTAINER_BASE = ENV.fetch("FILE_CONTAINER_PATH", "/softwares") + BASE_PATH = Pathname.new(CONTAINER_BASE).realpath def call(path:, ip:, user_agent:, referer:) sanitized = path.to_s @@ -8,12 +9,12 @@ class DownloadService return nil unless File.file?(full_path) escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_") - release = Release.find_by("cartridge_path LIKE ? OR source_path LIKE ?", - "%#{escaped}%", "%#{escaped}%") + asset = ReleaseAsset.find_by(path: File.join(CONTAINER_BASE, sanitized)) || + ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first Download.create!( file_path: sanitized, - release: release, + release: asset&.release, ip_address: ip, user_agent: user_agent&.truncate(500), referer: referer&.truncate(500) diff --git a/apps/api/app/services/software_highlighted_service.rb b/apps/api/app/services/software_highlighted_service.rb index 6fc89bf..25ff53c 100644 --- a/apps/api/app/services/software_highlighted_service.rb +++ b/apps/api/app/services/software_highlighted_service.rb @@ -8,7 +8,7 @@ class SoftwareHighlightedService .first return nil unless software - releases = Release.includes(:downloads).where(software_id: software.id).to_a + releases = Release.includes(:downloads, :release_assets).where(software_id: software.id).to_a build_response(software, releases) end end diff --git a/apps/api/app/services/software_response_builder.rb b/apps/api/app/services/software_response_builder.rb index f4e6046..7c673de 100644 --- a/apps/api/app/services/software_response_builder.rb +++ b/apps/api/app/services/software_response_builder.rb @@ -4,7 +4,7 @@ module SoftwareResponseBuilder def build_response(software, releases) 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 = sorted.find { |r| r.html_folder_path.present? } + web_playable = sorted.find { |r| r.release_assets.any? { |a| a.kind == "html" } } total_downloads = releases.sum { |r| r.association(:downloads).loaded? ? r.downloads.size : 0 } SoftwareDetailSerializer.render_as_hash(software, diff --git a/apps/api/app/services/software_service.rb b/apps/api/app/services/software_service.rb index 7f03f75..3ad7c9f 100644 --- a/apps/api/app/services/software_service.rb +++ b/apps/api/app/services/software_service.rb @@ -2,7 +2,7 @@ class SoftwareService include SoftwareResponseBuilder def index - softwares = Software.includes(releases: :downloads).includes(:external_links, :software_images).all + softwares = Software.includes(releases: [ :downloads, :release_assets ]).includes(:external_links, :software_images).all { softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a) } } end end diff --git a/apps/api/app/services/software_updater/c64_service.rb b/apps/api/app/services/software_updater/c64_service.rb index 0ed192b..1a7cae3 100644 --- a/apps/api/app/services/software_updater/c64_service.rb +++ b/apps/api/app/services/software_updater/c64_service.rb @@ -8,8 +8,8 @@ module SoftwareUpdater # a c64 buildhez nem tartozik html zip, csak a .prg fájl end - def release_paths(versioned) - { cartridge_path: full_path("#{versioned}.prg") } + def asset_paths(versioned) + { "cartridge" => full_path("#{versioned}.prg") } end end end diff --git a/apps/api/app/services/software_updater/tic80_service.rb b/apps/api/app/services/software_updater/tic80_service.rb index 935a24e..3af1902 100644 --- a/apps/api/app/services/software_updater/tic80_service.rb +++ b/apps/api/app/services/software_updater/tic80_service.rb @@ -13,12 +13,12 @@ module SoftwareUpdater parse_lua_metadata(full_path("#{versioned}.lua")) end - def release_paths(versioned) + def asset_paths(versioned) { - cartridge_path: full_path("#{versioned}.tic"), - source_path: full_path("#{versioned}.lua"), - html_folder_path: full_path(versioned), - docs_folder_path: full_path("#{versioned}-docs") + "cartridge" => full_path("#{versioned}.tic"), + "source" => full_path("#{versioned}.lua"), + "html" => full_path(versioned), + "docs" => full_path("#{versioned}-docs") } end diff --git a/apps/api/db/migrate/20260802000001_create_release_assets_and_backfill.rb b/apps/api/db/migrate/20260802000001_create_release_assets_and_backfill.rb new file mode 100644 index 0000000..a067777 --- /dev/null +++ b/apps/api/db/migrate/20260802000001_create_release_assets_and_backfill.rb @@ -0,0 +1,46 @@ +class CreateReleaseAssetsAndBackfill < ActiveRecord::Migration[8.1] + COLUMN_KINDS = { + "cartridge_path" => "cartridge", + "source_path" => "source", + "html_folder_path" => "html", + "docs_folder_path" => "docs" + }.freeze + + def up + create_table :release_assets, id: { type: :bigint, unsigned: true } do |t| + t.bigint :release_id, unsigned: true, null: false + t.string :kind, limit: 32, null: false + t.string :path, null: false + t.datetime :deleted_at, precision: 3 + t.timestamps precision: 3 + end + + add_index :release_assets, [ :release_id, :kind ], unique: true, name: "idx_release_assets_release_kind" + add_index :release_assets, :deleted_at, name: "idx_release_assets_deleted_at" + add_index :release_assets, :path, name: "idx_release_assets_path" + add_foreign_key :release_assets, :releases, name: "fk_releases_release_assets", on_delete: :cascade + + # A soft-deletelt release-ek asset sorai a release deleted_at-jét öröklik, + # így az unscoped alapú admin/updater logika konzisztens marad. + COLUMN_KINDS.each do |column, kind| + execute <<~SQL.squish + INSERT INTO release_assets (release_id, kind, path, deleted_at, created_at, updated_at) + SELECT id, '#{kind}', #{column}, deleted_at, NOW(3), NOW(3) + FROM releases + WHERE #{column} IS NOT NULL AND #{column} != '' + SQL + end + end + + def down + COLUMN_KINDS.each do |column, kind| + execute <<~SQL.squish + UPDATE releases r + JOIN release_assets ra ON ra.release_id = r.id AND ra.kind = '#{kind}' + SET r.#{column} = ra.path + SQL + end + + drop_table :release_assets + end +end diff --git a/apps/api/db/schema.rb b/apps/api/db/schema.rb index 83a6aad..f14a9de 100644 --- a/apps/api/db/schema.rb +++ b/apps/api/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_29_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_08_02_000001) do create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", default: "", null: false @@ -170,6 +170,18 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_29_000001) do t.index ["image_id"], name: "index_members_on_image_id" 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.string "cartridge_path" t.datetime "created_at", precision: 3 @@ -219,6 +231,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_29_000001) do 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 "members", "images" + 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 diff --git a/apps/api/lib/tasks/release_assets.rake b/apps/api/lib/tasks/release_assets.rake new file mode 100644 index 0000000..fe6b4ce --- /dev/null +++ b/apps/api/lib/tasks/release_assets.rake @@ -0,0 +1,33 @@ +namespace :release_assets do + desc "Verify that legacy Release path columns match release_assets rows" + task verify: :environment do + mapping = { + "cartridge_path" => "cartridge", + "source_path" => "source", + "html_folder_path" => "html", + "docs_folder_path" => "docs" + } + + ok = true + mapping.each do |column, kind| + legacy = Release.unscoped.where.not(column => [ nil, "" ]) + assets = ReleaseAsset.unscoped.where(kind: kind) + missing = legacy.where.not(id: assets.select(:release_id)) + mismatched = legacy + .joins("INNER JOIN release_assets ra ON ra.release_id = releases.id AND ra.kind = '#{kind}'") + .where("ra.path <> releases.#{column}") + + puts format("%-18s legacy: %4d asset: %4d missing: %d mismatch: %d", + column, legacy.count, assets.count, missing.count, mismatched.count) + missing.limit(10).each { |r| puts " MISSING release ##{r.id} (#{column}=#{r.public_send(column)})" } + mismatched.limit(10).each { |r| puts " MISMATCH release ##{r.id} (#{column}=#{r.public_send(column)})" } + ok &&= missing.count.zero? && mismatched.count.zero? + end + + if ok + puts "OK: every legacy path column is mirrored in release_assets." + else + abort "FAIL: legacy path columns and release_assets differ." + end + end +end