replace catalog service to api

This commit is contained in:
2026-05-05 19:27:29 +02:00
parent 189bce21e1
commit 93849795e3
91 changed files with 1503 additions and 1540 deletions
+5
View File
@@ -0,0 +1,5 @@
.bundle/
log/
tmp/
*.sqlite3
.env
+24
View File
@@ -0,0 +1,24 @@
FROM --platform=linux/amd64 ruby:3.3-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
default-libmysqlclient-dev \
git \
tzdata \
unzip \
nodejs \
libffi-dev \
libyaml-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Gemfile ./
RUN bundle install
COPY . .
EXPOSE 3000
CMD ["/bin/sh", "entrypoint.sh"]
+16
View File
@@ -0,0 +1,16 @@
source "https://rubygems.org"
gem "rails", "~> 8.0"
gem "mysql2", "~> 0.5"
gem "puma", ">= 5.0"
gem "rubyzip", "~> 2.3"
gem "bootsnap", require: false
gem "activeadmin"
gem "devise"
gem "sprockets-rails"
gem "sassc-rails"
group :development, :test do
gem "debug", platforms: %i[mri windows]
end
+298
View File
@@ -0,0 +1,298 @@
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.18)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activeadmin (3.5.1)
arbre (~> 1.2, >= 1.2.1)
csv
formtastic (>= 3.1)
formtastic_i18n (>= 0.4)
inherited_resources (~> 1.7)
jquery-rails (>= 4.2)
kaminari (>= 1.2.1)
railties (>= 6.1)
ransack (>= 4.0)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
timeout (>= 0.4.0)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0)
activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
arbre (1.7.0)
activesupport (>= 3.0.0)
ruby2_keywords (>= 0.0.2)
base64 (0.3.0)
bcrypt (3.1.22)
bigdecimal (4.1.2)
bootsnap (1.24.3)
msgpack (~> 1.2)
builder (3.3.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
csv (3.3.5)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
devise (5.0.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
responders
warden (~> 1.2.3)
drb (2.2.3)
erb (6.0.4)
erubi (1.13.1)
ffi (1.17.4-x86_64-linux-gnu)
formtastic (6.0.0)
actionpack (>= 7.2.0)
formtastic_i18n (0.7.0)
globalid (1.3.0)
activesupport (>= 6.1)
has_scope (0.9.0)
actionpack (>= 7.0)
activesupport (>= 7.0)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
inherited_resources (1.14.0)
actionpack (>= 6.0)
has_scope (>= 0.6)
railties (>= 6.0)
responders (>= 2)
io-console (0.8.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jquery-rails (4.6.1)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.5)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
kaminari-activerecord (= 1.2.2)
kaminari-core (= 1.2.2)
kaminari-actionview (1.2.2)
actionview
kaminari-core (= 1.2.2)
kaminari-activerecord (1.2.2)
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
logger (1.7.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.1.0)
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
mysql2 (0.5.7)
bigdecimal
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
orm_adapter (0.5.0)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
psych (5.3.1)
date
stringio
puma (8.0.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
bundler (>= 1.15.0)
railties (= 8.1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rake (13.4.2)
ransack (4.4.1)
activerecord (>= 7.2)
activesupport (>= 7.2)
i18n
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
reline (0.6.3)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
railties (>= 4.0.0)
sassc (>= 2.0)
sprockets (> 3.0)
sprockets-rails
tilt
securerandom (0.4.1)
sprockets (4.2.2)
concurrent-ruby (~> 1.0)
logger
rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stringio (3.2.0)
thor (1.5.0)
tilt (2.7.0)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
zeitwerk (2.7.5)
PLATFORMS
x86_64-linux-gnu
DEPENDENCIES
activeadmin
bootsnap
debug
devise
mysql2 (~> 0.5)
puma (>= 5.0)
rails (~> 8.0)
rubyzip (~> 2.3)
sassc-rails
sprockets-rails
BUNDLED WITH
2.5.22
+2
View File
@@ -0,0 +1,2 @@
require_relative "config/application"
Rails.application.load_tasks
+32
View File
@@ -0,0 +1,32 @@
ActiveAdmin.register AdminUser do
permit_params :email, :password, :password_confirmation
index do
selectable_column
id_column
column :email
column :created_at
actions
end
filter :email
filter :created_at
form do |f|
f.inputs do
f.input :email
f.input :password
f.input :password_confirmation
end
f.actions
end
show do
attributes_table do
row :id
row :email
row :created_at
row :updated_at
end
end
end
+27
View File
@@ -0,0 +1,27 @@
ActiveAdmin.register_page "Dashboard" do
menu priority: 1, label: proc { I18n.t("active_admin.dashboard") }
content title: proc { I18n.t("active_admin.dashboard") } do
columns do
column do
panel "Softwares" do
ul do
li "Total: #{Software.count}"
li "Released: #{Software.where(status: 'released').count}"
li "In development: #{Software.where(status: 'development').count}"
end
end
end
column do
panel "Recent Releases" do
table_for Release.order(created_at: :desc).limit(10) do
column(:software) { |r| link_to r.software.title, admin_software_path(r.software) rescue r.software_id }
column :version
column :created_at
end
end
end
end
end
end
+34
View File
@@ -0,0 +1,34 @@
ActiveAdmin.register ExternalLink do
permit_params :software_id, :label, :url
menu false
belongs_to :software
index do
selectable_column
id_column
column :label
column(:url) { |el| link_to el.url, el.url, target: "_blank" }
column :created_at
actions
end
form do |f|
f.inputs do
f.input :label
f.input :url
end
f.actions
end
show do
attributes_table do
row :id
row :label
row(:url) { |el| link_to el.url, el.url, target: "_blank" }
row :created_at
row :updated_at
end
end
end
+33
View File
@@ -0,0 +1,33 @@
ActiveAdmin.register Release do
actions :index, :show
menu false
belongs_to :software, optional: true
index do
selectable_column
id_column
column(:software) { |r| link_to r.software.title, admin_software_path(r.software) }
column :version
column :html_folder_path
column :created_at
actions only: [:show]
end
filter :version
show do
attributes_table 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
end
end
+97
View File
@@ -0,0 +1,97 @@
ActiveAdmin.register Software do
permit_params :name, :title, :author, :desc, :story,
:license, :platform, :status, :highlighted,
external_links_attributes: [:id, :label, :url, :_destroy],
releases_attributes: [:id, :_destroy]
menu priority: 2
index do
selectable_column
id_column
column :name
column :title
column :platform
column :status do |sw|
status_tag sw.status
end
column :highlighted
column :created_at
actions
end
filter :name
filter :title
filter :author
filter :platform, as: :select, collection: %w[tic80 ebitengine love]
filter :status, as: :select, collection: %w[development demo released archived]
filter :highlighted
show do
attributes_table do
row :id
row :name
row :title
row :author
row :platform
row :status
row :highlighted
row :license
row(:desc) { |sw| simple_format sw.desc }
row(:story) { |sw| simple_format sw.story }
row :created_at
row :updated_at
end
panel "External Links" do
table_for resource.external_links do
column :label
column(:url) { |el| link_to el.url, el.url, target: "_blank" }
column :created_at
column do |el|
link_to "Edit", edit_admin_software_external_link_path(resource, el)
concat " "
link_to "Delete", admin_software_external_link_path(resource, el),
method: :delete, data: { confirm: "Are you sure?" }
end
end
br
link_to "Add External Link",
new_admin_software_external_link_path(resource)
end
panel "Releases" do
table_for resource.releases.order(created_at: :desc) do
column :version
column :html_folder_path
column :cartridge_path
column :source_path
column :docs_folder_path
column :created_at
end
end
end
form do |f|
f.inputs "Details" do
f.input :name
f.input :title
f.input :author
f.input :platform, as: :select, collection: %w[tic80 ebitengine love]
f.input :status, as: :select, collection: %w[development demo released archived]
f.input :highlighted
f.input :license
f.input :desc, as: :text, input_html: { rows: 4 }
f.input :story, as: :text, input_html: { rows: 8 }
end
f.inputs "External Links" do
f.has_many :external_links, allow_destroy: true, new_record: true do |el|
el.input :label
el.input :url
end
end
f.actions
end
end
+2
View File
@@ -0,0 +1,2 @@
//= link active_admin.css
//= link active_admin.js
@@ -0,0 +1 @@
//= require active_admin/base
@@ -0,0 +1,2 @@
@import "active_admin/mixins";
@import "active_admin/base";
@@ -0,0 +1,8 @@
module Api
class SoftwareController < ApiController
def index
result = SoftwareListService.new.call
render json: result
end
end
end
@@ -0,0 +1,12 @@
module Api
class SoftwareHighlightedController < ApiController
def index
result = SoftwareHighlightedService.new.call
if result.nil?
render json: { error: "no highlighted software found" }, status: :not_found
else
render json: result
end
end
end
end
@@ -0,0 +1,2 @@
class ApiController < ActionController::API
end
@@ -0,0 +1,3 @@
class ApplicationController < ActionController::Base
protect_from_forgery with: :null_session
end
@@ -0,0 +1,13 @@
class FilesController < ApplicationController
def show
base = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath
full = base.join(params[:path])
return render plain: "Not Found", status: :not_found unless full.to_s.start_with?(base.to_s)
return render plain: "Not Found", status: :not_found unless full.exist? && full.file?
send_file full.to_s, disposition: "inline"
rescue Errno::ENOENT
render plain: "Not Found", status: :not_found
end
end
@@ -0,0 +1,32 @@
class UpdateController < ApiController
PLATFORM_SERVICES = {
"tic80" => "SoftwareUpdater::Tic80Service",
"ebitengine" => "SoftwareUpdater::EbitengineService",
"love" => "SoftwareUpdater::LoveService"
}.freeze
def update
return render plain: "Unauthorized", status: :unauthorized unless authorized?
platform = params[:platform]
name = params[:name]
version = params[:version]
return render plain: "Version not provided", status: :bad_request if version.blank?
service_class_name = PLATFORM_SERVICES[platform]
return render plain: "Unsupported platform: #{platform}", status: :bad_request unless service_class_name
service_class_name.constantize.new.update(name, version)
render plain: "Updated"
rescue => e
Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
render plain: e.message, status: :internal_server_error
end
private
def authorized?
params[:secret] == ENV.fetch("UPDATE_SECRET", "")
end
end
+8
View File
@@ -0,0 +1,8 @@
class AdminUser < ApplicationRecord
devise :database_authenticatable,
:recoverable, :rememberable, :validatable
def self.ransackable_attributes(auth_object = nil)
%w[created_at email id updated_at]
end
end
@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
+15
View File
@@ -0,0 +1,15 @@
class ExternalLink < ApplicationRecord
self.table_name = "external_links"
belongs_to :software
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id label software_id updated_at url]
end
def self.ransackable_associations(auth_object = nil)
%w[software]
end
end
+15
View File
@@ -0,0 +1,15 @@
class Release < ApplicationRecord
self.table_name = "releases"
belongs_to :software
default_scope { where(deleted_at: nil) }
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]
end
def self.ransackable_associations(auth_object = nil)
%w[software]
end
end
+18
View File
@@ -0,0 +1,18 @@
class Software < ApplicationRecord
self.table_name = "softwares"
has_many :releases, foreign_key: :software_id
has_many :external_links, foreign_key: :software_id
accepts_nested_attributes_for :external_links, allow_destroy: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[author created_at desc highlighted id license name platform site status story title updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[releases external_links]
end
end
@@ -0,0 +1,12 @@
class SoftwareHighlightedService
def call
software = Software.includes(:external_links)
.where(highlighted: true)
.order(id: :desc)
.first
return nil unless software
releases = Release.where(software_id: software.id).to_a
SoftwareSerializer.show_data(software, releases)
end
end
@@ -0,0 +1,9 @@
class SoftwareListService
def call
softwares = Software.includes(:releases, :external_links).all
items = softwares.map do |sw|
SoftwareSerializer.show_data(sw, sw.releases.to_a)
end
{ softwares: items }
end
end
@@ -0,0 +1,82 @@
class SoftwareSerializer
GO_ZERO_TIME = "0001-01-01T00:00:00Z"
FILE_PATH_FROM = "/softwares/"
FILE_PATH_TO = "/file/"
def self.show_data(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? }
{
software: serialize_software(software),
releases: sorted.map { |r| serialize_release(r) },
latestRelease: latest ? serialize_release(latest) : nil,
webPlayableRelease: web_playable ? serialize_release(web_playable) : nil
}
end
def self.serialize_software(sw)
{
"ID" => sw.id,
"CreatedAt" => fmt(sw.created_at),
"UpdatedAt" => fmt(sw.updated_at),
"DeletedAt" => fmt_nullable(sw.deleted_at),
"name" => sw.name,
"title" => sw.title,
"author" => sw.author,
"desc" => sw.desc.to_s,
"story" => sw.story.to_s,
"license" => sw.license.to_s,
"platform" => sw.platform,
"status" => sw.status,
"highlighted" => sw.highlighted ? true : false,
"externalLinks" => sw.external_links.map { |el| serialize_external_link(el) }
}
end
def self.serialize_release(r)
{
"ID" => r.id,
"CreatedAt" => fmt(r.created_at),
"UpdatedAt" => fmt(r.updated_at),
"DeletedAt" => fmt_nullable(r.deleted_at),
"softwareId" => r.software_id,
"version" => r.version,
"cartridgePath" => rewrite(r.cartridge_path),
"sourcePath" => rewrite(r.source_path),
"htmlFolderPath" => rewrite(r.html_folder_path),
"docsFolderPath" => rewrite(r.docs_folder_path)
}
end
def self.serialize_external_link(el)
{
"ID" => el.id,
"CreatedAt" => fmt(el.created_at),
"UpdatedAt" => fmt(el.updated_at),
"DeletedAt" => fmt_nullable(el.deleted_at),
"softwareId" => el.software_id,
"label" => el.label,
"url" => el.url
}
end
# null → Go zero-time string (matches GORM behavior)
def self.fmt(t)
return GO_ZERO_TIME if t.nil?
t.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
end
# null → JSON null (matches gorm.DeletedAt behavior)
def self.fmt_nullable(t)
return nil if t.nil?
t.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
end
def self.rewrite(path)
return "" if path.blank?
path.gsub(FILE_PATH_FROM, FILE_PATH_TO)
end
end
@@ -0,0 +1,11 @@
class SoftwareShowService
def initialize(name)
@name = name
end
def call
software = Software.includes(:external_links).find_by!(name: @name)
releases = Release.where(software_id: software.id).to_a
SoftwareSerializer.show_data(software, releases)
end
end
@@ -0,0 +1,61 @@
require "zip"
require "fileutils"
module SoftwareUpdater
class BaseService
def initialize
@base_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
end
private
def full_path(filename)
File.join(@base_path, filename)
end
def dir_exists?(dirname)
File.directory?(full_path(dirname))
end
def delete_dir(dirname)
FileUtils.rm_rf(full_path(dirname))
end
def create_dir(dirname)
FileUtils.mkdir_p(full_path(dirname))
end
def unzip_file(zip_filename, dest_dirname)
dest = full_path(dest_dirname)
Zip::File.open(full_path(zip_filename)) do |zip|
zip.each do |entry|
entry_dest = File.join(dest, entry.name)
FileUtils.mkdir_p(File.dirname(entry_dest))
entry.extract(entry_dest) { true }
end
end
end
def update_or_create_software(attrs)
software = Software.unscoped.find_or_initialize_by(name: attrs[:name])
software.assign_attributes(attrs.except(:name))
software.deleted_at = nil
software.save!
software
end
def upsert_external_link(software_id, label, url)
link = ExternalLink.unscoped.find_or_initialize_by(software_id: software_id, label: label)
link.url = url
link.deleted_at = nil
link.save!
end
def create_release_if_not_exists(attrs)
existing = Release.unscoped.find_by(software_id: attrs[:software_id], version: attrs[:version])
return existing if existing
Release.create!(attrs)
end
end
end
@@ -0,0 +1,34 @@
require "json"
module SoftwareUpdater
class EbitengineService < BaseService
def update(name, version)
versioned = "#{name}-#{version}"
zip_file = "#{versioned}.html.zip"
html_dir = versioned
delete_dir(html_dir) if dir_exists?(html_dir)
create_dir(html_dir)
unzip_file(zip_file, html_dir)
metadata = parse_metadata(full_path("#{versioned}.metadata.json"))
site_url = metadata.delete(:site)
software = update_or_create_software(metadata.merge(platform: "ebitengine"))
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
create_release_if_not_exists(
software_id: software.id,
version: version,
html_folder_path: full_path(html_dir)
)
end
private
def parse_metadata(path)
raw = JSON.parse(File.read(path), symbolize_names: true)
raw.slice(:name, :title, :author, :desc, :site, :license)
end
end
end
@@ -0,0 +1,34 @@
require "json"
module SoftwareUpdater
class LoveService < BaseService
def update(name, version)
versioned = "#{name}-#{version}"
zip_file = "#{versioned}.html.zip"
html_dir = versioned
delete_dir(html_dir) if dir_exists?(html_dir)
create_dir(html_dir)
unzip_file(zip_file, html_dir)
metadata = parse_metadata(full_path("#{versioned}.metadata.json"))
site_url = metadata.delete(:site)
software = update_or_create_software(metadata.merge(platform: "love"))
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
create_release_if_not_exists(
software_id: software.id,
version: version,
html_folder_path: full_path(html_dir)
)
end
private
def parse_metadata(path)
raw = JSON.parse(File.read(path), symbolize_names: true)
raw.slice(:name, :title, :author, :desc, :site, :license)
end
end
end
@@ -0,0 +1,50 @@
module SoftwareUpdater
class Tic80Service < BaseService
def update(name, version)
versioned = "#{name}-#{version}"
zip_file = "#{versioned}.html.zip"
docs_zip_file = "#{versioned}-docs.zip"
html_dir = versioned
docs_dir = "#{versioned}-docs"
delete_dir(html_dir) if dir_exists?(html_dir)
delete_dir(docs_dir) if dir_exists?(docs_dir)
create_dir(html_dir)
unzip_file(zip_file, html_dir)
create_dir(docs_dir)
unzip_file(docs_zip_file, docs_dir)
metadata = parse_lua_metadata(full_path("#{versioned}.lua"))
site_url = metadata.delete(:site)
software = update_or_create_software(metadata.merge(platform: "tic80"))
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
create_release_if_not_exists(
software_id: software.id,
version: version,
cartridge_path: full_path("#{versioned}.tic"),
source_path: full_path("#{versioned}.lua"),
html_folder_path: full_path(html_dir),
docs_folder_path: full_path(docs_dir)
)
end
private
def parse_lua_metadata(source_path)
metadata = {}
File.foreach(source_path) do |line|
break unless line.start_with?("--")
parts = line[2..].split(":", 2)
next if parts.length != 2
key = parts[0].strip.downcase.to_sym
value = parts[1].strip
metadata[key] = value
end
metadata
end
end
end
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application's executable is in bin/bundle.
#
require "rubygems"
m = Module.new do
module_function
def invoked_as_script?
File.expand_path($0) == File.expand_path(__FILE__)
end
def env_var_version
ENV["BUNDLER_VERSION"]
end
def cli_arg_version
return unless invoked_as_script?
return unless "--version" == ARGV.first
bundler_version = nil
update_index = nil
ARGV.each_with_index do |a, i|
bundler_version = a if update_index && update_index.succ == i
next unless a =~ /\A--bundler\z/
update_index = i
end
bundler_version
end
def gemfile
gemfile = ENV["BUNDLE_GEMFILE"]
return gemfile if gemfile && !gemfile.empty?
File.expand_path("../Gemfile", __dir__)
end
def lockfile
lockfile = "#{gemfile}.lock"
return lockfile if File.exist?(lockfile)
nil
end
def bundler_version
@bundler_version ||=
env_var_version || cli_arg_version ||
lockfile_version
end
def lockfile_version
return unless lockfile
lockfile_contents = File.read(lockfile)
return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s*(#{Gem::Version::VERSION_PATTERN})\n/
Regexp.last_match(1)
end
def load_bundler!
$LOAD_PATH.unshift(bundler_spec.lib_dirs_glob) if bundler_spec
require "bundler"
unless bundler_spec
Gem::GemRunner.new.run(["install", "bundler", "--version", bundler_version]) if bundler_version
require "bundler"
end
end
def bundler_spec
@bundler_spec ||= Gem::Specification.find_by_name("bundler", bundler_version)
rescue Gem::MissingSpecVersionError, Gem::MissingSpecError
nil
end
end
m.load_bundler!
if m.invoked_as_script?
load Gem.bin_path("bundler", "bundle")
end
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env ruby
APP_PATH = File.expand_path("../config/application", __dir__)
require_relative "../config/boot"
require "rails/commands"
+3
View File
@@ -0,0 +1,3 @@
require_relative "config/environment"
run Rails.application
Rails.application.load_server
+23
View File
@@ -0,0 +1,23 @@
require_relative "boot"
require "rails"
require "active_model/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "action_dispatch/railtie"
require "sprockets/railtie"
Bundler.require(*Rails.groups)
module Api
class Application < Rails::Application
config.load_defaults 8.0
config.time_zone = "UTC"
config.active_record.default_timezone = :utc
config.assets.prefix = "/admin/assets"
config.autoload_lib(ignore: %w[assets tasks])
end
end
+4
View File
@@ -0,0 +1,4 @@
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
require 'bundler/setup'
require 'bootsnap/setup'
+16
View File
@@ -0,0 +1,16 @@
default: &default
adapter: mysql2
encoding: utf8mb4
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: <%= ENV.fetch("DB_USER", "root") %>
password: <%= ENV.fetch("DB_PASSWORD", "") %>
host: <%= ENV.fetch("DB_HOST", "mysql") %>
port: <%= ENV.fetch("DB_PORT", "3306") %>
development:
<<: *default
database: <%= ENV.fetch("DB_NAME", "softwares") %>
production:
<<: *default
database: <%= ENV.fetch("DB_NAME", "softwares") %>
+3
View File
@@ -0,0 +1,3 @@
require_relative "application"
Rails.application.initialize!
@@ -0,0 +1,14 @@
Rails.application.configure do
config.eager_load = false
config.consider_all_requests_local = true
config.server_timing = true
config.cache_classes = false
config.action_controller.perform_caching = false
config.active_record.migration_error = :page_load
config.active_record.verbose_query_logs = true
config.log_level = :debug
config.log_tags = [:request_id]
end
@@ -0,0 +1,7 @@
Rails.application.configure do
config.eager_load = true
config.consider_all_requests_local = false
config.log_level = :info
config.log_tags = [:request_id]
config.active_record.verbose_query_logs = false
end
@@ -0,0 +1,14 @@
ActiveAdmin.setup do |config|
config.site_title = "Teletype Games Admin"
config.authentication_method = :authenticate_admin_user!
config.current_user_method = :current_admin_user
config.logout_link_path = :destroy_admin_user_session_path
config.logout_link_method = :delete
config.comments = false
config.batch_actions = true
config.filter_attributes = [:password, :password_confirmation, :current_password, :encrypted_password]
config.localize_format = :long
end
+16
View File
@@ -0,0 +1,16 @@
Devise.setup do |config|
config.mailer_sender = "noreply@teletype.hu"
require "devise/orm/active_record"
config.case_insensitive_keys = [:email]
config.strip_whitespace_keys = [:email]
config.skip_session_storage = [:http_auth]
config.stretches = Rails.env.test? ? 1 : 12
config.reconfirmable = false
config.expire_all_remember_me_on_sign_out = true
config.password_length = 6..128
config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
config.reset_password_within = 6.hours
config.sign_out_via = :delete
end
@@ -0,0 +1,5 @@
Rails.application.config.action_dispatch.default_headers = {
"Cross-Origin-Opener-Policy" => "same-origin",
"Cross-Origin-Embedder-Policy" => "require-corp",
"Cross-Origin-Resource-Policy" => "same-origin"
}
+8
View File
@@ -0,0 +1,8 @@
max_threads_count = ENV.fetch("RAILS_MAX_THREADS", 5)
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count
port ENV.fetch("PORT", 3000)
environment ENV.fetch("RAILS_ENV", "development")
pidfile ENV.fetch("PIDFILE", "tmp/pids/server.pid")
plugin :tmp_restart
+12
View File
@@ -0,0 +1,12 @@
Rails.application.routes.draw do
devise_for :admin_users, ActiveAdmin::Devise.config
ActiveAdmin.routes(self)
namespace :api do
get "software", to: "software#index"
get "software/highlighted", to: "software_highlighted#index"
end
get "update", to: "update#update"
get "file/*path", to: "files#show", format: false
end
@@ -0,0 +1,24 @@
class CreateSoftwares < ActiveRecord::Migration[8.1]
def change
create_table :softwares, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
if_not_exists: true do |t|
t.string :author
t.datetime :created_at, precision: 3
t.datetime :deleted_at, precision: 3
t.text :desc
t.boolean :highlighted, default: false
t.string :license, limit: 128
t.string :name, limit: 128
t.string :platform, limit: 128
t.string :site
t.string :status, limit: 20, default: "development"
t.text :story
t.string :title
t.datetime :updated_at, precision: 3
t.index :deleted_at, name: "idx_softwares_deleted_at"
t.index :name, name: "idx_softwares_name", unique: true
end
end
end
@@ -0,0 +1,23 @@
class CreateReleases < ActiveRecord::Migration[8.1]
def change
create_table :releases, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
if_not_exists: true do |t|
t.string :cartridge_path
t.datetime :created_at, precision: 3
t.datetime :deleted_at, precision: 3
t.string :docs_folder_path
t.string :html_folder_path
t.bigint :software_id, unsigned: true
t.string :source_path
t.datetime :updated_at, precision: 3
t.string :version, limit: 64
t.boolean :web_playable, default: false
t.index :deleted_at, name: "idx_releases_deleted_at"
t.index :software_id, name: "idx_releases_software_id"
end
add_foreign_key :releases, :softwares, name: "fk_softwares_releases" unless foreign_key_exists?(:releases, :softwares)
end
end
@@ -0,0 +1,19 @@
class CreateExternalLinks < ActiveRecord::Migration[8.1]
def change
create_table :external_links, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
if_not_exists: true do |t|
t.datetime :created_at, precision: 3
t.datetime :deleted_at, precision: 3
t.string :label, limit: 128
t.bigint :software_id, unsigned: true
t.datetime :updated_at, precision: 3
t.string :url
t.index :deleted_at, name: "idx_external_links_deleted_at"
t.index :software_id, name: "idx_external_links_software_id"
end
add_foreign_key :external_links, :softwares, name: "fk_softwares_external_links" unless foreign_key_exists?(:external_links, :softwares)
end
end
@@ -0,0 +1,17 @@
class CreateAdminUsers < ActiveRecord::Migration[8.1]
def change
create_table :admin_users, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
if_not_exists: true do |t|
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""
t.string :reset_password_token
t.datetime :reset_password_sent_at
t.datetime :remember_created_at
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
t.index :email, name: "index_admin_users_on_email", unique: true
t.index :reset_password_token, name: "index_admin_users_on_reset_password_token", unique: true
end
end
end
+173
View File
@@ -0,0 +1,173 @@
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_05_05_000004) 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
t.string "encrypted_password", default: "", null: false
t.datetime "remember_created_at"
t.datetime "reset_password_sent_at"
t.string "reset_password_token"
t.datetime "updated_at", null: false
t.index ["email"], name: "index_admin_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
end
create_table "articles", id: :integer, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.text "content"
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.string "slug"
t.string "title"
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
end
create_table "external_links", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.string "label", limit: 128
t.bigint "software_id", unsigned: true
t.datetime "updated_at", precision: 3
t.string "url"
t.index ["deleted_at"], name: "idx_external_links_deleted_at"
t.index ["software_id"], name: "idx_external_links_software_id"
end
create_table "goadmin_menu", id: { type: :integer, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.string "header", limit: 150
t.string "icon", limit: 50, null: false
t.integer "order", default: 0, null: false
t.integer "parent_id", default: 0, null: false
t.string "plugin_name", limit: 150, default: "", null: false
t.string "title", limit: 50, null: false
t.integer "type", default: 0, null: false
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
t.string "uri", null: false
t.string "uuid", limit: 150
end
create_table "goadmin_operation_log", id: { type: :integer, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.text "input", null: false
t.string "ip", limit: 15, null: false
t.string "method", limit: 10, null: false
t.string "path", null: false
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
t.integer "user_id", null: false
end
create_table "goadmin_permissions", id: { type: :integer, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.string "http_method"
t.text "http_path"
t.string "name", limit: 50, null: false
t.string "slug", limit: 50, null: false
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
t.index ["name"], name: "permissions_name_unique", unique: true
t.index ["slug"], name: "permissions_slug_unique", unique: true
end
create_table "goadmin_role_menu", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.integer "menu_id", null: false
t.integer "role_id", null: false
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
end
create_table "goadmin_role_permissions", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.integer "permission_id", null: false
t.integer "role_id", null: false
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
end
create_table "goadmin_role_users", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.integer "role_id", null: false
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
t.integer "user_id", null: false
end
create_table "goadmin_roles", id: { type: :integer, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.string "name", limit: 100, null: false
t.string "slug", limit: 100, null: false
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
t.index ["name"], name: "roles_name_unique", unique: true
t.index ["slug"], name: "roles_slug_unique", unique: true
end
create_table "goadmin_site", id: { type: :integer, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.string "description", limit: 3000, null: false
t.string "key_", limit: 100, null: false
t.integer "state", limit: 1, default: 0, null: false, unsigned: true
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
t.text "value", null: false
t.index ["key_"], name: "site_key_unique", unique: true
end
create_table "goadmin_user_permissions", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.integer "permission_id", null: false
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
t.integer "user_id", null: false
end
create_table "goadmin_users", id: { type: :integer, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "avatar"
t.timestamp "created_at", default: -> { "CURRENT_TIMESTAMP" }
t.string "name", limit: 100, null: false
t.string "password", limit: 100, null: false
t.string "remember_token", limit: 100
t.timestamp "updated_at", default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
t.string "username", limit: 100, null: false
t.index ["username"], name: "users_username_unique", 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
t.datetime "deleted_at", precision: 3
t.string "docs_folder_path"
t.string "html_folder_path"
t.bigint "software_id", unsigned: true
t.string "source_path"
t.datetime "updated_at", precision: 3
t.string "version", limit: 64
t.boolean "web_playable", default: false
t.index ["deleted_at"], name: "idx_releases_deleted_at"
t.index ["software_id"], name: "idx_releases_software_id"
end
create_table "softwares", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "author"
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.text "desc"
t.boolean "highlighted", default: false
t.string "license", limit: 128
t.string "name", limit: 128
t.string "platform", limit: 128
t.string "site"
t.string "status", limit: 20, default: "development"
t.text "story"
t.string "title"
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
t.index ["name"], name: "idx_softwares_name", unique: true
end
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links"
add_foreign_key "releases", "softwares", name: "fk_softwares_releases"
end
+4
View File
@@ -0,0 +1,4 @@
AdminUser.find_or_create_by!(email: ENV.fetch("ADMIN_EMAIL", "admin@teletype.hu")) do |user|
user.password = ENV.fetch("ADMIN_PASSWORD", "password123")
user.password_confirmation = ENV.fetch("ADMIN_PASSWORD", "password123")
end
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -e
mkdir -p tmp/pids tmp/cache tmp/sockets log
rm -f tmp/pids/server.pid
bundle check || bundle install
bundle exec rails db:migrate
bundle exec rails db:seed
bundle exec rails server -b 0.0.0.0 -p 3000