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
+4 -1
View File
@@ -2,7 +2,10 @@
"permissions": {
"allow": [
"Bash(go build *)",
"Bash(go vet *)"
"Bash(go vet *)",
"Bash(chmod +x *)",
"Bash(xargs ruby *)",
"Bash(docker compose exec *)"
]
}
}
+2
View File
@@ -5,3 +5,5 @@ teletype_softwares
pma-config.user.inc.php
data
apps/frontend/src/events.yaml
NOTES_*
+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
-39
View File
@@ -1,39 +0,0 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/teletype_softwares"
cmd = "go build -o ./tmp/teletype_softwares ./main.go"
delay = 1000
exclude_dir = [
"tmp",
]
exclude_file = []
exclude_regex = ["_test.go", "data"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
-21
View File
@@ -1,21 +0,0 @@
# stage 1: building application binary file
FROM --platform=linux/amd64 golang:1.19-alpine as build
WORKDIR /app
COPY . .
RUN go mod tidy
RUN go build -o main ./main.go
# stage 2: copy only the application binary file and necessary files to the alpine container
FROM --platform=linux/amd64 alpine:latest
RUN apk --update add ca-certificates
WORKDIR /app
COPY --from=build /app/main .
COPY --from=build /app/http/openapi.yml .
# run the service on container startup.
CMD ["/app/main"]
-14
View File
@@ -1,14 +0,0 @@
FROM --platform=linux/amd64 golang:1.23-alpine
RUN apk add curl git unzip
WORKDIR /app
COPY . .
RUN chmod +x ./entrypoint.sh
RUN curl -fLo install.sh https://raw.githubusercontent.com/cosmtrek/air/master/install.sh \
&& chmod +x install.sh && sh install.sh && cp ./bin/air /bin/air
CMD ["./entrypoint.sh"]
-66
View File
@@ -1,66 +0,0 @@
package domain
import (
"teletype_softwares/lib/mysql_utils"
)
type Domain struct {
SoftwareRepository SoftwareRepositoryInterface
ReleaseRepository ReleaseRepositoryInterface
FileRepository FileRepositoryInterface
ExternalLinkRepository ExternalLinkRepositoryInterface
DownloadService DownloadServiceInterface
SoftwareUpdaterService SoftwareUpdaterServiceInterface
SoftwareService SoftwareServiceInterface
FileService FileServiceInterface
}
func NewDomain() Domain {
DB := mysql_utils.Init()
MigrateGoDatabase(DB)
software_repository := &SoftwareRepository{db: DB}
release_repository := &ReleaseRepository{db: DB}
file_repository := NewFileRepository()
external_link_repository := &ExternalLinkRepository{db: DB}
software_service := NewSoftwareService(software_repository, release_repository)
tic80_updater := NewSoftwareUpdaterTIC80Service(
software_repository,
release_repository,
file_repository,
external_link_repository,
)
ebitengine_updater := NewSoftwareUpdaterEbitengineService(
software_repository,
release_repository,
file_repository,
external_link_repository,
)
love_updater := NewSoftwareUpdaterLoveService(
software_repository,
release_repository,
file_repository,
external_link_repository,
)
software_updater_service := NewSoftwareUpdaterService(tic80_updater, ebitengine_updater, love_updater)
download_service := NewDownloadService(software_repository, release_repository)
file_service := NewFileService(file_repository)
return Domain{
SoftwareRepository: software_repository,
ReleaseRepository: release_repository,
FileRepository: file_repository,
ExternalLinkRepository: external_link_repository,
DownloadService: download_service,
SoftwareUpdaterService: software_updater_service,
SoftwareService: software_service,
FileService: file_service,
}
}
@@ -1,10 +0,0 @@
package domain
import "gorm.io/gorm"
type ExternalLink struct {
gorm.Model
SoftwareID uint `gorm:"index" json:"softwareId"`
Label string `gorm:"size:128" json:"label"`
URL string `gorm:"size:255" json:"url"`
}
-13
View File
@@ -1,13 +0,0 @@
package domain
import (
"gorm.io/gorm"
)
func MigrateGoDatabase(db *gorm.DB) {
db.AutoMigrate(
Software{},
Release{},
ExternalLink{},
)
}
-13
View File
@@ -1,13 +0,0 @@
package domain
import "gorm.io/gorm"
type Release struct {
gorm.Model
SoftwareID uint `gorm:"index" json:"softwareId"`
Version string `gorm:"size:64" json:"version"`
CartridgePath string `gorm:"size:255" json:"cartridgePath"`
SourcePath string `gorm:"size:255" json:"sourcePath"`
HTMLFolderPath string `gorm:"size:255" json:"htmlFolderPath"`
DocsFolderPath string `gorm:"size:255" json:"docsFolderPath"`
}
-18
View File
@@ -1,18 +0,0 @@
package domain
import "gorm.io/gorm"
type Software struct {
gorm.Model
Name string `gorm:"uniqueIndex;size:128" json:"name"`
Title string `gorm:"size:255" json:"title"`
Author string `gorm:"size:255" json:"author"`
Desc string `gorm:"type:text" json:"desc"`
Story string `gorm:"type:text" json:"story"`
License string `gorm:"size:128" json:"license"`
Platform string `gorm:"size:128" json:"platform"`
Status string `gorm:"size:20;default:development" json:"status"`
Highlighted bool `gorm:"default:false" json:"highlighted"`
Releases []Release `json:"-"`
ExternalLinks []ExternalLink `json:"externalLinks"`
}
@@ -1,23 +0,0 @@
package domain
import "gorm.io/gorm"
type ExternalLinkRepositoryInterface interface {
UpsertByLabel(softwareID uint, label, url string) error
}
type ExternalLinkRepository struct {
db *gorm.DB
}
func (r *ExternalLinkRepository) UpsertByLabel(softwareID uint, label, url string) error {
var link ExternalLink
err := r.db.Where("software_id = ? AND label = ?", softwareID, label).First(&link).Error
if err == gorm.ErrRecordNotFound {
return r.db.Create(&ExternalLink{SoftwareID: softwareID, Label: label, URL: url}).Error
}
if err != nil {
return err
}
return r.db.Model(&link).Update("url", url).Error
}
-85
View File
@@ -1,85 +0,0 @@
package domain
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
type FileRepositoryInterface interface {
GetPath(path string) string
FileExists(path string) bool
CreateDir(path string) error
DeleteDir(path string) error
DeleteFile(path string) error
MoveFile(src_path, dest_path string) error
UnzipFile(path, dest_path string) error
}
type FileRepository struct {
fileContainerPath string
}
func NewFileRepository() *FileRepository {
file_container_path, _ := os.LookupEnv("FILE_CONTAINER_PATH")
return &FileRepository{
fileContainerPath: file_container_path,
}
}
func (fr *FileRepository) GetPath(path string) string {
return filepath.Join(fr.fileContainerPath, path)
}
func (fr *FileRepository) FileExists(path string) bool {
full_path := fr.GetPath(path)
_, err := os.Stat(full_path)
return err == nil
}
func (fr *FileRepository) CreateDir(path string) error {
full_path := fr.GetPath(path)
return os.MkdirAll(full_path, 0755)
}
func (fr *FileRepository) DeleteDir(path string) error {
full_path := fr.GetPath(path)
return os.RemoveAll(full_path)
}
func (fr *FileRepository) DeleteFile(path string) error {
full_path := fr.GetPath(path)
return os.RemoveAll(full_path)
}
func (fr *FileRepository) MoveFile(src_path, dest_path string) error {
full_src_path := fr.GetPath(src_path)
full_dest_path := fr.GetPath(dest_path)
dest_dir := filepath.Dir(full_dest_path)
if err := os.MkdirAll(dest_dir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
return os.Rename(full_src_path, full_dest_path)
}
func (fr *FileRepository) UnzipFile(zipfile, desc string) error {
zip_path := fr.GetPath(zipfile)
desc_path := fr.GetPath(desc)
if err := os.MkdirAll(desc_path, 0755); err != nil {
return err
}
cmd := exec.Command("unzip", "-q", zip_path, "-d", desc_path)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return os.Remove(zip_path)
}
-49
View File
@@ -1,49 +0,0 @@
package domain
import "gorm.io/gorm"
type ReleaseRepositoryInterface interface {
Create(release *Release) error
CreateIfNotExist(release *Release) error
FindLatestBySoftwareID(software_id uint) (*Release, error)
FindBySoftwareIDAndVersion(software_id uint, version string) (*Release, error)
ListBySoftwareID(software_id uint) []Release
}
type ReleaseRepository struct {
db *gorm.DB
}
func (r *ReleaseRepository) Create(release *Release) error {
return r.db.Create(release).Error
}
func (r *ReleaseRepository) CreateIfNotExist(release *Release) error {
var existing Release
err := r.db.Where("software_id = ? AND version = ?", release.SoftwareID, release.Version).First(&existing).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return r.db.Create(release).Error
}
return err
}
return nil
}
func (r *ReleaseRepository) FindLatestBySoftwareID(software_id uint) (*Release, error) {
var release Release
err := r.db.Where("software_id = ?", software_id).Order("created_at desc").First(&release).Error
return &release, err
}
func (r *ReleaseRepository) FindBySoftwareIDAndVersion(software_id uint, version string) (*Release, error) {
var release Release
err := r.db.Where("software_id = ? AND version = ?", software_id, version).First(&release).Error
return &release, err
}
func (r *ReleaseRepository) ListBySoftwareID(software_id uint) []Release {
var releases []Release
r.db.Where("software_id = ?", software_id).Find(&releases)
return releases
}
@@ -1,52 +0,0 @@
package domain
import "gorm.io/gorm"
type SoftwareRepositoryInterface interface {
List() ([]Software, error)
GetByName(name string) (*Software, error)
UpdateOrCreate(software *Software) error
GetHighlighted() (*Software, error)
}
type SoftwareRepository struct {
db *gorm.DB
}
func (r *SoftwareRepository) GetHighlighted() (*Software, error) {
var software Software
if err := r.db.Preload("Releases").Preload("ExternalLinks").Where("highlighted = 1").Order("id DESC").First(&software).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &software, nil
}
func (r *SoftwareRepository) GetByName(name string) (*Software, error) {
var software Software
if err := r.db.Preload("Releases").Preload("ExternalLinks").Where("name = ?", name).First(&software).Error; err != nil {
return nil, err
}
return &software, nil
}
func (r *SoftwareRepository) List() ([]Software, error) {
var softwares []Software
if err := r.db.Preload("Releases").Preload("ExternalLinks").Find(&softwares).Error; err != nil {
return nil, err
}
return softwares, nil
}
func (r *SoftwareRepository) UpdateOrCreate(software *Software) error {
var existing Software
if err := r.db.Where("name = ?", software.Name).First(&existing).Error; err == nil {
software.ID = existing.ID
return r.db.Model(&existing).Updates(software).Error
} else {
return r.db.Create(software).Error
}
}
-56
View File
@@ -1,56 +0,0 @@
package domain
import (
"errors"
)
var (
ErrSoftwareNotFound = errors.New("software not found")
ErrNoReleasesFound = errors.New("no releases found for software")
ErrReleaseNotFound = errors.New("release not found for software and version")
)
type DownloadServiceInterface interface {
GetLatestRelease(software_name string) (*Release, error)
GetSpecificRelease(software_name string, version string) (*Release, error)
}
type DownloadService struct {
softwareRepository SoftwareRepositoryInterface
releaseRepository ReleaseRepositoryInterface
}
func NewDownloadService(software_repository SoftwareRepositoryInterface, release_repository ReleaseRepositoryInterface) *DownloadService {
return &DownloadService{
softwareRepository: software_repository,
releaseRepository: release_repository,
}
}
func (s *DownloadService) GetLatestRelease(software_name string) (*Release, error) {
software, err := s.softwareRepository.GetByName(software_name)
if err != nil {
return nil, ErrSoftwareNotFound
}
release, err := s.releaseRepository.FindLatestBySoftwareID(software.ID)
if err != nil {
return nil, ErrNoReleasesFound
}
return release, nil
}
func (s *DownloadService) GetSpecificRelease(software_name string, version string) (*Release, error) {
software, err := s.softwareRepository.GetByName(software_name)
if err != nil {
return nil, ErrSoftwareNotFound
}
release, err := s.releaseRepository.FindBySoftwareIDAndVersion(software.ID, version)
if err != nil {
return nil, ErrReleaseNotFound
}
return release, nil
}
-19
View File
@@ -1,19 +0,0 @@
package domain
type FileServiceInterface interface {
GetPath(path string) string
}
type FileService struct {
FileRepository FileRepositoryInterface
}
func NewFileService(file_repository FileRepositoryInterface) *FileService {
return &FileService{
FileRepository: file_repository,
}
}
func (s *FileService) GetPath(path string) string {
return s.FileRepository.GetPath(path)
}
-153
View File
@@ -1,153 +0,0 @@
package domain
import (
"fmt"
"sort"
)
type SoftwareListDTO struct {
Software
LatestRelease *Release
}
type SoftwareDetaildListDTO struct {
Softwares []SoftwareShowData `json:"softwares"`
}
type SoftwareShowData struct {
Software *Software `json:"software"`
Releases []Release `json:"releases"`
LatestRelease *Release `json:"latestRelease"`
WebPlayableRelease *Release `json:"webPlayableRelease"`
}
type SoftwareServiceInterface interface {
List() ([]SoftwareListDTO, error)
DetailedList() (*SoftwareDetaildListDTO, error)
GetByName(name string) (*Software, error)
GetLatestRelease(software_id string) (*Release, error)
GetForShowByName(name string) (*SoftwareShowData, error)
GetHighlighted() (*SoftwareShowData, error)
}
type SoftwareService struct {
softeare_repository SoftwareRepositoryInterface
release_repository ReleaseRepositoryInterface
}
func (s *SoftwareService) GetHighlighted() (*SoftwareShowData, error) {
software, err := s.softeare_repository.GetHighlighted()
if err != nil {
return nil, err
}
if software == nil {
return nil, nil
}
return s.GetForShowByName(software.Name)
}
func NewSoftwareService(
softeare_repository SoftwareRepositoryInterface,
release_repository ReleaseRepositoryInterface,
) *SoftwareService {
return &SoftwareService{
softeare_repository: softeare_repository,
release_repository: release_repository,
}
}
func (s *SoftwareService) List() ([]SoftwareListDTO, error) {
softwares, err := s.softeare_repository.List()
if err != nil {
return nil, err
}
dtos := make([]SoftwareListDTO, 0, len(softwares))
for _, sw := range softwares {
dto := SoftwareListDTO{Software: sw}
if len(sw.Releases) > 0 {
sort.Slice(sw.Releases, func(i, j int) bool {
return sw.Releases[i].CreatedAt.After(sw.Releases[j].CreatedAt)
})
dto.LatestRelease = &sw.Releases[0]
}
dtos = append(dtos, dto)
}
return dtos, nil
}
func (s *SoftwareService) GetByName(name string) (*Software, error) {
return s.softeare_repository.GetByName(name)
}
func (s *SoftwareService) GetForShowByName(name string) (*SoftwareShowData, error) {
software, err := s.softeare_repository.GetByName(name)
if err != nil {
return nil, err
}
if software == nil {
return nil, fmt.Errorf("software not found")
}
releases := s.release_repository.ListBySoftwareID(software.ID)
var latest_release *Release
if len(releases) > 0 {
sort.Slice(releases, func(i, j int) bool {
return releases[i].CreatedAt.After(releases[j].CreatedAt)
})
latest_release = &releases[0]
}
var web_playable_release *Release
for i := range releases {
if releases[i].HTMLFolderPath != "" {
web_playable_release = &releases[i] // fix: slice elem címe, nem a loop változóé
break
}
}
return &SoftwareShowData{
Software: software,
Releases: releases,
LatestRelease: latest_release,
WebPlayableRelease: web_playable_release,
}, nil
}
func (s *SoftwareService) GetLatestRelease(software_id string) (*Release, error) {
software, err := s.softeare_repository.GetByName(software_id)
if err != nil {
return nil, err
}
if software == nil {
return nil, nil
}
if len(software.Releases) == 0 {
return nil, nil
}
sort.Slice(software.Releases, func(i, j int) bool {
return software.Releases[i].CreatedAt.After(software.Releases[j].CreatedAt)
})
return &software.Releases[0], nil
}
func (s *SoftwareService) DetailedList() (*SoftwareDetaildListDTO, error) {
softwares, err := s.softeare_repository.List()
if err != nil {
return nil, err
}
dtos := make([]SoftwareShowData, 0, len(softwares))
for _, software := range softwares {
showData, err := s.GetForShowByName(software.Name)
if err != nil {
return nil, err
}
dtos = append(dtos, *showData)
}
return &SoftwareDetaildListDTO{Softwares: dtos}, nil
}
@@ -1,36 +0,0 @@
package domain
import (
"fmt"
)
type SoftwareUpdaterServiceInterface interface {
Update(platform, name, version string) error
}
type SoftwareUpdaterService struct {
tic80Updater SoftwareUpdaterTIC80ServiceInterface
ebitengineUpdater SoftwareUpdaterEbitengineServiceInterface
loveUpdater SoftwareUpdaterLoveServiceInterface
}
func NewSoftwareUpdaterService(tic80_updater SoftwareUpdaterTIC80ServiceInterface, ebitengine_updater SoftwareUpdaterEbitengineServiceInterface, love_updater SoftwareUpdaterLoveServiceInterface) *SoftwareUpdaterService {
return &SoftwareUpdaterService{
tic80Updater: tic80_updater,
ebitengineUpdater: ebitengine_updater,
loveUpdater: love_updater,
}
}
func (s *SoftwareUpdaterService) Update(platform, name, version string) error {
if platform == "tic80" {
return s.tic80Updater.Update(name, version)
}
if platform == "ebitengine" {
return s.ebitengineUpdater.Update(name, version)
}
if platform == "love" {
return s.loveUpdater.Update(name, version)
}
return fmt.Errorf("unsupported platform: %s", platform)
}
@@ -1,134 +0,0 @@
package domain
import (
"encoding/json"
"fmt"
"os"
)
type SoftwareUpdaterEbitengineMetadata struct {
Name string `json:"name"`
Title string `json:"title"`
Author string `json:"author"`
Desc string `json:"desc"`
Site string `json:"site"`
License string `json:"license"`
}
type SoftwareUpdaterEbitengineServiceInterface interface {
Update(name, version string) error
}
type SoftwareUpdaterEbitengineService struct {
softwareRepository SoftwareRepositoryInterface
releaseRepository ReleaseRepositoryInterface
fileRepository FileRepositoryInterface
externalLinkRepository ExternalLinkRepositoryInterface
}
func NewSoftwareUpdaterEbitengineService(
software_repository SoftwareRepositoryInterface,
release_repository ReleaseRepositoryInterface,
file_repository FileRepositoryInterface,
external_link_repository ExternalLinkRepositoryInterface,
) *SoftwareUpdaterEbitengineService {
return &SoftwareUpdaterEbitengineService{
softwareRepository: software_repository,
releaseRepository: release_repository,
fileRepository: file_repository,
externalLinkRepository: external_link_repository,
}
}
func (s *SoftwareUpdaterEbitengineService) Update(name, version string) error {
fmt.Printf("Ebitengine Updater: Starting update for name: %s, version: %s\n", name, version)
versioned_name := name + "-" + version
zip_filename := versioned_name + ".html.zip"
metadata_filename := versioned_name + ".metadata.json"
html_dirname := versioned_name
fmt.Printf("Ebitengine Updater: Processing zip file: %s\n", zip_filename)
fmt.Printf("Ebitengine Updater: Processing metadata file: %s\n", metadata_filename)
fmt.Printf("Ebitengine Updater: Processing HTML directory: %s\n", html_dirname)
if s.fileRepository.FileExists(html_dirname) {
fmt.Printf("Ebitengine Updater: Removing existing HTML directory: %s\n", html_dirname)
s.fileRepository.DeleteDir(html_dirname)
}
fmt.Printf("Ebitengine Updater: Creating HTML directory: %s\n", html_dirname)
s.fileRepository.CreateDir(html_dirname)
fmt.Printf("Ebitengine Updater: Unzipping file: %s to directory: %s\n", zip_filename, html_dirname)
s.fileRepository.UnzipFile(zip_filename, html_dirname)
html_dir_path := s.fileRepository.GetPath(html_dirname)
metadata_path := s.fileRepository.GetPath(metadata_filename)
var err error
fmt.Printf("Ebitengine Updater: Extracting metadata from metadata file: %s\n", metadata_path)
meta_data, err := s.GetMetadata(metadata_path)
if err != nil {
fmt.Printf("Ebitengine Updater: Error extracting metadata: %s\n", err.Error())
return err
}
fmt.Printf("Ebitengine Updater: Extracted metadata: %+v\n", meta_data)
siteURL := meta_data.Site
software := s.BuildSoftware(meta_data)
err = s.softwareRepository.UpdateOrCreate(software)
if err != nil {
fmt.Printf("Ebitengine Updater: Error updating or creating software: %s\n", err.Error())
return err
}
if siteURL != "" {
if linkErr := s.externalLinkRepository.UpsertByLabel(software.ID, "Source Code", siteURL); linkErr != nil {
fmt.Printf("Ebitengine Updater: Error upserting external link: %s\n", linkErr.Error())
}
}
release := Release{
SoftwareID: software.ID,
Version: version,
CartridgePath: "",
SourcePath: "",
HTMLFolderPath: html_dir_path,
}
fmt.Printf("Ebitengine Updater: Creating release for software ID: %d, version: %s\n", software.ID, version)
s.releaseRepository.CreateIfNotExist(&release)
fmt.Printf("Ebitengine Updater: Successfully processed Lua cartridge: %s\n", name)
return nil
}
func (s *SoftwareUpdaterEbitengineService) BuildSoftware(metadata SoftwareUpdaterEbitengineMetadata) *Software {
software := &Software{
Name: metadata.Name,
Title: metadata.Title,
Author: metadata.Author,
Desc: metadata.Desc,
License: metadata.License,
Platform: "ebitengine",
}
return software
}
func (s *SoftwareUpdaterEbitengineService) GetMetadata(metadata_path string) (SoftwareUpdaterEbitengineMetadata, error) {
var metadata SoftwareUpdaterEbitengineMetadata
data, err := os.ReadFile(metadata_path)
if err != nil {
return metadata, err
}
if err := json.Unmarshal(data, &metadata); err != nil {
return metadata, err
}
return metadata, nil
}
@@ -1,134 +0,0 @@
package domain
import (
"encoding/json"
"fmt"
"os"
)
type SoftwareUpdaterLoveMetadata struct {
Name string `json:"name"`
Title string `json:"title"`
Author string `json:"author"`
Desc string `json:"desc"`
Site string `json:"site"`
License string `json:"license"`
}
type SoftwareUpdaterLoveServiceInterface interface {
Update(name, version string) error
}
type SoftwareUpdaterLoveService struct {
softwareRepository SoftwareRepositoryInterface
releaseRepository ReleaseRepositoryInterface
fileRepository FileRepositoryInterface
externalLinkRepository ExternalLinkRepositoryInterface
}
func NewSoftwareUpdaterLoveService(
software_repository SoftwareRepositoryInterface,
release_repository ReleaseRepositoryInterface,
file_repository FileRepositoryInterface,
external_link_repository ExternalLinkRepositoryInterface,
) *SoftwareUpdaterLoveService {
return &SoftwareUpdaterLoveService{
softwareRepository: software_repository,
releaseRepository: release_repository,
fileRepository: file_repository,
externalLinkRepository: external_link_repository,
}
}
func (s *SoftwareUpdaterLoveService) Update(name, version string) error {
fmt.Printf("Love Updater: Starting update for name: %s, version: %s\n", name, version)
versioned_name := name + "-" + version
zip_filename := versioned_name + ".html.zip"
metadata_filename := versioned_name + ".metadata.json"
html_dirname := versioned_name
fmt.Printf("Love Updater: Processing zip file: %s\n", zip_filename)
fmt.Printf("Love Updater: Processing metadata file: %s\n", metadata_filename)
fmt.Printf("Love Updater: Processing HTML directory: %s\n", html_dirname)
if s.fileRepository.FileExists(html_dirname) {
fmt.Printf("Love Updater: Removing existing HTML directory: %s\n", html_dirname)
s.fileRepository.DeleteDir(html_dirname)
}
fmt.Printf("Love Updater: Creating HTML directory: %s\n", html_dirname)
s.fileRepository.CreateDir(html_dirname)
fmt.Printf("Love Updater: Unzipping file: %s to directory: %s\n", zip_filename, html_dirname)
s.fileRepository.UnzipFile(zip_filename, html_dirname)
html_dir_path := s.fileRepository.GetPath(html_dirname)
metadata_path := s.fileRepository.GetPath(metadata_filename)
var err error
fmt.Printf("Love Updater: Extracting metadata from metadata file: %s\n", metadata_path)
meta_data, err := s.GetMetadata(metadata_path)
if err != nil {
fmt.Printf("Love Updater: Error extracting metadata: %s\n", err.Error())
return err
}
fmt.Printf("Love Updater: Extracted metadata: %+v\n", meta_data)
siteURL := meta_data.Site
software := s.BuildSoftware(meta_data)
err = s.softwareRepository.UpdateOrCreate(software)
if err != nil {
fmt.Printf("Love Updater: Error updating or creating software: %s\n", err.Error())
return err
}
if siteURL != "" {
if linkErr := s.externalLinkRepository.UpsertByLabel(software.ID, "Source Code", siteURL); linkErr != nil {
fmt.Printf("Love Updater: Error upserting external link: %s\n", linkErr.Error())
}
}
release := Release{
SoftwareID: software.ID,
Version: version,
CartridgePath: "",
SourcePath: "",
HTMLFolderPath: html_dir_path,
}
fmt.Printf("Love Updater: Creating release for software ID: %d, version: %s\n", software.ID, version)
s.releaseRepository.CreateIfNotExist(&release)
fmt.Printf("Love Updater: Successfully processed Lua cartridge: %s\n", name)
return nil
}
func (s *SoftwareUpdaterLoveService) BuildSoftware(metadata SoftwareUpdaterLoveMetadata) *Software {
software := &Software{
Name: metadata.Name,
Title: metadata.Title,
Author: metadata.Author,
Desc: metadata.Desc,
License: metadata.License,
Platform: "love",
}
return software
}
func (s *SoftwareUpdaterLoveService) GetMetadata(metadata_path string) (SoftwareUpdaterLoveMetadata, error) {
var metadata SoftwareUpdaterLoveMetadata
data, err := os.ReadFile(metadata_path)
if err != nil {
return metadata, err
}
if err := json.Unmarshal(data, &metadata); err != nil {
return metadata, err
}
return metadata, nil
}
@@ -1,159 +0,0 @@
package domain
import (
"bufio"
"fmt"
"os"
"strings"
)
type SoftwareUpdaterTIC80ServiceInterface interface {
Update(name, version string) error
}
type SoftwareUpdaterTIC80Service struct {
softwareRepository SoftwareRepositoryInterface
releaseRepository ReleaseRepositoryInterface
fileRepository FileRepositoryInterface
externalLinkRepository ExternalLinkRepositoryInterface
}
func NewSoftwareUpdaterTIC80Service(
software_repository SoftwareRepositoryInterface,
release_repository ReleaseRepositoryInterface,
file_repository FileRepositoryInterface,
external_link_repository ExternalLinkRepositoryInterface,
) *SoftwareUpdaterTIC80Service {
return &SoftwareUpdaterTIC80Service{
softwareRepository: software_repository,
releaseRepository: release_repository,
fileRepository: file_repository,
externalLinkRepository: external_link_repository,
}
}
func (s *SoftwareUpdaterTIC80Service) Update(name, version string) error {
fmt.Printf("TIC80 Updater: Starting update for name: %s, version: %s\n", name, version)
versioned_name := name + "-" + version
zip_filename := versioned_name + ".html.zip"
docs_zip_filename := versioned_name + "-docs.zip"
cartridge_filename := versioned_name + ".tic"
source_filename := versioned_name + ".lua"
html_dirname := versioned_name
docs_dirname := versioned_name + "-docs"
fmt.Printf("TIC80 Updater: Processing zip file: %s\n", zip_filename)
fmt.Printf("TIC80 Updater: Processing docs zip file: %s\n", docs_zip_filename)
fmt.Printf("TIC80 Updater: Processing cartridge file: %s\n", cartridge_filename)
fmt.Printf("TIC80 Updater: Processing source file: %s\n", source_filename)
fmt.Printf("TIC80 Updater: Processing HTML directory: %s\n", html_dirname)
fmt.Printf("TIC80 Updater: Processing docs directory: %s\n", docs_dirname)
if s.fileRepository.FileExists(html_dirname) {
fmt.Printf("TIC80 Updater: Removing existing HTML directory: %s\n", html_dirname)
s.fileRepository.DeleteDir(html_dirname)
}
if s.fileRepository.FileExists(docs_dirname) {
fmt.Printf("TIC80 Updater: Removing existing docs directory: %s\n", docs_dirname)
s.fileRepository.DeleteDir(docs_dirname)
}
s.fileRepository.CreateDir(html_dirname)
fmt.Printf("TIC80 Updater: Unzipping file: %s to directory: %s\n", zip_filename, html_dirname)
s.fileRepository.UnzipFile(zip_filename, html_dirname)
s.fileRepository.CreateDir(docs_dirname)
fmt.Printf("TIC80 Updater: Unzipping docs file: %s to directory: %s\n", docs_zip_filename, docs_dirname)
s.fileRepository.UnzipFile(docs_zip_filename, docs_dirname)
cartridge_path := s.fileRepository.GetPath((cartridge_filename))
source_path := s.fileRepository.GetPath(source_filename)
html_dir_path := s.fileRepository.GetPath(html_dirname)
docs_dir_path := s.fileRepository.GetPath(docs_dirname)
var err error
fmt.Printf("TIC80 Updater: Extracting metadata from source file: %s\n", source_path)
meta_data, err := s.GetMetadata(source_path)
if err != nil {
fmt.Printf("TIC80 Updater: Error extracting metadata: %s\n", err.Error())
return err
}
siteURL := meta_data["site"]
software := s.BuildSoftware(meta_data)
err = s.softwareRepository.UpdateOrCreate(software)
if err != nil {
fmt.Printf("TIC80 Updater: Error updating or creating software: %s\n", err.Error())
return err
}
if siteURL != "" {
if linkErr := s.externalLinkRepository.UpsertByLabel(software.ID, "Source Code", siteURL); linkErr != nil {
fmt.Printf("TIC80 Updater: Error upserting external link: %s\n", linkErr.Error())
}
}
fmt.Printf("TIC80 Updater: Creating release for software ID: %d, version: %s\n", software.ID, version)
release := Release{
SoftwareID: software.ID,
Version: version,
CartridgePath: cartridge_path,
SourcePath: source_path,
HTMLFolderPath: html_dir_path,
DocsFolderPath: docs_dir_path,
}
s.releaseRepository.CreateIfNotExist(&release)
fmt.Printf("TIC80 Updater: Successfully processed Lua cartridge: %s\n", name)
return nil
}
func (s *SoftwareUpdaterTIC80Service) BuildSoftware(metadata map[string]string) *Software {
software := &Software{
Name: metadata["name"],
Title: metadata["title"],
Author: metadata["author"],
Desc: metadata["desc"],
License: metadata["license"],
Platform: "tic80",
}
return software
}
func (s *SoftwareUpdaterTIC80Service) GetMetadata(source_path string) (map[string]string, error) {
file, err := os.Open(source_path)
if err != nil {
return nil, err
}
defer file.Close()
meta_data := make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "--") {
parts := strings.SplitN(strings.TrimPrefix(line, "--"), ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
meta_data[strings.ToLower(key)] = val
} else {
break
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return meta_data, nil
}
-2
View File
@@ -1,2 +0,0 @@
#!/bin/sh
go mod download && air -c .air.toml
-17
View File
@@ -1,17 +0,0 @@
module teletype_softwares
go 1.21.5
require (
github.com/DATA-DOG/go-sqlmock v1.5.1
github.com/go-chi/chi/v5 v5.0.10
github.com/google/uuid v1.4.0
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.5
)
require (
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
)
-18
View File
@@ -1,18 +0,0 @@
github.com/DATA-DOG/go-sqlmock v1.5.1 h1:FK6RCIUSfmbnI/imIICmboyQBkOckutaa6R5YYlLZyo=
github.com/DATA-DOG/go-sqlmock v1.5.1/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
@@ -1,48 +0,0 @@
package http
import (
"encoding/json"
"net/http"
"strings"
"teletype_softwares/domain"
)
type APISoftwareController struct {
softwareService domain.SoftwareServiceInterface
}
func NewAPISoftwareController(software_service domain.SoftwareServiceInterface) *APISoftwareController {
return &APISoftwareController{softwareService: software_service}
}
func replaceReleasePaths(release *domain.Release) {
release.CartridgePath = strings.ReplaceAll(release.CartridgePath, "/softwares/", "/file/")
release.SourcePath = strings.ReplaceAll(release.SourcePath, "/softwares/", "/file/")
release.HTMLFolderPath = strings.ReplaceAll(release.HTMLFolderPath, "/softwares/", "/file/")
release.DocsFolderPath = strings.ReplaceAll(release.DocsFolderPath, "/softwares/", "/file/")
}
func (c *APISoftwareController) Index(w http.ResponseWriter, r *http.Request) {
softwares, _ := c.softwareService.DetailedList()
for idx := range softwares.Softwares {
softwareShowData := &softwares.Softwares[idx]
for i := range softwareShowData.Releases {
replaceReleasePaths(&softwareShowData.Releases[i])
}
// LatestRelease és WebPlayableRelease a Releases slice elemeire mutat,
// ezért ezeket külön is frissíteni kell, mivel értékmásolat kerülhet bele
if softwareShowData.LatestRelease != nil {
replaceReleasePaths(softwareShowData.LatestRelease)
}
if softwareShowData.WebPlayableRelease != nil {
replaceReleasePaths(softwareShowData.WebPlayableRelease)
}
}
json.NewEncoder(w).Encode(softwares)
}
@@ -1,45 +0,0 @@
package http
import (
"encoding/json"
"net/http"
"teletype_softwares/domain"
)
type APISoftwareHighlightedController struct {
softwareService domain.SoftwareServiceInterface
}
func NewAPISoftwareHighlightedController(software_service domain.SoftwareServiceInterface) *APISoftwareHighlightedController {
return &APISoftwareHighlightedController{softwareService: software_service}
}
func (c *APISoftwareHighlightedController) Index(w http.ResponseWriter, r *http.Request) {
softwareShowData, err := c.softwareService.GetHighlighted()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if softwareShowData == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "no highlighted software found"})
return
}
for i := range softwareShowData.Releases {
replaceReleasePaths(&softwareShowData.Releases[i])
}
if softwareShowData.LatestRelease != nil {
replaceReleasePaths(softwareShowData.LatestRelease)
}
if softwareShowData.WebPlayableRelease != nil {
replaceReleasePaths(softwareShowData.WebPlayableRelease)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(softwareShowData)
}
@@ -1,46 +0,0 @@
package http
import (
"net/http"
"os"
"teletype_softwares/domain"
)
type SoftwareUpdaterController struct {
softwareUpdaterService domain.SoftwareUpdaterServiceInterface
softwareService domain.SoftwareServiceInterface
}
func NewSoftwareUpdaterController(
softwareUpdaterService domain.SoftwareUpdaterServiceInterface,
softwareService domain.SoftwareServiceInterface,
) *SoftwareUpdaterController {
return &SoftwareUpdaterController{
softwareUpdaterService: softwareUpdaterService,
softwareService: softwareService,
}
}
func (c *SoftwareUpdaterController) Update(w http.ResponseWriter, r *http.Request) {
secret := r.URL.Query().Get("secret")
if secret != os.Getenv("UPDATE_SECRET") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
platform := r.URL.Query().Get("platform")
name := r.URL.Query().Get("name")
version := r.URL.Query().Get("version")
if version == "" {
http.Error(w, "Version not provided", http.StatusBadRequest)
return
}
if err := c.softwareUpdaterService.Update(platform, name, version); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write([]byte("Updated"))
}
-18
View File
@@ -1,18 +0,0 @@
package http
import (
"teletype_softwares/domain"
"teletype_softwares/lib/http_utils"
)
func StartHttpServer(domain_instance domain.Domain) {
router := NewRouter(
NewAPISoftwareController(domain_instance.SoftwareService),
NewAPISoftwareHighlightedController(domain_instance.SoftwareService),
NewSoftwareUpdaterController(domain_instance.SoftwareUpdaterService, domain_instance.SoftwareService),
).Init()
http_utils.StartGenericHTTPServer(http_utils.StartGenericHTTPServerContext{
Router: router,
})
}
-12
View File
@@ -1,12 +0,0 @@
package http
import "net/http"
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
-39
View File
@@ -1,39 +0,0 @@
package http
import (
"net/http"
"github.com/go-chi/chi/v5"
)
type Router struct {
apiSoftwareController *APISoftwareController
apiSoftwareHighlightedController *APISoftwareHighlightedController
softwareUpdaterController *SoftwareUpdaterController
}
func NewRouter(
api_software_controller *APISoftwareController,
api_software_highlighted_controller *APISoftwareHighlightedController,
software_updater_controller *SoftwareUpdaterController,
) *Router {
return &Router{
apiSoftwareController: api_software_controller,
apiSoftwareHighlightedController: api_software_highlighted_controller,
softwareUpdaterController: software_updater_controller,
}
}
func (r *Router) Init() *chi.Mux {
router := chi.NewRouter()
router.Use(CORSMiddleware)
router.Get("/api/software", r.apiSoftwareController.Index)
router.Get("/api/software/highlighted", r.apiSoftwareHighlightedController.Index)
router.Get("/update", r.softwareUpdaterController.Update)
fs_file := http.FileServer(http.Dir("/softwares"))
router.Handle("/file/*", http.StripPrefix("/file/", fs_file))
return router
}
-38
View File
@@ -1,38 +0,0 @@
package http_utils
import (
"encoding/json"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func RespondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
response, _ := json.Marshal(payload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(response)
}
func GetParam(r *http.Request, name string) string {
return chi.URLParam(r, name)
}
func GetParamUUID(r *http.Request, name string) (uuid.UUID, error) {
return uuid.Parse(GetParam(r, name))
}
type StartGenericHTTPServerContext struct {
Router *chi.Mux
}
func StartGenericHTTPServer(ctx StartGenericHTTPServerContext) {
r := chi.NewRouter()
r.Use(LoggingMiddleware)
r.Mount("/", ctx.Router)
http.Handle("/", r)
log.Fatal(http.ListenAndServe(":80", r))
}
@@ -1,13 +0,0 @@
package http_utils
import (
"fmt"
"net/http"
)
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s %s\n", r.Method, r.RequestURI)
next.ServeHTTP(w, r)
})
}
-46
View File
@@ -1,46 +0,0 @@
package mysql_utils
import (
"database/sql"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type DBMockSuite struct {
DB *gorm.DB
Mock sqlmock.Sqlmock
}
func NewDBMockSuite() *DBMockSuite {
var (
db *sql.DB
gormdb *gorm.DB
mock sqlmock.Sqlmock
err error
)
db, mock, err = sqlmock.New()
if err != nil {
panic("SQL Mock error")
}
mock.ExpectQuery("SELECT VERSION()").WithArgs().WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
gormdb, err = gorm.Open(mysql.New(mysql.Config{
DSN: "sqlmock_db_0",
DriverName: "mysql",
Conn: db,
}), &gorm.Config{})
if err != nil {
panic("GORM open error")
}
return &DBMockSuite{
DB: gormdb,
Mock: mock,
}
}
-38
View File
@@ -1,38 +0,0 @@
package mysql_utils
import (
"fmt"
"os"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type DbConnectData struct {
User string
Password string
Host string
Port string
Database string
}
func getDbConnectData() DbConnectData {
return DbConnectData{
User: os.Getenv("DB_USER"),
Password: os.Getenv("DB_PASSWORD"),
Host: os.Getenv("DB_HOST"),
Port: os.Getenv("DB_PORT"),
Database: os.Getenv("DB_NAME"),
}
}
func Init() *gorm.DB {
c := getDbConnectData()
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", c.User, c.Password, c.Host, c.Port, c.Database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic(err)
}
return db
}
-45
View File
@@ -1,45 +0,0 @@
package template_utils
import (
"html/template"
"sync"
)
type TemplateCache struct {
cache map[string]*template.Template
mu sync.RWMutex
}
var globalCache *TemplateCache
func init() {
globalCache = &TemplateCache{
cache: make(map[string]*template.Template),
}
}
func GetTemplate(name string, files ...string) (*template.Template, error) {
globalCache.mu.RLock()
if tmpl, exists := globalCache.cache[name]; exists {
globalCache.mu.RUnlock()
return tmpl, nil
}
globalCache.mu.RUnlock()
tmpl, err := template.ParseFiles(files...)
if err != nil {
return nil, err
}
globalCache.mu.Lock()
globalCache.cache[name] = tmpl
globalCache.mu.Unlock()
return tmpl, nil
}
func ClearCache() {
globalCache.mu.Lock()
globalCache.cache = make(map[string]*template.Template)
globalCache.mu.Unlock()
}
-11
View File
@@ -1,11 +0,0 @@
package main
import (
"teletype_softwares/domain"
"teletype_softwares/http"
)
func main() {
domain := domain.NewDomain()
http.StartHttpServer(domain)
}
+14 -8
View File
@@ -138,12 +138,13 @@ services:
- proxy
- interstack
catalog:
api:
build:
context: ./apps/catalog
context: ./apps/api
dockerfile: Dockerfile.development
container_name: catalog
container_name: api
environment:
- RAILS_ENV=development
- DB_USER=root
- DB_PASSWORD=${DB_PASSWORD}
- DB_HOST=mysql
@@ -151,18 +152,22 @@ services:
- DB_NAME=softwares
- UPDATE_SECRET=${UPDATE_SECRET}
- FILE_CONTAINER_PATH=/softwares
- SECRET_KEY_BASE=${SECRET_KEY_BASE:-changeme_in_production_use_long_random_string}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@teletype.hu}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-password123}
depends_on:
mysql:
condition: service_healthy
volumes:
- ./data/softwares:/softwares
- ./apps/catalog:/app
- ./apps/api:/app
- api_bundle:/usr/local/bundle
labels:
- "traefik.enable=true"
- "traefik.http.routers.catalog.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/update`))"
- "traefik.http.routers.catalog.entrypoints=web"
- "traefik.http.routers.catalog.priority=10"
- "traefik.http.services.catalog.loadbalancer.server.port=80"
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/update`) || PathPrefix(`/admin`))"
- "traefik.http.routers.api.entrypoints=web"
- "traefik.http.routers.api.priority=10"
- "traefik.http.services.api.loadbalancer.server.port=3000"
- "traefik.docker.network=proxy"
networks:
- proxy
@@ -192,6 +197,7 @@ volumes:
woodpecker:
mysql-data:
softwares:
api_bundle:
networks:
proxy:
+6 -1
View File
@@ -25,4 +25,9 @@ DB_PASSWORD=
UPDATE_SECRET=
DROP_PASSWORD=
WEBAPP_WIKIJS_TOKEN=
DISCORD_INVITE_LINK=
DISCORD_INVITE_LINK=
# Rails API (apps/api)
ADMIN_EMAIL=
ADMIN_PASSWORD=
SECRET_KEY_BASE=