Compare commits

...

60 Commits

Author SHA1 Message Date
d59b9cf837 fix: use Mojo::Path for url escaping 2026-01-19 11:38:43 +01:00
77a45cc58e test: tighten integration gating 2026-01-15 11:57:12 +01:00
17eb69fed0 test: cover error formatting 2026-01-12 10:56:15 +01:00
7aa400b936 test: remove obsoletes tests 2026-01-05 17:37:57 +01:00
1a82fbac12 clean: trim db api (setnx handles potential collisions) 2026-01-05 17:37:46 +01:00
285d25223e tests: run async for https url 2026-01-05 07:26:46 +01:00
d88e35b965 tests: update ssl tests 2026-01-05 07:26:33 +01:00
b0aa64053b refactor: check_ssl is async by default 2026-01-05 07:25:55 +01:00
c398ff843d test: accept async validation 2026-01-05 07:22:20 +01:00
385084afc5 tests: align async validation tests 2026-01-05 07:22:13 +01:00
777b589946 feat: speed up validation 2026-01-05 07:22:05 +01:00
72013a9a08 test: add DNS cache coverage 2025-12-29 16:11:31 +01:00
e6fc9c919f feat: add DNS cache and subprocess resolution 2025-12-29 16:11:24 +01:00
39bead9da1 test: expect 400 for invalid short code 2025-12-29 16:03:45 +01:00
699f660ec2 fix: respond_once uses stash flag 2025-12-29 16:03:35 +01:00
2c28b603da test: adjust validation expectations for redirects 2025-12-29 15:46:10 +01:00
76fa8a7334 fix: correct IPv6 private range checks 2025-12-29 15:45:52 +01:00
8495d6ab26 fix: validate redirect targets to prevent SSRF via redirect chains 2025-12-29 15:37:17 +01:00
b203bcad78 refactor: normalize range checks into one clear path 2025-12-29 15:34:33 +01:00
7005e0852a refactor: use respond_once helper in async handlers 2025-12-29 15:31:59 +01:00
af5a924ae3 refactor: add respond_once helper and refactor async handlers to use it 2025-12-29 15:31:43 +01:00
4730c577fa test: cover head/get fallback 2025-12-29 15:25:24 +01:00
940f60e471 test: add UTF-8 path case 2025-12-29 15:25:16 +01:00
407289cd2a fix: head/get reachability fallback 2025-12-29 15:25:08 +01:00
b5ab00ef93 fix: decode UTF-8 after url_unescape 2025-12-29 15:24:56 +01:00
0b277a3e65 fix: use Socket getaddrinfo for host resolution 2025-12-29 15:13:46 +01:00
e9c298110d test: update validation specs for DNS SSRF logic 2025-12-29 15:08:53 +01:00
ae1dab8116 feat: add DNS-based SSRF checks 2025-12-29 15:08:46 +01:00
eb4c4e4c4c feat: use secure RNG for short codes 2025-12-29 15:08:35 +01:00
e2c4916565 fix: guard async responses with local flag 2025-12-29 15:08:26 +01:00
15f082fcdc deps: lock Bytes::Random::Secure and transitive deps 2025-12-29 15:08:05 +01:00
8e6665971e deps: add Bytes::Random::Secure 2025-12-29 15:07:58 +01:00
09a0fe017a fix: use $c->rendered instead of stash->{rendered} 2025-12-29 14:48:06 +01:00
9fc620130c fix: use $c->rendered instead of stash->{rendered} 2025-12-29 14:47:59 +01:00
2903aa51ff revert: cpanfile.snapshot 2025-12-29 08:59:41 +01:00
bf9579ab14 fix: deploy templates/public but not cpanfile.snapshot 2025-12-29 08:57:39 +01:00
48f2b8448a docs: update readme 2025-12-29 08:56:43 +01:00
10fd579d0b docs: update readme 2025-12-29 08:48:53 +01:00
4810966b1c feat: add version in /health 2025-12-29 08:43:55 +01:00
8c62bff80b clean: remove div 2025-12-29 08:43:05 +01:00
17857a6b56 feat: add brand-version to display version bottom right 2025-12-29 08:42:59 +01:00
4053b89cf4 feat: title top left, version bottom right 2025-12-29 08:42:48 +01:00
331dba9211 feat: setup version 2025-12-29 08:42:27 +01:00
bd4c6c9a1d test: add unit testing for URL.pm 2025-12-28 19:40:50 +01:00
6f40a4569a clean: shorten title 2025-12-28 19:32:47 +01:00
9f8570eea2 test: add unit testing for App.pm 2025-12-28 19:30:37 +01:00
611a25c88d fix: center the box and its button 2025-12-28 17:59:08 +01:00
801b09ac83 feat: favicon 2025-12-28 17:55:55 +01:00
39fd9d5c20 feat: add our favicon 2025-12-28 17:55:51 +01:00
b15b473033 clean: remove useless label 2025-12-28 17:53:58 +01:00
e9969841b1 feat: add brand-mark class to place "urupam" 2025-12-28 17:53:41 +01:00
edc1c8cd66 refactor: extract shared styles to app.css 2025-12-28 17:48:58 +01:00
fcbb8f8e5e feat: adopt layout and improve error handling 2025-12-28 17:48:37 +01:00
d8c43cd29b feat: add a shared html layout 2025-12-28 17:48:17 +01:00
2ae22a271b feat: use shared layout 2025-12-28 17:48:05 +01:00
6912495a04 test: use perl.org for reachability edge case 2025-12-28 17:46:49 +01:00
aa9f557aa0 docs: remove warning 2025-12-28 17:37:57 +01:00
fb500c7799 feat: create main template 2025-12-28 17:37:50 +01:00
d9b05bab33 feat: render our new main template 2025-12-28 17:37:43 +01:00
6ce43f4608 feat: use HTML templates for public errors 2025-12-28 17:32:25 +01:00
23 changed files with 1682 additions and 554 deletions

View File

