diff --git a/Changes b/Changes index 8e5f66448f..37bf8ef0b7 100644 --- a/Changes +++ b/Changes @@ -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 diff --git a/lib/Mojo/UserAgent/CookieJar.pm b/lib/Mojo/UserAgent/CookieJar.pm index f3bd41aaf0..e172892de8 100644 --- a/lib/Mojo/UserAgent/CookieJar.pm +++ b/lib/Mojo/UserAgent/CookieJar.pm @@ -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) = @_; @@ -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) = @_; @@ -93,6 +100,36 @@ 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}}; @@ -100,6 +137,34 @@ sub prepare { $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; @@ -145,6 +210,20 @@ L is a minimalistic and relaxed cookie jar used by L L implements the following attributes. +=head2 file + + my $file = $jar->file; + $jar = $jar->file('/home/sri/cookies.txt'); + +File to L cookies from and L cookies to in Netscape format. Note that this attribute is +B 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; @@ -195,7 +274,7 @@ Collect response cookies from transaction. =head2 empty - $jar->empty; + $jar = $jar->empty; Empty the jar. @@ -208,12 +287,30 @@ Find L objects in the jar for L 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. Note that this method is B 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. Note that this method is B and might change without warning! + +=head2 to_string + + my $string = $jar->to_string; + +Stringify cookies in Netscape format. Note that this method is B and might change without warning! + =head1 SEE ALSO L, L, L. diff --git a/t/mojo/cookiejar.t b/t/mojo/cookiejar.t index 3755b3e902..17a9a975bf 100644 --- a/t/mojo/cookiejar.t +++ b/t/mojo/cookiejar.t @@ -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; @@ -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(); diff --git a/t/mojo/cookies/curl.txt b/t/mojo/cookies/curl.txt new file mode 100644 index 0000000000..5a08525ff0 --- /dev/null +++ b/t/mojo/cookies/curl.txt @@ -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