Completed
Push — master ( eaac63...8dbf53 )
by Andreas
24:16
created

midcom_db_attachment::get_cache_path()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 0
dl 0
loc 14
ccs 8
cts 8
cp 1
crap 3
rs 10
c 0
b 0
f 0
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 12
    public function get_parent_guid_uncached()
42
    {
43 12
        return $this->parentguid;
44
    }
45
46
    public static function get_parent_guid_uncached_static($guid) : ?string
47
    {
48
        $mc = new midgard_collector('midgard_attachment', 'guid', $guid);
49
        $mc->set_key_property('parentguid');
50
        $mc->execute();
51
        return key($mc->list_keys());
52
    }
53
54
    /**
55
     * Opens the attachment for file IO.
56
     *
57
     * Returns a filehandle that can be used with the usual PHP file functions if successful,
58
     * the handle has to be closed with the close() method when you no longer need it, don't
59
     * let it fall over the end of the script.
60
     *
61
     * <b>Important Note:</b> It is important to use the close() member function of
62
     * this class to close the file handle, not just fclose(). Otherwise, the upgrade
63
     * notification switches will fail.
64
     *
65
     * @param string $mode The mode which should be used to open the attachment, same as
66
     *     the mode parameter of the PHP fopen call. This defaults to write access.
67
     * @return resource A file handle to the attachment if successful, false on failure.
68
     */
69 9
    public function open($mode = 'w')
70
    {
71 9
        if (!$this->id) {
72
            debug_add('Cannot open a non-persistent attachment.', MIDCOM_LOG_WARN);
73
            debug_print_r('Object state:', $this);
74
            return false;
75
        }
76
77 9
        if ($this->_open_handle !== null) {
78
            debug_add("Warning, the Attachment {$this->id} already had an open file handle, we close it implicitly.", MIDCOM_LOG_WARN);
79
            $this->close();
80
        }
81
82 9
        $blob = new blob($this->__object);
83 9
        $handle = $blob->get_handler($mode);
84
85 9
        if (!$handle) {
0 ignored issues
show
introduced by
$handle is of type false|resource, thus it always evaluated to false.
Loading history...
86
            debug_add("Failed to open attachment with mode {$mode}, last Midgard error was: " . midcom_connection::get_error_string(), MIDCOM_LOG_WARN);
87
            return false;
88
        }
89
90 9
        $this->_open_write_mode = ($mode[0] != 'r');
91 9
        $this->_open_handle = $handle;
92
93 9
        return $handle;
94
    }
95
96
    /**
97
     * Read the file and return its contents
98
     *
99
     * @return string
100
     */
101
    public function read()
102
    {
103
        $blob = new blob($this->__object);
104
        return $blob->read_content();
105
    }
106
107
    /**
108
     * Close the open write handle obtained by the open() call again.
109
     * It is required to call this function instead of a simple fclose to ensure proper
110
     * upgrade notifications.
111
     */
112 9
    public function close()
113
    {
114 9
        if ($this->_open_handle === null) {
115
            debug_add("Tried to close non-open attachment {$this->id}", MIDCOM_LOG_WARN);
116
            return;
117
        }
118
119 9
        fclose($this->_open_handle);
120 9
        $this->_open_handle = null;
121
122 9
        if ($this->_open_write_mode) {
123
            // We need to update the attachment now, this cannot be done in the Midgard Core
124
            // at this time.
125 9
            if (!$this->update()) {
126 1
                debug_add("Failed to update attachment {$this->id}", MIDCOM_LOG_WARN);
127 1
                return;
128
            }
129
130 8
            $this->file_to_cache();
131 8
            $this->_open_write_mode = false;
132
        }
133 9
    }
134
135
    /**
136
     * Rewrite a filename to URL safe form
137
     *
138
     * @param string $filename file name to rewrite
139
     * @param boolean $force_single_extension force file to single extension (defaults to true)
140
     * @todo add possibility to use the file utility to determine extension if missing.
141
     */
142 9
    public static function safe_filename($filename, $force_single_extension = true) : string
143
    {
144
        // we could use basename() here, except that it swallows multibyte chars at the
145
        // beginning of the string if we run in e.g. C locale..
146 9
        $parts = explode('/', trim($filename));
147 9
        $filename = end($parts);
148
149 9
        if ($force_single_extension) {
150 6
            $regex = '/^(.*)(\..*?)$/';
151
        } else {
152 3
            $regex = '/^(.*?)(\.[a-zA-Z0-9\.]*)$/';
153
        }
154 9
        if (preg_match($regex, $filename, $ext_matches)) {
155 5
            $name = $ext_matches[1];
156 5
            $ext = $ext_matches[2];
157
        } else {
158 4
            $name = $filename;
159 4
            $ext = '';
160
        }
161 9
        return midcom_helper_misc::urlize($name) . $ext;
162
    }
