release assets

This commit is contained in:
2026-08-02 18:26:29 +02:00
parent 9db8ff189c
commit c630278e42
18 changed files with 223 additions and 53 deletions
+12 -5
View File
@@ -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)
+7 -6
View File
@@ -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
+13 -15
View File
@@ -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);
});
}
+7 -3
View File
@@ -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
+22
View File
@@ -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
+16 -4
View File
@@ -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
@@ -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
@@ -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
@@ -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
+5 -4
View File
@@ -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)
@@ -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
@@ -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,
+1 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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
+14 -1
View File
@@ -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
+33
View File
@@ -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