Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,6 @@ source "https://rubygems.org", cooldown: 0 do
gem "openproject-octicons_helper", "~>19.35.0"
gem "openproject-primer_view_components", "~>0.89.2"
end

# LiveComponent pilot — see https://community.openproject.org/wp/DREAM-784
gem "live_component", "~> 0.4.0"
11 changes: 11 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,11 @@ GEM
logger
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
live_component (0.4.0)
railties (~> 8.0)
use_context (~> 1.2)
view_component (~> 4.0)
weak_key_map (~> 1.0)
lobby_boy (0.1.3)
omniauth (~> 1.1)
omniauth-openid-connect (>= 0.2.1)
Expand Down Expand Up @@ -1481,6 +1486,7 @@ GEM
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
use_context (1.2.0)
useragent (0.16.11)
validate_email (0.1.6)
activemodel (>= 3.0)
Expand All @@ -1498,6 +1504,7 @@ GEM
rack (>= 2.0.9)
warden-basic_auth (0.2.1)
warden (~> 1.2)
weak_key_map (1.0.0)
webauthn (3.4.3)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
Expand Down Expand Up @@ -1644,6 +1651,7 @@ DEPENDENCIES
launchy (~> 3.1.0)
letter_opener_web
listen (~> 3.10.0)
live_component (~> 0.4.0)
lograge (~> 0.15.0)
lookbook (= 2.3.14)
mail (= 2.9.1)
Expand Down Expand Up @@ -1995,6 +2003,7 @@ CHECKSUMS
letter_opener_web (3.0.0) sha256=3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
live_component (0.4.0) sha256=4518ef95d4cb771285c1c397db511d50f0cbd7db604fcfe9f3a64ada628ca43c
lobby_boy (0.1.3) sha256=9460bb3c052aef158eb3f137b8f7679ca756b8e2983d140dbdc0caa85c018172
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
lograge (0.15.0) sha256=34072c1f50b95019dc185137f5fec1a6759f5a1b6d55c28163d0cba204e6df16
Expand Down Expand Up @@ -2287,6 +2296,7 @@ CHECKSUMS
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
use_context (1.2.0) sha256=887b430251034e06d1f6550fa487f8cec05f76650922f028da139c0c285a62c7
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
validate_email (0.1.6) sha256=9dfe9016d527b17a8d3a6e95e4dc50a125400eef899d13d4cc2a254393f82ee4
validate_url (1.0.15) sha256=72fe164c0713d63a9970bd6700bea948babbfbdcec392f2342b6704042f57451
Expand All @@ -2295,6 +2305,7 @@ CHECKSUMS
view_component (4.12.0) sha256=b9a5979d1c43eba2aa21a06a86f661aab3990104855f2909ef7c3779acdcdcb4
warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0
warden-basic_auth (0.2.1) sha256=bfc752e0109c0182c3e69e930284c5e1e81e7b4a354aeb2b5914ead1391f3c6e
weak_key_map (1.0.0) sha256=dedbdb092955419f4a7c5c62cfda5b8427d91f9acbec4e2948d1ef7b78692ab7
webauthn (3.4.3) sha256=9be6f5f838f3405b0226e560aa40b67cc8c15ec9154509b997caa7ec9a05e1fc
webfinger (2.1.3) sha256=567a52bde77fb38ca6b67e55db755f988766ec4651c1d24916a65aa70540695c
webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
Expand Down
203 changes: 203 additions & 0 deletions app/controllers/live_components_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# frozen_string_literal: true

# Re-render endpoint for the LiveComponent pilot (DREAM-784).
# Deliberately NOT the library's Rack middleware: inheriting from
# ApplicationController runs user_setup so User.current is populated and
# component-level permission checks behave like any other request.
class LiveComponentsController < ApplicationController
# `no_authorization_required!` is only OpenProject's "did you remember to
# authorize" tripwire -- it enforces nothing. Login is normally enforced by
# `check_if_login_required`, which is a no-op when `Setting.login_required?`
# is false (a supported configuration for public instances), so require it
# explicitly: this endpoint renders client-named state and has no anonymous
# use case.
no_authorization_required! :render_component
before_action :require_login

