Przeglądaj źródła

Remove server side varients - add a bunch of new features

clanker 2 tygodni temu
rodzic
commit
51079a887e

+ 11 - 0
example/HomeEntrypoint.vala

@@ -50,4 +50,15 @@ namespace Example {
             return directives().set(counter_slot).set(page_slot);
         }
     }
+
+    /**
+     * Entrypoint for the guarded dashboard page. The page is protected by a
+     * client-side `stm-guard`; the entrypoint just greets the held `auth` slot.
+     */
+    public class DashboardEntrypoint : StatumEntrypoint {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            return directives().notify("info", "Welcome to your dashboard");
+        }
+    }
 }

+ 1 - 0
example/Main.vala

@@ -16,6 +16,7 @@ void main(string[] args) {
         // embeds the login/bump action references. Actions are auto-mapped to
         // GUID endpoints; the Statum client scripts are embedded by default.
         statum.add_entrypoint_page<HomePage, HomeEntrypoint>(new EndpointRoute("/"));
+        statum.add_entrypoint_page<DashboardPage, DashboardEntrypoint>(new EndpointRoute("/dashboard"));
         statum.action<LoginAction>();
         statum.action<LogoutAction>();
         statum.action<BumpCounterAction>();

+ 13 - 0
example/counter-card.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <pstm-name>counter-card</pstm-name>
+    <style>
+        .counter-card { border: 1px solid #ddd; padding: 1rem; border-radius: 6px; margin: 1rem 0; }
+    </style>
+</head>
+<body pstm-materialise-as="section" class="counter-card">
+    <p>Counter: <span stm-text="counter.value">0</span></p>
+    <button stm-action="counter.bump">Bump +1</button>
+</body>
+</html>

+ 17 - 0
example/dashboard.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <pstm-uri>/dashboard</pstm-uri>
+    <pstm-template>main</pstm-template>
+    <!-- A guard: unauthenticated visitors are redirected to "/" before the
+         entrypoint is even fetched (pre-entrypoint), and re-checked on every
+         evaluate cycle (e.g. after logging out). -->
+    <link rel="stm-guard" require="auth" href="/" pre-entrypoint>
+</head>
+<body>
+    <h2>Dashboard</h2>
+    <p>Signed in as <strong stm-text="auth.name">user</strong>
+       (<span stm-text="auth.role">role</span>).</p>
+    <p><a href="/">Back home</a></p>
+</body>
+</html>

+ 23 - 18
example/home.html

@@ -1,19 +1,24 @@
-<pstm-uri>/</pstm-uri>
-<pstm-template>main</pstm-template>
+<!DOCTYPE html>
+<html>
+<head>
+    <pstm-uri>/</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <!-- A component (fragment): its <head> merges into the page head and its
+         body is materialised into the wrapping element. -->
+    <pstm-fragment name="counter-card" />
 
-<p>
-    Counter: <span stm-text="counter.value">0</span>
-    <button stm-action="counter.bump">Bump +1</button>
-</p>
-
-<div stm-if="auth != null">
-    <p>Welcome back, <strong stm-text="auth.name">user</strong>.</p>
-    <button stm-action="auth.logout">Log out</button>
-</div>
-<div stm-else>
-    <p>Sign in to personalise this page.</p>
-    <form stm-action="page.login">
-        <label>Name <input name="name" placeholder='try "admin"'></label>
-        <button type="submit">Log in</button>
-    </form>
-</div>
+    <div stm-if="auth != null">
+        <p>Welcome back, <strong stm-text="auth.name">user</strong>.</p>
+        <p><a href="/dashboard">Dashboard</a> · <button stm-action="auth.logout">Log out</button></p>
+    </div>
+    <div stm-else>
+        <p>Sign in to personalise this page.</p>
+        <form stm-action="page.login">
+            <label>Name <input name="name" placeholder='try "admin"'></label>
+            <button type="submit">Log in</button>
+        </form>
+    </div>
+</body>
+</html>

+ 8 - 1
example/meson.build

@@ -9,6 +9,13 @@ home_page = custom_target('home-page',
     input: 'home.html',
     output: 'HomePage.vala',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'HomePage', '--ns=Example', '@INPUT@'],
+    depend_files: files('main.html', 'counter-card.html')
+)
+
+dashboard_page = custom_target('dashboard-page',
+    input: 'dashboard.html',
+    output: 'DashboardPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'DashboardPage', '--ns=Example', '@INPUT@'],
     depend_files: files('main.html')
 )
 
@@ -19,7 +26,7 @@ styles_resource = custom_target('styles-resource',
 )
 
 executable('statum-example',
-    ['Main.vala', 'HomeEntrypoint.vala', 'Actions.vala', home_page, styles_resource],
+    ['Main.vala', 'HomeEntrypoint.vala', 'Actions.vala', home_page, dashboard_page, styles_resource],
     dependencies: [statum_dep],
     install: false
 )

+ 42 - 0
js/statum.js

@@ -79,6 +79,13 @@
   /** Whether {@link init} has already run. */
   var initialized = false;
 
+  /** Whether the entrypoint has been fetched (guards without pre-entrypoint
+   *  only evaluate after this). */
+  var entrypointLoaded = false;
+
+  /** Set when a guard has triggered a redirect, so further work is skipped. */
+  var guardRedirected = false;
+
   // ---------------------------------------------------------------------------
   // Small utilities
   // ---------------------------------------------------------------------------
@@ -849,6 +856,9 @@
    * @returns {Promise<void>}
    */
   function loadEntrypoint() {
+    if (guardRedirected) return Promise.resolve();
+    // From this point on guards are "post-entrypoint" (all of them evaluate).
+    entrypointLoaded = true;
     var attempt = async function () {
       await prepareSlots();
       var headers = new Headers();
@@ -1366,9 +1376,41 @@
   }
 
   /** Re-render the whole document against the current state. */
+  /**
+   * Evaluate every `<link rel="stm-guard">` in the document head. A guard whose
+   * `require` predicate is false redirects the page to its `href`.
+   *
+   * Guards marked `pre-entrypoint` are evaluated on every cycle (including the
+   * initial, pre-entrypoint render, which uses only hydrated state); guards
+   * without it are evaluated only once the entrypoint has loaded. On every later
+   * evaluate cycle (set/clear) all guards are re-checked, so logging out, for
+   * example, can bounce the user off a guarded page.
+   */
+  function evaluateGuards() {
+    if (guardRedirected) return;
+    var scope = stateScope();
+    var guards = document.querySelectorAll('link[rel~="stm-guard"]');
+    for (var i = 0; i < guards.length; i++) {
+      var g = guards[i];
+      var preEntrypoint = g.hasAttribute('pre-entrypoint');
+      if (!preEntrypoint && !entrypointLoaded) continue;
+      var require = g.getAttribute('require');
+      if (!require) continue;
+      if (!evalExpr(require, scope)) {
+        guardRedirected = true;
+        var href = g.getAttribute('href') || '/';
+        console.warn('[statum] guard failed (' + require + '); redirecting to ' + href);
+        window.location.href = href;
+        return;
+      }
+    }
+  }
+
   function renderAll() {
+    if (guardRedirected) return;
     try { render(document.body, stateScope()); }
     catch (e) { console.error('[statum] render error', e); }
+    evaluateGuards();
   }
 
   // ---------------------------------------------------------------------------

+ 14 - 0
model.md

@@ -286,6 +286,20 @@ The attribute `stm-else-if` can only exist as the next sibling of an element wit
 
 The attribute `stm-else` can exist only as the next sibling of an element with `stm-if` or `stm-else-if`.
 
+## Guards
+
+A guard is a `<link rel="stm-guard">` element in the document `<head>` that redirects the page when a predicate fails. It is the standards-conforming, head-level way to express a page access/redirect policy (custom elements cannot validly live in `<head>`, so the directive rides on `<link>`, whose `rel` is the spec's extension point).
+
+```html
+<link rel="stm-guard" require="auth" href="/login" pre-entrypoint>
+```
+
+- `require`: an eval predicate (the same expression language as `stm-if`) evaluated against the current state scope (`{ typeName: public }`). When it is falsy, the page redirects to `href`.
+- `href`: the URL to redirect to when the guard fails.
+- `pre-entrypoint` (boolean, optional): when present, the guard is also evaluated on the initial page load **before** the entrypoint request is made, using only the hydrated (persisted) state. When absent, the guard is evaluated only on the evaluate cycles (i.e. once the entrypoint has loaded and thereafter on every state change). Either way it is re-checked on every evaluate cycle — so logging out, for example, immediately bounces the user off a guarded page.
+
+Guards are a client-side, display-level convenience (like `stm-if`): they redirect the browser, they are not a security boundary. Authoritative access control lives at the entrypoint/action endpoints, which verify the signed slot data they receive.
+
 ## Conditional classes
 
 The attribute `stm-class.x` where `x` is any class name can be used to toggle classes on or off with a predicate, e.g. `<div stm-class.alert="typeName.waterLevel > 50">`.

+ 13 - 7
src/ResourceEndpoint.vala

@@ -28,16 +28,22 @@ namespace Statum {
                 return new HttpStringResult(@"No such resource \"$((!)name)\".", StatusCode.NOT_FOUND);
             }
 
-            var accepts = http_context.request.headers
-                .get_or_empty("Accept-Encoding")
-                .select_many<string>(h => Wrap.array<string>(h.split(",")))
-                .select<string>(s => s.strip())
-                .to_hash_set();
+            var accepts = new HashSet<string>();
+            foreach (var header in http_context.request.headers.get_or_empty("Accept-Encoding")) {
+                foreach (var token in header.split(",")) {
+                    var encoding = token.strip();
+                    if (encoding.length > 0) {
+                        accepts.add(encoding);
+                    }
+                }
+            }
 
             var best = resource.get_best_encoding(accepts);
             var etag = resource.get_etag_for(best);
-            if (http_context.request.headers.get_or_empty("If-None-Match").contains(etag)) {
-                return new HttpEmptyResult(StatusCode.NOT_MODIFIED);
+            foreach (var header in http_context.request.headers.get_or_empty("If-None-Match")) {
+                if (header.contains(etag)) {
+                    return new HttpEmptyResult(StatusCode.NOT_MODIFIED);
+                }
             }
 
             return resource.to_result(best);

+ 0 - 6
src/Statum.vala

@@ -1,5 +1,4 @@
 using Inversion;
-using Invercargill.Expressions;
 using Astralis;
 
 namespace Statum {
@@ -19,11 +18,6 @@ namespace Statum {
             container.register_singleton<Cryptography.SigningProvider>();
             container.register_singleton<Cryptography.EncryptionProvider>();
 
-            // Enable predicate member-access on Properties (slot public/private
-            // data) for the runtime `pstm-if`/`pstm-guard` evaluator.
-            Invercargill.Expressions.TypeAccessorRegistry.get_instance()
-                .register_property_accessor(new StatumPropertiesAccessorFactory());
-
             container.register_singleton<StateService>();
             container.register_singleton<HeldSlotResolver>();
             container.register_singleton<EntrypointRouteTable>();

+ 18 - 107
src/StatumPage.vala

@@ -1,118 +1,51 @@
 using Invercargill;
 using Invercargill.DataStructures;
-using Invercargill.Expressions;
-using Inversion;
 using Astralis;
 
 namespace Statum {
 
-    /**
-     * Outcome of {@link StatumPage.select_variant}: either the index of the
-     * pre-rendered variant to serve, or a guard failure (optional redirect or an
-     * error page at a status).
-     */
-    public class StatumVariantSelection : Object {
-
-        public bool guard_failed { get; private set; }
-        public string? redirect { get; private set; }
-        public StatusCode status { get; private set; default = StatusCode.FORBIDDEN; }
-        public int variant_index { get; private set; }
-
-        /** Selects the variant at `index`. */
-        public static StatumVariantSelection variant(int index) {
-            var selection = new StatumVariantSelection();
-            selection.variant_index = index;
-            return selection;
-        }
-
-        /** Signals a guard failure that should redirect to `redirect` (HTTP 302). */
-        public static StatumVariantSelection redirect_to(string redirect) {
-            var selection = new StatumVariantSelection();
-            selection.guard_failed = true;
-            selection.redirect = redirect;
-            return selection;
-        }
-
-        /** Signals a guard failure that should serve an error page at `status`. */
-        public static StatumVariantSelection failure(StatusCode status = StatusCode.FORBIDDEN) {
-            var selection = new StatumVariantSelection();
-            selection.guard_failed = true;
-            selection.status = status;
-            return selection;
-        }
-
-    }
-
     /**
      * Base class for pre-rendered Statum pages.
      *
-     * A generated subclass (emitted by `statum-mkpstm`) extends this, providing
-     * the precompressed variant byte arrays and a compiled {@link select_variant}
-     * that evaluates `pstm-guard`s and the `pstm-if` matrix against the request's
-     * held slots. This base supplies header parsing → held slots, encoding
-     * negotiation, ETag handling and guard redirect/error-page serving.
+     * A page is a static, precompressed document served at its route. Build-time
+     * composition (templates/fragments, `pstm-res` rewriting) aside, it is just a
+     * precompressed resource — so a generated subclass (emitted by
+     * `statum-mkpstm`) provides the precompressed byte arrays and the
+     * encoding/ETag accessors, and this base does lean `Accept-Encoding`
+     * negotiation, `ETag`/`If-None-Match` handling and serving.
+     *
+     * No slots are resolved: pages are loaded by browser navigations, which carry
+     * no `X-Statum-Slot` headers, so the page endpoint stays as fast as possible.
      */
     public abstract class StatumPage : Object, Endpoint {
 
-        protected HeldSlotResolver resolver = inject<HeldSlotResolver>();
-
         /** Content type of the served page (normally `text/html`). */
         public abstract string content_type { get; }
 
-        /**
-         * Evaluates the page's guards and `pstm-if` matrix against the held
-         * slots, returning the variant to serve or a guard failure.
-         */
-        public abstract StatumVariantSelection select_variant(ReadOnlyAssociative<string, HeldSlot> held) throws GLib.Error;
-
-        /** Best supported encoding for the chosen variant. */
-        public abstract string get_best_encoding(int variant, Set<string> supported);
+        /** Best supported encoding from the encodings this page carries. */
+        public abstract string get_best_encoding(Set<string> supported);
 
-        /** The bytes for `variant` under `encoding`. */
-        public abstract unowned uint8[] get_encoding(int variant, string encoding);
+        /** The bytes for `encoding`. */
+        public abstract unowned uint8[] get_encoding(string encoding);
 
-        /** The ETag for `variant` under `encoding`. */
-        public abstract string get_etag_for(int variant, string encoding);
+        /** The ETag for `encoding`. */
+        public abstract string get_etag_for(string encoding);
 
         public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
-            var held = resolver.resolve(http_context).held;
-
-            StatumVariantSelection selection;
-            try {
-                selection = select_variant(held);
-            } catch (GLib.Error e) {
-                return new HttpStringResult(@"Internal error: $(e.message)", StatusCode.INTERNAL_SERVER_ERROR);
-            }
-
-            if (selection.guard_failed) {
-                return serve_guard_failure(selection);
-            }
-
-            var accepts = parse_accept_encoding(http_context);
-            var best = get_best_encoding(selection.variant_index, accepts);
-            var etag = get_etag_for(selection.variant_index, best);
+            var best = get_best_encoding(parse_accept_encoding(http_context));
+            var etag = get_etag_for(best);
 
             if (etag_matches(http_context, etag)) {
                 return new HttpEmptyResult(StatusCode.NOT_MODIFIED);
             }
 
-            var bytes = get_encoding(selection.variant_index, best);
-            var result = new HttpDataResult(Wrap.byte_array(bytes));
+            var result = new HttpDataResult(Wrap.byte_array(get_encoding(best)));
             result.set_header("Content-Type", content_type);
             result.set_header("Content-Encoding", best);
             result.set_header("ETag", etag);
             return result;
         }
 
-        private static HttpResult serve_guard_failure(StatumVariantSelection selection) {
-            if (selection.redirect != null) {
-                var result = new HttpEmptyResult((StatusCode) 302);
-                result.set_header("Location", (!)selection.redirect);
-                return result;
-            }
-            return new HttpStringResult("Forbidden", selection.status);
-        }
-
         private static Set<string> parse_accept_encoding(HttpContext http_context) {
             var supported = new HashSet<string>();
             foreach (var header in http_context.request.headers.get_or_empty("Accept-Encoding")) {
@@ -134,27 +67,5 @@ namespace Statum {
             }
             return false;
         }
-
     }
-
-    /**
-     * Registers a {@link PropertiesPropertyAccessor} for any {@link Element}
-     * wrapping a {@link Properties} value, so that `pstm-if`/`pstm-guard`
-     * predicate evaluation (the runtime-evaluator fallback) can read members off
-     * a slot's public/private data — e.g. `auth.role` or `auth.private.score`.
-     *
-     * Registered once by {@link StatumModule}.
-     */
-    public class StatumPropertiesAccessorFactory : Object, PropertyAccessorFactory {
-
-        public PropertyAccessor? create(Element element) {
-            Properties properties;
-            if (element.try_get_as<Properties>(out properties)) {
-                return new PropertiesPropertyAccessor(properties);
-            }
-            return null;
-        }
-
-    }
-
 }

Plik diff jest za duży
+ 263 - 600
tools/statum-mkpstm/statum-mkpstm.vala


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików