MDL-65584 blog: safer deletion of associations in privacy provider.
[moodle.git] / blog / classes / privacy / provider.php
CommitLineData
ce1ec9b4
FM
1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Data provider.
19 *
20 * @package core_blog
21 * @copyright 2018 Frédéric Massart
22 * @author Frédéric Massart <fred@branchup.tech>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26namespace core_blog\privacy;
27defined('MOODLE_INTERNAL') || die();
28
29use blog_entry;
30use context;
31use context_helper;
32use context_user;
33use context_system;
34use core_tag_tag;
35use core_privacy\local\metadata\collection;
36use core_privacy\local\request\approved_contextlist;
37use core_privacy\local\request\transform;
38use core_privacy\local\request\writer;
39
40require_once($CFG->dirroot . '/blog/locallib.php');
41
42/**
43 * Data provider class.
44 *
45 * @package core_blog
46 * @copyright 2018 Frédéric Massart
47 * @author Frédéric Massart <fred@branchup.tech>
48 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
49 */
50class provider implements
51 \core_privacy\local\metadata\provider,
799d32fd
AG
52 \core_privacy\local\request\subsystem\provider,
53 \core_privacy\local\request\core_userlist_provider {
ce1ec9b4
FM
54
55 /**
56 * Returns metadata.
57 *
58 * @param collection $collection The initialised collection to add items to.
59 * @return collection A listing of user data stored through this system.
60 */
61 public static function get_metadata(collection $collection) : collection {
62
63 $collection->add_database_table('post', [
64 'userid' => 'privacy:metadata:post:userid',
65 'subject' => 'privacy:metadata:post:subject',
66 'summary' => 'privacy:metadata:post:summary',
67 'uniquehash' => 'privacy:metadata:post:uniquehash',
68 'publishstate' => 'privacy:metadata:post:publishstate',
69 'created' => 'privacy:metadata:post:created',
70 'lastmodified' => 'privacy:metadata:post:lastmodified',
71
72 // The following columns are unused:
73 // coursemoduleid, courseid, moduleid, groupid, rating, usermodified.
74 ], 'privacy:metadata:post');
75
468c8d39 76 $collection->link_subsystem('core_comment', 'privacy:metadata:core_comments');
ce1ec9b4
FM
77 $collection->link_subsystem('core_files', 'privacy:metadata:core_files');
78 $collection->link_subsystem('core_tag', 'privacy:metadata:core_tag');
79
80 $collection->add_database_table('blog_external', [
81 'userid' => 'privacy:metadata:external:userid',
82 'name' => 'privacy:metadata:external:name',
83 'description' => 'privacy:metadata:external:description',
84 'url' => 'privacy:metadata:external:url',
85 'filtertags' => 'privacy:metadata:external:filtertags',
86 'timemodified' => 'privacy:metadata:external:timemodified',
87 'timefetched' => 'privacy:metadata:external:timefetched',
88 ], 'privacy:metadata:external');
89
90 // We do not report on blog_association because this is just context-related data.
91
92 return $collection;
93 }
94
95 /**
96 * Get the list of contexts that contain user information for the specified user.
97 *
98 * @param int $userid The user to search.
99 * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
100 */
101 public static function get_contexts_for_userid(int $userid) : \core_privacy\local\request\contextlist {
102 global $DB;
103 $contextlist = new \core_privacy\local\request\contextlist();
104
105 // There are at least one blog post.
106 if ($DB->record_exists_select('post', 'userid = :userid AND module IN (:blog, :blogext)', [
107 'userid' => $userid, 'blog' => 'blog', 'blogext' => 'blog_external'])) {
108 $sql = "
109 SELECT ctx.id
110 FROM {context} ctx
111 WHERE ctx.contextlevel = :ctxlevel
112 AND ctx.instanceid = :ctxuserid";
113 $params = [
114 'ctxlevel' => CONTEXT_USER,
115 'ctxuserid' => $userid,
116 ];
117 $contextlist->add_from_sql($sql, $params);
118
119 // Add the associated context of the blog posts.
120 $sql = "
121 SELECT DISTINCT ctx.id
122 FROM {post} p
123 JOIN {blog_association} ba
124 ON ba.blogid = p.id
125 JOIN {context} ctx
126 ON ctx.id = ba.contextid
127 WHERE p.userid = :userid";
128 $params = [
129 'userid' => $userid,
130 ];
131 $contextlist->add_from_sql($sql, $params);
132 }
133
134 // If there is at least one external blog, we add the user context. This is done this
135 // way because we can't directly add context to a contextlist.
136 if ($DB->record_exists('blog_external', ['userid' => $userid])) {
137 $sql = "
138 SELECT ctx.id
c4e7c3ca 139 FROM {context} ctx
ce1ec9b4
FM
140 WHERE ctx.contextlevel = :ctxlevel
141 AND ctx.instanceid = :ctxuserid";
142 $params = [
143 'ctxlevel' => CONTEXT_USER,
144 'ctxuserid' => $userid,
145 ];
146 $contextlist->add_from_sql($sql, $params);
147 }
148
149 // Include the user contexts in which the user comments.
150 $sql = "
151 SELECT DISTINCT ctx.id
152 FROM {context} ctx
153 JOIN {comments} c
154 ON c.contextid = ctx.id
155 WHERE c.component = :component
156 AND c.commentarea = :commentarea
157 AND c.userid = :userid";
158 $params = [
159 'component' => 'blog',
160 'commentarea' => 'format_blog',
161 'userid' => $userid
162 ];
163 $contextlist->add_from_sql($sql, $params);
164
165 return $contextlist;
166 }
167
799d32fd 168 /**
4dbae58e 169 * Get the list of users who have data within a context.
799d32fd
AG
170 *
171 * @param \core_privacy\local\request\userlist $userlist The userlist containing the list of users who have
172 * data in this context/plugin combination.
173 */
174 public static function get_users_in_context(\core_privacy\local\request\userlist $userlist) {
175 global $DB;
176 $context = $userlist->get_context();
177 if ($context->contextlevel == CONTEXT_COURSE || $context->contextlevel == CONTEXT_MODULE) {
178
179 $params = ['contextid' => $context->id];
180
181 $sql = "SELECT p.id, p.userid
182 FROM {post} p
183 JOIN {blog_association} ba ON ba.blogid = p.id AND ba.contextid = :contextid";
184
185 $posts = $DB->get_records_sql($sql, $params);
186 $userids = array_map(function($post) {
187 return $post->userid;
188 }, $posts);
189 $userlist->add_users($userids);
190
4acdbe5d
MG
191 if (!empty($posts)) {
192 // Add any user's who posted on the blog.
193 list($insql, $inparams) = $DB->get_in_or_equal(array_keys($posts), SQL_PARAMS_NAMED);
194 \core_comment\privacy\provider::get_users_in_context_from_sql($userlist, 'c', 'blog', 'format_blog', null, $insql,
799d32fd 195 $inparams);
4acdbe5d 196 }
799d32fd
AG
197 } else if ($context->contextlevel == CONTEXT_USER) {
198 $params = ['userid' => $context->instanceid];
199
200 $sql = "SELECT userid
201 FROM {blog_external}
202 WHERE userid = :userid";
203 $userlist->add_from_sql('userid', $sql, $params);
204
205 $sql = "SELECT userid
206 FROM {post}
207 WHERE userid = :userid";
208 $userlist->add_from_sql('userid', $sql, $params);
209
210 // Add any user's who posted on the blog.
211 \core_comment\privacy\provider::get_users_in_context_from_sql($userlist, 'c', 'blog', 'format_blog', $context->id);
212 }
213 }
214
ce1ec9b4
FM
215 /**
216 * Export all user data for the specified user, in the specified contexts.
217 *
218 * @param approved_contextlist $contextlist The approved contexts to export information for.
219 */
220 public static function export_user_data(approved_contextlist $contextlist) {
221 global $DB;
222
223 $sysctx = context_system::instance();
224 $fs = get_file_storage();
225 $userid = $contextlist->get_user()->id;
226 $ctxfields = context_helper::get_preload_record_columns_sql('ctx');
227 $rootpath = [get_string('blog', 'core_blog')];
228 $associations = [];
229
230 foreach ($contextlist as $context) {
231 switch ($context->contextlevel) {
232 case CONTEXT_USER:
233 $contextuserid = $context->instanceid;
234 $insql = ' > 0';
235 $inparams = [];
236
237 if ($contextuserid != $userid) {
238 // We will only be exporting comments, so fetch the IDs of the relevant entries.
239 $entryids = $DB->get_fieldset_sql("
240 SELECT DISTINCT c.itemid
241 FROM {comments} c
242 WHERE c.contextid = :contextid
243 AND c.userid = :userid
244 AND c.component = :component
245 AND c.commentarea = :commentarea", [
246 'contextid' => $context->id,
247 'userid' => $userid,
248 'component' => 'blog',
249 'commentarea' => 'format_blog'
250 ]);
251
252 if (empty($entryids)) {
253 // This should not happen, as the user context should not have been reported then.
bd5fdcfc 254 continue 2;
ce1ec9b4
FM
255 }
256
257 list($insql, $inparams) = $DB->get_in_or_equal($entryids, SQL_PARAMS_NAMED);
258 }
259
260 // Loop over each blog entry in context.
261 $sql = "userid = :userid AND module IN (:blog, :blogext) AND id $insql";
262 $params = array_merge($inparams, ['userid' => $contextuserid, 'blog' => 'blog', 'blogext' => 'blog_external']);
0288333c 263 $recordset = $DB->get_recordset_select('post', $sql, $params, 'id');
ce1ec9b4
FM
264 foreach ($recordset as $record) {
265
266 $subject = format_string($record->subject);
267 $path = array_merge($rootpath, [get_string('blogentries', 'core_blog'), $subject . " ({$record->id})"]);
268
269 // If the context is not mine, then we ONLY export the comments made by the exporting user.
270 if ($contextuserid != $userid) {
271 \core_comment\privacy\provider::export_comments($context, 'blog', 'format_blog',
272 $record->id, $path, true);
273 continue;
274 }
275
276 // Manually export the files as they reside in the system context so we can't use
277 // the write's helper methods. The same happens for attachments.
278 foreach ($fs->get_area_files($sysctx->id, 'blog', 'post', $record->id) as $f) {
279 writer::with_context($context)->export_file($path, $f);
280 }
281 foreach ($fs->get_area_files($sysctx->id, 'blog', 'attachment', $record->id) as $f) {
282 writer::with_context($context)->export_file($path, $f);
283 }
284
285 // Rewrite the summary files.
286 $summary = writer::with_context($context)->rewrite_pluginfile_urls($path, 'blog', 'post',
287 $record->id, $record->summary);
288
289 // Fetch associations.
290 $assocs = [];
291 $sql = "SELECT ba.contextid, $ctxfields
292 FROM {blog_association} ba
293 JOIN {context} ctx
294 ON ba.contextid = ctx.id
295 WHERE ba.blogid = :blogid";
296 $assocset = $DB->get_recordset_sql($sql, ['blogid' => $record->id]);
297 foreach ($assocset as $assocrec) {
298 context_helper::preload_from_record($assocrec);
299 $assocctx = context::instance_by_id($assocrec->contextid);
300 $assocs[] = $assocctx->get_context_name();
301 }
302 $assocset->close();
303
304 // Export associated tags.
305 \core_tag\privacy\provider::export_item_tags($userid, $context, $path, 'core', 'post', $record->id);
306
307 // Export all comments made on my post.
308 \core_comment\privacy\provider::export_comments($context, 'blog', 'format_blog',
309 $record->id, $path, false);
310
311 // Add blog entry data.
312 $entry = (object) [
313 'subject' => $subject,
314 'summary' => format_text($summary, $record->summaryformat),
315 'uniquehash' => $record->uniquehash,
316 'publishstate' => static::transform_publishstate($record->publishstate),
317 'created' => transform::datetime($record->created),
318 'lastmodified' => transform::datetime($record->lastmodified),
319 'associations' => $assocs
320 ];
321
322 writer::with_context($context)->export_data($path, $entry);
323 }
324 $recordset->close();
325
326 // Export external blogs.
327 $recordset = $DB->get_recordset('blog_external', ['userid' => $userid]);
328 foreach ($recordset as $record) {
329
330 $path = array_merge($rootpath, [get_string('externalblogs', 'core_blog'),
331 $record->name . " ({$record->id})"]);
332
333 // Export associated tags.
334 \core_tag\privacy\provider::export_item_tags($userid, $context, $path, 'core',
335 'blog_external', $record->id);
336
337 // Add data.
338 $external = (object) [
339 'name' => $record->name,
340 'description' => $record->description,
341 'url' => $record->url,
342 'filtertags' => $record->filtertags,
343 'modified' => transform::datetime($record->timemodified),
344 'lastfetched' => transform::datetime($record->timefetched),
345 ];
346
347 writer::with_context($context)->export_data($path, $external);
348 }
349 $recordset->close();
350 break;
351
352 case CONTEXT_COURSE:
353 case CONTEXT_MODULE:
354 $associations[] = $context->id;
355 break;
356 }
357 }
358
359 // Export associations.
360 if (!empty($associations)) {
361 list($insql, $inparams) = $DB->get_in_or_equal($associations, SQL_PARAMS_NAMED);
362 $sql = "
363 SELECT ba.contextid, p.subject, $ctxfields
364 FROM {post} p
365 JOIN {blog_association} ba
366 ON ba.blogid = p.id
367 JOIN {context} ctx
368 ON ctx.id = ba.contextid
369 WHERE ba.contextid $insql
370 AND p.userid = :userid
371 ORDER BY ba.contextid ASC";
372 $params = array_merge($inparams, ['userid' => $userid]);
373
374 $path = [get_string('privacy:path:blogassociations', 'core_blog')];
375
376 $flushassocs = function($context, $assocs) use ($path) {
377 writer::with_context($context)->export_data($path, (object) [
378 'associations' => $assocs
379 ]);
380 };
381
382 $lastcontextid = null;
383 $assocs = [];
384 $recordset = $DB->get_recordset_sql($sql, $params);
385 foreach ($recordset as $record) {
386 context_helper::preload_from_record($record);
387
388 if ($lastcontextid && $record->contextid != $lastcontextid) {
389 $flushassocs(context::instance_by_id($lastcontextid), $assocs);
390 $assocs = [];
391 }
392 $assocs[] = format_string($record->subject);
393 $lastcontextid = $record->contextid;
394 }
395
396 if ($lastcontextid) {
397 $flushassocs(context::instance_by_id($lastcontextid), $assocs);
398 }
399
400 $recordset->close();
401 }
402 }
403
404 /**
405 * Delete all data for all users in the specified context.
406 *
407 * @param context $context The specific context to delete data for.
408 */
409 public static function delete_data_for_all_users_in_context(context $context) {
410 global $DB;
411 switch ($context->contextlevel) {
412 case CONTEXT_USER:
413 static::delete_all_user_data($context);
414 break;
415
416 case CONTEXT_COURSE:
417 case CONTEXT_MODULE:
418 // We only delete associations here.
419 $DB->delete_records('blog_association', ['contextid' => $context->id]);
420 break;
421 }
ce1ec9b4 422 // Delete all the comments.
8c6fae4e 423 \core_comment\privacy\provider::delete_comments_for_all_users($context, 'blog', 'format_blog');
ce1ec9b4
FM
424 }
425
426 /**
427 * Delete all user data for the specified user, in the specified contexts.
428 *
429 * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
430 */
431 public static function delete_data_for_user(approved_contextlist $contextlist) {
432 global $DB;
ce1ec9b4
FM
433 $userid = $contextlist->get_user()->id;
434 $associationcontextids = [];
435
436 foreach ($contextlist as $context) {
437 if ($context->contextlevel == CONTEXT_USER && $context->instanceid == $userid) {
438 static::delete_all_user_data($context);
8c6fae4e 439 \core_comment\privacy\provider::delete_comments_for_all_users($context, 'blog', 'format_blog');
ce1ec9b4
FM
440 } else if ($context->contextlevel == CONTEXT_COURSE) {
441 // Only delete the course associations.
442 $associationcontextids[] = $context->id;
ce1ec9b4
FM
443 } else if ($context->contextlevel == CONTEXT_MODULE) {
444 // Only delete the module associations.
445 $associationcontextids[] = $context->id;
8c6fae4e
AG
446 } else {
447 \core_comment\privacy\provider::delete_comments_for_user($contextlist, 'blog', 'format_blog');
ce1ec9b4
FM
448 }
449 }
450
451 // Delete the associations.
452 if (!empty($associationcontextids)) {
453 list($insql, $inparams) = $DB->get_in_or_equal($associationcontextids, SQL_PARAMS_NAMED);
454 $sql = "SELECT ba.id
455 FROM {blog_association} ba
456 JOIN {post} p
457 ON p.id = ba.blogid
458 WHERE ba.contextid $insql
459 AND p.userid = :userid";
460 $params = array_merge($inparams, ['userid' => $userid]);
461 $associds = $DB->get_fieldset_sql($sql, $params);
462
d95812cb 463 $DB->delete_records_list('blog_association', 'id', $associds);
ce1ec9b4 464 }
ce1ec9b4
FM
465 }
466
799d32fd
AG
467 /**
468 * Delete multiple users within a single context.
469 *
470 * @param approved_userlist $userlist The approved context and user information to delete information for.
471 */
472 public static function delete_data_for_users(\core_privacy\local\request\approved_userlist $userlist) {
473 global $DB;
474
475 $context = $userlist->get_context();
476 $userids = $userlist->get_userids();
477
478 if ($context->contextlevel == CONTEXT_USER) {
479 // If one of the listed users matches this context then delete the blog, associations, and comments.
480 if (array_search($context->instanceid, $userids) !== false) {
481 self::delete_all_user_data($context);
482 \core_comment\privacy\provider::delete_comments_for_all_users($context, 'blog', 'format_blog');
483 return;
484 }
485 \core_comment\privacy\provider::delete_comments_for_users($userlist, 'blog', 'format_blog');
486 } else {
487 list($insql, $inparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
488 $sql = "SELECT ba.id
489 FROM {blog_association} ba
490 JOIN {post} p ON p.id = ba.blogid
491 WHERE ba.contextid = :contextid
492 AND p.userid $insql";
493 $inparams['contextid'] = $context->id;
494 $associds = $DB->get_fieldset_sql($sql, $inparams);
495
496 if (!empty($associds)) {
497 list($insql, $inparams) = $DB->get_in_or_equal($associds, SQL_PARAMS_NAMED, 'param', true);
498 $DB->delete_records_select('blog_association', "id $insql", $inparams);
499 }
500 }
501 }
502
ce1ec9b4
FM
503 /**
504 * Helper method to delete all user data.
505 *
506 * @param context_user $usercontext The user context.
507 * @return void
508 */
509 protected static function delete_all_user_data(context_user $usercontext) {
510 global $DB;
511 $userid = $usercontext->instanceid;
512
513 // Delete all blog posts.
514 $recordset = $DB->get_recordset_select('post', 'userid = :userid AND module IN (:blog, :blogext)', [
515 'userid' => $userid, 'blog' => 'blog', 'blogext' => 'blog_external']);
516 foreach ($recordset as $record) {
517 $entry = new blog_entry(null, $record);
518 $entry->delete(); // Takes care of files and associations.
519 }
520 $recordset->close();
521
522 // Delete all external blogs, and their associated tags.
523 $DB->delete_records('blog_external', ['userid' => $userid]);
524 core_tag_tag::delete_instances('core', 'blog_external', $usercontext->id);
ce1ec9b4
FM
525 }
526
527 /**
528 * Transform a publish state.
529 *
530 * @param string $publishstate The publish state.
531 * @return string
532 */
533 public static function transform_publishstate($publishstate) {
534 switch ($publishstate) {
535 case 'draft':
536 return get_string('publishtonoone', 'core_blog');
537 case 'site':
538 return get_string('publishtosite', 'core_blog');
539 case 'public':
540 return get_string('publishtoworld', 'core_blog');
541 default:
542 }
543 return get_string('privacy:unknown', 'core_blog');
544 }
545}