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