WarpEngine 0.5.0: a catalog that can say a title is not yours
A desktop client reading /api/software had no way to learn that a title costs money. There was nothing in the response to say so, no way to sign in, and no way to be told "you do not own this" — so a store with paid titles could only hand the client a 403 at download time and let it guess why. The fix belongs here rather than in the client. A client serves more than one store, so anything it knows about a particular one has to arrive from that store's own API; a rule compiled into the client is a rule that breaks every other catalog it reads. Three seams, each following the storage adapter's shape — documented contract, default that is byte for byte the old behaviour, one config key to replace it: - **access policy** — visible_software_scope / access_for / authorize_download. Every catalog entry now carries an `access` block (gated, entitled, price, purchaseUrl, webUrl) and both /api/download and /file/* ask before serving. The vocabulary is deliberately generic: a word from one host's domain would make every client that reads it specific to that host. - **client sign-in** — the device authorization grant (RFC 8628), over the host's own user model. The approval page stays the host's, because approving needs a session and HTML. Tokens are ApplicationTokens with a `catalog` scope, so publishing and reading stay separable. - **service descriptor** — GET /api/service says what this deployment is and whether it has a sign-in at all, which is how a client stops guessing. With no policy and no subject class configured — every deployment today — the API is unchanged: /api/auth/* answers 404, /api/service reports auth: null, and the 187 pre-existing examples pass untouched. A policy that raises is treated as a refusal, not permission. An artifact served because the gatekeeper crashed is the one failure mode this must not have, so a broken policy empties the catalog and denies the download. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,12 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
scope :all, default: true
|
||||
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
|
||||
# Two kinds of token share this table: one publishes software, the other reads the
|
||||
# catalog from somebody's desktop client. They are told apart by scope, and an admin
|
||||
# looking for one is rarely looking for the other.
|
||||
CATALOG_SCOPE_SQL = %(JSON_CONTAINS(COALESCE(scopes, '[]'), '"catalog"')).freeze
|
||||
scope("Publishing") { |scope| scope.where("NOT #{CATALOG_SCOPE_SQL}") }
|
||||
scope("Clients") { |scope| scope.where(CATALOG_SCOPE_SQL) }
|
||||
|
||||
index do
|
||||
id_column
|
||||
@@ -45,7 +51,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
end
|
||||
f.input :name
|
||||
f.input :scopes_string, label: "Scopes (comma separated)",
|
||||
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload.)
|
||||
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload, the "catalog" scope for a client reading the API. Client tokens are normally issued by device sign-in rather than created here.)
|
||||
f.input :unrestricted, hint: "Internal token: exempt from owner isolation (enforce_software_ownership)."
|
||||
f.input :expires_at, hint: "Leave empty for a token that never expires."
|
||||
end
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do
|
||||
# Read-only on purpose. A grant is created by a client and answered by a person on the
|
||||
# host's own page; an admin creating one by hand would be issuing somebody else a
|
||||
# credential, which is not a thing this page should make easy.
|
||||
actions :index, :show
|
||||
|
||||
menu parent: "🌀 WarpEngine", priority: 10, label: "📱 Device Sign-ins",
|
||||
if: proc { WarpEngine.identity_configured? }
|
||||
|
||||
config.sort_order = "created_at_desc"
|
||||
config.batch_actions = false
|
||||
|
||||
scope :all, default: true
|
||||
scope("Pending") { |scope| scope.where(approved_at: nil, denied_at: nil).where(expires_at: Time.current..) }
|
||||
scope("Approved") { |scope| scope.where.not(approved_at: nil) }
|
||||
scope("Denied") { |scope| scope.where.not(denied_at: nil) }
|
||||
|
||||
index do
|
||||
id_column
|
||||
column("Code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
|
||||
column("Device") { |g| g.client_name }
|
||||
column("State") { |g| status_tag g.state.to_s }
|
||||
column("Who") do |g|
|
||||
next "—" if g.subject_id.blank?
|
||||
|
||||
subject = g.subject
|
||||
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
|
||||
end
|
||||
column :expires_at
|
||||
column :created_at
|
||||
end
|
||||
|
||||
filter :client_name_cont, label: "Device"
|
||||
filter :created_at
|
||||
filter :expires_at
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row("User code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
|
||||
row("Device") { |g| g.client_name }
|
||||
row("State") { |g| status_tag g.state.to_s }
|
||||
row("Who") do |g|
|
||||
next "—" if g.subject_id.blank?
|
||||
|
||||
subject = g.subject
|
||||
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
|
||||
end
|
||||
# The device code itself is never shown: it is the client's live credential for as
|
||||
# long as the grant is pending, and this page is not where it should leak from.
|
||||
row("Token") do |g|
|
||||
token = g.application_token
|
||||
next "—" if token.nil?
|
||||
|
||||
link_to "#{token.token_prefix}… (#{token.name})", admin_application_token_path(token)
|
||||
end
|
||||
row :approved_at
|
||||
row :denied_at
|
||||
row :expires_at
|
||||
row :created_at
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
module WarpEngine
|
||||
# Who the caller is, on the read-only side of the API.
|
||||
#
|
||||
# Distinct from UpdateAuthentication, which guards publishing: that one asks "may this
|
||||
# pipeline write to the catalog", this one asks "whose library am I looking at". The
|
||||
# answer is allowed to be nobody — an anonymous caller is a normal, supported caller,
|
||||
# and a catalog with no policy configured never needs one.
|
||||
#
|
||||
# The credential is a bearer token, because that is what a client can carry: it has no
|
||||
# cookie jar and no browser session.
|
||||
module SubjectAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# The ApplicationToken behind the request, or nil.
|
||||
def current_access_token
|
||||
return @current_access_token if defined?(@current_access_token)
|
||||
|
||||
@current_access_token = resolve_access_token
|
||||
end
|
||||
|
||||
# Whoever that token belongs to — the host's own object. nil when the request is
|
||||
# anonymous, when the token is unknown, or when no subject class is configured.
|
||||
def current_subject
|
||||
current_access_token&.owner
|
||||
end
|
||||
|
||||
def resolve_access_token
|
||||
return nil unless WarpEngine.identity_configured?
|
||||
|
||||
token = bearer_token
|
||||
return nil if token.blank?
|
||||
|
||||
record = WarpEngine::ApplicationToken.authenticate(
|
||||
token, required_scope: WarpEngine::ApplicationToken::CATALOG_SCOPE
|
||||
)
|
||||
return nil if record.nil?
|
||||
|
||||
record.touch_last_used!
|
||||
record
|
||||
end
|
||||
|
||||
def bearer_token
|
||||
header = request.headers["Authorization"].to_s
|
||||
return nil unless header.start_with?("Bearer ")
|
||||
|
||||
header.delete_prefix("Bearer ").strip.presence
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
module WarpEngine
|
||||
# The device authorization grant, client side (RFC 8628).
|
||||
#
|
||||
# Both actions are unauthenticated, and have to be: the whole point of the flow is
|
||||
# that the caller has no credential yet. What protects it is that a device code is
|
||||
# useless until a signed-in person approves it on the host's own page.
|
||||
class Api::Auth::DevicesController < ApiController
|
||||
before_action :ensure_identity_configured
|
||||
|
||||
resource_description do
|
||||
short "Device sign-in"
|
||||
end
|
||||
|
||||
api :POST, "/api/auth/device", "Start a device sign-in and get a code pair"
|
||||
param :client_name, String, required: false, desc: "What to call this device in the person's account"
|
||||
returns code: 200, desc: "The code pair and where to take it"
|
||||
error code: 404, desc: "This deployment has no client sign-in"
|
||||
def create
|
||||
grant = service.request(client_name: params[:client_name])
|
||||
|
||||
render json: {
|
||||
deviceCode: grant.device_code,
|
||||
userCode: grant.formatted_user_code,
|
||||
verificationUrl: service.verification_url(base_url: request.base_url),
|
||||
interval: WarpEngine.config.device_code_interval.to_i,
|
||||
expiresIn: (grant.expires_at - Time.current).to_i
|
||||
}
|
||||
end
|
||||
|
||||
api :POST, "/api/auth/device/token", "Poll a device sign-in for its token"
|
||||
param :device_code, String, required: true, desc: "The device code from POST /api/auth/device"
|
||||
returns code: 200, desc: "state is one of pending, approved, denied, expired"
|
||||
error code: 404, desc: "No such device code, or no client sign-in here"
|
||||
def token
|
||||
state, plain = service.poll(device_code: params[:device_code])
|
||||
|
||||
# The token rides on the one poll that finds the grant newly approved; a client
|
||||
# that loses it starts the flow again. Keeping a plain token around to hand out
|
||||
# twice would mean storing it, which is the thing this design avoids.
|
||||
body = { state: state.to_s }
|
||||
body[:token] = plain if plain.present?
|
||||
render json: body
|
||||
rescue WarpEngine::DeviceGrantService::UnknownCode
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def service
|
||||
@service ||= WarpEngine::DeviceGrantService.new
|
||||
end
|
||||
|
||||
# A deployment with no configured subject class has no sign-in at all, and says so
|
||||
# the same way GET /api/service does — by not offering it.
|
||||
def ensure_identity_configured
|
||||
return if WarpEngine.identity_configured?
|
||||
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
module WarpEngine
|
||||
# Signing out: a client throws away its own token.
|
||||
#
|
||||
# Revocation is a soft delete on the ApplicationToken, so the record of which device
|
||||
# signed in and when survives it. The person's own list of devices — where somebody
|
||||
# revokes a token for a laptop they no longer have — is the host's page, because it
|
||||
# needs a session and a browser.
|
||||
class Api::Auth::TokensController < ApiController
|
||||
resource_description do
|
||||
short "Client tokens"
|
||||
end
|
||||
|
||||
api :DELETE, "/api/auth/token", "Revoke the bearer token this request carries"
|
||||
returns code: 204, desc: "Revoked"
|
||||
error code: 401, desc: "No usable bearer token on the request"
|
||||
def destroy
|
||||
token = current_access_token
|
||||
return head(:unauthorized) if token.nil?
|
||||
|
||||
WarpEngine::DeviceGrantService.new.revoke(token: token)
|
||||
head :no_content
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,7 @@ module WarpEngine
|
||||
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: 403, desc: "The access policy refused this caller"
|
||||
error code: 404, desc: "File not found"
|
||||
def show
|
||||
path = params[:path]
|
||||
@@ -19,7 +20,9 @@ module WarpEngine
|
||||
path: path,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent,
|
||||
referer: request.referer
|
||||
referer: request.referer,
|
||||
subject: current_subject,
|
||||
request: request
|
||||
)
|
||||
|
||||
if location.nil?
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
module WarpEngine
|
||||
# What this deployment is and what it can do, in one unauthenticated request.
|
||||
#
|
||||
# This is how a client stops guessing. Before it existed, everything a client knew
|
||||
# about a store was compiled into the client — which endpoints to call, whether
|
||||
# signing in was a thing here, where to send somebody who wanted to buy something.
|
||||
# Every one of those is a property of the *server*, and a client that carries them
|
||||
# can only ever serve the one store it was built for.
|
||||
class Api::ServiceController < ApiController
|
||||
resource_description do
|
||||
short "Service descriptor"
|
||||
end
|
||||
|
||||
api :GET, "/api/service", "What this WarpEngine deployment offers"
|
||||
desc <<~DESC
|
||||
Public on purpose: a client reads this *before* it can have a credential.
|
||||
|
||||
`auth` is null where the host has configured no client identity — the catalog is
|
||||
open, there is nobody to sign in as, and a client should not offer to. Where it is
|
||||
present, `auth.device` describes the device authorization grant a client with no
|
||||
browser of its own uses to sign in.
|
||||
|
||||
`catalog.gated` says whether any title here can require an entitlement. A client
|
||||
can render a store that never gates differently from one that sometimes does,
|
||||
without having to read the whole catalog first to find out.
|
||||
DESC
|
||||
returns code: 200, desc: "The descriptor" do
|
||||
property :engine, String, desc: "Always 'warp_engine'"
|
||||
property :version, String, desc: "Engine version, same value as the WarpEngine-Version header"
|
||||
property :catalog, Hash, desc: "Catalog properties" do
|
||||
property :gated, :boolean, desc: "Whether titles here can require an entitlement"
|
||||
end
|
||||
property :auth, Hash, desc: "How to sign in, or null where there is no sign-in"
|
||||
end
|
||||
def show
|
||||
render json: {
|
||||
engine: "warp_engine",
|
||||
version: WarpEngine::VERSION,
|
||||
catalog: { gated: !WarpEngine::AccessPolicy.open? },
|
||||
auth: auth_descriptor
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auth_descriptor
|
||||
return nil unless WarpEngine.identity_configured?
|
||||
|
||||
service = WarpEngine::DeviceGrantService.new
|
||||
{
|
||||
schemes: [ "bearer" ],
|
||||
device: {
|
||||
authorizeUrl: warp_engine.api_auth_device_url,
|
||||
tokenUrl: warp_engine.api_auth_device_token_url,
|
||||
revokeUrl: warp_engine.api_auth_token_url,
|
||||
verificationUrl: service.verification_url(base_url: request.base_url),
|
||||
interval: WarpEngine.config.device_code_interval.to_i
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -56,7 +56,7 @@ module WarpEngine
|
||||
end
|
||||
end
|
||||
def index
|
||||
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
|
||||
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id], subject: current_subject)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,7 +34,7 @@ module WarpEngine
|
||||
end
|
||||
error code: 404, desc: "No highlighted software found"
|
||||
def index
|
||||
result = WarpEngine::SoftwareHighlightedService.new.index
|
||||
result = WarpEngine::SoftwareHighlightedService.new.index(subject: current_subject)
|
||||
if result
|
||||
render json: result
|
||||
else
|
||||
|
||||
@@ -11,6 +11,10 @@ module WarpEngine
|
||||
# client needs the version most when something came back wrong.
|
||||
before_action :set_version_header
|
||||
|
||||
# Every read-only endpoint may be called with a bearer token; none of them requires
|
||||
# one. See WarpEngine::SubjectAuthentication.
|
||||
include WarpEngine::SubjectAuthentication
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
|
||||
render json: { error: "Internal server error" }, status: :internal_server_error
|
||||
@@ -28,6 +32,10 @@ module WarpEngine
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
rescue_from WarpEngine::DownloadService::Denied do
|
||||
render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_version_header
|
||||
|
||||
@@ -9,9 +9,12 @@ module WarpEngine
|
||||
param :path, String, required: true, desc: "File path"
|
||||
returns code: 200, desc: "File binary data"
|
||||
returns code: 301, desc: "Redirect to file URL"
|
||||
error code: 403, desc: "The access policy refused this caller"
|
||||
error code: 404, desc: "File not found"
|
||||
def show
|
||||
result = WarpEngine::FileService.new.show(WarpEngine::FileShowInputDto.new(path: params[:path]))
|
||||
result = WarpEngine::FileService.new.show(
|
||||
WarpEngine::FileShowInputDto.new(path: params[:path]), subject: current_subject
|
||||
)
|
||||
case result.type
|
||||
when :redirect then redirect_to result.url, status: :moved_permanently
|
||||
when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path)
|
||||
|
||||
@@ -6,6 +6,9 @@ module WarpEngine
|
||||
|
||||
UPDATE_SCOPE = "update".freeze
|
||||
UPLOAD_SCOPE = "upload".freeze
|
||||
# A token held by a *client* rather than a publisher: it reads the catalog and
|
||||
# downloads artifacts, and it never publishes anything.
|
||||
CATALOG_SCOPE = "catalog".freeze
|
||||
|
||||
# The generated token is only available in memory at creation time — the DB
|
||||
# stores nothing but the SHA256 digest and the non-secret prefix.
|
||||
@@ -78,6 +81,15 @@ module WarpEngine
|
||||
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
|
||||
end
|
||||
|
||||
# Publishing tokens and client tokens share this table but not their owners: one
|
||||
# belongs to whoever ships software, the other to whoever buys it. Both classes are
|
||||
# the host's to name, and either is acceptable here — which of the two a given token
|
||||
# may do is decided by its scopes, not by its owner.
|
||||
def self.permitted_owner_types
|
||||
[ WarpEngine.config.application_token_owner_class,
|
||||
WarpEngine.config.access_token_owner_class ].compact_blank
|
||||
end
|
||||
|
||||
def generate_token
|
||||
return if token_digest.present?
|
||||
|
||||
@@ -87,11 +99,11 @@ module WarpEngine
|
||||
end
|
||||
|
||||
def owner_type_matches_configuration
|
||||
expected = WarpEngine.config.application_token_owner_class
|
||||
if expected.blank?
|
||||
permitted = self.class.permitted_owner_types
|
||||
if permitted.empty?
|
||||
errors.add(:base, "application_token_owner_class is not configured")
|
||||
elsif owner_type != expected
|
||||
errors.add(:owner_type, "must be #{expected}")
|
||||
elsif !permitted.include?(owner_type)
|
||||
errors.add(:owner_type, "must be #{permitted.join(' or ')}")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
module WarpEngine
|
||||
# One pending sign-in from a client that has no browser of its own.
|
||||
#
|
||||
# The shape is RFC 8628's device authorization grant, and the reason for it is that a
|
||||
# desktop client cannot host a login form without asking a person to type a password
|
||||
# into a window that is not a browser. So the client asks for a pair of codes, sends
|
||||
# the person to the host's own page with the short one, and polls with the long one
|
||||
# until somebody approves it.
|
||||
#
|
||||
# Short-lived by design: this row exists for the minute or two between "the client
|
||||
# asked" and "the person answered". What survives it is the ApplicationToken.
|
||||
class DeviceGrant < ApplicationRecord
|
||||
self.table_name = "device_grants"
|
||||
|
||||
# No I, O, 0 or 1: this alphabet is read off one screen and typed into another, and
|
||||
# those four are where that goes wrong.
|
||||
USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".freeze
|
||||
USER_CODE_LENGTH = 8
|
||||
|
||||
belongs_to :application_token, class_name: "WarpEngine::ApplicationToken", optional: true
|
||||
belongs_to :subject, polymorphic: true, optional: true
|
||||
|
||||
validates :device_code, presence: true, uniqueness: true
|
||||
validates :user_code, presence: true, uniqueness: true
|
||||
validates :expires_at, presence: true
|
||||
|
||||
scope :pending, -> { where(approved_at: nil, denied_at: nil).where(expires_at: Time.current..) }
|
||||
|
||||
before_validation :generate_codes, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
def self.find_pending_by_user_code(code)
|
||||
pending.find_by(user_code: normalize_user_code(code))
|
||||
end
|
||||
|
||||
# Typed by a person, so it arrives with whatever case and separators they used.
|
||||
def self.normalize_user_code(code)
|
||||
code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
|
||||
end
|
||||
|
||||
def expired? = expires_at <= Time.current
|
||||
def approved? = approved_at.present?
|
||||
def denied? = denied_at.present?
|
||||
|
||||
# What the polling client is told. Order matters: a denied grant is denied even
|
||||
# after it expires, because "somebody said no" is the more useful answer.
|
||||
def state
|
||||
return :denied if denied?
|
||||
return :approved if approved?
|
||||
return :expired if expired?
|
||||
|
||||
:pending
|
||||
end
|
||||
|
||||
# Grouped for reading aloud and for typing: WARP-K7M2.
|
||||
def formatted_user_code
|
||||
user_code.to_s.scan(/.{1,4}/).join("-")
|
||||
end
|
||||
|
||||
# Housekeeping for a host that wants it: an expired grant has nothing left to give,
|
||||
# and its issued_token would be a live secret nobody is waiting for.
|
||||
def self.sweep_expired!
|
||||
where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil)
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[approved_at client_name created_at denied_at expires_at id subject_id subject_type updated_at user_code]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_codes
|
||||
self.device_code = SecureRandom.hex(32) if device_code.blank?
|
||||
self.user_code = self.class.generate_user_code if user_code.blank?
|
||||
end
|
||||
|
||||
def self.generate_user_code
|
||||
# Retried rather than trusted: the alphabet is small enough that a collision is
|
||||
# a real, if rare, event, and a unique index would turn it into a 500.
|
||||
10.times do
|
||||
candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join
|
||||
return candidate unless exists?(user_code: candidate)
|
||||
end
|
||||
raise "could not generate a free device user code"
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at ||= WarpEngine.config.device_code_ttl.to_i.seconds.from_now
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_device_grant, self)
|
||||
end
|
||||
end
|
||||
@@ -5,5 +5,8 @@ module WarpEngine
|
||||
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil }
|
||||
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil }
|
||||
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
|
||||
# Whether this title is gated, what it costs and where to get it. Always present —
|
||||
# see WarpEngine::Access. Under the open policy it is the constant OPEN answer.
|
||||
field(:access) { |_, opts| (opts[:access] || WarpEngine::Access::OPEN).as_json }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
module WarpEngine
|
||||
# The device authorization grant, from both ends.
|
||||
#
|
||||
# The client's end is #request, #poll and #revoke, all reachable over /api/auth/*.
|
||||
# The person's end is #approve and #deny, which the *host* calls from its own page —
|
||||
# approving needs a session and a logged-in human, and the engine has neither.
|
||||
class DeviceGrantService
|
||||
class NotConfigured < StandardError; end
|
||||
class UnknownCode < StandardError; end
|
||||
|
||||
# A client asks for a code pair. Deliberately unauthenticated: there is nobody to
|
||||
# authenticate as yet, which is the whole reason this flow exists.
|
||||
def request(client_name:)
|
||||
ensure_configured!
|
||||
|
||||
WarpEngine::DeviceGrant.create!(client_name: client_name.presence&.truncate(128))
|
||||
end
|
||||
|
||||
# The client polls with the device code. Returns [state, token], where the token is
|
||||
# the plain string and is available exactly once — on the poll that finds the grant
|
||||
# newly approved. A second poll gets :approved with no token, which is the honest
|
||||
# answer: the secret was handed over and is not kept.
|
||||
def poll(device_code:)
|
||||
ensure_configured!
|
||||
|
||||
grant = WarpEngine::DeviceGrant.find_by(device_code: device_code.to_s)
|
||||
raise UnknownCode if grant.nil?
|
||||
|
||||
return [ grant.state, nil ] unless grant.state == :approved
|
||||
|
||||
# Read once, then gone: the column exists only to carry the secret across the gap
|
||||
# between the browser that approved it and the client that is polling for it.
|
||||
plain = grant.issued_token
|
||||
grant.update_columns(issued_token: nil) if plain.present?
|
||||
[ :approved, plain ]
|
||||
end
|
||||
|
||||
# The host's approval page calls this with the code a person typed and the subject
|
||||
# they are signed in as. Issuing the token here rather than on the next poll keeps
|
||||
# the decision and its consequence in one transaction.
|
||||
def approve(user_code:, subject:)
|
||||
ensure_configured!
|
||||
|
||||
grant = WarpEngine::DeviceGrant.find_pending_by_user_code(user_code)
|
||||
raise UnknownCode if grant.nil?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
token = WarpEngine::ApplicationToken.create!(
|
||||
name: grant.client_name.presence || "Client",
|
||||
owner_type: WarpEngine.config.access_token_owner_class,
|
||||
owner_id: subject.id,
|
||||
scopes: [ WarpEngine::ApplicationToken::CATALOG_SCOPE ]
|
||||
)
|
||||
grant.update!(
|
||||
subject_type: WarpEngine.config.access_token_owner_class,
|
||||
subject_id: subject.id,
|
||||
application_token: token,
|
||||
issued_token: token.plain_token,
|
||||
approved_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
grant
|
||||
end
|
||||
|
||||
def deny(user_code:)
|
||||
ensure_configured!
|
||||
|
||||
grant = WarpEngine::DeviceGrant.find_pending_by_user_code(user_code)
|
||||
raise UnknownCode if grant.nil?
|
||||
|
||||
grant.update!(denied_at: Time.current)
|
||||
grant
|
||||
end
|
||||
|
||||
# Signing out: the client throws its own token away. Revocation is a soft delete on
|
||||
# the token, so the audit trail of who signed in from where survives it.
|
||||
def revoke(token:)
|
||||
return false if token.nil?
|
||||
|
||||
token.revoke!
|
||||
true
|
||||
end
|
||||
|
||||
# Where a person goes to type the user code. A path is made absolute against the
|
||||
# request's own base, so a host that configured "/devices" does not have to know its
|
||||
# own hostname.
|
||||
def verification_url(base_url: nil)
|
||||
configured = WarpEngine.config.identity_verification_url.presence || "/devices"
|
||||
return configured if configured.start_with?("http://", "https://")
|
||||
return configured if base_url.blank?
|
||||
|
||||
"#{base_url.to_s.chomp('/')}/#{configured.delete_prefix('/')}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_configured!
|
||||
raise NotConfigured unless WarpEngine.identity_configured?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,10 @@
|
||||
module WarpEngine
|
||||
class DownloadService
|
||||
# The access policy said no. The controller turns this into a 403 — distinct from
|
||||
# the nil that means "no such file", because telling a person their file is missing
|
||||
# when it is merely locked sends them looking for the wrong problem.
|
||||
class Denied < StandardError; end
|
||||
|
||||
def self.container_base
|
||||
WarpEngine.config.file_container_path
|
||||
end
|
||||
@@ -12,19 +17,28 @@ module WarpEngine
|
||||
# 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.
|
||||
def locate(path:, ip:, user_agent:, referer:)
|
||||
#
|
||||
# `subject` is whoever the request authenticated as, or nil. Under the open policy
|
||||
# it is ignored and every file is served, exactly as before.
|
||||
def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
|
||||
relative = path.to_s
|
||||
return nil unless storage.file?(relative)
|
||||
|
||||
log_download(relative, ip: ip, user_agent: user_agent, referer: referer)
|
||||
asset = find_asset(relative)
|
||||
grant = authorize!(asset, subject, request)
|
||||
|
||||
storage.locate(relative, filename: File.basename(relative))
|
||||
log_download(relative, asset: asset, ip: ip, user_agent: user_agent, referer: referer, subject: subject)
|
||||
|
||||
storage.locate(relative,
|
||||
filename: grant.filename.presence || File.basename(relative),
|
||||
expires_in: grant.expires_in)
|
||||
end
|
||||
|
||||
# Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza
|
||||
# (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való.
|
||||
def create(path:, ip:, user_agent:, referer:)
|
||||
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer)
|
||||
def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
|
||||
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer,
|
||||
subject: subject, request: request)
|
||||
return nil if location.nil?
|
||||
|
||||
location.file? ? location.path : nil
|
||||
@@ -36,18 +50,41 @@ module WarpEngine
|
||||
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
|
||||
# A policy that refuses returns nil; one that raises is treated as a refusal too.
|
||||
# An artifact served because the gatekeeper crashed is the one failure mode this
|
||||
# engine must not have.
|
||||
def authorize!(asset, subject, request)
|
||||
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
|
||||
raise Denied if grant.nil?
|
||||
|
||||
WarpEngine::Download.create!(
|
||||
grant
|
||||
rescue Denied
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
raise Denied
|
||||
end
|
||||
|
||||
def find_asset(relative)
|
||||
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
|
||||
WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
|
||||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
|
||||
end
|
||||
|
||||
def log_download(relative, asset:, ip:, user_agent:, referer:, subject:)
|
||||
download = WarpEngine::Download.create!(
|
||||
file_path: relative,
|
||||
release: asset&.release,
|
||||
ip_address: ip,
|
||||
user_agent: user_agent&.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",
|
||||
path: relative, asset: asset, release: asset&.release,
|
||||
software: asset&.release&.software, subject: subject, download: download, ip: ip)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,16 @@ module WarpEngine
|
||||
# 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)
|
||||
# `subject` is whoever the request authenticated as, or nil. Under the open policy
|
||||
# it is ignored and every file is served, exactly as before.
|
||||
#
|
||||
# A hosted (browser) build reaches this path as hundreds of relative requests for
|
||||
# js, wasm and images, which is why the gate here is the policy's plain yes/no
|
||||
# rather than anything signed: there is nothing to sign per file. A host serving
|
||||
# gated web builds to browsers will usually want its own session-based route in
|
||||
# front of this one — a browser has a session, and a redirect to a login page is a
|
||||
# better answer there than a bare 403.
|
||||
def show(input, subject: nil)
|
||||
relative = input.path.to_s
|
||||
|
||||
if storage.directory?(relative)
|
||||
@@ -20,11 +29,34 @@ module WarpEngine
|
||||
|
||||
return FileResultDto.not_found unless storage.file?(relative)
|
||||
|
||||
authorize!(relative, subject)
|
||||
|
||||
to_result(storage.locate(relative, filename: File.basename(relative)))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Same rule and same failure mode as DownloadService: a policy that refuses or
|
||||
# raises means no file. An artifact served because the gatekeeper crashed is the
|
||||
# one failure mode this engine must not have.
|
||||
def authorize!(relative, subject)
|
||||
# The open policy authorises everything, and this path serves a browser build as
|
||||
# hundreds of requests for js, wasm and images. Asking it per file would mean a
|
||||
# LIKE query per asset for an answer that is always yes.
|
||||
return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open?
|
||||
|
||||
asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first
|
||||
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: nil)
|
||||
raise WarpEngine::DownloadService::Denied if grant.nil?
|
||||
|
||||
grant
|
||||
rescue WarpEngine::DownloadService::Denied
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
raise WarpEngine::DownloadService::Denied
|
||||
end
|
||||
|
||||
def storage
|
||||
WarpEngine.storage
|
||||
end
|
||||
|
||||
@@ -2,15 +2,15 @@ module WarpEngine
|
||||
class SoftwareHighlightedService
|
||||
include SoftwareResponseBuilder
|
||||
|
||||
def index
|
||||
software = WarpEngine::Software.includes(:external_links, :software_images)
|
||||
def index(subject: nil)
|
||||
software = visible_scope(subject).includes(:external_links, :software_images)
|
||||
.where(highlighted: true)
|
||||
.order(id: :desc)
|
||||
.first
|
||||
return nil unless software
|
||||
|
||||
releases = WarpEngine::Release.includes(:release_assets).where(software_id: software.id).to_a
|
||||
build_response(software, releases, download_counts_for(releases.map(&:id)))
|
||||
build_response(software, releases, download_counts_for(releases.map(&:id)), subject: subject)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ module WarpEngine
|
||||
module SoftwareResponseBuilder
|
||||
private
|
||||
|
||||
def build_response(software, releases, download_counts)
|
||||
def build_response(software, releases, download_counts, subject: nil)
|
||||
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
|
||||
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
|
||||
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
|
||||
@@ -14,10 +14,33 @@ module WarpEngine
|
||||
latest: latest,
|
||||
web_playable: web_playable,
|
||||
total_downloads: total_downloads,
|
||||
download_counts: download_counts
|
||||
download_counts: download_counts,
|
||||
access: access_for(software, subject)
|
||||
)
|
||||
end
|
||||
|
||||
# Always present, even under the open policy: a client should never have to tell
|
||||
# "this catalog says nothing about access" from "this title is not gated". One of
|
||||
# those is a question and the other is an answer.
|
||||
def access_for(software, subject)
|
||||
WarpEngine.access_policy.access_for(software: software, subject: subject)
|
||||
rescue StandardError => e
|
||||
# A policy that raises must not take the catalog down with it. The open answer is
|
||||
# wrong here, so the closed one is what a broken policy gets: a title nobody can
|
||||
# download is recoverable, a paid title handed out for free is not.
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
WarpEngine::Access.new(gated: true, entitled: false)
|
||||
end
|
||||
|
||||
# The titles this subject may see. A policy that raises empties the catalog rather
|
||||
# than leaking it — the safe direction — but it is still a bug, so it is logged.
|
||||
def visible_scope(subject)
|
||||
WarpEngine.access_policy.visible_software_scope(subject: subject)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
WarpEngine::Software.none
|
||||
end
|
||||
|
||||
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
|
||||
def download_counts_for(release_ids)
|
||||
return {} if release_ids.empty?
|
||||
|
||||
@@ -2,11 +2,16 @@ module WarpEngine
|
||||
class SoftwareService
|
||||
include SoftwareResponseBuilder
|
||||
|
||||
def index(owner_id: nil)
|
||||
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
|
||||
# `subject` is whoever the request authenticated as, or nil. It decides two things:
|
||||
# which titles are listed at all (the policy's scope) and what each one's `access`
|
||||
# block says about entitlement.
|
||||
def index(owner_id: nil, subject: nil)
|
||||
softwares = visible_scope(subject)
|
||||
.includes(releases: [ :release_assets ])
|
||||
.includes(:external_links, :software_images)
|
||||
softwares = softwares.where(owner_id: owner_id) if owner_id.present?
|
||||
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
|
||||
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
|
||||
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts, subject: subject) } }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user