This commit is contained in:
2026-07-28 14:29:09 +02:00
parent 0024f75a20
commit 1513a451ae
30 changed files with 188 additions and 129 deletions
@@ -21,9 +21,7 @@ class Api::DownloadsController < ApiController
)
if full_path
ext = File.extname(full_path.to_s).delete_prefix(".")
mime = Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
send_file full_path, disposition: "attachment", type: mime
send_file full_path, disposition: "attachment", type: resolve_mime(full_path)
else
head :not_found
end
@@ -1,4 +1,4 @@
class Api::ImagesController < ApplicationController
class Api::ImagesController < ApiController
resource_description do
short "Images"
formats [ "binary" ]
@@ -11,9 +11,7 @@ class Api::ImagesController < ApplicationController
def show
image = ImageService.new.show(ImageShowInputDto.new(id: params[:id]))
send_file image.file_path, type: image.content_type, disposition: "inline"
rescue ActiveRecord::RecordNotFound
render plain: "Not Found", status: :not_found
rescue Errno::ENOENT
render plain: "Not Found", status: :not_found
head :not_found
end
end
@@ -2,42 +2,6 @@ class Api::SwaggerController < ActionController::Base
layout false
def index
render html: swagger_html.html_safe
end
private
def swagger_html
<<~HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Teletype Games API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
body { margin: 0; background: #fafafa; }
.swagger-ui .topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/api/docs.json?type=swagger',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: 'BaseLayout'
});
</script>
</body>
</html>
HTML
render template: "api/swagger/index", formats: [:html]
end
end
@@ -3,4 +3,20 @@ class ApiController < ActionController::API
api_version "1.0"
formats [ "json" ]
end
rescue_from StandardError do |e|
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
render json: { error: "Internal server error" }, status: :internal_server_error
end
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error: "Not found" }, status: :not_found
end
private
def resolve_mime(path)
ext = File.extname(path.to_s).delete_prefix(".")
Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
end
end
+2 -7
View File
@@ -1,6 +1,4 @@
class FilesController < ApplicationController
skip_forgery_protection
class FilesController < ApiController
resource_description do
short "Static files"
formats [ "binary" ]
@@ -15,10 +13,7 @@ class FilesController < ApplicationController
result = FileService.new.show(FileShowInputDto.new(path: params[:path]))
case result.type
when :redirect then redirect_to result.url, status: :moved_permanently
when :file
ext = File.extname(result.path.to_s).delete_prefix(".")
mime = Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
send_file result.path, disposition: "inline", type: mime
when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path)
when :not_found then head :not_found
end
end
@@ -29,12 +29,13 @@ class UpdateController < ApiController
render plain: e.message, status: :bad_request
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
render plain: "Internal server error", status: :internal_server_error
end
private
def authorized?
params[:secret] == ENV.fetch("UPDATE_SECRET", "")
secret = request.headers["X-Update-Secret"].presence || params[:secret]
secret == ENV.fetch("UPDATE_SECRET", "")
end
end
+3
View File
@@ -1,4 +1,7 @@
class Event < ApplicationRecord
validates :name, presence: true
validates :date, presence: true
default_scope { where(deleted_at: nil) }
scope :upcoming, -> { where("date > ?", Time.current).order(:date) }
+3
View File
@@ -3,6 +3,9 @@ class ExternalLink < ApplicationRecord
belongs_to :software
validates :label, presence: true
validates :url, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
+2
View File
@@ -2,6 +2,8 @@ class Member < ApplicationRecord
belongs_to :image, optional: true
has_one :admin_user
validates :nick, presence: true, uniqueness: true
def self.ransackable_attributes(auth_object = nil)
%w[avatar_filename created_at id image_id motto nick real_nick updated_at]
end
+3
View File
@@ -4,6 +4,9 @@ class Release < ApplicationRecord
belongs_to :software
has_many :downloads
validates :version, presence: true
validates :version, uniqueness: { scope: :software_id }
default_scope { where(deleted_at: nil) }
validate :c64_cannot_be_web_playable
+4
View File
@@ -11,6 +11,10 @@ class Software < ApplicationRecord
accepts_nested_attributes_for :external_links, allow_destroy: true
accepts_nested_attributes_for :releases, allow_destroy: true
validates :name, presence: true, uniqueness: true
validates :title, presence: true
validates :platform, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
@@ -14,5 +14,5 @@ class ReleaseSerializer < Blueprinter::Base
field(:sourcePath) { |r| r.source_path.blank? ? "" : r.source_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) }
field(:htmlFolderPath) { |r| r.html_folder_path.blank? ? "" : r.html_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) }
field(:docsFolderPath) { |r| r.docs_folder_path.blank? ? "" : r.docs_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) }
field(:downloadCount) { |r| r.downloads.size }
field(:downloadCount) { |r| r.association(:downloads).loaded? ? r.downloads.size : r.downloads.count }
end
@@ -3,5 +3,5 @@ class SoftwareDetailSerializer < Blueprinter::Base
field(:releases) { |_, opts| ReleaseSerializer.render_as_hash(opts[:releases]) }
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest]) : nil }
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable]) : nil }
field(:totalDownloads) { |sw, _| Download.where(release: sw.releases).count }
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
end
+6 -3
View File
@@ -2,14 +2,17 @@ class DownloadService
BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath
def call(path:, ip:, user_agent:, referer:)
full_path = BASE_PATH.join(path.to_s)
sanitized = path.to_s
full_path = BASE_PATH.join(sanitized).realpath
return nil unless full_path.to_s.start_with?(BASE_PATH.to_s)
return nil unless File.file?(full_path)
escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_")
release = Release.find_by("cartridge_path LIKE ? OR source_path LIKE ?",
"%#{path}%", "%#{path}%")
"%#{escaped}%", "%#{escaped}%")
Download.create!(
file_path: path,
file_path: sanitized,
release: release,
ip_address: ip,
user_agent: user_agent&.truncate(500),
+7
View File
@@ -3,6 +3,7 @@ class FileService
def show(input)
full_path = BASE_PATH.join(input.path.to_s)
return FileResultDto.not_found unless safe_path?(full_path)
if File.directory?(full_path)
index_path = full_path.join("index.html")
@@ -16,4 +17,10 @@ class FileService
FileResultDto.not_found
end
end
private
def safe_path?(path)
File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(BASE_PATH.to_s)
end
end
@@ -5,11 +5,13 @@ module SoftwareResponseBuilder
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? }
total_downloads = releases.sum { |r| r.association(:downloads).loaded? ? r.downloads.size : 0 }
SoftwareDetailSerializer.render_as_hash(software,
releases: sorted,
latest: latest,
web_playable: web_playable
releases: sorted,
latest: latest,
web_playable: web_playable,
total_downloads: total_downloads
)
end
end
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Teletype Games API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
body { margin: 0; background: #fafafa; }
.swagger-ui .topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/api/docs.json?type=swagger',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: 'BaseLayout'
});
</script>
</body>
</html>