DataModule.pm 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. package Trog::DataModule;
  2. use strict;
  3. use warnings;
  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 Trog::Log qw{:all};
  11. use Trog::Utils;
  12. use Trog::Auth();
  13. no warnings 'experimental';
  14. use feature qw{signatures};
  15. =head1 QUERY FORMAT
  16. The $query_language and $query_help variables are presented to the user as to how to use the search box in the tCMS header.
  17. =head1 POST STRUCTURE
  18. Posts generally need to have the following:
  19. data: Brief description of content, or the content itself.
  20. content_type: What this content actually is. Used to filter into the appropriate pages.
  21. href: Primary link. This is the subject of a news post, or a link to the item itself. Can be local or remote.
  22. local_href: Backup link. Automatically created link to a static cache of the content.
  23. title: Title of the content. Used as link name for the 'href' attribute.
  24. user: User was banned for this post
  25. id: Internal identifier in datastore for the post.
  26. tags: array ref of appropriate tags.
  27. created: timestamp of creation of this version of the post
  28. version: revision # of this post.
  29. =head1 CONSTRUCTOR
  30. =head2 new(Config::Simple $config)
  31. Try not to do expensive things here.
  32. =cut
  33. sub new ( $class, $config ) {
  34. $config = $config->vars();
  35. return bless( $config, $class );
  36. }
  37. #It is required that subclasses implement this
  38. sub lang ($self) { ... }
  39. sub help ($self) { ... }
  40. sub read ( $self, $query = {} ) { ... }
  41. sub write ($self) { ... }
  42. sub count ($self) { ... }
  43. sub tags ($self) { ... }
  44. =head1 METHODS
  45. =head2 get(%request)
  46. Queries the data model. Should return the following:
  47. 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).
  48. version => if id is passed, return the provided post version rather than the most recent one
  49. tags => ARRAYREF of tags, any one of which is required to give a result. If none are passed, no filtering is performed.
  50. 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.
  51. page => Offset multiplier for pagination.
  52. limit => Offset for pagination.
  53. like => Search query, as might be passed in the search bar.
  54. author => filter by post author
  55. If it is more efficient to filter within your data storage engine, you probably should override this method.
  56. As implemented, this takes the data as a given and filters in post.
  57. =cut
  58. sub get ( $self, %request ) {
  59. my $posts = $self->read( \%request );
  60. return @$posts if $request{raw};
  61. my @filtered = $self->filter( \%request, @$posts );
  62. @filtered = $self->_fixup(@filtered);
  63. @filtered = $self->paginate( \%request, @filtered );
  64. return @filtered;
  65. }
  66. sub _fixup ( $self, @filtered ) {
  67. my %user2display;
  68. # urlencode spaces in filenames
  69. @filtered = map {
  70. my $subj = $_;
  71. foreach my $param (qw{href preview video_href audio_href local_href wallpaper}) {
  72. next unless exists $subj->{$param};
  73. #XXX I don't remember what this fixes, but it also breaks things. URI::Escape usage instead is indicated.
  74. $subj->{$param} =~ s/ /%20/g;
  75. }
  76. $user2display{ $subj->{user} } //= Trog::Auth::username2display( $subj->{user} );
  77. $subj->{display_name} = $user2display{ $subj->{user} };
  78. #XXX Add dynamic routing data for posts which don't have them (/posts/$id) and (/users/$user)
  79. my $is_user_page = List::Util::any { $_ eq 'about' } @{ $subj->{tags} };
  80. if ( !exists $subj->{local_href} ) {
  81. $subj->{local_href} = "/posts/$subj->{id}";
  82. #XXX this needs to be correctly populated in the form?
  83. if ($is_user_page) {
  84. my $display_name = $user2display{ $subj->{user} };
  85. die "No display name for user!" unless $display_name;
  86. $subj->{local_href} = "/users/$display_name";
  87. }
  88. }
  89. if ( !exists $subj->{callback} ) {
  90. $subj->{callback} = "Trog::Routes::HTML::posts";
  91. $subj->{callback} = "Trog::Routes::HTML::users" if $is_user_page;
  92. }
  93. $subj->{method} = 'GET' unless exists( $subj->{method} );
  94. $subj->{user_class} = Trog::Auth::username2classname( $subj->{user} );
  95. $subj
  96. } @filtered;
  97. return @filtered;
  98. }
  99. sub _filter_param ( $query, $param, @filtered ) {
  100. @filtered = grep { ( $_->{$param} || '' ) eq $query->{$param} } @filtered;
  101. @filtered = _dedup_versions( $query->{version}, @filtered );
  102. return @filtered;
  103. }
  104. sub filter ( $self, $query, @filtered ) {
  105. $query->{acls} //= [];
  106. $query->{tags} //= [];
  107. $query->{exclude_tags} //= [];
  108. # If an ID or title or acl is passed, just get that (and all it's prior versions)
  109. foreach my $key (qw{id title aclname}) {
  110. next unless $query->{$key};
  111. return _filter_param( $query, $key, @filtered );
  112. }
  113. @filtered = _dedup_versions( undef, @filtered );
  114. #Filter out posts which are too old
  115. #Coerce older into numeric
  116. if ( $query->{older} ) {
  117. $query->{older} =~ s/[^0-9]//g;
  118. @filtered = grep { $_->{created} < $query->{older} } @filtered;
  119. }
  120. if ( $query->{newer} ) {
  121. $query->{newer} =~ s/[^0-9]//g;
  122. @filtered = grep { $_->{created} > $query->{newer} } @filtered;
  123. }
  124. # Filter posts not matching the passed tag(s), if any
  125. @filtered = grep {
  126. my $tags = $_->{tags};
  127. grep {
  128. my $t = $_;
  129. grep { $t eq $_ } @{ $query->{tags} }
  130. } @$tags
  131. } @filtered if @{ $query->{tags} };
  132. # Filter posts *matching* the passed exclude_tag(s), if any
  133. @filtered = grep {
  134. my $tags = $_->{tags};
  135. !grep {
  136. my $t = $_;
  137. grep { $t eq $_ } @{ $query->{exclude_tags} }
  138. } @$tags
  139. } @filtered if @{ $query->{exclude_tags} };
  140. # Filter posts without the proper ACLs
  141. @filtered = grep {
  142. my $tags = $_->{tags};
  143. grep {
  144. my $t = $_;
  145. grep { $t eq $_ } @{ $query->{acls} }
  146. } @$tags
  147. } @filtered unless grep { $_ eq 'admin' } @{ $query->{acls} };
  148. @filtered = grep { ($_->{form} || '') eq $query->{form} } @filtered if $query->{form};
  149. @filtered = grep { $_->{title} =~ m/\Q$query->{like}\E/i || $_->{data} =~ m/\Q$query->{like}\E/i } @filtered if $query->{like};
  150. @filtered = grep { $_->{user} eq $query->{author} } @filtered if $query->{author};
  151. return @filtered;
  152. }
  153. sub paginate ( $self, $query, @filtered ) {
  154. my $offset = int( $query->{limit} // 25 );
  155. $offset = @filtered < $offset ? @filtered : $offset;
  156. @filtered = splice( @filtered, ( int( $query->{page} ) - 1 ) * $offset, $offset ) if $query->{page} && $query->{limit};
  157. return @filtered;
  158. }
  159. sub _dedup_versions ( $version = -1, @posts ) {
  160. #ASSUMPTION made here - if we pass version this is direct ID query
  161. if ( defined $version ) {
  162. my $version_max = List::Util::max( map { $_->{version} } @posts );
  163. return map {
  164. $_->{version_max} //= $version_max;
  165. $_
  166. } grep { $_->{version} eq $version } @posts;
  167. }
  168. my @uniqids = List::Util::uniq( map { $_->{id} } @posts );
  169. my %posts_deduped;
  170. for my $id (@uniqids) {
  171. my @ofid = sort { $b->{version} <=> $a->{version} } grep { $_->{id} eq $id } @posts;
  172. my $version_max = List::Util::max( map { $_->{version} } @ofid );
  173. $posts_deduped{$id} = $ofid[0];
  174. $posts_deduped{$id}{version_max} = $version_max;
  175. # Show orig creation date, and original author.
  176. # XXX this doesn't show the mtime correctly for whatever reason, so I'm omitting it from the interface
  177. $posts_deduped{$id}{modified} = $ofid[0]{created};
  178. $posts_deduped{$id}{created} = $ofid[-1]{created};
  179. $posts_deduped{$id}{author} = $ofid[-1]{author};
  180. }
  181. my @deduped = @posts_deduped{@uniqids};
  182. return @deduped;
  183. }
  184. =head2 count() = INT $num
  185. Returns the total number of posts.
  186. Used to determine paginator parameters.
  187. =cut
  188. =head2 add(@posts) = BOOL $failed_or_not
  189. Add the provided posts to the datastore.
  190. If any post already exists with the same id, a new post with a version higher than it will be added.
  191. Passes an array of new posts to add to the data store module's write() function.
  192. These will have their parameters filtered to those present in the %schema hash.
  193. You probably won't want to override this.
  194. =cut
  195. my $not_ref = sub {
  196. return !Ref::Util::is_ref(shift);
  197. };
  198. my $valid_cb = sub {
  199. my $subname = shift;
  200. my ($modname) = $subname =~ m/^([\w|:]+)::\w+$/;
  201. # Modules always return 0 if they succeed!
  202. eval { require $modname; } and do {
  203. WARN("Post uses a callback whos module ($modname) cannot be found!");
  204. return 0;
  205. };
  206. no strict 'refs';
  207. my $ref = eval '\&' . $subname;
  208. use strict;
  209. return Ref::Util::is_coderef($ref);
  210. };
  211. my $hashref_or_string = sub {
  212. my $subj = shift;
  213. return Ref::Util::is_hashref($subj) || $not_ref->($subj);
  214. };
  215. my $arrayref_or_string = sub {
  216. my $subj = shift;
  217. return Ref::Util::is_arrayref($subj) || $not_ref->($subj);
  218. };
  219. # TODO more strict validation of strings?
  220. our %schema = (
  221. ## Parameters which must be in every single post
  222. 'title' => $not_ref,
  223. 'callback' => $valid_cb,
  224. 'tags' => \&Ref::Util::is_arrayref,
  225. 'version' => $not_ref,
  226. 'visibility' => $not_ref,
  227. 'aliases' => \&Ref::Util::is_arrayref,
  228. # title links here
  229. 'href' => $not_ref,
  230. # Link to post locally
  231. 'local_href' => $not_ref,
  232. # Post body
  233. 'data' => $arrayref_or_string,
  234. # How do I edit this post?
  235. 'form' => $not_ref,
  236. # Post is restricted to visibility to these ACLs if not public/unlisted
  237. 'acls' => \&Ref::Util::is_arrayref,
  238. 'id' => $not_ref,
  239. # Author of the post
  240. 'user' => $not_ref,
  241. 'created' => $not_ref,
  242. # Specific to various posts below.
  243. ## Series specific parameters
  244. 'child_form' => $not_ref,
  245. 'aclname' => $not_ref,
  246. 'tiled' => $not_ref,
  247. ## User specific parameters
  248. 'user_acls' => \&Ref::Util::is_arrayref,
  249. 'username' => $not_ref,
  250. 'display_name' => $not_ref,
  251. 'contact_email' => $not_ref,
  252. 'wallpaper_file' => $hashref_or_string,
  253. 'wallpaper' => $not_ref,
  254. ## user avatar, but does double duty in content posts as preview images on videos, etc
  255. 'preview_file' => $hashref_or_string,
  256. 'preview' => $not_ref,
  257. ## Content specific parameters
  258. 'audio_href' => $not_ref,
  259. 'video_href' => $not_ref,
  260. 'file' => $hashref_or_string,
  261. 'attachments' => \&Ref::Util::is_arrayref,
  262. 'header' => $not_ref,
  263. 'footer' => $not_ref,
  264. ## Entity details, which are used for constructing invoices
  265. 'payment_details' => $not_ref,
  266. 'payment_method' => $not_ref,
  267. ## Invoice specific
  268. 'payee' => $not_ref,
  269. 'payor' => $not_ref,
  270. 'due_days' => $not_ref,
  271. );
  272. sub add ( $self, @posts ) {
  273. my @to_write;
  274. foreach my $post (@posts) {
  275. # Filter all the irrelevant data
  276. foreach my $key ( keys(%$post) ) {
  277. # We need to have the key in the schema, and it validate.
  278. delete $post->{$key} unless List::Util::any { ( $_ eq $key ) && ( $schema{$key}->( $post->{$key} ) ) } keys(%schema);
  279. }
  280. $post->{id} //= Trog::Utils::uuid();
  281. $post->{aliases} //= [];
  282. $post->{aliases} = [ $post->{aliases} ] unless ref $post->{aliases} eq 'ARRAY';
  283. if ( $post->{aclname} ) {
  284. # Then this is a series
  285. $post->{local_href} //= "/$post->{aclname}";
  286. push( @{ $post->{aliases} }, "/posts/$post->{id}", "/series/$post->{id}" );
  287. }
  288. $post->{callback} //= 'Trog::Routes::HTML::posts';
  289. # If this is a user creation post, add in the /user/ route
  290. if ( $post->{callback} eq 'Trog::Routes::HTML::users' ) {
  291. $post->{local_href} //= "/users/$post->{display_name}";
  292. $post->{title} //= $post->{display_name};
  293. }
  294. $post->{local_href} //= "/posts/$post->{id}";
  295. $post->{method} //= 'GET';
  296. $post->{created} = time();
  297. my @existing_posts = $self->get( id => $post->{id} );
  298. if (@existing_posts) {
  299. my $existing_post = $existing_posts[0];
  300. $post->{version} = $existing_post->{version};
  301. $post->{version}++;
  302. }
  303. $post->{version} //= 0;
  304. $post = _process($post);
  305. push @to_write, $post;
  306. }
  307. $self->write( \@to_write );
  308. return 0;
  309. }
  310. #XXX this level of post-processing seems gross, but may be unavoidable
  311. # Not actually a subprocess, kek
  312. sub _process ($post) {
  313. # If the post is private, make sure it's associated assets are too.
  314. my $is_private_post = $post->{visibility} eq 'private';
  315. $post->{href} = _handle_upload( $post->{file}, $post->{id}, $is_private_post ) if $post->{file};
  316. $post->{preview} = _handle_upload( $post->{preview_file}, $post->{id}, $is_private_post ) if $post->{preview_file};
  317. $post->{wallpaper} = _handle_upload( $post->{wallpaper_file}, $post->{id}, $is_private_post ) if $post->{wallpaper_file};
  318. @{ $post->{attachments} } = map { _handle_upload( $_, $post->{id}, $is_private_post ) } @{ $post->{attachments} } if $post->{attachments};
  319. $post->{preview} = $post->{href} if $post->{app} && $post->{app} eq 'image';
  320. delete $post->{app};
  321. delete $post->{file};
  322. delete $post->{preview_file};
  323. delete $post->{wallpaper_file};
  324. delete $post->{scheme};
  325. delete $post->{route};
  326. delete $post->{domain};
  327. # Handle acls/tags
  328. $post->{tags} //= [];
  329. $post->{acls} //= [];
  330. @{ $post->{tags} } = grep {
  331. my $subj = $_;
  332. !grep { $_ eq $subj } qw{public private unlisted}
  333. } @{ $post->{tags} };
  334. push( @{ $post->{tags} }, @{ $post->{acls} } ) if $is_private_post;
  335. delete $post->{acls};
  336. push( @{ $post->{tags} }, $post->{visibility} );
  337. # Add the 'series' tag if we are in a series, restrict to relevant acl
  338. if ( $post->{series} ) {
  339. push( @{ $post->{tags} }, 'series' );
  340. push( @{ $post->{tags} }, $post->{series} );
  341. }
  342. #Filter adding the same acl twice
  343. @{ $post->{tags} } = List::Util::uniq( @{ $post->{tags} } );
  344. @{ $post->{aliases} } = List::Util::uniq( @{ $post->{aliases} } );
  345. # Handle multimedia content types
  346. $post->{content_type} = Trog::Utils::mime_type("www/$post->{href}") if $post->{href};
  347. $post->{video_content_type} = Trog::Utils::mime_type("www/$post->{video_href}") if $post->{video_href};
  348. $post->{audio_content_type} = Trog::Utils::mime_type("www/$post->{audio_href}") if $post->{audio_href};
  349. $post->{content_type} ||= 'text/html';
  350. $post->{is_video} = 1 if $post->{content_type} =~ m/^video\//;
  351. $post->{is_audio} = 1 if $post->{content_type} =~ m/^audio\//;
  352. $post->{is_image} = 1 if $post->{content_type} =~ m/^image\//;
  353. $post->{is_profile} = 1 if grep { $_ eq 'about' } @{ $post->{tags} };
  354. return $post;
  355. }
  356. # Browsers try and save time in the event that the same file is sent twice, so handle that.
  357. my %seen_files;
  358. sub _handle_upload ( $file, $uuid, $private=0 ) {
  359. my $fname;
  360. if ( ref $file ne 'HASH' ) {
  361. use Data::Dumper;
  362. $file =~ s/\\/\//g;
  363. $fname = basename($file);
  364. FATAL("Did not expect bogus file before its real counterpart!") unless $seen_files{$fname};
  365. return $seen_files{$fname};
  366. }
  367. my $f = $file->{tempname};
  368. $fname = basename( $file->{filename} );
  369. my $newname = "$uuid.$file->{filename}";
  370. $newname = "private/$newname" if $private;
  371. File::Copy::move( $f, "www/assets/$newname" );
  372. # Regrettably, we have no control over the perms of the temp file that starman creates for uploads.
  373. chmod(0755, "www/assets/$newname");
  374. $seen_files{$fname} = "/assets/$newname";
  375. return $seen_files{$fname};
  376. }
  377. =head2 delete(@posts)
  378. Delete the following posts.
  379. Will remove all versions of said post.
  380. You should override this, it is a stub here.
  381. =cut
  382. sub delete ($self) { die 'stub' }
  383. =head2 routes() = HASH
  384. Returns the routes to each post.
  385. You should override this for performance reasons, as it's just a wrapper around get() by defualt.
  386. =cut
  387. sub routes ($self) {
  388. my %routes = map { $_->{local_href} => { method => $_->{method}, callback => \&{ $_->{callback} } } } ( $self->get( limit => 0, acls => ['admin'] ) );
  389. return %routes;
  390. }
  391. =head2 aliases() = HASH
  392. Returns the aliases for each post, indexed by aliases.
  393. You should override this for performance reasons, as it's just a wrapper around get() by defualt.
  394. =cut
  395. sub aliases ($self) {
  396. my @posts = $self->get( limit => 0, acls => ['admin'] );
  397. my %aliases;
  398. foreach my $post (@posts) {
  399. @aliases{ @{ $post->{aliases} } } = $post->{local_href};
  400. }
  401. return %aliases;
  402. }
  403. 1;