Skip to content

Commit

Permalink
Add experimental support for persistent cookies in Netscape format
Browse files Browse the repository at this point in the history
  • Loading branch information
kraih committed Oct 27, 2023
1 parent 5864c72 commit 87c2c34
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 4 deletions.
5 changes: 4 additions & 1 deletion Changes
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@

9.35 2023-10-06
9.35 2023-10-27
- Added EXPERIMENTAL support for persistent cookies in Netscape format.
- Added EXPERIMENTAL file attribute to Mojo::UserAgent::CookieJar.
- Added EXPERIMENTAL load, save and to_string methods to Mojo::UserAgent::CookieJar.
- Fixed absolute URL support in url_for_file and url_for_asset methods. (rawleyfowler)

9.34 2023-09-11
Expand Down
103 changes: 100 additions & 3 deletions lib/Mojo/UserAgent/CookieJar.pm
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package Mojo::UserAgent::CookieJar;
use Mojo::Base -base;

use Mojo::Cookie::Request;
use Mojo::File qw(path);
use Mojo::Path;
use Scalar::Util qw(looks_like_number);

has 'ignore';
has [qw(file ignore)];
has max_cookie_size => 4096;

my $COMMENT = "# Netscape HTTP Cookie File\n# This file was generated by Mojolicious! Edit at your own risk.\n";

sub add {
my ($self, @cookies) = @_;

Expand Down Expand Up @@ -58,7 +61,11 @@ sub collect {
}
}

sub empty { delete shift->{jar} }
sub empty {
my $self = shift;
delete $self->{jar};
return $self;
}

