Bladeren bron

tCMS 2.0: Now with more perl

Changed to being a starman app
You should use mod_proxy to host this

Re-designed to make it easy to have:

* multiple data models
* multiple types of content
* easier theming
* easier configuration
Andy Baugh 9 jaren geleden
bovenliggende
commit
b12546fa1d
100 gewijzigde bestanden met toevoegingen van 2434 en 1564 verwijderingen
  1. 5 0
      .gitignore
  2. 16 0
      CHANGELOG
  3. 1 1
      LICENSE
  4. 19 0
      Makefile
  5. 0 39
      README.md
  6. 27 0
      Readme.md
  7. 0 1
      blog/1-Welcome!.post
  8. 3 0
      config/default.cfg
  9. 0 6
      css/avatars.css
  10. 0 6
      css/compat/ie.css
  11. 0 30
      css/compat/ie6-7.css
  12. 0 14
      css/compat/ie6.css
  13. 0 1
      css/custom/.gitignore
  14. 0 0
      data/DUMMY-dist.json
  15. 0 1
      fileshare/readme.txt
  16. BIN
      img/icon/favicon.ico
  17. BIN
      img/mime/denied.gif
  18. BIN
      img/mime/missing.gif
  19. BIN
      img/mime/tsarchive.gif
  20. BIN
      img/mime/tsaudio.gif
  21. BIN
      img/mime/tscode.gif
  22. BIN
      img/mime/tsdoc.png
  23. BIN
      img/mime/tsdownload.gif
  24. BIN
      img/mime/tsfile.gif
  25. BIN
      img/mime/tsfolder-up.gif
  26. BIN
      img/mime/tsfolder.gif
  27. BIN
      img/mime/tsimage.gif
  28. BIN
      img/mime/tsmodel.gif
  29. BIN
      img/mime/tsmovie.gif
  30. BIN
      img/mime/tsprop.png
  31. BIN
      img/mime/tsschematic.gif
  32. BIN
      img/mime/tssoftware.gif
  33. BIN
      img/mime/tssticky.gif
  34. 0 153
      index.php
  35. 118 0
      lib/Trog/Auth.pm
  36. 28 0
      lib/Trog/Config.pm
  37. 29 0
      lib/Trog/Data.pm
  38. 60 0
      lib/Trog/Data/DUMMY.pm
  39. 103 0
      lib/Trog/Data/FlatFile.pm
  40. 298 0
      lib/Trog/DataModule.pm
  41. 977 0
      lib/Trog/Routes/HTML.pm
  42. 30 0
      lib/Trog/Routes/JSON.pm
  43. 24 0
      lib/Trog/SQLite.pm
  44. 61 0
      lib/tCMS/Manual.pod
  45. 0 1
      microblog/.gitignore
  46. 20 0
      schema/auth.schema
  47. BIN
      sys/admin/.bengine.inc.swp
  48. BIN
      sys/admin/.settings.inc.swo
  49. 0 118
      sys/admin/bengine.inc
  50. 0 1
      sys/admin/config/.gitignore
  51. 0 27
      sys/admin/config/main.json.example
  52. 0 14
      sys/admin/config/users.inc
  53. 0 12
      sys/admin/config/users.json.example
  54. 0 52
      sys/admin/index.php
  55. 0 108
      sys/admin/mbengine.inc
  56. 0 24
      sys/admin/settings.inc
  57. 0 100
      sys/blogroll.inc
  58. 0 1
      sys/fileshare/include/audio-player-noswfobject.js
  59. 0 129
      sys/fileshare/include/audio-player-uncompressed.js
  60. 0 3
      sys/fileshare/include/audio-player.js
  61. 0 1
      sys/fileshare/include/blacklist
  62. BIN
      sys/fileshare/include/cortado.jar
  63. 0 12
      sys/fileshare/include/external.inc
  64. 0 12
      sys/fileshare/include/forbidden.inc
  65. 0 19
      sys/fileshare/include/license.txt
  66. 0 5
      sys/fileshare/include/notfound
  67. BIN
      sys/fileshare/include/player.swf
  68. 0 49
      sys/fileshare/sanitize.inc
  69. 0 17
      sys/fileshare/showaudio.inc
  70. 0 36
      sys/fileshare/showcode.inc
  71. 0 15
      sys/fileshare/showdoc.inc
  72. 0 143
      sys/fileshare/showfiles.inc
  73. 0 15
      sys/fileshare/showimg.inc
  74. 0 23
      sys/fileshare/showpost.inc
  75. 0 19
      sys/fileshare/showvideo.inc
  76. 0 157
      sys/microblog.inc
  77. 0 71
      sys/rss/blog.php
  78. 0 78
      sys/rss/microblog.php
  79. 0 1
      templates/custom/.gitignore
  80. 0 1
      templates/default/about.inc
  81. 0 1
      templates/default/footbar.inc
  82. 0 1
      templates/default/leftbar.inc
  83. 0 1
      templates/default/rightbar.inc
  84. 0 14
      templates/default/title.inc
  85. 1 0
      www/.gitignore
  86. BIN
      www/assets/audio/test.mp3
  87. BIN
      www/assets/video/test.ogv
  88. 0 0
      www/img/avatar/humm.gif
  89. 0 0
      www/img/icon/rss.png
  90. 112 0
      www/img/icon/tCMS.svg
  91. 0 0
      www/img/sys/testpattern.jpg
  92. 0 0
      www/scripts/main.js
  93. 18 0
      www/scripts/post.js
  94. 203 0
      www/server.psgi
  95. 46 0
      www/styles/config.css
  96. 52 0
      www/styles/login.css
  97. 39 0
      www/styles/notconfigured.css
  98. 42 0
      www/styles/post.css
  99. 0 0
      www/styles/print.css
  100. 102 31
      www/styles/screen.css

+ 5 - 0
.gitignore

@@ -1,3 +1,8 @@
 *.swp
 *.swo
 favicon.ico
+dist/
+www/assets/*.*
+data/DUMMY.json
+data/files/*
+pod2htmd.tmp

+ 16 - 0
CHANGELOG

@@ -1,3 +1,19 @@
+Version 3.0 Perlized [ALPHA]
+* Re-write with perl/psgi.
+* Re-architected to support multiple data backends, most important default one being elasticsearch
+* Simplified configuration model drastically
+* Multimedia content now first-class posts rather than junk in /fileman
+* Series of content now supported for easy category browsing
+* Private/Unlisted content now supported
+* User About pages now much easier to build
+* Theming massively simplified
+
+Version 2.0 IRONMAN SLOG development notes [ALPHA]:
+* tCMS now has an installer/updater in bin/installer. Makefile rules 'make install' and 'make update' also do this.
+* Fixed a bug where "" encapsulated titles in microblog editor would get baleeted on load.
+* Changed up the directory structure somewhat, mostly due to needing privately stored items.
+* Added an SVG logo.
+
 Version 1.2 "Maxim" Release Notes:
 *General mbengine.inc code cleanup, trying to play better golf while commenting more
 *Made microblogger start writing posts in JSON notation. This will enable posts from here on out to have additional metadata, manipulation ability, etc.

+ 1 - 1
LICENSE

@@ -1,4 +1,4 @@
-Copyright 2013 by Thomas A. and George S. Baugh
+Copyright 2020 by Thomas A. and George S. Baugh (Troglodyne LLC)
 The scripts/code/etc. that comprise tCMS are all released under the GPL.
 See https://gnu.org/licenses/gpl.html for details.
 

+ 19 - 0
Makefile

@@ -0,0 +1,19 @@
+.PHONY: install
+install:
+	test -d $(HOME)/.tcms || mkdir $(HOME)/.tcms
+	test -d www/themes || mkdir www/themes
+	test -d data/files || mkdir data/files
+	rm pod2htmd.tmp; /bin/true
+
+.PHONY: test
+test: reset-dummy-data
+	prove
+
+.PHONY: reset-dummy-data
+reset-dummy-data:
+	cp -f data/DUMMY-dist.json data/DUMMY.json
+
+.PHONY: depend
+depend:
+	sudo apt install -y sqlite3 libsqlite3-dev libdbd-sqlite3-perl cpanminus starman  libcal-dav-perl libtext-xslate-perl libserver-starter-perl libplack-perl libcal-dav-perl libconfig-tiny-perl libdatetime-format-http-perl libjson-maybexs-perl libuuid-tiny-perl libcapture-tiny-perl libconfig-simple-perl libdbi-perl libfile-slurper-perl libfile-touch-perl libfile-copy-recursive-perl libxml-rss-perl libmodule-install-perl
+	sudo cpanm Mojo::File Date::Format WWW::SitemapIndex::XML WWW::Sitemap::XML HTTP::Body Pod::Html URL::Encode

+ 0 - 39
README.md

@@ -1,39 +0,0 @@
-tCMS
-====
-
-A PHP flat-file CMS (teodesian.net CMS), geared towards a webmaster who mostly already "knows what he's doing".
-As such, it allows for some neat things like posting via flat files,
-in case you just wanted to use vim, etc. to blog (as I do).
-Still, I've added a lot of frontend convenience stuff, mostly as requests.
-
-Oh, yeah, did I mention it's responsive by default and degrades well on IE (last I checked)?
-
-See http://tcms.troglodyne.net for more information.
-
-Installing this is pretty easy,
-either grab an archive from above site and extract it or git clone it into a public html directory.
-
-WARNING: If you don't setup HTTP Server based Authentication, you deserve what you get.
-See the manual for more information: http://tcms.troglodyne.net/index.php?nav=1&dir=fileshare/manual
-
-As of the latest version, there should be no upgrade issues,
-despite switching to using JSON to store new postings.
-The code anticipates and uses the legacy style of accessing old posts in that instance.
-
-TODO/Ideas:
- * Convert blog posts to use JSON, similar to microblog, mostly to enable storing better metadata.
- * Theming importation ability, or a decent upgrading script for more effective cruise control.
- * Test code. I'll probably do this in perl,
-   since I'm used to it's test harnesses and Selenium::Remote::Driver for functional automated testing.
- * Support for torrent seedboxes tracking /fileshare to autoprovide magnet links to downloads
- * API conversion for signifigant functionality, mostly as a way to make it easier to extend tCMS.
-   - For example, a cron that watches your install for new posts then crossposts to twitter, etc.
-   - Distributed tCMS installs with gluster would be fun :D
- * Support for alternative authentication schemes (LDAP, etc.).
-   I doubt a manual mapping table from what you've set HTTP auth users to in tCMS is everyone's cup o tea.
- * Add option for using an SQLite database to store posting data, configs, etc.
- * ...And anything here too: http://tcms.troglodyne.net/index.php?nav=5&post=fileshare/manual/Appendix%2001-TODO.post
-
-Really, I don't wanna go too hog wild with features on this,
-since I've already accomplished pretty much 100% of what I want tCMS to do for me.
-Most of these are 'nice to have' items. I may think about working on some of these more if there's interest.

+ 27 - 0
Readme.md

@@ -0,0 +1,27 @@
+tCMeS
+=====
+
+It's basically tCMS, with elasticsearch as the storage backend
+
+Oh it's also a Perl PSGI app with a flippin' api now too :P
+
+Ideas:
+======
+Put *all* posts in elasticsearch, just filter by type and have a micro, blog, image (insta), video, podcat and wiki view with static renders
+
+Search bar that isn't SHIT
+
+*domain* picker at top -- manage all your web properties from one place
+
+login and registration (forces email for a domain to allow posting on said domain)
+User data *also* stored in ES -- it's their profile page!
+
+Error and Access logs immediately dumped into ES for EZ viewing in grafana
+
+Automatic analytics!
+
+Builtin paywall -- add in LDAP users not on primary domain, give differing privs
+Have all content able to assign to paywall packages
+
+One click share to social via oauth
+Mailing list blasts for paywall content

+ 0 - 1
blog/1-Welcome!.post

@@ -1 +0,0 @@
-Thank you for trying tCMS! To get started, please see the documentation over at tcms.troglodyne.net if you haven't already.

+ 3 - 0
config/default.cfg

@@ -0,0 +1,3 @@
+[general]
+    data_model=DUMMY
+    title=tCMS

+ 0 - 6
css/avatars.css

@@ -1,6 +0,0 @@
-/*User Images set here*/
-a.Nobody {
- background-image: url(../img/avatar/humm.gif);
- filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/avatar/hydra.png', sizingMethod='scale');
- -ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/avatar/hydra.png', sizingMethod='scale')"
-}

+ 0 - 6
css/compat/ie.css

@@ -1,6 +0,0 @@
-a.rss, a.logo, a.usericon, a.avatar {
- background-image: none !important;
-}
-p#linkcontainer {
- width: 6em;
-}

+ 0 - 30
css/compat/ie6-7.css

@@ -1,30 +0,0 @@
-div.linkbar {
- float: left;
- clear: none;
-}
-div.kontent {
- float: right;
- clear: none;
-}
-img.titlebar {
-}
-a.logo {
- font-size: 1.5em;
- font-family: courier;
- color: white;
- text-decoration: none;
-}
-a.logo:hover {
- text-decoration: underline;
-}
-#lefttitle {
- visibility: hidden;
- display: none;
-}
-#midtitle {
- float: left;
- vertical-align: middle;
-}
-#righttitle {
- float: right;
-}

+ 0 - 14
css/compat/ie6.css

@@ -1,14 +0,0 @@
-body {
- height: 100%;
- overflow-y: auto;
-}
-div.topbar {
- position: absolute;
-}
-#lefttitle {
- visibility: hidden;
- display: none;
-}
-#midtitle {
- float: left;
-}

+ 0 - 1
css/custom/.gitignore

@@ -1 +0,0 @@
-*.css

File diff suppressed because it is too large
+ 0 - 0
data/DUMMY-dist.json


+ 0 - 1
fileshare/readme.txt

@@ -1 +0,0 @@
-This is the fileshare. You should place stuff you wanna share here.

BIN
img/icon/favicon.ico


BIN
img/mime/denied.gif


BIN
img/mime/missing.gif


BIN
img/mime/tsarchive.gif


BIN
img/mime/tsaudio.gif


BIN
img/mime/tscode.gif


BIN
img/mime/tsdoc.png


BIN
img/mime/tsdownload.gif


BIN
img/mime/tsfile.gif


BIN
img/mime/tsfolder-up.gif


BIN
img/mime/tsfolder.gif


BIN
img/mime/tsimage.gif


BIN
img/mime/tsmodel.gif


BIN
img/mime/tsmovie.gif


BIN
img/mime/tsprop.png


BIN
img/mime/tsschematic.gif


BIN
img/mime/tssoftware.gif


BIN
img/mime/tssticky.gif


+ 0 - 153
index.php

@@ -1,153 +0,0 @@
-<!doctype html>
-<html dir="ltr" lang="en-US">
- <head>
-  <?php
-    //SRSBIZNUSS below - you probably shouldn't edit this unless you know what you are doing
-    //GET validation/sanitation and parameter variable definitions below
-    if (!empty($_SERVER["HTTPS"])) {
-      $protocol = "http";
-    } else {
-      $protocol = "https";
-    }
-    if (empty($_GET['nav'])) {
-      $nav = '';
-    }
-    else {
-      $nav = $_GET['nav'];
-    }
-    if (empty($_GET['post'])) {
-      $post = '';
-    }
-    else {
-      $post = $_GET['post'];
-    }
-
-    //input sanitization - XXX Why is this in the index? Should only be include in stuff that needs it
-    $pwd=$post;
-    include 'sys/fileshare/sanitize.inc';
-    if ($san == 1) {
-      return(0);
-    };
-    if(file_exists('sys/admin/config/main.json')) {
-      $config = json_decode(file_get_contents('sys/admin/config/main.json'),true);
-    } else {
-      # XXX Need to have manual be hosted in repo under sys/admin/manual
-      echo "</head><body>tCMS has not gone through initial configuration.<br />";
-      echo 'Please see the <a href="https://tcms.troglodyne.net/index.php?nav=5&post=fileshare/manual/Chapter%2000-Introduction.post">tCMS Manual</a> for how to accomplish this.';
-      die("</body></html>");
-    }
-  ?>
-  <meta charset="utf-8" />
-  <meta name="description" content="A Simple CMS by teodesian.net"/>
-  <meta name="viewport" content="width=device-width">
-  <link rel="stylesheet" type="text/css" href="css/structure.css" />
-  <link rel="stylesheet" type="text/css" href="css/screen.css" media="screen" />
-  <link rel="stylesheet" type="text/css" href="css/print.css" media="print" />
-  <?php
-    if(file_exists('css/custom/avatars.css')) {
-      echo '<link rel="stylesheet" type="text/css" href="css/custom/avatars.css" />';
-    } else {
-      echo '<link rel="stylesheet" type="text/css" href="css/avatars.css" />';
-    }
-  ?>
-  <!--Compatibility Stylesheets-->
-  <!--[if lte IE 8]>
-   <link rel="stylesheet" type="text/css" href="css/compat/ie.css">
-  <![endif]-->
-  <!--[if lte IE 7]>
-   <link rel="stylesheet" type="text/css" href="css/compat/ie6-7.css">
-  <[endif]-->
-  <!--[if IE 6]>
-   <link rel="stylesheet" type="text/css" href="css/compat/ie6.css">
-  <![endif]-->
-  <?php
-    if(file_exists('css/custom/screen.css')) {
-      echo '<link rel="stylesheet" type="text/css" href="css/custom/screen.css" />';
-    }
-    if(file_exists('css/custom/print.css')) {
-      echo '<link rel="stylesheet" type="text/css" href="css/custom/print.css" />';
-    }
-    if(file_exists('favicon.ico')) {
-      echo '<link rel="icon" type="image/vnd.microsoft.icon" href="favicon.ico" />';
-    } else {
-      echo '<link rel="icon" type="image/vnd.microsoft.icon" href="img/icon/favicon.ico" />';
-    }
-  ?>
-  <title>
-   <?php
-    echo $config['htmltitle'];
-   ?>
-  </title>
- </head>
- <body>
-  <div id="topkek">
-   <?php
-    //Site's Titlebar comes in here
-    include $config['toptitle'];
-   ?>
-  </div>
-  <div id="littlemenu">
-  </div>
-  <div id="kontainer">
-   <div id="leftbar" class="kontained">
-    <?php
-     include $config['leftbar'];
-    ?>
-   </div>
-   <div id="kontent" class="kontained">
-    <?php
-      /*$kontent basically is just a handler for what PHP include needs to be loaded
-      based on the context passed via GET params - if you wanna add another, add an
-      elseif case then specify the next number in the nav index along with the
-      corresponding file to include above.*/
-      if (empty($nav)) {
-        $kontent = $config['home'];
-      }
-      elseif ($nav == 1) {
-        $kontent = $config['fileshare'];
-      }
-      elseif ($nav == 2) {
-        $kontent = $config['microblog'];
-        $editable = 0;
-      }
-      elseif ($nav == 3) {
-        $kontent = $config['blog'];
-      }
-      elseif ($nav == 4) {
-        $kontent = $config['about'];
-      }
-      elseif ($nav == 5) {
-        $kontent = $config['postloader'];
-      }
-      elseif ($nav == 6) {
-        $kontent = $config['codeloader'];
-      }
-      elseif ($nav == 7) {
-        $kontent = $config['audioloader'];
-      }
-      elseif ($nav == 8) {
-        $kontent = $config['videoloader'];
-      }
-      elseif ($nav == 9) {
-        $kontent = $config['imgloader'];
-      }
-      elseif ($nav == 10) {
-        $kontent = $config['docloader'];
-      }
-      //Main Content Display Frame goes below
-      include $kontent;
-    ?>
-   </div>
-   <div id="rightbar" class="kontained">
-    <?php
-     include $config['rightbar'];
-    ?>
-   </div>
-  </div>
-   <div id="footbar">
-    <?php
-     include $config['footbar'];
-    ?>
-   </div>
- </body>
-</html>

