using Invercargill; using Invercargill.DataStructures; using Inversion; using Astralis; namespace Statum { /** * Base class for Statum entrypoint handlers. * * After a page is served its HTML boots the Statum JS, which GETs * `/_statum/entrypoint?uri=…`. The framework resolves the entrypoint bound * to that URI (see {@link StatumConfigurator.add_page}) and calls * {@link handle}, whose returned {@link DirectiveBuilder} directives hydrate * the page. * * Subclasses start a builder via {@link directives}, chain directives onto * it, and return it. They are given the {@link request}, {@link state_service}, * {@link channel_service} and {@link action_registry} (for authoring action * references to embed in state updates). */ public abstract class StatumEntrypoint : Object { /** The per-request Statum context (registered scoped by the framework). */ protected StatumRequest request = inject(); /** The singleton state service. */ protected StateService state_service = inject(); /** The singleton realtime channel service. */ protected ChannelService channel_service = inject(); /** The singleton action registry (for authoring action references). */ protected ActionRegistry action_registry = inject(); /** The singleton topic registry (for watching topics). */ protected TopicRegistry topic_registry = inject(); /** * Returns the directive builder that hydrates the page bound to this * entrypoint. The framework calls {@link DirectiveBuilder.build} on it. */ public abstract async DirectiveBuilder handle() throws GLib.Error; /** Starts a fluent {@link DirectiveBuilder} bound to the state service and held slots. */ protected DirectiveBuilder directives() { return new DirectiveBuilder(state_service, request.held); } } /** * Base class for Statum action handlers. * * Actions are auto-mapped to GUID endpoints (`/_statum/action/{guid}`) at * registration. The framework routes a request to the matching action, this * base resolves the held slots, decrypts the `X-Statum-Private` header into * {@link StatumRequest.action_private} (under {@link private_namespace}), * parses the form body, builds the {@link request}, then invokes * {@link handle} and renders the builder's directives. * * For typed private data, extend {@link TypedStatumAction} instead. */ public abstract class StatumAction : Object, Endpoint { /** Namespace used by the non-generic base (untyped action-private). */ public const string ACTION_PRIVATE_NAMESPACE = "action-private"; protected HeldSlotResolver resolver = inject(); protected Cryptography.EncryptionProvider encryption_provider = inject(); protected StateService state_service = inject(); protected ChannelService channel_service = inject(); protected ActionRegistry action_registry = inject(); protected TopicRegistry topic_registry = inject(); protected Inversion.Scope scope = inject(); /** The per-request Statum context (populated before {@link handle}). */ protected StatumRequest request; /** * Namespace under which this action's private blobs are sealed/read. * Overridden by {@link StatumAction} to the action's type name. */ protected virtual string private_namespace { get { return ACTION_PRIVATE_NAMESPACE; } } /** Whether valid `X-Statum-Private` is required (true for typed actions). */ protected virtual bool requires_valid_private { get { return false; } } /** Error message returned when private data can't be verified (overridable). */ protected virtual string private_error_message { get { return "Action private data could not be verified"; } } /** Error code returned when private data can't be verified (overridable). */ protected virtual string? private_error_code { get { return "bad_private"; } } /** * Returns the directive builder for this action's response (e.g. a * refreshed `set` for a mutated slot, plus any `notify`/`navigate`). */ public abstract async DirectiveBuilder handle() throws GLib.Error; /** Starts a fluent {@link DirectiveBuilder} bound to the state service and held slots. */ protected DirectiveBuilder directives() { return new DirectiveBuilder(state_service, request.held); } public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error { var resolved = resolver.resolve(http_context); // If the client referenced slots the server no longer has cached, ask // it to re-send those frames (then retry) before processing the action. if (resolved.transmit_keys.to_array().length > 0) { return StatumDirectives.transmit_result(resolved.transmit_keys, resolved.invalid_signatures); } FormData? form = null; var content_length_header = http_context.request.get_header("Content-Length"); var content_length = content_length_header != null ? int.parse((!)content_length_header) : 0; if (content_length > 0 && ( http_context.request.is_form_urlencoded() || http_context.request.is_multipart())) { form = yield FormDataParser.parse(http_context.request.request_body, http_context.request.content_type); } bool private_failed; var action_private = decrypt_action_private(http_context, out private_failed); request = new StatumRequest() { uri = route_context.requested_path, route_params = route_context.mapped_parameters, held = resolved.held, query = http_context.request.query_params, action_private = action_private, form = form }; scope.register_local_scoped(() => request); // For typed actions, a missing or undecryptable X-Statum-Private is a // hard failure: return an error directive and do not run the handler. if (requires_valid_private && private_failed) { var directives = new Series(); directives.add(new Model.ErrorDirectiveDto() { message = private_error_message, code = private_error_code }); return StatumDirectives.to_result(directives, resolved.invalid_signatures, state_service, resolved.stale_keys); } var builder = yield handle(); var directives = yield builder.build(); return StatumDirectives.to_result(directives, resolved.invalid_signatures, state_service, resolved.stale_keys); } private Properties decrypt_action_private(HttpContext http_context, out bool failed) { failed = false; var blob = http_context.request.get_header("X-Statum-Private"); if (blob == null || ((!)blob).length == 0) { failed = true; return new PropertyDictionary(); } try { return encryption_provider.read_properties(private_namespace, (!)blob); } catch (GLib.Error e) { failed = true; warning("[Statum] Could not decrypt the X-Statum-Private header under namespace \"%s\" (action %s): %s", private_namespace, this.get_type().name(), e.message); return new PropertyDictionary(); } } } /** * Base class for Statum action handlers that carry strongly-typed private * data. * * `TPrivate` is an ordinary GObject property class (e.g. * `public string order_id { get; set; }`); the framework maps it to/from the * sealed blob via GObject introspection ({@link GObjectMapping}). The seal * namespace is the action's type name, so one action type cannot decrypt * another's private blob. * * Read the decrypted, typed private data via {@link request_private}. * * (Named `TypedStatumAction` rather than `StatumAction` because * Vala does not permit a generic and non-generic class to share a name.) */ public abstract class TypedStatumAction : StatumAction { /** Typed actions require valid `X-Statum-Private`; bad/absent → error directive. */ protected override bool requires_valid_private { get { return true; } } /** * The {@link Type} materialised by {@link request_private}. * * The default returns `typeof(TPrivate)`, which erodes to `void` when * the action is instantiated reflectively — the usual case, because * registered actions are resolved by GType without their generic type * argument. Such subclasses must override this to name their private * model directly: * * ``` * protected override Type private_type { get { return typeof(MyPrivate); } } * ``` */ protected virtual Type private_type { get { return typeof(TPrivate); } } private Object? _private_data = null; private bool _private_resolved = false; /** * The decrypted, typed private data for this action, or `null` when the * blob is absent, undecryptable, or the action's {@link private_type} * is not a {@link Object} type (see the erasure note there). * * The materialised model is held in a plain `Object` field so the * reference survives the erased-generic return path (a container- * constructed action carries no `TPrivate` dup function, so a direct * `(TPrivate)` transfer would drop the only reference). */ protected TPrivate? request_private { get { if (!_private_resolved) { _private_resolved = true; var type = private_type; if (!type.is_a(typeof(Object))) { warning("[Statum] %s: typeof(TPrivate) resolved to \"%s\" at runtime (generic type erasure); " + "override `private_type` to return the private model's type so request_private can materialise it.", this.get_type().name(), type.name()); _private_data = null; } else { try { _private_data = GObjectMapping.from_properties(type, request.action_private); } catch (GLib.Error e) { warning("[Statum] %s: could not materialise typed private data from the decrypted blob: %s", this.get_type().name(), e.message); _private_data = null; } } } return (TPrivate) _private_data; } } protected override string private_namespace { get { return this.get_type().name(); } } } }