Passed
Push — master ( 83cee0...2131d7 )
by Andreas
19:23
created

midcom_db_attachment   B

Complexity

Total Complexity 51

Size/Duplication

Total Lines 374
Duplicated Lines 0 %

Test Coverage

Coverage 62.89%

Importance

Changes 0
Metric Value
eloc 144
dl 0
loc 374
ccs 100
cts 159
cp 0.6289
rs 7.92
c 0
b 0
f 0
wmc 51

18 Methods

Rating   Name   Duplication   Size   Complexity  
A copy_from_handle() 0 12 2
A copy_from_file() 0 11 2
A open() 0 25 4
A _on_deleted() 0 5 2
A _create_attachment_location() 0 20 3
A safe_filename() 0 20 3
A close() 0 20 4
A copy_from_memory() 0 12 2
A get_cache_path() 0 10 2
A get_url() 0 26 6
A update_cache() 0 7 3
A get_path() 0 6 2
A _on_updated() 0 3 1
A remove_from_cache() 0 5 2
A read() 0 4 1
A stat() 0 15 3
A _on_creating() 0 9 2
B file_to_cache() 0 32 7

How to fix   Complexity   

Complex Class

Complex classes like midcom_db_attachment often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use midcom_db_attachment, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @package midcom.db
4
 * @author The Midgard Project, http://www.midgard-project.org
5
 * @copyright The Midgard Project, http://www.midgard-project.org
6
 * @license http://www.gnu.org/licenses/lgpl.html GNU Lesser General Public License
7
 */
8
9
use midgard\portable\api\blob;
10
11
/**
12
 * MidCOM level replacement for the Midgard Attachment record with framework support.
13
 *
14
 * @property string $name Filename of the attachment
15
 * @property string $title Title of the attachment
16
 * @property string $location Location of the attachment in the blob directory structure
17
 * @property string $mimetype MIME type of the attachment
18
 * @property string $parentguid GUID of the object the attachment is attached to
19
 * @package midcom.db
20
 */
