Просмотр исходного кода

New post type wizard done, along with other fixes

Mostly vibecoded with claude
George Baugh 2 месяцев назад
Родитель
Сommit
1e9fa8c988

+ 1 - 0
Makefile.PL

@@ -38,6 +38,7 @@ WriteMakefile(
     'IO::Compress::Gzip'     => '0',
     'IO::Compress::Gzip'     => '0',
     'Imager::QRCode'         => '0',
     'Imager::QRCode'         => '0',
     'JSON::MaybeXS'          => '0',
     'JSON::MaybeXS'          => '0',
+    'JSON::Validator::Schema::Troglodyne' => '0',
     'List::Util'             => '0',
     'List::Util'             => '0',
     'Mojo::File'             => '0',
     'Mojo::File'             => '0',
     'POSIX'                  => '0',
     'POSIX'                  => '0',

+ 137 - 17
bin/tcms-useradd

@@ -11,6 +11,7 @@ use Pod::Usage;
 use List::Util qw{first};
 use List::Util qw{first};
 
 
 use Trog::Auth;
 use Trog::Auth;
+use Trog::Log;
 use Trog::Data;
 use Trog::Data;
 use Trog::Config;
 use Trog::Config;
 
 
@@ -19,9 +20,13 @@ $ENV{NOHUP} = 1;
 
 
 =head1 SYNOPSIS
 =head1 SYNOPSIS
 
 
-Add or edit a tCMS user.  In the event of a user edit, every option save for user is optional.
+Add or edit a tCMS user.  In the event of a user edit, every option save for user is optional,
+and the ones you leave out keep whatever value they already had.
 
 
-does not update user pages.  Use this to reset user passwords or fix broken users.
+Creating a user who does not exist yet needs --password, --display_name and --contact_email,
+as there is no existing profile post to inherit them from.
+
+Use this to reset user passwords or fix broken users.
 
 
 =head2 USAGE
 =head2 USAGE
 
 
@@ -37,7 +42,20 @@ Specify the user to add, or edit if the user already exists.
 
 
 =item --password
 =item --password
 
 
-Set a password for the user.  Leave blank if you want to keep the password for an existing user.
+Set a password for the user.  Omit the option entirely if you want to keep the
+password an existing user already has.
+
+Passing --password with no value prompts for one instead, with echo off, which
+keeps it out of your shell history and off your screen:
+
+    tcms-useradd --user foo --password           # prompts, twice, to confirm
+
+It can also be piped in, in which case it is read once and not confirmed:
+
+    echo hunter2 | tcms-useradd --user foo --password
+
+Note that setting a password kills any session the user currently holds, so
+they will have to log back in.
 
 
 =item --display_name
 =item --display_name
 
 
@@ -60,6 +78,53 @@ Display this output.
 
 
 =cut
 =cut
 
 
+# The logger is set up per-request by the server; a CLI run has to do it itself,
+# or the first thing Trog::Auth logs blows up on an undefined $log.
+sub _init_logs {
+    my ($who) = @_;
+    Trog::Log::log_init( 'logs/tcms.log', 'info' );
+    $Trog::Log::user = $who // 'nobody';
+    return;
+}
+
+# Read a password off the terminal without echoing it, so it stays out of the
+# shell history and off the screen.  Falls back to a plain read when we aren't
+# on a TTY, which is what makes `echo hunter2 | tcms-useradd ...` work.
+sub _read_password {
+    my ($prompt) = @_;
+    my $password;
+    if ( -t STDIN ) {
+        print STDERR $prompt;
+        require Term::ReadKey;
+        Term::ReadKey::ReadMode('noecho');
+        $password = <STDIN>;
+        Term::ReadKey::ReadMode('restore');
+        print STDERR "\n";
+    }
+    else {
+        $password = <STDIN>;
+    }
+    chomp $password if defined $password;
+    return $password;
+}
+
+# What a brand new user's profile post looks like before we merge anything over
+# the top of it.  Mirrors what setup() builds for the very first user.
+sub _default_user_post {
+    return {
+        data       => 'Default user',
+        preview    => '/img/avatar/humm.gif',
+        wallpaper  => '/img/sys/testpattern.jpg',
+        tags       => ['about'],
+        visibility => 'public',
+        acls       => ['admin'],
+        callback   => 'Trog::Routes::HTML::users',
+        method     => 'GET',
+        form       => 'profile.tx',
+        aliases    => [],
+    };
+}
+
 sub main {
 sub main {
     my %options;
     my %options;
     Getopt::Long::GetOptionsFromArray(
     Getopt::Long::GetOptionsFromArray(
@@ -67,40 +132,95 @@ sub main {
         'user=s'          => \$options{user},
         'user=s'          => \$options{user},
         'display_name=s'  => \$options{display_name},
         'display_name=s'  => \$options{display_name},
         'help|?'          => \$options{help},
         'help|?'          => \$options{help},
-        'password=s'      => \$options{password},
+        'password:s'      => \$options{password},
         'contact_email=s' => \$options{contact_email},
         'contact_email=s' => \$options{contact_email},
         'acl=s@'          => \$options{acl},
         'acl=s@'          => \$options{acl},
     );
     );
     pod2usage( -exitval => 0, -verbose => 1 ) if $options{help};
     pod2usage( -exitval => 0, -verbose => 1 ) if $options{help};
 
 
+    unless ( $options{user} ) {
+        print STDERR "--user is required.\n";
+        return 2;
+    }
+
+    # --password with no value means "ask me", as opposed to leaving the option
+    # out entirely, which means "leave the password alone".
+    if ( defined $options{password} && !length $options{password} ) {
+        my $interactive = -t STDIN;
+        $options{password} = _read_password("New password for '$options{user}': ");
+
+        unless ( defined $options{password} && length $options{password} ) {
+            print STDERR "No password provided.\n";
+            return 2;
+        }
+
+        # Only worth confirming when a human is typing it.  Piped input has no
+        # second line to read, and typos in a script aren't fixed by asking twice.
+        if ($interactive) {
+            my $again = _read_password("Confirm: ");
+            unless ( defined $again && $options{password} eq $again ) {
+                print STDERR "Passwords did not match.\n";
+                return 2;
+            }
+        }
+    }
+
     $options{acl} //= [];
     $options{acl} //= [];
     $options{acl} = [ $options{acl} ] if $options{acl} && ref $options{acl} ne 'ARRAY';
     $options{acl} = [ $options{acl} ] if $options{acl} && ref $options{acl} ne 'ARRAY';
     $options{acl} = ['admin'] unless @{ $options{acl} };
     $options{acl} = ['admin'] unless @{ $options{acl} };
 
 
-    Trog::Auth::killsession( $options{user} );
-    eval { Trog::Auth::useradd( $options{user}, $options{display_name}, $options{password}, $options{acl}, $options{contact_email} ) } or do {
-        return 1;
-    };
+    _init_logs( $options{user} );
 
 
-    # Find the user's post and edit it
+    # Find the user's existing post, if they have one.
     my $conf = Trog::Config::get();
     my $conf = Trog::Config::get();
     my $data = Trog::Data->new($conf);
     my $data = Trog::Data->new($conf);
 
 
     my @userposts = $data->get( tags => ['about'], acls => [qw{admin}] );
     my @userposts = $data->get( tags => ['about'], acls => [qw{admin}] );
     my $user_obj  = first { ( $_->{user} || '' ) eq $options{user} } @userposts;
     my $user_obj  = first { ( $_->{user} || '' ) eq $options{user} } @userposts;
-    my %merged    = (
+
+    # A user we've never seen before has no post to edit, so start from the
+    # defaults rather than dereferencing undef.
+    my $is_new = !$user_obj;
+    $user_obj //= _default_user_post();
+
+    if ($is_new) {
+        unless ( $options{password} && $options{display_name} && $options{contact_email} ) {
+            print STDERR "User '$options{user}' does not exist yet.\n";
+            print STDERR "Creating one needs --password, --display_name and --contact_email.\n";
+            return 2;
+        }
+    }
+
+    # Only the options actually passed should win.  Merging %options wholesale
+    # writes an undef over every field the caller didn't mention, which is how
+    # resetting a password used to blank out the display name and contact email.
+    my %provided = map { $_ => $options{$_} } grep { defined $options{$_} } qw{user display_name contact_email};
+
+    my %merged = (
         %$user_obj,
         %$user_obj,
-        %options,
-        $options{display_name} ? ( local_href => "/users/$options{display_name}" ) : ( local_href => $user_obj->{local_href} ),
+        %provided,
     );
     );
 
 
-    # We don't want the password in plain text
-    delete $merged{password};
+    # The display name is the title of the post, and the URL it lives at.
+    $merged{title}      = $merged{display_name}          if $merged{display_name};
+    $merged{local_href} = "/users/$merged{display_name}" if $merged{display_name};
+
+    Trog::Auth::killsession( $options{user} );
+
+    local $@;
+    eval { Trog::Auth::useradd( $options{user}, $merged{display_name}, $options{password}, $options{acl}, $merged{contact_email} ); 1 } or do {
+        print STDERR "Could not add user '$options{user}': $@";
+        return 1;
+    };
 
 
-    # The ACLs a user posesses is not necessarily what ACLs you need to view or edit a user's profile.
-    delete $merged{acl};
+    eval { $data->add( \%merged ); 1 } or do {
+        my @errors = ref $@ eq 'ARRAY' ? @{$@} : ($@);
+        print STDERR "Saved the credentials for '$options{user}', but their profile post was rejected:\n";
+        print STDERR join( "\n", @errors ), "\n";
+        return 1;
+    };
 
 
-    $data->add( \%merged );
+    print "@{[ $is_new ? 'Added' : 'Updated' ]} user '$options{user}'.\n";
     return 0;
     return 0;
 }
 }
 
 

+ 140 - 0
bin/tcmslogin

@@ -0,0 +1,140 @@
+#!/usr/bin/env perl
+
+package tcms::login;
+
+use strict;
+use warnings;
+
+use FindBin::libs;
+use Getopt::Long;
+use Pod::Usage;
+
+use Trog::Auth;
+use Trog::Log;
+
+# Don't murder our terminal when done
+$ENV{NOHUP} = 1;
+
+=head1 SYNOPSIS
+
+Log a user in and print the resulting session cookie, so that you can hand it
+to call.pl and exercise authenticated routes without a browser.
+
+=head2 USAGE
+
+    tcmslogin --user foo --password bar
+
+    # Which is meant to be used like this:
+    call.pl GET /admin/wyzzerdd '' "$(tcmslogin --user foo --password bar)"
+
+The cookie is printed to STDOUT and nothing else is, so it is safe to capture.
+Everything else the script has to say goes to STDERR.
+
+Note that logging in wastes any session the user already holds -- the session
+table keeps one row per user -- so running this will log that user out of their
+browser.
+
+=head2 OPTIONS
+
+=over 4
+
+=item --user
+
+The user to log in.  Required.
+
+=item --password
+
+That user's password.  Leaving the option out, or passing it with no value,
+reads it from STDIN instead, which keeps it out of your shell history:
+
+    tcmslogin --user foo             # prompts, with echo off
+    tcmslogin --user foo --password  # same
+    echo hunter2 | tcmslogin --user foo
+
+=item --token
+
+The user's current TOTP code, if they have 2FA enabled.
+
+=item --raw
+
+Print just the session id, without the C<tcmslogin=> prefix.
+
+=item --help, --man
+
+Display this output.
+
+=back
+
+=cut
+
+# The logger is set up per-request by the server; a CLI run has to do it itself,
+# or the first thing Trog::Auth logs blows up on an undefined $log.
+sub _init_logs {
+    my ($who) = @_;
+    Trog::Log::log_init( 'logs/tcms.log', 'info' );
+    $Trog::Log::user = $who // 'nobody';
+    return;
+}
+
+sub _read_password {
+    my ($prompt) = @_;
+    my $password;
+    if ( -t STDIN ) {
+        print STDERR $prompt;
+        require Term::ReadKey;
+        Term::ReadKey::ReadMode('noecho');
+        $password = <STDIN>;
+        Term::ReadKey::ReadMode('restore');
+        print STDERR "\n";
+    }
+    else {
+        $password = <STDIN>;
+    }
+    chomp $password if defined $password;
+    return $password;
+}
+
+sub main {
+    my %options;
+    Getopt::Long::GetOptionsFromArray(
+        \@_,
+        'user=s'     => \$options{user},
+        'password:s' => \$options{password},
+        'token=s'    => \$options{token},
+        'raw'        => \$options{raw},
+        'help|?'     => \$options{help},
+    );
+    pod2usage( -exitval => 0, -verbose => 1 ) if $options{help};
+
+    unless ( $options{user} ) {
+        print STDERR "--user is required.\n";
+        return 2;
+    }
+
+    _init_logs( $options{user} );
+
+    # Omitted entirely or passed with no value both mean "ask me".
+    $options{password} = _read_password("Password for '$options{user}': ")
+      unless defined $options{password} && length $options{password};
+    unless ( defined $options{password} && length $options{password} ) {
+        print STDERR "No password provided.\n";
+        return 2;
+    }
+
+    # mksession() tells us nothing about *why* it said no, by design -- it is
+    # the same '' for an unknown user, a bad password and a bad TOTP code.
+    my $session = Trog::Auth::mksession( $options{user}, $options{password}, $options{token}, 'tcmslogin' );
+    unless ($session) {
+        print STDERR "Login failed for '$options{user}'.\n";
+        print STDERR "Check the password, and pass --token if this user has 2FA enabled.\n";
+        print STDERR "Use bin/tcms-useradd --user $options{user} --password ... to reset it.\n";
+        return 1;
+    }
+
+    print $options{raw} ? "$session\n" : "tcmslogin=$session\n";
+    return 0;
+}
+
+exit main(@ARGV) unless caller;
+
+1;

+ 261 - 102
lib/Trog/DataModule.pm

@@ -10,13 +10,18 @@ use File::Copy;
 use Path::Tiny();
 use Path::Tiny();
 use Ref::Util();
 use Ref::Util();
 use File::Basename qw{basename};
 use File::Basename qw{basename};
+use File::Slurper();
+use JSON::MaybeXS();
+use JSON::Validator::Schema::Troglodyne();
+
+use Trog::Themes();
 
 
 use Trog::Log qw{:all};
 use Trog::Log qw{:all};
 use Trog::Utils;
 use Trog::Utils;
 use Trog::Auth();
 use Trog::Auth();
 
 
 no warnings 'experimental';
 no warnings 'experimental';
-use feature qw{signatures};
+use feature qw{signatures state};
 
 
 =head1 QUERY FORMAT
 =head1 QUERY FORMAT
 
 
@@ -50,6 +55,38 @@ sub new ( $class, $config ) {
     return bless( $config, $class );
     return bless( $config, $class );
 }
 }
 
 
+=head1 ABSTRACT METHODS
+
+Subclasses must implement all of these; the stubs here do nothing but die.
+count() belongs to this set as well, and is documented with the rest of the
+querying interface below.
+
+=head2 lang() = STRING $language
+
+The name of the query language this data model understands, shown to the user
+beside the search bar.
+
+=head2 help() = STRING $help
+
+Documentation for that query language, shown to the user alongside it.
+
+=head2 read($query) = ARRAYREF $posts
+
+Every post the data model holds, as an arrayref of hashrefs.  Filtering is
+get()'s job unless your storage engine can do it more cheaply itself, in which
+case honour $query here and override get() too.
+
+=head2 write($posts)
+
+Commit an arrayref of posts to storage.  Called by add() once the posts have
+been filtered and validated; don't validate again here.
+
+=head2 tags() = ARRAYREF $tags
+
+Every tag known to the datastore, for building tag pickers.
+
+=cut
+
 #It is required that subclasses implement this
 #It is required that subclasses implement this
 sub lang  ($self)                { ... }
 sub lang  ($self)                { ... }
 sub help  ($self)                { ... }
 sub help  ($self)                { ... }
@@ -145,6 +182,17 @@ sub _filter_param ( $query, $param, @filtered ) {
     return @filtered;
     return @filtered;
 }
 }
 
 