+ 118 - 0
lib/Trog/Auth.pm

@@ -0,0 +1,118 @@
+package Trog::Auth;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures};
+
+use UUID::Tiny ':std';
+use Digest::SHA 'sha256';
+use Trog::SQLite;
+
+=head1 Trog::Auth
+
+An SQLite3 authdb.
+
+=head1 Termination Conditions
+
+Throws exceptions in the event the session database cannot be accessed.
+
+=head1 FUNCTIONS
+
+=head2 session2user(sessid) = (STRING, INT)
+
+Translate a session UUID into a username and id.
+
+Returns empty strings on no active session.
+
+=cut
+
+sub session2user ($sessid) {
+    my $dbh = _dbh();
+    my $rows = $dbh->selectall_arrayref("SELECT name,id FROM sess_user WHERE session=?",{ Slice => {} }, $sessid);
+    return ('','') unless ref $rows eq 'ARRAY' && @$rows;
+    return ($rows->[0]->{name},$rows->[0]->{id});
+}
+
+=head2 acls4user(user_id) = ARRAYREF
+
+Return the list of ACLs belonging to the user.
+The function of ACLs are to allow you to access content tagged 'private' which are also tagged with the ACL name.
+
+The 'admin' ACL is the only special one, as it allows for authoring posts, configuring tCMS, adding series (ACLs) and more.
+
+=cut
+
+sub acls4user($user_id) {
+    my $dbh = _dbh();
+    my $records = $dbh->selectall_arrayref("SELECT acl FROM user_acl WHERE user_id = ?", { Slice => {} }, $user_id);
+    return () unless ref $records eq 'ARRAY' && @$records;
+    my @acls = map { $_->{acl} } @$records;
+    return \@acls;
+ }
+
+=head2 mksession(user, pass) = STRING
+
+Create a session for the user and waste all other sessions.
+
+Returns a session ID, or blank string in the event the user does not exist or incorrect auth was passed.
+
+=cut
+
+sub mksession ($user,$pass) {
+    my $dbh = _dbh();
+    my $records = $dbh->selectall_arrayref("SELECT salt FROM user WHERE name = ?", { Slice => {} }, $user);
+    return '' unless ref $records eq 'ARRAY' && @$records;
+    my $salt = $records->[0]->{salt};
+    my $hash = sha256($pass.$salt);
+    my $worked = $dbh->selectall_arrayref("SELECT id FROM user WHERE hash=? AND name = ?", { Slice => {} }, $hash, $user);
+    return '' unless ref $worked eq 'ARRAY' && @$worked;
+    my $uid = $worked->[0]->{id};
+    my $uuid = create_uuid_as_string(UUID_V1, UUID_NS_DNS);
+    $dbh->do("INSERT OR REPLACE INTO session (id,user_id) VALUES (?,?)", undef, $uuid, $uid) or return '';
+    return $uuid;
+}
+
+=head2 killsession(user) = BOOL
+
+Delete the provided user's session from the auth db.
+
+=cut
+
+sub killsession ($user) {
+    my $dbh = _dbh();
+    $dbh->do("DELETE FROM session WHERE user_id IN (SELECT id FROM user WHERE name=?)",undef,$user);
+    return 1;
+}
+
+=head2 useradd(user, pass) = BOOL
+
+Adds a user identified by the provided password into the auth DB.
+
+Returns True or False (likely false when user already exists).
+
+=cut
+
+sub useradd ($user, $pass, $acls) {
+    my $dbh = _dbh();
+    my $salt = create_uuid();
+    my $hash = sha256($pass.$salt);
+    my $res =  $dbh->do("INSERT OR REPLACE INTO user (name,salt,hash) VALUES (?,?,?)", undef, $user, $salt, $hash);
+    return unless $res && ref $acls eq 'ARRAY';
+
+    #XXX this is clearly not normalized with an ACL mapping table, will be an issue with large number of users
+    foreach my $acl (@$acls) {
+        return unless $dbh->do("INSERT OR REPLACE INTO user_acl (user_id,acl) VALUES ((SELECT id FROM user WHERE name=?),?)", undef, $user, $acl);
+    }
+    return 1;
+}
+
+# Ensure the db schema is OK, and give us a handle
+sub _dbh {
+    my $file   = 'schema/auth.schema';
+    my $dbname = "$ENV{HOME}/.tcms/auth.db";
+    return Trog::SQLite::dbh($file,$dbname);
+}
+
+1;

+ 28 - 0
lib/Trog/Config.pm

@@ -0,0 +1,28 @@
+package Trog::Config;
+
+use strict;
+use warnings;
+
+use Config::Simple;
+
+=head1 Trog::Config
+
+A thin wrapper around Config::Simple which reads the configuration from the appropriate place.
+
+=head2 Trog::Config::get() = Config::Simple
+
+Returns a configuration object that will be used by server.psgi, the data model and Routing modules.
+
+=cut
+
+our $home_cfg = "$ENV{HOME}/.tcms/main.cfg";
+
+sub get {
+    my $cf;
+    $cf = Config::Simple->new($home_cfg) if -f $home_cfg;
+    return $cf if $cf;
+    $cf = Config::Simple->new('config/default.cfg');
+    return $cf;
+}
+
+1;

+ 29 - 0
lib/Trog/Data.pm

@@ -0,0 +1,29 @@
+package Trog::Data;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures};
+
+#It's just a factory
+
+=head1 Trog::Data
+
+This is a data model factory.
+
+=head2 Trog::Data->new(Trog::Config) = $handle
+
+Returns a new Trog::Data::* class appropriate to what is configured in the Trog::Config object passed.
+
+=cut
+
+sub new( $class, $config ) {
+    my $module = "Trog::Data::".$config->param('general.data_model');
+    my $req = $module;
+    $req =~ s/::/\//g;
+    require "$req.pm";
+    return $module->new($config);
+}
+
+1;

+ 60 - 0
lib/Trog/Data/DUMMY.pm

@@ -0,0 +1,60 @@
+package Trog::Data::DUMMY;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures};
+
+use Carp qw{confess};
+use JSON::MaybeXS;
+use File::Slurper;
+use parent qw{Trog::DataModule};
+
+=head1 WARNING
+
+Do not use this as a production data model.  It is *not* safe to race conditions, and is only here for testing.
+
+=cut
+
+our $datastore = 'data/DUMMY.json';
+sub lang { 'Perl Regex in Quotemeta' }
+sub help { 'https://perldoc.perl.org/functions/quotemeta.html' }
+
+our $posts;
+
+sub read ($self, $query={}) {
+    confess "Can't find datastore!" unless -f $datastore;
+    my $slurped = File::Slurper::read_text($datastore);
+    $posts = JSON::MaybeXS::decode_json($slurped);
+    return $posts;
+}
+
+sub count ($self) {
+    $posts //= $self->read();
+    return scalar(@$posts);
+}
+
+sub write($self,$data,$overwrite=0) {
+    my $orig = [];
+    if ($overwrite) {
+        $orig = $data;
+    } else {
+        $orig = $self->read();
+        push(@$orig,@$data);
+    }
+    open(my $fh, '>', $datastore) or confess;
+    print $fh JSON::MaybeXS::encode_json($orig);
+    close $fh;
+}
+
+sub delete($self, @posts) {
+    my $example_posts = $self->read();
+    foreach my $update (@posts) {
+        @$example_posts = grep { $_->{id} ne $update->{id} } @$example_posts;
+    }
+    $self->write($example_posts,1);
+    return 0;
+}
+
+1;

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

@@ -0,0 +1,103 @@
+package Trog::Data::FlatFile;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures};
+
+use Carp qw{confess};
+use JSON::MaybeXS;
+use File::Slurper;
+use File::Copy;
+use Mojo::File;
+
+use parent qw{Trog::DataModule};
+
+our $datastore = 'data/files';
+sub lang { 'Perl Regex in Quotemeta' }
+sub help { 'https://perldoc.perl.org/functions/quotemeta.html' }
+
+=head1 Trog::Data::FlatFile
+
+This data model has multiple drawbacks, but is "good enough" for most low-content and few editor applications.
+You can only post once per second due to it storing each post as a file named after the timestamp.
+
+=cut
+
+our $parser = JSON::MaybeXS->new();
+
+sub read ($self, $query={}) {
+    #Optimize direct ID
+    my @index;
+    if ($query->{id}) {
+        @index = ("$datastore/$query->{id}");
+    } else {
+        @index = $self->_index();
+    }
+    $query->{limit} //= 25;
+
+    my @items;
+    foreach my $item (@index) {
+        next unless -f $item;
+        my $slurped = File::Slurper::read_text($item);
+        my $parsed  = $parser->decode($slurped);
+
+        #XXX this imposes an inefficiency in itself, get() will filter uselessly again here
+        my @filtered = $self->filter($query,@$parsed);
+
+        push(@items,@filtered) if @filtered;
+        last if scalar(@items) == $query->{limit};
+    }
+
+    return \@items;
+}
+
+sub _index ($self) {
+    confess "Can't find datastore!" unless -d $datastore;
+    opendir(my $dh, $datastore) or confess;
+    my @index = grep { -f } map { "$datastore/$_" } readdir $dh;
+    closedir $dh;
+    return sort { $b cmp $a } @index;
+}
+
+sub write($self,$data) {
+    foreach my $post (@$data) {
+        my $file = "$datastore/$post->{id}";
+        my $update = [$post];
+        if (-f $file) {
+            my $slurped = File::Slurper::read_text($file);
+            my $parsed  = $parser->decode($slurped);
+
+            $update = [(@$parsed, $post)];
+        }
+
+        open(my $fh, '>', $file) or confess;
+        print $fh $parser->encode($update);
+        close $fh;
+    }
+}
+
+sub count ($self) {
+    my @index = $self->_index();
+    return scalar(@index);
+}
+
+sub add ($self,@posts) {
+    my $ctime = time();
+    @posts = map {
+        $_->{id} //= $ctime;
+        $_->{created} = $ctime;
+        $_
+    } @posts;
+    return $self->SUPER::add(@posts);
+}
+
+sub delete($self, @posts) {
+    foreach my $update (@posts) {
+        unlink "$datastore/$update->{id}" or confess;
+    }
+    return 0;
+}
+
+1;

+ 298 - 0
lib/Trog/DataModule.pm

