build endpoints

This commit is contained in:
2026-08-05 20:12:35 +02:00
parent 1e974bdcc9
commit 4e2c45dc45
25 changed files with 603 additions and 252 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
actions :index, :show, :new, :create, :edit, :update, :destroy
permit_params :name, :owner_id, :expires_at, :scopes_string
permit_params :name, :owner_id, :expires_at, :scopes_string, :unrestricted
menu priority: 9, label: "🎟️ App Tokens"
@@ -17,6 +17,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
column("Token") { |t| code "#{t.token_prefix}", style: "font-family:monospace;" }
column("Owner") { |t| t.owner.try(:email) || t.owner.try(:name) || "#{t.owner_type} ##{t.owner_id}" }
column("Scopes") { |t| t.scopes_string }
column :unrestricted
column :expires_at
column :last_used_at
column :created_at
@@ -44,7 +45,8 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
end
f.input :name
f.input :scopes_string, label: "Scopes (comma separated)",
hint: %(A /update végponthoz az "update" scope kell.)
hint: %(A /build/publish (és a legacy /update) végponthoz az "update", a /build/upload-hoz az "upload" scope kell.)
f.input :unrestricted, hint: "Belső token: az owner-izoláció (enforce_software_ownership) nem vonatkozik rá."
f.input :expires_at, hint: "Üresen hagyva sosem jár le."
end
f.actions
@@ -62,6 +64,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest tárolva)" }
row("Owner") { |t| "#{t.owner_type} ##{t.owner_id}#{t.owner.try(:email) || t.owner.try(:name)}" }
row("Scopes") { |t| t.scopes_string }
row :unrestricted
row :expires_at
row :last_used_at
row :created_at
@@ -0,0 +1,71 @@
module WarpEngine
# Token-hitelesítés a publikáló (/build/*) endpointokhoz.
# A hitelesítési forrás kizárólagos: :database módban a shared secret nem
# érvényes, :env módban a DB-tokenek nem.
module UpdateAuthentication
extend ActiveSupport::Concern
private
attr_reader :current_application_token
# A token kizárólag az X-Update-Secret headerből jöhet — URL-ben a secret
# proxy- és access-logokba szivárogna.
def update_authorized?(required_scope:)
token = request.headers["X-Update-Secret"].presence
return false if token.blank?
case WarpEngine.config.application_token_source
when :database then database_token_authorized?(token, required_scope)
else env_secret_authorized?(token)
end
end
def env_secret_authorized?(token)
expected = WarpEngine.config.update_secret
# Konfigurálatlan secret esetén az endpoint zárva marad.
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
end
def database_token_authorized?(token, required_scope)
if WarpEngine.config.application_token_owner_class.blank?
Rails.logger.error("[#{self.class.name}] application_token_source=:database, de application_token_owner_class nincs beállítva — minden kérés elutasítva")
return false
end
record = WarpEngine::ApplicationToken.authenticate(token, required_scope: required_scope)
return false if record.nil?
record.touch_last_used!
@current_application_token = record
true
end
# Owner-kényszer: csak :database módban (van token) és bekapcsolt
# enforce_software_ownership mellett szűr. Owner nélküli software a
# backfillig szabad préda — a kényszer bekapcsolása előtt kell backfillelni.
def software_ownership_authorized?(name)
return true unless WarpEngine.config.enforce_software_ownership
token = current_application_token
return true if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
return true if software.nil? || software.owner_id.nil?
software.owner_type == token.owner_type && software.owner_id == token.owner_id
end
# Az először publikált (vagy backfill előtti, gazdátlan) software a beküldő
# token ownerét kapja. Unrestricted (belső) token nem foglal ownert.
def claim_software_ownership(name)
token = current_application_token
return if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
return if software.nil? || software.owner_id.present?
software.update_columns(owner_type: token.owner_type, owner_id: token.owner_id)
end
end
end
@@ -29,6 +29,7 @@ module WarpEngine
end
api :GET, "/api/software", "List all software entries with releases"
param :owner_id, :number, required: false, desc: "Filter to the softwares of one owner (publisher)"
returns code: 200, desc: "Wrapper object with softwares array" do
property :softwares, Array, desc: "Array of software entries" do
property :ID, Integer, desc: "Software ID"
@@ -55,7 +56,7 @@ module WarpEngine
end
end
def index
render json: WarpEngine::SoftwareService.new.index
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
end
end
end
@@ -0,0 +1,45 @@
module WarpEngine
module Build
class PublishController < ApiController
include UpdateAuthentication
resource_description do
short "Build release publishing"
end
api :POST, "/build/publish", "Register an uploaded build as a release"
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
param :name, String, required: true, desc: "Software name"
param :platform, String, required: true, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
param :version, String, required: true, desc: "Version string"
returns code: 200, desc: "JSON with the published name/platform/version"
error code: 401, desc: "Invalid secret"
error code: 403, desc: "Token does not own this software"
error code: 400, desc: "Missing or invalid arguments"
def create
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
%i[name platform version].each do |key|
return render json: { error: "#{key.to_s.capitalize} not provided" }, status: :bad_request if params[key].blank?
end
unless software_ownership_authorized?(params[:name])
return render json: { error: "Forbidden" }, status: :forbidden
end
input = WarpEngine::UpdateInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
WarpEngine::UpdateService.new.update(input)
claim_software_ownership(params[:name])
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
end
end
end
end
@@ -0,0 +1,65 @@
require "digest"
module WarpEngine
module Build
class UploadsController < ApiController
include UpdateAuthentication
resource_description do
short "Build artifact upload"
end
# A release-fájlnevek kötött konvenciója: <name>-<version>.<ext> vagy
# <name>-<version>-<target>.zip — az updater is ezeket keresi.
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
api :POST, "/build/upload", "Upload a build artifact into the drop area"
header "X-Update-Secret", "Shared secret or application token (upload scope)", required: true
param :name, String, required: true, desc: "Software name (filename must be prefixed with <name>-<version>)"
param :version, String, required: true, desc: "Version string"
param :file, File, required: true, desc: "Artifact file (multipart)"
param :sha256, String, required: false, desc: "Expected SHA256 hex digest; on mismatch the upload is rejected"
returns code: 200, desc: "JSON with stored file name, size and sha256"
error code: 401, desc: "Invalid secret"
error code: 403, desc: "Token does not own this software"
error code: 400, desc: "Missing or invalid arguments"
error code: 413, desc: "File larger than max_upload_size"
error code: 422, desc: "SHA256 mismatch"
def create
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPLOAD_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
name = params[:name].to_s
version = params[:version].to_s
file = params[:file]
return render json: { error: "Invalid name" }, status: :bad_request unless name.match?(NAME_FORMAT)
return render json: { error: "Invalid version" }, status: :bad_request unless version.match?(NAME_FORMAT)
return render json: { error: "File not provided" }, status: :bad_request unless file.respond_to?(:original_filename)
unless software_ownership_authorized?(name)
return render json: { error: "Forbidden" }, status: :forbidden
end
filename = File.basename(file.original_filename.to_s)
unless filename.start_with?("#{name}-#{version}.", "#{name}-#{version}-")
return render json: { error: "Filename must be prefixed with #{name}-#{version}" }, status: :bad_request
end
max = WarpEngine.config.max_upload_size
if file.size > max
return render json: { error: "File too large (max #{max / (1024 * 1024)}MB)" }, status: :payload_too_large
end
digest = Digest::SHA256.file(file.tempfile.path).hexdigest
if params[:sha256].present? && !ActiveSupport::SecurityUtils.secure_compare(params[:sha256].downcase, digest)
return render json: { error: "SHA256 mismatch" }, status: :unprocessable_entity
end
stored = WarpEngine::FileManagerService.new.upload("", file)
render json: { file: stored, size: file.size, sha256: digest }
end
end
end
end
@@ -1,73 +0,0 @@
module WarpEngine
class UpdateController < ApiController
resource_description do
short "Software updater"
formats [ "text" ]
end
rescue_from ArgumentError do |e|
render plain: e.message, status: :bad_request
end
rescue_from StandardError do |e|
Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
render plain: "Internal server error", status: :internal_server_error
end
api :GET, "/update", "Update software version in database"
param :secret, String, required: true, desc: "Shared secret or application token (X-Update-Secret header preferred)"
param :platform, String, required: false, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
param :name, String, required: false, desc: "Software name"
param :version, String, required: true, desc: "Version string"
returns code: 200, desc: "Plain text 'Updated'"
error code: 401, desc: "Invalid secret"
error code: 400, desc: "Version not provided or invalid arguments"
error code: 500, desc: "Internal server error"
def update
return render plain: "Unauthorized", status: :unauthorized unless authorized?
return render plain: "Version not provided", status: :bad_request if params[:version].blank?
input = WarpEngine::UpdateInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
WarpEngine::UpdateService.new.update(input)
render plain: "Updated"
end
private
# A hitelesítési forrás kizárólagos: :database módban a shared secret nem
# érvényes, :env módban a DB-tokenek nem.
def authorized?
token = request.headers["X-Update-Secret"].presence || params[:secret].presence
return false if token.blank?
case WarpEngine.config.application_token_source
when :database then database_token_authorized?(token)
else env_secret_authorized?(token)
end
end
def env_secret_authorized?(token)
expected = WarpEngine.config.update_secret
# Konfigurálatlan secret esetén az endpoint zárva marad.
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
end
def database_token_authorized?(token)
if WarpEngine.config.application_token_owner_class.blank?
Rails.logger.error("[UpdateController] application_token_source=:database, de application_token_owner_class nincs beállítva — minden kérés elutasítva")
return false
end
record = WarpEngine::ApplicationToken.authenticate(token, required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE)
return false if record.nil?
record.touch_last_used!
true
end
end
end
+2 -1
View File
@@ -5,6 +5,7 @@ module WarpEngine
self.table_name = "application_tokens"
UPDATE_SCOPE = "update".freeze
UPLOAD_SCOPE = "upload".freeze
# A generált token csak létrehozáskor, memóriában érhető el — a DB-ben
# kizárólag a SHA256 digest és a nem-titkos prefix tárolódik.
@@ -63,7 +64,7 @@ module WarpEngine
end
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix updated_at]
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
end
# A polimorf owner asszociációra a Ransack nem tud szűrni.
+5 -1
View File
@@ -2,6 +2,10 @@ module WarpEngine
class Software < ApplicationRecord
self.table_name = "softwares"
# A publikáló token ownere (pl. AdminUser) — 3rd party izolációhoz, ld.
# enforce_software_ownership. nil = belső / backfill előtti software.
belongs_to :owner, polymorphic: true, optional: true
has_many :software_images, foreign_key: :software_id, dependent: :destroy
has_many :images, through: :software_images
has_many :releases, foreign_key: :software_id
@@ -19,7 +23,7 @@ module WarpEngine
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]
%w[author created_at desc highlighted id license name owner_id owner_type platform site status story title updated_at]
end
def self.ransackable_associations(auth_object = nil)
@@ -14,6 +14,8 @@ module WarpEngine
field(:license) { |sw| sw.license.to_s }
field :platform
field :status
# Publikus owner-azonosító — az /api/software?owner_id= szűrőhöz.
field(:ownerId) { |sw| sw.owner_id }
field(:highlighted) { |sw| sw.highlighted ? true : false }
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
@@ -22,10 +22,9 @@ module WarpEngine
end
end
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
def upload(relative_dir, uploaded_file)
raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE
max = WarpEngine.config.max_upload_size
raise ArgumentError, "File too large (max #{max / (1024 * 1024)}MB)" if uploaded_file.size > max
dir = safe_path!(relative_dir)
raise ArgumentError, "Not a directory" unless dir.directory?
+2 -1
View File
@@ -2,8 +2,9 @@ module WarpEngine
class SoftwareService
include SoftwareResponseBuilder
def index
def index(owner_id: nil)
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
softwares = softwares.where(owner_id: owner_id) if owner_id.present?
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
end