# Every component reachable through this endpoint, mapped to the reflexes it
# may have dispatched on it.
#
# This controller performs no record-level authorization of its own: the
# client names the record to render, and the render endpoint reaches records
# a given user may not be allowed to view. Component-level read guards are
# therefore MANDATORY, not optional hardening. See the reporting notes in
# DREAM-784 for the disclosure details behind this requirement.
#
# IMPORTANT: a bare `render?` override is NOT sufficient. Every
# LiveComponent-adopting component added here must `prepend` its own guard
# module (added *after* `include LiveComponent::Base`, so it sits above the
# gem's render wrapper in the ancestor chain) that short-circuits `render_in`
# to `"".html_safe` when `render?` is false -- see
# PageHeaderComponent::RenderGuard for the pattern. And because
# LiveComponent::RenderComponent dispatches reflexes *before* it calls
# `component.render_in`, that guard does not cover reflexes either: each
# reflex must authorize itself as well.
ALLOWED_COMPONENTS = {
"Documents::ShowEditView::PageHeaderComponent" => %w[update_title].freeze
}.freeze

MAX_BODY_BYTES = 1.megabyte

# No pilot interaction sends more than one reflex per render.
MAX_REFLEXES = 4

# `__lc_attributes` is merged *last* into the rendered element's attributes
# by LiveComponent::Base::Overrides#render_in, so an unfiltered client value
# can add event-handler attributes and override the framework's own
# data-controller / data-component / data-livecomponent / data-id -- the
# attributes the client-side morph and targeting logic keys on. Accept only
# the two keys the pilot legitimately round-trips.
ALLOWED_LC_ATTRIBUTE_KEYS = %w[id data-id].freeze

# The serializer records which of a hash's keys were Symbols in a sibling
# `_lc_symkeys` entry (LiveComponent::Serializer::SYMBOL_KEYS_KEY), so it
# rides along inside __lc_attributes and has to be permitted -- constrained
# to naming only the attributes above.
LC_SYMBOL_KEYS = LiveComponent::Serializer::SYMBOL_KEYS_KEY

# Reflex argument values are run through the full LiveComponent serializer,
# which turns client hashes into arbitrary records (`_lc_gid` -> an unsigned
# GlobalID::Locator.locate) or arbitrary constants (`_lc_ser: "Module"` ->
# String#constantize). The pilot's reflexes take scalars, so require scalars.
SCALAR_PROP_TYPES = [String, Numeric, TrueClass, FalseClass, NilClass].freeze

GZIP_MAGIC = "\x1F\x8B".b.freeze

def render_component
# Rack 2.2's SYMBOL_TO_STATUS_CODE has no :content_too_large -- the
# symbol for 413 is :payload_too_large (verified against the app's
# pinned Rack version; the "obvious" symbol name raises ArgumentError).
return head :payload_too_large if request.content_length.to_i > MAX_BODY_BYTES

payload = decode_payload
return head :bad_request unless allowed_payload?(payload)

# The request payload is JSON, but the response is raw HTML: the JS
# client's Payload.decode only base64-decodes (and optionally gunzips) --
# it never JSON.parses. This matches the library's own reference Rack
# middleware (LiveComponent::Middleware#call), which encodes the render
# result directly with no JSON wrapping.
render plain: LiveComponent::Payload.encode(rendered_html(payload), compress: false),
content_type: "text/html"
end

private

def rendered_html(payload)
render_to_string(
LiveComponent::RenderComponent.new(payload["state"], payload["reflexes"] || []),
layout: false
)
end

def allowed_payload?(payload)
return false unless payload.is_a?(Hash)

state = payload["state"]

allowed_state?(state) && allowed_reflexes?(payload["reflexes"] || [], state["ruby_class"])
end

