1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Provides {@link flickr_client} class.
19 *
20 * @package     core
21 * @copyright   2017 David Mudrák <david@moodle.com>
22 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25defined('MOODLE_INTERNAL') || die();
26
27require_once($CFG->libdir.'/oauthlib.php');
28
29/**
30 * Simple Flickr API client implementing the features needed by Moodle
31 *
32 * @copyright 2017 David Mudrak <david@moodle.com>
33 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34 */
35class flickr_client extends oauth_helper {
36
37    /**
38     * Base URL for Flickr OAuth 1.0 API calls.
39     */
40    const OAUTH_ROOT = 'https://www.flickr.com/services/oauth';
41
42    /**
43     * Base URL for Flickr REST API calls.
44     */
45    const REST_ROOT = 'https://api.flickr.com/services/rest';
46
47    /**
48     * Base URL for Flickr Upload API call.
49     */
50    const UPLOAD_ROOT = 'https://up.flickr.com/services/upload/';
51
52    /**
53     * Set up OAuth and initialize the client.
54     *
55     * The callback URL specified here will override the one specified in the
56     * auth flow defined at Flickr Services.
57     *
58     * @param string $consumerkey
59     * @param string $consumersecret
60     * @param moodle_url|string $callbackurl
61     */
62    public function __construct($consumerkey, $consumersecret, $callbackurl = '') {
63        global $CFG;
64        $version = moodle_major_version();
65        $useragent = "MoodleSite/$version (+{$CFG->wwwroot})";
66
67        parent::__construct([
68            'api_root' => self::OAUTH_ROOT,
69            'oauth_consumer_key' => $consumerkey,
70            'oauth_consumer_secret' => $consumersecret,
71            'oauth_callback' => $callbackurl,
72            'http_options' => ['CURLOPT_USERAGENT' => $useragent]
73        ]);
74    }
75
76    /**
77     * Temporarily store the request token secret in the session.
78     *
79     * The request token secret is returned by the oauth request_token method.
80     * It needs to be stored in the session before the user is redirected to
81     * the Flickr to authorize the client. After redirecting back, this secret
82     * is used for exchanging the request token with the access token.
83     *
84     * The identifiers help to avoid collisions between multiple calls to this
85     * method from different plugins in the same session. They are used as the
86     * session cache identifiers. Provide an associative array identifying the
87     * particular method call. At least, the array must contain the 'caller'
88     * with the caller's component name. Use additional items if needed.
89     *
90     * @param array $identifiers Identification of the call
91     * @param string $secret
92     */
93    public function set_request_token_secret(array $identifiers, $secret) {
94
95        if (empty($identifiers) || empty($identifiers['caller'])) {
96            throw new coding_exception('Invalid call identification');
97        }
98
99        $cache = cache::make_from_params(cache_store::MODE_SESSION, 'core', 'flickrclient', $identifiers);
100        $cache->set('request_token_secret', $secret);
101    }
102
103    /**
104     * Returns previously stored request token secret.
105     *
106     * See {@link self::set_request_token_secret()} for more details on the
107     * $identifiers argument.
108     *
109     * @param array $identifiers Identification of the call
110     * @return string|bool False on error, string secret otherwise.
111     */
112    public function get_request_token_secret(array $identifiers) {
113
114        if (empty($identifiers) || empty($identifiers['caller'])) {
115            throw new coding_exception('Invalid call identification');
116        }
117
118        $cache = cache::make_from_params(cache_store::MODE_SESSION, 'core', 'flickrclient', $identifiers);
119
120        return $cache->get('request_token_secret');
121    }
122
123    /**
124     * Call a Flickr API method.
125     *
126     * @param string $function API function name like 'flickr.photos.getSizes' or just 'photos.getSizes'
127     * @param array $params Additional API call arguments.
128     * @param string $method HTTP method to use (GET or POST).
129     * @return object|bool Response as returned by the Flickr or false on invalid authentication
130     */
131    public function call($function, array $params = [], $method = 'GET') {
132
133        if (strpos($function, 'flickr.') !== 0) {
134            $function = 'flickr.'.$function;
135        }
136
137        $params['method'] = $function;
138        $params['format'] = 'json';
139        $params['nojsoncallback'] = 1;
140
141        $rawresponse = $this->request($method, self::REST_ROOT, $params);
142        $response = json_decode($rawresponse);
143
144        if (!is_object($response) || !isset($response->stat)) {
145            throw new moodle_exception('flickr_api_call_failed', 'core_error', '', $rawresponse);
146        }
147
148        if ($response->stat === 'ok') {
149            return $response;
150
151        } else if ($response->stat === 'fail' && $response->code == 98) {
152            // Authentication failure, give the caller a chance to re-authenticate.
153            return false;
154
155        } else {
156            throw new moodle_exception('flickr_api_call_failed', 'core_error', '', $response);
157        }
158
159        return $response;
160    }
161
162    /**
163     * Return the URL to fetch the given photo from.
164     *
165     * Flickr photos are distributed via farm servers staticflickr.com in
166     * various sizes (resolutions). The method tries to find the source URL of
167     * the photo in the highest possible resolution. Results are cached so that
168     * we do not need to query the Flickr API over and over again.
169     *
170     * @param string $photoid Flickr photo identifier
171     * @return string URL
172     */
173    public function get_photo_url($photoid) {
174
175        $cache = cache::make_from_params(cache_store::MODE_APPLICATION, 'core', 'flickrclient');
176
177        $url = $cache->get('photourl_'.$photoid);
178
179        if ($url === false) {
180            $response = $this->call('photos.getSizes', ['photo_id' => $photoid]);
181            // Sizes are returned from smallest to greatest.
182            if (!empty($response->sizes->size) && is_array($response->sizes->size)) {
183                while ($bestsize = array_pop($response->sizes->size)) {
184                    if (isset($bestsize->source)) {
185                        $url = $bestsize->source;
186                        break;
187                    }
188                }
189            }
190        }
191
192        if ($url === false) {
193            throw new repository_exception('cannotdownload', 'repository');
194
195        } else {
196            $cache->set('photourl_'.$photoid, $url);
197        }
198
199        return $url;
200    }
201
202    /**
203     * Upload a photo from Moodle file pool to Flickr.
204     *
205     * Optional meta information are title, description, tags, is_public,
206     * is_friend, is_family, safety_level, content_type and hidden.
207     * See {@link https://www.flickr.com/services/api/upload.api.html}.
208     *
209     * Upload can't be asynchronous because then the query would not return the
210     * photo ID which we need to add the photo to a photoset (album)
211     * eventually.
212     *
213     * @param stored_file $photo stored in Moodle file pool
214     * @param array $meta optional meta information
215     * @return int|bool photo id, false on authentication failure
216     */
217    public function upload(stored_file $photo, array $meta = []) {
218
219        $args = [
220            'title' => isset($meta['title']) ? $meta['title'] : null,
221            'description' => isset($meta['description']) ? $meta['description'] : null,
222            'tags' => isset($meta['tags']) ? $meta['tags'] : null,
223            'is_public' => isset($meta['is_public']) ? $meta['is_public'] : 0,
224            'is_friend' => isset($meta['is_friend']) ? $meta['is_friend'] : 0,
225            'is_family' => isset($meta['is_family']) ? $meta['is_family'] : 0,
226            'safety_level' => isset($meta['safety_level']) ? $meta['safety_level'] : 1,
227            'content_type' => isset($meta['content_type']) ? $meta['content_type'] : 1,
228            'hidden' => isset($meta['hidden']) ? $meta['hidden'] : 2,
229        ];
230
231        $this->sign_secret = $this->consumer_secret.'&'.$this->access_token_secret;
232        $params = $this->prepare_oauth_parameters(self::UPLOAD_ROOT, ['oauth_token' => $this->access_token] + $args, 'POST');
233
234        $params['photo'] = $photo;
235
236        $response = $this->http->post(self::UPLOAD_ROOT, $params);
237
238        // Reset http header and options to prepare for the next request.
239        $this->reset_state();
240
241        if ($response) {
242            $xml = simplexml_load_string($response);
243
244            if ((string)$xml['stat'] === 'ok') {
245                return (int)$xml->photoid;
246
247            } else if ((string)$xml['stat'] === 'fail' && (int)$xml->err['code'] == 98) {
248                // Authentication failure.
249                return false;
250
251            } else {
252                throw new moodle_exception('flickr_upload_failed', 'core_error', '',
253                    ['code' => (int)$xml->err['code'], 'message' => (string)$xml->err['msg']]);
254            }
255
256        } else {
257            throw new moodle_exception('flickr_upload_error', 'core_error', '', null, $response);
258        }
259    }
260
261    /**
262     * Resets curl state.
263     *
264     * @return void
265     */
266    public function reset_state(): void {
267        $this->http->cleanopt();
268        $this->http->resetHeader();
269    }
270}
271