DataModule.pm 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. package Trog::DataModule;
  2. use strict;
  3. use warnings;
  4. use UUID::Tiny;
  5. use List::Util;
  6. use File::Copy;
  7. use Mojo::File;
  8. use Plack::MIME;
  9. no warnings 'experimental';
  10. use feature qw{signatures};
  11. =head1 QUERY FORMAT
  12. The $query_language and $query_help variables are presented to the user as to how to use the search box in the tCMS header.
  13. =head1 POST STRUCTURE
  14. Posts generally need to have the following:
  15. data: Brief description of content, or the content itself.
  16. content_type: What this content actually is. Used to filter into the appropriate pages.
  17. href: Primary link. This is the subject of a news post, or a link to the item itself. Can be local or remote.
  18. local_href: Backup link. Automatically created link to a static cache of the content.
  19. title: Title of the content. Used as link name for the 'href' attribute.
  20. user: User was banned for this post
  21. id: Internal identifier in datastore for the post.
  22. tags: array ref of appropriate tags.
  23. created: timestamp of creation of this version of the post
  24. version: revision # of this post.
  25. =head1 CONSTRUCTOR
  26. =head2 new(Config::Simple $config)
  27. Try not to do expensive things here.
  28. =cut
  29. sub new ($class, $config) {
  30. $config = $config->vars();
  31. return bless($config, $class);
  32. }
  33. #It is required that subclasses implement this
  34. sub lang ($self) { ... }
  35. sub help ($self) { ... }
  36. sub read ($self,$query={}) { ... }
  37. sub write ($self) { ... }
  38. sub count ($self) { ... }
  39. =head1 METHODS
  40. =head2 get(%request)
  41. Queries the data model. Should return the following:
  42. 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).
  43. version => if id is passed, return the provided post version rather than the most recent one
  44. tags => ARRAYREF of tags, any one of which is required to give a result. If none are passed, no filtering is performed.
  45. 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.
  46. page => Offset multiplier for pagination.
  47. limit => Offset for pagination.
  48. like => Search query, as might be passed in the search bar.
  49. author => filter by post author
  50. If it is more efficient to filter within your data storage engine, you probably should override this method.
  51. As implemented, this takes the data as a given and filters in post.
  52. =cut
  53. sub get ($self, %request) {
  54. my $posts = $self->read(\%request);
  55. return @$posts if $request{raw};
  56. my @filtered = $self->filter(\%request, @$posts);
  57. @filtered = $self->_fixup(@filtered);
  58. @filtered = $self->paginate(\%request,@filtered);
  59. return @filtered;
  60. }
  61. sub _fixup ($self, @filtered) {
  62. @filtered = _add_media_type(@filtered);
  63. # urlencode spaces in filenames
  64. @filtered = map {
  65. my $subj = $_;
  66. foreach my $param (qw{href preview video_href audio_href local_href wallpaper}) {
  67. next unless exists $subj->{$param};
  68. $subj->{$param} =~ s/ /%20/g;
  69. }
  70. #XXX Add dynamic routing data for posts which don't have them (/posts/$id) and (/users/$user)
  71. my $is_user_page = grep { $_ eq 'about' } @{$subj->{tags}};
  72. if (!exists $subj->{local_href}) {
  73. $subj->{local_href} = "/posts/$subj->{id}";
  74. $subj->{local_href} = "/users/$subj->{user}" if $is_user_page;
  75. }
  76. if (!exists $subj->{callback}) {
  77. $subj->{callback} = "Trog::Routes::HTML::posts";
  78. $subj->{callback} = "Trog::Routes::HTML::users" if $is_user_page;
  79. }
  80. $subj->{method} = 'GET' unless exists($subj->{method});
  81. $subj
  82. } @filtered;
  83. return @filtered;
  84. }
  85. sub filter ($self, $query, @filtered) {
  86. $query->{acls} //= [];
  87. $query->{tags} //=[];
  88. $query->{exclude_tags} //= [];
  89. # If an ID is passed, just get that (and all it's prior versions)
  90. if ($query->{id}) {
  91. @filtered = grep { $_->{id} eq $query->{id} } @filtered;
  92. @filtered = _dedup_versions($query->{version}, @filtered);
  93. return @filtered;
  94. }
  95. # XXX aclname and id are essentially serving the same purpose, should unify
  96. if ($query->{aclname}) {
  97. @filtered = grep { ($_->{aclname} || '') eq $query->{aclname} } @filtered;
  98. @filtered = _dedup_versions($query->{version}, @filtered);
  99. return @filtered;
  100. }
  101. @filtered = _dedup_versions(undef, @filtered);
  102. #Filter out posts which are too old
  103. #Coerce older into numeric
  104. $query->{older} =~ s/[^0-9]//g if $query->{older};
  105. @filtered = grep { $_->{created} < $query->{older} } @filtered if $query->{older};
  106. # Filter posts not matching the passed tag(s), if any
  107. @filtered = grep {
  108. my $tags = $_->{tags};
  109. grep { my $t = $_; grep { $t eq $_ } @{$query->{tags}} } @$tags
  110. } @filtered if @{$query->{tags}};
  111. # Filter posts *matching* the passed exclude_tag(s), if any
  112. @filtered = grep {
  113. my $tags = $_->{tags};
  114. !grep { my $t = $_; grep { $t eq $_ } @{$query->{exclude_tags}} } @$tags
  115. } @filtered if @{$query->{exclude_tags}};
  116. # Filter posts without the proper ACLs
  117. @filtered = grep {
  118. my $tags = $_->{tags};
  119. grep { my $t = $_; grep { $t eq $_ } @{$query->{acls}} } @$tags
  120. } @filtered unless grep { $_ eq 'admin' } @{$query->{acls}};
  121. @filtered = grep { $_->{title} =~ m/\Q$query->{like}\E/i || $_->{data} =~ m/\Q$query->{like}\E/i } @filtered if $query->{like};
  122. @filtered = grep { $_->{user} eq $query->{author} } @filtered if $query->{author};
  123. return @filtered;
  124. }
  125. sub paginate ($self, $query, @filtered) {
  126. my $offset = int($query->{limit} // 25);
  127. $offset = @filtered < $offset ? @filtered : $offset;
  128. @filtered = splice(@filtered, ( int($query->{page}) -1) * $offset, $offset) if $query->{page} && $query->{limit};
  129. return @filtered;
  130. }
  131. sub _dedup_versions ($version=-1, @posts) {
  132. #ASSUMPTION made here - if we pass version this is direct ID query
  133. if (defined $version) {
  134. my $version_max = List::Util::max(map { $_->{version} } @posts);
  135. return map {
  136. $_->{version_max} //= $version_max;
  137. $_
  138. } grep { $_->{version} eq $version } @posts;
  139. }
  140. my @uniqids = List::Util::uniq(map { $_->{id} } @posts);
  141. my %posts_deduped;
  142. for my $id (@uniqids) {
  143. my @ofid = sort { $b->{version} <=> $a->{version} } grep { $_->{id} eq $id } @posts;
  144. my $version_max = List::Util::max(map { $_->{version } } @ofid);
  145. $posts_deduped{$id} = $ofid[0];
  146. $posts_deduped{$id}{version_max} = $version_max;
  147. # Show orig creation date, and original author.
  148. # XXX this doesn't show the mtime correctly for whatever reason, so I'm omitting it from the interface
  149. $posts_deduped{$id}{modified} = $ofid[0]{created};
  150. $posts_deduped{$id}{created} = $ofid[-1]{created};
  151. $posts_deduped{$id}{author} = $ofid[-1]{author};
  152. }
  153. my @deduped = @posts_deduped{@uniqids};
  154. return @deduped;
  155. }
  156. sub _add_media_type (@posts) {
  157. return map {
  158. my $post = $_;
  159. $post->{content_type} //= '';
  160. $post->{is_video} = 1 if $post->{content_type} =~ m/^video\//;
  161. $post->{is_audio} = 1 if $post->{content_type} =~ m/^audio\//;
  162. $post->{is_image} = 1 if $post->{content_type} =~ m/^image\//;
  163. $post->{is_profile} = 1 if grep {$_ eq 'about' } @{$post->{tags}};
  164. $post
  165. } @posts;
  166. }
  167. =head2 count() = INT $num
  168. Returns the total number of posts.
  169. Used to determine paginator parameters.
  170. =cut
  171. =head2 add(@posts) = BOOL $failed_or_not
  172. Add the provided posts to the datastore.
  173. If any post already exists with the same id, a new post with a version higher than it will be added.
  174. Passes an array of new posts to add to the data store module's write() function.
  175. You probably won't want to override this.
  176. =cut
  177. sub add ($self, @posts) {
  178. my @to_write;
  179. foreach my $post (@posts) {
  180. $post->{id} //= UUID::Tiny::create_uuid_as_string(UUID::Tiny::UUID_V1, UUID::Tiny::UUID_NS_DNS);
  181. $post->{aliases} //= [];
  182. $post->{aliases} = [$post->{aliases}] unless ref $post->{aliases} eq 'ARRAY';
  183. if ($post->{aclname}) {
  184. # Then this is a series
  185. $post->{local_href} //= "/$post->{aclname}";
  186. push(@{$post->{aliases}}, "/posts/$post->{id}", "/series/$post->{id}" );
  187. }
  188. # If this is a user creation post, add in the /user/ route
  189. if ($post->{callback} eq 'Trog::Routes::HTML::users') {
  190. $post->{local_href} = "/users/$post->{user}";
  191. }
  192. $post->{local_href} //= "/posts/$post->{id}";
  193. $post->{method} //= 'GET';
  194. $post->{callback} //= 'Trog::Routes::HTML::posts';
  195. $post->{created} = time();
  196. my @existing_posts = $self->get( id => $post->{id} );
  197. if (@existing_posts) {
  198. my $existing_post = $existing_posts[0];
  199. $post->{version} = $existing_post->{version};
  200. $post->{version}++;
  201. }
  202. $post->{version} //= 0;
  203. $post = _process($post);
  204. push @to_write, $post;
  205. }
  206. $self->write(\@to_write);
  207. #hup the parent to refresh the routing table IFF we aren't in an interactive session, such as migrate.pl
  208. if (!$ENV{NOHUP}) {
  209. my $parent = getppid;
  210. kill 'HUP', $parent;
  211. }
  212. return 0;
  213. }
  214. #XXX this level of post-processing seems gross, but may be unavoidable
  215. # Not actually a subprocess, kek
  216. sub _process ($post) {
  217. $post->{href} = _handle_upload($post->{file}, $post->{id}) if $post->{file};
  218. $post->{preview} = _handle_upload($post->{preview_file}, $post->{id}) if $post->{preview_file};
  219. $post->{wallpaper} = _handle_upload($post->{wallpaper_file}, $post->{id}) if $post->{wallpaper_file};
  220. $post->{preview} = $post->{href} if $post->{app} && $post->{app} eq 'image';
  221. delete $post->{app};
  222. delete $post->{file};
  223. delete $post->{preview_file};
  224. delete $post->{wallpaper_file};
  225. delete $post->{scheme};
  226. delete $post->{route};
  227. delete $post->{domain};
  228. # Handle acls/tags
  229. $post->{tags} //= [];
  230. $post->{acls} //= [];
  231. @{$post->{tags}} = grep { my $subj = $_; !grep { $_ eq $subj} qw{public private unlisted} } @{$post->{tags}};
  232. push(@{$post->{tags}}, @{$post->{acls}}) if $post->{visibility} eq 'private';
  233. delete $post->{acls};
  234. push(@{$post->{tags}}, $post->{visibility});
  235. # Add the 'series' tag if we are in a series, restrict to relevant acl
  236. if ($post->{series}) {
  237. push(@{$post->{tags}}, 'series');
  238. push(@{$post->{tags}}, $post->{series});
  239. }
  240. #Filter adding the same acl twice
  241. @{$post->{tags}} = List::Util::uniq(@{$post->{tags}});
  242. # Handle multimedia content types
  243. if ($post->{href}) {
  244. my $mf = Mojo::File->new("www/$post->{href}");
  245. my $ext = '.'.$mf->extname();
  246. $post->{content_type} = Plack::MIME->mime_type($ext) if $ext;
  247. }
  248. if ($post->{video_href}) {
  249. my $mf = Mojo::File->new("www/$post->{video_href}");
  250. my $ext = '.'.$mf->extname();
  251. $post->{video_content_type} = Plack::MIME->mime_type($ext) if $ext;
  252. }
  253. if ($post->{audio_href}) {
  254. my $mf = Mojo::File->new("www/$post->{audio_href}");
  255. my $ext = '.'.$mf->extname();
  256. $post->{audio_content_type} = Plack::MIME->mime_type($ext) if $ext;
  257. }
  258. $post->{content_type} ||= 'text/html';
  259. return $post;
  260. }
  261. sub _handle_upload ($file, $uuid) {
  262. my $f = $file->{tempname};
  263. my $newname = "$uuid.$file->{filename}";
  264. File::Copy::move($f, "www/assets/$newname");
  265. return "/assets/$newname";
  266. }
  267. =head2 delete(@posts)
  268. Delete the following posts.
  269. Will remove all versions of said post.
  270. You should override this, it is a stub here.
  271. =cut
  272. sub delete ($self) { die 'stub' }
  273. =head2 routes() = HASH
  274. Returns the routes to each post.
  275. You should override this for performance reasons, as it's just a wrapper around get() by defualt.
  276. =cut
  277. sub routes($self) {
  278. my %routes = map { $_->{local_href} => { method => $_->{method}, callback => \&{$_->{callback}} } } ($self->get( limit => 0, acls => ['admin'] ));
  279. return %routes;
  280. }
  281. =head2 aliases() = HASH
  282. Returns the aliases for each post, indexed by aliases.
  283. You should override this for performance reasons, as it's just a wrapper around get() by defualt.
  284. =cut
  285. sub aliases($self) {
  286. my @posts = $self->get( limit => 0, acls => ['admin'] );
  287. my %aliases;
  288. foreach my $post (@posts) {
  289. @aliases{@{$post->{aliases}}} = $post->{local_href};
  290. }
  291. return %aliases;
  292. }
  293. 1;