# AtomAPI support for LJ package Apache::LiveJournal::Interface::AtomAPI; use strict; use Apache::Constants qw(:common); use Digest::SHA1; use MIME::Base64; use lib "$ENV{'LJHOME'}/cgi-bin"; require 'parsefeed.pl'; require 'fbupload.pl'; BEGIN { $LJ::OPTMOD_XMLATOM = eval q{ use XML::Atom::Feed; use XML::Atom::Entry; use XML::Atom::Link; XML::Atom->VERSION < 0.09 ? 0 : 1; }; }; # check allowed Atom upload filetypes sub check_mime { my $mime = shift; return unless $mime; # TODO: add audio/etc support my %allowed_mime = ( image => qr{^image\/(?:gif|jpe?g|png|tiff?)$}i, #audio => qr{^(?:application|audio)\/(?:(?:x-)?ogg|wav)$}i ); foreach (keys %allowed_mime) { return $_ if $mime =~ $allowed_mime{$_} } return; } sub respond { my ($r, $status, $body, $type) = @_; my %msgs = ( 200 => 'OK', 201 => 'Created', 400 => 'Bad Request', 401 => 'Authentication Failed', 403 => 'Forbidden', 404 => 'Not Found', 500 => 'Server Error', ), my %mime = ( html => 'text/html', atom => 'application/x.atom+xml', xml => "text/xml; charset='utf-8'", ); # if the passed in body was a reference, send it # without any modification. otherwise, send some # prettier html to the client. my $out; if (ref $body) { $out = $$body; } else { $out = <$status $msgs{$status}

$msgs{$status}


$body

