File Coverage

lib/URI/Shortener.pm
Criterion Covered Total %
statement 114 145 78.6
branch 28 52 53.8
condition 7 40 17.5
subroutine 18 21 85.7
pod 5 6 83.3
total 172 264 65.1


line stmt bran cond sub pod time code
1             package URI::Shortener 1.006;
2              
3             #ABSTRACT: Shorten URIs so that you don't have to rely on external services
4              
5 1     1   43276 use strict;
  1         3  
  1         40  
6 1     1   6 use warnings;
  1         3  
  1         64  
7              
8 1     1   27 use v5.012;
  1         5  
9              
10 1     1   970 use Capture::Tiny qw{capture_merged};
  1         39285  
  1         85  
11 1     1   2119 use Carp::Always;
  1         1073  
  1         5  
12 1     1   982 use POSIX qw{floor};
  1         8600  
  1         7  
13 1     1   9506 use DBI;
  1         31409  
  1         110  
14 1     1   8793 use File::Touch;
  1         19078  
  1         102  
15 1     1   2385 use Crypt::PRNG;
  1         14130  
  1         3278  
16              
17              
18             my $SCHEMA_NAMES = {
19             uri_tablename => 'uris',
20             prefix_tablename => 'prefix',
21             uri_idxname => 'uri_idx',
22             prefix_idxname => 'prefix_idx',
23             cipher_idxname => 'cipher_idx',
24             created_idxname => 'created_idx',
25             };
26              
27             our $SCHEMA_SQLITE = qq{
28             CREATE TABLE IF NOT EXISTS prefix_tablename (
29             id INTEGER PRIMARY KEY AUTOINCREMENT,
30             prefix TEXT NOT NULL UNIQUE
31             );
32              
33             CREATE TABLE IF NOT EXISTS uri_tablename (
34             id INTEGER PRIMARY KEY AUTOINCREMENT,
35             prefix_id INTEGER NOT NULL REFERENCES prefix_tablename(id) ON DELETE CASCADE,
36             uri TEXT NOT NULL UNIQUE,
37             cipher TEXT DEFAULT NULL UNIQUE,
38             created INTEGER
39             );
40              
41             CREATE INDEX IF NOT EXISTS uri_idxname ON uri_tablename(uri);
42             CREATE INDEX IF NOT EXISTS prefix_idxname ON prefix_tablename(prefix);
43             CREATE INDEX IF NOT EXISTS cipher_idxname ON uri_tablename(cipher);
44             CREATE INDEX IF NOT EXISTS created_idxname ON uri_tablename(created);
45             };
46              
47             our $SCHEMA_PG = qq{
48             CREATE TABLE IF NOT EXISTS prefix_tablename (
49             id SERIAL PRIMARY KEY,
50             prefix TEXT NOT NULL UNIQUE
51             );
52              
53             CREATE TABLE IF NOT EXISTS uri_tablename (
54             id BIGSERIAL PRIMARY KEY,
55             prefix_id INTEGER NOT NULL REFERENCES prefix_tablename(id) ON DELETE CASCADE,
56             uri TEXT NOT NULL UNIQUE,
57             cipher TEXT DEFAULT NULL UNIQUE,
58             created INTEGER
59             );
60              
61             CREATE INDEX IF NOT EXISTS uri_idxname ON uri_tablename(uri);
62             CREATE INDEX IF NOT EXISTS prefix_idxname ON prefix_tablename(prefix);
63             CREATE INDEX IF NOT EXISTS cipher_idxname ON uri_tablename(cipher);
64             CREATE INDEX IF NOT EXISTS created_idxname ON uri_tablename(created);
65             };
66              
67             our $SCHEMA_MYSQL = qq{
68             CREATE TABLE IF NOT EXISTS prefix_tablename (
69             id INTEGER AUTO_INCREMENT,
70             prefix TEXT NOT NULL,
71             PRIMARY KEY(id)
72             );
73              
74             CREATE TABLE IF NOT EXISTS uri_tablename (
75             id BIGINT AUTO_INCREMENT,
76             prefix_id INTEGER NOT NULL REFERENCES prefix_tablename(id) ON DELETE CASCADE,
77             uri TEXT NOT NULL,
78             cipher VARCHAR(180) DEFAULT NULL UNIQUE,
79             created INTEGER,
80             PRIMARY KEY(id)
81             );
82              
83             };
84              
85              
86             sub new {
87 8     8 1 449942 my $class = shift;
88 8         89 my %options = (
89             %$SCHEMA_NAMES,
90             @_
91             );
92 8   50     68 $options{dbtype} //= 'sqlite';
93              
94 8   66     107 $options{domain} ||= join('',('a'..'z','A'..'Z'));
95              
96 8         24 foreach my $required (qw{domain prefix dbname seed}) {
97 29 100       120 die "$required required" unless $options{$required};
98             }
99 5 50 33     33 $options{length} = 12 if !$options{length} || $options{length} < 0;
100              
101             # Strip trailing slash from prefix
102 5         49 $options{prefix} =~ s|/+$||;
103              
104 5         46 $options{sqlite_schema} = $SCHEMA_SQLITE;
105 5         14 $options{mysql_schema} = $SCHEMA_MYSQL;
106 5         15 $options{pg_schema} = $SCHEMA_PG;
107              
108             # Mongle the schema appropriately
109 5         27 foreach my $sql_obj (keys(%$SCHEMA_NAMES)) {
110 30         537 $options{sqlite_schema} =~ s/\Q$sql_obj\E/$options{$sql_obj}/gmx;
111 30         318 $options{mysql_schema} =~ s/\Q$sql_obj\E/$options{$sql_obj}/gmx;
112 30         784 $options{pg_schema} =~ s/\Q$sql_obj\E/$options{$sql_obj}/gmx;
113             }
114              
115 5         22 $options{dbh} = {};
116              
117 5         28 return bless( \%options, $class );
118             }
119              
120              
121             sub cipher {
122 10070     10070 1 24324 my ( $self, $id ) = @_;
123 10070         248620 my $rr = Crypt::PRNG->new('Fortuna', $self->{seed} + $id);
124 10070         57402 return $rr->string_from($self->{domain}, $self->{length});
125             }
126              
127              
128             my $smash=0;
129             sub shorten {
130 20144     20144 1 127100 my ( $self, $uri ) = @_;
131              
132 20144         52738 my $query = "SELECT id, cipher FROM $self->{uri_tablename} WHERE uri=?";
133              
134 20144         51603 my $rows = $self->_dbh()->selectall_arrayref( $query, { Slice => {} }, $uri );
135 20144   50     4263316 $rows //= [];
136 20144 100       60439 if (@$rows) {
137 10074 100       29706 return $self->{prefix}."/".$rows->[0]{cipher} if $rows->[0]{cipher};
138 10070         40312 my $ciphered = $self->cipher( $rows->[0]{id} );
139 10070         1562867 my $worked = $self->_dbh()->do( "UPDATE $self->{uri_tablename} SET cipher=? WHERE id=?", undef, $ciphered, $rows->[0]{id} );
140             # In the (incredibly rare) event of a collision, just burn the row and move on.
141 10070 100       2063758 if (!$worked) {
142 65         873 warn "DANGER: cipher collision detected.";
143 65 50       33789 $self->_dbh()->do( "UPDATE $self->{uri_tablename} SET uri=? WHERE id=?", undef, "$uri-BURNED$smash", $rows->[0]{id} ) or die "Could not burn row";
144 65         20037 $smash++;
145 65 100       225 die "Too many failures to avoid name collisions encountered, prune your DB!" if $smash > 64;
146 64         399 goto \&shorten;
147             }
148 10005         75363 return $self->{prefix} . "/" . $ciphered;
149             }
150              
151             # Otherwise we need to store the URI and retrieve the ID.
152 10070         29549 my $pis = "SELECT id FROM $self->{prefix_tablename} WHERE prefix=?";
153 10070         31258 my $has_prefix = $self->_dbh->selectall_arrayref( $pis, { Slice => {} }, $self->{prefix} );
154 10070 100       2030052 unless (@$has_prefix) {
155 4 50       15 $self->_dbh()->do( "INSERT INTO $self->{prefix_tablename} (prefix) VALUES (?)", undef, $self->{prefix} ) or die $self->_dbh()->errstr;
156             }
157              
158 10070         33869 my $qq = "INSERT INTO $self->{uri_tablename} (uri,created,prefix_id) VALUES (?,?,(SELECT id FROM $self->{prefix_tablename} WHERE prefix=?))";
159 10070 50       31894 $self->_dbh()->do( $qq, undef, $uri, time(), $self->{prefix} ) or die $self->_dbh()->errstr;
160 10070         2392382 goto \&shorten;
161             }
162              
163              
164             sub lengthen {
165 6     6 1 2743 my ( $self, $uri ) = @_;
166 6         137 my ($cipher) = $uri =~ m|^\Q$self->{prefix}\E/(.*)$|;
167              
168 6         23 my $query = "SELECT uri FROM $self->{uri_tablename} WHERE cipher=? AND prefix_id IN (SELECT id FROM $self->{prefix_tablename} WHERE prefix=?)";
169              
170 6         20 my $rows = $self->_dbh()->selectall_arrayref( $query, { Slice => {} }, $cipher, $self->{prefix} );
171 6   50     1843 $rows //= [];
172 6 100       59 return unless @$rows;
173 4         27 return $rows->[0]{uri};
174             }
175              
176              
177             sub prune_before {
178 2     2 1 6 my ( $self, $when ) = @_;
179 2 50       9 $self->_dbh()->do( "DELETE FROM $self->{uri_tablename} WHERE created < ?", undef, $when ) or die $self->_dbh()->errstr;
180 2         367 return 1;
181             }
182              
183             my %db_dispatch = (
184             sqlite => \&_sqlite_dbh,
185             pg => \&_pg_dbh,
186             mysql => \&_my_dbh,
187             );
188              
189             sub _dbh {
190 50433     50433   104584 my ($self) = @_;
191 50433         169865 return $db_dispatch{$self->{dbtype}}->(@_);
192             }
193              
194             sub _sqlite_dbh {
195 50433     50433   97403 my ($self) = @_;
196 50433         107795 my $dbname = $self->{dbname};
197 50433 100       599021 return $self->{dbh}->{$dbname} if exists $self->{dbh}->{$dbname};
198              
199             # Some systems splash down without this. YMMV.
200 5 50 33     21 File::Touch::touch($dbname) if $dbname ne ':memory:' && !-f $dbname;
201              
202 5         68 my $db = DBI->connect( "dbi:SQLite:dbname=$dbname", "", "" );
203 5         4953 $db->{sqlite_allow_multiple_statements} = 1;
204 5 50       65 $db->do($self->{sqlite_schema}) or die "Could not ensure database consistency: " . $db->errstr;
205 5         4544 $db->{sqlite_allow_multiple_statements} = 0;
206 5         28 $self->{dbh}->{$dbname} = $db;
207              
208             # Turn on fkeys
209 5 50       36 $db->do("PRAGMA foreign_keys = ON") or die "Could not enable foreign keys";
210              
211             # Turn on WALmode, performance
212 5 50       243 $db->do("PRAGMA journal_mode = WAL") or die "Could not enable WAL mode";
213              
214 5         167 return $db;
215             }
216              
217             sub _pg_dbh {
218 0     0   0 my ($self) = @_;
219 0         0 my $dbname = $self->{dbname};
220 0 0       0 return $self->{dbh}->{$dbname} if exists $self->{dbh}->{$dbname};
221              
222 0   0     0 my $host = $self->{dbhost} // $ENV{PGHOST} || 'localhost';
223 0   0     0 my $port = $self->{dbport} // $ENV{PGPORT} || 5432;
224 0   0     0 my $user = $self->{dbuser} // $ENV{PGUSER};
225 0   0     0 my $pass = $self->{dbpass} // $ENV{PGPASSWORD};
226              
227 0         0 my $db = DBI->connect("dbi:Pg:dbname=$dbname;host=$host;port=$port", $user, $pass);
228              
229             #XXX pg is noisy even when you say 'IF NOT EXISTS'
230 0         0 my $result;
231 0     0   0 capture_merged { $result = $db->do($self->{pg_schema}) };
  0         0  
232 0 0       0 die "Could not ensure database consistency: " . $db->errstr unless $result;
233              
234 0         0 $self->{dbh}->{$dbname} = $db;
235 0         0 return $db;
236             }
237              
238             sub _my_dbh {
239 0     0   0 my ($self) = @_;
240 0         0 my $dbname = $self->{dbname};
241 0 0       0 return $self->{dbh}->{$dbname} if exists $self->{dbh}->{$dbname};
242              
243 0   0     0 my $host = $self->{dbhost} // $ENV{MYSQL_HOST} || 'localhost';
244 0   0     0 my $port = $self->{dbport} // $ENV{MYSQL_TCP_PORT} || 3306;
245 0   0     0 my $user = $self->{dbuser} // $ENV{DBI_USER};
246 0   0     0 my $pass = $self->{dbpass} // $ENV{MYSQL_PWD};
247              
248             # Handle the mysql defaults file
249 0   0     0 my $defaults_file = $self->{mysql_read_default_file} // "$ENV{HOME}/.my.cnf";
250 0   0     0 my $defaults_group = $self->{mysql_read_default_group} // 'client';
251 0         0 my $df = "";
252 0 0       0 $df .= "mysql_read_default_file=$defaults_file;" if -f $defaults_file;
253 0 0       0 $df .= "mysql_read_default_group=$defaults_group;" if $defaults_group;
254              
255 0         0 my $dsn = "dbi:mysql:mysql_multi_statements=1;database=$dbname;".$df."host=$host;port=$port";
256              
257 0         0 my $db = DBI->connect($dsn, $user, $pass);
258 0 0       0 $db->do($self->{mysql_schema}) or die "Could not ensure database consistency: " . $db->errstr;
259              
260 0         0 $self->{dbh}->{$dbname} = $db;
261 0         0 return $db;
262             }
263              
264              
265             sub migrate {
266 1     1 0 9 my ($self, $new) = @_;
267 1         11 my $from_dbh = $self->_dbh();
268 1         3 my $to_dbh = $new->_dbh();
269              
270 1         32 my $prefixes = $from_dbh->selectall_arrayref(qq|SELECT * FROM $self->{prefix_tablename}|);
271 1 50       126 _batch_insert($to_dbh, $new->{prefix_tablename}, @$prefixes) if @$prefixes;
272              
273 1         17 my $uris = $from_dbh->selectall_arrayref(qq|SELECT * FROM $self->{uri_tablename}|);
274 1 50       32846 _batch_insert($to_dbh, $new->{uri_tablename}, @$uris) if @$uris;
275             }
276              
277             # Gotta batch stuff cuz sqlite has a 10k param limit
278             sub _batch_insert {
279 2     2   1298 my ($dbh, $tbl, @data) = @_;
280 2         8 my $ncols = @{$data[0]};
  2         7  
281 2 50       10 die "No columns in table $tbl" unless $ncols;
282              
283 2         81 while (my @batch = splice(@data, 0,int(10_000 / $ncols)) ) {
284 7         33 my $param = join(',', map { '?' } @{$batch[0]});
  32         89  
  7         33  
285 7         60 my $bind = join(',', map { "($param)" } @batch);
  10003         21873  
286              
287 7         1023 my $query = "INSERT INTO $tbl VALUES $bind";
288              
289 7         245 print "Migrating ".scalar(@batch)." rows (".scalar(@data)." left) to $tbl...";
290 7         99 $dbh->do($query, undef, map { @$_ } @batch);
  10003         23175  
291 7         215935 print "Done.\n"
292             }
293             }
294              
295             1;
296              
297             __END__