21
class midcom_db_attachment extends midcom_core_dbaobject
22
{
23
    public $__midcom_class_name__ = __CLASS__;
24
    public $__mgdschema_class_name__ = 'midgard_attachment';
25
26
    public $_use_rcs = false;
27
28
    /**
29
     * Internal tracking state variable, holds the file handle of any open
30
     * attachment.
31
     *
32
     * @var resource
33
     */
34
    private $_open_handle;
35
36
    /**
37
     * Internal tracking state variable, true if the attachment has a handle opened in write mode
38
     */
39
    private $_open_write_mode = false;
40
41
    /**
42
     * Opens the attachment for file IO.
43
     *
44
     * Returns a filehandle that can be used with the usual PHP file functions if successful,
45
     * the handle has to be closed with the close() method when you no longer need it, don't
46
     * let it fall over the end of the script.
47
     *
48
     * <b>Important Note:</b> It is important to use the close() member function of
49
     * this class to close the file handle, not just fclose(). Otherwise, the upgrade
50
     * notification switches will fail.
51
     *
52
     * @param string $mode The mode which should be used to open the attachment, same as
53
     *     the mode parameter of the PHP fopen call. This defaults to write access.
54
     * @return resource A file handle to the attachment if successful, false on failure.
55
     */
56 8
    public function open(string $mode = 'w')
57
    {
58 8
        if (!$this->id) {
59
            debug_add('Cannot open a non-persistent attachment.', MIDCOM_LOG_WARN);
60
            debug_print_r('Object state:', $this);
61
            return false;
62
        }
63
64 8
        if ($this->_open_handle !== null) {
65
            debug_add("Warning, the Attachment {$this->id} already had an open file handle, we close it implicitly.", MIDCOM_LOG_WARN);
66
            $this->close();
67
        }
68
69 8
        $blob = new blob($this->__object);
70 8
        $handle = $blob->get_handler($mode);
71
72 8
        if (!$handle) {
0 ignored issues
show
introduced by
$handle is of type resource, thus it always evaluated to false.
Loading history...
73
            debug_add("Failed to open attachment with mode {$mode}, last Midgard error was: " . midcom_connection::get_error_string(), MIDCOM_LOG_WARN);
74
            return false;
75
        }
76
77 8
        $this->_open_write_mode = ($mode[0] != 'r');
78 8
        $this->_open_handle = $handle;
79
80 8
        return $handle;
81
    }
82
83
    /**
84
     * Read the file and return its contents
85
     */
86
    public function read() : ?string
87
    {
88
        $blob = new blob($this->__object);
89
        return $blob->read_content();
90
    }
91
92
    /**
93
     * Close the open write handle obtained by the open() call again.
94
     * It is required to call this function instead of a simple fclose to ensure proper
95
     * upgrade notifications.
96
     */
97 8
    public function close()
98
    {
99 8
        if ($this->_open_handle === null) {
100
            debug_add("Tried to close non-open attachment {$this->id}", MIDCOM_LOG_WARN);
101
            return;
102
        }
103
104 8
        fclose($this->_open_handle);
105 8
        $this->_open_handle = null;
106
107 8
        if ($this->_open_write_mode) {
108
            // We need to update the attachment now, this cannot be done in the Midgard Core
109
            // at this time.
110 8
            if (!$this->update()) {
111 1
                debug_add("Failed to update attachment {$this->id}", MIDCOM_LOG_WARN);
112 1
                return;
113
            }
114
115 7
            $this->file_to_cache();
116 7
            $this->_open_write_mode = false;
117
        }
118 8
    }
119
120
    /**
121
     * Rewrite a filename to URL safe form
122
     *
123
     * @param string $filename file name to rewrite
124
     * @param boolean $force_single_extension force file to single extension (defaults to true)
125
     * @todo add possibility to use the file utility to determine extension if missing.
126
     */
127 9
    public static function safe_filename(string $filename, bool $force_single_extension = true) : string
128
    {
129
        // we could use basename() here, except that it swallows multibyte chars at the
130
        // beginning of the string if we run in e.g. C locale..
131 9
        $parts = explode('/', trim($filename));
132 9
        $filename = end($parts);
133
134 9
        if ($force_single_extension) {
135 6
            $regex = '/^(.*)(\..*?)$/';
136
        } else {
137 3
            $regex = '/^(.*?)(\.[a-zA-Z0-9\.]*)$/';
138
        }
139 9
        if (preg_match($regex, $filename, $ext_matches)) {
140 5
            $name = $ext_matches[1];
141 5
            $ext = $ext_matches[2];
142
        } else {
143 4
            $name = $filename;
144 4
            $ext = '';
145
        }
146 9
        return midcom_helper_misc::urlize($name) . $ext;
147
    }
148
149
    /**
150
     * Get the path to the document in the static cache
151
     */
152 1
    private function get_cache_path() : string
153
    {
154
        // Copy the file to the static directory
155 1
        $cacheroot = midcom::get()->config->get('attachment_cache_root');
156 1
        $subdir = $this->guid[0];
157 1
        if (!file_exists("{$cacheroot}/{$subdir}/{$this->guid}")) {
158 1
            mkdir("{$cacheroot}/{$subdir}/{$this->guid}", 0777, true);
159
        }
160
161 1
        return "{$cacheroot}/{$subdir}/{$this->guid}/{$this->name}";
162
    }
163
164 6
    public static function get_url($attachment, string $name = null) : string
165
    {
166 6
        if (is_string($attachment)) {
167
            $guid = $attachment;
168
            if (null === $name) {
169
                $mc = self::new_collector('guid', $guid);
170
                $names = $mc->get_values('name');
171
                $name = array_pop($names);
172
            }
173 6
        } elseif (midcom::get()->dbfactory->is_a($attachment, 'midgard_attachment')) {
174 6
            $guid = $attachment->guid;
175 6
            $name = $attachment->name;
176
        } else {
177
            throw new midcom_error('Invalid attachment identifier');
178
        }
179
180 6
        if (midcom::get()->config->get('attachment_cache_enabled')) {
181
            $subdir = $guid[0];
182
183
            if (file_exists(midcom::get()->config->get('attachment_cache_root') . '/' . $subdir . '/' . $guid . '/' . $name)) {
184
                return midcom::get()->config->get('attachment_cache_url') . '/' . $subdir . '/' . $guid . '/' . urlencode($name);
185
            }
186
        }
187
188
        // Use regular MidCOM attachment server
189 6
        return midcom_connection::get_url('self') . 'midcom-serveattachmentguid-' . $guid . '/' . urlencode($name);
190
    }
191
192 7
    public function file_to_cache()
193
    {
194 7
        if (!midcom::get()->config->get('attachment_cache_enabled')) {
195 7
            return;
196
        }
197
198 1
        if (!$this->can_do('midgard:read', 'EVERYONE')) {
199
            debug_add("Attachment {$this->name} ({$this->guid}) is not publicly readable, not caching.");
200
            $this->remove_from_cache();
201
            return;
202
        }
203
204 1
        $filename = $this->get_cache_path();
205
206 1
        if (file_exists($filename) && is_link($filename)) {
207
            debug_add("Attachment {$this->name} ({$this->guid}) is already in cache as {$filename}, skipping.");
208
            return;
209
        }
210
211
        // Then symlink the file
212 1
        if (@symlink($this->get_path(), $filename)) {
213 1
            debug_add("Symlinked attachment {$this->name} ({$this->guid}) as {$filename}.");
214 1
            return;
215
        }
216
217
        // Symlink failed, actually copy the data
218
        if (!copy($this->get_path(), $filename)) {
219
            debug_add("Failed to cache attachment {$this->name} ({$this->guid}), copying failed.");
220
            return;
221
        }
222
223
        debug_add("Symlinking attachment {$this->name} ({$this->guid}) as {$filename} failed, data copied instead.");
224
    }
225
226
    private function remove_from_cache()
227
    {
228
        $filename = $this->get_cache_path();
229
        if (file_exists($filename)) {
230
            @unlink($filename);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

230
            /** @scrutinizer ignore-unhandled */ @unlink($filename);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
231
        }
232
    }
233
234
    /**
235
     * Simple wrapper for stat() on the blob object.
236
     *
237
     * @return mixed Either a stat array as for stat() or false on failure.
238
     */
239 4
    public function stat()
240
    {
241 4
        if (!$this->id) {
242
            debug_add('Cannot open a non-persistent attachment.', MIDCOM_LOG_WARN);
243
            debug_print_r('Object state:', $this);
244
            return false;
245
        }
246
247 4
        $path = $this->get_path();
248 4
        if (!file_exists($path)) {
249
            debug_add("File {$path} that blob {$this->guid} points to cannot be found", MIDCOM_LOG_WARN);
250
            return false;
251
        }
252
253 4
        return stat($path);
254
    }
255
256 8
    public function get_path() : string
257
    {
258 8
        if (!$this->id) {
259
            return '';
260
        }
261 8
        return (new blob($this->__object))->get_path();
262
    }
263
264
    /**
265
     * Internal helper, computes an MD5 string which is used as an attachment location.
266
     * If the location already exists, it will iterate until an unused location is found.
267
     */
268 11
    private function _create_attachment_location() : string
269
    {
270 11
        $max_tries = 500;
271
272 11
        for ($i = 0; $i < $max_tries; $i++) {
273 11
            $name = strtolower(md5(uniqid('', true)));
274 11
            $location = strtoupper($name[0] . '/' . $name[1]) . '/' . $name;
275
276
            // Check uniqueness
277 11
            $qb = self::new_query_builder();
278 11
            $qb->add_constraint('location', '=', $location);
279 11
            $result = $qb->count_unchecked();
280
281 11
            if ($result == 0) {
282 11
                debug_add("Created this location: {$location}");
283 11
                return $location;
284
            }
285
            debug_add("Location {$location} is in use, retrying");
286
        }
287
        throw new midcom_error('could not create attachment location');
288
    }
289
290
    /**
291
     * Simple creation event handler which fills out the location field if it
292
     * is still empty with a location generated by _create_attachment_location().
293
     *
294
     * @return boolean True if creation may commence.
295
     */
296 11
    public function _on_creating()
297
    {
298 11
        if (empty($this->mimetype)) {
299 5
            $this->mimetype = 'application/octet-stream';
300
        }
301
302 11
        $this->location = $this->_create_attachment_location();
303
304 11
        return true;
305
    }
306
307 8
    public function update_cache()
308
    {
309
        // Check if the attachment can be read anonymously
310 8
        if (   midcom::get()->config->get('attachment_cache_enabled')
311 8
            && !$this->can_do('midgard:read', 'EVERYONE')) {
312
            // Not public file, ensure it is removed
313
            $this->remove_from_cache();
314
        }
315 8
    }
316
317
    /**
318
     * Updated callback, triggers watches on the parent(!) object.
319
     */
320 8
    public function _on_updated()
321
    {
322 8
        $this->update_cache();
323 8
    }
324
325
    /**
326
     * Deleted callback, triggers watches on the parent(!) object.
327
     */
328 9
    public function _on_deleted()
329
    {
330 9
        if (midcom::get()->config->get('attachment_cache_enabled')) {
331
            // Remove attachment cache
332
            $this->remove_from_cache();
333
        }
334 9
    }
335
336
    /**
337
     * Updates the contents of the attachments with the contents given.
338
     *
339
     * @param mixed $source File contents.
340
     * @return boolean Indicating success.
341
     */
342
    public function copy_from_memory($source) : bool
343
    {
344
        $dest = $this->open();
345
        if (!$dest) {
0 ignored issues
show
introduced by
$dest is of type resource, thus it always evaluated to false.
Loading history...
346
            debug_add('Could not open attachment for writing, last Midgard error was: ' . midcom_connection::get_error_string(), MIDCOM_LOG_WARN);
347
            return false;
348
        }
349
350
        fwrite($dest, $source);
351
352
        $this->close();
353
        return true;
354
    }
355
356
    /**
357
     * Updates the contents of the attachments with the contents of the resource identified
358
     * by the filehandle passed.
359
     *
360
     * @param resource $source The handle to read from.
361
     * @return boolean Indicating success.
362
     */
363 8
    public function copy_from_handle($source) : bool
364
    {
365 8
        $dest = $this->open();
366 8
        if (!$dest) {
0 ignored issues
show
introduced by
$dest is of type resource, thus it always evaluated to false.
Loading history...
367
            debug_add('Could not open attachment for writing, last Midgard error was: ' . midcom_connection::get_error_string(), MIDCOM_LOG_WARN);
368
            return false;
369
        }
370
371 8
        stream_copy_to_stream($source, $dest);
372
373 8
        $this->close();
374 8
        return true;
375
    }
376
377
    /**
378
     * Updates the contents of the attachments with the contents of the file specified.
379
     * This is a wrapper for copy_from_handle.
380
     *
381
     * @param string $filename The file to read.
382
     * @return boolean Indicating success.
383
     */
384 5
    public function copy_from_file($filename) : bool
385
    {
386 5
        $source = @fopen($filename, 'r');
387 5
        if (!$source) {
0 ignored issues
show
introduced by
$source is of type false|resource, thus it always evaluated to false.
Loading history...
388
            debug_add('Could not open file for reading.' . midcom_connection::get_error_string(), MIDCOM_LOG_WARN);
389
            midcom::get()->debug->log_php_error(MIDCOM_LOG_WARN);
390
            return false;
391
        }
392 5
        $result = $this->copy_from_handle($source);
393 5
        fclose($source);
394 5
        return $result;
395
    }
396
}
397