3 * @copyright Copyright (C) 2010-2022, 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\Profile;
25 use Friendica\Content\Feature;
26 use Friendica\Content\Pager;
27 use Friendica\Core\Config\Capability\IManageConfigValues;
28 use Friendica\Core\Hook;
29 use Friendica\Core\L10n;
30 use Friendica\Core\Renderer;
31 use Friendica\Core\Session\Capability\IHandleUserSessions;
32 use Friendica\Core\System;
33 use Friendica\Database\Database;
34 use Friendica\Model\Contact;
35 use Friendica\Model\Item;
36 use Friendica\Model\Photo;
37 use Friendica\Model\Profile;
38 use Friendica\Module\Response;
39 use Friendica\Navigation\SystemMessages;
40 use Friendica\Network\HTTPException;
41 use Friendica\Object\Image;
42 use Friendica\Security\Security;
43 use Friendica\Util\ACLFormatter;
44 use Friendica\Util\DateTimeFormat;
45 use Friendica\Util\Images;
46 use Friendica\Util\Profiler;
47 use Friendica\Util\Strings;
48 use Psr\Log\LoggerInterface;
50 class Photos extends \Friendica\Module\BaseProfile
52 /** @var IHandleUserSessions */
56 /** @var IManageConfigValues */
62 /** @var SystemMessages */
63 private $systemMessages;
64 /** @var ACLFormatter */
65 private $aclFormatter;
66 /** @var array owner-view record */
69 public function __construct(ACLFormatter $aclFormatter, SystemMessages $systemMessages, Database $database, App $app, IManageConfigValues $config, App\Page $page, IHandleUserSessions $session, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = [])
71 parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters);
73 $this->session = $session;
75 $this->config = $config;
77 $this->database = $database;
78 $this->systemMessages = $systemMessages;
79 $this->aclFormatter = $aclFormatter;
81 $owner = Profile::load($this->app, $this->parameters['nickname'] ?? '');
82 if (!$owner || $owner['account_removed'] || $owner['account_expired']) {
83 throw new HTTPException\NotFoundException($this->t('User not found.'));
86 $this->owner = $owner;
89 protected function post(array $request = [])
91 if ($this->session->getLocalUserId() != $this->owner['uid']) {
92 throw new HTTPException\ForbiddenException($this->t('Permission denied.'));
95 $str_contact_allow = isset($request['contact_allow']) ? $this->aclFormatter->toString($request['contact_allow']) : $this->owner['allow_cid'] ?? '';
96 $str_group_allow = isset($request['group_allow']) ? $this->aclFormatter->toString($request['group_allow']) : $this->owner['allow_gid'] ?? '';
97 $str_contact_deny = isset($request['contact_deny']) ? $this->aclFormatter->toString($request['contact_deny']) : $this->owner['deny_cid'] ?? '';
98 $str_group_deny = isset($request['group_deny']) ? $this->aclFormatter->toString($request['group_deny']) : $this->owner['deny_gid'] ?? '';
100 $visibility = $request['visibility'] ?? '';
101 if ($visibility === 'public') {
102 // The ACL selector introduced in version 2019.12 sends ACL input data even when the Public visibility is selected
103 $str_contact_allow = $str_group_allow = $str_contact_deny = $str_group_deny = '';
104 } else if ($visibility === 'custom') {
105 // Since we know from the visibility parameter the item should be private, we have to prevent the empty ACL
106 // case that would make it public. So we always append the author's contact id to the allowed contacts.
107 // See https://github.com/friendica/friendica/issues/9672
108 $str_contact_allow .= $this->aclFormatter->toString(Contact::getPublicIdByUserId($this->owner['uid']));
111 // default post action - upload a photo
112 Hook::callAll('photo_post_init', $request);
114 // Determine the album to use
115 $album = trim($request['album'] ?? '');
116 $newalbum = trim($request['newalbum'] ?? '');
118 $this->logger->debug('album= ' . $album . ' newalbum= ' . $newalbum);
120 $album = $album ?: $newalbum ?: DateTimeFormat::localNow('Y');
123 * We create a wall item for every photo, but we don't want to
124 * overwhelm the data stream with a hundred newly uploaded photos.
125 * So we will make the first photo uploaded to this album in the last several hours
126 * visible by default, the rest will become visible over time when and if
127 * they acquire comments, likes, dislikes, and/or tags
130 $r = Photo::selectToArray([], ['`album` = ? AND `uid` = ? AND `created` > ?', $album, $this->owner['uid'], DateTimeFormat::utc('now - 3 hours')]);
131 if (!$r || ($album == $this->t(Photo::PROFILE_PHOTOS))) {
137 if (!empty($request['not_visible']) && $request['not_visible'] !== 'false') {
141 $ret = ['src' => '', 'filename' => '', 'filesize' => 0, 'type' => ''];
143 Hook::callAll('photo_post_file', $ret);
145 if (!empty($ret['src']) && !empty($ret['filesize'])) {
147 $filename = $ret['filename'];
148 $filesize = $ret['filesize'];
149 $type = $ret['type'];
150 $error = UPLOAD_ERR_OK;
151 } elseif (!empty($_FILES['userfile'])) {
152 $src = $_FILES['userfile']['tmp_name'];
153 $filename = basename($_FILES['userfile']['name']);
154 $filesize = intval($_FILES['userfile']['size']);
155 $type = $_FILES['userfile']['type'];
156 $error = $_FILES['userfile']['error'];
158 $error = UPLOAD_ERR_NO_FILE;
161 if ($error !== UPLOAD_ERR_OK) {
163 case UPLOAD_ERR_INI_SIZE:
164 $this->systemMessages->addNotice($this->t('Image exceeds size limit of %s', ini_get('upload_max_filesize')));
166 case UPLOAD_ERR_FORM_SIZE:
167 $this->systemMessages->addNotice($this->t('Image exceeds size limit of %s', Strings::formatBytes($request['MAX_FILE_SIZE'] ?? 0)));
169 case UPLOAD_ERR_PARTIAL:
170 $this->systemMessages->addNotice($this->t('Image upload didn\'t complete, please try again'));
172 case UPLOAD_ERR_NO_FILE:
173 $this->systemMessages->addNotice($this->t('Image file is missing'));
175 case UPLOAD_ERR_NO_TMP_DIR:
176 case UPLOAD_ERR_CANT_WRITE:
177 case UPLOAD_ERR_EXTENSION:
178 $this->systemMessages->addNotice($this->t('Server can\'t accept new file upload at this time, please contact your administrator'));
183 Hook::callAll('photo_post_end', $foo);
187 $type = Images::getMimeTypeBySource($src, $filename, $type);
189 $this->logger->info('photos: upload: received file: ' . $filename . ' as ' . $src . ' ('. $type . ') ' . $filesize . ' bytes');
191 $maximagesize = Strings::getBytesFromShorthand($this->config->get('system', 'maximagesize'));
193 if ($maximagesize && ($filesize > $maximagesize)) {
194 $this->systemMessages->addNotice($this->t('Image exceeds size limit of %s', Strings::formatBytes($maximagesize)));
197 Hook::callAll('photo_post_end', $foo);
202 $this->systemMessages->addNotice($this->t('Image file is empty.'));
205 Hook::callAll('photo_post_end', $foo);
209 $this->logger->debug('loading contents', ['src' => $src]);
211 $imagedata = @file_get_contents($src);
213 $image = new Image($imagedata, $type);
215 if (!$image->isValid()) {
216 $this->logger->notice('unable to process image');
217 $this->systemMessages->addNotice($this->t('Unable to process image.'));
220 Hook::callAll('photo_post_end',$foo);
224 $exif = $image->orient($src);
227 $max_length = $this->config->get('system', 'max_image_length');
228 if ($max_length > 0) {
229 $image->scaleDown($max_length);
232 $width = $image->getWidth();
233 $height = $image->getHeight();
237 $resource_id = Photo::newResource();
239 $r = Photo::store($image, $this->owner['uid'], 0, $resource_id, $filename, $album, 0 , Photo::DEFAULT, $str_contact_allow, $str_group_allow, $str_contact_deny, $str_group_deny);
242 $this->logger->warning('image store failed');
243 $this->systemMessages->addNotice($this->t('Image upload failed.'));
247 if ($width > 640 || $height > 640) {
248 $image->scaleDown(640);
249 Photo::store($image, $this->owner['uid'], 0, $resource_id, $filename, $album, 1, Photo::DEFAULT, $str_contact_allow, $str_group_allow, $str_contact_deny, $str_group_deny);
253 if ($width > 320 || $height > 320) {
254 $image->scaleDown(320);
255 Photo::store($image, $this->owner['uid'], 0, $resource_id, $filename, $album, 2, Photo::DEFAULT, $str_contact_allow, $str_group_allow, $str_contact_deny, $str_group_deny);
259 $uri = Item::newURI();
261 // Create item container
263 if (!empty($exif['GPS']) && Feature::isEnabled($this->owner['uid'], 'photo_location')) {
264 $lat = Photo::getGps($exif['GPS']['GPSLatitude'], $exif['GPS']['GPSLatitudeRef']);
265 $lon = Photo::getGps($exif['GPS']['GPSLongitude'], $exif['GPS']['GPSLongitudeRef']);
270 $arr['coord'] = $lat . ' ' . $lon;
273 $arr['guid'] = System::createUUID();
274 $arr['uid'] = $this->owner['uid'];
276 $arr['post-type'] = Item::PT_IMAGE;
278 $arr['resource-id'] = $resource_id;
279 $arr['contact-id'] = $this->owner['id'];
280 $arr['owner-name'] = $this->owner['name'];
281 $arr['owner-link'] = $this->owner['url'];
282 $arr['owner-avatar'] = $this->owner['thumb'];
283 $arr['author-name'] = $this->owner['name'];
284 $arr['author-link'] = $this->owner['url'];
285 $arr['author-avatar'] = $this->owner['thumb'];
287 $arr['allow_cid'] = $str_contact_allow;
288 $arr['allow_gid'] = $str_group_allow;
289 $arr['deny_cid'] = $str_contact_deny;
290 $arr['deny_gid'] = $str_group_deny;
291 $arr['visible'] = $visible;
294 $arr['body'] = '[url=' . $this->baseUrl . '/photos/' . $this->owner['nickname'] . '/image/' . $resource_id . ']'
295 . '[img]' . $this->baseUrl . "/photo/{$resource_id}-{$smallest}.".$image->getExt() . '[/img]'
298 $item_id = Item::insert($arr);
299 // Update the photo albums cache
300 Photo::clearAlbumCache($this->owner['uid']);
302 Hook::callAll('photo_post_end', $item_id);
304 // addon uploaders should call "exit()" within the photo_post_end hook
305 // if they do not wish to be redirected
307 $this->baseUrl->redirect($this->session->get('photo_return') ?? 'profile/' . $this->owner['nickname'] . '/photos');
310 protected function content(array $request = []): string
312 parent::content($request);
314 if ($this->config->get('system', 'block_public') && !$this->session->isAuthenticated()) {
315 throw new HttpException\ForbiddenException($this->t('Public access denied.'));
318 $owner_uid = $this->owner['uid'];
319 $is_owner = $this->session->getLocalUserId() == $owner_uid;
321 $remote_contact = false;
322 if ($this->session->getRemoteContactID($owner_uid)) {
323 $contact_id = $this->session->getRemoteContactID($owner_uid);
325 $contact = Contact::getContactForUser($contact_id, $owner_uid, ['blocked', 'pending']);
326 $remote_contact = $contact && !$contact['blocked'] && !$contact['pending'];
329 if ($this->owner['hidewall'] && !$this->session->isAuthenticated()) {
330 $this->baseUrl->redirect('profile/' . $owner['nickname'] . '/restricted');
333 $this->session->set('photo_return', $this->args->getCommand());
335 $sql_extra = Security::getPermissionsSQLByUserId($owner_uid);
337 $photo = $this->database->toArray($this->database->p(
338 "SELECT COUNT(DISTINCT `resource-id`) AS `count`
346 $total = $photo[0]['count'];
348 $pager = new Pager($this->l10n, $this->args->getQueryString(), 20);
350 $photos = $this->database->toArray($this->database->p(
353 ANY_VALUE(`id`) AS `id`,
354 ANY_VALUE(`filename`) AS `filename`,
355 ANY_VALUE(`type`) AS `type`,
356 ANY_VALUE(`album`) AS `album`,
357 max(`scale`) AS `scale`,
358 ANY_VALUE(`created`) AS `created`
363 GROUP BY `resource-id`
364 ORDER BY `created` DESC
369 $pager->getItemsPerPage()
372 $phototypes = Images::supportedTypes();
374 $photos = array_map(function ($photo) use ($phototypes) {
376 'id' => $photo['id'],
377 'link' => 'photos/' . $this->owner['nickname'] . '/image/' . $photo['resource-id'],
378 'title' => $this->t('View Photo'),
379 'src' => 'photo/' . $photo['resource-id'] . '-' . ((($photo['scale']) == 6) ? 4 : $photo['scale']) . '.' . $phototypes[$photo['type']],
380 'alt' => $photo['filename'],
382 'link' => 'photos/' . $this->owner['nickname'] . '/album/' . bin2hex($photo['album']),
383 'name' => $photo['album'],
384 'alt' => $this->t('View Album'),
389 $tpl = Renderer::getMarkupTemplate('photos_head.tpl');
390 $this->page['htmlhead'] .= Renderer::replaceMacros($tpl, [
391 '$ispublic' => $this->t('everybody')
394 if ($albums = Photo::getAlbums($this->owner['uid'])) {
395 $albums = array_map(function ($album) {
397 'text' => $album['album'],
398 'total' => $album['total'],
399 'url' => 'photos/' . $this->owner['nickname'] . '/album/' . bin2hex($album['album']),
400 'urlencode' => urlencode($album['album']),
401 'bin2hex' => bin2hex($album['album'])
405 $photo_albums_widget = Renderer::replaceMacros(Renderer::getMarkupTemplate('photo_albums.tpl'), [
406 '$nick' => $this->owner['nickname'],
407 '$title' => $this->t('Photo Albums'),
408 '$recent' => $this->t('Recent Photos'),
409 '$albums' => $albums,
410 '$upload' => [$this->t('Upload New Photos'), 'photos/' . $this->owner['nickname'] . '/upload'],
411 '$can_post' => $this->session->getLocalUserId() && $this->owner['uid'] == $this->session->getLocalUserId(),
415 if (!empty($photo_albums_widget)) {
416 $this->page['aside'] .= $photo_albums_widget;
419 $o = self::getTabsHTML('photos', $is_owner, $this->owner['nickname'], Profile::getByUID($this->owner['uid'])['hide-friends'] ?? false);
421 $tpl = Renderer::getMarkupTemplate('photos_recent.tpl');
422 $o .= Renderer::replaceMacros($tpl, [
423 '$title' => $this->t('Recent Photos'),
424 '$can_post' => $is_owner,
425 '$upload' => [$this->t('Upload New Photos'), 'photos/' . $this->owner['nickname'] . '/upload'],
426 '$photos' => $photos,
427 '$paginate' => $pager->renderFull($total),