warp_engine 0.2.0: pluggable storage adapter and publish notifications

Two seams the hosts needed, both backward compatible.

Storage: artifacts are served through WarpEngine::Storage.adapter instead of
raw filesystem calls. The default :local adapter keeps the previous behaviour
byte for byte, including the path traversal guard. A host can now set
config.storage_adapter to any object answering file?/directory?/locate and
serve builds from an object store - FileService and /api/download both honour
a Location.redirect, so a signing adapter turns them into redirects.
DownloadService#create still returns an absolute path (nil when missing) for
existing callers; #locate is the new entry point that can also return a
redirect. Ingestion (upload, extraction, file manager) stays local for now.

Publish: PublishService emits ActiveSupport::Notifications
("warp_engine.publish") with platform/name/version/software/release, so hosts
can react to a new build without hanging callbacks on the models.
WarpEngine.instruments_publish? lets a host feature-detect and keep its
fallback for older engine versions.
This commit is contained in:
2026-08-10 11:16:17 +02:00
parent 2a94fc785f
commit 03a51a5b30
13 changed files with 442 additions and 33 deletions
+1
View File
@@ -2,3 +2,4 @@ log/
spec/dummy/log/ spec/dummy/log/
spec/dummy/tmp/ spec/dummy/tmp/
Gemfile.lock Gemfile.lock
.bundle/
+62
View File
@@ -18,6 +18,12 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
a shared secret or by per-owner database tokens with expiry and scopes a shared secret or by per-owner database tokens with expiry and scopes
(`ApplicationToken`, managed in the admin). (`ApplicationToken`, managed in the admin).
- **Pluggable storage**: artifacts are served through a storage adapter
(`:local` by default); a host can serve them from an object store without
patching the engine.
- **Publish events**: every published release emits
`ActiveSupport::Notifications` (`warp_engine.publish`), so hosts can react
to new builds without model callbacks.
- **Public JSON API**: catalog listing, highlighted title, per-platform build - **Public JSON API**: catalog listing, highlighted title, per-platform build
matrix, image serving, download tracking, and a static file server for matrix, image serving, download tracking, and a static file server for
web-playable builds. web-playable builds.
@@ -325,6 +331,62 @@ server's repos is an admin-only endpoint (anything less yields
Woodpecker server, then log out and back in: the admin flag is written to Woodpecker server, then log out and back in: the admin flag is written to
the user record at login, a server restart alone is not enough. the user record at login, a server restart alone is not enough.
## Storage
Build artifacts are served through a storage adapter. The default is the
local filesystem under `file_container_path` — byte for byte the behaviour
the engine always had:
```ruby
c.storage_adapter = :local # default
```
A host that keeps its artifacts elsewhere (an object store behind a CDN, for
example) can plug in its own object instead of patching the engine. The
contract is three methods:
```ruby
class MyObjectStore
def file?(relative_path) = ... # true/false
def directory?(relative_path) = ... # true/false
# Return a WarpEngine::Storage::Location:
# Location.file(absolute_path) — the engine will send_file it
# Location.redirect(url) — the engine will redirect (signed URL)
def locate(relative_path, filename: nil, expires_in: nil) = ...
end
c.storage_adapter = MyObjectStore.new
```
`GET /api/download` and `GET /file/*` both go through the adapter, so a
signing adapter turns them into redirects without any further change.
`WarpEngine::DownloadService#create` still returns an absolute path (and
`nil` when there is none), so existing callers keep working;
`#locate` is the new entry point that can also hand back a redirect.
**Serving only.** Ingestion — `POST /build/upload`, archive extraction and
the admin file manager — still writes to the local disk. A remote adapter
needs its own upload path today.
## Publish events
Publishing a release emits an `ActiveSupport::Notifications` event, so a host
can react to a new build without hanging a callback on the models:
```ruby
ActiveSupport::Notifications.subscribe("warp_engine.publish") do |*, payload|
payload[:software] # WarpEngine::Software
payload[:release] # WarpEngine::Release
payload[:platform] # "godot"
payload[:name] # "mygame"
payload[:version] # "1.2.0"
end
```
Hosts that must support older engine versions can feature-detect with
`WarpEngine.respond_to?(:instruments_publish?) && WarpEngine.instruments_publish?`.
## Public API ## Public API
| Endpoint | Purpose | | Endpoint | Purpose |
@@ -8,23 +8,26 @@ module WarpEngine
api :GET, "/api/download", "Download a file by path" api :GET, "/api/download", "Download a file by path"
param :path, String, required: true, desc: "File path to download" param :path, String, required: true, desc: "File path to download"
returns code: 200, desc: "File binary data" returns code: 200, desc: "File binary data"
returns code: 302, desc: "Redirect to the storage location (non-local storage adapter)"
error code: 400, desc: "Path is blank" error code: 400, desc: "Path is blank"
error code: 404, desc: "File not found" error code: 404, desc: "File not found"
def show def show
path = params[:path] path = params[:path]
return render(json: { error: "Path is required" }, status: :bad_request) if path.blank? return render(json: { error: "Path is required" }, status: :bad_request) if path.blank?
full_path = WarpEngine::DownloadService.new.create( location = WarpEngine::DownloadService.new.locate(
path: path, path: path,
ip: request.remote_ip, ip: request.remote_ip,
user_agent: request.user_agent, user_agent: request.user_agent,
referer: request.referer referer: request.referer
) )
if full_path if location.nil?
send_file full_path, disposition: "attachment", type: resolve_mime(full_path)
else
render json: { error: "Not found" }, status: :not_found render json: { error: "Not found" }, status: :not_found
elsif location.redirect?
redirect_to location.url, allow_other_host: true
else
send_file location.path, disposition: "attachment", type: resolve_mime(location.path)
end end
end end
end end
+30 -11
View File
@@ -9,26 +9,45 @@ module WarpEngine
Pathname.new(container_base).realpath Pathname.new(container_base).realpath
end end
def create(path:, ip:, user_agent:, referer:) # A letöltés helyét adja vissza (fájl vagy aláírt URL) és naplózza a
sanitized = path.to_s # letöltést. A hely feloldása a storage adapteren megy — alapból :local,
base_path = self.class.base_path # tehát változatlanul lemezről.
full_path = base_path.join(sanitized).realpath def locate(path:, ip:, user_agent:, referer:)
return nil unless full_path.to_s.start_with?(base_path.to_s) relative = path.to_s
return nil unless File.file?(full_path) return nil unless storage.file?(relative)
escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_") log_download(relative, ip: ip, user_agent: user_agent, referer: referer)
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, sanitized)) ||
storage.locate(relative, filename: File.basename(relative))
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:)
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer)
return nil if location.nil?
location.file? ? location.path : nil
end
private
def storage
WarpEngine.storage
end
def log_download(relative, ip:, user_agent:, referer:)
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
WarpEngine::Download.create!( WarpEngine::Download.create!(
file_path: sanitized, file_path: relative,
release: asset&.release, release: asset&.release,
ip_address: ip, ip_address: ip,
user_agent: user_agent&.truncate(500), user_agent: user_agent&.truncate(500),
referer: referer&.truncate(500) referer: referer&.truncate(500)
) )
full_path
end end
end end
end end
+16 -15
View File
@@ -5,31 +5,32 @@ module WarpEngine
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.
def show(input) def show(input)
full_path = base_path.join(input.path.to_s) relative = input.path.to_s
return FileResultDto.not_found unless safe_path?(full_path)
if File.directory?(full_path) if storage.directory?(relative)
index_path = full_path.join("index.html") index = File.join(relative.chomp("/"), "index.html")
return FileResultDto.not_found unless File.file?(index_path) return FileResultDto.not_found unless storage.file?(index)
return FileResultDto.redirect("/file/#{input.path.to_s.chomp("/")}/index.html")
return FileResultDto.redirect("/file/#{index}")
end end
if File.file?(full_path) return FileResultDto.not_found unless storage.file?(relative)
FileResultDto.file(full_path)
else to_result(storage.locate(relative, filename: File.basename(relative)))
FileResultDto.not_found
end
end end
private private
def base_path def storage
@base_path ||= self.class.base_path WarpEngine.storage
end end
def safe_path?(path) def to_result(location)
File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(base_path.to_s) location.redirect? ? FileResultDto.redirect(location.url) : FileResultDto.file(location.path)
end end
end end
end end
+21 -1
View File
@@ -1,11 +1,31 @@
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
def publish(input) def publish(input)
unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform) unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
raise ArgumentError, "Unsupported platform: #{input.platform}" raise ArgumentError, "Unsupported platform: #{input.platform}"
end end
"WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize.new.update(input.name, input.version) release = "WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize
.new.update(input.name, input.version)
# A publikálás az egyetlen pont, ahol új build kerül a katalógusba —
# a host innen tud rá reagálni (értesítés, feed, csatorna-előléptetés)
# anélkül, hogy modell-callbackre kellene kapaszkodnia.
ActiveSupport::Notifications.instrument(
NOTIFICATION,
platform: input.platform,
name: input.name,
version: input.version,
software: release.respond_to?(:software) ? release.software : nil,
release: release
)
release
end end
end end
end end
+13
View File
@@ -3,6 +3,7 @@ require "apipie-rails"
require "warp_engine/version" require "warp_engine/version"
require "warp_engine/configuration" require "warp_engine/configuration"
require "warp_engine/storage"
module WarpEngine module WarpEngine
# A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace # A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace
@@ -23,6 +24,18 @@ module WarpEngine
def self.woodpecker_configured? def self.woodpecker_configured?
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?
true
end
def self.storage
Storage.adapter
end
end end
require "warp_engine/engine" require "warp_engine/engine"
+9 -1
View File
@@ -9,6 +9,12 @@ module WarpEngine
# :database — only WarpEngine::ApplicationToken is accepted, the shared secret is not # :database — only WarpEngine::ApplicationToken is accepted, the shared secret is not
# application_token_owner_class: class name of the mandatory token owner # application_token_owner_class: class name of the mandatory token owner
# (e.g. "AdminUser"); nil makes :database mode reject every request. # (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). # 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 # enforce_software_ownership: when true, a DB token may only upload/publish
# its own owner's softwares (unrestricted tokens are exempt). # its own owner's softwares (unrestricted tokens are exempt).
@@ -37,7 +43,8 @@ module WarpEngine
:woodpecker_url, :woodpecker_url,
:woodpecker_api_token, :woodpecker_api_token,
:woodpecker_repo_owner, :woodpecker_repo_owner,
:image_owners :image_owners,
:storage_adapter
def initialize def initialize
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares") @file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
@@ -55,6 +62,7 @@ module WarpEngine
@woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] @woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"]
@woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] @woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"]
@image_owners = [] @image_owners = []
@storage_adapter = :local
end end
end end
end end
+92
View File
@@ -0,0 +1,92 @@
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
Location = Struct.new(:kind, :path, :url, keyword_init: true) do
def file? = kind == :file
def redirect? = kind == :redirect
def self.file(path) = new(kind: :file, path: path)
def self.redirect(url) = new(kind: :redirect, url: url)
end
# The local filesystem, rooted at config.file_container_path.
class LocalAdapter
def base_path
Pathname.new(WarpEngine.config.file_container_path)
end
def absolute_path(relative_path)
base_path.join(relative_path.to_s)
end
def file?(relative_path)
path = absolute_path(relative_path)
File.file?(path) && inside_base?(path)
end
def directory?(relative_path)
path = absolute_path(relative_path)
File.directory?(path) && inside_base?(path)
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)
Location.file(absolute_path(relative_path).to_s)
end
private
# Path traversal guard: the resolved path must stay under the container.
def inside_base?(path)
root = base_path.realpath.to_s
Pathname.new(path).realpath.to_s.start_with?(root)
rescue Errno::ENOENT
false
end
end
class << self
def adapter
configured = WarpEngine.config.storage_adapter
case configured
when nil, :local, "local" then local_adapter
else configured
end
end
def local? = adapter.is_a?(LocalAdapter)
def local_adapter
@local_adapter ||= LocalAdapter.new
end
# Tests and hosts that swap the configuration at runtime.
def reset!
@local_adapter = nil
end
end
end
end
+1 -1
View File
@@ -1,3 +1,3 @@
module WarpEngine module WarpEngine
VERSION = "0.1.0" VERSION = "0.2.0"
end end
+59
View File
@@ -0,0 +1,59 @@
require "rails_helper"
require "tmpdir"
RSpec.describe WarpEngine::Storage do
let(:tmpdir) { Dir.mktmpdir }
before do
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
WarpEngine::Storage.reset!
end
after { FileUtils.rm_rf(tmpdir) }
describe ".adapter" do
it "defaults to the local filesystem" do
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
expect(described_class.adapter).to be_a(described_class::LocalAdapter)
expect(described_class).to be_local
end
it "returns whatever object the host configured" do
custom = Object.new
allow(WarpEngine.config).to receive(:storage_adapter).and_return(custom)
expect(described_class.adapter).to eq(custom)
expect(described_class).not_to be_local
end
end
describe described_class::LocalAdapter do
subject(:adapter) { described_class.new }
before { File.write(File.join(tmpdir, "game-1.0.zip"), "zip") }
it "sees files and directories under the container" do
FileUtils.mkdir_p(File.join(tmpdir, "game-1.0"))
expect(adapter.file?("game-1.0.zip")).to be(true)
expect(adapter.directory?("game-1.0")).to be(true)
expect(adapter.file?("missing.zip")).to be(false)
end
# The path traversal guard has to survive the move behind the adapter.
it "refuses paths escaping the container" do
outside = File.join(Dir.mktmpdir, "secret.txt")
File.write(outside, "nope")
expect(adapter.file?("../#{File.basename(File.dirname(outside))}/secret.txt")).to be(false)
end
it "locates a file as an absolute path" do
location = adapter.locate("game-1.0.zip")
expect(location).to be_file
expect(location.path).to eq(File.join(tmpdir, "game-1.0.zip"))
end
end
end
+33
View File
@@ -55,5 +55,38 @@ RSpec.describe WarpEngine::PublishService do
expect(mock_service).to have_received(:update).with("game", "1.0") expect(mock_service).to have_received(:update).with("game", "1.0")
end end
describe "instrumentation" do
let(:input) { WarpEngine::PublishInputDto.new(platform: "tic80", name: "game", version: "1.0") }
let(:release) { WarpEngine::Release.new(version: "1.0") }
before do
mock_service = instance_double(WarpEngine::Platforms::Tic80::Service, update: release)
allow(WarpEngine::Platforms::Tic80::Service).to receive(:new).and_return(mock_service)
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
payloads = []
ActiveSupport::Notifications.subscribe(described_class::NOTIFICATION) do |*, payload|
payloads << payload
end
described_class.new.publish(input)
expect(payloads.size).to eq(1)
expect(payloads.first).to include(platform: "tic80", name: "game", version: "1.0", release: release)
ensure
ActiveSupport::Notifications.unsubscribe(described_class::NOTIFICATION)
end
it "returns the release" do
expect(described_class.new.publish(input)).to eq(release)
end
it "advertises the feature so older hosts can feature-detect" do
expect(WarpEngine.instruments_publish?).to be(true)
end
end
end end
end end
+98
View File
@@ -0,0 +1,98 @@
require "rails_helper"
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
let(:tmpdir) { Dir.mktmpdir }
# Minimal adapter answering the documented contract.
let(:signing_adapter) do
Class.new do
def file?(_relative) = true
def directory?(_relative) = false
def locate(relative, filename: nil, expires_in: nil)
WarpEngine::Storage::Location.redirect("https://cdn.example/#{relative}?signed=1")
end
end.new
end
before do
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
WarpEngine::Storage.reset!
File.write(File.join(tmpdir, "game-1.0.zip"), "zip")
end
after { FileUtils.rm_rf(tmpdir) }
describe WarpEngine::FileService do
it "serves a local file (default adapter)" do
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
result = described_class.new.show(WarpEngine::FileShowInputDto.new(path: "game-1.0.zip"))
expect(result.type).to eq(:file)
expect(result.path).to eq(File.join(tmpdir, "game-1.0.zip"))
end
it "redirects a directory to its index.html" do
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
FileUtils.mkdir_p(File.join(tmpdir, "game-1.0"))
File.write(File.join(tmpdir, "game-1.0", "index.html"), "<h1>hi</h1>")
result = described_class.new.show(WarpEngine::FileShowInputDto.new(path: "game-1.0"))
expect(result.type).to eq(:redirect)
expect(result.url).to eq("/file/game-1.0/index.html")
end
it "hands back the adapter's redirect when storage is remote" do
allow(WarpEngine.config).to receive(:storage_adapter).and_return(signing_adapter)
result = described_class.new.show(WarpEngine::FileShowInputDto.new(path: "game-1.0.zip"))
expect(result.type).to eq(:redirect)
expect(result.url).to eq("https://cdn.example/game-1.0.zip?signed=1")
end
it "reports a missing file" do
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
result = described_class.new.show(WarpEngine::FileShowInputDto.new(path: "nope.zip"))
expect(result.type).to eq(:not_found)
end
end
describe WarpEngine::DownloadService do
let(:args) { { ip: "127.0.0.1", user_agent: "rspec", referer: nil } }
it "keeps returning an absolute path from #create (backward compatible)" do
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
path = described_class.new.create(path: "game-1.0.zip", **args)
expect(path).to eq(File.join(tmpdir, "game-1.0.zip"))
expect(WarpEngine::Download.count).to eq(1)
end
it "returns nil from #create for a missing file" do
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
expect(described_class.new.create(path: "nope.zip", **args)).to be_nil
expect(WarpEngine::Download.count).to eq(0)
end
it "returns a location from #locate and logs the download" do
allow(WarpEngine.config).to receive(:storage_adapter).and_return(signing_adapter)
location = described_class.new.locate(path: "game-1.0.zip", **args)
expect(location).to be_redirect
expect(location.url).to include("signed=1")
expect(WarpEngine::Download.count).to eq(1)
end
end
end