浏览代码

Add POD to the files perlcritic was complaining about

Documentation::RequirePod was flagging 18 files.  Followed the two
conventions already in the tree: =head1 <Package::Name> overview plus
=head2 name(ARGS) = RETURN per sub for modules, and SYNOPSIS/USAGE/OPTIONS
for bin/ scripts.

Pure-variable modules get a =head2 VARIABLES list instead, as there are no
subs to hang the documentation off of.

Wrote down two things found along the way rather than changing them here:
Routes/TXT.pm declares package Trog::Routes::JSON (it is unreferenced, so
harmless today, but loading it would clobber the real JSON routing table),
and migrate.pl has an unreachable exit 0 before its video migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJ2NAMQyytncwHKSmD5dFe
George Baugh 1 月之前
父节点
当前提交
7a9b9c0d73
共有 18 个文件被更改,包括 619 次插入 和 4 次删除
  1. 19 1
      bin/build_index.pl
  2. 24 0
      bin/favicon_mongler.pl
  3. 33 0
      bin/migrate.pl
  4. 33 0
      bin/migrate2.pl
  5. 30 0
      bin/migrate3.pl
  6. 23 0
      bin/migrate4.pl
  7. 23 0
      bin/migrate5.pl
  8. 30 0
      bin/migrate6.pl
  9. 36 0
      bin/tcms-hostname
  10. 23 0
      bin/totp
  11. 46 2
      lib/Theme.pm
  12. 25 0
      lib/Trog/Component/EmojiPicker.pm
  13. 25 0
      lib/Trog/Email.pm
  14. 79 0
      lib/Trog/Log.pm
  15. 46 0
      lib/Trog/Log/DBI.pm
  16. 37 0
      lib/Trog/Routes/TXT.pm
  17. 47 1
      lib/Trog/Utils.pm
  18. 40 0
      lib/Trog/Vars.pm

+ 19 - 1
bin/build_index.pl

@@ -8,7 +8,25 @@ use Trog::Data;
 use Trog::Config;
 use Trog::SQLite::TagIndex;
 
-# Use this to build the post index after you import data, otherwise it's not needed
+=head1 SYNOPSIS
+
+Rebuild the post index and the route table from the posts on disk.
+
+Use this to build the post index after you import data, otherwise it's not
+needed -- the index is maintained as posts are written, so the only time it
+gets out of step with the flat files is when something other than tCMS put them
+there.
+
+=head2 USAGE
+
+    bin/build_index.pl
+
+Takes no arguments, and must be run from the tCMS root, as the data paths in
+the configuration are relative to it.
+
+Safe to re-run; it rebuilds from scratch rather than adding to what's there.
+
+=cut
 
 my $conf   = Trog::Config::get();
 my $search = Trog::Data->new($conf);

+ 24 - 0
bin/favicon_mongler.pl

@@ -8,6 +8,30 @@ use File::Basename ();
 use File::Which    ();
 use File::Copy     ();
 
+=head1 SYNOPSIS
+
+Render a favicon SVG out to every size and format a browser might ask for.
+
+=head2 USAGE
+
+    bin/favicon_mongler.pl /path/to/favicon.svg
+
+Writes favicon-<size>.<ext> next to the source SVG for each size below, and
+copies the 32x32 .ico to favicon.ico, which is what browsers guess at when a
+page doesn't tell them otherwise.
+
+The sizes are the ones which actually get asked for: 32 for the tab, 48 for the
+desktop shortcut, 167 and 180 for iPads and iPhones, and 192 and 512 for the
+web app manifest.
+
+=head2 CAVEATS
+
+Needs inkscape on the PATH to do the rendering, and dies if it isn't there.
+
+Overwrites whatever is already at those paths without asking.
+
+=cut
+
 die "Usage:\n    favicon_mongler.pl /path/to/favicon.svg" unless $ARGV[0];
 my $icon = Cwd::abs_path( $ARGV[0] );
 my $bin  = File::Which::which('inkscape');

+ 33 - 0
bin/migrate.pl

@@ -12,6 +12,39 @@ use UUID::Tiny ':std';
 use File::Copy;
 use DateTime;
 