@@ -0,0 +1,298 @@
+package Trog::DataModule;
+
+use strict;
+use warnings;
+
+use List::Util;
+use File::Copy;
+use Mojo::File;
+
+no warnings 'experimental';
+use feature qw{signatures};
+
+=head1 QUERY FORMAT
+
+The $query_language and $query_help variables are presented to the user as to how to use the search box in the tCMS header.
+
+=head1 POST STRUCTURE
+
+Posts generally need to have the following:
+
+    data: Brief description of content, or the content itself.
+    content_type: What this content actually is.  Used to filter into the appropriate pages.
+    href: Primary link.  This is the subject of a news post, or a link to the item itself.  Can be local or remote.
+    local_href: Backup link.  Automatically created link to a static cache of the content.
+    title: Title of the content.  Used as link name for the 'href' attribute.
+    user: User was banned for this post
+    id: Internal identifier in datastore for the post.
+    tags: array ref of appropriate tags.
+    created: timestamp of creation of this version of the post
+    version: revision # of this post.
+
+=head1 CONSTRUCTOR
+
+=head2 new(Config::Simple $config)
+
+Try not to do expensive things here.
+
+=cut
+
+sub new ($class, $config) {
+    $config = $config->vars();
+    return bless($config, $class);
+}
+
+#It is required that subclasses implement this
+sub lang  ($self) { ... }
+sub help  ($self) { ... }
+sub read  ($self,$query={}) { ... }
+sub write ($self) { ... }
+sub count ($self) { ... }
+
+=head1 METHODS
+
+=head2 get(%request)
+
+Queries the data model.  Should return the following:
+
+    id   => Filter down to just the post by ID.  May be subsequently filtered by ACL, resulting in a 404 (which is good, as it does not disclose info).
+
+    version => if id is passed, return the provided post version rather than the most recent one
+
+    tags => ARRAYREF of tags, any one of which is required to give a result.  If none are passed, no filtering is performed.
+
+    acls => ARRAYREF of acl tags, any one of which is required to give result. Filter applies after tags.  'admin' ACL being present skips this filter.
+
+    page => Offset multiplier for pagination.
+
+    limit => Offset for pagination.
+
+    like => Search query, as might be passed in the search bar.
+
+    author => filter by post author
+
+If it is more efficient to filter within your data storage engine, you probably should override this method.
+As implemented, this takes the data as a given and filters in post.
+
+=cut
+
+sub get ($self, %request) {
+
+    my $posts = $self->read(\%request);
+
+    my @filtered = $self->filter(\%request, @$posts);
+    @filtered = $self->_fixup(@filtered);
+    @filtered = $self->paginate(\%request,@filtered);
+    return @filtered;
+}
+
+sub _fixup ($self, @filtered) {
+    @filtered = _add_post_type(@filtered);
+    # Next, add the type of post this is
+    @filtered = _add_media_type(@filtered);
+    # Finally, add visibility
+    @filtered = _add_visibility(@filtered);
+    return @filtered;
+}
+
+sub filter ($self, $query, @filtered) {
+    my %request = %$query; #XXX update varnames instead
+    $request{acls} //= [];
+    $request{tags} //=[];
+
+    # If an ID is passed, just get that (and all it's prior versions)
+    if ($request{id}) {
+        @filtered = grep { $_->{id} eq $request{id} } @filtered   if $request{id};
+        @filtered = _dedup_versions($request{version}, @filtered);
+        return @filtered;
+    }
+
+    @filtered = _dedup_versions(undef, @filtered);
+
+    #Filter out posts which are too old
+    @filtered = grep { $_->{created} < $request{older} } @filtered if $request{older};
+
+    #XXX Heal bad data -- probably not needed
+    @filtered = map { my $t = $_->{tags}; @$t = grep { defined $_ } @$t; $_ } @filtered;
+
+    # Next, handle the query, tags and ACLs
+    @filtered = grep { my $tags = $_->{tags}; grep { my $t = $_; grep {$t eq $_ } @{$request{tags}} } @$tags } @filtered if @{$request{tags}};
+    @filtered = grep { my $tags = $_->{tags}; grep { my $t = $_; grep {$t eq $_ } @{$request{acls}} } @$tags } @filtered unless grep { $_ eq 'admin' } @{$request{acls}};
+
+    @filtered = grep { $_->{title} =~ m/\Q$request{like}\E/i || $_->{data} =~ m/\Q$request{like}\E/i } @filtered if $request{like};
+
+    @filtered = grep { $_->{user} eq $request{author} } @filtered if $request{author};
+
+    return @filtered;
+}
+
+sub paginate ($self, $query, @filtered) {
+    my %request = %$query; #XXX change varnames
+    my $offset = int($request{limit} // 25);
+    $offset = @filtered < $offset ? @filtered : $offset;
+    @filtered = splice(@filtered, ( int($request{page}) -1) * $offset, $offset) if $request{page} && $request{limit};
+    return @filtered;
+}
+
+sub _dedup_versions ($version=-1, @posts) {
+
+    #ASSUMPTION made here - if we pass version this is direct ID query
+    if (defined $version) {
+        my $version_max = List::Util::max(map { $_->{version} } @posts);
+
+        return map {
+            $_->{version_max} //= $version_max;
+            $_
+        } grep { $_->{version} eq $version } @posts;
+    }
+
+    my @uniqids = List::Util::uniq(map { $_->{id} } @posts);
+    my %posts_deduped;
+    for my $id (@uniqids) {
+        my @ofid = sort { $b->{version} cmp $a->{version} } grep { $_->{id} eq $id } @posts;
+        my $version_max = List::Util::max(map { $_->{version } } @ofid);
+        $posts_deduped{$id} = $ofid[0];
+        $posts_deduped{$id}{version_max} = $version_max;
+    }
+    my @deduped = @posts_deduped{@uniqids};
+
+    return @deduped;
+}
+
+#XXX this probably should be re-factored to be baked into the data from the get-go
+sub _add_post_type (@posts) {
+    return map {
+        my $post = $_;
+        my $type = 'file';
+        $type = 'blog'      if grep { $_ eq 'blog'   } @{$post->{tags}};
+        $type = 'microblog' if grep { $_ eq 'news'   } @{$post->{tags}};
+        $type = 'profile'   if grep { $_ eq 'about'  } @{$post->{tags}};
+        $type = 'series'    if grep { $_ eq 'series' } @{$post->{tags}};
+        $post->{type} = $type;
+        $post
+    } @posts;
+}
+
+sub _add_media_type (@posts) {
+    return map {
+        my $post = $_;
+        $post->{content_type} //= '';
+        $post->{is_video}   = 1 if $post->{content_type} =~ m/^video\//;
+        $post->{is_audio}   = 1 if $post->{content_type} =~ m/^audio\//;
+        $post->{is_image}   = 1 if $post->{content_type} =~ m/^image\//;
+        $post->{is_profile} = 1 if grep {$_ eq 'about' } @{$post->{tags}};
+        $post
+    } @posts;
+}
+
+sub _add_visibility (@posts) {
+    return map {
+        my $post = $_;
+        my @visibilities = grep { my $tag = $_; grep { $_ eq $tag } qw{private unlisted public} } @{$post->{tags}};
+        $post->{visibility} = $visibilities[0];
+        $post
+    } @posts;
+}
+
+=head2 count() = INT $num
+
+Returns the total number of posts.
+Used to determine paginator parameters.
+
+=cut
+
+=head2 add(@posts) = BOOL $failed_or_not
+
+Add the provided posts to the datastore.
+If any post already exists with the same id, a new post with a version higher than it will be added.
+
+Passes an array of new posts to add to the data store module's write() function.
+
+You probably won't want to override this.
+
+=cut
+
+sub add ($self, @posts) {
+    require UUID::Tiny;
+    my @to_write;
+    foreach my $post (@posts) {
+        $post->{id} //= UUID::Tiny::create_uuid_as_string(UUID::Tiny::UUID_V1, UUID::Tiny::UUID_NS_DNS);
+        $post->{created} = time();
+        my @existing_posts = $self->get( id => $post->{id} );
+        if (@existing_posts) {
+            my $existing_post = $existing_posts[0];
+            $post->{version}  = $existing_post->{version};
+            $post->{version}++;
+        }
+        $post->{version} //= 0;
+
+        $post = _process($post);
+        push @to_write, $post;
+    }
+    $self->write(\@to_write);
+    return 0;
+}
+
+#XXX this level of post-processing seems gross, but may be unavoidable
+# Not actually a subprocess, kek
+sub _process ($post) {
+
+    $post->{href}      = _handle_upload($post->{file}, $post->{id})         if $post->{file};
+    $post->{preview}   = _handle_upload($post->{preview_file}, $post->{id}) if $post->{preview_file};
+    $post->{wallpaper} = _handle_upload($post->{wallpaper_file}, $post->{id})    if $post->{wallpaper_file};
+    $post->{preview} = $post->{href} if $post->{app} eq 'image';
+    delete $post->{app};
+    delete $post->{file};
+    delete $post->{preview_file};
+
+    delete $post->{route};
+    delete $post->{domain};
+
+    # Handle acls/tags
+    $post->{tags} //= [];
+    @{$post->{tags}} = grep { my $subj = $_; !grep { $_ eq $subj} qw{public private unlisted} } @{$post->{tags}};
+    push(@{$post->{tags}}, delete $post->{acls}) if $post->{visibility} eq 'private';
+    push(@{$post->{tags}}, delete $post->{visibility});
+
+    #Filter adding the same acl twice
+    @{$post->{tags}} = List::Util::uniq(@{$post->{tags}});
+
+    # Handle multimedia content types
+    if ($post->{href}) {
+        my $mf = Mojo::File->new("www/$post->{href}");
+        my $ext = '.'.$mf->extname();
+        $post->{content_type} = Plack::MIME->mime_type($ext) if $ext;
+    }
+    if ($post->{video_href}) {
+        my $mf = Mojo::File->new("www/$post->{video_href}");
+        my $ext = '.'.$mf->extname();
+        $post->{video_content_type} = Plack::MIME->mime_type($ext) if $ext;
+    }
+    if ($post->{audio_href}) {
+        my $mf = Mojo::File->new("www/$post->{audio_href}");
+        my $ext = '.'.$mf->extname();
+        $post->{audio_content_type} = Plack::MIME->mime_type($ext) if $ext;
+    }
+
+    return $post;
+}
+
+sub _handle_upload ($file, $uuid) {
+    my $f = $file->{tempname};
+    my $newname = "$uuid.$file->{filename}";
+    File::Copy::move($f, "www/assets/$newname");
+    return "/assets/$newname";
+}
+
+=head2 delete(@posts)
+
+Delete the following posts.
+Will remove all versions of said post.
+
+You should override this, it is a stub here.
+
+=cut
+
+sub delete ($self) { die 'stub' }
+
+1;

+ 977 - 0
lib/Trog/Routes/HTML.pm

@@ -0,0 +1,977 @@
+package Trog::Routes::HTML;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use File::Touch();
+use List::Util();
+use Capture::Tiny qw{capture};
+
+use Trog::Config;
+use Trog::Data;
+
+my $conf = Trog::Config::get();
+my $template_dir = 'www/templates';
+my $theme_dir = '';
+$theme_dir = "themes/".$conf->param('general.theme') if $conf->param('general.theme') && -d "www/themes/".$conf->param('general.theme');
+my $td = $theme_dir ? "/$theme_dir" : '';
+
+use lib 'www';
+
+our $landing_page  = 'default.tx';
+our $htmltitle     = 'title.tx';
+our $midtitle      = 'midtitle.tx';
+our $rightbar      = 'rightbar.tx';
+our $leftbar       = 'leftbar.tx';
+our $footbar       = 'footbar.tx';
+
+our %routes = (
+    default => {
+        callback => \&Trog::Routes::HTML::setup,
+    },
+    '/' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::index,
+    },
+# This should only be enabled to debug
+#    '/setup' => {
+#        method   => 'GET',
+#        callback => \&Trog::Routes::HTML::setup,
+#    },
+    '/login' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::login,
+    },
+    '/logout' => {
+        method => 'GET',
+        callback => \&Trog::Routes::HTML::logout,
+    },
+    '/auth' => {
+        method   => 'POST',
+        nostatic => 1,
+        callback => \&Trog::Routes::HTML::login,
+    },
+    '/config' => {
+        method   => 'GET',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::config,
+    },
+    '/config/save' => {
+        method   => 'POST',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::config_save,
+    },
+    '/post' => {
+        method   => 'GET',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::post,
+    },
+    '/post/save' => {
+        method   => 'POST',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::post_save,
+    },
+    '/post/delete' => {
+        method   => 'POST',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::post_delete,
+    },
+    '/post/(.*)' => {
+        method   => 'GET',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::post,
+        captures => ['id'],
+    },
+    '/posts/(.*)' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::posts,
+        captures => ['id'],
+    },
+    '/posts' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::posts,
+    },
+    '/profile' => {
+        method   => 'POST',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::profile,
+    },
+    '/themeclone' => {
+        method   => 'POST',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::themeclone,
+    },
+    '/sitemap', => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::sitemap,
+    },
+    '/sitemap_index.xml', => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::sitemap,
+        data     => { xml => 1 },
+    },
+    '/sitemap_index.xml.gz', => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::sitemap,
+        data     => { xml => 1, compressed => 1 },
+    },
+    '/sitemap/static.xml' => {
+        method => 'GET',
+        callback => \&Trog::Routes::HTML::sitemap,
+        data     => { xml => 1, map => 'static' },
+    },
+    '/sitemap/static.xml.gz' => {
+        method => 'GET',
+        callback => \&Trog::Routes::HTML::sitemap,
+        data     => { xml => 1, compressed => 1, map => 'static' },
+    },
+    '/sitemap/(.*).xml' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::sitemap,
+        data     => { xml => 1 },
+        captures => ['map'],
+    },
+    '/sitemap/(.*).xml.gz' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::sitemap,
+        data     => { xml => 1, compressed => 1},
+        captures => ['map'],
+    },
+    '/robots.txt' => {
+        method    => 'GET',
+        callback  => \&Trog::Routes::HTML::robots,
+    },
+    '/humans.txt' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::posts,
+        data     => { tag => ['about'] },
+    },
+    '/styles/avatars.css' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::avatars,
+        data     => { tag => ['about'] },
+    },
+    '/users/(.*)' => {
+        method => 'GET',
+        callback => \&Trog::Routes::HTML::users,
+        captures => ['username'],
+    },
+    '/manual' => {
+        method => 'GET',
+        auth   => 1,
+        callback => \&Trog::Routes::HTML::manual,
+    },
+    '/lib/(.*)' => {
+        method => 'GET',
+        auth   => 1,
+        captures => ['module'],
+        callback => \&Trog::Routes::HTML::manual,
+    },
+);
+
+# Build aliases for /posts and /post with extra data
+my @post_aliases = qw{news blog image video audio about files series};
+@routes{map { "/$_" } @post_aliases} = map { my %copy = %{$routes{'/posts'}}; $copy{data}{tag} = [$_]; \%copy } @post_aliases;
+
+#TODO clean this up so we don't need _build_post_type
+@routes{map { "/post/$_" } qw{image video audio files}} = map { my %copy = %{$routes{'/post'}}; $copy{data}{tag} = [$_]; $copy{data}{type} = 'file'; \%copy } qw{image video audio files};
+$routes{'/post/news'}    = { method => 'GET', auth => 1, callback => \&Trog::Routes::HTML::post, data => { tag => ['news'],    type => 'microblog' } };
+$routes{'/post/blog'}    = { method => 'GET', auth => 1, callback => \&Trog::Routes::HTML::post, data => { tag => ['blog'],    type => 'blog'      } };
+$routes{'/post/about'}   = { method => 'GET', auth => 1, callback => \&Trog::Routes::HTML::post, data => { tag => ['about'],   type => 'profile'   } };
+$routes{'/post/series'}  = { method => 'GET', auth => 1, callback => \&Trog::Routes::HTML::post, data => { tag => ['series'],  type => 'series'    } };
+
+# Build aliases for /posts/(.*) and /post/(.*) with extra data
+@routes{map { "/$_/(.*)" } @post_aliases} = map { my %copy = %{$routes{'/posts/(.*)'}}; \%copy } @post_aliases;
+@routes{map { "/post/$_/(.*)" } @post_aliases} = map { my %copy = %{$routes{'/post/(.*)'}}; \%copy } @post_aliases;
+
+# /series/$ID is a bit of a special case, it's actuallly gonna need special processing
+$routes{'/series/(.*)'} = { method => 'GET', auth => 1, callback => \&Trog::Routes::HTML::series, captures => ['id'] };
+
+# Grab theme routes
+my $themed = 0;
+if ($theme_dir) {
+    my $theme_mod = "$theme_dir/routes.pm";
+    if (-f "www/$theme_mod" ) {
+        require $theme_mod;
+        @routes{keys(%Theme::routes)} = values(%Theme::routes);
+        $themed = 1;
+    }
+}
+
+=head1 PRIMARY ROUTE
+
+=head2 index
+
+Implements the primary route used by all pages not behind auth.
+Most subsequent functions simply pass content to this function.
+
+=cut
+
+sub index ($query,$render_cb, $content = '', $i_styles = []) {
+    $query->{theme_dir}  = $td;
+
+    my $processor = Text::Xslate->new(
+        path   => $template_dir,
+    );
+
+    my $t_processor;
+    $t_processor = Text::Xslate->new(
+        path =>  "www/$theme_dir/templates",
+    ) if $theme_dir;
+
+    $content ||= _pick_processor($rightbar,$processor,$t_processor)->render($landing_page,$query);
+
+    my @styles = ('/styles/avatars.css');
+    if ($theme_dir) {
+        unshift(@styles, _themed_style("screen.css"))    if -f 'www/'._themed_style("screen.css");
+        unshift(@styles, _themed_style("structure.css")) if -f 'www/'._themed_style("structure.css");
+    }
+    push( @styles, @$i_styles );
+
+    #TODO allow theming of print css
+
+    my $search_info = Trog::Data->new($conf);
+
+    return $render_cb->('index.tx', {
+        code        => $query->{code},
+        user        => $query->{user},
+        search_lang => $search_info->lang(),
+        search_help => $search_info->help(),
+        route       => $query->{route},
+        theme_dir   => $td,
+        content     => $content,
+        title       => $query->{title} // $Theme::default_title // 'tCMS',
+        htmltitle   => _pick_processor("templates/$htmltitle" ,$processor,$t_processor)->render($htmltitle,$query),
+        midtitle    => _pick_processor("templates/$midtitle"  ,$processor,$t_processor)->render($midtitle,$query),
+        rightbar    => _pick_processor("templates/$rightbar"  ,$processor,$t_processor)->render($rightbar,$query),
+        leftbar     => _pick_processor("templates/$leftbar"   ,$processor,$t_processor)->render($leftbar,$query),
+        footbar     => _pick_processor("templates/$footbar"   ,$processor,$t_processor)->render($footbar,$query),
+        stylesheets => \@styles,
+    });
+}
+
+=head1 ADMIN ROUTES
+
+These are things that issue returns other than 200, and are not directly accessible by users via any defined route.
+
+=head2 notfound, forbidden, badrequest
+
+Implements the 4XX status codes.  Override templates named the same for theming this.
+
+=cut
+
+sub _generic_route ($rname, $code, $title, $query, $render_cb) {
+    $query->{code} = $code;
+
+    my $processor = Text::Xslate->new(
+        path   => _dir_for_resource("$rname.tx"),
+    );
+
+    $query->{title} = $title;
+    my $styles = _build_themed_styles("$rname.css");
+    my $content = $processor->render("$rname.tx", {
+        title    => $title,
+        route    => $query->{route},
+        user     => $query->{user},
+        styles   => $styles,
+    });
+    return Trog::Routes::HTML::index($query, $render_cb, $content, $styles);
+}
+
+sub notfound (@args) {
+    return _generic_route('notfound',404,"Return to sender, Address unknown", @args);
+}
+
+sub forbidden (@args) {
+    return _generic_route('forbidden', 403, "STAY OUT YOU RED MENACE", @args);
+}
+
+sub badrequest (@args) {
+    return _generic_route('badrequest', 400, "Bad Request", @args);
+}
+
+# TODO Rate limiting route
+
+=head1 NORMAL ROUTES
+
+These are expected to either return a 200, or redirect to something which does.
+
+=head2 robots
+
+Return an appropriate robots.txt
+
+=cut
+
+sub robots ($query, $render_cb) {
+    my $processor = Text::Xslate->new(
+        path   => $template_dir,
+    );
+    return [200, ["Content-type:text/plain\n"],[$processor->render('robots.tx', { domain => $query->{domain} })]];
+}
+
+=head2 setup
+
+One time setup page; should only display to the first user to visit the site which we presume to be the administrator.
+
+=cut
+
+sub setup ($query, $render_cb) {
+    File::Touch::touch("$ENV{HOME}/.tcms/setup");
+    return $render_cb->('notconfigured.tx', {
+        title => 'tCMS Requires Setup to Continue...',
+        stylesheets => _build_themed_styles('notconfigured.css'),
+    });
+}
+
+=head2 login
+
+Sets the user cookie if the provided user exists, or sets up the user as an admin with the provided credentials in the event that no users exist.
+
+=cut
+
+sub login ($query, $render_cb) {
+
+    # Redirect if we actually have a logged in user.
+    # Note to future me -- this user value is overwritten explicitly in server.psgi.
+    # If that ever changes, you will die
+    $query->{to} //= $query->{route};
+    $query->{to} = '/config' if $query->{to} eq '/login';
+    if ($query->{user}) {
+        return $routes{$query->{to}}{callback}->($query,$render_cb);
+    }
+
+    #Check and see if we have no users.  If so we will just accept whatever creds are passed.
+    my $hasusers = -f "$ENV{HOME}/.tcms/has_users";
+    my $btnmsg = $hasusers ? "Log In" : "Register";
+
+    my @headers;
+    if ($query->{username} && $query->{password}) {
+        if (!$hasusers) {
+            # Make the first user
+            Trog::Auth::useradd($query->{username}, $query->{password}, ['admin'] );
+            File::Touch::touch("$ENV{HOME}/.tcms/has_users");
+        }
+
+        $query->{failed} = 1;
+        my $cookie = Trog::Auth::mksession($query->{username}, $query->{password});
+        if ($cookie) {
+            # TODO secure / sameSite cookie to kill csrf, maybe do rememberme with Expires=~0
+            my $secure = '';
+            $secure = '; Secure' if $query->{scheme} eq 'https';
+            @headers = (
+                "Set-Cookie: tcmslogin=$cookie; HttpOnly; SameSite=Strict$secure",
+            );
+            $query->{failed} = 0;
+        }
+    }
+
+    $query->{failed} //= -1;
+    return $render_cb->('login.tx', {
+        title         => 'tCMS 2 ~ Login',
+        to            => $query->{to},
+        failure => int( $query->{failed} ),
+        message => int( $query->{failed} ) < 1 ? "Login Successful, Redirecting..." : "Login Failed.",
+        btnmsg        => $btnmsg,
+        stylesheets   => _build_themed_styles('login.css'),
+        theme_dir     => $td,
+    }, @headers);
+}
+
+=head2 logout
+
+Deletes your users' session and opens the login page.
+
+=cut
+
+sub logout ($query, $render_cb) {
+    Trog::Auth::killsession($query->{user}) if $query->{user};
+    delete $query->{user};
+    $query->{to} = '/config';
+    return login($query,$render_cb);
+}
+
+=head2 config
+
+Renders the configuration page, or redirects you back to the login page.
+
+=cut
+
+sub config ($query, $render_cb) {
+    if (!$query->{user}) {
+        return login($query,$render_cb);
+    }
+    #NOTE: we are relying on this to skip the ACL check with 'admin', this may not be viable in future?
+    return forbidden($query, $render_cb) unless grep { $_ eq 'admin' } @{$query->{acls}};
+
+    my $css   = _build_themed_styles('config.css');
+    my $js    = _build_themed_scripts('post.js');
+
+    $query->{failure} //= -1;
+
+    return $render_cb->('config.tx', {
+        title              => 'Configure tCMS',
+        stylesheets        => $css,
+        scripts            => $js,
+        themes             => _get_themes(),
+        data_models        => _get_data_models(),
+        current_theme      => $conf->param('general.theme') // '',
+        current_data_model => $conf->param('general.data_model') // 'DUMMY',
+        message     => $query->{message},
+        failure     => $query->{failure},
+        to          => '/config',
+    });
+}
+
+sub _get_themes {
+    my $dir = 'www/themes';
+    opendir(my $dh, $dir) || die "Can't opendir $dir: $!";
+    my @tdirs = grep { !/^\./ && -d "$dir/$_" } readdir($dh);
+    closedir $dh;
+    return \@tdirs;
+}
+
+sub _get_data_models {
+    my $dir = 'lib/Trog/Data';
+    opendir(my $dh, $dir) || die "Can't opendir $dir: $!";
+    my @dmods = map { s/\.pm$//g; $_ } grep { /\.pm$/ && -f "$dir/$_" } readdir($dh);
+    closedir $dh;
+    return \@dmods
+}
+
+=head2 config_save
+
+Implements /config/save route.  Saves what little configuration we actually use to ~/.tcms/tcms.conf
+
+=cut
+
+sub config_save ($query, $render_cb) {
+    $conf->param( 'general.theme',      $query->{theme} )      if defined $query->{theme};
+    $conf->param( 'general.data_model', $query->{data_model} ) if $query->{data_model};
+
+    $query->{failure} = 1;
+    $query->{message} = "Failed to save configuration!";
+    if ($conf->write($Trog::Config::home_cfg)) {
+        $query->{failure} = 0;
+        $query->{message} = "Configuration updated succesfully.";
+    }
+    #Get the PID of the parent port using lsof, send HUP
+    my $parent = getppid;
+    kill 'HUP', $parent;
+
+    return config($query, $render_cb);
+}
+
+=head2 themeclone
+
+Clone a theme by copying a directory.
+
+=cut
+
+sub themeclone ($query, $render_cb) {
+    my ($theme, $newtheme) = ($query->{theme},$query->{newtheme});
+
+    my $themedir = 'www/themes';
+
+    $query->{failure} = 1;
+    $query->{message} = "Failed to clone theme '$theme' as '$newtheme'!";
+    require File::Copy::Recursive;
+    if ($theme && $newtheme && File::Copy::Recursive::dircopy( "$themedir/$theme", "$themedir/$newtheme" )) {
+        $query->{failure} = 0;
+        $query->{message} = "Successfully cloned theme '$theme' as '$newtheme'.";
+    }
+    return config($query, $render_cb);
+}
+
+=head2 post
+
+Display the route for making new posts.
+
+=cut
+
+sub post ($query, $render_cb) {
+    if (!$query->{user}) {
+        return login($query, $render_cb);
+    }
+    $query->{acls} = _coerce_array($query->{acls});
+    return forbidden($query, $render_cb) unless grep { $_ eq 'admin' } @{$query->{acls}};
+
+    my $tags  = _coerce_array($query->{tag});
+    my @posts = _post_helper($query, $tags, $query->{acls});
+    my $css   = _build_themed_styles('post.css');
+    my $js    = _build_themed_scripts('post.js');
+    push(@$css, '/styles/avatars.css');
+    my @acls  = _post_helper({}, ['series'], $query->{acls});
+
+    my $app = 'file';
+    if ($query->{route}) {
+        $app = 'image' if $query->{route} =~ m/image$/;
+        $app = 'video' if $query->{route} =~ m/video$/;
+        $app = 'audio' if $query->{route} =~ m/audio$/;
+    }
+
+    #Filter displaying acl/visibility tags
+    my @visibuddies = qw{public unlisted private};
+    foreach my $post (@posts) {
+        @{$post->{tags}} = grep { my $tag = $_; !grep { $tag eq $_ } (@visibuddies, map { $_->{aclname} } @acls ) } @{$post->{tags}};
+    }
+
+    my $limit = int($query->{limit} || 25);
+
+    return $render_cb->('post.tx', {
+        title       => 'New Post',
+        to          => $query->{to},
+        failure     => $query->{failure} // -1,
+        message     => $query->{message},
+        post_visibilities => \@visibuddies,
+        stylesheets => $css,
+        scripts     => $js,
+        posts       => \@posts,
+        can_edit    => 1,
+        route       => $query->{route},
+        category    => '/posts',
+        limit       => $limit,
+        pages       => scalar(@posts) == $limit,
+        older       => @posts ? $posts[-1]->{created} : '',
+        sizes       => [25,50,100],
+        id          => $query->{id},
+        acls        => \@acls,
+        post        => { tags => $query->{tag} },
+        edittype    => $query->{type} || 'microblog',
+        app         => $app,
+    });
+}
+
+=head2 post_save
+
+Saves posts submitted via the /post pages
+
+=cut
+
+sub post_save ($query, $render_cb) {
+    my $to = delete $query->{to};
+
+    #Copy this down since it will be deleted later
+    my $acls = $query->{acls};
+    state $data = Trog::Data->new($conf);
+    $query->{tags}  = _coerce_array($query->{tags});
+    $query->{failure} = $data->add($query);
+    $query->{to} = $to;
+    $query->{acls} = $acls;
+    $query->{message} = $query->{failure} ? "Failed to add post!" : "Successfully added Post as $query->{id}";
+    delete $query->{id};
+    return post($query, $render_cb);
+}
+
+=head2 profile
+
+Saves / updates new users.
+
+=cut
+
+sub profile ($query, $render_cb) {
+    #TODO allow users to do something OTHER than be admins
+    if ($query->{password}) {
+        Trog::Auth::useradd($query->{username}, $query->{password}, ['admin'] );
+    }
+
+    #Make sure it is "self-authored", redact pw
+    $query->{user} = delete $query->{username};
+    delete $query->{password};
+
+    return post_save($query, $render_cb);
+}
+
+=head2 post_delete
+
+deletes posts.
+
+=cut
+
+sub post_delete ($query, $render_cb) {
+    state $data = Trog::Data->new($conf);
+    $query->{failure} = $data->delete($query);
+    $query->{to} = $query->{to};
+    $query->{message} = $query->{failure} ? "Failed to delete post $query->{id}!" : "Successfully deleted Post $query->{id}";
+    delete $query->{id};
+    return post($query, $render_cb);
+}
+
+=head2 series
+
+Add new 'series' (ACLs) to classify content with.
+
+=cut
+
+sub series ($query, $render_cb) {
+    #Grab the relevant tag (aclname), then pass that to posts
+    my @posts = _post_helper($query, [], $query->{acls});
+    delete $query->{id};
+
+    $query->{tag} = $posts[0]->{aclname};
+    return posts($query,$render_cb);
+}
+
+=head2 avatars
+
+Returns the avatars.css.  Limited to 1000 users.
+
+=cut
+
+sub avatars ($query, $render_cb) {
+    #XXX if you have more than 1000 editors you should stop
+    push(@{$query->{acls}}, 'public');
+    my $tags = _coerce_array($query->{tag});
+    $query->{limit} = 1000;
+    my $processor = Text::Xslate->new(
+        path   => $template_dir,
+    );
+    my @posts = _post_helper($query, $tags, $query->{acls});
+
+    my $content = $processor->render('avatars.tx', {
+        users => \@posts,
+    });
+
+    return [200,["Content-type: text/css\n"],[$content]];
+}
+
+=head2 users
+
+Implements direct user profile view.
+
+=cut
+
+sub users ($query, $render_cb) {
+    push(@{$query->{acls}}, 'public');
+    my @posts = _post_helper({ limit => 10000 }, ['about'], $query->{acls});
+    my @user = grep { $_->{user} eq $query->{username} } @posts;
+    $query->{id} = $user[0]->{id};
+    $query->{user_obj} = $user[0];
+    return posts($query,$render_cb);
+}
+
+=head2 posts
+
+Display multi or single posts, supports RSS and pagination.
+
+=cut
+
+sub posts ($query, $render_cb) {
+    my $tags = _coerce_array($query->{tag});
+
+    push(@{$query->{acls}}, 'public');
+    push(@{$query->{acls}}, 'unlisted') if $query->{id};
+    my @posts;
+
+    if ($query->{user_obj}) {
+        #Optimize the /users/* route
+        @posts = ($query->{user_obj});
+    } else {
+        @posts = _post_helper($query, $tags, $query->{acls});
+    }
+
+    #OK, so if we have a user as the ID we found, go grab the rest of their posts
+    if ($query->{id} && @posts && grep { $_ eq 'about'} @{$posts[0]->{tags}} ) {
+        my $user = shift(@posts);
+        my $id = delete $query->{id};
+        $query->{author} = $user->{user};
+        @posts = _post_helper($query, [], $query->{acls});
+        @posts = grep { $_->{id} ne $id } @posts;
+        unshift @posts, $user;
+    }
+
+    return notfound($query, $render_cb) unless @posts;
+
+    my $fmt = $query->{format} || '';
+    return _rss($query,\@posts) if $fmt eq 'rss';
+
+    my $processor = Text::Xslate->new(
+        path   => $template_dir,
+    );
+
+    # Themed header/footer for about page -- TODO maybe make this generic so we can have MESSAGE FROM JIMBO WALES everywhere
+    my ($header,$footer);
+    if ($query->{route} eq '/about' || $query->{route} eq '/humans.txt') {
+        my $t_processor;
+        $t_processor = Text::Xslate->new(
+            path =>  "www/$theme_dir/templates",
+        ) if $theme_dir;
+
+        $header = _pick_processor("templates/about_header.tx"  ,$processor,$t_processor)->render('about_header.tx', { theme_dir => $td } );
+        $footer = _pick_processor("templates/about_header.tx"  ,$processor,$t_processor)->render('about_footer.tx', { theme_dir => $td } );
+    }
+
+    my $styles = _build_themed_styles('posts.css');
+
+    $query->{title} = @$tags && $query->{domain} ? "$query->{domain} : @$tags" : undef;
+    my $limit = int($query->{limit} || 25);
+
+    my $content = $processor->render('posts.tx', {
+        title     => $query->{title},
+        posts     => \@posts,
+        in_series => !!($query->{route} =~ m/\/series\/\d*$/),
+        route     => $query->{route},
+        limit     => $limit,
+        pages     => scalar(@posts) == $limit,
+        older     => $posts[-1]->{created},
+        sizes     => [25,50,100],
+        rss       => !$query->{id} && !$query->{older},
+        tiled     => scalar(grep { $_ eq $query->{route} } qw{/files /audio /video /image /series /about}),
+        category  => $themed ? Theme::path_to_tile($query->{route}) : $query->{route},
+        about_header => $header,
+        about_footer => $footer,
+    });
+    return Trog::Routes::HTML::index($query, $render_cb, $content, $styles);
+}
+
+sub _post_helper ($query, $tags, $acls) {
+    state $data = Trog::Data->new($conf);
+    return $data->get(
+        older   => $query->{older},
+        page    => int($query->{page} || 1),
+        limit   => int($query->{limit} || 25),
+        tags    => $tags,
+        acls    => $acls,
+        like    => $query->{like},
+        author  => $query->{author},
+        id      => $query->{id},
+        version => $query->{version},
+    );
+}
+
+=head2 sitemap
+
+Return the sitemap index unless the static or a set of dynamic routes is requested.
+We have a maximum of 99,990,000 posts we can make under this model
+As we have 10,000 * 10,000 posts which are indexable via the sitemap format.
+1 top level index slot (10k posts) is taken by our static routes, the rest will be /posts.
+
+Passing ?xml=1 will result in an appropriate sitemap.xml instead.
+This is used to generate the static sitemaps as expected by search engines.
+
+Passing compressed=1 will gzip the output.
+
+=cut
+
+sub sitemap ($query, $render_cb) {
+
+    my (@to_map, $is_index, $route_type);
+    my $warning = '';
+    $query->{map} //= '';
+    if ($query->{map} eq 'static') {
+        # Return the map of static routes
+        $route_type = 'Static Routes';
+        @to_map = grep { !defined $routes{$_}->{captures} && $_ !~ m/^default|login|auth$/ && !$routes{$_}->{auth} } keys(%routes);
+    } elsif ( !$query->{map} ) {
+        # Return the index instead
+        @to_map = ('static');
+        my $data = Trog::Data->new($conf);
+        my $tot = $data->count();
+        my $size = 50000;
+        my $pages = int($tot / $size) + (($tot % $size) ? 1 : 0);
+
+        # Truncate pages at 10k due to standard
+        my $clamped = $pages > 49999 ? 49999 : $pages;
+        $warning = "More posts than possible to represent in sitemaps & index!  Old posts have been truncated." if $pages > 49999;
+
+        foreach my $page ($clamped..1) {
+            push(@to_map, "$page");
+        }
+        $is_index = 1;
+    } else {
+        $route_type = "Posts: Page $query->{map}";
+        # Return the map of the particular range of dynamic posts
+        $query->{limit} = 50000;
+        $query->{page} = $query->{map};
+        @to_map = _post_helper($query, [], ['public']);
+    }
+
+    if ( $query->{xml} ) {
+        my $sm;
+        my $xml_date = time();
+        my $fmt = "xml";
+        $fmt .= ".gz" if $query->{compressed};
+        if ( !$query->{map}) {
+            require WWW::SitemapIndex::XML;
+            $sm = WWW::SitemapIndex::XML->new();
+            foreach my $url (@to_map) {
+                $sm->add(
+                    loc     => "http://$query->{domain}/sitemap/$url.$fmt",
+                    lastmod => $xml_date,
+                );
+            }
+        } else {
+            require WWW::Sitemap::XML;
+            $sm = WWW::Sitemap::XML->new();
+            my $changefreq = $query->{map} eq 'static' ? 'monthly' : 'daily';
+            foreach my $url (@to_map) {
+                my $true_uri = "http://$query->{domain}$url";
+                $true_uri = "http://$query->{domain}/posts/$url->{id}" if ref $url eq 'HASH';
+                my %data = (
+                    loc        => $true_uri,
+                    lastmod    => $xml_date,
+                    mobile     => 1,
+                    changefreq => $changefreq,
+                    priority   => 1.0,
+                );
+
+                #add video & preview image if applicable
+                $data{images} = [{
+                    loc => "http://$query->{domain}$url->{href}",
+                    caption => $url->{data},
+                    title => $url->{title},
+                }] if $url->{is_image};
+
+                $data{videos} = [{
+                    content_loc   => "http://$query->{domain}$url->{href}",
+                    thumbnail_loc => "http://$query->{domain}$url->{preview}",
+                    title         => $url->{title},
+                    description   => $url->{data},
+                }] if $url->{is_video};
+
+                $sm->add(%data);
+            }
+        }
+        my $xml = $sm->as_xml();
+        require IO::String;
+        my $buf = IO::String->new();
+        my $ct = 'application/xml';
+        $xml->toFH($buf, 0);
+        seek $buf, 0, 0;
+
+        if ($query->{compressed}) {
+            require IO::Compress::Gzip;
+            my $compressed = IO::String->new();
+            IO::Compress::Gzip::gzip($buf => $compressed);
+            $ct = 'application/gzip';
+            $buf = $compressed;
+            seek $compressed, 0, 0;
+        }
+        return [200,["Content-type:$ct\n"], $buf];
+    }
+
+    @to_map = sort @to_map unless $is_index;
+    my $processor = Text::Xslate->new(
+        path   => _dir_for_resource('sitemap.tx'),
+    );
+
+    my $styles = _build_themed_styles('sitemap.css');
+
+    $query->{title} = "$query->{domain} : Sitemap";
+    my $content = $processor->render('sitemap.tx', {
+        title      => "Site Map",
+        to_map     => \@to_map,
+        is_index   => $is_index,
+        route_type => $route_type,
+        route      => $query->{route},
+    });
+
+    return Trog::Routes::HTML::index($query, $render_cb,$content,$styles);
+}
+
+sub _rss ($query,$posts) {
+    require XML::RSS;
+    my $rss = XML::RSS->new (version => '2.0');
+    my $now = DateTime->from_epoch(epoch => time());
+    $rss->channel(
+        title          => "$query->{domain}",
+        link           => "http://$query->{domain}/$query->{route}?format=rss",
+        language       => 'en', #TODO localization
+        description    => "$query->{domain} : $query->{route}",
+        pubDate        => $now,
+        lastBuildDate  => $now,
+    );
+
+    #TODO configurability
+    $rss->image(
+        title       => $query->{domain},
+        url         => "$td/img/icon/favicon.ico",
+        link        => "http://$query->{domain}",
+        width       => 88,
+        height      => 31,
+        description => "$query->{domain} favicon",
+    );
+
+    foreach my $post (@$posts) {
+        my $url = "http://$query->{domain}/posts/$post->{id}";
+        $rss->add_item(
+            title       => $post->{title},
+            permaLink   => $url,
+            link        => $url,
+            enclosure   => { url => $url, type=>"text/html" },
+            description => "<![CDATA[$post->{data}]]>",
+            pubDate     => DateTime->from_epoch(epoch => $post->{created} ), #TODO format like Thu, 23 Aug 1999 07:00:00 GMT
+            author      => $post->{user}, #TODO translate to "email (user)" format
+        );
+    }
+
+    return [200, ["Content-type: application/rss+xml\n"], [$rss->as_string]];
+}
+
+=head2 manual
+
+Implements the /manual and /lib/* routes.
+
+Basically a thin wrapper around Pod::Html.
+
+=cut
+
+sub manual ($query, $render_cb) {
+    require Pod::Html;
+    require Capture::Tiny;
+
+    #Fix links from Pod::HTML
+    $query->{module} =~ s/\.html$//g if $query->{module};
+
+    my $infile = $query->{module} ? "$query->{module}.pm" : 'tCMS/Manual.pod';
+    return notfound($query,$render_cb) unless -f "lib/$infile";
+    my $content = capture { Pod::Html::pod2html(qw{--podpath=lib --podroot=.},"--infile=lib/$infile") };
+    return $render_cb->('manual.tx', {
+        title       => 'tCMS Manual',
+        content     => $content,
+        stylesheets => _build_themed_styles('post.css'),
+    });
+}
+
+# Deal with Params which may or may not be arrays
+sub _coerce_array ($param) {
+    my $p = $param || [];
+    $p = [$param] if $param && (ref $param ne 'ARRAY');
+    return $p;
+}
+
+sub _build_themed_styles ($style) {
+    my @styles;
+    @styles = ("/styles/$style") if -f "www/styles/$style";
+    my $ts = _themed_style($style);
+    push(@styles, $ts) if $theme_dir && -f "www/$ts";
+    return \@styles;
+}
+
+sub _build_themed_scripts ($script) {
+    my @scripts = ("/scripts/$script");
+    my $ts = _themed_style($script);
+    push(@scripts, $ts) if $theme_dir && -f "www/$ts";
+    return \@scripts;
+}
+
+sub _pick_processor($file, $normal, $themed) {
+    return _dir_for_resource($file) eq $template_dir ? $normal : $themed;
+}
+
+# Pick appropriate dir based on whether theme override exists
+sub _dir_for_resource ($resource) {
+    return $theme_dir && -f "www/$theme_dir/$resource" ? $theme_dir : $template_dir;
+}
+
+sub _themed_style ($resource) {
+    return _dir_for_resource("styles/$resource")."/styles/$resource";
+}
+
+sub _themed_script ($resource) {
+    return _dir_for_resource("scripts/$resource")."/scripts/$resource";
+}
+
+1;

+ 30 - 0
lib/Trog/Routes/JSON.pm

@@ -0,0 +1,30 @@
+package Trog::Routes::JSON;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures};
+
+use JSON::MaybeXS();
+
+our %routes = (
+    '/api/catalog' => {
+        method     => 'GET',
+        callback   => \&catalog,
+        parameters => [],
+    },
+);
+
+my $contenttype = "Content-type:application/json;";
+
+sub catalog ($query, $input, $=) {
+    my $enc = JSON::MaybeXS->new( utf8 => 1 );
+    my %rcopy = %{\%routes};
+    foreach my $r (keys(%rcopy)) {
+        delete $rcopy{$r}{callback}
+    }
+    return [200,[$contenttype],[$enc->encode(\%rcopy)]];
+}
+
+1;

+ 24 - 0
lib/Trog/SQLite.pm

@@ -0,0 +1,24 @@
+package Trog::SQLite;
+
+use strict;
+use warnings;
+
+use DBI;
+use DBD::SQLite;
+use File::Slurper qw{read_text};
+
+my $dbh = {};
+# Ensure the db schema is OK, and give us a handle
+sub dbh {
+    my ($schema,$dbname) = @_;
+    return $dbh->{$schema} if $dbh->{$schema};
+    my $qq = read_text($schema);
+    my $db = DBI->connect("dbi:SQLite:dbname=$dbname","","");
+    $db->{sqlite_allow_multiple_statements} = 1;
+    $db->do($qq) or die "Could not ensure auth database consistency";
+    $db->{sqlite_allow_multiple_statements} = 0;
+    $dbh->{$schema} = $db;
+    return $db;
+}
+
+1;

+ 61 - 0
lib/tCMS/Manual.pod

@@ -0,0 +1,61 @@
+=head1 tCMS Manual
+
+=head2 First time setup
+
+Run these makefile targets:
+
+    make depend
+    make install
+
+From there, running tCMS is pretty simple:
+
+    starman --enable-ssl --ssl-key $MY_KEY_PATH --ssl-cert $MY_CERT_PATH www/server.psgi
+
+The application expects to run from the repository root.
+The first time you open the application, you will be presented with a first-time page that tells you to load /login.
+
+You will note that the submission button says 'Register' rather than 'Login'.
+The first user which logs in will be set up as the administrator, and all further users must be made by them via the /post/about route.
+
+You will want to do the following to make your user public:
+
+=over 4
+
+=item Create a user via the /post/about route using the name you just registered as.  This will require you to re-set your password.
+
+=item Ensure the 'public' visibility is chosen when submitting the form.
+
+=back
+
+=head2 Application Structure
+
+server.psgi is a very straightforward application router which serves requests based on routing modules.
+There are 3 routing modules you will need to know about:
+
+=over 4
+
+=item L<Trog::Routes::HTML> - Render various pages which (mostly) output text/html
+
+=item L<Trog::Routes::JSON> - Implement various application/json output routes
+
+=item Themed B<Routes> - Inside of any given theme in themes/ there will be a Routes.pm module defining custom routes for your theme.
+
+=back
+
+From there the routes are generally going to call out to the data model, which is configured via the /config route.
+There are only two relevant things to configure, which is what Theme and Data model to use.
+
+The configuration module is L<Trog::Config>.
+The Data model modules are all subclasses of L<Trog::Data>.
+
+We include a bogus data model for testing called 'DUMMY' which should not be used for production purposes.
+It is useful as an example for developers: L<Trog::Data::DUMMY>
+
+Authentication is accomplished via a cookie (tcmslogin) which we check against an sqlite database, ~/.tcms/auth.db
+Passwords are hashed and salted, and the only other thing stored there is what ACLs users have.
+The module controlling this is L<Trog::Auth>.
+
+=head2 Theming
+
+Themes are subdirectories of /themes, which mirror the structure of www/ internally.
+Stylesheets are included after the mainline ones, so that your styles will override the default.

+ 0 - 1
microblog/.gitignore

@@ -1 +0,0 @@
-[^.]*

+ 20 - 0
schema/auth.schema

@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS user (
+    id   INTEGER PRIMARY KEY AUTOINCREMENT,
+    name TEXT NOT NULL UNIQUE,
+    salt TEXT NOT NULL,
+    hash TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS session (
+    id TEXT PRIMARY KEY UNIQUE,
+    user_id INTEGER NOT NULL UNIQUE REFERENCES user(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS username_idx ON user(name);
+
+CREATE VIEW IF NOT EXISTS sess_user AS SELECT user.name AS name, user.id AS id, session.id AS session FROM user JOIN session ON session.user_id=user.id;
+
+CREATE TABLE IF NOT EXISTS user_acl (
+    user_id INTEGER NOT NULL UNIQUE REFERENCES user(id) ON DELETE CASCADE,
+    acl TEXT NOT NULL
+);

BIN
sys/admin/.bengine.inc.swp


BIN
sys/admin/.settings.inc.swo


+ 0 - 118
sys/admin/bengine.inc

@@ -1,118 +0,0 @@
-<script type="text/javascript">
-//JS to load posts when you click on them to edit
-
-  window.postsLoaded = {};
-
-  function toggle(id) {
-   var foo = document.getElementById(id);
-   if (foo.style.display == 'block') {
-     foo.style.display = 'none';
-   } else {
-     foo.style.display = 'block';
-   }
- }
-
- function loadpost(fragment_url,element_id) {
-   if (window.postsLoaded[element_id]) return;
-   var element = document.getElementById(element_id);
-   element.innerHTML = 'Loading ...';
-   var xmlhttp = new XMLHttpRequest();
-   xmlhttp.open("GET", fragment_url);
-   xmlhttp.onreadystatechange = function() {
-     if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
-       element.innerHTML = xmlhttp.responseText;
-       window.postsLoaded[element_id] = true;
-     }
-   }
-   xmlhttp.send(null);
- }
-</script>
-<!--Post Creation DOM-->
-<p class="posteditortitle">
- <a id="newpostlink" href="javascript:toggle('newpost')" title="Toggle hiding of New Post editor">
-  Create New Post
- </a>
-</p>
-<div id="newpost" class="disabled">
- <form method="POST">
-  <input type="hidden" name="id" value="CHANGEME" />
-  Post Title:<br />
-  <input id="newposttitle" name="title" class="cooltext" />
-  Post Body: <span style="font-style: italic;">
-   All HTML Tags accepted! Please see <a href="" title="Manual">here</a> regarding how to disable auto-formatting,
-   or if additional help is required.
-  </span>
-  <textarea id="newposttext" name="content"></textarea><br />
-  <input type="submit" name="mod" value="Create Post" class="coolbutton" />
- </form>
- <hr />
-</div>
-<?php
- /*Initialize vars, get directory contents*/
- $postincrementer = 0;
- $JSAIDS = "";
- $dir = $_SERVER['DOCUMENT_ROOT'].$config['basedir'].'/'.$config['blogdir'];
- $postlisting = scandir($dir);
- rsort($postlisting, SORT_NUMERIC);
- /*Post Manipulation*/
- if (!empty($_POST["id"])) {
-  /*Post Deletion*/
-  if ($_POST["mod"] == "Delete Post") {
-   $fh = unlink($_POST["id"]);
-   if (!$fh) die("ERROR: couldn't delete ".$_POST['id'].", check permissions");
-   echo "Deleted ".$_POST["id"]."<br />";
-  /*Post Editing*/
-  } elseif ($_POST["mod"] == "Commit Edit") {
-   $fh = fopen($_POST["id"], 'w');
-   if (!$fh) die("ERROR: couldn't write to ".$_POST['id'].", check permissions");
-   fwrite($fh,stripslashes($_POST["content"]));
-   fclose($fh);
-   echo "Edited ".$_POST["id"]."<br />";
-  /*Post Creation*/
-  } elseif ($_POST["mod"] == "Create Post") {
-    $pnum = intval(substr($postlisting[0],0,strpos($postlisting[0],'-'))) + 1;
-    $id = $dir.$pnum."-".$_POST["title"].".post";
-    /*echo $id."<br />";
-    var_dump($postlisting[0]);
-    var_dump($_POST);
-    die("FOOBAR");*/
-    $fh = fopen($id, 'w'); 
-    if (!$fh) die("ERROR: Couldn't Write ".$id.", check permissions");
-    fwrite($fh,stripslashes($_POST["content"]));
-    fclose($fh);
-    echo "Created ".$id."<br />";
-  /*Catchall*/
-  } else {
-    die("Nothing to do");
-  }
- }
-  /*Spit out the posts into the DOM so that they can be edited/deleted*/
-  $postlisting = scandir($dir);
-  rsort($postlisting, SORT_NUMERIC);
-  foreach ($postlisting as $key=>$val) {
-    $id = $_SERVER["DOCUMENT_ROOT"].$config['basedir'].$config['blogdir'].basename($val);
-    $posttitle = strstr($val,'.', true);
-  if (!empty($posttitle)) {
-   $postincrementer++;
-   print "
-    <p id=\"post".$postincrementer."\" class=\"posteditortitle\">
-    <a id=\"link".$postincrementer."\" href=\"javascript:toggle('postcontent".$postincrementer."')\" title=\"Toggle hiding of post editor\">
-    ".$posttitle."
-    </a>
-    </p>
-    <div id=\"postcontent".$postincrementer."\" class=\"disabled\">
-     <form method=\"POST\">
-     <input type=\"hidden\" name=\"id\" value=\"$id\" />
-      <textarea id=\"innerHTML".$postincrementer."\" name=\"content\" \">
-      </textarea><br />
-      <input type=\"submit\" name=\"mod\" value=\"Commit Edit\" class=\"coolbutton\">
-      <input type=\"submit\" name=\"mod\" value=\"Delete Post\" class=\"coolbutton\">
-     </form>
-    </div>";
-   $JSAIDS.="document.getElementById('link".$postincrementer."').addEventListener('click',function () {loadpost('/".$config['basedir'].$config['blogdir'].$val."','innerHTML".$postincrementer."',false);});\nwindow.postsLoaded['innerHTML".$postincrementer."'] = false;";
-  }
- }
-print "<script type=\"text/javascript\">\n
- window.onload = function() { $JSAIDS };\n
- </script>";
-?>

+ 0 - 1
sys/admin/config/.gitignore

@@ -1 +0,0 @@
-*.json

+ 0 - 27
sys/admin/config/main.json.example

@@ -1,27 +0,0 @@
-{
-    "toptitle" : "templates/default/title.inc",
-    "leftbar" : "templates/default/leftbar.inc",
-    "rightbar" : "templates/default/rightbar.inc",
-    "footbar" : "templates/default/footbar.inc",
-    "about" : "templates/default/about.inc",
-    "home" : "sys/blogroll.inc",
-    "fileshare" : "sys/fileshare/showfiles.inc",
-    "microblog" : "sys/microblog.inc",
-    "blog" : "sys/blogroll.inc",
-    "postloader" : "sys/fileshare/showpost.inc",
-    "codeloader" : "sys/fileshare/showcode.inc",
-    "audioloader" : "sys/fileshare/showaudio.inc",
-    "videoloader" : "sys/fileshare/showvideo.inc",
-    "imgloader" : "sys/fileshare/showimg.inc",
-    "docloader" : "sys/fileshare/showdoc.inc",
-    "blogdir" : "blog/",
-    "microblogdir" : "microblog/",
-    "filesharedir" : "fileshare",
-    "rssdir" : "sys/rss/",
-    "icondir" : "img/mime/",
-    "basedir" : "",
-    "htmltitle" : "Unconfigured tCMS Website",
-    "blogtitle" : "Blog",
-    "microblogtitle" : "Linklog",
-    "timezone" : "America/Chicago"
-}

+ 0 - 14
sys/admin/config/users.inc

@@ -1,14 +0,0 @@
-<?php
-$tcmsUsers = json_decode(file_get_contents('config/users.json'),true);
-if (!empty($_SERVER['REMOTE_USER']) && !empty($tcmsUsers)) {
-    $poster = "admin";
-    foreach (array_keys($tcmsUsers) as $user) {
-        if( $tcmsUsers[$user]['remoteUser'] == $_SERVER['REMOTE_USER']) {
-            $poster = $user;
-            break;
-        }   
-    }   
-} else {
-    $poster = "Nobody";
-}
-?>

+ 0 - 12
sys/admin/config/users.json.example

@@ -1,12 +0,0 @@
-{
-  "bambam" : {
-    "fullName" : "The Administrator",
-    "email" : "admin@example.com",
-    "remoteUser: "barney"
-  },
-  "wilma" : {
-    "fullName" : "Mr. Magoo",
-    "email" : "dev@null.io",
-    "remoteUser: "fred"
-  }
-}

+ 0 - 52
sys/admin/index.php

@@ -1,52 +0,0 @@
-<!doctype html>
-<html dir="ltr" lang="en-US">
- <head>
-  <meta charset="utf-8" />
-  <meta name="description" content="tCMS Control Panel"/>
-  <meta name="viewport" content="width=device-width">
-  <link rel="stylesheet" type="text/css" href="../../css/structure.css" />
-  <link rel="stylesheet" type="text/css" href="../../css/screen.css" media="screen" />
-  <link rel="stylesheet" type="text/css" href="../../css/print.css" media="print" />
-  <?php
-    if(file_exists('../../css/custom/avatars.css')) {
-      echo '<link rel="stylesheet" type="text/css" href="../../css/custom/avatars.css" />';
-    } else {
-      echo '<link rel="stylesheet" type="text/css" href="../../css/avatars.css" />';
-    }
-  ?>
-  <link rel="icon" type="image/vnd.microsoft.icon" href="../../img/icon/favicon.ico" />
-  <title>tCMS Admin</title>
-  <?php
-   $config = json_decode(file_get_contents('config/main.json'),true);
-  ?>
- </head>
- <body>
-  <div id="topkek" style="text-align: center; vertical-align: middle;">
-   <button title="Menu" id="clickme">&#9776;</button>
-   <span id="configbar">
-    <a class="topbar" title="Edit Various Settings" href="index.php">Settings</a>
-    <a class="topbar" title="Blog Writer" href="index.php?nav=1">Blog Writer</a>
-    <a class="topbar" title="Pop off about Stuff" href="index.php?nav=2">MicroBlogger</a>
-   </span>
-  </div>
-  <div id="kontent" style="display: block;">
-   <?php
-    if (!empty($_SERVER["HTTPS"])) {
-     $protocol = "https";
-    } else {
-     $protocol = "http";
-    }
-    if (empty($_GET['nav'])) {
-     $kontent = "settings.inc";
-    }
-    elseif ($_GET['nav'] == 1) {
-     $kontent = "bengine.inc";
-    }
-    elseif ($_GET['nav'] == 2) {
-     $kontent = "mbengine.inc";
-    }
-    include $kontent;
-   ?>
-  </div>
- </body>
-</html>

+ 0 - 108
sys/admin/mbengine.inc

@@ -1,108 +0,0 @@
-<?php
-  //TODO have include file here for string size validation function on titles, XSS Prevention (?)
-
-  // Function for creating a post, used twice in the code below (thus it is encapsulated).
-  function write_post($fh=null) {
-    //Pull in config due to function scoping
-    extract(json_decode(file_get_contents('config/main.json'),true));
-    $errors = array();//Create empty error array
-    //Validation checks
-    $url = stripslashes($_POST["URL"]);
-    if (empty($_POST['URL'])) {
-      $errors[] = "No url provided.";
-    } else if (!filter_var($url,FILTER_VALIDATE_URL)) {
-      $errors[] = '"'.$url.'" is not a valid ASCII URL.';
-    }
-    if (!empty($_POST["IMG"]) && !filter_var(stripslashes($_POST["IMG"]),FILTER_VALIDATE_URL)) {
-      $errors[] = 'Image "'.$url.'" is not a valid ASCII URL.';
-    }
-    if (!empty($_POST["AUD"]) && !filter_var(stripslashes($_POST["AUD"]),FILTER_VALIDATE_URL)) {
-      $errors[] = 'Audio "'.$url.'" is not a valid ASCII URL.';
-    }
-    if (!empty($_POST["VID"]) && !filter_var(stripslashes($_POST["VID"]),FILTER_VALIDATE_URL)) {
-      $errors[] = 'Video "'.$url.'" is not a valid ASCII URL.';
-    }
-    /*TODO Need to do extra validation here to prevent folks from doing something stupid
-    (like inserting executable code or large hex dumps of files). FILTER_VALIDATE_URL should catch
-    most of this, but especially on the title and commentary I can't be sure.*/
-    if (!count($errors)) {//All POST Vars needed to construct a coherent posting are here, let's go 
-      include_once("config/users.inc");//Import userland functions to figure out who's posting
-      $postBody = array(
-        "title"   => stripslashes($_POST["title"]),
-        "url"     => $url,
-        "image"   => stripslashes($_POST["IMG"]),
-        "audio"   => stripslashes($_POST["AUD"]),
-        "video"   => stripslashes($_POST["VID"]),
-        "comment" => stripslashes($_POST["comment"]),
-        "poster"  => $poster
-      );//XXX Note here that if editing, it changes poster to whoever last edited the post
-      if(empty($fh)) {//If none was passed in, we need to make one
-        $tdtime = new DateTime(null, new DateTimeZone($timezone));
-        $today = $tdtime->format('m.d.y');
-        $now = $tdtime->format('H:i:s');
-        $newsdir = $_SERVER["DOCUMENT_ROOT"].'/'.$basedir.$microblogdir;
-        @mkdir($newsdir.$today);
-        $fh = fopen($newsdir.$today."/".$now, 'w');
-        if (!$fh) die("ERROR: couldn't write $newsdir$today/$now to $newsdir$today, check permissions");
-      }
-      fwrite($fh,json_encode($postBody));
-      fclose($fh);
-    } else {//Print errors at the top, since we didn't have what we needed from POST
-      $message = 'Could not post due to errors:<br /><ul style="color: red; list-type: disc;">';
-      foreach ($errors as $err) {$message .= "<li>$err</li>";}
-      $message .= '</ul>POST Variable Dump below:<br /><em style="color: red; font-size: .75em;">'.print_r($_POST, true).'</em>';
-      echo $message;
-    }
-  }
-
-  //Microblog Posting engine - also used to display a form for submitting stories
-  if($_SERVER['REQUEST_METHOD'] == 'POST') {//Don't do anything unless we are POSTing 
-    if(empty($_POST["id"])) {//See if we need to post something new
-      write_post(); 
-    } else {//OK, so we've established that the post has an ID. Let's see if we're editing/deleting a post.
-      if (!empty($_POST["action"]) && $_POST["action"] == 'Delete') {//BLANKING IN PROGRESS
-        $res = unlink($_POST["id"]);
-        if (!$res) {
-          header("HTTP/1.1 500 Internal Server Error");
-          die("ERROR: couldn't delete ".$_POST['id'].", check permissions");
-        }
-        echo "Deleted ".$_POST["id"]."<br />";
-      } else {//Attempt editing, first detecting whether content is json
-        $fh = fopen($_POST["id"], 'w');
-        if (!$fh) {
-          header("HTTP/1.1 500 Internal Server Error");
-          die("ERROR (500): couldn't open ".$_POST['id'].", check permissions");
-        }
-        if(empty($_POST["type"]) && !empty($_POST["content"])) {//Do some munging if it's just raw text
-          $content = stripslashes($_POST["content"]);
-        } else {//Process the JSON Post, write to file
-          write_post($fh);
-        }
-        fwrite($fh,$content);//Just write the blob ,TODO validation
-        fclose($fh);
-        echo "Edited ".$_POST["id"]."<br />";
-      }
-    }
-  }
-  //DOM below
-?>
-<div id="mbengine">
- <div id="submissions">
-  <p class="title">Submissions:</p>
-  <form id="Submissions" method="POST">
-   Title *<br /><input class="cooltext" type="text" name="title" placeholder="Iowa Man Destroys Moon" />
-   URL *<br /><input class="cooltext" type="text" name="URL" placeholder="https://oneweirdtrick.scam" />
-   Image<br /><input class="cooltext" type="text" name="IMG" placeholder="https://gifdump.tld/Advice_Dog.jpg" />
-   Audio<br /><input class="cooltext" type="text" name="AUD" placeholder="https://soundclod.com/static.mp3"/>
-   Video<br /><input class="cooltext" type="text" name="VID" placeholder="https://youvimeo.tv/infomercial.mp4" />
-   Comments:<br /><textarea class="cooltext" name="comment" placeholder="Potzrebie"></textarea>
-   <input class="coolbutton" type="submit" value="Publish" text="Publish" />
-  </form>
- </div>
- <div id="stories">
-  <?php
-   $editable = 1;
-   include $_SERVER["DOCUMENT_ROOT"].'/'.$config['basedir']."sys/microblog.inc";
-  ?>
- </div>
-</div>

+ 0 - 24
sys/admin/settings.inc

@@ -1,24 +0,0 @@
-<p class="title">
- tCMS General Settings - Edit these Locally (for now).
-</p>
-<hr />
-<p class="title">
- Configuration Variables
-</p>
-For now, every big config variable is set in /sys/admin/config/main.json. Refer to it if you know what you are doing.<br /><br />
-Soon I will have a users.json defining what users are there (or alias http auth users).
-<p class="title">
-Themes: Coming soon.
-</p>
-<!--<form id="themeForm" method="post" name="themeForm">
-<select name="selectedTheme">
-  <?php
-    
-
-    //echo '<option></option>';
-  ?>
- </select>
-</form>-->
-<p>
-  Want to write your own theme (or modify one since I haven't finished the theme selector here)? Please see the <a href="teodesian.net/tCMS/index.php?nav=5&post=fileshare/manual/Chapter 03-Customization.post" title="GET UR MIND RITE">styling guide</a>.
-</p>

+ 0 - 100
sys/blogroll.inc

@@ -1,100 +0,0 @@
-<p class="title">
- <a title="RSS" href="sys/rss/blog.php" class="rss"></a>
- <?php
-  echo $config['blogtitle'];
- ?>
- <hr />
-</p>
-<?php
-
-if (!empty($_GET['post'])) {
-    $post=urldecode($_GET['post']);
-    $statz = stat($post);
-    $uid = $statz['uid'];
-    $udata = posix_getpwuid($uid);
-    $user = $udata['name'];
-
-    $date =  date("F d Y H:i:s", filemtime($post));
-
-    if (stristr($post,'.') != ".post") {
-        $title = basename($post);
-    } else {
-        $title = substr(strstr(basename($post),'-'),1,-5);
-    }
-
-    echo "<h3 class=\"blogtitles\"><a title=permalink href=\"index.php?nav=3&post=".urlencode($post)."\">$title</a></h3>\n";
-    echo "<em class=\"blogdetail\">Last modified on $date UTC by $user</em><hr />\n\n";
-    include "$post";
-    echo "\n<hr /><a style=\"textalign: center;\" href=\"".$_SERVER["PHP_SELF"]."?nav=3\">Back to Blog</a>";
-} else {
-
-$offset=0;
-if (!empty($_GET['index']) && !is_int($_GET['index'])) {
-	$offset = 10*$_GET['index'];
-}
-
-
-	//slurp up the files
-	$files = glob("blog/*.post");
-	$guid = count($files);
-
-	//sort by filename
-	
-	//initialize an array to house sort results
-	$files2 = array();
-	$files2 = array_pad($files2,$guid,0);
-
-	for ($i=0; $i<$guid; $i++) {
-		$j = explode('-',basename($files[$i]));
-		$j = $j[0];
-		$j = (int)$j;
-		$j--;
-		$files2[$j] = $files[$i];
-	}
-
-	$slen = count($files2)-1;
-	$ctr=0;
-	$older=0;
-
-	for ($i=$slen-$offset; $i>-1; $i--) {
-		$shitpost=$files2[$i];
-
-		//using a counter here to know when to stop, since I don't know how many posts there will be
-		if ($ctr > 9) {
-			$older=1;
-			break;
-		}
-		$ctr++;
-
-		$statz = stat($shitpost);
-		$uid = $statz['uid'];
-		$udata = posix_getpwuid($uid);
-		$user = $udata['name'];
-
-		$date =  date("F d Y H:i:s", filemtime($shitpost));
-
-		$title = substr(strstr(basename($shitpost),'-'),1,-5);
-
-		echo "<h3 class=\"blogtitles\"><a title=permalink href=\"".$_SERVER["PHP_SELF"]."?nav=3&post=".urlencode($shitpost)."\">$title</a></h3>\n";
-		echo "<em class=\"blogdetail\">Last modified on $date UTC by $user</em><br />\n";
-		include $shitpost;
-		echo "<hr />";
-	};
-
-echo "<table width=\"100%\"><tr><td>";
-if ($older) {
-	$offset=$_GET['index']+1;
-	echo "<a href=\"".$_SERVER["PHP_SELF"]."?nav=3&index=$offset\">Older Posts</a>";
-}
-echo "</td><td style=\"text-align: right;\">";
-if (!empty($_GET['index'])) {
-	$offset=$_GET['index']-1;
-	if ($offset == 0) {
-		echo "<a href=\"".$_SERVER["PHP_SELF"]."?nav=3\">Newer Posts</a>";
-	} else {
-		echo "<a href=\"".$_SERVER["PHP_SELF"]."?nav=3&index=$offset\">Newer Posts</a>";
-	}
-}
-echo "</td></tr></table>\n";
-}
-?>

+ 0 - 1
sys/fileshare/include/audio-player-noswfobject.js

@@ -1 +0,0 @@
-var AudioPlayer=function(){var H=[];var D;var F="";var A={};var E=-1;var G="9";function B(I){if(document.all&&!window[I]){for(var J=0;J<document.forms.length;J++){if(document.forms[J][I]){return document.forms[J][I];break}}}return document.all?window[I]:document[I]}function C(I,J,K){B(I).addListener(J,K)}return{setup:function(J,I){F=J;A=I;if(swfobject.hasFlashPlayerVersion(G)){swfobject.switchOffAutoHideShow();swfobject.createCSS("p.audioplayer_container span","visibility:hidden;height:24px;overflow:hidden;padding:0;border:none;")}},getPlayer:function(I){return B(I)},addListener:function(I,J,K){C(I,J,K)},embed:function(I,K){var N={};var L;var J={};var O={};var M={};for(L in A){N[L]=A[L]}for(L in K){N[L]=K[L]}if(N.transparentpagebg=="yes"){J.bgcolor="#FFFFFF";J.wmode="transparent"}else{if(N.pagebg){J.bgcolor="#"+N.pagebg}J.wmode="opaque"}J.menu="false";for(L in N){if(L=="pagebg"||L=="width"||L=="transparentpagebg"){continue}O[L]=N[L]}M.name=I;M.style="outline: none";O.playerID=I;swfobject.embedSWF(F,I,N.width.toString(),"24",G,false,O,J,M);H.push(I)},syncVolumes:function(I,K){E=K;for(var J=0;J<H.length;J++){if(H[J]!=I){B(H[J]).setVolume(E)}}},activate:function(I,J){if(D&&D!=I){B(D).close()}D=I},load:function(K,I,L,J){B(K).load(I,L,J)},close:function(I){B(I).close();if(I==D){D=null}},open:function(I,J){if(J==undefined){J=1}B(I).open(J==undefined?0:J-1)},getVolume:function(I){return E}}}();

+ 0 - 129
sys/fileshare/include/audio-player-uncompressed.js

@@ -1,129 +0,0 @@
-var AudioPlayer = function () {
-	var instances = [];
-	var activePlayerID;
-	var playerURL = "";
-	var defaultOptions = {};
-	var currentVolume = -1;
-	var requiredFlashVersion = "9";
-	
-	function getPlayer(playerID) {
-		if (document.all && !window[playerID]) {
-			for (var i = 0; i < document.forms.length; i++) {
-				if (document.forms[i][playerID]) {
-					return document.forms[i][playerID];
-					break;
-				}
-			}
-		}
-		return document.all ? window[playerID] : document[playerID];
-	}
-	
-	function addListener (playerID, type, func) {
-		getPlayer(playerID).addListener(type, func);
-	}
-	
-	return {
-		setup: function (url, options) {
-			playerURL = url;
-			defaultOptions = options;
-			if (swfobject.hasFlashPlayerVersion(requiredFlashVersion)) {
-				swfobject.switchOffAutoHideShow();
-				swfobject.createCSS("p.audioplayer_container span", "visibility:hidden;height:24px;overflow:hidden;padding:0;border:none;");
-			}
-		},
-
-		getPlayer: function (playerID) {
-			return getPlayer(playerID);
-		},
-		
-		addListener: function (playerID, type, func) {
-			addListener(playerID, type, func);
-		},
-		
-		embed: function (elementID, options) {
-			var instanceOptions = {};
-			var key;
-			
-			var flashParams = {};
-			var flashVars = {};
-			var flashAttributes = {};
-	
-			// Merge default options and instance options
-			for (key in defaultOptions) {
-				instanceOptions[key] = defaultOptions[key];
-			}
-			for (key in options) {
-				instanceOptions[key] = options[key];
-			}
-			
-			if (instanceOptions.transparentpagebg == "yes") {
-				flashParams.bgcolor = "#FFFFFF";
-				flashParams.wmode = "transparent";
-			} else {
-				if (instanceOptions.pagebg) {
-					flashParams.bgcolor = "#" + instanceOptions.pagebg;
-				}
-				flashParams.wmode = "opaque";
-			}
-			
-			flashParams.menu = "false";
-			
-			for (key in instanceOptions) {
-				if (key == "pagebg" || key == "width" || key == "transparentpagebg") {
-					continue;
-				}
-				flashVars[key] = instanceOptions[key];
-			}
-			
-			flashAttributes.name = elementID;
-			flashAttributes.style = "outline: none";
-			
-			flashVars.playerID = elementID;
-			
-			swfobject.embedSWF(playerURL, elementID, instanceOptions.width.toString(), "24", requiredFlashVersion, false, flashVars, flashParams, flashAttributes);
-			
-			instances.push(elementID);
-		},
-		
-		syncVolumes: function (playerID, volume) {	
-			currentVolume = volume;
-			for (var i = 0; i < instances.length; i++) {
-				if (instances[i] != playerID) {
-					getPlayer(instances[i]).setVolume(currentVolume);
-				}
-			}
-		},
-		
-		activate: function (playerID, info) {
-			if (activePlayerID && activePlayerID != playerID) {
-				getPlayer(activePlayerID).close();
-			}
-
-			activePlayerID = playerID;
-		},
-		
-		load: function (playerID, soundFile, titles, artists) {
-			getPlayer(playerID).load(soundFile, titles, artists);
-		},
-		
-		close: function (playerID) {
-			getPlayer(playerID).close();
-			if (playerID == activePlayerID) {
-				activePlayerID = null;
-			}
-		},
-		
-		open: function (playerID, index) {
-			if (index == undefined) {
-				index = 1;
-			}
-			getPlayer(playerID).open(index == undefined ? 0 : index-1);
-		},
-		
-		getVolume: function (playerID) {
-			return currentVolume;
-		}
-		
-	}
-	
-}();

File diff suppressed because it is too large
+ 0 - 3
sys/fileshare/include/audio-player.js


+ 0 - 1
sys/fileshare/include/blacklist

@@ -1 +0,0 @@
-img/sys/special/microblog/css/

BIN
sys/fileshare/include/cortado.jar


+ 0 - 12
sys/fileshare/include/external.inc

@@ -1,12 +0,0 @@
-<center>
-<?php
-
-$pwd = $_GET['dir'];
-
-echo '<p style="vertical-align: middle;">';
-echo '<img src="img/mime/denied.gif" alt="deeenied" />';
-echo 'No External linking Allowed';
-echo '<img src="img/mime/denied.gif" alt="deeniedagain" />';
-echo '</p>'
-
-?>

+ 0 - 12
sys/fileshare/include/forbidden.inc

@@ -1,12 +0,0 @@
-<center>
-<?php
-
-$pwd = $_GET['dir'];
-
-echo '<p style="vertical-align: middle;">';
-echo '<img src="img/mime/denied.gif" alt="deeenied" />';
-echo 'Access to '.$pwd.' Denied';
-echo '<img src="img/mime/denied.gif" alt="deeniedagain" />';
-echo '</p>'
-
-?>

+ 0 - 19
sys/fileshare/include/license.txt

@@ -1,19 +0,0 @@
-Copyright (c) 2010 Martin Laine
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.

+ 0 - 5
sys/fileshare/include/notfound

@@ -1,5 +0,0 @@
-<p style="text-align: center; vertical-align: center;">
- <img src="img/mime/missing.gif" alt="burritos" />
-  File not found
- <img src="img/mime/missing.gif" alt="getdownagain" />
-</p>

BIN
sys/fileshare/include/player.swf


+ 0 - 49
sys/fileshare/sanitize.inc

@@ -1,49 +0,0 @@
-<?php 
-$san=0;
-//Forbid anything starting with / and anything with .. in it; also remote links
- $badguy = strpos($pwd, '/');
- $badguys = strstr($pwd,'..');
-
- $http = stristr($pwd,'http://');
- $https = stristr($pwd,'https://');
- $ftp = stristr($pwd, 'ftp://');
- $gop = stristr($pwd, 'gopher://');
-
- if ($badguy === 0) {
-        include 'sys/fileshare/include/forbidden.inc';
-        $san=1;
-	return(0);
- }
-
- if ($badguys !== FALSE) {
-        include 'sys/fileshare/include/forbidden.inc';
-        $san=1;
-	return(0);
- }
-
-if ($http !== FALSE || $https !== FALSE || $ftp !== FALSE || $gop !== FALSE) {
-        include 'sys/fileshare/include/external.inc';
-        $san=1;
-	return(0);
-}
-
- //Check the list of other forbidden directories
- $blist = "sys/fileshare/include/blacklist";
- $channel = fopen($blist, "r");
- $contents = fread($channel, filesize($blist));
- $readable = preg_split('[/]', $contents);
- $countchocula = count($readable)-1;
-
- for ($a = 0; $a < $countchocula; $a++) {
-
-        $patterns = '^'.$readable[$a];
-        $foos = ereg($patterns, $pwd);
-
-        if ($foos == 1) {
-                include 'sys/fileshare/include/forbidden.inc';
-		$san=1;
-                return(0);
-        }
-
- }
-?>

+ 0 - 17
sys/fileshare/showaudio.inc

@@ -1,17 +0,0 @@
-<?php
-//Listen to audio -- ONLY MP3s IN THIS HOUSE
-//$title = basename($post);
- $tag = id3_get_tag($post);
- $date =  date("F d Y H:i:s", filemtime($post));
- $parent = dirname($post);
- echo "<h3><a class=nudes title=permalink href=\"$post\">".$tag["title"]."</a></h3>";
- echo "By: ".$tag["artist"]."<br />";
- echo "$date UTC<br/ >";
- echo "<p id=\"audioplayer_1\">Alternative content</p>";
- echo "<script type=\"text/javascript\">\n";
- echo "AudioPlayer.embed(\"audioplayer_1\", {soundFile: \"".$protocol."://teodesian.net/".$post."\"});\n";
- echo "</script><br />\n";
- echo "Description:<br />";
- echo $tag["comment"];
- echo "<a title=back href=\"index.php?nav=1&dir=$parent\"><img alt=back src=".$icondir."tsfolder-up.gif />$parent</a><hr />";
-?>

+ 0 - 36
sys/fileshare/showcode.inc

@@ -1,36 +0,0 @@
-<?php
-//Browse text files (like code)
-$statz = stat($post);
-$uid = $statz['uid'];
-$udata = posix_getpwuid($uid);
-$user = $udata['name'];
-$date =  date("F d Y H:i:s", filemtime($post));
-$title = basename($post);
-echo "<h3 class=\"blogtitles\"><a title=permalink href=\"$post\">$title</a></h3>\n";
-echo "Last modified on $date UTC by $user<br /><br />\n";
-$text = file_get_contents($post);
-
-// Convert UTF-8 string to HTML entities
-$text = mb_convert_encoding($text, 'HTML-ENTITIES',"UTF-8");
-// Convert HTML entities into ISO-8859-1
-$text = html_entity_decode($text,ENT_NOQUOTES, "ISO-8859-1");
-// Convert characters > 127 into their hexidecimal equivalents
-for($i = 0; $i < strlen($text); $i++) {
- $letter = $text[$i];
- $num = ord($letter);
- if($num>127) {
-  $out .= "&#$num;";
- } elseif ($letter == "\n") {
-  $out .= "<br />";
- } elseif ($letter == "\t") {
-  $out .= "&#8194;&#8194;&#8194;&#8194;";
- } elseif ($letter == " ") {
-  $out .= "&#8194;";
- } else {
-  $out .=  $letter;
- }
-}
-echo $out;
-$parent = dirname($post);
-echo "<hr /><a title=back href=\"index.php?nav=1&dir=$parent\"><img alt=back src=img/mime/tsfolder-up.gif />$parent</a>";
-?>

+ 0 - 15
sys/fileshare/showdoc.inc

@@ -1,15 +0,0 @@
-<?php
-// Using googgle docs until a decent PDF viewer actually exists
-// PDFObject would be nice if it didn't require a browser plugin
-$statz = stat($post);
-$uid = $statz['uid'];
-$udata = posix_getpwuid($uid);
-$user = $udata['name'];
-$date =  date("F d Y H:i:s", filemtime($post));
-$title = basename($post);
-$parent = dirname($post);
-echo "<a title=back href=\"index.php?nav=1&dir=$parent\"><img alt=back src=img/mime/tsfolder-up.gif />$parent</a><hr />";
-echo "<h3 class=blogtitles><a title=permalink href=\"$post\">$title</a></h3>\n";
-echo "<em class=blogdetail>Last modified on $date UTC by $user</em><br /><br />\n";
-echo "<iframe src=\"http://docs.google.com/gview?url=http://teodesian.net/$post&embedded=true\" style=\"width:100%; min-height:500px;\" frameborder=\"0\"></iframe>";
-?>

+ 0 - 143
sys/fileshare/showfiles.inc

@@ -1,143 +0,0 @@
-<?php
- $pwd = $_GET['dir'];
-
- //These variables are to check whether the directory we will link to exists, and to know what directory we are in 
- $check = @scandir($pwd.'/../', 1);
- $splode = preg_split('[/]', $pwd, -1);
- $predir = count($splode)-1;
- $test = array_slice($splode, -1, 1);
-
- #Establish MIME Type data
- #Link is used to specify special handler URLS for particular files
- #File specifies the link icon
- $arch_types = array(".tar",".gz",".z7",".bz",".bz2",".zip",".rar",".lsz",
-		    "link" => "",
-		    "file" => "tsarchive.gif");
- //Only MP3 supported for now
- $audio_types = array(".mp3",
-		     "link" => "index.php?nav=7&post=",
-		     "file" => "tsaudio.gif");
- $video_types = array(".ogv",
-		     "link" => "index.php?nav=8&post=",
-		     "file" => "tsmovie.gif");
- $image_types = array(".bmp",".gif",".jpg",".jpeg",".png",".ico",".svg",".xpm",
-		     "link" => "index.php?nav=9&post=",
-		     "file" => "tsimage.gif");
- $schematic_types = array(".dwg",".dxf",".cad",".gcode",".mcode",
-			 "link" => "",
-			 "file" => "tsschematic.gif");
- $model_types = array(".stl",".blend",".3ds",
-		     "link" => "",
-		     "file" => "tsmodel.gif");
- $binary_types = array(".exe",".o",".dll",".so",".jnilib",".a",".bin",".hex",
-		      "link" => "",
-		      "file" => "tssoftware.gif");
- $code_types = array(".py",".c",".h",".js",".php",".tcl",".m",".txt",
-		    "link" => "index.php?nav=6&post=",
-		    "file" => "tscode.gif");
- $doc_types = array(".htm",".html",".post",
-		   "link" => "index.php?nav=5&post=",
-                   "file" => "tsdoc.png");
- $legacy_types = array(".pdf",".doc",".docx",".xls",".xlsx",".ppt",".pptx",".pages",".ai",".psd",".tiff",".tif",".eps",".ps",".xps",
-		   "link" => "index.php?nav=10&post=",
-		   "file" => "tsprop.png");
- $mime_types = array($arch_types,$audio_types,$video_types,$image_types,$schematic_types,$model_types,$binary_types,$code_types,$doc_types,$legacy_types);
-
- //Find the directory contents
- $ls = @scandir($pwd, 1);
-
-// $dlist = array_filter($ls,function ($a) {return is_dir($a);});
- $dban1 = '.';
- $dban2 = '..';
- $dkey = array_search($dban1,$ls);
- if ($dkey!==false){unset($ls[$dkey]);};
- $dkey = array_search($dban2,$ls);
- if ($dkey!==false){unset($ls[$dkey]);};
-
- //See if this directory is even there
- if (@in_array($test[0], $check)) {
-	echo 'Directory listing for '.$pwd;
-	echo '<hr />';
-	$cnt = count($ls);
-    $dlist = array();
-    $flist = array();
-
-    //Trying to sort here
-    for ($n = 0; $n < $cnt; $n++) {
-        $filechk = @scandir($pwd.'/'.$ls[$n]);
-        if (! $filechk) {
-            foreach($mime_types as $mimes) {
-                foreach($mimes as $type) {
-                    $sstrn = @stristr($ls[$n],$type);
-                    if($sstrn !== FALSE && strlen($sstrn) == strlen($type)) {
-                        array_push($flist, $ls[$n]);
-                    }
-                }
-            }
-        }
-        else {
-            array_push($dlist, $ls[$n]);
-        }
-    }
-    sort($dlist);
-    sort($flist);
-    $ls = array_merge($dlist,$flist);
-	
-    //Yeah, I know this looks familiar. There's prolly some way to combine this by setting the $ikon and $link vars as an array.
-    for ($n = 0; $n < $cnt; $n++) {
-
-		//create links based on whether we are a file, and if otherwise we are a directory
-		$filechk = @scandir($pwd.'/'.$ls[$n]);
-		if (! $filechk) {
-			//default values for uncaught filetypes
-			$ikon = "tsfile.gif";
-			$link = "";
-
-			foreach ($mime_types as $mimes) {
-				foreach ($mimes as $type) {
-					//$link = $mimes["link"];
-                                        $sstrn = @stristr($ls[$n],$type);
-					if ($sstrn !== FALSE && strlen($sstrn) == strlen($type)) {
-						$ikon = $mimes["file"];
-						$link = $mimes["link"];
-						break 2;
-					};
-				};
-			};
-			echo '<img class="icon" src='.$config['icondir'].$ikon.' />';
-			echo '<a href="'.$link.$pwd.'/'.$ls[$n].'">'.$ls[$n].'</a><br />';
-		}
-		else {
-			echo '<img src="'.$config['icondir'].'tsfolder.gif" />';
-			echo '<a href="index.php?nav=1&dir='.$pwd.'/'.$ls[$n].'">'.$ls[$n].'</a><br />'."\n";
-		}
-	}
-
-	//Figure out what the previous directory is
-	$prevdirname = '';
-
-	for ($i =0; $i < $predir; $i++) {
-
-		//We want to catch the first case, and not put a / before it
-		if ($i == 0) {
-			$prevdirname = $prevdirname.$splode[$i];
-		}
-		else {
-			$prevdirname = $prevdirname.'/'.$splode[$i];
-		}
-	}
-
-	//If we are not in the TLD, make a link to the previous dir
-	if ($prevdirname <> '') {
-
-		echo '<img src="'.$config['icondir'].'tsfolder-up.gif" />';
-		echo '<a href="index.php?nav=1&dir='.$prevdirname.'">Up one level</a>'."\n";
-
-	}
- }
-
- //Catch bogus directories
- else {
- 	include 'sys/fileshare/include/notfound';
- }
-?> 

+ 0 - 15
sys/fileshare/showimg.inc

@@ -1,15 +0,0 @@
-<?php
-$info =  getimagesize($post);
-$ratio = round($info[0]/$info[1],2);
-$parent = dirname($post);
-if ($ratio == 1.00) {
- echo "<img alt=\"$post\" src=\"$post\" height=100% width=100% />";
-} elseif ($ratio > 1.00) {
- $ht = (1.20-($ratio-1))*100;
- echo "<img alt=\"$post\" src=\"$post\" height=$ht% width=100% />";
-} else {
- $wd = 100-(($ratio-1)*100);
- echo "<img alt=\"$post\" src=\"$post\" style=\"padding-left: auto; padding-right: auto;\" />";
-}
-echo "<a title=back href=\"index.php?nav=1&dir=$parent\"><img alt=back src=".$icondir."tsfolder-up.gif /></a>".basename($post)."&nbsp".$info[0]."x".$info[1]."<hr />";
-?>

+ 0 - 23
sys/fileshare/showpost.inc

@@ -1,23 +0,0 @@
-<?php
-//Generic .post file loader
-if ($nav == 5 && file_exists($post)) {
- $statz = stat($post);
- $uid = $statz['uid'];
- $udata = posix_getpwuid($uid);
- $user = $udata['name'];
- $date =  date("F d Y H:i:s", filemtime($post));
- if (stristr($post,'.') != ".post") {
-  $title = basename($post);
- } else {
-  $title = strstr(basename($post),'.', true);
- }
- echo "<h3 class=\"blogtitles\"><a title=permalink href=\"index.php?nav=5&post=$post\">$title</a></h3>\n";
- echo "<em class=\"blogdetail\">Last modified on $date UTC by $user</em><br /><hr />\n";
- include "$post";
- $parent = dirname($post);
- echo "<hr /><a style=\"padding-left: auto; padding-right: auto;\" title=back href=\"index.php?nav=1&dir=$parent\"><img alt=back src=img/mime/tsfolder-up.gif /></a>$parent";
-}
-
-//404 Loader for files specified in GET param that don't actually exist
-elseif ($nav == 5 && !file_exists($post)) {echo "<h1 style=\"padding-left: auto; padding-right: auto;\">404 - Not Found</h1>";}
-?>

+ 0 - 19
sys/fileshare/showvideo.inc

@@ -1,19 +0,0 @@
-<?php
-//watch movies -- Only OGV is supported
-$parent = dirname($post);
-echo "<a title=back href=\"index.php?nav=1&dir=$parent\"><img alt=back src=".$icondir."tsfolder-up.gif /></a>$parent<hr />";
-$title = basename($post);
-echo "<h3 class=blogtitles><a title=permalink href=\"$post\">".$title."</a></h3>";
-echo "<video src=\"".$post."\" type=\"video/ogg\" codecs=\"theora, vorbis\" controls=\"controls\" width=\"100%\" height=\"80%\" poster=\"img/sys/testpattern.jpg\">";
-echo "<applet code=\"com.fluendo.player.Cortado.class\" archive=\"sys/fileshare/video/cortado.jar\" width=\"100%\" height=\"80%\">";
-echo "<param name=\"url\" value=\"http://teodesian.net/".$post."\"/>\n";
-echo "<param name='bufferSize' value='4096'>\n";
-echo "<param name='bufferHigh' value='25'>\n";
-echo "<param name='bufferLow' value='5'>\n";
-echo "<param name='autoPlay' value='false'>\n";
-echo "<param name='statusHeight' value='20'>\n";
-echo "Install the Java Plugin, or Enable Scripts to see video here.<br />";
-echo "Or, <a href=\"".$post."\">download the video.\n";
-echo "</applet>";
-echo "</video>";
-?>

+ 0 - 157
sys/microblog.inc

@@ -1,157 +0,0 @@
-<?php
-  if ($editable) { //Insert the Only JS the project should have, all it does is toggle a div
-    echo "
-      <script type=\"text/javascript\">
-        function switchMenu(obj) {
-          var el = document.getElementById(obj);
-          if ( el.style.display != 'none' ) {
-            el.style.display = 'none';
-          }
-          else {
-            el.style.display = '';
-	  }
-        }
-      </script>\n";
-  }
-  echo '<p class="title"><a title="RSS" class="rss" href="/'.$config['basedir'].$config['rssdir'].'microblog.php"></a> '.$config['microblogtitle'];
-  //Set important times - $tdtime is today's date, $oldtime is the oldest known date a tCMS install had nuze for - defaults to today then searches microblog dir for entries to set date
-  $tdtime = new DateTime(null, new DateTimeZone($config['timezone']));
-  $oldtime = clone $tdtime;
-  //limit results of directory read to first entry -- much faster than doing it with PHP once you get a large filelist. 
-  exec("ls -tr1 ".$_SERVER["DOCUMENT_ROOT"].'/'.$config['basedir'].$config['microblogdir']." |head -1", $cmd_out);
-  if(!empty($cmd_out[0])) {
-    $oldtime = $oldtime = DateTime::createFromFormat('m.d.y', $cmd_out[0], new DateTimeZone($config['timezone']));
-  }
-  $oldtime->sub(new DateInterval('P1D'));
-  /*$today and $tmrw refer to times relative to what is passed by GET params -
-  $today is the date requested by GET, $tmrw is bool designating whether $today is something other than $tdtime 
-  error indicates whether you supplied a bogus GET param for date.*/
-  $tmrw = 0;
-  $error = 0;
-  $today = clone $tdtime;
-  if(!empty($_GET["date"])) {
-    $today = DateTime::createFromFormat('m.d.y', $_GET["date"], new DateTimeZone ($config['timezone']));
-    //Catch bogus input, set $tmwr to TRUE if $today was set to something other than today's date
-    if (!filter_var($_GET["date"],FILTER_VALIDATE_REGEXP,array('options' => array('regexp' => "/^(0[1-9]|1[012])[.](0[1-9]|[12][0-9]|3[01])[.]\d\d/")))) {
-      echo "</p>That's a funny looking date you provided there, mister.\n";
-      $error=1;
-    }
-    else if ($today > $tdtime) { //catch if day supplied by GET is IN THE FUTURE
-      echo "</p>Welcome to the future<br /><img style=\"max-width:100%; padding-left: auto; padding-right: auto;\" src=\"http://gunshowcomic.com/comics/20090930.png\" />\n";
-      $error=1;
-    }   
-    else if ($today < $tdtime) {
-      $tmrw = 1;
-    }
-  }
-  /*Catch if day in question has no news -
-  If not, display day before (or before that if still no news.
-  $oldtime used here to tell when to stop looping back)*/
-  if (!$error) {
-    while (empty($todaysnews)) {
-      $todaysnews = ""; //Set it to be something empty to prevent logspam
-      $yesterday = clone $today;//This may look strange, but it'll make sense later
-      $tomorrow = clone $today;
-      $tomorrow->add(new DateInterval('P1D'));
-      $yesterday->sub(new DateInterval('P1D'));
-      //Detect if We're at the end of postings
-      if ($yesterday < $oldtime) {
-        echo " (".$today->format('m.d.y')."):</p><hr />";
-        echo "For me, it was a beginning, but for you it is the end of the road.<br /><hr />\n";
-        if ($oldtime != $tdtime) {
-          $tomorrow = clone $oldtime;
-          $tomorrow->add(new DateInterval('P1D'));
-	  $tomorrow = $tomorrow->format('m.d.y');
-        }
-        $todaysnews = "end";
-      }
-      if ($todaysnews != "end") {
-        //Get news from directory if any exists for that day, glob will return empty if nothing is in dir
-        $todaysnews = glob($_SERVER["DOCUMENT_ROOT"].'/'.$config['basedir'].$config['microblogdir'].$today->format('m.d.y')."/*");
-        //Set display date for today's news, set $today to be yesterday in order to get while loop to recurse correctly
-        $realtime = $today->format('m.d.y');
-        if(!empty($_GET['fwd']) && $_GET['fwd']) {//Check whether we are traversing forward or backward in time
-          $today = clone $tomorrow;
-        } else { //Default to going back
-          $today = clone $yesterday;
-        }
-        //Finish by setting times for Yesterday and Tomorrow so that they can be used in links below
-        $tomorrow = clone $yesterday;
-        $tomorrow->add(new DateInterval('P2D'));
-        $tomorrow = $tomorrow->format('m.d.y');
-        $yesterday = $yesterday->format('m.d.y');
-      }
-    }
-    if ($todaysnews != "end") {
-      echo " (".$realtime."):</p><hr />\n";
-      foreach ($todaysnews as $i) {
-        $fh = fopen($i,'r');
-        $fc = fread($fh,10000); //If a microblog item is more than 1kb, you are doing something wrong.
-        fclose($fh);
-        $json = json_decode($fc);
-        if(is_null($json)) {
-          echo $fc;
-        } elseif (!empty($json->title) && !empty($json->url) && !empty($json->poster)) {
-          $out = '<h3 class="blogtitles">
-                    <a href="'.$json->url.'">'.$json->title.'</a>
-                    <a class="usericon '.$json->poster.'" title="Posted by '.$json->poster.'"></a>
-                  </h3>';
-          if(!empty($json->image)) {
-            $out .= '<img class="mblogimg" src="'.$json->image.'" />';
-          }
-          if(!empty($json->audio)) {
-            $out .= '<audio src="'.$json->audio.'" controls>
-                       Download Audio 
-                       <a href="'.$json->audio.'">Here</a><br />
-                     </audio>';
-          }
-          if(!empty($json->video)) {
-            $out .= '<video src="'.$json->video.'" controls>
-                       Download Video
-                       <a href="'.$json->video.'">Here</a><br />
-                     </video>';
-          }
-          if(!empty($json->comment)) {
-            $out .= $json->comment;
-          }
-          $out .= '<hr />';
-          echo $out;
-        } #Note that if nothing works out here, I'm just opting not to show anything.
-        if ($editable) {
-          $id=basename($i);
-	  $editblock = "
-            <a style=\"display: inline-block;\" onclick=\"switchMenu('$id');\">[Edit]</a>
-            <div style=\"display: none;\" id=\"$id\">
-             <form style=\"display: inline\" method=\"POST\">
-              <input type=\"hidden\" name=\"id\" value=\"$i\" />
-              <input type='hidden' name='action' value='Edit' />";
-          if(is_null($json)) {
-            $editblock .= "<textarea class=\"mbedit_text\" name=\"content\">$fc</textarea>";
-          } else {
-          $editblock .= '<input type="hidden" name="type" value="JSON" />
-            Title: <input class="cooltext" type="text" name="title" value="'.$json->title.'" /><br />
-            URL: <input class="cooltext" type="text" name="URL" value="'.$json->url.'" /><br />
-            Image: <input class="cooltext" type="text" name="IMG" value="'.$json->image.'" /><br />
-            Audio: <input class="cooltext" type="text" name="AUD" value="'.$json->audio.'" /><br />
-            Video: <input class="cooltext" type="text" name="VID" value="'.$json->video.'" /><br />
-            Comments: <textarea class="cooltext" name="comment">'.$json->comment.'</textarea>';
-          }
-          $editblock .= "<input class=\"coolbutton mbedit_button\" type=\"submit\" Value=\"Edit\" />
-             </form>
-             <form style=\"display: inline\" method=\"POST\">
-              <input type=\"hidden\" name=\"id\" value=\"$i\" />
-              <input type='hidden' name='action' value='Delete' />
-              <input class=\"coolbutton mbedit_button\" type=\"submit\" value=\"Delete\" />
-             </form>
-            </div>
-            <hr class=\"clear\" />";
-          echo $editblock;
-        }
-      }
-      echo "<a style=\"float: left;\" title=\"skips empty days\" href=\"?nav=2&date=".$yesterday."&fwd=0\">Older Entries</a>\n";
-    }
-    if ($tmrw) {
-      echo "<a style=\"float: right;\" href=\"?nav=2&date=".$tomorrow."&fwd=1\">Newer Entries</a>\n";
-    }
-  }
-?>

+ 0 - 71
sys/rss/blog.php

@@ -1,71 +0,0 @@
-<?php
-  header('Content-Type: application/rss+xml'); 
-  if (!empty($_SERVER["HTTPS"])) {
-    $protocol = "http";
-  } else {
-    $protocol = "https";
-  }
-	extract(json_decode(file_get_contents('../admin/config/main.json'),true));
-    echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
-	echo "<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n";
-	echo "<channel>\n";
-    $atomlink = "$protocol://".$_SERVER["SERVER_NAME"]."/".$basedir.$rssdir."blog.php";
-	echo "<atom:link href=\"".$atomlink."\" rel=\"self\" type=\"application/rss+xml\" />";
-	echo "\t<title>".$htmltitle."</title>\n";
-	echo "\t<description>".$blogtitle."</description>\n";
-	echo "\t<link>http://".$_SERVER["SERVER_NAME"]."/".$basedir."</link>\n";
-
-	$tiem = date(DATE_RFC2822, time());
-
-	echo "\t<lastBuildDate>$tiem</lastBuildDate>\n";
-	echo "\t<pubDate>$tiem</pubDate>\n";
-
-	$files = glob($_SERVER["DOCUMENT_ROOT"]."/".$basedir.$blogdir."*.post");
-	$guid = count($files);
-
-	//sort by filename
-	
-	//initialize an array to house sort results
-	$files2 = array();
-	$files2 = array_pad($files2,$guid,0);
-
-	for ($i=0; $i<$guid; $i++) {
-		$j = explode('-',basename($files[$i]));
-		$j = $j[0];
-		$j = (int)$j;
-		$j--;
-		$files2[$j] = $files[$i];
-	}
-
-	$slen = count($files2)-1;
-	$ctr = 0;
-
-		for ($i=$slen; $i>-1; $i--) {
-			$shitpost=$files2[$i];
-		
-			if ($ctr > 9) {break;};
-			$ctr++;
-
-                	$statz = stat($shitpost);
-                	$uid = $statz['uid'];
-                	$udata = posix_getpwuid($uid);
-                	$user = $udata['name'];
-
-                	$date =  date(DATE_RFC2822, filemtime($shitpost));
-
-                	$title = substr(strstr(basename($shitpost),'-'),1,-5);
-			$contents = file_get_contents($shitpost);
-
-			echo "\t<item>\n";
-               		echo "\t\t<title>$title</title>\n";
-                	echo "\t\t<description><![CDATA[".$contents."]]>\t\t</description>\n";
-			echo "\t\t<link>http://teodesian.net/index.php?nav=8&amp;post=".$shitpost."</link>\n";
-			echo "\t\t<guid isPermaLink=\"false\">$guid-teodesian.net</guid>\n";
-			echo "\t\t<pubDate>".$date."</pubDate>\n";
-			echo "\t\t<author>".$user."</author>\n";
-			echo "\t</item>\n";
-			$guid--;
-		}
-	echo "</channel>\n";
-	echo "</rss>";
-?>

+ 0 - 78
sys/rss/microblog.php

@@ -1,78 +0,0 @@
-<?php
-  header('Content-Type: application/rss+xml'); 
-  if (!empty($_SERVER["HTTPS"])) {
-    $protocol = "http";
-  } else {
-    $protocol = "https";
-  }
-  //Import your config, set some stuff up, then construct the mining laser
-  extract(json_decode(file_get_contents('../admin/config/main.json'),true));
-  $tcmsUsers = json_decode(file_get_contents('../admin/config/users.json'),true);
-  date_default_timezone_set($timezone);
-  $tiem = date(DATE_RSS);
-  $today = date("m.d.y");
-  $atomlink = "$protocol://".$_SERVER["SERVER_NAME"]."/".$basedir.$rssdir."microblog.php";
-  $newsdir = $_SERVER["DOCUMENT_ROOT"]."/".$basedir.$microblogdir;
-  $files = glob($newsdir.$today."/*");
-  $slen = count($files);
-  $feed = '<?xml version="1.0" encoding="UTF-8"?>
-            <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
-	     <channel>
-	      <atom:link href="'.$atomlink.'" rel="self" type="application/rss+xml" />
-	      <title>'.$htmltitle.'</title>
-	      <description>'.$microblogtitle.' RSS Feed</description>
-	      <link>http://'.$_SERVER['SERVER_NAME'].'/'.$basedir.'</link>
-	      <lastBuildDate>'.$tiem.'</lastBuildDate>
-	      <pubDate>'.$tiem.'</pubDate>';
-  foreach ($files as $shitpost) {
-    $storyPubDate =  date(DATE_RSS, strtotime(basename($shitpost)));
-    $contents = file_get_contents($shitpost);
-    #Set some sane defaults for cases where no user exists
-    $email = "null@example.com";
-    $author = "X";
-    #Check the format, do needful based on what's here
-    $json = json_decode($contents);
-    if(is_null($json)) {
-      //HAHAHA You thought you needed an XML parser, didn't you?
-      $theRipper = explode("<",$contents);
-      $theRipper = explode(">",$theRipper[2]);
-      $storyTitle = $theRipper[1];
-      $theRipper = explode('"',$theRipper[0]);
-      $storyLink = htmlspecialchars($theRipper[1]);
-      $theRipper = explode("</h3>",$contents);
-      $theRipper = explode("<hr />",$theRipper[1]);
-      $storyText = $theRipper[0];
-      $theRipper = explode("title=\"Posted by ",$contents);
-      $theRipper = explode('"',$theRipper[1]);
-      $poster = $theRipper[0];
-      if(isset($tcmsUsers[$poster])) {
-          $email = $tcmsUsers[$poster]["email"];
-          $author = $tcmsUsers[$poster]["fullName"]; 
-      }
-      $feed .= '<item>
-                 <title>'.$storyTitle.'</title>
-                 <description><![CDATA['.$storyText.']]></description>
-                 <link>'.preg_replace('/&/', '&#038;', $storyLink).'</link>
-                 <guid isPermaLink="false">'.basename($shitpost).'-'.$_SERVER["SERVER_NAME"].'</guid>
-                 <pubDate>'.$storyPubDate.'</pubDate>
-                 <author>'.$email.' ('.$author.')</author>
-                </item>';
-    } elseif (!empty($json->title) && !empty($json->url) && !empty($json->poster)) {
-        if(isset($tcmsUsers[$json->poster])) {
-            $email = $tcmsUsers[$json->poster]["email"];
-            $author = $tcmsUsers[$json->poster]["fullName"]; 
-        }
-        $feed .= '<item>
-                   <title>'.$json->title.'</title>
-                   <description><![CDATA['.$json->comment.']]></description>
-                   <link>'.preg_replace('/&/', '&#038;', $json->url).'</link>
-                   <guid isPermaLink="false">'.basename($shitpost).'-'.$_SERVER["SERVER_NAME"].'</guid>
-                   <pubDate>'.$storyPubDate.'</pubDate>
-                   <author>'.$email.' ('.$author.')</author>
-                  </item>';
-    }
-  }
-  $feed .= ' </channel>
-            </rss>';
-  print_r($feed);
- ?>

+ 0 - 1
templates/custom/.gitignore

@@ -1 +0,0 @@
-[^.]*

+ 0 - 1
templates/default/about.inc

@@ -1 +0,0 @@
-This is supposed to be a page all about the author(s) of this website. Nothing is here yet though.

+ 0 - 1
templates/default/footbar.inc

@@ -1 +0,0 @@
-Potzrebie

+ 0 - 1
templates/default/leftbar.inc

@@ -1 +0,0 @@
-Potzrebie

+ 0 - 1
templates/default/rightbar.inc

@@ -1 +0,0 @@
-Potzrebie

+ 0 - 14
templates/default/title.inc

@@ -1,14 +0,0 @@
-<div id="lefttitle" class="toplel">
- <?php
-  echo $config['htmltitle'];
- ?>
-</div>
-<div id="midtitle" class="toplel">
-</div>
-<button title="Menu" id="clickme">&#9776;</button>
-<div id="righttitle" class="toplel">
- <a href="index.php?nav=2" title="Micro Blog" class="topbar">LinkLog</a>
- <a href="index.php?nav=3" title="Blog" class="topbar">Blog</a>
- <a href="index.php?nav=1&amp;dir=fileshare" title="File Share" class="topbar">Fileshare</a>
- <a href="index.php?nav=4" title="About" class="topbar">About</a>
-</div>

+ 1 - 0
www/.gitignore

@@ -0,0 +1 @@
+themes/*

BIN
www/assets/audio/test.mp3


BIN
www/assets/video/test.ogv


+ 0 - 0
img/avatar/humm.gif → www/img/avatar/humm.gif


+ 0 - 0
img/icon/rss.png → www/img/icon/rss.png


File diff suppressed because it is too large
+ 112 - 0
www/img/icon/tCMS.svg


+ 0 - 0
img/sys/testpattern.jpg → www/img/sys/testpattern.jpg


+ 0 - 0
www/scripts/main.js


+ 18 - 0
www/scripts/post.js

@@ -0,0 +1,18 @@
+function switchMenu(obj) {
+    var el = document.getElementById(obj);
+    if ( el.style.display != 'none' ) {
+        el.style.display = 'none';
+    } else {
+        el.style.display = '';
+    }
+}
+
+function add2tags(id) {
+    var select = document.getElementById( id + '-tags');
+    var input  = document.getElementById( id + '-customtag');
+    var newOption = document.createElement('option');
+    newOption.value = input.value;
+    newOption.innerText = input.value;
+    newOption.selected = true;
+    select.appendChild(newOption);
+}

+ 203 - 0
www/server.psgi

@@ -0,0 +1,203 @@
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures};
+
+use Date::Format qw{strftime};
+
+use HTTP::Body   ();
+use URL::Encode  ();
+use Text::Xslate ();
+use Plack::MIME  ();
+use Mojo::File   ();
+use DateTime::Format::HTTP();
+use Encode qw{encode_utf8};
+use CGI::Cookie ();
+use File::Basename();
+
+#Grab our custom routes
+use lib 'lib';
+use Trog::Routes::HTML;
+use Trog::Routes::JSON;
+use Trog::Auth;
+
+# Troglodyne philosophy - simple as possible
+
+# Import the routes
+my %routes = %Trog::Routes::HTML::routes;
+@routes{keys(%Trog::Routes::JSON::routes)} = values(%Trog::Routes::JSON::routes);
+
+# Things we will actually produce from routes rather than just serving up files
+my $ct = 'Content-type';
+my %content_types = (
+    plain => "$ct:text/plain;",
+    html  => "$ct:text/html; charset=UTF-8",
+    json  => "$ct:application/json;",
+    blob  => "$ct:application/octet-stream;",
+);
+
+my $cc = 'Cache-control';
+my %cache_control = (
+    revalidate => "$cc: no-cache, max-age=0;",
+    nocache    => "$cc: no-store;",
+    static     => "$cc: public, max-age=604800, immutable",
+);
+
+=head2 $app
+
+Dispatches requests based on %routes built above.
+
+The dispatcher here does *not* do anything with the authn/authz data.  It sets those in the 'user' and 'acls' parameters of the query object passed to routes.
+
+If a path passed is not a defined route (or regex route), but exists as a file under www/, it will be served up immediately.
+
+=cut
+
+my $app = sub {
+    my $env = shift;
+
+    my $last_fetch = 0;
+    if ($env->{HTTP_IF_MODIFIED_SINCE}) {
+        $last_fetch = DateTime::Format::HTTP->parse_datetime($env->{HTTP_IF_MODIFIED_SINCE})->epoch();
+    }
+
+    my $query = {};
+    $query = URL::Encode::url_params_mixed($env->{QUERY_STRING}) if $env->{QUERY_STRING};
+    my $path = $env->{PATH_INFO};
+
+    # Let's open up our default route before we bother to see if users even exist
+    return $routes{default}{callback}->($query,\&_render) unless -f "$ENV{HOME}/.tcms/setup";
+
+    my $cookies = {};
+    if ($env->{HTTP_COOKIE}) {
+        $cookies = CGI::Cookie->parse($env->{HTTP_COOKIE});
+    }
+
+    my ($active_user,$user_id) = ('','');
+    if (exists $cookies->{tcmslogin}) {
+         ($active_user,$user_id) = Trog::Auth::session2user($cookies->{tcmslogin}->value);
+    }
+
+    #Disallow any paths that are naughty ( starman auto-removes .. up-traversal)
+    if (index($path,'/templates') == 0 || $path =~ m/.*\.psgi$/i ) {
+        return Trog::Routes::HTML::forbidden($query, \&_render);
+    }
+
+    # If it's just a file, serve it up
+    return _serve("www/$path", $last_fetch) if -f "www/$path";
+
+    #Handle regex/capture routes
+    if (!exists $routes{$path}) {
+        my @captures;
+        foreach my $pattern (keys(%routes)) {
+            @captures = $path =~ m/^$pattern$/;
+            if (@captures) {
+                $path = $pattern;
+                foreach my $field (@{$routes{$path}{captures}}) {
+                    $routes{$path}{data} //= {};
+                    $routes{$path}{data}{$field} = shift @captures;
+                }
+                last;
+            }
+        }
+    }
+
+    $query->{user}   = $active_user;
+    return Trog::Routes::HTML::notfound($query, \&_render) unless exists $routes{$path};
+    return Trog::Routes::HTML::badrequest($query, \&_render) unless $routes{$path}{method} eq $env->{REQUEST_METHOD};
+
+    @{$query}{keys(%{$routes{$path}{'data'}})} = values(%{$routes{$path}{'data'}}) if ref $routes{$path}{'data'} eq 'HASH' && %{$routes{$path}{'data'}};
+
+    #Actually parse the POSTDATA and dump it into the QUERY object if this is a POST
+    if ($env->{REQUEST_METHOD} eq 'POST') {
+
+        my $body = HTTP::Body->new( $env->{CONTENT_TYPE}, $env->{CONTENT_LENGTH} );
+        my $len = $env->{CONTENT_LENGTH};
+        while ( $len ) {
+            read($env->{'psgi.input'}, my $buf, ($len < 8192) ? 8192 : $len );
+            $len -= length($buf);
+            $body->add($buf);
+        }
+
+        @$query{keys(%{$body->param})}  = values(%{$body->param});
+        @$query{keys(%{$body->upload})} = values(%{$body->upload});
+    }
+
+    #Set various things we don't want overridden
+    $query->{acls} = Trog::Auth::acls4user($user_id) // [] if $user_id;
+
+    $query->{user}   = $active_user;
+    $query->{domain} = $env->{HTTP_HOST};
+    $query->{route}  = $env->{REQUEST_URI};
+    $query->{scheme} = $env->{'psgi.url_scheme'} // 'http';
+
+    my $output =  $routes{$path}{callback}->($query, \&_render);
+    return $output;
+};
+
+sub _serve ($path, $last_fetch=0) {
+    my $mf = Mojo::File->new($path);
+    my $ext = '.'.$mf->extname();
+    my $ft;
+    $ft = Plack::MIME->mime_type($ext) if $ext;
+    $ft = "$ct:$ft;" if $ft;
+    $ft ||= $content_types{plain};
+
+    my @headers = ($ft);
+
+    #TODO use static Cache-Control for everything but JS/CSS?
+    push(@headers,$cache_control{revalidate});
+
+    #TODO Return 304 unchanged for files that haven't changed since the requestor reports they last fetched
+    my $mt = (stat($path))[9];
+    my @gm = gmtime($mt);
+    my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
+    my $code = $mt > $last_fetch ? 200 : 304;
+    #XXX something broken about the above logic
+    $code=200;
+
+    push(@headers, "Last-Modified: $now_string\n");
+
+    my $h = join("\n",@headers);
+    if (open(my $fh, '<', $path)) {
+        return [ $code, [$h], $fh];
+    }
+    return [ 403, [$content_types{plain}], ["STAY OUT YOU RED MENACE"]];
+}
+
+sub _render ($template, $vars, @headers) {
+
+    my $processor = Text::Xslate->new(
+        path   => 'www/templates',
+        header => ['header.tx'],
+        footer => ['footer.tx'],
+    );
+
+    #XXX default vars that need to be pulled from config
+    $vars->{dir}       //= 'ltr';
+    $vars->{lang}      //= 'en-US';
+    $vars->{title}     //= 'tCMS';
+    #XXX Need to have minification detection and so forth, use LESS
+    $vars->{stylesheets}  //= [];
+    #XXX Need to have minification detection, use Typescript
+    $vars->{scripts} //= [];
+
+    # Absolute-ize the paths for scripts & stylesheets
+    @{$vars->{stylesheets}} = map { index($_, '/') == 0 ? $_ : "/$_" } @{$vars->{stylesheets}};
+    @{$vars->{scripts}}     = map { index($_, '/') == 0 ? $_ : "/$_" } @{$vars->{scripts}};
+
+    $vars->{contenttype} //= $content_types{html};
+    $vars->{cachecontrol} //= $cache_control{revalidate};
+
+    $vars->{code} ||= 200;
+
+    push(@headers, $vars->{contenttype});
+    push(@headers, $vars->{cachecontrol}) if $vars->{cachecontrol};
+    my $h = join("\n",@headers);
+
+    my $body = $processor->render($template,$vars);
+    return [$vars->{code}, [$h], [encode_utf8($body)]];
+}
+
+

+ 46 - 0
www/styles/config.css

@@ -0,0 +1,46 @@
+body, html {
+    font-size: 100%;
+    margin: 0;
+    background-color:white;
+}
+nav {
+    padding: .5rem;
+    height: 2rem;
+    line-height: 2rem;
+    font-size: 1.5rem;
+    background-color: black;
+    color: white;
+}
+section {
+    display: block;
+    margin: 1rem auto 0 auto;
+    padding: 0 1rem;
+}
+#notice {
+    display: table;
+    padding: .5rem;
+    background-color: rgba( 0, 0, 0, .75 );
+    color: #00FF00;
+    border-radius: .25rem;
+}
+#notice > img, #notice > span {
+    margin: .25rem;
+    display: table-cell;
+    vertical-align: middle;
+}
+/* Styles for larger viewports */
+@media( min-width: 768px ) {
+    section {
+        width: 80%;
+    }
+    #notice {
+        width: calc( 100% - 1rem);
+    }
+}
+a.topbar {
+    margin-top: .5rem;
+}
+
+#posttype, #subtitle {
+    display: none;
+}

