3 * @copyright Copyright (C) 2010-2023, 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'] ?? '', false);
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 $resource_id = Photo::newResource();
234 $smallest = Photo::storeWithPreview($image, $this->owner['uid'], $resource_id, $filename, $filesize, $album, '', $str_contact_allow, $str_group_allow, $str_contact_deny, $str_group_deny);
236 $this->logger->warning('image store failed');
237 $this->systemMessages->addNotice($this->t('Image upload failed.'));
241 $uri = Item::newURI();
243 // Create item container
245 if (!empty($exif['GPS']) && Feature::isEnabled($this->owner['uid'], 'photo_location')) {
246 $lat = Photo::getGps($exif['GPS']['GPSLatitude'], $exif['GPS']['GPSLatitudeRef']);
247 $lon = Photo::getGps($exif['GPS']['GPSLongitude'], $exif['GPS']['GPSLongitudeRef']);
252 $arr['coord'] = $lat . ' ' . $lon;
255 $arr['guid'] = System::createUUID();
256 $arr['uid'] = $this->owner['uid'];
258 $arr['post-type'] = Item::PT_IMAGE;
260 $arr['resource-id'] = $resource_id;
261 $arr['contact-id'] = $this->owner['id'];
262 $arr['owner-name'] = $this->owner['name'];
263 $arr['owner-link'] = $this->owner['url'];
264 $arr['owner-avatar'] = $this->owner['thumb'];
265 $arr['author-name'] = $this->owner['name'];
266 $arr['author-link'] = $this->owner['url'];
267 $arr['author-avatar'] = $this->owner['thumb'];
269 $arr['allow_cid'] = $str_contact_allow;
270 $arr['allow_gid'] = $str_group_allow;
271 $arr['deny_cid'] = $str_contact_deny;
272 $arr['deny_gid'] = $str_group_deny;
273 $arr['visible'] = $visible;
276 $arr['body'] = '[url=' . $this->baseUrl . '/photos/' . $this->owner['nickname'] . '/image/' . $resource_id . ']'
277 . '[img]' . $this->baseUrl . "/photo/{$resource_id}-{$smallest}.".$image->getExt() . '[/img]'
280 $item_id = Item::insert($arr);
281 // Update the photo albums cache
282 Photo::clearAlbumCache($this->owner['uid']);
284 Hook::callAll('photo_post_end', $item_id);
286 // addon uploaders should call "exit()" within the photo_post_end hook
287 // if they do not wish to be redirected
289 $this->baseUrl->redirect($this->session->get('photo_return') ?? 'profile/' . $this->owner['nickname'] . '/photos');
292 protected function content(array $request = []): string
294 parent::content($request);
296 if ($this->config->get('system', 'block_public') && !$this->session->isAuthenticated()) {
297 throw new HttpException\ForbiddenException($this->t('Public access denied.'));
300 $owner_uid = $this->owner['uid'];
301 $is_owner = $this->session->getLocalUserId() == $owner_uid;
303 if ($this->owner['hidewall'] && !$this->session->isAuthenticated()) {
304 $this->baseUrl->redirect('profile/' . $this->owner['nickname'] . '/restricted');
307 $this->session->set('photo_return', $this->args->getCommand());
309 $sql_extra = Security::getPermissionsSQLByUserId($owner_uid);
311 $photo = $this->database->toArray($this->database->p(
312 "SELECT COUNT(DISTINCT `resource-id`) AS `count`
320 $total = $photo[0]['count'];
322 $pager = new Pager($this->l10n, $this->args->getQueryString(), 20);
324 $photos = $this->database->toArray($this->database->p(
327 ANY_VALUE(`id`) AS `id`,
328 ANY_VALUE(`filename`) AS `filename`,
329 ANY_VALUE(`type`) AS `type`,
330 ANY_VALUE(`album`) AS `album`,
331 max(`scale`) AS `scale`,
332 ANY_VALUE(`created`) AS `created`
337 GROUP BY `resource-id`
338 ORDER BY `created` DESC
343 $pager->getItemsPerPage()
346 $phototypes = Images::supportedTypes();
348 $photos = array_map(function ($photo) use ($phototypes) {
350 'id' => $photo['id'],
351 'link' => 'photos/' . $this->owner['nickname'] . '/image/' . $photo['resource-id'],
352 'title' => $this->t('View Photo'),
353 'src' => 'photo/' . $photo['resource-id'] . '-' . ((($photo['scale']) == 6) ? 4 : $photo['scale']) . '.' . $phototypes[$photo['type']],
354 'alt' => $photo['filename'],
356 'link' => 'photos/' . $this->owner['nickname'] . '/album/' . bin2hex($photo['album']),
357 'name' => $photo['album'],
358 'alt' => $this->t('View Album'),
363 $tpl = Renderer::getMarkupTemplate('photos_head.tpl');
364 $this->page['htmlhead'] .= Renderer::replaceMacros($tpl, [
365 '$ispublic' => $this->t('everybody')
368 if ($albums = Photo::getAlbums($this->owner['uid'])) {
369 $albums = array_map(function ($album) {
371 'text' => $album['album'],
372 'total' => $album['total'],
373 'url' => 'photos/' . $this->owner['nickname'] . '/album/' . bin2hex($album['album']),
374 'urlencode' => urlencode($album['album']),
375 'bin2hex' => bin2hex($album['album'])
379 $photo_albums_widget = Renderer::replaceMacros(Renderer::getMarkupTemplate('photo_albums.tpl'), [
380 '$nick' => $this->owner['nickname'],
381 '$title' => $this->t('Photo Albums'),
382 '$recent' => $this->t('Recent Photos'),
383 '$albums' => $albums,
384 '$upload' => [$this->t('Upload New Photos'), 'photos/' . $this->owner['nickname'] . '/upload'],
385 '$can_post' => $this->session->getLocalUserId() && $this->owner['uid'] == $this->session->getLocalUserId(),
389 // Removing vCard for owner
391 $this->page['aside'] = '';
394 if (!empty($photo_albums_widget)) {
395 $this->page['aside'] .= $photo_albums_widget;
398 $o = self::getTabsHTML('photos', $is_owner, $this->owner['nickname'], Profile::getByUID($this->owner['uid'])['hide-friends'] ?? false);
400 $tpl = Renderer::getMarkupTemplate('photos_recent.tpl');
401 $o .= Renderer::replaceMacros($tpl, [
402 '$title' => $this->t('Recent Photos'),
403 '$can_post' => $is_owner,
404 '$upload' => [$this->t('Upload New Photos'), 'photos/' . $this->owner['nickname'] . '/upload'],
405 '$photos' => $photos,
406 '$paginate' => $pager->renderFull($total),