3 * @copyright Copyright (C) 2010-2021, the Friendica project
5 * @license GNU AGPL version 3 or any later version
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as
9 * published by the Free Software Foundation, either version 3 of the
10 * License, or (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22 namespace Friendica\Module;
24 use Friendica\BaseModule;
25 use Friendica\Core\Logger;
27 use Friendica\Model\Photo;
28 use Friendica\Object\Image;
29 use Friendica\Util\HTTPSignature;
30 use Friendica\Util\Proxy as ProxyUtils;
36 * /proxy/[sub1/[sub2/]]<base64url image url>[.ext][:size]
37 * /proxy?url=<image url>
39 class Proxy extends BaseModule
43 * Initializer method for this class.
45 * Sets application instance and checks if /proxy/ path is writable.
48 public static function rawContent(array $parameters = [])
51 throw new \Friendica\Network\HTTPException\ForbiddenException(DI::l10n()->t('Access denied.'));
54 // Set application instance here
58 * Pictures are stored in one of the following ways:
60 * 1. If a folder "proxy" exists and is writeable, then use this for caching
61 * 2. If a cache path is defined, use this
62 * 3. If everything else failed, cache into the database
64 * Question: Do we really need these three methods?
66 if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
67 header('HTTP/1.1 304 Not Modified');
68 header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
69 header('Etag: ' . $_SERVER['HTTP_IF_NONE_MATCH']);
70 header('Expires: ' . gmdate('D, d M Y H:i:s', time() + (31536000)) . ' GMT');
71 header('Cache-Control: max-age=31536000');
73 if (function_exists('header_remove')) {
74 header_remove('Last-Modified');
75 header_remove('Expires');
76 header_remove('Cache-Control');
83 if (function_exists('header_remove')) {
84 header_remove('Pragma');
85 header_remove('pragma');
88 $direct_cache = self::setupDirectCache();
90 $request = self::getRequestInfo();
92 if (empty($request['url'])) {
93 throw new \Friendica\Network\HTTPException\BadRequestException();
96 // Webserver already tried direct cache...
98 // Try to use filecache;
99 $cachefile = self::responseFromCache($request);
101 // Try to use photo from db
102 self::responseFromDB($request);
105 // If script is here, the requested url has never cached before.
106 // Let's fetch it, scale it if required, then save it in cache.
109 // It shouldn't happen but it does - spaces in URL
110 $request['url'] = str_replace(' ', '+', $request['url']);
111 $fetchResult = HTTPSignature::fetchRaw($request['url'], local_user(), ['timeout' => 10]);
112 $img_str = $fetchResult->getBody();
114 // If there is an error then return a blank image
115 if ((substr($fetchResult->getReturnCode(), 0, 1) == '4') || empty($img_str)) {
116 Logger::info('Error fetching image', ['image' => $request['url'], 'return' => $fetchResult->getReturnCode(), 'empty' => empty($img_str)]);
117 self::responseError();
121 $tempfile = tempnam(get_temppath(), 'cache');
122 file_put_contents($tempfile, $img_str);
123 $mime = mime_content_type($tempfile);
126 $image = new Image($img_str, $mime);
127 if (!$image->isValid()) {
128 Logger::info('The image is invalid', ['image' => $request['url'], 'mime' => $mime]);
129 self::responseError();
133 $basepath = $a->getBasePath();
134 $filepermission = DI::config()->get('system', 'proxy_file_chmod');
136 // Store original image
138 // direct cache , store under ./proxy/
139 $filename = $basepath . '/proxy/' . ProxyUtils::proxifyUrl($request['url'], true);
140 file_put_contents($filename, $image->asString());
141 if (!empty($filepermission)) {
142 chmod($filename, $filepermission);
144 } elseif($cachefile !== '') {
146 file_put_contents($cachefile, $image->asString());
149 Photo::store($image, 0, 0, $request['urlhash'], $request['url'], '', 100);
153 // reduce quality - if it isn't a GIF
154 if ($image->getType() != 'image/gif') {
155 $image->scaleDown($request['size']);
159 // Store scaled image
160 if ($direct_cache && $request['sizetype'] != '') {
161 $filename = $basepath . '/proxy/' . ProxyUtils::proxifyUrl($request['url'], true) . $request['sizetype'];
162 file_put_contents($filename, $image->asString());
163 if (!empty($filepermission)) {
164 chmod($filename, $filepermission);
168 self::responseImageHttpCache($image);
174 * Build info about requested image to be proxied
178 * 'url' => requested url,
179 * 'urlhash' => sha1 has of the url prefixed with 'pic:',
180 * 'size' => requested image size (int)
181 * 'sizetype' => requested image size (string): ':micro', ':thumb', ':small', ':medium', ':large'
185 private static function getRequestInfo()
188 $size = ProxyUtils::PIXEL_LARGE;
191 // Look for filename in the arguments
192 // @TODO: Replace with parameter from router
193 if (($a->argc > 1) && !isset($_REQUEST['url'])) {
194 if (isset($a->argv[3])) {
196 } elseif (isset($a->argv[2])) {
202 /// @TODO: Why? And what about $url in this case?
203 /// @TODO: Replace with parameter from router
204 if (isset($a->argv[3]) && ($a->argv[3] == 'thumb')) {
208 // thumb, small, medium and large.
209 if (substr($url, -6) == ':micro') {
210 $size = ProxyUtils::PIXEL_MICRO;
211 $sizetype = ':micro';
212 $url = substr($url, 0, -6);
213 } elseif (substr($url, -6) == ':thumb') {
214 $size = ProxyUtils::PIXEL_THUMB;
215 $sizetype = ':thumb';
216 $url = substr($url, 0, -6);
217 } elseif (substr($url, -6) == ':small') {
218 $size = ProxyUtils::PIXEL_SMALL;
219 $url = substr($url, 0, -6);
220 $sizetype = ':small';
221 } elseif (substr($url, -7) == ':medium') {
222 $size = ProxyUtils::PIXEL_MEDIUM;
223 $url = substr($url, 0, -7);
224 $sizetype = ':medium';
225 } elseif (substr($url, -6) == ':large') {
226 $size = ProxyUtils::PIXEL_LARGE;
227 $url = substr($url, 0, -6);
228 $sizetype = ':large';
231 $pos = strrpos($url, '=.');
233 $url = substr($url, 0, $pos + 1);
236 $url = str_replace(['.jpg', '.jpeg', '.gif', '.png'], ['','','',''], $url);
238 $url = base64_decode(strtr($url, '-_', '+/'), true);
241 $url = $_REQUEST['url'] ?? '';
246 'urlhash' => 'pic:' . sha1($url),
248 'sizetype' => $sizetype,
254 * setup ./proxy folder for direct cache
256 * @return bool False if direct cache can't be used.
257 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
259 private static function setupDirectCache()
262 $basepath = $a->getBasePath();
264 // If the cache path isn't there, try to create it
265 if (!is_dir($basepath . '/proxy') && is_writable($basepath)) {
266 mkdir($basepath . '/proxy');
269 // Checking if caching into a folder in the webroot is activated and working
270 $direct_cache = (is_dir($basepath . '/proxy') && is_writable($basepath . '/proxy'));
271 // we don't use direct cache if image url is passed in args and not in querystring
272 $direct_cache = $direct_cache && ($a->argc > 1) && !isset($_REQUEST['url']);
274 return $direct_cache;
279 * Try to reply with image in cachefile
281 * @param array $request Array from getRequestInfo
283 * @return string Cache file name, empty string if cache is not enabled.
285 * If cachefile exists, script ends here and this function will never returns
286 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
287 * @throws \ImagickException
289 private static function responseFromCache(&$request)
291 $cachefile = get_cachefile(hash('md5', $request['url']));
292 if ($cachefile != '' && file_exists($cachefile)) {
293 $img = new Image(file_get_contents($cachefile), mime_content_type($cachefile));
294 self::responseImageHttpCache($img);
301 * Try to reply with image in database
303 * @param array $request Array from getRequestInfo
305 * If the image exists in database, then script ends here and this function will never returns
306 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
307 * @throws \ImagickException
309 private static function responseFromDB(&$request)
311 $photo = Photo::getPhoto($request['urlhash']);
313 if ($photo !== false) {
314 $img = Photo::getImageForPhoto($photo);
315 self::responseImageHttpCache($img);
321 * In case of an error just stop. We don't return content to avoid caching problems
323 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
325 private static function responseError()
327 throw new \Friendica\Network\HTTPException\InternalServerErrorException();
331 * Output the image with cache headers
334 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
336 private static function responseImageHttpCache(Image $img)
338 if (is_null($img) || !$img->isValid()) {
339 Logger::info('The cached image is invalid');
340 self::responseError();
343 header('Content-type: ' . $img->getType());
344 header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
345 header('Etag: "' . md5($img->asString()) . '"');
346 header('Expires: ' . gmdate('D, d M Y H:i:s', time() + (31536000)) . ' GMT');
347 header('Cache-Control: max-age=31536000');
348 echo $img->asString();