+=head1 SYNOPSIS
+
+Migrate tCMS1 data to the tCMS2 flat file data model.
+
+Walks a tCMS1 docroot and writes each microblog entry and blog post out as a
+tCMS2 post under data/files, keyed by the original creation time so that the
+ordering survives.
+
+=head2 USAGE
+
+Edit $docroot and $dir below to point at the tCMS1 site, then:
+
+    bin/migrate.pl
+
+Run from the tCMS root; the output paths are relative to it.  Follow up with
+bin/build_index.pl, as this writes the flat files directly and leaves the post
+index untouched.
+
+=head2 CAVEATS
+
+Historical.  This is the first of the migrate scripts and only applies to a
+tCMS1 site; anything newer wants migrate2.pl onwards.
+
+Microblog entries come in two flavours -- JSON, and hand written HTML from
+before that -- so each file is tried as JSON first and parsed as HTML if that
+fails.  Post authorship is recovered from file ownership, with www-data and
+nologin owners becoming 'nobody'.
+
+There is an C<exit 0> partway through, before the video migration.  That code
+is unreachable as written and has never been run against anything current.
+
+=cut
+
 #Edit this to be whatever you need it to be
 my $docroot = "/var/www/teodesian.net/doc";
 

+ 33 - 0
bin/migrate2.pl

@@ -17,6 +17,39 @@ use UUID::Tiny;
 use Trog::SQLite;
 use Trog::SQLite::TagIndex;
 
+=head1 SYNOPSIS
+
+Migrate early tCMS3 flatfile sites to the 'all posts are series' code (August
+2021).
+
+Re-keys every post from a timestamp onto a UUID, keeping the old URL as an
+alias so nothing already linked breaks, and fills in the fields that release
+started requiring: local_href, callback, method, visibility and the form each
+post is edited with.  Then it rebuilds the index and adds the series posts
+(/series, /about, /config) that the new code expects to exist.
+
+=head2 USAGE
+
+Edit $user and @extra_series below to suit your installation, then:
+
+    bin/migrate2.pl
+
+Run from the tCMS root.
+
+=head2 CAVEATS
+
+Historical, and one way.  It deletes the timestamp keyed flat files as it
+rewrites them, so take a backup of data/ before running it.
+
+Also unlinks data/posts.db up front, as the index is rebuilt at the end
+regardless.
+
+The form a post gets is guessed from its tags and content type, and series
+child_form from the series title, so anything unusual will want checking
+afterwards.
+
+=cut
+
 # Kill the post index
 unlink "$FindBin::Bin/../data/posts.db";
 $ENV{NOHUP} = 1;

+ 30 - 0
bin/migrate3.pl

@@ -17,6 +17,36 @@ use UUID::Tiny;
 use Trog::SQLite;
 use Trog::SQLite::TagIndex;
 
+=head1 SYNOPSIS
+
+Migrate early tCMS3 flatfile sites to store rather than compute much data
+(December 2021).
+
+Visibility used to be worked out from a post's tags on every read, and whether
+a series tiled its children from its href.  This walks every post and writes
+those out as fields, so that the reading code doesn't have to derive them.
+
+=head2 USAGE
+
+Edit $user below to suit your installation, then:
+
+    bin/migrate3.pl
+
+Run from the tCMS root.  Assumes migrate2.pl has already been run.
+
+=head2 CAVEATS
+
+Historical.  Posts are re-saved through the data model, so this creates a new
+revision of every post on the site.
+
+Posts with no visibility tag at all are left without one, as there's no safe
+guess between public and private.
+
+The 'All Posts' series at the end is left commented out; uncomment it if your
+instance wants /posts to be a series of its own.
+
+=cut
+
 # Kill the post index
 unlink "$FindBin::Bin/../data/posts.db";
 $ENV{NOHUP} = 1;

+ 23 - 0
bin/migrate4.pl

@@ -11,6 +11,29 @@ use lib "$FindBin::Bin/../lib";
 
 use Trog::SQLite;
 
