StatumHandlers.vala 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Inversion;
  4. using Astralis;
  5. namespace Statum {
  6. /**
  7. * Base class for Statum entrypoint handlers.
  8. *
  9. * After a page is served its HTML boots the Statum JS, which GETs
  10. * `/_statum/entrypoint?uri=…`. The framework resolves the entrypoint bound
  11. * to that URI (see {@link StatumConfigurator.add_page}) and calls
  12. * {@link handle}, whose returned {@link DirectiveBuilder} directives hydrate
  13. * the page.
  14. *
  15. * Subclasses start a builder via {@link directives}, chain directives onto
  16. * it, and return it. They are given the {@link request}, {@link state_service},
  17. * {@link channel_service} and {@link action_registry} (for authoring action
  18. * references to embed in state updates).
  19. */
  20. public abstract class StatumEntrypoint : Object {
  21. /** The per-request Statum context (registered scoped by the framework). */
  22. protected StatumRequest request = inject<StatumRequest>();
  23. /** The singleton state service. */
  24. protected StateService state_service = inject<StateService>();
  25. /** The singleton realtime channel service. */
  26. protected ChannelService channel_service = inject<ChannelService>();
  27. /** The singleton action registry (for authoring action references). */
  28. protected ActionRegistry action_registry = inject<ActionRegistry>();
  29. /** The singleton topic registry (for watching topics). */
  30. protected TopicRegistry topic_registry = inject<TopicRegistry>();
  31. /**
  32. * Returns the directive builder that hydrates the page bound to this
  33. * entrypoint. The framework calls {@link DirectiveBuilder.build} on it.
  34. */
  35. public abstract async DirectiveBuilder handle() throws GLib.Error;
  36. /** Starts a fluent {@link DirectiveBuilder} bound to the state service and held slots. */
  37. protected DirectiveBuilder directives() {
  38. return new DirectiveBuilder(state_service, request.held);
  39. }
  40. }
  41. /**
  42. * Base class for Statum action handlers.
  43. *
  44. * Actions are auto-mapped to GUID endpoints (`/_statum/action/{guid}`) at
  45. * registration. The framework routes a request to the matching action, this
  46. * base resolves the held slots, decrypts the `X-Statum-Private` header into
  47. * {@link StatumRequest.action_private} (under {@link private_namespace}),
  48. * parses the form body, builds the {@link request}, then invokes
  49. * {@link handle} and renders the builder's directives.
  50. *
  51. * For typed private data, extend {@link TypedStatumAction} instead.
  52. */
  53. public abstract class StatumAction : Object, Endpoint {
  54. /** Namespace used by the non-generic base (untyped action-private). */
  55. public const string ACTION_PRIVATE_NAMESPACE = "action-private";
  56. protected HeldSlotResolver resolver = inject<HeldSlotResolver>();
  57. protected Cryptography.EncryptionProvider encryption_provider = inject<Cryptography.EncryptionProvider>();
  58. protected StateService state_service = inject<StateService>();
  59. protected ChannelService channel_service = inject<ChannelService>();
  60. protected ActionRegistry action_registry = inject<ActionRegistry>();
  61. protected TopicRegistry topic_registry = inject<TopicRegistry>();
  62. protected Inversion.Scope scope = inject<Inversion.Scope>();
  63. /** The per-request Statum context (populated before {@link handle}). */
  64. protected StatumRequest request;
  65. /**
  66. * Namespace under which this action's private blobs are sealed/read.
  67. * Overridden by {@link StatumAction<TPrivate>} to the action's type name.
  68. */
  69. protected virtual string private_namespace { get { return ACTION_PRIVATE_NAMESPACE; } }
  70. /** Whether valid `X-Statum-Private` is required (true for typed actions). */
  71. protected virtual bool requires_valid_private { get { return false; } }
  72. /** Error message returned when private data can't be verified (overridable). */
  73. protected virtual string private_error_message { get { return "Action private data could not be verified"; } }
  74. /** Error code returned when private data can't be verified (overridable). */
  75. protected virtual string? private_error_code { get { return "bad_private"; } }
  76. /**
  77. * Returns the directive builder for this action's response (e.g. a
  78. * refreshed `set` for a mutated slot, plus any `notify`/`navigate`).
  79. */
  80. public abstract async DirectiveBuilder handle() throws GLib.Error;
  81. /** Starts a fluent {@link DirectiveBuilder} bound to the state service and held slots. */
  82. protected DirectiveBuilder directives() {
  83. return new DirectiveBuilder(state_service, request.held);
  84. }
  85. public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
  86. var resolved = resolver.resolve(http_context);
  87. // If the client referenced slots the server no longer has cached, ask
  88. // it to re-send those frames (then retry) before processing the action.
  89. if (resolved.transmit_keys.to_array().length > 0) {
  90. return StatumDirectives.transmit_result(resolved.transmit_keys, resolved.invalid_signatures);
  91. }
  92. FormData? form = null;
  93. var content_length_header = http_context.request.get_header("Content-Length");
  94. var content_length = content_length_header != null ? int.parse((!)content_length_header) : 0;
  95. if (content_length > 0 && (
  96. http_context.request.is_form_urlencoded() || http_context.request.is_multipart())) {
  97. form = yield FormDataParser.parse(http_context.request.request_body, http_context.request.content_type);
  98. }
  99. bool private_failed;
  100. var action_private = decrypt_action_private(http_context, out private_failed);
  101. request = new StatumRequest() {
  102. uri = route_context.requested_path,
  103. route_params = route_context.mapped_parameters,
  104. held = resolved.held,
  105. query = http_context.request.query_params,
  106. action_private = action_private,
  107. form = form
  108. };
  109. scope.register_local_scoped<StatumRequest>(() => request);
  110. // For typed actions, a missing or undecryptable X-Statum-Private is a
  111. // hard failure: return an error directive and do not run the handler.
  112. if (requires_valid_private && private_failed) {
  113. var directives = new Series<Model.DirectiveDto>();
  114. directives.add(new Model.ErrorDirectiveDto() {
  115. message = private_error_message,
  116. code = private_error_code
  117. });
  118. return StatumDirectives.to_result(directives, resolved.invalid_signatures, state_service, resolved.stale_keys);
  119. }
  120. var builder = yield handle();
  121. var directives = yield builder.build();
  122. return StatumDirectives.to_result(directives, resolved.invalid_signatures, state_service, resolved.stale_keys);
  123. }
  124. private Properties decrypt_action_private(HttpContext http_context, out bool failed) {
  125. failed = false;
  126. var blob = http_context.request.get_header("X-Statum-Private");
  127. if (blob == null || ((!)blob).length == 0) {
  128. failed = true;
  129. return new PropertyDictionary();
  130. }
  131. try {
  132. return encryption_provider.read_properties(private_namespace, (!)blob);
  133. } catch (GLib.Error e) {
  134. failed = true;
  135. warning("[Statum] Could not decrypt the X-Statum-Private header under namespace \"%s\" (action %s): %s",
  136. private_namespace, this.get_type().name(), e.message);
  137. return new PropertyDictionary();
  138. }
  139. }
  140. }
  141. /**
  142. * Base class for Statum action handlers that carry strongly-typed private
  143. * data.
  144. *
  145. * `TPrivate` is an ordinary GObject property class (e.g.
  146. * `public string order_id { get; set; }`); the framework maps it to/from the
  147. * sealed blob via GObject introspection ({@link GObjectMapping}). The seal
  148. * namespace is the action's type name, so one action type cannot decrypt
  149. * another's private blob.
  150. *
  151. * Read the decrypted, typed private data via {@link request_private}.
  152. *
  153. * (Named `TypedStatumAction` rather than `StatumAction<TPrivate>` because
  154. * Vala does not permit a generic and non-generic class to share a name.)
  155. */
  156. public abstract class TypedStatumAction<TPrivate> : StatumAction {
  157. /** Typed actions require valid `X-Statum-Private`; bad/absent → error directive. */
  158. protected override bool requires_valid_private { get { return true; } }
  159. /**
  160. * The {@link Type} materialised by {@link request_private}.
  161. *
  162. * The default returns `typeof(TPrivate)`, which erodes to `void` when
  163. * the action is instantiated reflectively — the usual case, because
  164. * registered actions are resolved by GType without their generic type
  165. * argument. Such subclasses must override this to name their private
  166. * model directly:
  167. *
  168. * ```
  169. * protected override Type private_type { get { return typeof(MyPrivate); } }
  170. * ```
  171. */
  172. protected virtual Type private_type { get { return typeof(TPrivate); } }
  173. private Object? _private_data = null;
  174. private bool _private_resolved = false;
  175. /**
  176. * The decrypted, typed private data for this action, or `null` when the
  177. * blob is absent, undecryptable, or the action's {@link private_type}
  178. * is not a {@link Object} type (see the erasure note there).
  179. *
  180. * The materialised model is held in a plain `Object` field so the
  181. * reference survives the erased-generic return path (a container-
  182. * constructed action carries no `TPrivate` dup function, so a direct
  183. * `(TPrivate)` transfer would drop the only reference).
  184. */
  185. protected TPrivate? request_private {
  186. get {
  187. if (!_private_resolved) {
  188. _private_resolved = true;
  189. var type = private_type;
  190. if (!type.is_a(typeof(Object))) {
  191. warning("[Statum] %s: typeof(TPrivate) resolved to \"%s\" at runtime (generic type erasure); "
  192. + "override `private_type` to return the private model's type so request_private can materialise it.",
  193. this.get_type().name(), type.name());
  194. _private_data = null;
  195. } else {
  196. try {
  197. _private_data = GObjectMapping.from_properties(type, request.action_private);
  198. } catch (GLib.Error e) {
  199. warning("[Statum] %s: could not materialise typed private data from the decrypted blob: %s",
  200. this.get_type().name(), e.message);
  201. _private_data = null;
  202. }
  203. }
  204. }
  205. return (TPrivate) _private_data;
  206. }
  207. }
  208. protected override string private_namespace {
  209. get { return this.get_type().name(); }
  210. }
  211. }
  212. }