+ 52 - 0
www/styles/login.css

@@ -0,0 +1,52 @@
+body {
+    background-color: gray;
+    font-family: sans-serif;
+}
+#login {
+    margin: 0 auto;
+    max-width: 25rem;
+    color: white;
+}
+#logo {
+    display: block;
+    max-width: 90%;
+    margin: 0 0 0 5%;
+    height: 2rem;
+}
+#login form {
+    max-width: 85%;
+    display: block;
+    margin: 0 auto;
+}
+#maximumGo {
+    width: calc(100% - 1rem);
+}
+#copyright {
+    font-size: .75rem;
+    text-align: center;
+}
+
+input {
+    box-sizing: border-box;
+    border-radius: .5em;
+    border: .25em solid black;
+    color: white;
+    padding: .25em;
+    margin: .25em;
+}
+.input-group {
+    display: table;
+    width: 100%;
+}
+.input-group > input, .input-group > label {
+    display: table-cell;
+}
+.input-group > input {
+    width: calc(100% - 1rem);
+    background-color: #333;
+}
+input[type="submit"] {
+    box-shadow: 0 0 .5em black;
+    background-color: #333;
+    color: white;
+}

+ 39 - 0
www/styles/notconfigured.css

@@ -0,0 +1,39 @@
+body, html {
+    font-size: 100%;
+    margin: 0;
+    background-color:white;
+}
+nav {
+    padding: .5rem;
+    height: 2rem;
+    line-height: 2rem;
+    font-size: 1.5rem;
+    background-color: black;
+    color: white;
+}
+section {
+    display: block;
+    margin: 1rem auto 0 auto;
+    padding: 0 1rem;
+}
+#notice {
+    display: table;
+    padding: .5rem;
+    background-color: rgba( 0, 0, 0, .75 );
+    color: #00FF00;
+    border-radius: .25rem;
+}
+#notice > img, #notice > span {
+    margin: .25rem;
+    display: table-cell;
+    vertical-align: middle;
+}
+/* Styles for larger viewports */
+@media( min-width: 768px ) {
+    section {
+        width: 80%;
+    }
+    #notice {
+        width: calc( 100% - 1rem);
+    }
+}

