Changeset 27 for trunk/openid-comment
- Timestamp:
- 04/23/06 08:17:13 (4 years ago)
- Location:
- trunk/openid-comment
- Files:
-
- 11 added
- 6 modified
-
Changes (modified) (1 diff)
-
LICENSE (modified) (1 diff)
-
README (modified) (3 diffs)
-
php (added)
-
php/plugins (added)
-
php/plugins/function.MTCommentAuthorPictureURL.php (added)
-
php/plugins/function.MTLiveJournalSignOnThunk.php (added)
-
php/plugins/function.MTOpenIDSignOnThunk.php (added)
-
php/plugins/function.MTOpenIDSignOnURL.php (added)
-
php/plugins/init.openid-comment.php (added)
-
plugins/openid-comment/lib/MT (added)
-
plugins/openid-comment/lib/MT/Plugin (added)
-
plugins/openid-comment/lib/MT/Plugin/OpenIDIdentity.pm (added)
-
plugins/openid-comment/openid-comment.pl (modified) (6 diffs)
-
plugins/openid-comment/signon.cgi (modified) (6 diffs)
-
plugins/openid-comment/tmpl/blog_config.tmpl (modified) (1 diff)
-
pm_to_blib (added)
Legend:
- Unmodified
- Added
- Removed
-
trunk/openid-comment/Changes
r12 r27 1 1 Revision history for OpenID Comments for MT 2 2 3 1.6 14 April 2006 4 Provide commenters' depictions from their FOAF documents, if available, 5 with a CommentAuthorPictureURL tag. 6 Option to disable inline CSS styles in the SignOnThunk tags. 7 Using the "sign out" link no longer signs OpenID users out of TypeKey. 8 1.5 14 November 2005 9 Use commenters' names in FOAF and Atom documents, if available. 10 1.4 24 October 2005 11 Dynamic publishing support 12 Save new schema version properly after upgrade 13 1.3 24 September 2005 14 Add support for MySQL and Postgres databases 15 Use BigPAPI callbacks to fix List Commenters and Edit Commenter views 16 Unify asset path building in CommentAuthorIdentity and OpenIDSignOnThunk 17 Fix error with Blogger and Atom API posting 18 Error earlier when blog requires commenters' email addresses 19 Use special LJ handling for domain and tilde URLs 20 Improve quality and stylability of thunk HTML 3 21 1.2 16 August 2005 4 22 Don't assume availability of Digest::MD5 -
trunk/openid-comment/LICENSE
r12 r27 1 OpenID Comment 1.0for Movable Type1 OpenID Comments for Movable Type 2 2 Mark Paschal <markpasc@markpasc.org> 3 3 -
trunk/openid-comment/README
r12 r27 1 1 OpenID Comments for MT 2 2 3 This is a Movable Type plugin that empowers commenters on your 4 Movable Typesite to log in using OpenID.3 This is a Movable Type plugin that empowers commenters on your Movable Type 4 site to log in using OpenID. 5 5 6 6 http://www.sixapart.com/movabletype/ … … 35 35 <MTOpenIDSignOnThunk> 36 36 37 4. If "Require E-mail Addresses" is enabled on blogs that should 38 accept OpenID sign-ins, disable it. The OpenID protocol does 39 not provide commenters' email addresses, so this option is 40 incompatible with OpenID sign-ins. 41 42 43 CONFIGURATION 44 45 If the <MTOpenIDSignOnThunk> tag does not meet your needs, you can insert the 46 necessary HTML into your templates yourself and customize it. The HTML is: 47 48 <div id="openid"> 49 <form method="post" action="<MTOpenIDSignOnURL>"> 50 <input type="hidden" name="entry_id" value="<MTEntryID>" /> 51 <input type="hidden" name="__mode" value="signon" /> 52 <p> 53 <label>Your blog URL:</label> 54 <input name="openid_url" size="35" value="" /> 55 <input type="submit" value="Sign in" /> 56 </p> 57 </form> 58 </div> 59 60 There is also an <MTLiveJournalSignOnThunk> tag that allows your visitors to 61 sign in with OpenID by entering just their LiveJournal usernames. 62 37 63 38 64 COPYRIGHT AND LICENCE … … 40 66 Copyright 2005 Mark Paschal <markpasc@markpasc.org> 41 67 42 This library is free software; you can redistribute it and/or modify 43 it underthe same terms as Perl itself.68 This library is free software; you can redistribute it and/or modify it under 69 the same terms as Perl itself. 44 70 -
trunk/openid-comment/plugins/openid-comment/openid-comment.pl
r12 r27 6 6 package MT::Plugin::OpenIDComment; 7 7 8 9 MT::Template::Context->add_tag(CommentAuthorIdentity => \&identity_link); 10 MT::Template::Context->add_tag(OpenIDSignOnURL => \&signon_url); 11 MT::Template::Context->add_tag(OpenIDSignOnThunk => \&signon_thunk); 12 MT::Template::Context->add_tag(LiveJournalSignOnThunk => \&signon_thunk); 13 8 our $VERSION = '1.6'; 14 9 15 10 our $plugin_obj = MT::Plugin->new({ 16 11 name => 'OpenID Comments', 17 version => 1.2,12 version => $VERSION, 18 13 description => 'Empowers commenters to sign in with OpenID to comment', 19 14 20 15 settings => MT::PluginSettings->new([ 21 ['enable', { Default => 1, Scope => 'blog' }], 22 ['special_lj', { Default => 1, Scope => 'blog' }], 16 ['enable', { Default => 1, Scope => 'blog' }], 17 ['special_lj', { Default => 1, Scope => 'blog' }], 18 ['inline_style', { Default => 1, Scope => 'blog' }], 23 19 ]), 24 20 blog_config_template => 'blog_config.tmpl', … … 26 22 MT->add_plugin($plugin_obj); 27 23 24 sub instance { $plugin_obj; } 25 26 27 MT::Template::Context->add_tag(CommentAuthorIdentity => \&identity_link); 28 MT::Template::Context->add_tag(CommentAuthorPictureURL => \&identity_pic_url); 29 MT::Template::Context->add_tag(OpenIDSignOnURL => \&signon_url); 30 MT::Template::Context->add_tag(OpenIDSignOnThunk => \&signon_thunk); 31 MT::Template::Context->add_tag(LiveJournalSignOnThunk => \&signon_thunk); 32 33 sub schema_version { 2 } 28 34 29 35 sub identity_link { … … 35 41 ## then OpenID is disabled. 36 42 if($cmt->commenter_id) { 43 require MT::Author; 37 44 my $auth = MT::Author->load($cmt->commenter_id) or return "?"; 38 45 my $name = $auth->name; 39 if($name =~ m(^openid\n (.*)$)) {46 if($name =~ m(^openid\n)) { 40 47 my $cfg = MT::ConfigMgr->instance; 41 my $assets_path = ($cfg->StaticWebPath || $cfg->CGIPath) . 'openid-comment'; 42 my $identity = $1; 43 if($config->{special_lj} && $identity =~ m(^http://www.livejournal.com/users/([^/]+))) { 44 return qq(<a class="commenter-profile" href="${identity}info"><img alt="[LiveJournal user info]" src="$assets_path/livejournal.gif" width="17" height="17" /></a>); 48 my $assets_path = MT::Template::Context::_hdlr_static_path($ctx, {}) . 'openid-comment'; 49 50 require MT::Plugin::OpenIDIdentity; 51 my $id = MT::Plugin::OpenIDIdentity->load({ author_id => $auth->id }); 52 my $identity = $id->url; 53 54 if($config->{special_lj}) { 55 if( $identity =~ m(^http://www\.livejournal\.com\/users/[^/]+/$) || 56 $identity =~ m(^http://www\.livejournal\.com\/~[^/]+/$) || 57 $identity =~ m(^http://[^\.]+\.livejournal\.com\/$) 58 ) { 59 return qq(<a class="commenter-profile" href="${identity}info"><img alt="[LiveJournal user info]" src="$assets_path/livejournal.gif" width="17" height="17" /></a>); 60 } 45 61 } 46 62 return qq(<a class="commenter-profile" href="$identity"><img alt="[OpenID Commenter Profile]" src="$assets_path/openid.gif" width="16" height="16" /></a>); … … 50 66 } 51 67 68 sub identity_pic_url { 69 my ($ctx, $args) = @_; 70 my $cmt = $ctx->stash('comment') 71 or return $ctx->_no_comment_error('MT' . $ctx->stash('tag')); 72 my $config = $plugin_obj->get_config_hash('blog:'. $ctx->stash('blog_id')); 73 ## Don't check for enablement here, so we can handle if OpenID comments are made, 74 ## then OpenID is disabled. 75 if($cmt->commenter_id) { 76 require MT::Author; 77 my $auth = MT::Author->load($cmt->commenter_id) or return "?"; 78 my $name = $auth->name; 79 if($name =~ m(^openid\n)) { 80 require MT::Plugin::OpenIDIdentity; 81 my $id = MT::Plugin::OpenIDIdentity->load({ author_id => $auth->id }); 82 83 if(my $pic_url = $id->pic_url) { 84 return $pic_url; 85 } 86 } 87 } 88 return $args->{default} || ''; 89 } 90 52 91 sub signon_url { 53 92 my ($ctx, $args) = @_; 54 93 my $cgipath = $ctx->_hdlr_cgi_path; 55 "${cgipath}plugins/openid-comment/signon.cgi";94 return "${cgipath}plugins/openid-comment/signon.cgi"; 56 95 } 57 96 … … 59 98 my ($ctx, $args) = @_; 60 99 my $signon_url = signon_url($ctx, $args); 61 my $assets_path = MT ->instance->static_path. 'openid-comment';100 my $assets_path = MT::Template::Context::_hdlr_static_path($ctx, {}) . 'openid-comment'; 62 101 my $entry_id = $ctx->stash('entry')->id; 63 102 64 103 my $livejournal = $ctx->stash('tag') eq 'LiveJournalSignOnThunk' ? 1 : 0; 65 104 my $field_label = $livejournal ? 'Your LiveJournal username' : 'Your blog URL'; 66 my $field_name = $livejournal ? ' user' : 'openid_url';105 my $field_name = $livejournal ? 'lj_user' : 'openid_url'; 67 106 my $image_name = $livejournal ? 'livejournal' : 'openid'; 107 108 my $style = $plugin_obj->get_config_value('inline_style', 'blog:' . $ctx->stash('blog')->id) 109 ? qq( style="background:white url($assets_path/$image_name.gif) no-repeat; padding-left: 18px;") 110 : '' 111 ; 68 112 69 113 <<EOF; 70 <div id=" openid">114 <div id="signon-$image_name"> 71 115 <form method="post" action="$signon_url"> 72 116 <input type="hidden" name="entry_id" value="$entry_id" /> 73 117 <input type="hidden" name="__mode" value="signon" /> 74 118 <p> 75 <label >$field_label: <input name="$field_name" size="30" value="" style="background: url($assets_path/$image_name.gif) no-repeat; padding-left: 18px;" /></label>76 <input type="submit" value="Sign in" />119 <label for="$field_name">$field_label:</label> <input class="identity" id="$field_name" name="$field_name" size="30" value=""$style /> 120 <input class="signin" type="submit" value="Sign in" /> 77 121 </p> 78 122 </form> … … 81 125 } 82 126 127 my $original_handle_sign_in = \&MT::App::Comments::handle_sign_in; 128 *MT::App::Comments::handle_sign_in = sub { 129 my $app = shift; 130 my $q = $app->{query}; 131 132 if($q->param('logout') && $q->param('entry_id')) { 133 my (undef, $commenter) = $app->_get_commenter_session(); 134 if($commenter->name =~ m{ \A openid\n }xms) { 135 ## Remove cookie. 136 my $entry = MT::Entry->load($q->param('entry_id')) 137 or die "No such entry"; 138 my $weblog = MT::Blog->load($q->param('blog_id') || $entry->blog_id); 139 $app->_handle_sign_in($weblog); 140 141 ## But don't redirect through TypeKey. 142 143 return $app->redirect($entry->permalink); 144 } 145 } 146 147 return $original_handle_sign_in->($app); 148 }; 149 150 MT->add_callback('bigpapi::template::list_commenters', 5, $plugin_obj, sub { 151 my ($cb, $app, $template) = @_; 152 $$template =~ s((?<=<TMPL_VAR NAME=PROFILE_PAGE>)/)()s; 153 }); 154 155 sub _fix_identity { 156 my ($author_id, $blog_id) = @_; 157 my ($author_name, $author_url, $is_livejournal); 158 159 require MT::Plugin::OpenIDIdentity; 160 my $id = MT::Plugin::OpenIDIdentity->load({ author_id => $author_id }) 161 or return $app->error('No OpenID identity for openid\n author'); 162 my $identity = $id->url; 163 164 my $profile_url = $identity; 165 my $special_lj = $plugin_obj->get_config_value('special_lj', 'blog:' . $blog_id); 166 if($special_lj && ( 167 $identity =~ m(^http://www\.livejournal\.com\/users/[^/]+/$) || 168 $identity =~ m(^http://www\.livejournal\.com\/~[^/]+/$) || 169 $identity =~ m(^http://[^\.]+\.livejournal\.com\/$) 170 )) { 171 $profile_url .= 'info'; 172 } 173 174 my $display_url = $identity; 175 $display_url =~ s(^\w+://)(); 176 $display_url =~ s((?<=^.{40}).+)(...)s; 177 178 return ($display_url, $profile_url); 179 } 180 181 MT->add_callback('bigpapi::param::list_commenters', 5, $plugin_obj, sub { 182 my ($cb, $app, $param) = @_; 183 184 my $special_lj = $plugin_obj->get_config_value('special_lj', 'blog:' . $param->{blog_id}); 185 186 for my $table (@{ $param->{commenter_table} }) { 187 for my $cmtr (@{ $table->{object_loop} }) { 188 if($cmtr->{author} =~ m(^openid\n)) { 189 ($cmtr->{author}, $cmtr->{profile_page}) = _fix_identity( 190 $cmtr->{author_id}, $param->{blog_id}); 191 } 192 } 193 } 194 }); 195 196 MT->add_callback('bigpapi::param::edit_commenter', 5, $plugin_obj, sub { 197 my ($cb, $app, $param) = @_; 198 if($param->{name} =~ m(^openid\n)) { 199 ($param->{name}, $param->{profile_page}) = _fix_identity( 200 $param->{id}, $param->{blog_id}); 201 } 202 }); 203 83 204 84 205 1; -
trunk/openid-comment/plugins/openid-comment/signon.cgi
r12 r27 41 41 42 42 use Net::OpenID::Consumer; 43 use XML::XPath; 43 44 44 45 sub init { … … 47 48 48 49 $app->add_methods( 49 oops => \&oops,50 oops => \&oops, 50 51 signon => \&signon, 51 52 verify => \&verify, … … 76 77 77 78 my $identity = $q->param('openid_url'); 78 if(!$identity && $q->param(' user')) {79 $identity = 'http://www.livejournal.com/users/' . $q->param(' user');79 if(!$identity && $q->param('lj_user')) { 80 $identity = 'http://www.livejournal.com/users/' . $q->param('lj_user'); 80 81 } 81 82 … … 93 94 } 94 95 96 sub _rand { 97 my ($app) = @_; 98 $app->{__have_md5} = (eval { require Digest::MD5; 1 } ? 1 : 0) 99 unless exists $app->{__have_md5}; 100 $app->{__have_md5} ? substr(rand(), 2) : 101 Digest::MD5::md5_hex(Digest::MD5::md5_hex(time() . {} . rand() . $$)); 102 } 103 104 sub add_step { 105 my ($app, @step) = @_; 106 push @{ $app->{__upgrade_steps} }, \@step; 107 } 108 109 sub progress { 1 } 110 111 sub do_upgrade { 112 my $app = shift; 113 my $pl = MT::Plugin::OpenIDComment->instance; 114 my $schema = $pl->get_config_value('schema_version', 'system') || 0; 115 my $real_version = MT::Plugin::OpenIDComment->schema_version; 116 if($schema < $real_version) { 117 ## Make schema changes with MT::Upgrade. 118 { 119 require MT::Upgrade; # feel the burn 120 my $upg = MT::Upgrade->new; 121 local $MT::Upgrade::App = $app; 122 $upg->check_class('MT::Plugin::OpenIDIdentity'); 123 $upg->run_step($_) for @{ $app->{__upgrade_steps} }; 124 }; 125 126 ## Make data changes. 127 if($schema < 1) { 128 ## Delete duplicate OpenIDIdentities that don't contain a real URL. 129 my $id_iter = MT::Plugin::OpenIDIdentity->load_iter; 130 my @to_delete; 131 IDENTITY: 132 while(my $id = $id_iter->()) { 133 next IDENTITY if $id->url =~ m{ \A http }xms; 134 push @to_delete, $id; 135 } 136 $_->remove for @to_delete; 137 138 ## Make OpenIDIdentities for any openid Author who successfully 139 ## left a comment and doesn't already have one. 140 my $au_iter = MT::Author->load_iter({ type => MT::Author::COMMENTER }); 141 AUTHOR: 142 while(my $author = $au_iter->()) { 143 $author->name =~ m{ \A openid \n (.*) }xms; 144 my $url = $1; 145 146 next AUTHOR if !$url; 147 next AUTHOR if $url !~ m{ \A http }xms; 148 next AUTHOR if !MT::Comment->count({ commenter_id => $author->id }); 149 next AUTHOR if MT::Plugin::OpenIDIdentity->load({ 150 author_id => $author->id }); 151 152 my $id = MT::Plugin::OpenIDIdentity->new; 153 $id->url($url); 154 $id->author_id($author->id); 155 $id->save or return $app->error($id->errstr); 156 } 157 } 158 159 ## All done! Record the upgrade. 160 $pl->set_config_value('schema_version', $real_version, 'system'); 161 MT::log('OpenID Comments upgraded its database schema to schema version ' . 162 $pl->get_config_value('schema_version') .' (plugin version '. 163 MT::Plugin::OpenIDComment->instance->version . ')'); 164 } 165 } 166 167 sub _get_profile_data { 168 my ($app, $vident, $blog_id) = @_; 169 170 my $ua = eval { require LWPx::ParanoidAgent; 1; } 171 ? LWPx::ParanoidAgent->new 172 : LWP::UserAgent->new 173 ; 174 175 my $profile = {}; 176 177 ## FOAF 178 if(my $foaf_url = $vident->declared_foaf) { 179 MT::log('FOUND A FOAF URL'); 180 my $resp = $ua->get($foaf_url); 181 if($resp->is_success) { 182 MT::log('GOT SOME FOAF'); 183 my $xml = XML::XPath->new( xml => $resp->content ); 184 $xml->set_namespace('RDF', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'); 185 $xml->set_namespace('FOAF', 'http://xmlns.com/foaf/0.1/'); 186 if(my ($name_el) = $xml->findnodes('/RDF:RDF/FOAF:Person/FOAF:name')) { 187 $profile->{nickname} = $name_el->string_value; 188 } 189 if(my ($pic_el) = $xml->findnodes('/RDF:RDF/FOAF:Person/FOAF:depiction/@RDF:resource')) { 190 $profile->{pic_url} = $pic_el->string_value; 191 } elsif(my ($pic_el) = $xml->findnodes('/RDF:RDF/FOAF:Person/FOAF:img/@RDF:resource')) { 192 $profile->{pic_url} = $pic_el->string_value; 193 } 194 $xml->cleanup; 195 } 196 197 return $profile if $profile->{nickname} && $profile->{pic_url}; 198 } 199 200 ## Atom 201 if(my $atom_url = $vident->declared_atom) { 202 my $resp = $ua->get($atom_url); 203 if($resp->is_success) { 204 my $xml = XML::XPath->new( xml => $resp->content ); 205 if(!$profile->{nickname}) { 206 if(my ($name_el) = $xml->findnodes('/feed/author/name')) { 207 $profile->{nickname} = $name_el->string_value; 208 } 209 } 210 $xml->cleanup; 211 } 212 213 return $profile if $profile->{nickname}; 214 } 215 216 ## LJ username 217 if(MT::Plugin::OpenIDComment->instance->get_config_value('special_lj', 218 'blog:' . $blog_id) 219 ) { 220 my $url = $vident->url; 221 if( $url =~ m(^https?://www\.livejournal\.com\/users/([^/]+)/$) || 222 $url =~ m(^https?://www\.livejournal\.com\/~([^/]+)/$) || 223 $url =~ m(^https?://([^\.]+)\.livejournal\.com\/$) 224 ) { 225 $profile->{nickname} = $1; 226 } 227 228 return $profile if $profile->{nickname}; 229 } 230 231 $profile->{nickname} ||= $vident->display; 232 return $profile; 233 } 234 95 235 sub verify { 96 236 my $app = shift; … … 99 239 or return $app->error('Invalid entry id '. $q->param('entry_id') .' in verification'); 100 240 return $app->error('OpenID signons are not available on this blog') 101 unless $MT::Plugin::OpenIDComment::plugin_obj->get_config_value('enable',241 unless MT::Plugin::OpenIDComment->instance->get_config_value('enable', 102 242 'blog:' . $entry->blog_id); 243 my $fake_email = 0; 244 if(MT::Blog->count({ id => $entry->blog_id, require_comment_emails => 1 })) { 245 if(MT::Plugin::OpenIDComment->instance->get_config_value('fake_email')) { 246 $fake_email = 1; 247 } else { 248 return $app->error('This blog requires email addresses from commenters, which are not available when signing in with OpenID. Please inform the owner of the blog that the "Require E-mail Address" option should be disabled to allow OpenID sign-ins.'); 249 } 250 } 251 252 ## Uh-oh, no errors. We have to do real work. First make sure we can. 253 $app->do_upgrade; 103 254 104 255 my $csr = $app->_get_csr; … … 107 258 return $app->redirect($setup_url); 108 259 } elsif(my $vident = $csr->verified_identity) { 109 ## Verified, so set up a new commenter obj and cookie. 110 my $name = "openid\n" . $vident->url; 111 my $nickname = ($vident->url =~ m(^https?://www.livejournal.com/users/([^/]+))) ? $1 : $vident->display; 112 $app->_make_commenter( name => $name, email => '', nickname => $nickname ); 113 my $session_id = eval { require Digest::MD5; 1 } ? rand() : 114 Digest::MD5::md5_hex(Digest::MD5::md5_hex(time() . {} . rand() . $$)); 115 $app->_make_commenter_session($session_id, '', $name, $nickname); 116 260 ## Verified, so set up the commenter obj and session. 261 my ($author); 262 263 ## Discern nickname. 264 my $profile = $app->_get_profile_data($vident, $entry->blog_id); 265 266 require MT::Plugin::OpenIDIdentity; 267 my $id = MT::Plugin::OpenIDIdentity->load({ url => $vident->url }); 268 if($id) { 269 $author = $id->author; 270 if($author->nickname ne $profile->{nickname}) { 271 $author->nickname($profile->{nickname}); 272 $author->save or return $app->error($author->errstr); 273 } 274 275 if(!defined $id->pic_url || $id->pic_url ne $profile->{pic_url}) { 276 $id->pic_url($profile->{pic_url}); 277 $id->save or return $app->error($id->errstr); 278 } 279 } else { 280 require MT::Author; 281 ## Find an unused name. 282 my $name = "openid\n" . $app->_rand; 283 $name = "openid\n" . $app->_rand while MT::Author->count({ name => $name }); 284 285 $author = MT::Author->new; 286 $author->set_values({ 287 type => MT::Author::COMMENTER, 288 name => $name, 289 ## TODO: fake email to circumvent requirement 290 email => '', 291 nickname => $profile->{nickname}, 292 password => '(none)', 293 url => $vident->url, 294 }); 295 $author->save or return $app->error($author->errstr); 296 297 $id = MT::Plugin::OpenIDIdentity->new; 298 $id->url($vident->url); 299 $id->author_id($author->id); 300 $id->pic_url($profile->{pic_url}); 301 $id->save or return $app->error($id->errstr); 302 } 303 304 my $session_id = $app->_rand; 305 ## TODO: fake email to circumvent requirement 306 $app->_make_commenter_session($session_id, '', $author->name, $profile->{nickname}); 307 308 return $app->redirect($entry->permalink); 309 } elsif($q->param('openid.mode') eq 'cancel') { 310 ## Cancelled! 117 311 return $app->redirect($entry->permalink); 118 312 } -
trunk/openid-comment/plugins/openid-comment/tmpl/blog_config.tmpl
r12 r27 13 13 </div> 14 14 15 <div class="label">Use inline CSS styles:</div> 16 <div class="field"> 17 <p><label><input type="checkbox" name="inline_style"<TMPL_IF NAME=INLINE_STYLE> checked="checked"</TMPL_IF> value="1" /> 18 Use inline <code>style</code> CSS to style the sign on thunk fields. Disable this if you want to style the form with your own CSS instead.</label></p> 15 19 </div> 16 20 21 </div> 22
