Commit: 8ab9a1d1103117a6a185262ebeadba6c2595dcd4 Author: Peter Kokot <peterko...@gmail.com> Wed, 5 Dec 2018 14:51:46 +0100 Parents: 487a0bc254f1bf1cc98df09fb33f7a6068f6620a Branches: master
Link: http://git.php.net/?p=web/bugs.git;a=commitdiff;h=8ab9a1d1103117a6a185262ebeadba6c2595dcd4 Log: Refactor patches uploading This patch moves patches uploading functionality from the outdated HTTP_Upload package to a dedicated service class in the app. Additional changes in this context: - Functionality concerning retrieving patches data from database has been moved to a separate repository classes. - Some missed bugs fixed when uploading patches and no developer info were recorded. - Obsoleting patches functionality is now working again. - Added a simple unit test. Changed paths: M README.md D include/classes/bug_patchtracker.php M include/functions.php A src/Repository/ObsoletePatchRepository.php A src/Repository/PatchRepository.php A src/Utils/PatchTracker.php A src/Utils/Uploader.php M templates/addpatch.php A tests/Utils/UploaderTest.php A tests/fixtures/files/patch.txt M www/bug.php M www/patch-add.php M www/patch-display.php M www/report.php
diff --git a/README.md b/README.md index 564aa47..7e353d0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ composer install ```bash pear channel-update pear.php.net -pear install --alldeps Text_Diff HTTP_Upload-1.0.0b4 +pear install --alldeps Text_Diff ``` * Database: diff --git a/include/classes/bug_patchtracker.php b/include/classes/bug_patchtracker.php deleted file mode 100644 index 18c01d2..0000000 --- a/include/classes/bug_patchtracker.php +++ /dev/null @@ -1,364 +0,0 @@ -<?php - -require_once 'HTTP/Upload.php'; - -class Bug_Patchtracker -{ - private $uploader; - private $dbh; - - public function __construct() - { - if (!file_exists(BUG_PATCHTRACKER_TMPDIR)) { - if (!@mkdir(BUG_PATCHTRACKER_TMPDIR)) { - $this->uploader = false; - $this->dbh = $GLOBALS['dbh']; - return; - } - } - $this->uploader = new HTTP_Upload('en'); - $this->dbh = $GLOBALS['dbh']; - } - - /** - * Return the directory in which patches should be stored - * - * @param int $bugid - * @param string $name name of this patch line - * @return string - */ - private function patchDir($bugid, $name) - { - return BUG_PATCHTRACKER_TMPDIR . '/p' . $bugid . '/' . $name; - } - /** - * Create the directory in which patches for this bug ID will be stored - * - * @param int $bugid - */ - private function setupPatchDir($bugid, $name) - { - if (file_exists($this->patchDir($bugid, $name))) { - if (!is_dir($this->patchDir($bugid, $name))) { - return PEAR::raiseError('Cannot create patch storage for Bug #' . $bugid . - ', storage directory exists and is not a directory'); - } - return; - } - if (!file_exists(dirname($this->patchDir($bugid, $name)))) { - // setup bug directory - if (!@mkdir(dirname($this->patchDir($bugid, $name)))) { - require_once 'PEAR.php'; - return PEAR::raiseError('Cannot create patch storage for Bug #' . $bugid); - } - } elseif (!is_dir(dirname($this->patchDir($bugid, $name)))) { - return PEAR::raiseError('Cannot create patch storage for Bug #' . $bugid . - ', storage directory exists and is not a directory'); - } - // setup patch directory - if (!@mkdir($this->patchDir($bugid, $name))) { - require_once 'PEAR.php'; - return PEAR::raiseError('Cannot create patch storage for Bug #' . $bugid); - } - } - - /** - * Retrieve a unique, ordered patch filename - * - * @param int $bugid - * @param string $patch - * @return array array(revision, patch file name) - */ - private function newPatchFileName($bugid, $patch, $handle) - { - $id = time(); - PEAR::pushErrorHandling(PEAR_ERROR_RETURN); - $e = $this->dbh->prepare('INSERT INTO bugdb_patchtracker - (bugdb_id, patch, revision, developer) VALUES(?, ?, ?, ?)')->execute( - [$bugid, $patch, $id, $handle]); - if (PEAR::isError($e)) { - // try with another timestamp - $id++; - $e = $this->dbh->prepare('INSERT INTO bugdb_patchtracker - (bugdb_id, patch, revision, developer) VALUES(?, ?, ?, ?)')->execute( - [$bugid, $patch, $id, $handle]); - } - PEAR::popErrorHandling(); - if (PEAR::isError($e)) { - return PEAR::raiseError("Could not get unique patch file name for bug #{$bugid}, patch \"{$patch}\""); - } - return [$id, $this->getPatchFileName($id)]; - } - - /** - * Retrieve the name of the patch file on the system - * - * @param int $id revision ID - * @return string - */ - private function getPatchFileName($id) - { - return 'p' . $id . '.patch.txt'; - } - - /** - * Retrieve the full path to a patch file - * - * @param int $bugid - * @param string $name - * @param int $revision - * @return string - */ - public function getPatchFullpath($bugid, $name, $revision) - { - return $this->patchDir($bugid, $name).'/'.$this->getPatchFileName($revision); - } - - /** - * Attach a patch to this bug - * - * @param int $bugid - * @param string $patch uploaded patch filename form variable - * @param string $name patch name (allows several patches to be versioned) - * @param string $handle developer handle - * @param array $obsoletes obsoleted patches - * @return int patch revision - */ - public function attach($bugid, $patch, $name, $handle, $obsoletes) - { - if (!$this->uploader) { - return PEAR::raiseError('Upload directory for patches could not be initialized'); - } - if (!preg_match('/^[\w\-\.]+\z/', $name) || strlen($name) > 80) { - return PEAR::raiseError("Invalid patch name \"".htmlspecialchars($name)."\""); - } - if (!is_array($obsoletes)) { - return PEAR::raiseError('Invalid obsoleted patches'); - } - - $file = $this->uploader->getFiles($patch); - if (PEAR::isError($file)) { - return $file; - } - - if ($file->isValid()) { - $newobsoletes = []; - foreach ($obsoletes as $who) { - if (!$who) { - continue; // remove (none) - } - $who = explode('#', $who); - if (count($who) != 2) { - continue; - } - if (file_exists($this->getPatchFullpath($bugid, $who[0], $who[1]))) { - $newobsoletes[] = $who; - } - } - if (PEAR::isError($e = $this->setupPatchDir($bugid, $name))) { - return $e; - } - - $res = $this->newPatchFileName($bugid, $name, $handle); - if (PEAR::isError($res)) { - return $res; - } - list($id, $fname) = $res; - $file->setName($fname); - $allowed_mime_types = [ - 'application/x-txt', - 'text/plain', - 'text/x-diff', - 'text/x-patch', - 'text/x-c++', - 'text/x-c', - 'text/x-m4', - ]; - - // return mime type ala mimetype extension - if (class_exists('finfo')) { - $finfo = new finfo(FILEINFO_MIME); - if (!$finfo) { - return PEAR::raiseError('Error: Opening fileinfo database failed'); - } - - // get mime-type for a specific file - $mime = $finfo->file($file->getProp('tmp_name')); - // get rid of the charset part - $t = explode(';', $mime); - $mime = $t[0]; - } - else // NOTE: I didn't have PHP 5.3 around with fileinfo enabled :) - { - $mime = 'text/plain'; - } - if (!in_array($mime, $allowed_mime_types)) { - $this->dbh->prepare('DELETE FROM bugdb_patchtracker - WHERE bugdb_id = ? and patch = ? and revision = ?')->execute( - [$bugid, $name, $id]); - return PEAR::raiseError('Error: uploaded patch file must be text' - . ' file (save as e.g. "patch.txt" or "package.diff")' - . ' (detected "' . htmlspecialchars($mime) . '")' - ); - } - $tmpfile = $file->moveTo($this->patchDir($bugid, $name)); - if (PEAR::isError($tmpfile)) { - $this->dbh->prepare('DELETE FROM bugdb_patchtracker - WHERE bugdb_id = ? and patch = ? and revision = ?')->execute( - [$bugid, $name, $id]); - return $tmpfile; - } - if (!$file->getProp('size')) { - $this->detach($bugid, $name, $id); - return PEAR::raiseError('zero-length patches not allowed'); - } - if ($file->getProp('size') > 102400) { - $this->detach($bugid, $name, $id); - return PEAR::raiseError('Patch files cannot be larger than 100k'); - } - foreach ($newobsoletes as $obsolete) { - $this->obsoletePatch($bugid, $name, $id, $obsolete[0], $obsolete[1]); - } - return $id; - } elseif ($file->isMissing()) { - return PEAR::raiseError('Uploaded file is empty or nothing was uploaded.'); - } elseif ($file->error()) { - return PEAR::raiseError($file->errorMsg()); - } - return PEAR::raiseError('Unable to attach patch (try renaming the file with .txt extension)'); - } - - /** - * Remove a patch revision from this bug - * - * @param int $bugid - * @param string $name - * @param int $revision - */ - public function detach($bugid, $name, $revision) - { - $this->dbh->prepare('DELETE FROM bugdb_patchtracker - WHERE bugdb_id = ? and patch = ? and revision = ?')->execute( - [$bugid, $name, $revision]); - - @unlink($this->patchDir($bugid, $name).'/'.$this->getPatchFileName($revision)); - } - - /** - * Retrieve the actual contents of the patch - * - * @param int $bugid - * @param string $name - * @param int $revision - * @return string - */ - public function getPatch($bugid, $name, $revision) - { - if ($this->dbh->prepare(' - SELECT bugdb_id - FROM bugdb_patchtracker - WHERE bugdb_id = ? AND patch = ? AND revision = ?')->execute([$bugid, $name, $revision])->fetchOne() - ) { - $contents = @file_get_contents($this->getPatchFullpath($bugid, $name, $revision)); - if (!$contents) { - return PEAR::raiseError('Cannot retrieve patch revision "' . $revision . '" for patch "' . $name . '"'); - } - return $contents; - } - return PEAR::raiseError('No such patch revision "' . $revision . '", or no such patch "' . $name . '"'); - } - - /** - * Retrieve a listing of all patches and their revisions - * - * @param int $bugid - * @return array - */ - public function listPatches($bugid) - { - $query = ' - SELECT patch, revision, developer - FROM bugdb_patchtracker - WHERE bugdb_id = ? - ORDER BY revision DESC - '; - - return $this->dbh->prepare($query)->execute([$bugid])->fetchAll(PDO::FETCH_NUM, true, false, true); - } - - /** - * Retrieve a listing of all patches and their revisions - * - * @param int $bugid - * @param string $patch - * @return array - */ - public function listRevisions($bugid, $patch) - { - $query = ' - SELECT revision FROM bugdb_patchtracker - WHERE bugdb_id = ? AND patch = ? - ORDER BY revision DESC - '; - return $this->dbh->prepare($query)->execute([$bugid, $patch])->fetchAll(PDO::FETCH_NUM); - } - - /** - * Retrieve the developer who uploaded this patch - * - * @param int $bugid - * @param string $patch - * @param int $revision - * @return string|array array if no revision is selected - */ - public function getDeveloper($bugid, $patch, $revision = false) - { - if ($revision) { - return $this->dbh->prepare(' - SELECT developer - FROM bugdb_patchtracker - WHERE bugdb_id = ? AND patch = ? AND revision = ? - ')->execute([$bugid, $patch, $revision])->fetchOne(); - } - return $this->dbh->prepare(' - SELECT developer, revision - FROM bugdb_patchtracker - WHERE bugdb_id = ? AND patch = ? ORDER BY revision DESC - ')->execute([$bugid, $patch])->fetchAll(PDO::FETCH_ASSOC); - } - - public function getObsoletingPatches($bugid, $patch, $revision) - { - return $this->dbh->prepare(' - SELECT bugdb_id, patch, revision - FROM bugdb_obsoletes_patches - WHERE bugdb_id = ? AND obsolete_patch = ? AND obsolete_revision = ? - ')->execute([$bugid, $patch, $revision])->fetchAll(PDO::FETCH_ASSOC); - } - - public function getObsoletePatches($bugid, $patch, $revision) - { - return $this->dbh->prepare(' - SELECT bugdb_id, obsolete_patch, obsolete_revision - FROM bugdb_obsoletes_patches - WHERE bugdb_id = ? AND patch = ? AND revision = ? - ')->execute([$bugid, $patch, $revision])->fetchAll(PDO::FETCH_ASSOC); - } - - /** - * link to an obsolete patch from the new one - * - * @param int $bugid - * @param string $name better patch name - * @param int $revision better patch revision - * @param string $obsoletename - * @param int $obsoleterevision - */ - private function obsoletePatch($bugid, $name, $revision, $obsoletename, $obsoleterevision) - { - $this->dbh->prepare(' - INSERT INTO bugdb_obsoletes_patches - VALUES(?, ?, ?, ?, ?) - ')->execute([$bugid, $name, $revision, $obsoletename, $obsoleterevision]); - } -} diff --git a/include/functions.php b/include/functions.php index c3a2c6a..8f35fe2 100644 --- a/include/functions.php +++ b/include/functions.php @@ -1148,7 +1148,7 @@ function format_date($ts = null, $format = 'Y-m-d H:i e') if (!$ts) { $ts = time(); } - return gmdate($format, $ts - date('Z', $ts)); + return gmdate($format, (int)$ts - date('Z', (int)$ts)); } /** diff --git a/src/Repository/ObsoletePatchRepository.php b/src/Repository/ObsoletePatchRepository.php new file mode 100644 index 0000000..2335ec9 --- /dev/null +++ b/src/Repository/ObsoletePatchRepository.php @@ -0,0 +1,51 @@ +<?php + +namespace App\Repository; + +use App\Database\Database; + +/** + * Repository for retrieving data from the bugdb_obsoletes_patches database table. + */ +class ObsoletePatchRepository +{ + /** + * Database handler. + * @var Database + */ + private $dbh; + + /** + * Class constructor. + */ + public function __construct(Database $dbh) + { + $this->dbh = $dbh; + } + + /** + * Retrieve patches that obsoleted given patch. + */ + public function findObsoletingPatches(int $bugId, string $patch, int $revision): array + { + $sql = 'SELECT bugdb_id, patch, revision + FROM bugdb_obsoletes_patches + WHERE bugdb_id = ? AND obsolete_patch = ? AND obsolete_revision = ? + '; + + return $this->dbh->prepare($sql)->execute([$bugId, $patch, $revision])->fetchAll(); + } + + /** + * Retrieve obsolete patches by bug, patch and revision. + */ + public function findObsoletePatches(int $bugId, string $patch, int $revision): array + { + $sql = 'SELECT bugdb_id, obsolete_patch, obsolete_revision + FROM bugdb_obsoletes_patches + WHERE bugdb_id = ? AND patch = ? AND revision = ? + '; + + return $this->dbh->prepare($sql)->execute([$bugId, $patch, $revision])->fetchAll(); + } +} diff --git a/src/Repository/PatchRepository.php b/src/Repository/PatchRepository.php new file mode 100644 index 0000000..a3a6d87 --- /dev/null +++ b/src/Repository/PatchRepository.php @@ -0,0 +1,106 @@ +<?php + +namespace App\Repository; + +use App\Database\Database; + +/** + * Repository for retrieving data from the bugdb_patchtracker database table. + */ +class PatchRepository +{ + /** + * Database handler. + * @var Database + */ + private $dbh; + + /** + * Parent directory where patches are uploaded. + * @var string + */ + private $uploadsDir; + + /** + * Class constructor. + */ + public function __construct(Database $dbh) + { + $this->dbh = $dbh; + $this->uploadsDir = BUG_PATCHTRACKER_TMPDIR; + } + + /** + * Retrieve a list of all patches and their revisions by given bug id. + */ + public function findAllByBugId(int $bugId): array + { + $sql = 'SELECT patch, revision, developer + FROM bugdb_patchtracker + WHERE bugdb_id = ? + ORDER BY revision DESC + '; + + return $this->dbh->prepare($sql)->execute([$bugId])->fetchAll(); + } + + /** + * Retrieve the developer by patch. + */ + public function findDeveloper(int $bugId, string $patch, int $revision): string + { + $sql = 'SELECT developer + FROM bugdb_patchtracker + WHERE bugdb_id = ? AND patch = ? AND revision = ? + '; + + $arguments = [$bugId, $patch, $revision]; + + return $this->dbh->prepare($sql)->execute($arguments)->fetchOne(); + } + + /** + * Retrieve a list of all patches and their revisions. + */ + public function findRevisions(int $bugId, string $patch): array + { + $sql = 'SELECT revision + FROM bugdb_patchtracker + WHERE bugdb_id = ? AND patch = ? + ORDER BY revision DESC + '; + + return $this->dbh->prepare($sql)->execute([$bugId, $patch])->fetchAll(); + } + + /** + * Retrieve the actual contents of the patch. + */ + public function getPatchContents(int $bugId, string $name, int $revision): string + { + $sql = 'SELECT bugdb_id + FROM bugdb_patchtracker + WHERE bugdb_id = ? AND patch = ? AND revision = ? + '; + + if ($this->dbh->prepare($sql)->execute([$bugId, $name, $revision])->fetchOne()) { + $contents = @file_get_contents($this->getPatchPath($bugId, $name, $revision)); + + if (!$contents) { + throw new \Exception('Cannot retrieve patch revision "'.$revision.'" for patch "'.$name.'"'); + } + + return $contents; + } + + throw new \Exception('No such patch revision "'.$revision.'", or no such patch "'.$name.'"'); + } + + /** + * Get absolute patch file name. + */ + private function getPatchPath(int $bugId, string $name, int $revision): string + { + return $this->uploadsDir.'/p'.$bugId.'/'.$name.'/'.'p'.$revision.'.patch.txt'; + } +} diff --git a/src/Utils/PatchTracker.php b/src/Utils/PatchTracker.php new file mode 100644 index 0000000..127831a --- /dev/null +++ b/src/Utils/PatchTracker.php @@ -0,0 +1,242 @@ +<?php + +namespace App\Utils; + +use App\Utils\Uploader; +use App\Database\Database; + +/** + * Service for handling uploaded patches. + */ +class PatchTracker +{ + /** + * Database handler. + * @var Database + */ + private $dbh; + + /** + * File upload service. + * @var Uploader + */ + private $uploader; + + /** + * Parent directory where patches are uploaded. + * @var string + */ + private $uploadsDir; + + /** + * Maximum allowed patch file size. + */ + const MAX_FILE_SIZE = 100 * 1024; + + /** + * Valid media types (former MIME types) for the uploaded patch files. + */ + const VALID_MEDIA_TYPES = [ + 'application/x-txt', + 'text/plain', + 'text/x-diff', + 'text/x-patch', + 'text/x-c++', + 'text/x-c', + 'text/x-m4', + ]; + + /** + * Class constructor. + */ + public function __construct(Database $dbh, Uploader $uploader) + { + $this->dbh = $dbh; + $this->uploadsDir = BUG_PATCHTRACKER_TMPDIR; + + $this->uploader = $uploader; + $this->uploader->setMaxFileSize(self::MAX_FILE_SIZE); + $this->uploader->setValidMediaTypes(self::VALID_MEDIA_TYPES); + } + + /** + * Create a parent uploads directory for patches if it is missing. + */ + private function createUploadsDir(): void + { + if (!file_exists($this->uploadsDir) && !@mkdir($this->uploadsDir)) { + throw new \Exception('Patches upload directory could not be created.'); + } + } + + /** + * Get the directory in which patches for given bug id should be stored. + */ + private function getPatchDir(int $bugId, string $name): string + { + return $this->uploadsDir.'/p'.$bugId.'/'.$name; + } + + /** + * Create the directory in which patches for the given bug id will be stored. + */ + private function createPatchDir(int $bugId, string $name): void + { + $patchDir = $this->getPatchDir($bugId, $name); + $parentDir = dirname($patchDir); + + // Check if patch directory already exists. + if (is_dir($patchDir)) { + return; + } + + // Check if files with same names as directories already exist. + if (is_file($parentDir) || is_file($patchDir)) { + throw new \Exception('Cannot create patch storage for Bug #'.$bugId.', storage directory exists and is not a directory'); + } + + // Create parent directory + if (!file_exists($parentDir) && !@mkdir($parentDir)) { + throw new \Exception('Cannot create patch storage for Bug #'.$bugId); + } + + // Create patch directory + if (!@mkdir($patchDir)) { + throw new \Exception('Cannot create patch storage for Bug #'.$bugId); + } + } + + /** + * Retrieve a unique, ordered patch filename. + */ + private function newPatchFileName(int $bugId, string $patch, string $developer): int + { + $revision = time(); + + $sql = 'INSERT INTO bugdb_patchtracker + (bugdb_id, patch, revision, developer) VALUES (?, ?, ?, ?) + '; + + try { + $this->dbh->prepare($sql)->execute([$bugId, $patch, $revision, $developer]); + } catch (\Exception $e) { + // Try with another timestamp + try { + $revision++; + $this->dbh->prepare($sql)->execute([$bugId, $patch, $revision, $developer]); + } catch (\Exception $e) { + throw new \Exception('Could not get unique patch file name for bug #'.$bugId.', patch "'.$patch.'"'); + } + } + + return $revision; + } + + /** + * Retrieve the name of the patch file on the system. + */ + private function getPatchFileName(int $revision): string + { + return 'p'.$revision.'.patch.txt'; + } + + /** + * Retrieve the full path to a patch file. + */ + public function getPatchFullpath(int $bugId, string $name, int $revision): string + { + return $this->getPatchDir($bugId, $name).'/'.$this->getPatchFileName($revision); + } + + /** + * Attach a patch to this bug. + */ + public function attach(int $bugId, string $patch, string $name, string $developer, array $obsoletes = []): int + { + $this->uploader->setDir($this->getPatchDir($bugId, $name)); + + if (!is_array($obsoletes)) { + throw new \Exception('Invalid obsoleted patches'); + } + + try { + $revision = $this->newPatchFileName($bugId, $name, $developer); + $this->uploader->setDestinationFileName($this->getPatchFileName($revision)); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + + try { + $this->createUploadsDir(); + + $this->validatePatchName($name); + + $this->createPatchDir($bugId, $name); + + $this->uploader->upload($patch); + } catch (\Exception $e) { + $this->detach($bugId, $name, $revision); + + throw new \Exception($e->getMessage()); + } + + $newObsoletes = []; + foreach ($obsoletes as $obsoletePatch) { + // The none option in form. + if (!$obsoletePatch) { + continue; + } + + $obsoletePatch = explode('#', $obsoletePatch); + + if (count($obsoletePatch) != 2) { + continue; + } + + if (file_exists($this->getPatchFullpath($bugId, $obsoletePatch[0], $obsoletePatch[1]))) { + $newObsoletes[] = $obsoletePatch; + } + } + + foreach ($newObsoletes as $obsolete) { + $this->obsoletePatch($bugId, $name, $revision, $obsolete[0], $obsolete[1]); + } + + return $revision; + } + + /** + * Validate patch name. + */ + private function validatePatchName(string $name): void + { + if (!preg_match('/^[\w\-\.]+\z/', $name) || strlen($name) > 80) { + throw new \Exception('Invalid patch name "'.htmlspecialchars($name, ENT_QUOTES).'"'); + } + } + + /** + * Remove a patch revision from this bug. + */ + private function detach(int $bugId, string $name, int $revision): void + { + $sql = 'DELETE FROM bugdb_patchtracker + WHERE bugdb_id = ? AND patch = ? AND revision = ? + '; + + $this->dbh->prepare($sql)->execute([$bugId, $name, $revision]); + + @unlink($this->getPatchFullpath($bugId, $name, $revision)); + } + + /** + * Make patch obsolete by new patch. This create a link to an obsolete patch + * from the new one. + */ + private function obsoletePatch(int $bugId, string $name, int $revision, string $obsoleteName, int $obsoleteRevision): void + { + $sql = 'INSERT INTO bugdb_obsoletes_patches VALUES (?, ?, ?, ?, ?)'; + + $this->dbh->prepare($sql)->execute([$bugId, $name, $revision, $obsoleteName, $obsoleteRevision]); + } +} diff --git a/src/Utils/Uploader.php b/src/Utils/Uploader.php new file mode 100644 index 0000000..6591b78 --- /dev/null +++ b/src/Utils/Uploader.php @@ -0,0 +1,219 @@ +<?php + +namespace App\Utils; + +/** + * A basic upload service class for uploading files via HTML forms. + */ +class Uploader +{ + /** + * Maximum allowed file size in bytes. + * @var int + */ + private $maxFileSize = 2 * 1024 * 1024; + + /** + * Valid file extension. + * @var string + */ + private $validExtension; + + /** + * Valid media types. + * @var array + */ + private $validMediaTypes; + + /** + * Destination directory. + * @var string + */ + private $dir; + + /** + * Destination file name. + * @var string + */ + private $destinationFileName; + + /** + * Set the maximum allowed file size in bytes. + */ + public function setMaxFileSize(int $maxFileSize): void + { + $this->maxFileSize = $maxFileSize; + } + + /** + * Set allowed file extension without leading dot. For example, 'tgz'. + */ + public function setValidExtension(string $validExtension): void + { + $this->validExtension = $validExtension; + } + + /** + * Set array of valid media types. + */ + public function setValidMediaTypes(array $validMediaTypes): void + { + $this->validMediaTypes = $validMediaTypes; + } + + /** + * Set destination directory. + */ + public function setDir(string $dir): void + { + $this->dir = $dir; + } + + /** + * Set the destination file name. + */ + public function setDestinationFileName(string $destinationFileName): void + { + $this->destinationFileName = $destinationFileName; + } + + /** + * Upload file. + */ + public function upload(string $key): string + { + $files = isset($_FILES[$key]) ? $_FILES[$key] : []; + + // Check if uploaded file size exceeds the ini post_max_size directive. + if( + empty($_FILES) + && empty($_POST) + && isset($_SERVER['REQUEST_METHOD']) + && strtolower($_SERVER['REQUEST_METHOD']) === 'post' + ) { + $max = ini_get('post_max_size'); + throw new \Exception('Error on upload: Exceeded POST content length server limit of '.$max); + } + + // Some other upload error happened + if (empty($files) || $files['error'] !== UPLOAD_ERR_OK) { + throw new \Exception('Error on upload: Something went wrong. Error code: '.$files['error']); + } + + // Be sure we're dealing with an upload + if ($this->isUploadedFile($files['tmp_name']) === false) { + throw new \Exception('Error on upload: Invalid file definition'); + } + + // Check file extension + $uploadedName = $files['name']; + $ext = $this->getFileExtension($uploadedName); + if (isset($this->validExtension) && $ext !== $this->validExtension) { + throw new \Exception('Error on upload: Invalid file extension. Should be .'.$this->validExtension); + } + + // Check file size + if ($files['size'] > $this->maxFileSize) { + throw new \Exception('Error on upload: Exceeded file size limit '.$this->maxFileSize.' bytes'); + } + + // Check zero length file size + if (!$files['size']) { + throw new \Exception('Error on upload: Zero-length patches are not allowed'); + } + + // Check media type + $type = $this->getMediaType($files['tmp_name']); + if (isset($this->validMediaTypes) && !in_array($type, $this->validMediaTypes)) { + throw new \Exception('Error: Uploaded patch file must be text file + (save as e.g. "patch.txt" or "package.diff") + (detected "'.htmlspecialchars($type, ENT_QUOTES).'")' + ); + } + + // Rename the uploaded file + $destination = $this->dir.'/'.$this->destinationFileName; + + // Move uploaded file to final destination + if (!$this->moveUploadedFile($files['tmp_name'], $destination)) { + throw new \Exception('Error on upload: Something went wrong'); + } + + return $destination; + } + + /** + * Checks if given file has been uploaded via POST method. This is wrapped + * into a separate method for convenience of testing it via phpunit and using + * a mock. + */ + protected function isUploadedFile(string $file): bool + { + return is_uploaded_file($file); + } + + /** + * Move uploaded file to destination. This method is wrapping PHP function + * to allow testing with PHPUnit and creating a mock object. + */ + protected function moveUploadedFile(string $source, string $destination): bool + { + return move_uploaded_file($source, $destination); + } + + /** + * Rename file to a unique name. + */ + protected function renameFile(string $filename): ?string + { + $ext = $this->getFileExtension($filename); + + $rand = uniqid(rand()); + + $i = 0; + while (true) { + $newName = $rand.$i.'.'.$ext; + + if (!file_exists($this->dir.'/'.$newName)) { + return $newName; + } + + $i++; + } + } + + /** + * Returns file extension without a leading dot. + */ + protected function getFileExtension(string $filename): string + { + return strtolower(substr($filename, strripos($filename, '.') + 1)); + } + + /** + * Guess file media type (formerly known as MIME type) using the fileinfo + * extension. If fileinfo extension is not installed fallback to plain text + * type. + */ + protected function getMediaType(string $file): string + { + // If fileinfo extension is not available it defaults to text/plain. + if (!class_exists('finfo')) { + return 'text/plain'; + } + + $finfo = new \finfo(FILEINFO_MIME); + + if (!$finfo) { + throw new \Exception('Error: Opening fileinfo database failed.'); + } + + // Get type for a specific file + $type = $finfo->file($file); + + // Remove the charset part + $mediaType = explode(';', $type); + + return isset($mediaType[0]) ? $mediaType[0] : 'text/plain'; + } +} diff --git a/templates/addpatch.php b/templates/addpatch.php index 5285789..faad8c8 100644 --- a/templates/addpatch.php +++ b/templates/addpatch.php @@ -61,15 +61,13 @@ if (!$logged_in) { <td class="form-input"> <select name="obsoleted[]" multiple="true" size="5"> <option value="0">(none)</option> -<?php - foreach ($patches as $patchname => $patch2) { - foreach ($patch2 as $patch) { - echo '<option value="', htmlspecialchars($patchname . '#' . $patch[0]), - '">', htmlspecialchars($patchname), ', Revision ', - format_date($patch[0]), ' (', $patch[1], ')</option>'; - } - } -?> + <?php foreach ($patches as $patch): ?> + <option value="<?= htmlspecialchars($patch['patch'].'#'.$patch['revision'], ENT_QUOTES); ?>"> + <?= htmlspecialchars($patch['patch'], ENT_QUOTES); ?>, + Revision <?= format_date($patch['revision']); ?> + <?= $patch['developer'] ? '('.spam_protect(htmlspecialchars($patch['developer'], ENT_QUOTES)).')' : ''; ?> + </option> + <?php endforeach; ?> </select> </td> </tr> diff --git a/tests/Utils/UploaderTest.php b/tests/Utils/UploaderTest.php new file mode 100644 index 0000000..982cc7f --- /dev/null +++ b/tests/Utils/UploaderTest.php @@ -0,0 +1,54 @@ +<?php + +namespace App\Tests\Utils; + +use PHPUnit\Framework\TestCase; +use App\Utils\Uploader; + +class UploaderTest extends TestCase +{ + private $fixturesDirectory = __DIR__.'/../fixtures/files'; + + /** + * @dataProvider filesProvider + */ + public function testUpload($validExtension, $file) + { + $_FILES = []; + $_FILES['uploaded'] = $file; + + $uploader = $this->getMockBuilder(Uploader::class) + ->setMethods(['isUploadedFile', 'moveUploadedFile']) + ->getMock(); + + $uploader->expects($this->once()) + ->method('isUploadedFile') + ->will($this->returnValue(true)); + + $uploader->expects($this->once()) + ->method('moveUploadedFile') + ->will($this->returnValue(true)); + + $uploader->setMaxFileSize(100 * 1024); + $uploader->setValidExtension($validExtension); + $uploader->setDir(__DIR__.'/../../var/uploads'); + $tmpFile = $uploader->upload('uploaded'); + + $this->assertNotNull($tmpFile); + } + + public function filesProvider() + { + return [ + [ + 'txt', + [ + 'name' => 'patch.txt', + 'tmp_name' => $this->fixturesDirectory.'/patch.txt', + 'size' => filesize($this->fixturesDirectory.'/patch.txt'), + 'error' => UPLOAD_ERR_OK, + ] + ], + ]; + } +} diff --git a/tests/fixtures/files/patch.txt b/tests/fixtures/files/patch.txt new file mode 100644 index 0000000..d675fa4 --- /dev/null +++ b/tests/fixtures/files/patch.txt @@ -0,0 +1 @@ +foo bar diff --git a/www/bug.php b/www/bug.php index c676d13..bb87dc7 100644 --- a/www/bug.php +++ b/www/bug.php @@ -1,6 +1,8 @@ <?php /* User interface for viewing and editing bug details */ +use App\Repository\ObsoletePatchRepository; +use App\Repository\PatchRepository; use App\Utils\Captcha; use App\Repository\PullRequestRepository; @@ -10,6 +12,9 @@ require_once '../include/prepend.php'; // Start session session_start(); +$obsoletePatchRepository = new ObsoletePatchRepository($dbh); +$patchRepository = new PatchRepository($dbh); + define('SPAM_REJECT_MESSAGE', 'Your comment looks like SPAM by its content. Please consider rewording.'); $email = null; @@ -1059,9 +1064,7 @@ if ($bug['ldesc']) { // Display patches if ($show_bug_info && $bug_id != 'PREVIEW' && $bug['status'] !== 'Spam') { - require_once "{$ROOT_DIR}/include/classes/bug_patchtracker.php"; - $patches = new Bug_Patchtracker; - $p = $patches->listPatches($bug_id); + $p = $patchRepository->findAllByBugId($bug_id); $revs = []; echo "<h2>Patches</h2>\n"; @@ -1071,7 +1074,7 @@ if ($show_bug_info && $bug_id != 'PREVIEW' && $bug['status'] !== 'Spam') { foreach ($revs as $name => $revisions) { - $obsolete = $patches->getObsoletingPatches($bug_id, $name, $revisions[0][0]); + $obsolete = $obsoletePatchRepository->findObsoletingPatches($bug_id, $name, $revisions[0][0]); $style = !empty($obsolete) ? ' style="background-color: yellow; text-decoration: line-through;" ' : ''; $url_name = urlencode($name); $clean_name = clean($name); @@ -1080,7 +1083,7 @@ if ($show_bug_info && $bug_id != 'PREVIEW' && $bug['status'] !== 'Spam') { echo <<< OUTPUT <a href="patch-display.php?bug_id={$bug_id}&patch={$url_name}&revision=latest" {$style}>{$clean_name}</a> -(last revision {$formatted_date}) by {$submitter}) +(last revision {$formatted_date} by {$submitter}) <br> OUTPUT; } diff --git a/www/patch-add.php b/www/patch-add.php index 0b1db80..763a011 100644 --- a/www/patch-add.php +++ b/www/patch-add.php @@ -1,10 +1,17 @@ <?php +use App\Repository\PatchRepository; use App\Utils\Captcha; +use App\Utils\PatchTracker; +use App\Utils\Uploader; // Obtain common includes require_once '../include/prepend.php'; +$uploader = new Uploader(); +$patchTracker = new PatchTracker($dbh, $uploader); +$patchRepository = new PatchRepository($dbh); + session_start(); // Authenticate @@ -50,9 +57,6 @@ if (!$show_bug_info) { exit; } -require_once "{$ROOT_DIR}/include/classes/bug_patchtracker.php"; -$patchinfo = new Bug_Patchtracker; - $patch_name = (!empty($_GET['patchname']) && is_string($_GET['patchname'])) ? $_GET['patchname'] : ''; $patch_name = (!empty($_POST['name']) && is_string($_POST['name'])) ? $_POST['name'] : $patch_name; $patch_name_url = urlencode($patch_name); @@ -64,7 +68,7 @@ if (isset($_POST['addpatch'])) { // Check that patch name is given (required always) if (empty($patch_name)) { - $patches = $patchinfo->listPatches($bug_id); + $patches = $patchRepository->findAllByBugId($bug_id); $errors[] = 'No patch name entered'; include "{$ROOT_DIR}/templates/addpatch.php"; exit; @@ -90,24 +94,23 @@ if (isset($_POST['addpatch'])) { } if (count($errors)) { - throw new Exception(''); + throw new \Exception(''); } - PEAR::pushErrorHandling(PEAR_ERROR_RETURN); - $e = $patchinfo->attach($bug_id, 'patch', $patch_name, $email, $_POST['obsoleted']); - PEAR::popErrorHandling(); - - if (PEAR::isError($e)) { - $patches = $patchinfo->listPatches($bug_id); + try { + $revision = $patchTracker->attach($bug_id, 'patch', $patch_name, $email, $_POST['obsoleted']); + } catch (\Exception $e) { + $patches = $patchRepository->findAllByBugId($bug_id); $errors[] = $e->getMessage(); - $errors[] = 'Could not attach patch "' . htmlspecialchars($patch_name) . '" to Bug #' . $bug_id; + $errors[] = 'Could not attach patch "'.htmlspecialchars($patch_name).'" to Bug #'.$bug_id; include "{$ROOT_DIR}/templates/addpatch.php"; + exit; } - redirect("patch-display.php?bug={$bug_id}&patch={$patch_name_url}&revision={$e}"); - } catch (Exception $e) { - $patches = $patchinfo->listPatches($bug_id); + redirect("patch-display.php?bug={$bug_id}&patch={$patch_name_url}&revision={$revision}"); + } catch (\Exception $e) { + $patches = $patchRepository->findAllByBugId($bug_id); include "{$ROOT_DIR}/templates/addpatch.php"; exit; } @@ -115,27 +118,27 @@ if (isset($_POST['addpatch'])) { $email = $auth_user->email; } - PEAR::pushErrorHandling(PEAR_ERROR_RETURN); - $e = $patchinfo->attach($bug_id, 'patch', $patch_name, $auth_user->email, $_POST['obsoleted']); - PEAR::popErrorHandling(); - if (PEAR::isError($e)) { - $patches = $patchinfo->listPatches($bug_id); - $errors = [$e->getMessage(), - 'Could not attach patch "' . - htmlspecialchars($patch_name) . - '" to Bug #' . $bug_id]; + try { + $revision = $patchTracker->attach($bug_id, 'patch', $patch_name, $auth_user->email, $_POST['obsoleted']); + } catch (\Exception $e) { + $patches = $patchRepository->findAllByBugId($bug_id); + $errors = [ + $e->getMessage(), + 'Could not attach patch "'.htmlspecialchars($patch_name, ENT_QUOTES).'" to Bug #'.$bug_id + ]; include "{$ROOT_DIR}/templates/addpatch.php"; + exit; } // Add a comment to the bug report. - $patch_url = "{$site_method}://{$site_url}{$basedir}/patch-display.php?bug={$bug_id}&patch={$patch_name_url}&revision={$e}"; + $patch_url = "{$site_method}://{$site_url}{$basedir}/patch-display.php?bug={$bug_id}&patch={$patch_name_url}&revision={$revision}"; $text = <<<TXT The following patch has been added/updated: Patch Name: {$patch_name} -Revision: {$e} +Revision: {$revision} URL: {$patch_url} TXT; @@ -144,13 +147,13 @@ TXT; // Send emails mail_bug_updates($buginfo, $buginfo, $auth_user->email, $text, 4, $bug_id); - $patches = $patchinfo->listPatches($bug_id); + $patches = $patchRepository->findAllByBugId($bug_id); $errors = []; include "{$ROOT_DIR}/templates/patchadded.php"; exit; } $email = isset($_GET['email']) ? $_GET['email'] : ''; -$patches = $patchinfo->listPatches($bug_id); +$patches = $patchRepository->findAllByBugId($bug_id); include "{$ROOT_DIR}/templates/addpatch.php"; diff --git a/www/patch-display.php b/www/patch-display.php index 831e340..bcf82f5 100644 --- a/www/patch-display.php +++ b/www/patch-display.php @@ -1,10 +1,20 @@ <?php +use App\Repository\ObsoletePatchRepository; +use App\Repository\PatchRepository; +use App\Utils\PatchTracker; +use App\Utils\Uploader; + session_start(); // Obtain common includes require_once '../include/prepend.php'; +$obsoletePatchRepository = new ObsoletePatchRepository($dbh); +$patchRepository = new PatchRepository($dbh); +$uploader = new Uploader(); +$patchTracker = new PatchTracker($dbh, $uploader); + // Authenticate bugs_authenticate($user, $pw, $logged_in, $user_flags); @@ -30,9 +40,6 @@ if (empty($bug_id)) { $bug_id = (int) $_GET['bug_id']; } -require "{$ROOT_DIR}/include/classes/bug_patchtracker.php"; -$patchinfo = new Bug_Patchtracker; - if (!($buginfo = bugs_get_bug($bug_id))) { response_header('Error :: invalid bug selected'); display_bug_error("Invalid bug #{$bug_id} selected"); @@ -51,13 +58,13 @@ $pseudo_pkgs = get_pseudo_packages(false); if (isset($patch_name) && isset($revision)) { if ($revision == 'latest') { - $revisions = $patchinfo->listRevisions($buginfo['id'], $patch_name); + $revisions = $patchRepository->findRevisions($buginfo['id'], $patch_name); if (isset($revisions[0])) { $revision = $revisions[0]['revision']; } } - $path = $patchinfo->getPatchFullpath($bug_id, $patch_name, $revision); + $path = $patchTracker->getPatchFullpath($bug_id, $patch_name, $revision); if (!file_exists($path)) { response_header('Error :: no such patch/revision'); display_bug_error('Invalid patch/revision specified'); @@ -73,9 +80,10 @@ if (isset($patch_name) && isset($revision)) { readfile($path); exit; } - $patchcontents = $patchinfo->getPatch($buginfo['id'], $patch_name, $revision); - if (PEAR::isError($patchcontents)) { + try { + $patchcontents = $patchRepository->getPatchContents($buginfo['id'], $patch_name, $revision); + } catch (\Exception $e) { response_header('Error :: Cannot retrieve patch'); display_bug_error('Internal error: Invalid patch/revision specified (is in database, but not in filesystem)'); response_footer(); @@ -83,17 +91,17 @@ if (isset($patch_name) && isset($revision)) { } $package_name = $buginfo['package_name']; - $handle = $patchinfo->getDeveloper($bug_id, $patch_name, $revision); - $obsoletedby = $patchinfo->getObsoletingPatches($bug_id, $patch_name, $revision); - $obsoletes = $patchinfo->getObsoletePatches($bug_id, $patch_name, $revision); - $patches = $patchinfo->listPatches($bug_id); - $revisions = $patchinfo->listRevisions($bug_id, $patch_name); + $handle = $patchRepository->findDeveloper($bug_id, $patch_name, $revision); + $obsoletedby = $obsoletePatchRepository->findObsoletingPatches($bug_id, $patch_name, $revision); + $obsoletes = $obsoletePatchRepository->findObsoletePatches($bug_id, $patch_name, $revision); + $patches = $patchRepository->findAllByBugId($bug_id); + $revisions = $patchRepository->findRevisions($bug_id, $patch_name); response_header("Bug #{$bug_id} :: Patches"); include "{$ROOT_DIR}/templates/listpatches.php"; if (isset($_GET['diff']) && $_GET['diff'] && isset($_GET['old']) && is_numeric($_GET['old'])) { - $old = $patchinfo->getPatchFullpath($bug_id, $patch_name, $_GET['old']); + $old = $patchTracker->getPatchFullpath($bug_id, $patch_name, $_GET['old']); $new = $path; if (!realpath($old) || !realpath($new)) { response_header('Error :: Cannot retrieve patch'); @@ -116,7 +124,7 @@ if (isset($patch_name) && isset($revision)) { exit; } -$patches = $patchinfo->listPatches($bug_id); +$patches = $patchTracker->listPatches($bug_id); response_header("Bug #{$bug_id} :: Patches"); include "{$ROOT_DIR}/templates/listpatches.php"; response_footer(); diff --git a/www/report.php b/www/report.php index c2a52f4..9cd6414 100644 --- a/www/report.php +++ b/www/report.php @@ -1,6 +1,8 @@ <?php use App\Utils\Captcha; +use App\Utils\PatchTracker; +use App\Utils\Uploader; // Obtain common includes require_once '../include/prepend.php'; @@ -225,12 +227,13 @@ OUTPUT; $redirectToPatchAdd = false; if (!empty($_POST['in']['patchname']) && $_POST['in']['patchname']) { - require_once "{$ROOT_DIR}/include/classes/bug_patchtracker.php"; - $tracker = new Bug_Patchtracker; - PEAR::staticPushErrorHandling(PEAR_ERROR_RETURN); - $patchrevision = $tracker->attach($cid, 'patchfile', $_POST['in']['patchname'], $_POST['in']['handle'], []); - PEAR::staticPopErrorHandling(); - if (PEAR::isError($patchrevision)) { + $uploader = new Uploader(); + $tracker = new PatchTracker($dbh, $uploader); + + try { + $developer = !empty($_POST['in']['handle']) ? $_POST['in']['handle'] : $_POST['in']['email']; + $patchrevision = $tracker->attach($cid, 'patchfile', $_POST['in']['patchname'], $developer, []); + } catch (\Exception $e) { $redirectToPatchAdd = true; } }
-- PHP Webmaster List Mailing List (http://www.php.net/) To unsubscribe, visit: http://www.php.net/unsub.php