+ 42 - 0
www/styles/post.css

@@ -0,0 +1,42 @@
+body, html {
+    font-size: 100%;
+    margin: 0;
+    background-color:white;
+}
+nav {
+    padding: .5rem;
+    height: 2rem;
+    line-height: 2rem;
+    font-size: 1.5rem;
+    background-color: black;
+    color: white;
+}
+section {
+    display: block;
+    margin: 1rem auto 0 auto;
+    padding: 0 1rem;
+}
+#notice {
+    display: table;
+    padding: .5rem;
+    background-color: rgba( 0, 0, 0, .75 );
+    color: #00FF00;
+    border-radius: .25rem;
+}
+#notice > img, #notice > span {
+    margin: .25rem;
+    display: table-cell;
+    vertical-align: middle;
+}
+/* Styles for larger viewports */
+@media( min-width: 768px ) {
+    section {
+        width: 80%;
+    }
+    #notice {
+        width: calc( 100% - 1rem);
+    }
+}
+a.topbar {
+    margin-top: .5rem;
+}

+ 0 - 0
css/print.css → www/styles/print.css


+ 102 - 31
css/screen.css → www/styles/screen.css

@@ -48,6 +48,7 @@ audio, video {
 #topkek {
  background: rgb(0,0,0);
  box-shadow: 0 .25em .5em black;
+ padding-top: .1rem;
 }
 .toplel {
  color: white;
@@ -57,8 +58,10 @@ audio, video {
  font-family: courier;
  font-size: 1em;
  font-weight: bold;
+ padding-left: .5rem;
+ line-height: 2rem;
 }
-#midtitle {
+#midtitle, .midtitle {
  text-align: center;
 }
 #righttitle {
