DirIndex.pm 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. package Trog::DataSource::DirIndex;
  2. use v5.36;
  3. use re '/aa';
  4. use Cwd ();
  5. use Digest::SHA qw{sha256_hex};
  6. use File::Basename ();
  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. =head1 WHAT MAY BE INDEXED
  30. Only directories under C<www/>, and never C<www/assets/private>.
  31. That is not arbitrary caution. Everything under www/ is already served to
  32. anybody who asks, so listing it discloses nothing that a guessed URL would not --
  33. whereas a listing of anywhere else hands out the names of files the webserver
  34. would refuse to serve, and names are frequently the interesting part. The one
  35. exception is the private asset directory, which is under www/ but gated by the
  36. webserver rather than by us: its files are protected, so its filenames should be
  37. too.
  38. Symlinks are resolved before that check, and entries which point outside the
  39. tree are left out of the listing rather than shown with a link that will not
  40. work.
  41. =cut
  42. our $root = 'www';
  43. our @refused = ('www/assets/private');
  44. =head1 FUNCTIONS
  45. =head2 posts($series, $query) = @posts
  46. One post per entry in the series' directory.
  47. Each carries, beyond what every post has:
  48. name the entry's own name, as on disk
  49. path its path relative to www/
  50. is_dir whether it is a directory
  51. size bytes, 0 for a directory
  52. size_human the same, as a human reads it
  53. mtime last modification, which is also the post's created
  54. extension lowercased, without the dot, empty for a directory
  55. is_image / is_video / is_audio from the content type, as a stored post gets
  56. Registers an inotify watch on the directory the first time it is asked, so that
  57. the static render of this page is thrown away when the directory changes. See
  58. _watch().
  59. =cut
  60. sub posts ( $series, $query ) {
  61. my $dir = _resolve( $series->{directory} );
  62. return () unless $dir;
  63. _watch( $query, $dir->{relative} );
  64. opendir( my $dh, $dir->{absolute} ) or do {
  65. WARN("Could not read $dir->{relative}: $!");
  66. return ();
  67. };
  68. my @entries = grep { $_ ne '.' && $_ ne '..' && index( $_, '.' ) != 0 } readdir($dh);
  69. closedir($dh);
  70. my @out;
  71. foreach my $entry ( sort @entries ) {
  72. my $post = _post_for( $entry, $dir, $series );
  73. push( @out, $post ) if $post;
  74. }
  75. return @out;
  76. }
  77. # Everything this is allowed to look at, or nothing. Returns the absolute path
  78. # and the path relative to the tCMS root, since the first is what we read and
  79. # the second is what we log and watch.
  80. sub _resolve ($directory) {
  81. return undef unless defined $directory && length $directory;
  82. # Before abs_path, which would happily resolve one for us.
  83. if ( $directory =~ m{(?:^|/)\.\.(?:/|$)} ) {
  84. WARN("Refusing to index '$directory': it walks upwards");
  85. return undef;
  86. }
  87. my $base = Cwd::abs_path($root);
  88. if ( !$base ) {
  89. WARN("Refusing to index '$directory': there is no $root here to index under");
  90. return undef;
  91. }
  92. # Relative to the tCMS root, and named with or without the www/ prefix --
  93. # 'assets/pics' and 'www/assets/pics' are the same directory, and somebody
  94. # writing it into a form will pick whichever occurs to them.
  95. my $candidate = $directory;
  96. $candidate = "$root/$candidate" unless $candidate =~ m{^\Q$root\E(?:/|$)};
  97. my $absolute = Cwd::abs_path($candidate);
  98. if ( !$absolute || !-d $absolute ) {
  99. WARN("Refusing to index '$directory': it is not a directory here");
  100. return undef;
  101. }
  102. if ( !_within( $absolute, $base ) ) {
  103. WARN("Refusing to index '$directory': it resolves outside $root");
  104. return undef;
  105. }
  106. foreach my $refused (@refused) {
  107. my $forbidden = Cwd::abs_path($refused);
  108. next unless $forbidden;
  109. next unless _within( $absolute, $forbidden );
  110. WARN("Refusing to index '$directory': $refused holds files the webserver gates, so its names are gated too");
  111. return undef;
  112. }
  113. my $relative = $absolute;
  114. $relative =~ s{^\Q$base\E/?}{};
  115. return { absolute => $absolute, relative => length($relative) ? "$root/$relative" : $root };
  116. }
  117. # Path containment, done on the resolved paths rather than on the strings the
  118. # caller handed us: /www-backup is not inside /www, however it reads.
  119. sub _within ( $path, $within ) {
  120. return 1 if $path eq $within;
  121. return index( $path, "$within/" ) == 0;
  122. }
  123. sub _post_for ( $name, $dir, $series ) {
  124. my $absolute = "$dir->{absolute}/$name";
  125. # Resolved, so a symlink pointing out of the tree is left out rather than
  126. # listed with a link the webserver will not honour.
  127. my $real = Cwd::abs_path($absolute);
  128. if ( !$real || !_within( $real, Cwd::abs_path($root) ) ) {
  129. DEBUG("Skipping $name: it points outside $root");
  130. return undef;
  131. }
  132. my @stat = stat($absolute);
  133. return undef unless @stat;
  134. my $is_dir = -d _ ? 1 : 0;
  135. my $size = $is_dir ? 0 : $stat[7];
  136. my $mtime = $stat[9];
  137. # Relative to www/, which is also its URL.
  138. my $path = "$dir->{relative}/$name";
  139. $path =~ s{^\Q$root\E/?}{};
  140. my ($extension) = $is_dir ? ('') : ( lc( $name =~ m/\.([^.]+)$/ ? $1 : '' ) );
  141. my $content_type = $is_dir ? 'inode/directory' : ( Trog::Utils::mime_type($absolute) // 'application/octet-stream' );
  142. # Escaped per segment: a filename may legally hold a space, a question mark
  143. # or a hash, and none of those survive being dropped into an href as they
  144. # are. Per segment rather than wholesale, or the separators go too.
  145. my $href = '/' . join( '/', map { URI::Escape::uri_escape($_) } split( '/', $path ) );
  146. my $post = {
  147. # Stable across renders, because pagination and page anchors need it to
  148. # be, and opaque because a filename is not a thing to put in a URL.
  149. id => 'dirindex-' . substr( sha256_hex($path), 0, 32 ),
  150. title => $name,
  151. name => $name,
  152. path => $path,
  153. data => '',
  154. # Which template renders it. posts.tx dispatches on this, so without it
  155. # the entries come out through the default form rather than the type the
  156. # series named, and the page looks empty.
  157. form => $series->{child_form},
  158. is_dir => $is_dir,
  159. size => $size,
  160. size_human => _human_size($size),
  161. mtime => $mtime,
  162. extension => $extension,
  163. content_type => $content_type,
  164. is_image => ( $content_type =~ m{^image/} ? 1 : 0 ),
  165. is_video => ( $content_type =~ m{^video/} ? 1 : 0 ),
  166. is_audio => ( $content_type =~ m{^audio/} ? 1 : 0 ),
  167. # Where the thing actually is. The title links to the file and so does
  168. # the permalink, because for a listing they are the same thing.
  169. local_href => $href,
  170. href => $href,
  171. # Enough of a post to survive being rendered like one. The tags and
  172. # visibility come from the series, as they do for every datasource:
  173. # the reader reached this page by holding the series' acls.
  174. tags => $series->{tags} // [],
  175. visibility => $series->{visibility} // 'private',
  176. created => $mtime,
  177. version => 0,
  178. user => $series->{user},
  179. method => 'GET',
  180. };
  181. return $post;
  182. }
  183. sub _human_size ($bytes) {
  184. return '' unless defined $bytes;
  185. return "$bytes B" if $bytes < 1024;
  186. my @units = qw{KB MB GB TB PB};
  187. my $size = $bytes / 1024;
  188. foreach my $unit (@units) {
  189. return sprintf( '%.1f %s', $size, $unit ) if $size < 1024 || $unit eq $units[-1];
  190. $size /= 1024;
  191. }
  192. return "$bytes B";
  193. }
  194. =head2 filter($query, @posts) = @posts
  195. Apply the reader's search to the listing.
  196. A listing's posts have no body, so the default -- which searches a post's title
  197. and body -- would only ever match the name. Search the path and the extension
  198. as well, so that 'pdf' finds the PDFs and a subdirectory's name finds what is
  199. under it.
  200. =cut
  201. sub filter ( $query, @posts ) {
  202. $query //= {};
  203. if ( length( $query->{like} // '' ) ) {
  204. my $like = $query->{like};
  205. @posts = grep { Trog::DataSource::searchable_match( $like, $_->{name}, $_->{path}, $_->{extension} ) } @posts;
  206. }
  207. return Trog::DataSource::filter( { %$query, like => undef }, @posts );
  208. }
  209. =head2 order($query, @posts) = @posts
  210. Directories first, then by name.
  211. Not the default newest-first: this is a directory index, and the convention a
  212. reader expects from one is the convention every file browser has -- which also
  213. means page two holds what page one alphabetically ran out of, rather than
  214. whatever happened to be touched least recently.
  215. =cut
  216. sub order ( $query, @posts ) {
  217. return sort { ( $b->{is_dir} // 0 ) <=> ( $a->{is_dir} // 0 ) || lc( $a->{name} // '' ) cmp lc( $b->{name} // '' ) || ( $a->{name} // '' ) cmp ( $b->{name} // '' ) } @posts;
  218. }
  219. =head2 lang() = STRING
  220. =head2 help() = STRING
  221. What the search box searches on a directory listing.
  222. =cut
  223. sub lang { 'Case insensitive substring of a file name, path or extension' }
  224. sub help { 'https://en.wikipedia.org/wiki/Substring' }
  225. =head2 _watch($query, $directory)
  226. Ask tPSGI to tell us when the directory changes, and throw the static renders
  227. away when it does.
  228. These pages are cached exactly like any other: the render goes through
  229. Trog::Renderer, which hands anonymous, unparameterised, successful renders to
  230. tPSGI to save. That is the whole point -- a directory listing is cheap to build
  231. but there is no reason to build it per request -- but a cached listing of a
  232. directory that has since changed is simply wrong, and nothing else would ever
  233. invalidate it. A post being saved does not invalidate this page, because no post
  234. was saved: somebody dropped a file in a directory.
  235. Registered under an explicit key rather than the callback's address. posts()
  236. runs per request and this closure is built fresh each time, so the default key
  237. would stack a new copy of the same callback on every single view.
  238. The callback uses the tPSGI object it is handed rather than the one from the
  239. request that registered it: watches are shared between workers, so whichever
  240. worker notices the change is the one that must do the invalidating.
  241. =cut
  242. sub _watch ( $query, $directory ) {
  243. my $tpsgi = $query->{tpsgi};
  244. return 0 unless $tpsgi;
  245. # tCMS runs under whatever tPSGI is installed, and watches arrived in it
  246. # later than the rest of this. Without them the listing is still correct,
  247. # it just goes stale until something else invalidates the renders.
  248. return 0 unless $tpsgi->can('add_watch');
  249. $tpsgi->add_watch(
  250. $directory,
  251. sub {
  252. my ( $watcher, $change ) = @_;
  253. $watcher->invalidate_renders('html');
  254. return 1;
  255. },
  256. key => "Trog::DataSource::DirIndex:$directory",
  257. );
  258. return 1;
  259. }
  260. 1;