Quellcode durchsuchen

Declare what each route takes from the request, and read it from the input

tPSGI now refuses a request that holds a name its operation does not
declare, and gives the callback the request's input apart from the rest of
the query, in $query->{input}.  tCMS declared almost nothing, and its
callbacks read every parameter off the query itself, where the request could
also set any key that a callback or a template meant to set.  A page that
handed %$query to its template handed the request the template's variables.

Now %inputs in Trog::Routes::HTML names what each callback takes: the query
of a GET and the form of a POST.  build_routes() declares that for every
route of the callback, by the callback's name, so a post that names posts or
series by string gets it too.  It leaves out what the route fixes in its
data and what its path holds, which the router refuses to see twice.  Every
GET takes the campaign parameters.  A route with its own parameters, as the
JSON routes have, keeps them.  The form of a post is open, because a post
type defines its own fields, and Trog::DataModule::validate() keeps only
those its type describes.

The callbacks read their input from $query->{input}.  The dispatcher sets
to beside it, as a path on this site.  posts() copies the paging and the
search into its own working hash, which post_helper(), the datasources and
the paginator read.  post_save() builds the post from the input and the user
who is logged in, so a form cannot name another author.

Some things went with this:

- The search box sends like to whatever page it is on, so every page that
  renders it takes like.
- The paginator's jump to a month sends the year and the month as well as
  the older it computes from them, so a page of posts takes those too.
- absorb_feedback() no longer reads the saved and savefailed parameters
  that the feedback cookie replaced.
- The dispatcher no longer filters the acls of the request.  post_save()
  drops them before it builds the post, so nothing read the result.
- The comments about naming a variable guest, not domain, are gone, since
  the router no longer writes over a name of the input.

This needs the tPSGI that puts the input in $query->{input}.  t/TCMS.t
compiles the whole document with tPSGI's router when tPSGI is installed, so
that a route which gives one name to two places fails there rather than as a
site loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
troglodyne-bot vor 3 Tagen
Ursprung
Commit
cc4afe50c7

+ 17 - 7
design.md

@@ -69,13 +69,23 @@ Publishing a post creates a URL because the URL *is* a field on the post. There
 is no separate router config to keep in sync, and no rewrite rules.
 
 A key of the table is a path, or an OpenAPI path template such as
-`/sitemap/{map}.xml` or `/lib/{module+}`. Each variable of a template reaches the
-callback under its name, without the `+`. `TCMS::build_routes` turns the table
-into the OpenAPI 3 document that tPSGI routes by. A route's `parameters` are
-OpenAPI parameter objects, so tPSGI validates the request before the callback
-runs. Its flags, such as `auth` and `totp_exempt`, reach the callback as
-`x-tcms-auth` and so on in `$query->{dispatcher}`. The path of a post cannot
-hold a `{` or a `}`, because OpenAPI has no way to escape them.
+`/sitemap/{map}.xml` or `/lib/{module+}`. `TCMS::build_routes` turns the table
+into the OpenAPI 3 document that tPSGI routes by. Its flags, such as `auth` and
+`totp_exempt`, reach the callback as `x-tcms-auth` and so on in
+`$query->{dispatcher}`. The path of a post cannot hold a `{` or a `}`, because
+OpenAPI has no way to escape them.
+
+What a request may send is declared, and tPSGI refuses anything else with a 400.
+`%inputs` in `Trog::Routes::HTML` names what each callback takes: the query
+of a GET and the form of a POST.  `build_routes` declares that for every route of
+the callback, so a post that names its callback by string gets the same, less
+what the route fixes in its `data` or holds in its path.  A route can declare
+its own `parameters` instead.  The callback finds the input of the request in
+`$query->{input}`: the query, the form, each variable of the template without
+the `+`, and the route's `data`.  The rest of `$query` is what tPSGI and the
+dispatcher set, which no request can replace.  So a page can hand `%$query` to
+its template, and the request still chooses none of the template's
+variables.
 
 **Adding a content type does not require touching Perl.** A post type is a
 template that says how to display and edit it, plus a JSON sidecar that says what

+ 37 - 11
lib/TCMS.pm

@@ -124,6 +124,12 @@ x-tcms-auth and so on, which the callback finds in C<< $query->{dispatcher} >>.
 C<parameters> are OpenAPI parameter objects, and each variable of a template
 that they leave out is a required string.  A route with no method is a GET.
 
+A route that does not declare its parameters takes the query or the form that
+%Trog::Routes::HTML::inputs names for its callback, less what its C<data> fixes and
+its path holds.  Every GET also takes the parameters of a campaign, utm_source
+and the rest.  The wrapper gives the callback the input of the request in
+C<< $query->{input} >>, and sets C<to> beside it, as a path on this site.
+
 The C<default> route is not a path.  The wrapper calls it when there is no
 F<config/setup>.
 
@@ -131,6 +137,12 @@ F<config/setup>.
 
 my @tcms_keys = qw{auth nocache noindex nomap robot_name totp_exempt scripts authenticated_scripts};
 
