]> git.imager.perl.org - bse.git/blame - site/cgi-bin/modules/DevHelp/Validate.pm
update tests for new columns
[bse.git] / site / cgi-bin / modules / DevHelp / Validate.pm
CommitLineData
0ec4ac8a
TC
1package DevHelp::Validate;
2use strict;
3require Exporter;
4use vars qw(@EXPORT_OK @ISA);
a5e3fc4b 5@EXPORT_OK = qw(dh_validate dh_validate_hash dh_fieldnames dh_configure_fields);
0ec4ac8a
TC
6@ISA = qw(Exporter);
7
8sub new {
9 my ($class, %opts) = @_;
10
11 return bless \%opts, $class;
12}
13
14my %built_ins =
15 (
16 email =>
17 {
18 match => qr/^[^\@]+\@[\w.-]+\.\w+$/,
19 error => '$n is not a valid email address',
20 },
21 phone =>
22 {
23 match => qr/\d(?:\D*\d){3}/,
24 error => '$n is not a valid phone number',
25 },
26 postcode =>
27 {
28 match => qr/\d(?:\D*\d){3}/,
29 error => '$n is not a valid post code',
30 },
31 url =>
32 {
33 match => qr!^\w+://[\w-]+(?:\.[\w-]+)+(?::\d+)?!,
34 error => '$n is not a valid URL',
35 },
36 weburl =>
37 {
38 match => qr!^https?://[\w-]+(?:\.[\w-]+)+(?::\d+)?!,
39 error => '$n is not a valid URL, it must start with http:// or https://',
40 },
41 newbieweburl =>
42 {
43 match => qr!^(?:https?://)?[\w-]+(?:\.[\w-]+)+(?::\d+)?!,
44 error => '$n is not a valid URL',
45 },
46 confirm =>
47 {
48 confirm=>'password',
49 },
50 newconfirm =>
51 {
52 newconfirm=>'password',
53 },
54 required =>
55 {
56 required => 1,
57 },
58 abn =>
59 {
60 match => qr/\d(?:\D*\d){7}/,
61 error => '$n is not a valid ABN',
62 },
63 creditcardnumber =>
64 {
65 match => qr/^\D*\d(?:\D*\d){11,15}\D*$/,
66 error => '$n is not a valid credit card number',
67 },
68 creditcardexpiry =>
69 {
70 ccexpiry => 1,
71 },
41e7c841
TC
72 creditcardexpirysingle =>
73 {
74 ccexpirysingle => 1,
75 },
76 creditcardcvv =>
77 {
78 match => qr/^(\d){3,4}$/,
79 error => '$n is the 3 or 4 digit code on the back of your card',
80 },
0ec4ac8a
TC
81 miaa =>
82 {
83 match => qr/^\s*\d{1,6}\s*$/,
84 error => 'Not a valid MIAA membership number',
85 },
86 decimal =>
87 {
88 match => qr/^\s*(?:\d+(?:\.\d*)?|\.\d+)\s*$/,
89 error => 'Not a valid number',
90 },
91 money =>
92 {
93 match => qr/^\s*(?:\d+(?:\.\d\d)?|\.\d\d)\s*$/,
94 error => 'Not a valid money amount',
95 },
96 date =>
97 {
98 date => 1,
99 },
100 birthdate =>
101 {
102 date => 1,
103 maxdate => '+0y',
104 maxdatemsg => 'Your $n must be in the past',
105 },
106 adultbirthdate =>
107 {
108 date => 1,
109 maxdate => '-10y',
110 maxdatemsg => 'You must be at least 10 years old...',
111 mindate => '-100y',
112 },
113 futuredate =>
114 {
115 date => 1,
116 mindate => '-1d',
117 mindatemsg => 'The date entered must be in the future',
118 },
119 natural =>
120 {
121 integer => '0-', # 0 or higher
122 },
123 positiveint =>
124 {
125 integer => '1-', # 1 or higher
126 },
829c9ed9
TC
127 dh_one_line =>
128 {
129 nomatch => qr/[\x0D\x0A]/,
130 error => '$n may only contain a single line',
131 }
0ec4ac8a
TC
132 );
133
134sub dh_validate {
135 my ($cgi, $errors, $validation, $cfg, $section) = @_;
136
137 return DevHelp::Validate::CGI->new(cfg=>$cfg, cgi=>$cgi, section=>$section)
138 ->validate($errors, $validation);
139}
140
141sub dh_validate_hash {
142 my ($hash, $errors, $validation, $cfg, $section) = @_;
143
144 return DevHelp::Validate::Hash->new(cfg=>$cfg, hash=>$hash, section=>$section)
145 ->validate($errors, $validation);
146}
147
148sub validate {
149 my ($self, $errors, $validation) = @_;
150
151 my $rules = $validation->{rules};
152 my $fields = $validation->{fields};
153 my $optional = $validation->{optional};
154
155 my %cfg_rules;
156 _get_cfg_fields(\%cfg_rules, $self->{cfg}, $self->{section}, $fields)
157 if $self->{cfg} && $self->{section};
158
159 for my $rulename (keys %$rules) {
160 unless (exists $cfg_rules{rules}{$rulename}) {
161 $cfg_rules{rules}{$rulename} = $rules->{$rulename};
162 }
163 }
164 for my $rulename (keys %built_ins) {
165 unless (exists $cfg_rules{rules}{$rulename}) {
166 $cfg_rules{rules}{$rulename} = $built_ins{$rulename};
167 }
168 }
169
170 # merge the supplied fields into the config fields
171 my $cfg_fields = $cfg_rules{fields};
172 for my $field ( keys %$fields ) {
173 my $src = $fields->{$field};
174
175 my $dest = $cfg_fields->{$field} || {};
176
177 # the config overrides the software supplied fields
178 for my $override (qw(description required required_error)) {
179 if (defined $src->{$override} && !defined $dest->{$override}) {
180 $dest->{$override} = $src->{$override};
181 }
182 }
183
cc53c774 184 # but we add rules and required_if
0ec4ac8a
TC
185 if ($dest->{rules}) {
186 my $rules = $src->{rules};
187
188 # make a copy of the rules array if it's supplied that way so
189 # we don't modify someone else's data
190 $rules = ref $rules ? [ @$rules ] : [ split /;/, $rules ];
191
192 push @$rules, split /;/, $dest->{rules};
193 }
194 elsif ($src->{rules}) {
195 $dest->{rules} = $src->{rules};
196 }
cc53c774
TC
197 if ($dest->{required_if}) {
198 $dest->{required_if} .= ";" . $src->{required_if} if $src->{required_if};
199 }
200 elsif ($src->{required_if}) {
201 $dest->{required_if} = $src->{required_if};
202 }
0ec4ac8a
TC
203
204 $cfg_fields->{$field} = $dest if keys %$dest;
205 }
206 for my $field ( keys %$cfg_fields ) {
207 $self->validate_field($field, $cfg_fields->{$field}, $cfg_rules{rules},
208 $optional, $errors);
209 }
210
211 !keys %$errors;
212}
213
214sub validate_field {
215 my ($self, $field, $info, $rules, $optional, $errors) = @_;
216
217 my @data = $self->param($field);
218
219 my $required = $info->{required};
220 if (@data && $data[0] !~ /\S/ && $info->{required_if}) {
221 # field is required if any of the named fields are non-blank
222 for my $testfield (split /;/, $info->{required_if}) {
223 my $testvalue = $self->param($testfield);
224 if (defined $testvalue && $testvalue =~ /\S/) {
225 ++$required;
226 last;
227 }
228 }
229 }
230
231 my $rule_names = $info->{rules};
232 defined $rule_names or $rule_names = '';
233 $rule_names = [ split /;/, $rule_names ] unless ref $rule_names;
234
235 push @$rule_names, 'required' if $required;
236
237 @$rule_names or return;
238
239 RULE: for my $rule_name (@$rule_names) {
240 my $rule = $rules->{$rule_name};
241 unless ($rule) {
242 $rule = $self->_get_cfg_rule($rule_name);
243 if ($rule) {
244 $rules->{$rule_name} = $rule;
245 }
246 else {
247 print STDERR "** Unknown validation rule $rule_name for $field\n";
248 }
249 }
250 if (!$optional && $rule->{required} && !@data ) {
251 $errors->{$field} = _make_error($field, $info, $rule,
252 $info->{required_error} ||
253 $rule->{required_error} ||
254 '$n is a required field');
255 last RULE;
256 }
257 for my $data (@data) {
258 if ($rule->{required} && $data !~ /\S/) {
259 $errors->{$field} = _make_error($field, $info, $rule,
260 $info->{required_error} ||
261 $rule->{required_error} ||
262 '$n is a required field');
263 last RULE;
264 }
265 if ($rule->{newconfirm}) {
266 my $other = $self->param($rule->{newconfirm});
267 if ($other ne '' || $data ne '') {
268 if ($other ne $data) {
269 $errors->{$field} = _make_error($field, $info, $rule,
270 q!$n doesn't match the password!);
271 last RULE;
272 }
273 }
274 }
275 if ($data !~ /\S/ && !$rule->{required}) {
276 next RULE;
277 }
278 if ($rule->{match}) {
279 my $match = $rule->{match};
280 unless ($data =~ /$match/) {
281 $errors->{$field} = _make_error($field, $info, $rule);
282 last RULE;
283 }
284 }
50c3d7f9 285 if (defined $rule->{nomatch}) {
0ec4ac8a
TC
286 my $match = $rule->{nomatch};
287 if ($data =~ /$match/) {
288 $errors->{$field} = _make_error($field, $info, $rule);
289 last RULE;
290 }
291 }
292 if ($rule->{integer}) {
293 unless ($data =~ /^\s*([-+]?\d+)s*$/) {
294 $errors->{$field} = _make_error($field, $info, $rule,
295 '$n must be a whole number');
296 last RULE;
297 }
298 my $num = $1;
299 if (my ($from, $to) = $rule->{integer} =~ /^([+-]?\d+)-([+-]?\d+)$/) {
300 unless ($from <= $num and $num <= $to) {
301 $errors->{$field} = _make_error($field, $info, $rule,
302 $info->{range_error} ||
303 $rule->{range_error} ||
304 "\$n must be in the range $from to $to");
305 last RULE;
306 }
307 }
308 elsif (my ($from2) = $rule->{integer} =~ /^([+-]?\d+)-$/) {
309 unless ($from2 <= $num) {
310 $errors->{$field} = _make_error($field, $info, $rule,
311 $info->{range_error} ||
312 $rule->{range_error} ||
313 "\$n must be $from2 or higher");
314 last RULE;
315 }
316 }
317 }
318 if ($rule->{date}) {
319 unless ($data =~ m!^\s*(\d+)[-+/](\d+)[-+/](\d+)\s*$!) {
320 $errors->{$field} = _make_error($field, $info, $rule,
321 '$n must be a valid date');
322 last RULE;
323 }
324 my ($day, $month, $year) = ($1, $2, $3);
325 if ($day < 1 || $day > 31) {
326 $errors->{$field} = _make_error($field, $info, $rule,
327 '$n must be a valid date - day out of range');
328 last RULE;
329 }
330 if ($month < 1 || $month > 12) {
331 $errors->{$field} = _make_error($field, $info, $rule,
332 '$n must be a valid date - month out of range');
333 last RULE;
334 }
335 if ($rule->{mindate} || $rule->{maxdate}) {
336 require DevHelp::Date;
337 my $workdate = sprintf("%04d-%02d-%02d", $year, $month, $day);
338 if ($rule->{mindate}) {
339 my $mindate = DevHelp::Date::dh_parse_date_sql($rule->{mindate});
340 if ($workdate le $mindate) {
341 $errors->{$field} =
342 _make_error($field, $info, $rule,
343 $info->{mindatemsg} || $rule->{mindatemsg} || '$n is too early');
344 }
345 }
346 if (!$errors->{$field} && $rule->{maxdate}) {
347 my $maxdate = DevHelp::Date::dh_parse_date_sql($rule->{maxdate});
348 if ($workdate ge $maxdate) {
349 $errors->{$field} =
350 _make_error($field, $info, $rule,
351 $info->{mindatemsg} || $rule->{maxdatemsg} || '$n is too late');
352 }
353 }
354 }
355 }
356 if ($rule->{confirm}) {
357 my $other = $self->param($rule->{confirm});
358 unless ($other eq $data) {
359 $errors->{$field} = _make_error($field, $info, $rule,
360 q!$n doesn't match the password!);
361 last RULE;
362 }
363 }
364 if ($rule->{ccexpiry}) {
365 (my $year_field = $field) =~ s/month/year/;
366
367 unless ($data =~ /^\s*\d+\s*$/) {
368 $errors->{$field} = _make_error($field, $info, $rule,
369 q!$n month isn't a number!);
370 last RULE;
371 }
372 my $year = $self->param($year_field);
373 unless (defined $year && $year =~ /\s*\d+\s*$/) {
374 $errors->{$field} = _make_error($field, $info, $rule,
375 q!$n year isn't a number!);
376 last RULE;
377 }
378 my ($now_year, $now_month) = (localtime)[5, 4];
379 $now_year += 1900;
380 ++$now_month;
381 if ($year < $now_year || $year == $now_year && $data < $now_month) {
382 $errors->{$field} = _make_error($field, $info, $rule,
383 q!$n is in the past, your card has expired!);
384 last RULE;
385 }
386 }
41e7c841
TC
387 if ($rule->{ccexpirysingle}) {
388 unless ($data =~ m!^\s*(\d+)\s*/\s*(\d+)+\s*$!) {
389 $errors->{$field} = _make_error($field, $info, $rule,
390 q!$n must be in MM/YY format!);
391 last RULE;
392 }
393 my ($month, $year) = ($1, $2);
394 $year += 2000;
395 if ($month < 1 || $month > 12) {
396 $errors->{$field} = _make_error($field, $info, $rule,
397 q!$n month must be between 1 and 12!);
398 last RULE;
399 }
400 my ($now_year, $now_month) = (localtime)[5, 4];
401 $now_year += 1900;
402 ++$now_month;
403 if ($year < $now_year || $year == $now_year && $month < $now_month) {
404 $errors->{$field} = _make_error($field, $info, $rule,
405 q!$n is in the past, your card has expired!);
406 last RULE;
407 }
408 }
0ec4ac8a
TC
409 }
410 }
411}
412
413sub _make_error {
414 my ($field, $info, $rule, $message) = @_;
415
416 $message ||= $rule->{error} || 'Validation error on field $n';
417
418 my $name = $info->{description} || $field;
419 $message =~ s/\$n/$name/g;
420
421 return $message;
422}
423
424sub _get_cfg_fields {
425 my ($rules, $cfg, $section, $field_hash) = @_;
426
427 $rules->{rules} = {};
428 $rules->{fields} = {};
429
430 my $cfg_fields = $rules->{fields};
431
432 my $fields = $cfg->entry($section, 'fields', '');
433 my @names = ( split(/,/, $fields), keys %$field_hash );
434
435 for my $field (@names) {
0f9b108b 436 for my $cfg_name (qw(required rules description required_error range_error mindatemsg maxdatemsg)) {
0ec4ac8a
TC
437 my $value = $cfg->entry($section, "${field}_$cfg_name");
438 if (defined $value) {
439 $cfg_fields->{$field}{$cfg_name} = $value;
440 }
441 }
d44b5da9
TC
442
443 my $values = $cfg->entry($section, "${field}_values");
444 if (defined $values) {
445 my @values;
446 if ($values =~ /;/) {
447 for my $entry (split /;/, $values) {
cc53c774 448 if ($entry =~ /^([^=]*)=(.*)$/) {
d44b5da9
TC
449 push @values, [ $1, $2 ];
450 }
451 else {
452 push @values, [ $entry, $entry ];
453 }
454 }
455 }
456 else {
cc53c774
TC
457 my $strip;
458 if ($values =~ s/:([^:]*)$//) {
459 $strip = $1;
460 }
d44b5da9
TC
461 my %entries = $cfg->entriesCS($values);
462 my @order = $cfg->orderCS($values);
463
464 my %seen;
465 # we only want the last value in the order
466 @order = reverse grep !$seen{$_}++, reverse @order;
467 @values = map [ $_, $entries{$_} ], @order;
cc53c774
TC
468 if ($strip) {
469 $_->[0] =~ s/^\Q$strip// for @values;
470 }
d44b5da9
TC
471 }
472 $cfg_fields->{$field}{values} = \@values;
473 }
0ec4ac8a
TC
474 }
475}
476
a5e3fc4b
TC
477sub dh_configure_fields {
478 my ($fields, $cfg, $section) = @_;
479
480 my %cfg_rules;
481 _get_cfg_fields(\%cfg_rules, $cfg, $section, $fields);
482
483 # **FIXME** duplicated code
484 my $cfg_fields = $cfg_rules{fields};
485 for my $field ( keys %$fields ) {
486 my $src = $fields->{$field};
487
488 my $dest = $cfg_fields->{$field} || {};
489
490 # the config overrides the software supplied fields
491 for my $override (qw(description required required_error range_error mindatemsg maxdatemsg htmltype type width height size)) {
492 if (defined $src->{$override} && !defined $dest->{$override}) {
493 $dest->{$override} = $src->{$override};
494 }
495 }
496
497 # but we add rules
498 if ($dest->{rules}) {
499 my $rules = $src->{rules};
500
501 # make a copy of the rules array if it's supplied that way so
502 # we don't modify someone else's data
503 $rules = ref $rules ? [ @$rules ] : [ split /;/, $rules ];
504
505 push @$rules, split /;/, $dest->{rules};
506 }
507 elsif ($src->{rules}) {
508 $dest->{rules} = $src->{rules};
509 }
510
511 $cfg_fields->{$field} = $dest if keys %$dest;
512 }
513
514 return $cfg_fields;
515}
516
0ec4ac8a
TC
517sub _get_cfg_rule {
518 my ($self, $rulename) = @_;
519
520 my %rule = $self->{cfg}->entries("Validation Rule $rulename");
521
522 keys %rule or return;
523
524 \%rule;
525}
526
527sub dh_fieldnames {
528 my ($cfg, $section, $fields) = @_;
529
530 # this needs to be obsoleted now that dh_validate() checks the config
531
532 for my $field (keys %$fields) {
533 my $desc = $cfg->entry($section, $field);
534 defined $desc and $fields->{$field}{description} = $desc;
535 }
536}
537
538package DevHelp::Validate::CGI;
539use vars qw(@ISA);
540@ISA = qw(DevHelp::Validate);
541
542sub param {
543 my ($self, $field) = @_;
544
545 $self->{cgi}->param($field);
546}
547
548package DevHelp::Validate::Hash;
549use vars qw(@ISA);
550@ISA = qw(DevHelp::Validate);
551
552sub param {
553 my ($self, $field) = @_;
554
555 my $value = $self->{hash}{$field};
556
557 defined $value or return;
558
559 if (ref $value eq 'ARRAY') {
560 return @$value;
561 }
562
563 return $value;
564}
565
5661;
567
568__END__
569
570=head1 NAME
571
572DevHelp::Validate - handy configurable validation, I hope
573
574=head1 SYNOPSIS
575
576 use DevHelp::Validate qw(dh_validate);
577
578 dh_validate($cgi, \%errors, \%rules, $cfg)
579 or display_errors(..., \%errors);
580
581=head1 DESCRIPTION
582
583=head1 RULES PARAMETER
584
585The rules parameter is a hash with 2 keys:
586
587=over
588
589=item fields
590
591A hash of field names, for each of which the value is a hash.
592
593Each hash can have the following keys:
594
595=over
596
597=item rules
598
599A simple rule name, a ';' separated list of rule names or an array
600ref.
601
602=item description
603
604A short description of the field, for use in error messages.
605
606=back
607
608=item rules
609
610A hash of rules. See the rules description under L<CONFIGURED
611VALIDATON>.
612
613=back
614
615=head1 CONFIGURED VALIDATION
616
617Rules can be configured in the database.
618
619For the specified section name, each key is a CGI field name.
620
621The values of those keys gives the name of a validation rule, a string
622id for internationlization of the field description and a default
623field description, separated by commas.
624
625Each validation rule name has a corresponding section, [Validate Rule
626I<rule-name>], which describes the rule. Rule names can also refer to
627built-in rules,
628
629Values in the validation rule section are:
630
631=over
632
633=item required
634
635If this is non-zero the field is required.
636
637=item match
638
639If present, this is used as a regular expression the field must match.
640
829c9ed9
TC
641=item nomatch
642
643If present, this is used as a regular expression the field must not
644match.
645
0ec4ac8a
TC
646=item error
647
648Message returned as the error if the field fails validation.
649
650=item integer
651
652If set to 1, simply ensures the value is an integer.
653
654If set to a range I<integer>-I<integer> then ensures the value is an
655integer in that range.
656
657=item date
658
659If set to 1, simply validates the value as a date.
660
661=back
662
829c9ed9
TC
663=head1 BUILT-IN RULES
664
665=over
666
667=item email
668
669=item phone
670
671=item postcode
672
673=item url
674
675Matches any valid url, including mailto:, ftp:, etc. No checking of
676the scheme is done.
677
678=item weburl
679
680Matches any valid http or https url.
681
682=item newbieweburl
683
684Matches web URLs with or without "http://" or "https://".
685
686=item confirm
687
688Treats the given field as a confirmation field for a password field
689"password".
690
691=item newconfirm
692
693Treats the given field as a confirmation field for an optional
694password field "password".
695
696=item required
697
698The field is required. This should be used where the logic of the
699code requires a field, since it cannot be overridden by the config
700file.
701
702=item abn
703
704Valid Australian Business Number
705
706=item creditcardnumber
707
708Valid credit card number (no checksum checks are performed).
709
710=item creditcardexpiry
711
712Treats the field as the month part of a credit card expiry date, with
713the year field being the field with the same name, but "month"
714replaced with "year". The date of expiry is required to be in the
715future.
716
717=item miaa
718
719Valid MIAA membership number.
720
721=item decimal
722
723Valid simple decimal number
724
725=item money
726
727Valid decimal number with 0 or 2 digits after the decimal point.
728
729=item date
730
731A valid date. Currently dates are limited to Australian format
732dd/mm/yy or dd/mm/yyyy format dates.
733
734=item birthdate
735
736A valid date in the past.
737
738=item adultbirthdate
739
740A valid date at least 10 years in the past, at most 100 years in the
741past.
742
743=item futuredate
744
745A valid date in the future.
746
747=item natural
748
749An integer greater or equal to zero.
750
751=item positiveint
752
753A positive integer.
754
755=item dh_one_line
756
757The field may not contain line breaks
758
759=back
760
0ec4ac8a
TC
761=head1 AUTHOR
762
763Tony Cook <tony@develop-help.com>
764
765=cut