DataModule.pm 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. package Trog::DataModule;
  2. use strict;
  3. use warnings;
  4. no warnings 'experimental';
  5. use feature qw{signatures};
  6. =head1 QUERY FORMAT
  7. The $query_language and $query_help variables are presented to the user as to how to use the search box in the tCMS header.
  8. =head1 POST STRUCTURE
  9. Posts generally need to have the following:
  10. data: Brief description of content, or the content itself.
  11. content_type: What this content actually is. Used to filter into the appropriate pages.
  12. href: Primary link. This is the subject of a news post, or a link to the item itself. Can be local or remote.
  13. local_href: Backup link. Automatically created link to a static cache of the content.
  14. title: Title of the content. Used as link name for the 'href' attribute.
  15. user: User was banned for this post
  16. id: Internal identifier in datastore for the post.
  17. tags: array ref of appropriate tags.
  18. created: timestamp of creation of this version of the post
  19. version: revision # of this post.
  20. =head1 CONSTRUCTOR
  21. =head2 new(Config::Simple $config)
  22. Try not to do expensive things here.
  23. =cut
  24. sub new ($class, $config) {
  25. $config = $config->vars();
  26. return bless($config, $class);
  27. }
  28. #It is required that subclasses implement this
  29. sub lang ($self) { die 'stub' }
  30. sub help ($self) { die 'stub' }
  31. #If count is passed, just return the total posts
  32. sub read ($self,$count=0) { die 'stub' }
  33. sub write ($self) { die 'stub' }
  34. =head1 METHODS
  35. =head2 get(%request)
  36. Queries the data model. Should return the following:
  37. 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).
  38. version => if id is passed, return the provided post version rather than the most recent one
  39. tags => ARRAYREF of tags, any one of which is required to give a result. If none are passed, no filtering is performed.
  40. 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.
  41. page => Offset multiplier for pagination.
  42. limit => Offset for pagination.
  43. like => Search query, as might be passed in the search bar.
  44. author => filter by post author
  45. If it is more efficient to filter within your data storage engine, you probably should override this method.
  46. As implemented, this takes the data as a given and filters in post.
  47. =cut
  48. sub get ($self, %request) {
  49. my $example_posts = $self->read();
  50. $request{acls} //= [];
  51. $request{tags} //=[];
  52. my @filtered = @$example_posts;
  53. # If an ID is passed, just get that (and all it's prior versions
  54. if ($request{id}) {
  55. @filtered = grep { $_->{id} eq $request{id} } @filtered if $request{id};
  56. @filtered = _dedup_versions($request{version}, @filtered);
  57. @filtered = _add_post_type(@filtered);
  58. # Next, add the type of post this is
  59. @filtered = _add_media_type(@filtered);
  60. # Finally, add visibility
  61. @filtered = _add_visibility(@filtered);
  62. return (1, \@filtered);
  63. }
  64. @filtered = _dedup_versions(undef, @filtered);
  65. # Heal bad data
  66. @filtered = map { my $t = $_->{tags}; @$t = grep { defined $_ } @$t; $_ } @filtered;
  67. # Next, handle the query, tags and ACLs
  68. @filtered = grep { my $tags = $_->{tags}; grep { my $t = $_; grep {$t eq $_ } @{$request{tags}} } @$tags } @filtered if @{$request{tags}};
  69. @filtered = grep { my $tags = $_->{tags}; grep { my $t = $_; grep {$t eq $_ } @{$request{acls}} } @$tags } @filtered unless grep { $_ eq 'admin' } @{$request{acls}};
  70. @filtered = grep { $_->{data} =~ m/\Q$request{like}\E/i } @filtered if $request{like};
  71. @filtered = grep { $_->{user} eq $request{author} } @filtered if $request{author};
  72. # Finally, paginate
  73. my $offset = int($request{limit} // 25);
  74. $offset = @filtered < $offset ? @filtered : $offset;
  75. my $pages = int(scalar(@filtered) / ($offset || 1) );
  76. @filtered = splice(@filtered, ( int($request{page}) -1) * $offset, $offset) if $request{page} && $request{limit};
  77. # Next, go ahead and build the "post type"
  78. @filtered = _add_post_type(@filtered);
  79. # Next, add the type of post this is
  80. @filtered = _add_media_type(@filtered);
  81. # Finally, add visibility
  82. @filtered = _add_visibility(@filtered);
  83. return ($pages,\@filtered);
  84. }
  85. sub _dedup_versions ($version=-1, @posts) {
  86. if (defined $version) {
  87. my $version_max = List::Util::max(map { $_->{version } } @posts);
  88. return map {
  89. $_->{version_max} = $version_max;
  90. $_
  91. } grep { $_->{version} eq $version } @posts;
  92. }
  93. my @uniqids = List::Util::uniq(map { $_->{id} } @posts);
  94. my %posts_deduped;
  95. for my $id (@uniqids) {
  96. my @ofid = sort { $b->{version} cmp $a->{version} } grep { $_->{id} eq $id } @posts;
  97. my $version_max = List::Util::max(map { $_->{version } } @ofid);
  98. $posts_deduped{$id} = $ofid[0];
  99. $posts_deduped{$id}{version_max} = $version_max;
  100. }
  101. my @deduped = @posts_deduped{@uniqids};
  102. return @deduped;
  103. }
  104. #XXX this probably should be re-factored to be baked into the data from the get-go
  105. sub _add_post_type (@posts) {
  106. return map {
  107. my $post = $_;
  108. my $type = 'file';
  109. $type = 'blog' if grep { $_ eq 'blog' } @{$post->{tags}};
  110. $type = 'microblog' if grep { $_ eq 'news' } @{$post->{tags}};
  111. $type = 'profile' if grep { $_ eq 'about' } @{$post->{tags}};
  112. $type = 'series' if grep { $_ eq 'series' } @{$post->{tags}};
  113. $post->{type} = $type;
  114. $post
  115. } @posts;
  116. }
  117. sub _add_media_type (@posts) {
  118. return map {
  119. my $post = $_;
  120. $post->{content_type} //= '';
  121. $post->{is_video} = 1 if $post->{content_type} =~ m/^video\//;
  122. $post->{is_audio} = 1 if $post->{content_type} =~ m/^audio\//;
  123. $post->{is_image} = 1 if $post->{content_type} =~ m/^image\//;
  124. $post->{is_profile} = 1 if grep {$_ eq 'about' } @{$post->{tags}};
  125. $post
  126. } @posts;
  127. }
  128. sub _add_visibility (@posts) {
  129. return map {
  130. my $post = $_;
  131. my @visibilities = grep { my $tag = $_; grep { $_ eq $tag } qw{private unlisted public} } @{$post->{tags}};
  132. $post->{visibility} = $visibilities[0];
  133. $post
  134. } @posts;
  135. }
  136. =head2 total_posts() = INT $num
  137. Returns the total number of posts.
  138. Used to determine paginator parameters.
  139. =cut
  140. sub total_posts ($self) {
  141. my $example_posts = $self->read(1);
  142. return scalar(@$example_posts);
  143. }
  144. =head2 add(@posts) = BOOL $failed_or_not
  145. Add the provided posts to the datastore.
  146. If any post already exists with the same id, a new post with a version higher than it will be added.
  147. You probably won't want to override this.
  148. =cut
  149. sub add ($self, @posts) {
  150. require UUID::Tiny;
  151. my $example_posts = $self->read();
  152. foreach my $post (@posts) {
  153. $post->{id} //= UUID::Tiny::create_uuid_as_string(UUID::Tiny::UUID_V1, UUID::Tiny::UUID_NS_DNS);
  154. $post->{created} = time();
  155. my (undef, $existing_posts) = $self->get( id => $post->{id} );
  156. if (@$existing_posts) {
  157. my $existing_post = $existing_posts->[0];
  158. $post->{version} = $existing_post->{version};
  159. $post->{version}++;
  160. }
  161. $post->{version} //= 0;
  162. $post = _process($post);
  163. push @$example_posts, $post;
  164. }
  165. $self->write($example_posts);
  166. return 0;
  167. }
  168. #XXX this level of post-processing seems gross, but may be unavoidable
  169. # Not actually a subprocess, kek
  170. sub _process ($post) {
  171. $post->{href} = _handle_upload($post->{file}, $post->{id}) if $post->{file};
  172. $post->{preview} = _handle_upload($post->{preview_file}, $post->{id}) if $post->{preview_file};
  173. $post->{wallpaper} = _handle_upload($post->{wallpaper_file}, $post->{id}) if $post->{wallpaper_file};
  174. $post->{preview} = $post->{href} if $post->{app} eq 'image';
  175. delete $post->{app};
  176. delete $post->{file};
  177. delete $post->{preview_file};
  178. delete $post->{route};
  179. delete $post->{domain};
  180. # Handle acls/tags
  181. $post->{tags} //= [];
  182. @{$post->{tags}} = grep { my $subj = $_; !grep { $_ eq $subj} qw{public private unlisted} } @{$post->{tags}};
  183. push(@{$post->{tags}}, delete $post->{acls}) if $post->{visibility} eq 'private';
  184. push(@{$post->{tags}}, delete $post->{visibility});
  185. #Filter adding the same acl twice
  186. @{$post->{tags}} = List::Util::uniq(@{$post->{tags}});
  187. # Handle multimedia content types
  188. if ($post->{href}) {
  189. my $mf = Mojo::File->new("www/$post->{href}");
  190. my $ext = '.'.$mf->extname();
  191. $post->{content_type} = Plack::MIME->mime_type($ext) if $ext;
  192. }
  193. if ($post->{video_href}) {
  194. my $mf = Mojo::File->new("www/$post->{video_href}");
  195. my $ext = '.'.$mf->extname();
  196. $post->{video_content_type} = Plack::MIME->mime_type($ext) if $ext;
  197. }
  198. if ($post->{audio_href}) {
  199. my $mf = Mojo::File->new("www/$post->{audio_href}");
  200. my $ext = '.'.$mf->extname();
  201. $post->{audio_content_type} = Plack::MIME->mime_type($ext) if $ext;
  202. }
  203. return $post;
  204. }
  205. sub _handle_upload ($file, $uuid) {
  206. my $f = $file->{tempname};
  207. my $newname = "$uuid.$file->{filename}";
  208. File::Copy::move($f, "www/assets/$newname");
  209. return "/assets/$newname";
  210. }
  211. =head2 delete(@posts)
  212. Delete the following posts.
  213. Will remove all versions of said post.
  214. You should override this, it is a stub here.
  215. =cut
  216. sub delete ($self) { die 'stub' }
  217. 1;