root/branches/release-35/lib/MT/AtomServer.pm @ 1915

Revision 1915, 36.2 kB (checked in by fumiakiy, 20 months ago)

Removed "service.post" link from Atom 1.0 response, and added "self" link to both 0.3 and 1.0 responses. BugId:68406

  • Property svn:keywords set to Author Date Id Revision
Line 
1# Movable Type (r) Open Source (C) 2001-2008 Six Apart, Ltd.
2# This program is distributed under the terms of the
3# GNU General Public License, version 2.
4#
5# $Id$
6
7package MT::AtomServer;
8use strict;
9
10use MT::I18N qw( encode_text );
11use XML::Atom;
12use XML::Atom::Util qw( first textValue );
13use base qw( MT::App );
14use MIME::Base64 ();
15use Digest::SHA1 ();
16use MT::Atom;
17use MT::Util qw( encode_xml );
18use MT::Author;
19
20use constant NS_SOAP => 'http://schemas.xmlsoap.org/soap/envelope/';
21use constant NS_WSSE => 'http://schemas.xmlsoap.org/ws/2002/07/secext';
22use constant NS_WSU => 'http://schemas.xmlsoap.org/ws/2002/07/utility';
23
24sub init {
25    my $app = shift;
26    $app->{no_read_body} = 1
27        if $app->request_method eq 'POST' || $app->request_method eq 'PUT';
28    $app->SUPER::init(@_) or return $app->error("Initialization failed");
29    $app->request_content
30        if $app->request_method eq 'POST' || $app->request_method eq 'PUT';
31    $app->add_methods(
32        handle => \&handle,
33    );
34    $app->{default_mode} = 'handle';
35    $app->{is_admin} = 0;
36    $app->{warning_trace} = 0;
37    $app;
38}
39
40sub handle {
41    my $app = shift;
42
43    my $out = eval {
44        (my $pi = $app->path_info) =~ s!^/!!;
45        my($subapp, @args) = split /\//, $pi;
46        $app->{param} = {};
47        for my $arg (@args) {
48            my($k, $v) = split /=/, $arg, 2;
49            $app->{param}{$k} = $v;
50        }
51        if (my $action = $app->get_header('SOAPAction')) {
52            $app->{is_soap} = 1;
53            $action =~ s/"//g; # "
54            my($method) = $action =~ m!/([^/]+)$!;
55            $app->request_method($method);
56        }
57        my $apps = $app->config->AtomApp;
58        if (my $class = $apps->{$subapp}) {
59            bless $app, $class;
60        }
61        my $out = $app->handle_request;
62        return unless defined $out;
63        if ($app->{is_soap}) {
64            $out =~ s!^(<\?xml.*?\?>)!!;
65            $out = <<SOAP;
66$1
67<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
68    <soap:Body>$out</soap:Body>
69</soap:Envelope>
70SOAP
71        }
72        return $out;
73    };
74    if (my $e = $@) {
75        $app->error(500, $e);
76        $app->show_error("Internal Error");
77    }
78    return $out;
79}
80
81sub handle_request {
82    1;
83}
84
85sub error {
86    my $app = shift;
87    my($code, $msg) = @_;
88    return unless ref($app);
89    if ($code && $msg) {
90        chomp($msg = encode_xml($msg)); 
91        $app->response_code($code);
92        $app->response_message($msg);
93        $app->response_content_type('text/xml'); 
94        $app->response_content("<error>$msg</error>"); 
95    }
96    elsif ($code) {
97        return $app->SUPER::error($code);
98    }
99    return undef;
100}
101
102sub show_error {
103    my $app = shift;
104    my($err) = @_;
105    chomp($err = encode_xml($err));
106    if ($app->{is_soap}) {
107        my $code = $app->response_code;
108        if ($code >= 400) {
109            $app->response_code(500);
110            $app->response_message($err);
111        }
112        return <<FAULT;
113<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
114  <soap:Body>
115    <soap:Fault>
116      <faultcode>$code</faultcode>
117      <faultstring>$err</faultstring>
118    </soap:Fault>
119  </soap:Body>
120</soap:Envelope>
121FAULT
122    } else {
123        return <<ERR;
124<error>$err</error>
125ERR
126    }
127}
128
129sub get_auth_info {
130    my $app = shift;
131    my %param;
132    if ($app->{is_soap}) {
133        my $xml = $app->xml_body;
134        my $auth = first($xml, NS_WSSE, 'UsernameToken');
135        $param{Username} = textValue($auth, NS_WSSE, 'Username');
136        $param{PasswordDigest} = textValue($auth, NS_WSSE, 'Password');
137        $param{Nonce} = textValue($auth, NS_WSSE, 'Nonce');
138        $param{Created} = textValue($auth, NS_WSU, 'Created');
139    } else {
140        my $req = $app->get_header('X-WSSE')
141            or return $app->auth_failure(401, 'X-WSSE authentication required');
142        $req =~ s/^WSSE //;
143        my ($profile);
144        ($profile, $req) = $req =~ /(\S+),?\s+(.*)/;
145        return $app->error(400, "Unsupported WSSE authentication profile") 
146            if $profile !~ /\bUsernameToken\b/i;
147        for my $i (split /,\s*/, $req) {
148            my($k, $v) = split /=/, $i, 2;
149            $v =~ s/^"//;
150            $v =~ s/"$//;
151            $param{$k} = $v;
152        }
153    }
154    \%param;
155}
156
157sub authenticate {
158    my $app = shift;
159    my $auth = $app->get_auth_info
160        or return $app->auth_failure(401, "Unauthorized");
161    for my $f (qw( Username PasswordDigest Nonce Created )) {
162        return $app->auth_failure(400, "X-WSSE requires $f")
163            unless $auth->{$f};
164    }
165    require MT::Session;
166    my $nonce_record = MT::Session->load($auth->{Nonce});
167   
168    if ($nonce_record && $nonce_record->id eq $auth->{Nonce}) {
169        return $app->auth_failure(403, "Nonce already used");
170    }
171    $nonce_record = new MT::Session();
172    $nonce_record->set_values({
173        id => $auth->{Nonce},
174        start => time,
175        kind => 'AN'
176    });
177    $nonce_record->save();
178# xxx Expire sessions on shorter timeout?
179    my $enc = $app->config('PublishCharset');
180    my $username = encode_text($auth->{Username},undef,$enc);
181    my $user = MT::Author->load({ name => $username, type => 1 })
182        or return $app->auth_failure(403, 'Invalid login');
183    return $app->auth_failure(403, 'Invalid login')
184        unless $user->api_password;
185    return $app->auth_failure(403, 'Invalid login')
186        unless $user->is_active;
187    my $created_on_epoch = $app->iso2epoch($auth->{Created});
188    if (abs(time - $created_on_epoch) > $app->config('WSSETimeout')) {
189        return $app->auth_failure(403, 'X-WSSE UsernameToken timed out');
190    }
191    $auth->{Nonce} = MIME::Base64::decode_base64($auth->{Nonce});
192    my $expected = Digest::SHA1::sha1_base64(
193         $auth->{Nonce} . $auth->{Created} . $user->api_password);
194    # Some base64 implementors do it wrong and don't put the =
195    # padding on the end. This should protect us against that without
196    # creating any holes.
197    $expected =~ s/=*$//;
198    $auth->{PasswordDigest} =~ s/=*$//;
199    #print STDERR "expected $expected and got " . $auth->{PasswordDigest} . "\n";
200    return $app->auth_failure(403, 'X-WSSE PasswordDigest is incorrect')
201        unless $expected eq $auth->{PasswordDigest};
202    $app->{user} = $user;
203
204    ## update session so the user will be counted as active
205    require MT::Session;
206    my $sess_active = MT::Session->load( { kind => 'UA', name => $user->id } );
207    if (!$sess_active) {
208        $sess_active = MT::Session->new;
209        $sess_active->id($app->make_magic_token());
210        $sess_active->kind('UA'); # UA == User Activation
211        $sess_active->name($user->id);
212    }
213    $sess_active->start(time);
214    $sess_active->save;
215    return 1;
216}
217
218sub auth_failure {
219    my $app = shift;
220    $app->set_header('WWW-Authenticate', 'WSSE profile="UsernameToken"');
221    return $app->error(@_);
222}
223
224sub xml_body {
225    my $app = shift;
226    unless (exists $app->{xml_body}) {
227        if (LIBXML) {
228            my $parser = XML::LibXML->new;
229            $app->{xml_body} = $parser->parse_string($app->request_content);
230        } else {
231            my $xp = XML::XPath->new(xml => $app->request_content);
232            $app->{xml_body} = ($xp->find('/')->get_nodelist)[0];
233        }
234    }
235    $app->{xml_body};
236}
237
238sub atom_body {
239    my $app = shift;
240    my $atom;
241    if ($app->{is_soap}) {
242        my $xml = $app->xml_body;
243        $atom = MT::Atom::Entry->new(Elem => first($xml, NS_SOAP, 'Body'))
244            or return $app->error(500, MT::Atom::Entry->errstr);
245    } else {
246        $atom = MT::Atom::Entry->new(Stream => \$app->request_content)
247            or return $app->error(500, MT::Atom::Entry->errstr);
248    }
249    $atom;
250}
251
252# $target_zone is expected to be a number of hours from GMT
253sub iso2ts {
254    my $app = shift;
255    my($ts, $target_zone) = @_;
256    return unless $ts =~ /^(\d{4})(?:-?(\d{2})(?:-?(\d\d?)(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-]\d{2}:\d{2}))?)?)?)?/;
257    my($y, $mo, $d, $h, $m, $s, $zone) =
258        ($1, $2 || 1, $3 || 1, $4 || 0, $5 || 0, $6 || 0, $7);
259    if ($zone) {
260        my ($zh, $zm) = $zone =~ /([+-]\d\d):(\d\d)/;
261        use Time::Local qw( timegm );
262        my $ts = timegm( $s, $m, $h, $d, $mo - 1, $y - 1900 );
263        if ($zone ne 'Z') {
264            require MT::DateTime;
265            my $tz_secs = MT::DateTime->tz_offset_as_seconds($zone);
266            $ts -= $tz_secs;
267        }
268        if ($target_zone) {
269            my $tz_secs = (3600 * int($target_zone) + 
270                           60 * abs($target_zone - int($target_zone)));
271            $ts += $tz_secs;
272        }
273        ($s, $m, $h, $d, $mo, $y) = gmtime( $ts );
274        $y += 1900; $mo++;
275    }
276    sprintf("%04d%02d%02d%02d%02d%02d", $y, $mo, $d, $h, $m, $s);
277}
278
279sub iso2epoch {
280    my $app = shift;
281    my($ts) = @_;
282    return unless $ts =~ /^(\d{4})(?:-?(\d{2})(?:-?(\d\d?)(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-]\d{2}:\d{2}))?)?)?)?/;
283    my($y, $mo, $d, $h, $m, $s, $zone) =
284        ($1, $2 || 1, $3 || 1, $4 || 0, $5 || 0, $6 || 0, $7);
285
286    use Time::Local;
287    my $dt = timegm($s, $m, $h, $d, $mo-1, $y);
288    if ($zone && $zone ne 'Z') {
289        require MT::DateTime;
290        my $tz_secs = MT::DateTime->tz_offset_as_seconds($zone);
291        $dt -= $tz_secs;
292    }
293    $dt;
294}
295
296package MT::AtomServer::Weblog;
297use strict;
298
299use MT::I18N qw( encode_text );
300use XML::Atom;
301use XML::Atom::Feed;
302use base qw( MT::AtomServer );
303use MT::Blog;
304use MT::Entry;
305use MT::Util qw( encode_xml format_ts );
306use MT::Permission;
307use File::Spec;
308use File::Basename;
309
310use constant NS_APP => 'http://www.w3.org/2007/app';
311use constant NS_DC => 'http://purl.org/dc/elements/1.1/';
312use constant NS_TYPEPAD => 'http://sixapart.com/atom/typepad#';
313
314sub script { $_[0]->{cfg}->AtomScript . '/1.0' }
315
316sub atom_content_type   { 'application/atom+xml' }
317sub atom_x_content_type { 'application/atom+xml' }
318
319sub edit_link_rel { 'edit' }
320sub get_posts_order_field { 'modified_on' }
321
322sub new_feed {
323    my $app = shift;
324    XML::Atom::Feed->new( Version => 1.0 );
325}
326
327sub new_with_entry {
328    my $app = shift;
329    my ($entry) = @_;
330    my $atom = MT::Atom::Entry->new_with_entry( $entry, Version => 1.0 );
331
332    my $mo = MT::Atom::Entry::_create_issued($entry->modified_on, $entry->blog);
333    $atom->set(NS_APP(), 'edited', $mo);
334
335    $atom;
336}
337
338sub apply_basename {
339    my $app = shift;
340    my ($entry, $atom) = @_;
341
342    if (my $basename = $app->get_header('Slug')) {
343        my $entry_class = ref $entry;
344        my $basename_uses = $entry_class->exist({
345            blog_id  => $entry->blog_id,
346            basename => $basename,
347            ($entry->id ? ( id => { op => '!=', value => $entry->id } ) : ()),
348        });
349        if ($basename_uses) {
350            $basename = MT::Util::make_unique_basename($entry);
351        }
352
353        $entry->basename($basename);
354    }
355
356    $entry;
357}
358
359sub handle_request {
360    my $app = shift;
361    $app->authenticate || return;
362    if (my $svc = $app->{param}{svc}) {
363        if ($svc eq 'upload') {
364            return $app->handle_upload;
365        } elsif ($svc eq 'categories') {
366            return $app->get_categories;
367        }
368    }
369    my $method = $app->request_method;
370    if ($method eq 'POST') {
371        return $app->new_post;
372    } elsif ($method eq 'PUT') {
373        return $app->edit_post;
374    } elsif ($method eq 'DELETE') {
375        return $app->delete_post;
376    } elsif ($method eq 'GET') {
377        if ($app->{param}{entry_id}) {
378            return $app->get_post;
379        } elsif ($app->{param}{blog_id}) {
380            return $app->get_posts;
381        } else {
382            return $app->get_weblogs;
383        }
384    }
385}
386
387sub authenticate {
388    my $app = shift;
389
390    $app->SUPER::authenticate or return;
391    if (my $blog_id = $app->{param}{blog_id}) {
392        $app->{blog} = MT::Blog->load($blog_id)
393            or return $app->error(400, "Invalid blog ID '$blog_id'");
394        $app->{user} 
395            or return $app->error(403, "Authenticate");
396        if ($app->{user}->is_superuser()) {
397            $app->{perms} = new MT::Permission;
398            $app->{perms}->blog_id($blog_id);
399            $app->{perms}->author_id($app->{user}->id);
400            $app->{perms}->can_administer_blog(1);
401            return 1;
402        }
403        my $perms = $app->{perms} = MT::Permission->load({
404                    author_id => $app->{user}->id,
405                    blog_id => $app->{blog}->id });
406        return $app->error(403, "Permission denied.") unless $perms && $perms->can_create_post;
407    }
408    1;
409}
410
411sub publish {
412    my $app = shift;
413    my($entry, $no_ping) = @_;
414    my $blog = MT::Blog->load($entry->blog_id)
415        or return;
416    $app->rebuild_entry( Entry => $entry, Blog => $blog,
417                         BuildDependencies => 1 ) or return;
418    unless ($no_ping) {
419        $app->ping_and_save( Entry => $entry, Blog => $blog )
420            or return;
421    }
422    1;
423}
424
425sub get_weblogs {
426    my $app = shift;
427    my $user = $app->{user};
428    my $iter = $user->is_superuser
429        ? MT::Blog->load_iter()
430        : MT::Permission->load_iter({ author_id => $user->id });
431    my $base = $app->base . $app->uri;
432
433    # TODO: libxml support? XPath should always be available...
434    require XML::XPath;
435    require XML::XPath::Node::Element;
436    require XML::XPath::Node::Namespace;
437    require XML::XPath::Node::Text;
438
439    my $doc = XML::XPath::Node::Element->new('service');
440    my $app_ns = XML::XPath::Node::Namespace->new('#default' => NS_APP());
441    $doc->appendNamespace($app_ns);
442    my $atom_ns = XML::XPath::Node::Namespace->new('atom' => 'http://www.w3.org/2005/Atom');
443    $doc->appendNamespace($atom_ns);
444
445    while (my $thing = $iter->()) {
446        # TODO: provide media collection if author can upload to this blog.
447        if ($thing->isa('MT::Permission')) {
448            next if !$thing->can_create_post;
449        }
450
451        my $blog = $thing->isa('MT::Blog') ? $thing
452            : MT::Blog->load($thing->blog_id);
453        next unless $blog;
454        my $uri = $base . '/blog_id=' . $blog->id;
455
456        my $workspace = XML::XPath::Node::Element->new('workspace');
457        $doc->appendChild($workspace);
458
459        my $title = XML::XPath::Node::Element->new('atom:title', 'atom');
460        $title->appendChild(XML::XPath::Node::Text->new($blog->name));
461        $workspace->appendChild($title);
462
463        my $entries = XML::XPath::Node::Element->new('collection');
464        $entries->appendAttribute(XML::XPath::Node::Attribute->new('href', $uri));
465        $workspace->appendChild($entries);
466
467        my $e_title = XML::XPath::Node::Element->new('atom:title', 'atom');
468        $e_title->appendChild(XML::XPath::Node::Text->new(MT->translate('Entries')));
469        $entries->appendChild($e_title);
470
471        my $cats = XML::XPath::Node::Element->new('categories');
472        $cats->appendAttribute(XML::XPath::Node::Attribute->new('href', $uri . '/svc=categories'));
473        $entries->appendChild($cats);
474    }
475    $app->response_code(200);
476    $app->response_content_type('application/atomsvc+xml');
477    '<?xml version="1.0" encoding="utf-8"?>' . "\n" .                                                         
478        $doc->toString;
479}
480
481sub get_categories {
482    my $app = shift;
483    my $blog = $app->{blog};
484
485    # TODO: libxml support? XPath should always be available...
486    require XML::XPath;
487    require XML::XPath::Node::Element;
488    require XML::XPath::Node::Namespace;
489    require XML::XPath::Node::Text;
490
491    my $doc = XML::XPath::Node::Element->new('categories');
492    my $app_ns = XML::XPath::Node::Namespace->new('#default' => NS_APP());
493    $doc->appendNamespace($app_ns);
494    my $atom_ns = XML::XPath::Node::Namespace->new('atom' => 'http://www.w3.org/2005/Atom');
495    $doc->appendNamespace($atom_ns);
496    $doc->appendAttribute(XML::XPath::Node::Attribute->new('fixed', 'yes'));
497
498    my $iter = MT::Category->load_iter({ blog_id => $blog->id });
499    while (my $cat = $iter->()) {
500        my $cat_node = XML::XPath::Node::Element->new('atom:category', 'atom');
501        $cat_node->appendAttribute(XML::XPath::Node::Attribute->new('term', $cat->label));
502        $doc->appendChild($cat_node);
503    }
504
505    $app->response_code(200);
506    $app->response_content_type('application/atomcat+xml');
507    '<?xml version="1.0" encoding="utf-8"?>' . "\n" .                                                         
508        $doc->toString;
509}
510
511sub new_post {
512    my $app = shift;
513    my $atom = $app->atom_body or return $app->error(500, "No body!");
514    my $blog = $app->{blog};
515    my $user = $app->{user};
516    my $perms = $app->{perms};
517    my $enc = $app->config('PublishCharset');
518    ## Check for category in dc:subject. We will save it later if
519    ## it's present, but we want to give an error now if necessary.
520    my($cat);
521    if (my $label = $atom->get(NS_DC, 'subject')) {
522        my $label_enc = encode_text($label,'utf-8',$enc);
523        $cat = MT::Category->load({ blog_id => $blog->id, label => $label_enc })
524            or return $app->error(400, "Invalid category '$label'");
525    }
526
527    my $content = $atom->content;
528    my $type = $content->type; 
529    my $body = encode_text(MT::I18N::utf8_off($content->body),'utf-8',$enc); 
530    my $asset;
531    if ($type && $type !~ m!^application/.*xml$!) {
532        if ($type !~ m!^text/!) {
533            $asset = $app->_upload_to_asset or return;
534        }
535        elsif ($type && $type eq 'text/plain') {
536            ## Check for LifeBlog Note & SMS records.
537            my $format = $atom->get(NS_DC, 'format');
538            if ($format && ($format eq 'Note' || $format eq 'SMS')) {
539                $asset = $app->_upload_to_asset or return;
540            }
541        }
542    }
543    if ( $atom->get(NS_TYPEPAD, 'standalone') && $asset ) {
544        $app->response_code(201);
545        $app->response_content_type('application/atom_xml');
546        my $a = MT::Atom::Entry->new_with_asset($asset);
547        return $a->as_xml; 
548    } 
549
550    my $entry = MT::Entry->new;
551    my $orig_entry = $entry->clone;
552    $entry->blog_id($blog->id);
553    $entry->author_id($user->id);
554    $entry->created_by($user->id);
555    $entry->status($perms->can_publish_post ? MT::Entry::RELEASE() : MT::Entry::HOLD() );
556    $entry->allow_comments($blog->allow_comments_default);
557    $entry->allow_pings($blog->allow_pings_default);
558    $entry->convert_breaks($blog->convert_paras);
559    $entry->title(encode_text($atom->title,'utf-8',$enc));
560    $entry->text(encode_text(MT::I18N::utf8_off($atom->content()->body()),'utf-8',$enc));
561    $entry->excerpt(encode_text($atom->summary,'utf-8',$enc));
562    if (my $iso = $atom->issued) {
563        my $pub_ts = MT::Util::iso2ts($blog, $iso);
564        $entry->authored_on($pub_ts);
565        if ( 0 < MT::DateTime->compare( blog => $blog,
566                a => $pub_ts,
567                b => { value => time(), type => 'epoch' } )
568           )
569        {
570            $entry->status(MT::Entry::FUTURE())
571        }
572    }
573## xxx mt/typepad-specific fields
574    $app->apply_basename($entry, $atom);
575    $entry->discover_tb_from_entry();
576
577    if (my @link = $atom->link) {
578        my $i = 0;
579        my $img_html = '';
580        my $num_links = scalar @link;
581        for my $link (@link) {
582            next unless $link->rel eq 'related';
583            my($asset_id) = $link->href =~ /asset\-(\d+)$/;
584            if ($asset_id) {
585                require MT::Asset;
586                my $a = MT::Asset->load($asset_id);
587                next unless $a;
588                my $pkg = MT::Asset->handler_for_file($a->file_name);
589                my $asset = bless $a, $pkg;
590                $img_html .= $asset->as_html({ include => 1 });
591            }
592        }
593        if ($img_html) {
594            $img_html .= qq{<br style="clear: left;" />\n\n};
595            $entry->text($img_html . $body);
596        }
597    }
598
599    MT->run_callbacks('api_pre_save.entry', $app, $entry, $orig_entry)
600        or return $app->error(500, MT->translate("PreSave failed [_1]", MT->errstr));
601
602    $entry->save or return $app->error(500, $entry->errstr);
603
604    require MT::Log;
605    $app->log({
606        message => $app->translate("User '[_1]' (user #[_2]) added [lc,_4] #[_3]", $user->name, $user->id, $entry->id, $entry->class_label),
607        level => MT::Log::INFO(),
608        class => 'entry',
609        category => 'new',
610        metadata => $entry->id
611    });
612    ## Save category, if present.
613    if ($cat) {
614        my $place = MT::Placement->new;
615        $place->is_primary(1);
616        $place->entry_id($entry->id);
617        $place->blog_id($blog->id);
618        $place->category_id($cat->id);
619        $place->save or return $app->error(500, $place->errstr);
620    }
621
622    MT->run_callbacks('api_post_save.entry', $app, $entry, $orig_entry);
623
624    $app->publish($entry);
625    $app->response_code(201);
626    $app->response_content_type('application/atom+xml');
627    my $edit_uri = $app->base . $app->uri . '/blog_id=' . $entry->blog_id . '/entry_id=' . $entry->id;
628    $app->set_header('Location', $edit_uri);
629    $atom = $app->new_with_entry($entry);
630    $atom->add_link({ rel => $app->edit_link_rel,
631                      href => $edit_uri,
632                      type => 'application/atom+xml',  # even in Legacy
633                      title => $entry->title });
634    $atom->as_xml;
635}
636
637sub edit_post {
638    my $app = shift;
639    my $atom = $app->atom_body or return;
640    my $blog = $app->{blog};
641    my $enc = $app->config('PublishCharset');
642    my $entry_id = $app->{param}{entry_id}
643        or return $app->error(400, "No entry_id");
644    my $entry = MT::Entry->load($entry_id)
645        or return $app->error(400, "Invalid entry_id");
646    return $app->error(403, "Access denied")
647        unless $app->{perms}->can_edit_entry($entry, $app->{user});
648    my $orig_entry = $entry->clone;
649    $entry->title(encode_text($atom->title,'utf-8',$enc));
650    $entry->text(encode_text(MT::I18N::utf8_off($atom->content()->body()),'utf-8',$enc));
651    $entry->excerpt(encode_text($atom->summary,'utf-8',$enc));
652    $entry->modified_by($app->{user}->id);
653    if (my $iso = $atom->issued) {
654        my $pub_ts = MT::Util::iso2ts($blog, $iso);
655        $entry->authored_on($pub_ts);
656        if ( 0 < MT::DateTime->compare( blog => $blog,
657                a => $pub_ts,
658                b => { value => time(), type => 'epoch' } )
659           )
660        {
661            $entry->status(MT::Entry::FUTURE())
662        }
663    }
664## xxx mt/typepad-specific fields
665    $app->apply_basename($entry, $atom);
666    $entry->discover_tb_from_entry();
667
668    MT->run_callbacks('api_pre_save.entry', $app, $entry, $orig_entry)
669        or return $app->error(500, MT->translate("PreSave failed [_1]", MT->errstr));
670
671    $entry->save or return $app->error(500, "Entry not saved");
672
673    require MT::Log;
674    $app->log({
675        message => $app->translate("User '[_1]' (user #[_2]) edited [lc,_4] #[_3]", $app->{user}->name, $app->{user}->id, $entry->id, $entry->class_label),
676        level => MT::Log::INFO(),
677        class => 'entry',
678        category => 'new',
679        metadata => $entry->id
680    });
681
682    MT->run_callbacks('api_post_save.entry', $app, $entry, $orig_entry);
683
684    if ($entry->status == MT::Entry::RELEASE()) {
685        $app->publish($entry) or return $app->error(500, "Entry not published");
686    }
687    $app->response_code(200);
688    $app->response_content_type($app->atom_content_type);
689    $atom = $app->new_with_entry($entry);
690    $atom->as_xml;
691}
692
693sub get_posts {
694    my $app = shift;
695    my $blog = $app->{blog};
696    my %terms = (blog_id => $blog->id);
697    my %arg = (sort => $app->get_posts_order_field, direction => 'descend');
698    my $Limit = 20;
699    $arg{limit} = $Limit + 1;
700    $arg{offset} = $app->{param}{offset} || 0;
701    my $iter = MT::Entry->load_iter(\%terms, \%arg);
702    my $feed = $app->new_feed();
703    my $uri = $app->base . $app->uri . '/blog_id=' . $blog->id;
704    my $blogname = encode_text($blog->name, undef, 'utf-8');
705    $feed->add_link({ rel => 'alternate', type => 'text/html',
706                      href => $blog->site_url });
707    $feed->add_link({ rel => 'self', type => $app->atom_x_content_type,
708                      href => $uri });
709    $feed->title($blogname);
710    # FIXME: move the line to the Legacy class
711    if ( !$feed->version || ( $feed->version < 1.0 ) ) {
712        $feed->add_link({ rel => 'service.post', type => $app->atom_x_content_type,
713                          href => $uri, title => $blogname });
714    }
715    require URI;
716    my $site_uri = URI->new($blog->site_url);
717    if ( $site_uri ) {
718        my $blog_created = format_ts('%Y-%m-%d', $blog->created_on, $blog, 'en', 0);
719        my $id = 'tag:'.$site_uri->host.','.$blog_created.':'.$site_uri->path.'/'.$blog->id;
720        $feed->id($id);
721    }
722    my $latest_date = 0;
723    $uri .= '/entry_id=';
724    my @entries;
725    while (my $entry = $iter->()) {
726        my $e = $app->new_with_entry($entry);
727        $e->add_link({ rel => $app->edit_link_rel, type => $app->atom_x_content_type,
728                       href => ($uri . $entry->id), title => encode_text($entry->title, undef,'utf-8') });
729        # feed/updated should be added before entries
730        # so we postpone adding them until later
731        push @entries, $e;
732        my $date = $entry->modified_on || $entry->authored_on;
733        if ( $latest_date < $date ) {
734            $latest_date = $date;
735            $feed->updated( $e->updated );
736        }
737    }
738    $feed->add_entry($_) foreach @entries;
739    ## xxx add next/prev links
740    $app->response_content_type($app->atom_content_type);
741    $feed->as_xml;
742}
743
744sub get_post {
745    my $app = shift;
746    my $blog = $app->{blog};
747    my $entry_id = $app->{param}{entry_id}
748        or return $app->error(400, "No entry_id");
749    my $entry = MT::Entry->load($entry_id)
750        or return $app->error(400, "Invalid entry_id");
751    return $app->error(403, "Access denied")
752        unless $app->{perms}->can_edit_entry($entry, $app->{user});
753    $app->response_content_type($app->atom_content_type);
754    my $atom = $app->new_with_entry($entry);
755    my $uri = $app->base . $app->uri . '/blog_id=' . $blog->id;
756    $uri .= '/entry_id=';
757    $atom->add_link({ rel => $app->edit_link_rel, type => $app->atom_x_content_type,
758                      href => ($uri . $entry->id), title => encode_text($entry->title, undef,'utf-8') });
759    $atom->as_xml;
760}
761
762sub delete_post {
763    my $app = shift;
764    my $blog = $app->{blog};
765    my $entry_id = $app->{param}{entry_id}
766        or return $app->error(400, "No entry_id");
767    my $entry = MT::Entry->load($entry_id)
768        or return $app->error(400, "Invalid entry_id");
769    return $app->error(403, "Access denied")
770        unless $app->{perms}->can_edit_entry($entry, $app->{user});
771    $entry->remove
772        or return $app->error(500, $entry->errstr);
773    $app->publish($entry, 1) or return $app->error(500, $app->errstr);
774    '';
775}
776
777sub _upload_to_asset {
778    my $app = shift;
779    my $atom = $app->atom_body or return;
780    my $blog = $app->{blog};
781    my $user = $app->{user};
782    my %MIME2EXT = (
783        'text/plain'         => '.txt',
784        'image/jpeg'         => '.jpg',
785        'video/3gpp'         => '.3gp',
786        'application/x-mpeg' => '.mpg',
787        'video/mp4'          => '.mp4',
788        'video/quicktime'    => '.mov',
789        'audio/mpeg'         => '.mp3',
790        'audio/x-wav'        => '.wav',
791        'audio/ogg'          => '.ogg',
792        'audio/ogg-vorbis'   => '.ogg',
793    );
794
795    return $app->error(403, "Access denied") unless $app->{perms}->can_upload;
796    my $content = $atom->content;
797    my $type = $content->type
798        or return $app->error(400, "content \@type is required");
799    my $fname = $atom->title or return $app->error(400, "title is required");
800    $fname = basename($fname);
801    return $app->error(400, "Invalid or empty filename")
802        if $fname =~ m!/|\.\.|\0|\|!;
803
804    my $local_relative = File::Spec->catfile('%r', $fname);
805    my $local = File::Spec->catfile($blog->site_path, $fname);
806    my $fmgr = $blog->file_mgr;
807    my($base, $path, $ext) = File::Basename::fileparse($local, '\.[^\.]*');
808    $ext = $MIME2EXT{$type} unless $ext;
809    my $base_copy = $base;
810    my $ext_copy = $ext;
811    $ext_copy =~ s/\.//;
812    my $i = 1;
813    while ($fmgr->exists($path . $base . $ext)) {
814        $base = $base_copy . '_' . $i++;
815    }
816    $local = $path . $base . $ext;
817    my $data = $content->body;
818    defined(my $bytes = $fmgr->put_data($data, $local, 'upload'))
819        or return $app->error(500, "Error writing uploaded file");
820
821    eval { require Image::Size; };
822    return $app->error(500, MT->translate("Perl module Image::Size is required to determine width and height of uploaded images.")) if $@;
823    my ( $w, $h, $id ) = Image::Size::imgsize($local);
824
825    require MT::Asset;
826    my $asset_pkg = MT::Asset->handler_for_file($local);
827    my $is_image  = defined($w)
828      && defined($h)
829      && $asset_pkg->isa('MT::Asset::Image');
830    my $asset;
831    if (!($asset = $asset_pkg->load(
832                { file_path => $local, blog_id => $blog->id })))
833    {
834        $asset = $asset_pkg->new();
835        $asset->file_path($local_relative);
836        $asset->file_name($base.$ext);
837        $asset->file_ext($ext_copy);
838        $asset->blog_id($blog->id);
839        $asset->created_by( $user->id );
840    }
841    else {
842        $asset->modified_by( $user->id );
843    }
844    my $original = $asset->clone;
845    my $url = '%r/' . $base . $ext;
846    $asset->url($url);
847    if ($is_image) {
848        $asset->image_width($w);
849        $asset->image_height($h);
850    }
851    $asset->mime_type($type);
852    $asset->save;
853
854    MT->run_callbacks(
855        'api_upload_file.' . $asset->class,
856        File => $local, file => $local,
857        Url => $url, url => $url,
858        Size => $bytes, size => $bytes,
859        Asset => $asset, asset => $asset,
860        Type => $asset->class, type => $asset->class,
861        Blog => $blog, blog => $blog);
862    if ($is_image) {
863        MT->run_callbacks(
864            'api_upload_image',
865            File => $local, file => $local,
866            Url => $url, url => $url,
867            Size => $bytes, size => $bytes,
868            Asset => $asset, asset => $asset,
869            Height => $h, height => $h,
870            Width => $w, width => $w,
871            Type => 'image', type => 'image',
872            ImageType => $id, image_type => $id,
873            Blog => $blog, blog => $blog);
874    }
875
876    $asset;
877}
878
879sub handle_upload {
880    my $app = shift;
881    my $blog = $app->{blog};
882   
883    my $asset = $app->_upload_to_asset or return;
884
885    my $link = XML::Atom::Link->new;
886    $link->type($asset->mime_type);
887    $link->rel('alternate');
888    $link->href($asset->url);
889    my $atom = XML::Atom::Entry->new;
890    $atom->title($asset->file_name);
891    $atom->add_link($link);
892    $app->response_code(201);
893    $app->response_content_type('application/x.atom+xml');
894    $atom->as_xml;
895}
896
897package MT::AtomServer::Weblog::Legacy;
898use strict;
899
900use base qw( MT::AtomServer::Weblog );
901
902use MT::I18N qw( encode_text );
903use XML::Atom;  # for LIBXML
904use XML::Atom::Feed;
905use base qw( MT::AtomServer );
906use MT::Blog;
907use MT::Permission;
908
909use constant NS_CATEGORY => 'http://sixapart.com/atom/category#';
910use constant NS_DC => MT::AtomServer::Weblog->NS_DC();
911
912sub script { $_[0]->{cfg}->AtomScript . '/weblog' }
913
914sub atom_content_type   { 'application/xml' }
915sub atom_x_content_type { 'application/x.atom+xml' }
916
917sub edit_link_rel { 'service.edit' }
918sub get_posts_order_field { 'authored_on' }
919
920sub new_feed {
921    my $app = shift;
922    XML::Atom::Feed->new();
923}
924
925sub new_with_entry {
926    my $app = shift;
927    my ($entry) = @_;
928    MT::Atom::Entry->new_with_entry($entry);
929}
930
931sub apply_basename {}
932
933sub get_weblogs {
934    my $app = shift;
935    my $user = $app->{user};
936    my $iter = $user->is_superuser
937        ? MT::Blog->load_iter()
938        : MT::Permission->load_iter({ author_id => $user->id });
939    my $feed = $app->new_feed();
940    my $base = $app->base . $app->uri;
941    require URI;
942    my $uri = URI->new($base);
943    if ( $uri ) {
944        my $created = MT::Util::format_ts('%Y-%m-%d', $user->created_on, undef, 'en', 0);
945        my $id = 'tag:'.$uri->host.','.$created.':'.$uri->path.'/weblogs-'.$user->id;
946        $feed->id($id);
947    }
948    while (my $thing = $iter->()) {
949        if ($thing->isa('MT::Permission')) {
950            next unless $thing->can_create_post;
951        }
952        my $blog = $thing->isa('MT::Blog') ? $thing
953            : MT::Blog->load($thing->blog_id);
954        next unless $blog;
955        my $uri = $base . '/blog_id=' . $blog->id;
956        my $blogname = encode_text($blog->name . ' #' . $blog->id, undef, 'utf-8');
957        $feed->add_link({ rel => 'service.post', title => $blogname,
958                          href => $uri, type => 'application/x.atom+xml' });
959        $feed->add_link({ rel => 'service.feed', title => $blogname,
960                          href => $uri, type => 'application/x.atom+xml' });
961        $feed->add_link({ rel => 'service.upload', title => $blogname,
962                          href => $uri . '/svc=upload',
963                          type => 'application/x.atom+xml' });
964        $feed->add_link({ rel => 'service.categories', title => $blogname,
965                          href => $uri . '/svc=categories',
966                          type => 'application/x.atom+xml' });
967        $feed->add_link({ rel => 'alternate', title => $blogname,
968                          href => $blog->site_url,
969                          type => 'text/html' });
970    }
971    $app->response_code(200);
972    $app->response_content_type('application/x.atom+xml');
973    $feed->as_xml;
974}
975
976sub get_categories {
977    my $app = shift;
978    my $blog = $app->{blog};
979    my $iter = MT::Category->load_iter({ blog_id => $blog->id });
980    my $doc;
981    if (LIBXML) {
982        $doc = XML::LibXML::Document->createDocument('1.0', 'utf-8');
983        my $root = $doc->createElementNS(NS_CATEGORY, 'categories');
984        $doc->setDocumentElement($root);
985    } else {
986        $doc = XML::XPath::Node::Element->new('categories');
987        my $ns = XML::XPath::Node::Namespace->new('#default' => NS_CATEGORY);
988        $doc->appendNamespace($ns);
989    }
990    while (my $cat = $iter->()) {
991        my $catlabel = encode_text($cat->label, undef, 'utf-8');
992        if (LIBXML) {
993            my $elem = $doc->createElementNS(NS_DC, 'subject');
994            $doc->getDocumentElement->appendChild($elem);
995            $elem->appendChild(XML::LibXML::Text->new($catlabel));
996        } else {
997            my $elem = XML::XPath::Node::Element->new('subject');
998            my $ns = XML::XPath::Node::Namespace->new('#default' => NS_DC);
999            $elem->appendNamespace($ns);
1000            $doc->appendChild($elem);
1001            $elem->appendChild(XML::XPath::Node::Text->new($catlabel));
1002        }
1003    }
1004    $app->response_code(200);
1005    $app->response_content_type('application/x.atom+xml');
1006    if (LIBXML) {
1007        $doc->toString(1);
1008    } else {
1009        return '<?xml version="1.0" encoding="utf-8"?>' . "\n" . $doc->toString;
1010    }
1011}
1012
10131;
1014__END__
1015
1016=head1 NAME
1017
1018MT::AtomServer
1019
1020=head1 SYNOPSIS
1021
1022An Atom Publishing API interface for communicating with Movable Type.
1023
1024=head1 METHODS
1025
1026=head2 $app->xml_body()
1027
1028Takes the content posted to the server and parses it into an XML document.
1029Uses either XML::LibXML or XML::XPath depending on which is available.
1030
1031=head2 $app->iso2epoch($iso_ts)
1032
1033Converts C<$iso_ts> in the format of an ISO timestamp into a unix timestamp
1034(seconds since the epoch).
1035
1036=head2 $app->init
1037
1038Initializes the application.
1039
1040=head2 $app->get_auth_info
1041
1042Processes the request for WSSE authentication and returns a hash containing:
1043
1044=over 4
1045
1046=item * Username
1047
1048=item * PasswordDigest
1049
1050=item * Nonce
1051
1052=item * Created
1053
1054=back
1055
1056=head2 $app->handle_request
1057
1058The implementation of this in I<MT::AtomServer::Weblog> passes the request
1059to the proper method.
1060
1061=head2 $app->handle
1062
1063Wrapper method that determines the proper AtomServer package to pass the
1064request to.
1065
1066=head2 $app->iso2ts($iso_ts, $target_zone)
1067
1068Converts C<$iso_ts> in the format of an ISO timestamp into a MT-compatible
1069timestamp (YYYYMMDDHHMMSS) for the specified timezone C<$target_zone>.
1070
1071=head2 $app->atom_body
1072
1073Processes the request as Atom content and returns an XML::Atom object.
1074
1075=head2 $app->error($code, $message)
1076
1077Sends the HTTP headers necessary to relay an error.
1078
1079=head2 $app->authenticate()
1080
1081Checks the WSSE authentication with the local MT user database and
1082confirms the user is authorized to access the resources required by
1083the request.
1084
1085=head2 $app->show_error($message)
1086
1087Returns an XML wrapper for the error response.
1088
1089=head2 $app->auth_failure($code, $message)
1090
1091Handles the response in the event of an authentication failure.
1092
1093=head1 CALLBACKS
1094
1095=over 4
1096
1097=item api_pre_save.entry
1098
1099    callback($eh, $app, $entry, $original_entry)
1100
1101Called before saving a new or existing entry. If saving a new entry, the
1102$original_entry will have an unassigned 'id'. This callback is executed
1103as a filter, so your handler must return 1 to allow the entry to be saved.
1104
1105=item api_post_save.entry
1106
1107    callback($eh, $app, $entry, $original_entry)
1108
1109Called after saving a new or existing entry. If saving a new entry, the
1110$original_entry will have an unassigned 'id'.
1111
1112=back
1113
1114=cut
Note: See TracBrowser for help on using the browser.