1<?php 2 3namespace App\Utils; 4 5/** 6 * Service for handling uploaded patches. 7 */ 8class PatchTracker 9{ 10 /** 11 * Database handler. 12 * @var \PDO 13 */ 14 private $dbh; 15 16 /** 17 * File upload service. 18 * @var Uploader 19 */ 20 private $uploader; 21 22 /** 23 * Parent directory where patches are uploaded. 24 * @var string 25 */ 26 private $uploadsDir; 27 28 /** 29 * Maximum allowed patch file size. 30 */ 31 const MAX_FILE_SIZE = 100 * 1024; 32 33 /** 34 * Valid media types (former MIME types) for the uploaded patch files. 35 */ 36 const VALID_MEDIA_TYPES = [ 37 'application/x-txt', 38 'text/plain', 39 'text/x-diff', 40 'text/x-patch', 41 'text/x-c++', 42 'text/x-c', 43 'text/x-m4', 44 ]; 45 46 /** 47 * Class constructor. 48 */ 49 public function __construct(\PDO $dbh, Uploader $uploader, string $uploadsDir) 50 { 51 $this->dbh = $dbh; 52 $this->uploadsDir = $uploadsDir; 53 54 $this->uploader = $uploader; 55 $this->uploader->setMaxFileSize(self::MAX_FILE_SIZE); 56 $this->uploader->setValidMediaTypes(self::VALID_MEDIA_TYPES); 57 } 58 59 /** 60 * Create a parent uploads directory for patches if it is missing. 61 */ 62 private function createUploadsDir(): void 63 { 64 if (!file_exists($this->uploadsDir) && !@mkdir($this->uploadsDir)) { 65 throw new \Exception('Patches upload directory could not be created.'); 66 } 67 } 68 69 /** 70 * Get the directory in which patches for given bug id should be stored. 71 */ 72 private function getPatchDir(int $bugId, string $name): string 73 { 74 return $this->uploadsDir.'/p'.$bugId.'/'.$name; 75 } 76 77 /** 78 * Create the directory in which patches for the given bug id will be stored. 79 */ 80 private function createPatchDir(int $bugId, string $name): void 81 { 82 $patchDir = $this->getPatchDir($bugId, $name); 83 $parentDir = dirname($patchDir); 84 85 // Check if patch directory already exists. 86 if (is_dir($patchDir)) { 87 return; 88 } 89 90 // Check if files with same names as directories already exist. 91 if (is_file($parentDir) || is_file($patchDir)) { 92 throw new \Exception('Cannot create patch storage for Bug #'.$bugId.', storage directory exists and is not a directory'); 93 } 94 95 // Create parent directory 96 if (!file_exists($parentDir) && !@mkdir($parentDir)) { 97 throw new \Exception('Cannot create patch storage for Bug #'.$bugId); 98 } 99 100 // Create patch directory 101 if (!@mkdir($patchDir)) { 102 throw new \Exception('Cannot create patch storage for Bug #'.$bugId); 103 } 104 } 105 106 /** 107 * Retrieve a unique, ordered patch filename. 108 */ 109 private function newPatchFileName(int $bugId, string $patch, string $developer): int 110 { 111 $revision = time(); 112 113 $sql = 'INSERT INTO bugdb_patchtracker 114 (bugdb_id, patch, revision, developer) VALUES (?, ?, ?, ?) 115 '; 116 117 try { 118 $this->dbh->prepare($sql)->execute([$bugId, $patch, $revision, $developer]); 119 } catch (\Exception $e) { 120 // Try with another timestamp 121 try { 122 $revision++; 123 $this->dbh->prepare($sql)->execute([$bugId, $patch, $revision, $developer]); 124 } catch (\Exception $e) { 125 throw new \Exception('Could not get unique patch file name for bug #'.$bugId.', patch "'.$patch.'"'); 126 } 127 } 128 129 return $revision; 130 } 131 132 /** 133 * Retrieve the name of the patch file on the system. 134 */ 135 private function getPatchFileName(int $revision): string 136 { 137 return 'p'.$revision.'.patch.txt'; 138 } 139 140 /** 141 * Retrieve the full path to a patch file. 142 */ 143 public function getPatchFullpath(int $bugId, string $name, int $revision): string 144 { 145 return $this->getPatchDir($bugId, $name).'/'.$this->getPatchFileName($revision); 146 } 147 148 /** 149 * Attach a patch to this bug. 150 */ 151 public function attach(int $bugId, string $patch, string $name, string $developer, array $obsoletes = []): int 152 { 153 $this->uploader->setDir($this->getPatchDir($bugId, $name)); 154 155 if (!is_array($obsoletes)) { 156 throw new \Exception('Invalid obsoleted patches'); 157 } 158 159 try { 160 $revision = $this->newPatchFileName($bugId, $name, $developer); 161 $this->uploader->setDestinationFileName($this->getPatchFileName($revision)); 162 } catch (\Exception $e) { 163 throw new \Exception($e->getMessage()); 164 } 165 166 try { 167 $this->createUploadsDir(); 168 169 $this->validatePatchName($name); 170 171 $this->createPatchDir($bugId, $name); 172 173 $this->uploader->upload($patch); 174 } catch (\Exception $e) { 175 $this->detach($bugId, $name, $revision); 176 177 throw new \Exception($e->getMessage()); 178 } 179 180 $newObsoletes = []; 181 foreach ($obsoletes as $obsoletePatch) { 182 // The none option in form. 183 if (!$obsoletePatch) { 184 continue; 185 } 186 187 $obsoletePatch = explode('#', $obsoletePatch); 188 189 if (count($obsoletePatch) != 2) { 190 continue; 191 } 192 193 if (file_exists($this->getPatchFullpath($bugId, $obsoletePatch[0], $obsoletePatch[1]))) { 194 $newObsoletes[] = $obsoletePatch; 195 } 196 } 197 198 foreach ($newObsoletes as $obsolete) { 199 $this->obsoletePatch($bugId, $name, $revision, $obsolete[0], $obsolete[1]); 200 } 201 202 return $revision; 203 } 204 205 /** 206 * Validate patch name. 207 */ 208 private function validatePatchName(string $name): void 209 { 210 if (!preg_match('/^[\w\-\.]+\z/', $name) || strlen($name) > 80) { 211 throw new \Exception('Invalid patch name "'.htmlspecialchars($name, ENT_QUOTES).'"'); 212 } 213 } 214 215 /** 216 * Remove a patch revision from this bug. 217 */ 218 private function detach(int $bugId, string $name, int $revision): void 219 { 220 $sql = 'DELETE FROM bugdb_patchtracker 221 WHERE bugdb_id = ? AND patch = ? AND revision = ? 222 '; 223 224 $this->dbh->prepare($sql)->execute([$bugId, $name, $revision]); 225 226 @unlink($this->getPatchFullpath($bugId, $name, $revision)); 227 } 228 229 /** 230 * Make patch obsolete by new patch. This create a link to an obsolete patch 231 * from the new one. 232 */ 233 private function obsoletePatch(int $bugId, string $name, int $revision, string $obsoleteName, int $obsoleteRevision): void 234 { 235 $sql = 'INSERT INTO bugdb_obsoletes_patches VALUES (?, ?, ?, ?, ?)'; 236 237 $this->dbh->prepare($sql)->execute([$bugId, $name, $revision, $obsoleteName, $obsoleteRevision]); 238 } 239} 240