# Returns the decoded payload, or nil when the request body wasn't
# well-formed enough to decode: garbage JSON, a payload field that isn't
# valid base64/JSON, or a payload of the wrong shape entirely.
#
# This deliberately does not call LiveComponent::Payload.decode. That method
# hands a gzip body straight to Zlib.gunzip with no size cap, which puts the
# MAX_BODY_BYTES gate above on the wrong side of the decompression: 1 MB of
# gzip inflates to roughly 800 MB at DEFLATE's maximum ratio. Our transport
# (OpLiveComponentTransport) never compresses, so reject the compressed form
# outright rather than trying to bound the inflate.
def decode_payload
data = JSON.parse(request.body.read)
return nil unless data.is_a?(Hash) && data["payload"].is_a?(String)

raw = Base64.decode64(data["payload"])
return nil if raw.start_with?(GZIP_MAGIC)

JSON.parse(raw, max_nesting: 32)
rescue JSON::ParserError, TypeError, NoMethodError, ArgumentError => e
Rails.logger.debug { "LiveComponentsController: rejecting malformed payload (#{e.class}: #{e.message})" }
nil
end

# The allowlist has to bound the *whole* state tree, not just the root.
# LiveComponent::State.build recurses into `children` and `slots`, and each
# nested `ruby_class` is safe_constantize'd and has its own
# `deserialize_props` run -- which reaches ModelSerializer, and therefore
# arbitrary record loads, on a path with no `render?` and no RenderGuard.
# The pilot component declares no slots and renders no live children, so
# require both to be empty rather than trying to validate them.
def allowed_state?(state)
return false unless state.is_a?(Hash)
return false unless ALLOWED_COMPONENTS.key?(state["ruby_class"])
return false unless empty_tree?(state["children"]) && empty_tree?(state["slots"])

allowed_props?(state["props"], state["ruby_class"])
end

def empty_tree?(value)
value.nil? || value == {}
end

# Unknown prop names are not merely useless. LiveComponent::Base's
# `deserialize_props` memoizes a serializer into a *class-level* hash keyed
# by the client's own prop name (`prop_serializers[k] ||= ...`), so every
# invented name is interned for the life of the process.
def allowed_props?(props, ruby_class)
return false unless props.is_a?(Hash)
return false unless props.keys.all? { |key| component_prop_names(ruby_class).include?(key.to_s) }

allowed_lc_attributes?(props["__lc_attributes"])
end

def component_prop_names(ruby_class)
@component_prop_names ||= {}
@component_prop_names[ruby_class] ||= begin
# Safe to constantize: allowed_state? has already checked the allowlist.
init_args = ruby_class.constantize.__lc_init_args
init_args.filter_map { |type, name| name.to_s if %i[key keyreq].include?(type) } + ["__lc_attributes"]
end
end

def allowed_lc_attributes?(attributes)
return true if attributes.nil?
return false unless attributes.is_a?(Hash)

attributes.all? { |key, value| allowed_lc_attribute?(key.to_s, value) }
end

def allowed_lc_attribute?(key, value)
return allowed_lc_symbol_keys?(value) if key == LC_SYMBOL_KEYS

ALLOWED_LC_ATTRIBUTE_KEYS.include?(key) && value.is_a?(String)
end

def allowed_lc_symbol_keys?(value)
value.is_a?(Array) && value.all? { |name| ALLOWED_LC_ATTRIBUTE_KEYS.include?(name.to_s) }
end

# LiveComponent::SafeDispatcher accepts any public method defined on any
# ViewComponent subclass in the receiver's ancestor chain -- `public` is an
# API-design signal, not an authorization statement -- so narrow the
# reachable set to an explicit per-component allowlist here.
def allowed_reflexes?(reflexes, ruby_class)
return false unless reflexes.is_a?(Array) && reflexes.size <= MAX_REFLEXES

allowed = ALLOWED_COMPONENTS.fetch(ruby_class)