@@ -109,16 +112,58 @@ audio, video {
  height: 1em;
  width: 1em;
  background-size: 1em;
- background-image: url(../img/icon/rss.png);
+ background-image: url(/img/icon/rss.png);
  display: inline-block;
- filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/icon/rss.png', sizingMethod='scale');
- -ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../img/icon/rss.png', sizingMethod='scale')";
+ filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='/img/icon/rss.png', sizingMethod='scale');
+ -ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader(src='/img/icon/rss.png', sizingMethod='scale')";
+}
+.postericon {
+ width: 2.5rem;
+ height: 2.5rem;
 }
 .usericon, .buddyicon {
- width: 1em;
- height: 1em;
+ width: 2rem;
+ height: 2rem;
+ display: inline-block;
+}
+.usericon, .buddyicon, .postericon {
+ background-size: cover;
+ background-repeat: no-repeat;
+ padding-right: unset !important;
+}
+.postericon {
  float: right;
- background-size: 1em;
+}
+.circle, .usericon, .buddyicon, .postericon {
+ border-radius: 50%;
+}
+.portrait {
+ position: absolute;
+ background-color: white;
+ border: solid black 5px;
+ height: 10rem;
+ width: 10rem;
+ background-size: cover;
+ top: 13.5rem;
+}
+.banner {
+ position: relative;
+ padding: .5rem;
+ box-sizing: border-box;
+ resize: horizontal;
+ overflow: clip;
+ max-width: 45rem;
+ min-height: 25rem;
+ width: 100%;
+ background-repeat: no-repeat;
+ background-position: top center;
+ margin-left: auto;
+ margin-right: auto;
+}
+.banner > #postData {
+ position: absolute;
+ top: 19rem;
+ left: 12rem;
 }
 .avatar {
  width: 3em;
@@ -126,21 +171,13 @@ audio, video {
  float: left;
  background-size: 3em;
 }
-button#clickme {
+span#clickme {
  display: none;
- float: right;
- box-shadow: 0px 0px 0.5em #66CCFF;
- padding: 0 .25em;
- margin: .25em;
- font-size: .60em;
- background-color: #333;
- border-radius: .5em;
- border: .25em solid black;
- color: red;
+ font-size: 2rem;
+ line-height: 2rem;
 }
