Issues (806)

lib/midcom/db/attachment.php (3 issues)

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 string $__midcom_class_name__ = __CLASS__;
24
    public string $__mgdschema_class_name__ = 'midgard_attachment';
25
26
    public bool $_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 bool $_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|false A file handle to the attachment if successful, false on failure.
55
     */
56 18
    public function open(string $mode = 'w')
57
    {
58 18
        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 18
        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 18
        $blob = new blob($this->__object);
70 18
        $handle = $blob->get_handler($mode);
71
72 18
        if (!$handle) {
0 ignored issues
show
$handle is of type resource, thus it always evaluated to false.
Loading history...
73
            debug_add("Failed to open attachment with mode {$mode}, last PHP error was: ", MIDCOM_LOG_WARN);
74
            midcom::get()->debug->log_php_error(MIDCOM_LOG_WARN);
75
            return false;
76
        }
77
78 18
        $this->_open_write_mode = ($mode[0] != 'r');
79 18
        $this->_open_handle = $handle;
80
81 18
        return $handle;
82
    }
83
84
    /**
85
     * Read the file and return its contents
86
     */
87
    public function read() : ?string
88
    {
89
        $blob = new blob($this->__object);
90
        return $blob->read_content();
91
    }
92
93
    /**
94
     * Close the open write handle obtained by the open() call again.
95
     * It is required to call this function instead of a simple fclose to ensure proper
96
     * upgrade notifications.
97
     */
98 18
    public function close()
99
    {
100 18
        if ($this->_open_handle === null) {
101
            debug_add("Tried to close non-open attachment {$this->id}", MIDCOM_LOG_WARN);
102
            return;
103
        }
104
105 18
        fclose($this->_open_handle);
106 18
        $this->_open_handle = null;
107
108 18
        if ($this->_open_write_mode) {
109
            // We need to update the attachment now, this cannot be done in the Midgard Core
110
            // at this time.
111 18
            if (!$this->update()) {
112 11
                debug_add("Failed to update attachment {$this->id}", MIDCOM_LOG_WARN);
113 11
                return;
114
            }
115
116 7
            $this->file_to_cache();
117 7
            $this->_open_write_mode = false;
118
        }
119
    }
120
121
    /**
122
     * Rewrite a filename to URL safe form
123
     *
124
     * @todo add possibility to use the file utility to determine extension if missing.
125
     */
126 7
    public static function safe_filename(string $filename) : string
127
    {
128
        // we could use basename() or pathinfo() here, except that it swallows multibyte chars at the
129
        // beginning of the string if we run in e.g. C locale..
130 7
        $parts = explode('/', trim($filename));
131 7
        $filename = end($parts);
132
133 7
        if (preg_match('/^(.*)(\..*?)$/', $filename, $ext_matches)) {
134 4
            [, $name, $ext] = $ext_matches;
135
        } else {
136 3
            $name = $filename;
137 3
            $ext = '';
138
        }
139 7
        return midcom_helper_misc::urlize($name) . $ext;
140
    }
141
142
    /**
143
     * Get the path to the document in the static cache
144
     */
145 1
    private function get_cache_path() : string
146
    {
147
        // Copy the file to the static directory
148 1
        $cacheroot = midcom::get()->config->get('attachment_cache_root');
149 1
        $subdir = $this->guid[0];
150 1
        if (!file_exists("{$cacheroot}/{$subdir}/{$this->guid}")) {
151 1
            mkdir("{$cacheroot}/{$subdir}/{$this->guid}", 0777, true);
152
        }
153
154 1
        return "{$cacheroot}/{$subdir}/{$this->guid}/{$this->name}";
155
    }
156
157 6
    public static function get_url(midgard_attachment|midcom_db_attachment|string $attachment, ?string $name = null) : string
158
    {
159 6
        if (is_string($attachment)) {
160
            $guid = $attachment;
161
            if (null === $name) {
162
                $mc = self::new_collector('guid', $guid);
163
                $names = $mc->get_values('name');
164
                $name = array_pop($names);
165
            }
166
        } else {
167 6
            $guid = $attachment->guid;
168 6
            $name = $attachment->name;
169
        }
170
171 6
        if (!$guid) {
172 2
            return '';
173
        }
174
175 4
        if (midcom::get()->config->get('attachment_cache_enabled')) {
176
            $subdir = $guid[0];
177
178
            if (file_exists(midcom::get()->config->get('attachment_cache_root') . '/' . $subdir . '/' . $guid . '/' . $name)) {
179
                return midcom::get()->config->get('attachment_cache_url') . '/' . $subdir . '/' . $guid . '/' . urlencode($name);
180
            }
181
        }
182
183
        // Use regular MidCOM attachment server
184 4
        return midcom_connection::get_url('self') . 'midcom-serveattachmentguid-' . $guid . '/' . urlencode($name);
185
    }
186
187 7
    public function file_to_cache()
188
    {
189 7
        if (!midcom::get()->config->get('attachment_cache_enabled')) {
190 7
            return;
191
        }
192
193 1
        if (!$this->can_do('midgard:read', 'EVERYONE')) {
194
            debug_add("Attachment {$this->name} ({$this->guid}) is not publicly readable, not caching.");
195
            $this->remove_from_cache();
196
            return;
197
        }
198
199 1
        $filename = $this->get_cache_path();
200
201 1
        if (file_exists($filename) && is_link($filename)) {
202
            debug_add("Attachment {$this->name} ({$this->guid}) is already in cache as {$filename}, skipping.");
203
            return;
204
        }
205
206
        // Then symlink the file
207 1
        if (@symlink($this->get_path(), $filename)) {
208 1
            debug_add("Symlinked attachment {$this->name} ({$this->guid}) as {$filename}.");
209 1
            return;
210
        }
211
212
        // Symlink failed, actually copy the data
213
        if (!copy($this->get_path(), $filename)) {
214
            debug_add("Failed to cache attachment {$this->name} ({$this->guid}), copying failed.");
215
            return;
216
        }
217
218
        debug_add("Symlinking attachment {$this->name} ({$this->guid}) as {$filename} failed, data copied instead.");
219
    }
220
221
    private function remove_from_cache()
222
    {
223
        $filename = $this->get_cache_path();
224
        if (file_exists($filename)) {
225
            @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

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