DataModule.pm 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. package Trog::DataModule;
  2. use v5.36;
  3. use re '/aa';
  4. use FindBin::libs;
  5. use List::Util;
  6. use File::Copy;
  7. use Path::Tiny();
  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 Trog::Themes();
  14. use Trog::Log qw{:all};
  15. use Trog::Utils;
  16. use Trog::Auth();
  17. =head1 QUERY FORMAT
  18. The $query_language and $query_help variables are presented to the user as to how to use the search box in the tCMS header.
  19. =head1 POST STRUCTURE
  20. Posts generally need to have the following:
  21. data: Brief description of content, or the content itself.
  22. content_type: What this content actually is. Used to filter into the appropriate pages.
  23. href: Primary link. This is the subject of a news post, or a link to the item itself. Can be local or remote.
  24. local_href: Backup link. Automatically created link to a static cache of the content.
  25. title: Title of the content. Used as link name for the 'href' attribute.
  26. user: User was banned for this post
  27. id: Internal identifier in datastore for the post.
  28. tags: array ref of appropriate tags.
  29. created: timestamp of creation of this version of the post
  30. version: revision # of this post.
  31. =head1 CONSTRUCTOR
  32. =head2 new(Config::Simple $config)
  33. Try not to do expensive things here.
  34. =cut
  35. sub new ( $class, $config ) {
  36. $config = $config->vars();
  37. return bless( $config, $class );
  38. }
  39. =head1 ABSTRACT METHODS
  40. Subclasses must implement all of these; the stubs here do nothing but die.
  41. count() belongs to this set as well, and is documented with the rest of the
  42. querying interface below.
  43. =head2 lang() = STRING $language
  44. The name of the query language this data model understands, shown to the user
  45. beside the search bar.
  46. =head2 help() = STRING $help
  47. Documentation for that query language, shown to the user alongside it.
  48. =head2 read($query) = ARRAYREF $posts
  49. Every post the data model holds, as an arrayref of hashrefs. Filtering is
  50. get()'s job unless your storage engine can do it more cheaply itself, in which
  51. case honour $query here and override get() too.
  52. =head2 write($posts)
  53. Commit an arrayref of posts to storage. Called by add() once the posts have
  54. been filtered and validated; don't validate again here.
  55. =head2 tags() = ARRAYREF $tags
  56. Every tag known to the datastore, for building tag pickers.
  57. =cut
  58. #It is required that subclasses implement this
  59. sub lang ($self) { ... }
  60. sub help ($self) { ... }
  61. sub read ( $self, $query = {} ) { ... }
  62. sub write ($self) { ... }
  63. sub count ($self) { ... }
  64. sub tags ($self) { ... }
  65. =head1 METHODS
  66. =head2 get(%request)
  67. Queries the data model. Should return the following:
  68. 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).
  69. version => if id is passed, return the provided post version rather than the most recent one
  70. tags => ARRAYREF of tags, any one of which is required to give a result. If none are passed, no filtering is performed.
  71. 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.
  72. page => Offset multiplier for pagination.
  73. limit => Offset for pagination.
  74. like => Search query, as might be passed in the search bar.
  75. author => filter by post author
  76. If it is more efficient to filter within your data storage engine, you probably should override this method.
  77. As implemented, this takes the data as a given and filters in post.
  78. =cut
  79. sub get ( $self, %request ) {
  80. my $posts = $self->read( \%request );
  81. return @$posts if $request{raw};
  82. my @filtered = $self->filter( \%request, @$posts );
  83. @filtered = $self->_fixup(@filtered);
  84. @filtered = $self->paginate( \%request, @filtered );
  85. return @filtered;
  86. }
  87. sub _fixup ( $self, @filtered ) {
  88. my %user2display;
  89. # urlencode spaces in filenames
  90. @filtered = map {
  91. my $subj = $_;
  92. foreach my $param (qw{href preview video_href audio_href local_href wallpaper}) {
  93. next unless exists $subj->{$param};
  94. #XXX I don't remember what this fixes, but it also breaks things. URI::Escape usage instead is indicated.
  95. $subj->{$param} =~ s/ /%20/g;
  96. }
  97. $user2display{ $subj->{user} } //= Trog::Auth::username2display( $subj->{user} );
  98. $subj->{display_name} = $user2display{ $subj->{user} };
  99. #XXX Add dynamic routing data for posts which don't have them (/posts/$id) and (/users/$user)
  100. my $is_user_page = List::Util::any { ($_ // '') eq 'about' } @{ $subj->{tags} };
  101. if ( !exists $subj->{local_href} ) {
  102. $subj->{local_href} = "/posts/$subj->{id}";
  103. #XXX this needs to be correctly populated in the form?
  104. if ($is_user_page) {
  105. my $display_name = $user2display{ $subj->{user} };
  106. die "No display name for user!" unless $display_name;
  107. $subj->{local_href} = "/users/$display_name";
  108. }
  109. }
  110. if ( !exists $subj->{callback} ) {
  111. $subj->{callback} = "Trog::Routes::HTML::posts";
  112. $subj->{callback} = "Trog::Routes::HTML::users" if $is_user_page;
  113. }
  114. $subj->{method} = 'GET' unless exists( $subj->{method} );
  115. $subj->{user_class} = Trog::Auth::username2classname( $subj->{user} );
  116. $subj
  117. } @filtered;
  118. return @filtered;
  119. }
  120. sub _filter_param ( $query, $param, @filtered ) {
  121. @filtered = grep { ( $_->{$param} || '' ) eq $query->{$param} } @filtered;
  122. @filtered = _dedup_versions( $query->{version}, @filtered );
  123. return @filtered;
  124. }
  125. =head2 filter($query, @posts) = @filtered
  126. Apply a get() request's filters to a list of posts: tags, exclude_tags, acls,
  127. visibility, id, title, form, search terms and author.
  128. The ACL check is the load bearing one -- a post is only visible if the caller
  129. holds one of its acls, or the post is public or unlisted. Callers holding the
  130. 'admin' acl skip that filter entirely.
  131. =cut
  132. sub filter ( $self, $query, @filtered ) {
  133. $query->{acls} //= [];
  134. $query->{tags} //= [];
  135. $query->{exclude_tags} //= [];
  136. # If an ID or title or acl is passed, just get that (and all it's prior versions)
  137. foreach my $key (qw{id title aclname}) {
  138. next unless $query->{$key};
  139. return _filter_param( $query, $key, @filtered );
  140. }
  141. @filtered = _dedup_versions( undef, @filtered );
  142. #Filter out posts which are too old
  143. #Coerce older into numeric
  144. if ( $query->{older} ) {
  145. $query->{older} =~ s/[^0-9]//g;
  146. @filtered = grep { $_->{created} < $query->{older} } @filtered;
  147. }
  148. if ( $query->{newer} ) {
  149. $query->{newer} =~ s/[^0-9]//g;
  150. @filtered = grep { $_->{created} > $query->{newer} } @filtered;
  151. }
  152. # Filter posts not matching the passed tag(s), if any
  153. @filtered = grep {
  154. my $tags = $_->{tags};
  155. grep {
  156. my $t = $_;
  157. grep { ($t // '') eq $_ } @{ $query->{tags} }
  158. } @$tags
  159. } @filtered if @{ $query->{tags} };
  160. # Filter posts *matching* the passed exclude_tag(s), if any
  161. @filtered = grep {
  162. my $tags = $_->{tags};
  163. !grep {
  164. my $t = $_;
  165. grep { $t eq $_ } @{ $query->{exclude_tags} }
  166. } @$tags
  167. } @filtered if @{ $query->{exclude_tags} };
  168. # Filter posts without the proper ACLs
  169. @filtered = grep {
  170. my $tags = $_->{tags};
  171. grep {
  172. my $t = $_;
  173. grep { $t eq $_ } @{ $query->{acls} }
  174. } @$tags
  175. } @filtered unless grep { $_ eq 'admin' } @{ $query->{acls} };
  176. @filtered = grep { ($_->{form} || '') eq $query->{form} } @filtered if $query->{form};
  177. @filtered = grep { $_->{title} =~ m/\Q$query->{like}\E/i || $_->{data} =~ m/\Q$query->{like}\E/i } @filtered if $query->{like};
  178. @filtered = grep { $_->{user} eq $query->{author} } @filtered if $query->{author};
  179. return @filtered;
  180. }
  181. =head2 paginate($query, @posts) = @page
  182. The slice of @posts named by the request's page and limit. Both have to be
  183. present to page at all; limit defaults to 25 when computing the offset.
  184. =cut
  185. sub paginate ( $self, $query, @filtered ) {
  186. my $offset = int( $query->{limit} // 25 );
  187. $offset = @filtered < $offset ? @filtered : $offset;
  188. @filtered = splice( @filtered, ( int( $query->{page} ) - 1 ) * $offset, $offset ) if $query->{page} && $query->{limit};
  189. return @filtered;
  190. }
  191. sub _dedup_versions ( $version = -1, @posts ) {
  192. #ASSUMPTION made here - if we pass version this is direct ID query
  193. if ( defined $version ) {
  194. my $version_max = List::Util::max( map { $_->{version} } @posts );
  195. return map {
  196. $_->{version_max} //= $version_max;
  197. $_
  198. } grep { $_->{version} eq $version } @posts;
  199. }
  200. my @uniqids = List::Util::uniq( map { $_->{id} } @posts );
  201. my %posts_deduped;
  202. for my $id (@uniqids) {
  203. my @ofid = sort { $b->{version} <=> $a->{version} } grep { $_->{id} eq $id } @posts;
  204. my $version_max = List::Util::max( map { $_->{version} } @ofid );
  205. $posts_deduped{$id} = $ofid[0];
  206. $posts_deduped{$id}{version_max} = $version_max;
  207. # Show orig creation date, and original author.
  208. # XXX this doesn't show the mtime correctly for whatever reason, so I'm omitting it from the interface
  209. $posts_deduped{$id}{modified} = $ofid[0]{created};
  210. $posts_deduped{$id}{created} = $ofid[-1]{created};
  211. $posts_deduped{$id}{author} = $ofid[-1]{author};
  212. }
  213. my @deduped = @posts_deduped{@uniqids};
  214. return @deduped;
  215. }
  216. =head2 count() = INT $num
  217. Returns the total number of posts.
  218. Used to determine paginator parameters.
  219. =cut
  220. =head2 add(@posts) = BOOL $failed_or_not
  221. Add the provided posts to the datastore.
  222. If any post already exists with the same id, a new post with a version higher than it will be added.
  223. Passes an array of new posts to add to the data store module's write() function.
  224. These will have their parameters filtered to those described by the post type's
  225. schema (see schema_for()), and then validated against it. If the post doesn't
  226. hold up, this dies with an ARRAYREF of validation error strings -- a ref rather
  227. than a string so that Carp::Always doesn't staple a stack trace onto something
  228. we intend to show the user.
  229. You probably won't want to override this.
  230. =cut
  231. # The shape of a post, as an OpenAPIv3 object schema.
  232. #
  233. # This covers the fields every post has regardless of which form produced it.
  234. # Anything a *particular* post type ingests lives in that type's JSON sidecar
  235. # next to its template in the forms directory -- see _schema_for().
  236. our %post_schema = (
  237. type => 'object',
  238. properties => {
  239. ## Parameters which must be in every single post
  240. title => { type => 'string' },
  241. callback => { type => 'callback' },
  242. tags => { type => 'array', items => { type => 'string' } },
  243. version => { type => 'integer', minimum => 0 },
  244. visibility => { type => 'string', enum => [qw{public unlisted private}] },
  245. aliases => { type => 'array', items => { type => 'string' } },
  246. # title links here
  247. href => { type => 'string' },
  248. # Link to post locally
  249. local_href => { type => 'string' },
  250. # Post body. Multi-page types (presentations, invoices) send an array.
  251. data => {
  252. oneOf => [
  253. { type => 'string' },
  254. { type => 'array', items => { type => 'string' } },
  255. ],
  256. },
  257. # How do I edit this post?
  258. form => { type => 'string' },
  259. # Post is restricted to visibility to these ACLs if not public/unlisted
  260. acls => { type => 'array', items => { type => 'string' } },
  261. id => { type => 'string' },
  262. # Author of the post
  263. user => { type => 'string' },
  264. created => { type => 'integer' },
  265. # Posts are always GET, but it's stored, so it has to be describable.
  266. method => { type => 'string' },
  267. },
  268. );
  269. # Uploads arrive as an HTTP::Body hashref on the way in, and as the href string
  270. # they were turned into on the way back out of the datastore. Sidecars say
  271. # 'upload' and mean this.
  272. our %upload_schema = (
  273. oneOf => [
  274. { type => 'string' },
  275. { type => 'object' },
  276. ],
  277. );
  278. =head2 schema_for($form)
  279. Return the OpenAPIv3 schema describing what the named post type ingests: the
  280. base post schema above, with the type's JSON sidecar merged over the top.
  281. Sidecars live beside their templates (blog.tx has blog.json) and are written
  282. either by hand or by the Post Type Wizard. A post with no form, or a form with
  283. no sidecar, just gets the base schema.
  284. Cached, but keyed on the mtimes of the component dirs rather than memoized
  285. outright: adding or editing a sidecar bumps one of them, so every forked worker
  286. picks the change up on its very next add() with no restart needed.
  287. =cut
  288. # Stat the forms dirs, not their parents -- writing blog.json bumps the mtime
  289. # of forms/, and nothing above it. Both candidates, since either one gaining a
  290. # sidecar changes the answer.
  291. sub _sidecar_generation {
  292. return join( ':', map { ( stat("$_/forms") )[9] // 0 } Trog::Themes::template_dirs( 'text/html', 1 ) );
  293. }
  294. # A signature default only covers an absent arg, and plenty of posts have an
  295. # explicitly undef form.
  296. sub _type_of ($form) {
  297. $form = '' if !defined $form || ref $form;
  298. my ($type) = $form =~ m/^([A-Za-z0-9_-]+)\.tx$/;
  299. return $type // '';
  300. }
  301. =head2 type_meta_for($form)
  302. The parts of a post type's sidecar which aren't schema: x-tcms-relations,
  303. x-tcms-post-type and friends.
  304. schema_for() deliberately keeps only properties and required, since everything
  305. else would be noise to the validator -- but the relation tables and the post
  306. type's own metadata live out there and something has to read them.
  307. =cut
  308. sub type_meta_for ( $form = '' ) {
  309. state %cache;
  310. my $generation = _sidecar_generation();
  311. %cache = () unless exists $cache{$generation};
  312. my $type = _type_of($form);
  313. return $cache{$generation}{$type} if exists $cache{$generation}{$type};
  314. my $path = $type ? Trog::Themes::themed_file_in_dir( 'forms', "$type.json", 'text/html', 1 ) : undef;
  315. my $sidecar = $path ? _read_sidecar($path) : undef;
  316. my %meta;
  317. if ($sidecar) {
  318. %meta = map { $_ => $sidecar->{$_} } grep { $_ ne 'properties' && $_ ne 'required' && $_ ne 'type' } keys(%$sidecar);
  319. }
  320. $cache{$generation}{$type} = \%meta;
  321. return $cache{$generation}{$type};
  322. }
  323. =head2 private_fields_for($form)
  324. The fields of a post type which only an editor may see, as declared by
  325. x-tcms-private in the sidecar.
  326. A field is public unless it says otherwise. The point of declaring it here
  327. rather than guarding it in the template is that the value can then be dropped
  328. before rendering, so no amount of getting a template wrong can leak it.
  329. =cut
  330. sub private_fields_for ( $form = '' ) {
  331. my $properties = schema_for($form)->{properties} // {};
  332. return [ grep { $properties->{$_}{'x-tcms-private'} } keys(%$properties) ];
  333. }
  334. =head2 relations_for($form)
  335. The x-tcms-relations table for a post type, as a hashref keyed on the template
  336. variable the resolved posts land in. Empty when the type declares none.
  337. Each entry is { form => 'other.tx', from => 'field_name' } -- 'from' naming the
  338. field holding a UUID to resolve, or absent to mean every post of that type.
  339. =cut
  340. sub relations_for ( $form = '' ) {
  341. my $relations = type_meta_for($form)->{'x-tcms-relations'};
  342. return {} unless Ref::Util::is_hashref($relations);
  343. return $relations;
  344. }
  345. sub schema_for ( $form = '' ) {
  346. state %cache;
  347. my $generation = _sidecar_generation();
  348. # Only ever keep the current generation around.
  349. %cache = () unless exists $cache{$generation};
  350. my $type = _type_of($form);
  351. return $cache{$generation}{$type} if exists $cache{$generation}{$type};
  352. my %merged = ( %post_schema, properties => { %{ $post_schema{properties} } } );
  353. # Themed like the templates themselves: a theme that overrides blog.tx can
  354. # ship its own blog.json alongside it, and one that doesn't still gets the
  355. # stock schema rather than nothing at all.
  356. my $path = $type ? Trog::Themes::themed_file_in_dir( 'forms', "$type.json", 'text/html', 1 ) : undef;
  357. my $sidecar = $path ? _read_sidecar($path) : undef;
  358. if ($sidecar) {
  359. # The base schema is merged *last*, so a sidecar can add fields but can
  360. # never redefine one of ours. That matters: a sidecar retyping
  361. # 'callback' as a plain string would defeat the check that the sub it
  362. # names actually exists, which is a privilege problem rather than a
  363. # cosmetic one.
  364. %{ $merged{properties} } = ( %{ $sidecar->{properties} // {} }, %{ $merged{properties} } );
  365. # A sidecar may insist on its own fields, but it can't relax anything
  366. # the base schema already demands.
  367. my @required = List::Util::uniq( @{ $post_schema{required} // [] }, @{ $sidecar->{required} // [] } );
  368. # An empty required list isn't legal OpenAPI, so don't emit one.
  369. $merged{required} = \@required if @required;
  370. }
  371. $cache{$generation}{$type} = \%merged;
  372. return $cache{$generation}{$type};
  373. }
  374. sub _read_sidecar ($path) {
  375. return undef unless -f $path;
  376. local $@;
  377. my $spec = eval { JSON::MaybeXS::decode_json( File::Slurper::read_text($path) ) };
  378. if ( !$spec ) {
  379. WARN("Could not parse post type sidecar '$path': $@");
  380. return undef;
  381. }
  382. return undef unless Ref::Util::is_hashref($spec);
  383. _expand_pseudo_types($spec);
  384. return $spec;
  385. }
  386. # A relation is stored as the referenced post's UUID, and resolved into the
  387. # post itself at render time -- see the x-tcms-relations table a sidecar
  388. # declares alongside it, and Trog::Routes::HTML::_enrich_post. The pseudo-type
  389. # exists so a sidecar can say what a field *means* rather than just that it
  390. # happens to be a string.
  391. our %relation_schema = ( type => 'string' );
  392. # Sugar: a sidecar says {"type":"upload"} rather than spelling out the
  393. # string-or-hashref dance every upload field would otherwise need. Uploads are
  394. # a hashref on the way in from the browser and the href string they became on
  395. # the way back out of the datastore.
  396. sub _expand_pseudo_types ($node) {
  397. return unless Ref::Util::is_hashref($node);
  398. my %pseudo = (
  399. upload => \%upload_schema,
  400. relation => \%relation_schema,
  401. );
  402. my $type = $node->{type} // '';
  403. if ( $pseudo{$type} ) {
  404. delete $node->{type};
  405. %$node = ( %$node, %{ $pseudo{$type} } );
  406. return;
  407. }
  408. _expand_pseudo_types($_) foreach values( %{ $node->{properties} // {} } );
  409. _expand_pseudo_types( $node->{items} ) if $node->{items};
  410. foreach my $key (qw{oneOf anyOf allOf}) {
  411. next unless Ref::Util::is_arrayref( $node->{$key} );
  412. _expand_pseudo_types($_) foreach @{ $node->{$key} };
  413. }
  414. return;
  415. }
  416. =head2 validate($post)
  417. Filter a post down to the fields its type actually describes, then run what's
  418. left through the schema.
  419. Returns the list of validation errors, which is empty when the post is good.
  420. Note that filtering happens first and silently: the query hash handed to us by
  421. the router is full of things which aren't post fields at all, and never was
  422. meant to round-trip.
  423. =cut
  424. sub validate ($post) {
  425. state $validator;
  426. $validator //= JSON::Validator::Schema::Troglodyne->new();
  427. my $schema = schema_for( $post->{form} );
  428. foreach my $key ( keys(%$post) ) {
  429. # Drop everything the schema doesn't describe.
  430. my $property = $schema->{properties}{$key};
  431. if ( !$property ) {
  432. delete $post->{$key};
  433. next;
  434. }
  435. # An untouched form input submits the empty string, which is HTML for
  436. # "I wasn't filled in" rather than a number, a boolean or a file. We
  437. # leave it alone for actual string fields, which is what got stored
  438. # before any of this was typed.
  439. delete $post->{$key}
  440. if defined $post->{$key}
  441. && !ref $post->{$key}
  442. && $post->{$key} eq ''
  443. && ( $property->{type} // '' ) ne 'string';
  444. }
  445. # OpenAPIv3 coerces as it goes, so "4" lands in the datastore as 4 and
  446. # checkbox values land as real booleans.
  447. return $validator->validate( $post, $schema );
  448. }
  449. sub add ( $self, @posts ) {
  450. my @to_write;
  451. foreach my $post (@posts) {
  452. # Filter to what this post type actually describes, then validate it.
  453. my @errors = validate($post);
  454. # An arrayref rather than a string on purpose. These errors get shown
  455. # to whoever submitted the post, and Carp::Always decorates a string
  456. # die with a stack trace they have no use for.
  457. die [ map { "$_" } @errors ] if @errors;
  458. $post->{id} //= Trog::Utils::uuid();
  459. $post->{aliases} //= [];
  460. $post->{aliases} = [ $post->{aliases} ] unless ref $post->{aliases} eq 'ARRAY';
  461. if ( $post->{aclname} ) {
  462. # Then this is a series
  463. $post->{local_href} //= "/$post->{aclname}";
  464. push( @{ $post->{aliases} }, "/posts/$post->{id}", "/series/$post->{id}" );
  465. }
  466. # Every post needs one: _process pushes it into the tags, and an
  467. # undef there is a tag no query will ever match, which makes the post
  468. # invisible to everyone but an admin. A post type built without a
  469. # visibility selector would otherwise produce exactly that. Private
  470. # rather than public, because guessing wrong in the other direction
  471. # publishes something nobody asked to publish.
  472. $post->{visibility} //= 'private';
  473. $post->{callback} //= 'Trog::Routes::HTML::posts';
  474. # If this is a user creation post, add in the /user/ route
  475. if ( $post->{callback} eq 'Trog::Routes::HTML::users' ) {
  476. $post->{local_href} //= "/users/$post->{display_name}";
  477. $post->{title} //= $post->{display_name};
  478. }
  479. $post->{local_href} //= "/posts/$post->{id}";
  480. $post->{method} //= 'GET';
  481. $post->{created} = time();
  482. my @existing_posts = $self->get( id => $post->{id} );
  483. if (@existing_posts) {
  484. my $existing_post = $existing_posts[0];
  485. $post->{version} = $existing_post->{version};
  486. $post->{version}++;
  487. }
  488. $post->{version} //= 0;
  489. #XXX if local_href has /secure paths in it, fix this. We don't want to save that.
  490. if (index($post->{local_href}, '/secure/') == 0) {
  491. $post->{local_href} =~ s|/secure||gmx;
  492. }
  493. $post = _process($post);
  494. push @to_write, $post;
  495. }
  496. $self->write( \@to_write );
  497. return 0;
  498. }
  499. #XXX this level of post-processing seems gross, but may be unavoidable
  500. # Not actually a subprocess, kek
  501. sub _process ($post) {
  502. # If the post is private, make sure it's associated assets are too.
  503. my $is_private_post = $post->{visibility} eq 'private';
  504. $post->{href} = _handle_upload( $post->{file}, $post->{id}, $is_private_post ) if $post->{file};
  505. $post->{preview} = _handle_upload( $post->{preview_file}, $post->{id}, $is_private_post ) if $post->{preview_file};
  506. $post->{wallpaper} = _handle_upload( $post->{wallpaper_file}, $post->{id}, $is_private_post ) if $post->{wallpaper_file};
  507. @{ $post->{attachments} } = map { _handle_upload( $_, $post->{id}, $is_private_post ) } @{ $post->{attachments} } if $post->{attachments};
  508. $post->{preview} = $post->{href} if $post->{app} && $post->{app} eq 'image';
  509. delete $post->{app};
  510. delete $post->{file};
  511. delete $post->{preview_file};
  512. delete $post->{wallpaper_file};
  513. delete $post->{scheme};
  514. delete $post->{route};
  515. delete $post->{domain};
  516. # Handle acls/tags
  517. $post->{tags} //= [];
  518. $post->{acls} //= [];
  519. @{ $post->{tags} } = grep {
  520. my $subj = $_;
  521. !grep { $_ eq $subj } qw{public private unlisted}
  522. } @{ $post->{tags} };
  523. push( @{ $post->{tags} }, @{ $post->{acls} } ) if $is_private_post;
  524. delete $post->{acls};
  525. push( @{ $post->{tags} }, $post->{visibility} );
  526. # Add the 'series' tag if we are in a series, restrict to relevant acl
  527. if ( $post->{series} ) {
  528. push( @{ $post->{tags} }, 'series' );
  529. push( @{ $post->{tags} }, $post->{series} );
  530. }
  531. #Filter adding the same acl twice
  532. @{ $post->{tags} } = List::Util::uniq( @{ $post->{tags} } );
  533. @{ $post->{aliases} } = List::Util::uniq( @{ $post->{aliases} } );
  534. # Handle multimedia content types
  535. $post->{content_type} = Trog::Utils::mime_type("www/$post->{href}") if $post->{href};
  536. $post->{video_content_type} = Trog::Utils::mime_type("www/$post->{video_href}") if $post->{video_href};
  537. $post->{audio_content_type} = Trog::Utils::mime_type("www/$post->{audio_href}") if $post->{audio_href};
  538. $post->{content_type} ||= 'text/html';
  539. $post->{is_video} = 1 if $post->{content_type} =~ m/^video\//;
  540. $post->{is_audio} = 1 if $post->{content_type} =~ m/^audio\//;
  541. $post->{is_image} = 1 if $post->{content_type} =~ m/^image\//;
  542. $post->{is_profile} = 1 if grep { $_ eq 'about' } @{ $post->{tags} };
  543. return $post;
  544. }
  545. # Browsers try and save time in the event that the same file is sent twice, so handle that.
  546. my %seen_files;
  547. sub _handle_upload ( $file, $uuid, $private=0 ) {
  548. my $fname;
  549. if ( ref $file ne 'HASH' ) {
  550. use Data::Dumper;
  551. $file =~ s/\\/\//g;
  552. $fname = basename($file);
  553. FATAL("Did not expect bogus file before its real counterpart!") unless $seen_files{$fname};
  554. return $seen_files{$fname};
  555. }
  556. my $f = $file->{tempname};
  557. $fname = basename( $file->{filename} );
  558. my $newname = "$uuid.$file->{filename}";
  559. $newname = "private/$newname" if $private;
  560. File::Copy::move( $f, "www/assets/$newname" );
  561. # Regrettably, we have no control over the perms of the temp file that starman creates for uploads.
  562. chmod(0755, "www/assets/$newname");
  563. $seen_files{$fname} = "/assets/$newname";
  564. return $seen_files{$fname};
  565. }
  566. =head2 delete(@posts)
  567. Delete the following posts.
  568. Will remove all versions of said post.
  569. You should override this, it is a stub here.
  570. =cut
  571. sub delete ($self) { die 'stub' }
  572. =head2 routes() = HASH
  573. Returns the routes to each post.
  574. You should override this for performance reasons, as it's just a wrapper around get() by defualt.
  575. =cut
  576. sub routes ($self) {
  577. my %routes = map { $_->{local_href} => { method => $_->{method}, callback => \&{ $_->{callback} } } } ( $self->get( limit => 0, acls => ['admin'] ) );
  578. return %routes;
  579. }
  580. =head2 aliases() = HASH
  581. Returns the aliases for each post, indexed by aliases.
  582. You should override this for performance reasons, as it's just a wrapper around get() by defualt.
  583. =cut
  584. sub aliases ($self) {
  585. my @posts = $self->get( limit => 0, acls => ['admin'] );
  586. my %aliases;
  587. foreach my $post (@posts) {
  588. @aliases{ @{ $post->{aliases} } } = $post->{local_href};
  589. }
  590. return %aliases;
  591. }
  592. 1;