Commit 62fe2acc authored by Benni Mack's avatar Benni Mack
Browse files

[FEATURE] Add ExtensionVersion API

A new API class "ExtensionVersion" deals with a specific uploaded
version of an extension.

This class is responsible for checking sanitized versions, and serves
as entrypoint to hide the logic behind "tx_ter_extensions" (and soon "tx_ter_extensiondetails"), by also handling deletion, updating reviewstate
or uploading of new extension versions.

On top, all TYPO3_DB calls are removed from EXT:ter with this change, moving
more actual logic out of the SOAP API endpoints.

In addition, the non-SOAP-API is now using non-static calls, as all
logic is wrapped in a doUpload() method.
parent 79ab1165
Pipeline #9138 failed with stages
in 52 seconds
......@@ -61,6 +61,11 @@ class Configuration implements SingletonInterface
return $this->repositoryDirectory;
}
public function doesRepositoryBasePathExist(): bool
{
return @is_dir($this->repositoryDirectory);
}
public function getAdministratorsGroupId(): int
{
return $this->administratorsGroupId;
......
......@@ -17,6 +17,7 @@ use T3o\Ter\Exception\InternalServerErrorException;
use T3o\Ter\Exception\InvalidExtensionKeyFormatException;
use T3o\Ter\Exception\UnauthorizedException;
use T3o\Ter\Exception\UserNotFoundException;
use T3o\Ter\Exception\VersionExistsException;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -112,6 +113,20 @@ class ExtensionKey
return false;
}
/**
* Add a new extension key.
*
* @param ApiUser $authenticatedUser
* @param string $title
* @param string $description
* @param string $ownerUserName
* @return bool
* @throws ExtensionKeyAlreadyInUseException
* @throws InternalServerErrorException
* @throws InvalidExtensionKeyFormatException
* @throws UnauthorizedException
* @throws UserNotFoundException
*/
public function registerKey(
ApiUser $authenticatedUser,
string $title,
......@@ -160,6 +175,16 @@ class ExtensionKey
return true;
}
/**
* Owners or administrators can transfer the ownership of an extension key to somebody else (fe_user must exist)
*
* @param ApiUser $authenticatedUser
* @param string $newOwnerName
* @throws ExtensionKeyNotFoundException
* @throws InternalServerErrorException
* @throws UnauthorizedException
* @throws UserNotFoundException
*/
public function transferOwnership(ApiUser $authenticatedUser, string $newOwnerName): void
{
if (!$authenticatedUser->isAuthenticated()) {
......@@ -195,6 +220,30 @@ class ExtensionKey
}
}
/**
* Removes an extension key from the database, only possible if there are no versions of an extensions released.
* Only possible for owners or administrators.
*
* @param ApiUser $user
* @throws UnauthorizedException
* @throws VersionExistsException
*/
public function unregister(ApiUser $user): void
{
if (!$this->isApiUserOwnerOrAdmin($user)) {
throw new UnauthorizedException('Access denied.', ResultCodes::ERROR_DELETEEXTENSIONKEY_ACCESSDENIED);
}
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_ter_extensions');
$items = $conn->select(['*'], 'tx_ter_extensions', ['extensionkey' => $this->extensionKey])->fetchAll();
if (!empty($items)) {
throw new VersionExistsException('Cannot delete an extension, versions still exist', ResultCodes::ERROR_DELETEEXTENSIONKEY_CANTDELETEBECAUSEVERSIONSEXIST);
}
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_ter_extensionkeys');
$conn->delete('tx_ter_extensionkeys', ['extensionkey' => $this->extensionKey]);
}
/**
* Check if a user with such a name exists, and return the username from the DB (= with proper lowercase etc)
* or null, if the user does not exist.
......
<?php
declare(strict_types = 1);
namespace T3o\Ter\Api;
/*
* This file is part of TYPO3 CMS-extension "ter", created by Benni Mack.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/
use T3o\Ter\Exception\FailedDependencyException;
use T3o\Ter\Exception\InternalServerErrorException;
use T3o\Ter\Exception\NotFoundException;
use T3o\Ter\Exception\UnauthorizedException;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* API class checking against specific versions (= uploaded extensions) of an extensionkey.
*
* This class should also contain "add()" in the future to add files to the extension version.
* In addition, all checks against "tx_ter_extensions" and "tx_ter_extensiondetails" should be coupled in there,
* so no outside checks are needed anymore.
*/
class ExtensionVersion
{
/**
* @var ExtensionKey
*/
protected $extensionKey;
/**
* @var string
*/
protected $version;
public function __construct(ExtensionKey $extensionKey, string $version)
{
$this->extensionKey = $extensionKey;
$this->version = $version;
}
public function getExtensionKey(): ExtensionKey
{
return $this->extensionKey;
}
public function getVersion(): string
{
return $this->version;
}
/**
* Checks if the version number consists of 4.10.99, no checks against the database are made.
*
* @return bool
*/
public function isValidVersionNumber(): bool
{
return preg_match('/^(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})$/', $this->version) !== false;
// alternative (preg_match('/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/', $version) !== false) {
}
/**
* Checks if the version of the uploaded extension already exists in the database.
*/
public function doesExtensionVersionExist(): bool
{
return $this->getUidOfExtensionVersion() !== null;
}
/**
* Fetches the UID, currently needed because the "uid" is matched against "tx_ter_extensiondetails.extensionuid".
*
* @return int|null
*/
public function getUidOfExtensionVersion(): ?int
{
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_ter_extensions');
$record = $conn->select(['uid'], 'tx_ter_extensions', ['extensionkey' => (string)$this->extensionKey, 'version' => $this->version])->fetch();
if ($record['uid'] ?? 0) {
return (int)$record['uid'];
}
return null;
}
public function getStoragePrefix(): string
{
[$majorVersion, $minorVersion, $devVersion] = GeneralUtility::intExplode(
'.',
$this->getVersion()
);
return $this->getStorageFolder() . strtolower(((string)$this->extensionKey) . '_' . $majorVersion . '.' . $minorVersion . '.' . $devVersion);
}
protected function getStorageFolder(): string
{
$extensionKey = (string)$this->extensionKey;
$firstLetter = strtolower(substr($extensionKey, 0, 1));
$secondLetter = strtolower(substr($extensionKey, 1, 1));
$basePath = GeneralUtility::makeInstance(Configuration::class)->getRepositoryBasePath();
return $basePath . $firstLetter . '/' . $secondLetter . '/';
}
/**
* Checks if the extension has a dependency on one of the supported TYPO3 versions.
*
* @param object|array|null $dependencies
*/
public function checkExtensionDependencyOnSupportedTypo3Version($dependencies): void
{
[$oldestSupportedCoreVersion, $newestCoreVersion] = $this->getSupportedCoreVersions();
// Compare currently supported core version with the dependency in the extension
$typo3Range = '';
if (!is_array($dependencies)) {
throw new FailedDependencyException('No dependency for TYPO3 Core given.', ResultCodes::ERROR_UPLOADEXTENSION_TYPO3DEPENDENCYINCORRECT);
}
foreach ($dependencies as $dependency) {
if (is_object($dependency)) {
if ($dependency->kind == 'depends' && $dependency->extensionKey == 'typo3') {
$typo3Range = $dependency->versionRange;
break;
}
} else {
if ($dependency['kind'] == 'depends' && $dependency['extensionKey'] == 'typo3') {
$typo3Range = $dependency['versionRange'];
break;
}
}
}
[$lower, $upper] = GeneralUtility::trimExplode('-', $typo3Range);
if (empty($lower) || empty($upper)) {
// Either part of the range is empty
throw new FailedDependencyException('No range for TYPO3 Core given.', ResultCodes::ERROR_UPLOADEXTENSION_TYPO3DEPENDENCYINCORRECT);
} elseif (!preg_match('/^\d+\.\d+\.\d+$/', $lower) || !preg_match('/^\d+\.\d+\.\d+$/', $upper)) {
// Either part is not a full version number
throw new FailedDependencyException('No full version for TYPO3 Core given.', ResultCodes::ERROR_UPLOADEXTENSION_TYPO3DEPENDENCYINCORRECT);
} elseif (version_compare($lower, '0.0.0', '<=') || version_compare($upper, '0.0.0', '<=')) {
// Either part is a zero version (n < n.0 < n.0.0)
throw new FailedDependencyException('Invalid version for TYPO3 Core given.', ResultCodes::ERROR_UPLOADEXTENSION_TYPO3DEPENDENCYINCORRECT);
} elseif (version_compare($upper, $oldestSupportedCoreVersion, '<')) {
// Upper limit is lower than oldest core version
throw new FailedDependencyException('Invalid version constraint for TYPO3 Core given. Upper limit is lower than oldest core version.', ResultCodes::ERROR_UPLOADEXTENSION_TYPO3DEPENDENCYINCORRECT);
} elseif (version_compare($upper, $newestCoreVersion, '>')) {
// Upper limit is larger than newest core version
throw new FailedDependencyException('Invalid version constraint for TYPO3 Core given. Upper limit is higher than latest core version.', ResultCodes::ERROR_UPLOADEXTENSION_TYPO3DEPENDENCYINCORRECT);
} elseif (version_compare($lower, $upper, '>')) {
// Lower limit is higher than upper limit
throw new FailedDependencyException('Invalid version constraint for TYPO3 Core given. Lower limit is higher than upper limit.', ResultCodes::ERROR_UPLOADEXTENSION_TYPO3DEPENDENCYINCORRECT);
}
}
/**
* Set the review state, if the user is a reviewer.
*
* @param ApiUser $user
* @param int $reviewState
* @throws InternalServerErrorException
* @throws NotFoundException
* @throws UnauthorizedException
*/
public function updateReviewState(ApiUser $user, int $reviewState): void
{
if (!$user->isAuthenticated() || !$user->isReviewer()) {
throw new UnauthorizedException('Access denied.', ResultCodes::ERROR_SETREVIEWSTATE_ACCESSDENIED);
}
if (!$this->doesExtensionVersionExist()) {
throw new NotFoundException(
'Extension version does not exist.',
ResultCodes::ERROR_SETREVIEWSTATE_EXTENSIONVERSIONDOESNOTEXIST
);
}
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_ter_extensions');
$result = $conn->update('tx_ter_extensions', ['reviewstate' => $reviewState], ['extensionkey' => (string)$this->extensionKey, 'version' => $this->version]);
if ($result === 0) {
throw new InternalServerErrorException(
'Database error while updating extension review state.',
ResultCodes::ERROR_GENERAL_DATABASEERROR
);
}
}
/**
* Removes the extensiondetails, the preview files of a specific version.
* Only possible for administrators.
*
* @param ApiUser $user
* @throws InternalServerErrorException
* @throws UnauthorizedException
*/
public function delete(ApiUser $user): void
{
if (!$user->isAuthenticated() || !$user->isAdministrator()) {
throw new UnauthorizedException(
'Access denied. You must be administrator in order to delete extensions',
ResultCodes::ERROR_DELETEEXTENSION_ACCESS_DENIED
);
}
$apiConfiguration = GeneralUtility::makeInstance(Configuration::class);
if (!$apiConfiguration->doesRepositoryBasePathExist()) {
throw new InternalServerErrorException(
'Extension repository directory does not exist.',
ResultCodes::ERROR_GENERAL_EXTREPDIRDOESNTEXIST
);
}
$uidOfExtensionVersion = $this->getUidOfExtensionVersion();
if ($uidOfExtensionVersion === null) {
throw new InternalServerErrorException(
'deleteExtension_deleteFromDBAndRemoveFiles: Extension does not exist. (extensionkey: ' . (string)$this->extensionKey . ' version: ' . $this->version . ')',
ResultCodes::ERROR_DELETEEXTENSION_EXTENSIONDOESNTEXIST
);
}
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_ter_extensiondetails');
$conn->delete('tx_ter_extensiondetails', ['extensionuid' => $uidOfExtensionVersion]);
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_ter_extensions');
$affectedRows = $conn->delete('tx_ter_extensions', ['extensionkey' => (string)$this->extensionKey, 'version' => $this->version]);
if (!$affectedRows) {
throw new InternalServerErrorException(
'Database error while deleting extension. (extensionkey: ' . (string)$this->extensionKey . ' version: ' . $this->version . ')',
ResultCodes::ERROR_GENERAL_DATABASEERROR
);
}
$fullPathPrefix = $this->getStoragePrefix();
$filesToDelete = [
'.t3x',
'.gif',
'.png',
'_Distribution.png',
'_DistributionWelcome.png',
];
foreach ($filesToDelete as $file) {
if (file_exists($fullPathPrefix . $file)) {
@unlink($fullPathPrefix . $file);
}
}
}
/**
* Helper functions to fetch the latest and lowest supported TYPO3 Core versions. This method should be extracted into its own class
*
* @return array
* @throws FailedDependencyException
*/
protected function getSupportedCoreVersions(): array
{
$coreVersionData = GeneralUtility::getUrl(
Environment::getPublicPath() . '/' . $GLOBALS['TYPO3_CONF_VARS']['BE']['fileadminDir'] . 'currentcoredata.json'
);
$currentCores = json_decode($coreVersionData, true);
if ($currentCores === null) {
throw new FailedDependencyException('No core data found', ResultCodes::ERROR_UPLOADEXTENSION_TYPO3DEPENDENCYCHECKFAILED);
}
// Collect currently supported core versions
$supportedCoreVersions = [];
$oldestSupportedCoreVersion = '99.99.99';
$newestCoreVersion = '0.0.0';
foreach ($currentCores as $version => $coreInfo) {
if (is_array($coreInfo) && ($coreInfo['active'] === true || $coreInfo['elts'] === true)) {
// Only use keys that represent a branch number
if (strpos($version, '.') && preg_match('/^(\d+)\.\d+$/', $version, $matches)) {
$latestBranchVersion = $coreInfo['latest'];
} else {
// Manage core version without branch (>= 7 LTS)
$latestBranchVersion = $version . '.99.999';
}
// Checks the latest version
if (!preg_match('/dev|alpha/', $latestBranchVersion)) {
$supportedCoreVersions[] = $latestBranchVersion;
if (version_compare($newestCoreVersion, $latestBranchVersion, '<')) {
$newestCoreVersion = $latestBranchVersion;
}
}
// Check the oldest active version
if (version_compare($version . '.0', $oldestSupportedCoreVersion, '<')) {
$oldestSupportedCoreVersion = $version;
}
}
}
// clean newest core version
preg_match('/^(\d+)\.(\d+)\.(\d+)/', $newestCoreVersion, $matches);
$newestCoreVersion = $matches[1] . '.' . $matches[2] . '.999';
// get first beta of oldest active version
$oldestSupportedCoreVersionReleases = array_reverse($currentCores[$oldestSupportedCoreVersion]['releases']);
foreach ($oldestSupportedCoreVersionReleases as $subVersion => $subVersionInfo) {
if (!preg_match('/dev|alpha/', $subVersion)) {
$oldestSupportedCoreVersion = $subVersion;
break;
}
}
return [$oldestSupportedCoreVersion, $newestCoreVersion];
}
}
\ No newline at end of file
......@@ -23,14 +23,14 @@ class UploadQueue
{
private $tableName = 'tx_ter_extensionqueue';
public function addExtensionVersionToQueue(int $uidOfExtensionVersionRecord, string $extensionKey)
public function addExtensionVersionToQueue(ExtensionVersion $extensionVersion)
{
$this->getConnection()->insert(
$this->tableName,
[
'crdate' => $GLOBALS['EXEC_TIME'],
'extensionkey' => $extensionKey,
'extensionuid' => $uidOfExtensionVersionRecord,
'extensionkey' => (string)$extensionVersion->getExtensionKey(),
'extensionuid' => $extensionVersion->getUidOfExtensionVersion(),
'imported_to_fe' => 0,
'tstamp' => 0
]
......
This diff is collapsed.
......@@ -510,10 +510,8 @@ class ExtensionController extends \T3o\TerFe2\Controller\AbstractController
$extensionInfo->infoData->uploadComment = $form['comment'];
$filesData = (object)['fileData' => $files];
try {
$result = \tx_ter_api::uploadExtensionWithoutSoap($this->frontendUser['username'], $extensionInfo, $filesData);
if ($result) {
$this->redirect('index', 'Registerkey', null, ['uploaded' => true], $this->settings['pages']['manageKeysPID']);
}
GeneralUtility::makeInstance(\tx_ter_api::class)->uploadExtensionWithoutSoap($this->frontendUser['username'], $extensionInfo, $filesData);
$this->redirect('index', 'Registerkey', null, ['uploaded' => true], $this->settings['pages']['manageKeysPID']);
} catch (\Exception $exception) {
$this->forwardWithError($exception->getMessage(), 'uploadVersion');
}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment