DataModule.pm 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051
  1. package Trog::DataModule;
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use FindBin::libs;
  6. use List::Util;
  7. use File::Copy;
  8. use Ref::Util();
  9. use File::Basename qw{basename};
  10. use File::Slurper();
  11. use JSON::MaybeXS();
  12. use JSON::Validator::Schema::Troglodyne();
  13. use Time::HiRes();
  14. use Trog::Themes();
  15. use Trog::Log qw{:all};
  16. use Trog::Utils;
  17. use Trog::Auth();
  18. =head1 QUERY FORMAT
  19. The $query_language and $query_help variables are presented to the user as to how to use the search box in the tCMS header.
  20. =head1 POST STRUCTURE
  21. Posts generally need to have the following:
  22. data: Brief description of content, or the content itself.
  23. content_type: What this content actually is. Used to filter into the appropriate pages.
  24. href: Primary link. This is the subject of a news post, or a link to the item itself. Can be local or remote.
  25. local_href: Backup link. Automatically created link to a static cache of the content.
  26. title: Title of the content. Used as link name for the 'href' attribute.
  27. user: User was banned for this post
  28. id: Internal identifier in datastore for the post.
  29. tags: array ref of appropriate tags.
  30. created: timestamp of creation of this version of the post
  31. version: revision # of this post.
  32. =head1 CONSTRUCTOR
  33. =head2 new(Config::Simple $config)
  34. Try not to do expensive things here.
  35. =cut
  36. sub new ( $class, $config ) {
  37. $config = $config->vars();
  38. return bless( $config, $class );
  39. }
  40. =head1 ABSTRACT METHODS
  41. Subclasses must implement all of these; the stubs here do nothing but die.
  42. count() belongs to this set as well, and is documented with the rest of the
  43. querying interface below.
  44. =head2 lang() = STRING $language
  45. The name of the query language this data model understands, shown to the user
  46. beside the search bar.
  47. =head2 help() = STRING $help
  48. Documentation for that query language, shown to the user alongside it.
  49. =head2 read($query) = ARRAYREF $posts
  50. Every post the data model holds, as an arrayref of hashrefs. Filtering is
  51. get()'s job unless your storage engine can do it more cheaply itself, in which
  52. case honor $query here and override get() too.
  53. =head2 write($posts)
  54. Commit an arrayref of posts to storage. Called by add() once the posts have
  55. been filtered and validated; don't validate again here.
  56. =head2 tags() = ARRAYREF $tags
  57. Every tag known to the datastore, for building tag pickers.
  58. =cut
  59. # Each subclass implements these.
  60. sub lang { die "Trog::DataModule subclasses must implement lang()\n" }
  61. sub help { die "Trog::DataModule subclasses must implement help()\n" }
  62. sub read { die "Trog::DataModule subclasses must implement read()\n" }
  63. sub write { die "Trog::DataModule subclasses must implement write()\n" }
  64. sub count { die "Trog::DataModule subclasses must implement count()\n" }
  65. sub tags { die "Trog::DataModule subclasses must implement tags()\n" }
  66. =head1 METHODS
  67. =head2 get(%request)
  68. Queries the data model. Should return the following:
  69. 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).
  70. version => if id is passed, return the provided post version rather than the most recent one
  71. tags => ARRAYREF of tags, any one of which is required to give a result. If none are passed, no filtering is performed.
  72. 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.
  73. page => Offset multiplier for pagination.
  74. limit => Offset for pagination.
  75. like => Search query, as might be passed in the search bar.
  76. author => filter by post author
  77. If it is more efficient to filter within your data storage engine, you probably should override this method.
  78. As implemented, this takes the data as a given and filters in post.
  79. =cut
  80. sub get ( $self, %request ) {
  81. my $posts = $self->read( \%request );
  82. return @$posts if $request{raw};
  83. my @filtered = $self->filter( \%request, @$posts );
  84. @filtered = $self->fixup(@filtered);
  85. @filtered = $self->paginate( \%request, @filtered );
  86. return @filtered;
  87. }
  88. =head2 fixup(@posts) = LIST
  89. Fill in what a post needs for display and routing, and does not store: the
  90. author's display_name and user_class, and a local_href, callback and method
  91. where the post has none. Spaces in its href fields become %20. Called by
  92. get(), and by a subclass whose get() does not call the base one.
  93. =cut
  94. sub fixup ( $, @filtered ) {
  95. my %user2display;
  96. # urlencode spaces in filenames
  97. @filtered = map {
  98. my $subj = $_;
  99. foreach my $param (qw{href preview video_href audio_href local_href wallpaper}) {
  100. next unless exists $subj->{$param};
  101. #XXX I don't remember what this fixes, but it also breaks things. URI::Escape usage instead is indicated.
  102. $subj->{$param} =~ s/[ ]/%20/g;
  103. }
  104. $user2display{ $subj->{user} } //= Trog::Auth::username2display( $subj->{user} );
  105. $subj->{display_name} = $user2display{ $subj->{user} };
  106. #XXX Add dynamic routing data for posts which don't have them (/posts/$id) and (/users/$user)
  107. my $is_user_page = List::Util::any { ( $_ // '' ) eq 'about' } @{ $subj->{tags} };
  108. if ( !exists $subj->{local_href} ) {
  109. $subj->{local_href} = "/posts/$subj->{id}";
  110. #XXX this needs to be correctly populated in the form?
  111. if ($is_user_page) {
  112. my $display_name = $user2display{ $subj->{user} };
  113. die "No display name for user!" unless $display_name;
  114. $subj->{local_href} = "/users/$display_name";
  115. }
  116. }
  117. if ( !exists $subj->{callback} ) {
  118. $subj->{callback} = "Trog::Routes::HTML::posts";
  119. $subj->{callback} = "Trog::Routes::HTML::users" if $is_user_page;
  120. }
  121. $subj->{method} = 'GET' unless exists( $subj->{method} );
  122. $subj->{user_class} = Trog::Auth::username2classname( $subj->{user} );
  123. $subj
  124. } @filtered;
  125. return @filtered;
  126. }
  127. # Visibility and acls are both stored as tags, so a caller sees a post when it
  128. # holds one of them. admin sees everything.
  129. my sub filter_acls ( $query, @filtered ) {
  130. return @filtered if List::Util::any { $_ eq 'admin' } @{ $query->{acls} };
  131. return grep {
  132. my $tags = $_->{tags};
  133. grep {
  134. my $t = $_;
  135. grep { $t eq $_ } @{ $query->{acls} }
  136. } @$tags
  137. } @filtered;
  138. }
  139. =head2 filter($query, @posts) = @filtered
  140. Apply a get() request's filters to a list of posts: tags, exclude_tags, acls,
  141. visibility, id, title, form, search terms and author.
  142. The ACL check is the load bearing one -- a post is only visible if the caller
  143. holds one of its acls, or the post is public or unlisted. Callers holding the
  144. 'admin' acl skip that filter entirely.
  145. =cut
  146. sub filter ( $, $query, @filtered ) {
  147. $query->{acls} //= [];
  148. $query->{tags} //= [];
  149. $query->{exclude_tags} //= [];
  150. # If an ID or title or acl is passed, just get that (and all it's prior versions),
  151. # if the caller may see it.
  152. foreach my $key (qw{id title aclname}) {
  153. next unless $query->{$key};
  154. return filter_acls( $query, dedup_versions( $query->{version}, grep { ( $_->{$key} || '' ) eq $query->{$key} } @filtered ) );
  155. }
  156. @filtered = dedup_versions( undef, @filtered );
  157. #Filter out posts which are too old
  158. #Coerce older into numeric
  159. if ( $query->{older} ) {
  160. $query->{older} =~ s/\D//g;
  161. @filtered = grep { $_->{created} < $query->{older} } @filtered;
  162. }
  163. if ( $query->{newer} ) {
  164. $query->{newer} =~ s/\D//g;
  165. @filtered = grep { $_->{created} > $query->{newer} } @filtered;
  166. }
  167. # Filter posts not matching the passed tag(s), if any
  168. @filtered = grep {
  169. my $tags = $_->{tags};
  170. grep {
  171. my $t = $_;
  172. grep { ( $t // '' ) eq $_ } @{ $query->{tags} }
  173. } @$tags
  174. } @filtered if @{ $query->{tags} };
  175. # Filter posts *matching* the passed exclude_tag(s), if any
  176. @filtered = grep {
  177. my $tags = $_->{tags};
  178. List::Util::none {
  179. my $t = $_;
  180. grep { $t eq $_ } @{ $query->{exclude_tags} }
  181. }
  182. @$tags
  183. } @filtered if @{ $query->{exclude_tags} };
  184. @filtered = filter_acls( $query, @filtered );
  185. @filtered = grep { ( $_->{form} || '' ) eq $query->{form} } @filtered if $query->{form};
  186. @filtered = grep { $_->{title} =~ m/\Q$query->{like}\E/i || $_->{data} =~ m/\Q$query->{like}\E/i } @filtered if $query->{like};
  187. @filtered = grep { $_->{user} eq $query->{author} } @filtered if $query->{author};
  188. return @filtered;
  189. }
  190. =head2 paginate($query, @posts) = @page
  191. The slice of @posts named by the request's page and limit. Both have to be
  192. present to page at all; limit defaults to 25 when computing the offset.
  193. =cut
  194. sub paginate ( $, $query, @filtered ) {
  195. return @filtered unless $query->{page} && $query->{limit};
  196. my $offset = int( $query->{limit} // 25 );
  197. $offset = @filtered < $offset ? @filtered : $offset;
  198. # splice() warns rather than simply returning nothing when the offset is
  199. # past the end, and a page past the end is what a stale bookmark or a
  200. # walked-off paginator asks for. Empty is the right answer; the warning is
  201. # not part of it.
  202. my $start = ( int( $query->{page} ) - 1 ) * $offset;
  203. return () if $start > scalar(@filtered);
  204. return splice( @filtered, $start, $offset );
  205. }
  206. =head2 dedup_versions($version, @posts) = LIST
  207. Given a $version, the posts of that version, each with version_max set to the
  208. newest version among @posts.
  209. Given undef, one post per id: its newest version, with version_max set, created
  210. and author taken from its oldest version, and modified from the newest one.
  211. =cut
  212. sub dedup_versions ( $version = -1, @posts ) {
  213. #ASSUMPTION made here - if we pass version this is direct ID query
  214. if ( defined $version ) {
  215. my $version_max = List::Util::max( map { $_->{version} } @posts );
  216. return map {
  217. $_->{version_max} //= $version_max;
  218. $_
  219. } grep { $_->{version} eq $version } @posts;
  220. }
  221. my @uniqids = List::Util::uniq( map { $_->{id} } @posts );
  222. my %posts_deduped;
  223. for my $id (@uniqids) {
  224. my @ofid = reverse sort { $a->{version} <=> $b->{version} } grep { $_->{id} eq $id } @posts;
  225. my $version_max = List::Util::max( map { $_->{version} } @ofid );
  226. $posts_deduped{$id} = $ofid[0];
  227. $posts_deduped{$id}{version_max} = $version_max;
  228. # Show orig creation date, and original author.
  229. # A modified already present is from an earlier pass, which FlatFile makes
  230. # in read() before get() filters again; validate() never lets one be stored.
  231. $posts_deduped{$id}{modified} = $ofid[0]{modified} // $ofid[0]{created};
  232. $posts_deduped{$id}{created} = $ofid[-1]{created};
  233. $posts_deduped{$id}{author} = $ofid[-1]{author};
  234. }
  235. my @deduped = @posts_deduped{@uniqids};
  236. return @deduped;
  237. }
  238. =head2 count() = INT $num
  239. Returns the total number of posts.
  240. Used to determine paginator parameters.
  241. =cut
  242. =head2 add(@posts) = BOOL $failed_or_not
  243. Add the provided posts to the datastore.
  244. If any post already exists with the same id, a new post with a version higher than it will be added.
  245. Passes an array of new posts to add to the data store module's write() function.
  246. These will have their parameters filtered to those described by the post type's
  247. schema (see schema_for()), and then validated against it. If the post doesn't
  248. hold up, this dies with an ARRAYREF of validation error strings -- a ref rather
  249. than a string so that Carp::Always doesn't staple a stack trace onto something
  250. we intend to show the user.
  251. You probably won't want to override this.
  252. =cut
  253. # The shape of a post, as an OpenAPIv3 object schema.
  254. #
  255. # This covers the fields every post has regardless of which form produced it.
  256. # Anything a *particular* post type ingests lives in that type's JSON sidecar
  257. # next to its template in the forms directory -- see _schema_for().
  258. our %post_schema = (
  259. type => 'object',
  260. properties => {
  261. ## Parameters which must be in every single post
  262. title => { type => 'string' },
  263. callback => { type => 'callback' },
  264. tags => { type => 'array', items => { type => 'string' } },
  265. version => { type => 'integer', minimum => 0 },
  266. visibility => { type => 'string', enum => [qw{public unlisted private}] },
  267. aliases => { type => 'array', items => { type => 'string' } },
  268. # title links here
  269. href => { type => 'string' },
  270. # Link to post locally
  271. local_href => { type => 'string' },
  272. # Post body. Multi-page types (presentations, invoices) send an array.
  273. data => {
  274. oneOf => [
  275. { type => 'string' },
  276. { type => 'array', items => { type => 'string' } },
  277. ],
  278. },
  279. # How do I edit this post?
  280. form => { type => 'string' },
  281. # Post is restricted to visibility to these ACLs if not public/unlisted
  282. acls => { type => 'array', items => { type => 'string' } },
  283. id => { type => 'string' },
  284. # Author of the post
  285. user => { type => 'string' },
  286. created => { type => 'integer' },
  287. # Posts are always GET, but it's stored, so it has to be describable.
  288. method => { type => 'string' },
  289. },
  290. );
  291. # Uploads arrive as an HTTP::Body hashref on the way in, and as the href string
  292. # they were turned into on the way back out of the datastore. Sidecars say
  293. # 'upload' and mean this.
  294. our %upload_schema = (
  295. oneOf => [
  296. { type => 'string' },
  297. { type => 'object' },
  298. ],
  299. );
  300. =head2 schema_for($form)
  301. Return the OpenAPIv3 schema describing what the named post type ingests: the
  302. base post schema above, with the type's JSON sidecar merged over the top.
  303. Sidecars live beside their templates (blog.tx has blog.json) and are written
  304. either by hand or by the Post Type Wizard. A post with no form, or a form with
  305. no sidecar, just gets the base schema.
  306. The canned editor blocks a type includes are merged in too, since a form which
  307. splices in preview.tx collects a preview image no matter what its own sidecar
  308. says -- see includes_for() and include_schema_for(). A type's own declaration
  309. of a field beats the block's, and the base schema beats both.
  310. Cached, but keyed on the mtimes of the component dirs rather than memoized
  311. outright: adding or editing a sidecar bumps one of them, so every forked worker
  312. picks the change up on its very next add() with no restart needed.
  313. =cut
  314. # Stat the forms dirs, and the components dirs holding them: writing blog.json
  315. # bumps the mtime of forms/ and nothing above it, while the sidecar beside a
  316. # canned include (preview.json) lives in components/ itself. Both candidates of
  317. # each, since either one gaining a sidecar changes the answer.
  318. #
  319. # Time::HiRes::stat, because core stat truncates mtime to the second: the post
  320. # type wizard writes a sidecar and something reads it back moments later, and at
  321. # one second of resolution those two are frequently the same generation. The
  322. # reader then answers out of the cache it filled before the write, which is how
  323. # a type saved and used inside the same second validated against the schema it
  324. # had a moment ago. Falls back to whatever stat gives where the filesystem
  325. # carries no sub-second timestamps.
  326. my sub sidecar_generation {
  327. return join( ':', map { ( ( Time::HiRes::stat("$_/forms") )[9] // 0, ( Time::HiRes::stat($_) )[9] // 0 ) } Trog::Themes::template_dirs( 'text/html', 1 ) );
  328. }
  329. # A signature default only covers an absent arg, and plenty of posts have an
  330. # explicitly undef form.
  331. my sub type_of ($form) {
  332. $form = '' if !defined $form || ref $form;
  333. my ($type) = $form =~ m/^([\w-]+)\.tx$/; ## no critic (RegularExpressions::ProhibitRegexToStripAffix) -- the class is the check: anything else is no type
  334. return $type // '';
  335. }
  336. our %relation_schema = ( type => 'string' );
  337. # Sugar: a sidecar says {"type":"upload"} rather than spelling out the
  338. # string-or-hashref dance every upload field would otherwise need. Uploads are
  339. # a hashref on the way in from the browser and the href string they became on
  340. # the way back out of the datastore.
  341. my sub expand_pseudo_types ($root) { ## no critic (ProhibitSingleUseLexicalSubs) -- 26 lines, which would overcomplicate read_sidecar()
  342. my %pseudo = (
  343. upload => \%upload_schema,
  344. relation => \%relation_schema,
  345. );
  346. my @todo = ($root);
  347. for ( my $i = 0; $i < scalar(@todo); $i++ ) {
  348. my $node = $todo[$i];
  349. next unless Ref::Util::is_hashref($node);
  350. my $type = $node->{type} // '';
  351. if ( $pseudo{$type} ) {
  352. delete $node->{type};
  353. %$node = ( %$node, %{ $pseudo{$type} } );
  354. next;
  355. }
  356. push( @todo, values( %{ $node->{properties} // {} } ) );
  357. push( @todo, $node->{items} ) if $node->{items};
  358. foreach my $key (qw{oneOf anyOf allOf}) {
  359. push( @todo, @{ $node->{$key} } ) if Ref::Util::is_arrayref( $node->{$key} );
  360. }
  361. }
  362. return;
  363. }
  364. my sub read_sidecar ($path) {
  365. # -e, not -f: the question is whether a sidecar exists at all, and most
  366. # post types have none. Letting the read below fail instead would WARN
  367. # about every type that simply does not have one.
  368. return undef unless -e $path;
  369. local $@;
  370. my $spec = eval { JSON::MaybeXS::decode_json( File::Slurper::read_text($path) ) };
  371. if ( !$spec ) {
  372. WARN("Could not parse post type sidecar '$path': $@");
  373. return undef;
  374. }
  375. return undef unless Ref::Util::is_hashref($spec);
  376. expand_pseudo_types($spec);
  377. return $spec;
  378. }
  379. =head2 type_meta_for($form)
  380. The parts of a post type's sidecar which aren't schema: x-tcms-relations,
  381. x-tcms-post-type and friends.
  382. schema_for() deliberately keeps only properties and required, since everything
  383. else would be noise to the validator -- but the relation tables and the post
  384. type's own metadata live out there and something has to read them.
  385. =cut
  386. sub type_meta_for ( $form = '' ) {
  387. state %cache;
  388. my $generation = sidecar_generation();
  389. %cache = () unless exists $cache{$generation};
  390. my $type = type_of($form);
  391. return $cache{$generation}{$type} if exists $cache{$generation}{$type};
  392. my $path = $type ? Trog::Themes::themed_file_in_dir( 'forms', "$type.json", 'text/html', 1 ) : undef;
  393. my $sidecar = $path ? read_sidecar($path) : undef;
  394. my %meta;
  395. if ($sidecar) {
  396. %meta = map { $_ => $sidecar->{$_} } grep { $_ ne 'properties' && $_ ne 'required' && $_ ne 'type' } keys(%$sidecar);
  397. }
  398. $cache{$generation}{$type} = \%meta;
  399. return $cache{$generation}{$type};
  400. }
  401. =head2 include_schema_for($include)
  402. The schema fragment describing what a canned include collects, read from the
  403. JSON sidecar beside it: preview.tx has preview.json, exactly as blog.tx has
  404. blog.json.
  405. A canned include is a block of editor markup which several post types splice in,
  406. and the fields it collects belong to every type which does. Nothing said so
  407. until this existed, which cost twice over: a hand-written type had to repeat the
  408. declaration in its own sidecar, where the Post Type Wizard could no longer tell
  409. it apart from a field somebody had typed in; and a generated type had no
  410. declaration at all, so validate() dropped the preview image its own editor had
  411. just collected.
  412. Returns an empty hashref for an include which has no sidecar -- which is most of
  413. them, since edit_head.tx and friends collect nothing.
  414. =cut
  415. sub include_schema_for ( $include = '' ) {
  416. state %cache;
  417. my $generation = sidecar_generation();
  418. %cache = () unless exists $cache{$generation};
  419. # Same shape as a post type, so the same check: a name and nothing else,
  420. # since it is about to be turned into a path.
  421. my $name = type_of($include);
  422. return {} unless $name;
  423. return $cache{$generation}{$name} if exists $cache{$generation}{$name};
  424. my $path = Trog::Themes::themed_component("$name.json");
  425. my $sidecar = $path ? read_sidecar($path) : undef;
  426. $cache{$generation}{$name} = $sidecar // {};
  427. return $cache{$generation}{$name};
  428. }
  429. my sub includes_of ($type) { ## no critic (ProhibitSingleUseLexicalSubs) -- 17 lines, which would overcomplicate includes_for()
  430. my $path = Trog::Themes::themed_file_in_dir( 'forms', "$type.tx", 'text/html', 1 );
  431. my $template = $path ? eval { File::Slurper::read_text($path) } : undef;
  432. if ( !defined $template ) {
  433. my $meta = type_meta_for("$type.tx")->{'x-tcms-post-type'};
  434. my $recorded = Ref::Util::is_hashref($meta) ? $meta->{includes} : undef;
  435. return [] unless Ref::Util::is_arrayref($recorded);
  436. return [ grep { defined && !ref } @$recorded ];
  437. }
  438. # Kolon's line syntax, which is how every include in the tree is written --
  439. # including the ones the wizard generates. Deduped, since a template is
  440. # free to splice the same block in twice.
  441. my %seen;
  442. return [ grep { !$seen{$_}++ } $template =~ m/^\s*:\s*include\s+"([\w-]+\.tx)"/gm ];
  443. }
  444. =head2 includes_for($form)
  445. The canned includes a post type splices into its editor, as an arrayref of
  446. template names in the order they appear.
  447. Read out of the template rather than out of the sidecar, because the template is
  448. what actually renders: a hand-written type never recorded the list anywhere, and
  449. a generated one can still be edited by hand afterwards. The list the wizard
  450. recorded is the fallback for a type whose template cannot be read at all.
  451. Everything the template includes is returned, not just the blocks the wizard
  452. offers -- it is include_schema_for() which decides whether a given one collects
  453. anything.
  454. =cut
  455. sub includes_for ( $form = '' ) {
  456. state %cache;
  457. my $generation = sidecar_generation();
  458. %cache = () unless exists $cache{$generation};
  459. my $type = type_of($form);
  460. return [] unless $type;
  461. return $cache{$generation}{$type} if exists $cache{$generation}{$type};
  462. $cache{$generation}{$type} = includes_of($type);
  463. return $cache{$generation}{$type};
  464. }
  465. =head2 private_fields_for($form)
  466. The fields of a post type which only an editor may see, as declared by
  467. x-tcms-private in the sidecar.
  468. A field is public unless it says otherwise. The point of declaring it here
  469. rather than guarding it in the template is that the value can then be dropped
  470. before rendering, so no amount of getting a template wrong can leak it.
  471. =cut
  472. sub private_fields_for ( $form = '' ) {
  473. my $properties = schema_for($form)->{properties} // {};
  474. return [ grep { $properties->{$_}{'x-tcms-private'} } keys(%$properties) ];
  475. }
  476. =head2 indexed_fields_for($form)
  477. The custom fields of a post type which the Post Type Wizard was told to index,
  478. as declared by x-tcms-indexed in the sidecar.
  479. A field is unindexed unless it says otherwise. What indexing a field actually
  480. means is up to the data model -- see index_fields(), which most of them ignore.
  481. =cut
  482. sub indexed_fields_for ( $form = '' ) {
  483. my $properties = schema_for($form)->{properties} // {};
  484. # Sorted, unlike private_fields_for(), which is only ever asked whether a
  485. # given field is in it. This list is collected across every post type and
  486. # handed to a data model to act on, and an answer that comes back in a
  487. # different order each run makes a reconciliation impossible to reason about.
  488. return [ sort grep { $properties->{$_}{'x-tcms-indexed'} } keys(%$properties) ];
  489. }
  490. =head2 index_fields(@fields)
  491. Tell the data model which custom fields ought to be queryable, and let it do
  492. whatever that means for it.
  493. The default is to do nothing at all, and that is a perfectly good answer: a
  494. model with no notion of an index has nothing to build, and the wizard should not
  495. have to know which model it is talking to in order to offer the option.
  496. Called with the full set every time rather than with a delta, so a model that
  497. does act on it can bring itself into line in one pass -- including letting go of
  498. what is no longer asked for.
  499. =cut
  500. sub index_fields { return 0 }
  501. =head2 relations_for($form)
  502. The x-tcms-relations table for a post type, as a hashref keyed on the template
  503. variable the resolved posts land in. Empty when the type declares none.
  504. Each entry is { form => 'other.tx', from => 'field_name' } -- 'from' naming the
  505. field holding a UUID to resolve, or absent to mean every post of that type.
  506. =cut
  507. sub relations_for ( $form = '' ) {
  508. my $relations = type_meta_for($form)->{'x-tcms-relations'};
  509. return {} unless Ref::Util::is_hashref($relations);
  510. return $relations;
  511. }
  512. sub schema_for ( $form = '' ) {
  513. state %cache;
  514. my $generation = sidecar_generation();
  515. # Only ever keep the current generation around.
  516. %cache = () unless exists $cache{$generation};
  517. my $type = type_of($form);
  518. return $cache{$generation}{$type} if exists $cache{$generation}{$type};
  519. my %merged = ( %post_schema, properties => { %{ $post_schema{properties} } } );
  520. # Themed like the templates themselves: a theme that overrides blog.tx can
  521. # ship its own blog.json alongside it, and one that doesn't still gets the
  522. # stock schema rather than nothing at all.
  523. my $path = $type ? Trog::Themes::themed_file_in_dir( 'forms', "$type.json", 'text/html', 1 ) : undef;
  524. my $sidecar = $path ? read_sidecar($path) : undef;
  525. if ($sidecar) {
  526. # The base schema is merged *last*, so a sidecar can add fields but can
  527. # never redefine one of ours. That matters: a sidecar retyping
  528. # 'callback' as a plain string would defeat the check that the sub it
  529. # names actually exists, which is a privilege problem rather than a
  530. # cosmetic one.
  531. %{ $merged{properties} } = ( %{ $sidecar->{properties} // {} }, %{ $merged{properties} } );
  532. # A sidecar may insist on its own fields, but it can't relax anything
  533. # the base schema already demands.
  534. my @required = List::Util::uniq( @{ $post_schema{required} // [] }, @{ $sidecar->{required} // [] } );
  535. # An empty required list isn't legal OpenAPI, so don't emit one.
  536. $merged{required} = \@required if @required;
  537. }
  538. # And what the canned blocks this type's editor splices in collect. A form
  539. # which includes preview.tx collects a preview image whether or not anybody
  540. # wrote that down in its sidecar, and validate() drops every field the schema
  541. # does not describe -- so until this was merged in, a post type generated by
  542. # the wizard threw its own preview upload away on the way to the datastore.
  543. #
  544. # Merged under both of the above: only the properties, since it is the type
  545. # rather than a block it borrows which gets to say what it insists on, and
  546. # last of the three in precedence, so a type declaring one of these itself
  547. # still wins.
  548. foreach my $include ( @{ includes_for($form) } ) {
  549. my $properties = include_schema_for($include)->{properties};
  550. next unless Ref::Util::is_hashref($properties);
  551. %{ $merged{properties} } = ( %$properties, %{ $merged{properties} } );
  552. }
  553. $cache{$generation}{$type} = \%merged;
  554. return $cache{$generation}{$type};
  555. }
  556. # A relation is stored as the referenced post's UUID, and resolved into the
  557. # post itself at render time -- see the x-tcms-relations table a sidecar
  558. # declares alongside it, and Trog::Routes::HTML::enrich_post. The pseudo-type
  559. # exists so a sidecar can say what a field *means* rather than just that it
  560. # happens to be a string.
  561. =head2 validate($post)
  562. Filter a post down to the fields its type actually describes, then run what's
  563. left through the schema.
  564. Returns the list of validation errors, which is empty when the post is good.
  565. Note that filtering happens first and silently: the query hash handed to us by
  566. the router is full of things which aren't post fields at all, and never was
  567. meant to round-trip.
  568. =cut
  569. my sub validator {
  570. state $validator;
  571. $validator //= JSON::Validator::Schema::Troglodyne->new();
  572. return $validator;
  573. }
  574. sub validate ($post) {
  575. my $validator = validator();
  576. my $schema = schema_for( $post->{form} );
  577. foreach my $key ( keys(%$post) ) {
  578. # Drop everything the schema doesn't describe.
  579. my $property = $schema->{properties}{$key};
  580. if ( !$property ) {
  581. delete $post->{$key};
  582. next;
  583. }
  584. # An untouched form input submits the empty string, which is HTML for
  585. # "I wasn't filled in" rather than a number, a boolean or a file. We
  586. # leave it alone for actual string fields, which is what got stored
  587. # before any of this was typed.
  588. delete $post->{$key}
  589. if defined $post->{$key}
  590. && !ref $post->{$key}
  591. && $post->{$key} eq ''
  592. && ( $property->{type} // '' ) ne 'string';
  593. }
  594. # OpenAPIv3 coerces as it goes, so "4" lands in the datastore as 4 and
  595. # checkbox values land as real booleans.
  596. return $validator->validate( $post, $schema );
  597. }
  598. =head2 validate_built($post)
  599. Check the post that is actually about to be written.
  600. validate() runs on what was submitted. process() then builds the shape that
  601. gets stored -- it turns a visibility into a tag, folds the acls in, resolves
  602. uploads into hrefs -- and until now nothing looked at the result. That is how
  603. every post saved without a visibility came to carry a null in its tags: the
  604. schema describes tags as an array of strings and would have rejected it on the
  605. spot, but it was never asked.
  606. Checks rather than filters, unlike validate(). process() adds fields the base
  607. schema has never heard of -- content_type, is_video, attachments -- and dropping
  608. what it does not recognize would throw away the work it just did. So this looks
  609. only at the fields the schema actually describes, on a copy, and changes nothing.
  610. Returns the list of validation errors, empty when the post is fit to store.
  611. =cut
  612. sub validate_built ($post) {
  613. my $schema = schema_for( $post->{form} );
  614. my %described = map { $_ => $post->{$_} } grep { exists $schema->{properties}{$_} } keys(%$post);
  615. return validator()->validate( \%described, $schema );
  616. }
  617. sub add ( $self, @posts ) {
  618. my @to_write;
  619. foreach my $post (@posts) {
  620. # Filter to what this post type actually describes, then validate it.
  621. my @errors = validate($post);
  622. # An arrayref rather than a string on purpose. These errors get shown
  623. # to whoever submitted the post, and Carp::Always decorates a string
  624. # die with a stack trace they have no use for.
  625. die [ map { "$_" } @errors ] if @errors;
  626. # ||= rather than //= for the identity and routing scalars. An
  627. # untouched hidden input submits the empty string, and validate() keeps
  628. # an empty string for a field the schema types as a string -- which id,
  629. # local_href, callback and method all are. //= then saw a defined value
  630. # and left it, so a post created through any editor arrived here with
  631. # id => '' and the flat file model wrote it to 'data/files/', which is
  632. # the datastore directory rather than a post in it.
  633. $post->{id} ||= Trog::Utils::uuid();
  634. $post->{aliases} //= [];
  635. $post->{aliases} = [ $post->{aliases} ] unless ref $post->{aliases} eq 'ARRAY';
  636. if ( $post->{aclname} ) {
  637. # Then this is a series
  638. $post->{local_href} ||= "/$post->{aclname}";
  639. push( @{ $post->{aliases} }, "/posts/$post->{id}", "/series/$post->{id}" );
  640. }
  641. # Every post needs one: process pushes it into the tags, and an
  642. # undef there is a tag no query will ever match, which makes the post
  643. # invisible to everyone but an admin. A post type built without a
  644. # visibility selector would otherwise produce exactly that. Private
  645. # rather than public, because guessing wrong in the other direction
  646. # publishes something nobody asked to publish.
  647. $post->{visibility} ||= 'private';
  648. $post->{callback} ||= 'Trog::Routes::HTML::posts';
  649. # If this is a user creation post, add in the /user/ route
  650. if ( $post->{callback} eq 'Trog::Routes::HTML::users' ) {
  651. $post->{local_href} ||= "/users/$post->{display_name}";
  652. $post->{title} ||= $post->{display_name};
  653. }
  654. $post->{local_href} ||= "/posts/$post->{id}";
  655. $post->{method} ||= 'GET';
  656. $post->{created} = time();
  657. my @existing_posts = $self->get( id => $post->{id}, acls => [qw{admin}] );
  658. if (@existing_posts) {
  659. my $existing_post = $existing_posts[0];
  660. $post->{version} = $existing_post->{version};
  661. $post->{version}++;
  662. }
  663. $post->{version} //= 0;
  664. #XXX if local_href has /secure paths in it, fix this. We don't want to save that.
  665. if ( index( $post->{local_href}, '/secure/' ) == 0 ) {
  666. $post->{local_href} =~ s|/secure||gmx;
  667. }
  668. $post = process($post);
  669. # And check what process() built, not just what arrived. A post that
  670. # fails here is one this code has made wrong on its way to disk, so it
  671. # is a bug rather than bad input -- but the answer is still to refuse to
  672. # write it, because the alternative is what we did before, which was to
  673. # write it and find out years later.
  674. my @built = validate_built($post);
  675. if (@built) {
  676. WARN( "Refusing to store a post this built wrong: " . join( ', ', map { "$_" } @built ) );
  677. die [ map { "$_" } @built ];
  678. }
  679. push @to_write, $post;
  680. }
  681. $self->write( \@to_write );
  682. return 0;
  683. }
  684. my %seen_files;
  685. my sub handle_upload ( $file, $uuid, $private = 0 ) {
  686. my $fname;
  687. if ( ref $file ne 'HASH' ) {
  688. $file =~ s/\\/\//g;
  689. $fname = basename($file);
  690. FATAL("Did not expect bogus file before its real counterpart!") unless $seen_files{$fname};
  691. return $seen_files{$fname};
  692. }
  693. my $f = $file->{tempname};
  694. $fname = basename( $file->{filename} );
  695. my $newname = "$uuid.$file->{filename}";
  696. $newname = "private/$newname" if $private;
  697. File::Copy::move( $f, "www/assets/$newname" );
  698. # Regrettably, we have no control over the perms of the temp file that starman creates for uploads.
  699. chmod( 0755, "www/assets/$newname" );
  700. $seen_files{$fname} = "/assets/$newname";
  701. return $seen_files{$fname};
  702. }
  703. #XXX this level of post-processing seems gross, but may be unavoidable
  704. # Not actually a subprocess, kek
  705. =head2 process($post) = HASHREF
  706. Build the post that gets stored from the one that was submitted. It stores
  707. the uploaded files, folds the visibility, the acls and the series into the
  708. tags, removes the fields that only the form uses, and sets the content type
  709. fields from the files the post points at. Changes $post, and returns it.
  710. =cut
  711. sub process ($post) {
  712. # Here as well as in add(), because this is the sub that turns a visibility
  713. # into a tag: without one it pushed an undef into tags, which is a tag no
  714. # query ever matches, so the post went invisible to everyone but an admin.
  715. # Nothing complained, and the bunk tag was written to disk and stayed there.
  716. $post->{visibility} ||= 'private';
  717. # If the post is private, make sure it's associated assets are too.
  718. my $is_private_post = $post->{visibility} eq 'private';
  719. $post->{href} = handle_upload( $post->{file}, $post->{id}, $is_private_post ) if $post->{file};
  720. $post->{preview} = handle_upload( $post->{preview_file}, $post->{id}, $is_private_post ) if $post->{preview_file};
  721. $post->{wallpaper} = handle_upload( $post->{wallpaper_file}, $post->{id}, $is_private_post ) if $post->{wallpaper_file};
  722. @{ $post->{attachments} } = map { handle_upload( $_, $post->{id}, $is_private_post ) } @{ $post->{attachments} } if $post->{attachments};
  723. $post->{preview} = $post->{href} if $post->{app} && $post->{app} eq 'image';
  724. delete $post->{app};
  725. delete $post->{file};
  726. delete $post->{preview_file};
  727. delete $post->{wallpaper_file};
  728. delete $post->{scheme};
  729. delete $post->{route};
  730. delete $post->{domain};
  731. # Handle acls/tags
  732. $post->{tags} //= [];
  733. $post->{acls} //= [];
  734. @{ $post->{tags} } = grep {
  735. my $subj = $_;
  736. List::Util::none { $_ eq $subj } qw{public private unlisted}
  737. } @{ $post->{tags} };
  738. push( @{ $post->{tags} }, grep { defined } @{ $post->{acls} } ) if $is_private_post;
  739. delete $post->{acls};
  740. push( @{ $post->{tags} }, $post->{visibility} );
  741. # Add the 'series' tag if we are in a series, restrict to relevant acl
  742. if ( $post->{series} ) {
  743. push( @{ $post->{tags} }, 'series' );
  744. push( @{ $post->{tags} }, $post->{series} );
  745. }
  746. #Filter adding the same acl twice, and anything that isn't a tag at all --
  747. # an undef here is what every query below compares against, and it warns
  748. # its way through the lot before matching nothing.
  749. @{ $post->{tags} } = List::Util::uniq( grep { $_ } @{ $post->{tags} } );
  750. @{ $post->{aliases} } = List::Util::uniq( grep { $_ } @{ $post->{aliases} } );
  751. # Handle multimedia content types
  752. $post->{content_type} = Trog::Utils::mime_type("www/$post->{href}") if $post->{href};
  753. $post->{video_content_type} = Trog::Utils::mime_type("www/$post->{video_href}") if $post->{video_href};
  754. $post->{audio_content_type} = Trog::Utils::mime_type("www/$post->{audio_href}") if $post->{audio_href};
  755. $post->{content_type} ||= 'text/html';
  756. $post->{is_video} = 1 if $post->{content_type} =~ m/^video\//;
  757. $post->{is_audio} = 1 if $post->{content_type} =~ m/^audio\//;
  758. $post->{is_image} = 1 if $post->{content_type} =~ m/^image\//;
  759. $post->{is_profile} = 1 if List::Util::any { $_ eq 'about' } @{ $post->{tags} };
  760. return $post;
  761. }
  762. # Browsers try and save time in the event that the same file is sent twice, so handle that.
  763. =head2 delete(@posts)
  764. Delete the following posts.
  765. Will remove all versions of said post.
  766. You should override this, it is a stub here.
  767. =cut
  768. sub delete { die 'stub' }
  769. =head2 routes() = HASH
  770. Returns the routes to each post.
  771. You should override this for performance reasons, as it's just a wrapper around get() by default.
  772. =cut
  773. sub routes ($self) {
  774. my %routes = map { $_->{local_href} => { method => $_->{method}, callback => \&{ $_->{callback} } } } ( $self->get( limit => 0, acls => ['admin'] ) );
  775. return %routes;
  776. }
  777. =head2 aliases() = HASH
  778. Returns the aliases for each post, indexed by aliases.
  779. You should override this for performance reasons, as it's just a wrapper around get() by default.
  780. =cut
  781. sub aliases ($self) {
  782. my @posts = $self->get( limit => 0, acls => ['admin'] );
  783. my %aliases;
  784. foreach my $post (@posts) {
  785. $aliases{$_} = $post->{local_href} for @{ $post->{aliases} // [] };
  786. }
  787. return %aliases;
  788. }
  789. 1;