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:
@@ -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,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?
|
||||||
|
|||||||
@@ -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 || ""
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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,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 },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user