+# The parameters of a campaign, which any page can be linked with.
+my @urchin = qw{utm_source utm_medium utm_campaign utm_term utm_content};
+
+# How a browser sends a form.
+my @form_types = qw{application/x-www-form-urlencoded multipart/form-data};
+
 sub build_routes {
     my $conf   = Trog::Config::get();
     my $data   = Trog::Data->new($conf);
@@ -151,7 +163,8 @@ sub build_routes {
 
         # FlatFile and the tag index name the callback rather than hold it.
         my $callback = $v->{callback};
-        next                      unless $callback;
+        next unless $callback;
+        my $callback_name = ref $callback ? Sub::Util::subname($callback) : $callback;
         $callback = \&{$callback} unless ref $callback;
 
         #XXX For now we will discard the tPSGI object.  We might want it later.
@@ -166,8 +179,10 @@ sub build_routes {
             # Let's open up our default route if needed before we bother thinking any harder
             return $default_route->($query) unless -f "config/setup";    ## no critic (ProhibitFiletest_f) -- a flag file, only ever touched
 
+            my $input = $query->{input} //= {};
+
             # Set the urchin parameters if necessary.
-            %$Trog::Log::DBI::urchin = map { $_ => delete $query->{$_} } qw{utm_source utm_medium utm_campaign utm_term utm_content};
+            %$Trog::Log::DBI::urchin = map { $_ => $input->{$_} } @urchin;
 
             # Set the IP of the request so we can fail2ban
             $Trog::Log::ip = $query->{ip};
@@ -195,13 +210,6 @@ sub build_routes {
             $query->{user_acls} = [];
             $query->{user_acls} = Trog::Auth::acls4user($active_user) // [] if $active_user;
 
-            # Grab the list of ACLs we want to add to a post, if any.
-            $query->{acls} = [ $query->{acls} ] if ( $query->{acls} && ref $query->{acls} ne 'ARRAY' );
-
-            # Filter out passed ACLs which are naughty
-            my $is_admin = grep { $_ eq 'admin' } @{ $query->{user_acls} };
-            @{ $query->{acls} } = grep { $_ ne 'admin' } @{ $query->{acls} } unless $is_admin;
-
             $query->{user}         = $active_user;
             $query->{body}         = '';
             $query->{social_meta}  = 1;
@@ -213,7 +221,7 @@ sub build_routes {
 
             # Only ever a path on this site, so that no link can send a visitor to
             # another one.  One slash, because a browser reads two as a host.
-            $query->{to} = ( URI->new( $query->{to} )->path() || '/' ) =~ s{\A/+}{/}r if $query->{to};
+            $query->{to} = ( URI->new( $input->{to} )->path() || '/' ) =~ s{\A/+}{/}r if $input->{to};
 
             # Now that we have firmed up the actual routing, let's validate.
             return $tpsgi->forbidden($query) if exists $query->{dispatcher}{'x-tcms-auth'} && !$active_user;
@@ -232,11 +240,29 @@ sub build_routes {
             push( @parameters, { name => $variable, in => 'path', required => true, schema => { type => 'string' } } );
         }
 
+        # What the callback takes from the request, less what the route fixes in
+        # its data or holds in its path.  A route that declares its parameters
+        # declares all of its query.
+        my $inputs = { %{ $Trog::Routes::HTML::inputs{$callback_name} // {} } };
+        $_ = $_->() for grep { ref eq 'CODE' } @$inputs{qw{query body}};
+        my %taken = map { $_ => 1 } keys( %{ $v->{data} // {} } ), map { $_->{name} =~ s/[+]\z//r } @parameters;
+        my %query = ( ( $v->{parameters} ? () : %{ $inputs->{query} // {} } ), ( $method eq 'get' ? map { $_ => { type => 'string' } } @urchin : () ) );
+        push( @parameters, map { { name => $_, in => 'query', schema => $query{$_} } } grep { !$taken{$_} } sort keys(%query) ) if $method eq 'get';
+
+        my $body = $inputs->{body};
+        my %form = (
+            type       => 'object',
+            properties => { map { $_ => $body->{$_} } grep { !$taken{$_} } keys( %{ $body // {} } ) },
+            ( $inputs->{open} ? ( additionalProperties => true ) : () ),
+        );
+        my $request_body = $method eq 'post' && ( $body || $inputs->{open} ) ? { content => { map { $_ => { schema => \%form } } @form_types } } : undef;
+
         $paths{$k}{$method} = {
             'x-tpsgi-callback' => $cb_wrap,
             ( $v->{data} ? ( 'x-tpsgi-data' => $v->{data} ) : () ),
             ( map { ( "x-tcms-$_" => $v->{$_} ) } grep { exists $v->{$_} } @tcms_keys ),
-            ( @parameters ? ( parameters => \@parameters ) : () ),
+            ( @parameters   ? ( parameters  => \@parameters )  : () ),
+            ( $request_body ? ( requestBody => $request_body ) : () ),
             responses => { default => { description => 'The page, or an error page' } },
         };
     }

+ 194 - 137
lib/Trog/Routes/HTML.pm

@@ -176,10 +176,6 @@ our %routes = (
         noindex  => 1,
         nocache  => 1,
     },
-
-    # 'guest', not 'domain': the router sets $query->{domain} to the request's
-    # own host *after* it applies the path parameters, so a variable by that
-    # name never survives to the callback.
     '/guest/screenshot/{hypervisor}/{guest}' => {
         method   => 'GET',
         auth     => 1,
@@ -201,9 +197,6 @@ our %routes = (
         noindex  => 1,
         nocache  => 1,
     },
-
-    # 'guest', not 'domain', for the reason the screenshot route names it that
-    # way.
     '/guest/reprovision/log/{guest}' => {
         method     => 'GET',
         auth       => 1,
@@ -312,6 +305,77 @@ our %post_page_scripts   = (
     authenticated_scripts => [qw{post.js post_relations.js}],
 );
 
+=head2 %inputs
+
+What each callback in tCMS takes from the request, by the name of the callback.
+C<query> is the query of a GET, and C<body> the form of a POST, each a HASH of
+names to the JSON schema of each.  Either can be a CODE reference that returns
+one, for what is not known until the routes are built.  C<open> says that the body can hold other fields
+too, which a post type defines.  TCMS::build_routes() declares them for each
+route of the callback, less what the route's data fixes and its path holds.  A
+callback finds them in C<< $query->{input} >>.
+
+=cut
+
+my %text   = ( type => 'string' );
+my %number = ( type => 'integer' );
+my %texts  = ( type => 'array', items => { type => 'string' } );
+
+# The search box of every page sends like to the page it is on.
+my %search = ( like => \%text );
+
+# A page of posts: its pagination, the jump to a month, which sends the year
+# and month as well as the older it makes of them, a version of a post, RSS,
+# and an embed.
+my %page = (
+    %search,
+    ( map { $_ => \%number } qw{page limit version} ),
+    ( map { $_ => \%text } qw{older newer format embed nochrome year month} ),
+);
+
+my sub fields (@names) {
+    return { map { $_ => \%text } @names };
+}
+
+our %inputs = (
+    ( map { ( "Trog::Routes::HTML::$_" => { query => \%page } ) } qw{posts series users} ),
+    ( map { ( "Trog::Routes::HTML::$_" => { query => \%search } ) } qw{index logout totp secrets config resetpass processed manual post_wizard sitemap} ),
+    'Trog::Routes::HTML::login'          => { query => { %search, to => \%text }, body => fields(qw{app to username password token display_name contact_email}) },
+    'Trog::Routes::HTML::secrets_save'   => { body  => fields(qw{name secret}) },
+    'Trog::Routes::HTML::secrets_forget' => { body  => fields(qw{name}) },
+    'Trog::Routes::HTML::post_save'      => { body  => { to => \%text, tags => \%texts },                           open => 1 },
+    'Trog::Routes::HTML::profile'        => { body  => fields(qw{to username password display_name contact_email}), open => 1 },
+    'Trog::Routes::HTML::post_delete'    => { body  => fields(qw{id to}) },
+    'Trog::Routes::HTML::config_save'    => {
+        body => sub {
+            fields(
+                map {
+                    map { $_->{name} }
+                      @{ $_->{fields} }
+                } @{ Trog::Config::schema() }
+            );
+        }
+    },
+    'Trog::Routes::HTML::themeclone'   => { body => fields(qw{theme newtheme}) },
+    'Trog::Routes::HTML::do_resetpass' => { body => fields(qw{username password}) },
+
+    # The page hands its query to /api/requests_per, so it takes what that does.
+    'Trog::Routes::HTML::metrics' => {
+        query => sub {
+            return { %search, map { $_->{name} => $_->{schema} } @{ $Trog::Routes::JSON::routes{'/api/requests_per'}{parameters} } };
+        }
+    },
+    'Trog::Routes::HTML::sessions'          => { query => { %search, limit => \%number, filter_user => \%text, filter_event => \%text } },
+    'Trog::Routes::HTML::guest_act'         => { body  => fields(qw{action guest hypervisor to}) },
+    'Trog::Routes::HTML::guest_reprovision' => { body  => fields(qw{guest passphrase sudo totp remember to}) },
+    'Trog::Routes::HTML::post_wizard_save'  => {
+        body => {
+            %{ fields(qw{name overwrite datasource description display title_placeholder body_form wrapper inc_post_title inc_post_tags inc_title_input inc_preview inc_visibility inc_acls inc_tags inc_aliases inc_attachments}) },
+            map { $_ => \%texts } qw{param_name param_type param_label param_placeholder param_required param_relation_form param_relation_mode param_private param_indexed},
+        },
+    },
+);
+
 # Grab theme routes
 my $themed    = 0;
 my $theme_dir = Trog::Themes::get_dir();
@@ -392,8 +456,8 @@ sub feedback_cookie ( $failure, $message ) {
 
 =head2 absorb_feedback($query)
 
-Turn the parameters feedback_redirect() put in the URL back into the failure
-and message the jsalert banner reads.
+Turn the cookie that feedback_redirect() set back into the failure and message
+the jsalert banner reads.
 
 Done in index() rather than in each route, so that a save can hand feedback to
 any destination that renders a page, whichever route ends up serving it.
@@ -402,16 +466,7 @@ any destination that renders a page, whichever route ends up serving it.
 
 my sub absorb_feedback ($query) {
     my ( $failure, $message ) = feedback_from_cookie( $query->{cookies} );
-
-    # The query parameters are still read, for a redirect issued by a worker
-    # which had not been restarted yet, and for anyone who has one bookmarked.
-    if ( !defined $failure ) {
-        my ( $saved, $failed ) = ( $query->{saved}, $query->{savefailed} );
-        return unless defined $saved || defined $failed;
-
-        $failure = defined $failed ? 1       : 0;
-        $message = defined $failed ? $failed : $saved;
-    }
+    return unless defined $failure;
 
     $query->{failure} = $failure;
     $query->{message} = $message || ( $failure ? 'Save failed.' : 'Saved.' );
@@ -436,8 +491,7 @@ my sub absorb_feedback ($query) {
 
 Pull a save's outcome back out of the cookie feedback_redirect set.
 
-Returns nothing when there isn't one, so that the caller can fall back to the
-query parameters this replaced.
+Returns nothing when there isn't one.
 
 =cut
 
@@ -534,6 +588,8 @@ Most subsequent functions simply pass content to this function.
 
 sub index ( $query, $content = '', $i_styles = [] ) {
     $query->{theme_dir} = Trog::Themes::td();
+    my $in    = $query->{input} // {};
+    my $embed = $in->{embed};
 
     absorb_feedback($query);
 
@@ -543,11 +599,11 @@ sub index ( $query, $content = '', $i_styles = [] ) {
     return $content if ref $content eq "ARRAY";
 
     my @styles;
-    unshift( @styles, qw{embed.css} ) if $query->{embed};
+    unshift( @styles, qw{embed.css} ) if $embed;
     unshift( @styles, qw{screen.css structure.css} );
     push( @styles, @$i_styles );
     my @p_styles = qw{structure.css print.css};
-    unshift( @p_styles, qw{embed.css} ) if $query->{embed};
+    unshift( @p_styles, qw{embed.css} ) if $embed;
 
     my @series = get_series(0);
 
@@ -557,7 +613,7 @@ sub index ( $query, $content = '', $i_styles = [] ) {
     my ( $default_tags, $meta_desc, $meta_tags ) = build_social_meta( $query, $title );
 
     #Do embed content
-    my $tmpl = $query->{embed} ? 'embed.tx' : 'index.tx';
+    my $tmpl = $embed ? 'embed.tx' : 'index.tx';
     $query->{theme_dir} =~ s/^\/www\///;
 
     # Grab the avatar class for the logged in user
@@ -581,7 +637,8 @@ sub index ( $query, $content = '', $i_styles = [] ) {
             %$query,
 
             # What index.tx and embed.tx read that a route may leave unset.
-            ( map { $_ => $query->{$_} } qw{author is_admin failure message to user user_class nochrome route} ),
+            ( map { $_ => $query->{$_} } qw{author is_admin failure message to user user_class route} ),
+            nochrome     => $in->{nochrome},
             search_lang  => $search_lang,
             search_help  => $search_help,
             theme_dir    => Trog::Themes::td(),
@@ -591,7 +648,7 @@ sub index ( $query, $content = '', $i_styles = [] ) {
             stylesheets  => \@styles,
             print_styles => \@p_styles,
             show_madeby  => $Theme::show_madeby ? 1 : 0,
-            embed        => $query->{embed}     ? 1 : 0,
+            embed        => $embed              ? 1 : 0,
             embed_video  => $query->{primary_post}{is_video},
             default_tags => $default_tags,
             meta_desc    => $meta_desc,
@@ -765,29 +822,30 @@ sub login ($query) {
     my $btnmsg   = $hasusers ? "Log In" : "Register";
 
     my $headers;
-    if ( $query->{username} && $query->{password} ) {
+    my $in = $query->{input};
+    if ( $in->{username} && $in->{password} ) {
         if ( !$hasusers ) {
 
             # Make the first user
-            Trog::Auth::useradd( $query->{username}, $query->{display_name}, $query->{password}, ['admin'], $query->{contact_email} );
+            Trog::Auth::useradd( $in->{username}, $in->{display_name}, $in->{password}, ['admin'], $in->{contact_email} );
 
             # Add a stub user page and the initial series.
             my $dat = Trog::Data->new( Trog::Config::get() );
-            setup_initial_db( $dat, $query->{username}, $query->{display_name}, $query->{contact_email} );
+            setup_initial_db( $dat, $in->{username}, $in->{display_name}, $in->{contact_email} );
 
             # Ensure we stop registering new users
             File::Touch::touch("config/has_users");
         }
 
         $query->{failed} = 1;
-        my $cookie = Trog::Auth::mksession( $query->{username}, $query->{password}, $query->{token}, $query->{ip} // '' );
+        my $cookie = Trog::Auth::mksession( $in->{username}, $in->{password}, $in->{token}, $query->{ip} // '' );
         if ($cookie) {
 
             # A login is how you get to enrol, since the QR is not something to
             # hand out to whoever asks for it -- so a user with no enrolment yet
             # gets a session and goes straight there rather than where they were
             # headed.  The dispatcher keeps them there until they have one.
-            $query->{to} = '/totp' unless Trog::Auth::has_totp( $query->{username} );
+            $query->{to} = '/totp' unless Trog::Auth::has_totp( $in->{username} );
 
             # TODO secure / sameSite cookie to kill csrf, maybe do rememberme with Expires=~0
             my $secure = '';
@@ -1031,10 +1089,10 @@ sub secrets ($query) {
 sub secrets_save ($query) {
     require Trog::Vault;
 
-    my ( $ok, $message ) = Trog::Vault::set( $query->{user}, $query->{name} // '', $query->{secret} // '' );
+    my ( $ok, $message ) = Trog::Vault::set( $query->{user}, $query->{input}{name} // '', $query->{input}{secret} // '' );
 
     # This hash is cloned, logged around and handed to renderers.
-    delete $query->{secret};
+    delete $query->{input}{secret};
 
     return feedback_redirect( $query, '/secrets', $ok ? 0 : 1, $message );
 }
@@ -1042,7 +1100,7 @@ sub secrets_save ($query) {
 sub secrets_forget ($query) {
     require Trog::Vault;
 
-    my $name = $query->{name} // '';
+    my $name = $query->{input}{name} // '';
     my $gone = Trog::Vault::forget( $query->{user}, $name );
 
     return feedback_redirect( $query, '/secrets', $gone ? 0 : 1, $gone ? "'$name' forgotten" : "there was nothing stored as '$name'" );
@@ -1071,7 +1129,7 @@ sub resetpass ($query) {
 }
 
 sub do_resetpass ($query) {
-    my $user = $query->{username};
+    my $user = $query->{input}{username};
 
     # User Does not exist
     return $query->{tpsgi}->forbidden($query) if !Trog::Auth::user_exists($user);
@@ -1080,7 +1138,7 @@ sub do_resetpass ($query) {
     return $query->{tpsgi}->forbidden($query) if !$query->{user} && Trog::Auth::user_has_session($user);
 
     my $token   = Trog::Utils::uuid();
-    my $newpass = $query->{password} // Trog::Utils::uuid();
+    my $newpass = $query->{input}{password} // Trog::Utils::uuid();
     my $res     = Trog::Auth::add_change_request( type => 'reset_pass', user => $user, secret => $newpass, token => $token );
     die "Could not add auth change request!" unless $res;
 
@@ -1153,7 +1211,7 @@ sub config_save ($query) {
     return $query->{tpsgi}->forbidden($query)  unless List::Util::any { $_ eq 'admin' } @{ $query->{user_acls} };
 
     my $conf = Trog::Config::get();
-    config_apply( $conf, $query );
+    config_apply( $conf, $query->{input} );
 
     $query->{failure} = 1;
     $query->{message} = "Failed to save configuration!";
@@ -1168,10 +1226,10 @@ sub config_save ($query) {
     return config($query);
 }
 
-=head2 config_apply( Config::Simple conf, HASHREF query )
+=head2 config_apply( Config::Simple conf, HASHREF input )
 
 Copy whatever settings the form posted onto $conf, keyed by their full
-'section.key' name.
+'section.key' name.  $input is the input of the request.
 
 The schema is the allowlist: a request naming something config/default.cfg does
 not describe writes nothing, so a stray form field cannot invent configuration.
@@ -1180,10 +1238,10 @@ you go back to having none.
 
 =cut
 
-sub config_apply ( $conf, $query ) {
+sub config_apply ( $conf, $input ) {
     foreach my $section ( @{ Trog::Config::schema() } ) {
         foreach my $field ( @{ $section->{fields} } ) {
-            my $value = $query->{ $field->{name} };
+            my $value = $input->{ $field->{name} };
             next unless defined $value;
 
             # Duplicate inputs arrive as an array; the last one wins, same as
@@ -1207,7 +1265,7 @@ sub themeclone ($query) {
     return $query->{tpsgi}->see_also('/login') unless $query->{user};
     return $query->{tpsgi}->forbidden($query)  unless List::Util::any { $_ eq 'admin' } @{ $query->{user_acls} };
 
-    my ( $theme, $newtheme ) = ( $query->{theme}, $query->{newtheme} );
+    my ( $theme, $newtheme ) = @{ $query->{input} }{qw{theme newtheme}};
 
     my $themedir = 'www/themes';
 
@@ -1231,9 +1289,11 @@ sub post_save ($qq) {
     return $qq->{tpsgi}->see_also('/login') unless $qq->{user};
     return $qq->{tpsgi}->forbidden($qq)     unless List::Util::any { $_ eq 'admin' } @{ $qq->{user_acls} };
 
-    my $query = clone($qq);
+    # What the form sent, by whoever is logged in.
+    my $query = { %{ clone( $qq->{input} ) }, user => $qq->{user} };
 
-    my $to = delete $query->{to};
+    my $to = $qq->{to} // '';
+    delete $query->{to};
 
     $query->{tags} = Trog::Utils::coerce_array( $query->{tags} );
 
@@ -1241,10 +1301,6 @@ sub post_save ($qq) {
     $query->{data}        = Trog::Utils::coerce_array( $query->{data} ) if $query->{data_is_array};
     $query->{attachments} = Trog::Utils::coerce_array( $query->{attachments} );
 
-    # Filter bits and bobs XXX this is done deeper in, may require removal
-    delete $query->{primary_post};
-    delete $query->{social_meta};
-    delete $query->{deflate};
     delete $query->{acls};
 
     # Ensure there are no null tags
@@ -1301,28 +1357,25 @@ sub profile ($query) {
     my @userposts = $data->get( tags => ['about'], acls => [qw{admin}] );
 
     # Users are always self-authored, you see
+    my %post     = %{ $query->{input} };
+    my $username = delete $post{username} // '';
+    my $password = delete $post{password};
 
-    my $user_obj = List::Util::first { ( $_->{user} || '' ) eq $query->{username} } @userposts;
+    my $user_obj = List::Util::first { ( $_->{user} || '' ) eq $username } @userposts;
 
-    my $username = $query->{username};
-    my $password = $query->{password};
     my $changed =
          $username ne ( $user_obj->{user} // '' )
       || $password
-      || ( $query->{contact_email} // '' ) ne ( $user_obj->{contact_email} // '' )
-      || ( $query->{display_name}  // '' ) ne ( $user_obj->{display_name}  // '' );
-
-    #Make sure it is "self-authored", redact pw
-    $query->{user} = delete $query->{username};
-    delete $query->{password};
-
-    # Use the display name as the title
-    $query->{title} = $query->{display_name};
+      || ( $post{contact_email} // '' ) ne ( $user_obj->{contact_email} // '' )
+      || ( $post{display_name}  // '' ) ne ( $user_obj->{display_name}  // '' );
 
     my %merged = (
         %$user_obj,
-        %$query,
-        $query->{display_name} ? ( local_href => "/users/$query->{display_name}" ) : ( local_href => $user_obj->{local_href} ),
+        %post,
+
+        # Use the display name as the title
+        title => $post{display_name},
+        $post{display_name} ? ( local_href => "/users/$post{display_name}" ) : ( local_href => $user_obj->{local_href} ),
     );
 
     # Validate before touching the auth database, not after.  useradd() does an
@@ -1332,7 +1385,7 @@ sub profile ($query) {
     my @errors = Trog::DataModule::validate( clone( \%merged ) );
     if (@errors) {
         my $why = "Post failed validation:\n" . join( "\n", @errors );
-        WARN("Rejected profile update from $query->{user}: $why");
+        WARN("Rejected profile update from $username: $why");
         return $query->{tpsgi}->badrequest( $query, $why );
     }
 
@@ -1344,7 +1397,7 @@ sub profile ($query) {
         Trog::Auth::useradd( $username, $merged{display_name}, $password, \@acls, $merged{contact_email} );
     }
 
-    return post_save( \%merged );
+    return post_save( { %$query, user => $username, input => \%merged } );
 }
 
 =head2 post_delete
@@ -1360,7 +1413,7 @@ sub post_delete ($query) {
     state $data;
     $data //= Trog::Data->new( Trog::Config::get() );
 
-    $data->delete($query) and die "Could not delete post";
+    $data->delete( $query->{input} ) and die "Could not delete post";
     return $query->{tpsgi}->see_also( $query->{to} );
 }
 
@@ -1520,7 +1573,7 @@ Returns the avatars.css.
 
 sub avatars ($query) {
     $query->{user_acls} = [ viewer_acls($query) ];
-    my $tags = Trog::Utils::coerce_array( $query->{tag} );
+    my $tags = Trog::Utils::coerce_array( $query->{input}{tag} );
 
     my @posts = post_helper( $query, $tags, $query->{user_acls} );
     if (@posts) {
@@ -1552,7 +1605,7 @@ Returns 404 when no matching user is found or the user has no avatar.
 =cut
 
 sub libravatar ($query) {
-    my $hash = lc( $query->{avatar_hash} // '' );
+    my $hash = lc( $query->{input}{avatar_hash} // '' );
 
     my $users = Trog::Auth::users_with_emails();
     my $matched_user;
@@ -1686,8 +1739,13 @@ sub posts ( $query, $direct = 0 ) {
     # handed to index() as content, so index()'s own absorb would be too late.
     absorb_feedback($query);
 
+    # What the reader asked of the page, which the posts are looked up and
+    # paged by from here on.  embed() renders a post with no request.
+    my $in = $query->{input} // {};
+    $query->{$_} = $in->{$_} for grep { defined $in->{$_} } qw{older newer page limit like version};
+
     # Allow rss.xml to tell what posts to loop over
-    my $fmt = $query->{format} || '';
+    my $fmt = $in->{format} || '';
 
     #Process the input URI to capture tag/id
     $query->{route} //= $query->{to};
@@ -1697,7 +1755,8 @@ sub posts ( $query, $direct = 0 ) {
 
     my ( undef, undef, $id ) = split( m{/}, $query->{route} );
 
-    my $tags = Trog::Utils::coerce_array( $query->{tag} );
+    # A series names its own tag, and a route can fix one in its data.
+    my $tags = Trog::Utils::coerce_array( $query->{tag} // $in->{tag} );
     $query->{id} = $id if $id && !$query->{in_series};
 
     my $is_admin = grep { $_ eq 'admin' } @{ $query->{user_acls} };
@@ -1887,8 +1946,8 @@ sub posts ( $query, $direct = 0 ) {
             footers           => $footers,
             years             => [ reverse( $oldest_year .. $now_year ) ],
             months            => [ 0 .. 11 ],
-            embed             => $query->{embed},
-            nochrome          => $query->{nochrome},
+            embed             => $in->{embed},
+            nochrome          => $in->{nochrome},
         },
         contenttype => 'text/html',
         component   => 1,
@@ -2143,14 +2202,15 @@ sub sitemap ($query) {
     state $etag = "sitemap-" . time();
     my ( @to_map, $is_index, $route_type );
     my $warning = '';
-    $query->{map} //= '';
-    if ( $query->{map} eq 'static' ) {
+    my $in      = $query->{input};
+    my $map     = $in->{map} // '';
+    if ( $map eq 'static' ) {
 
         # Return the map of static routes
         $route_type = 'Static Routes';
         @to_map     = grep { CORE::index( $_, '{' ) < 0 && !$routes{$_}->{auth} && !$routes{$_}->{noindex} && !$routes{$_}->{nomap} } keys(%routes);
     }
-    elsif ( !$query->{map} ) {
+    elsif ( !$map ) {
 
         # Return the index instead
         @to_map = ('static');
@@ -2168,21 +2228,21 @@ sub sitemap ($query) {
         $is_index = 1;
     }
     else {
-        $route_type = "Posts: Page $query->{map}";
+        $route_type = "Posts: Page $map";
 
         # Return the map of the particular range of dynamic posts
         $query->{limit} = 50000;
-        $query->{page}  = $query->{map};
+        $query->{page}  = $map;
         @to_map         = post_helper( $query, [], ['public'] );
     }
 
-    if ( $query->{xml} ) {
+    if ( $in->{xml} ) {
         DEBUG("RENDER SITEMAP XML");
         my $sm;
         my $xml_date = time();
         my $fmt      = "xml";
-        $fmt .= ".gz" if $query->{compressed};
-        if ( !$query->{map} ) {
+        $fmt .= ".gz" if $in->{compressed};
+        if ( !$map ) {
             require WWW::SitemapIndex::XML;
             $sm = WWW::SitemapIndex::XML->new();
             foreach my $url (@to_map) {
@@ -2195,7 +2255,7 @@ sub sitemap ($query) {
         else {
             require WWW::Sitemap::XML;
             $sm = WWW::Sitemap::XML->new();
-            my $changefreq = $query->{map} eq 'static' ? 'monthly' : 'daily';
+            my $changefreq = $map eq 'static' ? 'monthly' : 'daily';
             foreach my $url (@to_map) {
                 my $true_uri = "http://$query->{domain}$url";
                 if ( ref $url eq 'HASH' ) {
@@ -2249,7 +2309,7 @@ sub sitemap ($query) {
         $xml->toFH( $buf, 0 );
         seek $buf, 0, 0;
 
-        if ( $query->{compressed} ) {
+        if ( $in->{compressed} ) {
             require IO::Compress::Gzip;
             my $compressed = IO::String->new();
             IO::Compress::Gzip::gzip( $buf => $compressed );
@@ -2292,10 +2352,10 @@ sub manual ($query) {
     require Capture::Tiny;
 
     #Fix links from Pod::HTML
-    $query->{module} =~ s/\.html$//g if $query->{module};
+    my $module = ( $query->{input}{module} // '' ) =~ s/\.html$//gr;
     $query->{failure} //= -1;
 
-    my $infile = $query->{module} ? "$query->{module}.pm" : 'tCMS/Manual.pod';
+    my $infile = $module ? "$module.pm" : 'tCMS/Manual.pod';
     my $found  = 0;
     foreach my $libdir (@INC) {
         $found = $libdir if -f "$libdir/$infile";    ## no critic (ProhibitFiletest_f) -- which @INC dir supplies the pod
@@ -2387,11 +2447,12 @@ sub sessions ($query) {
     return $query->{tpsgi}->see_also('/login') unless $query->{user};
     return $query->{tpsgi}->forbidden($query)  unless List::Util::any { $_ eq 'admin' } @{ $query->{user_acls} };
 
-    my $limit  = int( $query->{limit} // 100 );
+    my $in     = $query->{input};
+    my $limit  = int( $in->{limit} // 100 );
     my $events = Trog::Auth::audit_log(
         limit      => $limit,
-        username   => $query->{filter_user}  // '',
-        event_type => $query->{filter_event} // '',
+        username   => $in->{filter_user}  // '',
+        event_type => $in->{filter_event} // '',
     );
 
     # Format timestamps for display
@@ -2411,8 +2472,8 @@ sub sessions ($query) {
             is_admin     => 1,
             events       => $events,
             limit        => $limit,
-            filter_user  => $query->{filter_user}  // '',
-            filter_event => $query->{filter_event} // '',
+            filter_user  => $in->{filter_user}  // '',
+            filter_event => $in->{filter_event} // '',
             %$query,
         },
         undef,
@@ -2497,13 +2558,14 @@ sub guest_screenshot ($query) {
     return $query->{tpsgi}->see_also('/login') unless $query->{user};
     return $query->{tpsgi}->forbidden($query)  unless List::Util::any { $_ eq 'admin' } @{ $query->{user_acls} };
 
-    my $hypervisor = guest_hypervisor( $query, $query->{hypervisor} );
+    my $in         = $query->{input};
+    my $hypervisor = guest_hypervisor( $query, $in->{hypervisor} );
     return $query->{tpsgi}->notfound($query) unless $hypervisor;
 
     require Trog::DataSource::Virt;
-    my ( $path, $why ) = Trog::DataSource::Virt::screenshot( $hypervisor, $query->{guest} );
+    my ( $path, $why ) = Trog::DataSource::Virt::screenshot( $hypervisor, $in->{guest} );
     if ( !$path ) {
-        WARN("Could not screenshot '$query->{guest}': $why");
+        WARN("Could not screenshot '$in->{guest}': $why");
         return $query->{tpsgi}->notfound($query);
     }
 
@@ -2528,19 +2590,18 @@ sub guest_act ($query) {
     return $query->{tpsgi}->forbidden($query)  unless List::Util::any { $_ eq 'admin' } @{ $query->{user_acls} };
 
     my $to = $query->{to} || '/';
+    my $in = $query->{input};
 
-    my $hypervisor = guest_hypervisor( $query, $query->{hypervisor} );
+    my $hypervisor = guest_hypervisor( $query, $in->{hypervisor} );
     return feedback_redirect( $query, $to, 1, 'No such hypervisor.' ) unless $hypervisor;
 
     require Trog::DataSource::Virt;
 
-    # Same reason the screenshot route captures 'guest': a form field called
-    # 'domain' would be overwritten by the router with the request's host.
     my ( $ok, $message ) = Trog::DataSource::Virt::act(
-        $query->{action}, $hypervisor->{conn_uri}, $query->{guest}, $query->{user},
+        $in->{action}, $hypervisor->{conn_uri}, $in->{guest}, $query->{user},
     );
 
-    return feedback_redirect( $query, $to, $ok ? 0 : 1, "$query->{guest}: $message" );
+    return feedback_redirect( $query, $to, $ok ? 0 : 1, "$in->{guest}: $message" );
 }
 
 =head2 guest_reprovision
@@ -2566,28 +2627,26 @@ sub guest_reprovision ($query) {
     return $query->{tpsgi}->forbidden($query)  unless List::Util::any { $_ eq 'admin' } @{ $query->{user_acls} };
 
     my $to = $query->{to} || '/';
+    my $in = $query->{input};
 
     require Trog::DataSource::ProvisionedVirt;
 
-    # 'guest' rather than 'domain', for the reason the screenshot route captures
-    # it that way: the router overwrites $query->{domain} with the request's own
-    # host, so a form field by that name never arrives.
     my ( $ok, $message ) = Trog::DataSource::ProvisionedVirt::reprovision(
-        domain     => $query->{guest},
+        domain     => $in->{guest},
         user       => $query->{user},
-        passphrase => $query->{passphrase},
-        sudo       => $query->{sudo},
-        totp       => $query->{totp},
-        remember   => $query->{remember},
+        passphrase => $in->{passphrase},
+        sudo       => $in->{sudo},
+        totp       => $in->{totp},
+        remember   => $in->{remember},
     );
 
     # Deleted rather than merely unused: this hash is cloned, logged around and
     # handed to renderers, and neither of these has any business in any of that.
     # The code as much as the passphrase -- it is spent by now, but a spent code
     # in a log is still a code somebody wrote down.
-    delete $query->{$_} foreach qw{passphrase sudo totp};
+    delete $in->{$_} foreach qw{passphrase sudo totp};
 
-    return feedback_redirect( $query, $to, $ok ? 0 : 1, ( $query->{guest} // 'guest' ) . ": $message" );
+    return feedback_redirect( $query, $to, $ok ? 0 : 1, ( $in->{guest} // 'guest' ) . ": $message" );
 }
 
 =head2 guest_reprovision_log
@@ -2610,7 +2669,7 @@ sub guest_reprovision_log ($query) {
 
     require Trog::DataSource::ProvisionedVirt;
 
-    my $log = Trog::DataSource::ProvisionedVirt::log_for( $query->{guest} );
+    my $log = Trog::DataSource::ProvisionedVirt::log_for( $query->{input}{guest} );
     return $query->{tpsgi}->notfound($query) unless defined $log;
 
     return Trog::Renderer->render(
@@ -2652,13 +2711,11 @@ sub post_wizard ($query) {
 
     # Sticky checkboxes.  Nothing is submitted on a fresh GET, so fall back to
     # the defaults rather than rendering everything unticked.
-    my %checked = map { $_ => ( $query->{wizard_submitted} ? $query->{$_} : $wizard_checked_by_default{$_} ) ? 'checked' : '' } @wizard_checkboxes;
+    my $in      = $query->{input} // {};
+    my %checked = map { $_ => ( $query->{wizard_submitted} ? $in->{$_} : $wizard_checked_by_default{$_} ) ? 'checked' : '' } @wizard_checkboxes;
 
     return Trog::Routes::HTML::index(
         {
-            # Sticky form values first, so a failed submission doesn't lose the
-            # admin's work -- but everything the template actually depends on is
-            # set *after* this, as the whole of %$query is attacker controlled.
             %$query,
             title             => 'tCMS Post Wizard',
             theme_dir         => Trog::Themes::td(),
@@ -2668,14 +2725,14 @@ sub post_wizard ($query) {
             forms_dir         => Trog::Themes::forms_dir(),
             types             => wizard_types_json($forms),
             type_names        => [ map { ( $_ =~ s/\.tx$//r ) } @$forms ],
-            description       => wizard_description( $query->{description} ),
+            description       => wizard_description( $in->{description} ),
             datasources       => get_datasources(),
-            datasource        => wizard_scalar( $query->{datasource} ),
+            datasource        => wizard_scalar( $in->{datasource} ),
             checked           => \%checked,
-            name              => wizard_scalar( $query->{name} ),
-            title_placeholder => wizard_scalar( $query->{title_placeholder} ),
-            body_form         => wizard_scalar( $query->{body_form} ),
-            display           => wizard_scalar( $query->{display} ),
+            name              => wizard_scalar( $in->{name} ),
+            title_placeholder => wizard_scalar( $in->{title_placeholder} ),
+            body_form         => wizard_scalar( $in->{body_form} ),
+            display           => wizard_scalar( $in->{display} ),
             failure           => $query->{failure},
             message           => $query->{message} // '',
             to                => $query->{to}      // '',
@@ -2844,7 +2901,15 @@ sub post_wizard_save ($query) {
     # Anchored capture rather than a substitution, so anything containing a '/'
     # or a '..' simply fails to match instead of being silently scrubbed into
     # something that still escapes the directory.
-    my ($name) = wizard_scalar( $query->{name} ) =~ m/^([\w-]+)$/;
+    # Not up for discussion, so set whatever the form sent.  A post with no
+    # visibility gets an undef pushed into its tags by process, which is a tag
+    # nothing matches -- so the post is invisible to everyone but an admin, for
+    # no reason anybody chose.  A title and a set of acls are equally
+    # non-negotiable: every other form has them, and a type whose editor cannot
+    # set them produces posts nobody can title or restrict.
+    my %form = ( %{ $query->{input} }, map { $_ => 1 } @wizard_mandatory );
+
+    my ($name) = wizard_scalar( $form{name} ) =~ m/^([\w-]+)$/;
     return wizard_fail( $query, "Post type names may only contain letters, numbers, '-' and '_'." ) unless $name;
 
     # Whichever forms dir is in use: the theme's if it has one, the stock one
@@ -2863,41 +2928,33 @@ sub post_wizard_save ($query) {
 
     my $overwriting = -e $tx_file;
     return wizard_fail( $query, "Post type '$name.tx' already exists.  Tick 'Overwrite' if you meant to replace it." )
-      if $overwriting && !$query->{overwrite};
+      if $overwriting && !$form{overwrite};
 
     # Whitelisted against what is actually on disk: this name goes into a
     # sidecar and is require()d later, so a regex is not enough.
-    my $datasource = wizard_scalar( $query->{datasource} );
+    my $datasource = wizard_scalar( $form{datasource} );
     if ( $datasource && List::Util::none { $_ eq $datasource } @{ get_datasources() } ) {
         return wizard_fail( $query, "'$datasource' is not a datasource I can find." );
     }
 
-    my $fields = wizard_fields($query);
-
-    # Not up for discussion.  A post with no visibility gets an undef pushed
-    # into its tags by process, which is a tag nothing matches -- so the post
-    # is invisible to everyone but an admin, for no reason anybody chose.  A
-    # title and a set of acls are equally non-negotiable: every other form has
-    # them, and a type whose editor cannot set them produces posts nobody can
-    # title or restrict.
-    $query->{$_} = 1 foreach @wizard_mandatory;
+    my $fields = wizard_fields( \%form );
 
-    my @includes  = grep { $query->{$_} } @wizard_include_order;
-    my $body_form = wizard_scalar( $query->{body_form} ) eq 'form_multi.tx' ? 'form_multi.tx' : 'form_common.tx';
+    my @includes  = grep { $form{$_} } @wizard_include_order;
+    my $body_form = wizard_scalar( $form{body_form} ) eq 'form_multi.tx' ? 'form_multi.tx' : 'form_common.tx';
 
     my $spec = wizard_sidecar(
         $name, $fields, \@includes, $body_form, $datasource,
         {
-            description       => wizard_description( $query->{description} ),
-            display           => wizard_scalar( $query->{display} ),
-            title_placeholder => wizard_scalar( $query->{title_placeholder} ),
-            map { $_ => $query->{$_} ? 1 : 0 } qw{wrapper inc_post_title inc_post_tags},
+            description       => wizard_description( $form{description} ),
+            display           => wizard_scalar( $form{display} ),
+            title_placeholder => wizard_scalar( $form{title_placeholder} ),
+            map { $_ => $form{$_} ? 1 : 0 } qw{wrapper inc_post_title inc_post_tags},
         }
     );
 
     local $@;
     eval {
-        Path::Tiny::path($tx_file)->spew_utf8( wizard_template( $query, $fields, \@includes, $body_form, $datasource ) );
+        Path::Tiny::path($tx_file)->spew_utf8( wizard_template( \%form, $fields, \@includes, $body_form, $datasource ) );
         Path::Tiny::path($json_file)->spew_utf8( JSON::MaybeXS->new( pretty => 1, canonical => 1 )->encode($spec) );
         1;
     } or return wizard_fail( $query, "Failed to write post type '$name': $@" );
@@ -3412,7 +3469,7 @@ table, so a URL cannot name a list for this route to build.
 =cut
 
 sub bundle ($query) {
-    my $bundle = Trog::Bundle::served( map { "$_.js" } split( /[+]/, $query->{names} ) );
+    my $bundle = Trog::Bundle::served( map { "$_.js" } split( /[+]/, $query->{input}{names} ) );
     return $query->{tpsgi}->notfound($query) unless $bundle;
     return $query->{tpsgi}->redirect( $bundle->{href} ) if $query->{route} ne $bundle->{href};
     return [

+ 5 - 4
lib/Trog/Routes/JSON.pm

@@ -116,7 +116,7 @@ sub webmanifest {
 }
 
 sub process_auth_change_request ($query) {
-    my $token = $query->{token};
+    my $token = $query->{input}{token};
 
     my $msg = Trog::Auth::process_change_request($token);
     return $query->{tpsgi}->forbidden($query) unless $msg;
@@ -142,7 +142,7 @@ nobody's business here.
 =cut
 
 sub posts_of_form ($query) {
-    my @posts = Trog::Routes::HTML::post_helper( { form => $query->{form}, limit => 0 }, [], [ Trog::Routes::HTML::viewer_acls($query) ] );
+    my @posts = Trog::Routes::HTML::post_helper( { form => $query->{input}{form}, limit => 0 }, [], [ Trog::Routes::HTML::viewer_acls($query) ] );
 
     return _render(
         200, undef,
@@ -151,10 +151,11 @@ sub posts_of_form ($query) {
 }
 
 sub requests_per ($query) {
-    my $code = Trog::Utils::coerce_array( $query->{code} );
+    my $in   = $query->{input};
+    my $code = Trog::Utils::coerce_array( $in->{code} );
     return _render(
         200, undef,
-        %{ Trog::Log::Metrics::requests_per( $query->{period}, $query->{num_periods}, $query->{before}, @$code ) }
+        %{ Trog::Log::Metrics::requests_per( $in->{period}, $in->{num_periods}, $in->{before}, @$code ) }
     );
 }
 

+ 42 - 2
t/TCMS.t

@@ -12,6 +12,7 @@ use FindBin::libs;
 
 use Test::More;
 use Test::MockModule                   qw{strict};
+use Test::Fatal                        qw{exception};
 use JSON::MaybeXS                      ();
 use JSON::Validator::Schema::OpenAPIv3 ();
 
@@ -86,7 +87,7 @@ subtest 'an operation' => sub {
     is( ref $manual->{'x-tpsgi-callback'}, 'CODE', 'with a callback' );
     is( $manual->{'x-tcms-auth'},          1,      'and its auth as x-tcms-auth' );
     is_deeply(
-        $manual->{parameters},
+        [ grep { $_->{in} eq 'path' } @{ $manual->{parameters} } ],
         [ { name => 'module+', in => 'path', required => true, schema => { type => 'string' } } ],
         'and each variable of the template as a required path parameter'
     );
@@ -112,7 +113,7 @@ subtest 'the routes of posts' => sub {
 subtest 'where a redirect may send you' => sub {
     my $operation = $paths->{'/posts/p-one'}{get};
     my $to        = sub ($to) {
-        return $operation->{'x-tpsgi-callback'}->( { log_dir => "$ROOT/logs", verbose => 0 }, { route => '/posts/p-one', dispatcher => $operation, to => $to } )->[2][0];
+        return $operation->{'x-tpsgi-callback'}->( { log_dir => "$ROOT/logs", verbose => 0 }, { route => '/posts/p-one', dispatcher => $operation, input => { to => $to } } )->[2][0];
     };
     is( $to->('/config'),              '/config',     'a path on this site is kept' );
     is( $to->('https://bogus.test/x'), '/x',          'the path of another site is all that is kept of it' );
@@ -149,6 +150,45 @@ subtest 'validation' => sub {
     ok( scalar $schema->validate_request( [ get => '/api/requests_per' ], { query => { num_periods => 'many' } } ),      'as is a count that is not a number' );
 };
 
+subtest 'what a route takes from the request' => sub {
+    my $names = sub ( $operation, $in = 'query' ) {
+        return [ sort map { $_->{name} } grep { $_->{in} eq $in } @{ $operation->{parameters} // [] } ];
+    };
+
+    my @urchin = qw{utm_campaign utm_content utm_medium utm_source utm_term};
+    is_deeply(
+        $names->( $paths->{'/posts/p-one'}{get} ),
+        [ sort qw{like page limit version older newer format embed nochrome year month}, @urchin ],
+        'a page of posts takes its paging, search, feed and embed, and a campaign'
+    );
+    is_deeply( $names->( $paths->{'/humans.txt'}{get} ), $names->( $paths->{'/posts/p-one'}{get} ), 'and so does a route of posts that fixes its tag' );
+    ok( !( grep { $_ eq 'map' } @{ $names->( $paths->{'/sitemap/{map}.xml'}{get} ) } ), 'a name the path holds is not one of the query too' );
+    is_deeply( $names->( $paths->{'/sitemap/{map}.xml'}{get}, 'path' ), ['map'], 'but of the path' );
+    ok( !( grep { $_ eq 'title' || $_ eq 'scripts' || $_ eq 'template' } @{ $names->( $paths->{'/login'}{get} ) } ), 'the login page takes none of the names its page is made of' );
+
+    my $form = sub ($operation) { return $operation->{requestBody}{content}{'application/x-www-form-urlencoded'}{schema} };
+    is_deeply( [ sort keys( %{ $form->( $paths->{'/auth'}{post} )->{properties} } ) ], [qw{app contact_email display_name password to token username}], 'a login takes the fields of its form' );
+    ok( !exists $form->( $paths->{'/auth'}{post} )->{additionalProperties},                   'and no others' );
+    ok( $form->( $paths->{'/post/save'}{post} )->{additionalProperties},                      'a post takes the fields of its type too' );
+    ok( exists $form->( $paths->{'/config/save'}{post} )->{properties}{'general.data_model'}, 'the configuration takes each setting by its name' );
+    ok( !$paths->{'/post/save'}{post}{parameters},                                            'and a POST takes nothing in its query' );
+};
+
+subtest 'tPSGI routes by the document' => sub {
+    plan skip_all => 'tPSGI is not installed' unless eval { require TPSGI::Router; 1 };
+
+    my $router;
+    is( exception { $router = TPSGI::Router->new( 'TCMS.pm' => \%openapi ) }, undef, 'which it can compile, so no route gives a name to two places' );
+    return unless $router;
+
+    my $login = $router->match( 'GET', '/login' );
+    is_deeply( [ $router->validate( $login, query => { to => '/config' } ) ], [], 'the login page takes where to go next' );
+    my ($status) = $router->validate( $login, query => { scripts => '//bogus.test/x.js' } );
+    is( $status, 400, 'and nothing it does not declare' );
+    ($status) = $router->validate( $router->match( 'POST', '/auth' ), body => { username => 'x', title => '<b>x</b>' }, content_type => 'application/x-www-form-urlencoded' );
+    is( $status, 400, 'nor does a login' );
+};
+
 subtest 'robots' => sub {
     my $banned;
     my $renderer = Test::MockModule->new('Trog::Renderer');

+ 2 - 2
t/Trog-Auth-totp-mandatory.t

@@ -131,11 +131,11 @@ subtest 'a login without an enrolment is sent to enrol' => sub {
     $auth->redefine( mksession => sub { return 'a-session' } );
     $auth->redefine( has_totp  => sub { return 0 } );
 
-    Trog::Routes::HTML::login( { username => 'bob', password => 'hunter2', to => '/config', tpsgi => $tpsgi, scheme => 'http', route => '/auth' } );
+    Trog::Routes::HTML::login( { input => { username => 'bob', password => 'hunter2' }, to => '/config', tpsgi => $tpsgi, scheme => 'http', route => '/auth' } );
     is( $rendered->{to}, '/totp', 'a login with nothing enrolled goes to enrol, not where it was headed' );
 
     $auth->redefine( has_totp => sub { return 1 } );
-    Trog::Routes::HTML::login( { username => 'bob', password => 'hunter2', to => '/config', tpsgi => $tpsgi, scheme => 'http', route => '/auth' } );
+    Trog::Routes::HTML::login( { input => { username => 'bob', password => 'hunter2' }, to => '/config', tpsgi => $tpsgi, scheme => 'http', route => '/auth' } );
     is( $rendered->{to}, '/config', 'and one with a second factor goes where it was headed' );
 };
 

+ 3 - 3
t/Trog-DataSource-ProvisionedVirt.t

@@ -565,14 +565,14 @@ subtest 'the route' => sub {
     # The passphrase must not survive the request: this hash gets cloned, logged
     # around and handed to renderers.
     @SPAWNED = ();
-    my $query = { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, guest => 'guest.test.test', passphrase => 'hunter2', sudo => 'letmein', to => '/vms' };
+    my $query = { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, to => '/vms', input => { guest => 'guest.test.test', passphrase => 'hunter2', sudo => 'letmein' } };
     Trog::Routes::HTML::guest_reprovision($query);
     is( scalar(@SPAWNED),                  1,         'an admin gets their run' );
     is( $SPAWNED[0]{credentials}{keepass}, 'hunter2', 'the passphrase reaches the provisioner' );
     is( $SPAWNED[0]{credentials}{sudo},    'letmein', 'and so does the sudo password' );
 
     # This hash is cloned, logged around and handed to renderers.
-    ok( !exists $query->{$_}, "the $_ is gone from the query afterwards" ) foreach qw{passphrase sudo totp};
+    ok( !exists $query->{input}{$_}, "the $_ is gone from the input afterwards" ) foreach qw{passphrase sudo totp};
 
     # The log route.  It lives in logs/ rather than under www/, and the output
     # of a provisioner is a fine place for a hostname or an IP plan to turn up.
@@ -584,7 +584,7 @@ subtest 'the route' => sub {
     $res = Trog::Routes::HTML::guest_reprovision_log( { user => 'bob', user_acls => ['public'], tpsgi => $tpsgi } );
     is( $res->[0], 403, 'a non-admin cannot read it' );
 
-    $res = Trog::Routes::HTML::guest_reprovision_log( { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, guest => 'never.test.test' } );
+    $res = Trog::Routes::HTML::guest_reprovision_log( { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, input => { guest => 'never.test.test' } } );
     is( $res->[0], 404, 'and a guest with no log is a 404 rather than an empty page' );
 };
 

+ 10 - 0
t/Trog-Routes-HTML-series-lifecycle.t

@@ -265,8 +265,18 @@ our $ADMIN_PASS    = 'spec-password';
 
 # Always build a fresh query: posts() pushes onto user_acls, and series()
 # rewrites route/aclname/tag/in_series/primary_post in place.
+# What tPSGI and the dispatcher set on a query.  Anything else a test passes is
+# the input of the request, as tPSGI hands it over in $query->{input}.
+my %set_on_query = map { $_ => 1 } qw{route method scheme domain start deflate streaming ranges last_fetched has_query social_meta user_acls body tpsgi user fullpath cookies dispatcher primary_post};
+
 my sub query {
     my (%over) = @_;
+    my %input = map { $_ => delete $over{$_} } grep { !$set_on_query{$_} } keys(%over);
+
+    # The dispatcher keeps the path of where the request asks to go next.
+    $over{to}    = $input{to} if defined $input{to};
+    $over{input} = \%input;
+
     my %q = (
         route        => '/',
         method       => 'GET',

+ 4 - 4
t/Trog-Vault.t

@@ -396,18 +396,18 @@ subtest 'the page a user manages them from' => sub {
     is_deeply( $rendered->{secrets}, [], "and eve sees none of bob's" );
 
     # Storing, and the thing stored not surviving the request that stored it.
-    my $query = { user => 'eve', tpsgi => $tpsgi, name => 'ssh', secret => 'a passphrase' };
+    my $query = { user => 'eve', tpsgi => $tpsgi, input => { name => 'ssh', secret => 'a passphrase' } };
     my $res   = Trog::Routes::HTML::secrets_save($query);
     is( $res->[0], 303, 'saving redirects back to the page' );
-    ok( !exists $query->{secret}, 'and the secret is gone from the query afterwards' );
+    ok( !exists $query->{input}{secret}, 'and the secret is gone from the input afterwards' );
     is( Trog::Vault::get( 'eve', 'ssh' ), 'a passphrase', 'having been stored' );
 
-    $res = Trog::Routes::HTML::secrets_forget( { user => 'eve', tpsgi => $tpsgi, name => 'ssh' } );
+    $res = Trog::Routes::HTML::secrets_forget( { user => 'eve', tpsgi => $tpsgi, input => { name => 'ssh' } } );
     is( $res->[0],                        303,   'forgetting redirects too' );
     is( Trog::Vault::get( 'eve', 'ssh' ), undef, 'and it is gone' );
 
     # One user cannot reach into another's, whatever they post.
-    Trog::Routes::HTML::secrets_forget( { user => 'eve', tpsgi => $tpsgi, name => 'keepass' } );
+    Trog::Routes::HTML::secrets_forget( { user => 'eve', tpsgi => $tpsgi, input => { name => 'keepass' } } );
     is( Trog::Vault::get( 'bob', 'keepass' ), 'correct horse battery staple', "bob's is untouched by eve asking to forget it" );
 };