163
164
    /**
165
     * Get the path to the document in the static cache
166
     */
167 2
    public function get_cache_path() : ?string
168
    {
169 2
        if (!midcom::get()->config->get('attachment_cache_enabled')) {
170 1
            return null;
171
        }
172
173
        // Copy the file to the static directory
174 2
        $cacheroot = midcom::get()->config->get('attachment_cache_root');
175 2
        $subdir = substr($this->guid, 0, 1);
176 2
        if (!file_exists("{$cacheroot}/{$subdir}/{$this->guid}")) {
177 2
            mkdir("{$cacheroot}/{$subdir}/{$this->guid}", 0777, true);
178
        }
179
180 2
        return "{$cacheroot}/{$subdir}/{$this->guid}/{$this->name}";
181
    }
182
183 6
    public static function get_url($attachment, $name = null) : string
184
    {
185 6
        if (is_string($attachment)) {
186
            $guid = $attachment;
187
            if (null === $name) {
188
                $mc = self::new_collector('guid', $guid);
189
                $names = $mc->get_values('name');
190
                $name = array_pop($names);
191
            }
192 6
        } elseif (midcom::get()->dbfactory->is_a($attachment, 'midgard_attachment')) {
193 6
            $guid = $attachment->guid;
194 6
            $name = $attachment->name;
195
        } else {
196
            throw new midcom_error('Invalid attachment identifier');
197
        }
198
199 6
        if (midcom::get()->config->get('attachment_cache_enabled')) {
200
            $subdir = substr($guid, 0, 1);
201
202
            if (file_exists(midcom::get()->config->get('attachment_cache_root') . '/' . $subdir . '/' . $guid . '/' . $name)) {
203
                return midcom::get()->config->get('attachment_cache_url') . '/' . $subdir . '/' . $guid . '/' . urlencode($name);
204
            }
205
        }
206
207
        // Use regular MidCOM attachment server
208 6
        return midcom_connection::get_url('self') . 'midcom-serveattachmentguid-' . $guid . '/' . urlencode($name);
209
    }
210
211 8
    public function file_to_cache()
212
    {
213
        // Check if the attachment can be read anonymously
214 8
        if (!midcom::get()->config->get('attachment_cache_enabled')) {
215 8
            return;
216
        }
217
218 1
        if (!$this->can_do('midgard:read', 'EVERYONE')) {
219
            debug_add("Attachment {$this->name} ({$this->guid}) is not publicly readable, not caching.");
220
            return;
221
        }
222
223 1
        $filename = $this->get_cache_path();
224
225 1
        if (!$filename) {
226
            debug_add("Failed to generate cache path for attachment {$this->name} ({$this->guid}), not caching.");
227
            return;
228
        }
229
230 1
        if (file_exists($filename) && is_link($filename)) {
231
            debug_add("Attachment {$this->name} ({$this->guid}) is already in cache as {$filename}, skipping.");
232
            return;
233
        }
234
235
        // Then symlink the file
236 1
        $blob = new blob($this->__object);
237
238 1
        if (@symlink($blob->get_path(), $filename)) {
239 1
            debug_add("Symlinked attachment {$this->name} ({$this->guid}) as {$filename}.");
240 1
            return;
241
        }
242
243
        // Symlink failed, actually copy the data
244
        if (!copy($blob->get_path(), $filename)) {
245
            debug_add("Failed to cache attachment {$this->name} ({$this->guid}), copying failed.");
246
            return;
247
        }
248
249
        debug_add("Symlinking attachment {$this->name} ({$this->guid}) as {$filename} failed, data copied instead.");
250
    }
251
252
    /**
253
     * Simple wrapper for stat() on the blob object.
254
     *
255
     * @return mixed Either a stat array as for stat() or false on failure.
256
     */
257 4
    public function stat()
258
    {
259 4
        if (!$this->id) {
260
            debug_add('Cannot open a non-persistent attachment.', MIDCOM_LOG_WARN);
261
            debug_print_r('Object state:', $this);
262
            return false;
263
        }
264
265 4
        $blob = new blob($this->__object);
266
267 4
        $path = $blob->get_path();
268 4
        if (!file_exists($path)) {
269
            debug_add("File {$path} that blob {$this->guid} points to cannot be found", MIDCOM_LOG_WARN);
270
            return false;
271
        }
272
273 4
        return stat($path);
274
    }
275
276
    /**
277
     * Internal helper, computes an MD5 string which is used as an attachment location.
278
     * If the location already exists, it will iterate until an unused location is found.
279
     */
280 12
    private function _create_attachment_location() : string