@@ -2,21 +2,44 @@
`urupam` is a lightweight URL shortener built with Perl and Mojolicious, and backed by Redis. `urupam` is a lightweight URL shortener built with Perl and Mojolicious, and backed by Redis.
## Warning
It's a work in progress. API looks good but all the front part remains to do.
## Basic requirements ## Basic requirements
- Perl 5.42.0 - Perl 5.42.0
- Carton (handles perl deps) - Carton (handles perl deps)
- Redis - Redis
## How to run ## Installation
To run the application in development, you'll first need a Redis server. ### Classic
The easiest way is to start a local Redis instance using Docker: Run the installation script:
```sh
scripts/install.sh
```
It will create a `urupam` user and group, deploy the application in `/opt/urupam` and create/enable a `systemd` service. Documentation will be installed in `/usr/share/doc/urupam`.
The application will listen on `:8080`.
### Using docker
Build the image and use the `docker-compose` file:
```sh
docker build -t urupam .
docker compose up -d
```
Alternatively, if you already have a running `redis` instance, you can skip `compose` and start a standalone container:
```sh
docker run --name urupam -p 8080:8080 -d urupam:latest
```
## Hacking
To run the application in development, you'll first need a Redis server. The easiest way is to start one is using Docker:
```sh ```sh
docker run --name mojo-redis -p 6379:6379 -d redis docker run --name mojo-redis -p 6379:6379 -d redis
@@ -28,36 +51,28 @@ Install Perl dependencies with [Carton](https://github.com/perl-carton/carton):
carton install carton install
``` ```
Start the application with `morbo`: Add your changes and your tests, then start the application with `morbo`:
```sh ```sh
carton exec morbo bin/urupam carton exec morbo bin/urupam
``` ```
Application will listen on port `3000`. The application will listen on port `3000` by default.
## Installation ## Running tests
Run the installation script: As every perl project, tests are located in the `t` directory.
To run tests, use the `carton` command:
```sh ```sh
scripts/install.sh carton exec prove -lr t/
``` ```
Enable and start the systemd service: To run specific tests (like integration tests), use:
```sh ```sh
sudo systemctl enable --now urupam carton exec prove -lr t/integration.t
```
### Using docker
Build the image and use the `docker-compose` file:
```sh
cd docker
docker build -t urupam .
docker compose up -d
``` ```
## License ## License

View File

@@ -5,6 +5,7 @@ requires 'Mojo::URL';
requires 'Mojo::UserAgent'; requires 'Mojo::UserAgent';
requires 'Mojo::Util'; requires 'Mojo::Util';
requires 'Mojolicious'; requires 'Mojolicious';
requires 'Bytes::Random::Secure';
on test => sub { on test => sub {
requires 'Test::Mojo'; requires 'Test::Mojo';

View File

@@ -1,5 +1,19 @@
# carton snapshot format: version 1.0 # carton snapshot format: version 1.0
DISTRIBUTIONS DISTRIBUTIONS
Bytes-Random-Secure-0.29
pathname: D/DA/DAVIDO/Bytes-Random-Secure-0.29.tar.gz
provides:
Bytes::Random::Secure 0.29
requirements:
Carp 0
Crypt::Random::Seed 0
ExtUtils::MakeMaker 6.56
MIME::Base64 0
MIME::QuotedPrint 3.03
Math::Random::ISAAC 0
Scalar::Util 1.21
Test::More 0.98
perl 5.006000
Class-Load-0.25 Class-Load-0.25
pathname: E/ET/ETHER/Class-Load-0.25.tar.gz pathname: E/ET/ETHER/Class-Load-0.25.tar.gz
provides: provides:
@@ -30,6 +44,34 @@ DISTRIBUTIONS
perl 5.006 perl 5.006
strict 0 strict 0
warnings 0 warnings 0
Crypt-Random-Seed-0.03
pathname: D/DA/DANAJ/Crypt-Random-Seed-0.03.tar.gz
provides:
Crypt::Random::Seed 0.03
requirements:
Carp 0
Crypt::Random::TESHA2 0
Exporter 5.562
ExtUtils::MakeMaker 0
Fcntl 0
Test::More 0.45
base 0
constant 0
perl 5.006002
Crypt-Random-TESHA2-0.01
pathname: D/DA/DANAJ/Crypt-Random-TESHA2-0.01.tar.gz
provides:
Crypt::Random::TESHA2 0.01
Crypt::Random::TESHA2::Config 0.01
requirements:
Carp 0
Digest::SHA 5.22
Exporter 5.562
ExtUtils::MakeMaker 0
Test::More 0.45
Time::HiRes 1.9711
base 0
perl 5.006002
Data-OptList-0.114 Data-OptList-0.114
pathname: R/RJ/RJBS/Data-OptList-0.114.tar.gz pathname: R/RJ/RJBS/Data-OptList-0.114.tar.gz
provides: provides:
@@ -112,6 +154,15 @@ DISTRIBUTIONS
requirements: requirements:
ExtUtils::MakeMaker 0 ExtUtils::MakeMaker 0
perl 5.006 perl 5.006
Math-Random-ISAAC-1.004
pathname: J/JA/JAWNSY/Math-Random-ISAAC-1.004.tar.gz
provides:
Math::Random::ISAAC 1.004
Math::Random::ISAAC::PP 1.004
requirements:
ExtUtils::MakeMaker 6.31
Test::More 0.62
Test::NoWarnings 0.084
Module-Implementation-0.09 Module-Implementation-0.09
pathname: D/DR/DROLSKY/Module-Implementation-0.09.tar.gz pathname: D/DR/DROLSKY/Module-Implementation-0.09.tar.gz
provides: provides:
@@ -860,6 +911,15 @@ DISTRIBUTIONS
perl 5.008 perl 5.008
strict 0 strict 0
warnings 0 warnings 0
Test-NoWarnings-1.06
pathname: H/HA/HAARG/Test-NoWarnings-1.06.tar.gz
provides:
Test::NoWarnings 1.06
Test::NoWarnings::Warning 1.06
requirements:
ExtUtils::MakeMaker 0
Test::Builder 0.86
perl 5.006
Try-Tiny-0.32 Try-Tiny-0.32
pathname: E/ET/ETHER/Try-Tiny-0.32.tar.gz pathname: E/ET/ETHER/Try-Tiny-0.32.tar.gz
provides: provides:

View File

@@ -11,20 +11,28 @@ sub shorten {
my $json = $c->req->json; my $json = $c->req->json;
unless ( defined $json && ref $json eq 'HASH' ) { unless ( defined $json && ref $json eq 'HASH' ) {
$c->respond_once(
sub {
$c->render( $c->render(
json => { error => 'Invalid JSON format' }, json => { error => 'Invalid JSON format' },
status => 400 status => 400
); );
}
);
return; return;
} }
my $original_url = sanitize_input( $json->{url} || '' ); my $original_url = sanitize_input( $json->{url} || '' );
unless ($original_url) { unless ($original_url) {
$c->respond_once(
sub {
$c->render( $c->render(
json => { error => 'URL is required' }, json => { error => 'URL is required' },
status => 400 status => 400
); );
}
);
return; return;
} }
@@ -38,7 +46,8 @@ sub shorten {
)->then( )->then(
sub { sub {
my $short_code = shift; my $short_code = shift;
return if $c->stash->{rendered}; $c->respond_once(
sub {
my $short_url = $c->url_for("/$short_code")->to_abs; my $short_url = $c->url_for("/$short_code")->to_abs;
$c->render( $c->render(
json => { json => {
@@ -49,11 +58,15 @@ sub shorten {
} }
); );
} }
);
}
)->catch( )->catch(
sub { sub {
my $err = shift; my $err = shift;
return if $c->stash->{rendered}; $c->respond_once(
$c->app->log->error("API URL validation/creation error: $err"); sub {
$c->app->log->error(
"API URL validation/creation error: $err");
my $status = get_error_status($err); my $status = get_error_status($err);
my $sanitized_error = sanitize_error_message($err); my $sanitized_error = sanitize_error_message($err);
$c->render( $c->render(
@@ -62,6 +75,8 @@ sub shorten {
); );
} }
); );
}
);
} }
sub get_url { sub get_url {
@@ -72,17 +87,22 @@ sub get_url {
my $validator = $c->validator; my $validator = $c->validator;
unless ( $short_code && $validator->validate_short_code($short_code) ) { unless ( $short_code && $validator->validate_short_code($short_code) ) {
$c->respond_once(
sub {
$c->render( $c->render(
json => { error => 'Invalid short code format' }, json => { error => 'Invalid short code format' },
status => 400 status => 400
); );
}
);
return; return;
} }
return $url_service->get_original_url($short_code)->then( return $url_service->get_original_url($short_code)->then(
sub { sub {
my $original_url = shift; my $original_url = shift;
return if $c->stash->{rendered}; $c->respond_once(
sub {
if ($original_url) { if ($original_url) {
my $short_url = $c->url_for("/$short_code")->to_abs; my $short_url = $c->url_for("/$short_code")->to_abs;
$c->render( $c->render(
@@ -101,10 +121,13 @@ sub get_url {
); );
} }
} }
);
}
)->catch( )->catch(
sub { sub {
my $err = shift; my $err = shift;
return if $c->stash->{rendered}; $c->respond_once(
sub {
$c->app->log->error("API URL retrieval error: $err"); $c->app->log->error("API URL retrieval error: $err");
my $status = get_error_status($err); my $status = get_error_status($err);
my $sanitized_error = sanitize_error_message($err); my $sanitized_error = sanitize_error_message($err);
@@ -114,6 +137,8 @@ sub get_url {
); );
} }
); );
}
);
} }
1; 1;

View File

@@ -5,6 +5,7 @@ use Urupam::DB;
use Urupam::URL; use Urupam::URL;
use Urupam::Validation; use Urupam::Validation;
use Urupam::API; use Urupam::API;
use Urupam::Version;
sub startup { sub startup {
my $self = shift; my $self = shift;
@@ -29,6 +30,22 @@ sub startup {
$c->stash->{validator} ||= Urupam::Validation->new; $c->stash->{validator} ||= Urupam::Validation->new;
} }
); );
$self->helper(
version => sub {
my $c = shift;
$c->stash->{version} ||= Urupam::Version->new->get_version;
}
);
$self->helper(
respond_once => sub {
my $c = shift;
my $callback = shift;
return if $c->stash->{'urupam.responded'};
$c->stash->{'urupam.responded'} = 1;
$callback->($c);
}
);
my $r = $self->routes; my $r = $self->routes;
@@ -38,13 +55,22 @@ sub startup {
$c->render_later; $c->render_later;
$c->db->ping->then( $c->db->ping->then(
sub { sub {
return if $c->stash->{rendered}; $c->respond_once(
$c->render( json => { status => 'ok' } ); sub {
$c->render(
json => {
status => 'ok',
version => $c->version
}
);
}
);
} }
)->catch( )->catch(
sub { sub {
my $err = shift; my $err = shift;
return if $c->stash->{rendered}; $c->respond_once(
sub {
$c->app->log->error("Health check DB error: $err"); $c->app->log->error("Health check DB error: $err");
$c->render( $c->render(
json => { json => {
@@ -57,12 +83,12 @@ sub startup {
); );
} }
); );
}
);
$r->get('/')->to( $r->get('/')->to(
cb => sub { cb => sub {
my $c = shift; my $c = shift;
$c->render( $c->render( template => 'index' );
json => { status => 'hello from urupam', service => 'urupam' }
);
} }
); );
@@ -93,9 +119,13 @@ sub startup {
unless ( $short_code unless ( $short_code
&& $validator->validate_short_code($short_code) ) && $validator->validate_short_code($short_code) )
{ {
$c->respond_once(
sub {
$c->render( $c->render(
json => { error => 'Invalid short code format' }, template => '404',
status => 400 status => 404
);
}
); );
return; return;
} }
@@ -103,30 +133,36 @@ sub startup {
return $url_service->get_original_url($short_code)->then( return $url_service->get_original_url($short_code)->then(
sub { sub {
my $original_url = shift; my $original_url = shift;
return if $c->stash->{rendered}; $c->respond_once(
sub {
if ($original_url) { if ($original_url) {
$c->redirect_to($original_url); $c->redirect_to($original_url);
} }
else { else {
$c->render( $c->render(
json => { error => 'Short code not found' }, template => '404',
status => 404 status => 404
); );
} }
} }
);
}
)->catch( )->catch(
sub { sub {
my $err = shift; my $err = shift;
return if $c->stash->{rendered}; $c->respond_once(
sub {
$c->app->log->error("Redirect lookup error: $err"); $c->app->log->error("Redirect lookup error: $err");
$c->render( $c->render(
json => { error => 'Internal server error' }, template => '500',
status => 500 status => 500
); );
} }
); );
} }
); );
}
);
} }

View File

@@ -20,48 +20,6 @@ sub get {
return $promise; return $promise;
} }
sub set {
my ( $self, $key, $value ) = @_;
my $promise = Mojo::Promise->new;
$self->redis->set(
$key => $value,
sub {
my ( $redis, $err, $result ) = @_;
$err ? $promise->reject($err) : $promise->resolve($result);
}
);
return $promise;
}
sub incr {
my ( $self, $key ) = @_;
my $promise = Mojo::Promise->new;
$self->redis->incr(
$key => sub {
my ( $redis, $err, $value ) = @_;
$err ? $promise->reject($err) : $promise->resolve($value);
}
);
return $promise;
}
sub exists {
my ( $self, $key ) = @_;
my $promise = Mojo::Promise->new;
$self->redis->exists(
$key => sub {
my ( $redis, $err, $exists ) = @_;
if ($err) {
$promise->reject($err);
}
else {
$promise->resolve( $exists ? 1 : 0 );
}
}
);
return $promise;
}
sub setnx { sub setnx {
my ( $self, $key, $value ) = @_; my ( $self, $key, $value ) = @_;
my $promise = Mojo::Promise->new; my $promise = Mojo::Promise->new;

View File

@@ -3,6 +3,7 @@ package Urupam::URL;
use Mojo::Base -base; use Mojo::Base -base;
use Mojo::Promise; use Mojo::Promise;
use Mojo::Util qw(b64_encode); use Mojo::Util qw(b64_encode);
use Bytes::Random::Secure qw(random_string_from random_bytes);
has db => sub { die 'db attribute required' }; has db => sub { die 'db attribute required' };
@@ -32,11 +33,7 @@ sub _validate_url {
sub _generate_random_code { sub _generate_random_code {
my ($self) = @_; my ($self) = @_;
my $code = ''; return random_string_from( $CHARSET, $CODE_LENGTH );
for ( 1 .. $CODE_LENGTH ) {
$code .= substr( $CHARSET, int( rand( length($CHARSET) ) ), 1 );
}
return $code;
} }
sub _generate_code_from_url { sub _generate_code_from_url {
@@ -55,11 +52,25 @@ sub _generate_code_from_url {
} }
my $max_start = length($encoded) - $CODE_LENGTH; my $max_start = length($encoded) - $CODE_LENGTH;
my $start_pos = int( rand( ( $max_start > 0 ? $max_start : 0 ) + 1 ) ); my $range = ( $max_start > 0 ? $max_start : 0 ) + 1;
my $start_pos = $self->_secure_int($range);
return substr( $encoded, $start_pos, $CODE_LENGTH ); return substr( $encoded, $start_pos, $CODE_LENGTH );
} }
sub _secure_int {
my ( $self, $max ) = @_;
return 0 unless defined $max && $max > 1;
my $limit = int( 0xFFFFFFFF / $max ) * $max;
my $value;
do {
$value = unpack( 'N', random_bytes(4) );
} while ( $value >= $limit );
return $value % $max;
}
sub generate_short_code { sub generate_short_code {
my ( $self, $original_url, $use_pure_random ) = @_; my ( $self, $original_url, $use_pure_random ) = @_;
@@ -172,4 +183,3 @@ sub get_original_url {
} }
1; 1;

View File

@@ -4,7 +4,8 @@ use strict;
use warnings; use warnings;
use Exporter 'import'; use Exporter 'import';
use Mojo::URL; use Mojo::URL;
use Mojo::Util qw(url_unescape); use Mojo::Path;
use Mojo::Util qw(url_unescape decode);
our @EXPORT_OK = qw( our @EXPORT_OK = qw(
sanitize_input sanitize_input
@@ -34,8 +35,7 @@ sub sanitize_error_message {
$sanitized =~ s/[^\p{L}\p{N}_\s\.\-\:\/]//gu; $sanitized =~ s/[^\p{L}\p{N}_\s\.\-\:\/]//gu;
$sanitized =~ s/\s+/ /g; $sanitized =~ s/\s+/ /g;
$sanitized =~ s/^\s+|\s+$//g; $sanitized =~ s/^\s+|\s+$//g;
return return length($sanitized) > 200
length($sanitized) > 200
? substr( $sanitized, 0, 200 ) . '...' ? substr( $sanitized, 0, 200 ) . '...'
: $sanitized; : $sanitized;
} }
@@ -78,13 +78,24 @@ sub sanitize_url {
if ( $url =~ /%[0-9a-f]{2}/i ) { if ( $url =~ /%[0-9a-f]{2}/i ) {
my $path = url_unescape( $parsed->path->to_string ); my $path = url_unescape( $parsed->path->to_string );
$parsed->path($path); $path = decode( 'UTF-8', $path ) if length $path;
$parsed->path( Mojo::Path->new($path) );
my $query = $parsed->query->to_string; my $query = $parsed->query->to_string;
$parsed->query( url_unescape($query) ) if length $query; if ( length $query ) {
my $decoded_query = url_unescape($query);
$decoded_query = decode( 'UTF-8', $decoded_query )
if length $decoded_query;
$parsed->query($decoded_query);
}
my $fragment = $parsed->fragment; my $fragment = $parsed->fragment;
$parsed->fragment( url_unescape($fragment) ) if defined $fragment; if ( defined $fragment ) {
my $decoded_fragment = url_unescape($fragment);
$decoded_fragment = decode( 'UTF-8', $decoded_fragment )
if length $decoded_fragment;
$parsed->fragment($decoded_fragment);
}
$url = $parsed->to_string; $url = $parsed->to_string;
} }

View File

@@ -4,11 +4,14 @@ use Mojo::Base -base;
use Mojo::URL; use Mojo::URL;
use Mojo::UserAgent; use Mojo::UserAgent;
use Mojo::Promise; use Mojo::Promise;
use Mojo::IOLoop;
use Urupam::Utils qw(sanitize_url); use Urupam::Utils qw(sanitize_url);
use Socket
qw(getaddrinfo getnameinfo NI_NUMERICHOST NI_NUMERICSERV AF_INET AF_INET6 SOCK_STREAM);
my $MAX_URL_LENGTH = 2048; my $MAX_URL_LENGTH = 2048;
my $CONNECT_TIMEOUT = 10; my $CONNECT_TIMEOUT = 0.2;
my $REQUEST_TIMEOUT = 10; my $REQUEST_TIMEOUT = 0.4;
my $MAX_REDIRECTS = 3; my $MAX_REDIRECTS = 3;
my $DNS_ERROR_PATTERN = my $DNS_ERROR_PATTERN =
@@ -23,6 +26,12 @@ my @BLOCKED_DOMAINS = qw(
localhost 127.0.0.1 0.0.0.0 ::1 localhost 127.0.0.1 0.0.0.0 ::1
); );
my $DNS_CACHE_TTL = 300;
my $REACHABILITY_CACHE_TTL = 300;
my $DNS_RESOLVE_TIMEOUT = 0.2;
my %dns_cache;
my %reachability_cache;
has ua => sub { has ua => sub {
my $self = shift; my $self = shift;
Mojo::UserAgent->new( Mojo::UserAgent->new(
@@ -93,50 +102,287 @@ sub _is_private_ipv4 {
sub _is_private_ipv6 { sub _is_private_ipv6 {
my ( $self, $ip ) = @_; my ( $self, $ip ) = @_;
return 0 unless defined $ip;
$ip = lc($ip); $ip = lc($ip);
$ip =~ s/^\[|\]$//g; $ip =~ s/^\[|\]$//g;
return
$ip eq '::1'
|| $ip eq '::'
|| $ip =~ /^::ffff:(127\.|192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/;
}
sub is_blocked_url { return 1 if $ip eq '::1';
my ( $self, $url ) = @_; return 1 if $ip eq '::';
return 0 unless defined $url;
my $parsed = $self->_parse_url($url); if ( $ip =~ /^::ffff:(.+)$/ ) {
return 0 unless $parsed; return $self->_is_private_ipv4($1) ? 1 : 0;
my $host = lc( $parsed->host // '' );
for my $blocked (@BLOCKED_DOMAINS) {
return 1 if $host eq $blocked;
} }
if ( $self->_is_private_ipv4($host) ) { return 0 unless $ip =~ /^([0-9a-f]{0,4}:)+[0-9a-f]{0,4}$/ || $ip =~ /^::/;
return 1;
}
if ( $self->_is_private_ipv6($host) ) { my @parts = split /:/, $ip;
return 1; return 0 unless @parts > 0;
}
my $first_part = $parts[0] || '';
return 0 unless length($first_part) > 0;
my $first = hex($first_part);
return 1 if ( $first & 0xfe00 ) == 0xfc00;
return 1 if ( $first & 0xffc0 ) == 0xfe80;
return 0; return 0;
} }
sub check_url_reachable { sub _resolve_host {
my ( $self, $host ) = @_;
return Mojo::Promise->resolve( [] )
unless defined $host && length($host) > 0;
if ( $self->_is_valid_ipv4($host) ) {
return Mojo::Promise->resolve( [ { type => 'ipv4', ip => $host } ] );
}
my $ipv6_host = $host;
$ipv6_host =~ s/^\[|\]$//g;
if ( $self->_is_private_ipv6($ipv6_host) ) {
return Mojo::Promise->resolve(
[ { type => 'ipv6', ip => $ipv6_host } ] );
}
if ( $ipv6_host =~ /^([0-9a-f]{0,4}:){2,}[0-9a-f]{0,4}$/i
|| $ipv6_host =~ /^::/ )
{
return Mojo::Promise->resolve(
[ { type => 'ipv6', ip => $ipv6_host } ] );
}
if ( my $cached = $self->_get_cached_addresses($host) ) {
return Mojo::Promise->resolve($cached);
}
my $promise = Mojo::Promise->new;
my $resolved = 0;
my $cache_key = lc($host);
my $now = time();
my $timer = Mojo::IOLoop->timer(
$DNS_RESOLVE_TIMEOUT => sub {
return if $resolved;
$resolved = 1;
$dns_cache{$cache_key} = {
addresses => [],
expires => $now + $DNS_CACHE_TTL
};
$promise->resolve( [] );
}
);
Mojo::IOLoop->subprocess(
sub {
my ($hostname) = @_;
my ( $err, @results ) =
getaddrinfo( $hostname, undef, { socktype => SOCK_STREAM } );
return { error => $err, results => \@results };
},
sub {
my ( $subprocess, $err, $data ) = @_;
return if $resolved;
$resolved = 1;
Mojo::IOLoop->remove($timer);
if ($err) {
$promise->resolve( [] );
return;
}
my $res = $data;
if ( $res->{error} ) {
$promise->resolve( [] );
return;
}
my @addresses;
for my $result ( @{ $res->{results} } ) {
my ( $hostnum, undef ) =
getnameinfo( $result->{addr},
NI_NUMERICHOST | NI_NUMERICSERV );
next unless defined $hostnum && length $hostnum;
if ( $result->{family} == AF_INET ) {
push @addresses, { type => 'ipv4', ip => $hostnum };
}
elsif ( $result->{family} == AF_INET6 ) {
push @addresses, { type => 'ipv6', ip => $hostnum };
}
}
my $addresses_ref = \@addresses;
$dns_cache{$cache_key} = {
addresses => $addresses_ref,
expires => $now + $DNS_CACHE_TTL
};
$promise->resolve($addresses_ref);
},
$host
);
return $promise;
}
sub _addresses_contain_private {
my ( $self, $addresses ) = @_;
return 0 unless defined $addresses && ref $addresses eq 'ARRAY';
for my $addr (@$addresses) {
if ( $addr->{type} eq 'ipv4'
&& $self->_is_private_ipv4( $addr->{ip} ) )
{
return 1;
}
if ( $addr->{type} eq 'ipv6'
&& $self->_is_private_ipv6( $addr->{ip} ) )
{
return 1;
}
}
return 0;
}
sub _get_cached_addresses {
my ( $self, $host ) = @_;
return undef unless defined $host && length $host;
my $cache_key = lc($host);
my $cached = $dns_cache{$cache_key};
return undef unless $cached;
return $cached->{addresses} if time() < $cached->{expires};
delete $dns_cache{$cache_key};
return undef;
}
sub _cache_reachability {
my ( $self, $url, $ok, $error ) = @_;
return unless defined $url && length $url;
$reachability_cache{$url} = {
ok => $ok ? 1 : 0,
error => $error,
expires => time() + $REACHABILITY_CACHE_TTL
};
}
sub _clear_caches {
# Test helper
%dns_cache = ();
%reachability_cache = ();
return;
}
sub _get_cached_reachability {
my ( $self, $url ) = @_; my ( $self, $url ) = @_;
return undef unless defined $url && length $url;
return Mojo::Promise->reject('URL is required') my $cached = $reachability_cache{$url};
unless defined $url && length($url) > 0; return undef unless $cached;
return $cached if time() < $cached->{expires};
delete $reachability_cache{$url};
return undef;
}
return $self->ua->head_p($url)->then( sub _fire_and_forget {
my ( $self, $promise ) = @_;
return unless $promise;
$promise->catch( sub { } );
return;
}
sub is_blocked_url {
my ( $self, $url ) = @_;
return Mojo::Promise->resolve(0) unless defined $url;
my $parsed = $self->_parse_url($url);
return Mojo::Promise->resolve(0) unless $parsed;
my $host = lc( $parsed->host // '' );
return Mojo::Promise->resolve(0) unless length($host) > 0;
for my $blocked (@BLOCKED_DOMAINS) {
return Mojo::Promise->resolve(1) if $host eq $blocked;
}
if ( $self->_is_private_ipv4($host) ) {
return Mojo::Promise->resolve(1);
}
if ( $self->_is_private_ipv6($host) ) {
return Mojo::Promise->resolve(1);
}
if ( my $cached = $self->_get_cached_addresses($host) ) {
return Mojo::Promise->resolve(
$self->_addresses_contain_private($cached) ? 1 : 0 );
}
# Intentional: skip blocking on cold hosts to keep latency low, DNS runs in background.
$self->_fire_and_forget( $self->_resolve_host($host) );
return Mojo::Promise->resolve(0);
}
sub _create_ssrf_safe_ua {
my $self = shift;
return Mojo::UserAgent->new(
connect_timeout => $self->connect_timeout,
request_timeout => $self->request_timeout,
max_redirects => 0,
insecure => 0
);
}
sub _follow_redirect_with_validation {
my ( $self, $ua, $url, $redirect_count ) = @_;
$redirect_count //= 0;
return Mojo::Promise->reject('Too many redirects')
if $redirect_count > $self->max_redirects;
return $ua->head_p($url)->then(
sub { sub {
my $tx = shift; my $tx = shift;
my $code = $tx->result->code; my $code = $tx->result->code;
if ( $code >= 300 && $code < 400 ) {
my $location = $tx->result->headers->location;
return Mojo::Promise->reject('Redirect without Location header')
unless defined $location;
my $redirect_url = Mojo::URL->new($location)->to_abs($url);
return $self->is_blocked_url($redirect_url)->then(
sub {
my $blocked = shift;
if ($blocked) {
return Mojo::Promise->reject(
'Redirect to blocked domain or local address');
}
return $self->_follow_redirect_with_validation( $ua,
$redirect_url, $redirect_count + 1 );
}
);
}
return 1 if $code >= 200 && $code < 400; return 1 if $code >= 200 && $code < 400;
if ( $code == 403 || $code == 404 || $code == 405 ) {
return $ua->get_p($url)->then(
sub {
my $get_tx = shift;
my $get_code = $get_tx->result->code;
return 1 if $get_code >= 200 && $get_code < 400;
return 1
if $get_code == 403
|| $get_code == 404
|| $get_code == 405;
return Mojo::Promise->reject(
"URL returned $get_code error")
if $get_code >= 400;
return Mojo::Promise->reject(
"URL returned unexpected status: $get_code");
}
);
}
return Mojo::Promise->reject("URL returned $code error") return Mojo::Promise->reject("URL returned $code error")
if $code >= 400; if $code >= 400;
return Mojo::Promise->reject( return Mojo::Promise->reject(
@@ -153,17 +399,54 @@ sub check_url_reachable {
); );
} }
sub check_ssl_certificate { sub check_url_reachable {
my ( $self, $url ) = @_; my ( $self, $url ) = @_;
return Mojo::Promise->reject('URL is required') return Mojo::Promise->reject('URL is required')
unless defined $url && length($url) > 0; unless defined $url && length($url) > 0;
if ( my $cached = $self->_get_cached_reachability($url) ) {
return $cached->{ok}
? Mojo::Promise->resolve(1)
: Mojo::Promise->reject( $cached->{error} );
}
my $ssrf_ua = $self->_create_ssrf_safe_ua;
return $self->_follow_redirect_with_validation( $ssrf_ua, $url )->then(
sub {
$self->_cache_reachability( $url, 1, undef );
return 1;
}
)->catch(
sub {
my $err = shift;
$self->_cache_reachability( $url, 0, $err );
return Mojo::Promise->reject($err);
}
);
}
sub check_url_reachable_async {
my ( $self, $url ) = @_;
return Mojo::Promise->resolve(1) unless defined $url && length $url;
return Mojo::Promise->resolve(1)
if $self->_get_cached_reachability($url);
$self->_fire_and_forget( $self->check_url_reachable($url) );
return Mojo::Promise->resolve(1);
}
sub check_ssl_certificate {
my ( $self, $url ) = @_;
return Mojo::Promise->resolve(1) unless defined $url && length $url;
my $parsed = $self->_parse_url($url); my $parsed = $self->_parse_url($url);
return Mojo::Promise->resolve(1) return Mojo::Promise->resolve(1)
unless $parsed && $parsed->scheme && $parsed->scheme eq 'https'; unless $parsed && $parsed->scheme && $parsed->scheme eq 'https';
return $self->ua->head_p($url)->then( sub { return 1; } )->catch( $self->_fire_and_forget(
$self->ua->head_p($url)->then( sub { return 1; } )->catch(
sub { sub {
my $err = shift; my $err = shift;
my $err_str = "$err"; my $err_str = "$err";
@@ -177,12 +460,15 @@ sub check_ssl_certificate {
return Mojo::Promise->reject( return Mojo::Promise->reject(
$self->_format_error_message( $error_type, $err_str ) ); $self->_format_error_message( $error_type, $err_str ) );
} }
)
); );
return Mojo::Promise->resolve(1);
} }
sub validate_short_code { sub validate_short_code {
my ( $self, $code ) = @_; my ( $self, $code ) = @_;
return defined $code && length($code) > 0 && $code =~ /^[0-9a-zA-Z\-_]+$/; return defined $code && length($code) == 12 && $code =~ /^[0-9a-zA-Z\-_]+$/;
} }
sub validate_url_with_checks { sub validate_url_with_checks {
@@ -201,21 +487,29 @@ sub validate_url_with_checks {
unless $parsed->scheme && $parsed->scheme =~ /^https?$/i; unless $parsed->scheme && $parsed->scheme =~ /^https?$/i;
return Mojo::Promise->reject('Invalid URL format') unless $parsed->host; return Mojo::Promise->reject('Invalid URL format') unless $parsed->host;
my $normalized = $parsed->to_string;
return Mojo::Promise->reject( return Mojo::Promise->reject(
"URL exceeds maximum length of $MAX_URL_LENGTH characters") "URL exceeds maximum length of $MAX_URL_LENGTH characters")
unless $self->is_valid_url_length($sanitized); unless $self->is_valid_url_length($normalized);
return $self->is_blocked_url($normalized)->then(
sub {
my $blocked = shift;
return Mojo::Promise->reject( return Mojo::Promise->reject(
'This URL cannot be shortened (blocked domain or local address)') 'This URL cannot be shortened (blocked domain or local address)'
if $self->is_blocked_url($sanitized); ) if $blocked;
my $ssl_check = my $ssl_check =
$parsed->scheme eq 'https' $parsed->scheme eq 'https'
? $self->check_ssl_certificate($sanitized) ? $self->check_ssl_certificate($normalized)
: Mojo::Promise->resolve(1); : Mojo::Promise->resolve(1);
return $ssl_check->then( return $ssl_check->then(
sub { return $self->check_url_reachable($sanitized); } ) sub { return $self->check_url_reachable_async($normalized); } )
->then( sub { return $sanitized; } ); ->then( sub { return $normalized; } );
}
);
} }
1; 1;

12
lib/Urupam/Version.pm Normal file
View File

@@ -0,0 +1,12 @@
package Urupam::Version;
use Mojo::Base -base;
has version => sub { '0.1.0' };
sub get_version {
my $self = shift;
return $self->version;
}
1;

166
public/css/app.css Normal file
View File

@@ -0,0 +1,166 @@
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #222;
background: #fafafa;
margin: 0;
padding: 0;
}
.page {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
}
.page-center {
text-align: center;
}
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #444;
}
.brand-mark {
position: fixed;
left: 1rem;
top: 1rem;
font-size: 0.85rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #777;
}
.brand-version {
position: fixed;
right: 1rem;
bottom: 1rem;
font-size: 0.85rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #777;
}
.page-center h1 {
margin-bottom: 0.5rem;
}
p {
font-size: 1.1rem;
color: #666;
}
.form-group {
margin-bottom: 1.5rem;
}
#shorten-form {
max-width: 420px;
margin: 0 auto;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #444;
font-weight: 500;
}
input[type="url"] {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
}
input[type="url"]:focus {
outline: none;
border-color: #444;
}
button {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
background-color: #444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
button:hover {
background-color: #333;
}
button:disabled {
background-color: #999;
cursor: not-allowed;
}
.result {
margin-top: 2rem;
padding: 1rem;
border-radius: 4px;
display: none;
}
.result.success {
background-color: #e8f5e9;
border: 1px solid #4caf50;
color: #2e7d32;
}
.result.error {
background-color: #ffebee;
border: 1px solid #f44336;
color: #c62828;
}
.result.show {
display: block;
}
.short-url {
word-break: break-all;
font-weight: 500;
margin-top: 0.5rem;
}
.short-url a {
color: #2e7d32;
text-decoration: none;
}
.short-url a:hover {
text-decoration: underline;
}
.error-message {
margin-top: 0.5rem;
}
.link-home {
display: inline-block;
margin-top: 1.5rem;
color: #444;
text-decoration: none;
border-bottom: 1px solid #444;
}
.link-home:hover {
color: #333;
border-color: #333;
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

View File

@@ -26,7 +26,7 @@ groupadd urupam
useradd -s /bin/bash -g urupam -d /opt/urupam urupam useradd -s /bin/bash -g urupam -d /opt/urupam urupam
# deploy code # deploy code
cp -r bin lib cpan* /opt/urupam cp -r bin lib cpanfile* public templates /opt/urupam
chown -R urupam:urupam /opt/urupam chown -R urupam:urupam /opt/urupam
# install dependencies # install dependencies

104
t/02_app.t Normal file
View File

@@ -0,0 +1,104 @@
use Test::More;
use Test::Mojo;
use Mojo::Promise;
use Urupam::App;
use_ok('Urupam::App');
package Mock::DB;
use Mojo::Base -base;
use Mojo::Promise;
has ping_cb => sub {
sub { Mojo::Promise->resolve(1) }
};
sub ping {
my ( $self, @args ) = @_;
return $self->ping_cb->( $self, @args );
}
package Mock::Validator;
use Mojo::Base -base;
has validate_short_code_cb => sub {
sub { 1 }
};
sub validate_short_code {
my ( $self, $code ) = @_;
return $self->validate_short_code_cb->( $self, $code );
}
package Mock::URLService;
use Mojo::Base -base;
use Mojo::Promise;
has get_original_url_cb => sub {
sub { Mojo::Promise->resolve('https://example.com') }
};
sub get_original_url {
my ( $self, $code ) = @_;
return $self->get_original_url_cb->( $self, $code );
}
package main;
my $t = Test::Mojo->new('Urupam::App');
my $db = Mock::DB->new;
my $validator = Mock::Validator->new;
my $url_service = Mock::URLService->new;
$t->app->helper( db => sub { $db } );
$t->app->helper( validator => sub { $validator } );
$t->app->helper( url_service => sub { $url_service } );
sub reset_mocks {
$db->ping_cb( sub { Mojo::Promise->resolve(1) } );
$validator->validate_short_code_cb( sub { 1 } );
$url_service->get_original_url_cb(
sub { Mojo::Promise->resolve('https://example.com') } );
}
subtest 'GET /health' => sub {
reset_mocks();
$t->get_ok('/health')->status_is(200)->json_is( '/status' => 'ok' );
reset_mocks();
$db->ping_cb( sub { Mojo::Promise->reject('boom') } );
$t->get_ok('/health')
->status_is(503)
->json_is( '/status' => 'error' )
->json_is( '/error' => 'Database connection failed' );
};
subtest 'GET / - index template' => sub {
reset_mocks();
$t->get_ok('/')->status_is(200)->content_like(qr/urupam/i);
};
subtest 'GET /:short_code' => sub {
reset_mocks();
$validator->validate_short_code_cb( sub { 0 } );
$t->get_ok('/bad@code')->status_is(404)->content_like(qr/404 Not Found/);
reset_mocks();
$url_service->get_original_url_cb( sub { Mojo::Promise->resolve(undef) } );
$t->get_ok('/AbCdEf123456')
->status_is(404)
->content_like(qr/404 Not Found/);
reset_mocks();
$t->get_ok('/AbCdEf123456')
->status_is(302)
->header_is( Location => 'https://example.com' );
reset_mocks();
$url_service->get_original_url_cb( sub { Mojo::Promise->reject('boom') } );
$t->get_ok('/AbCdEf123456')
->status_is(500)
->content_like(qr/500 Internal Server Error/);
};
done_testing();

View File

@@ -61,36 +61,6 @@ subtest 'get' => sub {
); );
}; };
subtest 'set' => sub {
test_method(
'set',
'set',
[ 'test_key', 'test_value' ],
[ [ 'OK', undef, 'OK', 'returns success result' ] ],
[ [ 'Write error', 'error is returned' ] ]
);
};
subtest 'incr' => sub {
test_method(
'incr', 'incr', ['test_key'],
[ [ 42, undef, 42, 'returns correct value' ] ],
[ [ 'Increment error', 'error is returned' ] ]
);
};
subtest 'exists' => sub {
test_method(
'exists', 'exists',
['test_key'],
[
[ 1, undef, 1, 'returns 1 when key exists' ],
[ 0, undef, 0, 'returns 0 when key does not exist' ],
],
[ [ 'Exists error', 'error is returned' ] ]
);
};
subtest 'setnx' => sub { subtest 'setnx' => sub {
test_method( test_method(
'setnx', 'setnx', 'setnx', 'setnx',

183
t/04_url.t Normal file
View File

@@ -0,0 +1,183 @@
use Test::More;
use Mojo::Promise;
use Urupam::URL;
use_ok('Urupam::URL');
package Mock::DB;
use Mojo::Base -base;
use Mojo::Promise;
has setnx_cb => sub {
sub { Mojo::Promise->resolve(1) }
};
has get_cb => sub {
sub { Mojo::Promise->resolve('https://example.com') }
};
sub setnx {
my ( $self, $key, $value ) = @_;
return $self->setnx_cb->( $self, $key, $value );
}
sub get {
my ( $self, $key ) = @_;
return $self->get_cb->( $self, $key );
}
package main;
my $db = Mock::DB->new;
my $url = Urupam::URL->new( db => $db );
sub wait_promise {
my ($promise) = @_;
my ( $value, $error );
$promise->then( sub { $value = shift } )
->catch( sub { $error = shift } )
->wait;
return ( $value, $error );
}
sub reset_db {
$db->setnx_cb( sub { Mojo::Promise->resolve(1) } );
$db->get_cb( sub { Mojo::Promise->resolve('https://example.com') } );
}
subtest '_validate_short_code' => sub {
my @valid = ( [ 'AbCdEf123456', 'valid short code passes' ], );
my @invalid = (
[ 'short', 'short code length fails' ],
[ 'AbCdEf1234567', 'long code length fails' ],
[ 'AbCdEf12@456', 'invalid chars fail' ],
[ undef, 'undef fails' ],
);
for my $case (@valid) {
ok( $url->_validate_short_code( $case->[0] ), $case->[1] );
}
for my $case (@invalid) {
ok( !$url->_validate_short_code( $case->[0] ), $case->[1] );
}
};
subtest 'generate_short_code - invalid URL' => sub {
my $long_url = 'http://example.com/' . ( 'a' x 2049 );
my @cases = (
[ '', qr/^Original URL is required$/, 'empty URL rejected' ],
[ $long_url, qr/exceeds maximum length/, 'long URL rejected' ],
);
for my $case (@cases) {
my ( $value, $error ) =
wait_promise( $url->generate_short_code( $case->[0] ) );
is( $value, undef, 'invalid URL has no result' );
like( $error, $case->[1], $case->[2] );
}
};
subtest 'generate_short_code - success' => sub {
my ( $value, $error ) =
wait_promise( $url->generate_short_code('https://example.com') );
is( $error, undef, 'no error for valid URL' );
ok( defined $value, 'short code generated' );
is( length($value), 12, 'short code length is 12' );
like( $value, qr/^[0-9a-zA-Z\-_]+$/, 'short code matches pattern' );
};
subtest 'create_short_url - custom code' => sub {
reset_db();
my ( $value, $error ) =
wait_promise(
$url->create_short_url( 'https://example.com', 'AbCdEf123456' ) );
is( $error, undef, 'no error for custom code' );
is( $value, 'AbCdEf123456', 'custom code is returned' );
my @cases = (
[
sub { reset_db(); },
'bad',
qr/^Invalid short code format$/,
'invalid custom code rejected'
],
[
sub {
reset_db();
$db->setnx_cb( sub { Mojo::Promise->resolve(0) } );
},
'AbCdEf123456',
qr/^Database error: Short code already exists$/,
'custom code collision rejected'
],
[
sub {
reset_db();
$db->setnx_cb(
sub { Mojo::Promise->reject('connection failed') } );
},
'AbCdEf123456',
qr/^Database error: connection failed$/,
'db error message is wrapped'
],
);
for my $case (@cases) {
$case->[0]->();
( $value, $error ) =
wait_promise(
$url->create_short_url( 'https://example.com', $case->[1] ) );
is( $value, undef, 'custom code failure has no result' );
like( $error, $case->[2], $case->[3] );
}
};
subtest 'create_short_url - retry behavior' => sub {
reset_db();
$db->setnx_cb( sub { Mojo::Promise->resolve(0) } );
{
no warnings 'redefine';
local *Urupam::URL::generate_short_code = sub {
return Mojo::Promise->resolve('AbCdEf123456');
};
my ( $value, $error ) =
wait_promise( $url->create_short_url('https://example.com') );
is( $value, undef, 'retry exhaustion has no result' );
like(
$error,
qr/Failed to generate unique short code after retry/,
'retry exhaustion returns error'
);
}
reset_db();
$db->setnx_cb( sub { Mojo::Promise->resolve(1) } );
my $calls = 0;
{
no warnings 'redefine';
local *Urupam::URL::generate_short_code = sub {
$calls++;
return $calls == 1
? Mojo::Promise->reject('Database error: connection failed')
: Mojo::Promise->resolve('AbCdEf123456');
};
my ( $value, $error ) =
wait_promise( $url->create_short_url('https://example.com') );
is( $error, undef, 'retry succeeds without error' );
is( $value, 'AbCdEf123456', 'code returned after retry' );
is( $calls, 2, 'retry invoked after database error' );
}
};
subtest 'get_original_url' => sub {
reset_db();
my ( $value, $error ) =
wait_promise( $url->get_original_url('AbCdEf123456') );
is( $error, undef, 'get_original_url has no error' );
is( $value, 'https://example.com', 'returns stored URL' );
};
done_testing();

View File

@@ -106,6 +106,11 @@ subtest 'sanitize_url' => sub {
'https://example.com/~user/docs', 'https://example.com/~user/docs',
'unescapes multiple percent-encoded segments' 'unescapes multiple percent-encoded segments'
], ],
[
'https://fr.wikipedia.org/wiki/Pic_L%C3%A9nine',
'https://fr.wikipedia.org/wiki/Pic_L%C3%A9nine',
'preserves UTF-8 percent-encoded path'
],
[ [
'https://example.com?q=hello%20world', 'https://example.com?q=hello%20world',
'https://example.com?q=hello%20world', 'https://example.com?q=hello%20world',

View File

@@ -2,6 +2,7 @@ use Test::More;
use Test::MockObject; use Test::MockObject;
use Mojo::Promise; use Mojo::Promise;
use Urupam::Validation; use Urupam::Validation;
use Socket qw(AF_INET);
use_ok('Urupam::Validation'); use_ok('Urupam::Validation');
@@ -21,15 +22,25 @@ sub mock_ua_with_code {
my $mock_ua = Test::MockObject->new; my $mock_ua = Test::MockObject->new;
my $mock_tx = Test::MockObject->new; my $mock_tx = Test::MockObject->new;
my $mock_result = Test::MockObject->new; my $mock_result = Test::MockObject->new;
my $mock_get_tx = Test::MockObject->new;
my $mock_get_result = Test::MockObject->new;
$mock_result->mock( 'code', sub { $code } ); $mock_result->mock( 'code', sub { $code } );
$mock_tx->mock( 'result', sub { $mock_result } ); $mock_tx->mock( 'result', sub { $mock_result } );
$mock_get_result->mock( 'code', sub { $code } );
$mock_get_tx->mock( 'result', sub { $mock_get_result } );
$mock_ua->mock( $mock_ua->mock(
'head_p', 'head_p',
sub { sub {
return Mojo::Promise->resolve($mock_tx); return Mojo::Promise->resolve($mock_tx);
} }
); );
$mock_ua->mock(
'get_p',
sub {
return Mojo::Promise->resolve($mock_get_tx);
}
);
return $mock_ua; return $mock_ua;
} }
@@ -43,9 +54,49 @@ sub mock_ua_with_error {
return Mojo::Promise->reject($error); return Mojo::Promise->reject($error);
} }
); );
$mock_ua->mock(
'get_p',
sub {
return Mojo::Promise->reject($error);
}
);
return $mock_ua; return $mock_ua;
} }
sub with_resolved_addresses {
my ( $addresses, $code ) = @_;
no warnings 'redefine';
local *Urupam::Validation::_resolve_host = sub {
return Mojo::Promise->resolve($addresses);
};
return $code->();
}
sub with_ssrf_ua {
my ( $ua, $code ) = @_;
no warnings 'redefine';
local *Urupam::Validation::_create_ssrf_safe_ua = sub {
return $ua;
};
return $code->();
}
sub with_subprocess_stub {
my ( $result, $code, $calls_ref ) = @_;
no warnings 'redefine';
local *Mojo::IOLoop::subprocess = sub {
my ( $class, $work, $finish, $host ) = @_;
$$calls_ref++ if defined $calls_ref;
$finish->( undef, undef, $result );
return;
};
return $code->();
}
sub clear_validation_caches {
$validator->_clear_caches;
}
subtest 'is_valid_url_length' => sub { subtest 'is_valid_url_length' => sub {
ok( $validator->is_valid_url_length('http://example.com'), ok( $validator->is_valid_url_length('http://example.com'),
'valid URL length passes' ); 'valid URL length passes' );
@@ -100,6 +151,38 @@ subtest '_format_error_message' => sub {
); );
}; };
subtest 'classify and format error string' => sub {
my @cases = (
[
'SSL certificate verification failed',
'SSL certificate error: SSL certificate verification failed',
'ssl error classified and formatted'
],
[
'Name or service not known',
'DNS resolution failed: Name or service not known',
'dns error classified and formatted'
],
[
'Connection refused',
'Cannot reach URL: Connection refused',
'connection error classified and formatted'
],
[
'Some unknown error',
'URL validation failed: Some unknown error',
'unknown error classified and formatted'
],
);
for my $case (@cases) {
my ( $err, $expected, $label ) = @$case;
my $type = $validator->_classify_error($err);
is( $validator->_format_error_message( $type, $err ),
$expected, $label );
}
};
subtest '_is_valid_ipv4' => sub { subtest '_is_valid_ipv4' => sub {
my @valid = ( my @valid = (
[ '192.168.1.1', 'valid IPv4 passes' ], [ '192.168.1.1', 'valid IPv4 passes' ],
@@ -158,10 +241,19 @@ subtest '_is_private_ipv6' => sub {
[ '::ffff:192.168.1.1', '::ffff:192.168.1.1 is private' ], [ '::ffff:192.168.1.1', '::ffff:192.168.1.1 is private' ],
[ '::ffff:10.0.0.1', '::ffff:10.0.0.1 is private' ], [ '::ffff:10.0.0.1', '::ffff:10.0.0.1 is private' ],
[ '::ffff:172.16.0.1', '::ffff:172.16.0.1 is private' ], [ '::ffff:172.16.0.1', '::ffff:172.16.0.1 is private' ],
[ 'fc00:0:0:0:0:0:0:1', 'fc00::/7 (unique local) is private' ],
[ 'fcff:0:0:0:0:0:0:1', 'fc00::/7 (unique local) is private' ],
[ 'fd00:0:0:0:0:0:0:1', 'fc00::/7 (unique local) is private' ],
[ 'fdff:0:0:0:0:0:0:1', 'fc00::/7 (unique local) is private' ],
[ 'fe80:0:0:0:0:0:0:1', 'fe80::/10 (link-local) is private' ],
[ 'fe80:0:0:0:0:0:0:abcd', 'fe80::/10 (link-local) is private' ],
[ 'febf:0:0:0:0:0:0:1', 'fe80::/10 (link-local) is private' ],
); );
my @public = ( my @public = (
[ '2001:db8::1', '2001:db8::1 is not private' ], [ '2001:db8::1', '2001:db8::1 is not private' ],
[ '::ffff:8.8.8.8', '::ffff:8.8.8.8 is not private' ], [ '::ffff:8.8.8.8', '::ffff:8.8.8.8 is not private' ],
[ 'fec0::1', 'fec0:: is not private (deprecated, but not blocked)' ],
[ 'fec1::1', 'fec1:: is not private' ],
[ 'invalid', 'invalid IPv6 is not private' ], [ 'invalid', 'invalid IPv6 is not private' ],
); );
@@ -184,6 +276,22 @@ subtest 'is_blocked_url' => sub {
[ 'http://192.168.1.1/path', '192.168.1.1 is blocked' ], [ 'http://192.168.1.1/path', '192.168.1.1 is blocked' ],
[ 'http://10.0.0.1/path', '10.0.0.1 is blocked' ], [ 'http://10.0.0.1/path', '10.0.0.1 is blocked' ],
[ 'http://172.16.0.1/path', '172.16.0.1 is blocked' ], [ 'http://172.16.0.1/path', '172.16.0.1 is blocked' ],
[
'http://[fc00:0:0:0:0:0:0:1]/path',
'fc00::/7 (unique local) is blocked'
],
[
'http://[fd00:0:0:0:0:0:0:1]/path',
'fc00::/7 (unique local) is blocked'
],
[
'http://[fe80:0:0:0:0:0:0:1]/path',
'fe80::/10 (link-local) is blocked'
],
[
'http://[febf:0:0:0:0:0:0:1]/path',
'fe80::/10 (link-local) is blocked'
],
); );
my @allowed = ( my @allowed = (
[ 'http://example.com/path', 'public domain is not blocked' ], [ 'http://example.com/path', 'public domain is not blocked' ],
@@ -192,27 +300,73 @@ subtest 'is_blocked_url' => sub {
[ undef, 'undef is not blocked' ], [ undef, 'undef is not blocked' ],
); );
with_resolved_addresses(
[],
sub {
for my $case (@blocked) { for my $case (@blocked) {
ok( $validator->is_blocked_url( $case->[0] ), $case->[1] ); my ( $result, $error ) =
wait_promise( $validator->is_blocked_url( $case->[0] ) );
ok( $result, $case->[1] );
is( $error, undef, "no error for $case->[1]" );
} }
for my $case (@allowed) { for my $case (@allowed) {
ok( !$validator->is_blocked_url( $case->[0] ), $case->[1] ); my ( $result, $error ) =
wait_promise( $validator->is_blocked_url( $case->[0] ) );
ok( !$result, $case->[1] );
is( $error, undef, "no error for $case->[1]" );
} }
}
);
};
subtest '_resolve_host - caches results' => sub {
my $calls = 0;
my $result = {
error => 0,
results => [ { addr => '127.0.0.1', family => AF_INET } ],
};
with_subprocess_stub(
$result,
sub {
my ( $value, $error ) =
wait_promise( $validator->_resolve_host('example.com') );
is( $error, undef, 'first resolve has no error' );
is( scalar @$value, 1, 'first resolve returns one address' );
},
\$calls
);
with_subprocess_stub(
$result,
sub {
my ( $value, $error ) =
wait_promise( $validator->_resolve_host('example.com') );
is( $error, undef, 'cached resolve has no error' );
is( scalar @$value, 1, 'cached resolve returns one address' );
},
\$calls
);
is( $calls, 1, 'subprocess called once due to cache' );
}; };
subtest 'validate_short_code' => sub { subtest 'validate_short_code' => sub {
my @valid = ( my @valid = (
[ 'abc123', 'alphanumeric code passes' ], [ 'abc123456789', 'alphanumeric code passes' ],
[ 'ABC123', 'uppercase code passes' ], [ 'ABC123456789', 'uppercase code passes' ],
[ 'abc-123', 'code with dash passes' ], [ 'ab-123456789', 'code with dash passes' ],
[ 'abc_123', 'code with underscore passes' ], [ 'ab_123456789', 'code with underscore passes' ],
[ 'a', 'single character passes' ], [ '0123456789ab', '12 character code passes' ],
); );
my @invalid = ( my @invalid = (
[ 'abc@123', 'code with @ fails' ], [ 'abc@12345678', 'code with @ fails' ],
[ 'abc.123', 'code with dot fails' ], [ 'abc.12345678', 'code with dot fails' ],
[ 'abc 123', 'code with space fails' ], [ 'abc 12345678', 'code with space fails' ],
[ 'abc123', 'code too short fails' ],
[ 'a', 'single character fails' ],
[ 'abc123456789012345', 'code too long fails' ],
[ '', 'empty code fails' ], [ '', 'empty code fails' ],
[ undef, 'undef code fails' ], [ undef, 'undef code fails' ],
); );
@@ -227,32 +381,124 @@ subtest 'validate_short_code' => sub {
}; };
subtest 'check_url_reachable - success codes' => sub { subtest 'check_url_reachable - success codes' => sub {
for my $code ( 200, 201, 301 ) { clear_validation_caches();
$validator->ua( mock_ua_with_code($code) ); for my $code ( 200, 201 ) {
my ( $result, $error ) = with_ssrf_ua(
wait_promise( $validator->check_url_reachable('http://example.com') ); mock_ua_with_code($code),
sub {
my ( $result, $error ) = wait_promise(
$validator->check_url_reachable('http://example.com') );
is( $result, 1, "$code status returns 1" ); is( $result, 1, "$code status returns 1" );
is( $error, undef, "$code status has no error" ); is( $error, undef, "$code status has no error" );
} }
);
}
}; };
subtest 'check_url_reachable - error codes' => sub { subtest 'check_url_reachable - error codes' => sub {
clear_validation_caches();
my @cases = ( my @cases = (
[ 404, qr/URL returned 404 error/, '4xx status returns error' ], [ 410, qr/URL returned 410 error/, '4xx status returns error' ],
[ 500, qr/URL returned 500 error/, '5xx status returns error' ], [ 500, qr/URL returned 500 error/, '5xx status returns error' ],
[ 100, qr/unexpected status/, 'unexpected status returns error' ], [ 100, qr/unexpected status/, 'unexpected status returns error' ],
); );
for my $case (@cases) { for my $case (@cases) {
$validator->ua( mock_ua_with_code( $case->[0] ) ); my $url = "http://example.com/$case->[0]";
with_ssrf_ua(
mock_ua_with_code( $case->[0] ),
sub {
my ( $result, $error ) = my ( $result, $error ) =
wait_promise( $validator->check_url_reachable('http://example.com') ); wait_promise( $validator->check_url_reachable($url) );
is( $result, undef, "$case->[0] status has no result" ); is( $result, undef, "$case->[0] status has no result" );
like( $error, $case->[1], $case->[2] ); like( $error, $case->[1], $case->[2] );
} }
);
}
};
subtest 'check_url_reachable - HEAD fallback to GET' => sub {
clear_validation_caches();
my $mock_ua = Test::MockObject->new;
my $head_tx = Test::MockObject->new;
my $head_result = Test::MockObject->new;
my $get_tx = Test::MockObject->new;
my $get_result = Test::MockObject->new;
$head_result->mock( 'code', sub { 404 } );
$head_tx->mock( 'result', sub { $head_result } );
$get_result->mock( 'code', sub { 200 } );
$get_tx->mock( 'result', sub { $get_result } );
$mock_ua->mock(
'head_p',
sub {
return Mojo::Promise->resolve($head_tx);
}
);
$mock_ua->mock(
'get_p',
sub {
return Mojo::Promise->resolve($get_tx);
}
);
my ( $result, $error );
with_ssrf_ua(
$mock_ua,
sub {
( $result, $error ) = wait_promise(
$validator->check_url_reachable('http://example.com') );
}
);
is( $result, 1, 'GET fallback returns success' );
is( $error, undef, 'GET fallback has no error' );
};
subtest 'check_url_reachable - HEAD fallback error' => sub {
clear_validation_caches();
my $mock_ua = Test::MockObject->new;
my $head_tx = Test::MockObject->new;
my $head_result = Test::MockObject->new;
my $get_tx = Test::MockObject->new;
my $get_result = Test::MockObject->new;
$head_result->mock( 'code', sub { 405 } );
$head_tx->mock( 'result', sub { $head_result } );
$get_result->mock( 'code', sub { 500 } );
$get_tx->mock( 'result', sub { $get_result } );
$mock_ua->mock(
'head_p',
sub {
return Mojo::Promise->resolve($head_tx);
}
);
$mock_ua->mock(
'get_p',
sub {
return Mojo::Promise->resolve($get_tx);
}
);
my ( $result, $error );
with_ssrf_ua(
$mock_ua,
sub {
( $result, $error ) = wait_promise(
$validator->check_url_reachable('http://example.com') );
}
);
is( $result, undef, 'GET fallback error has no result' );
like( $error, qr/URL returned 500 error/, 'GET fallback error reported' );
}; };
subtest 'check_url_reachable - classified errors' => sub { subtest 'check_url_reachable - classified errors' => sub {
clear_validation_caches();
my @cases = ( my @cases = (
[ [
'Name or service not known', 'Name or service not known',
@@ -277,12 +523,17 @@ subtest 'check_url_reachable - classified errors' => sub {
); );
for my $case (@cases) { for my $case (@cases) {
$validator->ua( mock_ua_with_error( $case->[0] ) ); clear_validation_caches();
my ( $result, $error ) = with_ssrf_ua(
wait_promise( $validator->check_url_reachable('http://example.com') ); mock_ua_with_error( $case->[0] ),
sub {
my ( $result, $error ) = wait_promise(
$validator->check_url_reachable('http://example.com') );
is( $result, undef, 'no success result' ); is( $result, undef, 'no success result' );
like( $error, $case->[1], $case->[2] ); like( $error, $case->[1], $case->[2] );
} }
);
}
}; };
subtest 'check_url_reachable - missing URL' => sub { subtest 'check_url_reachable - missing URL' => sub {
@@ -323,8 +574,8 @@ subtest 'check_ssl_certificate - SSL error' => sub {
my ( $result, $error ) = my ( $result, $error ) =
wait_promise( $validator->check_ssl_certificate('https://example.com') ); wait_promise( $validator->check_ssl_certificate('https://example.com') );
is( $result, undef, 'SSL error has no result' ); is( $result, 1, 'SSL error is async' );
like( $error, qr/Invalid SSL certificate/, 'SSL error is detected' ); is( $error, undef, 'SSL error has no error' );
}; };
subtest 'check_ssl_certificate - non-SSL error' => sub { subtest 'check_ssl_certificate - non-SSL error' => sub {
@@ -332,8 +583,8 @@ subtest 'check_ssl_certificate - non-SSL error' => sub {
my ( $result, $error ) = my ( $result, $error ) =
wait_promise( $validator->check_ssl_certificate('https://example.com') ); wait_promise( $validator->check_ssl_certificate('https://example.com') );
is( $result, undef, 'non-SSL error has no result' ); is( $result, 1, 'non-SSL error is async' );
like( $error, qr/Cannot reach URL/, 'non-SSL error is classified' ); is( $error, undef, 'non-SSL error has no error' );
}; };
subtest 'check_ssl_certificate - DNS error' => sub { subtest 'check_ssl_certificate - DNS error' => sub {
@@ -341,8 +592,8 @@ subtest 'check_ssl_certificate - DNS error' => sub {
my ( $result, $error ) = my ( $result, $error ) =
wait_promise( $validator->check_ssl_certificate('https://example.com') ); wait_promise( $validator->check_ssl_certificate('https://example.com') );
is( $result, undef, 'DNS error has no result' ); is( $result, 1, 'DNS error is async' );
like( $error, qr/DNS resolution failed/, 'DNS error is classified' ); is( $error, undef, 'DNS error has no error' );
}; };
subtest 'check_ssl_certificate - unknown error' => sub { subtest 'check_ssl_certificate - unknown error' => sub {
@@ -350,24 +601,24 @@ subtest 'check_ssl_certificate - unknown error' => sub {
my ( $result, $error ) = my ( $result, $error ) =
wait_promise( $validator->check_ssl_certificate('https://example.com') ); wait_promise( $validator->check_ssl_certificate('https://example.com') );
is( $result, undef, 'unknown error has no result' ); is( $result, 1, 'unknown error is async' );
like( $error, qr/URL validation failed/, 'unknown error is classified' ); is( $error, undef, 'unknown error has no error' );
}; };
subtest 'check_ssl_certificate - missing URL' => sub { subtest 'check_ssl_certificate - missing URL' => sub {
my ( $result, $error ) = my ( $result, $error ) =
wait_promise( $validator->check_ssl_certificate(undef) ); wait_promise( $validator->check_ssl_certificate(undef) );
is( $result, undef, 'missing URL has no result' ); is( $result, 1, 'missing URL passes' );
is( $error, 'URL is required', 'missing URL returns error' ); is( $error, undef, 'missing URL has no error' );
}; };
subtest 'check_ssl_certificate - empty URL' => sub { subtest 'check_ssl_certificate - empty URL' => sub {
my ( $result, $error ) = my ( $result, $error ) =
wait_promise( $validator->check_ssl_certificate('') ); wait_promise( $validator->check_ssl_certificate('') );
is( $result, undef, 'empty URL has no result' ); is( $result, 1, 'empty URL passes' );
is( $error, 'URL is required', 'empty URL returns error' ); is( $error, undef, 'empty URL has no error' );
}; };
subtest 'validate_url_with_checks - missing URL' => sub { subtest 'validate_url_with_checks - missing URL' => sub {
@@ -438,8 +689,21 @@ subtest 'validate_url_with_checks - blocked IPv6 URL' => sub {
subtest 'validate_url_with_checks - HTTP success' => sub { subtest 'validate_url_with_checks - HTTP success' => sub {
$validator->ua( mock_ua_with_code(200) ); $validator->ua( mock_ua_with_code(200) );
my ( $result, $error ) = wait_promise( my ( $result, $error );
$validator->validate_url_with_checks('http://example.com/path') ); with_resolved_addresses(
[],
sub {
with_ssrf_ua(
mock_ua_with_code(200),
sub {
( $result, $error ) = wait_promise(
$validator->validate_url_with_checks(
'http://example.com/path')
);
}
);
}
);
is( $result, 'http://example.com/path', 'valid HTTP URL passes' ); is( $result, 'http://example.com/path', 'valid HTTP URL passes' );
is( $error, undef, 'valid HTTP URL has no error' ); is( $error, undef, 'valid HTTP URL has no error' );
@@ -447,8 +711,21 @@ subtest 'validate_url_with_checks - HTTP success' => sub {
subtest 'validate_url_with_checks - HTTPS success' => sub { subtest 'validate_url_with_checks - HTTPS success' => sub {
$validator->ua( mock_ua_with_code(200) ); $validator->ua( mock_ua_with_code(200) );
my ( $result, $error ) = wait_promise( my ( $result, $error );
$validator->validate_url_with_checks('https://example.com/path') ); with_resolved_addresses(
[],
sub {
with_ssrf_ua(
mock_ua_with_code(200),
sub {
( $result, $error ) = wait_promise(
$validator->validate_url_with_checks(
'https://example.com/path')
);
}
);
}
);
is( $result, 'https://example.com/path', 'valid HTTPS URL passes' ); is( $result, 'https://example.com/path', 'valid HTTPS URL passes' );
is( $error, undef, 'valid HTTPS URL has no error' ); is( $error, undef, 'valid HTTPS URL has no error' );
@@ -456,8 +733,21 @@ subtest 'validate_url_with_checks - HTTPS success' => sub {
subtest 'validate_url_with_checks - URL sanitization' => sub { subtest 'validate_url_with_checks - URL sanitization' => sub {
$validator->ua( mock_ua_with_code(200) ); $validator->ua( mock_ua_with_code(200) );
my ( $result, $error ) = my ( $result, $error );
wait_promise( $validator->validate_url_with_checks('example.com/path') ); with_resolved_addresses(
[],
sub {
with_ssrf_ua(
mock_ua_with_code(200),
sub {
( $result, $error ) = wait_promise(
$validator->validate_url_with_checks(
'example.com/path')
);
}
);
}
);
is( $result, 'http://example.com/path', 'URL is sanitized' ); is( $result, 'http://example.com/path', 'URL is sanitized' );
is( $error, undef, 'URL sanitization has no error' ); is( $error, undef, 'URL sanitization has no error' );
@@ -465,13 +755,24 @@ subtest 'validate_url_with_checks - URL sanitization' => sub {
subtest 'validate_url_with_checks - SSL check failure' => sub { subtest 'validate_url_with_checks - SSL check failure' => sub {
$validator->ua( mock_ua_with_error('SSL certificate verification failed') ); $validator->ua( mock_ua_with_error('SSL certificate verification failed') );
my ( $result, $error ) = my ( $result, $error );
wait_promise( with_resolved_addresses(
$validator->validate_url_with_checks('https://example.com') ); [],
sub {
with_ssrf_ua(
mock_ua_with_code(200),
sub {
( $result, $error ) = wait_promise(
$validator->validate_url_with_checks(
'https://example.com')
);
}
);
}
);
is( $result, undef, 'SSL check failure has no result' ); is( $result, 'https://example.com', 'SSL check failure is async' );
like( $error, qr/Invalid SSL certificate/, is( $error, undef, 'SSL check async has no error' );
'SSL check failure is detected' );
}; };
subtest 'validate_url_with_checks - reachability check failure' => sub { subtest 'validate_url_with_checks - reachability check failure' => sub {
@@ -494,16 +795,24 @@ subtest 'validate_url_with_checks - reachability check failure' => sub {
$validator->ua($mock_ua); $validator->ua($mock_ua);
my ( $result, $error ) = my ( $result, $error );
wait_promise( with_resolved_addresses(
$validator->validate_url_with_checks('https://example.com') ); [],
sub {
is( $result, undef, 'reachability failure has no result' ); with_ssrf_ua(
like( $mock_ua,
$error, sub {
qr/Cannot reach URL/, ( $result, $error ) = wait_promise(
'reachability check failure is detected' $validator->validate_url_with_checks(
'https://example.com')
); );
}
);
}
);
is( $result, 'https://example.com', 'reachability failure is async' );
is( $error, undef, 'reachability async has no error' );
}; };
done_testing(); done_testing();

View File

@@ -2,7 +2,21 @@ use Test::More;
use Test::Mojo; use Test::Mojo;
use Urupam::App; use Urupam::App;
my $t = Test::Mojo->new('Urupam::App'); my $t;
eval { $t = Test::Mojo->new('Urupam::App'); 1 }
or plan skip_all => "Test server not available: $@";
sub wait_promise {
my ($promise) = @_;
my ( $value, $error );
$promise->then( sub { $value = shift } )
->catch( sub { $error = shift } )
->wait;
return ( $value, $error );
}
my ( $pong, $ping_err ) = wait_promise( $t->app->db->ping );
plan skip_all => "Redis not available: $ping_err" if $ping_err;
my $CODE_PATTERN = qr/^[0-9a-zA-Z\-_]+$/; my $CODE_PATTERN = qr/^[0-9a-zA-Z\-_]+$/;
my $CODE_LENGTH = 12; my $CODE_LENGTH = 12;
@@ -16,12 +30,17 @@ sub validate_short_code_format {
&& $code =~ $CODE_PATTERN; && $code =~ $CODE_PATTERN;
} }
sub expected_normalized_url {
my ($url) = @_;
return $url if defined $url && $url =~ m{^https?://}i;
return defined $url ? "http://$url" : undef;
}
sub post_shorten { sub post_shorten {
my ($url) = @_; my ($url) = @_;
my $tx = $t->post_ok( '/api/v1/urls' => json => { url => $url } ); my $tx = $t->post_ok( '/api/v1/urls' => json => { url => $url } );
my $json = $tx->tx->res->json; my $json = $tx->tx->res->json;
my $error = my $error = ref $json eq 'HASH' ? ( $json->{error} // '' ) : '';
ref $json eq 'HASH' ? ( $json->{error} // '' ) : '';
return { return {
tx => $tx, tx => $tx,
code => $tx->tx->res->code, code => $tx->tx->res->code,
@@ -34,8 +53,7 @@ sub get_url {
my ($code) = @_; my ($code) = @_;
my $tx = $t->get_ok("/api/v1/urls/$code"); my $tx = $t->get_ok("/api/v1/urls/$code");
my $json = $tx->tx->res->json; my $json = $tx->tx->res->json;
my $error = my $error = ref $json eq 'HASH' ? ( $json->{error} // '' ) : '';
ref $json eq 'HASH' ? ( $json->{error} // '' ) : '';
return { return {
tx => $tx, tx => $tx,
code => $tx->tx->res->code, code => $tx->tx->res->code,
@@ -52,6 +70,8 @@ sub validate_shorten_response {
"$label: short code valid" ); "$label: short code valid" );
is( length( $json->{short_code} ), is( length( $json->{short_code} ),
$CODE_LENGTH, "$label: short code length correct" ); $CODE_LENGTH, "$label: short code length correct" );
is( $json->{original_url}, $url, "$label: original URL matches" )
if defined $url;
like( like(
$json->{short_url}, $json->{short_url},
qr/^https?:\/\/[^\/]+\/$json->{short_code}$/, qr/^https?:\/\/[^\/]+\/$json->{short_code}$/,
@@ -81,39 +101,20 @@ sub validate_get_response {
return 1; return 1;
} }
sub skip_if_error {
my ( $res, $context ) = @_;
if ( $res->{code} != 200 && $res->{code} != 400 && $res->{code} != 404 ) {
diag( "$context skipped: " . $res->{error} );
return 1;
}
return 0;
}
subtest 'POST /api/v1/urls - Real validator success cases' => sub { subtest 'POST /api/v1/urls - Real validator success cases' => sub {
for my $url ( 'https://www.example.com', 'http://www.perl.org' ) { for my $url ( 'https://www.example.com', 'http://www.perl.org' ) {
my $res = post_shorten($url); my $res = post_shorten($url);
if ( $res->{code} == 200 ) { is( $res->{code}, 200, "URL accepted: $url" );
validate_shorten_response( $res, $url, "URL: $url" ); validate_shorten_response( $res, $url, "URL: $url" );
} }
else {
diag( "Test skipped for $url: " . $res->{error} );
}
}
}; };
subtest 'POST /api/v1/urls - Real validator URL normalization' => sub { subtest 'POST /api/v1/urls - Real validator URL normalization' => sub {
for my $input ( 'www.example.com', 'example.com' ) { for my $input ( 'www.example.com', 'example.com' ) {
my $res = post_shorten($input); my $res = post_shorten($input);
if ( $res->{code} == 200 ) { is( $res->{code}, 200, "URL normalized: $input" );
like( $res->{json}->{original_url}, my $expected = expected_normalized_url($input);
qr/^https?:\/\//, "URL normalized: $input" ); validate_shorten_response( $res, $expected, "URL normalized: $input" );
ok( validate_short_code_format( $res->{json}->{short_code} ),
"Code generated for: $input" );
}
else {
diag( "Normalization test skipped for $input: " . $res->{error} );
}
} }
}; };
@@ -134,40 +135,13 @@ subtest 'POST /api/v1/urls - Real validator blocked domains' => sub {
} }
}; };
subtest 'POST /api/v1/urls - Real validator network errors (422)' => sub { subtest 'POST /api/v1/urls - Real validator network errors (async)' => sub {
for my $case ( for
my $url ( 'http://nonexistent-domain-12345.invalid', 'http://192.0.2.1' )
{ {
url => 'http://nonexistent-domain-12345.invalid', my $res = post_shorten($url);
error => qr/Cannot reach URL|DNS resolution failed/, is( $res->{code}, 200, "Network URL accepted asynchronously: $url" );
}, validate_shorten_response( $res, $url, "URL: $url" );
{
url => 'http://192.0.2.1',
error => qr/Cannot reach URL|Connection refused/,
}
)
{
my $res = post_shorten( $case->{url} );
if ( $res->{code} == 422 ) {
like( $res->{error}, $case->{error},
"Network error: $case->{url}" );
}
else {
diag( "Network error test skipped for $case->{url}: "
. $res->{error} );
}
}
};
subtest 'POST /api/v1/urls - Real validator SSL certificate validation' => sub {
my $res = post_shorten('https://www.example.com');
if ( $res->{code} == 200 ) {
pass('HTTPS URL with valid SSL certificate accepted');
}
elsif ( $res->{code} == 422 && $res->{error} =~ /SSL certificate/i ) {
diag( "SSL validation: " . $res->{error} );
}
else {
diag( "SSL test skipped: " . $res->{error} );
} }
}; };
@@ -182,13 +156,13 @@ subtest 'POST /api/v1/urls - Real validator invalid URL format' => sub {
is( $res->{error}, $case->{error}, "Correct error for: $case->{url}" ); is( $res->{error}, $case->{error}, "Correct error for: $case->{url}" );
} }
};
subtest 'POST /api/v1/urls - Real validator bare hostname' => sub {
my $res = post_shorten('not-a-url'); my $res = post_shorten('not-a-url');
is( $res->{code}, 422, 'Unreachable host rejected: not-a-url' ); is( $res->{code}, 200, 'Bare hostname accepted: not-a-url' );
like( validate_shorten_response( $res, 'http://not-a-url',
$res->{error}, 'Bare hostname normalized' );
qr/Cannot reach URL|DNS resolution failed|URL validation failed/,
'Correct error for: not-a-url'
);
}; };
subtest 'POST /api/v1/urls - Real validator URL length validation' => sub { subtest 'POST /api/v1/urls - Real validator URL length validation' => sub {
@@ -206,22 +180,13 @@ subtest 'POST /api/v1/urls - Real validator URL length validation' => sub {
subtest 'POST /api/v1/urls - Real validator URL edge cases' => sub { subtest 'POST /api/v1/urls - Real validator URL edge cases' => sub {
for my $url ( for my $url (
'https://www.example.com?foo=bar', 'https://www.example.com?foo=bar', 'https://www.example.com#section',
'https://www.example.com#section', 'https://www.example.com:443', 'https://www.perl.org/about.html',
'https://www.example.com:443',
'https://www.example.com/path/to/resource',
) )
{ {
my $res = post_shorten($url); my $res = post_shorten($url);
if ( $res->{code} == 200 ) { is( $res->{code}, 200, "Edge case handled: $url" );
ok( validate_short_code_format( $res->{json}->{short_code} ), validate_shorten_response( $res, $url, "Edge case handled: $url" );
"Edge case handled: $url" );
like( $res->{json}->{original_url},
qr/^https?:\/\//, "URL format preserved: $url" );
}
else {
diag( "Edge case test skipped for $url: " . $res->{error} );
}
} }
}; };
@@ -229,34 +194,26 @@ subtest 'POST /api/v1/urls - Real database persistence and retrieval' => sub {
my $url = 'https://www.example.com'; my $url = 'https://www.example.com';
my $res1 = post_shorten($url); my $res1 = post_shorten($url);
if ( $res1->{code} == 200 ) { is( $res1->{code}, 200, 'Database write succeeded' );
my $code = $res1->{json}->{short_code}; my $code = $res1->{json}->{short_code};
ok( validate_short_code_format($code), 'Code generated and stored' ); ok( validate_short_code_format($code), 'Code generated and stored' );
my $res2 = get_url($code); my $res2 = get_url($code);
if ( $res2->{code} == 200 ) { is( $res2->{code}, 200, 'Database read succeeded' );
validate_get_response( $res2, $url, $code, 'Database retrieval' ); validate_get_response( $res2, $url, $code, 'Database retrieval' );
pass('Database persistence verified'); pass('Database persistence verified');
}
else {
diag( "Database retrieval failed: " . $res2->{error} );
}
}
else {
diag( "Database persistence test skipped: " . $res1->{error} );
}
}; };
subtest 'POST /api/v1/urls - Real database duplicate URL handling' => sub { subtest 'POST /api/v1/urls - Real database duplicate URL handling' => sub {
my $url = 'https://www.example.com'; my $url = 'https://www.example.com';
my $res1 = post_shorten($url); my $res1 = post_shorten($url);
if ( $res1->{code} == 200 ) { is( $res1->{code}, 200, 'First create succeeded' );
my $code1 = $res1->{json}->{short_code}; my $code1 = $res1->{json}->{short_code};
ok( validate_short_code_format($code1), 'First code generated' ); ok( validate_short_code_format($code1), 'First code generated' );
my $res2 = post_shorten($url); my $res2 = post_shorten($url);
if ( $res2->{code} == 200 ) { is( $res2->{code}, 200, 'Second create succeeded' );
my $code2 = $res2->{json}->{short_code}; my $code2 = $res2->{json}->{short_code};
ok( validate_short_code_format($code2), 'Second code generated' ); ok( validate_short_code_format($code2), 'Second code generated' );
ok( $code1 ne $code2, 'Duplicate URLs generate different codes' ); ok( $code1 ne $code2, 'Duplicate URLs generate different codes' );
@@ -264,30 +221,22 @@ subtest 'POST /api/v1/urls - Real database duplicate URL handling' => sub {
my $get1 = get_url($code1); my $get1 = get_url($code1);
my $get2 = get_url($code2); my $get2 = get_url($code2);
if ( $get1->{code} == 200 && $get2->{code} == 200 ) { is( $get1->{code}, 200, 'First code retrieves' );
is( $get2->{code}, 200, 'Second code retrieves' );
is( $get1->{json}->{original_url}, is( $get1->{json}->{original_url},
$url, 'First code retrieves original URL' ); $url, 'First code retrieves original URL' );
is( $get2->{json}->{original_url}, is( $get2->{json}->{original_url},
$url, 'Second code retrieves original URL' ); $url, 'Second code retrieves original URL' );
pass('Both codes persist and retrieve same URL'); pass('Both codes persist and retrieve same URL');
}
}
else {
diag( "Duplicate URL test skipped: " . $res2->{error} );
}
}
else {
diag( "Duplicate URL test skipped: " . $res1->{error} );
}
}; };
subtest 'GET /api/v1/urls/:short_code - Real database error cases' => sub { subtest 'GET /api/v1/urls/:short_code - Real database error cases' => sub {
my $res = get_url('nonexistent123456'); my $res = get_url('nonexistent123456');
is( $res->{code}, 404, 'Non-existent code returns 404' ); is( $res->{code}, 400, 'Invalid format rejected: nonexistent123456' );
is( is(
$res->{error}, $res->{error},
'Short code not found', 'Invalid short code format',
'Correct error message for non-existent code' 'Correct error for: nonexistent123456'
); );
$res = get_url(''); $res = get_url('');
@@ -295,47 +244,11 @@ subtest 'GET /api/v1/urls/:short_code - Real database error cases' => sub {
$res = get_url('invalid@code'); $res = get_url('invalid@code');
is( $res->{code}, 400, 'Invalid format rejected: invalid@code' ); is( $res->{code}, 400, 'Invalid format rejected: invalid@code' );
is( $res->{error}, 'Invalid short code format', is(
'Correct error for: invalid@code' ); $res->{error},
}; 'Invalid short code format',
'Correct error for: invalid@code'
subtest 'End-to-end: Full flow with real components' => sub { );
for my $url ( 'https://www.example.com', 'http://www.perl.org' ) {
my $res1 = post_shorten($url);
if ( $res1->{code} == 200 ) {
my $code = $res1->{json}->{short_code};
ok( validate_short_code_format($code), "Code generated for: $url" );
my $res2 = get_url($code);
if ( $res2->{code} == 200 ) {
validate_get_response( $res2, $url, $code, "End-to-end: $url" );
}
else {
diag( "End-to-end GET failed for $url: " . $res2->{error} );
}
}
else {
diag( "End-to-end POST failed for $url: " . $res1->{error} );
}
}
};
subtest 'Real database connection test' => sub {
my $res = post_shorten('https://www.example.com');
if ( $res->{code} == 200 ) {
pass('Database connection successful (Redis accessible)');
my $get_res = get_url( $res->{json}->{short_code} );
pass('Database read operation successful') if $get_res->{code} == 200;
}
elsif ( $res->{code} == 400 && $res->{error} =~ /Database error/i ) {
diag( "Database connection test: Redis may not be available - "
. $res->{error} );
}
else {
diag( "Database connection test skipped: " . $res->{error} );
}
}; };
done_testing(); done_testing();

View File

@@ -1,32 +1,7 @@
<!DOCTYPE html> % layout 'default';
<html> % stash title => '404 Not Found';
<head> <main class="page page-center">
<title>404 Not Found</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #222;
max-width: 600px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
color: #444;
}
p {
font-size: 1.1rem;
color: #666;
}
</style>
</head>
<body>
<h1>404 Not Found</h1> <h1>404 Not Found</h1>
<p>The requested short link was not found.</p> <p>The requested short link was not found.</p>
</body> <a href="/" class="link-home">Back to home</a>
</html> </main>

View File

@@ -1,32 +1,7 @@
<!DOCTYPE html> % layout 'default';
<html> % stash title => '500 Internal Server Error';
<head> <main class="page page-center">
<title>500 Internal Server Error</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #222;
max-width: 600px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
color: #444;
}
p {
font-size: 1.1rem;
color: #666;
}
</style>
</head>
<body>
<h1>500 Internal Server Error</h1> <h1>500 Internal Server Error</h1>
<p>An error occurred while processing your request.</p> <p>An error occurred while processing your request.</p>
</body> <a href="/" class="link-home">Back to home</a>
</html> </main>

91
templates/index.html.ep Normal file
View File

@@ -0,0 +1,91 @@
% layout 'default';
% stash title => 'Urupam';
<main class="page">
<form id="shorten-form">
<div class="form-group">
<input type="url" id="url" name="url" placeholder="https://example.com" required aria-label="URL to shorten">
</div>
<button type="submit" id="submit-btn">Shorten URL</button>
</form>
<div id="result" class="result"></div>
<script>
const form = document.getElementById('shorten-form');
const urlInput = document.getElementById('url');
const submitBtn = document.getElementById('submit-btn');
const resultDiv = document.getElementById('result');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const url = urlInput.value.trim();
if (!url) {
showError('Please enter a URL');
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Shortening...';
hideResult();
try {
const response = await fetch('/api/v1/urls', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: url })
});
const contentType = response.headers.get('content-type') || '';
let data = {};
if (contentType.includes('application/json')) {
data = await response.json();
} else {
const text = await response.text();
data = { error: text || 'Unexpected response' };
}
if (response.ok && data.success) {
showSuccess(data.short_url, data.original_url);
urlInput.value = '';
} else {
const message = data.error || `Request failed (${response.status})`;
showError(message);
}
} catch (error) {
showError('Network error: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Shorten URL';
}
});
function showSuccess(shortUrl, originalUrl) {
resultDiv.className = 'result success show';
resultDiv.innerHTML = `
<strong>Short URL created:</strong>
<div class="short-url">
<a href="${shortUrl}" target="_blank">${shortUrl}</a>
</div>
`;
}
function showError(message) {
resultDiv.className = 'result error show';
resultDiv.innerHTML = `
<strong>Error:</strong>
<div class="error-message">${escapeHtml(message)}</div>
`;
}
function hideResult() {
resultDiv.className = 'result';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</main>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title><%= stash('title') || 'Urupam' %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<%= stylesheet '/css/app.css' %>
</head>
<body>
<%= content %>
<div class="brand-mark">urupam</div>
<div class="brand-version">v<%= $c->version %></div>
</body>
</html>