diff --git a/js/forum/src/components/activity-page.js b/js/forum/src/components/activity-page.js index f59ae1f23..4984afe7c 100644 --- a/js/forum/src/components/activity-page.js +++ b/js/forum/src/components/activity-page.js @@ -46,9 +46,11 @@ export default class ActivityPage extends UserPage { loadResults(offset) { return app.store.find('activity', { - users: this.user().id(), - page: {offset, limit: this.loadLimit}, - type: this.props.filter + filter: { + user: this.user().id(), + type: this.props.filter + }, + page: {offset, limit: this.loadLimit} }) } diff --git a/js/forum/src/components/composer.js b/js/forum/src/components/composer.js index 2ddd9caae..12a8c54f8 100644 --- a/js/forum/src/components/composer.js +++ b/js/forum/src/components/composer.js @@ -208,6 +208,7 @@ class Composer extends Component { if (flexible.length) { flexible.height(height - (flexible.offset().top - this.$().offset().top) - + parseInt(flexible.css('padding-bottom')) - this.$('.text-editor-controls').outerHeight(true)); } } diff --git a/js/forum/src/components/discussion-page.js b/js/forum/src/components/discussion-page.js index e56423124..9a449a09c 100644 --- a/js/forum/src/components/discussion-page.js +++ b/js/forum/src/components/discussion-page.js @@ -65,7 +65,7 @@ export default class DiscussionPage extends mixin(Component, evented) { params() { return { - near: this.currentNear, + page: {near: this.currentNear}, include: ['posts', 'posts.user', 'posts.user.groups'] }; } @@ -91,7 +91,7 @@ export default class DiscussionPage extends mixin(Component, evented) { var includedPosts = []; if (discussion.payload && discussion.payload.included) { discussion.payload.included.forEach(record => { - if (record.type === 'posts' && record.links && record.links.discussion) { + if (record.type === 'posts' && record.relationships && record.relationships.discussion) { includedPosts.push(app.store.getById('posts', record.id)); } }); diff --git a/js/forum/src/components/discussions-search-results.js b/js/forum/src/components/discussions-search-results.js index b6f679878..ab6bed8dc 100644 --- a/js/forum/src/components/discussions-search-results.js +++ b/js/forum/src/components/discussions-search-results.js @@ -9,7 +9,7 @@ export default class DiscussionsSearchResults { search(string) { this.results[string] = []; - return app.store.find('discussions', {q: string, page: {limit: 3}, include: 'relevantPosts,relevantPosts.discussion'}).then(results => { + return app.store.find('discussions', {filter: {q: string}, page: {limit: 3}, include: 'relevantPosts,relevantPosts.discussion'}).then(results => { this.results[string] = results; }); } diff --git a/js/forum/src/components/notification-list.js b/js/forum/src/components/notification-list.js index 0f545e48f..931571a67 100644 --- a/js/forum/src/components/notification-list.js +++ b/js/forum/src/components/notification-list.js @@ -79,7 +79,7 @@ export default class NotificationList extends Component { this.loading(true); m.redraw(); app.store.find('notifications').then(notifications => { - app.session.user().pushData({unreadNotificationsCount: 0}); + app.session.user().pushAttributes({unreadNotificationsCount: 0}); this.loading(false); app.cache.notifications = notifications.sort((a, b) => b.time() - a.time()); m.redraw(); diff --git a/js/forum/src/components/post-stream.js b/js/forum/src/components/post-stream.js index c4c64576b..8aef5a44d 100644 --- a/js/forum/src/components/post-stream.js +++ b/js/forum/src/components/post-stream.js @@ -76,11 +76,11 @@ class PostStream extends mixin(Component, evented) { sync() { var addedPosts = this.discussion.addedPosts(); if (addedPosts) addedPosts.forEach(this.pushPost.bind(this)); - this.discussion.pushData({links: {addedPosts: null}}); + this.discussion.pushAttributes({links: {addedPosts: null}}); var removedPosts = this.discussion.removedPosts(); if (removedPosts) removedPosts.forEach(this.removePost.bind(this)); - this.discussion.pushData({removedPosts: null}); + this.discussion.pushAttributes({removedPosts: null}); } /** @@ -352,8 +352,8 @@ class PostStream extends mixin(Component, evented) { this.clear(); return app.store.find('posts', { - discussions: this.discussion.id(), - near: number + filter: {discussion: this.discussion.id()}, + page: {near: number} }).then(this.setup.bind(this)); } diff --git a/js/forum/src/components/reply-composer.js b/js/forum/src/components/reply-composer.js index b3b5025a2..5336a9c1e 100644 --- a/js/forum/src/components/reply-composer.js +++ b/js/forum/src/components/reply-composer.js @@ -32,7 +32,7 @@ export default class ReplyComposer extends ComposerBody { data() { return { content: this.content(), - links: {discussion: this.props.discussion} + relationships: {discussion: this.props.discussion} }; } @@ -47,8 +47,8 @@ export default class ReplyComposer extends ComposerBody { app.store.createRecord('posts').save(data).then((post) => { app.composer.hide(); - discussion.pushData({ - links: { + discussion.pushAttributes({ + relationships: { lastUser: post.user(), lastPost: post }, @@ -58,7 +58,7 @@ export default class ReplyComposer extends ComposerBody { readTime: post.time(), readNumber: post.number() }); - discussion.data().links.posts.linkage.push({type: 'posts', id: post.id()}); + discussion.data().relationships.posts.data.push({type: 'posts', id: post.id()}); // If we're currently viewing the discussion which this reply was made // in, then we can add the post to the end of the post stream. diff --git a/js/forum/src/components/settings-page.js b/js/forum/src/components/settings-page.js index c9c7c44e4..14a5a2ece 100644 --- a/js/forum/src/components/settings-page.js +++ b/js/forum/src/components/settings-page.js @@ -113,7 +113,7 @@ export default class SettingsPage extends UserPage { label: 'Allow others to see when I am online', state: this.user().preferences().discloseOnline, onchange: (value, component) => { - this.user().pushData({lastSeenTime: null}); + this.user().pushAttributes({lastSeenTime: null}); this.save('discloseOnline')(value, component); } }) diff --git a/js/forum/src/components/users-search-results.js b/js/forum/src/components/users-search-results.js index a9bf956f1..2cc38504a 100644 --- a/js/forum/src/components/users-search-results.js +++ b/js/forum/src/components/users-search-results.js @@ -3,7 +3,7 @@ import avatar from 'flarum/helpers/avatar'; export default class UsersSearchResults { search(string) { - return app.store.find('users', {q: string, page: {limit: 5}}); + return app.store.find('users', {filter: {q: string}, page: {limit: 5}}); } view(string) { diff --git a/js/forum/src/initializers/post-controls.js b/js/forum/src/initializers/post-controls.js index c0600e304..2a96aa9cb 100644 --- a/js/forum/src/initializers/post-controls.js +++ b/js/forum/src/initializers/post-controls.js @@ -13,17 +13,17 @@ export default function(app) { function hideAction() { this.save({ isHidden: true }); - this.pushData({ hideTime: new Date(), hideUser: app.session.user() }); + this.pushAttributes({ hideTime: new Date(), hideUser: app.session.user() }); } function restoreAction() { this.save({ isHidden: false }); - this.pushData({ hideTime: null, hideUser: null }); + this.pushAttributes({ hideTime: null, hideUser: null }); } function deleteAction() { this.delete(); - this.discussion().pushData({removedPosts: [this.id()]}); + // this.discussion().pushAttributes({removedPosts: [this.id()]}); if (app.current instanceof DiscussionPage) { app.current.stream.removePost(this.id()); } diff --git a/js/lib/model.js b/js/lib/model.js index 6e3ba9dd4..f6b346460 100644 --- a/js/lib/model.js +++ b/js/lib/model.js @@ -6,18 +6,27 @@ export default class Model { this.store = store; } + id() { + return this.data().id; + } + pushData(newData) { var data = this.data(); for (var i in newData) { - if (i === 'links') { + if (i === 'relationships') { data[i] = data[i] || {}; for (var j in newData[i]) { if (newData[i][j] instanceof Model) { - newData[i][j] = {linkage: {type: newData[i][j].data().type, id: newData[i][j].data().id}}; + newData[i][j] = {data: {type: newData[i][j].data().type, id: newData[i][j].data().id}}; } data[i][j] = newData[i][j]; } + } else if (i === 'attributes') { + data[i] = data[i] || {}; + for (var j in newData[i]) { + data[i][j] = newData[i][j]; + } } else { data[i] = newData[i]; } @@ -26,19 +35,40 @@ export default class Model { this.freshness = new Date(); } - save(data) { - if (data.links) { - for (var i in data.links) { - var model = data.links[i]; - var linkage = model => { + pushAttributes(attributes) { + var data = {attributes}; + + if (attributes.relationships) { + data.relationships = attributes.relationships; + delete attributes.relationships; + } + + this.pushData(data); + } + + save(attributes) { + var data = { + type: this.data().type, + id: this.data().id, + attributes + }; + + if (attributes.relationships) { + data.relationships = {}; + + for (var i in attributes.relationships) { + var model = attributes.relationships[i]; + var relationshipData = model => { return {type: model.data().type, id: model.data().id}; }; if (model instanceof Array) { - data.links[i] = {linkage: model.map(linkage)}; + data.relationships[i] = {data: model.map(relationshipData)}; } else { - data.links[i] = {linkage: linkage(model)}; + data.relationships[i] = {data: relationshipData(model)}; } } + + delete attributes.relationships; } // clone the relevant parts of the model's old data so that we can revert @@ -46,7 +76,7 @@ export default class Model { var oldData = {}; var currentData = this.data(); for (var i in data) { - if (i === 'links') { + if (i === 'relationships') { oldData[i] = oldData[i] || {}; for (var j in currentData[i]) { oldData[i][j] = currentData[i][j]; @@ -59,7 +89,7 @@ export default class Model { this.pushData(data); return app.request({ - method: this.exists ? 'PUT' : 'POST', + method: this.exists ? 'PATCH' : 'POST', url: app.config['api_url']+'/'+this.data().type+(this.exists ? '/'+this.data().id : ''), data: {data}, background: true, @@ -84,36 +114,36 @@ export default class Model { }).then(() => this.exists = false); } - static prop(name, transform) { + static attribute(name, transform) { return function() { - var data = this.data()[name]; + var data = this.data().attributes[name]; return transform ? transform(data) : data; } } - static one(name) { + static hasOne(name) { return function() { var data = this.data(); - if (data.links) { - var link = data.links[name]; - return link && app.store.getById(link.linkage.type, link.linkage.id); + if (data.relationships) { + var relationship = data.relationships[name]; + return relationship && app.store.getById(relationship.data.type, relationship.data.id); } } } - static many(name) { + static hasMany(name) { return function() { var data = this.data(); - if (data.links) { - var link = this.data().links[name]; - return link && link.linkage.map(function(link) { - return app.store.getById(link.type, link.id) + if (data.relationships) { + var relationship = this.data().relationships[name]; + return relationship && relationship.data.map(function(link) { + return app.store.getById(link.type, link.id); }); } } } - static date(data) { + static transformDate(data) { return data ? new Date(data) : null; } } diff --git a/js/lib/models/activity.js b/js/lib/models/activity.js index e2ddab39b..8bffd3fde 100644 --- a/js/lib/models/activity.js +++ b/js/lib/models/activity.js @@ -2,12 +2,11 @@ import Model from 'flarum/model'; class Activity extends Model {} -Activity.prototype.id = Model.prop('id'); -Activity.prototype.contentType = Model.prop('contentType'); -Activity.prototype.content = Model.prop('content'); -Activity.prototype.time = Model.prop('time', Model.date); +Activity.prototype.contentType = Model.attribute('contentType'); +Activity.prototype.content = Model.attribute('content'); +Activity.prototype.time = Model.attribute('time', Model.transformDate); -Activity.prototype.user = Model.one('user'); -Activity.prototype.subject = Model.one('subject'); +Activity.prototype.user = Model.hasOne('user'); +Activity.prototype.subject = Model.hasOne('subject'); export default Activity; diff --git a/js/lib/models/discussion.js b/js/lib/models/discussion.js index fe39bbf4a..4d9f53fa5 100644 --- a/js/lib/models/discussion.js +++ b/js/lib/models/discussion.js @@ -6,25 +6,25 @@ class Discussion extends Model { pushData(newData) { super.pushData(newData); - var links = this.data().links; - var posts = links && links.posts; - if (posts) { - if (newData.removedPosts) { - posts.linkage.forEach((linkage, i) => { - if (newData.removedPosts.indexOf(linkage.id) !== -1) { - posts.linkage.splice(i, 1); - } - }); - } + // var links = this.data().links; + // var posts = links && links.posts; + // if (posts) { + // if (newData.removedPosts) { + // posts.linkage.forEach((linkage, i) => { + // if (newData.removedPosts.indexOf(linkage.id) !== -1) { + // posts.linkage.splice(i, 1); + // } + // }); + // } - if (newData.links && newData.links.addedPosts) { - newData.links.addedPosts.linkage.forEach(linkage => { - if (posts.linkage[posts.linkage.length - 1].id != linkage.id) { - posts.linkage.push(linkage); - } - }); - } - } + // if (newData.links && newData.links.addedPosts) { + // newData.links.addedPosts.linkage.forEach(linkage => { + // if (posts.linkage[posts.linkage.length - 1].id != linkage.id) { + // posts.linkage.push(linkage); + // } + // }); + // } + // } } unreadCount() { @@ -40,34 +40,33 @@ class Discussion extends Model { } } -Discussion.prototype.id = Model.prop('id'); -Discussion.prototype.title = Model.prop('title'); +Discussion.prototype.title = Model.attribute('title'); Discussion.prototype.slug = computed('title', title => title.toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').replace(/-$|^-/g, '') || '-'); -Discussion.prototype.startTime = Model.prop('startTime', Model.date); -Discussion.prototype.startUser = Model.one('startUser'); -Discussion.prototype.startPost = Model.one('startPost'); +Discussion.prototype.startTime = Model.attribute('startTime', Model.transformDate); +Discussion.prototype.startUser = Model.hasOne('startUser'); +Discussion.prototype.startPost = Model.hasOne('startPost'); -Discussion.prototype.lastTime = Model.prop('lastTime', Model.date); -Discussion.prototype.lastUser = Model.one('lastUser'); -Discussion.prototype.lastPost = Model.one('lastPost'); -Discussion.prototype.lastPostNumber = Model.prop('lastPostNumber'); +Discussion.prototype.lastTime = Model.attribute('lastTime', Model.transformDate); +Discussion.prototype.lastUser = Model.hasOne('lastUser'); +Discussion.prototype.lastPost = Model.hasOne('lastPost'); +Discussion.prototype.lastPostNumber = Model.attribute('lastPostNumber'); -Discussion.prototype.canReply = Model.prop('canReply'); -Discussion.prototype.canRename = Model.prop('canRename'); -Discussion.prototype.canDelete = Model.prop('canDelete'); +Discussion.prototype.canReply = Model.attribute('canReply'); +Discussion.prototype.canRename = Model.attribute('canRename'); +Discussion.prototype.canDelete = Model.attribute('canDelete'); -Discussion.prototype.commentsCount = Model.prop('commentsCount'); +Discussion.prototype.commentsCount = Model.attribute('commentsCount'); Discussion.prototype.repliesCount = computed('commentsCount', commentsCount => Math.max(0, commentsCount - 1)); -Discussion.prototype.posts = Model.many('posts'); -Discussion.prototype.postIds = function() { return this.data().links.posts.linkage.map((link) => link.id); }; -Discussion.prototype.relevantPosts = Model.many('relevantPosts'); -Discussion.prototype.addedPosts = Model.many('addedPosts'); -Discussion.prototype.removedPosts = Model.prop('removedPosts'); +Discussion.prototype.posts = Model.hasMany('posts'); +Discussion.prototype.postIds = function() { return this.data().relationships.posts.data.map((link) => link.id); }; +Discussion.prototype.relevantPosts = Model.hasMany('relevantPosts'); +Discussion.prototype.addedPosts = Model.hasMany('addedPosts'); +Discussion.prototype.removedPosts = Model.attribute('removedPosts'); -Discussion.prototype.readTime = Model.prop('readTime', Model.date); -Discussion.prototype.readNumber = Model.prop('readNumber'); +Discussion.prototype.readTime = Model.attribute('readTime', Model.transformDate); +Discussion.prototype.readNumber = Model.attribute('readNumber'); Discussion.prototype.isUnread = computed('unreadCount', unreadCount => !!unreadCount); diff --git a/js/lib/models/group.js b/js/lib/models/group.js index 3379fa383..c1f1cae17 100644 --- a/js/lib/models/group.js +++ b/js/lib/models/group.js @@ -2,10 +2,9 @@ import Model from 'flarum/model'; class Group extends Model {} -Group.prototype.id = Model.prop('id'); -Group.prototype.nameSingular = Model.prop('nameSingular'); -Group.prototype.namePlural = Model.prop('namePlural'); -Group.prototype.color = Model.prop('color'); -Group.prototype.icon = Model.prop('icon'); +Group.prototype.nameSingular = Model.attribute('nameSingular'); +Group.prototype.namePlural = Model.attribute('namePlural'); +Group.prototype.color = Model.attribute('color'); +Group.prototype.icon = Model.attribute('icon'); export default Group; diff --git a/js/lib/models/notification.js b/js/lib/models/notification.js index b6e4120c9..0f66adce3 100644 --- a/js/lib/models/notification.js +++ b/js/lib/models/notification.js @@ -3,17 +3,16 @@ import computed from 'flarum/utils/computed'; class Notification extends Model {} -Notification.prototype.id = Model.prop('id'); -Notification.prototype.contentType = Model.prop('contentType'); -Notification.prototype.subjectId = Model.prop('subjectId'); -Notification.prototype.content = Model.prop('content'); -Notification.prototype.time = Model.prop('time', Model.date); -Notification.prototype.isRead = Model.prop('isRead'); -Notification.prototype.unreadCount = Model.prop('unreadCount'); +Notification.prototype.contentType = Model.attribute('contentType'); +Notification.prototype.subjectId = Model.attribute('subjectId'); +Notification.prototype.content = Model.attribute('content'); +Notification.prototype.time = Model.attribute('time', Model.date); +Notification.prototype.isRead = Model.attribute('isRead'); +Notification.prototype.unreadCount = Model.attribute('unreadCount'); Notification.prototype.additionalUnreadCount = computed('unreadCount', unreadCount => Math.max(0, unreadCount - 1)); -Notification.prototype.user = Model.one('user'); -Notification.prototype.sender = Model.one('sender'); -Notification.prototype.subject = Model.one('subject'); +Notification.prototype.user = Model.hasOne('user'); +Notification.prototype.sender = Model.hasOne('sender'); +Notification.prototype.subject = Model.hasOne('subject'); export default Notification; diff --git a/js/lib/models/post.js b/js/lib/models/post.js index 671e3e91d..53f347a21 100644 --- a/js/lib/models/post.js +++ b/js/lib/models/post.js @@ -3,26 +3,25 @@ import computed from 'flarum/utils/computed'; class Post extends Model {} -Post.prototype.id = Model.prop('id'); -Post.prototype.number = Model.prop('number'); -Post.prototype.discussion = Model.one('discussion'); +Post.prototype.number = Model.attribute('number'); +Post.prototype.discussion = Model.hasOne('discussion'); -Post.prototype.time = Model.prop('time', Model.date); -Post.prototype.user = Model.one('user'); -Post.prototype.contentType = Model.prop('contentType'); -Post.prototype.content = Model.prop('content'); -Post.prototype.contentHtml = Model.prop('contentHtml'); +Post.prototype.time = Model.attribute('time', Model.transformDate); +Post.prototype.user = Model.hasOne('user'); +Post.prototype.contentType = Model.attribute('contentType'); +Post.prototype.content = Model.attribute('content'); +Post.prototype.contentHtml = Model.attribute('contentHtml'); Post.prototype.contentPlain = computed('contentHtml', contentHtml => $('
').html(contentHtml.replace(/(<\/p>|untouched. + */ +abstract class TextFormatter implements FormatterInterface +{ + /** + * A list of tags to ignore when applying formatting. + * + * @var array + */ + protected $ignoreTags = ['code', 'pre']; + + /** + * {@inheritdoc} + */ + public function config(FormatterManager $manager) + { + } + + /** + * {@inheritdoc} + */ + public function formatBeforePurification($text, Model $model = null) + { + return $this->formatAroundIgnoredTags($text, function ($text) use ($model) { + return $this->formatTextBeforePurification($text, $model); + }); + } + + /** + * {@inheritdoc} + */ + public function formatAfterPurification($text, Model $model = null) + { + return $this->formatAroundIgnoredTags($text, function ($text) use ($model) { + return $this->formatTextAfterPurification($text, $model); + }); + } + + /** + * Format non-ignored text before purification has taken place. + * + * @param string $text + * @param Model $model + * @return mixed + */ + protected function formatTextBeforePurification($text, Model $model = null) + { + return $text; + } + + /** + * Format non-ignored text after purification has taken place. + * + * @param string $text + * @param Model $model + * @return string + */ + protected function formatTextAfterPurification($text, Model $model = null) + { + return $text; + } + + /** + * Run a callback on parts of the provided text that aren't within the list + * of ignored tags. + * + * @param string $text + * @param callable $callback + * @return string + */ + protected function formatAroundIgnoredTags($text, callable $callback) + { + return $this->formatAroundTags($text, $this->ignoreTags, $callback); + } + + /** + * Run a callback on parts of the provided text that aren't within the + * given list of tags. + * + * @param string $text + * @param array $tags + * @param callable $callback + * @return string + */ + protected function formatAroundTags($text, array $tags, callable $callback) + { + $chunks = preg_split('/(<.+?>)/is', $text, 0, PREG_SPLIT_DELIM_CAPTURE); + $openTag = null; + + for ($i = 0; $i < count($chunks); $i++) { + if ($i % 2 === 0) { // even numbers are text + // Only process this chunk if there are no unclosed $ignoreTags + if (null === $openTag) { + $chunks[$i] = $callback($chunks[$i]); + } + } else { // odd numbers are tags + // Only process this tag if there are no unclosed $ignoreTags + if (null === $openTag) { + // Check whether this tag is contained in $ignoreTags and is not self-closing + if (preg_match("`<(" . implode('|', $tags) . ").*(?$`is", $chunks[$i], $matches)) { + $openTag = $matches[1]; + } + } else { + // Otherwise, check whether this is the closing tag for $openTag. + if (preg_match('`\s*' . $openTag . '>`i', $chunks[$i], $matches)) { + $openTag = null; + } + } + } + } + + return implode($chunks); + } +} diff --git a/src/Core/Models/Forum.php b/src/Core/Forum.php similarity index 71% rename from src/Core/Models/Forum.php rename to src/Core/Forum.php index 5980317f1..5153bf73a 100755 --- a/src/Core/Models/Forum.php +++ b/src/Core/Forum.php @@ -1,4 +1,4 @@ -discussions = $discussions; - } - - public function handle($command) - { - $user = $command->user; - $discussion = $this->discussions->findOrFail($command->discussionId, $user); - - $discussion->assertCan($user, 'delete'); - - event(new DiscussionWillBeDeleted($discussion, $command)); - - $discussion->delete(); - $this->dispatchEventsFor($discussion); - - return $discussion; - } -} diff --git a/src/Core/Handlers/Commands/DeletePostCommandHandler.php b/src/Core/Handlers/Commands/DeletePostCommandHandler.php deleted file mode 100644 index 6e4321a88..000000000 --- a/src/Core/Handlers/Commands/DeletePostCommandHandler.php +++ /dev/null @@ -1,32 +0,0 @@ -posts = $posts; - } - - public function handle($command) - { - $user = $command->user; - $post = $this->posts->findOrFail($command->postId, $user); - - $post->assertCan($user, 'delete'); - - event(new PostWillBeDeleted($post, $command)); - - $post->delete(); - $this->dispatchEventsFor($post); - - return $post; - } -} diff --git a/src/Core/Handlers/Commands/DeleteUserCommandHandler.php b/src/Core/Handlers/Commands/DeleteUserCommandHandler.php deleted file mode 100644 index e1b6e2d3c..000000000 --- a/src/Core/Handlers/Commands/DeleteUserCommandHandler.php +++ /dev/null @@ -1,32 +0,0 @@ -users = $users; - } - - public function handle($command) - { - $user = $command->user; - $userToDelete = $this->users->findOrFail($command->userId, $user); - - $userToDelete->assertCan($user, 'delete'); - - event(new UserWillBeDeleted($userToDelete, $command)); - - $userToDelete->delete(); - $this->dispatchEventsFor($userToDelete); - - return $userToDelete; - } -} diff --git a/src/Core/Handlers/Commands/EditDiscussionCommandHandler.php b/src/Core/Handlers/Commands/EditDiscussionCommandHandler.php deleted file mode 100644 index 27f98f1ce..000000000 --- a/src/Core/Handlers/Commands/EditDiscussionCommandHandler.php +++ /dev/null @@ -1,35 +0,0 @@ -discussions = $discussions; - } - - public function handle($command) - { - $user = $command->user; - $discussion = $this->discussions->findOrFail($command->discussionId, $user); - - if (isset($command->data['title'])) { - $discussion->assertCan($user, 'rename'); - $discussion->rename($command->data['title'], $user); - } - - event(new DiscussionWillBeSaved($discussion, $command)); - - $discussion->save(); - $this->dispatchEventsFor($discussion); - - return $discussion; - } -} diff --git a/src/Core/Handlers/Commands/EditPostCommandHandler.php b/src/Core/Handlers/Commands/EditPostCommandHandler.php deleted file mode 100644 index 7528dfefc..000000000 --- a/src/Core/Handlers/Commands/EditPostCommandHandler.php +++ /dev/null @@ -1,49 +0,0 @@ -posts = $posts; - } - - public function handle($command) - { - $user = $command->user; - $post = $this->posts->findOrFail($command->postId, $user); - - if ($post instanceof CommentPost) { - if (isset($command->data['content'])) { - $post->assertCan($user, 'edit'); - - $post->revise($command->data['content'], $user); - } - - if (isset($command->data['isHidden'])) { - $post->assertCan($user, 'edit'); - - if ($command->data['isHidden']) { - $post->hide($user); - } else { - $post->restore($user); - } - } - } - - event(new PostWillBeSaved($post, $command)); - - $post->save(); - $this->dispatchEventsFor($post); - - return $post; - } -} diff --git a/src/Core/Handlers/Commands/EditUserCommandHandler.php b/src/Core/Handlers/Commands/EditUserCommandHandler.php deleted file mode 100644 index 76b19fb74..000000000 --- a/src/Core/Handlers/Commands/EditUserCommandHandler.php +++ /dev/null @@ -1,59 +0,0 @@ -users = $users; - } - - public function handle($command) - { - $user = $command->user; - $userToEdit = $this->users->findOrFail($command->userId, $user); - - $userToEdit->assertCan($user, 'edit'); - - if (isset($command->data['username'])) { - $userToEdit->assertCan($user, 'rename'); - $userToEdit->rename($command->data['username']); - } - - if (isset($command->data['email'])) { - $userToEdit->requestEmailChange($command->data['email']); - } - - if (isset($command->data['password'])) { - $userToEdit->changePassword($command->data['password']); - } - - if (isset($command->data['bio'])) { - $userToEdit->changeBio($command->data['bio']); - } - - if (! empty($command->data['readTime'])) { - $userToEdit->markAllAsRead(); - } - - if (! empty($command->data['preferences'])) { - foreach ($command->data['preferences'] as $k => $v) { - $userToEdit->setPreference($k, $v); - } - } - - event(new UserWillBeSaved($userToEdit, $command)); - - $userToEdit->save(); - $this->dispatchEventsFor($userToEdit); - - return $userToEdit; - } -} diff --git a/src/Core/Handlers/Commands/GenerateAccessTokenCommandHandler.php b/src/Core/Handlers/Commands/GenerateAccessTokenCommandHandler.php deleted file mode 100644 index 15a74d8a5..000000000 --- a/src/Core/Handlers/Commands/GenerateAccessTokenCommandHandler.php +++ /dev/null @@ -1,14 +0,0 @@ -userId); - $token->save(); - - return $token; - } -} diff --git a/src/Core/Handlers/Commands/ReadDiscussionCommandHandler.php b/src/Core/Handlers/Commands/ReadDiscussionCommandHandler.php deleted file mode 100644 index 2d9e6d376..000000000 --- a/src/Core/Handlers/Commands/ReadDiscussionCommandHandler.php +++ /dev/null @@ -1,39 +0,0 @@ -discussions = $discussions; - } - - public function handle($command) - { - $user = $command->user; - - if (! $user->exists) { - throw new PermissionDeniedException; - } - - $discussion = $this->discussions->findOrFail($command->discussionId, $user); - - $state = $discussion->stateFor($user); - $state->read($command->readNumber); - - event(new DiscussionStateWillBeSaved($state, $command)); - - $state->save(); - $this->dispatchEventsFor($state); - - return $state; - } -} diff --git a/src/Core/Handlers/Commands/ReadNotificationCommandHandler.php b/src/Core/Handlers/Commands/ReadNotificationCommandHandler.php deleted file mode 100644 index c31c45231..000000000 --- a/src/Core/Handlers/Commands/ReadNotificationCommandHandler.php +++ /dev/null @@ -1,28 +0,0 @@ -user; - - if (! $user->exists) { - throw new PermissionDeniedException; - } - - $notification = Notification::where('user_id', $user->id)->findOrFail($command->notificationId); - - $notification->read(); - - $notification->save(); - $this->dispatchEventsFor($notification); - - return $notification; - } -} diff --git a/src/Core/Handlers/Commands/RegisterUserCommandHandler.php b/src/Core/Handlers/Commands/RegisterUserCommandHandler.php deleted file mode 100644 index a473681ee..000000000 --- a/src/Core/Handlers/Commands/RegisterUserCommandHandler.php +++ /dev/null @@ -1,31 +0,0 @@ -data, 'username'), - array_get($command->data, 'email'), - array_get($command->data, 'password') - ); - - event(new UserWillBeSaved($user, $command)); - - $user->save(); - $this->dispatchEventsFor($user); - - return $user; - } -} diff --git a/src/Core/Handlers/Events/DiscussionRenamedNotifier.php b/src/Core/Handlers/Events/DiscussionRenamedNotifier.php deleted file mode 100755 index 1833f471a..000000000 --- a/src/Core/Handlers/Events/DiscussionRenamedNotifier.php +++ /dev/null @@ -1,45 +0,0 @@ -notifications = $notifications; - } - - /** - * Register the listeners for the subscriber. - * - * @param \Illuminate\Contracts\Events\Dispatcher $events - */ - public function subscribe(Dispatcher $events) - { - $events->listen('Flarum\Core\Events\DiscussionWasRenamed', __CLASS__.'@whenDiscussionWasRenamed'); - } - - public function whenDiscussionWasRenamed(DiscussionWasRenamed $event) - { - $post = DiscussionRenamedPost::reply( - $event->discussion->id, - $event->user->id, - $event->oldTitle, - $event->discussion->title - ); - - $post = $event->discussion->addPost($post); - - if ($event->discussion->start_user_id !== $event->user->id) { - $notification = new DiscussionRenamedNotification($post); - - $this->notifications->sync($notification, $post->exists ? [$event->discussion->startUser] : []); - } - } -} diff --git a/src/Core/Handlers/Events/UserActivitySyncer.php b/src/Core/Handlers/Events/UserActivitySyncer.php deleted file mode 100755 index b2e3d4d42..000000000 --- a/src/Core/Handlers/Events/UserActivitySyncer.php +++ /dev/null @@ -1,76 +0,0 @@ -activity = $activity; - } - - public function subscribe(Dispatcher $events) - { - $events->listen('Flarum\Core\Events\PostWasPosted', __CLASS__.'@whenPostWasPosted'); - $events->listen('Flarum\Core\Events\PostWasHidden', __CLASS__.'@whenPostWasHidden'); - $events->listen('Flarum\Core\Events\PostWasRestored', __CLASS__.'@whenPostWasRestored'); - $events->listen('Flarum\Core\Events\PostWasDeleted', __CLASS__.'@whenPostWasDeleted'); - $events->listen('Flarum\Core\Events\UserWasRegistered', __CLASS__.'@whenUserWasRegistered'); - } - - public function whenPostWasPosted(PostWasPosted $event) - { - $this->postBecameVisible($event->post); - } - - public function whenPostWasHidden(PostWasHidden $event) - { - $this->postBecameInvisible($event->post); - } - - public function whenPostWasRestored(PostWasRestored $event) - { - $this->postBecameVisible($event->post); - } - - public function whenPostWasDeleted(PostWasDeleted $event) - { - $this->postBecameInvisible($event->post); - } - - public function whenUserWasRegistered(UserWasRegistered $event) - { - $this->activity->sync(new JoinedActivity($event->user), [$event->user]); - } - - protected function postBecameVisible(Post $post) - { - $activity = $this->postedActivity($post); - - $this->activity->sync($activity, [$post->user]); - } - - protected function postBecameInvisible(Post $post) - { - $activity = $this->postedActivity($post); - - $this->activity->sync($activity, []); - } - - protected function postedActivity(Post $post) - { - return $post->number == 1 ? new StartedDiscussionActivity($post) : new PostedActivity($post); - } -} diff --git a/src/Core/Handlers/Events/UserMetadataUpdater.php b/src/Core/Handlers/Events/UserMetadataUpdater.php deleted file mode 100755 index e8fedaffd..000000000 --- a/src/Core/Handlers/Events/UserMetadataUpdater.php +++ /dev/null @@ -1,70 +0,0 @@ -listen('Flarum\Core\Events\PostWasPosted', __CLASS__.'@whenPostWasPosted'); - $events->listen('Flarum\Core\Events\PostWasDeleted', __CLASS__.'@whenPostWasDeleted'); - $events->listen('Flarum\Core\Events\PostWasHidden', __CLASS__.'@whenPostWasHidden'); - $events->listen('Flarum\Core\Events\PostWasRestored', __CLASS__.'@whenPostWasRestored'); - $events->listen('Flarum\Core\Events\DiscussionWasStarted', __CLASS__.'@whenDiscussionWasStarted'); - $events->listen('Flarum\Core\Events\DiscussionWasDeleted', __CLASS__.'@whenDiscussionWasDeleted'); - } - - public function whenPostWasPosted(PostWasPosted $event) - { - $this->updateCommentsCount($event->post->user, 1); - } - - public function whenPostWasDeleted(PostWasDeleted $event) - { - $this->updateCommentsCount($event->post->user, -1); - } - - public function whenPostWasHidden(PostWasHidden $event) - { - $this->updateCommentsCount($event->post->user, -1); - } - - public function whenPostWasRestored(PostWasRestored $event) - { - $this->updateCommentsCount($event->post->user, 1); - } - - public function whenDiscussionWasStarted(DiscussionWasStarted $event) - { - $this->updateDiscussionsCount($event->discussion->startUser, 1); - } - - public function whenDiscussionWasDeleted(DiscussionWasDeleted $event) - { - $this->updateDiscussionsCount($event->discussion->startUser, -1); - } - - protected function updateCommentsCount(User $user, $amount) - { - $user->comments_count += $amount; - $user->save(); - } - - protected function updateDiscussionsCount(User $user, $amount) - { - $user->discussions_count += $amount; - $user->save(); - } -} diff --git a/src/Core/Models/Model.php b/src/Core/Model.php similarity index 97% rename from src/Core/Models/Model.php rename to src/Core/Model.php index 6350ca969..cc5a369db 100755 --- a/src/Core/Models/Model.php +++ b/src/Core/Model.php @@ -1,11 +1,9 @@ -user_id = $userId; - $notification->sender_id = $senderId; - $notification->type = $type; - $notification->subject_id = $subjectId; - $notification->data = $data; - $notification->time = time(); - - return $notification; - } - - public function read() - { - $this->is_read = true; - } - - /** - * Unserialize the data attribute. - * - * @param string $value - * @return string - */ - public function getDataAttribute($value) - { - return json_decode($value); - } - - /** - * Serialize the data attribute. - * - * @param string $value - */ - public function setDataAttribute($value) - { - $this->attributes['data'] = $value ? json_encode($value) : null; - } - - /** - * Define the relationship with the notification's recipient. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function user() - { - return $this->belongsTo('Flarum\Core\Models\User', 'user_id'); - } - - /** - * Define the relationship with the notification's sender. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function sender() - { - return $this->belongsTo('Flarum\Core\Models\User', 'sender_id'); - } - - public function subject() - { - return $this->mappedMorphTo(static::$subjects, 'subject', 'type', 'subject_id'); - } - - public static function getTypes() - { - return static::$subjects; - } - - /** - * Register a notification type. - * - * @param string $type - * @param string $class - * @return void - */ - public static function registerType($class) - { - if ($subject = $class::getSubjectModel()) { - static::$subjects[$class::getType()] = $subject; - } - } -} diff --git a/src/Core/Models/Permission.php b/src/Core/Models/Permission.php deleted file mode 100644 index 22a876e04..000000000 --- a/src/Core/Models/Permission.php +++ /dev/null @@ -1,5 +0,0 @@ - 'required|integer', - 'time' => 'required|date', - 'content' => 'required', - 'number' => 'integer', - 'user_id' => 'integer', - 'edit_time' => 'date', - 'edit_user_id' => 'integer', - 'hide_time' => 'date', - 'hide_user_id' => 'integer', - ]; - - /** - * The table associated with the model. - * - * @var string - */ - protected $table = 'posts'; - - /** - * The attributes that should be mutated to dates. - * - * @var array - */ - protected $dates = ['time', 'edit_time', 'hide_time']; - - /** - * A map of post types, as specified in the `type` column, to their - * classes. - * - * @var array - */ - protected static $types = []; - - /** - * The type of post this is, to be stored in the posts table. - * - * Should be overwritten by subclasses with the value that is - * to be stored in the database, which will then be used for - * mapping the hydrated model instance to the proper subtype. - * - * @var string - */ - public static $type = ''; - - /** - * Raise an event when a post is deleted. Add an event listener to set the - * post's number, and update the discussion's number index, when inserting - * a post. - * - * @return void - */ - public static function boot() - { - parent::boot(); - - static::creating(function ($post) { - $post->type = $post::$type; - $post->number = ++$post->discussion->number_index; - $post->discussion->save(); - }); - - static::deleting(function ($post) { - if ($post->number == 1) { - throw new ValidationFailureException; - } - }); - - static::deleted(function ($post) { - $post->raise(new PostWasDeleted($post)); - }); - - static::addGlobalScope(new RegisteredTypesScope); - } - - /** - * Define the relationship with the post's discussion. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function discussion() - { - return $this->belongsTo('Flarum\Core\Models\Discussion', 'discussion_id'); - } - - /** - * Define the relationship with the post's author. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function user() - { - return $this->belongsTo('Flarum\Core\Models\User', 'user_id'); - } - - /** - * Define the relationship with the user who edited the post. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function editUser() - { - return $this->belongsTo('Flarum\Core\Models\User', 'edit_user_id'); - } - - /** - * Define the relationship with the user who hid the post. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function hideUser() - { - return $this->belongsTo('Flarum\Core\Models\User', 'hide_user_id'); - } - - /** - * Terminate the query and return an array of matching IDs. - * Example usage: `$ids = $discussion->posts()->ids()` - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return array - */ - public function scopeIds($query) - { - return array_map('intval', $query->get(['id'])->fetch('id')->all()); - } - - /** - * Get all posts, regardless of their type, by removing the - * `RegisteredTypesScope` global scope constraints applied on this model. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder - */ - public function scopeAllTypes($query) - { - return $this->removeGlobalScopes($query); - } - - /** - * Create a new model instance according to the post's type. - * - * @param array $attributes - * @return static|object - */ - public function newFromBuilder($attributes = [], $connection = null) - { - if (!empty($attributes->type)) { - $type = $attributes->type; - if (isset(static::$types[$type])) { - $class = static::$types[$type]; - if (class_exists($class)) { - $instance = new $class; - $instance->exists = true; - $instance->setRawAttributes((array) $attributes, true); - $instance->setConnection($connection ?: $this->connection); - return $instance; - } - } - } - - return parent::newFromBuilder($attributes, $connection); - } - - /** - * Register a post type and its model class. - * - * @param string $class - * @return void - */ - public static function addType($class) - { - static::$types[$class::$type] = $class; - } - - public static function getTypes() - { - return static::$types; - } -} diff --git a/src/Core/Notifications/NotificationInterface.php b/src/Core/Notifications/Blueprint.php similarity index 53% rename from src/Core/Notifications/NotificationInterface.php rename to src/Core/Notifications/Blueprint.php index ca908afe6..223984a29 100644 --- a/src/Core/Notifications/NotificationInterface.php +++ b/src/Core/Notifications/Blueprint.php @@ -1,42 +1,33 @@ notificationId = $notificationId; + $this->actor = $actor; + } +} diff --git a/src/Core/Notifications/Commands/ReadNotificationHandler.php b/src/Core/Notifications/Commands/ReadNotificationHandler.php new file mode 100644 index 000000000..4747da536 --- /dev/null +++ b/src/Core/Notifications/Commands/ReadNotificationHandler.php @@ -0,0 +1,33 @@ +actor; + + if ($actor->isGuest()) { + throw new PermissionDeniedException; + } + + $notification = Notification::where('user_id', $actor->id)->findOrFail($command->notificationId); + + $notification->read(); + $notification->save(); + + $this->dispatchEventsFor($notification); + + return $notification; + } +} diff --git a/src/Core/Notifications/DiscussionRenamedNotification.php b/src/Core/Notifications/DiscussionRenamedBlueprint.php similarity index 61% rename from src/Core/Notifications/DiscussionRenamedNotification.php rename to src/Core/Notifications/DiscussionRenamedBlueprint.php index e047826b7..db6cd7e67 100644 --- a/src/Core/Notifications/DiscussionRenamedNotification.php +++ b/src/Core/Notifications/DiscussionRenamedBlueprint.php @@ -1,36 +1,57 @@ post = $post; } - public function getSubject() - { - return $this->post->discussion; - } - + /** + * {@inheritdoc} + */ public function getSender() { return $this->post->user; } + /** + * {@inheritdoc} + */ + public function getSubject() + { + return $this->post->discussion; + } + + /** + * {@inheritdoc} + */ public function getData() { return ['postNumber' => (int) $this->post->number]; } + /** + * {@inheritdoc} + */ public static function getType() { return 'discussionRenamed'; } + /** + * {@inheritdoc} + */ public static function getSubjectModel() { return 'Flarum\Core\Models\Discussion'; diff --git a/src/Core/Repositories/EloquentNotificationRepository.php b/src/Core/Notifications/EloquentNotificationRepository.php similarity index 50% rename from src/Core/Repositories/EloquentNotificationRepository.php rename to src/Core/Notifications/EloquentNotificationRepository.php index 2590c48c2..6aca56947 100644 --- a/src/Core/Repositories/EloquentNotificationRepository.php +++ b/src/Core/Notifications/EloquentNotificationRepository.php @@ -1,26 +1,31 @@ -raw('MAX(id) AS id'), app('db')->raw('SUM(is_read = 0) AS unread_count')) + $primaries = Notification::select( + app('flarum.db')->raw('MAX(id) AS id'), + app('flarum.db')->raw('SUM(is_read = 0) AS unread_count') + ) ->where('user_id', $user->id) - ->whereIn('type', array_filter(array_keys(Notification::getTypes()), [$user, 'shouldAlert'])) + ->whereIn('type', $user->getAlertableNotificationTypes()) ->where('is_deleted', false) ->groupBy('type', 'subject_id') - ->orderBy('time', 'desc') + ->latest('time') ->skip($offset) ->take($limit); return Notification::with('subject') ->select('notifications.*', 'p.unread_count') ->mergeBindings($primaries->getQuery()) - ->join(app('db')->raw('('.$primaries->toSql().') p'), 'notifications.id', '=', 'p.id') - ->orderBy('time', 'desc') + ->join(app('flarum.db')->raw('('.$primaries->toSql().') p'), 'notifications.id', '=', 'p.id') + ->latest('time') ->get(); } } diff --git a/src/Core/Notifications/Events/NotificationWillBeSent.php b/src/Core/Notifications/Events/NotificationWillBeSent.php new file mode 100644 index 000000000..fc30a253e --- /dev/null +++ b/src/Core/Notifications/Events/NotificationWillBeSent.php @@ -0,0 +1,30 @@ +blueprint = $blueprint; + $this->users = $users; + } +} diff --git a/src/Core/Notifications/Listeners/DiscussionRenamedNotifier.php b/src/Core/Notifications/Listeners/DiscussionRenamedNotifier.php new file mode 100755 index 000000000..1d6c1d545 --- /dev/null +++ b/src/Core/Notifications/Listeners/DiscussionRenamedNotifier.php @@ -0,0 +1,56 @@ +notifications = $notifications; + } + + /** + * @param Dispatcher $events + */ + public function subscribe(Dispatcher $events) + { + $events->listen(DiscussionWasRenamed::class, __CLASS__.'@whenDiscussionWasRenamed'); + } + + /** + * @param DiscussionWasRenamed $event + */ + public function whenDiscussionWasRenamed(DiscussionWasRenamed $event) + { + $post = DiscussionRenamedPost::reply( + $event->discussion->id, + $event->actor->id, + $event->oldTitle, + $event->discussion->title + ); + + $post = $event->discussion->mergePost($post); + + if ($event->discussion->start_user_id !== $event->actor->id) { + $blueprint = new DiscussionRenamedBlueprint($post); + + if ($post->exists) { + $this->notifications->sync($blueprint, [$event->discussion->startUser]); + } else { + $this->notifications->delete($blueprint); + } + } + } +} diff --git a/src/Core/Notifications/MailableBlueprint.php b/src/Core/Notifications/MailableBlueprint.php new file mode 100644 index 000000000..a34850231 --- /dev/null +++ b/src/Core/Notifications/MailableBlueprint.php @@ -0,0 +1,18 @@ +is_read = true; + } + + /** + * When getting the data attribute, unserialize the JSON stored in the + * database into a plain array. + * + * @param string $value + * @return mixed + */ + public function getDataAttribute($value) + { + return json_decode($value, true); + } + + /** + * When setting the data attribute, serialize it into JSON for storage in + * the database. + * + * @param mixed $value + */ + public function setDataAttribute($value) + { + $this->attributes['data'] = json_encode($value); + } + + /** + * Get the subject model for this notification record by looking up its + * type in our subject model map. + * + * @return string|null + */ + public function getSubjectModelAttribute() + { + return array_get(static::$subjectModels, $this->type); + } + + /** + * Define the relationship with the notification's recipient. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo('Flarum\Core\Users\User', 'user_id'); + } + + /** + * Define the relationship with the notification's sender. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function sender() + { + return $this->belongsTo('Flarum\Core\Users\User', 'sender_id'); + } + + /** + * Define the relationship with the notification's subject. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function subject() + { + return $this->morphTo('subject', 'subjectModel', 'subject_id'); + } + + /** + * Get the type-to-subject-model map. + * + * @return array + */ + public static function getSubjectModels() + { + return static::$subjectModels; + } + + /** + * Set the subject model for the given notification type. + * + * @param string $type The notification type. + * @param string $subjectModel The class name of the subject model for that + * type. + * @return void + */ + public static function setSubjectModel($type, $subjectModel) + { + static::$subjectModels[$type] = $subjectModel; + } +} diff --git a/src/Core/Notifications/NotificationAbstract.php b/src/Core/Notifications/NotificationAbstract.php deleted file mode 100644 index 72a67f3c3..000000000 --- a/src/Core/Notifications/NotificationAbstract.php +++ /dev/null @@ -1,54 +0,0 @@ -mailer = $mailer; - $this->forum = $forum; } - public function send(NotificationInterface $notification, User $user) + /** + * @param MailableBlueprint $blueprint + * @param User $user + */ + public function send(MailableBlueprint $blueprint, User $user) { $this->mailer->send( - $notification->getEmailView(), + $blueprint->getEmailView(), compact('notification', 'user'), - function ($message) use ($notification, $user) { + function (Message $message) use ($blueprint, $user) { $message->to($user->email, $user->username) - ->subject($notification->getEmailSubject()); + ->subject($blueprint->getEmailSubject()); } ); } diff --git a/src/Core/Notifications/NotificationRepositoryInterface.php b/src/Core/Notifications/NotificationRepositoryInterface.php new file mode 100644 index 000000000..df24a5997 --- /dev/null +++ b/src/Core/Notifications/NotificationRepositoryInterface.php @@ -0,0 +1,16 @@ +notifications = $notifications; $this->mailer = $mailer; } @@ -27,68 +53,88 @@ class NotificationSyncer * visible to anyone else. If it is being made visible for the first time, * attempt to send the user an email. * - * @param \Flarum\Core\Notifications\NotificationInterface $notification - * @param \Flarum\Core\Models\User[] $users + * @param Blueprint $blueprint + * @param User[] $users * @return void */ - public function sync(NotificationInterface $notification, array $users) + public function sync(Blueprint $blueprint, array $users) { - $attributes = $this->getAttributes($notification); + $attributes = $this->getAttributes($blueprint); + // Find all existing notification records in the database matching this + // blueprint. We will begin by assuming that they all need to be + // deleted in order to match the provided list of users. $toDelete = Notification::where($attributes)->get(); $toUndelete = []; $newRecipients = []; + // For each of the provided users, check to see if they already have + // a notification record in the database. If they do, we will make sure + // it isn't marked as deleted. If they don't, we will want to create a + // new record for them. foreach ($users as $user) { - $existing = $toDelete->where('user_id', $user->id)->first(); + $existing = $toDelete->first(function ($i, $notification) use ($user) { + return $notification->user_id === $user->id; + }); - if (($k = $toDelete->search($existing)) !== false) { + if ($existing) { $toUndelete[] = $existing->id; - $toDelete->pull($k); + $toDelete->forget($toDelete->search($existing)); } elseif (! $this->onePerUser || ! in_array($user->id, $this->sentTo)) { $newRecipients[] = $user; $this->sentTo[] = $user->id; } } + // Delete all of the remaining notification records which weren't + // removed from this collection by the above loop. Un-delete the + // existing records that we want to keep. if (count($toDelete)) { - Notification::whereIn('id', $toDelete->lists('id'))->update(['is_deleted' => true]); + $this->setDeleted($toDelete->lists('id'), true); } if (count($toUndelete)) { - Notification::whereIn('id', $toUndelete)->update(['is_deleted' => false]); + $this->setDeleted($toUndelete, false); } + // Create a notification record, and send an email, for all users + // receiving this notification for the first time (we know because they + // didn't have a record in the database). if (count($newRecipients)) { - $now = Carbon::now('utc')->toDateTimeString(); - - event(new NotificationWillBeSent($notification, $newRecipients)); - - Notification::insert( - array_map(function ($user) use ($attributes, $notification, $now) { - return $attributes + ['user_id' => $user->id, 'time' => $now]; - }, $newRecipients) - ); - - foreach ($newRecipients as $user) { - if ($user->shouldEmail($notification::getType())) { - $this->mailer->send($notification, $user); - } - } + $this->sendNotifications($blueprint, $newRecipients); } } - public function delete(NotificationInterface $notification) + /** + * Delete a notification for all users. + * + * @param Blueprint $blueprint + * @return void + */ + public function delete(Blueprint $blueprint) { - Notification::where($this->getAttributes($notification))->update(['is_deleted' => true]); + Notification::where($this->getAttributes($blueprint))->update(['is_deleted' => true]); } - public function restore(NotificationInterface $notification) + /** + * Restore a notification for all users. + * + * @param Blueprint $blueprint + * @return void + */ + public function restore(Blueprint $blueprint) { - Notification::where($this->getAttributes($notification))->update(['is_deleted' => false]); + Notification::where($this->getAttributes($blueprint))->update(['is_deleted' => false]); } - public function onePerUser(Closure $callback) + /** + * Limit notifications to one per user for the entire duration of the given + * callback. + * + * @param callable $callback + * @return void + */ + public function onePerUser(callable $callback) { $this->sentTo = []; $this->onePerUser = true; @@ -98,13 +144,75 @@ class NotificationSyncer $this->onePerUser = false; } - protected function getAttributes(NotificationInterface $notification) + /** + * Create a notification record and send an email (depending on user + * preference) from a blueprint to a list of recipients. + * + * @param Blueprint $blueprint + * @param User[] $recipients + */ + protected function sendNotifications(Blueprint $blueprint, array $recipients) + { + $now = Carbon::now('utc')->toDateTimeString(); + + event(new NotificationWillBeSent($blueprint, $recipients)); + + $attributes = $this->getAttributes($blueprint); + + Notification::insert( + array_map(function (User $user) use ($attributes, $now) { + return $attributes + [ + 'user_id' => $user->id, + 'time' => $now + ]; + }, $recipients) + ); + + if ($blueprint instanceof MailableBlueprint) { + $this->mailNotifications($blueprint); + } + } + + /** + * Mail a notification to a list of users. + * + * @param MailableBlueprint $blueprint + * @param User[] $recipients + */ + protected function mailNotifications(MailableBlueprint $blueprint, array $recipients) + { + foreach ($recipients as $user) { + if ($user->shouldEmail($blueprint::getType())) { + $this->mailer->send($blueprint, $user); + } + } + } + + /** + * Set the deleted status of a list of notification records. + * + * @param int[] $ids + * @param bool $isDeleted + */ + protected function setDeleted(array $ids, $isDeleted) + { + Notification::whereIn('id', $ids)->update(['is_deleted' => $isDeleted]); + } + + /** + * Construct an array of attributes to be stored in a notification record in + * the database, given a notification blueprint. + * + * @param Blueprint $blueprint + * @return array + */ + protected function getAttributes(Blueprint $blueprint) { return [ - 'type' => $notification::getType(), - 'sender_id' => $notification->getSender()->id, - 'subject_id' => $notification->getSubject()->id, - 'data' => ($data = $notification->getData()) ? json_encode($data) : null + 'type' => $blueprint::getType(), + 'sender_id' => ($sender = $blueprint->getSender()) ? $sender->id : null, + 'subject_id' => ($subject = $blueprint->getSubject()) ? $subject->id : null, + 'data' => ($data = $blueprint->getData()) ? json_encode($data) : null ]; } } diff --git a/src/Core/Notifications/NotificationsServiceProvider.php b/src/Core/Notifications/NotificationsServiceProvider.php index 6be9c2f65..aa8662d16 100644 --- a/src/Core/Notifications/NotificationsServiceProvider.php +++ b/src/Core/Notifications/NotificationsServiceProvider.php @@ -13,19 +13,24 @@ class NotificationsServiceProvider extends ServiceProvider public function boot() { $this->extend([ - (new Extend\EventSubscriber('Flarum\Core\Handlers\Events\DiscussionRenamedNotifier')), + (new Extend\EventSubscriber('Flarum\Core\Notifications\Listeners\DiscussionRenamedNotifier')), - (new Extend\NotificationType('Flarum\Core\Notifications\DiscussionRenamedNotification')) + (new Extend\NotificationType('Flarum\Core\Notifications\DiscussionRenamedBlueprint')) ->subjectSerializer('Flarum\Api\Serializers\DiscussionBasicSerializer') ->enableByDefault('alert') ]); } + /** + * Register the service provider. + * + * @return void + */ public function register() { $this->app->bind( - 'Flarum\Core\Repositories\NotificationRepositoryInterface', - 'Flarum\Core\Repositories\EloquentNotificationRepository' + 'Flarum\Core\Notifications\NotificationRepositoryInterface', + 'Flarum\Core\Notifications\EloquentNotificationRepository' ); } } diff --git a/src/Core/Posts/Commands/DeletePost.php b/src/Core/Posts/Commands/DeletePost.php new file mode 100644 index 000000000..f817b4829 --- /dev/null +++ b/src/Core/Posts/Commands/DeletePost.php @@ -0,0 +1,41 @@ +postId = $postId; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Posts/Commands/DeletePostHandler.php b/src/Core/Posts/Commands/DeletePostHandler.php new file mode 100644 index 000000000..3b386558c --- /dev/null +++ b/src/Core/Posts/Commands/DeletePostHandler.php @@ -0,0 +1,44 @@ +posts = $posts; + } + + /** + * @param DeletePost $command + * @return \Flarum\Core\Posts\Post + */ + public function handle(DeletePost $command) + { + $actor = $command->actor; + + $post = $this->posts->findOrFail($command->postId, $actor); + + $post->assertCan($actor, 'delete'); + + event(new PostWillBeDeleted($post, $actor, $command->data)); + + $post->delete(); + + $this->dispatchEventsFor($post); + + return $post; + } +} diff --git a/src/Core/Posts/Commands/EditPost.php b/src/Core/Posts/Commands/EditPost.php new file mode 100644 index 000000000..9dd551315 --- /dev/null +++ b/src/Core/Posts/Commands/EditPost.php @@ -0,0 +1,39 @@ +postId = $postId; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Posts/Commands/EditPostHandler.php b/src/Core/Posts/Commands/EditPostHandler.php new file mode 100644 index 000000000..52c38b06b --- /dev/null +++ b/src/Core/Posts/Commands/EditPostHandler.php @@ -0,0 +1,65 @@ +posts = $posts; + } + + /** + * @param EditPost $command + * @return \Flarum\Core\Posts\Post + * @throws \Flarum\Core\Exceptions\PermissionDeniedException + */ + public function handle(EditPost $command) + { + $actor = $command->actor; + $data = $command->data; + + $post = $this->posts->findOrFail($command->postId, $actor); + + if ($post instanceof CommentPost) { + $attributes = array_get($data, 'attributes', []); + + if (isset($attributes['content'])) { + $post->assertCan($actor, 'edit'); + + $post->revise($attributes['content'], $actor); + } + + if (isset($attributes['isHidden'])) { + $post->assertCan($actor, 'edit'); + + if ($attributes['isHidden']) { + $post->hide($actor); + } else { + $post->restore(); + } + } + } + + event(new PostWillBeSaved($post, $actor, $data)); + + $post->save(); + + $this->dispatchEventsFor($post); + + return $post; + } +} diff --git a/src/Core/Posts/Commands/PostReply.php b/src/Core/Posts/Commands/PostReply.php new file mode 100644 index 000000000..55ac91619 --- /dev/null +++ b/src/Core/Posts/Commands/PostReply.php @@ -0,0 +1,39 @@ +discussionId = $discussionId; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Handlers/Commands/PostReplyCommandHandler.php b/src/Core/Posts/Commands/PostReplyHandler.php similarity index 52% rename from src/Core/Handlers/Commands/PostReplyCommandHandler.php rename to src/Core/Posts/Commands/PostReplyHandler.php index f930c2625..d92f9b767 100644 --- a/src/Core/Handlers/Commands/PostReplyCommandHandler.php +++ b/src/Core/Posts/Commands/PostReplyHandler.php @@ -1,48 +1,63 @@ -discussions = $discussions; $this->notifications = $notifications; } - public function handle($command) + /** + * @param PostReply $command + * @return CommentPost + * @throws \Flarum\Core\Exceptions\PermissionDeniedException + */ + public function handle(PostReply $command) { - $user = $command->user; + $actor = $command->actor; // Make sure the user has permission to reply to this discussion. First, // make sure the discussion exists and that the user has permission to // view it; if not, fail with a ModelNotFound exception so we don't give // away the existence of the discussion. If the user is allowed to view // it, check if they have permission to reply. - $discussion = $this->discussions->findOrFail($command->discussionId, $user); + $discussion = $this->discussions->findOrFail($command->discussionId, $actor); - $discussion->assertCan($user, 'reply'); + $discussion->assertCan($actor, 'reply'); // Create a new Post entity, persist it, and dispatch domain events. - // Before persistance, though, fire an event to give plugins an + // Before persistence, though, fire an event to give plugins an // opportunity to alter the post entity based on data in the command. $post = CommentPost::reply( $command->discussionId, - array_get($command->data, 'content'), - $user->id + array_get($command->data, 'attributes.content'), + $actor->id ); - event(new PostWillBeSaved($post, $command)); + event(new PostWillBeSaved($post, $actor, $command->data)); $post->save(); diff --git a/src/Core/Models/CommentPost.php b/src/Core/Posts/CommentPost.php similarity index 58% rename from src/Core/Models/CommentPost.php rename to src/Core/Posts/CommentPost.php index bf7ba92f4..7113091dd 100755 --- a/src/Core/Models/CommentPost.php +++ b/src/Core/Posts/CommentPost.php @@ -1,34 +1,36 @@ -time = time(); $post->discussion_id = $discussionId; $post->user_id = $userId; - $post->type = 'comment'; + $post->type = static::$type; $post->raise(new PostWasPosted($post)); @@ -49,18 +51,18 @@ class CommentPost extends Post /** * Revise the post's content. * - * @param string $content - * @param \Flarum\Core\Models\User $user + * @param string $content + * @param User $actor * @return $this */ - public function revise($content, $user) + public function revise($content, User $actor) { if ($this->content !== $content) { $this->content = $content; $this->content_html = static::formatContent($this); $this->edit_time = time(); - $this->edit_user_id = $user->id; + $this->edit_user_id = $actor->id; $this->raise(new PostWasRevised($this)); } @@ -71,18 +73,18 @@ class CommentPost extends Post /** * Hide the post. * - * @param \Flarum\Core\Models\User $user + * @param User $actor * @return $this */ - public function hide($user) + public function hide(User $actor) { if ($this->number == 1) { - throw new ValidationFailureException; + throw new DomainException('Cannot hide the first post of a discussion'); } if (! $this->hide_time) { $this->hide_time = time(); - $this->hide_user_id = $user->id; + $this->hide_user_id = $actor->id; $this->raise(new PostWasHidden($this)); } @@ -93,13 +95,12 @@ class CommentPost extends Post /** * Restore the post. * - * @param \Flarum\Core\Models\User $user * @return $this */ - public function restore($user) + public function restore() { if ($this->number == 1) { - throw new ValidationFailureException; + throw new DomainException('Cannot restore the first post of a discussion'); } if ($this->hide_time !== null) { @@ -113,9 +114,9 @@ class CommentPost extends Post } /** - * Get the content formatter as HTML. + * Get the content formatted as HTML. * - * @param string $value + * @param string $value * @return string */ public function getContentHtmlAttribute($value) @@ -128,10 +129,30 @@ class CommentPost extends Post return $value; } + /** + * Define the relationship with the user who edited the post. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function editUser() + { + return $this->belongsTo('Flarum\Core\Users\User', 'edit_user_id'); + } + + /** + * Define the relationship with the user who hid the post. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function hideUser() + { + return $this->belongsTo('Flarum\Core\Users\User', 'hide_user_id'); + } + /** * Get text formatter instance. * - * @return \Flarum\Core\Formatter\FormatterManager + * @return FormatterManager */ public static function getFormatter() { @@ -141,7 +162,7 @@ class CommentPost extends Post /** * Set text formatter instance. * - * @param \Flarum\Core\Formatter\FormatterManager $formatter + * @param FormatterManager $formatter */ public static function setFormatter(FormatterManager $formatter) { @@ -151,10 +172,10 @@ class CommentPost extends Post /** * Format a string of post content using the set formatter. * - * @param string $content + * @param CommentPost $post * @return string */ - protected static function formatContent($post) + protected static function formatContent(CommentPost $post) { return static::$formatter->format($post->content, $post); } diff --git a/src/Core/Models/DiscussionRenamedPost.php b/src/Core/Posts/DiscussionRenamedPost.php similarity index 90% rename from src/Core/Models/DiscussionRenamedPost.php rename to src/Core/Posts/DiscussionRenamedPost.php index a9fb581fa..0db8e2809 100755 --- a/src/Core/Models/DiscussionRenamedPost.php +++ b/src/Core/Posts/DiscussionRenamedPost.php @@ -1,4 +1,4 @@ -findByIds([$id], $user); + $posts = $this->findByIds([$id], $actor); if (! count($posts)) { throw new ModelNotFoundException; @@ -58,17 +50,9 @@ class EloquentPostRepository implements PostRepositoryInterface } /** - * Find posts that match certain conditions, optionally making sure they - * are visible to a certain user, and/or using other criteria. - * - * @param array $where - * @param \Flarum\Core\Models\User|null $user - * @param array $sort - * @param integer $count - * @param integer $start - * @return \Illuminate\Database\Eloquent\Collection + * {@inheritdoc} */ - public function findWhere($where = [], User $user = null, $sort = [], $count = null, $start = 0) + public function findWhere($where = [], User $actor = null, $sort = [], $count = null, $start = 0) { $query = Post::where($where) ->skip($start) @@ -80,39 +64,29 @@ class EloquentPostRepository implements PostRepositoryInterface $ids = $query->lists('id'); - return $this->findByIds($ids, $user); + return $this->findByIds($ids, $actor); } /** - * Find posts by their IDs, optionally making sure they are visible to a - * certain user. - * - * @param array $ids - * @param \Flarum\Core\Models\User|null $user - * @return \Illuminate\Database\Eloquent\Collection + * {@inheritdoc} */ - public function findByIds(array $ids, User $user = null) + public function findByIds(array $ids, User $actor = null) { - $ids = $this->filterDiscussionVisibleTo($ids, $user); + $ids = $this->filterDiscussionVisibleTo($ids, $actor); $posts = Post::with('discussion')->whereIn('id', (array) $ids)->get(); - return $this->filterVisibleTo($posts, $user); + return $this->filterVisibleTo($posts, $actor); } /** - * Find posts by matching a string of words against their content, - * optionally making sure they are visible to a certain user. - * - * @param string $string - * @param \Flarum\Core\Models\User|null $user - * @return \Illuminate\Database\Eloquent\Collection + * {@inheritdoc} */ - public function findByContent($string, User $user = null) + public function findByContent($string, User $actor = null) { $ids = $this->fulltext->match($string); - $ids = $this->filterDiscussionVisibleTo($ids, $user); + $ids = $this->filterDiscussionVisibleTo($ids, $actor); $query = Post::select('id', 'discussion_id')->whereIn('id', $ids); @@ -122,23 +96,16 @@ class EloquentPostRepository implements PostRepositoryInterface $posts = $query->get(); - return $this->filterVisibleTo($posts, $user); + return $this->filterVisibleTo($posts, $actor); } /** - * Get the position within a discussion where a post with a certain number - * is. If the post with that number does not exist, the index of the - * closest post to it will be returned. - * - * @param integer $discussionId - * @param integer $number - * @param \Flarum\Core\Models\User|null $user - * @return integer + * {@inheritdoc} */ - public function getIndexForNumber($discussionId, $number, User $user = null) + public function getIndexForNumber($discussionId, $number, User $actor = null) { $query = Discussion::find($discussionId) - ->visiblePosts($user) + ->postsVisibleTo($actor) ->where('time', '<', function ($query) use ($discussionId, $number) { $query->select('time') ->from('posts') @@ -154,14 +121,14 @@ class EloquentPostRepository implements PostRepositoryInterface return $query->count(); } - protected function filterDiscussionVisibleTo($ids, $user) + protected function filterDiscussionVisibleTo($ids, User $actor) { // For each post ID, we need to make sure that the discussion it's in // is visible to the user. - if ($user) { + if ($actor) { $ids = Discussion::join('posts', 'discussions.id', '=', 'posts.discussion_id') ->whereIn('posts.id', $ids) - ->whereVisibleTo($user) + ->whereVisibleTo($actor) ->get(['posts.id']) ->lists('id'); } @@ -169,11 +136,11 @@ class EloquentPostRepository implements PostRepositoryInterface return $ids; } - protected function filterVisibleTo($posts, $user) + protected function filterVisibleTo($posts, User $actor) { - if ($user) { - $posts = $posts->filter(function ($post) use ($user) { - return $post->can($user, 'view'); + if ($actor) { + $posts = $posts->filter(function ($post) use ($actor) { + return $post->can($actor, 'view'); }); } diff --git a/src/Core/Models/EventPost.php b/src/Core/Posts/EventPost.php similarity index 93% rename from src/Core/Models/EventPost.php rename to src/Core/Posts/EventPost.php index 084e2a9be..f12b6844e 100755 --- a/src/Core/Models/EventPost.php +++ b/src/Core/Posts/EventPost.php @@ -1,4 +1,4 @@ -post = $post; + } +} diff --git a/src/Core/Posts/Events/PostWasHidden.php b/src/Core/Posts/Events/PostWasHidden.php new file mode 100644 index 000000000..4709c825d --- /dev/null +++ b/src/Core/Posts/Events/PostWasHidden.php @@ -0,0 +1,21 @@ +post = $post; + } +} diff --git a/src/Core/Posts/Events/PostWasPosted.php b/src/Core/Posts/Events/PostWasPosted.php new file mode 100644 index 000000000..72c1cd041 --- /dev/null +++ b/src/Core/Posts/Events/PostWasPosted.php @@ -0,0 +1,21 @@ +post = $post; + } +} diff --git a/src/Core/Posts/Events/PostWasRestored.php b/src/Core/Posts/Events/PostWasRestored.php new file mode 100644 index 000000000..18dcae834 --- /dev/null +++ b/src/Core/Posts/Events/PostWasRestored.php @@ -0,0 +1,21 @@ +post = $post; + } +} diff --git a/src/Core/Posts/Events/PostWasRevised.php b/src/Core/Posts/Events/PostWasRevised.php new file mode 100644 index 000000000..518339519 --- /dev/null +++ b/src/Core/Posts/Events/PostWasRevised.php @@ -0,0 +1,21 @@ +post = $post; + } +} diff --git a/src/Core/Posts/Events/PostWillBeDeleted.php b/src/Core/Posts/Events/PostWillBeDeleted.php new file mode 100644 index 000000000..779e2fde5 --- /dev/null +++ b/src/Core/Posts/Events/PostWillBeDeleted.php @@ -0,0 +1,40 @@ +post = $post; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Posts/Events/PostWillBeSaved.php b/src/Core/Posts/Events/PostWillBeSaved.php new file mode 100644 index 000000000..0160e3066 --- /dev/null +++ b/src/Core/Posts/Events/PostWillBeSaved.php @@ -0,0 +1,40 @@ +post = $post; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Posts/MergeablePost.php b/src/Core/Posts/MergeablePost.php new file mode 100644 index 000000000..5396d89ef --- /dev/null +++ b/src/Core/Posts/MergeablePost.php @@ -0,0 +1,22 @@ + 'required|integer', + 'time' => 'required|date', + 'content' => 'required', + 'number' => 'integer', + 'user_id' => 'integer', + 'edit_time' => 'date', + 'edit_user_id' => 'integer', + 'hide_time' => 'date', + 'hide_user_id' => 'integer', + ]; + + /** + * {@inheritdoc} + */ + protected $table = 'posts'; + + /** + * {@inheritdoc} + */ + protected static $dateAttributes = ['time', 'edit_time', 'hide_time']; + + /** + * A map of post types, as specified in the `type` column, to their + * classes. + * + * @var array + */ + protected static $models = []; + + /** + * The type of post this is, to be stored in the posts table. + * + * Should be overwritten by subclasses with the value that is + * to be stored in the database, which will then be used for + * mapping the hydrated model instance to the proper subtype. + * + * @var string + */ + public static $type = ''; + + /** + * {@inheritdoc} + */ + public static function boot() + { + parent::boot(); + + // When a post is created, set its type according to the value of the + // subclass. Also give it an auto-incrementing number within the + // discussion. + static::creating(function (Post $post) { + $post->type = $post::$type; + $post->number = ++$post->discussion->number_index; + $post->discussion->save(); + }); + + // Don't allow the first post in a discussion to be deleted, because + // it doesn't make sense. The discussion must be deleted instead. + static::deleting(function (Post $post) { + if ($post->number == 1) { + throw new DomainException('Cannot delete the first post of a discussion'); + } + }); + + static::deleted(function (Post $post) { + $post->raise(new PostWasDeleted($post)); + }); + + static::addGlobalScope(new RegisteredTypesScope); + } + + /** + * Define the relationship with the post's discussion. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function discussion() + { + return $this->belongsTo('Flarum\Core\Discussions\Discussion', 'discussion_id'); + } + + /** + * Define the relationship with the post's author. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo('Flarum\Core\Users\User', 'user_id'); + } + + /** + * Get all posts, regardless of their type, by removing the + * `RegisteredTypesScope` global scope constraints applied on this model. + * + * @param Builder $query + * @return Builder + */ + public function scopeAllTypes(Builder $query) + { + return $this->removeGlobalScopes($query); + } + + /** + * Create a new model instance according to the post's type. + * + * @param array $attributes + * @param string|null $connection + * @return static|object + */ + public function newFromBuilder($attributes = [], $connection = null) + { + $attributes = (array) $attributes; + + if (! empty($attributes['type']) + && isset(static::$models[$attributes['type']]) + && class_exists($class = static::$models[$attributes['type']]) + ) { + $instance = new $class; + $instance->exists = true; + $instance->setRawAttributes($attributes, true); + $instance->setConnection($connection ?: $this->connection); + + return $instance; + } + + return parent::newFromBuilder($attributes, $connection); + } + + /** + * Get the type-to-model map. + * + * @return array + */ + public static function getModels() + { + return static::$models; + } + + /** + * Set the model for the given post type. + * + * @param string $type The post type. + * @param string $model The class name of the model for that type. + * @return void + */ + public static function setModel($type, $model) + { + static::$models[$type] = $model; + } +} diff --git a/src/Core/Repositories/PostRepositoryInterface.php b/src/Core/Posts/PostRepositoryInterface.php similarity index 55% rename from src/Core/Repositories/PostRepositoryInterface.php rename to src/Core/Posts/PostRepositoryInterface.php index 3e0f47f53..a407926b2 100644 --- a/src/Core/Repositories/PostRepositoryInterface.php +++ b/src/Core/Posts/PostRepositoryInterface.php @@ -1,6 +1,6 @@ -extend([ + new Extend\PostType('Flarum\Core\Posts\CommentPost'), + new Extend\PostType('Flarum\Core\Posts\DiscussionRenamedPost') + ]); + + CommentPost::setFormatter($this->app->make('flarum.formatter')); + + Post::allow('*', function ($post, $user, $action) { + return $post->discussion->can($user, $action.'Posts') ?: null; + }); + + // When fetching a discussion's posts: if the user doesn't have permission + // to moderate the discussion, then they can't see posts that have been + // hidden by someone other than themself. + Discussion::addPostVisibilityScope(function ($query, User $user, Discussion $discussion) { + if (! $discussion->can($user, 'editPosts')) { + $query->where(function ($query) use ($user) { + $query->whereNull('hide_user_id') + ->orWhere('hide_user_id', $user->id); + }); + } + }); + + Post::allow('view', function ($post, $user) { + return ! $post->hide_user_id || $post->can($user, 'edit') ?: null; + }); + + // A post is allowed to be edited if the user has permission to moderate + // the discussion which it's in, or if they are the author and the post + // hasn't been deleted by someone else. + Post::allow('edit', function ($post, $user) { + if ($post->discussion->can($user, 'editPosts') || + ($post->user_id == $user->id && (! $post->hide_user_id || $post->hide_user_id == $user->id)) + ) { + return true; + } + }); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + $this->app->bind( + 'Flarum\Core\Posts\PostRepositoryInterface', + 'Flarum\Core\Posts\EloquentPostRepository' + ); + } +} diff --git a/src/Core/Models/RegisteredTypesScope.php b/src/Core/Posts/RegisteredTypesScope.php similarity index 72% rename from src/Core/Models/RegisteredTypesScope.php rename to src/Core/Posts/RegisteredTypesScope.php index 24f579d74..3ffd65022 100644 --- a/src/Core/Models/RegisteredTypesScope.php +++ b/src/Core/Posts/RegisteredTypesScope.php @@ -1,8 +1,8 @@ -getQuery(); $this->whereIndex = count($query->wheres); $this->bindingIndex = count($query->getRawBindings()['where']); - $types = array_keys($model::getTypes()); + $types = array_keys($post::getModels()); $this->bindingCount = count($types); $query->whereIn('type', $types); } @@ -49,11 +49,11 @@ class RegisteredTypesScope implements ScopeInterface /** * Remove the scope from the given Eloquent query builder. * - * @param \Illuminate\Database\Eloquent\Builder $builder - * @param \Illuminate\Database\Eloquent\Model $model + * @param Builder $builder + * @param Model $post * @return void */ - public function remove(Builder $builder, Eloquent $model) + public function remove(Builder $builder, Model $post) { $query = $builder->getQuery(); diff --git a/src/Core/Repositories/ActivityRepositoryInterface.php b/src/Core/Repositories/ActivityRepositoryInterface.php deleted file mode 100644 index 7687732e4..000000000 --- a/src/Core/Repositories/ActivityRepositoryInterface.php +++ /dev/null @@ -1,8 +0,0 @@ -whereIn('type', array_keys(Activity::getSubjectModels())) - ->orderBy('time', 'desc') - ->skip($offset) - ->take($limit); - - if ($type !== null) { - $query->where('type', $type); - } - - return $query->get(); - } -} diff --git a/src/Core/Repositories/EloquentUserRepository.php b/src/Core/Repositories/EloquentUserRepository.php deleted file mode 100644 index 89a143083..000000000 --- a/src/Core/Repositories/EloquentUserRepository.php +++ /dev/null @@ -1,106 +0,0 @@ -scopeVisibleTo($query, $user)->firstOrFail(); - } - - /** - * Find a user by an identification (username or email). - * - * @param string $identification - * @return \Flarum\Core\Models\User|null - */ - public function findByIdentification($identification) - { - $field = filter_var($identification, FILTER_VALIDATE_EMAIL) ? 'email' : 'username'; - - return User::where($field, $identification)->first(); - } - - /** - * Find a user by email. - * - * @param string $email - * @return \Flarum\Core\Models\User|null - */ - public function findByEmail($email) - { - return User::where('email', $email)->first(); - } - - /** - * Get the ID of a user with the given username. - * - * @param string $username - * @param \Flarum\Core\Models\User $user - * @return integer|null - */ - public function getIdForUsername($username, User $user = null) - { - $query = User::where('username', 'like', $username); - - return $this->scopeVisibleTo($query, $user)->pluck('id'); - } - - /** - * Find users by matching a string of words against their username, - * optionally making sure they are visible to a certain user. - * - * @param string $string - * @param \Flarum\Core\Models\User|null $user - * @return array - */ - public function getIdsForUsername($string, User $user = null) - { - $query = User::select('id') - ->where('username', 'like', '%'.$string.'%') - ->orderByRaw('username = ? desc', [$string]) - ->orderByRaw('username like ? desc', [$string.'%']); - - return $this->scopeVisibleTo($query, $user)->lists('id'); - } - - /** - * Scope a query to only include records that are visible to a user. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Flarum\Core\Models\User $user - * @return \Illuminate\Database\Eloquent\Builder - */ - protected function scopeVisibleTo(Builder $query, User $user = null) - { - if ($user !== null) { - $query->whereVisibleTo($user); - } - - return $query; - } -} diff --git a/src/Core/Repositories/NotificationRepositoryInterface.php b/src/Core/Repositories/NotificationRepositoryInterface.php deleted file mode 100644 index d23f5d8f1..000000000 --- a/src/Core/Repositories/NotificationRepositoryInterface.php +++ /dev/null @@ -1,8 +0,0 @@ -getDefaultSort(); + + foreach ($sort as $field => $order) { + if (is_array($order)) { + foreach ($order as $value) { + $search->getQuery()->orderByRaw(snake_case($field).' != ?', [$value]); + } + } else { + $search->getQuery()->orderBy(snake_case($field), $order); + } + } + } + + /** + * @param Search $search + * @param int $offset + */ + protected function applyOffset(Search $search, $offset) + { + if ($offset > 0) { + $search->getQuery()->skip($offset); + } + } + + /** + * @param Search $search + * @param int|null $limit + */ + protected function applyLimit(Search $search, $limit) + { + if ($limit > 0) { + $search->getQuery()->take($limit); + } + } +} \ No newline at end of file diff --git a/src/Core/Search/Discussions/DiscussionSearchCriteria.php b/src/Core/Search/Discussions/DiscussionSearchCriteria.php deleted file mode 100644 index e2b971926..000000000 --- a/src/Core/Search/Discussions/DiscussionSearchCriteria.php +++ /dev/null @@ -1,17 +0,0 @@ -user = $user; - $this->query = $query; - $this->sort = $sort; - } -} diff --git a/src/Core/Search/Discussions/DiscussionSearchResults.php b/src/Core/Search/Discussions/DiscussionSearchResults.php deleted file mode 100644 index 58219cde2..000000000 --- a/src/Core/Search/Discussions/DiscussionSearchResults.php +++ /dev/null @@ -1,24 +0,0 @@ -discussions = $discussions; - $this->areMoreResults = $areMoreResults; - } - - public function getDiscussions() - { - return $this->discussions; - } - - public function areMoreResults() - { - return $this->areMoreResults; - } -} diff --git a/src/Core/Search/Discussions/DiscussionSearcher.php b/src/Core/Search/Discussions/DiscussionSearcher.php deleted file mode 100644 index 79db52405..000000000 --- a/src/Core/Search/Discussions/DiscussionSearcher.php +++ /dev/null @@ -1,120 +0,0 @@ - 'desc']; - - public function __construct(GambitManager $gambits, DiscussionRepositoryInterface $discussions, PostRepositoryInterface $posts) - { - $this->gambits = $gambits; - $this->discussions = $discussions; - $this->posts = $posts; - } - - public function addRelevantPost($discussionId, $postId) - { - if (empty($this->relevantPosts[$discussionId])) { - $this->relevantPosts[$discussionId] = []; - } - $this->relevantPosts[$discussionId][] = $postId; - } - - public function setDefaultSort($defaultSort) - { - $this->defaultSort = $defaultSort; - } - - public function getQuery() - { - return $this->query->getQuery(); - } - - public function getUser() - { - return $this->user; - } - - public function addActiveGambit($gambit) - { - $this->activeGambits[] = $gambit; - } - - public function getActiveGambits() - { - return $this->activeGambits; - } - - public function search(DiscussionSearchCriteria $criteria, $limit = null, $offset = 0, $load = []) - { - $this->user = $criteria->user; - $this->query = $this->discussions->query()->whereVisibleTo($criteria->user); - - $this->gambits->apply($criteria->query, $this); - - $sort = $criteria->sort ?: $this->defaultSort; - - foreach ($sort as $field => $order) { - if (is_array($order)) { - foreach ($order as $value) { - $this->query->orderByRaw(snake_case($field).' != ?', [$value]); - } - } else { - $this->query->orderBy(snake_case($field), $order); - } - } - - if ($offset > 0) { - $this->query->skip($offset); - } - if ($limit > 0) { - $this->query->take($limit + 1); - } - - event(new DiscussionSearchWillBePerformed($this, $criteria)); - - $discussions = $this->query->get(); - - if ($limit > 0 && $areMoreResults = $discussions->count() > $limit) { - $discussions->pop(); - } - - if (in_array('relevantPosts', $load)) { - $load = array_diff($load, ['relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user']); - - $postIds = []; - foreach ($this->relevantPosts as $id => $posts) { - $postIds = array_merge($postIds, array_slice($posts, 0, 2)); - } - $posts = $postIds ? $this->posts->findByIds($postIds, $this->user)->load('user')->all() : []; - - foreach ($discussions as $discussion) { - $discussion->relevantPosts = array_filter($posts, function ($post) use ($discussion) { - return $post->discussion_id == $discussion->id; - }); - } - } - - // @todo make instance rather than static and set on all discussions - Discussion::setStateUser($this->user); - $discussions->load($load); - - return new DiscussionSearchResults($discussions, $areMoreResults); - } -} diff --git a/src/Core/Search/Discussions/Gambits/AuthorGambit.php b/src/Core/Search/Discussions/Gambits/AuthorGambit.php deleted file mode 100644 index 89925efef..000000000 --- a/src/Core/Search/Discussions/Gambits/AuthorGambit.php +++ /dev/null @@ -1,30 +0,0 @@ -users = $users; - } - - protected function conditions(SearcherInterface $searcher, array $matches, $negate) - { - $username = trim($matches[1], '"'); - - $id = $this->users->getIdForUsername($username); - - $searcher->getQuery()->where('start_user_id', $negate ? '!=' : '=', $id); - } -} diff --git a/src/Core/Search/Discussions/Gambits/FulltextGambit.php b/src/Core/Search/Discussions/Gambits/FulltextGambit.php deleted file mode 100644 index 51e543b6a..000000000 --- a/src/Core/Search/Discussions/Gambits/FulltextGambit.php +++ /dev/null @@ -1,32 +0,0 @@ -posts = $posts; - } - - public function apply($string, SearcherInterface $searcher) - { - $posts = $this->posts->findByContent($string, $searcher->user); - - $discussions = []; - foreach ($posts as $post) { - $discussions[] = $id = $post->discussion_id; - $searcher->addRelevantPost($id, $post->id); - } - $discussions = array_unique($discussions); - - // TODO: implement negate (match for - at start of string) - $searcher->getQuery()->whereIn('id', $discussions); - - $searcher->setDefaultSort(['id' => $discussions]); - } -} diff --git a/src/Core/Search/Discussions/Gambits/UnreadGambit.php b/src/Core/Search/Discussions/Gambits/UnreadGambit.php deleted file mode 100644 index 9c9f8428f..000000000 --- a/src/Core/Search/Discussions/Gambits/UnreadGambit.php +++ /dev/null @@ -1,36 +0,0 @@ -discussions = $discussions; - } - - protected function conditions(SearcherInterface $searcher, array $matches, $negate) - { - $user = $searcher->user; - - if ($user->exists) { - $readIds = $this->discussions->getReadIds($user); - - if (! $negate) { - $searcher->getQuery()->whereNotIn('id', $readIds)->where('last_time', '>', $user->read_time ?: 0); - } else { - $searcher->getQuery()->whereIn('id', $readIds)->orWhere('last_time', '<=', $user->read_time ?: 0); - } - } - } -} diff --git a/src/Core/Search/GambitInterface.php b/src/Core/Search/GambitInterface.php index 44bb536e7..547ed983f 100644 --- a/src/Core/Search/GambitInterface.php +++ b/src/Core/Search/GambitInterface.php @@ -2,5 +2,12 @@ interface GambitInterface { - public function apply($string, SearcherInterface $searcher); + /** + * Apply conditions to the searcher for a bit of the search string. + * + * @param Search $search + * @param string $bit The piece of the search string. + * @return bool Whether or not the gambit was active for this bit. + */ + public function apply(Search $search, $bit); } diff --git a/src/Core/Search/GambitManager.php b/src/Core/Search/GambitManager.php index 0d534b1d1..1f6a0e0f1 100644 --- a/src/Core/Search/GambitManager.php +++ b/src/Core/Search/GambitManager.php @@ -1,54 +1,102 @@ container = $container; } + /** + * Add a gambit. + * + * @param string $gambit + */ public function add($gambit) { $this->gambits[] = $gambit; } - public function apply($string, $searcher) + /** + * Apply gambits to a search, given a search query. + * + * @param Search $search + * @param string $query + */ + public function apply(Search $search, $query) { - $string = $this->applyGambits($string, $searcher); + $query = $this->applyGambits($search, $query); - if ($string) { - $this->applyFulltext($string, $searcher); + if ($query) { + $this->applyFulltext($search, $query); } } + /** + * Set the gambit to handle fulltext searching. + * + * @param string $gambit + */ public function setFulltextGambit($gambit) { $this->fulltextGambit = $gambit; } - protected function bits($string) + /** + * Explode a search query into an array of bits. + * + * @param string $query + * @return array + */ + protected function explode($query) { - return str_getcsv($string, ' '); + return str_getcsv($query, ' '); } - protected function applyGambits($string, $searcher) + /** + * @param Search $search + * @param string $query + * @return string + */ + protected function applyGambits(Search $search, $query) { - $bits = $this->bits($string); + $bits = $this->explode($query); $gambits = array_map([$this->container, 'make'], $this->gambits); foreach ($bits as $k => $bit) { foreach ($gambits as $gambit) { - if ($gambit->apply($bit, $searcher)) { - $searcher->addActiveGambit($gambit); + if (! $gambit instanceof GambitInterface) { + throw new LogicException('Gambit ' . get_class($gambit) + . ' does not implement ' . GambitInterface::class); + } + + if ($gambit->apply($search, $bit)) { + $search->addActiveGambit($gambit); unset($bits[$k]); break; } @@ -58,7 +106,11 @@ class GambitManager return implode(' ', $bits); } - protected function applyFulltext($string, $searcher) + /** + * @param Search $search + * @param string $query + */ + protected function applyFulltext(Search $search, $query) { if (! $this->fulltextGambit) { return; @@ -66,7 +118,7 @@ class GambitManager $gambit = $this->container->make($this->fulltextGambit); - $searcher->addActiveGambit($gambit); - $gambit->apply($string, $searcher); + $search->addActiveGambit($gambit); + $gambit->apply($search, $query); } } diff --git a/src/Core/Search/RegexGambit.php b/src/Core/Search/RegexGambit.php new file mode 100644 index 000000000..6265f0362 --- /dev/null +++ b/src/Core/Search/RegexGambit.php @@ -0,0 +1,49 @@ +match($bit)) { + list($negate) = array_splice($matches, 1, 1); + + $this->conditions($searcher, $matches, !! $negate); + } + + return !! $matches; + } + + /** + * Match the bit against this gambit. + * + * @param string $bit + * @return array + */ + protected function match($bit) + { + if (preg_match('/^(-?)'.$this->pattern.'$/i', $bit, $matches)) { + return $matches; + } + } + + /** + * Apply conditions to the search, given that the gambit was matched. + * + * @param Search $search The search object. + * @param array $matches An array of matches from the search bit. + * @param bool $negate Whether or not the bit was negated, and thus whether + * or not the conditions should be negated. + * @return mixed + */ + abstract protected function conditions(Search $search, array $matches, $negate); +} diff --git a/src/Core/Search/Search.php b/src/Core/Search/Search.php new file mode 100644 index 000000000..5f65f2e35 --- /dev/null +++ b/src/Core/Search/Search.php @@ -0,0 +1,107 @@ +query = $query; + $this->actor = $actor; + } + + /** + * Get the query builder for the search results query. + * + * @return Builder + */ + public function getQuery() + { + return $this->query; + } + + /** + * Get the user who is performing the search. + * + * @return User + */ + public function getActor() + { + return $this->actor; + } + + /** + * Get the default sort order for the search. + * + * @return array + */ + public function getDefaultSort() + { + return $this->defaultSort; + } + + /** + * Set the default sort order for the search. This will only be applied if + * a sort order has not been specified in the search criteria. + * + * @param array $defaultSort An array of sort-order pairs, where the column + * is the key, and the order is the value. The order may be 'asc', + * 'desc', or an array of IDs to order by. + * @return mixed + */ + public function setDefaultSort(array $defaultSort) + { + $this->defaultSort = $defaultSort; + } + + /** + * Get a list of the gambits that are active in this search. + * + * @return GambitInterface[] + */ + public function getActiveGambits() + { + return $this->activeGambits; + } + + /** + * Add a gambit as being active in this search. + * + * @param GambitInterface $gambit + * @return void + */ + public function addActiveGambit(GambitInterface $gambit) + { + $this->activeGambits[] = $gambit; + } +} diff --git a/src/Core/Search/SearchCriteria.php b/src/Core/Search/SearchCriteria.php new file mode 100644 index 000000000..d08d5594f --- /dev/null +++ b/src/Core/Search/SearchCriteria.php @@ -0,0 +1,48 @@ +actor = $actor; + $this->query = $query; + $this->sort = $sort; + } +} diff --git a/src/Core/Search/SearchResults.php b/src/Core/Search/SearchResults.php new file mode 100644 index 000000000..c5ba22fee --- /dev/null +++ b/src/Core/Search/SearchResults.php @@ -0,0 +1,42 @@ +results = $results; + $this->areMoreResults = $areMoreResults; + } + + /** + * @return Collection + */ + public function getResults() + { + return $this->results; + } + + /** + * @return bool + */ + public function areMoreResults() + { + return $this->areMoreResults; + } +} diff --git a/src/Core/Search/SearcherInterface.php b/src/Core/Search/SearcherInterface.php deleted file mode 100644 index f1e400ac4..000000000 --- a/src/Core/Search/SearcherInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -users = $users; - } - - public function apply($string, SearcherInterface $searcher) - { - $users = $this->users->getIdsForUsername($string, $searcher->user); - - $searcher->getQuery()->whereIn('id', $users); - - $searcher->setDefaultSort(['id' => $users]); - } -} diff --git a/src/Core/Search/Users/UserSearchCriteria.php b/src/Core/Search/Users/UserSearchCriteria.php deleted file mode 100644 index eb06f332f..000000000 --- a/src/Core/Search/Users/UserSearchCriteria.php +++ /dev/null @@ -1,17 +0,0 @@ -user = $user; - $this->query = $query; - $this->sort = $sort; - } -} diff --git a/src/Core/Search/Users/UserSearchResults.php b/src/Core/Search/Users/UserSearchResults.php deleted file mode 100644 index ece38a80b..000000000 --- a/src/Core/Search/Users/UserSearchResults.php +++ /dev/null @@ -1,24 +0,0 @@ -users = $users; - $this->areMoreResults = $areMoreResults; - } - - public function getUsers() - { - return $this->users; - } - - public function areMoreResults() - { - return $this->areMoreResults; - } -} diff --git a/src/Core/Search/Users/UserSearcher.php b/src/Core/Search/Users/UserSearcher.php deleted file mode 100644 index da0dd2f7f..000000000 --- a/src/Core/Search/Users/UserSearcher.php +++ /dev/null @@ -1,89 +0,0 @@ - 'asc']; - - public function __construct(GambitManager $gambits, UserRepositoryInterface $users) - { - $this->gambits = $gambits; - $this->users = $users; - } - - public function setDefaultSort($defaultSort) - { - $this->defaultSort = $defaultSort; - } - - public function getQuery() - { - return $this->query->getQuery(); - } - - public function getUser() - { - return $this->user; - } - - public function addActiveGambit($gambit) - { - $this->activeGambits[] = $gambit; - } - - public function getActiveGambits() - { - return $this->activeGambits; - } - - public function search(UserSearchCriteria $criteria, $limit = null, $offset = 0, $load = []) - { - $this->user = $criteria->user; - $this->query = $this->users->query()->whereVisibleTo($criteria->user); - - $this->gambits->apply($criteria->query, $this); - - $sort = $criteria->sort ?: $this->defaultSort; - - foreach ($sort as $field => $order) { - if (is_array($order)) { - foreach ($order as $value) { - $this->query->orderByRaw(snake_case($field).' != ?', [$value]); - } - } else { - $this->query->orderBy(snake_case($field), $order); - } - } - - if ($offset > 0) { - $this->query->skip($offset); - } - if ($limit > 0) { - $this->query->take($limit + 1); - } - - event(new UserSearchWillBePerformed($this, $criteria)); - - $users = $this->query->get(); - - if ($limit > 0 && $areMoreResults = $users->count() > $limit) { - $users->pop(); - } - - $users->load($load); - - return new UserSearchResults($users, $areMoreResults); - } -} diff --git a/src/Core/Support/DispatchesEvents.php b/src/Core/Support/DispatchesEvents.php index 7dcc353db..50d8c8b27 100644 --- a/src/Core/Support/DispatchesEvents.php +++ b/src/Core/Support/DispatchesEvents.php @@ -1,4 +1,4 @@ -getConditions($action) as $condition) { - $can = $condition($this, $user, $action); + $can = $condition($this, $actor, $action); if ($can !== null) { return $can; @@ -42,15 +70,13 @@ trait Locked * Assert that the user has a certain permission for this model, throwing * an exception if they don't. * - * @param \Flarum\Core\Models\User $user - * @param string $permission - * @return void - * - * @throws \Flarum\Core\Exceptions\PermissionDeniedException + * @param User $actor + * @param string $action + * @throws PermissionDeniedException */ - public function assertCan($user, $action) + public function assertCan(User $actor, $action) { - if (! $this->can($user, $action)) { + if (! $this->can($actor, $action)) { throw new PermissionDeniedException; } } diff --git a/src/Core/Support/VisibleScope.php b/src/Core/Support/VisibleScope.php index a0c5a3d70..d09cf29f8 100644 --- a/src/Core/Support/VisibleScope.php +++ b/src/Core/Support/VisibleScope.php @@ -1,15 +1,37 @@ token = $token; + } +} diff --git a/src/Core/Handlers/Commands/ConfirmEmailCommandHandler.php b/src/Core/Users/Commands/ConfirmEmailHandler.php similarity index 54% rename from src/Core/Handlers/Commands/ConfirmEmailCommandHandler.php rename to src/Core/Users/Commands/ConfirmEmailHandler.php index 96c473c54..d307130d5 100644 --- a/src/Core/Handlers/Commands/ConfirmEmailCommandHandler.php +++ b/src/Core/Users/Commands/ConfirmEmailHandler.php @@ -1,23 +1,34 @@ -users = $users; } - public function handle($command) + /** + * @param ConfirmEmail $command + * @return \Flarum\Core\Users\User + * @throws InvalidConfirmationTokenException + */ + public function handle(ConfirmEmail $command) { $token = EmailToken::find($command->token); diff --git a/src/Core/Users/Commands/DeleteAvatar.php b/src/Core/Users/Commands/DeleteAvatar.php new file mode 100644 index 000000000..2cb171d38 --- /dev/null +++ b/src/Core/Users/Commands/DeleteAvatar.php @@ -0,0 +1,30 @@ +userId = $userId; + $this->actor = $actor; + } +} diff --git a/src/Core/Handlers/Commands/DeleteAvatarCommandHandler.php b/src/Core/Users/Commands/DeleteAvatarHandler.php similarity index 62% rename from src/Core/Handlers/Commands/DeleteAvatarCommandHandler.php rename to src/Core/Users/Commands/DeleteAvatarHandler.php index 4aca5ae76..f760b6add 100644 --- a/src/Core/Handlers/Commands/DeleteAvatarCommandHandler.php +++ b/src/Core/Users/Commands/DeleteAvatarHandler.php @@ -1,12 +1,11 @@ -users = $users; $this->uploadDir = $uploadDir; } - public function handle(DeleteAvatarCommand $command) + /** + * @param DeleteAvatar $command + * @return \Flarum\Core\Users\User + */ + public function handle(DeleteAvatar $command) { + $actor = $command->actor; + $user = $this->users->findOrFail($command->userId); // Make sure the current user is allowed to edit the user profile. // This will let admins and the user themselves pass through, and // throw an exception otherwise. - $user->assertCan($command->actor, 'edit'); + $user->assertCan($actor, 'edit'); $avatarPath = $user->avatar_path; $user->changeAvatarPath(null); - event(new AvatarWillBeDeleted($user, $command)); + event(new AvatarWillBeDeleted($user, $actor)); $this->uploadDir->delete($avatarPath); diff --git a/src/Core/Users/Commands/DeleteUser.php b/src/Core/Users/Commands/DeleteUser.php new file mode 100644 index 000000000..5ca892fce --- /dev/null +++ b/src/Core/Users/Commands/DeleteUser.php @@ -0,0 +1,41 @@ +userId = $userId; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Users/Commands/DeleteUserHandler.php b/src/Core/Users/Commands/DeleteUserHandler.php new file mode 100644 index 000000000..b413b7614 --- /dev/null +++ b/src/Core/Users/Commands/DeleteUserHandler.php @@ -0,0 +1,44 @@ +users = $users; + } + + /** + * @param DeleteUser $command + * @return User + * @throws \Flarum\Core\Exceptions\PermissionDeniedException + */ + public function handle(DeleteUser $command) + { + $actor = $command->actor; + $user = $this->users->findOrFail($command->userId, $actor); + + $user->assertCan($actor, 'delete'); + + event(new UserWillBeDeleted($user, $actor, $command->data)); + + $user->delete(); + $this->dispatchEventsFor($user); + + return $user; + } +} diff --git a/src/Core/Users/Commands/EditUser.php b/src/Core/Users/Commands/EditUser.php new file mode 100644 index 000000000..fd35f426e --- /dev/null +++ b/src/Core/Users/Commands/EditUser.php @@ -0,0 +1,39 @@ +userId = $userId; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Users/Commands/EditUserHandler.php b/src/Core/Users/Commands/EditUserHandler.php new file mode 100644 index 000000000..567be3a90 --- /dev/null +++ b/src/Core/Users/Commands/EditUserHandler.php @@ -0,0 +1,75 @@ +users = $users; + } + + /** + * @param EditUser $command + * @return User + * @throws \Flarum\Core\Exceptions\PermissionDeniedException + */ + public function handle(EditUser $command) + { + $actor = $command->actor; + $data = $command->data; + + $user = $this->users->findOrFail($command->userId, $actor); + + $user->assertCan($actor, 'edit'); + + $attributes = array_get($data, 'attributes', []); + + if (isset($attributes['username'])) { + $user->assertCan($actor, 'rename'); + $user->rename($attributes['username']); + } + + if (isset($attributes['email'])) { + $user->requestEmailChange($attributes['email']); + } + + if (isset($attributes['password'])) { + $user->changePassword($attributes['password']); + } + + if (isset($attributes['bio'])) { + $user->changeBio($attributes['bio']); + } + + if (! empty($attributes['readTime'])) { + $user->markAllAsRead(); + } + + if (! empty($attributes['preferences'])) { + foreach ($attributes['preferences'] as $k => $v) { + $user->setPreference($k, $v); + } + } + + event(new UserWillBeSaved($actor, $actor, $data)); + + $user->save(); + $this->dispatchEventsFor($user); + + return $user; + } +} diff --git a/src/Core/Users/Commands/RegisterUser.php b/src/Core/Users/Commands/RegisterUser.php new file mode 100644 index 000000000..2a03e01c9 --- /dev/null +++ b/src/Core/Users/Commands/RegisterUser.php @@ -0,0 +1,30 @@ +actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Users/Commands/RegisterUserHandler.php b/src/Core/Users/Commands/RegisterUserHandler.php new file mode 100644 index 000000000..6830e061d --- /dev/null +++ b/src/Core/Users/Commands/RegisterUserHandler.php @@ -0,0 +1,35 @@ +actor; + $data = $command->data; + + // TODO: check whether or not registration is open (config) + + $user = User::register( + array_get($data, 'attributes.username'), + array_get($data, 'attributes.email'), + array_get($data, 'attributes.password') + ); + + event(new UserWillBeSaved($user, $actor, $data)); + + $user->save(); + $this->dispatchEventsFor($user); + + return $user; + } +} diff --git a/src/Core/Users/Commands/RequestPasswordReset.php b/src/Core/Users/Commands/RequestPasswordReset.php new file mode 100644 index 000000000..a046897e1 --- /dev/null +++ b/src/Core/Users/Commands/RequestPasswordReset.php @@ -0,0 +1,19 @@ +email = $email; + } +} diff --git a/src/Core/Handlers/Commands/RequestPasswordResetCommandHandler.php b/src/Core/Users/Commands/RequestPasswordResetHandler.php similarity index 68% rename from src/Core/Handlers/Commands/RequestPasswordResetCommandHandler.php rename to src/Core/Users/Commands/RequestPasswordResetHandler.php index 13845c63b..5666248c2 100644 --- a/src/Core/Handlers/Commands/RequestPasswordResetCommandHandler.php +++ b/src/Core/Users/Commands/RequestPasswordResetHandler.php @@ -1,14 +1,14 @@ -users = $users; @@ -29,7 +32,12 @@ class RequestPasswordResetCommandHandler $this->url = $url; } - public function handle(RequestPasswordResetCommand $command) + /** + * @param RequestPasswordReset $command + * @return \Flarum\Core\Users\User + * @throws ModelNotFoundException + */ + public function handle(RequestPasswordReset $command) { $user = $this->users->findByEmail($command->email); @@ -49,7 +57,7 @@ class RequestPasswordResetCommandHandler 'forumTitle' => Core::config('forum_title') ]; - $this->mailer->send(['text' => 'flarum::emails.resetPassword'], $data, function ($message) use ($user) { + $this->mailer->send(['text' => 'flarum::emails.resetPassword'], $data, function (Message $message) use ($user) { $message->to($user->email); $message->subject('Reset Your Password'); }); diff --git a/src/Core/Users/Commands/UploadAvatar.php b/src/Core/Users/Commands/UploadAvatar.php new file mode 100644 index 000000000..62c995b90 --- /dev/null +++ b/src/Core/Users/Commands/UploadAvatar.php @@ -0,0 +1,40 @@ +userId = $userId; + $this->file = $file; + $this->actor = $actor; + } +} diff --git a/src/Core/Handlers/Commands/UploadAvatarCommandHandler.php b/src/Core/Users/Commands/UploadAvatarHandler.php similarity index 70% rename from src/Core/Handlers/Commands/UploadAvatarCommandHandler.php rename to src/Core/Users/Commands/UploadAvatarHandler.php index efa447231..3d5ff29b1 100644 --- a/src/Core/Handlers/Commands/UploadAvatarCommandHandler.php +++ b/src/Core/Users/Commands/UploadAvatarHandler.php @@ -1,8 +1,7 @@ -users = $users; $this->uploadDir = $uploadDir; } - public function handle(UploadAvatarCommand $command) + /** + * @param UploadAvatar $command + * @return \Flarum\Core\Users\User + * @throws \Flarum\Core\Exceptions\PermissionDeniedException + */ + public function handle(UploadAvatar $command) { + $actor = $command->actor; + $user = $this->users->findOrFail($command->userId); // Make sure the current user is allowed to edit the user profile. // This will let admins and the user themselves pass through, and // throw an exception otherwise. - $user->assertCan($command->actor, 'edit'); + $user->assertCan($actor, 'edit'); $tmpFile = tempnam(sys_get_temp_dir(), 'avatar'); $command->file->moveTo($tmpFile); - $uploadName = Str::lower(Str::quickRandom()) . '.jpg'; - - $manager = new ImageManager(array('driver' => 'imagick')); + $manager = new ImageManager(['driver' => 'imagick']); $manager->make($tmpFile)->fit(100, 100)->save(); + event(new AvatarWillBeSaved($user, $actor, $tmpFile)); + $mount = new MountManager([ 'source' => new Filesystem(new Local(pathinfo($tmpFile, PATHINFO_DIRNAME))), 'target' => $this->uploadDir, @@ -57,9 +67,9 @@ class UploadAvatarCommandHandler $mount->delete($file); } - $user->changeAvatarPath($uploadName); + $uploadName = Str::lower(Str::quickRandom()) . '.jpg'; - event(new AvatarWillBeUploaded($user, $command)); + $user->changeAvatarPath($uploadName); $mount->move("source://".pathinfo($tmpFile, PATHINFO_BASENAME), "target://$uploadName"); diff --git a/src/Core/Users/EloquentUserRepository.php b/src/Core/Users/EloquentUserRepository.php new file mode 100644 index 000000000..1404af5f0 --- /dev/null +++ b/src/Core/Users/EloquentUserRepository.php @@ -0,0 +1,80 @@ +scopeVisibleTo($query, $actor)->firstOrFail(); + } + + /** + * {@inheritdoc} + */ + public function findByIdentification($identification) + { + $field = filter_var($identification, FILTER_VALIDATE_EMAIL) ? 'email' : 'username'; + + return User::where($field, $identification)->first(); + } + + /** + * {@inheritdoc} + */ + public function findByEmail($email) + { + return User::where('email', $email)->first(); + } + + /** + * {@inheritdoc} + */ + public function getIdForUsername($username, User $actor = null) + { + $query = User::where('username', 'like', $username); + + return $this->scopeVisibleTo($query, $actor)->pluck('id'); + } + + /** + * {@inheritdoc} + */ + public function getIdsForUsername($string, User $actor = null) + { + $query = User::where('username', 'like', '%'.$string.'%') + ->orderByRaw('username = ? desc', [$string]) + ->orderByRaw('username like ? desc', [$string.'%']); + + return $this->scopeVisibleTo($query, $actor)->lists('id'); + } + + /** + * Scope a query to only include records that are visible to a user. + * + * @param Builder $query + * @param User $actor + * @return Builder + */ + protected function scopeVisibleTo(Builder $query, User $actor = null) + { + if ($actor !== null) { + $query->whereVisibleTo($actor); + } + + return $query; + } +} diff --git a/src/Core/Models/EmailToken.php b/src/Core/Users/EmailToken.php similarity index 65% rename from src/Core/Models/EmailToken.php rename to src/Core/Users/EmailToken.php index aeca16d70..020c033e9 100644 --- a/src/Core/Models/EmailToken.php +++ b/src/Core/Users/EmailToken.php @@ -1,25 +1,26 @@ -belongsTo('Flarum\Core\Models\User'); + return $this->belongsTo('Flarum\Core\Users\User'); } } diff --git a/src/Core/Users/Events/AvatarWillBeDeleted.php b/src/Core/Users/Events/AvatarWillBeDeleted.php new file mode 100644 index 000000000..e1fc60bae --- /dev/null +++ b/src/Core/Users/Events/AvatarWillBeDeleted.php @@ -0,0 +1,30 @@ +user = $user; + $this->actor = $actor; + } +} diff --git a/src/Core/Users/Events/AvatarWillBeSaved.php b/src/Core/Users/Events/AvatarWillBeSaved.php new file mode 100644 index 000000000..6d1b685d8 --- /dev/null +++ b/src/Core/Users/Events/AvatarWillBeSaved.php @@ -0,0 +1,39 @@ +user = $user; + $this->actor = $actor; + $this->path = $path; + } +} diff --git a/src/Core/Users/Events/UserAvatarWasChanged.php b/src/Core/Users/Events/UserAvatarWasChanged.php new file mode 100644 index 000000000..c90a8e237 --- /dev/null +++ b/src/Core/Users/Events/UserAvatarWasChanged.php @@ -0,0 +1,21 @@ +user = $user; + } +} diff --git a/src/Core/Users/Events/UserBioWasChanged.php b/src/Core/Users/Events/UserBioWasChanged.php new file mode 100644 index 000000000..0408ad216 --- /dev/null +++ b/src/Core/Users/Events/UserBioWasChanged.php @@ -0,0 +1,21 @@ +user = $user; + } +} diff --git a/src/Core/Users/Events/UserEmailChangeWasRequested.php b/src/Core/Users/Events/UserEmailChangeWasRequested.php new file mode 100644 index 000000000..d872424db --- /dev/null +++ b/src/Core/Users/Events/UserEmailChangeWasRequested.php @@ -0,0 +1,30 @@ +user = $user; + $this->email = $email; + } +} diff --git a/src/Core/Users/Events/UserEmailWasChanged.php b/src/Core/Users/Events/UserEmailWasChanged.php new file mode 100644 index 000000000..6b6e338ab --- /dev/null +++ b/src/Core/Users/Events/UserEmailWasChanged.php @@ -0,0 +1,21 @@ +user = $user; + } +} diff --git a/src/Core/Users/Events/UserPasswordWasChanged.php b/src/Core/Users/Events/UserPasswordWasChanged.php new file mode 100644 index 000000000..2f407ef64 --- /dev/null +++ b/src/Core/Users/Events/UserPasswordWasChanged.php @@ -0,0 +1,21 @@ +user = $user; + } +} diff --git a/src/Core/Users/Events/UserSearchWillBePerformed.php b/src/Core/Users/Events/UserSearchWillBePerformed.php new file mode 100644 index 000000000..b2c47a202 --- /dev/null +++ b/src/Core/Users/Events/UserSearchWillBePerformed.php @@ -0,0 +1,27 @@ +search = $search; + $this->criteria = $criteria; + } +} diff --git a/src/Core/Users/Events/UserWasActivated.php b/src/Core/Users/Events/UserWasActivated.php new file mode 100644 index 000000000..533fe2bbc --- /dev/null +++ b/src/Core/Users/Events/UserWasActivated.php @@ -0,0 +1,21 @@ +user = $user; + } +} diff --git a/src/Core/Users/Events/UserWasDeleted.php b/src/Core/Users/Events/UserWasDeleted.php new file mode 100644 index 000000000..6217eae45 --- /dev/null +++ b/src/Core/Users/Events/UserWasDeleted.php @@ -0,0 +1,21 @@ +user = $user; + } +} diff --git a/src/Core/Users/Events/UserWasRegistered.php b/src/Core/Users/Events/UserWasRegistered.php new file mode 100644 index 000000000..da8bf4ae8 --- /dev/null +++ b/src/Core/Users/Events/UserWasRegistered.php @@ -0,0 +1,21 @@ +user = $user; + } +} diff --git a/src/Core/Users/Events/UserWasRenamed.php b/src/Core/Users/Events/UserWasRenamed.php new file mode 100644 index 000000000..22a6f3654 --- /dev/null +++ b/src/Core/Users/Events/UserWasRenamed.php @@ -0,0 +1,21 @@ +user = $user; + } +} diff --git a/src/Core/Users/Events/UserWillBeDeleted.php b/src/Core/Users/Events/UserWillBeDeleted.php new file mode 100644 index 000000000..96860476c --- /dev/null +++ b/src/Core/Users/Events/UserWillBeDeleted.php @@ -0,0 +1,39 @@ +user = $user; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Users/Events/UserWillBeSaved.php b/src/Core/Users/Events/UserWillBeSaved.php new file mode 100644 index 000000000..c90495d7c --- /dev/null +++ b/src/Core/Users/Events/UserWillBeSaved.php @@ -0,0 +1,39 @@ +user = $user; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Models/Group.php b/src/Core/Users/Group.php similarity index 66% rename from src/Core/Models/Group.php rename to src/Core/Users/Group.php index bb9614bcd..e5f0559f4 100755 --- a/src/Core/Models/Group.php +++ b/src/Core/Users/Group.php @@ -1,32 +1,26 @@ -belongsToMany('Flarum\Core\Models\User', 'users_groups'); + return $this->belongsToMany('Flarum\Core\Users\User', 'users_groups'); } } diff --git a/src/Core/Models/Guest.php b/src/Core/Users/Guest.php similarity index 63% rename from src/Core/Models/Guest.php rename to src/Core/Users/Guest.php index 9ea7e541d..2a6344d3c 100755 --- a/src/Core/Models/Guest.php +++ b/src/Core/Users/Guest.php @@ -1,11 +1,16 @@ -mailer = $mailer; } /** - * Register the listeners for the subscriber. - * - * @param \Illuminate\Contracts\Events\Dispatcher $events + * @param Dispatcher $events */ public function subscribe(Dispatcher $events) { - $events->listen('Flarum\Core\Events\UserWasRegistered', __CLASS__.'@whenUserWasRegistered'); - $events->listen('Flarum\Core\Events\UserEmailChangeWasRequested', __CLASS__.'@whenUserEmailChangeWasRequested'); + $events->listen(UserWasRegistered::class, __CLASS__.'@whenUserWasRegistered'); + $events->listen(UserEmailChangeWasRequested::class, __CLASS__.'@whenUserEmailChangeWasRequested'); } + /** + * @param UserWasRegistered $event + */ public function whenUserWasRegistered(UserWasRegistered $event) { $user = $event->user; - $data = $this->getPayload($user, $user->email); + $data = $this->getEmailData($user, $user->email); - $this->mailer->send(['text' => 'flarum::emails.activateAccount'], $data, function ($message) use ($user) { + $this->mailer->send(['text' => 'flarum::emails.activateAccount'], $data, function (Message $message) use ($user) { $message->to($user->email); $message->subject('Activate Your New Account'); }); } + /** + * @param UserEmailChangeWasRequested $event + */ public function whenUserEmailChangeWasRequested(UserEmailChangeWasRequested $event) { $email = $event->email; - $data = $this->getPayload($event->user, $email); + $data = $this->getEmailData($event->user, $email); - $this->mailer->send(['text' => 'flarum::emails.confirmEmail'], $data, function ($message) use ($email) { + $this->mailer->send(['text' => 'flarum::emails.confirmEmail'], $data, function (Message $message) use ($email) { $message->to($email); $message->subject('Confirm Your New Email Address'); }); } - protected function generateToken($user, $email) + /** + * @param User $user + * @param string $email + * @return EmailToken + */ + protected function generateToken(User $user, $email) { $token = EmailToken::generate($user->id, $email); $token->save(); @@ -57,7 +74,14 @@ class EmailConfirmationMailer return $token; } - protected function getPayload($user, $email) + /** + * Get the data that should be made available to email templates. + * + * @param User $user + * @param string $email + * @return array + */ + protected function getEmailData(User $user, $email) { $token = $this->generateToken($user, $email); diff --git a/src/Core/Users/Listeners/UserMetadataUpdater.php b/src/Core/Users/Listeners/UserMetadataUpdater.php new file mode 100755 index 000000000..a21d5bf2a --- /dev/null +++ b/src/Core/Users/Listeners/UserMetadataUpdater.php @@ -0,0 +1,94 @@ +listen(PostWasPosted::class, __CLASS__.'@whenPostWasPosted'); + $events->listen(PostWasDeleted::class, __CLASS__.'@whenPostWasDeleted'); + $events->listen(PostWasHidden::class, __CLASS__.'@whenPostWasHidden'); + $events->listen(PostWasRestored::class, __CLASS__.'@whenPostWasRestored'); + $events->listen(DiscussionWasStarted::class, __CLASS__.'@whenDiscussionWasStarted'); + $events->listen(DiscussionWasDeleted::class, __CLASS__.'@whenDiscussionWasDeleted'); + } + + /** + * @param PostWasPosted $event + */ + public function whenPostWasPosted(PostWasPosted $event) + { + $this->updateCommentsCount($event->post->user, 1); + } + + /** + * @param PostWasDeleted $event + */ + public function whenPostWasDeleted(PostWasDeleted $event) + { + $this->updateCommentsCount($event->post->user, -1); + } + + /** + * @param PostWasHidden $event + */ + public function whenPostWasHidden(PostWasHidden $event) + { + $this->updateCommentsCount($event->post->user, -1); + } + + /** + * @param PostWasRestored $event + */ + public function whenPostWasRestored(PostWasRestored $event) + { + $this->updateCommentsCount($event->post->user, 1); + } + + /** + * @param DiscussionWasStarted $event + */ + public function whenDiscussionWasStarted(DiscussionWasStarted $event) + { + $this->updateDiscussionsCount($event->discussion->startUser, 1); + } + + /** + * @param DiscussionWasDeleted $event + */ + public function whenDiscussionWasDeleted(DiscussionWasDeleted $event) + { + $this->updateDiscussionsCount($event->discussion->startUser, -1); + } + + /** + * @param User $user + * @param int $amount + */ + protected function updateCommentsCount(User $user, $amount) + { + $user->comments_count += $amount; + $user->save(); + } + + /** + * @param User $user + * @param int $amount + */ + protected function updateDiscussionsCount(User $user, $amount) + { + $user->discussions_count += $amount; + $user->save(); + } +} diff --git a/src/Core/Models/PasswordToken.php b/src/Core/Users/PasswordToken.php similarity index 64% rename from src/Core/Models/PasswordToken.php rename to src/Core/Users/PasswordToken.php index 6896a395e..e9de0687c 100644 --- a/src/Core/Models/PasswordToken.php +++ b/src/Core/Users/PasswordToken.php @@ -1,25 +1,25 @@ -belongsTo('Flarum\Core\Models\User'); + return $this->belongsTo('Flarum\Core\Users\User'); } } diff --git a/src/Core/Users/Permission.php b/src/Core/Users/Permission.php new file mode 100644 index 000000000..8a7f547b5 --- /dev/null +++ b/src/Core/Users/Permission.php @@ -0,0 +1,7 @@ +users = $users; + } + + /** + * {@inheritdoc} + */ + public function apply(Search $search, $bit) + { + $users = $this->users->getIdsForUsername($bit, $search->getActor()); + + $search->getQuery()->whereIn('id', $users); + + $search->setDefaultSort(['id' => $users]); + } +} diff --git a/src/Core/Users/Search/UserSearch.php b/src/Core/Users/Search/UserSearch.php new file mode 100644 index 000000000..16b725755 --- /dev/null +++ b/src/Core/Users/Search/UserSearch.php @@ -0,0 +1,7 @@ +gambits = $gambits; + $this->users = $users; + } + + /** + * @param SearchCriteria $criteria + * @param int|null $limit + * @param int $offset + * @param array $load An array of relationships to load on the results. + * @return SearchResults + */ + public function search(SearchCriteria $criteria, $limit = null, $offset = 0, array $load = []) + { + $actor = $criteria->actor; + + $query = $this->users->query()->whereVisibleTo($actor); + + // Construct an object which represents this search for users. + // Apply gambits to it, sort, and paging criteria. Also give extensions + // an opportunity to modify it. + $search = new UserSearch($query->getQuery(), $actor); + + $this->gambits->apply($search, $criteria->query); + $this->applySort($search, $criteria->sort); + $this->applyOffset($search, $offset); + $this->applyLimit($search, $limit + 1); + + event(new UserSearchWillBePerformed($search, $criteria)); + + // Execute the search query and retrieve the results. We get one more + // results than the user asked for, so that we can say if there are more + // results. If there are, we will get rid of that extra result. + $users = $query->get(); + + if ($areMoreResults = ($limit > 0 && $users->count() > $limit)) { + $users->pop(); + } + + $users->load($load); + + return new SearchResults($users, $areMoreResults); + } +} diff --git a/src/Core/Models/User.php b/src/Core/Users/User.php similarity index 59% rename from src/Core/Models/User.php rename to src/Core/Users/User.php index cc1632d6a..272c5c29b 100755 --- a/src/Core/Models/User.php +++ b/src/Core/Users/User.php @@ -1,29 +1,47 @@ - 'integer', ]; - /** - * The table associated with the model. - * - * @var string - */ - protected $table = 'users'; - - /** - * The attributes that should be mutated to dates. - * - * @var array - */ - protected $dates = ['join_time', 'last_seen_time', 'read_time', 'notification_read_time']; - /** * The hasher with which to hash passwords. * @@ -63,10 +67,19 @@ class User extends Model */ protected static $hasher; + /** + * An array of registered user preferences. Each preference is defined with + * a key, and its value is an array containing the following keys: + * + * - transformer: a callback that confines the value of the preference + * - default: a default value if the preference isn't set + * + * @var array + */ protected static $preferences = []; /** - * Raise an event when a post is deleted. + * Boot the model. * * @return void */ @@ -82,9 +95,9 @@ class User extends Model /** * Register a new user. * - * @param string $username - * @param string $email - * @param string $password + * @param string $username + * @param string $email + * @param string $password * @return static */ public static function register($username, $email, $password) @@ -104,7 +117,7 @@ class User extends Model /** * Rename the user. * - * @param string $username + * @param string $username * @return $this */ public function rename($username) @@ -121,7 +134,7 @@ class User extends Model /** * Change the user's email. * - * @param string $email + * @param string $email * @return $this */ public function changeEmail($email) @@ -135,6 +148,12 @@ class User extends Model return $this; } + /** + * Request that the user's email be changed. + * + * @param string $email + * @return $this + */ public function requestEmailChange($email) { if ($email !== $this->email) { @@ -156,7 +175,7 @@ class User extends Model /** * Change the user's password. * - * @param string $password + * @param string $password * @return $this */ public function changePassword($password) @@ -169,9 +188,9 @@ class User extends Model } /** - * Store the password as a hash. + * Set the password attribute, storing it as a hash. * - * @param string $value + * @param string $value */ public function setPasswordAttribute($value) { @@ -181,7 +200,7 @@ class User extends Model /** * Change the user's bio. * - * @param string $bio + * @param string $bio * @return $this */ public function changeBio($bio) @@ -195,15 +214,15 @@ class User extends Model } /** - * Get the content formatter as HTML. + * Get the user's bio formatted as HTML. * - * @param string $value + * @param string $value * @return string */ public function getBioHtmlAttribute($value) { if ($value === null) { - $this->bio_html = $value = static::formatBio($this->bio); + $this->bio_html = $value = static::formatBio($this); $this->save(); } @@ -211,7 +230,7 @@ class User extends Model } /** - * Mark all discussions as read by setting the user's read_time. + * Mark all discussions as read. * * @return $this */ @@ -223,7 +242,7 @@ class User extends Model } /** - * Mark all notifications as read by setting the user's notification_read_time. + * Mark all notifications as read. * * @return $this */ @@ -252,6 +271,7 @@ class User extends Model /** * Get the URL of the user's avatar. * + * @todo Allow different storage locations to be used * @return string */ public function getAvatarUrlAttribute() @@ -262,7 +282,7 @@ class User extends Model /** * Check if a given password matches the user's password. * - * @param string $password + * @param string $password * @return boolean */ public function checkPassword($password) @@ -284,26 +304,10 @@ class User extends Model return $this; } - /** - * Confirm the user's email. - * - * @return $this - */ - public function confirmEmail() - { - $this->is_confirmed = true; - $this->confirmation_token = null; - - $this->raise(new UserEmailWasConfirmed($this)); - - return $this; - } - /** * Check whether the user has a certain permission based on their groups. * - * @param string $permission - * @param string $entity + * @param string $permission * @return boolean */ public function hasPermission($permission) @@ -312,70 +316,111 @@ class User extends Model return true; } - static $permissions = []; - - if (! isset($permissions[$this->id])) { - $permissions[$this->id] = $this->permissions()->get(); + if (! array_key_exists('permissions', $this->relations)) { + $this->setRelation('permissions', $this->permissions()->get()); } - return (bool) $permissions[$this->id]->contains('permission', $permission); + return (bool) $this->permissions->contains('permission', $permission); } + /** + * Get the notification types that should be alerted to this user, according + * to their preferences. + * + * @return array + */ + public function getAlertableNotificationTypes() + { + $types = array_keys(Notification::getSubjectModels()); + + return array_filter($types, [$this, 'shouldAlert']); + } + + /** + * Get the number of unread notifications for the user. + * + * @return mixed + */ public function getUnreadNotificationsCount() { - $types = array_keys(Notification::getTypes()); - return $this->notifications() - ->whereIn('type', array_filter($types, [$this, 'shouldAlert'])) + ->whereIn('type', $this->getAlertableNotificationTypes()) ->where('time', '>', $this->notification_read_time ?: 0) ->where('is_read', 0) ->count($this->getConnection()->raw('DISTINCT type, subject_id')); } + /** + * Get the values of all registered preferences for this user, by + * transforming their stored preferences and merging them with the defaults. + * + * @param string $value + * @return array + */ public function getPreferencesAttribute($value) { - $defaults = []; + $defaults = array_build(static::$preferences, function ($key, $value) { + return [$key, $value['default']]; + }); - foreach (static::$preferences as $k => $v) { - $defaults[$k] = $v['default']; - } + $user = array_only((array) json_decode($value, true), array_keys(static::$preferences)); - return array_merge($defaults, array_only((array) json_decode($value, true), array_keys(static::$preferences))); + return array_merge($defaults, $user); } + /** + * Encode an array of preferences for storage in the database. + * + * @param mixed $value + */ public function setPreferencesAttribute($value) { $this->attributes['preferences'] = json_encode($value); } - public static function registerPreference($key, $transformer = null, $default = null) - { - static::$preferences[$key] = [ - 'transformer' => $transformer, - 'default' => $default - ]; - } - - public static function notificationPreferenceKey($type, $method) - { - return 'notify_'.$type.'_'.$method; - } - + /** + * Check whether or not the user should receive an alert for a notification + * type. + * + * @param string $type + * @return bool + */ public function shouldAlert($type) { - return $this->preference(static::notificationPreferenceKey($type, 'alert')); + return (bool) $this->getPreference(static::getNotificationPreferenceKey($type, 'alert')); } + /** + * Check whether or not the user should receive an email for a notification + * type. + * + * @param string $type + * @return bool + */ public function shouldEmail($type) { - return $this->preference(static::notificationPreferenceKey($type, 'email')); + return (bool) $this->getPreference(static::getNotificationPreferenceKey($type, 'email')); } - public function preference($key, $default = null) + /** + * Get the value of a preference for this user. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function getPreference($key, $default = null) { return array_get($this->preferences, $key, $default); } + /** + * Set the value of a preference for this user. + * + * @param string $key + * @param mixed $value + * @return $this + */ public function setPreference($key, $value) { if (isset(static::$preferences[$key])) { @@ -393,6 +438,11 @@ class User extends Model return $this; } + /** + * Set the user as being last seen just now. + * + * @return $this + */ public function updateLastSeen() { $this->last_seen_time = time(); @@ -403,7 +453,7 @@ class User extends Model /** * Check whether or not the user is an administrator. * - * @return boolean + * @return bool */ public function isAdmin() { @@ -413,7 +463,7 @@ class User extends Model /** * Check whether or not the user is a guest. * - * @return boolean + * @return bool */ public function isGuest() { @@ -427,7 +477,7 @@ class User extends Model */ public function activity() { - return $this->hasMany('Flarum\Core\Models\Activity'); + return $this->hasMany('Flarum\Core\Activity\Activity'); } /** @@ -437,7 +487,7 @@ class User extends Model */ public function groups() { - return $this->belongsToMany('Flarum\Core\Models\Group', 'users_groups'); + return $this->belongsToMany('Flarum\Core\Users\Group', 'users_groups'); } /** @@ -447,11 +497,12 @@ class User extends Model */ public function notifications() { - return $this->hasMany('Flarum\Core\Models\Notification'); + return $this->hasMany('Flarum\Core\Notifications\Notification'); } /** - * Define the relationship with the user's permissions. + * Define the relationship with the permissions of all of the groups that + * the user is in. * * @return \Illuminate\Database\Eloquent\Builder */ @@ -459,6 +510,10 @@ class User extends Model { $groupIds = [Group::GUEST_ID]; + // If a user's account hasn't been activated, they are essentially no + // more than a guest. If they are activated, we can give them the + // standard 'member' group, as well as any other groups they've been + // assigned to. if ($this->is_activated) { $groupIds = array_merge($groupIds, [Group::MEMBER_ID], $this->groups->lists('id')); } @@ -473,7 +528,7 @@ class User extends Model */ public function accessTokens() { - return $this->hasMany('Flarum\Core\Models\AccessToken'); + return $this->hasMany('Flarum\Api\AccessToken'); } /** @@ -487,19 +542,9 @@ class User extends Model } /** - * Get text formatter instance. + * Set the text formatter instance. * - * @return \Flarum\Core\Formatter\FormatterManager - */ - public static function getFormatter() - { - return static::$formatter; - } - - /** - * Set text formatter instance. - * - * @param \Flarum\Core\Formatter\FormatterManager $formatter + * @param FormatterManager $formatter */ public static function setFormatter(FormatterManager $formatter) { @@ -507,13 +552,38 @@ class User extends Model } /** - * Format a string of post content using the set formatter. + * Get the formatted content of a user's bio. * - * @param string $content + * @param User $user * @return string */ - protected static function formatBio($content) + protected static function formatBio(User $user) { - return static::$formatter->format($content); + return static::$formatter->format($user->bio, $user); + } + + /** + * Register a preference with a transformer and a default value. + * + * @param string $key + * @param callable $transformer + * @param mixed $default + */ + public static function addPreference($key, callable $transformer = null, $default = null) + { + static::$preferences[$key] = compact('transformer', 'default'); + } + + /** + * Get the key for a preference which flags whether or not the user will + * receive a notification for $type via $method. + * + * @param string $type + * @param string $method + * @return string + */ + public static function getNotificationPreferenceKey($type, $method) + { + return 'notify_'.$type.'_'.$method; } } diff --git a/src/Core/Repositories/UserRepositoryInterface.php b/src/Core/Users/UserRepositoryInterface.php similarity index 57% rename from src/Core/Repositories/UserRepositoryInterface.php rename to src/Core/Users/UserRepositoryInterface.php index 99c9710b1..1af4a102d 100644 --- a/src/Core/Repositories/UserRepositoryInterface.php +++ b/src/Core/Users/UserRepositoryInterface.php @@ -1,6 +1,4 @@ -extend([ + new Extend\EventSubscriber('Flarum\Core\Users\Listeners\UserMetadataUpdater'), + new Extend\EventSubscriber('Flarum\Core\Users\Listeners\EmailConfirmationMailer') + ]); + + User::setHasher($this->app->make('hash')); + User::setFormatter($this->app->make('flarum.formatter')); + + User::addPreference('discloseOnline', 'boolval', true); + User::addPreference('indexProfile', 'boolval', true); + + User::allow('*', function (User $user, User $actor, $action) { + return $actor->hasPermission('user.'.$action) ?: null; + }); + + User::allow(['edit', 'delete'], function (User $user, User $actor) { + return $user->id == $actor->id ?: null; + }); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + $this->app->bind( + 'Flarum\Core\Users\UserRepositoryInterface', + 'Flarum\Core\Users\EloquentUserRepository' + ); + + $this->registerAvatarsFilesystem(); + $this->registerGambits(); + } + + public function registerAvatarsFilesystem() + { + $avatarsFilesystem = function (Container $app) { + return $app->make('Illuminate\Contracts\Filesystem\Factory')->disk('flarum-avatars')->getDriver(); + }; + + $this->app->when('Flarum\Core\Users\Commands\UploadAvatarHandler') + ->needs('League\Flysystem\FilesystemInterface') + ->give($avatarsFilesystem); + + $this->app->when('Flarum\Core\Users\Commands\DeleteAvatarHandler') + ->needs('League\Flysystem\FilesystemInterface') + ->give($avatarsFilesystem); + } + + public function registerGambits() + { + $this->app->instance('flarum.userGambits', []); + + $this->app->when('Flarum\Core\Users\Search\UserSearcher') + ->needs('Flarum\Core\Search\GambitManager') + ->give(function (Container $app) { + $gambits = new GambitManager($app); + + foreach ($app->make('flarum.userGambits') as $gambit) { + $gambits->add($gambit); + } + + $gambits->setFulltextGambit('Flarum\Core\Users\Search\Gambits\FulltextGambit'); + + return $gambits; + }); + } +} diff --git a/src/Extend/ActivityType.php b/src/Extend/ActivityType.php index da52f9fd2..d175a179e 100644 --- a/src/Extend/ActivityType.php +++ b/src/Extend/ActivityType.php @@ -1,7 +1,7 @@ name = $name; - $this->class = $class; - $this->priority = $priority; - } - - public function extend(Container $container) - { - $container->make('flarum.formatter')->add($this->name, $this->class, $this->priority); - } -} diff --git a/src/Extend/Model.php b/src/Extend/Model.php index b38a55111..bc449e0ec 100644 --- a/src/Extend/Model.php +++ b/src/Extend/Model.php @@ -82,7 +82,7 @@ class Model implements ExtenderInterface } foreach ($this->scopeVisible as $callback) { - $model::addVisiblePostsScope($callback); + $model::addVisibleScope($callback); } foreach ($this->allow as $info) { diff --git a/src/Extend/NotificationType.php b/src/Extend/NotificationType.php index 4f204cbc6..2380d93b4 100644 --- a/src/Extend/NotificationType.php +++ b/src/Extend/NotificationType.php @@ -1,9 +1,10 @@ class; + $type = $class::getType(); - Notification::registerType($class); + Notification::setSubjectModel($type, $class); - User::registerPreference(User::notificationPreferenceKey($class::getType(), 'alert'), 'boolval', in_array('alert', $this->enabled)); + User::addPreference(User::getNotificationPreferenceKey($type, 'alert'), 'boolval', in_array('alert', $this->enabled)); - if ($class::isEmailable()) { - User::registerPreference(User::notificationPreferenceKey($class::getType(), 'email'), 'boolval', in_array('email', $this->enabled)); + if ((new ReflectionClass($class))->implementsInterface('Flarum\Core\Notifications\MailableBlueprint')) { + User::addPreference(User::getNotificationPreferenceKey($type, 'email'), 'boolval', in_array('email', $this->enabled)); } - NotificationSerializer::$subjects[$class::getType()] = $this->serializer; + NotificationSerializer::setSubjectSerializer($type, $this->serializer); } } diff --git a/src/Extend/PostFormatter.php b/src/Extend/PostFormatter.php new file mode 100644 index 000000000..04018272c --- /dev/null +++ b/src/Extend/PostFormatter.php @@ -0,0 +1,20 @@ +class = $class; + } + + public function extend(Container $container) + { + $container->make('flarum.formatter')->add($this->class); + } +} diff --git a/src/Extend/PostType.php b/src/Extend/PostType.php index 97d051698..57e762ef0 100644 --- a/src/Extend/PostType.php +++ b/src/Extend/PostType.php @@ -1,7 +1,7 @@ class); + $class = $this->class; + + Post::setModel($class::$type, $class); } } diff --git a/src/Forum/Actions/BaseAction.php b/src/Forum/Actions/BaseAction.php deleted file mode 100644 index f89e2dd00..000000000 --- a/src/Forum/Actions/BaseAction.php +++ /dev/null @@ -1,13 +0,0 @@ -bus->dispatch($command); - } -} diff --git a/src/Forum/Actions/ConfirmEmailAction.php b/src/Forum/Actions/ConfirmEmailAction.php index ea99065fd..0c8e5ddb2 100644 --- a/src/Forum/Actions/ConfirmEmailAction.php +++ b/src/Forum/Actions/ConfirmEmailAction.php @@ -1,30 +1,54 @@ bus = $bus; + } + + /** + * @param Request $request + * @param array $routeParams + * @return \Psr\Http\Message\ResponseInterface + */ + public function handle(Request $request, array $routeParams = []) { try { $token = array_get($routeParams, 'token'); - $command = new ConfirmEmailCommand($token); - $user = $this->dispatch($command); + + $user = $this->bus->dispatch( + new ConfirmEmail($token) + ); } catch (InvalidConfirmationTokenException $e) { - return 'Invalid confirmation token'; + return new HtmlResponse('Invalid confirmation token'); } - $token = $this->dispatch(new GenerateAccessTokenCommand($user->id)); + $token = $this->bus->dispatch( + new GenerateAccessToken($user->id) + ); return $this->withRememberCookie( $this->redirectTo('/'), $token->id ); - // TODO: ->with('alert', ['type' => 'success', 'message' => 'Thanks for confirming!']); } } diff --git a/src/Forum/Actions/DiscussionAction.php b/src/Forum/Actions/DiscussionAction.php index 9de02cc2d..5851691e1 100644 --- a/src/Forum/Actions/DiscussionAction.php +++ b/src/Forum/Actions/DiscussionAction.php @@ -1,17 +1,19 @@ apiClient->send('Flarum\Api\Actions\Discussions\ShowAction', [ - 'id' => $params['id'], - 'near' => $params['near'] + $response = $this->apiClient->send(app('flarum.actor'), 'Flarum\Api\Actions\Discussions\ShowAction', [ + 'id' => $routeParams['id'], + 'near' => $routeParams['near'] ]); // TODO: return an object instead of an array? return [ - 'title' => $response->data->title, + 'title' => $response->data->attributes->title, 'response' => $response ]; } diff --git a/src/Forum/Actions/IndexAction.php b/src/Forum/Actions/IndexAction.php index 1e2d10070..723c7b278 100644 --- a/src/Forum/Actions/IndexAction.php +++ b/src/Forum/Actions/IndexAction.php @@ -7,50 +7,61 @@ use Flarum\Assets\LessCompiler; use Flarum\Core; use Flarum\Forum\Events\RenderView; use Flarum\Locale\JsCompiler as LocaleJsCompiler; -use Flarum\Support\Actor; use Flarum\Support\HtmlAction; use Illuminate\Database\ConnectionInterface; use Psr\Http\Message\ServerRequestInterface as Request; -use Symfony\Component\HttpFoundation\Session\SessionInterface; class IndexAction extends HtmlAction { + /** + * @var Client + */ protected $apiClient; - protected $actor; - - protected $session; - + /** + * @var ConnectionInterface + */ protected $database; + /** + * @var array + */ public static $translations = []; - public function __construct(Client $apiClient, Actor $actor, ConnectionInterface $database, SessionInterface $session) + /** + * @param Client $apiClient + * @param ConnectionInterface $database + */ + public function __construct(Client $apiClient, ConnectionInterface $database) { $this->apiClient = $apiClient; - $this->actor = $actor; - $this->session = $session; $this->database = $database; } - public function render(Request $request, $params = []) + /** + * @param Request $request + * @param array $routeParams + * @return \Illuminate\Contracts\View\View + */ + public function render(Request $request, array $routeParams = []) { $config = $this->database->table('config') ->whereIn('key', ['base_url', 'api_url', 'forum_title', 'welcome_title', 'welcome_message', 'theme_primary_color']) ->lists('value', 'key'); $session = []; - $alert = $this->session->get('alert'); - $response = $this->apiClient->send('Flarum\Api\Actions\Forum\ShowAction'); + $actor = app('flarum.actor'); + + $response = $this->apiClient->send($actor, 'Flarum\Api\Actions\Forum\ShowAction'); $data = [$response->data]; if (isset($response->included)) { $data = array_merge($data, $response->included); } - if (($user = $this->actor->getUser()) && $user->exists) { + if ($actor->exists) { $session = [ - 'userId' => $user->id, + 'userId' => $actor->id, 'token' => $request->getCookieParams()['flarum_remember'], ]; @@ -58,7 +69,7 @@ class IndexAction extends HtmlAction // the user + their groups, when we already have this information on // $this->actor. Can we simply run the CurrentUserSerializer // manually? - $response = $this->apiClient->send('Flarum\Api\Actions\Users\ShowAction', ['id' => $user->id]); + $response = $this->apiClient->send($actor, 'Flarum\Api\Actions\Users\ShowAction', ['id' => $actor->id]); $data[] = $response->data; if (isset($response->included)) { @@ -66,7 +77,7 @@ class IndexAction extends HtmlAction } } - $details = $this->getDetails($request, $params); + $details = $this->getDetails($request, $routeParams); $data = array_merge($data, array_get($details, 'data', [])); $response = array_get($details, 'response'); @@ -78,8 +89,7 @@ class IndexAction extends HtmlAction ->with('layout', 'flarum.forum::forum') ->with('data', $data) ->with('response', $response) - ->with('session', $session) - ->with('alert', $alert); + ->with('session', $session); $root = __DIR__.'/../../..'; $public = public_path().'/assets'; @@ -102,7 +112,7 @@ class IndexAction extends HtmlAction $assets->addLess("@$name: $value;"); } - $locale = $user->locale ?: Core::config('locale', 'en'); + $locale = $actor->locale ?: Core::config('locale', 'en'); $localeManager = app('flarum.localeManager'); $translations = $localeManager->getTranslations($locale); @@ -119,14 +129,19 @@ class IndexAction extends HtmlAction ->with('scripts', [$assets->getJsFile(), $localeCompiler->getFile()]); } - protected function getDetails($request, $params) + /** + * @param Request $request + * @param array $routeParams + * @return array + */ + protected function getDetails(Request $request, array $routeParams) { $queryParams = $request->getQueryParams(); // Only preload data if we're viewing the default index with no filters, // otherwise we have to do all kinds of crazy stuff if (!count($queryParams) && $request->getUri()->getPath() === '/') { - $response = $this->apiClient->send('Flarum\Api\Actions\Discussions\IndexAction'); + $response = $this->apiClient->send(app('flarum.actor'), 'Flarum\Api\Actions\Discussions\IndexAction'); return [ 'response' => $response @@ -136,6 +151,10 @@ class IndexAction extends HtmlAction return []; } + /** + * @param $translations + * @return array + */ protected static function filterTranslations($translations) { $filtered = []; diff --git a/src/Forum/Actions/LoginAction.php b/src/Forum/Actions/LoginAction.php index b835c76c5..be9761972 100644 --- a/src/Forum/Actions/LoginAction.php +++ b/src/Forum/Actions/LoginAction.php @@ -2,30 +2,46 @@ use Flarum\Api\Client; use Flarum\Forum\Events\UserLoggedIn; -use Flarum\Core\Repositories\UserRepositoryInterface; +use Flarum\Core\Users\UserRepositoryInterface; +use Flarum\Support\Action; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\JsonResponse; -class LoginAction extends BaseAction +class LoginAction extends Action { use WritesRememberCookie; + /** + * @var UserRepositoryInterface + */ protected $users; + /** + * @var Client + */ protected $apiClient; + /** + * @param UserRepositoryInterface $users + * @param Client $apiClient + */ public function __construct(UserRepositoryInterface $users, Client $apiClient) { $this->users = $users; $this->apiClient = $apiClient; } - public function handle(Request $request, $routeParams = []) + /** + * @param Request $request + * @param array $routeParams + * @return \Psr\Http\Message\ResponseInterface|EmptyResponse + */ + public function handle(Request $request, array $routeParams = []) { $params = array_only($request->getAttributes(), ['identification', 'password']); - $data = $this->apiClient->send('Flarum\Api\Actions\TokenAction', $params); + $data = $this->apiClient->send(app('flarum.actor'), 'Flarum\Api\Actions\TokenAction', $params); // TODO: The client needs to pass through exceptions(?) or the whole // response so we can look at the response code. For now if there isn't diff --git a/src/Forum/Actions/LogoutAction.php b/src/Forum/Actions/LogoutAction.php index 26d76d6ec..a91411266 100644 --- a/src/Forum/Actions/LogoutAction.php +++ b/src/Forum/Actions/LogoutAction.php @@ -1,15 +1,21 @@ actor->getUser(); + $user = app('flarum.actor'); if ($user->exists) { $user->accessTokens()->delete(); diff --git a/src/Forum/Actions/ResetPasswordAction.php b/src/Forum/Actions/ResetPasswordAction.php index ff6a163b8..82fa6d5c3 100644 --- a/src/Forum/Actions/ResetPasswordAction.php +++ b/src/Forum/Actions/ResetPasswordAction.php @@ -1,12 +1,17 @@ bus = $bus; + } + + /** + * @param Request $request + * @param array $routeParams + * @return \Zend\Diactoros\Response\RedirectResponse + */ + public function handle(Request $request, array $routeParams = []) { $input = $request->getParsedBody(); @@ -19,8 +39,8 @@ class SavePasswordAction extends BaseAction return $this->redirectTo('/reset/'.$token->id); // TODO: Use UrlGenerator } - $this->dispatch( - new EditUserCommand($token->user_id, $token->user, ['password' => $password]) + $this->bus->dispatch( + new EditUser($token->user_id, $token->user, ['password' => $password]) ); $token->delete(); diff --git a/src/Forum/Events/UserLoggedIn.php b/src/Forum/Events/UserLoggedIn.php index 50581129e..8a2e88704 100644 --- a/src/Forum/Events/UserLoggedIn.php +++ b/src/Forum/Events/UserLoggedIn.php @@ -1,6 +1,6 @@ app->bind('flarum.actor', function () { + return new Guest; + }); + $this->app->singleton( 'Flarum\Http\UrlGeneratorInterface', function () { @@ -116,6 +121,7 @@ class ForumServiceProvider extends ServiceProvider protected function action($class) { return function (ServerRequestInterface $httpRequest, $routeParams) use ($class) { + /** @var \Flarum\Support\Action $action */ $action = $this->app->make($class); return $action->handle($httpRequest, $routeParams); diff --git a/src/Forum/Middleware/LoginWithCookie.php b/src/Forum/Middleware/LoginWithCookie.php index cd4ee8051..e4d73f2f7 100644 --- a/src/Forum/Middleware/LoginWithCookie.php +++ b/src/Forum/Middleware/LoginWithCookie.php @@ -1,7 +1,7 @@ actor = $actor; + $this->app = $app; } /** @@ -26,7 +29,7 @@ class LoginWithCookie implements MiddlewareInterface if (($token = array_get($request->getCookieParams(), 'flarum_remember')) && ($accessToken = AccessToken::where('id', $token)->first()) ) { - $this->actor->setUser($user = $accessToken->user); + $this->app->instance('flarum.actor', $user = $accessToken->user); $user->updateLastSeen()->save(); } diff --git a/src/Http/RouteCollection.php b/src/Http/RouteCollection.php index 825033fe5..e44297a61 100644 --- a/src/Http/RouteCollection.php +++ b/src/Http/RouteCollection.php @@ -43,6 +43,11 @@ class RouteCollection return $this->addRoute('PUT', $path, $name, $handler); } + public function patch($path, $name, $handler) + { + return $this->addRoute('PATCH', $path, $name, $handler); + } + public function delete($path, $name, $handler) { return $this->addRoute('DELETE', $path, $name, $handler); diff --git a/src/Core/Seeders/ConfigTableSeeder.php b/src/Seeders/ConfigTableSeeder.php similarity index 100% rename from src/Core/Seeders/ConfigTableSeeder.php rename to src/Seeders/ConfigTableSeeder.php diff --git a/src/Core/Seeders/DiscussionsTableSeeder.php b/src/Seeders/DiscussionsTableSeeder.php similarity index 100% rename from src/Core/Seeders/DiscussionsTableSeeder.php rename to src/Seeders/DiscussionsTableSeeder.php diff --git a/src/Core/Seeders/GroupsTableSeeder.php b/src/Seeders/GroupsTableSeeder.php similarity index 100% rename from src/Core/Seeders/GroupsTableSeeder.php rename to src/Seeders/GroupsTableSeeder.php diff --git a/src/Core/Seeders/PermissionsTableSeeder.php b/src/Seeders/PermissionsTableSeeder.php similarity index 100% rename from src/Core/Seeders/PermissionsTableSeeder.php rename to src/Seeders/PermissionsTableSeeder.php diff --git a/src/Core/Seeders/UsersTableSeeder.php b/src/Seeders/UsersTableSeeder.php similarity index 100% rename from src/Core/Seeders/UsersTableSeeder.php rename to src/Seeders/UsersTableSeeder.php diff --git a/src/Support/Action.php b/src/Support/Action.php index 669db92f1..385d0d65c 100644 --- a/src/Support/Action.php +++ b/src/Support/Action.php @@ -1,26 +1,21 @@ actor = $actor; - $this->bus = $bus; - } - - protected function callAction($class, $params = []) - { - $action = app($class); - return $action->call($params); - } + /** + * @param Request $request + * @param array $routeParams + * @return \Psr\Http\Message\ResponseInterface + */ + abstract public function handle(Request $request, array $routeParams = []); + /** + * @return EmptyResponse + */ protected function success() { return new EmptyResponse(); @@ -28,11 +23,12 @@ abstract class Action /** * @param string $url - * @return \Psr\Http\Message\ResponseInterface + * @return RedirectResponse */ protected function redirectTo($url) { - $content = sprintf(' + $content = sprintf(<<<'HTML' + @@ -43,7 +39,9 @@ abstract class Action Redirecting to %1$s. -', htmlspecialchars($url, ENT_QUOTES, 'UTF-8')); + +HTML +, htmlspecialchars($url, ENT_QUOTES, 'UTF-8')); $response = new RedirectResponse($url); $response->getBody()->write($content); diff --git a/src/Support/Actor.php b/src/Support/Actor.php deleted file mode 100755 index 18e43ea43..000000000 --- a/src/Support/Actor.php +++ /dev/null @@ -1,23 +0,0 @@ -user ?: new Guest; - } - - public function setUser($user) - { - $this->user = $user; - } - - public function isAuthenticated() - { - return (bool) $this->user; - } -} diff --git a/src/Support/HtmlAction.php b/src/Support/HtmlAction.php index a716e0470..856f4d87c 100644 --- a/src/Support/HtmlAction.php +++ b/src/Support/HtmlAction.php @@ -1,17 +1,22 @@ render($request, $routeParams); $response = new Response(); $response->getBody()->write($view->render()); + return $response; } @@ -20,5 +25,5 @@ abstract class HtmlAction extends Action * @param array $routeParams * @return \Illuminate\Contracts\View\View */ - abstract protected function render(Request $request, $routeParams = []); + abstract protected function render(Request $request, array $routeParams = []); } diff --git a/src/Support/ServiceProvider.php b/src/Support/ServiceProvider.php index 494778a2a..f67858b49 100644 --- a/src/Support/ServiceProvider.php +++ b/src/Support/ServiceProvider.php @@ -1,10 +1,22 @@ extend($this->extenders()); + } + /** * Register the service provider. * @@ -12,19 +24,33 @@ class ServiceProvider extends IlluminateServiceProvider */ public function register() { - // } - public function extend() + /** + * @return ExtenderInterface[] + */ + public function extenders() { - // @todo don't support func_get_args - foreach (func_get_args() as $extenders) { - if (! is_array($extenders)) { - $extenders = [$extenders]; - } - foreach ($extenders as $extender) { - $extender->extend($this->app); + return []; + } + + /** + * @param ExtenderInterface|ExtenderInterface[] $extenders + * @return void + */ + protected function extend($extenders) + { + if (! is_array($extenders)) { + $extenders = [$extenders]; + } + + foreach ($extenders as $extender) { + if (! $extender instanceof ExtenderInterface) { + throw new InvalidArgumentException('Argument must be an object of type ' + . ExtenderInterface::class); } + + $extender->extend($this->app); } } }