+=head2 filter($query, @posts) = @filtered
+
+Apply a get() request's filters to a list of posts: tags, exclude_tags, acls,
+visibility, id, title, form, search terms and author.
+
+The ACL check is the load bearing one -- a post is only visible if the caller
+holds one of its acls, or the post is public or unlisted.  Callers holding the
+'admin' acl skip that filter entirely.
+
+=cut
+
 sub filter ( $self, $query, @filtered ) {
 sub filter ( $self, $query, @filtered ) {
     $query->{acls}         //= [];
     $query->{acls}         //= [];
     $query->{tags}         //= [];
     $query->{tags}         //= [];
@@ -205,6 +253,13 @@ sub filter ( $self, $query, @filtered ) {
     return @filtered;
     return @filtered;
 }
 }
 
 
+=head2 paginate($query, @posts) = @page
+
+The slice of @posts named by the request's page and limit.  Both have to be
+present to page at all; limit defaults to 25 when computing the offset.
+
+=cut
+
 sub paginate ( $self, $query, @filtered ) {
 sub paginate ( $self, $query, @filtered ) {
     my $offset = int( $query->{limit} // 25 );
     my $offset = int( $query->{limit} // 25 );
     $offset   = @filtered < $offset ? @filtered : $offset;
     $offset   = @filtered < $offset ? @filtered : $offset;
@@ -257,120 +312,224 @@ If any post already exists with the same id, a new post with a version higher th
 
 
 Passes an array of new posts to add to the data store module's write() function.
 Passes an array of new posts to add to the data store module's write() function.
 
 
-These will have their parameters filtered to those present in the %schema hash.
+These will have their parameters filtered to those described by the post type's
+schema (see schema_for()), and then validated against it.  If the post doesn't
+hold up, this dies with an ARRAYREF of validation error strings -- a ref rather
+than a string so that Carp::Always doesn't staple a stack trace onto something
+we intend to show the user.
 
 
 You probably won't want to override this.
 You probably won't want to override this.
 
 
 =cut
 =cut
 
 
-my $not_ref = sub {
-    return !Ref::Util::is_ref(shift);
-};
-
-my $valid_cb = sub {
-    my $subname = shift;
-    my ($modname) = $subname =~ m/^([\w|:]+)::\w+$/;
-
-    # Modules always return 0 if they succeed!
-    eval { require $modname; } and do {
-        WARN("Post uses a callback whos module ($modname) cannot be found!");
-        return 0;
-    };
-
-    no strict 'refs';
-    my $ref = eval '\&' . $subname;
-    use strict;
-    return Ref::Util::is_coderef($ref);
-};
-
-my $hashref_or_string = sub {
-    my $subj = shift;
-    return Ref::Util::is_hashref($subj) || $not_ref->($subj);
-};
-
-my $arrayref_or_string = sub {
-    my $subj = shift;
-    return Ref::Util::is_arrayref($subj) || $not_ref->($subj);
-};
-
-# TODO more strict validation of strings?
-our %schema = (
-    ## Parameters which must be in every single post
-    'title'      => $not_ref,
-    'callback'   => $valid_cb,
-    'tags'       => \&Ref::Util::is_arrayref,
-    'version'    => $not_ref,
-    'visibility' => $not_ref,
-    'aliases'    => \&Ref::Util::is_arrayref,
-
-    # title links here
-    'href' => $not_ref,
-
-    # Link to post locally
-    'local_href' => $not_ref,
-
-    # Post body
-    'data' => $arrayref_or_string,
-
-    # How do I edit this post?
-    'form' => $not_ref,
-
-    # Post is restricted to visibility to these ACLs if not public/unlisted
-    'acls' => \&Ref::Util::is_arrayref,
-    'id'   => $not_ref,
-
-    # Author of the post
-    'user'    => $not_ref,
-    'created' => $not_ref,
-
-    # Specific to various posts below.
-
-    ## Series specific parameters
-    'child_form' => $not_ref,
-    'aclname'    => $not_ref,
-    'tiled'      => $not_ref,
-
-    ## User specific parameters
-    'user_acls'      => \&Ref::Util::is_arrayref,
-    'username'       => $not_ref,
-    'display_name'   => $not_ref,
-    'contact_email'  => $not_ref,
-    'wallpaper_file' => $hashref_or_string,
-    'wallpaper'      => $not_ref,
-
-    ## user avatar, but does double duty in content posts as preview images on videos, etc
-    'preview_file' => $hashref_or_string,
-    'preview'      => $not_ref,
-
-    ## Content specific parameters
-    'audio_href'  => $not_ref,
-    'video_href'  => $not_ref,
-    'file'        => $hashref_or_string,
-    'attachments' => \&Ref::Util::is_arrayref,
-    'header'      => $not_ref,
-    'footer'      => $not_ref,
-
-    ## Entity details, which are used for constructing invoices
-    'payment_details' => $not_ref,
-    'payment_method'  => $not_ref,
-
-    ## Invoice specific
-    'payee'    => $not_ref,
-    'payor'    => $not_ref,
-    'due_days' => $not_ref,
+# The shape of a post, as an OpenAPIv3 object schema.
+#
+# This covers the fields every post has regardless of which form produced it.
+# Anything a *particular* post type ingests lives in that type's JSON sidecar
+# next to its template in the forms directory -- see _schema_for().
+our %post_schema = (
+    type       => 'object',
+    properties => {
+
+        ## Parameters which must be in every single post
+        title      => { type => 'string' },
+        callback   => { type => 'callback' },
+        tags       => { type => 'array',   items   => { type => 'string' } },
+        version    => { type => 'integer', minimum => 0 },
+        visibility => { type => 'string',  enum    => [qw{public unlisted private}] },
+        aliases    => { type => 'array',   items   => { type => 'string' } },
+
+        # title links here
+        href => { type => 'string' },
+
+        # Link to post locally
+        local_href => { type => 'string' },
+
+        # Post body.  Multi-page types (presentations, invoices) send an array.
+        data => {
+            oneOf => [
+                { type => 'string' },
+                { type => 'array', items => { type => 'string' } },
+            ],
+        },
+
+        # How do I edit this post?
+        form => { type => 'string' },
+
+        # Post is restricted to visibility to these ACLs if not public/unlisted
+        acls => { type => 'array', items => { type => 'string' } },
+        id   => { type => 'string' },
+
+        # Author of the post
+        user    => { type => 'string' },
+        created => { type => 'integer' },
+
+        # Posts are always GET, but it's stored, so it has to be describable.
+        method => { type => 'string' },
+    },
+);
+
+# Uploads arrive as an HTTP::Body hashref on the way in, and as the href string
+# they were turned into on the way back out of the datastore.  Sidecars say
+# 'upload' and mean this.
+our %upload_schema = (
+    oneOf => [
+        { type => 'string' },
+        { type => 'object' },
+    ],
 );
 );
 
 
+=head2 schema_for($form)
+
+Return the OpenAPIv3 schema describing what the named post type ingests: the
+base post schema above, with the type's JSON sidecar merged over the top.
+
+Sidecars live beside their templates (blog.tx has blog.json) and are written
+either by hand or by the Post Type Wizard.  A post with no form, or a form with
+no sidecar, just gets the base schema.
+
+Cached, but keyed on the mtimes of the component dirs rather than memoized
+outright: adding or editing a sidecar bumps one of them, so every forked worker
+picks the change up on its very next add() with no restart needed.
+
+=cut
+
+sub schema_for ( $form = '' ) {
+    state %cache;
+
+    # Both dirs, since either one of them gaining a sidecar changes the answer.
+    my $generation = join( ':', map { ( stat($_) )[9] // 0 } Trog::Themes::template_dirs( 'text/html', 1 ) );
+
+    # Only ever keep the current generation around.
+    %cache = () unless exists $cache{$generation};
+
+    # A signature default only covers an absent arg, and plenty of posts have
+    # an explicitly undef form.
+    $form = '' if !defined $form || ref $form;
+    my ($type) = $form =~ m/^([A-Za-z0-9_-]+)\.tx$/;
+    $type //= '';
+    return $cache{$generation}{$type} if exists $cache{$generation}{$type};
+
+    my %merged = ( %post_schema, properties => { %{ $post_schema{properties} } } );
+
+    # Themed like the templates themselves: a theme that overrides blog.tx can
+    # ship its own blog.json alongside it, and one that doesn't still gets the
+    # stock schema rather than nothing at all.
+    my $path    = $type ? Trog::Themes::themed_file_in_dir( 'forms', "$type.json", 'text/html', 1 ) : undef;
+    my $sidecar = $path ? _read_sidecar($path)                                                      : undef;
+    if ($sidecar) {
+
+        # The base schema is merged *last*, so a sidecar can add fields but can
+        # never redefine one of ours.  That matters: a sidecar retyping
+        # 'callback' as a plain string would defeat the check that the sub it
+        # names actually exists, which is a privilege problem rather than a
+        # cosmetic one.
+        %{ $merged{properties} } = ( %{ $sidecar->{properties} // {} }, %{ $merged{properties} } );
+
+        # A sidecar may insist on its own fields, but it can't relax anything
+        # the base schema already demands.
+        my @required = List::Util::uniq( @{ $post_schema{required} // [] }, @{ $sidecar->{required} // [] } );
+
+        # An empty required list isn't legal OpenAPI, so don't emit one.
+        $merged{required} = \@required if @required;
+    }
+
+    $cache{$generation}{$type} = \%merged;
+    return $cache{$generation}{$type};
+}
+
+sub _read_sidecar ($path) {
+    return undef unless -f $path;
+
+    local $@;
+    my $spec = eval { JSON::MaybeXS::decode_json( File::Slurper::read_text($path) ) };
+    if ( !$spec ) {
+        WARN("Could not parse post type sidecar '$path': $@");
+        return undef;
+    }
+    return undef unless Ref::Util::is_hashref($spec);
+
+    _expand_uploads($spec);
+    return $spec;
+}
+
+# Sugar: a sidecar says {"type":"upload"} rather than spelling out the
+# string-or-hashref dance every upload field would otherwise need.  Uploads are
+# a hashref on the way in from the browser and the href string they became on
+# the way back out of the datastore.
+sub _expand_uploads ($node) {
+    return unless Ref::Util::is_hashref($node);
+
+    if ( ( $node->{type} // '' ) eq 'upload' ) {
+        delete $node->{type};
+        %$node = ( %$node, %upload_schema );
+        return;
+    }
+
+    _expand_uploads($_) foreach values( %{ $node->{properties} // {} } );
+    _expand_uploads( $node->{items} ) if $node->{items};
+    foreach my $key (qw{oneOf anyOf allOf}) {
+        next unless Ref::Util::is_arrayref( $node->{$key} );
+        _expand_uploads($_) foreach @{ $node->{$key} };
+    }
+    return;
+}
+
+=head2 validate($post)
+
+Filter a post down to the fields its type actually describes, then run what's
+left through the schema.
+
+Returns the list of validation errors, which is empty when the post is good.
+Note that filtering happens first and silently: the query hash handed to us by
+the router is full of things which aren't post fields at all, and never was
+meant to round-trip.
+
+=cut
+
+sub validate ($post) {
+    state $validator;
+    $validator //= JSON::Validator::Schema::Troglodyne->new();
+
+    my $schema = schema_for( $post->{form} );
+
+    foreach my $key ( keys(%$post) ) {
+
+        # Drop everything the schema doesn't describe.
+        my $property = $schema->{properties}{$key};
+        if ( !$property ) {
+            delete $post->{$key};
+            next;
+        }
+
+        # An untouched form input submits the empty string, which is HTML for
+        # "I wasn't filled in" rather than a number, a boolean or a file.  We
+        # leave it alone for actual string fields, which is what got stored
+        # before any of this was typed.
+        delete $post->{$key}
+          if defined $post->{$key}
+          && !ref $post->{$key}
+          && $post->{$key} eq ''
+          && ( $property->{type} // '' ) ne 'string';
+    }
+
+    # OpenAPIv3 coerces as it goes, so "4" lands in the datastore as 4 and
+    # checkbox values land as real booleans.
+    return $validator->validate( $post, $schema );
+}
+
 sub add ( $self, @posts ) {
 sub add ( $self, @posts ) {
     my @to_write;
     my @to_write;
 
 
     foreach my $post (@posts) {
     foreach my $post (@posts) {
 
 
-        # Filter all the irrelevant data
-        foreach my $key ( keys(%$post) ) {
+        # Filter to what this post type actually describes, then validate it.
+        my @errors = validate($post);
 
 
-            # We need to have the key in the schema, and it validate.
-            delete $post->{$key} unless List::Util::any { ( $_ eq $key ) && ( $schema{$key}->( $post->{$key} ) ) } keys(%schema);
-        }
+        # An arrayref rather than a string on purpose.  These errors get shown
+        # to whoever submitted the post, and Carp::Always decorates a string
+        # die with a stack trace they have no use for.
+        die [ map { "$_" } @errors ] if @errors;
 
 
         $post->{id}      //= Trog::Utils::uuid();
         $post->{id}      //= Trog::Utils::uuid();
         $post->{aliases} //= [];
         $post->{aliases} //= [];

+ 48 - 4
lib/Trog/Renderer/Base.pm

@@ -24,6 +24,30 @@ Sets up the methods which must be present for all templates, e.g. render_it for
 
 
 our %renderers;
 our %renderers;
 
 
+=head2 render(%options)
+
+Render a template and build the PSGI response around it.
+
+Recognized options:
+
+    template    => the template's filename, relative to the template dir.  Required.
+    contenttype => what we're rendering, e.g. 'text/html'.  Picks the template dir.
+    component   => render a component rather than a whole page.  Returns just the
+                   body string, with no headers and no compression.
+    data        => the variables handed to the template.
+    code        => HTTP status for the response.
+    headers     => extra headers, merged over the computed ones.
+    deflate     => gzip the body and set Content-Encoding.
+    post_processor => CODEREF run over the body before headers are computed, for
+                   things like minifiers.
+    child_processor / child_renderer => override how post bodies are rendered by
+                   the render_it() template function.  Built for you if omitted.
+
+Dies unless the template exists.  Returns the body string for components, and a
+PSGI arrayref of [ code, headers, body ] otherwise.
+
+=cut
+
 sub render (%options) {
 sub render (%options) {
     die "Templated renders require a template to be passed" unless $options{template};
     die "Templated renders require a template to be passed" unless $options{template};
 
 
@@ -31,8 +55,14 @@ sub render (%options) {
     my $t            = "$template_dir/$options{template}";
     my $t            = "$template_dir/$options{template}";
     die "Templated renders require an existing template to be passed, got $template_dir/$options{template}" unless -f $t || -s $t;
     die "Templated renders require an existing template to be passed, got $template_dir/$options{template}" unless -f $t || -s $t;
 
 
+    # Xslate resolves includes against every dir in path, first match winning,
+    # so handing it both lets a theme override individual templates without
+    # having to fork the ones next to them.  With no theme configured this is
+    # just the stock dir, exactly as it was.
+    my @template_dirs = Trog::Themes::template_dirs( $options{contenttype}, $options{component} );
+
     #TODO make this work with posts all the time
     #TODO make this work with posts all the time
-    $options{child_processor} //= Text::Xslate->new( path => $template_dir );
+    $options{child_processor} //= Text::Xslate->new( path => \@template_dirs );
     my $child_processor = $options{child_processor};
     my $child_processor = $options{child_processor};
     $options{child_renderer} //= sub {
     $options{child_renderer} //= sub {
         my ( $template_string, $options ) = @_;
         my ( $template_string, $options ) = @_;
@@ -42,15 +72,18 @@ sub render (%options) {
         return $out ? $out : $template_string;
         return $out ? $out : $template_string;
     };
     };
 
 
-    $renderers{$template_dir} //= Text::Xslate->new(
-        path     => $template_dir,
+    # Keyed on the whole search path, not just the winning dir: two different
+    # paths that happen to start with the same dir are not the same renderer.
+    my $renderer_key = join( ':', @template_dirs );
+    $renderers{$renderer_key} //= Text::Xslate->new(
+        path     => \@template_dirs,
         function => {
         function => {
             render_it => $options{child_renderer},
             render_it => $options{child_renderer},
         },
         },
     );
     );
 
 
     my $code = $options{code};
     my $code = $options{code};
-    my $body = encode_utf8( $renderers{$template_dir}->render( $options{template}, $options{data} ) );
+    my $body = encode_utf8( $renderers{$renderer_key}->render( $options{template}, $options{data} ) );
 
 
     # Users can supply a post_processor to futz with the output (such as with minifiers) if they wish.
     # Users can supply a post_processor to futz with the output (such as with minifiers) if they wish.
     $body = $options{post_processor}->($body) if $options{post_processor} && ref $options{post_processor} eq 'CODE';
     $body = $options{post_processor}->($body) if $options{post_processor} && ref $options{post_processor} eq 'CODE';
@@ -70,6 +103,17 @@ sub render (%options) {
     return [ $code, [%headers], [$dfh] ];
     return [ $code, [%headers], [$dfh] ];
 }
 }
 
 
+=head2 headers($options, $body) = %headers
+
+The response headers for a rendered body: content type and length, caching,
+Server-Timing, and the security headers (CSP, X-Frame-Options, HSTS when we're
+on https, nosniff).  Anything in $options->{headers} is merged over the top.
+
+ETags are only set for logged in users, as everyone else is served out of the
+static render cache.
+
+=cut
+
 sub headers ( $options, $body ) {
 sub headers ( $options, $body ) {
     my $query   = $options->{data};
     my $query   = $options->{data};
     my $uh      = ref $options->{headers} eq 'HASH'      ? $options->{headers}        : {};
     my $uh      = ref $options->{headers} eq 'HASH'      ? $options->{headers}        : {};

+ 451 - 17
lib/Trog/Routes/HTML.pm

@@ -12,6 +12,7 @@ use File::Touch();
 use File::Basename qw{basename};
 use File::Basename qw{basename};
 use List::Util();
 use List::Util();
 use List::MoreUtils();
 use List::MoreUtils();
+use Ref::Util();
 use Capture::Tiny qw{capture};
 use Capture::Tiny qw{capture};
 use HTML::SocialMeta;
 use HTML::SocialMeta;
 
 
@@ -19,6 +20,7 @@ use Clone qw{clone};
 use Encode qw{encode_utf8};
 use Encode qw{encode_utf8};
 use Digest::MD5 qw{md5_hex};
 use Digest::MD5 qw{md5_hex};
 use Digest::SHA qw{sha256_hex};
 use Digest::SHA qw{sha256_hex};
+use JSON::MaybeXS();
 use IO::Compress::Gzip;
 use IO::Compress::Gzip;
 use Path::Tiny();
 use Path::Tiny();
 use File::Basename qw{dirname};
 use File::Basename qw{dirname};
@@ -169,6 +171,13 @@ our %routes = (
         noindex  => 1,
         noindex  => 1,
         nocache  => 1,
         nocache  => 1,
     },
     },
+    '/admin/wyzzerdd/save' => {
+        method   => 'POST',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::post_wizard_save,
+        noindex  => 1,
+        nocache  => 1,
+    },
 
 
     #TODO transform into posts?
     #TODO transform into posts?
     '/sitemap',
     '/sitemap',
@@ -878,7 +887,15 @@ sub post_save ($qq) {
     state $data;
     state $data;
     $data //= Trog::Data->new(Trog::Config::get());
     $data //= Trog::Data->new(Trog::Config::get());
 
 
-    $data->add($query) and die "Could not add post";
+    # A post that doesn't match its type's schema is the user's mistake, not a
+    # server error, so hand the validation errors back rather than dying.
+    local $@;
+    eval { $data->add($query); 1 } or do {
+        my @errors = Ref::Util::is_arrayref($@) ? @{$@} : ($@);
+        my $why    = "Post failed validation:\n" . join( "\n", @errors );
+        WARN("Rejected post from $qq->{user}: $why");
+        return $qq->{tpsgi}->badrequest( $qq, $why );
+    };
 
 
     # Instruct tpsgi to invalidate the cached render.
     # Instruct tpsgi to invalidate the cached render.
     $qq->{tpsgi}->add_post_close_callback(sub {
     $qq->{tpsgi}->add_post_close_callback(sub {
@@ -913,13 +930,12 @@ sub profile ($query) {
 
 
     my $user_obj = List::Util::first { ( $_->{user} || '' ) eq $query->{username} } @userposts;
     my $user_obj = List::Util::first { ( $_->{user} || '' ) eq $query->{username} } @userposts;
 
 
-    if ( $query->{username} ne $user_obj->{user} || $query->{password} || $query->{contact_email} ne $user_obj->{contact_email} || $query->{display_name} ne $user_obj->{display_name} ) {
-        my $for_user = Trog::Auth::acls4user( $query->{username} );
-
-        #TODO support non-admin users
-        my @acls = @$for_user ? @$for_user : qw{admin};
-        Trog::Auth::useradd( $query->{username}, $query->{display_name}, $query->{password}, \@acls, $query->{contact_email} );
-    }
+    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
     #Make sure it is "self-authored", redact pw
     $query->{user} = delete $query->{username};
     $query->{user} = delete $query->{username};
@@ -934,6 +950,25 @@ sub profile ($query) {
         $query->{display_name} ? ( local_href => "/users/$query->{display_name}" ) : ( local_href => $user_obj->{local_href} ),
         $query->{display_name} ? ( local_href => "/users/$query->{display_name}" ) : ( local_href => $user_obj->{local_href} ),
     );
     );
 
 
+    # Validate before touching the auth database, not after.  useradd() does an
+    # INSERT OR REPLACE on the user row, which cascades to the session table and
+    # logs the user straight out -- doing that and *then* rejecting the post
+    # leaves the credentials disagreeing with the profile post they came from.
+    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");
+        return $query->{tpsgi}->badrequest( $query, $why );
+    }
+
+    if ($changed) {
+        my $for_user = Trog::Auth::acls4user($username);
+
+        #TODO support non-admin users
+        my @acls = @$for_user ? @$for_user : qw{admin};
+        Trog::Auth::useradd( $username, $merged{display_name}, $password, \@acls, $merged{contact_email} );
+    }
+
     return post_save( \%merged );
     return post_save( \%merged );
 }
 }
 
 
@@ -1220,8 +1255,8 @@ sub posts ( $query, $direct = 0 ) {
     return $header if ref $footer eq 'ARRAY';
     return $header if ref $footer eq 'ARRAY';
 
 
     # List the available headers/footers
     # List the available headers/footers
-    my $headers = Trog::Themes::templates_in_dir( "headers", 'text/html', 1 );
-    my $footers = Trog::Themes::templates_in_dir( "footers", 'text/html', 1 );
+    my $headers = Trog::Themes::themed_templates_in_dir( "headers", 'text/html', 1 );
+    my $footers = Trog::Themes::themed_templates_in_dir( "footers", 'text/html', 1 );
 
 
     #XXX used to be post.css, but probably not good anymore?
     #XXX used to be post.css, but probably not good anymore?
     my $styles = [];
     my $styles = [];
@@ -1266,7 +1301,7 @@ sub posts ( $query, $direct = 0 ) {
         $_
         $_
     } _post_helper( {}, ['series'], $query->{user_acls} );
     } _post_helper( {}, ['series'], $query->{user_acls} );
 
 
-    my $forms = Trog::Themes::templates_in_dir( "forms", 'text/html', 1 );
+    my $forms = Trog::Themes::themed_templates_in_dir( "forms", 'text/html', 1 );
 
 
     my $edittype = $query->{primary_post} ? $query->{primary_post}->{child_form}          : $query->{form};
     my $edittype = $query->{primary_post} ? $query->{primary_post}->{child_form}          : $query->{form};
     my $tiled    = $query->{primary_post} ? !$is_admin && $query->{primary_post}->{tiled} : 0;
     my $tiled    = $query->{primary_post} ? !$is_admin && $query->{primary_post}->{tiled} : 0;
@@ -1658,6 +1693,16 @@ sub manual ($query) {
     );
     );
 }
 }
 
 
+=head2 processed
+
+Implements /processed.
+
+The "we got it, go check your email" page, shown after a request that finishes
+out of band -- password and TOTP resets, chiefly.  Deliberately says nothing
+about whether the account existed.
+
+=cut
+
 sub processed ($query) {
 sub processed ($query) {
     return Trog::Routes::HTML::index(
     return Trog::Routes::HTML::index(
         {
         {
@@ -1669,6 +1714,16 @@ sub processed ($query) {
     );
     );
 }
 }
 
 
+=head2 metrics
+
+Implements /metrics.  Admin only.
+
+Renders the request metrics dashboard.  The page pulls its actual numbers from
+/api/requests_per over XHR and draws them with chart.js, so there's nothing to
+compute here.
+
+=cut
+
 sub metrics ($query) {
 sub metrics ($query) {
     return $query->{tpsgi}->see_also('/login')                    unless $query->{user};
     return $query->{tpsgi}->see_also('/login')                    unless $query->{user};
     return $query->{tpsgi}->forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
     return $query->{tpsgi}->forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
@@ -1689,6 +1744,20 @@ sub metrics ($query) {
     );
     );
 }
 }
 
 
+=head2 sessions
+
+Implements /sessions.  Admin only.
+
+The session audit log: logins, logouts and failures, most recent first.
+Filterable by username and event type via the filter_user and filter_event
+parameters, and capped by limit (100 by default).
+
+Timestamps are formatted here rather than in the template, and session ids are
+truncated to their first 8 characters -- there's no reason to put a live
+session id on a page.
+
+=cut
+
 sub sessions ($query) {
 sub sessions ($query) {
     return $query->{tpsgi}->see_also('/login')  unless $query->{user};
     return $query->{tpsgi}->see_also('/login')  unless $query->{user};
     return $query->{tpsgi}->forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
     return $query->{tpsgi}->forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
@@ -1726,35 +1795,377 @@ sub sessions ($query) {
     );
     );
 }
 }
 
 
+# The canned includes the wizard is willing to splice into a generated form.
+# Deliberately a whitelist keyed on the checkbox name -- the include name ends
+# up interpolated straight into a template file, so it can never come from the
+# submitted data.
+our %wizard_includes = (
+    inc_preview     => 'preview.tx',
+    inc_visibility  => 'visibility.tx',
+    inc_acls        => 'acls.tx',
+    inc_tags        => 'tags.tx',
+    inc_aliases     => 'aliases.tx',
+    inc_attachments => 'attachments.tx',
+);
+
+# Emitted in this order, so generated forms read like the hand-written ones.
+our @wizard_include_order = qw{inc_preview inc_visibility inc_acls inc_tags inc_aliases inc_attachments};
+
+# Every checkbox on the wizard, and which of them start out ticked.
+our @wizard_checkboxes         = ( qw{wrapper inc_post_title inc_post_tags inc_title_input}, @wizard_include_order, 'overwrite' );
+our %wizard_checked_by_default = map { $_ => 1 } qw{wrapper inc_post_title inc_post_tags inc_title_input inc_preview inc_visibility inc_acls inc_tags inc_aliases};
+
+# Which HTML input the wizard emits, and what that field is in the sidecar's
+# OpenAPIv3 schema.  No 'file': _process() in Trog::DataModule only knows how to
+# turn the four hardcoded upload fields into hrefs, so a custom file field would
+# store an HTTP::Body::OctetStream that nothing ever picks up.  Point folks at
+# the attachment uploader instead.
+our %wizard_field_types = (
+    text     => { type => 'string' },
+    url      => { type => 'string' },
+    date     => { type => 'string' },
+    textarea => { type => 'string' },
+    number   => { type => 'integer' },
+    checkbox => { type => 'boolean' },
+);
+
+# Field names which aren't in %schema but would still collide with something.
+our @wizard_reserved = qw{app to form data_is_array addpost extra_tags is_image is_video is_audio is_profile content_type version};
+
+=head2 post_wizard
+
+Implements /admin/wyzzerdd.  Admin only.
+
+The post type wizard: lists the post types which currently exist and offers a
+form for building a new one.  See post_wizard_save() for what happens when that
+form comes back.
+
+Re-rendered by post_wizard_save() on both success and failure, so it keeps the
+submitted values and checkbox states rather than making the admin retype
+everything after a rejected submission.
+
+=cut
+
 sub post_wizard ($query) {
 sub post_wizard ($query) {
     return $query->{tpsgi}->see_also('/login')  unless $query->{user};
     return $query->{tpsgi}->see_also('/login')  unless $query->{user};
     return $query->{tpsgi}->forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
     return $query->{tpsgi}->forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
 
 
+    $query->{failure} //= -1;
+
     # Get the existing post types
     # Get the existing post types
-    my $forms = Trog::Themes::templates_in_dir( "forms", 'text/html', 1 );
+    my $forms = Trog::Themes::themed_templates_in_dir( "forms", 'text/html', 1 );
+
+    # 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;
 
 
     return Trog::Routes::HTML::index(
     return Trog::Routes::HTML::index(
         {
         {
-            title        => 'tCMS Post Wizard',
-            theme_dir    => Trog::Themes::td(),
-            template     => 'post_wizard.tx',
-            is_admin     => 1,
-            forms        => $forms,
+            # 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,
             %$query,
+            title             => 'tCMS Post Wizard',
+            theme_dir         => Trog::Themes::td(),
+            template          => 'post_wizard.tx',
+            is_admin          => 1,
+            forms             => $forms,
+            forms_dir         => Trog::Themes::forms_dir(),
+            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} ),
+            failure           => $query->{failure},
+            message           => $query->{message} // '',
+            to                => $query->{to}      // '',
         },
         },
         undef,
         undef,
         ['post.css'],
         ['post.css'],
     );
     );
 }
 }
 
 
+=head2 post_wizard_save
+
+Implements POST /admin/wyzzerdd/save.
+
+Writes a new post type template into whichever forms directory is currently in
+use, along with a JSON sidecar naming its custom fields so that
+Trog::DataModule::schema_for will merge it over the base post schema, so the
+type's own fields survive validation at save time.
+
+=cut
+
+sub post_wizard_save ($query) {
+    return $query->{tpsgi}->see_also('/login')  unless $query->{user};
+    return $query->{tpsgi}->forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
+
+    # 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/^([A-Za-z0-9_-]+)$/;
+    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
+    # otherwise.  Never mkdir it -- silently conjuring a forms/ dir inside a
+    # theme is not something an admin asked for by pressing this button.
+    my $dir = Trog::Themes::forms_dir();
+    return _wizard_fail( $query, "Forms directory '$dir' does not exist, or is not writable." ) unless -d $dir && -w $dir;
+
+    my $tx_file   = "$dir/$name.tx";
+    my $json_file = "$dir/$name.json";
+
+    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};
+
+    my $fields    = _wizard_fields($query);
+    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 $spec = _wizard_sidecar( $name, $fields, \@includes, $body_form );
+
+    local $@;
+    eval {
+        Path::Tiny::path($tx_file)->spew_utf8( _wizard_template( $query, $fields, \@includes, $body_form ) );
+        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': $@" );
+
+    INFO("Post type '$name' written to $tx_file by $query->{user}");
+
+    # Nothing else is needed to publish a new type: themed_templates_in_dir()
+    # re-reads
+    # the directory every call, and Xslate resolves includes lazily, so both the
+    # wizard's own list and the series child_form dropdown pick it up on the
+    # next request without a restart.  Overwriting an *existing* type is another
+    # matter -- that changes how already-rendered anonymous pages should look.
+    if ($overwriting) {
+        $query->{tpsgi}->add_post_close_callback(
+            sub {
+                $query->{tpsgi}->invalidate_renders('html');
+            }
+        );
+    }
+
+    return post_wizard(
+        {
+            %$query,
+            wizard_submitted => 1,
+            failure          => 0,
+            message          => "Created post type '$name.tx'.",
+            to               => '/admin/wyzzerdd',
+        }
+    );
+}
+
+# Repeated params arrive as arrayrefs; anywhere we want one string, insist on one string.
+sub _wizard_scalar ( $value = '' ) {
+    return '' if !defined $value || ref $value;
+    return $value;
+}
+
+sub _wizard_fail ( $query, $message ) {
+    WARN($message);
+    return post_wizard(
+        {
+            %$query,
+            wizard_submitted => 1,
+            failure          => 1,
+            message          => $message,
+        }
+    );
+}
+
+=head2 _kolon_safe
+
+Make a submitted string safe to splice into a generated Kolon template.
+Newlines have to go, as a ':' at the start of a line is a Kolon directive, and
+the angle brackets have to go so that '<:' can never be reassembled.
+
+=cut
+
+sub _kolon_safe ( $string = '' ) {
+    $string = _wizard_scalar($string);
+    $string =~ s/[\r\n]+/ /g;
+    $string =~ s/&/&amp;/g;
+    $string =~ s/</&lt;/g;
+    $string =~ s/>/&gt;/g;
+    $string =~ s/"/&quot;/g;
+    return $string;
+}
+
+=head2 _wizard_fields
+
+Zip the parallel param_* arrays submitted by the wizard back into a list of
+field definitions.  Every row always submits exactly one of each param, so the
+arrays stay aligned even when a row is left blank.
+
+=cut
+
+sub _wizard_fields ($query) {
+    my $names  = Trog::Utils::coerce_array( $query->{param_name} );
+    my $types  = Trog::Utils::coerce_array( $query->{param_type} );
+    my $labels = Trog::Utils::coerce_array( $query->{param_label} );
+    my $phs    = Trog::Utils::coerce_array( $query->{param_placeholder} );
+    my $reqs   = Trog::Utils::coerce_array( $query->{param_required} );
+
+    my ( @fields, %seen );
+    foreach my $index ( 0 .. $#$names ) {
+        my ($fname) = lc( _wizard_scalar( $names->[$index] ) ) =~ m/^([a-z][a-z0-9_]{0,31})$/;
+
+        # Blank and bogus rows just get dropped.
+        next unless $fname;
+        next if $seen{$fname}++;
+
+        # Never let a custom field shadow a core post attribute.
+        next if exists $Trog::DataModule::post_schema{properties}{$fname};
+        next if grep { $fname eq $_ } @wizard_reserved;
+
+        my $type = _wizard_scalar( $types->[$index] );
+        $type = 'text' unless $wizard_field_types{$type};
+
+        push(
+            @fields,
+            {
+                name        => $fname,
+                type        => $type,
+                label       => _kolon_safe( $labels->[$index] ) || ucfirst($fname),
+                placeholder => _kolon_safe( $phs->[$index] ),
+                required    => _wizard_scalar( $reqs->[$index] ) ? 1 : 0,
+            }
+        );
+    }
+    return \@fields;
+}
+
+=head2 _wizard_sidecar
+
+Turn the wizard's field list into the post type's JSON sidecar: an OpenAPIv3
+object schema describing what this type ingests, with the wizard's own UI
+metadata hung off x- extension keys so there's only one file to keep in sync.
+
+Trog::DataModule::schema_for() merges this over the base post schema.
+
+=cut
+
+sub _wizard_sidecar ( $name, $fields, $includes, $body_form ) {
+    my %properties;
+    my @required;
+
+    foreach my $field (@$fields) {
+        my $property = { %{ $wizard_field_types{ $field->{type} } } };
+        $property->{'x-tcms-input'} = $field->{type};
+        $property->{'x-tcms-label'} = $field->{label};
+        $property->{'x-tcms-placeholder'} = $field->{placeholder} if length $field->{placeholder};
+
+        $properties{ $field->{name} } = $property;
+        push( @required, $field->{name} ) if $field->{required};
+    }
+
+    my %spec = (
+        'x-tcms-post-type' => {
+            name      => $name,
+            generated => JSON::MaybeXS::true(),
+            generator => 'Trog::Routes::HTML::post_wizard_save',
+            body_form => $body_form,
+            includes  => [ map { $wizard_includes{$_} } @$includes ],
+        },
+        type       => 'object',
+        properties => \%properties,
+    );
+    $spec{required} = \@required if @required;
+
+    return \%spec;
+}
+
+sub _wizard_field_html ($field) {
+    my ( $n, $t, $l, $p ) = @$field{qw{name type label placeholder}};
+    my $required = $field->{required} ? 'required ' : '';
+
+    return qq|            $l<br /><textarea ${required}class="cooltext" name="$n" placeholder="$p"><: \$post.$n :></textarea>\n|
+      if $t eq 'textarea';
+
+    return qq|            <label for="<: \$post.id :>-$n">$l<input id="<: \$post.id :>-$n" class="coolcb" type="checkbox" name="$n" value="1" <: if ( \$post.$n ) { "checked" } :> /></label><br />\n|
+      if $t eq 'checkbox';
+
+    return qq|            $l<br /><input ${required}class="cooltext" type="$t" name="$n" placeholder="$p" value="<: \$post.$n :>" />\n|;
+}
+
+=head2 _wizard_template
+
+Assemble the actual .tx file, following the shape of the hand-written forms:
+a display half guarded by !$post.addpost, and an edit half guarded by $can_edit.
+
+=cut
+
+sub _wizard_template ( $query, $fields, $includes, $body_form ) {
+    my $placeholder = _kolon_safe( $query->{title_placeholder} ) || 'Iowa Man Destroys Moon';
+
+    # NOT escaped, on purpose.  Letting an admin write template code is the
+    # entire point of the wizard, and it is no more privileged than the data
+    # field of any post, which already gets run through render_it().  Both
+    # sides of this route are behind the admin ACL.
+    my $display = _wizard_scalar( $query->{display} );
+
+    my $out = "<!-- Generated by the tCMS Post Type Wizard.  Regenerating this type will overwrite any hand edits. -->\n";
+    $out .= qq|<div class="post <: \$style :>">\n| if $query->{wrapper};
+    $out .= qq|    : if ( !\$post.addpost ) {\n|;
+    $out .= qq|        : include "post_title.tx";\n| if $query->{inc_post_title};
+    $out .= qq|        : include "post_tags.tx";\n|  if $query->{inc_post_tags};
+    $out .= "$display\n"                             if $display;
+    $out .= qq|    : }\n\n|;
+    $out .= qq|    : if ( \$can_edit ) {\n|;
+    $out .= qq|        <div class="postedit">\n|;
+    $out .= qq|        : include "edit_head.tx";\n|;
+    $out .= qq|        <form class="Submissions" action="/post/save" method="POST" enctype="multipart/form-data">\n|;
+    $out .= qq|            Title *<br /><input required class="cooltext" type="text" name="title" placeholder="$placeholder" value="<: \$post.title :>" />\n|
+      if $query->{inc_title_input};
+    $out .= _wizard_field_html($_) foreach @$fields;
+
+    foreach my $include (@$includes) {
+        $out .= qq|            : include "$wizard_includes{$include}";\n|;
+    }
+
+    # Always last, and never optional: this is what carries the post body, the
+    # app/to/id/form hiddens and the submit button.
+    $out .= qq|            : include "$body_form";\n|;
+    $out .= qq|        </form>\n|;
+    $out .= qq|        : include "edit_foot.tx";\n|;
+    $out .= qq|        </div>\n|;
+    $out .= qq|    : }\n|;
+    $out .= qq|</div>\n| if $query->{wrapper};
+    return $out;
+}
+
 
 
 # basically a file rewrite rule for themes
 # basically a file rewrite rule for themes
+=head2 icon
+
+Implements the /img/icon/* routes.
+
+Serves a themed icon, falling back to the stock one.  A rewrite rule with extra
+steps, essentially, so that themes can replace individual icons.
+
+=cut
+
 sub icon ($query) {
 sub icon ($query) {
     my $path = $query->{route};
     my $path = $query->{route};
     my $tpath = Trog::Themes::themed("img/icon/$path");
     my $tpath = Trog::Themes::themed("img/icon/$path");
     return $query->{tpsgi}->serve( $path, $tpath, $query->{start}, $query->{streaming}, $query->{ranges}, $query->{last_fetched}, $query->{deflate}  );
     return $query->{tpsgi}->serve( $path, $tpath, $query->{start}, $query->{streaming}, $query->{ranges}, $query->{last_fetched}, $query->{deflate}  );
 }
 }
 
 
+=head2 totp_qr
+
+Implements the /totp/* routes.
+
+Serves the QR code generated when a user enables TOTP.  These live outside
+www/, so they go out through serve() rather than the static file handler --
+handing someone else's enrolment QR out to the world would be handing them the
+shared secret.
+
+=cut
+
 sub totp_qr ($query) {
 sub totp_qr ($query) {
     my $fname = basename($query->{route});
     my $fname = basename($query->{route});
     # For authenticated content, we have to now do a serve().
     # For authenticated content, we have to now do a serve().
@@ -1769,6 +2180,16 @@ sub totp_qr ($query) {
     );
     );
 }
 }
 
 
+=head2 rss_style
+
+Implements /styles/rss-style.xsl.
+
+The XSL stylesheet which makes the RSS feed legible in a browser.  The header
+and footer are rendered here and passed in as strings because the output is
+XSL rather than HTML, so an include directive isn't available.
+
+=cut
+
 sub rss_style ($query) {
 sub rss_style ($query) {
     $query->{port}       = ":$query->{port}" if $query->{port};
     $query->{port}       = ":$query->{port}" if $query->{port};
     $query->{title}      = qq{<xsl:value-of select="rss/channel/title"/>};
     $query->{title}      = qq{<xsl:value-of select="rss/channel/title"/>};
@@ -1796,6 +2217,19 @@ sub _build_themed_scripts ($scripts) {
     return \@scripts;
     return \@scripts;
 }
 }
 
 
+=head2 finish_render($template, $vars, %headers)
+
+Render a full page, as opposed to a component.
+
+Fills in the defaults every page needs (lang, title, status code, content type,
+cache control), resolves stylesheet and script names through the theme, makes
+their paths absolute, renders the header and footer, and hands the lot to
+Trog::Renderer.
+
+Just about everything in this module ends up here; index() is the usual way in.
+
+=cut
+
 sub finish_render ( $template, $vars, %headers ) {
 sub finish_render ( $template, $vars, %headers ) {
 
 
     #XXX default vars that need to be pulled from config
     #XXX default vars that need to be pulled from config

+ 195 - 13
lib/Trog/Themes.pm

@@ -19,6 +19,17 @@ Utility functions for getting themed paths.
 
 
 our $template_dir = 'www/templates';
 our $template_dir = 'www/templates';
 
 
+=head2 routes() = %routes
+
+The active theme's own routing table, from its routes.pm.
+
+Themes can add routes of their own by shipping a routes.pm which populates
+%Theme::routes.  Returns an empty list when there's no theme, or when the theme
+hasn't got one.  Dies if there is a routes.pm and it won't load, as silently
+serving a site with half its routes missing is worse than not starting.
+
+=cut
+
 sub routes {
 sub routes {
     my $rdir = get_dir();
     my $rdir = get_dir();
     return () unless -f "$rdir/routes.pm";
     return () unless -f "$rdir/routes.pm";
@@ -31,6 +42,18 @@ sub routes {
     return %Theme::routes;
     return %Theme::routes;
 }
 }
 
 
+=head2 get_dir() = STRING $dir
+
+The active theme's directory, relative to the tCMS root, e.g.
+C<www/themes/mytheme>.  Empty string when no theme is configured, or when the
+configured one doesn't exist on disk -- so it's safe to interpolate either way,
+and truthiness is the test for "is there a theme".
+
+Memoized for the life of the process: changing the theme goes through
+/config/save, which signals the workers to restart.
+
+=cut
+
 sub get_dir {
 sub get_dir {
     state $tdir = '';
     state $tdir = '';
     return $tdir if $tdir;
     return $tdir if $tdir;
@@ -43,18 +66,60 @@ sub get_dir {
     return $tdir;
     return $tdir;
 };
 };
 
 
+=head2 td() = STRING $dir
+
+get_dir() as a rooted URL path rather than a filesystem one, for templates to
+build asset links with.  Empty string when there's no theme.
+
+=cut
+
 sub td {
 sub td {
     my $dir = get_dir();
     my $dir = get_dir();
     return $dir ? "/$dir" : '';
     return $dir ? "/$dir" : '';
 }
 }
 
 
-sub template_dir ( $template, $content_type, $is_component = 0, $is_dir = 0 ) {
-    my $ct = $Trog::Vars::byct{$content_type};
-    my ( $mtd, $mtemp ) = ( get_dir() . "/templates/$ct", "$template_dir/$ct" );
+# The theme's template dir and the stock one, in that order of preference.
+# The theme entry is empty when no theme is configured, so callers can just
+# grep for truth rather than repeating the "is there a theme" dance.
+sub _template_dirs ( $content_type, $is_component = 0 ) {
+    my $ct    = $Trog::Vars::byct{$content_type};
+    my $theme = get_dir();
+
+    my ( $themed, $stock ) = ( $theme ? "$theme/templates/$ct" : '', "$template_dir/$ct" );
     if ($is_component) {
     if ($is_component) {
-        $mtd   .= "/components";
-        $mtemp .= "/components";
+        $themed .= "/components" if $themed;
+        $stock  .= "/components";
     }
     }
+    return ( $themed, $stock );
+}
+
+=head2 template_dirs($content_type, $is_component)
+
+Every directory which could satisfy a template, most specific first.
+
+Hand this to Text::Xslate as its C<path> and a theme gets to override
+individual templates without having to fork every last one of them.
+
+=cut
+
+sub template_dirs ( $content_type, $is_component = 0 ) {
+    return grep { $_ && -d $_ } _template_dirs( $content_type, $is_component );
+}
+
+=head2 template_dir($template, $content_type, $is_component, $is_dir)
+
+The single directory which owns $template: the theme's if it has its own copy,
+the stock one otherwise.  With $is_dir, $template names a subdirectory (forms,
+headers, footers) rather than a file.
+
+This answers "who owns this one thing", which is what you want when *writing*.
+For reading a whole directory you almost always want themed_templates_in_dir()
+instead, which spans both rather than making you pick.
+
+=cut
+
+sub template_dir ( $template, $content_type, $is_component = 0, $is_dir = 0 ) {
+    my ( $mtd, $mtemp ) = _template_dirs( $content_type, $is_component );
     if ($is_dir) {
     if ($is_dir) {
         return $mtd && -d "$mtd/$template" ? $mtd : $mtemp;
         return $mtd && -d "$mtd/$template" ? $mtd : $mtemp;
     }
     }
@@ -67,10 +132,24 @@ sub _dir_for_resource ($resource) {
     return $theme_dir && -f "$theme_dir/$resource" ? $theme_dir : '';
     return $theme_dir && -f "$theme_dir/$resource" ? $theme_dir : '';
 }
 }
 
 
+=head2 themed($resource)
+
+Path to a www/-relative resource, preferring the theme's copy.
+
+=cut
+
 sub themed ($resource) {
 sub themed ($resource) {
     return _dir_for_resource("$resource") . "/$resource";
     return _dir_for_resource("$resource") . "/$resource";
 }
 }
 
 
+=head2 themed_style($resource) = @hrefs
+
+Stylesheets are the exception to the "one or the other" rule: you get the stock
+sheet *and* the theme's, in that order, so a theme can override a handful of
+rules without restating the whole thing.
+
+=cut
+
 # For style we want to load *both* style files and have the override come later.
 # For style we want to load *both* style files and have the override come later.
 sub themed_style ($resource) {
 sub themed_style ($resource) {
     my @styles = ("/styles/$resource");
     my @styles = ("/styles/$resource");
@@ -80,24 +159,127 @@ sub themed_style ($resource) {
     return @styles;
     return @styles;
 }
 }
 
 
+=head2 themed_script($resource)
+
+Path to a script under www/scripts, preferring the theme's copy.
+
+=cut
+
 sub themed_script ($resource) {
 sub themed_script ($resource) {
     return _dir_for_resource("scripts/$resource") . "/scripts/$resource";
     return _dir_for_resource("scripts/$resource") . "/scripts/$resource";
 }
 }
 
 
+=head2 themed_template($resource)
+
+Path to a template under www/templates, preferring the theme's copy.
+
+=cut
+
 sub themed_template ($resource) {
 sub themed_template ($resource) {
     return _dir_for_resource("templates/$resource") . "/templates/$resource";
     return _dir_for_resource("templates/$resource") . "/templates/$resource";
 }
 }
 
 
+=head2 dir_for($path, $content_type, $is_component)
+
+The single directory which owns a given component subdir: the theme's if it has
+one, the stock one otherwise.  This is the right answer for I<writing> -- see
+forms_dir() and the post type wizard.  For reading, you almost certainly want
+themed_templates_in_dir() instead, which spans both.
+
+=cut
+
+sub dir_for ( $path, $ct, $is_component = 0 ) {
+    return template_dir( $path, $ct, $is_component, 1 ) . "/$path";
+}
+
+=head2 forms_dir()
+
+dir_for() the post type forms directory, which is where the post type wizard
+writes new types and their schema sidecars.
+
+=cut
+
+sub forms_dir {
+    return dir_for( 'forms', 'text/html', 1 );
+}
+
+=head2 files_in_dir($path, $content_type, $is_component, $ext)
+
+Bare filenames with the given extension in whichever single directory dir_for()
+picks.  See themed_templates_in_dir() for the version which spans both.
+
+=cut
+
+sub files_in_dir ( $path, $ct, $is_component = 0, $ext = 'tx' ) {
+    return _read_dir( dir_for( $path, $ct, $is_component ), $ext );
+}
+
+sub _read_dir ( $dir, $ext ) {
+    my $files = [];
+    return $files unless -d $dir;
+    opendir( my $dh, $dir ) or return $files;
+    while ( my $file = readdir($dh) ) {
+        push( @$files, $file ) if -f "$dir/$file" && $file =~ m/\.\Q$ext\E$/;
+    }
+    closedir($dh);
+    return $files;
+}
+
+=head2 templates_in_dir($path, $content_type, $is_component)
+
+files_in_dir() restricted to templates.
+
+Beware that this only ever reads one directory -- see themed_templates_in_dir()
+for why that's usually not what you want.
+
+=cut
+
 sub templates_in_dir ( $path, $ct, $is_component = 0 ) {
 sub templates_in_dir ( $path, $ct, $is_component = 0 ) {
-    $path = template_dir( $path, $ct, $is_component, 1 ) . "/$path";
-    my $forms = [];
-    return $forms unless -d $path;
-    opendir( my $dh, $path );
-    while ( my $form = readdir($dh) ) {
-        push( @$forms, $form ) if -f "$path/$form" && $form =~ m/.*\.tx$/;
+    return files_in_dir( $path, $ct, $is_component, 'tx' );
+}
+
+=head2 themed_templates_in_dir($path, $content_type, $is_component)
+
+Same calling convention as templates_in_dir(), but it doesn't make you pick a
+side: you get the theme's templates where the theme has them, plus the stock
+ones it hasn't overridden.
+
+templates_in_dir() resolves through template_dir(), which is winner-take-all --
+if a theme has a forms/ dir at all, its contents are the *only* post types the
+site can see, and every core one vanishes.  That's rarely what a theme author
+means by overriding a template.
+
+Returns bare filenames, deduped, theme first.  Which of the two files a given
+name resolves to is then up to whoever renders it -- see template_dirs().
+
+=cut
+
+sub themed_templates_in_dir ( $path, $ct, $is_component = 0 ) {
+    my @dirs = grep { $_ } map { "$_/$path" } _template_dirs( $ct, $is_component );
+
+    my ( @templates, %seen );
+    foreach my $dir (@dirs) {
+        foreach my $template ( @{ _read_dir( $dir, 'tx' ) } ) {
+
+            # First one wins, and the theme's dir comes first.
+            push( @templates, $template ) unless $seen{$template}++;
+        }
+    }
+    return \@templates;
+}
+
+=head2 themed_file_in_dir($path, $file, $content_type, $is_component)
+
+The path to a single named file in an abstract template dir, preferring the
+theme's copy.  Returns undef when neither has it.
+
+=cut
+
+sub themed_file_in_dir ( $path, $file, $ct, $is_component = 0 ) {
+    foreach my $dir ( grep { $_ } _template_dirs( $ct, $is_component ) ) {
+        return "$dir/$path/$file" if -f "$dir/$path/$file";
     }
     }
-    close($dh);
-    return $forms;
+    return undef;
 }
 }
 
 
 1;
 1;

+ 3 - 88
lib/Trog/Vars.pm

@@ -6,8 +6,6 @@ use warnings;
 use feature qw{signatures};
 use feature qw{signatures};
 no warnings qw{experimental};
 no warnings qw{experimental};
 
 
-use Ref::Util();
-use List::Util qw{any};
 
 
 #1MB chunks
 #1MB chunks
 our $CHUNK_SEP  = 'tCMSep666YOLO42069';
 our $CHUNK_SEP  = 'tCMSep666YOLO42069';
@@ -33,91 +31,8 @@ our %cache_control = (
     static     => "public, max-age=604800, immutable",
     static     => "public, max-age=604800, immutable",
 );
 );
 
 
-our $not_ref = sub {
-    return !Ref::Util::is_ref(shift);
-};
-
-our $valid_cb = sub {
-    my $subname = shift;
-    my ($modname) = $subname =~ m/^([\w|:]+)::\w+$/;
-
-    # Modules always return 0 if they succeed!
-    eval { require $modname; } and do {
-        WARN("Post uses a callback whos module ($modname) cannot be found!");
-        return 0;
-    };
-
-    no strict 'refs';
-    my $ref = eval '\&' . $subname;
-    use strict;
-    return Ref::Util::is_coderef($ref);
-};
-
-our $hashref_or_string = sub {
-    my $subj = shift;
-    return Ref::Util::is_hashref($subj) || $not_ref->($subj);
-};
-
-# Shared Post schema
-our %schema = (
-    ## Parameters which must be in every single post
-    'title'      => $not_ref,
-    'callback'   => $valid_cb,
-    'tags'       => \&Ref::Util::is_arrayref,
-    'version'    => $not_ref,
-    'visibility' => $not_ref,
-    'aliases'    => \&Ref::Util::is_arrayref,
-
-    # title links here
-    'href' => $not_ref,
-
-    # Link to post locally
-    'local_href' => $not_ref,
-
-    # Post body
-    'data' => $not_ref,
-
-    # How do I edit this post?
-    'form' => $not_ref,
-
-    # Post is restricted to visibility to these ACLs if not public/unlisted
-    'acls' => \&Ref::Util::is_arrayref,
-    'id'   => $not_ref,
-
-    # Author of the post
-    'user'    => $not_ref,
-    'created' => $not_ref,
-);
-
-=head2 filter($data,[$schema]) = %$data_filtered
-
-Filter the provided data through the default schema, and optionally a user-provided schema.
-
-Remove unwanted params to keep data slim & secure.
-
-=cut
-
-sub filter ( $data, $user_schema = {} ) {
-    %$user_schema = (
-        %schema,
-        %$user_schema,
-    );
-
-    # Filter all the irrelevant data
-    foreach my $key ( keys(%$data) ) {
-
-        # We need to have the key in the schema, and it validate.
-        delete $data->{$key} unless List::Util::any { ( $_ eq $key ) && ( $user_schema->{$key}->( $data->{$key} ) ) } keys(%$user_schema);
-
-        #use Data::Dumper;
-        #print Dumper($data);
-
-        # All parameters in the schema are MANDATORY.
-        foreach my $param ( keys(%$user_schema) ) {
-            die "Missing mandatory parameter $param" unless exists $data->{$param};
-        }
-    }
-    return %$data;
-}
+# NOTE: the post schema and its validators used to be duplicated here as well
+# as in Trog::DataModule, and filter() had no callers at all.  Both are gone --
+# a post's shape is now an OpenAPIv3 schema, see Trog::DataModule::schema_for().
 
 
 1;
 1;

+ 259 - 0
t/Trog-DataModule-schema.t

@@ -0,0 +1,259 @@
+use strict;
+use warnings;
+
+use Test::More;
+use Test::MockModule qw{strict};
+use Test::Deep;
+use Test::Fatal qw{exception};
+use JSON::MaybeXS();
+use Path::Tiny();
+use FindBin;
+use feature qw{signatures};
+no warnings qw{experimental::signatures};
+
+use lib "$FindBin::Bin/../lib";
+
+require_ok('Trog::DataModule') or BAIL_OUT("Can't find SUT");
+require_ok('Trog::Routes::HTML');
+
+# Point the SUT at a temp forms dir we control and hand it whatever sidecars
+# each subtest wants.  The dir has to be real, as schema_for() keys its cache on
+# the dir's mtime.
+my $tempdir = Path::Tiny->tempdir();
+my %sidecars;
+
+my $thememock = Test::MockModule->new('Trog::Themes');
+$thememock->redefine( 'template_dirs', sub { ("$tempdir") } );
+$thememock->redefine(
+    'themed_file_in_dir',
+    sub ( $path, $file, @ ) {
+        return -f "$tempdir/$file" ? "$tempdir/$file" : undef;
+    }
+);
+
+# The logger isn't initialized outside of a running server.
+my $logmock = Test::MockModule->new('Trog::DataModule');
+$logmock->redefine( 'WARN', sub { note(shift) } );
+
+my $slurpmock = Test::MockModule->new('File::Slurper');
+$slurpmock->redefine( 'read_text', sub { $sidecars{ Path::Tiny::path(shift)->basename } } );
+
+# -f has to agree with %sidecars, and the dir mtime has to move, or the
+# mtime-keyed cache will hand back a stale answer.
+my $generation = 0;
+sub set_sidecars {
+    %sidecars = @_;
+    $tempdir->child($_)->remove foreach map { $_->basename } $tempdir->children();
+    $tempdir->child($_)->spew_utf8('{}') foreach keys %sidecars;
+    $generation++;
+    utime( time() + $generation, time() + $generation, "$tempdir" );
+    return;
+}
+
+sub sidecar { return JSON::MaybeXS::encode_json( { type => 'object', @_ } ) }
+
+subtest 'the base schema applies with no sidecar at all' => sub {
+    set_sidecars();
+
+    my $schema = Trog::DataModule::schema_for('blog.tx');
+    ok( exists $schema->{properties}{title},   "base properties are there" );
+    ok( !exists $schema->{properties}{gravy},  "and nothing else is" );
+    ok( !exists $schema->{required},           "an empty required list isn't emitted - it isn't legal OpenAPI" );
+
+    is_deeply(
+        Trog::DataModule::schema_for(''),
+        $schema,
+        "a post with no form still gets the base schema"
+    );
+};
+
+subtest 'a sidecar merges over the base' => sub {
+    set_sidecars(
+        'recipe.json' => sidecar(
+            required   => ['servings'],
+            properties => { servings => { type => 'integer' }, vegan => { type => 'boolean' } },
+        )
+    );
+
+    my $schema = Trog::DataModule::schema_for('recipe.tx');
+    ok( exists $schema->{properties}{servings}, "the sidecar's fields are merged in" );
+    ok( exists $schema->{properties}{title},    "...without losing the base's" );
+    is_deeply( $schema->{required}, ['servings'], "required carries over" );
+
+    ok( !exists Trog::DataModule::schema_for('blog.tx')->{properties}{servings},
+        "and they don't leak onto another post type" );
+};
+
+subtest 'sidecars cannot redefine core post fields' => sub {
+    set_sidecars(
+        'evil.json' => sidecar(
+            properties => {
+                id       => { type => 'object' },
+                acls     => { type => 'string' },
+                callback => { type => 'string' },
+            },
+        )
+    );
+
+    my $schema = Trog::DataModule::schema_for('evil.tx');
+    is( $schema->{properties}{id}{type},       'string',   "id keeps its own type" );
+    is( $schema->{properties}{acls}{type},     'array',    "acls stays an array" );
+    is( $schema->{properties}{callback}{type}, 'callback', "callback keeps the type that actually checks the sub exists" );
+};
+
+subtest 'a malformed sidecar is skipped, not fatal' => sub {
+    set_sidecars( 'busted.json' => '{ this is not json' );
+
+    my $schema;
+    is( exception { $schema = Trog::DataModule::schema_for('busted.tx') }, undef, "no exception" );
+    ok( exists $schema->{properties}{title}, "we fall back to the base schema" );
+};
+
+subtest 'the cache follows the forms dir mtime' => sub {
+    set_sidecars( 'recipe.json' => sidecar( properties => { servings => { type => 'integer' } } ) );
+    ok( exists Trog::DataModule::schema_for('recipe.tx')->{properties}{servings}, "declared" );
+
+    set_sidecars();
+    ok( !exists Trog::DataModule::schema_for('recipe.tx')->{properties}{servings},
+        "removing the sidecar is picked up without a restart" );
+};
+
+subtest 'validate() filters, coerces and reports' => sub {
+    set_sidecars(
+        'recipe.json' => sidecar(
+            properties => {
+                servings => { type => 'integer' },
+                vegan    => { type => 'boolean' },
+                photo    => { type => 'upload' },
+            },
+        )
+    );
+
+    my $post = {
+        form       => 'recipe.tx',
+        title      => 'Cookies',
+        visibility => 'public',
+        servings   => '12',
+        vegan      => '1',
+
+        # The router hands us plenty that was never a post field.
+        app   => 'junk',
+        to    => '/blog',
+        gravy => 'yes please',
+    };
+    is_deeply( [ Trog::DataModule::validate($post) ], [], "a good post has no errors" );
+    is( $post->{servings}, 12, "strings coerce to the declared type" );
+    isa_ok( $post->{vegan}, 'JSON::PP::Boolean', "checkbox values become real booleans" );
+    ok( !exists $post->{app} && !exists $post->{to} && !exists $post->{gravy}, "undescribed keys are filtered out" );
+
+    my ($error) = Trog::DataModule::validate( { form => 'recipe.tx', servings => 'lots' } );
+    like( $error, qr/servings/, "a bad value is reported rather than silently dropped" );
+
+    # 'upload' is our own sugar for the string-or-hashref an upload field is,
+    # depending on which direction it's travelling.
+    is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', photo => { filename => 'x' } } ) ], [], "an upload hashref passes" );
+    is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', photo => '/assets/x.jpg' } ) ],      [], "so does the href it becomes" );
+};
+
+subtest 'empty inputs are treated as absent, except for strings' => sub {
+    set_sidecars( 'recipe.json' => sidecar( properties => { servings => { type => 'integer' } } ) );
+
+    # An untouched number input submits '', which is not an integer and was
+    # never meant to be one.
+    my $post = { form => 'recipe.tx', servings => '', title => '' };
+    is_deeply( [ Trog::DataModule::validate($post) ], [], "no spurious type error" );
+    ok( !exists $post->{servings}, "the blank number is dropped" );
+    is( $post->{title}, '', "a blank string field is left exactly as it was before any of this was typed" );
+};
+
+subtest 'the custom types actually bite' => sub {
+    set_sidecars();
+
+    my ($bad) = Trog::DataModule::validate( { title => 'x', callback => 'Some::Random::Sub' } );
+    like( $bad, qr/cannot be loaded/, "a callback naming a module that isn't there is refused" );
+
+    is_deeply(
+        [ Trog::DataModule::validate( { title => 'x', callback => 'Trog::Routes::HTML::posts' } ) ],
+        [],
+        "a real one is fine"
+    );
+};
+
+subtest 'add() rejects a post that does not validate' => sub {
+    set_sidecars( 'recipe.json' => sidecar( required => ['servings'], properties => { servings => { type => 'integer' } } ) );
+
+    my $written;
+    {
+        no warnings qw{once};
+        @TestData::ISA = ('Trog::DataModule');
+        *TestData::get   = sub { return () };
+        *TestData::write = sub { $written = $_[1]; return 0 };
+    }
+
+    my $why = exception {
+        bless( {}, 'TestData' )->add( { form => 'recipe.tx', title => 'Cookies', visibility => 'public' } );
+    };
+    is( ref $why, 'ARRAY', "it dies with the error list, not a string" );
+    like( join( ' ', @$why ), qr/servings/, "which names the offending field" );
+
+    # A string die would come back wearing a Carp::Always stack trace, which is
+    # no use at all to whoever just submitted the form.
+    unlike( join( ' ', @$why ), qr/DataModule\.pm line/, "and carries no stack trace" );
+    ok( !$written, "nothing was written" );
+
+    bless( {}, 'TestData' )->add( { form => 'recipe.tx', title => 'Cookies', visibility => 'public', servings => '4' } );
+    is( $written->[0]{servings}, 4, "the good one went through, coerced" );
+};
+
+subtest '_wizard_fields zips the parallel arrays' => sub {
+
+    # The middle row was left blank.  Dropping it must not shift the type of
+    # the row after it -- this is the whole reason param_required is a select.
+    my $fields = Trog::Routes::HTML::_wizard_fields(
+        {
+            param_name        => [ 'Servings', '',     'cook_time', 'id',   'servings' ],
+            param_type        => [ 'number',   'text', 'textarea',  'text', 'text' ],
+            param_label       => [ 'Servings', '',     'Cook Time', 'ID',   'Dupe' ],
+            param_placeholder => [ '4',        '',     '45 min',    '',     '' ],
+            param_required    => [ 1,          0,      0,           0,      0 ],
+        }
+    );
+
+    is( scalar @$fields, 2, "blank, core-shadowing and duplicate rows are dropped" );
+    is( $fields->[0]{name},      'servings',  "names are lowercased" );
+    is( $fields->[0]{required},  1,           "required survives" );
+    is( $fields->[1]{name},      'cook_time', "the row after the blank one is still present" );
+    is( $fields->[1]{type},      'textarea',  "...with its own type, not the blank row's" );
+};
+
+subtest '_wizard_sidecar emits a usable OpenAPIv3 schema' => sub {
+    my $spec = Trog::Routes::HTML::_wizard_sidecar(
+        'recipe',
+        [
+            { name => 'servings',  type => 'number',   label => 'Servings',  placeholder => '4', required => 1 },
+            { name => 'vegan',     type => 'checkbox', label => 'Vegan?',    placeholder => '', required => 0 },
+        ],
+        ['inc_tags'],
+        'form_common.tx',
+    );
+
+    is( $spec->{type},                          'object',   "it's an object schema" );
+    is( $spec->{properties}{servings}{type},    'integer',  "a number input is an integer" );
+    is( $spec->{properties}{vegan}{type},       'boolean',  "a checkbox is a boolean" );
+    is_deeply( $spec->{required}, ['servings'], "only the required rows are required" );
+
+    # UI metadata rides along on x- keys so there's only one file to keep in sync.
+    is( $spec->{properties}{servings}{'x-tcms-label'},       'Servings', "labels are kept" );
+    is( $spec->{properties}{servings}{'x-tcms-placeholder'}, '4',        "so are placeholders" );
+    ok( !exists $spec->{properties}{vegan}{'x-tcms-placeholder'}, "but an empty placeholder isn't written out" );
+    is_deeply( $spec->{'x-tcms-post-type'}{includes}, ['tags.tx'], "the includes are recorded for regeneration" );
+};
+
+subtest '_kolon_safe defuses template syntax' => sub {
+    my $safe = Trog::Routes::HTML::_kolon_safe(qq{<: \$post.id :>\n: include "evil.tx";});
+    unlike( $safe, qr/</,      "no angle brackets survive" );
+    unlike( $safe, qr/[\r\n]/, "no newlines survive, so ':' can't start a directive" );
+    is( Trog::Routes::HTML::_kolon_safe( [] ), '', "a repeated param can't sneak a ref through" );
+};
+
+done_testing();

+ 1 - 0
t/Trog-SQLite.t

@@ -19,6 +19,7 @@ subtest 'dbh' => sub {
     my $works = 0;
     my $works = 0;
     no warnings qw{redefine once};
     no warnings qw{redefine once};
     local *TrogDBD::do = sub { $works };
     local *TrogDBD::do = sub { $works };
+    local *TrogDBD::errstr = sub { 'bogus' };
 
 
     like(exception { Trog::SQLite::dbh('bogus','bogus') }, qr/ensure/i, "Failure to enforce schema throws");
     like(exception { Trog::SQLite::dbh('bogus','bogus') }, qr/ensure/i, "Failure to enforce schema throws");
     $works = 1;
     $works = 1;

+ 91 - 0
t/Trog-Themes-themed-templates.t

@@ -0,0 +1,91 @@
+use strict;
+use warnings;
+
+use Test::More;
+use Test::MockModule qw{strict};
+use Path::Tiny();
+use FindBin;
+
+use lib "$FindBin::Bin/../lib";
+
+require_ok('Trog::Themes') or BAIL_OUT("Can't find SUT");
+
+# A stock template tree and a theme which only partially overrides it.
+my $root  = Path::Tiny->tempdir();
+my $stock = $root->child('www/templates/html/components/forms');
+my $theme = $root->child('www/themes/spec/templates/html/components/forms');
+$_->mkpath foreach ( $stock, $theme );
+
+$stock->child($_)->spew_utf8("stock $_") foreach qw{blog.tx series.tx profile.tx blog.json series.json};
+$theme->child($_)->spew_utf8("themed $_") foreach qw{blog.tx gallery.tx blog.json};
+
+my $themedir = '';
+my $mock     = Test::MockModule->new('Trog::Themes');
+$mock->redefine( 'get_dir', sub { $themedir } );
+
+# $template_dir is what the stock tree hangs off; point it at the fixture.
+{
+    no warnings qw{once};
+    $Trog::Themes::template_dir = "$root/www/templates";
+}
+
+subtest 'with no theme, nothing changes' => sub {
+    $themedir = '';
+
+    is_deeply(
+        [ Trog::Themes::template_dirs( 'text/html', 1 ) ],
+        ["$root/www/templates/html/components"],
+        "only the stock dir is searched"
+    );
+
+    my $old = [ sort @{ Trog::Themes::templates_in_dir( 'forms', 'text/html', 1 ) } ];
+    my $new = [ sort @{ Trog::Themes::themed_templates_in_dir( 'forms', 'text/html', 1 ) } ];
+    is_deeply( $new, $old, "the themed helper agrees with the old one" );
+    is_deeply( $new, [qw{blog.tx profile.tx series.tx}], "...and lists the stock templates" );
+};
+
+subtest 'with a partial theme, the two disagree' => sub {
+    $themedir = "$root/www/themes/spec";
+
+    is_deeply(
+        [ Trog::Themes::template_dirs( 'text/html', 1 ) ],
+        [ "$root/www/themes/spec/templates/html/components", "$root/www/templates/html/components" ],
+        "the theme is searched first, then the stock tree"
+    );
+
+    # This is the shortcoming: one forms/ dir in a theme hides every stock
+    # post type the theme didn't happen to copy.
+    is_deeply(
+        [ sort @{ Trog::Themes::templates_in_dir( 'forms', 'text/html', 1 ) } ],
+        [qw{blog.tx gallery.tx}],
+        "templates_in_dir() sees only the theme's"
+    );
+
+    is_deeply(
+        [ sort @{ Trog::Themes::themed_templates_in_dir( 'forms', 'text/html', 1 ) } ],
+        [qw{blog.tx gallery.tx profile.tx series.tx}],
+        "themed_templates_in_dir() adds back the ones the theme didn't override"
+    );
+
+    my $listed = Trog::Themes::themed_templates_in_dir( 'forms', 'text/html', 1 );
+    is( scalar( grep { $_ eq 'blog.tx' } @$listed ), 1, "an overridden template is listed once, not twice" );
+    is( $listed->[0], 'blog.tx', "and the theme's copies come first" );
+};
+
+subtest 'themed_file_in_dir prefers the theme, falls back to stock' => sub {
+    $themedir = "$root/www/themes/spec";
+
+    is( Trog::Themes::themed_file_in_dir( 'forms', 'blog.json', 'text/html', 1 ),
+        "$theme/blog.json", "the theme's copy wins" );
+    is( Trog::Themes::themed_file_in_dir( 'forms', 'series.json', 'text/html', 1 ),
+        "$stock/series.json", "one the theme lacks falls back" );
+    is( Trog::Themes::themed_file_in_dir( 'forms', 'nope.json', 'text/html', 1 ),
+        undef, "and one nobody has is undef, not a path that doesn't exist" );
+};
+
+subtest 'an empty or missing dir is not fatal' => sub {
+    $themedir = "$root/www/themes/spec";
+    is_deeply( Trog::Themes::themed_templates_in_dir( 'nosuchdir', 'text/html', 1 ), [], "just an empty list" );
+};
+
+done_testing();

+ 32 - 0
www/scripts/post_wizard.js

@@ -0,0 +1,32 @@
+// Post Type Wizard - repeatable custom field rows.
+//
+// Every row contributes exactly one param_name, param_type, param_label,
+// param_placeholder and param_required, so the five arrive server side as
+// aligned arrays.  This is why 'required' is a <select> and not a checkbox --
+// an unchecked checkbox submits nothing and would shift every later row.
+
+function addParam() {
+    var tpl  = document.getElementById('wizard-param-template');
+    var host = document.getElementById('wizard-params');
+    if ( tpl === null || host === null ) {
+        console.log('post wizard param template missing');
+        return false;
+    }
+    host.appendChild(document.importNode(tpl.content, true));
+    return false;
+}
+
+function delParam(button) {
+    var row = button.closest('.wizard-param-row');
+    if (row) {
+        row.parentNode.removeChild(row);
+    }
+    return false;
+}
+
+// Start with one blank row, otherwise the feature isn't discoverable.
+document.addEventListener("DOMContentLoaded", function(event) {
+    if ( document.getElementById('wizard-params') !== null ) {
+        addParam();
+    }
+});

+ 3 - 0
www/templates/html/components/400.tx

@@ -1,3 +1,6 @@
 <h1>400 Bad Request</h1>
 <h1>400 Bad Request</h1>
+: if ( $title != 'Bad Request' ) {
+<pre class="responsive-text"><: $title :></pre>
+: }
 <br /><br />
 <br /><br />
 See the SiteMap <a href="/sitemap">here</a> as to the valid ways to communicate with this website.
 See the SiteMap <a href="/sitemap">here</a> as to the valid ways to communicate with this website.

+ 19 - 0
www/templates/html/components/forms/blog.json

@@ -0,0 +1,19 @@
+{
+   "x-tcms-post-type" : {
+      "name" : "blog",
+      "generated" : false,
+      "description" : "A long form post.  Body text and an optional preview image."
+   },
+   "type" : "object",
+   "properties" : {
+      "preview" : {
+         "type" : "string",
+         "x-tcms-label" : "Preview Image"
+      },
+      "preview_file" : {
+         "type" : "upload",
+         "x-tcms-label" : "Preview Image",
+         "x-tcms-input" : "file"
+      }
+   }
+}

+ 9 - 0
www/templates/html/components/forms/dns.json

@@ -0,0 +1,9 @@
+{
+   "x-tcms-post-type" : {
+      "name" : "dns",
+      "generated" : false,
+      "description" : "Stub.  dns.tx is empty, so this type ingests nothing beyond the base post fields."
+   },
+   "type" : "object",
+   "properties" : {}
+}

+ 18 - 0
www/templates/html/components/forms/entities.json

@@ -0,0 +1,18 @@
+{
+   "x-tcms-post-type" : {
+      "name" : "entities",
+      "generated" : false,
+      "description" : "A party to an invoice -- who to pay, and how."
+   },
+   "type" : "object",
+   "properties" : {
+      "payment_method" : {
+         "type" : "string",
+         "x-tcms-label" : "Payment Method"
+      },
+      "payment_details" : {
+         "type" : "string",
+         "x-tcms-label" : "Payment Details"
+      }
+   }
+}

+ 24 - 0
www/templates/html/components/forms/file.json

@@ -0,0 +1,24 @@
+{
+   "x-tcms-post-type" : {
+      "name" : "file",
+      "generated" : false,
+      "description" : "An uploaded asset.  Renders as an image, audio or video player depending on its content type."
+   },
+   "type" : "object",
+   "properties" : {
+      "preview" : {
+         "type" : "string",
+         "x-tcms-label" : "Preview Image"
+      },
+      "preview_file" : {
+         "type" : "upload",
+         "x-tcms-label" : "Preview Image",
+         "x-tcms-input" : "file"
+      },
+      "file" : {
+         "type" : "upload",
+         "x-tcms-label" : "File",
+         "x-tcms-input" : "file"
+      }
+   }
+}

+ 33 - 0
www/templates/html/components/forms/invoice.json

@@ -0,0 +1,33 @@
+{
+   "x-tcms-post-type" : {
+      "name" : "invoice",
+      "generated" : false,
+      "description" : "A multi-page invoice drawn between two entity posts."
+   },
+   "type" : "object",
+   "properties" : {
+      "preview" : {
+         "type" : "string",
+         "x-tcms-label" : "Preview Image"
+      },
+      "preview_file" : {
+         "type" : "upload",
+         "x-tcms-label" : "Preview Image",
+         "x-tcms-input" : "file"
+      },
+      "payee" : {
+         "type" : "string",
+         "x-tcms-label" : "Payee"
+      },
+      "payor" : {
+         "type" : "string",
+         "x-tcms-label" : "Payor"
+      },
+      "due_days" : {
+         "type" : "integer",
+         "minimum" : 0,
+         "x-tcms-label" : "Due in (days)",
+         "x-tcms-input" : "number"
+      }
+   }
+}

+ 31 - 0
www/templates/html/components/forms/microblog.json

@@ -0,0 +1,31 @@
+{
+   "x-tcms-post-type" : {
+      "name" : "microblog",
+      "generated" : false,
+      "description" : "A short post pointing at something else, optionally with embedded audio or video."
+   },
+   "type" : "object",
+   "properties" : {
+      "preview" : {
+         "type" : "string",
+         "x-tcms-label" : "Preview Image"
+      },
+      "preview_file" : {
+         "type" : "upload",
+         "x-tcms-label" : "Preview Image",
+         "x-tcms-input" : "file"
+      },
+      "audio_href" : {
+         "type" : "string",
+         "x-tcms-label" : "Audio",
+         "x-tcms-input" : "url",
+         "x-tcms-placeholder" : "https://soundclod.com/static.mp3"
+      },
+      "video_href" : {
+         "type" : "string",
+         "x-tcms-label" : "Video",
+         "x-tcms-input" : "url",
+         "x-tcms-placeholder" : "https://youvimeo.tv/infomercial.mp4"
+      }
+   }
+}

+ 30 - 0
www/templates/html/components/forms/presentation.json

@@ -0,0 +1,30 @@
+{
+   "x-tcms-post-type" : {
+      "name" : "presentation",
+      "generated" : false,
+      "description" : "A multi-page post rendered as a slide deck, with attachments referrable by index."
+   },
+   "type" : "object",
+   "properties" : {
+      "preview" : {
+         "type" : "string",
+         "x-tcms-label" : "Preview Image"
+      },
+      "preview_file" : {
+         "type" : "upload",
+         "x-tcms-label" : "Preview Image",
+         "x-tcms-input" : "file"
+      },
+      "video_href" : {
+         "type" : "string",
+         "x-tcms-label" : "Video",
+         "x-tcms-input" : "url",
+         "x-tcms-placeholder" : "https://youvimeo.tv/infomercial.mp4"
+      },
+      "attachments" : {
+         "type" : "array",
+         "items" : { "type" : "upload" },
+         "x-tcms-label" : "Attachments"
+      }
+   }
+}

+ 46 - 0
www/templates/html/components/forms/profile.json

@@ -0,0 +1,46 @@
+{
+   "x-tcms-post-type" : {
+      "name" : "profile",
+      "generated" : false,
+      "description" : "A user.  Posts to /profile rather than /post/save, as it also drives Trog::Auth.",
+      "comment" : "'password' is deliberately absent: profile() strips it before the post is saved, and it must never reach the datastore."
+   },
+   "type" : "object",
+   "properties" : {
+      "preview" : {
+         "type" : "string",
+         "x-tcms-label" : "Avatar"
+      },
+      "preview_file" : {
+         "type" : "upload",
+         "x-tcms-label" : "Avatar",
+         "x-tcms-input" : "file"
+      },
+      "username" : {
+         "type" : "string",
+         "x-tcms-label" : "Username"
+      },
+      "display_name" : {
+         "type" : "string",
+         "x-tcms-label" : "Display Name"
+      },
+      "contact_email" : {
+         "type" : "email",
+         "x-tcms-label" : "Contact Email"
+      },
+      "wallpaper" : {
+         "type" : "string",
+         "x-tcms-label" : "Wallpaper"
+      },
+      "wallpaper_file" : {
+         "type" : "upload",
+         "x-tcms-label" : "Wallpaper",
+         "x-tcms-input" : "file"
+      },
+      "user_acls" : {
+         "type" : "array",
+         "items" : { "type" : "string" },
+         "x-tcms-label" : "User ACLs"
+      }
+   }
+}

+ 42 - 0
www/templates/html/components/forms/series.json

@@ -0,0 +1,42 @@
+{
+   "x-tcms-post-type" : {
+      "name" : "series",
+      "generated" : false,
+      "description" : "A category of posts.  Owns an ACL, and dictates which post type its children use."
+   },
+   "type" : "object",
+   "required" : [ "aclname", "child_form" ],
+   "properties" : {
+      "preview" : {
+         "type" : "string",
+         "x-tcms-label" : "Preview Image"
+      },
+      "preview_file" : {
+         "type" : "upload",
+         "x-tcms-label" : "Preview Image",
+         "x-tcms-input" : "file"
+      },
+      "aclname" : {
+         "type" : "string",
+         "x-tcms-label" : "ACL name"
+      },
+      "child_form" : {
+         "type" : "string",
+         "pattern" : "^[A-Za-z0-9_-]+\\.tx$",
+         "x-tcms-label" : "Content Template"
+      },
+      "tiled" : {
+         "type" : "boolean",
+         "x-tcms-label" : "Tile Display?",
+         "x-tcms-input" : "checkbox"
+      },
+      "header" : {
+         "type" : "string",
+         "x-tcms-label" : "Header"
+      },
+      "footer" : {
+         "type" : "string",
+         "x-tcms-label" : "Footer"
+      }
+   }
+}

+ 1 - 1
www/templates/html/components/forms/series.tx

@@ -33,7 +33,7 @@
         Title *<br /><input required class="cooltext" type="text" name="title" placeholder="Iowa Man Destroys Moon" value="<: $post.title :>" />
         Title *<br /><input required class="cooltext" type="text" name="title" placeholder="Iowa Man Destroys Moon" value="<: $post.title :>" />
         URL *<br /><input required class="cooltext" type="text" name="local_href" placeholder="/someurl" value="<: $post.local_href :>" />
         URL *<br /><input required class="cooltext" type="text" name="local_href" placeholder="/someurl" value="<: $post.local_href :>" />
         ACL name *<br /><input required class="cooltext" type="text" name="aclname" value="<: $post.aclname :>" />
         ACL name *<br /><input required class="cooltext" type="text" name="aclname" value="<: $post.aclname :>" />
-        <label for="<: $post.id :>-tiled">Tile Display?<input id="<: $post.id :>-tiled" class="coolcb" type="checkbox" name="tiled" <: if ( $post.tiled ) { "checked" } :> /></label>
+        <label for="<: $post.id :>-tiled">Tile Display?<input id="<: $post.id :>-tiled" class="coolcb" type="checkbox" name="tiled" value="1" <: if ( $post.tiled ) { "checked" } :> /></label>
         <br />
         <br />
         Content Template *<br />
         Content Template *<br />
         <select required class="cooltext" name="child_form">
         <select required class="cooltext" name="child_form">

+ 96 - 1
www/templates/html/components/post_wizard.tx

@@ -1 +1,96 @@
-TEST
+<script type="text/javascript" src="/scripts/post_wizard.js"></script>
+<div id="backoffice">
+<p class="title">
+ Existing Post Types:
+</p>
+A "post type" is just a template living in <code>templates/html/components/forms/</code>.
+Every post picks exactly one of them &mdash; it's what the <em>Content Template</em> dropdown
+selects when you edit a Series.
+<hr />
+<ul id="existing-post-types">
+: for $forms -> $form {
+    <li><: $form :></li>
+: }
+</ul>
+<hr />
+<p class="title">
+ Create a new Post Type:
+</p>
+This writes a new template into the forms directory currently in use
+(<code><: $forms_dir :></code>) along with a matching <code>.json</code> sidecar describing
+any custom fields, so that the data model knows to keep them when a post is saved.
+<hr />
+
+<form class="Submissions" id="postTypeWizard" method="post" action="/admin/wyzzerdd/save">
+    Type Name * &mdash; letters, numbers, dash and underscore only<br />
+    <input required class="cooltext" type="text" name="name" pattern="[A-Za-z0-9_-]+" placeholder="recipe" value="<: $name :>" />
+
+    <p class="title">Canned Fields</p>
+    Most post types reuse the same handful of blocks.  Tick whichever ones this type needs.
+    <br />
+    <label for="wiz-wrapper">Wrap output in the standard post &lt;div&gt;<input id="wiz-wrapper" class="coolcb" type="checkbox" name="wrapper" value="1" <: $checked.wrapper :> /></label><br />
+    <label for="wiz-post_title">Post title &amp; permalink (post_title.tx)<input id="wiz-post_title" class="coolcb" type="checkbox" name="inc_post_title" value="1" <: $checked.inc_post_title :> /></label><br />
+    <label for="wiz-post_tags">Post tag list (post_tags.tx)<input id="wiz-post_tags" class="coolcb" type="checkbox" name="inc_post_tags" value="1" <: $checked.inc_post_tags :> /></label><br />
+    <label for="wiz-title_input">Title input in the editor<input id="wiz-title_input" class="coolcb" type="checkbox" name="inc_title_input" value="1" <: $checked.inc_title_input :> /></label><br />
+    <label for="wiz-preview">Preview image upload (preview.tx)<input id="wiz-preview" class="coolcb" type="checkbox" name="inc_preview" value="1" <: $checked.inc_preview :> /></label><br />
+    <label for="wiz-visibility">Visibility selector (visibility.tx)<input id="wiz-visibility" class="coolcb" type="checkbox" name="inc_visibility" value="1" <: $checked.inc_visibility :> /></label><br />
+    <label for="wiz-acls">ACL selector (acls.tx)<input id="wiz-acls" class="coolcb" type="checkbox" name="inc_acls" value="1" <: $checked.inc_acls :> /></label><br />
+    <label for="wiz-tags">Tag editor (tags.tx)<input id="wiz-tags" class="coolcb" type="checkbox" name="inc_tags" value="1" <: $checked.inc_tags :> /></label><br />
+    <label for="wiz-aliases">Alias URL editor (aliases.tx)<input id="wiz-aliases" class="coolcb" type="checkbox" name="inc_aliases" value="1" <: $checked.inc_aliases :> /></label><br />
+    <label for="wiz-attachments">Attachment uploader (attachments.tx)<input id="wiz-attachments" class="coolcb" type="checkbox" name="inc_attachments" value="1" <: $checked.inc_attachments :> /></label><br />
+
+    <br />
+    Title placeholder text<br />
+    <input class="cooltext" type="text" name="title_placeholder" placeholder="Iowa Man Destroys Moon" value="<: $title_placeholder :>" />
+
+    <br />
+    Content Body &mdash; this is always the last thing in the editor, and is what carries the post body itself.<br />
+    <select class="cooltext" name="body_form">
+        <option value="form_common.tx" <: $body_form == 'form_multi.tx' ? '' : 'selected' :> >Single page (form_common.tx)</option>
+        <option value="form_multi.tx" <: $body_form == 'form_multi.tx' ? 'selected' : '' :> >Multiple pages, like presentations (form_multi.tx)</option>
+    </select>
+
+    <p class="title">Custom Fields</p>
+    These become extra inputs in the editor, and are available in your display template below
+    as <code>&lt;: $post.your_field_name :&gt;</code>.
+    Uploads aren't supported here &mdash; use the Attachment uploader above for those.
+    <div id="wizard-params"></div>
+    <button type="button" style="float:right;" class="coolbutton" onclick="addParam(); return false;">Add Field</button>
+    <br style="clear:both;" />
+
+    <p class="title">Display Template</p>
+    <a href="https://metacpan.org/pod/Text::Xslate::Syntax::Kolon" title="GET UR MIND RITE">Kolon</a>
+    markup for how this post type renders when someone looks at it.
+    Use <code>&lt;: $post.yourfield :&gt;</code> for your custom fields, and
+    <code>&lt;: render_it($post.data) | mark_raw :&gt;</code> for the post body.
+    <br />
+    <textarea class="cooltext" name="display" rows="12" placeholder="&lt;div class=&quot;postData responsive-text&quot;&gt;&#10;    &lt;: render_it($post.data) | mark_raw :&gt;&#10;&lt;/div&gt;"><: $display :></textarea>
+
+    <br />
+    <label for="wiz-overwrite">Overwrite it if this type already exists?<input id="wiz-overwrite" class="coolcb" type="checkbox" name="overwrite" value="1" <: $checked.overwrite :> /></label>
+    <br />
+    <input type="submit" class="coolbutton" value="Create Post Type" />
+</form>
+
+<template id="wizard-param-template">
+    <div class="wizard-param-row">
+        <hr />
+        Name <input required class="cooltext" type="text" name="param_name" pattern="[A-Za-z][A-Za-z0-9_]*" placeholder="cook_time" />
+        Type <select class="cooltext" name="param_type">
+            <option value="text">Text</option>
+            <option value="url">URL</option>
+            <option value="number">Number</option>
+            <option value="date">Date</option>
+            <option value="textarea">Long Text</option>
+            <option value="checkbox">Checkbox</option>
+        </select>
+        Label <input class="cooltext" type="text" name="param_label" placeholder="Cook Time" />
+        Placeholder <input class="cooltext" type="text" name="param_placeholder" placeholder="45 minutes" />
+        <select class="cooltext" name="param_required">
+            <option value="0">Optional</option>
+            <option value="1">Required</option>
+        </select>
+        <button type="button" class="coolbutton" onclick="delParam(this); return false;">Remove</button>
+    </div>
+</template>
+</div>