xref: /web-bugs/src/Utils/PatchTracker.php (revision 5cd2630a)
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