HTML } $type = $mime{$type} || 'text/html'; $r->status_line("$status $msgs{$status}"); $r->content_type($type); $r->send_http_header(); $r->print($out); return OK; }; sub handle_upload { my ($r, $remote, $u, $opts, $entry) = @_; # entry could already be populated from a standalone # service.post posting. my $standalone = $entry ? 1 : 0; unless ($entry) { my $buff; $r->read($buff, $r->header_in("Content-length")); eval { $entry = XML::Atom::Entry->new( \$buff ); }; return respond($r, 400, "Could not parse the entry due to invalid markup.
$@
") if $@; } my $mime = $entry->content()->type(); my $mime_area = check_mime( $mime ); return respond($r, 400, "Unsupported MIME type: $mime") unless $mime_area; if ($mime_area eq 'image') { return respond($r, 400, "Unable to upload media. Your account doesn't have the required access.") unless LJ::get_cap($u, 'fb_can_upload') && $LJ::FB_SITEROOT; my $err; LJ::load_user_props( $u, qw/ emailpost_gallery emailpost_imgsecurity / ); my $summary = LJ::trim( $entry->summary() ); my $fb = LJ::FBUpload::do_upload( $u, \$err, { path => $entry->title(), rawdata => \$entry->content()->body(), imgsec => $u->{emailpost_imgsecurity}, caption => $summary, galname => $u->{emailpost_gallery} || 'AtomUpload', } ); return respond($r, 500, "There was an error uploading the media: $err") if $err || ! $fb; if (ref $fb && $fb->{Error}->{code}) { my $errstr = $fb->{Error}->{content}; return respond($r, 500, "There was an error uploading the media: $errstr"); } my $atom_reply = XML::Atom::Entry->new(); $atom_reply->title( $fb->{Title} ); if ($standalone) { $atom_reply->summary('Media post'); my $id = "atom:$u->{user}:$fb->{PicID}"; $fb->{Summary} = $summary; LJ::MemCache::set( $id, $fb, 1800 ); $atom_reply->id( "urn:fb:$LJ::FB_DOMAIN:$id" ); } my $link = XML::Atom::Link->new(); $link->type('text/html'); $link->rel('alternate'); $link->href( $fb->{URL} ); $atom_reply->add_link($link); $r->header_out("Location", $fb->{URL}); return respond($r, 201, \$atom_reply->as_xml(), 'atom'); } } sub handle_post { my ($r, $remote, $u, $opts) = @_; my ($buff, $entry); # read the content $r->read($buff, $r->header_in("Content-length")); # try parsing it eval { $entry = XML::Atom::Entry->new( \$buff ); }; return respond($r, 400, "Could not parse the entry due to invalid markup.
$@
") if $@; # on post, the entry must NOT include an id return respond($r, 400, "Must not include an <id> field in a new entry.") if $entry->id(); # detect 'standalone' media posts return handle_upload( @_, $entry ) if $entry->get("http://sixapart.com/atom/typepad#", 'standalone'); # remove the SvUTF8 flag. See same code in synsuck.pl for # an explanation $entry->title( pack( 'C*', unpack( 'C*', $entry->title() ) ) ); $entry->link( pack( 'C*', unpack( 'C*', $entry->link() ) ) ); $entry->content( pack( 'C*', unpack( 'C*', $entry->content()->body() ) ) ); # Retrieve fotobilder media links from clients that embed via # standalone tags or service.upload transfers. Add to post entry # body. my $body = $entry->content()->body(); my @links = $entry->link(); my (@images, $link_count); foreach my $link (@links) { # $link is now a valid XML::Atom::Link object my $rel = $link->get('rel'); my $type = $link->get('type'); my $id = $link->get('href'); next unless $rel eq 'related' && check_mime($type) && $id; $id =~ s/^urn:fb:$LJ::FB_DOMAIN://; my $fb = LJ::MemCache::get( $id ); next unless $fb; push @images, { url => $fb->{URL}, width => $fb->{Width}, height => $fb->{Height}, caption => $fb->{Summary}, title => $fb->{Title} }; } $body .= LJ::FBUpload::make_html( $u, \@images ); # build a post event request. my $req = { 'usejournal' => ( $remote->{'userid'} != $u->{'userid'} ) ? $u->{'user'} : undef, 'ver' => 1, 'username' => $u->{'user'}, 'lineendings' => 'unix', 'subject' => $entry->title(), 'event' => $body, 'props' => {}, 'security' => 'public', 'tz' => 'guess', }; my $err; my $res = LJ::Protocol::do_request("postevent", $req, \$err, { 'noauth' => 1 }); if ($err) { my $errstr = LJ::Protocol::error_message($err); return respond($r, 500, "Unable to post new entry. Protocol error: $errstr."); } my $atom_reply = XML::Atom::Entry->new(); $atom_reply->title( $entry->title() ); $atom_reply->summary( substr( $entry->content->body(), 0, 100 ) ); my $link; my $edit_url = "$LJ::SITEROOT/interface/atom/edit/$res->{'itemid'}"; $link = XML::Atom::Link->new(); $link->type('application/x.atom+xml'); $link->rel('service.edit'); $link->href( $edit_url ); $link->title( $entry->title() ); $atom_reply->add_link($link); $link = XML::Atom::Link->new(); $link->type('text/html'); $link->rel('alternate'); $link->href( $res->{url} ); $link->title( $entry->title() ); $atom_reply->add_link($link); $r->header_out("Location", $edit_url); return respond($r, 201, \$atom_reply->as_xml(), 'atom'); } sub handle_edit { my ($r, $remote, $u, $opts) = @_; my $method = $opts->{'method'}; # first, try to load the item and fail if it's not there my $jitemid = $opts->{'param'}; my $req = { 'usejournal' => ($remote->{'userid'} != $u->{'userid'}) ? $u->{'user'} : undef, 'ver' => 1, 'username' => $u->{'user'}, 'selecttype' => 'one', 'itemid' => $jitemid, }; my $err; my $olditem = LJ::Protocol::do_request("getevents", $req, \$err, { 'noauth' => 1 }); if ($err) { my $errstr = LJ::Protocol::error_message($err); return respond($r, 404, "Unable to retrieve the item requested for editing. Protocol error: $errstr."); } $olditem = $olditem->{'events'}->[0]; if ($method eq "GET") { # return an AtomEntry for this item # use the interface between make_feed and create_view_atom in # ljfeed.pl # get the log2 row (need logtime for createtime) my $row = LJ::get_log2_row($u, $jitemid) || return respond($r, 404, "Could not load the original entry."); # we need to put into $item: itemid, ditemid, subject, event, # createtime, eventtime, modtime my $ctime = LJ::mysqldate_to_time($row->{'logtime'}, 1); my $item = { 'itemid' => $olditem->{'itemid'}, 'ditemid' => $olditem->{'itemid'}*256 + $olditem->{'anum'}, 'eventtime' => LJ::alldatepart_s2($row->{'eventtime'}), 'createtime' => $ctime, 'modtime' => $olditem->{'props'}->{'revtime'} || $ctime, 'subject' => $olditem->{'subject'}, 'event' => $olditem->{'event'}, }; my $ret = LJ::Feed::create_view_atom( { 'u' => $u }, $u, { 'saycharset' => "utf-8", 'noheader' => 1, 'apilinks' => 1, }, [$item] ); return respond($r, 200, \$ret, 'xml'); } if ($method eq "PUT") { # read the content my $buff; $r->read($buff, $r->header_in("Content-length")); # try parsing it my $entry; eval { $entry = XML::Atom::Entry->new( \$buff ); }; return respond($r, 400, "Could not parse the entry due to invalid markup.
$@
") if $@; # remove the SvUTF8 flag. See same code in synsuck.pl for # an explanation $entry->title( pack( 'C*', unpack( 'C*', $entry->title() ) ) ); $entry->link( pack( 'C*', unpack( 'C*', $entry->link() ) ) ); $entry->content( pack( 'C*', unpack( 'C*', $entry->content()->body() ) ) ); # the AtomEntry must include which must match the one we sent # on GET unless ($entry->id() =~ m#atom1:$u->{'user'}:(\d+)$# && $1 == $olditem->{'itemid'}*256 + $olditem->{'anum'}) { return respond($r, 400, "Incorrect <id> field in this request."); } # build an edit event request. Preserve fields that aren't being # changed by this item (perhaps the AtomEntry isn't carrying the # complete information). $req = { 'usejournal' => ( $remote->{'userid'} != $u->{'userid'} ) ? $u->{'user'} : undef, 'ver' => 1, 'username' => $u->{'user'}, 'itemid' => $jitemid, 'lineendings' => 'unix', 'subject' => $entry->title() || $olditem->{'subject'}, 'event' => $entry->content()->body() || $olditem->{'event'}, 'props' => $olditem->{'props'}, 'security' => $olditem->{'security'}, 'allowmask' => $olditem->{'allowmask'}, }; $err = undef; my $res = LJ::Protocol::do_request("editevent", $req, \$err, { 'noauth' => 1 }); if ($err) { my $errstr = LJ::Protocol::error_message($err); return respond($r, 500, "Unable to update entry. Protocol error: $errstr."); } return respond($r, 200, "The entry was successfully updated."); } if ($method eq "DELETE") { # build an edit event request to delete the entry. $req = { 'usejournal' => ($remote->{'userid'} != $u->{'userid'}) ? $u->{'user'}:undef, 'ver' => 1, 'username' => $u->{'user'}, 'itemid' => $jitemid, 'lineendings' => 'unix', 'event' => '', }; $err = undef; my $res = LJ::Protocol::do_request("editevent", $req, \$err, { 'noauth' => 1 }); if ($err) { my $errstr = LJ::Protocol::error_message($err); return respond($r, 500, "Unable to delete entry. Protocol error: $errstr."); } return respond($r, 200, "Entry successfully deleted."); } } # fetch lj tags, display as categories sub handle_categories { my ($r, $remote, $u, $opts) = @_; my $ret = ''; $ret .= ''; my $tags = LJ::Tags::get_usertags($u, { remote => $remote }) || {}; foreach (sort { $a->{name} cmp $b->{name} } values %$tags) { $ret .= "$_->{name}"; } $ret .= ''; return respond($r, 200, \$ret, 'xml'); } sub handle_feed { my ($r, $remote, $u, $opts) = @_; # simulate a call to the S1 data view creator, with appropriate # options my %op = ('pathextra' => "/atom", 'saycharset'=> "utf-8", 'apilinks' => 1, ); my $ret = LJ::Feed::make_feed($r, $u, $remote, \%op); unless (defined $ret) { if ($op{'redir'}) { # this happens if the account was renamed or a syn account. # the redir URL is wrong because ljfeed.pl is too # dataview-specific. Since this is an admin interface, we can # just fail. return respond ($r, 404, "The account $u->{'user'} is of a wrong type and does not allow AtomAPI administration."); } if ($op{'handler_return'}) { # this could be a conditional GET shortcut, honor it $r->status($op{'handler_return'}); return OK; } # should never get here return respond ($r, 404, "Unknown error."); } # everything's fine, return the XML body with the correct content type return respond($r, 200, \$ret, 'xml'); } # this routine accepts the apache request handle, performs # authentication, calls the appropriate method handler, and # prints the response. sub handle { my $r = shift; return respond($r, 404, "This server does not support the Atom API.") unless $LJ::OPTMOD_XMLATOM; # break the uri down: /interface/atom/[/] my ( $action, $param, $oldparam ) = ( $1, $2, $3 ) if $r->uri =~ m#^/interface/atom(?:api)?/?(\w+)?(?:/(\w+))?(?:/(\d+))?$#; my $valid_actions = qr{feed|edit|post|upload|categories}; # old uri was was: /interface/atomapi//[/] # support both by shifting params around if we see something extra. if ($action !~ /$valid_actions/ && $r->uri =~ /atomapi/ ) { $action = $param; $param = $oldparam; } # let's authenticate. # # if wsse information is supplied, use it. # if not, fall back to digest. my $wsse = $r->header_in('X-WSSE'); my $nonce_dup; my $u = $wsse ? auth_wsse($wsse, \$nonce_dup) : LJ::auth_digest($r); return respond( $r, 401, "Authentication failed for this AtomAPI request.") unless $u; return respond( $r, 401, "Authentication failed for this AtomAPI request.") if $nonce_dup && $action && $action ne 'post'; # service autodiscovery # TODO: Add communities? my $method = $r->method; if ( $method eq 'GET' && ! $action ) { LJ::load_user_props( $u, 'journaltitle' ); my $title = $u->{journaltitle} || 'Untitled Journal'; my $feed = XML::Atom::Feed->new(); foreach (qw/ post feed upload categories /) { my $link = XML::Atom::Link->new(); $link->title($title); $link->type('application/x.atom+xml'); $link->rel("service.$_"); $link->href("$LJ::SITEROOT/interface/atom/$_"); $feed->add_link($link); } my $link = XML::Atom::Link->new(); $link->title($title); $link->type('text/html'); $link->rel('alternate'); $link->href( LJ::journal_base($u) ); $feed->add_link($link); return respond($r, 200, \$feed->as_xml(), 'atom'); } $action =~ /$valid_actions/ or return respond($r, 400, "Unknown URI scheme: /interface/atom/$action"); unless (($action eq 'feed' and $method eq 'GET') or ($action eq 'categories' and $method eq 'GET') or ($action eq 'post' and $method eq 'POST') or ($action eq 'upload' and $method eq 'POST') or ($action eq 'edit' and {'GET'=>1,'PUT'=>1,'DELETE'=>1}->{$method})) { return respond($r, 400, "URI scheme /interface/atom/$action is incompatible with request method $method."); } if (($action ne 'edit' && $param) or ($action eq 'edit' && $param !~ m#^\d+$#)) { return respond($r, 400, "Either the URI lacks a required parameter, or its format is improper."); } # we've authenticated successfully and remote is set. But can remote # manage the requested account? my $remote = LJ::get_remote(); unless (LJ::can_manage($remote, $u)) { return respond($r, 403, "User $remote->{'user'} has no administrative access to account $u->{user}."); } # handle the requested action my $opts = { 'action' => $action, 'method' => $method, 'param' => $param }; { 'feed' => \&handle_feed, 'post' => \&handle_post, 'edit' => \&handle_edit, 'upload' => \&handle_upload, 'categories' => \&handle_categories, }->{$action}->( $r, $remote, $u, $opts ); return OK; } # Authenticate via the WSSE header. # Returns valid $u on success, undef on failure. sub auth_wsse { my ($wsse, $nonce_dup) = @_; $wsse =~ s/UsernameToken // or return undef; # parse credentials into a hash. my %creds; foreach (split /, /, $wsse) { my ($k, $v) = split '=', $_, 2; $v =~ s/^['"]//; $v =~ s/['"]$//; $v =~ s/=$// if $k =~ /passworddigest/i; # strip base64 newline char $creds{ lc($k) } = $v; } # invalid create time? invalid wsse. my $ctime = LJ::ParseFeed::w3cdtf_to_time( $creds{created} ) or return undef; # prevent replay attacks. $ctime = LJ::mysqldate_to_time( $ctime, 'gmt' ); return undef if abs(time() - $ctime) > 42300; my $u = LJ::load_user( LJ::canonical_username( $creds{'username'} ) ) or return undef; if (@LJ::MEMCACHE_SERVERS && ref $nonce_dup) { $$nonce_dup = 1 unless LJ::MemCache::add( "wsse_auth:$creds{username}:$creds{nonce}", 1, 180 ) } # validate hash my $hash = Digest::SHA1::sha1_base64( $creds{nonce} . $creds{created} . $u->{password} ); # Nokia's WSSE implementation is incorrect as of 1.5, and they # base64 encode their nonce *value*. If the initial comparison # fails, we need to try this as well before saying it's invalid. if ($hash ne $creds{passworddigest}) { $hash = Digest::SHA1::sha1_base64( MIME::Base64::decode_base64( $creds{nonce} ) . $creds{created} . $u->{password} ); return undef if $hash ne $creds{passworddigest}; } # If we're here, we're valid. LJ::set_remote($u); return $u; } 1;