]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
4e86dcd35e5ed9bd485b11dd97dab35d5da5721a
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / URLVerify.pm
1 package OpenILS::Application::URLVerify;
2 use base qw/OpenILS::Application/;
3 use strict; use warnings;
4 use OpenSRF::Utils::Logger qw(:logger);
5 use OpenSRF::MultiSession;
6 use OpenILS::Utils::Fieldmapper;
7 use OpenILS::Utils::CStoreEditor q/:funcs/;
8 use OpenILS::Application::AppUtils;
9 use LWP::UserAgent;
10
11 my $U = 'OpenILS::Application::AppUtils';
12
13
14 __PACKAGE__->register_method(
15     method => 'validate_session',
16     api_name => 'open-ils.url_verify.session.validate',
17     stream => 1,
18     signature => {
19         desc => q/
20             Performs verification on all (or a subset of the) URLs within the requested session.
21         /,
22         params => [
23             {desc => 'Authentication token', type => 'string'},
24             {desc => 'Session ID (url_verify.session.id)', type => 'number'},
25             {desc => 'URL ID list (optional).  An empty list will result in no URLs being processed', type => 'array'},
26             {
27                 desc => q/
28                     Options (optional).
29                         report_all => bypass response throttling and return all URL sub-process
30                             responses to the caller.  Not recommened for remote (web, etc.) clients,
31                             because it can be a lot of data.
32                         resume_attempt => atttempt_id.  Resume verification after a failure.
33                         resume_with_new_attempt => If true, resume from resume_attempt, but
34                             create a new attempt to track the resumption.
35                     /,
36                 type => 'hash'
37             }
38         ],
39         return => {desc => q/
40             Stream of objects containing the number of URLs to be processed (url_count),
41             the number processed thus far including redirects (total_processed),
42             and the current url_verification object (current_verification).
43
44             Note that total_processed may ultimately exceed url_count, since it
45             includes non-anticipate-able redirects.
46
47             The final response contains url_count, total_processed, and the
48             verification_attempt object (attempt).
49             /
50         }
51     }
52 );
53
54 sub validate_session {
55     my ($self, $client, $auth, $session_id, $url_ids, $options) = @_;
56     $options ||= {};
57
58     my $e = new_editor(authtoken => $auth, xact => 1);
59     return $e->die_event unless $e->checkauth;
60     return $e->die_event unless $e->allowed('VERIFY_URL');
61
62     my $session = $e->retrieve_url_verify_session($session_id)
63         or return $e->die_event;
64
65     my $attempt_id = $options->{resume_attempt};
66
67     if (!$url_ids) {
68
69         # No URLs provided, load all URLs for the requested session
70
71         my $query = {
72             select => {uvu => ['id']},
73             from => {
74                 uvu => { # url
75                     cbrebi => { # bucket item
76                         join => { cbreb => { # bucket
77                             join => { uvs => { # session
78                                 filter => {id => $session_id}
79                             }}
80                         }}
81                     }
82                 }
83             }
84         };
85
86         if ($attempt_id) {
87
88             # when resuming an existing attempt (that presumably failed
89             # mid-processing), we only want to process URLs that either
90             # have no linked url_verification or have an un-completed
91             # url_verification.
92
93             $logger->info("url: resuming attempt $attempt_id");
94
95             $query->{from}->{uvu}->{uvuv} = {
96                 type => 'left',
97                 filter => {attempt => $attempt_id}
98             };
99
100             $query->{where} = {
101                 '+uvuv' => {
102                     '-or' => [
103                         {id => undef}, # no verification started
104                         {res_code => undef} # verification started but did no complete
105                     ]
106                 }
107             };
108
109         } else {
110
111             # this is a new attempt, so we only want to process URLs that
112             # originated from the source records and not from redirects.
113
114             $query->{where} = {
115                 '+uvu' => {redirect_from => undef}
116             };
117         }
118
119         my $ids = $e->json_query($query);
120         $url_ids = [ map {$_->{id}} @$ids ];
121     }
122
123     my $url_count = scalar(@$url_ids);
124     $logger->info("url: processing $url_count URLs");
125
126     my $attempt;
127     if ($attempt_id and !$options->{resume_with_new_attempt}) {
128
129         $attempt = $e->retrieve_url_verification_attempt($attempt_id)
130             or return $e->die_event;
131
132         # no data was written
133         $e->rollback;
134
135     } else {
136
137         $attempt = Fieldmapper::url_verify::verification_attempt->new;
138         $attempt->session($session_id);
139         $attempt->usr($e->requestor->id);
140         $attempt->start_time('now');
141
142         $e->create_url_verify_verification_attempt($attempt)
143             or return $e->die_event;
144
145         $e->commit;
146     }
147
148     # END DB TRANSACTION
149
150     # Now cycle through the URLs in batches.
151
152     my $batch_size = $U->ou_ancestor_setting_value(
153         $session->owning_lib,
154         'url_verify.verification_batch_size', $e) || 5;
155
156     my $num_processed = 0; # total number processed, including redirects
157     my $resp_window = 1;
158
159     # before we start the real work, let the caller know
160     # the attempt (id) so recovery is possible.
161
162     $client->respond({
163         url_count => $url_count,
164         total_processed => $num_processed,
165         attempt => $attempt
166     });
167
168     my $multises = OpenSRF::MultiSession->new(
169
170         app => 'open-ils.url_verify', # hey, that's us!
171         cap => $batch_size,
172
173         success_handler => sub {
174             my ($self, $req) = @_;
175
176             # API call streams fleshed url_verification objects.  We wrap
177             # those up with some extra info and pass them on to the caller.
178
179             for my $resp (@{$req->{response}}) {
180                 my $content = $resp->content;
181
182                 if ($content) {
183
184                     $num_processed++;
185
186                     if ($options->{report_all} or ($num_processed % $resp_window == 0)) {
187                         $client->respond({
188                             url_count => $url_count,
189                             current_verification => $content,
190                             total_processed => $num_processed
191                         });
192                     }
193
194                     # start off responding quickly, then throttle
195                     # back to only relaying every 256 messages.
196                     $resp_window *= 2 unless $resp_window == 256;
197                 }
198             }
199         },
200
201         failure_handler => sub {
202             my ($self, $req) = @_;
203
204             # {error} should be an Error w/ a toString
205             $logger->error("url: error processing URL: " . $req->{error});
206         }
207     );
208
209     sort_and_fire_domains($e, $auth, $attempt, $url_ids, $multises);
210
211     # Wait for all requests to be completed
212     $multises->session_wait(1);
213
214     # All done.  Let's wrap up the attempt.
215     $attempt->finish_time('now');
216
217     $e->xact_begin;
218     $e->update_url_verify_verification_attempt($attempt) or return $e->die_event;
219     $e->xact_commit;
220
221     return {
222         url_count => $url_count,
223         total_processed => $num_processed,
224         attempt => $attempt
225     };
226 }
227
228 # retrieves the URL domains and sorts them into buckets
229 # Iterates over the buckets and fires the multi-session call
230 # the main drawback to this domain sorting approach is that
231 # any domain used a lot more than the others will be the
232 # only domain standing after the others are exhausted, which
233 # means it will take a beating at the end of the batch.
234 sub sort_and_fire_domains {
235     my ($e, $auth, $attempt, $url_ids, $multises) = @_;
236
237     # there is potential here for data sets to be too large
238     # for delivery, but it's not likely, since we're only
239     # fetching ID and domain.
240     my $urls = $e->json_query(
241         {
242             select => {uvu => ['id', 'domain']},
243             from => 'uvu',
244             where => {id => $url_ids}
245         },
246         # {substream => 1} only if needed
247     );
248
249     # sort them into buckets based on domain name
250     my %domains;
251     for my $url (@$urls) {
252         $domains{$url->{domain}} = [] unless $domains{$url->{domain}};
253         push(@{$domains{$url->{domain}}}, $url->{id});
254     }
255
256     # loop through the domains and fire the verification call
257     while (keys %domains) {
258         for my $domain (keys %domains) {
259
260             my $url_id = pop(@{$domains{$domain}});
261             delete $domains{$domain} unless @{$domains{$domain}};
262
263             $multises->request(
264                 'open-ils.url_verify.verify_url',
265                 $auth, $attempt->id, $url_id);
266         }
267     }
268 }
269
270
271 __PACKAGE__->register_method(
272     method => 'verify_url',
273     api_name => 'open-ils.url_verify.verify_url',
274     stream => 1,
275     signature => {
276         desc => q/
277             Performs verification of a single URL.  When a redirect is detected,
278             a new URL is created to model the redirect and the redirected URL
279             is then tested, up to max-redirects or a loop is detected.
280         /,
281         params => [
282             {desc => 'Authentication token', type => 'string'},
283             {desc => 'Verification attempt ID (url_verify.verification_attempt.id)', type => 'number'},
284             {desc => 'URL id (url_verify.url.id)', type => 'number'},
285         ],
286         return => {desc => q/Stream of url_verification objects, one per URL tested/}
287     }
288 );
289
290 =head comment
291
292 verification.res_code:
293
294 999 bad hostname, etc. (IO::Socket::Inet errors)
295 998 in-flight errors (e.g connection closed prematurely)
296 997 timeout
297 996 redirect loop
298 995 max redirects
299
300 verification.res_text:
301
302 $@ or custom message "Redirect Loop"
303
304 =cut
305
306 sub verify_url {
307     my ($self, $client, $auth, $attempt_id, $url_id) = @_;
308     my %seen_urls;
309
310     my $e = new_editor(authtoken => $auth);
311     return $e->event unless $e->checkauth;
312
313     my $url = $e->retrieve_url_verify_url($url_id) or return $e->event;
314
315     my ($attempt, $delay, $max_redirects, $timeout) =
316         collect_verify_attempt_and_settings($e, $attempt_id);
317
318     return $e->event unless $e->allowed(
319         'VERIFY_URL', $attempt->session->owning_lib);
320
321     my $cur_url = $url;
322     my $loop_detected = 0;
323     my $redir_count = 0;
324
325     while ($redir_count++ < $max_redirects) {
326
327         if ($seen_urls{$cur_url->full_url}) {
328             $loop_detected = 1;
329             last;
330         }
331
332         $seen_urls{$cur_url->full_url} = $cur_url;
333
334         my $url_resp = verify_one_url($e, $attempt, $cur_url, $timeout);
335
336         # something tragic happened
337         return $url_resp if $U->is_event($url_resp);
338
339         # flesh and respond to the caller
340         $url_resp->{verification}->url($cur_url);
341         $client->respond($url_resp->{verification});
342
343         $cur_url = $url_resp->{redirect_url} or last;
344     }
345
346     if ($loop_detected or $redir_count > $max_redirects) {
347
348         my $vcation = Fieldmapper::url_verify::url_verification->new;
349         $vcation->url($cur_url->id);
350         $vcation->attempt($attempt->id);
351         $vcation->req_time('now');
352
353         if ($loop_detected) {
354             $logger->info("url: redirect loop detected at " . $cur_url->full_url);
355             $vcation->res_code('996');
356             $vcation->res_text('Redirect Loop');
357
358         } else {
359             $logger->info("url: max redirects reached for source URL " . $url->full_url);
360             $vcation->res_code('995');
361             $vcation->res_text('Max Redirects');
362         }
363
364         $e->xact_begin;
365         $e->create_url_verify_url_verification($vcation) or return $e->die_event;
366         $e->xact_commit;
367     }
368
369     # The calling code is likely not multi-threaded, so a
370     # per-URL (i.e. per-thread) delay would not be possible.
371     # Applying the delay here allows the caller to process
372     # batches of URLs without having to worry about the delay.
373     sleep $delay;
374
375     return undef;
376 }
377
378 # temporarily cache some data to avoid a pile
379 # of data lookups on every URL processed.
380 my %cache;
381 sub collect_verify_attempt_and_settings {
382     my ($e, $attempt_id) = @_;
383     my $attempt;
384
385     if (!(keys %cache) or $cache{age} > 20) { # configurable?
386         %cache = (
387             age => 0,
388             attempt => {},
389             delay => {},
390             redirects => {},
391             timeout => {},
392         );
393     }
394
395     if ( !($attempt = $cache{attempt}{$attempt_id}) ) {
396
397         # attempt may have just been created, so
398         # we need to guarantee a write-DB read.
399         $e->xact_begin;
400
401         $attempt =
402             $e->retrieve_url_verify_verification_attempt([
403                 $attempt_id, {
404                     flesh => 1,
405                     flesh_fields => {uvva => ['session']}
406                 }
407             ]) or return $e->die_event;
408
409         $e->rollback;
410
411         $cache{attempt}{$attempt_id} = $attempt;
412     }
413
414     my $org = $attempt->session->owning_lib;
415
416     if (!$cache{timeout}{$org}) {
417
418         $cache{delay}{$org} = $U->ou_ancestor_setting_value(
419             $org, 'url_verify.url_verification_delay', $e);
420
421         # 0 is a valid delay
422         $cache{delay}{$org} = 2 unless defined $cache{delay}{$org};
423
424         $cache{redirects}{$org} = $U->ou_ancestor_setting_value(
425             $org, 'url_verify.url_verification_max_redirects', $e) || 20;
426
427         $cache{timeout}{$org} = $U->ou_ancestor_setting_value(
428             $org, 'url_verify.url_verification_max_wait', $e) || 5;
429
430         $logger->info(
431             sprintf("url: loaded settings delay=%s; max_redirects=%s; timeout=%s",
432                 $cache{delay}{$org}, $cache{redirects}{$org}, $cache{timeout}{$org}));
433     }
434
435     $cache{age}++;
436
437
438     return (
439         $cache{attempt}{$attempt_id},
440         $cache{delay}{$org},
441         $cache{redirects}{$org},
442         $cache{timeout}{$org}
443     );
444 }
445
446
447 # searches for a completed url_verfication for any url processed
448 # within this verification attempt whose full_url matches the
449 # full_url of the provided URL.
450 sub find_matching_url_for_attempt {
451     my ($e, $attempt, $url) = @_;
452
453     my $match = $e->json_query({
454         select => {uvuv => ['id']},
455         from => {
456             uvuv => {
457                 uvva => { # attempt
458                     filter => {id => $attempt->id}
459                 },
460                 uvu => {} # url
461             }
462         },
463         where => {
464             '+uvu' => {
465                 id => {'!=' => $url->id},
466                 full_url => $url->full_url
467             },
468
469             # There could be multiple verifications for matching URLs
470             # We only want a verification that completed.
471             # Note also that 2 identical URLs processed within the same
472             # sub-batch will have to each be fully processed in their own
473             # right, since neither knows how the other will ultimately fare.
474             '+uvuv' => {
475                 res_time => {'!=' => undef}
476             }
477         }
478     })->[0];
479
480     return $e->retrieve_url_verify_url_verification($match->{id}) if $match;
481     return undef;
482 }
483
484
485 =head comment
486
487 1. create the verification object and commit.
488 2. test the URL
489 3. update the verification object to capture the results of the test
490 4. Return redirect_url object if this is a redirect, otherwise undef.
491
492 =cut
493
494 sub verify_one_url {
495     my ($e, $attempt, $url, $timeout) = @_;
496
497     my $url_text = $url->full_url;
498     my $redir_url;
499
500     # first, create the verification object so we can a) indicate that
501     # we're working on this URL and b) get the DB to set the req_time.
502
503     my $vcation = Fieldmapper::url_verify::url_verification->new;
504     $vcation->url($url->id);
505     $vcation->attempt($attempt->id);
506     $vcation->req_time('now');
507
508     # begin phase-I DB communication
509
510     $e->xact_begin;
511
512     my $match_vcation = find_matching_url_for_attempt($e, $attempt, $url);
513
514     if ($match_vcation) {
515         $logger->info("url: found matching URL in verification attempt [$url_text]");
516         $vcation->res_code($match_vcation->res_code);
517         $vcation->res_text($match_vcation->res_text);
518         $vcation->redirect_to($match_vcation->redirect_to);
519     }
520
521     $e->create_url_verify_url_verification($vcation) or return $e->die_event;
522     $e->xact_commit;
523
524     # found a matching URL, no need to re-process
525     return {verification => $vcation} if $match_vcation;
526
527     # End phase-I DB communication
528     # No active DB xact means no cstore timeout concerns.
529
530     # Now test the URL.
531
532     $ENV{FTP_PASSIVE} = 1; # TODO: setting?
533
534     my $ua = LWP::UserAgent->new(ssl_opts => {verify_hostname => 0}); # TODO: verify_hostname setting?
535     $ua->timeout($timeout);
536
537     my $req = HTTP::Request->new(HEAD => $url->full_url);
538
539     # simple_request avoids LWP's auto-redirect magic
540     my $res = $ua->simple_request($req);
541
542     $logger->info(sprintf(
543         "url: received HTTP '%s' / '%s' [%s]",
544         $res->code,
545         $res->message,
546         $url_text
547     ));
548
549     $vcation->res_code($res->code);
550     $vcation->res_text($res->message);
551
552     # is this a redirect?
553     if ($res->code =~ /^3/) {
554
555         if (my $loc = $res->headers->{location}) {
556             $redir_url = Fieldmapper::url_verify::url->new;
557             $redir_url->redirect_from($url->id);
558             $redir_url->full_url($loc);
559
560             $logger->info("url: redirect found $url_text => $loc");
561
562         } else {
563             $logger->info("url: server returned 3XX but no 'Location' header for url $url_text");
564         }
565     }
566
567     # Begin phase-II DB communication
568
569     $e->xact_begin;
570
571     if ($redir_url) {
572         $redir_url = $e->create_url_verify_url($redir_url) or return $e->die_event;
573         $vcation->redirect_to($redir_url->id);
574     }
575
576     $vcation->res_time('now');
577     $e->update_url_verify_url_verification($vcation) or return $e->die_event;
578     $e->commit;
579
580     return {
581         verification => $vcation,
582         redirect_url => $redir_url
583     };
584 }
585
586
587 1;