diff --git a/src/applications/auth/controller/PhabricatorAuthRegisterController.php b/src/applications/auth/controller/PhabricatorAuthRegisterController.php index 6c40375227..d27a644480 100644 --- a/src/applications/auth/controller/PhabricatorAuthRegisterController.php +++ b/src/applications/auth/controller/PhabricatorAuthRegisterController.php @@ -1,665 +1,657 @@ accountKey = idx($data, 'akey'); } public function processRequest() { $request = $this->getRequest(); if ($request->getUser()->isLoggedIn()) { return $this->renderError(pht('You are already logged in.')); } $is_setup = false; if (strlen($this->accountKey)) { $result = $this->loadAccountForRegistrationOrLinking($this->accountKey); list($account, $provider, $response) = $result; $is_default = false; } else if ($this->isFirstTimeSetup()) { list($account, $provider, $response) = $this->loadSetupAccount(); $is_default = true; $is_setup = true; } else { list($account, $provider, $response) = $this->loadDefaultAccount(); $is_default = true; } if ($response) { return $response; } $invite = $this->loadInvite(); if (!$provider->shouldAllowRegistration()) { if ($invite) { // If the user has an invite, we allow them to register with any // provider, even a login-only provider. } else { // TODO: This is a routine error if you click "Login" on an external // auth source which doesn't allow registration. The error should be // more tailored. return $this->renderError( pht( 'The account you are attempting to register with uses an '. 'authentication provider ("%s") which does not allow '. 'registration. An administrator may have recently disabled '. 'registration with this provider.', $provider->getProviderName())); } } $user = new PhabricatorUser(); $default_username = $account->getUsername(); $default_realname = $account->getRealName(); $default_email = $account->getEmail(); if ($invite) { $default_email = $invite->getEmailAddress(); } if (!PhabricatorUserEmail::isValidAddress($default_email)) { $default_email = null; } if ($default_email !== null) { // We should bypass policy here becase e.g. limiting an application use // to a subset of users should not allow the others to overwrite // configured application emails $application_email = id(new PhabricatorMetaMTAApplicationEmailQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withAddresses(array($default_email)) ->executeOne(); if ($application_email) { $default_email = null; } } if ($default_email !== null) { // If the account source provided an email, but it's not allowed by // the configuration, roadblock the user. Previously, we let the user // pick a valid email address instead, but this does not align well with // user expectation and it's not clear the cases it enables are valuable. // See discussion in T3472. if (!PhabricatorUserEmail::isAllowedAddress($default_email)) { return $this->renderError( array( pht( 'The account you are attempting to register with has an invalid '. 'email address (%s). This Phabricator install only allows '. 'registration with specific email addresses:', $default_email), phutil_tag('br'), phutil_tag('br'), PhabricatorUserEmail::describeAllowedAddresses(), )); } // If the account source provided an email, but another account already // has that email, just pretend we didn't get an email. // TODO: See T3472. if ($default_email !== null) { $same_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $default_email); if ($same_email) { if ($invite) { // We're allowing this to continue. The fact that we loaded the // invite means that the address is nonprimary and unverified and // we're OK to steal it. } else { $default_email = null; } } } } $profile = id(new PhabricatorRegistrationProfile()) ->setDefaultUsername($default_username) ->setDefaultEmail($default_email) ->setDefaultRealName($default_realname) ->setCanEditUsername(true) ->setCanEditEmail(($default_email === null)) ->setCanEditRealName(true) ->setShouldVerifyEmail(false); $event_type = PhabricatorEventType::TYPE_AUTH_WILLREGISTERUSER; $event_data = array( 'account' => $account, 'profile' => $profile, ); $event = id(new PhabricatorEvent($event_type, $event_data)) ->setUser($user); PhutilEventEngine::dispatchEvent($event); $default_username = $profile->getDefaultUsername(); $default_email = $profile->getDefaultEmail(); $default_realname = $profile->getDefaultRealName(); $can_edit_username = $profile->getCanEditUsername(); $can_edit_email = $profile->getCanEditEmail(); $can_edit_realname = $profile->getCanEditRealName(); $must_set_password = $provider->shouldRequireRegistrationPassword(); $can_edit_anything = $profile->getCanEditAnything() || $must_set_password; $force_verify = $profile->getShouldVerifyEmail(); // Automatically verify the administrator's email address during first-time // setup. if ($is_setup) { $force_verify = true; } $value_username = $default_username; $value_realname = $default_realname; $value_email = $default_email; $value_password = null; $errors = array(); $require_real_name = PhabricatorEnv::getEnvConfig('user.require-real-name'); $e_username = strlen($value_username) ? null : true; $e_realname = $require_real_name ? true : null; $e_email = strlen($value_email) ? null : true; $e_password = true; $e_captcha = true; $skip_captcha = false; if ($invite) { // If the user is accepting an invite, assume they're trustworthy enough // that we don't need to CAPTCHA them. $skip_captcha = true; } $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); $min_len = (int)$min_len; $from_invite = $request->getStr('invite'); if ($from_invite && $can_edit_username) { $value_username = $request->getStr('username'); $e_username = null; } if (($request->isFormPost() || !$can_edit_anything) && !$from_invite) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); if ($must_set_password && !$skip_captcha) { $e_captcha = pht('Again'); $captcha_ok = AphrontFormRecaptchaControl::processCaptcha($request); if (!$captcha_ok) { $errors[] = pht('Captcha response is incorrect, try again.'); $e_captcha = pht('Invalid'); } } if ($can_edit_username) { $value_username = $request->getStr('username'); if (!strlen($value_username)) { $e_username = pht('Required'); $errors[] = pht('Username is required.'); } else if (!PhabricatorUser::validateUsername($value_username)) { $e_username = pht('Invalid'); $errors[] = PhabricatorUser::describeValidUsername(); } else { $e_username = null; } } if ($must_set_password) { $value_password = $request->getStr('password'); $value_confirm = $request->getStr('confirm'); if (!strlen($value_password)) { $e_password = pht('Required'); $errors[] = pht('You must choose a password.'); } else if ($value_password !== $value_confirm) { $e_password = pht('No Match'); $errors[] = pht('Password and confirmation must match.'); } else if (strlen($value_password) < $min_len) { $e_password = pht('Too Short'); $errors[] = pht( 'Password is too short (must be at least %d characters long).', $min_len); } else if ( PhabricatorCommonPasswords::isCommonPassword($value_password)) { $e_password = pht('Very Weak'); $errors[] = pht( 'Password is pathologically weak. This password is one of the '. 'most common passwords in use, and is extremely easy for '. 'attackers to guess. You must choose a stronger password.'); } else { $e_password = null; } } if ($can_edit_email) { $value_email = $request->getStr('email'); if (!strlen($value_email)) { $e_email = pht('Required'); $errors[] = pht('Email is required.'); } else if (!PhabricatorUserEmail::isValidAddress($value_email)) { $e_email = pht('Invalid'); $errors[] = PhabricatorUserEmail::describeValidAddresses(); } else if (!PhabricatorUserEmail::isAllowedAddress($value_email)) { $e_email = pht('Disallowed'); $errors[] = PhabricatorUserEmail::describeAllowedAddresses(); } else { $e_email = null; } } if ($can_edit_realname) { $value_realname = $request->getStr('realName'); if (!strlen($value_realname) && $require_real_name) { $e_realname = pht('Required'); $errors[] = pht('Real name is required.'); } else { $e_realname = null; } } if (!$errors) { $image = $this->loadProfilePicture($account); if ($image) { $user->setProfileImagePHID($image->getPHID()); } try { $verify_email = false; if ($force_verify) { $verify_email = true; } if ($value_email === $default_email) { if ($account->getEmailVerified()) { $verify_email = true; } if ($provider->shouldTrustEmails()) { $verify_email = true; } if ($invite) { $verify_email = true; } } $email_obj = null; if ($invite) { // If we have a valid invite, this email may exist but be // nonprimary and unverified, so we'll reassign it. $email_obj = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $value_email); } if (!$email_obj) { $email_obj = id(new PhabricatorUserEmail()) ->setAddress($value_email); } $email_obj->setIsVerified((int)$verify_email); $user->setUsername($value_username); $user->setRealname($value_realname); if ($is_setup) { $must_approve = false; } else if ($invite) { $must_approve = false; } else { $must_approve = PhabricatorEnv::getEnvConfig( 'auth.require-approval'); } if ($must_approve) { $user->setIsApproved(0); } else { $user->setIsApproved(1); } if ($invite) { $allow_reassign_email = true; } else { $allow_reassign_email = false; } $user->openTransaction(); $editor = id(new PhabricatorUserEditor()) ->setActor($user); $editor->createNewUser($user, $email_obj, $allow_reassign_email); if ($must_set_password) { $envelope = new PhutilOpaqueEnvelope($value_password); $editor->changePassword($user, $envelope); } if ($is_setup) { $editor->makeAdminUser($user, true); } $account->setUserPHID($user->getPHID()); $provider->willRegisterAccount($account); $account->save(); $user->saveTransaction(); if (!$email_obj->getIsVerified()) { $email_obj->sendVerificationEmail($user); } if ($must_approve) { $this->sendWaitingForApprovalEmail($user); } if ($invite) { $invite->setAcceptedByPHID($user->getPHID())->save(); } return $this->loginUser($user); } catch (AphrontDuplicateKeyQueryException $exception) { $same_username = id(new PhabricatorUser())->loadOneWhere( 'userName = %s', $user->getUserName()); $same_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $value_email); if ($same_username) { $e_username = pht('Duplicate'); $errors[] = pht('Another user already has that username.'); } if ($same_email) { // TODO: See T3340. $e_email = pht('Duplicate'); $errors[] = pht('Another user already has that email.'); } if (!$same_username && !$same_email) { throw $exception; } } } unset($unguarded); } $form = id(new AphrontFormView()) ->setUser($request->getUser()); if (!$is_default) { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('External Account')) ->setValue( id(new PhabricatorAuthAccountView()) ->setUser($request->getUser()) ->setExternalAccount($account) ->setAuthProvider($provider))); } if ($can_edit_username) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Phabricator Username')) ->setName('username') ->setValue($value_username) ->setError($e_username)); } else { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Phabricator Username')) ->setValue($value_username) ->setError($e_username)); } if ($can_edit_realname) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Real Name')) ->setName('realName') ->setValue($value_realname) ->setError($e_realname)); } if ($must_set_password) { $form->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Password')) ->setName('password') ->setError($e_password)); $form->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Confirm Password')) ->setName('confirm') ->setError($e_password) ->setCaption( $min_len ? pht('Minimum length of %d characters.', $min_len) : null)); } if ($can_edit_email) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Email')) ->setName('email') ->setValue($value_email) ->setCaption(PhabricatorUserEmail::describeAllowedAddresses()) ->setError($e_email)); } if ($must_set_password && !$skip_captcha) { $form->appendChild( id(new AphrontFormRecaptchaControl()) ->setLabel(pht('Captcha')) ->setError($e_captcha)); } $submit = id(new AphrontFormSubmitControl()); if ($is_setup) { $submit ->setValue(pht('Create Admin Account')); } else { $submit ->addCancelButton($this->getApplicationURI('start/')) ->setValue(pht('Register Phabricator Account')); } $form->appendChild($submit); $crumbs = $this->buildApplicationCrumbs(); if ($is_setup) { $crumbs->addTextCrumb(pht('Setup Admin Account')); $title = pht('Welcome to Phabricator'); } else { $crumbs->addTextCrumb(pht('Register')); $crumbs->addTextCrumb($provider->getProviderName()); $title = pht('Phabricator Registration'); } $welcome_view = null; if ($is_setup) { $welcome_view = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) ->setTitle(pht('Welcome to Phabricator')) ->appendChild( pht( 'Installation is complete. Register your administrator account '. 'below to log in. You will be able to configure options and add '. 'other authentication mechanisms (like LDAP or OAuth) later on.')); } $object_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setForm($form) ->setFormErrors($errors); $invite_header = null; if ($invite) { $invite_header = $this->renderInviteHeader($invite); } return $this->buildApplicationPage( array( $crumbs, $welcome_view, $invite_header, $object_box, ), array( 'title' => $title, )); } private function loadDefaultAccount() { $providers = PhabricatorAuthProvider::getAllEnabledProviders(); $account = null; $provider = null; $response = null; foreach ($providers as $key => $candidate_provider) { if (!$candidate_provider->shouldAllowRegistration()) { unset($providers[$key]); continue; } if (!$candidate_provider->isDefaultRegistrationProvider()) { unset($providers[$key]); } } if (!$providers) { $response = $this->renderError( pht( 'There are no configured default registration providers.')); return array($account, $provider, $response); } else if (count($providers) > 1) { $response = $this->renderError( pht( 'There are too many configured default registration providers.')); return array($account, $provider, $response); } $provider = head($providers); $account = $provider->getDefaultExternalAccount(); return array($account, $provider, $response); } private function loadSetupAccount() { $provider = new PhabricatorPasswordAuthProvider(); $provider->attachProviderConfig( id(new PhabricatorAuthProviderConfig()) ->setShouldAllowRegistration(1) ->setShouldAllowLogin(1) ->setIsEnabled(true)); $account = $provider->getDefaultExternalAccount(); $response = null; return array($account, $provider, $response); } private function loadProfilePicture(PhabricatorExternalAccount $account) { $phid = $account->getProfileImagePHID(); if (!$phid) { return null; } // NOTE: Use of omnipotent user is okay here because the registering user // can not control the field value, and we can't use their user object to // do meaningful policy checks anyway since they have not registered yet. // Reaching this means the user holds the account secret key and the // registration secret key, and thus has permission to view the image. $file = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($phid)) ->executeOne(); if (!$file) { return null; } - try { - $xformer = new PhabricatorImageTransformer(); - return $xformer->executeProfileTransform( - $file, - $width = 50, - $min_height = 50, - $max_height = 50); - } catch (Exception $ex) { - phlog($ex); - return null; - } + $xform = PhabricatorFileTransform::getTransformByKey( + PhabricatorFileThumbnailTransform::TRANSFORM_PROFILE); + return $xform->executeTransform($file); } protected function renderError($message) { return $this->renderErrorPage( pht('Registration Failed'), array($message)); } private function sendWaitingForApprovalEmail(PhabricatorUser $user) { $title = '[Phabricator] '.pht( 'New User "%s" Awaiting Approval', $user->getUsername()); $body = new PhabricatorMetaMTAMailBody(); $body->addRawSection( pht( 'Newly registered user "%s" is awaiting account approval by an '. 'administrator.', $user->getUsername())); $body->addLinkSection( pht('APPROVAL QUEUE'), PhabricatorEnv::getProductionURI( '/people/query/approval/')); $body->addLinkSection( pht('DISABLE APPROVAL QUEUE'), PhabricatorEnv::getProductionURI( '/config/edit/auth.require-approval/')); $admins = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withIsAdmin(true) ->execute(); if (!$admins) { return; } $mail = id(new PhabricatorMetaMTAMail()) ->addTos(mpull($admins, 'getPHID')) ->setSubject($title) ->setBody($body->render()) ->saveAndSend(); } } diff --git a/src/applications/files/PhabricatorImageTransformer.php b/src/applications/files/PhabricatorImageTransformer.php index 34cdf67023..829f5d9f2d 100644 --- a/src/applications/files/PhabricatorImageTransformer.php +++ b/src/applications/files/PhabricatorImageTransformer.php @@ -1,561 +1,396 @@ applyMemeToFile($file, $upper_text, $lower_text); return PhabricatorFile::newFromFileData( $image, array( 'name' => 'meme-'.$file->getName(), 'ttl' => time() + 60 * 60 * 24, 'canCDN' => true, )); } - public function executeProfileTransform( - PhabricatorFile $file, - $x, - $min_y, - $max_y) { - - $image = $this->crudelyCropTo($file, $x, $min_y, $max_y); - - return PhabricatorFile::newFromFileData( - $image, - array( - 'name' => 'profile-'.$file->getName(), - 'canCDN' => true, - )); - } - public function executeConpherenceTransform( PhabricatorFile $file, $top, $left, $width, $height) { $image = $this->crasslyCropTo( $file, $top, $left, $width, $height); return PhabricatorFile::newFromFileData( $image, array( 'name' => 'conpherence-'.$file->getName(), + 'profile' => true, 'canCDN' => true, )); } - private function crudelyCropTo(PhabricatorFile $file, $x, $min_y, $max_y) { - $data = $file->loadFileData(); - $img = imagecreatefromstring($data); - $sx = imagesx($img); - $sy = imagesy($img); - - $scaled_y = ($x / $sx) * $sy; - if ($scaled_y > $max_y) { - // This image is very tall and thin. - $scaled_y = $max_y; - } else if ($scaled_y < $min_y) { - // This image is very short and wide. - $scaled_y = $min_y; - } - - $cropped = $this->applyScaleWithImagemagick($file, $x, $scaled_y); - if ($cropped != null) { - return $cropped; - } - - $img = $this->applyScaleTo( - $file, - $x, - $scaled_y); - - return self::saveImageDataInAnyFormat($img, $file->getMimeType()); - } - private function crasslyCropTo(PhabricatorFile $file, $top, $left, $w, $h) { $data = $file->loadFileData(); $src = imagecreatefromstring($data); $dst = $this->getBlankDestinationFile($w, $h); $scale = self::getScaleForCrop($file, $w, $h); $orig_x = $left / $scale; $orig_y = $top / $scale; $orig_w = $w / $scale; $orig_h = $h / $scale; imagecopyresampled( $dst, $src, 0, 0, $orig_x, $orig_y, $w, $h, $orig_w, $orig_h); return self::saveImageDataInAnyFormat($dst, $file->getMimeType()); } private function getBlankDestinationFile($dx, $dy) { $dst = imagecreatetruecolor($dx, $dy); imagesavealpha($dst, true); imagefill($dst, 0, 0, imagecolorallocatealpha($dst, 255, 255, 255, 127)); return $dst; } - private function applyScaleTo(PhabricatorFile $file, $dx, $dy) { - $data = $file->loadFileData(); - $src = imagecreatefromstring($data); - - $x = imagesx($src); - $y = imagesy($src); - - $scale = min(($dx / $x), ($dy / $y), 1); - - $sdx = $scale * $x; - $sdy = $scale * $y; - - $dst = $this->getBlankDestinationFile($dx, $dy); - imagesavealpha($dst, true); - imagefill($dst, 0, 0, imagecolorallocatealpha($dst, 255, 255, 255, 127)); - - imagecopyresampled( - $dst, - $src, - ($dx - $sdx) / 2, ($dy - $sdy) / 2, - 0, 0, - $sdx, $sdy, - $x, $y); - - return $dst; - - } - public static function getScaleForCrop( PhabricatorFile $file, $des_width, $des_height) { $metadata = $file->getMetadata(); $width = $metadata[PhabricatorFile::METADATA_IMAGE_WIDTH]; $height = $metadata[PhabricatorFile::METADATA_IMAGE_HEIGHT]; if ($height < $des_height) { $scale = $height / $des_height; } else if ($width < $des_width) { $scale = $width / $des_width; } else { $scale_x = $des_width / $width; $scale_y = $des_height / $height; $scale = max($scale_x, $scale_y); } return $scale; } private function applyMemeToFile( PhabricatorFile $file, $upper_text, $lower_text) { $data = $file->loadFileData(); $img_type = $file->getMimeType(); $imagemagick = PhabricatorEnv::getEnvConfig('files.enable-imagemagick'); if ($img_type != 'image/gif' || $imagemagick == false) { return $this->applyMemeTo( $data, $upper_text, $lower_text, $img_type); } $data = $file->loadFileData(); $input = new TempFile(); Filesystem::writeFile($input, $data); list($out) = execx('convert %s info:', $input); $split = phutil_split_lines($out); if (count($split) > 1) { return $this->applyMemeWithImagemagick( $input, $upper_text, $lower_text, count($split), $img_type); } else { return $this->applyMemeTo($data, $upper_text, $lower_text, $img_type); } } private function applyMemeTo( $data, $upper_text, $lower_text, $mime_type) { $img = imagecreatefromstring($data); // Some PNGs have color palettes, and allocating the dark border color // fails and gives us whatever's first in the color table. Copy the image // to a fresh truecolor canvas before working with it. $truecolor = imagecreatetruecolor(imagesx($img), imagesy($img)); imagecopy($truecolor, $img, 0, 0, 0, 0, imagesx($img), imagesy($img)); $img = $truecolor; $phabricator_root = dirname(phutil_get_library_root('phabricator')); $font_root = $phabricator_root.'/resources/font/'; $font_path = $font_root.'tuffy.ttf'; if (Filesystem::pathExists($font_root.'impact.ttf')) { $font_path = $font_root.'impact.ttf'; } $text_color = imagecolorallocate($img, 255, 255, 255); $border_color = imagecolorallocatealpha($img, 0, 0, 0, 110); $border_width = 4; $font_max = 200; $font_min = 5; for ($i = $font_max; $i > $font_min; $i--) { $fit = $this->doesTextBoundingBoxFitInImage( $img, $upper_text, $i, $font_path); if ($fit['doesfit']) { $x = ($fit['imgwidth'] - $fit['txtwidth']) / 2; $y = $fit['txtheight'] + 10; $this->makeImageWithTextBorder($img, $i, $x, $y, $text_color, $border_color, $border_width, $font_path, $upper_text); break; } } for ($i = $font_max; $i > $font_min; $i--) { $fit = $this->doesTextBoundingBoxFitInImage($img, $lower_text, $i, $font_path); if ($fit['doesfit']) { $x = ($fit['imgwidth'] - $fit['txtwidth']) / 2; $y = $fit['imgheight'] - 10; $this->makeImageWithTextBorder( $img, $i, $x, $y, $text_color, $border_color, $border_width, $font_path, $lower_text); break; } } return self::saveImageDataInAnyFormat($img, $mime_type); } private function makeImageWithTextBorder($img, $font_size, $x, $y, $color, $stroke_color, $bw, $font, $text) { $angle = 0; $bw = abs($bw); for ($c1 = $x - $bw; $c1 <= $x + $bw; $c1++) { for ($c2 = $y - $bw; $c2 <= $y + $bw; $c2++) { if (!(($c1 == $x - $bw || $x + $bw) && $c2 == $y - $bw || $c2 == $y + $bw)) { $bg = imagettftext($img, $font_size, $angle, $c1, $c2, $stroke_color, $font, $text); } } } imagettftext($img, $font_size, $angle, $x , $y, $color , $font, $text); } private function doesTextBoundingBoxFitInImage($img, $text, $font_size, $font_path) { // Default Angle = 0 $angle = 0; $bbox = imagettfbbox($font_size, $angle, $font_path, $text); $text_height = abs($bbox[3] - $bbox[5]); $text_width = abs($bbox[0] - $bbox[2]); return array( 'doesfit' => ($text_height * 1.05 <= imagesy($img) / 2 && $text_width * 1.05 <= imagesx($img)), 'txtwidth' => $text_width, 'txtheight' => $text_height, 'imgwidth' => imagesx($img), 'imgheight' => imagesy($img), ); } - private function applyScaleWithImagemagick(PhabricatorFile $file, $dx, $dy) { - $img_type = $file->getMimeType(); - $imagemagick = PhabricatorEnv::getEnvConfig('files.enable-imagemagick'); - - if ($img_type != 'image/gif' || $imagemagick == false) { - return null; - } - - $data = $file->loadFileData(); - $src = imagecreatefromstring($data); - - $x = imagesx($src); - $y = imagesy($src); - - if (self::isEnormousGIF($x, $y)) { - return null; - } - - $scale = min(($dx / $x), ($dy / $y), 1); - - $sdx = $scale * $x; - $sdy = $scale * $y; - - $input = new TempFile(); - Filesystem::writeFile($input, $data); - - $resized = new TempFile(); - - $future = new ExecFuture( - 'convert %s -coalesce -resize %sX%s%s %s', - $input, - $sdx, - $sdy, - '!', - $resized); - - // Don't spend more than 10 seconds resizing; just fail if it takes longer - // than that. - $future->setTimeout(10)->resolvex(); - - return Filesystem::readFile($resized); - } - private function applyMemeWithImagemagick( $input, $above, $below, $count, $img_type) { $output = new TempFile(); $future = new ExecFuture( 'convert %s -coalesce +adjoin %s_%s', $input, $input, '%09d'); $future->setTimeout(10)->resolvex(); $output_files = array(); for ($ii = 0; $ii < $count; $ii++) { $frame_name = sprintf('%s_%09d', $input, $ii); $output_name = sprintf('%s_%09d', $output, $ii); $output_files[] = $output_name; $frame_data = Filesystem::readFile($frame_name); $memed_frame_data = $this->applyMemeTo( $frame_data, $above, $below, $img_type); Filesystem::writeFile($output_name, $memed_frame_data); } $future = new ExecFuture('convert -loop 0 %Ls %s', $output_files, $output); $future->setTimeout(10)->resolvex(); return Filesystem::readFile($output); } -/* -( Detecting Enormous Files )------------------------------------------- */ - - - /** - * Determine if an image is enormous (too large to transform). - * - * Attackers can perform a denial of service attack by uploading highly - * compressible images with enormous dimensions but a very small filesize. - * Transforming them (e.g., into thumbnails) may consume huge quantities of - * memory and CPU relative to the resources required to transmit the file. - * - * In general, we respond to these images by declining to transform them, and - * using a default thumbnail instead. - * - * @param int Width of the image, in pixels. - * @param int Height of the image, in pixels. - * @return bool True if this image is enormous (too large to transform). - * @task enormous - */ - public static function isEnormousImage($x, $y) { - // This is just a sanity check, but if we don't have valid dimensions we - // shouldn't be trying to transform the file. - if (($x <= 0) || ($y <= 0)) { - return true; - } - - return ($x * $y) > (4096 * 4096); - } - - - /** - * Determine if a GIF is enormous (too large to transform). - * - * For discussion, see @{method:isEnormousImage}. We need to be more - * careful about GIFs, because they can also have a large number of frames - * despite having a very small filesize. We're more conservative about - * calling GIFs enormous than about calling images in general enormous. - * - * @param int Width of the GIF, in pixels. - * @param int Height of the GIF, in pixels. - * @return bool True if this image is enormous (too large to transform). - * @task enormous - */ - public static function isEnormousGIF($x, $y) { - if (self::isEnormousImage($x, $y)) { - return true; - } - - return ($x * $y) > (800 * 800); - } - /* -( Saving Image Data )-------------------------------------------------- */ /** * Save an image resource to a string representation suitable for storage or * transmission as an image file. * * Optionally, you can specify a preferred MIME type like `"image/png"`. * Generally, you should specify the MIME type of the original file if you're * applying file transformations. The MIME type may not be honored if * Phabricator can not encode images in the given format (based on available * extensions), but can save images in another format. * * @param resource GD image resource. * @param string? Optionally, preferred mime type. * @return string Bytes of an image file. * @task save */ public static function saveImageDataInAnyFormat($data, $preferred_mime = '') { $preferred = null; switch ($preferred_mime) { case 'image/gif': $preferred = self::saveImageDataAsGIF($data); break; case 'image/png': $preferred = self::saveImageDataAsPNG($data); break; } if ($preferred !== null) { return $preferred; } $data = self::saveImageDataAsJPG($data); if ($data !== null) { return $data; } $data = self::saveImageDataAsPNG($data); if ($data !== null) { return $data; } $data = self::saveImageDataAsGIF($data); if ($data !== null) { return $data; } throw new Exception(pht('Failed to save image data into any format.')); } /** * Save an image in PNG format, returning the file data as a string. * * @param resource GD image resource. * @return string|null PNG file as a string, or null on failure. * @task save */ private static function saveImageDataAsPNG($image) { if (!function_exists('imagepng')) { return null; } ob_start(); $result = imagepng($image, null, 9); $output = ob_get_clean(); if (!$result) { return null; } return $output; } /** * Save an image in GIF format, returning the file data as a string. * * @param resource GD image resource. * @return string|null GIF file as a string, or null on failure. * @task save */ private static function saveImageDataAsGIF($image) { if (!function_exists('imagegif')) { return null; } ob_start(); $result = imagegif($image); $output = ob_get_clean(); if (!$result) { return null; } return $output; } /** * Save an image in JPG format, returning the file data as a string. * * @param resource GD image resource. * @return string|null JPG file as a string, or null on failure. * @task save */ private static function saveImageDataAsJPG($image) { if (!function_exists('imagejpeg')) { return null; } ob_start(); $result = imagejpeg($image); $output = ob_get_clean(); if (!$result) { return null; } return $output; } } diff --git a/src/applications/files/controller/PhabricatorFileComposeController.php b/src/applications/files/controller/PhabricatorFileComposeController.php index 6903cff5d3..bb95cb67eb 100644 --- a/src/applications/files/controller/PhabricatorFileComposeController.php +++ b/src/applications/files/controller/PhabricatorFileComposeController.php @@ -1,338 +1,339 @@ getRequest(); $viewer = $request->getUser(); $colors = array( 'red' => pht('Verbillion'), 'orange' => pht('Navel Orange'), 'yellow' => pht('Prim Goldenrod'), 'green' => pht('Lustrous Verdant'), 'blue' => pht('Tropical Deep'), 'sky' => pht('Wide Open Sky'), 'indigo' => pht('Pleated Khaki'), 'violet' => pht('Aged Merlot'), 'pink' => pht('Easter Bunny'), 'charcoal' => pht('Gemstone'), 'backdrop' => pht('Driven Snow'), ); $manifest = PHUIIconView::getSheetManifest(PHUIIconView::SPRITE_PROJECTS); if ($request->isFormPost()) { $project_phid = $request->getStr('projectPHID'); if ($project_phid) { $project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withPHIDs(array($project_phid)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$project) { return new Aphront404Response(); } $icon = $project->getIcon(); $color = $project->getColor(); switch ($color) { case 'grey': $color = 'charcoal'; break; case 'checkered': $color = 'backdrop'; break; } } else { $icon = $request->getStr('icon'); $color = $request->getStr('color'); } if (!isset($colors[$color]) || !isset($manifest['projects-'.$icon])) { return new Aphront404Response(); } $root = dirname(phutil_get_library_root('phabricator')); - $icon_file = $root.'/resources/sprite/projects_1x/'.$icon.'.png'; + $icon_file = $root.'/resources/sprite/projects_2x/'.$icon.'.png'; $icon_data = Filesystem::readFile($icon_file); $data = $this->composeImage($color, $icon_data); $file = PhabricatorFile::buildFromFileDataOrHash( $data, array( 'name' => 'project.png', + 'profile' => true, 'canCDN' => true, )); if ($project_phid) { $edit_uri = '/project/profile/'.$project->getID().'/'; $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_IMAGE) ->setNewValue($file->getPHID()); $editor = id(new PhabricatorProjectTransactionEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnMissingFields(true) ->setContinueOnNoEffect(true); $editor->applyTransactions($project, $xactions); return id(new AphrontRedirectResponse())->setURI($edit_uri); } else { $content = array( 'phid' => $file->getPHID(), ); return id(new AphrontAjaxResponse())->setContent($content); } } $value_color = head_key($colors); $value_icon = head_key($manifest); $value_icon = substr($value_icon, strlen('projects-')); require_celerity_resource('people-profile-css'); $buttons = array(); foreach ($colors as $color => $name) { $buttons[] = javelin_tag( 'button', array( 'class' => 'grey profile-image-button', 'sigil' => 'has-tooltip compose-select-color', 'style' => 'margin: 0 8px 8px 0', 'meta' => array( 'color' => $color, 'tip' => $name, ), ), id(new PHUIIconView()) ->addClass('compose-background-'.$color)); } $sort_these_first = array( 'projects-fa-briefcase', 'projects-fa-tags', 'projects-fa-folder', 'projects-fa-group', 'projects-fa-bug', 'projects-fa-trash-o', 'projects-fa-calendar', 'projects-fa-flag-checkered', 'projects-fa-envelope', 'projects-fa-truck', 'projects-fa-lock', 'projects-fa-umbrella', 'projects-fa-cloud', 'projects-fa-building', 'projects-fa-credit-card', 'projects-fa-flask', ); $manifest = array_select_keys( $manifest, $sort_these_first) + $manifest; $icons = array(); $icon_quips = array( '8ball' => pht('Take a Risk'), 'alien' => pht('Foreign Interface'), 'announce' => pht('Louder is Better'), 'art' => pht('Unique Snowflake'), 'award' => pht('Shooting Star'), 'bacon' => pht('Healthy Vegetables'), 'bandaid' => pht('Durable Infrastructure'), 'beer' => pht('Healthy Vegetable Juice'), 'bomb' => pht('Imminent Success'), 'briefcase' => pht('Adventure Pack'), 'bug' => pht('Costumed Egg'), 'calendar' => pht('Everyone Loves Meetings'), 'cloud' => pht('Water Cycle'), 'coffee' => pht('Half-Whip Nonfat Soy Latte'), 'creditcard' => pht('Expense It'), 'death' => pht('Calcium Promotes Bone Health'), 'desktop' => pht('Magical Portal'), 'dropbox' => pht('Cardboard Box'), 'education' => pht('Debt'), 'experimental' => pht('CAUTION: Dangerous Chemicals'), 'facebook' => pht('Popular Social Network'), 'facility' => pht('Pollution Solves Problems'), 'film' => pht('Actual Physical Film'), 'forked' => pht('You Can\'t Eat Soup'), 'games' => pht('Serious Business'), 'ghost' => pht('Haunted'), 'gift' => pht('Surprise!'), 'globe' => pht('Scanner Sweep'), 'golf' => pht('Business Meeting'), 'heart' => pht('Undergoing a Major Surgery'), 'intergalactic' => pht('Jupiter'), 'lock' => pht('Extremely Secret'), 'mail' => pht('Oragami'), 'martini' => pht('Healthy Olive Drink'), 'medical' => pht('Medic!'), 'mobile' => pht('Cellular Telephone'), 'music' => pht("\xE2\x99\xAB"), 'news' => pht('Actual Physical Newspaper'), 'orgchart' => pht('It\'s Good to be King'), 'peoples' => pht('Angel and Devil'), 'piechart' => pht('Actual Physical Pie'), 'poison' => pht('Healthy Bone Juice'), 'putabirdonit' => pht('Put a Bird On It'), 'radiate' => pht('Radiant Beauty'), 'savings' => pht('Oink Oink'), 'search' => pht('Sleuthing'), 'shield' => pht('Royal Crest'), 'speed' => pht('Slow and Steady'), 'sprint' => pht('Fire Exit'), 'star' => pht('The More You Know'), 'storage' => pht('Stack of Pancakes'), 'tablet' => pht('Cellular Telephone For Giants'), 'travel' => pht('Pretty Clearly an Airplane'), 'twitter' => pht('Bird Stencil'), 'warning' => pht('No Caution Required, Everything Looks Safe'), 'whale' => pht('Friendly Walrus'), 'fa-flask' => pht('Experimental'), 'fa-briefcase' => pht('Briefcase'), 'fa-bug' => pht('Bug'), 'fa-building' => pht('Company'), 'fa-calendar' => pht('Deadline'), 'fa-cloud' => pht('The Cloud'), 'fa-credit-card' => pht('Accounting'), 'fa-envelope' => pht('Communication'), 'fa-flag-checkered' => pht('Goal'), 'fa-folder' => pht('Folder'), 'fa-group' => pht('Team'), 'fa-lock' => pht('Policy'), 'fa-tags' => pht('Tag'), 'fa-trash-o' => pht('Garbage'), 'fa-truck' => pht('Release'), 'fa-umbrella' => pht('An Umbrella'), ); foreach ($manifest as $icon => $spec) { $icon = substr($icon, strlen('projects-')); $icons[] = javelin_tag( 'button', array( 'class' => 'grey profile-image-button', 'sigil' => 'has-tooltip compose-select-icon', 'style' => 'margin: 0 8px 8px 0', 'meta' => array( 'icon' => $icon, 'tip' => idx($icon_quips, $icon, $icon), ), ), id(new PHUIIconView()) ->setSpriteIcon($icon) ->setSpriteSheet(PHUIIconView::SPRITE_PROJECTS)); } $dialog_id = celerity_generate_unique_node_id(); $color_input_id = celerity_generate_unique_node_id();; $icon_input_id = celerity_generate_unique_node_id(); $preview_id = celerity_generate_unique_node_id(); $preview = id(new PHUIIconView()) ->setID($preview_id) ->addClass('compose-background-'.$value_color) ->setSpriteIcon($value_icon) ->setSpriteSheet(PHUIIconView::SPRITE_PROJECTS); $color_input = javelin_tag( 'input', array( 'type' => 'hidden', 'name' => 'color', 'value' => $value_color, 'id' => $color_input_id, )); $icon_input = javelin_tag( 'input', array( 'type' => 'hidden', 'name' => 'icon', 'value' => $value_icon, 'id' => $icon_input_id, )); Javelin::initBehavior('phabricator-tooltips'); Javelin::initBehavior( 'icon-composer', array( 'dialogID' => $dialog_id, 'colorInputID' => $color_input_id, 'iconInputID' => $icon_input_id, 'previewID' => $preview_id, 'defaultColor' => $value_color, 'defaultIcon' => $value_icon, )); $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->setFormID($dialog_id) ->setClass('compose-dialog') ->setTitle(pht('Compose Image')) ->appendChild( phutil_tag( 'div', array( 'class' => 'compose-header', ), pht('Choose Background Color'))) ->appendChild($buttons) ->appendChild( phutil_tag( 'div', array( 'class' => 'compose-header', ), pht('Choose Icon'))) ->appendChild($icons) ->appendChild( phutil_tag( 'div', array( 'class' => 'compose-header', ), pht('Preview'))) ->appendChild($preview) ->appendChild($color_input) ->appendChild($icon_input) ->addCancelButton('/') ->addSubmitButton(pht('Save Image')); return id(new AphrontDialogResponse())->setDialog($dialog); } private function composeImage($color, $icon_data) { $icon_img = imagecreatefromstring($icon_data); $map = CelerityResourceTransformer::getCSSVariableMap(); $color_string = idx($map, $color, '#ff00ff'); $color_const = hexdec(trim($color_string, '#')); - $canvas = imagecreatetruecolor(50, 50); + $canvas = imagecreatetruecolor(100, 100); imagefill($canvas, 0, 0, $color_const); - imagecopy($canvas, $icon_img, 0, 0, 0, 0, 50, 50); + imagecopy($canvas, $icon_img, 0, 0, 0, 0, 100, 100); return PhabricatorImageTransformer::saveImageDataInAnyFormat( $canvas, 'image/png'); } } diff --git a/src/applications/files/controller/PhabricatorFileInfoController.php b/src/applications/files/controller/PhabricatorFileInfoController.php index fdc5a7773c..0e3d041eac 100644 --- a/src/applications/files/controller/PhabricatorFileInfoController.php +++ b/src/applications/files/controller/PhabricatorFileInfoController.php @@ -1,376 +1,382 @@ phid = idx($data, 'phid'); $this->id = idx($data, 'id'); } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); if ($this->phid) { $file = id(new PhabricatorFileQuery()) ->setViewer($user) ->withPHIDs(array($this->phid)) ->executeOne(); if (!$file) { return new Aphront404Response(); } return id(new AphrontRedirectResponse())->setURI($file->getInfoURI()); } $file = id(new PhabricatorFileQuery()) ->setViewer($user) ->withIDs(array($this->id)) ->executeOne(); if (!$file) { return new Aphront404Response(); } $phid = $file->getPHID(); $header = id(new PHUIHeaderView()) ->setUser($user) ->setPolicyObject($file) ->setHeader($file->getName()); $ttl = $file->getTTL(); if ($ttl !== null) { $ttl_tag = id(new PHUITagView()) ->setType(PHUITagView::TYPE_STATE) ->setBackgroundColor(PHUITagView::COLOR_YELLOW) ->setName(pht('Temporary')); $header->addTag($ttl_tag); } $partial = $file->getIsPartial(); if ($partial) { $partial_tag = id(new PHUITagView()) ->setType(PHUITagView::TYPE_STATE) ->setBackgroundColor(PHUITagView::COLOR_ORANGE) ->setName(pht('Partial Upload')); $header->addTag($partial_tag); } $actions = $this->buildActionView($file); $timeline = $this->buildTransactionView($file); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb( 'F'.$file->getID(), $this->getApplicationURI("/info/{$phid}/")); $object_box = id(new PHUIObjectBoxView()) ->setHeader($header); $this->buildPropertyViews($object_box, $file, $actions); return $this->buildApplicationPage( array( $crumbs, $object_box, $timeline, ), array( 'title' => $file->getName(), 'pageObjects' => array($file->getPHID()), )); } private function buildTransactionView(PhabricatorFile $file) { $user = $this->getRequest()->getUser(); $timeline = $this->buildTransactionTimeline( $file, new PhabricatorFileTransactionQuery()); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); $add_comment_header = $is_serious ? pht('Add Comment') : pht('Question File Integrity'); $draft = PhabricatorDraft::newFromUserAndKey($user, $file->getPHID()); $add_comment_form = id(new PhabricatorApplicationTransactionCommentView()) ->setUser($user) ->setObjectPHID($file->getPHID()) ->setDraft($draft) ->setHeaderText($add_comment_header) ->setAction($this->getApplicationURI('/comment/'.$file->getID().'/')) ->setSubmitButtonName(pht('Add Comment')); return array( $timeline, $add_comment_form, ); } private function buildActionView(PhabricatorFile $file) { $request = $this->getRequest(); $viewer = $request->getUser(); $id = $file->getID(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $file, PhabricatorPolicyCapability::CAN_EDIT); $view = id(new PhabricatorActionListView()) ->setUser($viewer) ->setObjectURI($this->getRequest()->getRequestURI()) ->setObject($file); $can_download = !$file->getIsPartial(); if ($file->isViewableInBrowser()) { $view->addAction( id(new PhabricatorActionView()) ->setName(pht('View File')) ->setIcon('fa-file-o') ->setHref($file->getViewURI()) ->setDisabled(!$can_download) ->setWorkflow(!$can_download)); } else { $view->addAction( id(new PhabricatorActionView()) ->setUser($viewer) ->setRenderAsForm($can_download) ->setDownload($can_download) ->setName(pht('Download File')) ->setIcon('fa-download') ->setHref($file->getViewURI()) ->setDisabled(!$can_download) ->setWorkflow(!$can_download)); } $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit File')) ->setIcon('fa-pencil') ->setHref($this->getApplicationURI("/edit/{$id}/")) ->setWorkflow(!$can_edit) ->setDisabled(!$can_edit)); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Delete File')) ->setIcon('fa-times') ->setHref($this->getApplicationURI("/delete/{$id}/")) ->setWorkflow(true) ->setDisabled(!$can_edit)); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('View Transforms')) ->setIcon('fa-crop') ->setHref($this->getApplicationURI("/transforms/{$id}/"))); return $view; } private function buildPropertyViews( PHUIObjectBoxView $box, PhabricatorFile $file, PhabricatorActionListView $actions) { $request = $this->getRequest(); $user = $request->getUser(); $properties = id(new PHUIPropertyListView()); $properties->setActionList($actions); $box->addPropertyList($properties, pht('Details')); if ($file->getAuthorPHID()) { $properties->addProperty( pht('Author'), $user->renderHandle($file->getAuthorPHID())); } $properties->addProperty( pht('Created'), phabricator_datetime($file->getDateCreated(), $user)); $finfo = id(new PHUIPropertyListView()); $box->addPropertyList($finfo, pht('File Info')); $finfo->addProperty( pht('Size'), phutil_format_bytes($file->getByteSize())); $finfo->addProperty( pht('Mime Type'), $file->getMimeType()); $width = $file->getImageWidth(); if ($width) { $finfo->addProperty( pht('Width'), pht('%s px', new PhutilNumber($width))); } $height = $file->getImageHeight(); if ($height) { $finfo->addProperty( pht('Height'), pht('%s px', new PhutilNumber($height))); } $is_image = $file->isViewableImage(); if ($is_image) { $image_string = pht('Yes'); $cache_string = $file->getCanCDN() ? pht('Yes') : pht('No'); } else { $image_string = pht('No'); $cache_string = pht('Not Applicable'); } $finfo->addProperty(pht('Viewable Image'), $image_string); $finfo->addProperty(pht('Cacheable'), $cache_string); $builtin = $file->getBuiltinName(); if ($builtin === null) { $builtin_string = pht('No'); } else { $builtin_string = $builtin; } $finfo->addProperty(pht('Builtin'), $builtin_string); + $is_profile = $file->getIsProfileImage() + ? pht('Yes') + : pht('No'); + + $finfo->addProperty(pht('Profile'), $is_profile); + $storage_properties = new PHUIPropertyListView(); $box->addPropertyList($storage_properties, pht('Storage')); $storage_properties->addProperty( pht('Engine'), $file->getStorageEngine()); $storage_properties->addProperty( pht('Format'), $file->getStorageFormat()); $storage_properties->addProperty( pht('Handle'), $file->getStorageHandle()); $phids = $file->getObjectPHIDs(); if ($phids) { $attached = new PHUIPropertyListView(); $box->addPropertyList($attached, pht('Attached')); $attached->addProperty( pht('Attached To'), $user->renderHandleList($phids)); } if ($file->isViewableImage()) { $image = phutil_tag( 'img', array( 'src' => $file->getViewURI(), 'class' => 'phui-property-list-image', )); $linked_image = phutil_tag( 'a', array( 'href' => $file->getViewURI(), ), $image); $media = id(new PHUIPropertyListView()) ->addImageContent($linked_image); $box->addPropertyList($media); } else if ($file->isAudio()) { $audio = phutil_tag( 'audio', array( 'controls' => 'controls', 'class' => 'phui-property-list-audio', ), phutil_tag( 'source', array( 'src' => $file->getViewURI(), 'type' => $file->getMimeType(), ))); $media = id(new PHUIPropertyListView()) ->addImageContent($audio); $box->addPropertyList($media); } $engine = null; try { $engine = $file->instantiateStorageEngine(); } catch (Exception $ex) { // Don't bother raising this anywhere for now. } if ($engine) { if ($engine->isChunkEngine()) { $chunkinfo = new PHUIPropertyListView(); $box->addPropertyList($chunkinfo, pht('Chunks')); $chunks = id(new PhabricatorFileChunkQuery()) ->setViewer($user) ->withChunkHandles(array($file->getStorageHandle())) ->execute(); $chunks = msort($chunks, 'getByteStart'); $rows = array(); $completed = array(); foreach ($chunks as $chunk) { $is_complete = $chunk->getDataFilePHID(); $rows[] = array( $chunk->getByteStart(), $chunk->getByteEnd(), ($is_complete ? pht('Yes') : pht('No')), ); if ($is_complete) { $completed[] = $chunk; } } $table = id(new AphrontTableView($rows)) ->setHeaders( array( pht('Offset'), pht('End'), pht('Complete'), )) ->setColumnClasses( array( '', '', 'wide', )); $chunkinfo->addProperty( pht('Total Chunks'), count($chunks)); $chunkinfo->addProperty( pht('Completed Chunks'), count($completed)); $chunkinfo->addRawContent($table); } } } } diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php index 3de1bdc9f6..34a2bb5df5 100644 --- a/src/applications/files/storage/PhabricatorFile.php +++ b/src/applications/files/storage/PhabricatorFile.php @@ -1,1368 +1,1386 @@ setViewer(PhabricatorUser::getOmnipotentUser()) ->withClasses(array('PhabricatorFilesApplication')) ->executeOne(); $view_policy = $app->getPolicy( FilesDefaultViewCapability::CAPABILITY); return id(new PhabricatorFile()) ->setViewPolicy($view_policy) ->setIsPartial(0) ->attachOriginalFile(null) ->attachObjects(array()) ->attachObjectPHIDs(array()); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'metadata' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text255?', 'mimeType' => 'text255?', 'byteSize' => 'uint64', 'storageEngine' => 'text32', 'storageFormat' => 'text32', 'storageHandle' => 'text255', 'authorPHID' => 'phid?', 'secretKey' => 'bytes20?', 'contentHash' => 'bytes40?', 'ttl' => 'epoch?', 'isExplicitUpload' => 'bool?', 'mailKey' => 'bytes20', 'isPartial' => 'bool', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'authorPHID' => array( 'columns' => array('authorPHID'), ), 'contentHash' => array( 'columns' => array('contentHash'), ), 'key_ttl' => array( 'columns' => array('ttl'), ), 'key_dateCreated' => array( 'columns' => array('dateCreated'), ), 'key_partial' => array( 'columns' => array('authorPHID', 'isPartial'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorFileFilePHIDType::TYPECONST); } public function save() { if (!$this->getSecretKey()) { $this->setSecretKey($this->generateSecretKey()); } if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); } return parent::save(); } public function getMonogram() { return 'F'.$this->getID(); } public static function readUploadedFileData($spec) { if (!$spec) { throw new Exception('No file was uploaded!'); } $err = idx($spec, 'error'); if ($err) { throw new PhabricatorFileUploadException($err); } $tmp_name = idx($spec, 'tmp_name'); $is_valid = @is_uploaded_file($tmp_name); if (!$is_valid) { throw new Exception('File is not an uploaded file.'); } $file_data = Filesystem::readFile($tmp_name); $file_size = idx($spec, 'size'); if (strlen($file_data) != $file_size) { throw new Exception('File size disagrees with uploaded size.'); } return $file_data; } public static function newFromPHPUpload($spec, array $params = array()) { $file_data = self::readUploadedFileData($spec); $file_name = nonempty( idx($params, 'name'), idx($spec, 'name')); $params = array( 'name' => $file_name, ) + $params; return self::newFromFileData($file_data, $params); } public static function newFromXHRUpload($data, array $params = array()) { return self::newFromFileData($data, $params); } /** * Given a block of data, try to load an existing file with the same content * if one exists. If it does not, build a new file. * * This method is generally used when we have some piece of semi-trusted data * like a diff or a file from a repository that we want to show to the user. * We can't just dump it out because it may be dangerous for any number of * reasons; instead, we need to serve it through the File abstraction so it * ends up on the CDN domain if one is configured and so on. However, if we * simply wrote a new file every time we'd potentially end up with a lot * of redundant data in file storage. * * To solve these problems, we use file storage as a cache and reuse the * same file again if we've previously written it. * * NOTE: This method unguards writes. * * @param string Raw file data. * @param dict Dictionary of file information. */ public static function buildFromFileDataOrHash( $data, array $params = array()) { $file = id(new PhabricatorFile())->loadOneWhere( 'name = %s AND contentHash = %s LIMIT 1', idx($params, 'name'), self::hashFileContent($data)); if (!$file) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $file = PhabricatorFile::newFromFileData($data, $params); unset($unguarded); } return $file; } public static function newFileFromContentHash($hash, array $params) { // Check to see if a file with same contentHash exist $file = id(new PhabricatorFile())->loadOneWhere( 'contentHash = %s LIMIT 1', $hash); if ($file) { // copy storageEngine, storageHandle, storageFormat $copy_of_storage_engine = $file->getStorageEngine(); $copy_of_storage_handle = $file->getStorageHandle(); $copy_of_storage_format = $file->getStorageFormat(); $copy_of_byte_size = $file->getByteSize(); $copy_of_mime_type = $file->getMimeType(); $new_file = PhabricatorFile::initializeNewFile(); $new_file->setByteSize($copy_of_byte_size); $new_file->setContentHash($hash); $new_file->setStorageEngine($copy_of_storage_engine); $new_file->setStorageHandle($copy_of_storage_handle); $new_file->setStorageFormat($copy_of_storage_format); $new_file->setMimeType($copy_of_mime_type); $new_file->copyDimensions($file); $new_file->readPropertiesFromParameters($params); $new_file->save(); return $new_file; } return $file; } public static function newChunkedFile( PhabricatorFileStorageEngine $engine, $length, array $params) { $file = PhabricatorFile::initializeNewFile(); $file->setByteSize($length); // TODO: We might be able to test the first chunk in order to figure // this out more reliably, since MIME detection usually examines headers. // However, enormous files are probably always either actually raw data // or reasonable to treat like raw data. $file->setMimeType('application/octet-stream'); $chunked_hash = idx($params, 'chunkedHash'); if ($chunked_hash) { $file->setContentHash($chunked_hash); } else { // See PhabricatorChunkedFileStorageEngine::getChunkedHash() for some // discussion of this. $seed = Filesystem::readRandomBytes(64); $hash = PhabricatorChunkedFileStorageEngine::getChunkedHashForInput( $seed); $file->setContentHash($hash); } $file->setStorageEngine($engine->getEngineIdentifier()); $file->setStorageHandle(PhabricatorFileChunk::newChunkHandle()); $file->setStorageFormat(self::STORAGE_FORMAT_RAW); $file->setIsPartial(1); $file->readPropertiesFromParameters($params); return $file; } private static function buildFromFileData($data, array $params = array()) { if (isset($params['storageEngines'])) { $engines = $params['storageEngines']; } else { $size = strlen($data); $engines = PhabricatorFileStorageEngine::loadStorageEngines($size); if (!$engines) { throw new Exception( pht( 'No configured storage engine can store this file. See '. '"Configuring File Storage" in the documentation for '. 'information on configuring storage engines.')); } } assert_instances_of($engines, 'PhabricatorFileStorageEngine'); if (!$engines) { throw new Exception(pht('No valid storage engines are available!')); } $file = PhabricatorFile::initializeNewFile(); $data_handle = null; $engine_identifier = null; $exceptions = array(); foreach ($engines as $engine) { $engine_class = get_class($engine); try { list($engine_identifier, $data_handle) = $file->writeToEngine( $engine, $data, $params); // We stored the file somewhere so stop trying to write it to other // places. break; } catch (PhabricatorFileStorageConfigurationException $ex) { // If an engine is outright misconfigured (or misimplemented), raise // that immediately since it probably needs attention. throw $ex; } catch (Exception $ex) { phlog($ex); // If an engine doesn't work, keep trying all the other valid engines // in case something else works. $exceptions[$engine_class] = $ex; } } if (!$data_handle) { throw new PhutilAggregateException( 'All storage engines failed to write file:', $exceptions); } $file->setByteSize(strlen($data)); $file->setContentHash(self::hashFileContent($data)); $file->setStorageEngine($engine_identifier); $file->setStorageHandle($data_handle); // TODO: This is probably YAGNI, but allows for us to do encryption or // compression later if we want. $file->setStorageFormat(self::STORAGE_FORMAT_RAW); $file->readPropertiesFromParameters($params); if (!$file->getMimeType()) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $data); $file->setMimeType(Filesystem::getMimeType($tmp)); } try { $file->updateDimensions(false); } catch (Exception $ex) { // Do nothing } $file->save(); return $file; } public static function newFromFileData($data, array $params = array()) { $hash = self::hashFileContent($data); $file = self::newFileFromContentHash($hash, $params); if ($file) { return $file; } return self::buildFromFileData($data, $params); } public function migrateToEngine(PhabricatorFileStorageEngine $engine) { if (!$this->getID() || !$this->getStorageHandle()) { throw new Exception( "You can not migrate a file which hasn't yet been saved."); } $data = $this->loadFileData(); $params = array( 'name' => $this->getName(), ); list($new_identifier, $new_handle) = $this->writeToEngine( $engine, $data, $params); $old_engine = $this->instantiateStorageEngine(); $old_identifier = $this->getStorageEngine(); $old_handle = $this->getStorageHandle(); $this->setStorageEngine($new_identifier); $this->setStorageHandle($new_handle); $this->save(); $this->deleteFileDataIfUnused( $old_engine, $old_identifier, $old_handle); return $this; } private function writeToEngine( PhabricatorFileStorageEngine $engine, $data, array $params) { $engine_class = get_class($engine); $data_handle = $engine->writeFile($data, $params); if (!$data_handle || strlen($data_handle) > 255) { // This indicates an improperly implemented storage engine. throw new PhabricatorFileStorageConfigurationException( "Storage engine '{$engine_class}' executed writeFile() but did ". "not return a valid handle ('{$data_handle}') to the data: it ". "must be nonempty and no longer than 255 characters."); } $engine_identifier = $engine->getEngineIdentifier(); if (!$engine_identifier || strlen($engine_identifier) > 32) { throw new PhabricatorFileStorageConfigurationException( "Storage engine '{$engine_class}' returned an improper engine ". "identifier '{$engine_identifier}': it must be nonempty ". "and no longer than 32 characters."); } return array($engine_identifier, $data_handle); } /** * Download a remote resource over HTTP and save the response body as a file. * * This method respects `security.outbound-blacklist`, and protects against * HTTP redirection (by manually following "Location" headers and verifying * each destination). It does not protect against DNS rebinding. See * discussion in T6755. */ public static function newFromFileDownload($uri, array $params = array()) { $timeout = 5; $redirects = array(); $current = $uri; while (true) { try { if (count($redirects) > 10) { throw new Exception( pht('Too many redirects trying to fetch remote URI.')); } $resolved = PhabricatorEnv::requireValidRemoteURIForFetch( $current, array( 'http', 'https', )); list($resolved_uri, $resolved_domain) = $resolved; $current = new PhutilURI($current); if ($current->getProtocol() == 'http') { // For HTTP, we can use a pre-resolved URI to defuse DNS rebinding. $fetch_uri = $resolved_uri; $fetch_host = $resolved_domain; } else { // For HTTPS, we can't: cURL won't verify the SSL certificate if // the domain has been replaced with an IP. But internal services // presumably will not have valid certificates for rebindable // domain names on attacker-controlled domains, so the DNS rebinding // attack should generally not be possible anyway. $fetch_uri = $current; $fetch_host = null; } $future = id(new HTTPSFuture($fetch_uri)) ->setFollowLocation(false) ->setTimeout($timeout); if ($fetch_host !== null) { $future->addHeader('Host', $fetch_host); } list($status, $body, $headers) = $future->resolve(); if ($status->isRedirect()) { // This is an HTTP 3XX status, so look for a "Location" header. $location = null; foreach ($headers as $header) { list($name, $value) = $header; if (phutil_utf8_strtolower($name) == 'location') { $location = $value; break; } } // HTTP 3XX status with no "Location" header, just treat this like // a normal HTTP error. if ($location === null) { throw $status; } if (isset($redirects[$location])) { throw new Exception( pht( 'Encountered loop while following redirects.')); } $redirects[$location] = $location; $current = $location; // We'll fall off the bottom and go try this URI now. } else if ($status->isError()) { // This is something other than an HTTP 2XX or HTTP 3XX status, so // just bail out. throw $status; } else { // This is HTTP 2XX, so use the the response body to save the // file data. $params = $params + array( 'name' => basename($uri), ); return self::newFromFileData($body, $params); } } catch (Exception $ex) { if ($redirects) { throw new PhutilProxyException( pht( 'Failed to fetch remote URI "%s" after following %s redirect(s) '. '(%s): %s', $uri, new PhutilNumber(count($redirects)), implode(' > ', array_keys($redirects)), $ex->getMessage()), $ex); } else { throw $ex; } } } } public static function normalizeFileName($file_name) { $pattern = "@[\\x00-\\x19#%&+!~'\$\"\/=\\\\?<> ]+@"; $file_name = preg_replace($pattern, '_', $file_name); $file_name = preg_replace('@_+@', '_', $file_name); $file_name = trim($file_name, '_'); $disallowed_filenames = array( '.' => 'dot', '..' => 'dotdot', '' => 'file', ); $file_name = idx($disallowed_filenames, $file_name, $file_name); return $file_name; } public function delete() { // We want to delete all the rows which mark this file as the transformation // of some other file (since we're getting rid of it). We also delete all // the transformations of this file, so that a user who deletes an image // doesn't need to separately hunt down and delete a bunch of thumbnails and // resizes of it. $outbound_xforms = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withTransforms( array( array( 'originalPHID' => $this->getPHID(), 'transform' => true, ), )) ->execute(); foreach ($outbound_xforms as $outbound_xform) { $outbound_xform->delete(); } $inbound_xforms = id(new PhabricatorTransformedFile())->loadAllWhere( 'transformedPHID = %s', $this->getPHID()); $this->openTransaction(); foreach ($inbound_xforms as $inbound_xform) { $inbound_xform->delete(); } $ret = parent::delete(); $this->saveTransaction(); $this->deleteFileDataIfUnused( $this->instantiateStorageEngine(), $this->getStorageEngine(), $this->getStorageHandle()); return $ret; } /** * Destroy stored file data if there are no remaining files which reference * it. */ public function deleteFileDataIfUnused( PhabricatorFileStorageEngine $engine, $engine_identifier, $handle) { // Check to see if any files are using storage. $usage = id(new PhabricatorFile())->loadAllWhere( 'storageEngine = %s AND storageHandle = %s LIMIT 1', $engine_identifier, $handle); // If there are no files using the storage, destroy the actual storage. if (!$usage) { try { $engine->deleteFile($handle); } catch (Exception $ex) { // In the worst case, we're leaving some data stranded in a storage // engine, which is not a big deal. phlog($ex); } } } public static function hashFileContent($data) { return sha1($data); } public function loadFileData() { $engine = $this->instantiateStorageEngine(); $data = $engine->readFile($this->getStorageHandle()); switch ($this->getStorageFormat()) { case self::STORAGE_FORMAT_RAW: $data = $data; break; default: throw new Exception('Unknown storage format.'); } return $data; } /** * Return an iterable which emits file content bytes. * * @param int Offset for the start of data. * @param int Offset for the end of data. * @return Iterable Iterable object which emits requested data. */ public function getFileDataIterator($begin = null, $end = null) { $engine = $this->instantiateStorageEngine(); return $engine->getFileDataIterator($this, $begin, $end); } public function getViewURI() { if (!$this->getPHID()) { throw new Exception( 'You must save a file before you can generate a view URI.'); } return $this->getCDNURI(null); } private function getCDNURI($token) { $name = self::normalizeFileName($this->getName()); $name = phutil_escape_uri($name); $parts = array(); $parts[] = 'file'; $parts[] = 'data'; // If this is an instanced install, add the instance identifier to the URI. // Instanced configurations behind a CDN may not be able to control the // request domain used by the CDN (as with AWS CloudFront). Embedding the // instance identity in the path allows us to distinguish between requests // originating from different instances but served through the same CDN. $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); if (strlen($instance)) { $parts[] = '@'.$instance; } $parts[] = $this->getSecretKey(); $parts[] = $this->getPHID(); if ($token) { $parts[] = $token; } $parts[] = $name; $path = '/'.implode('/', $parts); // If this file is only partially uploaded, we're just going to return a // local URI to make sure that Ajax works, since the page is inevitably // going to give us an error back. if ($this->getIsPartial()) { return PhabricatorEnv::getURI($path); } else { return PhabricatorEnv::getCDNURI($path); } } /** * Get the CDN URI for this file, including a one-time-use security token. * */ public function getCDNURIWithToken() { if (!$this->getPHID()) { throw new Exception( 'You must save a file before you can generate a CDN URI.'); } return $this->getCDNURI($this->generateOneTimeToken()); } public function getInfoURI() { return '/'.$this->getMonogram(); } public function getBestURI() { if ($this->isViewableInBrowser()) { return $this->getViewURI(); } else { return $this->getInfoURI(); } } public function getDownloadURI() { $uri = id(new PhutilURI($this->getViewURI())) ->setQueryParam('download', true); return (string) $uri; } public function getURIForTransform(PhabricatorFileTransform $transform) { return $this->getTransformedURI($transform->getTransformKey()); } private function getTransformedURI($transform) { $parts = array(); $parts[] = 'file'; $parts[] = 'xform'; $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); if (strlen($instance)) { $parts[] = '@'.$instance; } $parts[] = $transform; $parts[] = $this->getPHID(); $parts[] = $this->getSecretKey(); $path = implode('/', $parts); $path = $path.'/'; return PhabricatorEnv::getCDNURI($path); } public function isViewableInBrowser() { return ($this->getViewableMimeType() !== null); } public function isViewableImage() { if (!$this->isViewableInBrowser()) { return false; } $mime_map = PhabricatorEnv::getEnvConfig('files.image-mime-types'); $mime_type = $this->getMimeType(); return idx($mime_map, $mime_type); } public function isAudio() { if (!$this->isViewableInBrowser()) { return false; } $mime_map = PhabricatorEnv::getEnvConfig('files.audio-mime-types'); $mime_type = $this->getMimeType(); return idx($mime_map, $mime_type); } public function isTransformableImage() { // NOTE: The way the 'gd' extension works in PHP is that you can install it // with support for only some file types, so it might be able to handle // PNG but not JPEG. Try to generate thumbnails for whatever we can. Setup // warns you if you don't have complete support. $matches = null; $ok = preg_match( '@^image/(gif|png|jpe?g)@', $this->getViewableMimeType(), $matches); if (!$ok) { return false; } switch ($matches[1]) { case 'jpg'; case 'jpeg': return function_exists('imagejpeg'); break; case 'png': return function_exists('imagepng'); break; case 'gif': return function_exists('imagegif'); break; default: throw new Exception('Unknown type matched as image MIME type.'); } } public static function getTransformableImageFormats() { $supported = array(); if (function_exists('imagejpeg')) { $supported[] = 'jpg'; } if (function_exists('imagepng')) { $supported[] = 'png'; } if (function_exists('imagegif')) { $supported[] = 'gif'; } return $supported; } public function instantiateStorageEngine() { return self::buildEngine($this->getStorageEngine()); } public static function buildEngine($engine_identifier) { $engines = self::buildAllEngines(); foreach ($engines as $engine) { if ($engine->getEngineIdentifier() == $engine_identifier) { return $engine; } } throw new Exception( "Storage engine '{$engine_identifier}' could not be located!"); } public static function buildAllEngines() { $engines = id(new PhutilSymbolLoader()) ->setType('class') ->setConcreteOnly(true) ->setAncestorClass('PhabricatorFileStorageEngine') ->selectAndLoadSymbols(); $results = array(); foreach ($engines as $engine_class) { $results[] = newv($engine_class['name'], array()); } return $results; } public function getViewableMimeType() { $mime_map = PhabricatorEnv::getEnvConfig('files.viewable-mime-types'); $mime_type = $this->getMimeType(); $mime_parts = explode(';', $mime_type); $mime_type = trim(reset($mime_parts)); return idx($mime_map, $mime_type); } public function getDisplayIconForMimeType() { $mime_map = PhabricatorEnv::getEnvConfig('files.icon-mime-types'); $mime_type = $this->getMimeType(); return idx($mime_map, $mime_type, 'fa-file-o'); } public function validateSecretKey($key) { return ($key == $this->getSecretKey()); } public function generateSecretKey() { return Filesystem::readRandomCharacters(20); } public function updateDimensions($save = true) { if (!$this->isViewableImage()) { throw new Exception( 'This file is not a viewable image.'); } if (!function_exists('imagecreatefromstring')) { throw new Exception( 'Cannot retrieve image information.'); } $data = $this->loadFileData(); $img = imagecreatefromstring($data); if ($img === false) { throw new Exception( 'Error when decoding image.'); } $this->metadata[self::METADATA_IMAGE_WIDTH] = imagesx($img); $this->metadata[self::METADATA_IMAGE_HEIGHT] = imagesy($img); if ($save) { $this->save(); } return $this; } public function copyDimensions(PhabricatorFile $file) { $metadata = $file->getMetadata(); $width = idx($metadata, self::METADATA_IMAGE_WIDTH); if ($width) { $this->metadata[self::METADATA_IMAGE_WIDTH] = $width; } $height = idx($metadata, self::METADATA_IMAGE_HEIGHT); if ($height) { $this->metadata[self::METADATA_IMAGE_HEIGHT] = $height; } return $this; } /** * Load (or build) the {@class:PhabricatorFile} objects for builtin file * resources. The builtin mechanism allows files shipped with Phabricator * to be treated like normal files so that APIs do not need to special case * things like default images or deleted files. * * Builtins are located in `resources/builtin/` and identified by their * name. * * @param PhabricatorUser Viewing user. * @param list List of builtin file names. * @return dict Dictionary of named builtins. */ public static function loadBuiltins(PhabricatorUser $user, array $names) { $specs = array(); foreach ($names as $name) { $specs[] = array( 'originalPHID' => PhabricatorPHIDConstants::PHID_VOID, 'transform' => 'builtin:'.$name, ); } // NOTE: Anyone is allowed to access builtin files. $files = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withTransforms($specs) ->execute(); $files = mpull($files, null, 'getName'); $root = dirname(phutil_get_library_root('phabricator')); $root = $root.'/resources/builtin/'; $build = array(); foreach ($names as $name) { if (isset($files[$name])) { continue; } // This is just a sanity check to prevent loading arbitrary files. if (basename($name) != $name) { throw new Exception("Invalid builtin name '{$name}'!"); } $path = $root.$name; if (!Filesystem::pathExists($path)) { throw new Exception("Builtin '{$path}' does not exist!"); } $data = Filesystem::readFile($path); $params = array( 'name' => $name, 'ttl' => time() + (60 * 60 * 24 * 7), 'canCDN' => true, 'builtin' => $name, ); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $file = PhabricatorFile::newFromFileData($data, $params); $xform = id(new PhabricatorTransformedFile()) ->setOriginalPHID(PhabricatorPHIDConstants::PHID_VOID) ->setTransform('builtin:'.$name) ->setTransformedPHID($file->getPHID()) ->save(); unset($unguarded); $file->attachObjectPHIDs(array()); $file->attachObjects(array()); $files[$name] = $file; } return $files; } /** * Convenience wrapper for @{method:loadBuiltins}. * * @param PhabricatorUser Viewing user. * @param string Single builtin name to load. * @return PhabricatorFile Corresponding builtin file. */ public static function loadBuiltin(PhabricatorUser $user, $name) { return idx(self::loadBuiltins($user, array($name)), $name); } public function getObjects() { return $this->assertAttached($this->objects); } public function attachObjects(array $objects) { $this->objects = $objects; return $this; } public function getObjectPHIDs() { return $this->assertAttached($this->objectPHIDs); } public function attachObjectPHIDs(array $object_phids) { $this->objectPHIDs = $object_phids; return $this; } public function getOriginalFile() { return $this->assertAttached($this->originalFile); } public function attachOriginalFile(PhabricatorFile $file = null) { $this->originalFile = $file; return $this; } public function getImageHeight() { if (!$this->isViewableImage()) { return null; } return idx($this->metadata, self::METADATA_IMAGE_HEIGHT); } public function getImageWidth() { if (!$this->isViewableImage()) { return null; } return idx($this->metadata, self::METADATA_IMAGE_WIDTH); } public function getCanCDN() { if (!$this->isViewableImage()) { return false; } return idx($this->metadata, self::METADATA_CAN_CDN); } public function setCanCDN($can_cdn) { $this->metadata[self::METADATA_CAN_CDN] = $can_cdn ? 1 : 0; return $this; } public function isBuiltin() { return ($this->getBuiltinName() !== null); } public function getBuiltinName() { return idx($this->metadata, self::METADATA_BUILTIN); } public function setBuiltinName($name) { $this->metadata[self::METADATA_BUILTIN] = $name; return $this; } + public function getIsProfileImage() { + return idx($this->metadata, self::METADATA_PROFILE); + } + + public function setIsProfileImage($value) { + $this->metadata[self::METADATA_PROFILE] = $value; + return $this; + } + protected function generateOneTimeToken() { $key = Filesystem::readRandomCharacters(16); // Save the new secret. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $token = id(new PhabricatorAuthTemporaryToken()) ->setObjectPHID($this->getPHID()) ->setTokenType(self::ONETIME_TEMPORARY_TOKEN_TYPE) ->setTokenExpires(time() + phutil_units('1 hour in seconds')) ->setTokenCode(PhabricatorHash::digest($key)) ->save(); unset($unguarded); return $key; } public function validateOneTimeToken($token_code) { $token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withObjectPHIDs(array($this->getPHID())) ->withTokenTypes(array(self::ONETIME_TEMPORARY_TOKEN_TYPE)) ->withExpired(false) ->withTokenCodes(array(PhabricatorHash::digest($token_code))) ->executeOne(); return $token; } /** * Write the policy edge between this file and some object. * * @param phid Object PHID to attach to. * @return this */ public function attachToObject($phid) { $edge_type = PhabricatorObjectHasFileEdgeType::EDGECONST; id(new PhabricatorEdgeEditor()) ->addEdge($phid, $edge_type, $this->getPHID()) ->save(); return $this; } /** * Remove the policy edge between this file and some object. * * @param phid Object PHID to detach from. * @return this */ public function detachFromObject($phid) { $edge_type = PhabricatorObjectHasFileEdgeType::EDGECONST; id(new PhabricatorEdgeEditor()) ->removeEdge($phid, $edge_type, $this->getPHID()) ->save(); return $this; } /** * Configure a newly created file object according to specified parameters. * * This method is called both when creating a file from fresh data, and * when creating a new file which reuses existing storage. * * @param map Bag of parameters, see @{class:PhabricatorFile} * for documentation. * @return this */ private function readPropertiesFromParameters(array $params) { $file_name = idx($params, 'name'); $this->setName($file_name); $author_phid = idx($params, 'authorPHID'); $this->setAuthorPHID($author_phid); $file_ttl = idx($params, 'ttl'); $this->setTtl($file_ttl); $view_policy = idx($params, 'viewPolicy'); if ($view_policy) { $this->setViewPolicy($params['viewPolicy']); } $is_explicit = (idx($params, 'isExplicitUpload') ? 1 : 0); $this->setIsExplicitUpload($is_explicit); $can_cdn = idx($params, 'canCDN'); if ($can_cdn) { $this->setCanCDN(true); } $builtin = idx($params, 'builtin'); if ($builtin) { $this->setBuiltinName($builtin); } + $profile = idx($params, 'profile'); + if ($profile) { + $this->setIsProfileImage(true); + } + $mime_type = idx($params, 'mime-type'); if ($mime_type) { $this->setMimeType($mime_type); } return $this; } public function getRedirectResponse() { $uri = $this->getBestURI(); // TODO: This is a bit iffy. Sometimes, getBestURI() returns a CDN URI // (if the file is a viewable image) and sometimes a local URI (if not). // For now, just detect which one we got and configure the response // appropriately. In the long run, if this endpoint is served from a CDN // domain, we can't issue a local redirect to an info URI (which is not // present on the CDN domain). We probably never actually issue local // redirects here anyway, since we only ever transform viewable images // right now. $is_external = strlen(id(new PhutilURI($uri))->getDomain()); return id(new AphrontRedirectResponse()) ->setIsExternal($is_external) ->setURI($uri); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorFileEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorFileTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorPolicyInterface Implementation )-------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: if ($this->isBuiltin()) { return PhabricatorPolicies::getMostOpenPolicy(); } + if ($this->getIsProfileImage()) { + return PhabricatorPolicies::getMostOpenPolicy(); + } return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_NOONE; } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { $viewer_phid = $viewer->getPHID(); if ($viewer_phid) { if ($this->getAuthorPHID() == $viewer_phid) { return true; } } switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: // If you can see the file this file is a transform of, you can see // this file. if ($this->getOriginalFile()) { return true; } // If you can see any object this file is attached to, you can see // the file. return (count($this->getObjects()) > 0); } return false; } public function describeAutomaticCapability($capability) { $out = array(); $out[] = pht('The user who uploaded a file can always view and edit it.'); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $out[] = pht( 'Files attached to objects are visible to users who can view '. 'those objects.'); $out[] = pht( 'Thumbnails are visible only to users who can view the original '. 'file.'); break; } return $out; } /* -( PhabricatorSubscribableInterface Implementation )-------------------- */ public function isAutomaticallySubscribed($phid) { return ($this->authorPHID == $phid); } public function shouldShowSubscribersProperty() { return true; } public function shouldAllowSubscription($phid) { return true; } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return array( $this->getAuthorPHID(), ); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $this->saveTransaction(); } } diff --git a/src/applications/files/transform/PhabricatorFileImageTransform.php b/src/applications/files/transform/PhabricatorFileImageTransform.php index b6e6844653..6c40e6bbfa 100644 --- a/src/applications/files/transform/PhabricatorFileImageTransform.php +++ b/src/applications/files/transform/PhabricatorFileImageTransform.php @@ -1,378 +1,382 @@ |null Width and height, if available. */ public function getTransformedDimensions(PhabricatorFile $file) { return null; } public function canApplyTransform(PhabricatorFile $file) { if (!$file->isViewableImage()) { return false; } if (!$file->isTransformableImage()) { return false; } return true; } protected function willTransformFile(PhabricatorFile $file) { $this->file = $file; $this->data = null; $this->image = null; $this->imageX = null; $this->imageY = null; } + protected function getFileProperties() { + return array(); + } + protected function applyCropAndScale( $dst_w, $dst_h, $src_x, $src_y, $src_w, $src_h, $use_w, $use_h, $scale_up) { // Figure out the effective destination width, height, and offsets. $cpy_w = min($dst_w, $use_w); $cpy_h = min($dst_h, $use_h); // If we aren't scaling up, and are copying a very small source image, // we're just going to center it in the destination image. if (!$scale_up) { $cpy_w = min($cpy_w, $src_w); $cpy_h = min($cpy_h, $src_h); } $off_x = ($dst_w - $cpy_w) / 2; $off_y = ($dst_h - $cpy_h) / 2; if ($this->shouldUseImagemagick()) { $argv = array(); $argv[] = '-coalesce'; $argv[] = '-shave'; $argv[] = $src_x.'x'.$src_y; $argv[] = '-resize'; if ($scale_up) { $argv[] = $dst_w.'x'.$dst_h; } else { $argv[] = $dst_w.'x'.$dst_h.'>'; } $argv[] = '-bordercolor'; $argv[] = 'rgba(255, 255, 255, 0)'; $argv[] = '-border'; $argv[] = $off_x.'x'.$off_y; return $this->applyImagemagick($argv); } $src = $this->getImage(); $dst = $this->newEmptyImage($dst_w, $dst_h); $trap = new PhutilErrorTrap(); $ok = @imagecopyresampled( $dst, $src, $off_x, $off_y, $src_x, $src_y, $cpy_w, $cpy_h, $src_w, $src_h); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($ok === false) { throw new Exception( pht( 'Failed to imagecopyresampled() image: %s', $errors)); } $data = PhabricatorImageTransformer::saveImageDataInAnyFormat( $dst, $this->file->getMimeType()); return $this->newFileFromData($data); } protected function applyImagemagick(array $argv) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $this->getData()); $out = new TempFile(); $future = new ExecFuture('convert %s %Ls %s', $tmp, $argv, $out); // Don't spend more than 10 seconds resizing; just fail if it takes longer // than that. $future->setTimeout(10)->resolvex(); $data = Filesystem::readFile($out); return $this->newFileFromData($data); } /** * Create a new @{class:PhabricatorFile} from raw data. * * @param string Raw file data. */ protected function newFileFromData($data) { if ($this->file) { $name = $this->file->getName(); } else { $name = 'default.png'; } $name = $this->getTransformKey().'-'.$name; return PhabricatorFile::newFromFileData( $data, array( 'name' => $name, 'canCDN' => true, - )); + ) + $this->getFileProperties()); } /** * Create a new image filled with transparent pixels. * * @param int Desired image width. * @param int Desired image height. * @return resource New image resource. */ protected function newEmptyImage($w, $h) { $w = (int)$w; $h = (int)$h; if (($w <= 0) || ($h <= 0)) { throw new Exception( pht('Can not create an image with nonpositive dimensions.')); } $trap = new PhutilErrorTrap(); $img = @imagecreatetruecolor($w, $h); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($img === false) { throw new Exception( pht( 'Unable to imagecreatetruecolor() a new empty image: %s', $errors)); } $trap = new PhutilErrorTrap(); $ok = @imagesavealpha($img, true); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($ok === false) { throw new Exception( pht( 'Unable to imagesavealpha() a new empty image: %s', $errors)); } $trap = new PhutilErrorTrap(); $color = @imagecolorallocatealpha($img, 255, 255, 255, 127); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($color === false) { throw new Exception( pht( 'Unable to imagecolorallocatealpha() a new empty image: %s', $errors)); } $trap = new PhutilErrorTrap(); $ok = @imagefill($img, 0, 0, $color); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($ok === false) { throw new Exception( pht( 'Unable to imagefill() a new empty image: %s', $errors)); } return $img; } /** * Get the pixel dimensions of the image being transformed. * * @return list Width and height of the image. */ protected function getImageDimensions() { if ($this->imageX === null) { $image = $this->getImage(); $trap = new PhutilErrorTrap(); $x = @imagesx($image); $y = @imagesy($image); $errors = $trap->getErrorsAsString(); $trap->destroy(); if (($x === false) || ($y === false) || ($x <= 0) || ($y <= 0)) { throw new Exception( pht( 'Unable to determine image dimensions with '. 'imagesx()/imagesy(): %s', $errors)); } $this->imageX = $x; $this->imageY = $y; } return array($this->imageX, $this->imageY); } /** * Get the raw file data for the image being transformed. * * @return string Raw file data. */ protected function getData() { if ($this->data !== null) { return $this->data; } $file = $this->file; $max_size = (1024 * 1024 * 4); $img_size = $file->getByteSize(); if ($img_size > $max_size) { throw new Exception( pht( 'This image is too large to transform. The transform limit is %s '. 'bytes, but the image size is %s bytes.', new PhutilNumber($max_size), new PhutilNumber($img_size))); } $data = $file->loadFileData(); $this->data = $data; return $this->data; } /** * Get the GD image resource for the image being transformed. * * @return resource GD image resource. */ protected function getImage() { if ($this->image !== null) { return $this->image; } if (!function_exists('imagecreatefromstring')) { throw new Exception( pht( 'Unable to transform image: the imagecreatefromstring() function '. 'is not available. Install or enable the "gd" extension for PHP.')); } $data = $this->getData(); $data = (string)$data; // First, we're going to write the file to disk and use getimagesize() // to determine its dimensions without actually loading the pixel data // into memory. For very large images, we'll bail out. // In particular, this defuses a resource exhaustion attack where the // attacker uploads a 40,000 x 40,000 pixel PNGs of solid white. These // kinds of files compress extremely well, but require a huge amount // of memory and CPU to process. $tmp = new TempFile(); Filesystem::writeFile($tmp, $data); $tmp_path = (string)$tmp; $trap = new PhutilErrorTrap(); $info = @getimagesize($tmp_path); $errors = $trap->getErrorsAsString(); $trap->destroy(); unset($tmp); if ($info === false) { throw new Exception( pht( 'Unable to get image information with getimagesize(): %s', $errors)); } list($width, $height) = $info; if (($width <= 0) || ($height <= 0)) { throw new Exception( pht( 'Unable to determine image width and height with getimagesize().')); } $max_pixels = (4096 * 4096); $img_pixels = ($width * $height); if ($img_pixels > $max_pixels) { throw new Exception( pht( 'This image (with dimensions %spx x %spx) is too large to '. 'transform. The image has %s pixels, but transforms are limited '. 'to images with %s or fewer pixels.', new PhutilNumber($width), new PhutilNumber($height), new PhutilNumber($img_pixels), new PhutilNumber($max_pixels))); } $trap = new PhutilErrorTrap(); $image = @imagecreatefromstring($data); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($image === false) { throw new Exception( pht( 'Unable to load image data with imagecreatefromstring(): %s', $errors)); } $this->image = $image; return $this->image; } private function shouldUseImagemagick() { if (!PhabricatorEnv::getEnvConfig('files.enable-imagemagick')) { return false; } if ($this->file->getMimeType() != 'image/gif') { return false; } // Don't try to preserve the animation in huge GIFs. list($x, $y) = $this->getImageDimensions(); if (($x * $y) > (512 * 512)) { return false; } return true; } } diff --git a/src/applications/files/transform/PhabricatorFileThumbnailTransform.php b/src/applications/files/transform/PhabricatorFileThumbnailTransform.php index 328742f2e8..c7b579e836 100644 --- a/src/applications/files/transform/PhabricatorFileThumbnailTransform.php +++ b/src/applications/files/transform/PhabricatorFileThumbnailTransform.php @@ -1,214 +1,224 @@ name = $name; return $this; } public function setKey($key) { $this->key = $key; return $this; } public function setDimensions($x, $y) { $this->dstX = $x; $this->dstY = $y; return $this; } public function setScaleUp($scale) { $this->scaleUp = $scale; return $this; } public function getTransformName() { return $this->name; } public function getTransformKey() { return $this->key; } + protected function getFileProperties() { + $properties = array(); + switch ($this->key) { + case self::TRANSFORM_PROFILE: + $properties['profile'] = true; + break; + } + return $properties; + } + public function generateTransforms() { return array( id(new PhabricatorFileThumbnailTransform()) ->setName(pht("Profile (100px \xC3\x97 100px)")) ->setKey(self::TRANSFORM_PROFILE) ->setDimensions(100, 100) ->setScaleUp(true), id(new PhabricatorFileThumbnailTransform()) ->setName(pht("Pinboard (280px \xC3\x97 210px)")) ->setKey(self::TRANSFORM_PINBOARD) ->setDimensions(280, 210), id(new PhabricatorFileThumbnailTransform()) ->setName(pht('Thumbgrid (100px)')) ->setKey(self::TRANSFORM_THUMBGRID) ->setDimensions(100, null), id(new PhabricatorFileThumbnailTransform()) ->setName(pht('Preview (220px)')) ->setKey(self::TRANSFORM_PREVIEW) ->setDimensions(220, null), ); } public function applyTransform(PhabricatorFile $file) { $this->willTransformFile($file); list($src_x, $src_y) = $this->getImageDimensions(); $dst_x = $this->dstX; $dst_y = $this->dstY; $dimensions = $this->computeDimensions( $src_x, $src_y, $dst_x, $dst_y); $copy_x = $dimensions['copy_x']; $copy_y = $dimensions['copy_y']; $use_x = $dimensions['use_x']; $use_y = $dimensions['use_y']; $dst_x = $dimensions['dst_x']; $dst_y = $dimensions['dst_y']; return $this->applyCropAndScale( $dst_x, $dst_y, ($src_x - $copy_x) / 2, ($src_y - $copy_y) / 2, $copy_x, $copy_y, $use_x, $use_y, $this->scaleUp); } public function getTransformedDimensions(PhabricatorFile $file) { $dst_x = $this->dstX; $dst_y = $this->dstY; // If this is transform has fixed dimensions, we can trivially predict // the dimensions of the transformed file. if ($dst_y !== null) { return array($dst_x, $dst_y); } $src_x = $file->getImageWidth(); $src_y = $file->getImageHeight(); if (!$src_x || !$src_y) { return null; } $dimensions = $this->computeDimensions( $src_x, $src_y, $dst_x, $dst_y); return array($dimensions['dst_x'], $dimensions['dst_y']); } private function computeDimensions($src_x, $src_y, $dst_x, $dst_y) { if ($dst_y === null) { // If we only have one dimension, it represents a maximum dimension. // The other dimension of the transform is scaled appropriately, except // that we never generate images with crazily extreme aspect ratios. if ($src_x < $src_y) { // This is a tall, narrow image. Use the maximum dimension for the // height and scale the width. $use_y = $dst_x; $dst_y = $dst_x; $use_x = $dst_y * ($src_x / $src_y); $dst_x = max($dst_y / 4, $use_x); } else { // This is a short, wide image. Use the maximum dimension for the width // and scale the height. $use_x = $dst_x; $use_y = $dst_x * ($src_y / $src_x); $dst_y = max($dst_x / 4, $use_y); } // In this mode, we always copy the entire source image. We may generate // margins in the output. $copy_x = $src_x; $copy_y = $src_y; } else { $scale_up = $this->scaleUp; // Otherwise, both dimensions are fixed. Figure out how much we'd have to // scale the image down along each dimension to get the entire thing to // fit. $scale_x = ($dst_x / $src_x); $scale_y = ($dst_y / $src_y); if (!$scale_up) { $scale_x = min($scale_x, 1); $scale_y = min($scale_y, 1); } if ($scale_x > $scale_y) { // This image is relatively tall and narrow. We're going to crop off the // top and bottom. $scale = $scale_x; } else { // This image is relatively short and wide. We're going to crop off the // left and right. $scale = $scale_y; } $copy_x = $dst_x / $scale_x; $copy_y = $dst_y / $scale_x; if (!$scale_up) { $copy_x = min($src_x, $copy_x); $copy_y = min($src_y, $copy_y); } // In this mode, we always use the entire destination image. We may // crop the source input. $use_x = $dst_x; $use_y = $dst_y; } return array( 'copy_x' => $copy_x, 'copy_y' => $copy_y, 'use_x' => $use_x, 'use_y' => $use_y, 'dst_x' => $dst_x, 'dst_y' => $dst_y, ); } public function getDefaultTransform(PhabricatorFile $file) { $x = (int)$this->dstX; $y = (int)$this->dstY; $name = 'image-'.$x.'x'.nonempty($y, $x).'.png'; $root = dirname(phutil_get_library_root('phabricator')); $data = Filesystem::readFile($root.'/resources/builtin/'.$name); return $this->newFileFromData($data); } } diff --git a/src/applications/files/transform/PhabricatorFileTransform.php b/src/applications/files/transform/PhabricatorFileTransform.php index 633a80887a..caaf46920d 100644 --- a/src/applications/files/transform/PhabricatorFileTransform.php +++ b/src/applications/files/transform/PhabricatorFileTransform.php @@ -1,62 +1,74 @@ canApplyTransform($file)) { + try { + return $this->applyTransform($file); + } catch (Exception $ex) { + // Ignore. + } + } + + return $this->getDefaultTransform($file); + } + public static function getAllTransforms() { static $map; if ($map === null) { $xforms = id(new PhutilSymbolLoader()) ->setAncestorClass(__CLASS__) ->loadObjects(); $result = array(); foreach ($xforms as $xform_template) { foreach ($xform_template->generateTransforms() as $xform) { $key = $xform->getTransformKey(); if (isset($result[$key])) { throw new Exception( pht( 'Two %s objects define the same transform key ("%s"), but '. 'each transform must have a unique key.', __CLASS__, $key)); } $result[$key] = $xform; } } $map = $result; } return $map; } public static function getTransformByKey($key) { $all = self::getAllTransforms(); $xform = idx($all, $key); if (!$xform) { throw new Exception( pht( 'No file transform with key "%s" exists.', $key)); } return $xform; } } diff --git a/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php b/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php index 231181614d..0f59e23286 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php @@ -1,254 +1,251 @@ id = $data['id']; } public function processRequest() { $request = $this->getRequest(); $viewer = $request->getUser(); $user = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withIDs(array($this->id)) ->needProfileImage(true) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$user) { return new Aphront404Response(); } $profile_uri = '/p/'.$user->getUsername().'/'; $supported_formats = PhabricatorFile::getTransformableImageFormats(); $e_file = true; $errors = array(); if ($request->isFormPost()) { $phid = $request->getStr('phid'); $is_default = false; if ($phid == PhabricatorPHIDConstants::PHID_VOID) { $phid = null; $is_default = true; } else if ($phid) { $file = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($phid)) ->executeOne(); } else { if ($request->getFileExists('picture')) { $file = PhabricatorFile::newFromPHPUpload( $_FILES['picture'], array( 'authorPHID' => $viewer->getPHID(), 'canCDN' => true, )); } else { $e_file = pht('Required'); $errors[] = pht( 'You must choose a file when uploading a new profile picture.'); } } if (!$errors && !$is_default) { if (!$file->isTransformableImage()) { $e_file = pht('Not Supported'); $errors[] = pht( 'This server only supports these image formats: %s.', implode(', ', $supported_formats)); } else { - $xformer = new PhabricatorImageTransformer(); - $xformed = $xformer->executeProfileTransform( - $file, - $width = 50, - $min_height = 50, - $max_height = 50); + $xform = PhabricatorFileTransform::getTransformByKey( + PhabricatorFileThumbnailTransform::TRANSFORM_PROFILE); + $xformed = $xform->executeTransform($file); } } if (!$errors) { if ($is_default) { $user->setProfileImagePHID(null); } else { $user->setProfileImagePHID($xformed->getPHID()); $xformed->attachToObject($user->getPHID()); } $user->save(); return id(new AphrontRedirectResponse())->setURI($profile_uri); } } $title = pht('Edit Profile Picture'); $form = id(new PHUIFormLayoutView()) ->setUser($viewer); $default_image = PhabricatorFile::loadBuiltin($viewer, 'profile.png'); $images = array(); $current = $user->getProfileImagePHID(); $has_current = false; if ($current) { $files = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($current)) ->execute(); if ($files) { $file = head($files); if ($file->isTransformableImage()) { $has_current = true; $images[$current] = array( 'uri' => $file->getBestURI(), 'tip' => pht('Current Picture'), ); } } } // Try to add external account images for any associated external accounts. $accounts = id(new PhabricatorExternalAccountQuery()) ->setViewer($viewer) ->withUserPHIDs(array($user->getPHID())) ->needImages(true) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->execute(); foreach ($accounts as $account) { $file = $account->getProfileImageFile(); if ($account->getProfileImagePHID() != $file->getPHID()) { // This is a default image, just skip it. continue; } $provider = PhabricatorAuthProvider::getEnabledProviderByKey( $account->getProviderKey()); if ($provider) { $tip = pht('Picture From %s', $provider->getProviderName()); } else { $tip = pht('Picture From External Account'); } if ($file->isTransformableImage()) { $images[$file->getPHID()] = array( 'uri' => $file->getBestURI(), 'tip' => $tip, ); } } $images[PhabricatorPHIDConstants::PHID_VOID] = array( 'uri' => $default_image->getBestURI(), 'tip' => pht('Default Picture'), ); require_celerity_resource('people-profile-css'); Javelin::initBehavior('phabricator-tooltips', array()); $buttons = array(); foreach ($images as $phid => $spec) { $button = javelin_tag( 'button', array( 'class' => 'grey profile-image-button', 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => $spec['tip'], 'size' => 300, ), ), phutil_tag( 'img', array( 'height' => 50, 'width' => 50, 'src' => $spec['uri'], ))); $button = array( phutil_tag( 'input', array( 'type' => 'hidden', 'name' => 'phid', 'value' => $phid, )), $button, ); $button = phabricator_form( $viewer, array( 'class' => 'profile-image-form', 'method' => 'POST', ), $button); $buttons[] = $button; } if ($has_current) { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Current Picture')) ->setValue(array_shift($buttons))); } $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Use Picture')) ->setValue($buttons)); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setFormErrors($errors) ->setForm($form); $upload_form = id(new AphrontFormView()) ->setUser($viewer) ->setEncType('multipart/form-data') ->appendChild( id(new AphrontFormFileControl()) ->setName('picture') ->setLabel(pht('Upload Picture')) ->setError($e_file) ->setCaption( pht('Supported formats: %s', implode(', ', $supported_formats)))) ->appendChild( id(new AphrontFormSubmitControl()) ->addCancelButton($profile_uri) ->setValue(pht('Upload Picture'))); $upload_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Upload New Picture')) ->setForm($upload_form); $nav = $this->buildIconNavView($user); $nav->selectFilter('/'); $nav->appendChild($form_box); $nav->appendChild($upload_box); return $this->buildApplicationPage( $nav, array( 'title' => $title, )); } } diff --git a/src/applications/project/controller/PhabricatorProjectEditPictureController.php b/src/applications/project/controller/PhabricatorProjectEditPictureController.php index 58e345930e..ca99159718 100644 --- a/src/applications/project/controller/PhabricatorProjectEditPictureController.php +++ b/src/applications/project/controller/PhabricatorProjectEditPictureController.php @@ -1,304 +1,301 @@ id = $data['id']; } public function processRequest() { $request = $this->getRequest(); $viewer = $request->getUser(); $id = $request->getURIData('id'); $project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withIDs(array($this->id)) ->needImages(true) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$project) { return new Aphront404Response(); } $edit_uri = $this->getApplicationURI('profile/'.$project->getID().'/'); $view_uri = $this->getApplicationURI('profile/'.$project->getID().'/'); $supported_formats = PhabricatorFile::getTransformableImageFormats(); $e_file = true; $errors = array(); if ($request->isFormPost()) { $phid = $request->getStr('phid'); $is_default = false; if ($phid == PhabricatorPHIDConstants::PHID_VOID) { $phid = null; $is_default = true; } else if ($phid) { $file = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($phid)) ->executeOne(); } else { if ($request->getFileExists('picture')) { $file = PhabricatorFile::newFromPHPUpload( $_FILES['picture'], array( 'authorPHID' => $viewer->getPHID(), 'canCDN' => true, )); } else { $e_file = pht('Required'); $errors[] = pht( 'You must choose a file when uploading a new project picture.'); } } if (!$errors && !$is_default) { if (!$file->isTransformableImage()) { $e_file = pht('Not Supported'); $errors[] = pht( 'This server only supports these image formats: %s.', implode(', ', $supported_formats)); } else { - $xformer = new PhabricatorImageTransformer(); - $xformed = $xformer->executeProfileTransform( - $file, - $width = 50, - $min_height = 50, - $max_height = 50); + $xform = PhabricatorFileTransform::getTransformByKey( + PhabricatorFileThumbnailTransform::TRANSFORM_PROFILE); + $xformed = $xform->executeTransform($file); } } if (!$errors) { if ($is_default) { $new_value = null; } else { $new_value = $xformed->getPHID(); } $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_IMAGE) ->setNewValue($new_value); $editor = id(new PhabricatorProjectTransactionEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnMissingFields(true) ->setContinueOnNoEffect(true); $editor->applyTransactions($project, $xactions); return id(new AphrontRedirectResponse())->setURI($edit_uri); } } $title = pht('Edit Project Picture'); $form = id(new PHUIFormLayoutView()) ->setUser($viewer); $default_image = PhabricatorFile::loadBuiltin($viewer, 'project.png'); $images = array(); $current = $project->getProfileImagePHID(); $has_current = false; if ($current) { $files = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($current)) ->execute(); if ($files) { $file = head($files); if ($file->isTransformableImage()) { $has_current = true; $images[$current] = array( 'uri' => $file->getBestURI(), 'tip' => pht('Current Picture'), ); } } } $images[PhabricatorPHIDConstants::PHID_VOID] = array( 'uri' => $default_image->getBestURI(), 'tip' => pht('Default Picture'), ); require_celerity_resource('people-profile-css'); Javelin::initBehavior('phabricator-tooltips', array()); $buttons = array(); foreach ($images as $phid => $spec) { $button = javelin_tag( 'button', array( 'class' => 'grey profile-image-button', 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => $spec['tip'], 'size' => 300, ), ), phutil_tag( 'img', array( 'height' => 50, 'width' => 50, 'src' => $spec['uri'], ))); $button = array( phutil_tag( 'input', array( 'type' => 'hidden', 'name' => 'phid', 'value' => $phid, )), $button, ); $button = phabricator_form( $viewer, array( 'class' => 'profile-image-form', 'method' => 'POST', ), $button); $buttons[] = $button; } if ($has_current) { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Current Picture')) ->setValue(array_shift($buttons))); } $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Use Picture')) ->setValue($buttons)); $launch_id = celerity_generate_unique_node_id(); $input_id = celerity_generate_unique_node_id(); Javelin::initBehavior( 'launch-icon-composer', array( 'launchID' => $launch_id, 'inputID' => $input_id, )); $compose_button = javelin_tag( 'button', array( 'class' => 'grey', 'id' => $launch_id, 'sigil' => 'icon-composer', ), pht('Choose Icon and Color...')); $compose_input = javelin_tag( 'input', array( 'type' => 'hidden', 'id' => $input_id, 'name' => 'phid', )); $compose_form = phabricator_form( $viewer, array( 'class' => 'profile-image-form', 'method' => 'POST', ), array( $compose_input, $compose_button, )); $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Quick Create')) ->setValue($compose_form)); $default_button = javelin_tag( 'button', array( 'class' => 'grey', ), pht('Use Project Icon')); $default_input = javelin_tag( 'input', array( 'type' => 'hidden', 'name' => 'projectPHID', 'value' => $project->getPHID(), )); $default_form = phabricator_form( $viewer, array( 'class' => 'profile-image-form', 'method' => 'POST', 'action' => '/file/compose/', ), array( $default_input, $default_button, )); $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Use Default')) ->setValue($default_form)); $upload_form = id(new AphrontFormView()) ->setUser($viewer) ->setEncType('multipart/form-data') ->appendChild( id(new AphrontFormFileControl()) ->setName('picture') ->setLabel(pht('Upload Picture')) ->setError($e_file) ->setCaption( pht('Supported formats: %s', implode(', ', $supported_formats)))) ->appendChild( id(new AphrontFormSubmitControl()) ->addCancelButton($edit_uri) ->setValue(pht('Upload Picture'))); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setFormErrors($errors) ->setForm($form); $upload_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Upload New Picture')) ->setForm($upload_form); $nav = $this->buildIconNavView($project); $nav->selectFilter("edit/{$id}/"); $nav->appendChild($form_box); $nav->appendChild($upload_box); return $this->buildApplicationPage( $nav, array( 'title' => $title, )); } } diff --git a/webroot/rsrc/css/phui/phui-header-view.css b/webroot/rsrc/css/phui/phui-header-view.css index ef707191e5..5b48f722c6 100644 --- a/webroot/rsrc/css/phui/phui-header-view.css +++ b/webroot/rsrc/css/phui/phui-header-view.css @@ -1,141 +1,142 @@ /** * @provides phui-header-view-css */ .phui-header-shell { background-color: #e0e3ec; border-width: 1px 0; border-style: solid; border-color: {$hovergrey}; overflow: hidden; } body .phui-header-shell.phui-header-no-backgound { background-color: transparent; border: none; } body .phui-header-shell.phui-bleed-header { background-color: #fff; border-bottom: 1px solid {$thinblueborder}; width: auto; margin: 16px; } body .phui-header-shell.phui-bleed-header .phui-header-view { padding: 8px 24px 8px 0; color: {$bluetext}; } .phui-header-shell + .phabricator-form-view { border-top-width: 0; } .phui-property-list-view + .diviner-document-section { margin-top: -1px; } .phui-header-view { padding: 16px; font-size: 15px; color: {$darkbluetext}; position: relative; } .phui-header-view a, .phui-header-view a.simple { color: {$darkbluetext}; } .phui-header-view .phui-header-action-links { float: right; } .phui-object-box .phui-header-view .phui-header-action-links { margin-right: 12px; margin-top: 4px; } .device-phone .phui-object-box .phui-header-view .phui-header-action-links { margin-right: 4px; margin-top: -1px; } .device-phone .phui-header-action-link .phui-button-text { visibility: hidden; width: 0; margin-left: 8px; } .phui-header-divider { margin: 0 4px; font-weight: normal; color: {$lightbluetext}; } body.device-phone .phui-header-view { padding: 12px 8px; } .phui-header-tags { margin-left: 12px; font-size: 13px; } .phui-header-tags .phui-tag-view { margin-left: 4px; } .phui-header-image { display: inline-block; background-repeat: no-repeat; + background-size: 50px; border: 2px solid white; width: 50px; height: 50px; margin: 12px; float: left; border-radius: 2px; } .phui-header-subheader { font-weight: normal; font-size: 14px; margin-top: 6px; } .phui-header-subheader .phui-icon-view { display: inline-block; margin: -2px 4px -2px 0; font-size: 15px; } .phui-header-subheader, .phui-header-subheader .policy-link { color: {$darkbluetext}; } .phui-header-subheader .phui-header-status-dark { color: {$indigo}; text-shadow: 0 1px #fff; } .phui-header-subheader .phui-header-status-dark .phui-icon-view { color: {$indigo}; } .phui-header-subheader .phui-header-status-red { color: {$red}; } .phui-header-subheader .phui-header-status-green { color: {$green}; } .phui-header-action-links .phui-mobile-menu { display: none; } .device .phui-header-action-links .phui-mobile-menu { display: inline-block; } diff --git a/webroot/rsrc/css/phui/phui-timeline-view.css b/webroot/rsrc/css/phui/phui-timeline-view.css index b82c9385a7..6e956037aa 100644 --- a/webroot/rsrc/css/phui/phui-timeline-view.css +++ b/webroot/rsrc/css/phui/phui-timeline-view.css @@ -1,376 +1,377 @@ /** * @provides phui-timeline-view-css */ .phui-timeline-view { padding: 0 16px; background-image: url('/rsrc/image/BFCFDA.png'); background-repeat: repeat-y; background-position: 94px; } .device-tablet .phui-timeline-view { background-position: 31px; } .device-phone .phui-timeline-view { padding: 0; background-position: 24px; } .phui-timeline-major-event .phui-timeline-group { border-left: 1px solid {$lightblueborder}; border-right: 1px solid {$lightblueborder}; } .device-desktop .phui-timeline-event-view { margin-left: 62px; position: relative; } .device-desktop .phui-timeline-event-view.phui-timeline-minor-event { margin-left: 65px; } .device-desktop .phui-timeline-spacer { min-height: 16px; } .device-desktop .phui-timeline-event-view.the-worlds-end { background: {$lightblueborder}; width: 9px; height: 9px; border-radius: 2px; margin-left: 74px; } .device-desktop .phui-timeline-wedge { border-bottom: 1px solid {$lightblueborder}; position: absolute; width: 12px; } .device-phone .phui-timeline-minor-event, .device-tablet .phui-timeline-minor-event { padding-left: 3px; } .phui-timeline-major-event .phui-timeline-content { border-top: 1px solid {$lightblueborder}; border-bottom: 1px solid {$lightblueborder}; } .phui-timeline-title { line-height: 18px; min-height: 19px; position: relative; color: {$bluetext}; } .phui-timeline-minor-event .phui-timeline-title { padding: 4px 8px 4px 33px; } .phui-timeline-title a { font-weight: bold; color: {$darkbluetext}; } .device-desktop .phui-timeline-wedge { left: -12px; } .device-desktop .phui-timeline-major-event .phui-timeline-wedge { top: 24px; } .device-desktop .phui-timeline-minor-event .phui-timeline-wedge { top: 12px; left: -18px; width: 20px; } .phui-timeline-image { background-repeat: no-repeat; + background-size: 50px; position: absolute; border-radius: 3px; } .device-desktop .phui-timeline-major-event .phui-timeline-image { width: 50px; height: 50px; top: 0px; left: -62px; } .device-desktop .phui-timeline-minor-event .phui-timeline-image { width: 26px; height: 26px; background-size: 26px auto; left: -41px; } .phui-timeline-major-event .phui-timeline-title { background: {$lightgreybackground}; min-height: 18px; } .phui-timeline-title { padding: 5px 8px; overflow-x: auto; overflow-y: hidden; } .phui-timeline-title-with-icon { padding-left: 38px; } .phui-timeline-title-with-menu { padding-right: 36px; } .phui-timeline-view .phui-icon-view.phui-timeline-token { vertical-align: middle; margin-right: 4px; } .phui-timeline-token.strikethrough { position: relative; } .phui-timeline-token.strikethrough:before { position: absolute; content: ""; left: 0; top: 50%; right: 0; border-top: 3px solid; border-color: {$darkbluetext}; -webkit-transform:rotate(-40deg); -moz-transform:rotate(-40deg); -ms-transform:rotate(-40deg); -o-transform:rotate(-40deg); transform:rotate(-40deg); } .phui-timeline-major-event .phui-timeline-content .phui-timeline-core-content { padding: 16px 12px; line-height: 18px; background: #fff; } .phui-timeline-core-content { overflow-x: auto; } .phui-timeline-core-content .comment-deleted { font-style: italic; } .device .phui-timeline-event-view { min-height: 23px; position: relative; } .device-phone .phui-timeline-event-view { margin: 0 8px; } .device .phui-timeline-image { display: none; } .device .phui-timeline-spacer { min-height: 8px; border-width: 0; } .phui-timeline-spacer.phui-timeline-spacer-bold { border-bottom: 4px solid {$lightblueborder}; margin: 0; } .phui-timeline-spacer-bold + .phui-timeline-spacer { background-color: #ebecee; } .phui-timeline-icon-fill { position: absolute; width: 30px; height: 30px; background-color: {$lightblueborder}; top: 0; left: 0; text-align: center; } .phui-icon-view.phui-timeline-icon:before { font-size: 14px; } .phui-timeline-minor-event .phui-timeline-icon-fill { height: 26px; width: 26px; border-radius: 3px; } .phui-timeline-icon-fill .phui-timeline-icon { margin-top: 7px; } .phui-timeline-minor-event .phui-timeline-icon-fill .phui-timeline-icon { margin-top: 6px; } .phui-timeline-extra, .phui-timeline-extra .phabricator-content-source-view { font-size: 11px; font-weight: normal; color: {$lightbluetext}; } .phui-timeline-title .phui-timeline-extra a { font-weight: normal; color: {$bluetext}; } .device-desktop .phui-timeline-extra { float: right; } .device .phui-timeline-extra { display: inline-block; line-height: 16px; margin-left: 8px; white-space: nowrap; } .device-phone .phui-timeline-extra { display: block; margin: 0; } .phui-timeline-icon-fill-red { background-color: {$red}; } .phui-timeline-icon-fill-orange { background-color: {$orange}; } .phui-timeline-icon-fill-yellow { background-color: {$yellow}; } .phui-timeline-icon-fill-green { background-color: {$green}; } .phui-timeline-icon-fill-sky { background-color: {$sky}; } .phui-timeline-icon-fill-blue { background-color: {$blue}; } .phui-timeline-icon-fill-indigo { background-color: {$indigo}; } .phui-timeline-icon-fill-violet { background-color: {$violet}; } .phui-timeline-icon-fill-grey { background-color: #888; } .phui-timeline-icon-fill-black { background-color: #333; } .phui-timeline-shell.anchor-target { background: {$lightyellow}; padding: 4px; margin: -4px; } .phui-timeline-preview-header { background: #e0e3ec; color: {$darkgreytext}; padding: 4px 1.25%; border: solid {$blueborder} 1px 0; } .phui-timeline-change-details { padding: 10px 0; border-style: solid; border-color: #efefef; border-width: 1px 0; } .phui-timeline-older-transactions-are-hidden { background: {$lightyellow}; border: 1px solid {$yellow}; text-align: center; padding: 12px; color: {$darkgreytext}; cursor: pointer; } .device-phone .phui-timeline-older-transactions-are-hidden { margin: 0 8px; } .phui-timeline-title .phui-timeline-extra-information a { font-weight: normal; color: {$bluetext}; } .phui-timeline-comment-actions .phui-icon-view { width: 16px; height: 16px; font-size: 16px; text-align: center; overflow: hidden; } .phui-timeline-menu { position: absolute; right: 3px; top: 4px; width: 28px; height: 22px; text-align: center; line-height: 22px; font-size: 15px; border-left: 1px solid {$lightblueborder}; } .phui-timeline-menu:focus { outline: none; } .phui-timeline-menu .phui-icon-view { color: {$lightgreytext}; } a.phui-timeline-menu .phui-icon-view { color: {$bluetext}; } .device-desktop a.phui-timeline-menu:hover .phui-icon-view { color: {$darkgreytext}; } .phui-timeline-menu.phuix-dropdown-open { background: {$hovergrey}; } .phui-timeline-view + .phui-object-box { margin-top: 0; }