-button#clickme:active {
- padding-left: .30em;
- border-color: gray;
+span#clickme:hover {
+ cursor: pointer;
 }
 .coolbutton, .cooltext, textarea {
  box-sizing: border-box;
@@ -162,13 +199,17 @@ button#clickme:active {
  background-color: #333;
  width: 100%;
 }
-#Submissions input, #Submissions textarea {
+.Submissions input, .Submissions textarea, .Submissions select {
  width: 95%;
  display: block;
  margin-right: auto;
  margin-left: auto;
 }
-#Submissions textarea {
+
+.Config input, .Config textarea, .Config select {
+ display:inline;
+}
+.Submissions textarea {
  height: 20em;
  vertical-align: top;
 }
@@ -203,7 +244,7 @@ img.titlebar {
  height: 1.5em;
  float: left;
 }
-p.title {
+.title {
  padding-top: 0px;
  margin-top: 0px;
  font-weight: bold;
@@ -247,12 +288,15 @@ p.posteditortitle {
 }
 /*Responsive design stuff that used to be in JS, modify as needed*/
 @media (max-width: 1024px) {
-  #lefttitle {
-    width: 100%;
-    max-width: 100%;
-  }  
-  #clickme {
-    display: table-cell !important;
+  div#midtitle {
+      display: none;
+  }
+  div#lefttitle {
+    min-width: calc(100vw - 5.5rem);
+  }
+  span#clickme {
+    display: inline-block;
+    flex-shrink: 0;
   }
   #righttitle, #configbar {
     visibility: hidden;
