#!/usr/bin/perl use strict; use warnings; use WWW::Shopify; =head1 NAME WWW::Shopify::Public - Main object representing public app access to a particular Shopify store. =cut =head1 DESCRIPTION Inherits all methods from L, provides additional mechanisms to modify the used access_token, user-agent and url handler. =cut package WWW::Shopify::Public; use parent 'WWW::Shopify'; =head1 METHODS =head2 new($url, $api_key, $access_token) Creates a new WWW::Shopify::Public object, which allows you to make calls via the shopify public app interface. You can see an overview of the authentication workflow for a public app here: L<< http://api.shopify.com/authentication.html >> =cut sub new { my $class = shift; my ($shop_url, $api_key, $access_token, $api_version) = @_; my $self = $class->SUPER::new($shop_url,undef,undef,$api_version); # This is an optional addition; it's not necessary. $self->api_key($api_key); $self->access_token($access_token); $self->ua->default_header("X-Shopify-Access-Token" => $access_token); return $self; } =head2 api_key([$api_key]) Gets/sets the app's access token. =cut sub api_key { $_[0]->{_api_key} = $_[1] if defined $_[1]; return $_[0]->{_api_key}; } =head2 access_token([$access_token]) Gets/sets the app's access token. =cut sub access_token { $_[0]->{_access_token} = $_[1] if defined $_[1]; return $_[0]->{_access_token}; } sub is_valid { my $self = shift; $self->SUPER::is_valid(@_); return $self->{_access_token}; } =head2 authorize_url(@$scope, $redirect) When the shop doesn't have an access_token, this is what you should be redirecting your client to. Inputs should be your scope, as an array, and the url you want to redirect to. =cut use URI::Escape; sub authorize_url { my ($self, $scope, $redirect, $nonce, $options) = @_; my $hostname = $self->shop_url; my %parameters = ( 'client_id' => $self->api_key, 'scope' => join(",", @$scope), 'redirect_uri' => $redirect, ($nonce ? ('state' => $nonce) : ()), ($options ? ('grant_options[]' => $options) : ()) ); return "https://$hostname/admin/oauth/authorize?" . join("&", map { my $key = $_; ref($parameters{$key}) ? (map { "$key=" . uri_escape($_) } @{$parameters{$key}}) : ("$key=" . uri_escape($parameters{$key})) } keys(%parameters)); } =head2 scope_compare(@$scope1, @$scope2) Determines whether scope1 is more or less permissive than scope2. If the scopes cannot be compared easily (e.g. [write_orders, read_products], [read_products, write_script_tag]), then undef is returned. If scopes can be compared, returns -1, 0 or 1 as appropriate. =cut use Exporter 'import'; our @EXPORT_OK = qw(scope_compare scope_clean verify_install calc_hmac_install_signature); use List::MoreUtils qw(uniq); sub scope_compare { my ($scope1, $scope2) = @_; die new WWW::Shopify::Exception("Invalid scopes passed to compare.") unless ref($scope1) && ref($scope2) && ref($scope1) eq "ARRAY" && ref($scope2) eq "ARRAY"; $scope1 = scope_clean($scope1); $scope2 = scope_clean($scope2); # If scope2 has more permission settings than scope1, we've jumped up in permissions. return 1 if int(@$scope1) < int(@$scope2); return -1 if (int(@$scope1) > int(@$scope2)); # Here, we should have exactly equal amounts of scopes in both arrays, sorted. So they should be the same types down. for (0..int(@$scope1)-1) { die new WWW::Shopify::Exception("Invalid scope: $_") unless $scope1->[$_] =~ m/(read|write)_(\w+)/; my ($scope1_permission, $scope1_type) = ($1, $2); die new WWW::Shopify::Exception("Invalid scope: $_") unless $scope2->[$_] =~ m/(read|write)_(\w+)/; my ($scope2_permission, $scope2_type) = ($1, $2); # If we have not the same type, that means we've got some weird permissions going on, so we return undef, because it's # neither less nor more permissive, necessarily, just different. return undef unless $scope1_type eq $scope2_type; # When we've jumped down to a read in our second scope, we're less permissive. return -1 if $scope2_permission eq "read" && $scope1_permission eq "write"; # When we've jumped up to a write in our return 1 if $scope1_permission eq "read" && $scope2_permission eq "write"; } return 0; } # TODO There are probably still edge cases here. We should check this more thourghly # namely we should probably be checking for string starts with write m/^write_.*/ sub scope_clean { my ($scope) = @_; my @writes = grep { $_ =~ m/write_.*/ } @$scope; my @reads = grep { $_ =~ m/read_.*/ } @$scope; my @fscopes = @writes; for my $p (@reads) { $p =~ m/read_(.*)/; my $a = $1; push(@fscopes, $p) unless (grep { $_ eq "write_" . $1 } @writes); } @fscopes = sort(@fscopes); return \@fscopes; } =head2 refresh_token($new_secret, $refresh_token) Refreshes a token. =cut sub refresh_token { my ($self, $new_secret, $refresh_token) = (@_); my $req = HTTP::Request->new(POST => "https://" . $self->shop_url . "/admin/oauth/access_token"); $req->content_type('application/x-www-form-urlencoded'); my %parameters = ( 'client_id' => $self->api_key, 'client_secret' => $new_secret, 'refresh_token' => $refresh_token, 'access_token' => $self->access_token ); $req->content(join("&", map { "$_=" . uri_escape($parameters{$_}) } keys(%parameters))); my $res; my $success = undef; for (my $i = 0; $i < 5 && !$success; ++$i) { $res = $self->url_handler->request($req); $success = $res->is_success(); sleep 1 unless $success; } die new WWW::Shopify::Exception("Unable to reach server for https://" . $self->shop_url . "/admin/oauth/access_token: " . $res->code() . ".\n" . $res->content) unless $res->is_success(); my $decoded = JSON::decode_json($res->content); die new WWW::Shopify::Exception("Unable to refresh access token.") unless defined $decoded->{access_token}; return %{$decoded} if wantarray; return $decoded->{access_token}; } =head2 exchange_token($shared_secret, $code) When you have a temporary code, which you should get from authorize_url's redirect and you want to exchange it for a token, you call this. =cut sub exchange_token { my ($self, $shared_secret, $code) = (@_); my $req = HTTP::Request->new(POST => "https://" . $self->shop_url . "/admin/oauth/access_token"); $req->content_type('application/x-www-form-urlencoded'); my %parameters = ( 'client_id' => $self->api_key, 'client_secret' => $shared_secret, 'code' => $code ); $req->content(join("&", map { "$_=" . uri_escape($parameters{$_}) } keys(%parameters))); my $res; # If you go through the install too fast, Shopify will give you an access denied thing. So in that case, let's repeat until my $success = undef; for (my $i = 0; $i < 5 && !$success; ++$i) { $res = $self->url_handler->request($req); $success = $res->is_success(); sleep 1 unless $success; } die new WWW::Shopify::Exception("Unable to reach server for https://" . $self->shop_url . "/admin/oauth/access_token: " . $res->code() . ".\n" . $res->content) unless $res->is_success(); my $decoded = JSON::decode_json($res->content); die new WWW::Shopify::Exception("Unable to retrieve access token.") unless defined $decoded->{access_token}; return %{$decoded} if wantarray; return $decoded->{access_token}; } sub calc_hmac_install_signature { return WWW::Shopify::calc_hmac_login_signature(@_); } sub verify_install { my ($shared_secret, $params) = @_; return undef unless $params->{hmac}; my $hmac = ref($params->{hmac}) eq 'ARRAY' ? $params->{hmac}->[0] : $params->{hmac}; return calc_hmac_install_signature($shared_secret, $params) eq $hmac; } =head1 SEE ALSO L, L =head1 AUTHOR Adam Harrison (adamdharrison@gmail.com) =head1 LICENSE See LICENSE in the main directory. =cut 1;