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
introduced
by
![]() |
|||||
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
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
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.');
}
![]() |
|||||
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
|
|||||
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 |