Bladeren bron

Stop an unfilled hidden id writing over the datastore, and get the error out of the URL

Two bugs, and the second is why the first was so hard to read.

Every editor posts <input type="hidden" name="id"> and it is empty for a new
post.  validate() keeps an empty string for a field the schema types as a string
-- which id is -- so the //= in add() saw a defined value and left it alone.  The
flat file model then wrote the post to "data/files/", which is the datastore
directory rather than a post in it, and rename() onto a directory fails.  That is
the "Not a directory" in File::Slurper::Temp: nothing to do with File::Slurper,
and nothing to do with the datasource being added at the time.

The identity and routing scalars in add() now use ||=, since for id, local_href,
callback, method and visibility an empty string means the field was not filled
in.  FlatFile::write refuses a post with no id besides, the way the SQLite model
already did -- add() will not produce one, but nothing guarantees a caller went
through add(), and the failure mode is writing over the datastore.

The error then went into the redirect as a query parameter, and because it was a
plain string die rather than the arrayref add() uses for validation errors,
Carp::Always had decorated it with the entire call stack: Starman, Net::Server,
Plack and all.  Several thousand characters of URL, which tPSGI answered with a
419 for excessive URL length.  The user saw neither the page they saved from nor
the error, and the actual message was buried in the middle of a stack trace.

So the message rides in a cookie now, which is the one header that survives a
redirect.  It is trimmed to one line, capped at 512 characters, and stripped of
the file-and-line Perl glues on; the whole of it still goes to the log.  The
query parameters are still read, for a redirect issued by a worker that has not
restarted yet.

Two things fell out of moving it.  finish_render() has always accepted %headers
and ignored them, which nothing noticed because nothing passed any; it forwards
them now, and index() uses that to expire the cookie once the banner has shown
it.  And a page carrying a banner must not be cached, or the one person who
saved something has their message served to everybody -- the query parameter
form was accidentally safe there, since a query string skips the render cache on
its own, and a cookie is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6Q1gRJ2Rr85S8FQ47EjnQ
George Baugh 1 maand geleden
bovenliggende
commit
669f04889d

+ 7 - 0
lib/Trog/Data/FlatFile.pm

