# Copyright 2001-2004 Six Apart. This code cannot be redistributed without # permission from www.movabletype.org. # # $Id: XMLRPCServer.pm,v 1.31 2004/09/15 02:09:42 ezra Exp $ # # AOG changes: # Added GetPostRange # Added _renderPost # Modified getPost and getRecentPosts to use _renderPost # Modified editPost not set the post content if not specified # Added _renderComment, getComment, getCommentRange # Updates for compatibility with MovableType 3.15 package MT::XMLRPCServer::Util; use Time::Local qw( timegm ); use MT::Util qw( offset_time_list ); sub mt_new { my $cfg = $ENV{MOD_PERL} ? Apache->request->dir_config('MTConfig') : $MT::XMLRPCServer::MT_DIR . '/mt.cfg'; my $mt = MT->new( Config => $cfg ) or die MT::XMLRPCServer::_fault(MT->errstr); $mt; } # TBD: Refactor me! sub iso2ts { my($blog, $iso) = @_; die MT::XMLRPCServer::_fault("Invalid timestamp format") unless $iso =~ /^(\d{4})(?:-?(\d{2})(?:-?(\d\d?)(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})?)?)?)?/; my($y, $mo, $d, $h, $m, $s, $offset) = ($1, $2 || 1, $3 || 1, $4 || 0, $5 || 0, $6 || 0, $7); if ($offset && !MT::ConfigMgr->instance->IgnoreISOTimezones) { $mo--; $y -= 1900; my $time = timegm($s, $m, $h, $d, $mo, $y); ## If it's not already in UTC, first convert to UTC. if ($offset ne 'Z') { my($sign, $h, $m) = $offset =~ /([+-])(\d{2}):(\d{2})/; $offset = $h * 3600 + $m * 60; $offset *= -1 if $sign eq '-'; $time -= $offset; } ## Now apply the offset for this weblog. ($s, $m, $h, $d, $mo, $y) = offset_time_list($time, $blog); $mo++; $y += 1900; } sprintf "%04d%02d%02d%02d%02d%02d", $y, $mo, $d, $h, $m, $s; } sub ts2iso { my ($blog, $ts) = @_; ($yr, $mo, $dy, $hr, $mn, $sc) = unpack('A4A2A2A2A2A2A2', $ts); $ts = timegm($sc, $mn, $hr, $dy, $mo, $yr); ($sc, $mn, $hr, $dy, $mo, $yr) = offset_time_list($ts, $blog, '-'); $yr += 1900; sprintf("%04d-%02d-%02d %02d:%02d:%02d", $yr, $mo, $dy, $hr, $mn, $sc); } package MT::XMLRPCServer; use strict; use MT; use MT::Util qw( first_n_words decode_html start_background_task); use MT::ErrorHandler; BEGIN { @MT::XMLRPCServer::ISA = qw( MT::ErrorHandler ) } use vars qw( $MT_DIR ); my($HAVE_XML_PARSER); BEGIN { eval { require XML::Parser }; $HAVE_XML_PARSER = $@ ? 0 : 1; } sub _fault { SOAP::Fault->faultcode(1)->faultstring($_[0]); } ## This is sort of a hack. XML::Parser automatically makes everything ## UTF-8, and that is causing severe problems with the serialization ## of database records (what happens is this: we construct a string ## consisting of pack('N', length($string)) . $string. If the $string SV ## is flagged as UTF-8, the packed length is then upgraded to UTF-8, ## which turns characters with values greater than 128 into two bytes, ## like v194.129. And so on. This is obviously now what we want, because ## pack produces a series of bytes, not a string that should be mucked ## about with.) ## ## The following subroutine strips the UTF8 flag from a string, thus ## forcing it into a series of bytes. "pack 'C0'" is a magic way of ## forcing the following string to be packed as bytes, not as UTF8. sub no_utf8 { for (@_) { next if ref; $_ = pack 'C0A*', $_; } } sub _login { my $class = shift; my($user, $pass, $blog_id) = @_; require MT::Author; my $author = MT::Author->load({ name => $user }) or return; $author->is_valid_password($pass) or return; return $author unless $blog_id; require MT::Permission; my $perms = MT::Permission->load({ author_id => $author->id, blog_id => $blog_id }); ($author, $perms); } sub _publish { my $class = shift; my($mt, $entry, $no_ping) = @_; require MT::Blog; my $blog = MT::Blog->load($entry->blog_id); $mt->rebuild_entry( Entry => $entry, Blog => $blog, BuildDependencies => 1 ) or return $class->error("Rebuild error: " . $mt->errstr); unless ($no_ping) { $mt->ping_and_save(Blog => $blog, Entry => $entry) or return $class->error("Ping error: " . $mt->errstr); } 1; } # Render a post in a form suitable for SOAP return. # _renderPost(entry, fields) # =entry= The entry (aka the post). # # =fields= The set of fields of the entry to encode. # This can be an array, a hash or something else # If an array, each element must be a string which # is the name of the field to encode. # If a hash, the keys are strings that are the fields # to encode and the value for the key must be true for # the field to be included. # Anything else (including undefined) causes all of the # the fields to be rendered except fields marked "URO" # - upon request only. # # The actual field values: # # postid => Post ID # userid => User ID of the author of the post # mt_blogid => Blog ID of the post [URO] # title => Post title # description => Entry text # content => synonymn for description [URO] # mt_description_length => length of the description [URO] # dateCreated => Timestamp of when the post was created # dateModified => Timestamp of when the post was last modified # link => URL for the post # permaLink => Synonym for link # mt_allow_pings => Whether pings are allowed # mt_allow_comments => Whether comments are allowed # mt_convert_breaks => Text filter # mt_text_more => Extended entry text for the post # mt_excerpt => Excerpt for the post # mt_keywords => Keywords for the post # mt_status => Status (0:draft, 1:publish) [URO] # # =blog= Need this for time format conversions # sub _renderPost($$$) { my ($entry, $fields, $blog) = @_; my %i_fields; # Allow either array or hash for the convenience of the caller if (ref $fields eq 'ARRAY') { for (@$fields) { $i_fields{$_} = 1; } $fields = \%i_fields; # switch to internal hash after populating it } elsif (! (ref $fields eq 'HASH')) # if not array or hash, ignore { $fields = undef; # return all fields } my $row = { }; # start with empty hash # for each keyed return, only return it if either the key was passed in or # no keys were passed in at all $row->{dateCreated} = SOAP::Data->type(dateTime => MT::XMLRPCServer::Util::ts2iso($blog, $entry->created_on)) if !$fields || $fields->{dateCreated}; $row->{dateModified} = SOAP::Data->type(dateTime => MT::XMLRPCServer::Util::ts2iso($blog, $entry->modified_on)) if !$fields || $fields->{dateModified}; $row->{userid} = SOAP::Data->type(string => $entry->author_id) if !$fields || $fields->{userid}; $row->{postid} = SOAP::Data->type(string => $entry->id) if !$fields || $fields->{postid}; $row->{title} = SOAP::Data->type(string => $entry->title) if !$fields || $fields->{title}; # Content and description are the same, therefore we don't do both by default. # Because description is used more often and content is only used in the blogger # context we'll make content only show up if specifically requested. This works # fine for acting like blogger because in that case the fields have to passed in # explicitly anyway. $row->{description} = SOAP::Data->type(string => $entry->text) if !$fields || $fields->{description}; $row->{content} = SOAP::Data->type(string => $entry->text) if $fields && $fields->{content}; # link and permalink are the same. my $link = $entry->permalink; # Is the variable here to cache the value? Not sure. $row->{link} = SOAP::Data->type(string => $link) if !$fields || $fields->{link}; $row->{permaLink} = SOAP::Data->type(string => $link) if !$fields || $fields->{permalink}; $row->{mt_allow_comments} = SOAP::Data->type(int => $entry->allow_comments) if !$fields || $fields->{mt_allow_comments}; $row->{mt_allow_pings} = SOAP::Data->type(int => $entry->allow_pings) if !$fields || $fields->{mt_allow_pings}; $row->{mt_convert_breaks} = SOAP::Data->type(string => $entry->convert_breaks) if !$fields || $fields->{mt_convert_breaks}; $row->{mt_text_more} = SOAP::Data->type(string => $entry->text_more) if !$fields || $fields->{mt_text_more}; $row->{mt_excerpt} = SOAP::Data->type(string => $entry->excerpt) if !$fields || $fields->{mt_excerpt}; $row->{mt_keywords} = SOAP::Data->type(string => $entry->keywords) if !$fields || $fields->{mt_keywords}; # URO fields $row->{mt_description_length} = SOAP::Data->type(int => length $entry->text) if $fields && $fields->{mt_description_length}; $row->{mt_blogid} = SOAP::Data->type(int => $entry->{blog_id}) if $fields && $fields->{mt_blogid}; $row->{mt_status} = SOAP::Data->type(int => $entry->{status}) if $fields && $fields->{mt_status}; return $row; # return value } sub newPost { my $class = shift; my($appkey, $blog_id, $user, $pass, $item, $publish); if ($class eq 'blogger') { ($appkey, $blog_id, $user, $pass, my($content), $publish) = @_; $item->{description} = $content; } else { ($blog_id, $user, $pass, $item, $publish) = @_; } die _fault("No blog_id") unless $blog_id; no_utf8($blog_id, values %$item); unless ($HAVE_XML_PARSER) { for my $f (qw( title description mt_text_more mt_excerpt mt_keywords )) { next unless defined $item->{$f}; $item->{$f} = decode_html($item->{$f}); $item->{$f} =~ s!'!'!g; #' } } my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. require MT::Blog; my $blog = MT::Blog->load($blog_id) or die _fault("Invalid blog ID '$blog_id'"); my($author, $perms) = __PACKAGE__->_login($user, $pass, $blog_id); die _fault("Invalid login") unless $author; die _fault("No posting privileges") unless $perms && $perms->can_post; require MT::Entry; my $entry = MT::Entry->new; $entry->blog_id($blog_id); # Check for author in the item if (my $author_id = $item->{userid}) { require MT::Permission; my $perm = MT::Permission->load({ blog_id => $blog_id, author_id => $author_id }); if ($perm) { $entry->author_id($author_id); } else { die _fault("Author $author_id not associated with this weblog"); } } else { $entry->author_id($author->id); } ## In 2.1 we changed the behavior of the $publish flag. Previously, ## it was used to determine the post status. That was a bad idea. ## So now entries added through XML-RPC are always set to publish, ## *unless* the user has set "NoPublishMeansDraft 1" in mt.cfg, which ## enables the old behavior. if ($mt->{cfg}->NoPublishMeansDraft) { $entry->status($publish ? MT::Entry::RELEASE() : MT::Entry::HOLD()); } else { $entry->status(MT::Entry::RELEASE()); } $entry->allow_comments($blog->allow_comments_default); $entry->allow_pings($blog->allow_pings_default); $entry->convert_breaks(defined $item->{mt_convert_breaks} ? $item->{mt_convert_breaks} : $blog->convert_paras); $entry->allow_comments($item->{mt_allow_comments}) if exists $item->{mt_allow_comments}; $entry->title($item->{title} || first_n_words($item->{description}, 5)); $entry->text($item->{description}); for my $field (qw( allow_pings )) { my $val = $item->{"mt_$field"}; next unless defined $val; die _fault("Value for 'mt_$field' must be either 0 or 1 (was '$val')") unless $val == 0 || $val == 1; $entry->$field($val); } $entry->excerpt($item->{mt_excerpt}) if $item->{mt_excerpt}; $entry->text_more($item->{mt_text_more}) if $item->{mt_text_more}; $entry->keywords($item->{mt_keywords}) if $item->{mt_keywords}; if (my $urls = $item->{mt_tb_ping_urls}) { $urls = [ split(qr|[[:space:]]+|, $urls) ] if ref($urls) ne 'ARRAY'; # AOG fix for w.bloggar $entry->to_ping_urls(join "\n", @$urls); } if (my $iso = $item->{dateCreated}) { $entry->created_on(MT::XMLRPCServer::Util::iso2ts($blog, $iso)) || die MT::XMLRPCServer::_fault("Invalid timestamp format");; } $entry->save; $mt->log("'" . $author->name . "' added entry #" . $entry->id); if ($publish) { __PACKAGE__->_publish($mt, $entry) or die _fault(__PACKAGE__->errstr); } SOAP::Data->type(string => $entry->id); } sub editPost { my $class = shift; my($appkey, $entry_id, $user, $pass, $item, $publish); if ($class eq 'blogger') { ($appkey, $entry_id, $user, $pass, my($content), $publish) = @_; $item->{description} = $content; } else { ($entry_id, $user, $pass, $item, $publish) = @_; } die _fault("No entry_id") unless $entry_id; no_utf8(values %$item); unless ($HAVE_XML_PARSER) { for my $f (qw( title description mt_text_more mt_excerpt mt_keywords )) { next unless defined $item->{$f}; $item->{$f} = decode_html($item->{$f}); $item->{$f} =~ s!'!'!g; #' } } my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. require MT::Entry; my $entry = MT::Entry->load($entry_id) or die _fault("Invalid entry ID '$entry_id'"); my($author, $perms) = __PACKAGE__->_login($user, $pass, $entry->blog_id); die _fault("Invalid login") unless $author; die _fault("Not privileged to edit entry") unless $perms && $perms->can_edit_entry($entry, $author); # Check for author in the item if (my $author_id = $item->{userid}) { require MT::Permission; my $perm = MT::Permission->load({ blog_id => $entry->blog_id, author_id => $author_id }); if ($perm) { $entry->author_id($author_id); } else { die _fault("Author $author_id not associated with this weblog"); } } $entry->status(MT::Entry::RELEASE()) if $publish; $entry->title($item->{title}) if defined $item->{title}; $entry->text($item->{description}) if defined $item->{description}; $entry->convert_breaks($item->{mt_convert_breaks}) if exists $item->{mt_convert_breaks}; $entry->allow_comments($item->{mt_allow_comments}) if exists $item->{mt_allow_comments}; for my $field (qw( allow_pings )) { my $val = $item->{"mt_$field"}; next unless defined $val; die _fault("Value for 'mt_$field' must be either 0 or 1 (was '$val')") unless $val == 0 || $val == 1; $entry->$field($val); } $entry->excerpt($item->{mt_excerpt}) if defined $item->{mt_excerpt}; $entry->text_more($item->{mt_text_more}) if defined $item->{mt_text_more}; $entry->keywords($item->{mt_keywords}) if defined $item->{mt_keywords}; if (my $urls = $item->{mt_tb_ping_urls}) { $urls = [ split(qr|[[:space:]]+|, $urls) ] if ref($urls) ne 'ARRAY'; #AOG fix for w.bloggar $entry->to_ping_urls(join "\n", @$urls); } if (my $iso = $item->{dateCreated}) { $entry->created_on(MT::XMLRPCServer::Util::iso2ts($entry->blog_id, $iso)) || die MT::XMLRPCServer::_fault("Invalid timestamp format");; } $entry->save; if ($publish) { __PACKAGE__->_publish($mt, $entry) or die _fault(__PACKAGE__->errstr); } SOAP::Data->type(boolean => 1); } sub getUsersBlogs { shift if UNIVERSAL::isa($_[0] => __PACKAGE__); my($appkey, $user, $pass) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. my($author) = __PACKAGE__->_login($user, $pass); die _fault("Invalid login") unless $author; require MT::Permission; require MT::Blog; my $iter = MT::Permission->load_iter({ author_id => $author->id }); my @res; while (my $perms = $iter->()) { next unless $perms->can_post; my $blog = MT::Blog->load($perms->blog_id); push @res, { url => SOAP::Data->type(string => $blog->site_url), blogid => SOAP::Data->type(string => $blog->id), blogName => SOAP::Data->type(string => $blog->name) }; } \@res; } # Create a hash object to return describing a user. # The fields can be either an array or a hash. If omitted or not one of those two types # then all fields are returned. # userid -> ID for the user # firstname -> first name (stuff before the first whitespace) # lastname -> last name (stuff after the first whitespace) # name -> the full unmodified name (what's actually stored) [URO] # nickname -> users's nickname # email -> e-mail address if any # url -> user's home page, if any sub _renderUser { my ($author, $fields) = @_; my %i_fields; # Allow either array or hash for the convenience of the caller if (ref $fields eq 'ARRAY') { for (@$fields) { $i_fields{$_} = 1; } $fields = \%i_fields; # switch to internal hash after populating it } elsif (! (ref $fields eq 'HASH')) # if not array or hash, ignore { $fields = undef; # return all fields } my $row = { }; # start with empty hash my($fname, $lname) = split /\s+/, $author->name; $lname ||= ''; # make sure it's defined $row->{userid} = SOAP::Data->type(string => $author->id) if !$fields or $fields->{userid}; $row->{firstname} = SOAP::Data->type(string => $fname) if !$fields or $fields->{firstname}; $row->{lastname} = SOAP::Data->type(string => $lname) if !$fields or $fields->{lastname}; $row->{name} = SOAP::Data->type(string => $author->name) if $fields and $fields->{name}; $row->{nickname} = SOAP::Data->type(string => $author->nickname) if !$fields or $fields->{nickname}; $row->{email} = SOAP::Data->type(string => $author->email) if !$fields or $fields->{email}; $row->{url} = SOAP::Data->type(string => $author->url) if !$fields or $fields->{url}; return $row; } sub getUserInfo { shift if UNIVERSAL::isa($_[0] => __PACKAGE__); my($appkey, $user, $pass, $fields) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. my($author) = __PACKAGE__->_login($user, $pass); die _fault("Invalid login") unless $author; return _renderUser($author, $fields); } sub getRecentPosts { my $class = shift; my($blog_id, $user, $pass, $num, $titles_only); if ($class eq 'blogger') { (my($appkey), $blog_id, $user, $pass, $num, $titles_only) = @_; } else { ($blog_id, $user, $pass, $num, $titles_only) = @_; } my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. my($author, $perms) = __PACKAGE__->_login($user, $pass, $blog_id); die _fault("Invalid login") unless $author; die _fault("No posting privileges") unless $perms && $perms->can_post; require MT::Blog; my $blog = MT::Blog->load($blog_id); require MT::Entry; my $iter = MT::Entry->load_iter({ blog_id => $blog_id }, { 'sort' => 'created_on', direction => 'descend', limit => $num }); my @res; while (my $entry = $iter->()) { my @fields = qw( dateCreated userid postid ); my $fields = \@fields; # so we can pass nil instead of an empty list if ($class eq 'blogger') { push @fields, 'content'; } else { push @fields, 'title'; $fields = undef unless ($titles_only); # return all fields } my $row = _renderPost($entry, $fields, $blog); push @res, $row; } \@res; } # Kind of a silly method but we need it for backward compatibility. # It's subsumed by getPostRange. sub getRecentPostTitles { getRecentPosts(@_, 1); } sub deletePost { shift if UNIVERSAL::isa($_[0] => __PACKAGE__); my($appkey, $entry_id, $user, $pass, $publish) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. require MT::Entry; my $entry = MT::Entry->load($entry_id) or die _fault("Invalid entry ID '$entry_id'"); my($author, $perms) = __PACKAGE__->_login($user, $pass, $entry->blog_id); die _fault("Invalid login") unless $author; die _fault("Not privileged to delete entry") unless $perms && $perms->can_edit_entry($entry, $author); $entry->remove; if ($publish) { __PACKAGE__->_publish($mt, $entry, 1) or die _fault(__PACKAGE__->errstr); } SOAP::Data->type(boolean => 1); } sub getPost { my $class = shift; my($entry_id, $user, $pass) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. require MT::Entry; my $entry = MT::Entry->load($entry_id) or die _fault("Invalid entry ID '$entry_id'"); my($author, $perms) = __PACKAGE__->_login($user, $pass, $entry->blog_id); die _fault("Invalid login") unless $author; die _fault("Not privileged to get entry") unless $perms && $perms->can_edit_entry($entry, $author); require MT::Blog; my $blog = MT::Blog->load($entry->blog_id); _renderPost($entry, undef, $blog); } sub supportedMethods { [ 'blogger.newPost', 'blogger.editPost', 'blogger.getRecentPosts', 'blogger.getUsersBlogs', 'blogger.getUserInfo', 'blogger.deletePost', 'metaWeblog.getPost', 'metaWeblog.newPost', 'metaWeblog.editPost', 'metaWeblog.getRecentPosts', 'metaWeblog.newMediaObject', 'mt.getCategoryList', 'mt.setPostCategories', 'mt.getPostCategories', 'mt.getTrackbackPings', 'mt.supportedTextFilters', 'mt.getRecentPostTitles', 'mt.publishPost', 'mt.getPostRange', 'mt.getComment', 'mt.getCommentRange', 'mt.newComment', 'mt.editComment', 'mt.deleteComment', 'mt.getUserList' ]; } sub supportedTextFilters { my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. my $filters = $mt->all_text_filters; my @res; for my $filter (keys %$filters) { push @res, { key => SOAP::Data->type(string => $filter), label => SOAP::Data->type(string => $filters->{$filter}{label}) }; } \@res; } ## getCategoryList, getPostCategories, and setPostCategories were ## originally written by Daniel Drucker with the assistance of ## Six Apart, then later modified by Six Apart. sub getCategoryList { my $class = shift; my($blog_id, $user, $pass) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. my($author, $perms) = $class->_login($user, $pass, $blog_id); die _fault("Invalid login") unless $author; die _fault("Author does not have privileges") unless $perms && $perms->can_post; require MT::Category; my $iter = MT::Category->load_iter({ blog_id => $blog_id }); my @data; while (my $cat = $iter->()) { push @data, { categoryName => SOAP::Data->type(string => $cat->label), categoryId => SOAP::Data->type(string => $cat->id) }; } \@data; } # Returns a list of users for a specific weblog. The invoker must have # posting privileges for that weblog. An optional fourth argument are # the fields to return. If omitted, all fields are returned. # See _renderUser for a list of field names. sub getUserList { my $class = shift; my($blog_id, $user, $pass, $fields) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. my($author, $perms) = $class->_login($user, $pass, $blog_id); die _fault("Invalid login") unless $author; die _fault("Author does not have privileges") unless $perms && $perms->can_post; require MT::Author; require MT::Permission; # authors are associated with weblogs via a Permissions object my $iter = MT::Permission->load_iter({ blog_id => $blog_id }); my @data; while (my $perm = $iter->()) { my $author = MT::Author->load($perm->author_id); push @data, _renderUser($author, $fields) if ref $author; } \@data; } sub getPostCategories { my $class = shift; my($entry_id, $user, $pass) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. require MT::Entry; my $entry = MT::Entry->load($entry_id) or die _fault("Invalid entry ID '$entry_id'"); my($author, $perms) = $class->_login($user, $pass, $entry->blog_id); die _fault("Invalid login") unless $author; die _fault("No posting privileges") unless $perms && $perms->can_post; my @data; my $prim = $entry->category; my $cats = $entry->categories; for my $cat (@$cats) { my $is_primary = $prim && $cat->id == $prim->id ? 1 : 0; push @data, { categoryName => SOAP::Data->type(string => $cat->label), categoryId => SOAP::Data->type(string => $cat->id), isPrimary => SOAP::Data->type(boolean => $is_primary), }; } \@data; } sub setPostCategories { my $class = shift; my($entry_id, $user, $pass, $cats) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. require MT::Entry; require MT::Placement; my $entry = MT::Entry->load($entry_id) or die _fault("Invalid entry ID '$entry_id'"); my($author, $perms) = $class->_login($user, $pass, $entry->blog_id); die _fault("Invalid login") unless $author; die _fault("Not privileged to set entry categories") unless $perms && $perms->can_edit_entry($entry, $author); my @place = MT::Placement->load({ entry_id => $entry_id }); for my $place (@place) { $place->remove; } ## Keep track of which category is named the primary category. ## If the first structure in the array does not have an isPrimary ## key, we just make it the primary category; if it does, we use ## that flag to determine the primary category. my $is_primary = 1; for my $cat (@$cats) { my $place = MT::Placement->new; $place->entry_id($entry_id); $place->blog_id($entry->blog_id); if (defined $cat->{isPrimary} && $is_primary) { $place->is_primary($cat->{isPrimary}); } else { $place->is_primary($is_primary); } ## If we just set the is_primary flag to 1, we don't want to ## make any other categories primary. $is_primary = 0 if $place->is_primary; $place->category_id($cat->{categoryId}); $place->save or die _fault("Saving placement failed: " . $place->errstr); } SOAP::Data->type(boolean => 1); } sub getTrackbackPings { my $class = shift; my($entry_id) = @_; require MT::Trackback; require MT::TBPing; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. my $tb = MT::Trackback->load({ entry_id => $entry_id }) or return []; my $iter = MT::TBPing->load_iter({ tb_id => $tb->id }); my @data; while (my $ping = $iter->()) { push @data, { pingTitle => SOAP::Data->type(string => $ping->title), pingURL => SOAP::Data->type(string => $ping->source_url), pingIP => SOAP::Data->type(string => $ping->ip), }; } \@data; } sub publishPost { my $class = shift; my($entry_id, $user, $pass) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. require MT::Entry; my $entry = MT::Entry->load($entry_id) or die _fault("Invalid entry ID '$entry_id'"); my($author, $perms) = __PACKAGE__->_login($user, $pass, $entry->blog_id); die _fault("Invalid login") unless $author; die _fault("Not privileged to edit entry") unless $perms && $perms->can_edit_entry($entry, $author); # AOG: if the client says publish, then publish it! if ($entry->status() != MT::Entry::RELEASE()) { $entry->status(MT::Entry::RELEASE()); $entry->save(); } $mt->rebuild_entry( Entry => $entry, BuildDependencies => 1 ) or die _fault("Publish failed: " . $mt->errstr); SOAP::Data->type(boolean => 1); } sub runPeriodicTasks { my $class = shift; my ($user, $pass) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); my $author = $class->_login($user, $pass); use POSIX qw(strftime); use MT::Entry qw{HOLD RELEASE FUTURE}; # Iterate over just those blogs for which the auth'd user has # edit_config access. require MT::Permission; my $iter = MT::Blog->load_iter({}, {join => ['MT::Permission', 'blog_id', { author_id => $author->id() }]}); my @nows = (); my $total_changed = 0; my $pub_cnt = 0; my $blogs_modified = 0; # my $next_scheduled = undef; while (my $blog = $iter->()) { # Have to check the permissions here because MT::Object won't # let us filter by a single bit of a field. my $perm = MT::Permission->load({author_id => $author->id(), blog_id => $blog->id()}); next unless $perm->can_edit_config(); my $result = $class->publishScheduledFuturePosts($blog->id(), $user, $pass); $pub_cnt += $result->{publishedCount}; $blogs_modified++ if $result->{publishedCount}; # if (defined($result->{nextScheduledTime})) { # if (!defined($next_scheduled) # || ($next_scheduled > $result->{nextScheduledTime})) # { # $next_scheduled = $result->{nextScheduledTime}; # } # } } MT->run_callbacks('PeriodicTask'); # TBD: define scheduling mech. to notifiy remote scheduler. { publishedCount => $pub_cnt, # nextScheduledTime => $next_scheduled, numBlogs => $blogs_modified }; } sub publishScheduledFuturePosts { my $class = shift; my ($blog_id, $user, $pass) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); my $author = $class->_login($user, $pass); my $blog = MT::Blog->load($blog_id); my $now = time; # Convert $now to user's timezone, which is how future post dates # are stored. $now = MT::Util::offset_time($now); $now = strftime("%Y%m%d%H%M%S", gmtime($now)); my $iter = MT::Entry->load_iter({blog_id => $blog->id, status => FUTURE}, {'sort' => 'created_on', direction => 'descend'}); my @queue; while (my $i = $iter->()) { push @queue, $i->id(); } my $changed = 0; my $total_changed = 0; my @results; # my $next_scheduled = undef; foreach my $entry_id (@queue) { my $entry = MT::Entry->load($entry_id); if ($entry->created_on <= $now) { $entry->status(RELEASE); $entry->save or die $entry->errstr; start_background_task(sub { $mt->rebuild_entry( Entry => $entry, Blog => $blog ) or die $mt->errstr; }); $changed++; $total_changed++; } else { # my $entry_utc = MT::XMLRPCServer::Util::ts2iso($blog, # $entry->created_on); # if (!defined($next_scheduled) || $entry_utc < $next_scheduled) # { # $next_scheduled = $entry_utc; # } } } if ($changed) { $mt->rebuild_indexes( Blog => $blog ) or die $mt->errstr; } { responseCode => 'success', publishedCount => $total_changed, # nextScheduledTime => $next_scheduled }; } sub getNextScheduled { my $class = shift; my ($user, $pass) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); my $author = $class->_login($user, $pass); my $next_scheduled = MT::get_next_sched_post_for_user($author->id()); { nextScheduledTime => $next_scheduled }; } sub setRemoteAuthToken { my $class = shift; my ($user, $pass, $remote_auth_username, $remote_auth_token) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. my($author) = __PACKAGE__->_login($user, $pass); die _fault("Invalid login") unless $author; $author->remote_auth_username($remote_auth_username); $author->remote_auth_token($remote_auth_token); $author->save(); 1; } sub newMediaObject { my $class = shift; my($blog_id, $user, $pass, $file) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. my($author, $perms) = __PACKAGE__->_login($user, $pass, $blog_id); die _fault("Invalid login") unless $author; die _fault("Not privileged to upload files") unless $perms && $perms->can_upload; require MT::Blog; require File::Spec; my $blog = MT::Blog->load($blog_id); my $fname = $file->{name} or die _fault("No filename provided"); if ($fname =~ m!\.\.|\0|\|!) { die _fault("Invalid filename '$fname'"); } my $local_file = File::Spec->catfile($blog->site_path, $file->{name}); my $fmgr = $blog->file_mgr; my($vol, $path, $name) = File::Spec->splitpath($local_file); $path =~ s!/$!!; ## OS X doesn't like / at the end in mkdir(). unless ($fmgr->exists($path)) { $fmgr->mkpath($path) or die _fault("Error making path '$path': " . $fmgr->errstr); } defined(my $bytes = $fmgr->put_data($file->{bits}, $local_file, 'upload')) or die _fault("Error writing uploaded file: " . $fmgr->errstr); my $url = $blog->site_url . $fname; { url => SOAP::Data->type(string => $url) }; } ## getTemplate and setTemplate are not applicable in MT's template ## structure, so they are unimplemented (they return a fault). ## We assign it twice to get rid of "setTemplate used only once" warnings. sub getTemplate { die _fault( "Template methods are not implemented, due to differences between " . "the Blogger API and the Movable Type API."); } *setTemplate = *setTemplate = \&getTemplate; # AOG added # Get a range of posts. A set of keys specifies the properties of the range. # Arguments: # BlogId # UserName # Password # SearchTerms : hash of strings to values. See below. # OutputKeys : array of strings # # -SearchTerms # before => Timestamp. No post created after this time is included # after => Timestamp. No post created before this time is included # count => Integer. The number of posts in the range. # reverse => Bool. If present and set then posts are in reverse chronological order. # If count is also set then the oldest =count= posts are returned. # -OutputKeys # see _renderPost # # Permissions: # This method allows anonymous access by passing in empty strings as the # user name and password. The output is restricted to published posts # unless there is a successful login that has either post or edit_post # privileges. # sub getPostRange { my $class = shift; my ($blog_id, $user, $pass, $terms, $fields) = @_; # need to do this to get the DB connection working my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. # Login and check permissions # We allow a no-login call but must make sure in that case that # only public data is returned my ($author, $perms); # if there is a login attempt, it is a hard failure if it doesn't succeed if ($user || $pass) { ($author, $perms) = __PACKAGE__->_login($user, $pass, $blog_id); die _fault("Invalid login") unless $author; } # Do basic argument type checking so we can make code simplifying assumptions later die _fault("Terms (4th arg) must be hash") unless ref $terms eq 'HASH'; die _fault("Fields (5th arg) must be array, hash or nil") unless (!defined $fields) || (ref $fields eq 'ARRAY') || (ref $fields eq 'HASH'); require MT::Blog; my $blog = MT::Blog->load($blog_id); die _fault("Invalid blog ID: $blog_id") unless $blog; # Load up the basic packages we need require MT::Entry; # set up the arguments for the load iterator. The values here are fixed except # for 'direction' which can be changed later but must always be present. my %loadTerms = ( 'blog_id' => $blog_id ); my %loadArgs = ( sort => 'created_on' , direction => 'descend' ); # if no permissions, only release entries are visible $loadTerms{status} = MT::Entry::RELEASE() unless $perms && ( $perms->can_post || $perms->can_edit_all_posts ); # restrict to posts by a specific author? $loadTerms{'author_id'} = $terms->{userid} if exists $terms->{userid}; # get the base time range my $before = $terms->{'before'}; my $after = $terms->{'after'}; # Verify format and strip the internal 'T' if ($before) { $before = MT::XMLRPCServer::Util::iso2ts($blog, $before) || die _fault("Invalid timestamp format in before term: '$before'");; } # Verify format and strip the internal 'T' if ($after) { $after = MT::XMLRPCServer::Util::iso2ts($blog, $after) || die _fault("Invalid timestamp format in after term: '$after'");; } # Set up the range for the dates if either limit has been specified if ($before || $after) { $loadTerms{created_on} = [ $after , $before ]; $loadArgs{range} = { created_on => 1 } } # The maximum number of posts to return my $count = $terms->{'count'}; $loadArgs{'limit'} = $count if ($count && $count > 0); # Check for ordering override $loadArgs{'direction'} = 'ascend' if $terms->{'reverse'}; die _fault("Terms must include at least one of 'before' or 'after' or 'count'") unless $before || $after || $count; # Go to the DB my $iter = MT::Entry->load_iter(\%loadTerms, \%loadArgs); # Generate the return values my @res; # accumulate return data here while (my $entry = $iter->()) { push @res, _renderPost($entry, $fields, $blog); } \@res; } # ---------------- # Comment support # Render a comment in a form suitable for SOAP return. # _renderCommnent(comment, fields) # =comment= The entry # # =fields= The set of fields to return. # This can be an array, a hash or something else # If an array, each element must be a string which # is the name of the field to encode. # If a hash, the keys are strings that are the fields # to encode and the value for the key must be true for # the field to be included. # Anything else (including undefined) causes most of the # the fields to be rendered. The non rendered fields are # marked "URO" - upon request only # # The actual field values: # # blogid => ID of the blog the post is in [URO] # commentid => ID of the comment itself # postid => ID of the post to which the comment is attached # text => Content of the comment # text_length => Length of the comment [URO] # author => Name of the author # email => E-mail address of author # url => URL of author # ip => IP address of host from where the comment was posted # dateCreated => Date comment was posted # visible => Whether the comment is visible (i.e., not pending moderation) # # =blog= Need this for time format conversions sub _renderComment { my ($comment, $fields, $blog) = @_; my %i_fields; # Allow either array or hash for the convenience of the caller if (ref $fields eq 'ARRAY') { for (@$fields) { $i_fields{$_} = 1; } $fields = \%i_fields; # switch to internal hash after populating it } elsif (! (ref $fields eq 'HASH')) # if not array or hash, ignore { $fields = undef; # return all fields } my $row = { }; # start with empty hash # for each keyed return, only return it if either the key was passed in or # no keys were passed in at all # This cries out to be parameterized in to a loop. We'd need the external key, # the internal field name and the data type, plus a conversion method (for dates) # The conversion method could be tied to the type rather than the field, though. $row->{dateCreated} = SOAP::Data->type(dateTime => MT::XMLRPCServer::Util::ts2iso($blog, $comment->created_on)) if !$fields || $fields->{dateCreated}; $row->{commentid} = SOAP::Data->type(string => $comment->id) if !$fields || $fields->{commentid}; $row->{postid} = SOAP::Data->type(string => $comment->entry_id) if !$fields || $fields->{postid}; $row->{blogid} = SOAP::Data->type(string => $comment->blog_id) if $fields && $fields->{blogid}; $row->{text} = SOAP::Data->type(string => $comment->text) if !$fields || $fields->{text}; $row->{text_length} = SOAP::Data->type(int => length $comment->text) if $fields && $fields->{text_length}; $row->{author} = SOAP::Data->type(string => $comment->author) if !$fields || $fields->{author}; $row->{email} = SOAP::Data->type(string => $comment->email) if !$fields || $fields->{email}; $row->{url} = SOAP::Data->type(string => $comment->url) if !$fields || $fields->{url}; $row->{ip} = SOAP::Data->type(string => $comment->ip) if !$fields || $fields->{ip}; $row->{visible} = SOAP::Data->type(string => $comment->visible) if !$fields || $fields->{visible}; return $row; # return value } # Get a comment by the comment ID sub getComment { my ($class, $id, $fields) = @_; die _fault("GetComment requires 1 arguments") unless scalar @_ > 1; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. require MT::Comment; my $comment = MT::Comment->load($id) or die _fault("Invalid comment ID '$id'"); require MT::Blog; my $blog = MT::Blog->load($comment->blog_id); _renderComment($comment, $fields, $blog); } sub getCommentRange { my ($class, $user, $pass, $terms, $fields) = @_; my %loadTerms; # Do basic argument type checking so we can make code simplifying assumptions later die _fault("Terms (3rd arg) must be hash") unless ref $terms eq 'HASH'; die _fault("Fields (4th arg) must be array, hash or nil") unless (!defined $fields) || (ref $fields eq 'ARRAY') || (ref $fields eq 'HASH'); # need to do this to get the DB connection working my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. # Need to compute the weblog ID first because we need it to log in my ($blog_id, $entry); if (defined ($blog_id = $terms->{blogid}) ) { $loadTerms{blog_id} = $blog_id; } elsif (my $entry_id = $terms->{postid}) { # Load the post so we can verify the ID and get the weblog ID require MT::Entry; $entry = MT::Entry->load($entry_id) or die _fault("Invalid post ID '$entry_id'"); # cache the weblog ID for later and set the DB query term $blog_id = $entry->blog_id; $loadTerms{entry_id} = $entry_id; } else { die _fault("Terms must include either weblog ID or post ID"); } # Check for valid weblog, because we need the actual weblog object later on require MT::Blog; my $blog = MT::Blog->load($blog_id); die _fault("Invalid weblog ID: $blog_id") unless $blog; # Login and check permissions # We allow a no-login call my ($author, $perms); # if there is a login attempt, it must succeed if ($user || $pass) { ($author, $perms) = __PACKAGE__->_login($user, $pass, $blog_id); die _fault("Invalid login") unless $author; } # set up the arguments for the load iterator. The values here are fixed except # for 'direction' which can be changed later but must always be present. my %loadArgs = ( sort => 'created_on' , direction => 'descend' ); # get the base time range my $before = $terms->{'before'}; my $after = $terms->{'after'}; # Verify format if ($before) { $before = MT::XMLRPCServer::Util::iso2ts($blog, $before) || die _fault("Invalid timestamp format in before term: '$before'");; } # Verify format if ($after) { $after = MT::XMLRPCServer::Util::iso2ts($blog, $after) || die _fault("Invalid timestamp format in after term: '$after'");; } # Set up the range for the dates if either limit has been specified if ($before || $after) { $loadTerms{created_on} = [ $after , $before ]; $loadArgs{range} = { created_on => 1 } } # Whether the comment is visible (not pending moderation) $loadTerms{visible} = $terms->{visible} if exists $terms->{visible}; # If the user isn't logged in or doesn't have edit all permission, only # comments marked visible are ... well, visible. $loadTerms{visible} = 1 unless $perms && $perms->can_edit_all_posts; # The maximum number of posts to return my $count = $terms->{'count'}; $loadArgs{'limit'} = $count if ($count && $count > 0); # Check for ordering override $loadArgs{'direction'} = 'ascend' if $terms->{'reverse'}; die _fault("Terms must include at least one of 'before' or 'after', 'count' or 'postid'") unless $before || $after || $count || $entry; require MT::Comment; # Get the required packages # Go to the DB and set up a load iterator to walk to targeted rows my $iter = MT::Comment->load_iter(\%loadTerms, \%loadArgs); # Generate the return values my @res; # accumulate return data here while (my $comment = $iter->()) { push @res, _renderComment($comment, $fields, $blog); } \@res; } # Set the elements of a comment object via a hash table # It is the responsibility of the caller to make sure that # blog_id and entry_id are consistent and handle any # rebuilding required. The attribute renaming is done to # be consistent with the existing API sub _setCommentAttributes($$) { my ($comment, $fields) = @_; # fix up timestamps so it's in internal format # the caller must ensure that blog_id is set in the fields, but that's # easy because the comment creation will fail anyway if it's not. require MT::Blog; my $blog = MT::Blog->load($fields->{blogid}); die _fault('Error:internal:MT:XMLRPC:_setCommentAttributes:Attempted to set comment attributes on non-existent weblog '.$fields->{blogid}) unless $blog; $fields->{dateCreated} = MT::XMLRPCServer::Util::iso2ts($blog, $fields->{dateCreated}) if ($fields->{dateCreated}); # The array is either a field name, or an array of two elements where # the internal and external field names are different. The first element # is the internal (DB column) name and the second is the external name. foreach my $f ( 'author', 'text', 'url', 'ip', 'email', 'visible' , [qw(created_on dateCreated)] , [qw(blog_id blogid)] , [qw(entry_id postid)] ) { my ($attr, $key) = ref $f ? @{$f} : ($f, $f); $comment->$attr($fields->{$key}) if exists $fields->{$key}; } } sub _commentItemFixup { my ($item) = @_; # Do some input fixup. no_utf8(values %$item); unless ($HAVE_XML_PARSER) { for my $f (qw( author text url email )) { next unless defined $item->{$f}; $item->{$f} = decode_html($item->{$f}); $item->{$f} =~ s!'!'!g; } } } # This does not rebuild indices or the entry. That must be done separately. sub newComment { my $class = shift; my ($post_id, $user, $pass, $item) = @_; die _fault("Error:client:MT:XMLRPC:newComment:The entry for the comment was not provided") unless $post_id; # argument format cleanup no_utf8($post_id); _commentItemFixup($item); my $mt = MT::XMLRPCServer::Util::mt_new(); # get access to DB require MT::Entry; my $post = MT::Entry->load($post_id) or die _fault("Error:client:MT:XMLRPC:newComment:The entry (ID=$post_id) does not exist"); # validate user. We restrict access to the RPC form to authors # who can post. my $blog_id = $post->blog_id; my ($author, $perms) = __PACKAGE__->_login($user, $pass, $blog_id); die _fault("Error:client:MT:XMLRPC:newComment:Invalid login") unless $author; die _fault("Error:client:MT:XMLRPC:newComment:The author does not have privilege to post on the weblog (ID=$blog_id)") unless $perms && $perms->can_post; die _fault("Error:client:MT:XMLRPC:newComment:Comment text is required but was not provided") unless $item->{text}; $item->{blogid} = $blog_id; $item->{postid} = $post_id; require MT::Comment; my $comment = MT::Comment->new; _setCommentAttributes($comment, $item); $comment->save; return _renderComment($comment, [qw(blogid postid commentid)]); } # This does not rebuild indices or the entry. That must be done separately. sub editComment { my $class = shift; my ($comment_id, $user, $pass, $item) = @_; die _fault("Error:client:MT:XMLRPC:editComment:No comment was specified") unless $comment_id; # argument format cleanup no_utf8($comment_id); _commentItemFixup($item); my $mt = MT::XMLRPCServer::Util::mt_new(); # get access to DB require MT::Comment; my $comment = MT::Comment->load($comment_id) or die _fault("Error:client:MT:XMLRPC:editComment:Comment $comment_id does not exist"); # validate user. We restrict access to the RPC form to authors # who can post. my $blog_id = $comment->blog_id; my ($author, $perms) = __PACKAGE__->_login($user, $pass, $blog_id); die _fault("Error:client:MT:XMLRPC:Invalid login") unless $author; die _fault("Error:client:MT:XMLRPC:The author does not have posting privileges for this weblog (ID=$blog_id)") unless $perms && $perms->can_post; # Handle changing the post ID. We don't support changing the weblog ID because # that's too ugly to be worth it. # Note that we need to pass in the blog ID to _setCommentAttributes # so that it can do the time conversions. $item->{blogid} = $blog_id; # force it to be identical if (my $new_post_id = $item->{postid}) { # change requested if ($new_post_id != $comment->post_id) { # don't worry about it if there's no change # Verify that the new entry exists and is in the same weblog require MT::Entry; my $new_entry = MT::Entry->load($new_post_id); die _fault("Error:client:MT::XMLRPC::editComment:The entry ID $new_post_id does not exist") unless $new_entry; die _fault("Error:client:MT::XMLRPC::editComment:The new entry (ID=$new_post_id) is not in the same weblog (old=$blog_id, new=".$new_entry->blog_id().')') unless $new_entry->blog_id() == $blog_id; } } _setCommentAttributes($comment, $item); $comment->save; return _renderComment($comment, $item); } sub deleteComment { shift if UNIVERSAL::isa($_[0] => __PACKAGE__); my($comment_id, $user, $pass) = @_; my $mt = MT::XMLRPCServer::Util::mt_new(); ## Will die if MT->new fails. require MT::Comment; my $comment = MT::Comment->load($comment_id) or die _fault("Invalid comment ID '$comment_id'"); my($author, $perms) = __PACKAGE__->_login($user, $pass, $comment->blog_id); die _fault("Invalid login") unless $author; require MT::Entry; my $entry = MT::Entry->load($comment->entry_id); # If the entry doesn't exist, then it's ok to delete the comment regardless # of permissions (since we have no post to check against) die _fault("Not privileged to delete comments") unless $perms && (!$entry || $perms->can_edit_entry($entry, $author)); $comment->remove; SOAP::Data->type(boolean => 1); } # ---------------- ## The above methods will be called as blogger.newPost, blogger.editPost, ## etc., because we are implementing Blogger's API. Thus, the empty ## subclass. package blogger; BEGIN { @blogger::ISA = qw( MT::XMLRPCServer ); } package metaWeblog; BEGIN { @metaWeblog::ISA = qw( MT::XMLRPCServer ); } package mt; BEGIN { @mt::ISA = qw( MT::XMLRPCServer ); } 1;