@@ -263,11 +307,12 @@ p.posteditortitle {
     max-width: 100%;
     background-color: rgba(0,0,0,.75);
     border-bottom-left-radius: 1em;
+    padding-left: .5rem;
   }
-  #clickme:active ~ #righttitle, #clickme:focus ~ #righttitle, #righttitle:hover, #righttitle a:active, #clickme:active ~ #configbar, #clickme:focus ~ #configbar, #configbar:hover, #configbar a:active  {
+  #clickme:hover ~ #righttitle, #clickme:active ~ #righttitle, #clickme:focus ~ #righttitle, #righttitle:hover, #righttitle a:active, #clickme:active ~ #configbar, #clickme:focus ~ #configbar, #configbar:hover, #configbar a:active  {
     visibility: visible;
   }
-  #righttitle a, #configbar a {
+  #righttitle > a, #configbar a {
     display: block;
     border-right: 0;
   }
@@ -281,4 +326,30 @@ p.posteditortitle {
    width: 100%;
    display: block;
   }
+  .responsive-hide {
+    display:none;
+  }
+}
+.manageUserEntry {
+    border: .25rem dashed black;
+    padding: .5rem;
+}
+
+.undecorated {
+    text-decoration: none;
+}
+
+.tile {
+    display: inline-block;
+}
+
+.preview {
+    background-repeat: no-repeat;
+    background-position: center;
+    background-size: contain;
+}
+
+.profile {
+    background-size: 75%;
+    background-position-y: center;
 }

Some files were not shown because too many files changed in this diff