DirIndex.pm 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. package Trog::DataSource::DirIndex;
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use Cwd ();
  6. use Digest::SHA qw{sha256_hex};
  7. use Path::Tiny ();
  8. use URI::Escape ();
  9. use Trog::DataSource ();
  10. use Trog::Utils ();
  11. use Trog::Log qw{WARN DEBUG};
  12. =head1 Trog::DataSource::DirIndex
  13. Builds posts out of the contents of a directory.
  14. A series says which directory in its C<directory> field, and its children become
  15. one post per entry: a file listing, a download page, a gallery, whatever the post
  16. type's template makes of it. Nothing is written to the datastore -- the
  17. directory is the source of truth, and the posts are rebuilt from it on every
  18. view.
  19. "x-tcms-datasource": "Trog::DataSource::DirIndex"
  20. Build the child type in the Post Type Wizard and pick this as its datasource.
  21. The fields each entry carries are listed under posts() below; a template can use
  22. any of them.
  23. =head2 EDITABLE
  24. False. An entry is built from the filesystem every time the page is drawn and
  25. has nothing behind it to save, so a post type backed by this wants no editor:
  26. a form posting to /post/save would write a real post to sit alongside, and
  27. collide with, the synthesized ones. Put files in the directory instead.
  28. =cut
  29. sub EDITABLE { return 0 }
  30. =head2 CACHEABLE
  31. True, which is only defensible because of the watch watch() registers. A
  32. listing is cheap to build but there is no reason to build it per request, and the
  33. one thing that would make a cached copy wrong -- the directory changing -- is
  34. exactly the thing inotify tells us about.
  35. Take that watch away and this has to become false, or the page is stale with no
  36. way of ever becoming right again.
  37. =cut
  38. sub CACHEABLE { return 1 }
  39. =head1 WHAT MAY BE INDEXED
  40. Only directories under C<www/>, and never C<www/assets/private>.
  41. That is not arbitrary caution. Everything under www/ is already served to
  42. anybody who asks, so listing it discloses nothing that a guessed URL would not --
  43. whereas a listing of anywhere else hands out the names of files the webserver
  44. would refuse to serve, and names are frequently the interesting part.
  45. =head2 A NOTE ON www/assets/private
  46. The refusal is a guard rail against an operator mistake, and not the thing that
  47. makes private assets private.
  48. Private uploads live under www/assets/private, and on a correctly provisioned
  49. site nginx is what protects them: it auth_requests the /authenticated route,
  50. which answers 200 for a request carrying a live session and 403 for one that
  51. isn't, and serves the file or refuses it accordingly. That is the entire reason
  52. that route exists. So pointing a listing at that directory does not hand the
  53. files out -- a reader who follows one of the links still gets asked to log in.
  54. What it would hand out is the names, which the site owner probably did not mean
  55. to publish either, and which nothing else would stop. Sharing that directory is
  56. operator error; refusing it here is cheap, and turns a mistake made in a text
  57. field into an error in the log rather than a disclosure nobody notices. If you
  58. genuinely want to publish a listing of it, put the files somewhere public instead
  59. -- that is what the distinction between the two directories is for.
  60. None of this holds if the site is served without that nginx configuration. See
  61. the nginxproxy recipe referenced in the Readme; several features besides this one
  62. assume it.
  63. =head2 SYMLINKS
  64. Resolved before the containment check, so entries pointing outside the tree are
  65. left out of the listing rather than shown with a link that will not work.
  66. =cut
  67. our $root = 'www';
  68. our @refused = ('www/assets/private');
  69. # Path containment, done on the resolved paths rather than on the strings the
  70. # caller handed us: /www-backup is not inside /www, however it reads.
  71. my sub within ( $path, $within ) {
  72. return 1 if $path eq $within;
  73. return index( $path, "$within/" ) == 0;
  74. }
  75. # Everything this is allowed to look at, or nothing. Returns the absolute path
  76. # and the path relative to the tCMS root, since the first is what we read and
  77. # the second is what we log and watch.
  78. my sub resolve ($directory) {
  79. return undef unless $directory;
  80. # Before abs_path, which would happily resolve one for us.
  81. if ( $directory =~ m{(?:^|/)\.\.(?:/|$)} ) {
  82. WARN("Refusing to index '$directory': it walks upwards");
  83. return undef;
  84. }
  85. my $base = Cwd::abs_path($root);
  86. if ( !$base ) {
  87. WARN("Refusing to index '$directory': there is no $root here to index under");
  88. return undef;
  89. }
  90. # Relative to the tCMS root, and named with or without the www/ prefix --
  91. # 'assets/pics' and 'www/assets/pics' are the same directory, and somebody
  92. # writing it into a form will pick whichever occurs to them.
  93. my $candidate = $directory;
  94. $candidate = "$root/$candidate" unless $candidate =~ m{^\Q$root\E(?:/|$)};
  95. my $absolute = Cwd::abs_path($candidate);
  96. if ( !$absolute || !-d $absolute ) {
  97. WARN("Refusing to index '$directory': it is not a directory here");
  98. return undef;
  99. }
  100. if ( !within( $absolute, $base ) ) {
  101. WARN("Refusing to index '$directory': it resolves outside $root");
  102. return undef;
  103. }
  104. foreach my $refused (@refused) {
  105. my $forbidden = Cwd::abs_path($refused);
  106. next unless $forbidden;
  107. next unless within( $absolute, $forbidden );
  108. WARN("Refusing to index '$directory': $refused holds files the webserver gates, so its names are gated too");
  109. return undef;
  110. }
  111. my $relative = $absolute;
  112. $relative =~ s{^\Q$base\E/?}{};
  113. return { absolute => $absolute, relative => $relative ne q{} ? "$root/$relative" : $root };
  114. }
  115. my sub human_size ($bytes) {
  116. return '' unless defined $bytes;
  117. return "$bytes B" if $bytes < 1024;
  118. my @units = qw{KB MB GB TB PB};
  119. my $size = $bytes / 1024;
  120. foreach my $unit (@units) {
  121. return sprintf( '%.1f %s', $size, $unit ) if $size < 1024 || $unit eq $units[-1];
  122. $size /= 1024;
  123. }
  124. return "$bytes B";
  125. }
  126. my sub post_for ( $name, $dir, $series ) {
  127. my $absolute = "$dir->{absolute}/$name";
  128. # Resolved, so a symlink pointing out of the tree is left out rather than
  129. # listed with a link the webserver will not honour.
  130. my $real = Cwd::abs_path($absolute);
  131. if ( !$real || !within( $real, Cwd::abs_path($root) ) ) {
  132. DEBUG("Skipping $name: it points outside $root");
  133. return undef;
  134. }
  135. my @stat = stat($absolute);
  136. return undef unless @stat;
  137. my $is_dir = -d _ ? 1 : 0;
  138. my $size = $is_dir ? 0 : $stat[7];
  139. my $mtime = $stat[9];
  140. # Relative to www/, which is also its URL.
  141. my $path = "$dir->{relative}/$name";
  142. $path =~ s{^\Q$root\E/?}{};
  143. my ($extension) = $is_dir ? ('') : ( lc( $name =~ m/\.([^.]+)$/ ? $1 : '' ) );
  144. my $content_type = $is_dir ? 'inode/directory' : ( Trog::Utils::mime_type($absolute) // 'application/octet-stream' );
  145. # Escaped per segment: a filename may legally hold a space, a question mark
  146. # or a hash, and none of those survive being dropped into an href as they
  147. # are. Per segment rather than wholesale, or the separators go too.
  148. my $href = '/' . join( '/', map { URI::Escape::uri_escape($_) } split( '/', $path ) );
  149. my $post = {
  150. # Stable across renders, because pagination and page anchors need it to
  151. # be, and opaque because a filename is not a thing to put in a URL.
  152. id => 'dirindex-' . substr( sha256_hex($path), 0, 32 ),
  153. title => $name,
  154. name => $name,
  155. path => $path,
  156. data => '',
  157. # Which template renders it. posts.tx dispatches on this, so without it
  158. # the entries come out through the default form rather than the type the
  159. # series named, and the page looks empty.
  160. form => $series->{child_form},
  161. is_dir => $is_dir,
  162. size => $size,
  163. size_human => human_size($size),
  164. mtime => $mtime,
  165. extension => $extension,
  166. content_type => $content_type,
  167. is_image => ( $content_type =~ m{^image/} ? 1 : 0 ),
  168. is_video => ( $content_type =~ m{^video/} ? 1 : 0 ),
  169. is_audio => ( $content_type =~ m{^audio/} ? 1 : 0 ),
  170. # Where the thing actually is. The title links to the file and so does
  171. # the permalink, because for a listing they are the same thing.
  172. local_href => $href,
  173. href => $href,
  174. # Enough of a post to survive being rendered like one. The tags and
  175. # visibility come from the series, as they do for every datasource:
  176. # the reader reached this page by holding the series' acls.
  177. tags => $series->{tags} // [],
  178. visibility => $series->{visibility} // 'private',
  179. created => $mtime,
  180. version => 0,
  181. user => $series->{user},
  182. method => 'GET',
  183. };
  184. return $post;
  185. }
  186. =head1 FUNCTIONS
  187. =head2 posts($series, $query) = @posts
  188. One post per entry in the series' directory.
  189. Each carries, beyond what every post has:
  190. name the entry's own name, as on disk
  191. path its path relative to www/
  192. is_dir whether it is a directory
  193. size bytes, 0 for a directory
  194. size_human the same, as a human reads it
  195. mtime last modification, which is also the post's created
  196. extension lowercased, without the dot, empty for a directory
  197. is_image / is_video / is_audio from the content type, as a stored post gets
  198. Registers an inotify watch on the directory the first time it is asked, so that
  199. the static render of this page is thrown away when the directory changes. See
  200. watch().
  201. =cut
  202. sub posts ( $series, $query ) {
  203. my $dir = resolve( $series->{directory} );
  204. return () unless $dir;
  205. watch( $query, $dir->{relative} );
  206. my @entries;
  207. eval {
  208. @entries = map { $_->basename } Path::Tiny::path( $dir->{absolute} )->children(qr/\A[^.]/);
  209. 1;
  210. } or do {
  211. WARN("Could not read $dir->{relative}: $@");
  212. return ();
  213. };
  214. my @out;
  215. foreach my $entry ( sort @entries ) {
  216. my $post = post_for( $entry, $dir, $series );
  217. push( @out, $post ) if $post;
  218. }
  219. return @out;
  220. }
  221. =head2 filter($query, @posts) = @posts
  222. Apply the reader's search to the listing.
  223. A listing's posts have no body, so the default -- which searches a post's title
  224. and body -- would only ever match the name. Search the path and the extension
  225. as well, so that 'pdf' finds the PDFs and a subdirectory's name finds what is
  226. under it.
  227. =cut
  228. sub filter ( $query, @posts ) {
  229. $query //= {};
  230. if ( ( $query->{like} // q{} ) ne q{} ) {
  231. my $like = $query->{like};
  232. @posts = grep { Trog::DataSource::searchable_match( $like, $_->{name}, $_->{path}, $_->{extension} ) } @posts;
  233. }
  234. return Trog::DataSource::filter( { %$query, like => undef }, @posts );
  235. }
  236. =head2 order($query, @posts) = @posts
  237. Directories first, then by name.
  238. Not the default newest-first: this is a directory index, and the convention a
  239. reader expects from one is the convention every file browser has -- which also
  240. means page two holds what page one alphabetically ran out of, rather than
  241. whatever happened to be touched least recently.
  242. =cut
  243. sub order ( $, @posts ) {
  244. my @sorted = sort { ( $b->{is_dir} // 0 ) <=> ( $a->{is_dir} // 0 ) || lc( $a->{name} // '' ) cmp lc( $b->{name} // '' ) || ( $a->{name} // '' ) cmp ( $b->{name} // '' ) } @posts;
  245. return @sorted;
  246. }
  247. =head2 lang() = STRING
  248. What the search box searches on a directory listing.
  249. =head2 help() = STRING
  250. Where to read about it.
  251. =cut
  252. sub lang { return 'Case insensitive substring of a file name, path or extension' }
  253. sub help { return 'https://en.wikipedia.org/wiki/Substring' }
  254. =head2 watch($query, $directory)
  255. Ask tPSGI to tell us when the directory changes, and throw the static renders
  256. away when it does.
  257. These pages are cached exactly like any other: the render goes through
  258. Trog::Renderer, which hands anonymous, unparameterised, successful renders to
  259. tPSGI to save. That is the whole point -- a directory listing is cheap to build
  260. but there is no reason to build it per request -- but a cached listing of a
  261. directory that has since changed is simply wrong, and nothing else would ever
  262. invalidate it. A post being saved does not invalidate this page, because no post
  263. was saved: somebody dropped a file in a directory.
  264. Registered under an explicit key rather than the callback's address. posts()
  265. runs per request and this closure is built fresh each time, so the default key
  266. would stack a new copy of the same callback on every single view.
  267. The callback uses the tPSGI object it is handed rather than the one from the
  268. request that registered it: watches are shared between workers, so whichever
  269. worker notices the change is the one that must do the invalidating.
  270. =cut
  271. sub watch ( $query, $directory ) {
  272. my $tpsgi = $query->{tpsgi};
  273. return 0 unless $tpsgi;
  274. # tCMS runs under whatever tPSGI is installed, and watches arrived in it
  275. # later than the rest of this. Without them the listing is still correct,
  276. # it just goes stale until something else invalidates the renders.
  277. return 0 unless $tpsgi->can('add_watch');
  278. $tpsgi->add_watch(
  279. $directory,
  280. sub {
  281. my ( $watcher, $change ) = @_;
  282. $watcher->invalidate_renders('html');
  283. return 1;
  284. },
  285. key => "Trog::DataSource::DirIndex:$directory",
  286. );
  287. return 1;
  288. }
  289. 1;