@@ -144,6 +144,13 @@ sub write ( $self, $data ) {
     my $spool = File::Basename::dirname($datastore);
 
     foreach my $post (@$data) {
+
+        # Or the write lands on the datastore directory itself: "$datastore/"
+        # is a directory, rename() onto it fails, and the error names File::
+        # Slurper rather than the post that had no id.  add() will not produce
+        # one, but nothing else guarantees a caller went through add().
+        confess "Cannot write a post with no id" unless length( $post->{id} // '' );
+
         my $file = "$datastore/$post->{id}";
 
         File::Path::make_path($datastore);

+ 16 - 9
lib/Trog/DataModule.pm

@@ -707,14 +707,21 @@ sub add ( $self, @posts ) {
         # die with a stack trace they have no use for.
         die [ map { "$_" } @errors ] if @errors;
 
-        $post->{id}      //= Trog::Utils::uuid();
+        # ||= rather than //= for the identity and routing scalars.  An
+        # untouched hidden input submits the empty string, and validate() keeps
+        # an empty string for a field the schema types as a string -- which id,
+        # local_href, callback and method all are.  //= then saw a defined value
+        # and left it, so a post created through any editor arrived here with
+        # id => '' and the flat file model wrote it to 'data/files/', which is
+        # the datastore directory rather than a post in it.
+        $post->{id} ||= Trog::Utils::uuid();
         $post->{aliases} //= [];
         $post->{aliases} = [ $post->{aliases} ] unless ref $post->{aliases} eq 'ARRAY';
 
         if ( $post->{aclname} ) {
 
             # Then this is a series
-            $post->{local_href} //= "/$post->{aclname}";
+            $post->{local_href} ||= "/$post->{aclname}";
             push( @{ $post->{aliases} }, "/posts/$post->{id}", "/series/$post->{id}" );
         }
 
@@ -724,18 +731,18 @@ sub add ( $self, @posts ) {
         # visibility selector would otherwise produce exactly that.  Private
         # rather than public, because guessing wrong in the other direction
         # publishes something nobody asked to publish.
-        $post->{visibility} //= 'private';
+        $post->{visibility} ||= 'private';
 
-        $post->{callback} //= 'Trog::Routes::HTML::posts';
+        $post->{callback} ||= 'Trog::Routes::HTML::posts';
 
         # If this is a user creation post, add in the /user/ route
         if ( $post->{callback} eq 'Trog::Routes::HTML::users' ) {
-            $post->{local_href} //= "/users/$post->{display_name}";
-            $post->{title}      //= $post->{display_name};
+            $post->{local_href} ||= "/users/$post->{display_name}";
+            $post->{title}      ||= $post->{display_name};
         }
 
-        $post->{local_href} //= "/posts/$post->{id}";
-        $post->{method}     //= 'GET';
+        $post->{local_href} ||= "/posts/$post->{id}";
+        $post->{method}     ||= 'GET';
         $post->{created} = time();
         my @existing_posts = $self->get( id => $post->{id} );
         if (@existing_posts) {
@@ -778,7 +785,7 @@ sub _process ($post) {
     # into a tag: without one it pushed an undef into tags, which is a tag no
     # query ever matches, so the post went invisible to everyone but an admin.
     # Nothing complained, and the bunk tag was written to disk and stayed there.
-    $post->{visibility} //= 'private';
+    $post->{visibility} ||= 'private';
 
     # If the post is private, make sure it's associated assets are too.
     my $is_private_post = $post->{visibility} eq 'private';

+ 124 - 11
lib/Trog/Routes/HTML.pm

@@ -39,6 +39,8 @@ use Trog::Renderer;
 use Trog::Email;
 use Trog::DataSource;
 
+use CGI::Cookie ();
+
 our $landing_page = 'default.tx';
 
 # Note to maintainers: never ever remove backends from this list.
@@ -296,15 +298,48 @@ else to put it.
 =cut
 
 sub _feedback_redirect ( $query, $to, $failure, $message ) {
+    my $cookie = _feedback_cookie( $failure, $message );
+
+    # Appended to what see_also() built rather than replacing it, so this still
+    # logs and still redirects the way every other redirect in here does.
+    my $response = $query->{tpsgi}->see_also($to);
+    push( @{ $response->[1] }, 'Set-Cookie' => $cookie );
+
+    return $response;
+}
+
+=head2 _feedback_cookie($failure, $message) = STRING
+
+The Set-Cookie which carries a save's outcome to the page it redirects to.
+
+A cookie because a redirect's own headers are gone by the time the destination
+renders, and a cookie is the one header that survives the hop.  It used to be a
+query parameter, which meant a save's message was in the URL: a validation error
+carrying a Carp::Always stack trace built a URL of several thousand characters,
+and tPSGI answered the redirect it had just issued with a 419.  The user was
+shown neither the page nor the error.
+
+Trimmed to one line, because the banner puts it in a JS string literal, and
+capped at $feedback_max characters, because a cookie has a size limit of its own
+and nothing a person needs to read is longer than that.  The whole of it is in
+the log either way.
+
+=cut
+
+our $feedback_max = 512;
 
-    # The banner puts this in a JS string literal, so it has to be one line.
+sub _feedback_cookie ( $failure, $message ) {
     $message //= '';
     $message =~ s/\s*\n+\s*/; /g;
     $message =~ s/;\s*$//;
+    $message = substr( $message, 0, $feedback_max - 3 ) . '...' if length($message) > $feedback_max;
 
-    my $sep = index( $to, '?' ) != -1 ? '&'          : '?';
-    my $key = $failure                ? 'savefailed' : 'saved';
-    return $query->{tpsgi}->see_also( $to . $sep . $key . '=' . URI::Escape::uri_escape($message) );
+    my $value = ( $failure ? 1 : 0 ) . ':' . URI::Escape::uri_escape($message);
+
+    # HttpOnly because the banner is rendered server side and no script has any
+    # business reading it; Max-Age because a message nobody collected should not
+    # follow them around; Path=/ because the destination is wherever they were.
+    return "tcmsfeedback=$value; Path=/; HttpOnly; SameSite=Lax; Max-Age=30";
 }
 
 =head2 _absorb_feedback($query)
@@ -318,11 +353,30 @@ any destination that renders a page, whichever route ends up serving it.
 =cut
 
 sub _absorb_feedback ($query) {
-    my ( $saved, $failed ) = ( $query->{saved}, $query->{savefailed} );
-    return unless defined $saved || defined $failed;
+    my ( $failure, $message ) = _feedback_from_cookie( $query->{cookies} );
+
+    # The query parameters are still read, for a redirect issued by a worker
+    # which had not been restarted yet, and for anyone who has one bookmarked.
+    if ( !defined $failure ) {
+        my ( $saved, $failed ) = ( $query->{saved}, $query->{savefailed} );
+        return unless defined $saved || defined $failed;
+
+        $failure = defined $failed ? 1       : 0;
+        $message = defined $failed ? $failed : $saved;
+    }
+
+    $query->{failure} = $failure;
+    $query->{message} = $message || ( $failure ? 'Save failed.' : 'Saved.' );
 
-    $query->{failure} = defined $failed ? 1 : 0;
-    $query->{message} = ( defined $failed ? $failed : $saved ) || ( $query->{failure} ? 'Save failed.' : 'Saved.' );
+    # A page showing a banner is not a page to cache: the banner is for the one
+    # person who just saved something, and a static of it would be served to
+    # everybody.  The query parameter form was accidentally safe here, since a
+    # query string skips the render cache on its own; a cookie is not.
+    $query->{nocache} = 1;
+
+    # Read once.  Without this the banner reappears on every page until the
+    # cookie expires.
+    $query->{feedback_seen} = 1;
 
     # Nothing further to navigate to -- we are already there.  Without this the
     # banner's success branch would bounce us onwards.
@@ -330,6 +384,29 @@ sub _absorb_feedback ($query) {
     return;
 }
 
+=head2 _feedback_from_cookie($cookies) = ($failure, $message)
+
+Pull a save's outcome back out of the cookie _feedback_redirect set.
+
+Returns nothing when there isn't one, so that the caller can fall back to the
+query parameters this replaced.
+
+=cut
+
+sub _feedback_from_cookie ($cookies) {
+    return () unless $cookies;
+
+    local $@;
+    my $jar = eval { CGI::Cookie->parse($cookies) };
+    return () unless ref $jar eq 'HASH' && $jar->{tcmsfeedback};
+
+    my $value = $jar->{tcmsfeedback}->value // '';
+    my ( $failure, $message ) = $value =~ m/^([01]):(.*)$/s;
+    return () unless defined $failure;
+
+    return ( int($failure), URI::Escape::uri_unescape($message) );
+}
+
 =head2 index
 
 Implements the primary route used by all pages not behind auth.
@@ -375,6 +452,11 @@ sub index ( $query, $content = '', $i_styles = [], $i_scripts = [] ) {
 
     my ( $search_lang, $search_help ) = _search_language($query);
 
+    # Consumed, so expire it: otherwise the banner reappears on every page the
+    # reader visits until the cookie times out on its own.
+    my %headers;
+    %headers = ( 'Set-Cookie' => 'tcmsfeedback=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0' ) if $query->{feedback_seen};
+
     return finish_render(
         $tmpl,
         {
@@ -394,7 +476,8 @@ sub index ( $query, $content = '', $i_styles = [], $i_scripts = [] ) {
             default_tags => $default_tags,
             meta_desc    => $meta_desc,
             meta_tags    => $meta_tags,
-        }
+        },
+        %headers,
     );
 }
 
@@ -971,7 +1054,7 @@ sub post_save ($qq) {
     local $@;
     eval { $data->add($query); 1 } or do {
         my @errors = Ref::Util::is_arrayref($@) ? @{$@} : ($@);
-        my $why    = "Post failed validation:\n" . join( "\n", @errors );
+        my $why    = "Post failed validation:\n" . join( "\n", map { _short_error($_) } @errors );
 
         # The full detail goes to the log; the user gets it in the banner on
         # the page they came from, rather than a dead-end 400.
@@ -2389,6 +2472,27 @@ sub post_wizard_save ($query) {
     );
 }
 
+=head2 _short_error($error) = STRING
+
+An error as a person should see it: the first line, without the file and line
+number Perl glued onto the end.
+
+add() dies with an arrayref of validation errors precisely so that they arrive
+clean, but a failure further down -- a write that could not land, say -- is an
+ordinary string die, and Carp::Always decorates those with the entire call
+stack.  That whole thing used to go to the user.
+
+The undecorated original is logged; this is only what reaches the banner.
+
+=cut
+
+sub _short_error ( $error = '' ) {
+    $error = "$error";
+    $error =~ s/\n.*//s;
+    $error =~ s/\s+at\s+\S+\s+line\s+\d+\.?\s*$//;
+    return $error;
+}
+
 =head2 _wizard_reindex($name, $fields)
 
 Bring the data model's idea of which custom fields are queryable into line with
@@ -2805,7 +2909,16 @@ sub finish_render ( $template, $vars, %headers ) {
     $vars->{code} ||= 200;
     $vars->{theme_dir} =~ s/^\/www\/// if $vars->{theme_dir};
 
-    return Trog::Renderer->render( template => $template, data => $vars, contenttype => 'text/html', code => $vars->{code} );
+    # %headers was accepted and then ignored, which nothing noticed because
+    # nothing passed any.  index() passes one now, to expire the feedback cookie
+    # once its message has been rendered.
+    return Trog::Renderer->render(
+        template    => $template,
+        data        => $vars,
+        contenttype => 'text/html',
+        code        => $vars->{code},
+        ( %headers ? ( headers => \%headers ) : () ),
+    );
 }
 
 1;

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

@@ -7,6 +7,7 @@ use Test::Deep;
 use Test::Fatal qw{exception};
 use JSON::MaybeXS();
 use Path::Tiny();
+use URI::Escape();
 use FindBin;
 use feature qw{signatures};
 no warnings qw{experimental::signatures};
@@ -326,6 +327,46 @@ subtest 'a post always ends up with a visibility' => sub {
     is( $written->[0]{visibility}, 'public', 'and an explicit one is left alone' );
 };
 
+subtest 'a save message never becomes a URL' => sub {
+
+    # The whole shape of the bug: a write that failed deep in File::Slurper,
+    # decorated by Carp::Always with the entire call stack, went into the
+    # redirect URL as a query parameter.  The result was several thousand
+    # characters, and tPSGI answered its own redirect with a 419 -- so the user
+    # saw neither the page they saved from nor the error.
+    my $realistic = "Couldn't rename data/dnZHxmlXE5 to data/files/: Not a directory at /opt/perl5/lib/File/Slurper/Temp.pm line 56.\n" . "\tFile::Slurper::Temp::write_text(\"data/files/\", \"[{...}]\") called at /opt/perl5/lib/File/Slurper/Temp.pm line 63\n" . ( "\tTrog::Routes::HTML::post_save(HASH(0x5e798a626570)) called at lib/TCMS.pm line 130\n" x 40 );
+
+    my $short = Trog::Routes::HTML::_short_error($realistic);
+    unlike( $short, qr/\n/,       'the trace is gone' );
+    unlike( $short, qr/line \d+/, 'and so is the file and line Perl glued on' );
+    like( $short, qr/^Couldn't rename data/, 'leaving what actually went wrong' );
+
+    my $cookie = Trog::Routes::HTML::_feedback_cookie( 1, $realistic );
+    ok( length($cookie) < 2048, 'even the whole untrimmed thing fits in a cookie' )
+      or diag( 'cookie was ' . length($cookie) . ' bytes' );
+    like( $cookie, qr/^tcmsfeedback=1:/, 'flagged as a failure' );
+    like( $cookie, qr/HttpOnly/,         'and not readable by script, since nothing needs to' );
+    like( $cookie, qr/Max-Age=\d+/,      'and does not outlive the message it carries' );
+
+    # One line, because the banner puts it in a JS string literal.
+    my ($value) = $cookie =~ m/^tcmsfeedback=1:([^;]*)/;
+    unlike( URI::Escape::uri_unescape($value), qr/\n/, 'the message is one line' );
+
+    # Round trips.
+    my ( $failure, $message ) = Trog::Routes::HTML::_feedback_from_cookie($cookie);
+    is( $failure, 1, 'the outcome comes back out' );
+    like( $message, qr/^Couldn't rename/, 'and so does the message' );
+
+    ( $failure, $message ) = Trog::Routes::HTML::_feedback_from_cookie( Trog::Routes::HTML::_feedback_cookie( 0, "Saved post 'Thing'." ) );
+    is( $failure, 0,                     'a success comes back as one' );
+    is( $message, "Saved post 'Thing'.", 'with its message intact' );
+
+    # And nothing at all when there is no cookie to read.
+    is_deeply( [ Trog::Routes::HTML::_feedback_from_cookie('') ],                 [], 'no cookies, no feedback' );
+    is_deeply( [ Trog::Routes::HTML::_feedback_from_cookie('other=thing') ],      [], 'nor other cookies' );
+    is_deeply( [ Trog::Routes::HTML::_feedback_from_cookie('tcmsfeedback=xyz') ], [], 'nor one that is malformed' );
+};
+
 subtest '_wizard_fields zips the parallel arrays' => sub {
 
     # The middle row was left blank.  Dropping it must not shift the type of

+ 73 - 13
t/Trog-Routes-HTML-series-lifecycle.t

@@ -1350,18 +1350,26 @@ subtest 'saves report back through the jsalert banner' => sub {
     );
     my ( $code, $headers ) = @$res;
     my %h = @{ $headers || [] };
-    is( $code, 303, 'a good save redirects' );
-    like( $h{Location}, qr{^/secure/specblog\?saved=}, 'to where we came from, flagged as saved' );
-    unlike( $h{Location}, qr/\n/, 'and the message is one line, since it lands in a JS string' );
+    is( $code,        303,                'a good save redirects' );
+    is( $h{Location}, '/secure/specblog', 'to where we came from, and nowhere else' );
+
+    # The outcome rides in a cookie, not the URL.  It used to be a query
+    # parameter, and a save which failed with a stack trace attached built a URL
+    # of several thousand characters that tPSGI answered with a 419 -- so the
+    # user saw neither the page nor the error.
+    unlike( $h{Location}, qr/[?&]/, 'the URL carries no message at all' );
+    ok( length( $h{Location} ) < 2048, 'so it cannot outgrow what a server will accept' );
+    like( $h{'Set-Cookie'}, qr/^tcmsfeedback=0:/, 'the outcome is in a cookie instead' );
+    unlike( $h{'Set-Cookie'}, qr/\n/, 'and the message is one line, since it lands in a JS string' );
 
     _reindex();
 
-    # Follow the redirect the way a browser would.
-    my ($qs) = $h{Location} =~ m/\?(.*)$/;
-    my %params = map { my ( $k, $v ) = split( /=/, $_, 2 ); ( $k => URI::Escape::uri_unescape($v) ) } split( /&/, $qs );
+    # Follow the redirect the way a browser would: same destination, carrying
+    # the cookie it was just handed.
+    my $feedback = ( split( /;/, $h{'Set-Cookie'} ) )[0];
 
     my ( $rcode, $body, $err ) = _render(
-        _admin( route => '/specblog', has_query => 1, %params ),
+        _admin( route => '/specblog', cookies => $feedback ),
         \&Trog::Routes::HTML::series,
     );
     is( $rcode, 200, 'the destination renders' ) or diag($err);
@@ -1385,22 +1393,74 @@ subtest 'saves report back through the jsalert banner' => sub {
     );
     ( $code, $headers ) = @$res;
     %h = @{ $headers || [] };
-    is( $code, 303, 'a rejected save redirects too, rather than dead-ending on a 400' );
-    like( $h{Location},                              qr{^/secure/specblog\?savefailed=}, 'flagged as failed' );
-    like( URI::Escape::uri_unescape( $h{Location} ), qr/Not in enum list/,               'carrying the validator error' );
+    is( $code,        303,                'a rejected save redirects too, rather than dead-ending on a 400' );
+    is( $h{Location}, '/secure/specblog', 'to the same place a good one goes' );
+    like( $h{'Set-Cookie'},                              qr/^tcmsfeedback=1:/, 'flagged as failed' );
+    like( URI::Escape::uri_unescape( $h{'Set-Cookie'} ), qr/Not in enum list/, 'carrying the validator error' );
 
     ok( !_find_post('Spec Rejected Child'), 'and the bad post was not written' );
 
-    ($qs) = $h{Location} =~ m/\?(.*)$/;
-    %params = map { my ( $k, $v ) = split( /=/, $_, 2 ); ( $k => URI::Escape::uri_unescape($v) ) } split( /&/, $qs );
+    $feedback = ( split( /;/, $h{'Set-Cookie'} ) )[0];
 
     ( $rcode, $body, $err ) = _render(
-        _admin( route => '/specblog', has_query => 1, %params ),
+        _admin( route => '/specblog', cookies => $feedback ),
         \&Trog::Routes::HTML::series,
     );
     is( $rcode, 200, 'the destination still renders' ) or diag($err);
     like( $body, qr/var loginFailure = 1;/, 'the banner is in failure mode' );
     like( $body, qr/Not in enum list/,      'and shows why' );
+
+    # Read once: the cookie is expired by the page that rendered it, or the
+    # banner follows the reader around until it times out on its own.
+    my ( undef, undef, undef, $rheaders ) = _render(
+        _admin( route => '/specblog', cookies => $feedback ),
+        \&Trog::Routes::HTML::series,
+    );
+    my %rh = @{ $rheaders || [] };
+    like( $rh{'Set-Cookie'}, qr/^tcmsfeedback=;.*Max-Age=0/, 'and the page that shows it expires it' );
+
+    # A page showing a banner must not be cached, or the one person who saved
+    # something has their message served to everybody.  The query parameter
+    # form was accidentally safe here; a cookie is not.
+    my $tpsgi = Test::TPSGI->new( run_callbacks => 1 );
+    _render( _anon( route => '/specblog', cookies => $feedback, tpsgi => $tpsgi ), \&Trog::Routes::HTML::series );
+    is_deeply( [ keys %{ $tpsgi->{renders} } ], [], 'and it is never saved as a static' );
+};
+
+subtest 'a hidden id that was never filled in' => sub {
+
+    # Every editor posts <input type="hidden" name="id"> and it is empty for a
+    # new post.  validate() keeps an empty string for a field the schema types
+    # as a string, which id is, so //= saw a defined value and left it -- and
+    # the flat file model wrote the post to 'data/files/', the datastore
+    # directory rather than a post in it.  The error named File::Slurper, and
+    # its stack trace then went into the redirect URL and produced a 419.
+    my $res = Trog::Routes::HTML::post_save(
+        _admin(
+            route      => '/post/save',
+            method     => 'POST',
+            to         => '/specblog',
+            id         => '',
+            form       => 'blog.tx',
+            title      => 'Spec Empty Id Child',
+            data       => 'Saved despite an empty hidden id.',
+            visibility => 'public',
+            tags       => ['specblog'],
+        )
+    );
+    my ( $code, $headers ) = @$res;
+    my %h = @{ $headers || [] };
+    is( $code, 303, 'the save succeeds' );
+    like( $h{'Set-Cookie'}, qr/^tcmsfeedback=0:/, 'and reports success' );
+
+    _reindex();
+    my $saved = _find_post('Spec Empty Id Child');
+    ok( $saved,                       'the post was written' ) or return;
+    ok( length( $saved->{id} // '' ), 'with an id of its own' );
+    isnt( $saved->{id}, '', 'rather than the empty one it was handed' );
+
+    # And the datastore directory is still a directory.
+    ok( -d 'data/files', 'the datastore was not written over' );
 };
 
 subtest 'the topbar did not silently overflow' => sub {

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

@@ -0,0 +1,24 @@
+{
+   "properties" : {
+      "directory" : {
+         "type" : "string",
+         "x-tcms-input" : "text",
+         "x-tcms-label" : "Directory to index"
+      }
+   },
+   "type" : "object",
+   "x-tcms-datasource" : "Trog::DataSource::DirIndex",
+   "x-tcms-post-type" : {
+      "body_form" : "form_common.tx",
+      "generated" : true,
+      "generator" : "Trog::Routes::HTML::post_wizard_save",
+      "includes" : [
+         "preview.tx",
+         "visibility.tx",
+         "acls.tx",
+         "tags.tx",
+         "aliases.tx"
+      ],
+      "name" : "dirindex"
+   }
+}

+ 11 - 0
www/templates/html/components/forms/dirindex.tx

@@ -0,0 +1,11 @@
+<!-- Generated by the tCMS Post Type Wizard.  Regenerating this type will overwrite any hand edits. -->
+<div class="post <: $style :> <: $tiled ? 'tile' : '' :>">
+    : if ( !$post.addpost ) {
+        : include "post_title.tx";
+        : include "post_tags.tx";
+<div class="postData responsive-text">
+<h3>Index of Directory <: $post.directory :>:</h3>
+<: render_it($post.data) | mark_raw :>
+</div>
+    : }
+</div>