sub find {
my ($self, $url) = @_;
Expand Down Expand Up @@ -93,13 +100,71 @@ sub find {
return \@found;
}

sub load {
my $self = shift;

my $file = $self->file;
return $self unless $file && -r $file;

for my $line (split "\n", path($file)->slurp) {

# Prefix used by curl for HttpOnly cookies
my $httponly = $line =~ s/^#HttpOnly_// ? 1 : 0;
next if $line =~ /^#/;

my @values = split "\t", $line;
next if @values != 7;

$self->add(Mojo::Cookie::Response->new({
domain => $values[0] =~ s/^\.//r,
host_only => $values[1] eq 'FALSE' ? 1 : 0,
path => $values[2],
secure => $values[3] eq 'FALSE' ? 0 : 1,
expires => $values[4] eq '0' ? undef : $values[4],
name => $values[5],
value => $values[6],
httponly => $httponly
}));
}

return $self;
}

sub prepare {
my ($self, $tx) = @_;
return unless keys %{$self->{jar}};
my $req = $tx->req;
$req->cookies(@{$self->find($req->url)});
}

sub save {
my $self = shift;
return $self unless my $file = $self->file;

my $final = path($file);
my $tmp = path("$file.$$");
$tmp->spew($COMMENT . $self->to_string)->move_to($final);

return $self;
}

sub to_string {
my $self = shift;

my @lines;
for my $cookie (@{$self->all}) {
my $line = [
$cookie->domain, $cookie->host_only ? 'FALSE' : 'TRUE',
$cookie->path, $cookie->secure ? 'TRUE' : 'FALSE',
$cookie->expires // 0, $cookie->name,
$cookie->value
];
push @lines, join "\t", @$line;
}

return join "\n", @lines, '';
}

sub _compare {
my ($cookie, $path, $name, $domain) = @_;
return $cookie->path ne $path || $cookie->name ne $name || $cookie->domain ne $domain;
Expand Down Expand Up @@ -145,6 +210,20 @@ L<Mojo::UserAgent::CookieJar> is a minimalistic and relaxed cookie jar used by L
L<Mojo::UserAgent::CookieJar> implements the following attributes.
=head2 file
my $file = $jar->file;
$jar = $jar->file('/home/sri/cookies.txt');
File to L</"load"> cookies from and L</"save"> cookies to in Netscape format. Note that this attribute is
B<EXPERIMENTAL> and might change without warning!
# Save cookies in file
$jar->file('cookies.txt')->save;
# Load cookies from file
$jar->file('cookies.txt')->load;
=head2 ignore
my $ignore = $jar->ignore;
Expand Down Expand Up @@ -195,7 +274,7 @@ Collect response cookies from transaction.
=head2 empty
$jar->empty;
$jar = $jar->empty;
Empty the jar.
Expand All @@ -208,12 +287,30 @@ Find L<Mojo::Cookie::Request> objects in the jar for L<Mojo::URL> object.
# Names of all cookies found
say $_->name for @{$jar->find(Mojo::URL->new('http://example.com/foo'))};
=head2 load
$jar = $jar->load;
Load cookies from L</"file">. Note that this method is B<EXPERIMENTAL> and might change without warning!
=head2 prepare
$jar->prepare(Mojo::Transaction::HTTP->new);
Prepare request cookies for transaction.
=head2 save
$jar = $jar->save;
Save cookies to L</"file">. Note that this method is B<EXPERIMENTAL> and might change without warning!
=head2 to_string
my $string = $jar->to_string;
Stringify cookies in Netscape format. Note that this method is B<EXPERIMENTAL> and might change without warning!
=head1 SEE ALSO
L<Mojolicious>, L<Mojolicious::Guides>, L<https://mojolicious.org>.
Expand Down
162 changes: 162 additions & 0 deletions t/mojo/cookiejar.t
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use Mojo::Base -strict;

use Test::More;
use Mojo::Cookie::Response;
use Mojo::File qw(curfile tempdir);
use Mojo::Transaction::HTTP;
use Mojo::URL;
use Mojo::UserAgent::CookieJar;
Expand Down Expand Up @@ -379,4 +380,165 @@ subtest 'Gather cookies with invalid path' => sub {
is_deeply $jar->all, [], 'no cookies';
};

subtest 'Load cookies from Netscape cookies.txt file' => sub {
my $cookies = curfile->dirname->child('cookies');

subtest 'Not configured' => sub {
my $jar = Mojo::UserAgent::CookieJar->new;
is_deeply $jar->load->all, [], 'no cookies';
};

subtest 'Missing file' => sub {
my $jar = Mojo::UserAgent::CookieJar->new;
is_deeply $jar->file($cookies->child('missing.txt')->to_string)->load->all, [], 'no cookies';
};

subtest 'Load file created by curl' => sub {
my $jar = Mojo::UserAgent::CookieJar->new;
my $cookies = $jar->file($cookies->child('curl.txt')->to_string)->load->all;

is $cookies->[0]->name, 'AEC', 'right name';
is $cookies->[0]->value, 'Ack', 'right value';
is $cookies->[0]->domain, 'google.com', 'right domain';
is $cookies->[0]->path, '/', 'right path';
is $cookies->[0]->expires, 4713964099, 'expires';
is $cookies->[0]->secure, 1, 'is secure';
is $cookies->[0]->httponly, 1, 'is HttpOnly';
is $cookies->[0]->host_only, 0, 'allows subdomains';

is $cookies->[1]->name, '__Secure-ENID', 'right name';
is $cookies->[1]->value, '15.SE', 'right value';
is $cookies->[1]->domain, 'google.com', 'right domain';
is $cookies->[1]->path, '/', 'right path';
is $cookies->[1]->expires, 4732598797, 'expires';
is $cookies->[1]->secure, 1, 'is secure';
is $cookies->[1]->httponly, 1, 'is HttpOnly';
is $cookies->[1]->host_only, 0, 'allows subdomains';

is $cookies->[2]->name, 'csv', 'right name';
is $cookies->[2]->value, '2', 'right value';
is $cookies->[2]->domain, 'reddit.com', 'right domain';
is $cookies->[2]->path, '/', 'right path';
is $cookies->[2]->expires, 4761486052, 'expires';
is $cookies->[2]->secure, 1, 'is secure';
is $cookies->[2]->httponly, 0, 'not HttpOnly';
is $cookies->[2]->host_only, 0, 'allows subdomains';

is $cookies->[3]->name, 'csrf_token', 'right name';
is $cookies->[3]->value, '3329d93c563f6a017045f516c5c515fc', 'right value';
is $cookies->[3]->domain, 'reddit.com', 'right domain';
is $cookies->[3]->path, '/', 'right path';
ok !$cookies->[3]->expires, 'does not expire';
is $cookies->[3]->secure, 1, 'is secure';
is $cookies->[3]->httponly, 0, 'not HttpOnly';
is $cookies->[3]->host_only, 0, 'allows subdomains';

is $cookies->[4]->name, 'CONSENT', 'right name';
is $cookies->[4]->value, 'PENDING+648', 'right value';
is $cookies->[4]->domain, 'whatever.youtube.com', 'right domain';
is $cookies->[4]->path, '/about', 'right path';
is $cookies->[4]->expires, 4761484436, 'expires';
is $cookies->[4]->secure, 1, 'is secure';
is $cookies->[4]->httponly, 0, 'not HttpOnly';
is $cookies->[4]->host_only, 0, 'allows subdomains';

is $cookies->[5]->name, 'susecom-cookie', 'right name';
is $cookies->[5]->value, '50fbf56aa575290e', 'right value';
is $cookies->[5]->domain, 'www.suse.com', 'right domain';
is $cookies->[5]->path, '/', 'right path';
ok !$cookies->[5]->expires, 'does not expire';
is $cookies->[5]->secure, 0, 'not secure';
is $cookies->[5]->httponly, 0, 'not HttpOnly';
is $cookies->[5]->host_only, 1, 'does not allow subdomains';
};
};

subtest 'Save cookies to Netscape cookies.txt file' => sub {
my $tmp = tempdir;

subtest 'Not configured' => sub {
my $jar = Mojo::UserAgent::CookieJar->new;
is_deeply $jar->save->all, [], 'no cookies';
};

subtest 'Empty jar' => sub {
my $file = $tmp->child('empty.txt');
my $jar = Mojo::UserAgent::CookieJar->new(file => $file->to_string);

ok !-e $file, 'file does not exist';
is_deeply $jar->save->all, [], 'no cookies';
ok -e $file, 'file exists';
is_deeply $jar->load->all, [], 'no cookies';

my $content = $file->slurp;
like $content, qr/# Netscape HTTP Cookie File/, 'Netscape comment is present';
like $content, qr/# This file was generated by Mojolicious! Edit at your own risk./, 'warning comment is present';
};

subtest 'Store standard cookies' => sub {
my $file = $tmp->child('session.txt');
my $jar = Mojo::UserAgent::CookieJar->new(file => $file->to_string);

$jar->add(Mojo::Cookie::Response->new(domain => 'example.com', path => '/foo', name => 'a', value => 'b'));

ok !-e $file, 'file does not exist';
$jar->save;
ok -e $file, 'file exists';
my $content = $file->slurp;

like $content, qr/# Netscape HTTP Cookie File/, 'Netscape comment is present';
like $content, qr/# This file was generated by Mojolicious! Edit at your own risk./, 'warning comment is present';
like $content, qr/example\.com\tTRUE\t\/foo\tFALSE\t0\ta\tb/, 'cookie is present';

my $jar2 = Mojo::UserAgent::CookieJar->new(file => $file->to_string)->load;
my $cookies = $jar2->all;
is $cookies->[0]->name, 'a', 'right name';
is $cookies->[0]->value, 'b', 'right value';
is $cookies->[0]->domain, 'example.com', 'right domain';
is $cookies->[0]->path, '/foo', 'right path';
ok !$cookies->[0]->expires, 'does not expire';
ok !$cookies->[1], 'no more cookies';

$jar2->empty->add(Mojo::Cookie::Response->new(domain => 'mojolicious.org', path => '/', name => 'c', value => 'd'))
->save;

my $jar3 = Mojo::UserAgent::CookieJar->new(file => $file->to_string)->load;
$cookies = $jar3->all;
is $cookies->[0]->name, 'c', 'right name';
is $cookies->[0]->value, 'd', 'right value';
is $cookies->[0]->domain, 'mojolicious.org', 'right domain';
is $cookies->[0]->path, '/', 'right path';
ok !$cookies->[0]->expires, 'does not expire';
ok !$cookies->[1], 'no more cookies';
};
};

subtest 'Stringify cookies in Netscape format' => sub {
subtest 'Session cookies' => sub {
my $jar = Mojo::UserAgent::CookieJar->new;
$jar->add(
Mojo::Cookie::Response->new(domain => 'mojolicious.org', path => '/', name => 'c', value => 'd'),
Mojo::Cookie::Response->new(domain => 'example.com', path => '/foo', name => 'foo', value => 'bar')
);
my $content = $jar->to_string;
like $content, qr/mojolicious\.org\tTRUE\t\/\tFALSE\t0\tc\td/, 'first cookie';
like $content, qr/example\.com\tTRUE\t\/foo\tFALSE\t0\tfoo\tbar/, 'second cookie';
};

subtest 'Secure cookies' => sub {
my $jar = Mojo::UserAgent::CookieJar->new;
$jar->add(Mojo::Cookie::Response->new(
domain => 'www.mojolicious.org',
path => '/',
secure => 1,
host_only => 1,
expires => 4732598797,
name => 'one',
value => 'One'
));
my $content = $jar->to_string;
like $content, qr/www.mojolicious.org\tFALSE\t\/\tTRUE\t4732598797\tone\tOne/, 'first cookie';
};
};

done_testing();
10 changes: 10 additions & 0 deletions t/mojo/cookies/curl.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

www.suse.com FALSE / FALSE 0 susecom-cookie 50fbf56aa575290e
.reddit.com TRUE / TRUE 4761486052 csv 2
.reddit.com TRUE / TRUE 0 csrf_token 3329d93c563f6a017045f516c5c515fc
#HttpOnly_.google.com TRUE / TRUE 4713964099 AEC Ack
#HttpOnly_.google.com TRUE / TRUE 4732598797 __Secure-ENID 15.SE
.whatever.youtube.com TRUE /about TRUE 4761484436 CONSENT PENDING+648

0 comments on commit 87c2c34

Please sign in to comment.