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("Active") { |scope| scope.where("expires_at IS NULL OR 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
scope("Publishing") { |scope| scope.where("NOT #{CATALOG_SCOPE_SQL}") }
scope("Clients") { |scope| scope.where(CATALOG_SCOPE_SQL) }
@@ -98,9 +96,6 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
end
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
create! do |success, _failure|
success.html do
+1 -5
View File
@@ -1,7 +1,4 @@
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
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.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
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|
token = g.application_token
next "" if token.nil?
-15
View File
@@ -13,7 +13,6 @@ ActiveAdmin.register_page "Files" do
entries = []
end
# Picker mode: hide admin header/menu/footer
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
end
@@ -26,7 +25,6 @@ ActiveAdmin.register_page "Files" do
end
end
# Breadcrumbs
div class: "fm-breadcrumbs" do
parts = current_dir.split("/").reject(&:blank?)
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
@@ -39,7 +37,6 @@ ActiveAdmin.register_page "Files" do
end
end
# Action bar (also drop zone on standalone page)
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|
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
@@ -56,9 +53,7 @@ ActiveAdmin.register_page "Files" do
end
end
# File listing
table class: "fm-table" do
# Pre-fetch download counts for files in current directory
file_entries = entries.select { |e| e[:type] != :directory }
dl_counts = if file_entries.any?
WarpEngine::Download.where(file_path: file_entries.map { |e| e[:path] })
@@ -68,7 +63,6 @@ ActiveAdmin.register_page "Files" do
{}
end
# File icon helper
file_icon = ->(name) do
ext = File.extname(name).downcase
case ext
@@ -100,7 +94,6 @@ ActiveAdmin.register_page "Files" do
end
tbody do
# Parent directory link
if current_dir.present?
parent = File.dirname(current_dir)
parent = "" if parent == "."
@@ -145,32 +138,26 @@ ActiveAdmin.register_page "Files" do
td class: "fm-entry-actions" do
entry_id = entry[:path].parameterize
# Download (files only)
if entry[:type] != :directory
a "⬇️", href: "/file/#{entry[:path]}", class: "fm-icon-btn", title: "Download", download: entry[:name]
end
# 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;"
# 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;"
# Stats (files only)
if entry[:type] != :directory
a "📊", href: admin_downloads_path(q: { file_path_cont: entry[:path] }), class: "fm-icon-btn", title: "Stats"
end
# Picker mode: Select button
if picker_mode
abs_path = File.join(WarpEngine.config.file_container_path, entry[:path])
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;"
end
# Hidden rename form
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: "path", value: entry[:path]
@@ -178,7 +165,6 @@ ActiveAdmin.register_page "Files" do
input type: "hidden", name: "dir", value: current_dir
end
# Hidden delete form
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: "_method", value: "delete"
@@ -197,7 +183,6 @@ ActiveAdmin.register_page "Files" do
end
end
# JSON API for AJAX file picker
page_action :list, method: :get do
service = WarpEngine::FileManagerService.new
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"
# 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 = -> {
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
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
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
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("Branch") { |p| p["branch"] }
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") : "-" }
end
else
@@ -1,35 +1,16 @@
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
extend ActiveSupport::Concern
private
# The ApplicationToken behind the request, or nil.
def current_access_token
return @current_access_token if defined?(@current_access_token)
@current_access_token = resolve_access_token
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
return @current_subject if defined?(@current_subject)
@@ -51,9 +32,6 @@ module WarpEngine
record
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
resolver = WarpEngine.config.subject_resolver
return nil if resolver.nil?
@@ -1,7 +1,5 @@
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
extend ActiveSupport::Concern
@@ -9,8 +7,6 @@ module WarpEngine
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:)
token = request.headers["X-Update-Secret"].presence
return false if token.blank?
@@ -23,7 +19,7 @@ module WarpEngine
def env_secret_authorized?(token)
expected = WarpEngine.config.update_secret
# With no secret configured the endpoint stays closed.
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
end
@@ -41,9 +37,6 @@ module WarpEngine
true
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)
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
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)
token = current_application_token
return if token.nil? || token.unrestricted?
@@ -1,9 +1,5 @@
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
before_action :ensure_identity_configured
@@ -34,9 +30,6 @@ module WarpEngine
def token
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[:token] = plain if plain.present?
render json: body
@@ -50,8 +43,6 @@ module WarpEngine
@service ||= WarpEngine::DeviceGrantService.new
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
return if WarpEngine.identity_configured?
@@ -1,10 +1,5 @@
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
resource_description do
short "Client tokens"
@@ -1,11 +1,5 @@
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
resource_description do
short "Service descriptor"
+1 -12
View File
@@ -5,14 +5,8 @@ module WarpEngine
formats [ "json" ]
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
# Every read-only endpoint may be called with a bearer token; none of them requires
# one. See WarpEngine::SubjectAuthentication.
include WarpEngine::SubjectAuthentication
rescue_from StandardError do |e|
@@ -39,12 +33,7 @@ module WarpEngine
private
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
end
@@ -1,8 +1,6 @@
module WarpEngine
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
resource_description do
short "Woodpecker CI pipeline configs"
@@ -58,9 +56,6 @@ module WarpEngine
)
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
configs = params[:configuration].presence || params[:configs].presence || []
configs.each do |config|
@@ -9,8 +9,6 @@ module WarpEngine
short "Build artifact upload"
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/
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
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
# 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
belongs_to :owner, polymorphic: true
@@ -33,7 +30,6 @@ module WarpEngine
Digest::SHA256.hexdigest(token)
end
# The live (not deleted, not expired) token carrying the required scope, else nil.
def self.authenticate(token, required_scope: nil)
return nil if token.blank?
@@ -48,7 +44,6 @@ module WarpEngine
expires_at.present? && expires_at <= Time.current
end
# Revocation = soft delete, the audit trail stays.
def revoke!
update_column(:deleted_at, Time.current)
end
@@ -57,7 +52,6 @@ module WarpEngine
update_column(:last_used_at, Time.current)
end
# Admin form: comma separated scope list
def scopes_string
Array(scopes).join(", ")
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]
end
# Ransack cannot filter on the polymorphic owner association.
def self.ransackable_associations(auth_object = nil)
[]
end
@@ -81,10 +74,6 @@ module WarpEngine
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
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
[ WarpEngine.config.application_token_owner_class,
WarpEngine.config.access_token_owner_class ].compact_blank
+2 -20
View File
@@ -1,19 +1,8 @@
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
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_LENGTH = 8
@@ -33,7 +22,6 @@ module WarpEngine
pending.find_by(user_code: normalize_user_code(code))
end
# Typed by a person, so it arrives with whatever case and separators they used.
def self.normalize_user_code(code)
code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
end
@@ -42,8 +30,6 @@ module WarpEngine
def approved? = approved_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
return :denied if denied?
return :approved if approved?
@@ -52,13 +38,10 @@ module WarpEngine
:pending
end
# Grouped for reading aloud and for typing: WARP-K7M2.
def formatted_user_code
user_code.to_s.scan(/.{1,4}/).join("-")
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!
where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil)
end
@@ -79,8 +62,7 @@ module WarpEngine
end
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
candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join
return candidate unless exists?(user_code: candidate)
-19
View File
@@ -2,29 +2,14 @@ module WarpEngine
class Pipeline < ApplicationRecord
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
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
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?
validates :woodpecker_repo_id, presence: true, uniqueness: true
@@ -57,10 +42,6 @@ module WarpEngine
@software_taken_from = others.map(&:full_name)
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(
"[WarpEngine::Pipeline] #{full_name} took software #{software_id} from " \
"#{@software_taken_from.join(', ')}"
-2
View File
@@ -2,8 +2,6 @@ module WarpEngine
class Software < ApplicationRecord
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
has_many :software_images, foreign_key: :software_id, dependent: :destroy
@@ -4,7 +4,6 @@ module WarpEngine
FILE_PATH_TO = "/file/"
# A lemezen tárolt asset-útvonalak publikus /file/ URL-lé írása configból.
def self.file_path_from
"#{WarpEngine.config.file_container_path.chomp("/")}/"
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(: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 }
# 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 }
end
end
@@ -14,7 +14,7 @@ module WarpEngine
field(:license) { |sw| sw.license.to_s }
field :platform
field :status
# Public owner id — for the /api/software?owner_id= filter.
field(:ownerId) { |sw| sw.owner_id }
field(:highlighted) { |sw| sw.highlighted ? true : false }
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
@@ -1,14 +1,7 @@
module WarpEngine
module Platforms
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
extend ActiveSupport::Concern
@@ -1,13 +1,10 @@
require "erb"
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
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:)
platform = platform.to_s
return nil unless platform.match?(PLATFORM_FORMAT)
@@ -4,12 +4,7 @@ require "net/http"
require "digest"
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
CAVAGE_PARAM = /(\w+)="([^"]*)"/
@@ -17,7 +12,7 @@ module WarpEngine
@key_mutex = Mutex.new
class << self
# The downloaded key is cached process-wide (per URL).
def fetch_public_key(url)
@key_mutex.synchronize do
@key_cache[url] ||= Net::HTTP.get(URI.parse(url))
@@ -64,8 +59,6 @@ module WarpEngine
nil
end
# --- RFC 9421 ---
def rfc9421_valid?(key)
input = @request.headers["Signature-Input"].to_s
match = input.match(/\A\s*([\w.-]+)=(\(.*)\z/m)
@@ -102,8 +95,6 @@ module WarpEngine
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)
return true unless components.include?("content-digest")
@@ -114,8 +105,6 @@ module WarpEngine
ActiveSupport::SecurityUtils.secure_compare(digest, expected)
end
# --- draft-cavage fallback ---
def cavage_valid?(key)
params = cavage_params
return false if params.nil? || params["signature"].blank?
@@ -126,7 +115,6 @@ module WarpEngine
key.verify(nil, Base64.decode64(params["signature"]), signing_string)
end
# Parameters of the Signature header (or the "Authorization: Signature ..." form).
def cavage_params
header = @request.headers["Signature"].presence
if header.nil?
@@ -1,25 +1,15 @@
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 NotConfigured < 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:)
ensure_configured!
WarpEngine::DeviceGrant.create!(client_name: client_name.presence&.truncate(128))
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:)
ensure_configured!
@@ -28,16 +18,11 @@ module WarpEngine
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
grant.update_columns(issued_token: nil) if plain.present?
[ :approved, plain ]
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:)
ensure_configured!
@@ -73,8 +58,6 @@ module WarpEngine
grant
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:)
return false if token.nil?
@@ -82,9 +65,6 @@ module WarpEngine
true
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)
configured = WarpEngine.config.identity_verification_url.presence || "/devices"
return configured if configured.start_with?("http://", "https://")
+1 -17
View File
@@ -1,25 +1,16 @@
module WarpEngine
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
def self.container_base
WarpEngine.config.file_container_path
end
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
def self.base_path
Pathname.new(container_base).realpath
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)
relative = path.to_s
return nil unless storage.file?(relative)
@@ -34,8 +25,6 @@ module WarpEngine
expires_in: grant.expires_in)
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)
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer,
subject: subject, request: request)
@@ -50,9 +39,6 @@ module WarpEngine
WarpEngine.storage
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)
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
raise Denied if grant.nil?
@@ -80,8 +66,6 @@ module WarpEngine
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",
path: relative, asset: asset, release: asset&.release,
software: asset&.release&.software, subject: subject, download: download, ip: ip)
@@ -1,6 +1,6 @@
module WarpEngine
class FileManagerService
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
def base_path
@base_path ||= Pathname.new(WarpEngine.config.file_container_path)
end
+2 -19
View File
@@ -1,22 +1,10 @@
module WarpEngine
class FileService
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
def self.base_path
Pathname.new(WarpEngine.config.file_container_path).realpath
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)
relative = input.path.to_s
@@ -36,13 +24,8 @@ module WarpEngine
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)
# 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?
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)
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)
runs = @client.list_pipelines(pipeline.woodpecker_repo_id, page: page)
refresh_last_pipeline(pipeline, runs.first) if page == 1 && runs.is_a?(Array)
@@ -22,7 +20,6 @@ module WarpEngine
private
# Woodpecker returns unix epoch seconds in "created".
def refresh_last_pipeline(pipeline, run)
return unless run && pipeline.persisted?
+1 -6
View File
@@ -1,8 +1,6 @@
module WarpEngine
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
def publish(input)
@@ -13,9 +11,6 @@ module WarpEngine
release = "WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize
.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(
NOTIFICATION,
platform: input.platform,
@@ -5,7 +5,7 @@ module WarpEngine
def build_response(software, releases, download_counts, subject: nil)
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
web_playable = latest if latest&.release_assets&.any? { |a| a.kind == "html" }
total_downloads = releases.sum { |r| download_counts.fetch(r.id, 0) }
@@ -19,21 +19,14 @@ module WarpEngine
)
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)
WarpEngine.access_policy.access_for(software: software, subject: subject)
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}")
WarpEngine::Access.new(gated: true, entitled: false)
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)
WarpEngine.access_policy.visible_software_scope(subject: subject)
rescue StandardError => e
@@ -41,7 +34,6 @@ module WarpEngine
WarpEngine::Software.none
end
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
def download_counts_for(release_ids)
return {} if release_ids.empty?
WarpEngine::Download.where(release_id: release_ids).group(:release_id).count
@@ -2,9 +2,6 @@ module WarpEngine
class SoftwareService
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)
softwares = visible_scope(subject)
.includes(releases: [ :release_assets ])
@@ -21,8 +21,6 @@ module WarpEngine
@token = token || WarpEngine.config.woodpecker_api_token
end
# --- Repos ---
def list_repos
get("/api/repos")
end
@@ -39,8 +37,6 @@ module WarpEngine
delete("/api/repos/#{repo_id}")
end
# --- Secrets ---
def list_secrets(repo_id)
get("/api/repos/#{repo_id}/secrets")
end
@@ -59,8 +55,6 @@ module WarpEngine
delete("/api/repos/#{repo_id}/secrets/#{secret_name}")
end
# --- Pipelines ---
def list_pipelines(repo_id, page: 1, per_page: 25)
get("/api/repos/#{repo_id}/pipelines",
params: { page: page, perPage: per_page })
@@ -139,8 +133,7 @@ module WarpEngine
begin
JSON.parse(response.body)
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(
"Expected JSON from #{uri.path} but got: #{response.body.truncate(80)}",
status: response.code.to_i, body: response.body