DataSource.pm 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. package Trog::DataSource;
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use Trog::DataModule ();
  6. use Trog::Log qw{:all};
  7. =head1 Trog::DataSource
  8. What a datasource is, and the default answers for the parts of it a given
  9. datasource doesn't care to implement.
  10. A post type can say its posts come from somewhere other than the datastore --
  11. see Trog::DataSource::Virt, which builds them out of libvirt guests. The series
  12. is still an ordinary post; only its children are synthesized, and they are
  13. rebuilt on every view rather than stored.
  14. Datasources are plain packages under this namespace, called as functions rather
  15. than methods, and named by a post type's sidecar in x-tcms-datasource.
  16. =head1 THE CONTRACT
  17. posts($series, $query) = @posts required
  18. The children of $series, as post hashrefs. Enough of a post to survive being
  19. rendered like one -- see Trog::DataSource::Virt::_post_for for the shape.
  20. EDITABLE optional constant
  21. Whether the wizard should generate an editor for a type drawn from this source.
  22. A source which builds its posts from somewhere else has nothing to edit, and
  23. saying nothing means no.
  24. CACHEABLE optional constant
  25. Whether a page drawn from this source may be saved as a static render. Saying
  26. nothing means no -- see cacheable() for why that is the safe way round.
  27. filter($query, @posts) = @posts optional
  28. Apply the viewer's search to the synthesized posts. Defaulted below; implement
  29. it to search the fields your posts actually carry.
  30. order($query, @posts) = @posts optional
  31. The order they belong in. Defaulted below; implement it if your posts have an
  32. order of their own that a date and a title don't capture.
  33. lang() = STRING optional
  34. help() = STRING optional
  35. What the search box is searching, and where to read about it, for a page drawn
  36. from this source. Defaulted below.
  37. =head1 A NOTE ON WHAT A LISTING DISCLOSES
  38. A datasource turns something into a list, and a list of names is itself
  39. information. Two things follow that are worth thinking about before you write
  40. one.
  41. The first is that the names may be more sensitive than the things. A file whose
  42. contents the webserver will refuse to serve still has a name, and publishing that
  43. name publishes the fact of it. Decide deliberately what your source is willing
  44. to enumerate, rather than enumerating whatever it can reach and relying on
  45. whatever gates the contents.
  46. The second is that the webserver is part of the security model and you cannot see
  47. it from here. On a correctly provisioned site nginx auth_requests the
  48. /authenticated route -- which answers 200 for a request carrying a live session
  49. and 403 otherwise -- and gates www/assets/private on the result. That protection
  50. is real, it is why that route exists, and it is also invisible to this code and
  51. absent on a site provisioned some other way. Do not lean on it, and do not
  52. duplicate it either: your job is to decide what to put in the list.
  53. Trog::DataSource::DirIndex is the worked example, and documents where it drew
  54. that line and why.
  55. =head1 FUNCTIONS
  56. =head2 filter($query, @posts) = @posts
  57. The default search over synthesized posts: the query's C<like>, C<author>,
  58. C<older> and C<newer>, with the same meanings they have for stored posts.
  59. Deliberately not the tag, acl or version filtering Trog::DataModule::filter()
  60. also does. Those are answers about the datastore, and they are already settled
  61. before a datasource is reached: the series carries the acls, the reader got to
  62. its page by holding them, and every synthesized post belongs to that series.
  63. Applying them again would at best be redundant, and at worst would blank the
  64. page for a source whose posts carry no tags -- which nothing requires them to.
  65. Nor does it paginate. That is the route's job and is the same for every source
  66. -- see order(), which is the part a source does get a say in.
  67. A source with more to search than a title should say so by implementing filter()
  68. rather than by hoping this one covers it.
  69. =cut
  70. sub filter ( $query, @posts ) {
  71. $query //= {};
  72. if ( ( $query->{like} // q{} ) ne q{} ) {
  73. my $like = $query->{like};
  74. @posts = grep { searchable_match( $like, $_->{title}, $_->{data} ) } @posts;
  75. }
  76. @posts = grep { ( $_->{user} // '' ) eq $query->{author} } @posts if $query->{author};
  77. # Coerced to a number the same way Trog::DataModule::filter() does it, since
  78. # this arrives straight off a query string.
  79. if ( $query->{older} ) {
  80. ( my $older = $query->{older} ) =~ s/[^0-9]//g;
  81. @posts = grep { ( $_->{created} // 0 ) < $older } @posts if $older;
  82. }
  83. if ( $query->{newer} ) {
  84. ( my $newer = $query->{newer} ) =~ s/[^0-9]//g;
  85. @posts = grep { ( $_->{created} // 0 ) > $newer } @posts if $newer;
  86. }
  87. return @posts;
  88. }
  89. =head2 searchable_match($like, @values) = BOOL
  90. Whether any of @values contains $like, case insensitively.
  91. The same case insensitive substring test Trog::DataModule::filter() applies to a
  92. stored post's title and body, factored out so that a datasource can hand it the
  93. fields its own posts actually carry without having to restate what a search
  94. means. Quotemeta'd, so a search for '*' looks for an asterisk.
  95. Anything in @values which isn't a plain string is skipped, so an arrayref body
  96. or a nested structure is passed over rather than matched against its address.
  97. =cut
  98. sub searchable_match ( $like, @values ) {
  99. return 0 unless ( $like // q{} ) ne q{};
  100. foreach my $value (@values) {
  101. next if !defined $value || ref $value;
  102. return 1 if $value =~ m/\Q$like\E/i;
  103. }
  104. return 0;
  105. }
  106. =head2 order($query, @posts) = @posts
  107. The order synthesized posts belong in: newest first, then by title, then by id.
  108. Pagination hands out page 2 of an order, so there had better be one, and it had
  109. better be the same order next time somebody asks -- a list that comes back
  110. shuffled makes page 2 a lottery rather than the next page. Sorting the ties out
  111. by title and then id is what makes this total rather than merely mostly decided.
  112. Newest-first matches how stored posts are listed. The tiebreaks carry the
  113. weight in practice, because a source that builds its posts on the fly tends to
  114. stamp them all with the time it built them: every libvirt guest comes back with
  115. the same created, so what this really does for that page is sort it by name.
  116. =cut
  117. sub order ( $, @posts ) {
  118. return sort { ( $b->{created} // 0 ) <=> ( $a->{created} // 0 ) || ( $a->{title} // '' ) cmp ( $b->{title} // '' ) || ( $a->{id} // '' ) cmp ( $b->{id} // '' ) } @posts;
  119. }
  120. =head2 lang() = STRING
  121. What the search box searches on a page drawn from a datasource which hasn't said.
  122. Not the data model's answer: the model's query language describes what it can do
  123. to the datastore, and a datasource page is not being served out of the datastore.
  124. =cut
  125. sub lang { return 'Case insensitive substring' }
  126. =head2 help() = STRING
  127. Where to read about it.
  128. =cut
  129. sub help { return 'https://en.wikipedia.org/wiki/Substring' }
  130. =head2 cacheable($source) = BOOL
  131. Whether pages drawn from $source may be cached as static renders.
  132. A datasource page is cached like any other unless something says not to, and
  133. nothing else ever invalidates it: saving a post does not, because no post was
  134. saved -- whatever the source draws on changed instead. So a source has to
  135. answer one of two questions, and it has to answer deliberately.
  136. Either it knows what it depends on and can say when that changes, in which case
  137. it should register a watch (Trog::DataSource::DirIndex::_watch is the worked
  138. example) and declare CACHEABLE. Or it doesn't, in which case its pages must not
  139. be cached at all, because the cached copy has no way of ever becoming right
  140. again.
  141. Absent means not cacheable, which is the same way round as EDITABLE: a source
  142. whose author has not thought about invalidation gets the answer that is merely
  143. slow rather than the one that is wrong.
  144. =cut
  145. sub cacheable ($source) {
  146. return 0 unless $source && $source->can('CACHEABLE');
  147. return $source->CACHEABLE ? 1 : 0;
  148. }
  149. =head2 for_type($form) = STRING or undef
  150. The datasource a post type names, or nothing when it names none.
  151. Checked against this namespace rather than requiring whatever we were told to:
  152. the name comes out of a sidecar, and a sidecar cannot be allowed to make us load
  153. an arbitrary module. Returns the module name without loading it; see load().
  154. =cut
  155. sub for_type ( $form = '' ) {
  156. return undef unless $form;
  157. my $meta = Trog::DataModule::type_meta_for($form);
  158. my $source = $meta->{'x-tcms-datasource'};
  159. return undef unless $source && !ref $source;
  160. if ( $source !~ m/^Trog::DataSource::\w+$/ ) {
  161. WARN("Post type '$form' names a datasource '$source' which isn't one");
  162. return undef;
  163. }
  164. return $source;
  165. }
  166. =head2 load($source) = BOOL
  167. Load a datasource named by for_type(), returning whether it worked.
  168. =cut
  169. sub load ($source) {
  170. my $modpath = $source;
  171. $modpath =~ s{::}{/}g;
  172. $modpath .= '.pm';
  173. local $@;
  174. eval { require $modpath; 1 } or do {
  175. WARN("Datasource '$source' will not load: $@");
  176. return 0;
  177. };
  178. return 1;
  179. }
  180. 1;