reflexes.all? do |reflex|
reflex.is_a?(Hash) &&
allowed.include?(reflex["method_name"]) &&
allowed_reflex_props?(reflex["props"])
end
end

def allowed_reflex_props?(props)
return true if props.nil?

props.is_a?(Hash) && props.each_value.all? do |value|
SCALAR_PROP_TYPES.any? { |type| value.is_a?(type) }
end
end
end
9 changes: 9 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@
require "primer/view_components"
require "primer/view_components/engine"

# live_component (0.4.0) hooks on_load(:action_controller) and calls `helper`,
# which ActionController::API does not implement. This hook registers earlier
# than the gem's, so when an API base fires the load hooks it first gains a
# no-op `helper` and the gem's hook becomes harmless (pilot workaround,
# DREAM-784 — the upstream fix is hooking :action_controller_base instead).
ActiveSupport.on_load(:action_controller) do
define_singleton_method(:helper) { |*, **, &_blk| } unless respond_to?(:helper)

Check warning on line 48 in config/application.rb

View workflow job for this annotation

GitHub Actions / RuboCop

[rubocop] reported by reviewdog 🐶 Empty block detected. Raw Output: config/application.rb:48:3: W: Lint/EmptyBlock: Empty block detected.
end

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups(:opf_plugins))
Expand Down
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@
delete "/favorite" => "favorites#unfavorite"
end

# LiveComponent pilot (DREAM-784): client-initiated re-render endpoint
post "/live_components/render", to: "live_components#render_component"

resources :project_queries, only: %i[show new create update destroy], controller: "projects/queries" do
concerns :shareable

Expand Down
16 changes: 16 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@blocknote/mantine": "^0.51.4",
"@blocknote/react": "^0.51.3",
"@braintree/sanitize-url": "^7.1.2",
"@camertron/live-component": "0.4.0",
"@datorama/akita": "^8.0.1",
"@floating-ui/dom": "^1.2.1",
"@fullcalendar/angular": "^6.1.21",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { LiveController } from '@camertron/live-component';
import { OPToastEvent } from 'core-app/shared/components/toaster/toast-event';

export default class PageHeaderLiveController extends LiveController {
static targets = ['titleInput'];

declare readonly titleInputTarget:HTMLInputElement;
declare readonly hasTitleInputTarget:boolean;

edit(event:Event):void {
event.preventDefault();
this.render((component) => { component.props.state = 'edit'; })
.catch((error:unknown) => this.reportRenderFailure(error));
}

cancel(event:Event):void {
event.preventDefault();
this.render((component) => { component.props.state = 'show'; })
.catch((error:unknown) => this.reportRenderFailure(error));
}

save(event:Event):void {
event.preventDefault();
const title = this.titleInputTarget.value;
this.render((component) => {
component.call('update_title', { title });
}).catch((error:unknown) => this.reportRenderFailure(error));
}

// Also runs on connect(): the library calls after_update() at the end of
// propagate_state, and connect() reaches it via
// propagate_state_from_element(). Both effects below are idempotent, but
// the name implies a narrower trigger than it has.
after_update():void {
if (this.hasTitleInputTarget) {
this.titleInputTarget.focus();
this.titleInputTarget.select();
}

const url = new URL(window.location.href);
if (url.searchParams.has('state')) {
url.searchParams.delete('state');
window.history.replaceState({}, document.title, url.toString());
}
}

// LiveController's task queue logs a render failure but re-rejects it, so
// every caller has to handle the rejection itself. Without this, a 4xx/5xx,
// a dropped connection, an expired session (302 to the sign-in page) and a
// permission-denied empty body -- which makes the library's own state
// lookup throw -- all present identically as "the button did nothing".
private reportRenderFailure(error:unknown):void {
console.error('LiveComponent render failed', error);

window.dispatchEvent(new CustomEvent(OPToastEvent, {
detail: {
message: I18n.t('js.error.internal'),
type: 'error',
},
}));
}
}
Loading
Loading