DirIndex.pm 13 KB

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