281
    {
282 12
        $max_tries = 500;
283
284 12
        for ($i = 0; $i < $max_tries; $i++) {
285 12
            $name = strtolower(md5(uniqid('', true)));
286 12
            $location = strtoupper($name[0] . '/' . $name[1]) . '/' . $name;
287
288
            // Check uniqueness
289 12
            $qb = self::new_query_builder();
290 12
            $qb->add_constraint('location', '=', $location);
291 12
            $result = $qb->count_unchecked();
292
293 12
            if ($result == 0) {
294 12
                debug_add("Created this location: {$location}");
295 12
                return $location;
296
            }
297
            debug_add("Location {$location} is in use, retrying");
298
        }
299
        throw new midcom_error('could not create attachment location');
300
    }
301
302
    /**
303
     * Simple creation event handler which fills out the location field if it
304
     * is still empty with a location generated by _create_attachment_location().
305
     *
306
     * @return boolean True if creation may commence.
307
     */
308 12
    public function _on_creating()
309
    {
310 12
        if (empty($this->mimetype)) {
311 6
            $this->mimetype = 'application/octet-stream';
312
        }
313
314 12
        $this->location = $this->_create_attachment_location();
315
316 12
        return true;
317
    }
318
319 9
    public function update_cache()
320
    {
321
        // Check if the attachment can be read anonymously
322 9
        if (   midcom::get()->config->get('attachment_cache_enabled')
323 9
            && !$this->can_do('midgard:read', 'EVERYONE')) {
324
            // Not public file, ensure it is removed
325
            $filename = $this->get_cache_path();
326
            if (file_exists($filename)) {
327
                @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

327
                /** @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...
328
            }
329
        }
330 9
    }
331
332
    /**
333
     * Updated callback, triggers watches on the parent(!) object.
334
     */
335 9
    public function _on_updated()
336
    {
337 9
        $this->update_cache();
338 9
    }
339
340
    /**
341
     * Deleted callback, triggers watches on the parent(!) object.
342
     */
343 10
    public function _on_deleted()
344
    {
345 10
        if (midcom::get()->config->get('attachment_cache_enabled')) {
346
            // Remove attachment cache
347
            $filename = $this->get_cache_path();
348
            if (file_exists($filename)) {
349
                @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

349
                /** @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...
350
            }
351
        }
352 10
    }
353
354
    /**
355
     * Updates the contents of the attachments with the contents given.
356
     *
357
     * @param mixed $source File contents.
358
     * @return boolean Indicating success.
359
     */
360
    public function copy_from_memory($source) : bool
361
    {
362
        $dest = $this->open();
363
        if (!$dest) {
0 ignored issues
show
introduced by
$dest is of type resource, thus it always evaluated to false.
Loading history...
364
            debug_add('Could not open attachment for writing, last Midgard error was: ' . midcom_connection::get_error_string(), MIDCOM_LOG_WARN);
365
            return false;
366
        }
367
368
        fwrite($dest, $source);
369
370
        $this->close();
371
        return true;
372
    }
373
374
    /**
375
     * Updates the contents of the attachments with the contents of the resource identified
376
     * by the filehandle passed.
377
     *
378
     * @param resource $source The handle to read from.
379
     * @return boolean Indicating success.
380
     */
381 9
    public function copy_from_handle($source) : bool
382
    {
383 9
        $dest = $this->open();
384 9
        if (!$dest) {
0 ignored issues
show
introduced by
$dest is of type resource, thus it always evaluated to false.
Loading history...
385
            debug_add('Could not open attachment for writing, last Midgard error was: ' . midcom_connection::get_error_string(), MIDCOM_LOG_WARN);
386
            return false;
387
        }
388
389 9
        stream_copy_to_stream($source, $dest);
390
391 9
        $this->close();
392 9
        return true;
393
    }
394
395
    /**
396
     * Updates the contents of the attachments with the contents of the file specified.
397
     * This is a wrapper for copy_from_handle.
398
     *
399
     * @param string $filename The file to read.
400
     * @return boolean Indicating success.
401
     */
402 6
    public function copy_from_file($filename) : bool
403
    {
404 6
        $source = @fopen($filename, 'r');
405 6
        if (!$source) {
0 ignored issues
show
introduced by
$source is of type false|resource, thus it always evaluated to false.
Loading history...
406
            debug_add('Could not open file for reading.' . midcom_connection::get_error_string(), MIDCOM_LOG_WARN);
407
            midcom::get()->debug->log_php_error(MIDCOM_LOG_WARN);
408
            return false;
409
        }
410 6
        $result = $this->copy_from_handle($source);
411 6
        fclose($source);
412 6
        return $result;
413
    }
414
}
415