A kódbázis kommentek nélkül marad

Kérésre: minden magyarázó komment kikerült a forrásfájlokból — 89 Ruby, 16
TypeScript, 14 Vue, plusz a CSS/JS/CJS. Nem soralapú kereséssel: a Ruby-t a
Ripper tokenizálta, a JS/TS/CSS-t állapotgép járta végig, hogy az URL-ekben,
reguláris kifejezésekben és heredocokban álló // és # jelek helyükön
maradjanak.

Három komment maradt, mert nélkülük nem indul a kód: az entrypoint.sh
shebangja, a vite-env.d.ts hármas perjeles referenciája, és a sanitize
teszt @vitest-environment direktívája (ez utóbbi a magyarázó része nélkül).

Egy helyen kódot is kellett írni: a CommandBlock másolás-hibaágán a komment
volt a catch egyetlen tartalma, és üres blokkot az eslint nem enged — a
copied jelző visszaállítása került a helyére.

A yaml, Dockerfile, Makefile, erb és markdown fájlokat nem érintettem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 12:52:47 +02:00
co-authored by Claude Opus 5
parent 4505571052
commit a0fbf1e2b4
63 changed files with 46 additions and 634 deletions
+1 -6
View File
@@ -10,9 +10,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
scope :all, default: true scope :all, default: true
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) } scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) } scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
# Two kinds of token share this table: one publishes software, the other reads the
# catalog from somebody's desktop client. They are told apart by scope, and an admin
# looking for one is rarely looking for the other.
CATALOG_SCOPE_SQL = %(JSON_CONTAINS(COALESCE(scopes, '[]'), '"catalog"')).freeze CATALOG_SCOPE_SQL = %(JSON_CONTAINS(COALESCE(scopes, '[]'), '"catalog"')).freeze
scope("Publishing") { |scope| scope.where("NOT #{CATALOG_SCOPE_SQL}") } scope("Publishing") { |scope| scope.where("NOT #{CATALOG_SCOPE_SQL}") }
scope("Clients") { |scope| scope.where(CATALOG_SCOPE_SQL) } scope("Clients") { |scope| scope.where(CATALOG_SCOPE_SQL) }
@@ -98,9 +96,6 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
end end
controller do controller do
# The plain token only exists right after creation; it travels via the
# session to its one-time display (flash is unsuitable: the AA layout
# renders every flash key as a message bar).
def create def create
create! do |success, _failure| create! do |success, _failure|
success.html do success.html do
+1 -5
View File
@@ -1,7 +1,4 @@
ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do
# Read-only on purpose. A grant is created by a client and answered by a person on the
# host's own page; an admin creating one by hand would be issuing somebody else a
# credential, which is not a thing this page should make easy.
actions :index, :show actions :index, :show
menu parent: "🌀 WarpEngine", priority: 10, label: "📱 Device Sign-ins", menu parent: "🌀 WarpEngine", priority: 10, label: "📱 Device Sign-ins",
@@ -45,8 +42,7 @@ ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do
subject = g.subject subject = g.subject
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}" subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
end end
# The device code itself is never shown: it is the client's live credential for as
# long as the grant is pending, and this page is not where it should leak from.
row("Token") do |g| row("Token") do |g|
token = g.application_token token = g.application_token
next "" if token.nil? next "" if token.nil?
-15
View File
@@ -13,7 +13,6 @@ ActiveAdmin.register_page "Files" do
entries = [] entries = []
end end
# Picker mode: hide admin header/menu/footer
if picker_mode if picker_mode
text_node "<style>#header,#tabs,.footer,#title_bar,#utility_nav{display:none!important}#active_admin_content{margin:0;padding:0}#wrapper{margin:0}body{min-height:auto}</style>".html_safe text_node "<style>#header,#tabs,.footer,#title_bar,#utility_nav{display:none!important}#active_admin_content{margin:0;padding:0}#wrapper{margin:0}body{min-height:auto}</style>".html_safe
end end
@@ -26,7 +25,6 @@ ActiveAdmin.register_page "Files" do
end end
end end
# Breadcrumbs
div class: "fm-breadcrumbs" do div class: "fm-breadcrumbs" do
parts = current_dir.split("/").reject(&:blank?) parts = current_dir.split("/").reject(&:blank?)
picker_params = picker_mode ? { picker: 1, field: picker_field } : {} picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
@@ -39,7 +37,6 @@ ActiveAdmin.register_page "Files" do
end end
end end
# Action bar (also drop zone on standalone page)
div class: "fm-actions fm-dropzone", id: "fm-page-dropzone" do div class: "fm-actions fm-dropzone", id: "fm-page-dropzone" do
form action: admin_files_upload_path, method: "post", enctype: "multipart/form-data", class: "fm-inline-form" do |_f| form action: admin_files_upload_path, method: "post", enctype: "multipart/form-data", class: "fm-inline-form" do |_f|
input type: "hidden", name: "authenticity_token", value: form_authenticity_token input type: "hidden", name: "authenticity_token", value: form_authenticity_token
@@ -56,9 +53,7 @@ ActiveAdmin.register_page "Files" do
end end
end end
# File listing
table class: "fm-table" do table class: "fm-table" do
# Pre-fetch download counts for files in current directory
file_entries = entries.select { |e| e[:type] != :directory } file_entries = entries.select { |e| e[:type] != :directory }
dl_counts = if file_entries.any? dl_counts = if file_entries.any?
WarpEngine::Download.where(file_path: file_entries.map { |e| e[:path] }) WarpEngine::Download.where(file_path: file_entries.map { |e| e[:path] })
@@ -68,7 +63,6 @@ ActiveAdmin.register_page "Files" do
{} {}
end end
# File icon helper
file_icon = ->(name) do file_icon = ->(name) do
ext = File.extname(name).downcase ext = File.extname(name).downcase
case ext case ext
@@ -100,7 +94,6 @@ ActiveAdmin.register_page "Files" do
end end
tbody do tbody do
# Parent directory link
if current_dir.present? if current_dir.present?
parent = File.dirname(current_dir) parent = File.dirname(current_dir)
parent = "" if parent == "." parent = "" if parent == "."
@@ -145,32 +138,26 @@ ActiveAdmin.register_page "Files" do
td class: "fm-entry-actions" do td class: "fm-entry-actions" do
entry_id = entry[:path].parameterize entry_id = entry[:path].parameterize
# Download (files only)
if entry[:type] != :directory if entry[:type] != :directory
a "⬇️", href: "/file/#{entry[:path]}", class: "fm-icon-btn", title: "Download", download: entry[:name] a "⬇️", href: "/file/#{entry[:path]}", class: "fm-icon-btn", title: "Download", download: entry[:name]
end end
# Rename
a "✏️", href: "#", class: "fm-icon-btn", title: "Rename", a "✏️", href: "#", class: "fm-icon-btn", title: "Rename",
onclick: "var n=prompt('New name:','#{j entry[:name]}');if(n){var f=document.getElementById('rename-#{entry_id}');f.querySelector('[name=new_name]').value=n;f.submit();}return false;" onclick: "var n=prompt('New name:','#{j entry[:name]}');if(n){var f=document.getElementById('rename-#{entry_id}');f.querySelector('[name=new_name]').value=n;f.submit();}return false;"
# Delete
a "🗑️", href: "#", class: "fm-icon-btn fm-icon-danger", title: "Delete", a "🗑️", href: "#", class: "fm-icon-btn fm-icon-danger", title: "Delete",
onclick: "if(confirm('Delete \\'#{j entry[:name]}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;" onclick: "if(confirm('Delete \\'#{j entry[:name]}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;"
# Stats (files only)
if entry[:type] != :directory if entry[:type] != :directory
a "📊", href: admin_downloads_path(q: { file_path_cont: entry[:path] }), class: "fm-icon-btn", title: "Stats" a "📊", href: admin_downloads_path(q: { file_path_cont: entry[:path] }), class: "fm-icon-btn", title: "Stats"
end end
# Picker mode: Select button
if picker_mode if picker_mode
abs_path = File.join(WarpEngine.config.file_container_path, entry[:path]) abs_path = File.join(WarpEngine.config.file_container_path, entry[:path])
a "Select", href: "#", class: "fm-btn fm-btn-select", a "Select", href: "#", class: "fm-btn fm-btn-select",
onclick: "var inp=window.parent.document.getElementById('#{j picker_field}');if(inp){inp.value='#{j abs_path}';}var ov=window.parent.document.querySelector('.fm-modal-overlay');if(ov){ov.remove();window.parent.document.body.style.overflow='';}return false;" onclick: "var inp=window.parent.document.getElementById('#{j picker_field}');if(inp){inp.value='#{j abs_path}';}var ov=window.parent.document.querySelector('.fm-modal-overlay');if(ov){ov.remove();window.parent.document.body.style.overflow='';}return false;"
end end
# Hidden rename form
form action: admin_files_rename_path, method: "post", id: "rename-#{entry_id}", style: "display:none" do form action: admin_files_rename_path, method: "post", id: "rename-#{entry_id}", style: "display:none" do
input type: "hidden", name: "authenticity_token", value: form_authenticity_token input type: "hidden", name: "authenticity_token", value: form_authenticity_token
input type: "hidden", name: "path", value: entry[:path] input type: "hidden", name: "path", value: entry[:path]
@@ -178,7 +165,6 @@ ActiveAdmin.register_page "Files" do
input type: "hidden", name: "dir", value: current_dir input type: "hidden", name: "dir", value: current_dir
end end
# Hidden delete form
form action: admin_files_delete_path, method: "post", id: "delete-#{entry_id}", style: "display:none" do form action: admin_files_delete_path, method: "post", id: "delete-#{entry_id}", style: "display:none" do
input type: "hidden", name: "authenticity_token", value: form_authenticity_token input type: "hidden", name: "authenticity_token", value: form_authenticity_token
input type: "hidden", name: "_method", value: "delete" input type: "hidden", name: "_method", value: "delete"
@@ -197,7 +183,6 @@ ActiveAdmin.register_page "Files" do
end end
end end
# JSON API for AJAX file picker
page_action :list, method: :get do page_action :list, method: :get do
service = WarpEngine::FileManagerService.new service = WarpEngine::FileManagerService.new
dir = params[:dir].to_s.presence || "" dir = params[:dir].to_s.presence || ""
-2
View File
@@ -3,8 +3,6 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images" menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
# SoftwareImage a natív használó; a hoston regisztrált image_owners
# (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member).
used_ids = -> { used_ids = -> {
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) + WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
WarpEngine.config.image_owners.flat_map { |o| o[:image_ids].call } WarpEngine.config.image_owners.flat_map { |o| o[:image_ids].call }
+1 -4
View File
@@ -1,9 +1,6 @@
ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
actions :index, :show, :edit, :update actions :index, :show, :edit, :update
# Without this the edit form cannot save at all: ActiveAdmin hands unpermitted params to
# the model and Rails raises ForbiddenAttributesError. The two fields here are the two
# the form offers; everything else about a pipeline comes from the Woodpecker sync.
permit_params :platform, :software_id permit_params :platform, :software_id
menu parent: "🌀 WarpEngine", priority: 10, label: "🚀 Pipelines" menu parent: "🌀 WarpEngine", priority: 10, label: "🚀 Pipelines"
@@ -91,7 +88,7 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
column("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "yes" : "no" } column("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "yes" : "no" }
column("Branch") { |p| p["branch"] } column("Branch") { |p| p["branch"] }
column("Message") { |p| p["message"]&.truncate(60) } column("Message") { |p| p["message"]&.truncate(60) }
# Woodpecker returns unix epoch seconds in "created"
column("Created") { |p| p["created"] ? Time.zone.at(p["created"]).strftime("%Y-%m-%d %H:%M") : "-" } column("Created") { |p| p["created"] ? Time.zone.at(p["created"]).strftime("%Y-%m-%d %H:%M") : "-" }
end end
else else
@@ -1,35 +1,16 @@
module WarpEngine module WarpEngine
# Who the caller is, on the read-only side of the API.
#
# Distinct from UpdateAuthentication, which guards publishing: that one asks "may this
# pipeline write to the catalog", this one asks "whose library am I looking at". The
# answer is allowed to be nobody — an anonymous caller is a normal, supported caller,
# and a catalog with no policy configured never needs one.
#
# The credential is a bearer token, because that is what a client can carry: it has no
# cookie jar and no browser session.
module SubjectAuthentication module SubjectAuthentication
extend ActiveSupport::Concern extend ActiveSupport::Concern
private private
# The ApplicationToken behind the request, or nil.
def current_access_token def current_access_token
return @current_access_token if defined?(@current_access_token) return @current_access_token if defined?(@current_access_token)
@current_access_token = resolve_access_token @current_access_token = resolve_access_token
end end
# Whoever the request is on behalf of — the host's own object, or nil.
#
# Two ways to be somebody, tried in that order. A bearer token is what a client
# carries. A *browser* carries a session instead, and the engine has no idea what a
# session is here — so a host that wants its signed-in visitors recognised on these
# endpoints supplies a resolver:
#
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
#
# Without one, a browser is simply anonymous, which is what it always was.
def current_subject def current_subject
return @current_subject if defined?(@current_subject) return @current_subject if defined?(@current_subject)
@@ -51,9 +32,6 @@ module WarpEngine
record record
end end
# A resolver that raises must not take the request with it: it runs on every read
# endpoint, and a broken one would turn the whole API into 500s rather than into
# anonymous requests, which is the honest fallback.
def resolve_host_subject def resolve_host_subject
resolver = WarpEngine.config.subject_resolver resolver = WarpEngine.config.subject_resolver
return nil if resolver.nil? return nil if resolver.nil?
@@ -1,7 +1,5 @@
module WarpEngine module WarpEngine
# Token authentication for the publishing (/build/*) endpoints.
# The auth source is exclusive: in :database mode the shared secret is not
# accepted, in :env mode DB tokens are not.
module UpdateAuthentication module UpdateAuthentication
extend ActiveSupport::Concern extend ActiveSupport::Concern
@@ -9,8 +7,6 @@ module WarpEngine
attr_reader :current_application_token attr_reader :current_application_token
# The token is accepted from the X-Update-Secret header only — in the URL
# it would leak into proxy and access logs.
def update_authorized?(required_scope:) def update_authorized?(required_scope:)
token = request.headers["X-Update-Secret"].presence token = request.headers["X-Update-Secret"].presence
return false if token.blank? return false if token.blank?
@@ -23,7 +19,7 @@ module WarpEngine
def env_secret_authorized?(token) def env_secret_authorized?(token)
expected = WarpEngine.config.update_secret expected = WarpEngine.config.update_secret
# With no secret configured the endpoint stays closed.
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected) expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
end end
@@ -41,9 +37,6 @@ module WarpEngine
true true
end end
# Ownership enforcement applies only in :database mode (there is a token)
# with enforce_software_ownership on. An ownerless software is up for grabs
# until the backfill — backfill before enabling the enforcement.
def software_ownership_authorized?(name) def software_ownership_authorized?(name)
return true unless WarpEngine.config.enforce_software_ownership return true unless WarpEngine.config.enforce_software_ownership
@@ -56,8 +49,6 @@ module WarpEngine
software.owner_type == token.owner_type && software.owner_id == token.owner_id software.owner_type == token.owner_type && software.owner_id == token.owner_id
end end
# A first-published (or pre-backfill, ownerless) software gets the
# submitting token's owner. Unrestricted (internal) tokens claim nothing.
def claim_software_ownership(name) def claim_software_ownership(name)
token = current_application_token token = current_application_token
return if token.nil? || token.unrestricted? return if token.nil? || token.unrestricted?
@@ -1,9 +1,5 @@
module WarpEngine module WarpEngine
# The device authorization grant, client side (RFC 8628).
#
# Both actions are unauthenticated, and have to be: the whole point of the flow is
# that the caller has no credential yet. What protects it is that a device code is
# useless until a signed-in person approves it on the host's own page.
class Api::Auth::DevicesController < ApiController class Api::Auth::DevicesController < ApiController
before_action :ensure_identity_configured before_action :ensure_identity_configured
@@ -34,9 +30,6 @@ module WarpEngine
def token def token
state, plain = service.poll(device_code: params[:device_code]) state, plain = service.poll(device_code: params[:device_code])
# The token rides on the one poll that finds the grant newly approved; a client
# that loses it starts the flow again. Keeping a plain token around to hand out
# twice would mean storing it, which is the thing this design avoids.
body = { state: state.to_s } body = { state: state.to_s }
body[:token] = plain if plain.present? body[:token] = plain if plain.present?
render json: body render json: body
@@ -50,8 +43,6 @@ module WarpEngine
@service ||= WarpEngine::DeviceGrantService.new @service ||= WarpEngine::DeviceGrantService.new
end end
# A deployment with no configured subject class has no sign-in at all, and says so
# the same way GET /api/service does — by not offering it.
def ensure_identity_configured def ensure_identity_configured
return if WarpEngine.identity_configured? return if WarpEngine.identity_configured?
@@ -1,10 +1,5 @@
module WarpEngine module WarpEngine
# Signing out: a client throws away its own token.
#
# Revocation is a soft delete on the ApplicationToken, so the record of which device
# signed in and when survives it. The person's own list of devices — where somebody
# revokes a token for a laptop they no longer have — is the host's page, because it
# needs a session and a browser.
class Api::Auth::TokensController < ApiController class Api::Auth::TokensController < ApiController
resource_description do resource_description do
short "Client tokens" short "Client tokens"
@@ -1,11 +1,5 @@
module WarpEngine module WarpEngine
# What this deployment is and what it can do, in one unauthenticated request.
#
# This is how a client stops guessing. Before it existed, everything a client knew
# about a store was compiled into the client — which endpoints to call, whether
# signing in was a thing here, where to send somebody who wanted to buy something.
# Every one of those is a property of the *server*, and a client that carries them
# can only ever serve the one store it was built for.
class Api::ServiceController < ApiController class Api::ServiceController < ApiController
resource_description do resource_description do
short "Service descriptor" short "Service descriptor"
+1 -12
View File
@@ -5,14 +5,8 @@ module WarpEngine
formats [ "json" ] formats [ "json" ]
end end
# Every response the engine serves names the version that served it, so a client can
# branch on the engine's age without a round trip to ask. Set *before* the action,
# not after: an error handled by `rescue_from` never reaches an after_action, and a
# client needs the version most when something came back wrong.
before_action :set_version_header before_action :set_version_header
# Every read-only endpoint may be called with a bearer token; none of them requires
# one. See WarpEngine::SubjectAuthentication.
include WarpEngine::SubjectAuthentication include WarpEngine::SubjectAuthentication
rescue_from StandardError do |e| rescue_from StandardError do |e|
@@ -39,12 +33,7 @@ module WarpEngine
private private
def set_version_header def set_version_header
# The name is spelled out here rather than taken from WarpEngine::VERSION_HEADER on
# purpose. A deployed process can end up with these controllers and an older
# `lib/` — it happened on the first deploy of this feature — and a controller that
# needs a constant from the newer half answers 500 to every request instead of
# serving the catalog. A response header is not worth that fragility. The constant
# is still the documented name, and a spec holds the two together.
response.headers["WarpEngine-Version"] = WarpEngine::VERSION response.headers["WarpEngine-Version"] = WarpEngine::VERSION
end end
@@ -1,8 +1,6 @@
module WarpEngine module WarpEngine
module Build module Build
# Woodpecker configuration-extension endpoint: on every pipeline start the
# CI server POSTs the repo's marker file and receives the platform's full
# pipeline YAML. GET renders the same thing as a preview.
class ConfigsController < ApiController class ConfigsController < ApiController
resource_description do resource_description do
short "Woodpecker CI pipeline configs" short "Woodpecker CI pipeline configs"
@@ -58,9 +56,6 @@ module WarpEngine
) )
end end
# The first submitted config that parses as a marker (Hash with a `platform`
# key). The docs call the key "configuration", the example-config-service
# uses "configs" — accept both.
def find_marker def find_marker
configs = params[:configuration].presence || params[:configs].presence || [] configs = params[:configuration].presence || params[:configs].presence || []
configs.each do |config| configs.each do |config|
@@ -9,8 +9,6 @@ module WarpEngine
short "Build artifact upload" short "Build artifact upload"
end end
# Release file naming convention: <name>-<version>.<ext> or
# <name>-<version>-<target>.zip — the updater looks for these too.
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/ NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
api :POST, "/build/upload", "Upload a build artifact into the artifact directory" api :POST, "/build/upload", "Upload a build artifact into the artifact directory"
+1 -12
View File
@@ -6,12 +6,9 @@ module WarpEngine
UPDATE_SCOPE = "update".freeze UPDATE_SCOPE = "update".freeze
UPLOAD_SCOPE = "upload".freeze UPLOAD_SCOPE = "upload".freeze
# A token held by a *client* rather than a publisher: it reads the catalog and
# downloads artifacts, and it never publishes anything.
CATALOG_SCOPE = "catalog".freeze CATALOG_SCOPE = "catalog".freeze
# The generated token is only available in memory at creation time — the DB
# stores nothing but the SHA256 digest and the non-secret prefix.
attr_reader :plain_token attr_reader :plain_token
belongs_to :owner, polymorphic: true belongs_to :owner, polymorphic: true
@@ -33,7 +30,6 @@ module WarpEngine
Digest::SHA256.hexdigest(token) Digest::SHA256.hexdigest(token)
end end
# The live (not deleted, not expired) token carrying the required scope, else nil.
def self.authenticate(token, required_scope: nil) def self.authenticate(token, required_scope: nil)
return nil if token.blank? return nil if token.blank?
@@ -48,7 +44,6 @@ module WarpEngine
expires_at.present? && expires_at <= Time.current expires_at.present? && expires_at <= Time.current
end end
# Revocation = soft delete, the audit trail stays.
def revoke! def revoke!
update_column(:deleted_at, Time.current) update_column(:deleted_at, Time.current)
end end
@@ -57,7 +52,6 @@ module WarpEngine
update_column(:last_used_at, Time.current) update_column(:last_used_at, Time.current)
end end
# Admin form: comma separated scope list
def scopes_string def scopes_string
Array(scopes).join(", ") Array(scopes).join(", ")
end end
@@ -70,7 +64,6 @@ module WarpEngine
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at] %w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
end end
# Ransack cannot filter on the polymorphic owner association.
def self.ransackable_associations(auth_object = nil) def self.ransackable_associations(auth_object = nil)
[] []
end end
@@ -81,10 +74,6 @@ module WarpEngine
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank? self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
end end
# Publishing tokens and client tokens share this table but not their owners: one
# belongs to whoever ships software, the other to whoever buys it. Both classes are
# the host's to name, and either is acceptable here — which of the two a given token
# may do is decided by its scopes, not by its owner.
def self.permitted_owner_types def self.permitted_owner_types
[ WarpEngine.config.application_token_owner_class, [ WarpEngine.config.application_token_owner_class,
WarpEngine.config.access_token_owner_class ].compact_blank WarpEngine.config.access_token_owner_class ].compact_blank
+2 -20
View File
@@ -1,19 +1,8 @@
module WarpEngine module WarpEngine
# One pending sign-in from a client that has no browser of its own.
#
# The shape is RFC 8628's device authorization grant, and the reason for it is that a
# desktop client cannot host a login form without asking a person to type a password
# into a window that is not a browser. So the client asks for a pair of codes, sends
# the person to the host's own page with the short one, and polls with the long one
# until somebody approves it.
#
# Short-lived by design: this row exists for the minute or two between "the client
# asked" and "the person answered". What survives it is the ApplicationToken.
class DeviceGrant < ApplicationRecord class DeviceGrant < ApplicationRecord
self.table_name = "device_grants" self.table_name = "device_grants"
# No I, O, 0 or 1: this alphabet is read off one screen and typed into another, and
# those four are where that goes wrong.
USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".freeze USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".freeze
USER_CODE_LENGTH = 8 USER_CODE_LENGTH = 8
@@ -33,7 +22,6 @@ module WarpEngine
pending.find_by(user_code: normalize_user_code(code)) pending.find_by(user_code: normalize_user_code(code))
end end
# Typed by a person, so it arrives with whatever case and separators they used.
def self.normalize_user_code(code) def self.normalize_user_code(code)
code.to_s.upcase.gsub(/[^A-Z0-9]/, "") code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
end end
@@ -42,8 +30,6 @@ module WarpEngine
def approved? = approved_at.present? def approved? = approved_at.present?
def denied? = denied_at.present? def denied? = denied_at.present?
# What the polling client is told. Order matters: a denied grant is denied even
# after it expires, because "somebody said no" is the more useful answer.
def state def state
return :denied if denied? return :denied if denied?
return :approved if approved? return :approved if approved?
@@ -52,13 +38,10 @@ module WarpEngine
:pending :pending
end end
# Grouped for reading aloud and for typing: WARP-K7M2.
def formatted_user_code def formatted_user_code
user_code.to_s.scan(/.{1,4}/).join("-") user_code.to_s.scan(/.{1,4}/).join("-")
end end
# Housekeeping for a host that wants it: an expired grant has nothing left to give,
# and its issued_token would be a live secret nobody is waiting for.
def self.sweep_expired! def self.sweep_expired!
where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil) where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil)
end end
@@ -79,8 +62,7 @@ module WarpEngine
end end
def self.generate_user_code def self.generate_user_code
# Retried rather than trusted: the alphabet is small enough that a collision is
# a real, if rare, event, and a unique index would turn it into a 500.
10.times do 10.times do
candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join
return candidate unless exists?(user_code: candidate) return candidate unless exists?(user_code: candidate)
-19
View File
@@ -2,29 +2,14 @@ module WarpEngine
class Pipeline < ApplicationRecord class Pipeline < ApplicationRecord
self.table_name = "pipelines" self.table_name = "pipelines"
# Repos synced from Woodpecker without a matching Software land here
# until a platform is assigned by hand.
UNKNOWN_PLATFORM = "unknown".freeze UNKNOWN_PLATFORM = "unknown".freeze
belongs_to :software, class_name: "WarpEngine::Software", optional: true belongs_to :software, class_name: "WarpEngine::Software", optional: true
# Pipelines this record took the software from during the last save, by full name.
# The admin says so out loud: a silent reassignment is what made the old behaviour
# confusing in the first place.
attr_reader :software_taken_from attr_reader :software_taken_from
default_scope { where(deleted_at: nil) } default_scope { where(deleted_at: nil) }
# One pipeline per software, and the newest assignment wins.
#
# `Software#pipeline` is a `has_one`, so two pipelines pointing at the same software
# is not an error — it is worse than one: the software keeps showing whichever row
# comes first, and assigning it elsewhere looks like it did nothing. Rather than
# refusing the assignment, the link moves: whoever held that software lets go of it.
#
# Deliberately a callback and not a unique index. Rows here are soft-deleted, and a
# unique index counts deleted rows too, so a pipeline someone removed last year would
# block the software from ever being linked again.
before_save :claim_software_from_other_pipelines, if: :will_save_change_to_software_id? before_save :claim_software_from_other_pipelines, if: :will_save_change_to_software_id?
validates :woodpecker_repo_id, presence: true, uniqueness: true validates :woodpecker_repo_id, presence: true, uniqueness: true
@@ -57,10 +42,6 @@ module WarpEngine
@software_taken_from = others.map(&:full_name) @software_taken_from = others.map(&:full_name)
return if @software_taken_from.empty? return if @software_taken_from.empty?
# Logged rather than flashed. The first attempt at this put a message on screen by
# overriding the admin's `update` action, which bypassed the permitted-params path
# and made every pipeline edit fail with ForbiddenAttributesError. A silent
# reassignment is a small problem; an admin page that cannot save is a large one.
Rails.logger.info( Rails.logger.info(
"[WarpEngine::Pipeline] #{full_name} took software #{software_id} from " \ "[WarpEngine::Pipeline] #{full_name} took software #{software_id} from " \
"#{@software_taken_from.join(', ')}" "#{@software_taken_from.join(', ')}"
-2
View File
@@ -2,8 +2,6 @@ module WarpEngine
class Software < ApplicationRecord class Software < ApplicationRecord
self.table_name = "softwares" self.table_name = "softwares"
# Owner of the publishing token (e.g. AdminUser) — for 3rd-party isolation,
# see enforce_software_ownership. nil = internal / pre-backfill software.
belongs_to :owner, polymorphic: true, optional: true belongs_to :owner, polymorphic: true, optional: true
has_many :software_images, foreign_key: :software_id, dependent: :destroy has_many :software_images, foreign_key: :software_id, dependent: :destroy
@@ -4,7 +4,6 @@ module WarpEngine
FILE_PATH_TO = "/file/" FILE_PATH_TO = "/file/"
# A lemezen tárolt asset-útvonalak publikus /file/ URL-lé írása configból.
def self.file_path_from def self.file_path_from
"#{WarpEngine.config.file_container_path.chomp("/")}/" "#{WarpEngine.config.file_container_path.chomp("/")}/"
end end
@@ -5,8 +5,7 @@ module WarpEngine
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil } field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil }
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil } field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil }
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 } field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
# Whether this title is gated, what it costs and where to get it. Always present —
# see WarpEngine::Access. Under the open policy it is the constant OPEN answer.
field(:access) { |_, opts| (opts[:access] || WarpEngine::Access::OPEN).as_json } field(:access) { |_, opts| (opts[:access] || WarpEngine::Access::OPEN).as_json }
end end
end end
@@ -14,7 +14,7 @@ module WarpEngine
field(:license) { |sw| sw.license.to_s } field(:license) { |sw| sw.license.to_s }
field :platform field :platform
field :status field :status
# Public owner id — for the /api/software?owner_id= filter.
field(:ownerId) { |sw| sw.owner_id } field(:ownerId) { |sw| sw.owner_id }
field(:highlighted) { |sw| sw.highlighted ? true : false } field(:highlighted) { |sw| sw.highlighted ? true : false }
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) } field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
@@ -1,14 +1,7 @@
module WarpEngine module WarpEngine
module Platforms module Platforms
module Builds module Builds
# Linux on 64-bit ARM: Raspberry Pi 4/5, Odroid, and the retro handhelds.
# Batocera and the ES-family distributions run on these as much as on
# x86_64, and an x64 binary installs there but will not start — which is
# why this is a separate kind rather than something linux_x64 can cover.
#
# Include this in a platform service only once its pipeline actually
# produces the artifact: registering the kind makes /api/builds report it
# as missing for every release until then.
module BuildLinuxArm64 module BuildLinuxArm64
extend ActiveSupport::Concern extend ActiveSupport::Concern
@@ -1,13 +1,10 @@
require "erb" require "erb"
module WarpEngine module WarpEngine
# Renders the /build/config platform templates: the pipeline logic lives in
# app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb, the
# per-platform builder images come from WarpEngine.config.ci_platforms.
class CiConfigService class CiConfigService
PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/ PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/
# The rendered pipeline YAML, or nil when the platform is not served.
def render(platform:, name:, update_server:) def render(platform:, name:, update_server:)
platform = platform.to_s platform = platform.to_s
return nil unless platform.match?(PLATFORM_FORMAT) return nil unless platform.match?(PLATFORM_FORMAT)
@@ -4,12 +4,7 @@ require "net/http"
require "digest" require "digest"
module WarpEngine module WarpEngine
# Verifies the signature of Woodpecker configuration-extension requests.
# Woodpecker 3.x signs with RFC 9421 HTTP message signatures (ed25519, via
# yaronf/httpsign): Signature-Input + Signature + Content-Digest headers,
# covered components "@request-target" and "content-digest". Older versions
# used draft-cavage http-signatures (a single Signature header) — kept as a
# fallback.
class CiSignatureVerifier class CiSignatureVerifier
CAVAGE_PARAM = /(\w+)="([^"]*)"/ CAVAGE_PARAM = /(\w+)="([^"]*)"/
@@ -17,7 +12,7 @@ module WarpEngine
@key_mutex = Mutex.new @key_mutex = Mutex.new
class << self class << self
# The downloaded key is cached process-wide (per URL).
def fetch_public_key(url) def fetch_public_key(url)
@key_mutex.synchronize do @key_mutex.synchronize do
@key_cache[url] ||= Net::HTTP.get(URI.parse(url)) @key_cache[url] ||= Net::HTTP.get(URI.parse(url))
@@ -64,8 +59,6 @@ module WarpEngine
nil nil
end end
# --- RFC 9421 ---
def rfc9421_valid?(key) def rfc9421_valid?(key)
input = @request.headers["Signature-Input"].to_s input = @request.headers["Signature-Input"].to_s
match = input.match(/\A\s*([\w.-]+)=(\(.*)\z/m) match = input.match(/\A\s*([\w.-]+)=(\(.*)\z/m)
@@ -102,8 +95,6 @@ module WarpEngine
end end
end end
# When content-digest is a covered component, the body itself must match
# the digest header — this is what ties the signature to the payload.
def content_digest_valid?(components) def content_digest_valid?(components)
return true unless components.include?("content-digest") return true unless components.include?("content-digest")
@@ -114,8 +105,6 @@ module WarpEngine
ActiveSupport::SecurityUtils.secure_compare(digest, expected) ActiveSupport::SecurityUtils.secure_compare(digest, expected)
end end
# --- draft-cavage fallback ---
def cavage_valid?(key) def cavage_valid?(key)
params = cavage_params params = cavage_params
return false if params.nil? || params["signature"].blank? return false if params.nil? || params["signature"].blank?
@@ -126,7 +115,6 @@ module WarpEngine
key.verify(nil, Base64.decode64(params["signature"]), signing_string) key.verify(nil, Base64.decode64(params["signature"]), signing_string)
end end
# Parameters of the Signature header (or the "Authorization: Signature ..." form).
def cavage_params def cavage_params
header = @request.headers["Signature"].presence header = @request.headers["Signature"].presence
if header.nil? if header.nil?
@@ -1,25 +1,15 @@
module WarpEngine module WarpEngine
# The device authorization grant, from both ends.
#
# The client's end is #request, #poll and #revoke, all reachable over /api/auth/*.
# The person's end is #approve and #deny, which the *host* calls from its own page —
# approving needs a session and a logged-in human, and the engine has neither.
class DeviceGrantService class DeviceGrantService
class NotConfigured < StandardError; end class NotConfigured < StandardError; end
class UnknownCode < StandardError; end class UnknownCode < StandardError; end
# A client asks for a code pair. Deliberately unauthenticated: there is nobody to
# authenticate as yet, which is the whole reason this flow exists.
def request(client_name:) def request(client_name:)
ensure_configured! ensure_configured!
WarpEngine::DeviceGrant.create!(client_name: client_name.presence&.truncate(128)) WarpEngine::DeviceGrant.create!(client_name: client_name.presence&.truncate(128))
end end
# The client polls with the device code. Returns [state, token], where the token is
# the plain string and is available exactly once — on the poll that finds the grant
# newly approved. A second poll gets :approved with no token, which is the honest
# answer: the secret was handed over and is not kept.
def poll(device_code:) def poll(device_code:)
ensure_configured! ensure_configured!
@@ -28,16 +18,11 @@ module WarpEngine
return [ grant.state, nil ] unless grant.state == :approved return [ grant.state, nil ] unless grant.state == :approved
# Read once, then gone: the column exists only to carry the secret across the gap
# between the browser that approved it and the client that is polling for it.
plain = grant.issued_token plain = grant.issued_token
grant.update_columns(issued_token: nil) if plain.present? grant.update_columns(issued_token: nil) if plain.present?
[ :approved, plain ] [ :approved, plain ]
end end
# The host's approval page calls this with the code a person typed and the subject
# they are signed in as. Issuing the token here rather than on the next poll keeps
# the decision and its consequence in one transaction.
def approve(user_code:, subject:) def approve(user_code:, subject:)
ensure_configured! ensure_configured!
@@ -73,8 +58,6 @@ module WarpEngine
grant grant
end end
# Signing out: the client throws its own token away. Revocation is a soft delete on
# the token, so the audit trail of who signed in from where survives it.
def revoke(token:) def revoke(token:)
return false if token.nil? return false if token.nil?
@@ -82,9 +65,6 @@ module WarpEngine
true true
end end
# Where a person goes to type the user code. A path is made absolute against the
# request's own base, so a host that configured "/devices" does not have to know its
# own hostname.
def verification_url(base_url: nil) def verification_url(base_url: nil)
configured = WarpEngine.config.identity_verification_url.presence || "/devices" configured = WarpEngine.config.identity_verification_url.presence || "/devices"
return configured if configured.start_with?("http://", "https://") return configured if configured.start_with?("http://", "https://")
+1 -17
View File
@@ -1,25 +1,16 @@
module WarpEngine module WarpEngine
class DownloadService class DownloadService
# The access policy said no. The controller turns this into a 403 — distinct from
# the nil that means "no such file", because telling a person their file is missing
# when it is merely locked sends them looking for the wrong problem.
class Denied < StandardError; end class Denied < StandardError; end
def self.container_base def self.container_base
WarpEngine.config.file_container_path WarpEngine.config.file_container_path
end end
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
def self.base_path def self.base_path
Pathname.new(container_base).realpath Pathname.new(container_base).realpath
end end
# A letöltés helyét adja vissza (fájl vagy aláírt URL) és naplózza a
# letöltést. A hely feloldása a storage adapteren megy — alapból :local,
# tehát változatlanul lemezről.
#
# `subject` is whoever the request authenticated as, or nil. Under the open policy
# it is ignored and every file is served, exactly as before.
def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil) def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
relative = path.to_s relative = path.to_s
return nil unless storage.file?(relative) return nil unless storage.file?(relative)
@@ -34,8 +25,6 @@ module WarpEngine
expires_in: grant.expires_in) expires_in: grant.expires_in)
end end
# Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza
# (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való.
def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil) def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer, location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer,
subject: subject, request: request) subject: subject, request: request)
@@ -50,9 +39,6 @@ module WarpEngine
WarpEngine.storage WarpEngine.storage
end end
# A policy that refuses returns nil; one that raises is treated as a refusal too.
# An artifact served because the gatekeeper crashed is the one failure mode this
# engine must not have.
def authorize!(asset, subject, request) def authorize!(asset, subject, request)
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request) grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
raise Denied if grant.nil? raise Denied if grant.nil?
@@ -80,8 +66,6 @@ module WarpEngine
referer: referer&.truncate(500) referer: referer&.truncate(500)
) )
# The same seam the publish side has: a host that wants its own record of who
# downloaded what subscribes rather than reaching into this class.
ActiveSupport::Notifications.instrument("warp_engine.download", ActiveSupport::Notifications.instrument("warp_engine.download",
path: relative, asset: asset, release: asset&.release, path: relative, asset: asset, release: asset&.release,
software: asset&.release&.software, subject: subject, download: download, ip: ip) software: asset&.release&.software, subject: subject, download: download, ip: ip)
@@ -1,6 +1,6 @@
module WarpEngine module WarpEngine
class FileManagerService class FileManagerService
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
def base_path def base_path
@base_path ||= Pathname.new(WarpEngine.config.file_container_path) @base_path ||= Pathname.new(WarpEngine.config.file_container_path)
end end
+2 -19
View File
@@ -1,22 +1,10 @@
module WarpEngine module WarpEngine
class FileService class FileService
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
def self.base_path def self.base_path
Pathname.new(WarpEngine.config.file_container_path).realpath Pathname.new(WarpEngine.config.file_container_path).realpath
end end
# A fájlok helyét a storage adapter adja (alapból :local, azaz a lemez) —
# így a host az objektumtárból is kiszolgálhat anélkül, hogy az engine-t
# patchelné. Lásd WarpEngine::Storage.
# `subject` is whoever the request authenticated as, or nil. Under the open policy
# it is ignored and every file is served, exactly as before.
#
# A hosted (browser) build reaches this path as hundreds of relative requests for
# js, wasm and images, which is why the gate here is the policy's plain yes/no
# rather than anything signed: there is nothing to sign per file. A host serving
# gated web builds to browsers will usually want its own session-based route in
# front of this one — a browser has a session, and a redirect to a login page is a
# better answer there than a bare 403.
def show(input, subject: nil) def show(input, subject: nil)
relative = input.path.to_s relative = input.path.to_s
@@ -36,13 +24,8 @@ module WarpEngine
private private
# Same rule and same failure mode as DownloadService: a policy that refuses or
# raises means no file. An artifact served because the gatekeeper crashed is the
# one failure mode this engine must not have.
def authorize!(relative, subject) def authorize!(relative, subject)
# The open policy authorises everything, and this path serves a browser build as
# hundreds of requests for js, wasm and images. Asking it per file would mean a
# LIKE query per asset for an answer that is always yes.
return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open? return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open?
asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first
@@ -8,8 +8,6 @@ module WarpEngine
@client.trigger_pipeline(pipeline.woodpecker_repo_id, branch: branch) @client.trigger_pipeline(pipeline.woodpecker_repo_id, branch: branch)
end end
# Fetches one page of a pipeline's runs; the newest one refreshes the
# cached last_pipeline_* columns (Pipelines index and the CI API).
def list_pipelines(pipeline, page: 1) def list_pipelines(pipeline, page: 1)
runs = @client.list_pipelines(pipeline.woodpecker_repo_id, page: page) runs = @client.list_pipelines(pipeline.woodpecker_repo_id, page: page)
refresh_last_pipeline(pipeline, runs.first) if page == 1 && runs.is_a?(Array) refresh_last_pipeline(pipeline, runs.first) if page == 1 && runs.is_a?(Array)
@@ -22,7 +20,6 @@ module WarpEngine
private private
# Woodpecker returns unix epoch seconds in "created".
def refresh_last_pipeline(pipeline, run) def refresh_last_pipeline(pipeline, run)
return unless run && pipeline.persisted? return unless run && pipeline.persisted?
+1 -6
View File
@@ -1,8 +1,6 @@
module WarpEngine module WarpEngine
class PublishService class PublishService
# Az esemény neve, amit a host lehallgathat. A payload:
# platform: String, name: String, version: String,
# software: WarpEngine::Software, release: WarpEngine::Release
NOTIFICATION = "warp_engine.publish".freeze NOTIFICATION = "warp_engine.publish".freeze
def publish(input) def publish(input)
@@ -13,9 +11,6 @@ module WarpEngine
release = "WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize release = "WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize
.new.update(input.name, input.version) .new.update(input.name, input.version)
# A publikálás az egyetlen pont, ahol új build kerül a katalógusba —
# a host innen tud rá reagálni (értesítés, feed, csatorna-előléptetés)
# anélkül, hogy modell-callbackre kellene kapaszkodnia.
ActiveSupport::Notifications.instrument( ActiveSupport::Notifications.instrument(
NOTIFICATION, NOTIFICATION,
platform: input.platform, platform: input.platform,
@@ -5,7 +5,7 @@ module WarpEngine
def build_response(software, releases, download_counts, subject: nil) def build_response(software, releases, download_counts, subject: nil)
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
web_playable = latest if latest&.release_assets&.any? { |a| a.kind == "html" } web_playable = latest if latest&.release_assets&.any? { |a| a.kind == "html" }
total_downloads = releases.sum { |r| download_counts.fetch(r.id, 0) } total_downloads = releases.sum { |r| download_counts.fetch(r.id, 0) }
@@ -19,21 +19,14 @@ module WarpEngine
) )
end end
# Always present, even under the open policy: a client should never have to tell
# "this catalog says nothing about access" from "this title is not gated". One of
# those is a question and the other is an answer.
def access_for(software, subject) def access_for(software, subject)
WarpEngine.access_policy.access_for(software: software, subject: subject) WarpEngine.access_policy.access_for(software: software, subject: subject)
rescue StandardError => e rescue StandardError => e
# A policy that raises must not take the catalog down with it. The open answer is
# wrong here, so the closed one is what a broken policy gets: a title nobody can
# download is recoverable, a paid title handed out for free is not.
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}") Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
WarpEngine::Access.new(gated: true, entitled: false) WarpEngine::Access.new(gated: true, entitled: false)
end end
# The titles this subject may see. A policy that raises empties the catalog rather
# than leaking it — the safe direction — but it is still a bug, so it is logged.
def visible_scope(subject) def visible_scope(subject)
WarpEngine.access_policy.visible_software_scope(subject: subject) WarpEngine.access_policy.visible_software_scope(subject: subject)
rescue StandardError => e rescue StandardError => e
@@ -41,7 +34,6 @@ module WarpEngine
WarpEngine::Software.none WarpEngine::Software.none
end end
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
def download_counts_for(release_ids) def download_counts_for(release_ids)
return {} if release_ids.empty? return {} if release_ids.empty?
WarpEngine::Download.where(release_id: release_ids).group(:release_id).count WarpEngine::Download.where(release_id: release_ids).group(:release_id).count
@@ -2,9 +2,6 @@ module WarpEngine
class SoftwareService class SoftwareService
include SoftwareResponseBuilder include SoftwareResponseBuilder
# `subject` is whoever the request authenticated as, or nil. It decides two things:
# which titles are listed at all (the policy's scope) and what each one's `access`
# block says about entitlement.
def index(owner_id: nil, subject: nil) def index(owner_id: nil, subject: nil)
softwares = visible_scope(subject) softwares = visible_scope(subject)
.includes(releases: [ :release_assets ]) .includes(releases: [ :release_assets ])
@@ -21,8 +21,6 @@ module WarpEngine
@token = token || WarpEngine.config.woodpecker_api_token @token = token || WarpEngine.config.woodpecker_api_token
end end
# --- Repos ---
def list_repos def list_repos
get("/api/repos") get("/api/repos")
end end
@@ -39,8 +37,6 @@ module WarpEngine
delete("/api/repos/#{repo_id}") delete("/api/repos/#{repo_id}")
end end
# --- Secrets ---
def list_secrets(repo_id) def list_secrets(repo_id)
get("/api/repos/#{repo_id}/secrets") get("/api/repos/#{repo_id}/secrets")
end end
@@ -59,8 +55,6 @@ module WarpEngine
delete("/api/repos/#{repo_id}/secrets/#{secret_name}") delete("/api/repos/#{repo_id}/secrets/#{secret_name}")
end end
# --- Pipelines ---
def list_pipelines(repo_id, page: 1, per_page: 25) def list_pipelines(repo_id, page: 1, per_page: 25)
get("/api/repos/#{repo_id}/pipelines", get("/api/repos/#{repo_id}/pipelines",
params: { page: page, perPage: per_page }) params: { page: page, perPage: per_page })
@@ -139,8 +133,7 @@ module WarpEngine
begin begin
JSON.parse(response.body) JSON.parse(response.body)
rescue JSON::ParserError rescue JSON::ParserError
# A wrong path falls through to the Woodpecker SPA, which answers
# 200 with index.html — surface that as an API error, not a parse one.
raise ApiError.new( raise ApiError.new(
"Expected JSON from #{uri.path} but got: #{response.body.truncate(80)}", "Expected JSON from #{uri.path} but got: #{response.body.truncate(80)}",
status: response.code.to_i, body: response.body status: response.code.to_i, body: response.body
-4
View File
@@ -1,11 +1,7 @@
WarpEngine::Engine.routes.draw do WarpEngine::Engine.routes.draw do
namespace :api do namespace :api do
# What this deployment is and what it can do. A client reads it before it can have
# a credential, so it is public and cheap.
get "service", to: "service#show" get "service", to: "service#show"
# Device sign-in, for clients that have no browser of their own (RFC 8628).
# Inactive — 404 on every action — unless the host configured a subject class.
namespace :auth do namespace :auth do
post "device", to: "devices#create" post "device", to: "devices#create"
post "device/token", to: "devices#token" post "device/token", to: "devices#token"
@@ -3,8 +3,7 @@ class CreateApplicationTokens < ActiveRecord::Migration[8.1]
create_table :application_tokens, id: { type: :bigint, unsigned: true }, create_table :application_tokens, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.string :name, limit: 128, null: false t.string :name, limit: 128, null: false
# The owner class comes from the host (WarpEngine.config.application_token_owner_class),
# so no FK.
t.string :owner_type, limit: 128, null: false t.string :owner_type, limit: 128, null: false
t.bigint :owner_id, null: false, unsigned: true t.bigint :owner_id, null: false, unsigned: true
t.string :token_digest, limit: 64, null: false t.string :token_digest, limit: 64, null: false
@@ -1,12 +1,10 @@
class AddBuildOwnership < ActiveRecord::Migration[8.1] class AddBuildOwnership < ActiveRecord::Migration[8.1]
def change def change
# Owner of the publishing token; nil = internal / pre-backfill software.
# The owner class comes from the host (application_token_owner_class), so no FK.
add_column :softwares, :owner_type, :string, limit: 128 add_column :softwares, :owner_type, :string, limit: 128
add_column :softwares, :owner_id, :bigint, unsigned: true add_column :softwares, :owner_id, :bigint, unsigned: true
add_index :softwares, [ :owner_type, :owner_id ], name: "idx_softwares_owner" add_index :softwares, [ :owner_type, :owner_id ], name: "idx_softwares_owner"
# unrestricted = internal token: exempt from enforce_software_ownership.
add_column :application_tokens, :unrestricted, :boolean, default: false, null: false add_column :application_tokens, :unrestricted, :boolean, default: false, null: false
end end
end end
@@ -2,26 +2,16 @@ class CreateDeviceGrants < ActiveRecord::Migration[8.1]
def change def change
create_table :device_grants, id: { type: :bigint, unsigned: true }, create_table :device_grants, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
# Both codes are secrets in the sense that guessing one grants a session, but they
# have different jobs: the device code is long and never shown to a person, the
# user code is short enough to read off a screen and type into a browser.
t.string :device_code, limit: 64, null: false t.string :device_code, limit: 64, null: false
t.string :user_code, limit: 16, null: false t.string :user_code, limit: 16, null: false
# What the client called itself. Shown on the approval page, so a person can tell
# which machine is asking, and kept on the issued token as its name.
t.string :client_name, limit: 128 t.string :client_name, limit: 128
# The approving subject comes from the host
# (WarpEngine.config.access_token_owner_class), so no FK — same reasoning as
# application_tokens.owner_type.
t.string :subject_type, limit: 128 t.string :subject_type, limit: 128
t.bigint :subject_id, unsigned: true t.bigint :subject_id, unsigned: true
t.bigint :application_token_id, unsigned: true t.bigint :application_token_id, unsigned: true
# The issued token, in the clear, for the seconds between "approved" and "the
# client's next poll". It is cleared on the poll that hands it over, so this
# column holds a live secret only while somebody is waiting for it. There is no
# way around storing it: the approval happens in a browser and the poll arrives on
# a different request, so the two cannot share memory. Everything else about a
# token is stored as a digest — this is the one exception, and it is temporary.
t.string :issued_token, limit: 64 t.string :issued_token, limit: 64
t.datetime :approved_at, precision: 3 t.datetime :approved_at, precision: 3
t.datetime :denied_at, precision: 3 t.datetime :denied_at, precision: 3
@@ -17,7 +17,6 @@ module HostApp
config.time_zone = "UTC" config.time_zone = "UTC"
config.active_record.default_timezone = :utc config.active_record.default_timezone = :utc
# Demo stack: reachable as localhost, gitea-network hostnames, etc.
config.hosts.clear config.hosts.clear
end end
end end
@@ -3,7 +3,6 @@ Rails.application.config.to_prepare do
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares") c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images") c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
# With no secret configured the /build/* endpoints reject every request.
c.update_secret = ENV["UPDATE_SECRET"] c.update_secret = ENV["UPDATE_SECRET"]
end end
end end
@@ -1,6 +1,5 @@
Rails.application.routes.draw do Rails.application.routes.draw do
apipie apipie
# Keep the engine mount the last entry so the host's own routes win.
mount WarpEngine::Engine => "/" mount WarpEngine::Engine => "/"
end end
@@ -11,8 +11,7 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.string :site t.string :site
t.string :status, limit: 20, default: "development" t.string :status, limit: 20, default: "development"
t.boolean :highlighted, default: false t.boolean :highlighted, default: false
# Owner of the publishing token (enforce_software_ownership) — no FK,
# the owner class comes from the host.
t.string :owner_type, limit: 128 t.string :owner_type, limit: 128
t.bigint :owner_id t.bigint :owner_id
t.datetime :deleted_at, precision: 3 t.datetime :deleted_at, precision: 3
@@ -87,7 +86,7 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.string :token_digest, limit: 64, null: false t.string :token_digest, limit: 64, null: false
t.string :token_prefix, limit: 12, null: false t.string :token_prefix, limit: 12, null: false
t.json :scopes t.json :scopes
# Internal token: exempt from enforce_software_ownership.
t.boolean :unrestricted, default: false, null: false t.boolean :unrestricted, default: false, null: false
t.datetime :expires_at, precision: 3 t.datetime :expires_at, precision: 3
t.datetime :last_used_at, precision: 3 t.datetime :last_used_at, precision: 3
@@ -98,12 +97,6 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.index :deleted_at t.index :deleted_at
end end
# NOTE: device_grants is NOT created here. It ships as its own migration in the
# engine's db/migrate, which the host runs from the appended path — so creating it
# here as well would be a second CREATE TABLE for the same name. The same is true
# of application_tokens below, which predates this note and is why a host
# installing today has to delete that block from its copy by hand.
create_table :downloads do |t| create_table :downloads do |t|
t.string :file_path, null: false t.string :file_path, null: false
t.references :release, foreign_key: { on_delete: :nullify }, index: false t.references :release, foreign_key: { on_delete: :nullify }, index: false
@@ -1,81 +1,4 @@
Rails.application.config.to_prepare do Rails.application.config.to_prepare do
WarpEngine.configure do |c| WarpEngine.configure do |c|
# Storage locations for build artifacts and images (defaults from ENV:
# FILE_CONTAINER_PATH and IMAGE_CONTAINER_PATH).
# c.file_container_path = "/softwares"
# c.image_container_path = "/images"
# Shared secret of the /build/* endpoints (default: ENV["UPDATE_SECRET"]).
# With no secret configured the endpoints reject every request.
# c.update_secret = ENV["UPDATE_SECRET"]
# Auth source of the /build/* endpoints — an exclusive choice:
# :env — the shared secret above is accepted (default)
# :database — only DB-stored WarpEngine::ApplicationToken records are
# accepted (with the "update" scope); the shared secret
# stops working the moment you switch.
# :database mode also requires the owner class every token belongs to:
# c.application_token_source = :database
# c.application_token_owner_class = "AdminUser"
# Woodpecker configuration extension (/build/config): builder images of
# the served platforms and the CI server's signing key. An empty
# ci_platforms (default) keeps the feature inactive.
# c.ci_platforms = {
# "godot" => { builder: "registry.example/godot-builder:4.6" },
# "tic80" => { builder: "registry.example/tic80-builder:1.0",
# exporter: "registry.example/tic80pro:1.0" }
# }
# c.ci_extension_public_key_url = "https://ci.example.org/api/signature/public-key"
# c.ci_update_server = nil # nil: the request base_url
# Woodpecker CI management — repo sync, secret provisioning, pipeline control.
# All three must be set for the management features to activate.
# c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.example.org"
# c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
# c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org/user (e.g. "games")
# Size cap in bytes for /build/upload (and the admin file manager, default: 500MB).
# c.max_upload_size = 500 * 1024 * 1024
# Owner isolation: a DB token may only upload/publish its own owner's
# softwares (unrestricted tokens are exempt). Enable only after existing
# softwares got an owner (backfill)!
# c.enforce_software_ownership = true
# Who may see a title and who may download it. :open (the default) lists every
# software and serves every artifact — the behaviour of a catalog nobody sells
# from. A host that does sell supplies a policy answering three methods; see
# WarpEngine::AccessPolicy. What it returns is what clients are told, so a paid
# title can announce itself as paid instead of failing at the download.
# c.access_policy = MyStore::AccessPolicy.new
# Client sign-in. nil (default) means there is none: /api/auth/* is inactive and
# GET /api/service reports auth: null, so a client offers no sign-in at all. Set
# the class a *client* token belongs to — usually your user model — to turn on
# the device authorization grant.
# c.access_token_owner_class = "User"
#
# Your own page where a signed-in person types the code their client displayed.
# It calls WarpEngine::DeviceGrantService#approve. A path is made absolute
# against the request, so you need not know your own hostname (default "/devices").
# c.identity_verification_url = "/devices"
# A browser has a session rather than a bearer token, and the engine cannot
# read one. Say how, and your signed-in visitors are recognised on the
# read-only endpoints too:
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
# c.device_code_ttl = 600 # seconds a pending code lives
# c.device_code_interval = 5 # seconds a client is told to wait between polls
# If host models also reference catalog images, register them so the
# admin Images page's orphan detection takes them into account:
# c.image_owners = [
# {
# label: "member",
# image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
# usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
# }
# ]
end end
end end
+1 -14
View File
@@ -1,7 +1,3 @@
# Az engine ActiveJob-ra épülő jobot szállít (PipelineSyncJob), ezért a
# framework betöltése a mi dolgunk: a host application.rb-je nem feltétlenül
# require-öli az active_job/railtie-t, és eager loadnál (production) a
# WarpEngine::ApplicationJob különben uninitialized constant-tal elszáll.
require "active_job/railtie" require "active_job/railtie"
require "blueprinter" require "blueprinter"
@@ -13,9 +9,7 @@ require "warp_engine/storage"
require "warp_engine/access" require "warp_engine/access"
module WarpEngine module WarpEngine
# A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace
# által generált "warp_engine_" prefixet üresre cseréljük. Az engine.rb require-je
# előtt kell definiálva lennie.
def self.table_name_prefix def self.table_name_prefix
"" ""
end end
@@ -32,10 +26,6 @@ module WarpEngine
config.woodpecker_url.present? && config.woodpecker_api_token.present? config.woodpecker_url.present? && config.woodpecker_api_token.present?
end end
# A host innen tudja, hogy a publikálás ActiveSupport::Notifications-t szór
# ("warp_engine.publish"), és nem kell modell-callbackre kapaszkodnia.
# Régebbi engine-verziókon a metódus nem létezik, ezért a hívó oldalon
# respond_to?-val kérdezendő.
def self.instruments_publish? def self.instruments_publish?
true true
end end
@@ -44,13 +34,10 @@ module WarpEngine
Storage.adapter Storage.adapter
end end
# Who may see and download what. :open by default — see WarpEngine::AccessPolicy.
def self.access_policy def self.access_policy
AccessPolicy.current AccessPolicy.current
end end
# The host has configured a subject class, so client sign-in is available. A client
# asks GET /api/service rather than this, but the engine's own controllers need it.
def self.identity_configured? def self.identity_configured?
config.access_token_owner_class.present? config.access_token_owner_class.present?
end end
+3 -44
View File
@@ -1,29 +1,7 @@
module WarpEngine module WarpEngine
# Who may see a title, and who may download it.
#
# Until now every catalog entry was public and every artifact was free: the API
# listed all software and /api/download handed over any file it could find. That is
# the right default for a catalog nobody sells from, and it stays the default — but a
# host that does sell needs the engine to *say so*, because the clients reading this
# API have no other way to learn it. A desktop client cannot know that a title is
# paid; it can only be told.
#
# This module is that seam. The default policy is byte for byte the previous
# behaviour, and a host swaps it on the configuration:
#
# c.access_policy = MyStorePolicy.new # or :open (default)
#
# A policy is any object answering to this contract:
#
# visible_software_scope(subject:) -> ActiveRecord::Relation
# access_for(software:, subject:) -> WarpEngine::Access
# authorize_download(asset:, subject:, request:) -> Access::Grant or nil
#
# `subject` is whoever the request authenticated as (see SubjectAuthentication), or
# nil for an anonymous caller. It is deliberately untyped here: the engine has no user
# model, and whose object this is belongs to the host.
module AccessPolicy module AccessPolicy
# Everything visible, everything open, no prices. The catalog as it always was.
class Open class Open
def visible_software_scope(subject: nil) def visible_software_scope(subject: nil)
WarpEngine::Software.all WarpEngine::Software.all
@@ -33,8 +11,6 @@ module WarpEngine
Access::OPEN Access::OPEN
end end
# An open catalog authorises every asset it can find. Returning a bare grant
# rather than `true` keeps one return type across policies.
def authorize_download(asset: nil, subject: nil, request: nil) def authorize_download(asset: nil, subject: nil, request: nil)
Access::Grant::OPEN Access::Grant::OPEN
end end
@@ -56,24 +32,14 @@ module WarpEngine
@open_policy ||= Open.new @open_policy ||= Open.new
end end
# Tests and hosts that swap the configuration at runtime.
def reset! def reset!
@open_policy = nil @open_policy = nil
end end
end end
end end
# What a client is told about one title's availability.
#
# The vocabulary is deliberately generic — `gated`, `entitled`, `price` — because
# every client reading it serves more than one store. A word from any particular
# host's domain ("product", "purchase order", "library") would make the client
# that reads it specific to that host, which is exactly what this engine exists
# to prevent.
class Access class Access
# Where a hosted (browser) build is played, when the host serves it somewhere other
# than the engine's own /file/ path. nil leaves the client with what it already
# builds, which is the pre-existing behaviour.
attr_reader :gated, :entitled, :price_cents, :currency, :purchase_url, :web_url attr_reader :gated, :entitled, :price_cents, :currency, :purchase_url, :web_url
def initialize(gated: false, entitled: true, price_cents: nil, currency: nil, def initialize(gated: false, entitled: true, price_cents: nil, currency: nil,
@@ -86,9 +52,6 @@ module WarpEngine
@web_url = web_url @web_url = web_url
end end
# An open catalog's answer, and the shape every response carries even when no
# policy is configured: a client should never have to tell "no access block" from
# "not gated". One of those is a question, the other is an answer.
OPEN = new.freeze OPEN = new.freeze
def as_json(*) def as_json(*)
@@ -101,10 +64,6 @@ module WarpEngine
} }
end end
# A download the policy allowed.
#
# `filename` and `expires_in` let a host override what the engine would otherwise
# decide on its own; both nil means "you choose", which is what the open policy says.
class Grant class Grant
attr_reader :filename, :expires_in attr_reader :filename, :expires_in
+1 -51
View File
@@ -1,56 +1,6 @@
module WarpEngine module WarpEngine
class Configuration class Configuration
# Owner contract for image_owners elements:
# label: String
# image_ids: -> { Array<Integer> } — image ids used by the owner
# usage_label: ->(image) { String or nil } — label to display when in use
# application_token_source: auth source of the /build/* endpoints, exclusive.
# :env — the shared secret (update_secret) is accepted, DB tokens are not
# :database — only WarpEngine::ApplicationToken is accepted, the shared secret is not
# application_token_owner_class: class name of the mandatory token owner
# (e.g. "AdminUser"); nil makes :database mode reject every request.
# storage_adapter: where build artifacts are served from.
# :local (default) — the local filesystem under file_container_path,
# byte for byte the previous behaviour;
# any object — must answer file?/directory?/locate, see
# WarpEngine::Storage. Serving only: uploads and
# archive extraction still write to the local disk.
# max_upload_size: file size cap in bytes for /build/upload (and the admin file manager).
# enforce_software_ownership: when true, a DB token may only upload/publish
# its own owner's softwares (unrestricted tokens are exempt).
# Enable ONLY after the backfill: any token can claim an ownerless software.
# ci_platforms: platforms served by /build/config:
# { "godot" => { builder: "<image>" }, "tic80" => { builder: ..., exporter: ... } }
# Empty map = the feature is inactive (POST → 204, GET → 404).
# ci_extension_public_key(_url): the Woodpecker httpsig ed25519 public key as
# PEM, or a URL to fetch it from (e.g. https://ci.../api/signature/public-key).
# With neither set, POST /build/config rejects every request.
# ci_update_server: server URL written into the upload/publish steps; nil → the request's base_url.
# access_policy: who may see a title and who may download it.
# :open (default) — every software listed, every artifact served, no prices:
# byte for byte the previous behaviour;
# any object — must answer visible_software_scope/access_for/
# authorize_download, see WarpEngine::AccessPolicy.
# access_token_owner_class: class name of the subject a *client* token belongs to
# (e.g. "Accounts::User"). nil (default) means no client sign-in: /api/auth/* is
# inactive and GET /api/service reports auth: null. Deliberately separate from
# application_token_owner_class, which owns *publishing* tokens — a publisher and
# a customer are rarely the same kind of thing.
# identity_verification_url: the host's own page where a person approves a device
# code. Path or absolute URL; nil falls back to "/devices". The page is the host's
# because approving needs a session, a login and HTML — none of which is the
# engine's business.
# subject_resolver: how to recognise a caller that is not carrying a bearer token —
# a browser with a session, typically. A callable taking the Rack request and
# returning the host's own subject object, or nil:
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
# nil (default) makes every non-bearer request anonymous, which is what the
# read-only API always did.
# device_code_ttl / device_code_interval: how long a pending device code lives, and
# how often a client is told to poll for it.
# woodpecker_url / woodpecker_api_token / woodpecker_repo_owner:
# Woodpecker CI management (repo sync, secret provisioning, pipeline control).
# All nil → the management features are inactive.
attr_accessor :file_container_path, attr_accessor :file_container_path,
:image_container_path, :image_container_path,
:update_secret, :update_secret,
+1 -16
View File
@@ -2,23 +2,10 @@ module WarpEngine
class Engine < ::Rails::Engine class Engine < ::Rails::Engine
isolate_namespace WarpEngine isolate_namespace WarpEngine
# Az app/admin fájlok ActiveAdmin DSL-t tartalmaznak, nem definiálnak a
# fájlnévnek megfelelő konstansokat — a Zeitwerk (eager load) nem nyúlhat hozzájuk.
initializer "warp_engine.ignore_admin_dir", before: :setup_main_autoloader do initializer "warp_engine.ignore_admin_dir", before: :setup_main_autoloader do
Rails.autoloaders.main.ignore(Engine.root.join("app/admin")) Rails.autoloaders.main.ignore(Engine.root.join("app/admin"))
end end
# A jövőbeli katalógus-migrációk az engine db/migrate-jéből futnak a host
# rails db:migrate-jével, másolás nélkül.
#
# FONTOS: emiatt az engine és a host migrációi EGY névtérben vannak, és két
# azonos verziószám a host `db:migrate`-jét indulás előtt megállítja
# (DuplicateMigrationVersionError) — nem a miénket, hanem az övét, az egész
# alkalmazásban. Az engine migrációi ezért **valódi, másodperc-pontosságú
# időbélyeget** kapnak (20260819093412), soha nem kerek kézzel írt számot
# (20260819000001): pont az utóbbiakra ír rá egy host, ami ugyanaznap ugyanezzel
# a szokással ír migrációt. Így ütközött a device_grants a katalógus-API
# `carry_the_store_config_in_the_registry`-jével.
initializer "warp_engine.append_migrations" do |app| initializer "warp_engine.append_migrations" do |app|
unless app.root.to_s.start_with?(root.to_s) unless app.root.to_s.start_with?(root.to_s)
config.paths["db/migrate"].expanded.each do |path| config.paths["db/migrate"].expanded.each do |path|
@@ -27,13 +14,11 @@ module WarpEngine
end end
end end
# Az admin erőforrásokat a HOST ActiveAdmin példánya tölti be; az engine csak
# regisztrálja a saját app/admin könyvtárát. Auth/téma/route-ok a host dolga.
initializer "warp_engine.active_admin" do |app| initializer "warp_engine.active_admin" do |app|
if defined?(ActiveAdmin) if defined?(ActiveAdmin)
admin_dir = Engine.root.join("app/admin").to_s admin_dir = Engine.root.join("app/admin").to_s
ActiveAdmin.application.load_paths << admin_dir ActiveAdmin.application.load_paths << admin_dir
# dev módban az engine admin-fájljainak szerkesztése is triggerel reloadot
app.config.watchable_dirs[admin_dir] = [ :rb ] app.config.watchable_dirs[admin_dir] = [ :rb ]
end end
end end
+1 -27
View File
@@ -1,26 +1,5 @@
module WarpEngine module WarpEngine
# Where the artifacts physically live.
#
# Until now every path in the engine was a local filesystem path. This module
# is the seam a host needs to serve builds from somewhere else (an object
# store behind a CDN, for example) without patching the engine.
#
# The default adapter is :local and behaves exactly as before - same paths,
# same traversal protection, same File.file? checks.
#
# A custom adapter is any object answering to this contract:
#
# file?(relative_path) -> true/false
# directory?(relative_path) -> true/false
# locate(relative_path, filename: nil, expires_in: nil) -> Location
#
# It is set on the configuration:
#
# c.storage_adapter = MyObjectStore.new # or :local (default)
#
# NOTE: ingestion (build/upload, archive extraction, the admin file manager)
# still writes to the local filesystem. A remote adapter therefore needs its
# own upload path today; the serving side is what this seam covers.
module Storage module Storage
Location = Struct.new(:kind, :path, :url, keyword_init: true) do Location = Struct.new(:kind, :path, :url, keyword_init: true) do
def file? = kind == :file def file? = kind == :file
@@ -30,7 +9,6 @@ module WarpEngine
def self.redirect(url) = new(kind: :redirect, url: url) def self.redirect(url) = new(kind: :redirect, url: url)
end end
# The local filesystem, rooted at config.file_container_path.
class LocalAdapter class LocalAdapter
def base_path def base_path
Pathname.new(WarpEngine.config.file_container_path) Pathname.new(WarpEngine.config.file_container_path)
@@ -50,15 +28,12 @@ module WarpEngine
File.directory?(path) && inside_base?(path) File.directory?(path) && inside_base?(path)
end end
# expires_in is part of the contract for signing adapters; the local
# filesystem has nothing to sign, so it is ignored here.
def locate(relative_path, filename: nil, expires_in: nil) def locate(relative_path, filename: nil, expires_in: nil)
Location.file(absolute_path(relative_path).to_s) Location.file(absolute_path(relative_path).to_s)
end end
private private
# Path traversal guard: the resolved path must stay under the container.
def inside_base?(path) def inside_base?(path)
root = base_path.realpath.to_s root = base_path.realpath.to_s
Pathname.new(path).realpath.to_s.start_with?(root) Pathname.new(path).realpath.to_s.start_with?(root)
@@ -83,7 +58,6 @@ module WarpEngine
@local_adapter ||= LocalAdapter.new @local_adapter ||= LocalAdapter.new
end end
# Tests and hosts that swap the configuration at runtime.
def reset! def reset!
@local_adapter = nil @local_adapter = nil
end end
-3
View File
@@ -1,8 +1,5 @@
module WarpEngine module WarpEngine
VERSION = "0.5.2" VERSION = "0.5.2"
# The header every API response carries. Named here rather than written out at the one
# place that sets it: clients read it, the README documents it, and a string in three
# places is a string that eventually differs in one of them.
VERSION_HEADER = "WarpEngine-Version".freeze VERSION_HEADER = "WarpEngine-Version".freeze
end end
-1
View File
@@ -1,3 +1,2 @@
# Test-only: plays the ApplicationToken owner role in the dummy app.
class TestOwner < ActiveRecord::Base class TestOwner < ActiveRecord::Base
end end
@@ -1,4 +1,3 @@
# Test-only: plays the ApplicationToken owner role (AdminUser in the host).
class CreateTestOwners < ActiveRecord::Migration[8.1] class CreateTestOwners < ActiveRecord::Migration[8.1]
def change def change
create_table :test_owners, id: { type: :bigint, unsigned: true }, create_table :test_owners, id: { type: :bigint, unsigned: true },
-12
View File
@@ -1,15 +1,3 @@
# 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_08_19_093412) do ActiveRecord::Schema[8.1].define(version: 2026_08_19_093412) do
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3 t.datetime "created_at", precision: 3
-2
View File
@@ -1,5 +1,3 @@
# TestOwner exists only in the dummy app — host-side usage must override the
# owner (e.g. owner: create(:admin_user)).
FactoryBot.define do FactoryBot.define do
factory :test_owner, class: "TestOwner" do factory :test_owner, class: "TestOwner" do
name { "test owner" } name { "test owner" }
-1
View File
@@ -41,7 +41,6 @@ RSpec.describe WarpEngine::Storage do
expect(adapter.file?("missing.zip")).to be(false) expect(adapter.file?("missing.zip")).to be(false)
end end
# The path traversal guard has to survive the move behind the adapter.
it "refuses paths escaping the container" do it "refuses paths escaping the container" do
outside = File.join(Dir.mktmpdir, "secret.txt") outside = File.join(Dir.mktmpdir, "secret.txt")
File.write(outside, "nope") File.write(outside, "nope")
-25
View File
@@ -1,25 +1,8 @@
require "rails_helper" require "rails_helper"
# The engine appends its `db/migrate` to the host's migration paths instead of copying
# migrations into the host, so the two share **one** version namespace. A collision does
# not fail politely somewhere in the engine: it stops the host's `db:migrate` before it
# runs anything, for the whole application.
#
# That is exactly what happened to `device_grants`. It was numbered 20260819000001 — a
# hand-picked round number — and the catalog API had written
# `20260819000001_carry_the_store_config_in_the_registry` on the same day with the same
# habit. Neither repository could see the other's number.
#
# The defence is that engine migrations carry a real second-resolution timestamp, which
# nobody hand-writes and nothing rounds to. This is the test that says so.
RSpec.describe "Engine migrations" do RSpec.describe "Engine migrations" do
# `202608050000 01`-style numbers: a date, then zeros, then a counter. Rails generates
# `20260819093412`; a person types this.
ROUND_VERSION = /\A\d{8}0{4}\d{2}\z/ ROUND_VERSION = /\A\d{8}0{4}\d{2}\z/
# Numbered before this file existed and already deployed everywhere. Renumbering a
# migration that has run is worse than the risk it carries, so they are named here
# rather than quietly excluded by a rule that would also excuse the next one.
GRANDFATHERED = %w[ GRANDFATHERED = %w[
20260805000001 20260805000001
20260805000003 20260805000003
@@ -49,14 +32,6 @@ RSpec.describe "Engine migrations" do
"timestamp — `date -u +%Y%m%d%H%M%S` — not a hand-picked round number." "timestamp — `date -u +%Y%m%d%H%M%S` — not a hand-picked round number."
end end
# A table created by BOTH the install template and one of our own migrations is a
# second CREATE TABLE for the same name in whatever host installs us — the template
# runs, then the appended engine migration runs, and the second one fails.
#
# `application_tokens` is exactly that, and has been since before this file existed:
# every host installing today has to delete that block from its generated copy by
# hand, which is what teletype-orbit's own migration says in its header. It is
# grandfathered here rather than quietly excused, so the list can only shrink.
it "does not create a table the install generator also creates" do it "does not create a table the install generator also creates" do
template = WarpEngine::Engine.root.join( template = WarpEngine::Engine.root.join(
"lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb" "lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb"
-3
View File
@@ -60,9 +60,6 @@ RSpec.describe WarpEngine::Pipeline do
it { is_expected.to belong_to(:software).optional } it { is_expected.to belong_to(:software).optional }
end end
# A software has one pipeline. Two pipelines pointing at the same one is not an error
# the database catches, it is a link that silently does nothing — so the newest
# assignment takes it, and says what it took it from.
describe "assigning a software another pipeline already has" do describe "assigning a software another pipeline already has" do
it "moves the link and leaves the other pipeline without one" do it "moves the link and leaves the other pipeline without one" do
software = create(:software) software = create(:software)
+1 -2
View File
@@ -6,10 +6,9 @@ RSpec.describe WarpEngine::Software, type: :model do
it { should validate_presence_of(:name) } it { should validate_presence_of(:name) }
it { should validate_presence_of(:title) } it { should validate_presence_of(:title) }
it { should validate_presence_of(:platform) } it { should validate_presence_of(:platform) }
# MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive
it { should validate_uniqueness_of(:name).case_insensitive } it { should validate_uniqueness_of(:name).case_insensitive }
# a törlést a DB-szintű ON DELETE CASCADE végzi, a modellen nincs dependent opció
it { should have_many(:releases) } it { should have_many(:releases) }
it { should have_many(:external_links) } it { should have_many(:external_links) }
it { should have_many(:software_images).dependent(:destroy) } it { should have_many(:software_images).dependent(:destroy) }
-1
View File
@@ -11,7 +11,6 @@ rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip abort e.to_s.strip
end end
# A factory_bot_rails a dummy app gyökerében keresne; a factory-k az engine spec/ alatt élnek
FactoryBot.definition_file_paths = [ File.expand_path("factories", __dir__) ] FactoryBot.definition_file_paths = [ File.expand_path("factories", __dir__) ]
FactoryBot.reload FactoryBot.reload
@@ -15,7 +15,6 @@ RSpec.describe "Build configs endpoint", type: :request do
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(signing_key.public_to_pem) allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(signing_key.public_to_pem)
end end
# Woodpecker 3.x-style RFC 9421 signature over @request-target + content-digest.
def signed_headers(body, path: "/build/config", digest_body: nil) def signed_headers(body, path: "/build/config", digest_body: nil)
digest = "sha-256=:#{Digest::SHA256.base64digest(digest_body || body)}:" digest = "sha-256=:#{Digest::SHA256.base64digest(digest_body || body)}:"
inner = %{("@request-target" "content-digest");created=#{Time.now.to_i};alg="ed25519"} inner = %{("@request-target" "content-digest");created=#{Time.now.to_i};alg="ed25519"}
@@ -33,7 +32,6 @@ RSpec.describe "Build configs endpoint", type: :request do
} }
end end
# Legacy draft-cavage signature (single Signature header).
def cavage_signed_headers(method: "post", path: "/build/config") def cavage_signed_headers(method: "post", path: "/build/config")
date = Time.now.httpdate date = Time.now.httpdate
signing_string = "(request-target): #{method} #{path}\ndate: #{date}" signing_string = "(request-target): #{method} #{path}\ndate: #{date}"
+1 -12
View File
@@ -1,13 +1,8 @@
require "rails_helper" require "rails_helper"
# Signing a client in, end to end: the client asks for a code, a person approves it on
# the host's page, the client's next poll carries the token away, and the token then
# works as a bearer credential on the read-only API.
RSpec.describe "Device sign-in", type: :request do RSpec.describe "Device sign-in", type: :request do
let(:owner) { create(:test_owner) } let(:owner) { create(:test_owner) }
# The identity seam is off by default. Configuring the subject class is what turns
# the whole flow on — including whether it exists at all.
def configure_identity!(verification: "/devices") def configure_identity!(verification: "/devices")
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner") allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
allow(WarpEngine.config).to receive(:identity_verification_url).and_return(verification) allow(WarpEngine.config).to receive(:identity_verification_url).and_return(verification)
@@ -37,7 +32,7 @@ RSpec.describe "Device sign-in", type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
json = JSON.parse(response.body) json = JSON.parse(response.body)
expect(json["deviceCode"]).to be_present expect(json["deviceCode"]).to be_present
# Grouped and free of I/O/0/1, because it is typed by hand into a browser.
expect(json["userCode"]).to match(/\A[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}\z/) expect(json["userCode"]).to match(/\A[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}\z/)
expect(json["verificationUrl"]).to eq("http://www.example.com/devices") expect(json["verificationUrl"]).to eq("http://www.example.com/devices")
expect(json["interval"]).to eq(5) expect(json["interval"]).to eq(5)
@@ -64,8 +59,6 @@ RSpec.describe "Device sign-in", type: :request do
expect(body["token"]).to be_present expect(body["token"]).to be_present
end end
# The plain token is never stored, so it cannot be handed out twice. A client that
# loses it starts again — which is cheaper than a database full of live secrets.
it "does not repeat the token on a second poll" do it "does not repeat the token on a second poll" do
post "/api/auth/device", params: { client_name: "laptop" } post "/api/auth/device", params: { client_name: "laptop" }
json = JSON.parse(response.body) json = JSON.parse(response.body)
@@ -145,8 +138,6 @@ RSpec.describe "Device sign-in", type: :request do
expect(record.scopes).to eq([ "catalog" ]) expect(record.scopes).to eq([ "catalog" ])
end end
# A publishing token must not become a client token by accident, and vice versa:
# the scope is what separates them, and the catalog endpoint requires its own.
it "is not accepted as a publishing credential" do it "is not accepted as a publishing credential" do
token token
@@ -197,8 +188,6 @@ RSpec.describe "Device sign-in", type: :request do
end end
end end
# A browser carries a session, not a bearer token, and the engine has no idea what a
# session is. A host that wants its signed-in visitors recognised says how.
describe "the host's own subject resolver" do describe "the host's own subject resolver" do
let(:seen) { [] } let(:seen) { [] }
let(:policy) do let(:policy) do
-5
View File
@@ -1,8 +1,5 @@
require "rails_helper" require "rails_helper"
# The descriptor is how a client stops being built for one particular store: everything
# it used to have compiled in — is there a sign-in, where does it live, can titles be
# gated — is answered here instead.
RSpec.describe "GET /api/service", type: :request do RSpec.describe "GET /api/service", type: :request do
after { WarpEngine::AccessPolicy.reset! } after { WarpEngine::AccessPolicy.reset! }
@@ -53,8 +50,6 @@ RSpec.describe "GET /api/service", type: :request do
expect(auth["device"]["interval"]).to eq(5) expect(auth["device"]["interval"]).to eq(5)
end end
# A host that configured a bare path should not have to know its own hostname; one
# that put the approval page on another domain should keep it.
it "makes a configured path absolute against the request" do it "makes a configured path absolute against the request" do
get "/api/service" get "/api/service"
-7
View File
@@ -1,10 +1,6 @@
require "rails_helper" require "rails_helper"
# Every response the engine serves carries the version that served it, so a client can
# branch on the engine's age without asking a separate endpoint for it.
RSpec.describe "the WarpEngine-Version header", type: :request do RSpec.describe "the WarpEngine-Version header", type: :request do
# The controller writes the name as a literal so that it cannot depend on a constant a
# half-updated deploy might not have. This is what keeps the two in step.
it "is the name the constant documents" do it "is the name the constant documents" do
expect(WarpEngine::VERSION_HEADER).to eq("WarpEngine-Version") expect(WarpEngine::VERSION_HEADER).to eq("WarpEngine-Version")
end end
@@ -18,9 +14,6 @@ RSpec.describe "the WarpEngine-Version header", type: :request do
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION) expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
end end
# The one a client needs most: something came back wrong, and it wants to know whether
# the engine on the other end is old enough to explain it. `rescue_from` never reaches
# an after_action, which is why the header is set before the action runs.
it "is on an error response" do it "is on an error response" do
get "/api/image/999999" get "/api/image/999999"
-7
View File
@@ -1,14 +1,9 @@
require "rails_helper" require "rails_helper"
require "tmpdir" require "tmpdir"
# The access seam, from both sides: what the catalog says about a title, and whether an
# artifact is handed over. The load-bearing case is the *default* one — a catalog with
# no policy configured has to behave exactly as it did before this existed.
RSpec.describe "The access policy" do RSpec.describe "The access policy" do
let(:tmpdir) { Dir.mktmpdir } let(:tmpdir) { Dir.mktmpdir }
# A policy that gates everything except what the subject is named after. Small enough
# to read, and it exercises every method of the contract.
let(:gating_policy) do let(:gating_policy) do
Class.new do Class.new do
def initialize(open_name) = @open_name = open_name def initialize(open_name) = @open_name = open_name
@@ -153,8 +148,6 @@ RSpec.describe "The access policy" do
before { allow(WarpEngine.config).to receive(:access_policy).and_return(broken_policy) } before { allow(WarpEngine.config).to receive(:access_policy).and_return(broken_policy) }
# The direction of the failure is the point. A broken gatekeeper must not become an
# open one: an empty catalog is recoverable, a paid title given away is not.
it "empties the catalog rather than leaking it" do it "empties the catalog rather than leaking it" do
create(:software, status: "released") create(:software, status: "released")
-2
View File
@@ -64,8 +64,6 @@ RSpec.describe WarpEngine::PublishService do
allow(WarpEngine::Platforms::Tic80::Service).to receive(:new).and_return(mock_service) allow(WarpEngine::Platforms::Tic80::Service).to receive(:new).and_return(mock_service)
end end
# The host reacts to a new build through this event instead of hanging a
# callback on the Release model.
it "emits warp_engine.publish with the release in the payload" do it "emits warp_engine.publish with the release in the payload" do
payloads = [] payloads = []
ActiveSupport::Notifications.subscribe(described_class::NOTIFICATION) do |*, payload| ActiveSupport::Notifications.subscribe(described_class::NOTIFICATION) do |*, payload|
-4
View File
@@ -1,13 +1,9 @@
require "rails_helper" require "rails_helper"
require "tmpdir" require "tmpdir"
# The serving side (FileService, DownloadService) goes through the storage
# adapter. With :local nothing changes; with a custom adapter the same call
# can hand back a redirect instead of a file.
RSpec.describe "Serving through the storage adapter" do RSpec.describe "Serving through the storage adapter" do
let(:tmpdir) { Dir.mktmpdir } let(:tmpdir) { Dir.mktmpdir }
# Minimal adapter answering the documented contract.
let(:signing_adapter) do let(:signing_adapter) do
Class.new do Class.new do
def file?(_relative) = true def file?(_relative) = true