1<?php
2/*
3 * vim:set softtabstop=4 shiftwidth=4 expandtab:
4 *
5 * LICENSE: GNU Affero General Public License, version 3 (AGPL-3.0-or-later)
6 * Copyright 2001 - 2020 Ampache.org
7 *
8 * This program is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU Affero General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 * GNU Affero General Public License for more details.
17 *
18 * You should have received a copy of the GNU Affero General Public License
19 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
20 *
21 */
22
23declare(strict_types=0);
24
25namespace Ampache\Module\Util;
26
27use Requests;
28
29/**
30 * AmazonSearch Class
31 *
32 * This class accepts 3 tokens: a public_key (Amazon API ID),
33 * a private_key (Amazon API password used for signing requests),
34 * and an associate_tag (Amazon Associate ID tag) and creates a signed
35 * query to request information (images) from the Amazon Web Service API.
36 * Currently it is semi-hardcoded to do music searches and only return
37 * information about the album art.
38 *
39 * This class has been updated to conform to changes made to the AWS on 02/21/2012.
40 * https://affiliate-program.amazon.com/gp/advertising/api/detail/api-changes.html
41 */
42class AmazonSearch
43{
44    public $base_url_default = 'webservices.amazon.com';
45    public $url_suffix       = '/onca/xml';
46    public $base_url;
47    public $search;
48    public $public_key;    // AWSAccessKeyId
49    public $private_key;   // AWSSecretKey
50    public $associate_tag; // Amazon Affiliate Associate Tag
51    public $results = array(); // Array of results
52    public $_parser;   // The XML parser
53    public $_grabtags; // Tags to grab the contents of
54    public $_sourceTag; // source tag don't ask
55    public $_subTag; // Stupid hack to make things come our right
56    public $_currentTag; // Stupid hack to make things come out right
57    public $_currentTagContents;
58    public $_currentPage           = 0;
59    public $_maxPage               = 1;
60    public $_default_results_pages = 1;
61    public $_proxy_host            = ""; // Proxy host
62    public $_proxy_port            = ""; // Proxy port
63    public $_proxy_user            = ""; // Proxy user
64    public $_proxy_pass            = ""; // Proxy pass
65
66    /**
67     * Class Constructor
68     * @param $public_key
69     * @param $private_key
70     * @param $associate_tag
71     * @param string $base_url_param
72     */
73    public function __construct($public_key, $private_key, $associate_tag, $base_url_param = '')
74    {
75
76        /* If we have a base url then use it */
77        if ($base_url_param != '') {
78            $this->base_url = str_replace('http://', '', $base_url_param);
79            debug_event(self::class, 'Retrieving from ' . $base_url_param . $this->url_suffix, 5);
80        } else {
81            $this->base_url = $this->base_url_default;
82            debug_event(self::class, 'Retrieving from DEFAULT', 5);
83        }
84
85        // AWS credentials
86        $this->public_key    = $public_key;
87        $this->private_key   = $private_key;
88        $this->associate_tag = $associate_tag;
89
90        $this->_grabtags = array(
91            'ASIN',
92            'ProductName',
93            'Catalog',
94            'ErrorMsg',
95            'Description',
96            'ReleaseDate',
97            'Manufacturer',
98            'ImageUrlSmall',
99            'ImageUrlMedium',
100            'ImageUrlLarge',
101            'Author',
102            'Artist',
103            'Title',
104            'URL',
105            'SmallImage',
106            'MediumImage',
107            'LargeImage'
108        );
109    } // AmazonSearch
110
111    /**
112     * setProxy
113     * Set the class up to search through an http proxy.
114     * The parameters are the proxy's hostname or IP address (a string)
115     * port, username, and password. These are passed directly to the
116     * Requests class when the search is done.
117     * @param string $host
118     * @param string $port
119     * @param string $user
120     * @param string $pass
121     */
122    public function setProxy($host = '', $port = '', $user = '', $pass = '')
123    {
124        if ($host) {
125            $this->_proxy_host = $host;
126        }
127        if ($port) {
128            $this->_proxy_port = $port;
129        }
130        if ($user) {
131            $this->_proxy_user = $user;
132        }
133        if ($pass) {
134            $this->_proxy_pass = $pass;
135        }
136    } // setProxy
137
138    /**
139     * Create the XML parser to process the response.
140     */
141    public function createParser()
142    {
143        $this->_parser = xml_parser_create();
144
145        xml_parser_set_option($this->_parser, XML_OPTION_CASE_FOLDING, false);
146
147        xml_set_object($this->_parser, $this);
148
149        xml_set_element_handler($this->_parser, 'startElement', 'endElement');
150
151        xml_set_character_data_handler($this->_parser, 'cdata');
152    } // createParser
153
154    /**
155     * Run a search.
156     *
157     * @param string $url The URL of the Amazon webservice.
158     */
159    public function runSearch($url)
160    {
161
162        // create the parser
163        $this->createParser();
164
165        // get the proxy config
166        $options = $this->getProxyConfig();
167
168        debug_event(self::class, 'Amazon request: ' . $url, 5);
169        // make the request and retrieve the response
170        $request  = Requests::get($url, array(), $options);
171        $contents = $request->body;
172
173        //debug_event(self::class, $contents, 5);
174        if (!xml_parse($this->_parser, $contents)) {
175            debug_event(self::class, 'Error:' . sprintf('XML error: %s at line %d', xml_error_string(xml_get_error_code($this->_parser)), xml_get_current_line_number($this->_parser)), 1);
176        }
177
178        xml_parser_free($this->_parser);
179    } // runSearch
180
181    /**
182     * getProxyConfig
183     * Build the proxy options array.
184     * Returning the array of proxy config options.
185     * @return array
186     */
187    public function getProxyConfig()
188    {
189        $options = array();
190        if ($this->_proxy_host) {
191            $proxy   = array();
192            $proxy[] = $this->_proxy_host . ($this->_proxy_port ? ':' . $this->_proxy_port : '');
193            if ($this->_proxy_user) {
194                $proxy[] = $this->_proxy_user;
195                $proxy[] = $this->_proxy_pass;
196            }
197            $options['proxy'] = $proxy;
198        }
199
200        return $options;
201    } // getProxyConfig
202
203    /**
204     * Create an XML search string.
205     *
206     * @param array $terms The search terms to include within the query.
207     * @param string $type The type of result desired.
208     * @return array
209     */
210    public function search($terms, $type = 'Music')
211    {
212        $params = array();
213
214        $params['Service']        = 'AWSECommerceService';
215        $params['AWSAccessKeyId'] = $this->public_key;
216        $params['AssociateTag']   = $this->associate_tag;
217        $params['Timestamp']      = gmdate("Y-m-d\TH:i:s\Z");
218        $params['Version']        = '2009-03-31';
219        $params['Operation']      = 'ItemSearch';
220        $params['Artist']         = $terms['artist'];
221        $params['Title']          = $terms['album'];
222        $params['Keywords']       = $terms['keywords'];
223        $params['SearchIndex']    = $type;
224
225        // sort by keys
226        ksort($params);
227
228        $canonicalized_query = array();
229
230        foreach ($params as $param => $value) {
231            $param = str_replace("%7E", "~", rawurlencode($param));
232            $value = str_replace("%7E", "~", rawurlencode($value));
233
234            $canonicalized_query[] = $param . "=" . $value;
235        }
236
237        // build the query string
238        $canonicalized_query = implode('&', $canonicalized_query);
239        $string_to_sign      = 'GET' . "\n" . $this->base_url . "\n" . $this->url_suffix . "\n" . $canonicalized_query;
240
241        $url = 'http://' . $this->base_url . $this->url_suffix . '?' . $canonicalized_query . '&Signature=' . $this->signString($string_to_sign);
242
243        $this->runSearch($url);
244
245        unset($this->results['ASIN']);
246
247        return $this->results;
248    } // search
249
250    /**
251     * signString
252     * Sign a query string
253     * @param string $string_to_sign The string to sign
254     * @return string
255     */
256    public function signString($string_to_sign)
257    {
258
259        // hash and encode the query string
260        $signature = base64_encode(hash_hmac("sha256", $string_to_sign, $this->private_key, true));
261
262        // urlencode the signed string, replace illegal char
263        $signature = str_replace("%7E", "~", rawurlencode($signature));
264
265        return $signature;
266    } // signString
267
268    /**
269     * Lookup the selected item by the 'Amazon Standard Identification Number'
270     *
271     * @param string $asin The 'Amazon standard Identification Number'
272     * @param string $type The category of results desired from the web service.
273     * @return array
274     */
275    public function lookup($asin, $type = 'Music')
276    {
277        if (is_array($asin)) {
278            foreach ($asin as $key => $value) {
279                $this->runSearchAsin($key);
280            }
281        } else {
282            // if array of asin's
283            $this->runSearchAsin($asin);
284        } // else
285
286        unset($this->results['ASIN']);
287
288        return $this->results;
289    } // lookup
290
291    /**
292     * Query the AWS for information about the selected item by ASIN and parse the results.
293     *
294     * @param string $asin The 'Amazon standard Identification Number'
295     */
296    public function runSearchAsin($asin)
297    {
298
299        // get the proxy config
300        $options = $this->getProxyConfig();
301
302        // create the xml parser
303        $this->createParser();
304
305        $params                   = array();
306        $params['Service']        = 'AWSECommerceService';
307        $params['AWSAccessKeyId'] = $this->public_key;
308        $params['AssociateTag']   = $this->associate_tag;
309        $params['Timestamp']      = gmdate("Y-m-d\TH:i:s\Z");
310        $params['Version']        = '2009-03-31';
311        $params['Operation']      = 'ItemLookup';
312        $params['ItemId']         = $asin;
313        $params['ResponseGroup']  = 'Images';
314
315        ksort($params);
316
317        // assemble the query terms
318        $canonicalized_query = array();
319        foreach ($params as $param => $value) {
320            $param = str_replace("%7E", "~", rawurlencode($param));
321            $value = str_replace("%7E", "~", rawurlencode($value));
322
323            $canonicalized_query[] = $param . "=" . $value;
324        }
325
326        // build the url query string
327        $canonicalized_query = implode('&', $canonicalized_query);
328        $string_to_sign      = 'GET' . "\n" . $this->base_url . "\n" . $this->url_suffix . "\n" . $canonicalized_query;
329
330        $url = 'http://' . $this->base_url . $this->url_suffix . '?' . $canonicalized_query . '&Signature=' . $this->signString($string_to_sign);
331
332        // make the request
333        $request  = Requests::get($url, array(), $options);
334        $contents = $request->body;
335
336        if (!xml_parse($this->_parser, $contents)) {
337            debug_event(self::class, 'Error:' . sprintf('XML error: %s at line %d', xml_error_string(xml_get_error_code($this->_parser)), xml_get_current_line_number($this->_parser)), 1);
338        }
339
340        xml_parser_free($this->_parser);
341    } // runSearchAsin
342
343    /**
344     * Start XML Element.
345     * @param $parser
346     * @param $tag
347     * @param $attributes
348     */
349    public function startElement($parser, $tag, $attributes)
350    {
351        if ($tag == "ASIN") {
352            $this->_sourceTag = $tag;
353        }
354        if ($tag == "SmallImage" || $tag == "MediumImage" || $tag == "LargeImage") {
355            $this->_subTag = $tag;
356        }
357
358        // If it's in the tag list, don't grab our search results
359        if (strlen($this->_sourceTag)) {
360            $this->_currentTag = $tag;
361        } else {
362            if ($tag != "TotalPages") {
363                $this->_currentTag = '';
364            } else {
365                $this->_currentTag = $tag;
366            }
367        }
368    } // startElement
369
370    /**
371     * CDATA handler.
372     * @param $parser
373     * @param $cdata
374     */
375    public function cdata($parser, $cdata)
376    {
377        $tag    = $this->_currentTag;
378        $subtag = $this->_subTag;
379        $source = $this->_sourceTag;
380
381        switch ($tag) {
382            case 'URL':
383                $this->results[$source][$subtag] = trim($cdata);
384                break;
385            case 'ASIN':
386                $this->_sourceTag = trim($cdata);
387                break;
388            case 'TotalPages':
389                debug_event(self::class, "TotalPages= " . trim($cdata), 5);
390                $this->_maxPage = trim($cdata);
391                break;
392            default:
393                if (strlen($tag)) {
394                    $this->results[$source][$tag] = trim($cdata);
395                }
396                break;
397        } // end switch
398    } // cdata
399
400    /**
401     * End XML Element
402     * @param $parser
403     * @param $tag
404     */
405    public function endElement($parser, $tag)
406    {
407
408        // zero the tag
409        $this->_currentTag = '';
410    } // endElement
411}
412