+=head1 SYNOPSIS
+
+Migrate to 2FA.
+
+Adds the totp_secret column to the auth database's user table, which is where
+Trog::Auth::totp() stores a user's TOTP secret once they enroll.
+
+=head2 USAGE
+
+    bin/migrate4.pl
+
+Run from the tCMS root, as the schema and database paths are relative to it.
+
+=head2 CAVEATS
+
+Historical, and not idempotent -- SQLite will refuse a second ALTER TABLE for a
+column which already exists, so re-running it dies rather than doing nothing.
+
+Existing users end up with a NULL secret, which is to say 2FA off, and can
+enroll whenever they like.
+
+=cut
+
 sub _dbh {
     my $file   = 'schema/auth.schema';
     my $dbname = "config/auth.db";

+ 23 - 0
bin/migrate5.pl

@@ -11,6 +11,29 @@ use lib "$FindBin::Bin/../lib";
 
 use Trog::SQLite;
 
+=head1 SYNOPSIS
+
+Password reset code.
+
+Adds the contact_email column to the auth database's user table, without which
+there is nowhere to send a password reset to.
+
+=head2 USAGE
+
+    bin/migrate5.pl
+
+Run from the tCMS root, as the schema and database paths are relative to it.
+
+=head2 CAVEATS
+
+Historical, and not idempotent -- SQLite will refuse a second ALTER TABLE for a
+column which already exists, so re-running it dies rather than doing nothing.
+
+Existing users end up with a NULL contact email and so can't reset their own
+password until one is set.  Fix that with bin/tcms-useradd --contact_email.
+
+=cut
+
 sub _dbh {
     my $file   = 'schema/auth.schema';
     my $dbname = "config/auth.db";

+ 30 - 0
bin/migrate6.pl

@@ -11,6 +11,36 @@ use lib "$FindBin::Bin/../lib";
 
 use Trog::SQLite;
 
+=head1 SYNOPSIS
+
+Display names.
+
+Adds the display_name column to the auth database's user table, then walks the
+flat file posts and converts profile posts from being titled with the user's
+login name to being titled with their display name, moving them from
+/users/<login> to /users/<display name> in the process.
+
+=head2 USAGE
+
+    bin/migrate6.pl
+
+Run from the tCMS root.  Rebuild the post index afterwards, as the profile
+posts have changed their local_href and the routes table still points at the
+old one -- the script says so when it has actually changed something.
+
+=head2 CAVEATS
+
+Historical, and not idempotent -- SQLite will refuse a second ALTER TABLE for a
+column which already exists, so re-running it dies rather than doing nothing.
+
+Post revisions are rewritten in place rather than through the data model, so
+take a backup of data/ before running it.
+
+Users with no display name set are skipped, which leaves their profile post
+where it was.
+
+=cut
+
 sub _dbh {
     my $file   = 'schema/auth.schema';
     my $dbname = "config/auth.db";

+ 36 - 0
bin/tcms-hostname

@@ -8,6 +8,42 @@ use FindBin::libs;
 use Trog::Config();
 use Getopt::Long qw{GetOptionsFromArray};
 
+=head1 SYNOPSIS
+
+Get, or set, the hostname this tCMS instance serves.
+
+Everything which needs to know the site's domain -- TOTP enrollment, the DNS
+zone, generated URLs -- reads it out of the configuration, so this is where it
+gets put there in the first place.
+
+=head2 USAGE
+
+    bin/tcms-hostname                  # print the configured hostname
+    bin/tcms-hostname example.com      # set it, then print it
+    bin/tcms-hostname --user           # print it as a valid username
+
+=head2 OPTIONS
+
+=over 4
+
+=item --user
+
+Print the hostname mangled into something usable as a system username: dots
+become dashes, and it's truncated to 32 characters.  Does not change what is
+stored in the configuration.
+
+=back
+
+=head2 CAVEATS
+
+Writes to $Trog::Config::home_cfg rather than saving over whatever get()
+loaded, as that is the tracked config/default.cfg until the instance has a
+configuration of its own.
+
+Dies if asked to print a hostname which was never set.
+
+=cut
+
 my $user;
 GetOptionsFromArray(\@ARGV,
     'user' => \$user,

+ 23 - 0
bin/totp

@@ -8,6 +8,29 @@ use FindBin::libs;
 use Trog::Auth;
 use Trog::Config;
 
+=head1 SYNOPSIS
+
+Print a user's TOTP enrollment details, so that they can set up 2FA without
+going through the web UI -- or get back in when they've lost their
+authenticator and you need to read them the current code.
+
+=head2 USAGE
+
+    bin/totp username
+
+Prints the otpauth:// URI (what a QR code would encode), the base32 secret to
+type into an authenticator by hand, and the code which is valid right now.
+
+Note that this hands out everything needed to generate that user's codes
+forever, so treat the output like a password.
+
+=head2 CAVEATS
+
+Requires general.hostname to be set in the tCMS configuration, as the domain is
+part of what an authenticator shows the user.  Set it with bin/tcms-hostname.
+
+=cut
+
 my $user   = shift @ARGV;
 my $domain = Trog::Config->get()->param('general.hostname');
 

+ 46 - 2
lib/Theme.pm

@@ -6,8 +6,52 @@ use warnings;
 no warnings 'experimental';
 use feature qw{signatures};
 
-# An example of the bare minimum your themes' routes.pm need.
-# Copy and alter things below as needed.
+=head1 Theme
+
+An example of the bare minimum your themes' routes.pm need.
+Copy and alter things below as needed.
+
+Themes are loaded by Trog::Themes::routes(), which requires the active theme's
+routes.pm and expects it to have populated the package variables below, along
+with %Theme::routes if the theme wants routes of its own.
+
+Everything here is optional -- Trog::Routes::HTML falls back to generic tCMS
+defaults for anything a theme leaves unset -- but a site that sets none of it
+will identify itself as "Another tCMS Site" to search engines and social media.
+
+=head2 VARIABLES
+
+=over 4
+
+=item $default_title
+
+Fallback <title> for pages which have no post of their own to name them.
+
+=item $default_image
+
+The image handed to social media scrapers as the site's preview, relative to
+the theme directory.
+
+=item $display_name
+
+What the site calls itself, e.g. in the og:site_name meta tag.
+
+=item $description
+
+Fallback meta description, used when the page has no post body to summarize.
+
+=item $default_tags
+
+Comma separated keywords for the meta keywords tag.
+
+=item $twitter_account, $fb_app_id
+
+Set these if you want the relevant twitter: and fb: meta tags emitted.
+Left empty, HTML::SocialMeta is handed nothing to put in them.
+
+=back
+
+=cut
 
 our $default_title = 'tCMS';
 our $default_image = 'img/icon/favicon-48.png';

+ 25 - 0
lib/Trog/Component/EmojiPicker.pm

@@ -9,6 +9,31 @@ use feature qw{signatures state};
 use JSON::MaybeXS;
 use Trog::Renderer;
 
+=head1 Trog::Component::EmojiPicker
+
+The emoji picker component.
+
+=head1 Termination Conditions
+
+Dies if the emoji list isn't on disk, as there's no sensible picker to show
+without it.  It's a frontend dependency, so C<make prereq-frontend> is what
+puts it there.
+
+=head1 FUNCTIONS
+
+=head2 render() = STRING
+
+Render the picker as an HTML component.
+
+The emoji list ships as a flat array, which is no use to a picker with tabs, so
+it gets bucketed by category on the first call and memoized thereafter -- the
+list can't change without a redeploy.
+
+Returns the rendered body, rather than a PSGI triplet, as components are meant
+to be composed into a page by their caller.
+
+=cut
+
 sub render () {
     state %categorized;
 

+ 25 - 0
lib/Trog/Email.pm

@@ -13,6 +13,31 @@ use Trog::Auth;
 use Trog::Log qw{:all};
 use Trog::Renderer;
 
+=head1 Trog::Email
+
+Sending mail out of tCMS.
+
+=head1 Termination Conditions
+
+Dies when asked to mail a user who has no contact email set on their profile.
+Failure to actually send is logged as FATAL rather than thrown, as by then the
+message has already been built and there's nothing the caller can do about it.
+
+=head1 FUNCTIONS
+
+=head2 contact(STRING user, STRING from, STRING subject, HASHREF data) = BOOL
+
+Send a templated message to a user's contact email.
+
+$data must have a 'template' key naming the template to render; everything else
+in it is passed through to that template as its data.  The template is rendered
+twice, once as text and once as HTML, and both are attached to a multipart
+message so that the recipient's client can pick whichever it prefers.
+
+Returns 1.
+
+=cut
+
 sub contact ( $user, $from, $subject, $data ) {
     my $email = Trog::Auth::email4user($user);
     die "No contact email set for user $user!" unless $email;

+ 79 - 0
lib/Trog/Log.pm

@@ -23,6 +23,56 @@ our ( $log, $user );
 $Trog::Log::user = 'nobody';
 $Trog::Log::ip   = '0.0.0.0';
 
+=head1 Trog::Log
+
+Logging for tCMS.
+
+Everything goes through one Log::Dispatch object with three outputs: a rotating
+file for the operator, the screen for anything at error level or above (which
+is what ends up in the webserver's own log), and Trog::Log::DBI, which is what
+Trog::Log::Metrics later reads its time series out of.
+
+Import the level subroutines you need, or C<:all>:
+
+    use Trog::Log qw{:all};
+    INFO("something happened");
+
+=head1 Termination Conditions
+
+log_init() dies if it isn't told where to log and at what level.  FATAL() dies
+by design, that being the point of it.  Note that the level subroutines will
+themselves die on an undefined $log, so log_init() has to have run first --
+which the server does per-worker, and CLI tools have to do for themselves.
+
+=head1 VARIABLES
+
+=over 4
+
+=item $Trog::Log::user
+
+Who the current request belongs to.  Interpolated into every log line, so that
+the request log can be grepped by user.  Defaults to 'nobody'.
+
+=item $Trog::Log::ip
+
+Where the current request came from, same deal.  Defaults to '0.0.0.0'.
+
+=back
+
+=head1 FUNCTIONS
+
+=head2 log_init(STRING logname, STRING level) = BOOL
+
+Build the logger.  Must be called before anything tries to log.
+
+$logname is the path to the log file; its directory is also where the metrics
+database gets put.  $level is a Log::Dispatch level, generally 'info' or
+'debug'.
+
+Returns 1.
+
+=cut
+
 sub log_init {
     my ($LOGNAME, $LEVEL)  =@_;
 
@@ -73,11 +123,28 @@ sub _dbh {
     return Trog::SQLite::dbh( 'schema/log.schema', "$LOGDIR/log.db" );
 }
 
+=head2 is_debug() = BOOL
+
+Whether we were initialized at debug level.  Useful for skipping the
+construction of an expensive message which would only be thrown away.
+
+=cut
+
 sub is_debug {
     $LEVEL //= 'info';
     return $LEVEL eq 'debug';
 }
 
+=head2 uuid([STRING requestid]) = STRING
+
+Get, or set, the request ID stamped onto every log line.
+
+The server sets this once per request so that the lines belonging to it can be
+picked back out of an interleaved log, and so that Trog::Log::DBI can group a
+request's messages under the request itself.
+
+=cut
+
 sub uuid {
     my $requestid = shift;
     $rq = $requestid if $requestid;
@@ -96,6 +163,18 @@ sub _log {
     return "$tstamp [$level]: RequestId $uuid From $Trog::Log::ip |$Trog::Log::user| $msg\n";
 }
 
+=head2 DEBUG(STRING msg), INFO(STRING msg), WARN(STRING msg), FATAL(STRING msg)
+
+Log a message at the named level.
+
+Lines are formatted as an ISO8601 timestamp, the level, the request ID, the
+originating IP and the user, followed by the message.  Don't change the leading
+timestamp -- fail2ban parses these.
+
+FATAL logs and then dies, so it never returns.
+
+=cut
+
 sub DEBUG {
     $log->debug( _log( shift, 'DEBUG' ) );
 }

+ 46 - 0
lib/Trog/Log/DBI.pm

@@ -13,6 +13,43 @@ use POSIX::strptime qw{strptime};
 
 our ( $referer, $ua, $urchin );
 
+=head1 Trog::Log::DBI
+
+A Log::Dispatch::DBI subclass which files tCMS's request log into SQLite, so
+that Trog::Log::Metrics has something to compute time series out of.
+
+Rather than storing log lines verbatim, log_message() picks apart the format
+Trog::Log emits and stores the pieces in columns.  Lines which don't match the
+request format are treated as free text belonging to whichever request was in
+flight, and are buffered until that request's own line shows up to hang them
+off of.
+
+Anything too mangled to identify a request is dropped -- a metrics database is
+not the place to go looking for them, the log file has them either way.
+
+=head1 VARIABLES
+
+=over 4
+
+=item $referer, $ua, $urchin
+
+Per-request context which isn't in the log line itself.  The routes set these
+before responding; they only exist for metrics, which is why they aren't in the
+text logs.  $urchin is a hashref of utm_* parameters, and is only recorded when
+it has a utm_source.
+
+=back
+
+=head1 METHODS
+
+=head2 create_statement() = DBI::st
+
+Called by Log::Dispatch::DBI at construction.  Prepares, and returns, the
+statement for the requests view, and prepares the two extra statements
+log_message() needs for messages and urchin data.
+
+=cut
+
 sub create_statement {
     my $self = shift;
 
@@ -30,6 +67,15 @@ sub create_statement {
 
 my %buffer;
 
+=head2 log_message(HASH params) = MIXED
+
+Record one log line.
+
+Returns the request insert's result for a request line, 1 for a message that
+got buffered, and undef for a line we couldn't make sense of.
+
+=cut
+
 sub log_message {
     my ( $self, %params ) = @_;
 

+ 37 - 0
lib/Trog/Routes/TXT.pm

@@ -33,6 +33,43 @@ our %routes = (
     },
 );
 
+=head1 Trog::Routes::TXT
+
+Routes which render as text/plain.
+
+=head1 CAVEATS
+
+This module is not wired up to anything.  TCMS.pm builds its routing table out
+of Trog::Routes::HTML and Trog::Routes::JSON only, so nothing here is reachable
+and /text/zone will 404.
+
+Worse, the package statement in this file says Trog::Routes::JSON rather than
+Trog::Routes::TXT, so loading it would quietly replace the JSON routing table
+with this one.  Fix that before wiring it in.
+
+=head1 VARIABLES
+
+=over 4
+
+=item %routes
+
+The usual tCMS routing table -- path to method, callback, parameters and
+whether the route requires admin.
+
+=back
+
+=head1 ROUTES
+
+=head2 zone
+
+Render zone.tx, this instance's DNS zone file.  Admin only.
+
+Note that it passes the query through as-is; zone.tx wants $ip, $nameservers,
+$subdomains and friends, so it needs a data gathering pass before it will
+render anything.
+
+=cut
+
 sub zone ($query) {
     return _render( 200, {}, $query );
 }

+ 47 - 1
lib/Trog/Utils.pm

@@ -16,19 +16,52 @@ use Ref::Util qw{is_hashref};
 use Trog::Log qw{WARN};
 use Trog::Config();
 
-# Deal with Params which may or may not be arrays
+=head1 Trog::Utils
+
+Odds and ends which are needed in more than one place and belong to no
+particular subsystem.
+
+=head1 FUNCTIONS
+
+=head2 coerce_array(SCALAR|ARRAYREF param) = ARRAYREF
+
+Deal with Params which may or may not be arrays.
+
+Query parameters arrive as a bare scalar when the caller passed one of
+something and as an arrayref when they passed several, which means every
+consumer would otherwise have to check.  Returns an empty arrayref for anything
+false, so the result is always safe to dereference.
+
+=cut
+
 sub coerce_array ($param) {
     my $p = $param || [];
     $p = [$param] if $param && ( ref $param ne 'ARRAY' );
     return $p;
 }
 
+=head2 strip_and_trunc(STRING s) = STRING
+
+Flatten a chunk of post body into something fit for a meta description or
+preview blurb -- tags stripped, truncated to 280 characters.
+
+Returns undef when handed nothing, rather than an empty string.
+
+=cut
+
 sub strip_and_trunc ($s) {
     return unless $s;
     $s =~ s/<[^>]*>//g;
     return substr $s, 0, 280;
 }
 
+=head2 uuid() = STRING
+
+A fresh UUID.  Thin wrapper around UUID::uuid(), so that callers don't have to
+care which of the several UUID modules we ended up using.
+
+=cut
+
 sub uuid {
     return UUID::uuid();
 }
@@ -38,6 +71,19 @@ my %extra_types = (
     '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
 );
 
+=head2 mime_type(STRING file) = STRING
+
+Figure out a file's Content-Type.
+
+Tries the extension first, as that's cheap and right nearly all of the time,
+and only falls back to libmagic sniffing the file's contents when the extension
+tells us nothing.  %extra_types patches in the handful of types the upstream
+finders don't know about.
+
+Returns undef if even libmagic can't say.
+
+=cut
+
 sub mime_type ($file) {
 
     # Use libmagic and if that doesn't work try guessing based on extension.

+ 40 - 0
lib/Trog/Vars.pm

@@ -6,6 +6,46 @@ use warnings;
 use feature qw{signatures};
 no warnings qw{experimental};
 
+=head1 Trog::Vars
+
+Constants shared between the data model, the renderer and the routes.
+
+Nothing here is computed; it's all package variables so that callers can read
+them directly rather than paying for a function call on every request.
+
+=head2 VARIABLES
+
+=over 4
+
+=item $CHUNK_SEP
+
+Separator used when a file upload has to be reassembled from chunks.
+Chosen to be something no browser will ever send us by accident.
+
+=item $CHUNK_SIZE
+
+Size of said chunks, in bytes.  1MB.
+
+=item %content_types
+
+Maps the short names used in templates and route definitions (html, json, ...)
+onto the Content-Type header they mean.
+
+=item %byct
+
+The reverse of %content_types, for going from a Content-Type back to the short
+name -- which is how the renderer picks which Trog::Renderer subclass handles a
+response.
+
+=item %cache_control
+
+Named Cache-Control policies.  'revalidate' for content which may have changed,
+'nocache' for anything user specific, and 'static' for assets which are
+immutable once published.
+
+=back
+
+=cut
 
 #1MB chunks
 our $CHUNK_SEP  = 'tCMSep666YOLO42069';