File Coverage

blib/lib/FWS/Lite.pm
Criterion Covered Total %
statement 12 164 7.3
branch 0 46 0.0
condition 0 6 0.0
subroutine 4 15 26.6
pod 11 11 100.0
total 27 242 11.1


line stmt bran cond sub pod time code
1             package FWS::Lite;
2              
3 1     1   27884 use 5.006;
  1         4  
  1         43  
4 1     1   6 use strict;
  1         2  
  1         174  
5              
6             =head1 NAME
7              
8             FWS::Lite - Version independent access to Framework Sites installations and common methods
9              
10             =head1 VERSION
11              
12             Version 0.004
13              
14             =cut
15              
16             our $VERSION = '0.004';
17              
18             =head1 SYNOPSIS
19              
20             use FWS::Lite;
21              
22             #
23             # Create FWS with MySQL connectivity
24             #
25             my $fws = FWS::Lite->new( DBName => "theDBName",
26             DBUser => "myUser",
27             DBPassword => "myPass");
28              
29             #
30             # create FWS with SQLite connectivity
31             #
32             my $fws2 = FWS::Lite->new( DBType => "SQLite",
33             DBName => "/home/user/your.db");
34              
35              
36              
37             =head1 DESCRIPTION
38              
39             This module provides basic input and output to a FrameWork Sites installation or can be used independently using the methodologies of FrameWork Sites data structures and file handling in a small package.
40              
41             =head1 CONSTRUCTOR
42              
43             Most uses of FWS::Lite are accessing data from live FWS installations and do not require anything but the database credentials. All non-required settings can be set for completeness or for the ability to run native FWS Code via FWS::Lite for testing that needs these set to determine location and site context.
44              
45             =head2 new
46              
47             my $fws = $fws->new(
48             DBName => "DBNameOrSQLitePathAndFile", # MySQL required
49             DBUser => "myDBUser", # MySQL required
50             DBPassword => "myDBPassword", # MySQL required
51             DBHost => "somePlace.somewhere.com", # default: localhost
52             DBType => "MySQL") # default: MySQL
53              
54             Depending on if you are connecting to a MySQL or SQLite a combination of the following are required.
55              
56             =over 4
57              
58             =item * DBName (MySQL and SQLite Required)
59              
60             For MySQL this is the DB Name. For SQLite this is the DB file path and file name.
61             MySQL example: user_fws
62             SQLite example: /home/user/secureFiles/user_fws.db
63              
64             =item * DBUser (MySQL Required)
65              
66             Required for MySQL and is the database user that has full grant access to the database.
67              
68             =item * DBPassword (MySQL Required)
69              
70             The DBUser's password.
71              
72             =item * DBHost (MySQL Required if your database is not on localhost)
73              
74             The DBHost will default to 'localhost' if not specified, but can be what ever is configured for the database environment.
75              
76              
77             =item * DBType (SQLite Required)
78              
79             The DBType will default to 'MySQL' if not specified, but needs to be added if you are connecting to SQLite.
80              
81             =back
82              
83             Non-required parameters for FWS installations can be added, but depending on the scope of your task they usually are not needed unless your testing code, or interacting with web elements that display rendered content from a stand alone script.
84              
85             =over 4
86              
87             =item * domain
88              
89             Full domain name with http prefix. Example: http://www.example.com
90              
91             =item * filePath
92              
93             Full path name of common files. Example: /home/user/www/files
94              
95             =item * fileSecurePath
96              
97             Full path name of non web accessible files. Example: /home/user/secureFiles
98              
99             =item * fileWebPath
100              
101             Web path for the same place filePath points to. Example: /files
102              
103             =item * secureDomain
104              
105             Secure domain name with https prefix. For non-secure sites that do not have an SSL cert you can use the http:// prefix to disable SSL. Example: https://www.example.com
106              
107             =back
108              
109             =cut
110              
111              
112             ####################
113             ####### HIDE ####### FWS 2.0 Web import block
114             ####################
115              
116             sub new {
117 0     0 1   my $class = shift;
118 0           my $self = {@_};
119 0           bless $self, $class;
120 0           return $self;
121             }
122              
123             ####################
124             ##### END HIDE ##### FWS 2.0 Web import block
125             ####################
126              
127              
128             =head1 DATA METHODS
129              
130             FWS methods that connect, read, write, reorder or alter the database itself.
131              
132             =head2 connectDBH
133              
134             Do the initial database connection via MySQL or SQLite. This method will return back the DBH it creates, but it is only here for completeness and would normally never be used. For FWS database routines this is not required as it will be implied when executing those methods..
135              
136             $fws->connectDBH();
137              
138             =cut
139              
140             sub connectDBH {
141 0     0 1   my ($self) = @_;
142            
143             #
144             # grab the DBI if we don't have it yet
145             #
146 0 0         if (!defined $self->{'_DBH'}) {
147              
148             #
149             # hook up with some DBI
150             #
151 1     1   2369 use DBI;
  1         21462  
  1         1806  
152            
153             #
154             # default set to mysql
155             #
156 0           my $connectString = $self->{'DBType'}.":".$self->{'DBName'}.":".$self->{'DBHost'}.":3306";
157            
158             #
159             # SQLite
160             #
161 0 0         if ($self->{'DBType'} =~ /SQLite/i) { $connectString = "SQLite:".$self->{'DBName'} }
  0            
162            
163             #
164             # set the DBH for use throughout the script
165             #
166 0           $self->{'_DBH'} = DBI->connect("DBI:".$connectString,$self->{'DBUser'}, $self->{'DBPassword'});
167            
168             #
169             # in case the user is going to do thier own thing, we will pass back the DBH
170             #
171 0           return $self->{'_DBH'};
172             }
173             }
174              
175              
176              
177             =head2 runSQL
178              
179             Return an reference to an array that contains the results of the SQL ran.
180              
181              
182              
183             #
184             # retrieve a reference to an array of data we asked for
185             #
186             my $dataArray = $fws->runSQL(SQL=>"select id,type from id_and_type_table"); # Any SQL statement or query
187            
188             #
189             # loop though the array
190             #
191             while (@$dataArray) {
192            
193             #
194             # collect the data each row at a time
195             #
196             my $id = shift(@$dataArray);
197             my $type = shift(@$dataArray);
198              
199             #
200             # display or do something with the data
201             #
202             print "ID: ".$id." - ".$type."\n";
203             }
204            
205              
206             =cut
207              
208             sub runSQL {
209 0     0 1   my ($self,%paramHash) = @_;
210            
211 0           $self->connectDBH();
212              
213             #
214             # Get this data array ready to slurp
215             # and set the failFlag for future use to autocreate a dB schema
216             # based on a default setting
217             #
218 0           my @data;
219             my $errorResponse;
220              
221             #
222             # use the dbh we were handed... if not use the default one.
223             #
224 0 0         if (!exists $paramHash{'DBH'}) {$paramHash{'DBH'} = $self->{'_DBH'}}
  0            
225            
226             #
227             # once loging is turned on we can enable this
228             #
229             #$self->SQLLog($paramHash{'SQL'});
230              
231             #
232             # prepare the SQL and loop though the arrays
233             #
234              
235 0           my $sth = $paramHash{'DBH'}->prepare($paramHash{'SQL'});
236 0 0         if ($sth ne '') {
237 0           $sth->{PrintError} = 0;
238 0           $sth->execute();
239              
240             #
241             # clean way to get error response
242             #
243 0 0         if (defined $DBI::errstr) { $errorResponse .= $DBI::errstr }
  0            
244              
245             #
246             # set the row variable ready to be populated
247             #
248 0           my @row;
249             my @cleanRow;
250 0           my $clean;
251              
252             #
253             # SQL lite gathing and normilization
254             #
255 0 0         if ($self->{'DBType'} =~ /^SQLite$/i) {
256 0           while (@row = $sth->fetchrow) {
257 0           while (@row) {
258 0           $clean = shift(@row);
259 0 0         $clean = '' if !defined $clean;
260 0           $clean =~ s/\\\\/\\/sg;
261 0           push (@cleanRow,$clean);
262             }
263 0           push (@data,@cleanRow);
264             }
265             }
266            
267             #
268             # Fault to MySQL if we didn't find another type
269             #
270             else {
271 0           while (@row = $sth->fetchrow) {
272 0           while (@row) {
273 0           $clean = shift(@row);
274 0 0         $clean = '' if !defined $clean;
275 0           push (@cleanRow,$clean);
276             }
277 0           push (@data,@cleanRow);
278             }
279             }
280             }
281              
282             #
283             # check if myDBH has been blanked - if so we have an error
284             # or I didn't have one to begin with
285             #
286 0 0         if ($errorResponse) {
287             #
288             # once FWSLog is enabled I can enable this
289             #
290 0           warn 'SQL ERROR: '.$paramHash{'SQL'}. ' - '.$errorResponse;
291             #$self->FWSLog('SQL ERROR: '.$paramHash{'SQL'});
292             }
293              
294             #
295             # return this back as a normal array
296             #
297 0           return \@data;
298             }
299              
300              
301              
302             =head2 alterTable
303              
304             Alter a table to conform to the given definition without restriction. The key will describe its index type and lesser definitions of field type will be applied without error or fault. The return will give back any statement that was used to alter the table definition or table creation statement. Use with caution on existing fields as its primary use is for new table creation or programmatic adding of new fields to a table that might not have a field that is needed.
305              
306             #
307             # retrieve a reference to an array of data we asked for
308             #
309             # Note: It is not recommended to change the data structure of
310             # FWS default tables
311             #
312             print $fws->alterTable( table =>"table_name", # case sensitive table name
313             field =>"field_name", # case sensitive field name
314             type =>"char(255)", # Any standard cross platform type
315             key =>"", # MUL, PRIMARY KEY, FULL TEXT
316             default =>""); # '0000-00-00', 1, 'this default value'...
317              
318             =cut
319              
320             ####################
321             ####### HIDE ####### FWS 2.0 Web import block
322             ####################
323              
324             sub alterTable {
325 0     0 1   my ($self, %paramHash) =@_;
326              
327             #
328             # set some vars we will flip depending on db type alot is defaulted to mysql, because that
329             # is the norm, we will groom things that need to be groomed
330             #
331 0           my $sqlReturn;
332 0           my $autoIncrement = "AUTO_INCREMENT ";
333 0           my $indexStatement = "alter table ".$paramHash{'table'}." add INDEX ".$paramHash{'table'}."_".$paramHash{'field'}." (".$paramHash{'field'}.")";
334              
335             #
336             # if default is timestamp lets not put tic's around it
337             #
338 0 0         if ($paramHash{'default'} ne 'CURRENT_TIMESTAMP') { $paramHash{'default'} = "'".$paramHash{'default'}."'" }
  0            
339              
340             #
341             # the add statement we will use to alter tha table
342             #
343 0           my $addStatement = "alter table ".$paramHash{'table'}." add ".$paramHash{'field'}." ".$paramHash{'type'}." NOT NULL default ".$paramHash{'default'};
344              
345             #
346             # add primary key if the table is not an ext field
347             #
348 0           my $primaryKey = "PRIMARY KEY";
349              
350             #
351             # show tables statement
352             #
353 0           my $showTablesStatement = "show tables";
354              
355             #
356             # do SQLite changes
357             #
358 0 0         if ($self->{'DBType'} =~ /^sqlite$/i) {
359 0           $autoIncrement = "";
360 0           $indexStatement = "create index ".$paramHash{'table'}."_".$paramHash{'field'}." on ".$paramHash{'table'}." (".$paramHash{'field'}.")";
361 0           $showTablesStatement = "select name from sqlite_master where type='table'";
362             }
363              
364             #
365             # do mySQL changes
366             #
367 0 0         if ($self->{'DBType'} =~ /^mysql$/i) {
368 0 0         if ($paramHash{'key'} eq 'FULLTEXT') {
369 0           $indexStatement = "create FULLTEXT index ".$paramHash{'table'}."_".$paramHash{'field'}." on ".$paramHash{'table'}." (".$paramHash{'field'}.")";
370             }
371             }
372             #
373             # FULTEXT is MUL if not mysql, and mysql returns them as MUL even if they are full text so we don't need to updated them if they are set to that
374             # so lets change it to MUL to keep mysql and other DB's without FULLTEXT syntax happy
375             #
376 0 0         if ($paramHash{'key'} eq 'FULLTEXT') { $paramHash{'key'} = 'MUL' }
  0            
377              
378             #
379             # compile the statement
380             #
381 0           my $createStatement = "create table ".$paramHash{'table'}." (guid char(36) NOT NULL default '')";
382              
383             #
384             # get the table hash
385             #
386 0           my %tableHash;
387 0           my $tableList = $self->runSQL(SQL=>$showTablesStatement);
388 0           while (@$tableList) {
389 0           my $fieldInc = shift(@$tableList);
390 0           $tableHash{$fieldInc} = 1;
391             }
392              
393             #
394             # create the table if it does not exist
395             #
396 0 0         if (!exists $tableHash{$paramHash{'table'}}) {
397 0           $self->runSQL(SQL=>$createStatement);
398 0           $sqlReturn .= $createStatement.";\n";
399             }
400              
401             #
402             # get the table def hash
403             #
404 0           my $tableFieldHash = $self->tableFieldHash($paramHash{'table'});
405              
406             #
407             # make the field if its not there
408             #
409 0 0         if (!exists $tableFieldHash->{$paramHash{'field'}}{"type"}) {
410 0           $self->runSQL(SQL=>$addStatement);
411 0           $sqlReturn .= $addStatement.";\n";
412             }
413              
414             #
415             # change the datatype if we are talking about MySQL
416             #
417 0           my $changeStatement = "alter table ".$paramHash{'table'}." change ".$paramHash{'field'}." ".$paramHash{'field'}." ".$paramHash{'type'}." NOT NULL default ".$paramHash{'default'};
418 0 0 0       if ($paramHash{'type'} ne $tableFieldHash->{$paramHash{'field'}}{"type"} && $self->DBType() =~ /^mysql$/i) {
419 0           $self->runSQL(SQL=>$changeStatement);
420 0           $sqlReturn .= $changeStatement."; ";
421             }
422              
423             #
424             # need to add change syntax for SQLlite TODO
425             #
426              
427             #
428             # Set a default for the index
429             #
430 0 0         if (!exists $tableFieldHash->{$paramHash{'table'}."_".$paramHash{'field'}}{"key"}) { $tableFieldHash->{$paramHash{'table'}."_".$paramHash{'field'}}{"key"} = '' }
  0            
431            
432             #
433             # set any keys if not the same
434             #
435 0 0 0       if ($tableFieldHash->{$paramHash{'table'}."_".$paramHash{'field'}}{"key"} ne "MUL" && $paramHash{'key'} ne "") {
436 0           $self->runSQL(SQL=>$indexStatement);
437 0           $sqlReturn .= $indexStatement.";\n";
438             }
439              
440 0           return $sqlReturn;
441             }
442              
443             ####################
444             ##### END HIDE ##### FWS 2.0 Web import block
445             ####################
446              
447             =head2 tableFieldHash
448              
449             Return a multi-dimensional hash of all the fields in a table with its properties. This usually isn't used by anything but internal table alteration methods, but it could be useful for someone making conditionals to determine the data structure before adding or changing data.
450              
451             $tableFieldHashRef = $fws->tableFieldHash('the_table');
452              
453             #
454             # the return dump will have the following structure
455             #
456             $hash->{field}{type}
457             $hash->{field}{key}
458             $hash->{field}{ord}
459             $hash->{field}{null}
460             $hash->{field}{default}
461             $hash->{field}{extra}
462            
463             $hash->{field_2}{type}
464             $hash->{field_2}{key}
465             $hash->{field_2}{ord}
466             $hash->{field_2}{null}
467             $hash->{field_2}{default}
468             $hash->{field_2}{extra}
469            
470             ...
471              
472              
473             =cut
474              
475             sub tableFieldHash {
476 0     0 1   my ($self,$table) = @_;
477              
478             #
479             # set an order counter so we can sort by this if needed
480             #
481 0           my $fieldOrd = 0;
482              
483             #
484             # TODO CACHE
485             #
486 0           my $tableFieldHash = {};
487              
488             #
489             # if we have a cached version, just return it
490             #
491 0 0         if (!keys %$tableFieldHash) {
492             #
493             # we are not pulling this from cache, lets start from scratch
494             #
495 0           my %tableFieldHash;
496              
497              
498             #
499             # grab the table def hash for mysql
500             #
501 0 0         if ($self->{'DBType'} =~ /^mysql$/i) {
502 0           my $tableData = $self->runSQL(SQL=>"desc ".$table);
503 0           while (@$tableData) {
504 0           $fieldOrd++;
505 0           my $fieldInc = shift(@$tableData);
506 0           $tableFieldHash{$fieldInc}{'type'} = shift(@$tableData);
507 0           $tableFieldHash{$fieldInc}{'ord'} = $fieldOrd;
508 0           $tableFieldHash{$fieldInc}{'null'} = shift(@$tableData);
509 0           $tableFieldHash{$table."_".$fieldInc}{'key'} = shift(@$tableData);
510 0           $tableFieldHash{$fieldInc}{'default'} = shift(@$tableData);
511 0           $tableFieldHash{$fieldInc}{'extra'} = shift(@$tableData);
512             }
513             }
514              
515             #
516             # grab the table def hash for sqlite
517             #
518 0 0         if ($self->{'DBType'} =~ /^sqlite$/i) {
519 0           my $tableData = $self->runSQL(SQL=>"PRAGMA table_info(".$table.")");
520 0           while (@$tableData) {
521 0           shift(@$tableData);
522 0           my $fieldInc = shift(@$tableData);
523 0           shift(@$tableData);
524 0           shift(@$tableData);
525 0           shift(@$tableData);
526 0           $tableFieldHash{$fieldInc}{'type'} = shift(@$tableData);
527              
528 0           $fieldOrd++;
529 0           $tableFieldHash{$fieldInc}{'ord'} = $fieldOrd;
530             }
531              
532 0           $tableData = $self->runSQL(SQL=>"PRAGMA index_list(".$table.")");
533 0           while (@$tableData) {
534 0           shift(@$tableData);
535 0           my $fieldInc = shift(@$tableData);
536 0           shift(@$tableData);
537              
538 0           $tableFieldHash{$fieldInc}{"key"} = "MUL";
539             }
540             }
541 0           return \%tableFieldHash;
542             }
543             else {
544             #
545             # TODO SAVE CACHE
546             #
547             }
548             }
549              
550              
551              
552              
553             =head1 FORMAT METHODS
554              
555             FWS methods that use or manipulate text either for rendering or default population.
556              
557             =head2 createGUID
558              
559             Return a non repeatable Globally Unique Identifier to be used to populate the guid field that is default on all FWS tables.
560              
561             #
562             # retrieve a guid to use with a new record
563             #
564             my $guid = $fws->createGUID();
565              
566             =cut
567              
568             ####################
569             ####### HIDE ####### FWS 2.0 Web import block
570             ####################
571              
572             sub createGUID {
573 0     0 1   my ($self) =@_;
574 0           my $guid;
575 1     1   972 use Digest::SHA1 qw(sha1);
  1         18907  
  1         910  
576 0           $guid = join('-', unpack('H8 H4 H4 H4 H12', sha1( shift().shift().time().rand().$<.$$)));
577 0           return $guid;
578             }
579              
580             ####################
581             ##### END HIDE ##### FWS 2.0 Web import block
582             ####################
583              
584             =head2 createPassword
585              
586             Return a random password or text key that can be used for temp password or unique configurable small strings.
587              
588             #
589             # retrieve a password that is 6-8 characters long and does not contain commonly mistaken letters
590             #
591             my $tempPassword = $fws->createPassword(
592             composition => "qwertyupasdfghjkzxcvbnmQWERTYUPASDFGHJKZXCVBNM23456789"
593             lowLength => 6,
594             highLength => 8);
595              
596             =cut
597              
598              
599             ##################################################################
600             sub createPassword {
601 0     0 1   my ($self, %paramHash) = @_;
602 0           my $returnString;
603 0           my @pass = split //,$paramHash{'composition'};
604 0           my $length = int(rand($paramHash{'highLengthy'} - $paramHash{'lowLength'} + 1)) + $paramHash{'lowLength'};
605 0           for(1..$length) { $returnString .= $pass[int(rand($#pass))] }
  0            
606 0           return $returnString;
607             }
608              
609              
610              
611             =head1 FILE METHODS
612              
613             FWS methods that access the file system for its results.
614              
615             =head2 fileArray
616              
617             Return a directory listing into a FWS hash array reference.
618            
619             #
620             # retrieve a reference to an array of data we asked for
621             #
622             my $fileArray = $fws->fileArray( directory =>"/home/directory" );
623              
624             #
625             # loop though the array printing the files we found
626             #
627             for my $i (0 .. $#$fileArray) {
628             print $fileArray->[$i]{"file"}. "\n";
629             }
630              
631             =cut
632              
633             sub fileArray {
634 0     0 1   my ($self,%paramHash) =@_;
635              
636             #
637             # ensure nothing scary is in the directory
638             #
639 0           $paramHash{'directory'} = $self->safeDir($paramHash{'directory'});
640              
641             #
642             # pull the directory into an array
643             #
644 0           opendir(DIR, $paramHash{'directory'});
645 0           my @getDir = grep(!/^\.\.?$/,readdir(DIR));
646 0           closedir(DIR);
647              
648 0           my @fileHashArray;
649 0           foreach my $dirFile (@getDir) {
650 0 0         if (-f $paramHash{'directory'}.'/'.$dirFile) {
651              
652 0           my %fileHash;
653 0           $fileHash{'file'} = $dirFile;
654 0           $fileHash{'fullFile'} = $paramHash{'directory'}.'/'.$dirFile;
655 0           $fileHash{'size'} = (stat $fileHash{'fullFile'})[7];
656 0           $fileHash{'date'} = (stat $fileHash{'fullFile'})[9];
657              
658             #
659             # push it to the array
660             #
661 0           push (@fileHashArray,{%fileHash});
662             }
663             }
664 0           return \@fileHashArray;
665             }
666              
667              
668             =head1 SAFETY METHODS
669              
670             FWS Safety methods are used for security when using unknown parameters that could be malicious. When ever data is passed to another method it should be wrapped in its appropriate safety method under the guidance of each method.
671              
672             =head2 safeDir
673              
674             All directories should be wrapped in this method before being applied. It will remove any context that could change its scope to higher than its given location. When using directories ALWAYS prepend them with $fws->{"fileDir"} or $fws->{"secureFileDir"} to ensure they root path is always in a known location to further prevent any tampering. NEVER use a directory that is not prepended with a known depth!
675              
676             #
677             # will return //this/could/be/dangerous
678             #
679             print $fws->safeDir("../../this/could/be/dangrous");
680            
681             #
682             # will return this/is/fine
683             #
684             print $fws->safeDir("this/is/fine");
685              
686             =cut
687              
688             sub safeDir {
689 0     0 1   my ($self, $incommingText) = @_;
690 0           $incommingText =~ s/(\.\.|\||;)//sg;
691 0           return $incommingText;
692             }
693              
694              
695             =head2 safeFile
696              
697             All files should be wrapped in this method before being applied. It will remove any context that could change its scope to a different directory.
698              
699             #
700             # will return ....i-am-trying-to-change-dir.ext
701             #
702             print $fws->safeDir("../../i-am-trying-to-change-dir.ext");
703              
704             =cut
705              
706              
707             sub safeFile {
708 0     0 1   my ($self, $incommingText) = @_;
709 0           $incommingText =~ s/(\/|\\|;|\|)//sg;
710 0           return $incommingText;
711             }
712              
713              
714             =head2 safeSQL
715              
716             All fields and dynamic content in SQL statements should be wrapped in this method before being applied. It will add double tics and escape any escapes so you can not break out of a statement and inject anything not intended.
717              
718             #
719             # will return this '' or 1=1 or '' is super bad
720             #
721             print $fws->safeSQL("this ' or 1=1 or ' is super bad");
722              
723             =cut
724              
725             sub safeSQL {
726 0     0 1   my ($self, $incommingText) = @_;
727 0           $incommingText =~ s/\'/\'\'/sg;
728 0           $incommingText =~ s/\\/\\\\/sg;
729 0           return $incommingText;
730             }
731              
732              
733             ##########################################################################################
734             =head1 AUTHOR
735              
736             Nate Lewis, C<< >>
737              
738             =head1 BUGS
739              
740             Please report any bugs or feature requests to C, or through
741             the web interface at L. I will be notified, and then you'll
742             automatically be notified of progress on your bug as I make changes.
743              
744              
745             =head1 SUPPORT
746              
747             You can find documentation for this module with the perldoc command.
748              
749             perldoc FWS::Lite
750              
751              
752             You can also look for information at:
753              
754             =over 4
755              
756             =item * RT: CPAN's request tracker (report bugs here)
757              
758             L
759              
760             =item * AnnoCPAN: Annotated CPAN documentation
761              
762             L
763              
764             =item * CPAN Ratings
765              
766             L
767              
768             =item * Search CPAN
769              
770             L
771              
772             =back
773              
774             =head1 LICENSE AND COPYRIGHT
775              
776             Copyright 2012 Nate Lewis.
777              
778             This program is free software; you can redistribute it and/or modify it
779             under the terms of either: the GNU General Public License as published
780             by the Free Software Foundation; or the Artistic License.
781              
782             See http://dev.perl.org/licenses/ for more information.
783              
784              
785             =cut
786              
787             1; # End of FWS::Lite