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>|
)/g, '$1 ')).text()); -Post.prototype.editTime = Model.prop('editTime', Model.date); -Post.prototype.editUser = Model.one('editUser'); +Post.prototype.editTime = Model.attribute('editTime', Model.transformDate); +Post.prototype.editUser = Model.hasOne('editUser'); Post.prototype.isEdited = computed('editTime', editTime => !!editTime); -Post.prototype.hideTime = Model.prop('hideTime', Model.date); -Post.prototype.hideUser = Model.one('hideUser'); +Post.prototype.hideTime = Model.attribute('hideTime', Model.transformDate); +Post.prototype.hideUser = Model.hasOne('hideUser'); Post.prototype.isHidden = computed('hideTime', hideTime => !!hideTime); -Post.prototype.canEdit = Model.prop('canEdit'); -Post.prototype.canDelete = Model.prop('canDelete'); +Post.prototype.canEdit = Model.attribute('canEdit'); +Post.prototype.canDelete = Model.attribute('canDelete'); export default Post; diff --git a/js/lib/models/user.js b/js/lib/models/user.js index 87d62fad8..4c29ce196 100644 --- a/js/lib/models/user.js +++ b/js/lib/models/user.js @@ -6,29 +6,28 @@ import Badge from 'flarum/components/badge'; class User extends Model {} -User.prototype.id = Model.prop('id'); -User.prototype.username = Model.prop('username'); -User.prototype.email = Model.prop('email'); -User.prototype.isConfirmed = Model.prop('isConfirmed'); -User.prototype.password = Model.prop('password'); -User.prototype.avatarUrl = Model.prop('avatarUrl'); -User.prototype.bio = Model.prop('bio'); -User.prototype.bioHtml = Model.prop('bioHtml'); -User.prototype.preferences = Model.prop('preferences'); +User.prototype.username = Model.attribute('username'); +User.prototype.email = Model.attribute('email'); +User.prototype.isConfirmed = Model.attribute('isConfirmed'); +User.prototype.password = Model.attribute('password'); +User.prototype.avatarUrl = Model.attribute('avatarUrl'); +User.prototype.bio = Model.attribute('bio'); +User.prototype.bioHtml = Model.attribute('bioHtml'); +User.prototype.preferences = Model.attribute('preferences'); -User.prototype.groups = Model.many('groups'); +User.prototype.groups = Model.hasMany('groups'); -User.prototype.joinTime = Model.prop('joinTime', Model.date); -User.prototype.lastSeenTime = Model.prop('lastSeenTime', Model.date); +User.prototype.joinTime = Model.attribute('joinTime', Model.transformDate); +User.prototype.lastSeenTime = Model.attribute('lastSeenTime', Model.transformDate); User.prototype.online = function() { return this.lastSeenTime() > moment().subtract(5, 'minutes').toDate(); }; -User.prototype.readTime = Model.prop('readTime', Model.date); -User.prototype.unreadNotificationsCount = Model.prop('unreadNotificationsCount'); +User.prototype.readTime = Model.attribute('readTime', Model.transformDate); +User.prototype.unreadNotificationsCount = Model.attribute('unreadNotificationsCount'); -User.prototype.discussionsCount = Model.prop('discussionsCount'); -User.prototype.commentsCount = Model.prop('commentsCount'); +User.prototype.discussionsCount = Model.attribute('discussionsCount'); +User.prototype.commentsCount = Model.attribute('commentsCount'); ; -User.prototype.canEdit = Model.prop('canEdit'); -User.prototype.canDelete = Model.prop('canDelete'); +User.prototype.canEdit = Model.attribute('canEdit'); +User.prototype.canDelete = Model.attribute('canDelete'); User.prototype.color = computed('username', 'avatarUrl', 'avatarColor', function(username, avatarUrl, avatarColor) { if (avatarColor) { diff --git a/js/lib/utils/truncate.js b/js/lib/utils/truncate.js index e59286fd9..2acd77df0 100644 --- a/js/lib/utils/truncate.js +++ b/js/lib/utils/truncate.js @@ -1,5 +1,6 @@ export default function truncate(string, length, start) { start = start || 0; + string = string || ''; return (start > 0 ? '...' : '')+string.substring(start, start + length)+(string.length > start + length ? '...' : ''); } diff --git a/src/Core/Models/AccessToken.php b/src/Api/AccessToken.php similarity index 72% rename from src/Core/Models/AccessToken.php rename to src/Api/AccessToken.php index 7adb8f652..5f00b1f62 100644 --- a/src/Core/Models/AccessToken.php +++ b/src/Api/AccessToken.php @@ -1,33 +1,31 @@ -belongsTo('Flarum\Core\Models\User'); + return $this->belongsTo('Flarum\Core\Users\User'); } } diff --git a/src/Api/Actions/ActionInterface.php b/src/Api/Actions/ActionInterface.php index 537bb2740..27e33bc6b 100644 --- a/src/Api/Actions/ActionInterface.php +++ b/src/Api/Actions/ActionInterface.php @@ -7,7 +7,7 @@ interface ActionInterface /** * Handle a request to the API, returning an HTTP response. * - * @param \Flarum\Api\Request $request + * @param Request $request * @return \Psr\Http\Message\ResponseInterface */ public function handle(Request $request); diff --git a/src/Api/Actions/Activity/IndexAction.php b/src/Api/Actions/Activity/IndexAction.php index 0daf5d104..6c5165f0a 100644 --- a/src/Api/Actions/Activity/IndexAction.php +++ b/src/Api/Actions/Activity/IndexAction.php @@ -1,7 +1,7 @@ actor->getUser(); + $userId = $request->get('filter.user'); + $actor = $request->actor; - $user = $this->users->findOrFail($request->get('users'), $actor); + $user = $this->users->findOrFail($userId, $actor); - return $this->activity->findByUser($user->id, $actor, $request->limit, $request->offset, $request->get('type')) + return $this->activity->findByUser( + $user->id, + $actor, + $request->limit, + $request->offset, + $request->get('filter.type') + ) ->load($request->include); } } diff --git a/src/Api/Actions/Discussions/CreateAction.php b/src/Api/Actions/Discussions/CreateAction.php index b69f7c9b0..0e19e2b74 100644 --- a/src/Api/Actions/Discussions/CreateAction.php +++ b/src/Api/Actions/Discussions/CreateAction.php @@ -1,8 +1,7 @@ bus = $bus; - $this->forum = $forum; } /** * Create a discussion according to input from the API request. * * @param JsonApiRequest $request - * @return \Flarum\Core\Models\Model + * @return \Flarum\Core\Discussions\Discussion */ protected function create(JsonApiRequest $request) { - $user = $request->actor->getUser(); + $actor = $request->actor; $discussion = $this->bus->dispatch( - new StartDiscussionCommand($user, $this->forum, $request->get('data')) + new StartDiscussion($actor, $request->get('data')) ); // After creating the discussion, we assume that the user has seen all // of the posts in the discussion; thus, we will mark the discussion // as read if they are logged in. - if ($user->exists) { + if ($actor->exists) { $this->bus->dispatch( - new ReadDiscussionCommand($discussion->id, $user, 1) + new ReadDiscussion($discussion->id, $actor, 1) ); } diff --git a/src/Api/Actions/Discussions/DeleteAction.php b/src/Api/Actions/Discussions/DeleteAction.php index f96a2dc6b..1d6ff0948 100644 --- a/src/Api/Actions/Discussions/DeleteAction.php +++ b/src/Api/Actions/Discussions/DeleteAction.php @@ -1,6 +1,6 @@ get('id'); + $actor = $request->actor; + $input = $request->all(); + $this->bus->dispatch( - new DeleteDiscussionCommand($request->get('id'), $request->actor->getUser()) + new DeleteDiscussion($id, $actor, $input) ); } } diff --git a/src/Api/Actions/Discussions/IndexAction.php b/src/Api/Actions/Discussions/IndexAction.php index 94042a0d5..9dd676957 100644 --- a/src/Api/Actions/Discussions/IndexAction.php +++ b/src/Api/Actions/Discussions/IndexAction.php @@ -1,7 +1,7 @@ actor->getUser(), - $request->get('q'), + $criteria = new SearchCriteria( + $request->actor, + $request->get('filter.q'), $request->sort ); $load = array_merge($request->include, ['state']); + $results = $this->searcher->search($criteria, $request->limit, $request->offset, $load); + // TODO: add query params (filter, sort, include) to the pagination URLs static::addPaginationLinks( $document, $request, @@ -104,6 +100,6 @@ class IndexAction extends SerializeCollectionAction $results->areMoreResults() ); - return $results->getDiscussions(); + return $results->getResults(); } } diff --git a/src/Api/Actions/Discussions/ShowAction.php b/src/Api/Actions/Discussions/ShowAction.php index 3d296b754..043d380f9 100644 --- a/src/Api/Actions/Discussions/ShowAction.php +++ b/src/Api/Actions/Discussions/ShowAction.php @@ -1,7 +1,7 @@ actor->getUser(); + $discussionId = $request->get('id'); + $actor = $request->actor; - $discussion = $this->discussions->findOrFail($request->get('id'), $user); + $discussion = $this->discussions->findOrFail($discussionId, $actor); - $discussion->posts_ids = $discussion->visiblePosts($user)->orderBy('time')->lists('id'); + $discussion->posts_ids = $discussion->postsVisibleTo($actor)->orderBy('time')->lists('id'); + // TODO: Refactor to be simpler, and get posts straight from the + // discussion's postsVisibleTo relation method. if (in_array('posts', $request->include)) { - $length = strlen($prefix = 'posts.'); - $relations = array_filter(array_map(function ($relationship) use ($prefix, $length) { - return substr($relationship, 0, $length) === $prefix ? substr($relationship, $length) : false; + $prefixLength = strlen($prefix = 'posts.'); + + $postRelations = array_filter(array_map(function ($relation) use ($prefix, $prefixLength) { + return substr($relation, 0, $prefixLength) === $prefix ? substr($relation, $prefixLength) : false; }, $request->include)); - $discussion->posts = $this->getPosts($request, ['discussion_id' => $discussion->id])->load($relations); + $discussion->posts = $this->getPosts($request, ['discussion_id' => $discussion->id])->load($postRelations); } return $discussion; diff --git a/src/Api/Actions/Discussions/UpdateAction.php b/src/Api/Actions/Discussions/UpdateAction.php index 0f924a966..991bb07a4 100644 --- a/src/Api/Actions/Discussions/UpdateAction.php +++ b/src/Api/Actions/Discussions/UpdateAction.php @@ -1,7 +1,7 @@ true, - 'addedPosts.user' => true, - 'addedPosts.discussion' => true - ]; + public static $include = []; /** * @inheritdoc @@ -54,9 +50,7 @@ class UpdateAction extends SerializeResourceAction public static $sort; /** - * Instantiate the action. - * - * @param \Illuminate\Contracts\Bus\Dispatcher $bus + * @param Dispatcher $bus */ public function __construct(Dispatcher $bus) { @@ -67,24 +61,25 @@ class UpdateAction extends SerializeResourceAction * Update a discussion according to input from the API request, and return * it ready to be serialized and assigned to the JsonApi response. * - * @param \Flarum\Api\JsonApiRequest $request - * @param \Tobscure\JsonApi\Document $document - * @return \Illuminate\Database\Eloquent\Collection + * @param JsonApiRequest $request + * @param Document $document + * @return \Flarum\Core\Discussions\Discussion */ protected function data(JsonApiRequest $request, Document $document) { - $user = $request->actor->getUser(); + $actor = $request->actor; $discussionId = $request->get('id'); + $data = $request->get('data'); - if ($data = array_except($request->get('data'), ['readNumber'])) { - $discussion = $this->bus->dispatch( - new EditDiscussionCommand($discussionId, $user, $data) - ); - } + $discussion = $this->bus->dispatch( + new EditDiscussion($discussionId, $actor, $data) + ); - if ($readNumber = $request->get('data.readNumber')) { + // TODO: Refactor the ReadDiscussion (state) command into EditDiscussion? + // That's what extensions will do anyway. + if ($readNumber = array_get($data, 'attributes.readNumber')) { $state = $this->bus->dispatch( - new ReadDiscussionCommand($discussionId, $user, $readNumber) + new ReadDiscussion($discussionId, $actor, $readNumber) ); $discussion = $state->discussion; diff --git a/src/Api/Actions/ForgotAction.php b/src/Api/Actions/ForgotAction.php index d51914673..e394c62ab 100644 --- a/src/Api/Actions/ForgotAction.php +++ b/src/Api/Actions/ForgotAction.php @@ -2,7 +2,7 @@ use Flarum\Api\Request; use Flarum\Core\Repositories\UserRepositoryInterface; -use Flarum\Core\Commands\RequestPasswordResetCommand; +use Flarum\Core\Commands\RequestPasswordReset; use Illuminate\Contracts\Bus\Dispatcher; use Zend\Diactoros\Response\EmptyResponse; @@ -29,7 +29,7 @@ class ForgotAction extends JsonApiAction $email = $request->get('email'); $this->bus->dispatch( - new RequestPasswordResetCommand($email) + new RequestPasswordReset($email) ); return new EmptyResponse(); diff --git a/src/Api/Actions/Forum/ShowAction.php b/src/Api/Actions/Forum/ShowAction.php index 5875e54c6..cb64ee4ec 100644 --- a/src/Api/Actions/Forum/ShowAction.php +++ b/src/Api/Actions/Forum/ShowAction.php @@ -45,9 +45,9 @@ class ShowAction extends SerializeResourceAction * Get the forum, ready to be serialized and assigned to the JsonApi * response. * - * @param \Flarum\Api\JsonApiRequest $request - * @param \Tobscure\JsonApi\Document $document - * @return array + * @param JsonApiRequest $request + * @param Document $document + * @return \Flarum\Core\Forum */ protected function data(JsonApiRequest $request, Document $document) { diff --git a/src/Api/Actions/Groups/IndexAction.php b/src/Api/Actions/Groups/IndexAction.php index a614dbd71..c82460647 100644 --- a/src/Api/Actions/Groups/IndexAction.php +++ b/src/Api/Actions/Groups/IndexAction.php @@ -1,6 +1,6 @@ respond($request); } catch (ValidationFailureException $e) { @@ -38,7 +39,7 @@ abstract class JsonApiAction implements ActionInterface /** * Handle an API request and return an API response. * - * @param \Flarum\Api\Request $request + * @param Request $request * @return \Psr\Http\Message\ResponseInterface */ abstract protected function respond(Request $request); diff --git a/src/Api/Actions/Notifications/IndexAction.php b/src/Api/Actions/Notifications/IndexAction.php index 0ea41ff92..db22df77c 100644 --- a/src/Api/Actions/Notifications/IndexAction.php +++ b/src/Api/Actions/Notifications/IndexAction.php @@ -1,6 +1,6 @@ actor->isAuthenticated()) { + $actor = $request->actor; + + if ($actor->isGuest()) { throw new PermissionDeniedException; } - $user = $request->actor->getUser(); + $actor->markNotificationsAsRead()->save(); - $user->markNotificationsAsRead()->save(); - - return $this->notifications->findByUser($user, $request->limit, $request->offset) + return $this->notifications->findByUser($actor, $request->limit, $request->offset) ->load($request->include); } } diff --git a/src/Api/Actions/Notifications/UpdateAction.php b/src/Api/Actions/Notifications/UpdateAction.php index def34a0e3..9ea9d7cba 100644 --- a/src/Api/Actions/Notifications/UpdateAction.php +++ b/src/Api/Actions/Notifications/UpdateAction.php @@ -1,6 +1,6 @@ bus->dispatch( - new ReadNotificationCommand($request->get('id'), $request->actor->getUser()) + new ReadNotification($request->get('id'), $request->actor) ); } } diff --git a/src/Api/Actions/Posts/CreateAction.php b/src/Api/Actions/Posts/CreateAction.php index 76eccc373..2565a7717 100644 --- a/src/Api/Actions/Posts/CreateAction.php +++ b/src/Api/Actions/Posts/CreateAction.php @@ -1,7 +1,7 @@ actor->getUser(); - - $discussionId = $request->get('data.links.discussion.linkage.id'); + $actor = $request->actor; + $discussionId = $request->get('data.relationships.discussion.data.id'); $post = $this->bus->dispatch( - new PostReplyCommand($discussionId, $user, $request->get('data')) + new PostReply($discussionId, $actor, $request->get('data')) ); // After replying, we assume that the user has seen all of the posts // in the discussion; thus, we will mark the discussion as read if // they are logged in. - if ($user->exists) { + if ($actor->exists) { $this->bus->dispatch( - new ReadDiscussionCommand($discussionId, $user, $post->number) + new ReadDiscussion($discussionId, $actor, $post->number) ); } diff --git a/src/Api/Actions/Posts/DeleteAction.php b/src/Api/Actions/Posts/DeleteAction.php index 441d7f399..60bddd766 100644 --- a/src/Api/Actions/Posts/DeleteAction.php +++ b/src/Api/Actions/Posts/DeleteAction.php @@ -1,6 +1,6 @@ bus->dispatch( - new DeletePostCommand($request->get('id'), $request->actor->getUser()) + new DeletePost($request->get('id'), $request->actor) ); } } diff --git a/src/Api/Actions/Posts/GetsPosts.php b/src/Api/Actions/Posts/GetsPosts.php index d1a458308..bd9cc5df6 100644 --- a/src/Api/Actions/Posts/GetsPosts.php +++ b/src/Api/Actions/Posts/GetsPosts.php @@ -4,12 +4,22 @@ use Flarum\Api\JsonApiRequest; trait GetsPosts { + /** + * @var \Flarum\Core\Posts\PostRepositoryInterface + */ + protected $posts; + + /** + * @param JsonApiRequest $request + * @param array $where + * @return \Illuminate\Database\Eloquent\Collection + */ protected function getPosts(JsonApiRequest $request, array $where) { - $user = $request->actor->getUser(); + $actor = $request->actor; - if (isset($where['discussion_id']) && ($near = $request->get('near')) > 1) { - $offset = $this->posts->getIndexForNumber($where['discussion_id'], $near, $user); + if (isset($where['discussion_id']) && ($near = $request->get('page.near')) > 1) { + $offset = $this->posts->getIndexForNumber($where['discussion_id'], $near, $actor); $offset = max(0, $offset - $request->limit / 2); } else { $offset = 0; @@ -17,7 +27,7 @@ trait GetsPosts return $this->posts->findWhere( $where, - $user, + $actor, $request->sort, $request->limit, $offset diff --git a/src/Api/Actions/Posts/IndexAction.php b/src/Api/Actions/Posts/IndexAction.php index a99813197..9fbd90217 100644 --- a/src/Api/Actions/Posts/IndexAction.php +++ b/src/Api/Actions/Posts/IndexAction.php @@ -1,6 +1,6 @@ get('ids'); - $user = $request->actor->getUser(); + $actor = $request->actor; if (count($postIds)) { - $posts = $this->posts->findByIds($postIds, $user); + $posts = $this->posts->findByIds($postIds, $actor); } else { $where = []; - if ($discussionId = $request->get('discussions')) { + if ($discussionId = $request->get('filter.discussion')) { $where['discussion_id'] = $discussionId; } - if ($number = $request->get('number')) { + if ($number = $request->get('page.number')) { $where['number'] = $number; } - if ($userId = $request->get('users')) { + if ($userId = $request->get('filter.user')) { $where['user_id'] = $userId; } $posts = $this->getPosts($request, $where); diff --git a/src/Api/Actions/Posts/ShowAction.php b/src/Api/Actions/Posts/ShowAction.php index 0e6c560f8..05f172e8e 100644 --- a/src/Api/Actions/Posts/ShowAction.php +++ b/src/Api/Actions/Posts/ShowAction.php @@ -1,7 +1,7 @@ posts->findOrFail($request->get('id'), $request->actor->getUser()); + return $this->posts->findOrFail($request->get('id'), $request->actor); } } diff --git a/src/Api/Actions/Posts/UpdateAction.php b/src/Api/Actions/Posts/UpdateAction.php index 99f8ebdb1..462c11e1d 100644 --- a/src/Api/Actions/Posts/UpdateAction.php +++ b/src/Api/Actions/Posts/UpdateAction.php @@ -1,6 +1,6 @@ bus->dispatch( - new EditPostCommand($request->get('id'), $request->actor->getUser(), $request->get('data')) + new EditPost($request->get('id'), $request->actor, $request->get('data')) ); } } diff --git a/src/Api/Actions/SerializeAction.php b/src/Api/Actions/SerializeAction.php index 93bcb59a7..8b04050a7 100644 --- a/src/Api/Actions/SerializeAction.php +++ b/src/Api/Actions/SerializeAction.php @@ -63,8 +63,8 @@ abstract class SerializeAction extends JsonApiAction /** * Handle an API request and return an API response. * - * @param \Flarum\Api\Request $request - * @return \Psr\Http\Message\ResponseInterface + * @param Request $request + * @return JsonResponse */ public function respond(Request $request) { @@ -78,14 +78,15 @@ abstract class SerializeAction extends JsonApiAction $serializer = new static::$serializer($request->actor, $request->include, $request->link); $document->setData($this->serialize($serializer, $data)); + return new JsonResponse($document, 200, ['content-type' => 'application/vnd.api+json']); } /** * Get the data to be serialized and assigned to the response document. * - * @param \Flarum\Api\JsonApiRequest $request - * @param \Tobscure\JsonApi\Document $document + * @param JsonApiRequest $request + * @param Document $document * @return array */ abstract protected function data(JsonApiRequest $request, Document $document); @@ -93,7 +94,7 @@ abstract class SerializeAction extends JsonApiAction /** * Serialize the data as appropriate. * - * @param \Tobscure\JsonApi\SerializerInterface $serializer + * @param SerializerInterface $serializer * @param array $data * @return \Tobscure\JsonApi\Elements\ElementInterface */ @@ -103,8 +104,8 @@ abstract class SerializeAction extends JsonApiAction * Extract parameters from the request input and assign them to the * request, restricted by the action's specifications. * - * @param \Flarum\Api\Request $request - * @return void + * @param Request $request + * @return JsonApiRequest */ protected static function buildJsonApiRequest(Request $request) { @@ -160,8 +161,8 @@ abstract class SerializeAction extends JsonApiAction * Add pagination links to a JSON-API response, based on input parameters * and the default parameters of this action. * - * @param \Tobscure\JsonApi\Document $document - * @param \Flarum\Api\JsonApiRequest $request + * @param Document $document + * @param JsonApiRequest $request * @param string $url The base URL to build pagination links with. * @param integer|boolean $total The total number of results (used to build * a 'last' link), or just true if there are more results but how many diff --git a/src/Api/Actions/SerializeCollectionAction.php b/src/Api/Actions/SerializeCollectionAction.php index e735eb0cf..c8f6e5d44 100644 --- a/src/Api/Actions/SerializeCollectionAction.php +++ b/src/Api/Actions/SerializeCollectionAction.php @@ -7,7 +7,7 @@ abstract class SerializeCollectionAction extends SerializeAction /** * Serialize the data as appropriate. * - * @param \Tobscure\JsonApi\SerializerInterface $serializer + * @param SerializerInterface $serializer * @param array $data * @return \Tobscure\JsonApi\Elements\Collection */ diff --git a/src/Api/Actions/SerializeResourceAction.php b/src/Api/Actions/SerializeResourceAction.php index 3592094ed..9cd7adac3 100644 --- a/src/Api/Actions/SerializeResourceAction.php +++ b/src/Api/Actions/SerializeResourceAction.php @@ -7,7 +7,7 @@ abstract class SerializeResourceAction extends SerializeAction /** * Serialize the data as appropriate. * - * @param \Tobscure\JsonApi\SerializerInterface $serializer + * @param SerializerInterface $serializer * @param array $data * @return \Tobscure\JsonApi\Elements\Resource */ diff --git a/src/Api/Actions/TokenAction.php b/src/Api/Actions/TokenAction.php index 99e8c7a84..b65105266 100644 --- a/src/Api/Actions/TokenAction.php +++ b/src/Api/Actions/TokenAction.php @@ -1,10 +1,10 @@ is_activated) { event(new UserEmailChangeWasRequested($user, $user->email)); + return new JsonResponse([ 'code' => 'confirm_email', 'email' => $user->email @@ -47,7 +48,7 @@ class TokenAction extends JsonApiAction } $token = $this->bus->dispatch( - new GenerateAccessTokenCommand($user->id) + new GenerateAccessToken($user->id) ); return new JsonResponse([ diff --git a/src/Api/Actions/Users/CreateAction.php b/src/Api/Actions/Users/CreateAction.php index fc3e48170..ce46aa5fd 100644 --- a/src/Api/Actions/Users/CreateAction.php +++ b/src/Api/Actions/Users/CreateAction.php @@ -1,7 +1,6 @@ bus = $bus; - $this->forum = $forum; } /** * Register a user according to input from the API request. * * @param JsonApiRequest $request - * @return \Flarum\Core\Models\Model + * @return \Flarum\Core\Users\User */ protected function create(JsonApiRequest $request) { return $this->bus->dispatch( - new RegisterUserCommand($request->actor->getUser(), $this->forum, $request->get('data')) + new RegisterUser($request->actor, $request->get('data')) ); } } diff --git a/src/Api/Actions/Users/DeleteAction.php b/src/Api/Actions/Users/DeleteAction.php index c236fdc9e..81061f180 100644 --- a/src/Api/Actions/Users/DeleteAction.php +++ b/src/Api/Actions/Users/DeleteAction.php @@ -1,6 +1,6 @@ bus->dispatch( - new DeleteUserCommand($request->get('id'), $request->actor->getUser()) + new DeleteUser($request->get('id'), $request->actor) ); } } diff --git a/src/Api/Actions/Users/DeleteAvatarAction.php b/src/Api/Actions/Users/DeleteAvatarAction.php index 0529ba1f8..2919c2623 100644 --- a/src/Api/Actions/Users/DeleteAvatarAction.php +++ b/src/Api/Actions/Users/DeleteAvatarAction.php @@ -1,6 +1,6 @@ bus->dispatch( - new DeleteAvatarCommand($request->get('id'), $request->actor->getUser()) + new DeleteAvatar($request->get('id'), $request->actor) ); } } diff --git a/src/Api/Actions/Users/IndexAction.php b/src/Api/Actions/Users/IndexAction.php index 8626978f3..0229c5336 100644 --- a/src/Api/Actions/Users/IndexAction.php +++ b/src/Api/Actions/Users/IndexAction.php @@ -1,7 +1,7 @@ actor->getUser(), - $request->get('q'), + $criteria = new SearchCriteria( + $request->actor, + $request->get('filter.q'), $request->sort ); @@ -97,6 +91,6 @@ class IndexAction extends SerializeCollectionAction $results->areMoreResults() ); - return $results->getUsers(); + return $results->getResults(); } } diff --git a/src/Api/Actions/Users/ShowAction.php b/src/Api/Actions/Users/ShowAction.php index 931bc6210..4fdf1f782 100644 --- a/src/Api/Actions/Users/ShowAction.php +++ b/src/Api/Actions/Users/ShowAction.php @@ -1,6 +1,6 @@ users->getIdForUsername($id); } - return $this->users->findOrFail($id, $request->actor->getUser()); + return $this->users->findOrFail($id, $request->actor); } } diff --git a/src/Api/Actions/Users/UpdateAction.php b/src/Api/Actions/Users/UpdateAction.php index 64e732bdc..6f30713f7 100644 --- a/src/Api/Actions/Users/UpdateAction.php +++ b/src/Api/Actions/Users/UpdateAction.php @@ -1,6 +1,6 @@ bus->dispatch( - new EditUserCommand($request->get('id'), $request->actor->getUser(), $request->get('data')) + new EditUser($request->get('id'), $request->actor, $request->get('data')) ); } } diff --git a/src/Api/Actions/Users/UploadAvatarAction.php b/src/Api/Actions/Users/UploadAvatarAction.php index ebf3b66f8..2b699efcb 100644 --- a/src/Api/Actions/Users/UploadAvatarAction.php +++ b/src/Api/Actions/Users/UploadAvatarAction.php @@ -1,6 +1,6 @@ bus->dispatch( - new UploadAvatarCommand( + new UploadAvatar( $request->get('id'), $request->http->getUploadedFiles()['avatar'], - $request->actor->getUser() + $request->actor ) ); } diff --git a/src/Api/ApiServiceProvider.php b/src/Api/ApiServiceProvider.php index 591b47b5f..77d7eb75d 100644 --- a/src/Api/ApiServiceProvider.php +++ b/src/Api/ApiServiceProvider.php @@ -1,5 +1,6 @@ app->singleton('Flarum\Support\Actor'); + $this->app->bind('flarum.actor', function () { + return new Guest; + }); $this->app->singleton( 'Flarum\Http\UrlGeneratorInterface', @@ -33,11 +36,6 @@ class ApiServiceProvider extends ServiceProvider */ public function boot() { - $this->app->singleton( - 'Illuminate\Contracts\Debug\ExceptionHandler', - 'Flarum\Api\ExceptionHandler' - ); - $this->routes(); } @@ -94,7 +92,7 @@ class ApiServiceProvider extends ServiceProvider ); // Edit a user - $routes->put( + $routes->patch( '/users/{id}', 'flarum.api.users.update', $this->action('Flarum\Api\Actions\Users\UpdateAction') @@ -142,7 +140,7 @@ class ApiServiceProvider extends ServiceProvider ); // Mark a single notification as read - $routes->put( + $routes->patch( '/notifications/{id}', 'flarum.api.notifications.update', $this->action('Flarum\Api\Actions\Notifications\UpdateAction') @@ -175,7 +173,7 @@ class ApiServiceProvider extends ServiceProvider ); // Edit a discussion - $routes->put( + $routes->patch( '/discussions/{id}', 'flarum.api.discussions.update', $this->action('Flarum\Api\Actions\Discussions\UpdateAction') @@ -202,7 +200,6 @@ class ApiServiceProvider extends ServiceProvider ); // Create a post - // @todo consider 'discussions/{id}/links/posts'? $routes->post( '/posts', 'flarum.api.posts.create', @@ -217,7 +214,7 @@ class ApiServiceProvider extends ServiceProvider ); // Edit a post - $routes->put( + $routes->patch( '/posts/{id}', 'flarum.api.posts.update', $this->action('Flarum\Api\Actions\Posts\UpdateAction') @@ -258,7 +255,7 @@ class ApiServiceProvider extends ServiceProvider ); // Edit a group - $routes->put( + $routes->patch( '/groups/{id}', 'flarum.api.groups.update', $this->action('Flarum\Api\Actions\Groups\UpdateAction') @@ -276,7 +273,7 @@ class ApiServiceProvider extends ServiceProvider { return function (ServerRequestInterface $httpRequest, $routeParams) use ($class) { $action = app($class); - $actor = app('Flarum\Support\Actor'); + $actor = app('flarum.actor'); $input = array_merge($httpRequest->getQueryParams(), $httpRequest->getAttributes(), $routeParams); $request = new Request($input, $actor, $httpRequest); diff --git a/src/Api/Client.php b/src/Api/Client.php index 6ef31b6d5..b1934ce6f 100644 --- a/src/Api/Client.php +++ b/src/Api/Client.php @@ -1,35 +1,37 @@ -container = $container; - $this->actor = $actor; } /** * Execute the given API action class, pass the input and return its response. * + * @param User $actor * @param string $actionClass * @param array $input * @return object */ - public function send($actionClass, array $input = []) + public function send(User $actor, $actionClass, array $input = []) { /** @var \Flarum\Api\Actions\JsonApiAction $action */ $action = $this->container->make($actionClass); - $response = $action->handle(new Request($input, $this->actor)); + $response = $action->handle(new Request($input, $actor)); return json_decode($response->getBody()); } diff --git a/src/Api/Commands/GenerateAccessToken.php b/src/Api/Commands/GenerateAccessToken.php new file mode 100644 index 000000000..cef67a819 --- /dev/null +++ b/src/Api/Commands/GenerateAccessToken.php @@ -0,0 +1,19 @@ +userId = $userId; + } +} \ No newline at end of file diff --git a/src/Api/Commands/GenerateAccessTokenHandler.php b/src/Api/Commands/GenerateAccessTokenHandler.php new file mode 100644 index 000000000..160c9a830 --- /dev/null +++ b/src/Api/Commands/GenerateAccessTokenHandler.php @@ -0,0 +1,15 @@ +userId); + + $token->save(); + + return $token; + } +} \ No newline at end of file diff --git a/src/Api/Events/SerializeAttributes.php b/src/Api/Events/SerializeAttributes.php index 53faf719f..a92cdf7c6 100644 --- a/src/Api/Events/SerializeAttributes.php +++ b/src/Api/Events/SerializeAttributes.php @@ -1,14 +1,36 @@ serializer = $serializer; $this->model = $model; diff --git a/src/Api/ExceptionHandler.php b/src/Api/ExceptionHandler.php deleted file mode 100644 index 57a400329..000000000 --- a/src/Api/ExceptionHandler.php +++ /dev/null @@ -1,54 +0,0 @@ -is('api/*')) { - $error = []; - if (Config::get('app.debug')) { - $error['code'] = (new \ReflectionClass($e))->getShortName(); - } - if ($detail = $e->getMessage()) { - $error['detail'] = $detail; - } - $statusCode = $e instanceof HttpException ? $e->getStatusCode() : 500; - if (count($error)) { - return $this->renderErrors([$error], $statusCode); - } else { - return new Response(null, $statusCode); - } - } - - return parent::render($request, $e); - } - - protected function renderErrors($errors, $httpCode = 500) - { - return new JsonResponse(['errors' => $errors], $httpCode); - } -} diff --git a/src/Api/Middleware/LoginWithHeader.php b/src/Api/Middleware/LoginWithHeader.php index 3f0a83fd8..7dc905ae0 100644 --- a/src/Api/Middleware/LoginWithHeader.php +++ b/src/Api/Middleware/LoginWithHeader.php @@ -1,7 +1,7 @@ actor = $actor; + $this->app = $app; } /** @@ -35,7 +36,7 @@ class LoginWithHeader implements MiddlewareInterface ($token = substr($header, strlen($this->prefix))) && ($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/Api/Request.php b/src/Api/Request.php index ff2d16590..fef99fb67 100644 --- a/src/Api/Request.php +++ b/src/Api/Request.php @@ -1,12 +1,19 @@ input = $input; - $this->actor = $actor; + $this->actor = $actor ?: new Guest; $this->http = $http; } + /** + * @param $key + * @param null $default + * @return mixed + */ public function get($key, $default = null) { return array_get($this->input, $key, $default); } + /** + * @return array + */ public function all() { return $this->input; diff --git a/src/Api/Serializers/ActivitySerializer.php b/src/Api/Serializers/ActivitySerializer.php index 907932112..c64661cb8 100644 --- a/src/Api/Serializers/ActivitySerializer.php +++ b/src/Api/Serializers/ActivitySerializer.php @@ -1,6 +1,6 @@ $activity->type, 'time' => $activity->time->toRFC3339String() ]; - - return $this->extendAttributes($activity, $attributes); } + /** + * @return callable + */ public function user() { return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer'); } + /** + * @return callable + */ public function sender() { return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer'); } + /** + * @return callable + */ public function subject() { return $this->hasOne(function ($activity) { - return static::$subjects[$activity->type]; + return static::$subjectSerializers[$activity->type]; }); } + /** + * @param $type + * @param $serializer + */ public static function setSubjectSerializer($type, $serializer) { static::$subjectSerializers[$type] = $serializer; diff --git a/src/Api/Serializers/BaseSerializer.php b/src/Api/Serializers/BaseSerializer.php deleted file mode 100644 index bc27496fb..000000000 --- a/src/Api/Serializers/BaseSerializer.php +++ /dev/null @@ -1,112 +0,0 @@ -actor = $actor; - } - - /** - * Fire an event to allow custom serialization of attributes. - * - * @param mixed $model The model to serialize. - * @param array $attributes Attributes that have already been serialized. - * @return array - */ - protected function extendAttributes($model, &$attributes = []) - { - event(new SerializeAttributes($this, $model, $attributes)); - - return $attributes; - } - - protected function relationship($serializer, $relation = null, $many = false) - { - // Get the relationship name from the stack trace. - if (is_null($relation)) { - list(, , $caller) = debug_backtrace(false, 3); - $relation = $caller['function']; - } - - return function ($model, $include, $included, $links) use ($serializer, $many, $relation) { - if ($relation instanceof Closure) { - $data = $relation($model, $include); - } else { - if ($include) { - $data = $model->$relation; - } elseif ($many) { - $relationIds = $relation.'_ids'; - $data = isset($model->$relationIds) ? $model->$relationIds : $model->$relation()->lists('id'); - } else { - $relationId = $relation.'_id'; - $data = $model->$relationId; - } - } - - if ($serializer instanceof Closure) { - $serializer = $serializer($model, $data); - } - $serializer = new $serializer($this->actor, $included, $links); - return $many ? $serializer->collection($data) : $serializer->resource($data); - }; - } - - public function hasOne($serializer, $relation = null) - { - return $this->relationship($serializer, $relation); - } - - public function hasMany($serializer, $relation = null) - { - return $this->relationship($serializer, $relation, true); - } - - /** - * Add a custom relationship to the serializer. - * - * @param string $name The name of the relationship. - * @param Closure $callback The callback to execute. - * @return void - */ - public static function addRelationship($name, $callback) - { - static::$relationships[$name] = $callback; - } - - /** - * Check for and execute custom relationships. - * - * @param string $name - * @param array $arguments - * @return mixed - */ - public function __call($name, $arguments) - { - if (isset(static::$relationships[$name])) { - array_unshift($arguments, $this); - return call_user_func_array(static::$relationships[$name], $arguments); - } - } -} diff --git a/src/Api/Serializers/CurrentUserSerializer.php b/src/Api/Serializers/CurrentUserSerializer.php index a34d24be4..f9dbc86a6 100644 --- a/src/Api/Serializers/CurrentUserSerializer.php +++ b/src/Api/Serializers/CurrentUserSerializer.php @@ -2,20 +2,21 @@ class CurrentUserSerializer extends UserSerializer { - protected function attributes($user) + /** + * {@inheritdoc} + */ + protected function getDefaultAttributes($user) { - $attributes = parent::attributes($user); + $attributes = parent::getDefaultAttributes($user); - $actingUser = $this->actor->getUser(); - - if ($user->id === $actingUser->id) { + if ($user->id == $this->actor->id) { $attributes += [ - 'readTime' => $user->read_time ? $user->read_time->toRFC3339String() : null, + 'readTime' => $user->read_time ? $user->read_time->toRFC3339String() : null, 'unreadNotificationsCount' => $user->getUnreadNotificationsCount(), - 'preferences' => $user->preferences + 'preferences' => $user->preferences ]; } - return $this->extendAttributes($user, $attributes); + return $attributes; } } diff --git a/src/Api/Serializers/DiscussionBasicSerializer.php b/src/Api/Serializers/DiscussionBasicSerializer.php index d9f69eedd..01f685e55 100644 --- a/src/Api/Serializers/DiscussionBasicSerializer.php +++ b/src/Api/Serializers/DiscussionBasicSerializer.php @@ -1,67 +1,67 @@ $discussion->title ]; - - if (count($discussion->removedPosts)) { - $attributes['removedPosts'] = $discussion->removedPosts; - } - - return $this->extendAttributes($discussion, $attributes); } - public function startUser() + /** + * @return callable + */ + protected function startUser() { return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer'); } - public function startPost() + /** + * @return callable + */ + protected function startPost() { return $this->hasOne('Flarum\Api\Serializers\PostBasicSerializer'); } - public function lastUser() + /** + * @return callable + */ + protected function lastUser() { return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer'); } - public function lastPost() + /** + * @return callable + */ + protected function lastPost() { return $this->hasOne('Flarum\Api\Serializers\PostBasicSerializer'); } - public function posts() + /** + * @return callable + */ + protected function posts() { return $this->hasMany('Flarum\Api\Serializers\PostSerializer'); } - public function relevantPosts() + /** + * @return callable + */ + protected function relevantPosts() { return $this->hasMany('Flarum\Api\Serializers\PostBasicSerializer'); } - - public function addedPosts() - { - return $this->hasMany('Flarum\Api\Serializers\PostSerializer'); - } } diff --git a/src/Api/Serializers/DiscussionSerializer.php b/src/Api/Serializers/DiscussionSerializer.php index e865609c2..59d80a8fe 100644 --- a/src/Api/Serializers/DiscussionSerializer.php +++ b/src/Api/Serializers/DiscussionSerializer.php @@ -1,34 +1,34 @@ actor->getUser(); - $state = $discussion->state; - - $attributes += [ + $attributes = parent::getDefaultAttributes($discussion) + [ 'commentsCount' => (int) $discussion->comments_count, 'participantsCount' => (int) $discussion->participants_count, 'startTime' => $discussion->start_time->toRFC3339String(), 'lastTime' => $discussion->last_time ? $discussion->last_time->toRFC3339String() : null, 'lastPostNumber' => $discussion->last_post_number, - 'canReply' => $discussion->can($user, 'reply'), - 'canRename' => $discussion->can($user, 'rename'), - 'canDelete' => $discussion->can($user, 'delete'), - - 'readTime' => $state && $state->read_time ? $state->read_time->toRFC3339String() : null, - 'readNumber' => $state ? (int) $state->read_number : 0 + 'canReply' => $discussion->can($this->actor, 'reply'), + 'canRename' => $discussion->can($this->actor, 'rename'), + 'canDelete' => $discussion->can($this->actor, 'delete') ]; - return $this->extendAttributes($discussion, $attributes); + Discussion::setStateUser($this->actor); + + if ($state = $discussion->state) { + $attributes += [ + 'readTime' => $state->read_time ? $state->read_time->toRFC3339String() : null, + 'readNumber' => (int) $state->read_number + ]; + } + + return $attributes; } } diff --git a/src/Api/Serializers/ForumSerializer.php b/src/Api/Serializers/ForumSerializer.php index 56143f844..12911a268 100644 --- a/src/Api/Serializers/ForumSerializer.php +++ b/src/Api/Serializers/ForumSerializer.php @@ -1,31 +1,27 @@ $forum->title ]; - - return $this->extendAttributes($forum, $attributes); } } diff --git a/src/Api/Serializers/GroupSerializer.php b/src/Api/Serializers/GroupSerializer.php index 395351700..57e5085bc 100644 --- a/src/Api/Serializers/GroupSerializer.php +++ b/src/Api/Serializers/GroupSerializer.php @@ -1,30 +1,23 @@ (int) $group->id, 'nameSingular' => $group->name_singular, 'namePlural' => $group->name_plural, 'color' => $group->color, 'icon' => $group->icon, ]; - - return $this->extendAttributes($group, $attributes); } } diff --git a/src/Api/Serializers/NotificationSerializer.php b/src/Api/Serializers/NotificationSerializer.php index fc21ccaf9..570ab43ea 100644 --- a/src/Api/Serializers/NotificationSerializer.php +++ b/src/Api/Serializers/NotificationSerializer.php @@ -1,11 +1,9 @@ 'Flarum\Api\Serializers\DiscussionBasicSerializer' - ]; + protected static $subjectSerializers = []; /** - * Serialize attributes of an notification model for JSON output. - * - * @param Notification $notification The notification model to serialize. - * @return array + * {@inheritdoc} */ - protected function attributes($notification) + protected function getDefaultAttributes($notification) { - $attributes = [ - 'id' => (int) $notification->id, + return [ + 'id' => (int) $notification->id, 'contentType' => $notification->type, - 'content' => $notification->data, - 'time' => $notification->time->toRFC3339String(), - 'isRead' => (bool) $notification->is_read, + 'content' => $notification->data, + 'time' => $notification->time->toRFC3339String(), + 'isRead' => (bool) $notification->is_read, 'unreadCount' => $notification->unread_count ]; - - return $this->extendAttributes($notification, $attributes); } + /** + * @return callable + */ public function user() { return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer'); } + /** + * @return callable + */ public function sender() { return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer'); } + /** + * @return callable + */ public function subject() { return $this->hasOne(function ($notification) { - return static::$subjects[$notification->type]; + return static::$subjectSerializers[$notification->type]; }); } + + /** + * @param $type + * @param $serializer + */ + public static function setSubjectSerializer($type, $serializer) + { + static::$subjectSerializers[$type] = $serializer; + } } diff --git a/src/Api/Serializers/PostBasicSerializer.php b/src/Api/Serializers/PostBasicSerializer.php index f8cfaae0b..0dfcac96b 100644 --- a/src/Api/Serializers/PostBasicSerializer.php +++ b/src/Api/Serializers/PostBasicSerializer.php @@ -1,29 +1,22 @@ (int) $post->id, - 'number' => (int) $post->number, - 'time' => $post->time->toRFC3339String(), - 'contentType' => $post->type + 'id' => (int) $post->id, + 'number' => (int) $post->number, + 'time' => $post->time->toRFC3339String(), + 'contentType' => $post->type ]; if ($post->type === 'comment') { @@ -32,14 +25,20 @@ class PostBasicSerializer extends BaseSerializer $attributes['content'] = $post->content; } - return $this->extendAttributes($post, $attributes); + return $attributes; } + /** + * @return callable + */ public function user() { return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer'); } + /** + * @return callable + */ public function discussion() { return $this->hasOne('Flarum\Api\Serializers\DiscussionBasicSerializer'); diff --git a/src/Api/Serializers/PostSerializer.php b/src/Api/Serializers/PostSerializer.php index 491622ddc..0adfe393b 100644 --- a/src/Api/Serializers/PostSerializer.php +++ b/src/Api/Serializers/PostSerializer.php @@ -3,22 +3,19 @@ class PostSerializer extends PostBasicSerializer { /** - * Serialize attributes of a Post model for JSON output. - * - * @param Post $post The Post model to serialize. - * @return array + * {@inheritdoc} */ - protected function attributes($post) + protected function getDefaultAttributes($post) { - $attributes = parent::attributes($post); - $user = $this->actor->getUser(); + $attributes = parent::getDefaultAttributes($post); unset($attributes['content']); - $canEdit = $post->can($user, 'edit'); + $canEdit = $post->can($this->actor, 'edit'); if ($post->type === 'comment') { $attributes['contentHtml'] = $post->content_html; + if ($canEdit) { $attributes['content'] = $post->content; } @@ -37,27 +34,39 @@ class PostSerializer extends PostBasicSerializer $attributes += [ 'canEdit' => $canEdit, - 'canDelete' => $post->can($user, 'delete') + 'canDelete' => $post->can($this->actor, 'delete') ]; - return $this->extendAttributes($post, $attributes); + return $attributes; } + /** + * @return callable + */ public function user() { return $this->hasOne('Flarum\Api\Serializers\UserSerializer'); } + /** + * @return callable + */ public function discussion() { return $this->hasOne('Flarum\Api\Serializers\DiscussionSerializer'); } + /** + * @return callable + */ public function editUser() { return $this->hasOne('Flarum\Api\Serializers\UserSerializer'); } + /** + * @return callable + */ public function hideUser() { return $this->hasOne('Flarum\Api\Serializers\UserSerializer'); diff --git a/src/Api/Serializers/Serializer.php b/src/Api/Serializers/Serializer.php new file mode 100644 index 000000000..d25097e2e --- /dev/null +++ b/src/Api/Serializers/Serializer.php @@ -0,0 +1,178 @@ +actor = $actor; + } + + /** + * {@inheritdoc} + */ + protected function getAttributes($model) + { + $attributes = $this->getDefaultAttributes($model); + + event(new SerializeAttributes($this, $model, $attributes)); + + return $attributes; + } + + /** + * Get the default set of serialized attributes for a model. + * + * @param $model + * @return array + */ + abstract protected function getDefaultAttributes($model); + + /** + * Get a closure that returns a Collection/Resource representing a relation. + * + * @param string|Closure $serializer The name of the serializer, or a + * Closure returning the name of the serializer, to use for the related + * items. + * @param string|Closure|null $relation If a string is provided, it will be + * used to retrieve the relation data from the model: + * - If the relation is being included, the relation will be accessed + * as a property on the model. + * - If the relation is not being included and is a to-many relation, a + * list of IDs will be accessed as a property on the model with the + * suffix '_ids', otherwise by querying the relation method. + * - If the relation is not being included and is a to-one relation, + * the ID will be accessed as a property on the model with the suffix + * '_id'. + * If a closure is provided, it will be passed the model and + * whether or not the relation is being included. It is expected to + * return the relation data. + * @param bool $many Whether or not this is a to-many relation. + * @return callable + */ + protected function getRelationship($serializer, $relation = null, $many = false) + { + // If no relationship name was provided, we can guess it from the + // stack trace. The assumes that one of the hasOne or hasMany methods + // was called from directly inside a serializer method. + if (is_null($relation)) { + list(, , $caller) = debug_backtrace(false, 3); + $relation = $caller['function']; + } + + return function ($model, $include, $included, $links) use ($serializer, $many, $relation) { + // If the passed relation was a closure, we can let that take care + // of retrieving the relation data from the model. Otherwise, we + // need to get the data from the model itself, using the relation + // name provided. + if ($relation instanceof Closure) { + $data = $relation($model, $include); + } else { + if ($include) { + $data = $model->$relation; + } elseif ($many) { + $relationIds = $relation.'_ids'; + $data = isset($model->$relationIds) ? $model->$relationIds : $model->$relation()->lists('id'); + } else { + $relationId = $relation.'_id'; + $data = $model->$relationId; + } + } + + // If the passed serializer was a closure, we'll need to run + // that in order to find out which serializer class to instantiate. + // This is useful for polymorphic relations. + if ($serializer instanceof Closure) { + $serializer = $serializer($model, $data); + } + + /** @var \Tobscure\JsonApi\SerializerInterface $serializer */ + $serializer = new $serializer($this->actor, $included, $links); + + return $many ? $serializer->collection($data) : $serializer->resource($data); + }; + } + + /** + * Get a closure that returns a Resource representing a relation. + * + * @param string $serializer + * @param string|Closure|null $relation + * @see Serializer::getRelationship() + * @return callable + */ + public function hasOne($serializer, $relation = null) + { + return $this->getRelationship($serializer, $relation); + } + + /** + * Get a closure that returns a Collection representing a relation. + * + * @param string $serializer + * @param string|Closure|null $relation + * @see Serializer::getRelationship() + * @return callable + */ + public function hasMany($serializer, $relation = null) + { + return $this->getRelationship($serializer, $relation, true); + } + + /** + * Add a custom relation to the model. + * + * @param string $name The name of the relation. + * @param callable $callback The callback to execute. This should return a + * relation closure {@see Serializer::getRelationship()} + */ + public static function setRelationMethod($name, callable $callback) + { + static::$relationMethods[get_called_class()][$name] = $callback; + } + + /** + * Check for and execute custom relationships. + * + * @param string $method + * @param array $parameters + * @return mixed + * @throws BadMethodCallException + */ + public function __call($method, $parameters) + { + if (isset(static::$relationMethods[$method])) { + array_unshift($parameters, $this); + + return call_user_func_array(static::$relationMethods[$method], $parameters); + } + + $className = get_class($this); + + throw new BadMethodCallException("Call to undefined method {$className}::{$method}()"); + } +} diff --git a/src/Api/Serializers/UserBasicSerializer.php b/src/Api/Serializers/UserBasicSerializer.php index b1bfd1781..650552635 100644 --- a/src/Api/Serializers/UserBasicSerializer.php +++ b/src/Api/Serializers/UserBasicSerializer.php @@ -1,30 +1,26 @@ $user->username, 'avatarUrl' => $user->avatar_url ]; - - return $this->extendAttributes($user, $attributes); } + /** + * @return callable + */ protected function groups() { return $this->hasMany('Flarum\Api\Serializers\GroupSerializer'); diff --git a/src/Api/Serializers/UserSerializer.php b/src/Api/Serializers/UserSerializer.php index b4c1bfa3a..ca79f5ad9 100644 --- a/src/Api/Serializers/UserSerializer.php +++ b/src/Api/Serializers/UserSerializer.php @@ -3,17 +3,13 @@ class UserSerializer extends UserBasicSerializer { /** - * Serialize attributes of a User model for JSON output. - * - * @param User $user The User model to serialize. - * @return array + * {@inheritdoc} */ - protected function attributes($user) + protected function getDefaultAttributes($user) { - $attributes = parent::attributes($user); + $attributes = parent::getDefaultAttributes($user); - $actingUser = $this->actor->getUser(); - $canEdit = $user->can($actingUser, 'edit'); + $canEdit = $user->can($this->actor, 'edit'); $attributes += [ 'bioHtml' => $user->bio_html, @@ -21,10 +17,10 @@ class UserSerializer extends UserBasicSerializer 'discussionsCount' => (int) $user->discussions_count, 'commentsCount' => (int) $user->comments_count, 'canEdit' => $canEdit, - 'canDelete' => $user->can($actingUser, 'delete'), + 'canDelete' => $user->can($this->actor, 'delete'), ]; - if ($user->preference('discloseOnline')) { + if ($user->getPreference('discloseOnline')) { $attributes += [ 'lastSeenTime' => $user->last_seen_time ? $user->last_seen_time->toRFC3339String() : null ]; @@ -39,6 +35,6 @@ class UserSerializer extends UserBasicSerializer ]; } - return $this->extendAttributes($user, $attributes); + return $attributes; } } diff --git a/src/Assets/AssetManager.php b/src/Assets/AssetManager.php index 1e6df4fc6..181207f98 100644 --- a/src/Assets/AssetManager.php +++ b/src/Assets/AssetManager.php @@ -1,6 +1,6 @@ commands('Flarum\Console\InstallCommand'); $this->commands('Flarum\Console\SeedCommand'); + $this->commands('Flarum\Console\ImportCommand'); $this->commands('Flarum\Console\GenerateExtensionCommand'); } diff --git a/src/Console/ImportCommand.php b/src/Console/ImportCommand.php new file mode 100644 index 000000000..bbd31dc2c --- /dev/null +++ b/src/Console/ImportCommand.php @@ -0,0 +1,278 @@ +app = $app; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function fire() + { + if (!$this->confirm('Warning: all Flarum tables will be truncated. Proceed? [y|N]', false)) { + return; + } + + app('config')->set('database.connections.esotalk', [ + 'driver' => 'mysql', + 'host' => env('DB_HOST'), + 'database' => 'esotalk', + 'username' => env('DB_USERNAME'), + 'password' => env('DB_PASSWORD'), + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => 'et_', + 'strict' => false, + ]); + + User::$rules = []; + + $from = app('db')->connection('esotalk'); + $to = app('db')->connection(); + + $this->importTags($from, $to); + $this->importUsers($from, $to); + $this->importDiscussions($from, $to); + + $to->table('notifications')->update(['is_read' => true]); + } + + protected function importTags($from, $to) + { + $colors = ['#F16655', '#F59B66', '#4E89DA', '#5AC169', '#96A2AF']; + + $this->info('Importing tags...'); + + $to->table('tags')->truncate(); + + $channels = $from->table('channel')->orderBy('lft')->get(); + + $progress = new ProgressBar($this->output, count($channels)); + + $i = 0; + foreach ($channels as $c) { + $tag = new Tag; + + $tag->id = $c->channelId; + $tag->name = $c->title; + $tag->slug = $c->slug; + $tag->description = $c->description; + $tag->color = $colors[$i++ % count($colors)]; + $tag->discussions_count = $c->countConversations; + $tag->position = $c->lft; + + $tag->save(); + + $progress->advance(); + } + + $progress->finish(); + $this->info("\n"); + } + + protected function importUsers($from, $to) + { + $this->info('Importing users...'); + + $to->table('users')->truncate(); + $to->table('email_tokens')->truncate(); + $to->table('password_tokens')->truncate(); + $to->table('access_tokens')->truncate(); + $to->table('users_groups')->truncate(); + + $members = $from->table('member')->get(); + + $progress = new ProgressBar($this->output, count($members)); + + foreach ($members as $m) { + $preferences = unserialize($m->preferences); + $user = new User; + + $user->id = $m->memberId; + $user->username = $m->username; + $user->email = $m->email; + $user->is_activated = true; + $user->password = ''; + $user->join_time = $m->joinTime; + $user->last_seen_time = $m->lastActionTime; + $user->avatar_path = $m->avatarFormat ? $m->memberId.'.'.$m->avatarFormat : null; + $user->username = $m->username; + $user->read_time = array_get($preferences, 'markedAllConversationsAsRead'); + $user->notification_read_time = array_get($preferences, 'notificationCheckTime'); + $user->preferences = ['discloseOnline' => !array_get($preferences, 'hideOnline')]; + $user->discussions_count = $m->countConversations; + $user->comments_count = $m->countPosts; + + $user->save(); + + $this->app->make('Flarum\Core\Activity\ActivitySyncer') + ->sync(new JoinedActivity($user), [$user]); + + $progress->advance(); + } + + $progress->finish(); + $this->info("\n"); + } + + protected function importDiscussions($from, $to) + { + $this->info('Importing discussions...'); + + $to->table('discussions')->truncate(); + $to->table('discussions_tags')->truncate(); + $to->table('posts')->truncate(); + $to->table('notifications')->truncate(); + $to->table('users_discussions')->truncate(); + $to->table('activity')->truncate(); + $to->table('mentions_posts')->truncate(); + $to->table('mentions_users')->truncate(); + + $conversations = $from->table('conversation')->where('private', 0)->get(); + + $progress = new ProgressBar($this->output, count($conversations)); + + foreach ($conversations as $c) { + $discussion = new Discussion; + + $discussion->id = $c->conversationId; + $discussion->title = $c->title; + $discussion->is_sticky = $c->sticky; + + $discussion->start_user_id = $c->startMemberId; + $discussion->start_time = $c->startTime; + + $discussion->last_user_id = $c->lastPostMemberId; + $discussion->last_time = $c->lastPostTime; + + $discussion->save(); + + $discussion->tags()->sync([$c->channelId]); + + foreach ($from->table('post')->where('conversationId', $c->conversationId)->get() as $p) { + $post = new CommentPost; + + $post->id = $p->postId; + $post->discussion_id = $p->conversationId; + $post->user_id = $p->memberId; + $post->time = $p->time; + $post->edit_user_id = $p->editMemberId; + $post->edit_time = $p->editTime; + $post->hide_user_id = $p->deleteMemberId; + $post->hide_time = $p->deleteTime; + $post->content = $p->content; + + $this->formatPost($post); + + $post->save(); + + if (!$post->hide_time) { + event(new PostWasPosted($post)); + } + } + + $discussion->last_post_id = $p->postId; + $discussion->last_post_number = $post->number; + $discussion->comments_count = $post->number; + + $discussion->save(); + + $states = $from->table('member_conversation') + ->where('conversationId', $c->conversationId) + ->where('type', 'member') + ->get(); + foreach ($states as $s) { + $state = new DiscussionState; + + $state->discussion_id = $s->conversationId; + $state->user_id = $s->id; + $state->read_time = time(); + $state->read_number = $discussion->posts()->orderBy('time', 'asc')->skip(min($discussion->comments_count, $s->lastRead) - 1)->pluck('number'); + + $state->save(); + } + + $progress->advance(); + } + + $progress->finish(); + $this->info("\n"); + } + + protected function formatPost($post) + { + // Code blocks + $regexp = "/(.*)^\s*\[code\]\n?(.*?)\n?\[\/code]$/ims"; + while (preg_match($regexp, $post->content)) { + $post->content = preg_replace($regexp, "$1```\n$2\n```", $post->content); + } + + // Inline tags + $replace = [ + '/\[url=(.*?)\](.*?)\[\/url\]/i' => '[$2]($1)', + '/\[b\](.*?)\[\/b\]/i' => '**$1**', + '/\[i\](.*?)\[\/i\]/i' => '*$1*', + '/\[h\](.*?)\[\/h\]/i' => '# $1', + '/\[img\](.*?)\[\/img\]/i' => '![]($1)', + '/\[code\](.*?)\[\/code\]/i' => '`$1`' + ]; + $post->content = preg_replace(array_keys($replace), array_values($replace), $post->content); + + // Quotes + $regexp = "/(.*?)\n?\[quote(?:=(.*?)(]?))?\]\n?(.*?)\n?\[\/quote\]\n{0,2}/is"; + while (preg_match($regexp, $post->content)) { + $post->content = preg_replace_callback($regexp, function ($matches) use ($post) { + if (strpos($matches[2], ':') !== false) { + list($postId, $user) = explode(':', $matches[2]); + $mentionedPost = CommentPost::find($postId); + + return $matches[1]."\n@".$mentionedPost->user->username.'#'.$mentionedPost->number.' '; + } else { + return $matches[1].'> '.str_replace("\n", "\n> ", $matches[4])."\n\n"; + } + }, $post->content); + } + } +} diff --git a/src/Core/Models/Activity.php b/src/Core/Activity/Activity.php similarity index 86% rename from src/Core/Models/Activity.php rename to src/Core/Activity/Activity.php index 57f50df31..bc7d42c12 100644 --- a/src/Core/Models/Activity.php +++ b/src/Core/Activity/Activity.php @@ -1,4 +1,6 @@ -belongsTo('Flarum\Core\Models\User', 'user_id'); + return $this->belongsTo('Flarum\Core\Users\User', 'user_id'); } /** @@ -106,7 +104,8 @@ class Activity extends Model * Set the subject model for the given activity type. * * @param string $type The activity type. - * @param string $class The class name of the subject model for that type. + * @param string $subjectModel The class name of the subject model for that + * type. * @return void */ public static function setSubjectModel($type, $subjectModel) diff --git a/src/Core/Activity/ActivityAbstract.php b/src/Core/Activity/ActivityAbstract.php deleted file mode 100644 index d39e67b42..000000000 --- a/src/Core/Activity/ActivityAbstract.php +++ /dev/null @@ -1,5 +0,0 @@ -extend([ - (new Extend\EventSubscriber('Flarum\Core\Handlers\Events\UserActivitySyncer')), + (new Extend\EventSubscriber('Flarum\Core\Activity\Listeners\UserActivitySyncer')), - (new Extend\ActivityType('Flarum\Core\Activity\PostedActivity')) + (new Extend\ActivityType('Flarum\Core\Activity\PostedBlueprint')) ->subjectSerializer('Flarum\Api\Serializers\PostBasicSerializer'), - (new Extend\ActivityType('Flarum\Core\Activity\StartedDiscussionActivity')) + (new Extend\ActivityType('Flarum\Core\Activity\StartedDiscussionBlueprint')) ->subjectSerializer('Flarum\Api\Serializers\PostBasicSerializer'), - (new Extend\ActivityType('Flarum\Core\Activity\JoinedActivity')) + (new Extend\ActivityType('Flarum\Core\Activity\JoinedBlueprint')) ->subjectSerializer('Flarum\Api\Serializers\UserBasicSerializer') ]); } + /** + * Register the service provider. + * + * @return void + */ public function register() { $this->app->bind( - 'Flarum\Core\Repositories\ActivityRepositoryInterface', - 'Flarum\Core\Repositories\EloquentActivityRepository' + 'Flarum\Core\Activity\ActivityRepositoryInterface', + 'Flarum\Core\Activity\EloquentActivityRepository' ); } } diff --git a/src/Core/Activity/ActivitySyncer.php b/src/Core/Activity/ActivitySyncer.php index 04171d5cf..06db00bf6 100644 --- a/src/Core/Activity/ActivitySyncer.php +++ b/src/Core/Activity/ActivitySyncer.php @@ -1,12 +1,22 @@ activity = $activity; @@ -16,38 +26,92 @@ class ActivitySyncer * Sync a piece of activity so that it is present for the specified users, * and not present for anyone else. * - * @param \Flarum\Core\Activity\ActivityInterface $activity + * @param Blueprint $blueprint * @param \Flarum\Core\Models\User[] $users * @return void */ - public function sync(ActivityInterface $activity, array $users) + public function sync(Blueprint $blueprint, array $users) { - Activity::unguard(); - - $attributes = [ - 'type' => $activity::getType(), - 'subject_id' => $activity->getSubject()->id, - 'time' => $activity->getTime() - ]; + $attributes = $this->getAttributes($blueprint); + // Find all existing activity 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 = Activity::where($attributes)->get(); $toInsert = []; + // For each of the provided users, check to see if they already have + // an activity record in the database. If they do, we can leave it be; + // otherwise, we will need to create a new one for them. foreach ($users as $user) { - $existing = $toDelete->where('user_id', $user->id)->first(); + $existing = $toDelete->first(function ($activity) use ($user) { + return $activity->user_id === $user->id; + }); - if ($k = $toDelete->search($existing)) { - $toDelete->pull($k); + if ($existing) { + $toDelete->forget($toDelete->search($existing)); } else { $toInsert[] = $attributes + ['user_id' => $user->id]; } } + // Finally, delete all of the remaining activity records which weren't + // removed from this collection by the above loop. Insert the records + // we need to insert as well. if (count($toDelete)) { - Activity::whereIn('id', $toDelete->lists('id'))->delete(); + $this->deleteActivity($toDelete->lists('id')); } + if (count($toInsert)) { - Activity::insert($toInsert); + $this->createActivity($toInsert); } } + + /** + * Delete a piece of activity for all users. + * + * @param Blueprint $blueprint + * @return void + */ + public function delete(Blueprint $blueprint) + { + Activity::where($this->getAttributes($blueprint))->delete(); + } + + /** + * Delete a list of activity records. + * + * @param int[] $ids + */ + protected function deleteActivity(array $ids) + { + Activity::whereIn('id', $ids)->delete(); + } + + /** + * Insert a list of activity record into the database. + * + * @param array[] $records An array containing arrays of activity record + * attributes to insert. + */ + protected function createActivity(array $records) + { + Activity::insert($records); + } + + /** + * Construct an array of attributes to be stored in an activity record in + * the database, given an activity blueprint. + * + * @param Blueprint $blueprint + * @return array + */ + protected function getAttributes(Blueprint $blueprint) + { + return [ + 'type' => $blueprint::getType(), + 'subject_id' => $blueprint->getSubject()->id, + 'time' => $blueprint->getTime() + ]; + } } diff --git a/src/Core/Activity/ActivityInterface.php b/src/Core/Activity/Blueprint.php similarity index 71% rename from src/Core/Activity/ActivityInterface.php rename to src/Core/Activity/Blueprint.php index c4bf9a648..1a30fdbb5 100644 --- a/src/Core/Activity/ActivityInterface.php +++ b/src/Core/Activity/Blueprint.php @@ -1,11 +1,16 @@ whereIn('type', $this->getRegisteredTypes()) + ->latest('time') + ->skip($offset) + ->take($limit); + + if ($type !== null) { + $query->where('type', $type); + } + + return $query->get(); + } + + /** + * Get a list of activity types that have been registered with the activity + * model. + * + * @return array + */ + protected function getRegisteredTypes() + { + return array_keys(Activity::getSubjectModels()); + } +} diff --git a/src/Core/Activity/JoinedActivity.php b/src/Core/Activity/JoinedActivity.php deleted file mode 100644 index 38b6bf4ce..000000000 --- a/src/Core/Activity/JoinedActivity.php +++ /dev/null @@ -1,33 +0,0 @@ -user = $user; - } - - public function getSubject() - { - return $this->user; - } - - public function getTime() - { - return $this->user->join_time; - } - - public static function getType() - { - return 'joined'; - } - - public static function getSubjectModel() - { - return 'Flarum\Core\Models\User'; - } -} diff --git a/src/Core/Activity/JoinedBlueprint.php b/src/Core/Activity/JoinedBlueprint.php new file mode 100644 index 000000000..71efc0682 --- /dev/null +++ b/src/Core/Activity/JoinedBlueprint.php @@ -0,0 +1,59 @@ +user = $user; + } + + /** + * {@inheritdoc} + */ + public function getSubject() + { + return $this->user; + } + + /** + * {@inheritdoc} + */ + public function getTime() + { + return $this->user->join_time; + } + + /** + * {@inheritdoc} + */ + public static function getType() + { + return 'joined'; + } + + /** + * {@inheritdoc} + */ + public static function getSubjectModel() + { + return 'Flarum\Core\Users\User'; + } +} diff --git a/src/Core/Activity/Listeners/UserActivitySyncer.php b/src/Core/Activity/Listeners/UserActivitySyncer.php new file mode 100755 index 000000000..e47a29afa --- /dev/null +++ b/src/Core/Activity/Listeners/UserActivitySyncer.php @@ -0,0 +1,126 @@ +activity = $activity; + } + + /** + * @param \Illuminate\Contracts\Events\Dispatcher $events + * @return void + */ + 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'); + } + + /** + * @param \Flarum\Core\Posts\Events\PostWasPosted $event + * @return void + */ + public function whenPostWasPosted(PostWasPosted $event) + { + $this->postBecameVisible($event->post); + } + + /** + * @param \Flarum\Core\Posts\Events\PostWasHidden $event + * @return void + */ + public function whenPostWasHidden(PostWasHidden $event) + { + $this->postBecameInvisible($event->post); + } + + /** + * @param \Flarum\Core\Posts\Events\PostWasRestored $event + * @return void + */ + public function whenPostWasRestored(PostWasRestored $event) + { + $this->postBecameVisible($event->post); + } + + /** + * @param \Flarum\Core\Posts\Events\PostWasDeleted $event + * @return void + */ + public function whenPostWasDeleted(PostWasDeleted $event) + { + $this->postBecameInvisible($event->post); + } + + /** + * @param \Flarum\Core\Users\Events\UserWasRegistered $event + * @return void + */ + public function whenUserWasRegistered(UserWasRegistered $event) + { + $blueprint = new JoinedBlueprint($event->user); + + $this->activity->sync($blueprint, [$event->user]); + } + + /** + * Sync activity to a post's author when a post becomes visible. + * + * @param \Flarum\Core\Posts\Post $post + * @return void + */ + protected function postBecameVisible(Post $post) + { + $blueprint = $this->postedBlueprint($post); + + $this->activity->sync($blueprint, [$post->user]); + } + + /** + * Delete activity when a post becomes invisible. + * + * @param \Flarum\Core\Posts\Post $post + * @return void + */ + protected function postBecameInvisible(Post $post) + { + $blueprint = $this->postedBlueprint($post); + + $this->activity->delete($blueprint); + } + + /** + * Create the appropriate activity blueprint for a post. + * + * @param \Flarum\Core\Posts\Post $post + * @return \Flarum\Core\Activity\Blueprint + */ + protected function postedBlueprint(Post $post) + { + return $post->number == 1 ? new StartedDiscussionBlueprint($post) : new PostedBlueprint($post); + } +} diff --git a/src/Core/Activity/PostedActivity.php b/src/Core/Activity/PostedActivity.php deleted file mode 100644 index 8c306c663..000000000 --- a/src/Core/Activity/PostedActivity.php +++ /dev/null @@ -1,33 +0,0 @@ -post = $post; - } - - public function getSubject() - { - return $this->post; - } - - public function getTime() - { - return $this->post->time; - } - - public static function getType() - { - return 'posted'; - } - - public static function getSubjectModel() - { - return 'Flarum\Core\Models\Post'; - } -} diff --git a/src/Core/Activity/PostedBlueprint.php b/src/Core/Activity/PostedBlueprint.php new file mode 100644 index 000000000..6ba2a3874 --- /dev/null +++ b/src/Core/Activity/PostedBlueprint.php @@ -0,0 +1,59 @@ +post = $post; + } + + /** + * {@inheritdoc} + */ + public function getSubject() + { + return $this->post; + } + + /** + * {@inheritdoc} + */ + public function getTime() + { + return $this->post->time; + } + + /** + * {@inheritdoc} + */ + public static function getType() + { + return 'posted'; + } + + /** + * {@inheritdoc} + */ + public static function getSubjectModel() + { + return 'Flarum\Core\Posts\Post'; + } +} diff --git a/src/Core/Activity/StartedDiscussionActivity.php b/src/Core/Activity/StartedDiscussionActivity.php deleted file mode 100644 index 9b7b3a550..000000000 --- a/src/Core/Activity/StartedDiscussionActivity.php +++ /dev/null @@ -1,9 +0,0 @@ -token = $token; - } -} diff --git a/src/Core/Commands/DeleteAvatarCommand.php b/src/Core/Commands/DeleteAvatarCommand.php deleted file mode 100644 index 3e38ae82a..000000000 --- a/src/Core/Commands/DeleteAvatarCommand.php +++ /dev/null @@ -1,20 +0,0 @@ -userId = $userId; - $this->actor = $actor; - } -} diff --git a/src/Core/Commands/DeleteDiscussionCommand.php b/src/Core/Commands/DeleteDiscussionCommand.php deleted file mode 100644 index 15256cf8b..000000000 --- a/src/Core/Commands/DeleteDiscussionCommand.php +++ /dev/null @@ -1,14 +0,0 @@ -discussionId = $discussionId; - $this->user = $user; - } -} diff --git a/src/Core/Commands/DeletePostCommand.php b/src/Core/Commands/DeletePostCommand.php deleted file mode 100644 index 289618909..000000000 --- a/src/Core/Commands/DeletePostCommand.php +++ /dev/null @@ -1,14 +0,0 @@ -postId = $postId; - $this->user = $user; - } -} diff --git a/src/Core/Commands/DeleteUserCommand.php b/src/Core/Commands/DeleteUserCommand.php deleted file mode 100644 index 104718d4b..000000000 --- a/src/Core/Commands/DeleteUserCommand.php +++ /dev/null @@ -1,14 +0,0 @@ -userId = $userId; - $this->user = $user; - } -} diff --git a/src/Core/Commands/EditDiscussionCommand.php b/src/Core/Commands/EditDiscussionCommand.php deleted file mode 100644 index f986daa12..000000000 --- a/src/Core/Commands/EditDiscussionCommand.php +++ /dev/null @@ -1,17 +0,0 @@ -discussionId = $discussionId; - $this->user = $user; - $this->data = $data; - } -} diff --git a/src/Core/Commands/EditPostCommand.php b/src/Core/Commands/EditPostCommand.php deleted file mode 100644 index f21740056..000000000 --- a/src/Core/Commands/EditPostCommand.php +++ /dev/null @@ -1,17 +0,0 @@ -postId = $postId; - $this->user = $user; - $this->data = $data; - } -} diff --git a/src/Core/Commands/EditUserCommand.php b/src/Core/Commands/EditUserCommand.php deleted file mode 100644 index 00ab6d039..000000000 --- a/src/Core/Commands/EditUserCommand.php +++ /dev/null @@ -1,17 +0,0 @@ -userId = $userId; - $this->user = $user; - $this->data = $data; - } -} diff --git a/src/Core/Commands/GenerateAccessTokenCommand.php b/src/Core/Commands/GenerateAccessTokenCommand.php deleted file mode 100644 index 871636877..000000000 --- a/src/Core/Commands/GenerateAccessTokenCommand.php +++ /dev/null @@ -1,11 +0,0 @@ -userId = $userId; - } -} diff --git a/src/Core/Commands/PostReplyCommand.php b/src/Core/Commands/PostReplyCommand.php deleted file mode 100644 index dd3167838..000000000 --- a/src/Core/Commands/PostReplyCommand.php +++ /dev/null @@ -1,17 +0,0 @@ -discussionId = $discussionId; - $this->user = $user; - $this->data = $data; - } -} diff --git a/src/Core/Commands/ReadDiscussionCommand.php b/src/Core/Commands/ReadDiscussionCommand.php deleted file mode 100644 index ffb625835..000000000 --- a/src/Core/Commands/ReadDiscussionCommand.php +++ /dev/null @@ -1,17 +0,0 @@ -discussionId = $discussionId; - $this->user = $user; - $this->readNumber = $readNumber; - } -} diff --git a/src/Core/Commands/ReadNotificationCommand.php b/src/Core/Commands/ReadNotificationCommand.php deleted file mode 100644 index 711832ce3..000000000 --- a/src/Core/Commands/ReadNotificationCommand.php +++ /dev/null @@ -1,14 +0,0 @@ -notificationId = $notificationId; - $this->user = $user; - } -} diff --git a/src/Core/Commands/RegisterUserCommand.php b/src/Core/Commands/RegisterUserCommand.php deleted file mode 100644 index a881212ba..000000000 --- a/src/Core/Commands/RegisterUserCommand.php +++ /dev/null @@ -1,17 +0,0 @@ -user = $user; - $this->forum = $forum; - $this->data = $data; - } -} diff --git a/src/Core/Commands/RequestPasswordResetCommand.php b/src/Core/Commands/RequestPasswordResetCommand.php deleted file mode 100644 index 448dc2eea..000000000 --- a/src/Core/Commands/RequestPasswordResetCommand.php +++ /dev/null @@ -1,11 +0,0 @@ -email = $email; - } -} diff --git a/src/Core/Commands/StartDiscussionCommand.php b/src/Core/Commands/StartDiscussionCommand.php deleted file mode 100644 index f5f0d8afe..000000000 --- a/src/Core/Commands/StartDiscussionCommand.php +++ /dev/null @@ -1,17 +0,0 @@ -user = $user; - $this->forum = $forum; - $this->data = $data; - } -} diff --git a/src/Core/Commands/UploadAvatarCommand.php b/src/Core/Commands/UploadAvatarCommand.php deleted file mode 100644 index a5da11db3..000000000 --- a/src/Core/Commands/UploadAvatarCommand.php +++ /dev/null @@ -1,31 +0,0 @@ -userId = $userId; - $this->file = $file; - $this->actor = $actor; - } -} diff --git a/src/Core/CoreServiceProvider.php b/src/Core/CoreServiceProvider.php index 97b44b589..6bb2b461f 100644 --- a/src/Core/CoreServiceProvider.php +++ b/src/Core/CoreServiceProvider.php @@ -1,15 +1,7 @@ loadViewsFrom(__DIR__.'/../../views', 'flarum'); - $this->addEventHandlers(); - $this->bootModels(); - $this->addPostTypes(); - $this->grantPermissions(); - $this->mapCommandHandlers(); - } + $this->app->make('Illuminate\Contracts\Bus\Dispatcher')->mapUsing(function ($command) { + return get_class($command).'Handler@handle'; + }); - public function mapCommandHandlers() - { - $this->app->make(Bus::class)->mapUsing(function ($command) { - return Bus::simpleMapping( - $command, - 'Flarum\Core\Commands', - 'Flarum\Core\Handlers\Commands' - ); + Model::setValidator($this->app['validator']); + + Forum::allow('*', function (Forum $forum, User $user, $action) { + return $user->hasPermission('forum.'.$action) ?: null; }); } @@ -48,205 +33,15 @@ class CoreServiceProvider extends ServiceProvider */ public function register() { - // Register a singleton entity that represents this forum. This entity - // will be used to check for global forum permissions (like viewing the - // forum, registering, and starting discussions). - $this->app->singleton('flarum.forum', 'Flarum\Core\Models\Forum'); + $this->app->singleton('flarum.forum', 'Flarum\Core\Forum'); // TODO: probably use Illuminate's AggregateServiceProvider // functionality, because it includes the 'provides' stuff. $this->app->register('Flarum\Core\Activity\ActivityServiceProvider'); + $this->app->register('Flarum\Core\Discussions\DiscussionsServiceProvider'); $this->app->register('Flarum\Core\Formatter\FormatterServiceProvider'); $this->app->register('Flarum\Core\Notifications\NotificationsServiceProvider'); - - // TODO: refactor these into the appropriate service providers, when - // (if) we restructure our namespaces per-entity - // (Flarum\Core\Discussions\DiscussionsServiceProvider, etc.) - $this->app->bind( - 'Flarum\Core\Repositories\DiscussionRepositoryInterface', - 'Flarum\Core\Repositories\EloquentDiscussionRepository' - ); - - $this->app->bind( - 'Flarum\Core\Repositories\PostRepositoryInterface', - 'Flarum\Core\Repositories\EloquentPostRepository' - ); - - $this->app->bind( - 'Flarum\Core\Repositories\UserRepositoryInterface', - 'Flarum\Core\Repositories\EloquentUserRepository' - ); - - $this->app->bind( - 'Flarum\Core\Search\Discussions\Fulltext\DriverInterface', - 'Flarum\Core\Search\Discussions\Fulltext\MySqlFulltextDriver' - ); - - $this->registerDiscussionGambits(); - $this->registerUserGambits(); - $this->registerAvatarsFilesystem(); - } - - public function registerAvatarsFilesystem() - { - $avatarsFilesystem = function (Container $app) { - return $app->make('Illuminate\Contracts\Filesystem\Factory')->disk('flarum-avatars')->getDriver(); - }; - - $this->app->when('Flarum\Core\Handlers\Commands\UploadAvatarCommandHandler') - ->needs('League\Flysystem\FilesystemInterface') - ->give($avatarsFilesystem); - - $this->app->when('Flarum\Core\Handlers\Commands\DeleteAvatarCommandHandler') - ->needs('League\Flysystem\FilesystemInterface') - ->give($avatarsFilesystem); - } - - public function registerDiscussionGambits() - { - $this->app->instance('flarum.discussionGambits', [ - 'Flarum\Core\Search\Discussions\Gambits\AuthorGambit', - 'Flarum\Core\Search\Discussions\Gambits\UnreadGambit' - ]); - - $this->app->when('Flarum\Core\Search\Discussions\DiscussionSearcher') - ->needs('Flarum\Core\Search\GambitManager') - ->give(function (Container $app) { - $gambits = new GambitManager($app); - - foreach ($app->make('flarum.discussionGambits') as $gambit) { - $gambits->add($gambit); - } - - $gambits->setFulltextGambit('Flarum\Core\Search\Discussions\Gambits\FulltextGambit'); - - return $gambits; - }); - } - - public function registerUserGambits() - { - $this->app->instance('flarum.userGambits', []); - - $this->app->when('Flarum\Core\Search\Users\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\Search\Users\Gambits\FulltextGambit'); - - return $gambits; - }); - } - - public function addPostTypes() - { - $this->extend([ - new Extend\PostType('Flarum\Core\Models\CommentPost'), - new Extend\PostType('Flarum\Core\Models\DiscussionRenamedPost') - ]); - } - - public function addEventHandlers() - { - $this->extend([ - new Extend\EventSubscriber('Flarum\Core\Handlers\Events\DiscussionMetadataUpdater'), - new Extend\EventSubscriber('Flarum\Core\Handlers\Events\UserMetadataUpdater'), - new Extend\EventSubscriber('Flarum\Core\Handlers\Events\EmailConfirmationMailer') - ]); - } - - public function bootModels() - { - Model::setValidator($this->app['validator']); - - CommentPost::setFormatter($this->app['flarum.formatter']); - - User::setHasher($this->app['hash']); - User::setFormatter($this->app['flarum.formatter']); - - User::registerPreference('discloseOnline', 'boolval', true); - User::registerPreference('indexProfile', 'boolval', true); - } - - public function grantPermissions() - { - Forum::allow('*', function ($forum, $user, $action) { - if ($user->hasPermission('forum.'.$action)) { - return true; - } - }); - - Post::allow('*', function ($post, $user, $action) { - if ($post->discussion->can($user, $action.'Posts')) { - return true; - } - }); - - // 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::addVisiblePostsScope(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) { - if (! $post->hide_user_id || $post->can($user, 'edit')) { - return true; - } - }); - - // 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; - } - }); - - User::allow('*', function ($discussion, $user, $action) { - if ($user->hasPermission('user.'.$action)) { - return true; - } - }); - - User::allow(['edit', 'delete'], function ($user, $actor) { - if ($user->id == $actor->id) { - return true; - } - }); - - Discussion::allow('*', function ($discussion, $user, $action) { - if ($user->hasPermission('discussion.'.$action)) { - return true; - } - }); - - // Allow a user to rename their own discussion. - Discussion::allow('rename', function ($discussion, $user) { - if ($discussion->start_user_id == $user->id) { - return true; - // @todo add limitations to time etc. according to a config setting - } - }); - - Discussion::allow('delete', function ($discussion, $user) { - if ($discussion->start_user_id == $user->id && $discussion->participants_count == 1) { - return true; - // @todo add limitations to time etc. according to a config setting - } - }); + $this->app->register('Flarum\Core\Posts\PostsServiceProvider'); + $this->app->register('Flarum\Core\Users\UsersServiceProvider'); } } diff --git a/src/Core/DatabaseServiceProvider.php b/src/Core/DatabaseServiceProvider.php index 3d1b65e67..c02ec9b9b 100644 --- a/src/Core/DatabaseServiceProvider.php +++ b/src/Core/DatabaseServiceProvider.php @@ -40,6 +40,8 @@ class DatabaseServiceProvider extends ServiceProvider $this->app->booting(function() { $resolver = $this->app->make('Illuminate\Database\ConnectionResolverInterface'); Model::setConnectionResolver($resolver); + + Model::setEventDispatcher($this->app->make('events')); }); } } diff --git a/src/Core/Discussions/Commands/DeleteDiscussion.php b/src/Core/Discussions/Commands/DeleteDiscussion.php new file mode 100644 index 000000000..8ff715489 --- /dev/null +++ b/src/Core/Discussions/Commands/DeleteDiscussion.php @@ -0,0 +1,41 @@ +discussionId = $discussionId; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Discussions/Commands/DeleteDiscussionHandler.php b/src/Core/Discussions/Commands/DeleteDiscussionHandler.php new file mode 100644 index 000000000..35f6ad946 --- /dev/null +++ b/src/Core/Discussions/Commands/DeleteDiscussionHandler.php @@ -0,0 +1,44 @@ +discussions = $discussions; + } + + /** + * @param \Flarum\Core\Discussions\Commands\DeleteDiscussion $command + * @return \Flarum\Core\Discussions\Discussion + */ + public function handle(DeleteDiscussion $command) + { + $actor = $command->actor; + + $discussion = $this->discussions->findOrFail($command->discussionId, $actor); + + $discussion->assertCan($actor, 'delete'); + + event(new DiscussionWillBeDeleted($discussion, $actor, $command->data)); + + $discussion->delete(); + + $this->dispatchEventsFor($discussion); + + return $discussion; + } +} diff --git a/src/Core/Discussions/Commands/EditDiscussion.php b/src/Core/Discussions/Commands/EditDiscussion.php new file mode 100644 index 000000000..5537d868b --- /dev/null +++ b/src/Core/Discussions/Commands/EditDiscussion.php @@ -0,0 +1,39 @@ +discussionId = $discussionId; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Discussions/Commands/EditDiscussionHandler.php b/src/Core/Discussions/Commands/EditDiscussionHandler.php new file mode 100644 index 000000000..ef1649c55 --- /dev/null +++ b/src/Core/Discussions/Commands/EditDiscussionHandler.php @@ -0,0 +1,50 @@ +discussions = $discussions; + } + + /** + * @param EditDiscussion $command + * @return \Flarum\Core\Discussions\Discussion + * @throws \Flarum\Core\Exceptions\PermissionDeniedException + */ + public function handle(EditDiscussion $command) + { + $actor = $command->actor; + $data = $command->data; + $attributes = array_get($data, 'attributes', []); + + $discussion = $this->discussions->findOrFail($command->discussionId, $actor); + + if (isset($attributes['title'])) { + $discussion->assertCan($actor, 'rename'); + $discussion->rename($attributes['title'], $actor); + } + + event(new DiscussionWillBeSaved($discussion, $actor, $data)); + + $discussion->save(); + + $this->dispatchEventsFor($discussion); + + return $discussion; + } +} diff --git a/src/Core/Discussions/Commands/ReadDiscussion.php b/src/Core/Discussions/Commands/ReadDiscussion.php new file mode 100644 index 000000000..dd4422957 --- /dev/null +++ b/src/Core/Discussions/Commands/ReadDiscussion.php @@ -0,0 +1,39 @@ +discussionId = $discussionId; + $this->actor = $actor; + $this->readNumber = $readNumber; + } +} diff --git a/src/Core/Discussions/Commands/ReadDiscussionHandler.php b/src/Core/Discussions/Commands/ReadDiscussionHandler.php new file mode 100644 index 000000000..b8978b6cf --- /dev/null +++ b/src/Core/Discussions/Commands/ReadDiscussionHandler.php @@ -0,0 +1,51 @@ +discussions = $discussions; + } + + /** + * @param ReadDiscussion $command + * @return \Flarum\Core\Discussions\DiscussionState + * @throws \Flarum\Core\Exceptions\PermissionDeniedException + */ + public function handle(ReadDiscussion $command) + { + $actor = $command->actor; + + if (! $actor->exists) { + throw new PermissionDeniedException; + } + + $discussion = $this->discussions->findOrFail($command->discussionId, $actor); + + $state = $discussion->stateFor($actor); + $state->read($command->readNumber); + + event(new DiscussionStateWillBeSaved($state)); + + $state->save(); + + $this->dispatchEventsFor($state); + + return $state; + } +} diff --git a/src/Core/Discussions/Commands/StartDiscussion.php b/src/Core/Discussions/Commands/StartDiscussion.php new file mode 100644 index 000000000..27c48decb --- /dev/null +++ b/src/Core/Discussions/Commands/StartDiscussion.php @@ -0,0 +1,30 @@ +actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Handlers/Commands/StartDiscussionCommandHandler.php b/src/Core/Discussions/Commands/StartDiscussionHandler.php similarity index 54% rename from src/Core/Handlers/Commands/StartDiscussionCommandHandler.php rename to src/Core/Discussions/Commands/StartDiscussionHandler.php index 5a35ed839..eb30318f3 100644 --- a/src/Core/Handlers/Commands/StartDiscussionCommandHandler.php +++ b/src/Core/Discussions/Commands/StartDiscussionHandler.php @@ -1,43 +1,63 @@ -bus = $bus; + $this->forum = $forum; } - public function handle($command) + /** + * @param StartDiscussion $command + * @return mixed + */ + public function handle(StartDiscussion $command) { - $command->forum->assertCan($command->user, 'startDiscussion'); + $actor = $command->actor; + $data = $command->data; + + $this->forum->assertCan($actor, 'startDiscussion'); // Create a new Discussion entity, persist it, and dispatch domain // events. Before persistance, though, fire an event to give plugins // an opportunity to alter the discussion entity based on data in the // command they may have passed through in the controller. $discussion = Discussion::start( - array_get($command->data, 'title'), - $command->user + array_get($data, 'attributes.title'), + $actor ); - event(new DiscussionWillBeSaved($discussion, $command)); + event(new DiscussionWillBeSaved($discussion, $actor, $data)); $discussion->save(); // Now that the discussion has been created, we can add the first post. // We will do this by running the PostReply command. $post = $this->bus->dispatch( - new PostReplyCommand($discussion->id, $command->user, $command->data) + new PostReply($discussion->id, $actor, $data) ); // Before we dispatch events, refresh our discussion instance's @@ -50,6 +70,6 @@ class StartDiscussionCommandHandler $discussion->save(); - return $post->discussion; + return $discussion; } } diff --git a/src/Core/Models/Discussion.php b/src/Core/Discussions/Discussion.php similarity index 82% rename from src/Core/Models/Discussion.php rename to src/Core/Discussions/Discussion.php index 6dc133146..8ad0b36da 100755 --- a/src/Core/Models/Discussion.php +++ b/src/Core/Discussions/Discussion.php @@ -1,13 +1,17 @@ -start_time = time(); $discussion->start_user_id = $user->id; + $discussion->setRelation('startUser', $user); + $discussion->raise(new DiscussionWasStarted($discussion)); return $discussion; @@ -114,7 +120,7 @@ class Discussion extends Model * Rename the discussion. Raises the DiscussionWasRenamed event. * * @param string $title - * @param \Flarum\Core\Models\User $user + * @param User $user * @return $this */ public function rename($title, User $user) @@ -132,7 +138,7 @@ class Discussion extends Model /** * Set the discussion's start post details. * - * @param \Flarum\Core\Models\Post $post + * @param \Flarum\Core\Posts\Post $post * @return $this */ public function setStartPost(Post $post) @@ -147,7 +153,7 @@ class Discussion extends Model /** * Set the discussion's last post details. * - * @param \Flarum\Core\Models\Post $post + * @param \Flarum\Core\Posts\Post $post * @return $this */ public function setLastPost(Post $post) @@ -211,7 +217,7 @@ class Discussion extends Model * the same post as was originally intended to be saved. It also may not * exist, if the merge logic resulted in deletion. */ - public function mergePost(Mergable $post) + public function mergePost(MergeablePost $post) { $lastPost = $this->posts()->latest('time')->first(); @@ -225,21 +231,21 @@ class Discussion extends Model */ public function posts() { - return $this->hasMany('Flarum\Core\Models\Post'); + return $this->hasMany('Flarum\Core\Posts\Post'); } /** * Define the relationship with the discussion's posts, but only ones which * are visible to the given user. * - * @param \Flarum\Core\Models\User $user + * @param User $user * @return \Illuminate\Database\Eloquent\Relations\HasMany */ - public function visiblePosts(User $user) + public function postsVisibleTo(User $user) { $query = $this->posts(); - foreach (static::$visiblePostsScopes as $scope) { + foreach (static::$postVisibilityScopes as $scope) { $scope($query, $user, $this); } @@ -253,7 +259,7 @@ class Discussion extends Model */ public function comments() { - return $this->visiblePosts(new Guest)->where('type', 'comment'); + return $this->postsVisibleTo(new Guest)->where('type', 'comment'); } /** @@ -277,7 +283,7 @@ class Discussion extends Model */ public function startPost() { - return $this->belongsTo('Flarum\Core\Models\Post', 'start_post_id'); + return $this->belongsTo('Flarum\Core\Posts\Post', 'start_post_id'); } /** @@ -287,7 +293,7 @@ class Discussion extends Model */ public function startUser() { - return $this->belongsTo('Flarum\Core\Models\User', 'start_user_id'); + return $this->belongsTo('Flarum\Core\Users\User', 'start_user_id'); } /** @@ -297,7 +303,7 @@ class Discussion extends Model */ public function lastPost() { - return $this->belongsTo('Flarum\Core\Models\Post', 'last_post_id'); + return $this->belongsTo('Flarum\Core\Posts\Post', 'last_post_id'); } /** @@ -307,7 +313,7 @@ class Discussion extends Model */ public function lastUser() { - return $this->belongsTo('Flarum\Core\Models\User', 'last_user_id'); + return $this->belongsTo('Flarum\Core\Users\User', 'last_user_id'); } /** @@ -317,7 +323,7 @@ class Discussion extends Model */ public function readers() { - return $this->belongsToMany('Flarum\Core\Models\User', 'users_discussions'); + return $this->belongsToMany('Flarum\Core\Users\User', 'users_discussions'); } /** @@ -327,24 +333,24 @@ class Discussion extends Model * If no user is passed (i.e. in the case of eager loading the 'state' * relation), then the static `$stateUser` property is used. * - * @see \Flarum\Core\Models\Discussion::setStateUser() + * @see Discussion::setStateUser() * - * @param \Flarum\Core\Models\User $user + * @param User|null $user * @return \Illuminate\Database\Eloquent\Relations\HasOne */ public function state(User $user = null) { $user = $user ?: static::$stateUser; - return $this->hasOne('Flarum\Core\Models\DiscussionState')->where('user_id', $user ? $user->id : null); + return $this->hasOne('Flarum\Core\Discussions\DiscussionState')->where('user_id', $user ? $user->id : null); } /** * Get the state model for a user, or instantiate a new one if it does not * exist. * - * @param \Flarum\Core\Models\User $user - * @return \Flarum\Core\Models\DiscussionState + * @param User $user + * @return \Flarum\Core\Discussions\DiscussionState */ public function stateFor(User $user) { @@ -362,7 +368,7 @@ class Discussion extends Model /** * Set the user for which the state relationship should be loaded. * - * @param \Flarum\Core\Models\User $user + * @param User $user */ public static function setStateUser(User $user) { @@ -376,8 +382,8 @@ class Discussion extends Model * query. It is passed three parameters: the query builder object, the * user to constrain posts for, and the discussion instance. */ - public static function addVisiblePostsScope(callable $scope) + public static function addPostVisibilityScope(callable $scope) { - static::$visiblePostsScopes[] = $scope; + static::$postVisibilityScopes[] = $scope; } } diff --git a/src/Core/Repositories/DiscussionRepositoryInterface.php b/src/Core/Discussions/DiscussionRepositoryInterface.php similarity index 66% rename from src/Core/Repositories/DiscussionRepositoryInterface.php rename to src/Core/Discussions/DiscussionRepositoryInterface.php index 025576863..e9f20ab7d 100644 --- a/src/Core/Repositories/DiscussionRepositoryInterface.php +++ b/src/Core/Discussions/DiscussionRepositoryInterface.php @@ -1,6 +1,6 @@ -belongsTo('Flarum\Core\Models\Discussion', 'discussion_id'); + return $this->belongsTo('Flarum\Core\Discussions\Discussion', 'discussion_id'); } /** @@ -65,14 +62,14 @@ class DiscussionState extends Model */ public function user() { - return $this->belongsTo('Flarum\Core\Models\User', 'user_id'); + return $this->belongsTo('Flarum\Core\Users\User', 'user_id'); } /** * Set the keys for a save update query. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param Builder $query + * @return Builder */ protected function setKeysForSaveQuery(Builder $query) { diff --git a/src/Core/Discussions/DiscussionsServiceProvider.php b/src/Core/Discussions/DiscussionsServiceProvider.php new file mode 100644 index 000000000..33e360b85 --- /dev/null +++ b/src/Core/Discussions/DiscussionsServiceProvider.php @@ -0,0 +1,74 @@ +extend([ + new Extend\EventSubscriber('Flarum\Core\Discussions\Listeners\DiscussionMetadataUpdater') + ]); + + Discussion::allow('*', function (Discussion $discussion, User $user, $action) { + return $user->hasPermission('discussion.'.$action) ?: null; + }); + + // Allow a user to rename their own discussion. + Discussion::allow('rename', function (Discussion $discussion, User $user) { + return $discussion->start_user_id == $user->id ?: null; + // TODO: add limitations to time etc. according to a config setting + }); + + Discussion::allow('delete', function (Discussion $discussion, User $user) { + return $discussion->start_user_id == $user->id && $discussion->participants_count == 1 ?: null; + // TODO: add limitations to time etc. according to a config setting + }); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + $this->app->bind( + 'Flarum\Core\Discussions\DiscussionRepositoryInterface', + 'Flarum\Core\Discussions\EloquentDiscussionRepository' + ); + + $this->app->bind( + 'Flarum\Core\Discussions\Search\Fulltext\DriverInterface', + 'Flarum\Core\Discussions\Search\Fulltext\MySqlFulltextDriver' + ); + + $this->app->instance('flarum.discussionGambits', [ + 'Flarum\Core\Discussions\Search\Gambits\AuthorGambit', + 'Flarum\Core\Discussions\Search\Gambits\UnreadGambit' + ]); + + $this->app->when('Flarum\Core\Discussions\Search\DiscussionSearcher') + ->needs('Flarum\Core\Search\GambitManager') + ->give(function (Container $app) { + $gambits = new GambitManager($app); + + foreach ($app->make('flarum.discussionGambits') as $gambit) { + $gambits->add($gambit); + } + + $gambits->setFulltextGambit('Flarum\Core\Discussions\Search\Gambits\FulltextGambit'); + + return $gambits; + }); + } +} diff --git a/src/Core/Repositories/EloquentDiscussionRepository.php b/src/Core/Discussions/EloquentDiscussionRepository.php similarity index 67% rename from src/Core/Repositories/EloquentDiscussionRepository.php rename to src/Core/Discussions/EloquentDiscussionRepository.php index 25430e57b..316e9fa91 100644 --- a/src/Core/Repositories/EloquentDiscussionRepository.php +++ b/src/Core/Discussions/EloquentDiscussionRepository.php @@ -1,15 +1,14 @@ -search = $search; + $this->criteria = $criteria; + } +} diff --git a/src/Core/Discussions/Events/DiscussionStateWillBeSaved.php b/src/Core/Discussions/Events/DiscussionStateWillBeSaved.php new file mode 100644 index 000000000..3727e8b6b --- /dev/null +++ b/src/Core/Discussions/Events/DiscussionStateWillBeSaved.php @@ -0,0 +1,19 @@ +state = $state; + } +} diff --git a/src/Core/Discussions/Events/DiscussionWasDeleted.php b/src/Core/Discussions/Events/DiscussionWasDeleted.php new file mode 100644 index 000000000..67c9cd3c1 --- /dev/null +++ b/src/Core/Discussions/Events/DiscussionWasDeleted.php @@ -0,0 +1,20 @@ +discussion = $discussion; + } +} diff --git a/src/Core/Discussions/Events/DiscussionWasRead.php b/src/Core/Discussions/Events/DiscussionWasRead.php new file mode 100644 index 000000000..ac142be7f --- /dev/null +++ b/src/Core/Discussions/Events/DiscussionWasRead.php @@ -0,0 +1,19 @@ +state = $state; + } +} diff --git a/src/Core/Discussions/Events/DiscussionWasRenamed.php b/src/Core/Discussions/Events/DiscussionWasRenamed.php new file mode 100644 index 000000000..e4b8e198e --- /dev/null +++ b/src/Core/Discussions/Events/DiscussionWasRenamed.php @@ -0,0 +1,34 @@ +discussion = $discussion; + $this->actor = $actor; + $this->oldTitle = $oldTitle; + } +} diff --git a/src/Core/Discussions/Events/DiscussionWasStarted.php b/src/Core/Discussions/Events/DiscussionWasStarted.php new file mode 100644 index 000000000..93c28e1f9 --- /dev/null +++ b/src/Core/Discussions/Events/DiscussionWasStarted.php @@ -0,0 +1,19 @@ +discussion = $discussion; + } +} diff --git a/src/Core/Discussions/Events/DiscussionWillBeDeleted.php b/src/Core/Discussions/Events/DiscussionWillBeDeleted.php new file mode 100644 index 000000000..e8a927f0f --- /dev/null +++ b/src/Core/Discussions/Events/DiscussionWillBeDeleted.php @@ -0,0 +1,40 @@ +discussion = $discussion; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Discussions/Events/DiscussionWillBeSaved.php b/src/Core/Discussions/Events/DiscussionWillBeSaved.php new file mode 100644 index 000000000..da2eab33f --- /dev/null +++ b/src/Core/Discussions/Events/DiscussionWillBeSaved.php @@ -0,0 +1,40 @@ +discussion = $discussion; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/src/Core/Handlers/Events/DiscussionMetadataUpdater.php b/src/Core/Discussions/Listeners/DiscussionMetadataUpdater.php similarity index 61% rename from src/Core/Handlers/Events/DiscussionMetadataUpdater.php rename to src/Core/Discussions/Listeners/DiscussionMetadataUpdater.php index 2fdd27a27..416b467c3 100755 --- a/src/Core/Handlers/Events/DiscussionMetadataUpdater.php +++ b/src/Core/Discussions/Listeners/DiscussionMetadataUpdater.php @@ -1,27 +1,28 @@ -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(PostWasPosted::class, __CLASS__.'@whenPostWasPosted'); + $events->listen(PostWasDeleted::class, __CLASS__.'@whenPostWasDeleted'); + $events->listen(PostWasHidden::class, __CLASS__.'@whenPostWasHidden'); + $events->listen(PostWasRestored::class, __CLASS__.'@whenPostWasRestored'); } + /** + * @param PostWasPosted $event + */ public function whenPostWasPosted(PostWasPosted $event) { $discussion = $event->post->discussion; @@ -32,16 +33,25 @@ class DiscussionMetadataUpdater $discussion->save(); } + /** + * @param PostWasDeleted $event + */ public function whenPostWasDeleted(PostWasDeleted $event) { $this->removePost($event->post); } + /** + * @param PostWasHidden $event + */ public function whenPostWasHidden(PostWasHidden $event) { $this->removePost($event->post); } + /** + * @param PostWasRestored $event + */ public function whenPostWasRestored(PostWasRestored $event) { $discussion = $event->post->discussion; @@ -52,6 +62,9 @@ class DiscussionMetadataUpdater $discussion->save(); } + /** + * @param Post $post + */ protected function removePost(Post $post) { $discussion = $post->discussion; diff --git a/src/Core/Discussions/Search/DiscussionSearch.php b/src/Core/Discussions/Search/DiscussionSearch.php new file mode 100644 index 000000000..29d8909be --- /dev/null +++ b/src/Core/Discussions/Search/DiscussionSearch.php @@ -0,0 +1,55 @@ + 'desc']; + + /** + * @var array + */ + protected $relevantPostIds = []; + + /** + * Get the related IDs for each result. + * + * @return int[] + */ + public function getRelevantPostIds() + { + return $this->relevantPostIds; + } + + /** + * Set the relevant post IDs for a result. + * + * @param int $discussionId + * @param int[] $postIds + * @return void + */ + public function setRelevantPostIds($discussionId, array $postIds) + { + $this->relevantPostIds[$discussionId] = $postIds; + } + + /** + * Add a relevant post ID for a discussion result. + * + * @param int $discussionId + * @param int $postId + * @return void + */ + public function addRelevantPostId($discussionId, $postId) + { + $this->relevantPostIds[$discussionId][] = $postId; + } +} diff --git a/src/Core/Discussions/Search/DiscussionSearcher.php b/src/Core/Discussions/Search/DiscussionSearcher.php new file mode 100644 index 000000000..3004671d3 --- /dev/null +++ b/src/Core/Discussions/Search/DiscussionSearcher.php @@ -0,0 +1,124 @@ +gambits = $gambits; + $this->discussions = $discussions; + $this->posts = $posts; + } + + /** + * @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->discussions->query()->whereVisibleTo($actor); + + // Construct an object which represents this search for discussions. + // Apply gambits to it, sort, and paging criteria. Also give extensions + // an opportunity to modify it. + $search = new DiscussionSearch($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 DiscussionSearchWillBePerformed($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. + $discussions = $query->get(); + + if ($areMoreResults = ($limit > 0 && $discussions->count() > $limit)) { + $discussions->pop(); + } + + // The relevant posts relationship isn't a typical Eloquent + // relationship; rather, we need to extract that information from our + // search object. We will delegate that task and prevent Eloquent + // from trying to load it. + if (in_array('relevantPosts', $load)) { + $this->loadRelevantPosts($discussions, $search); + + $load = array_diff($load, ['relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user']); + } + + Discussion::setStateUser($actor); + $discussions->load($load); + + return new SearchResults($discussions, $areMoreResults); + } + + /** + * Load relevant posts onto each discussion using information from the + * search. + * + * @param Collection $discussions + * @param DiscussionSearch $search + */ + protected function loadRelevantPosts(Collection $discussions, DiscussionSearch $search) + { + $postIds = []; + foreach ($search->getRelevantPostIds() as $relevantPostIds) { + $postIds = array_merge($postIds, array_slice($relevantPostIds, 0, 2)); + } + + $posts = $postIds ? $this->posts->findByIds($postIds, $search->getActor())->load('user')->all() : []; + + foreach ($discussions as $discussion) { + $discussion->relevantPosts = array_filter($posts, function ($post) use ($discussion) { + return $post->discussion_id == $discussion->id; + }); + } + } +} diff --git a/src/Core/Search/Discussions/Fulltext/DriverInterface.php b/src/Core/Discussions/Search/Fulltext/DriverInterface.php similarity index 54% rename from src/Core/Search/Discussions/Fulltext/DriverInterface.php rename to src/Core/Discussions/Search/Fulltext/DriverInterface.php index a80c6f07f..fd11ae2ec 100644 --- a/src/Core/Search/Discussions/Fulltext/DriverInterface.php +++ b/src/Core/Discussions/Search/Fulltext/DriverInterface.php @@ -1,4 +1,4 @@ -users = $users; + } + + /** + * {@inheritdoc} + */ + protected function conditions(Search $search, array $matches, $negate) + { + if (! $search instanceof DiscussionSearch) { + throw new LogicException('This gambit can only be applied on a DiscussionSearch'); + } + + $username = trim($matches[1], '"'); + + $id = $this->users->getIdForUsername($username); + + $search->getQuery()->where('start_user_id', $negate ? '!=' : '=', $id); + } +} diff --git a/src/Core/Discussions/Search/Gambits/FulltextGambit.php b/src/Core/Discussions/Search/Gambits/FulltextGambit.php new file mode 100644 index 000000000..66b7bec16 --- /dev/null +++ b/src/Core/Discussions/Search/Gambits/FulltextGambit.php @@ -0,0 +1,47 @@ +posts = $posts; + } + + /** + * {@inheritdoc} + */ + public function apply(Search $search, $bit) + { + if (! $search instanceof DiscussionSearch) { + throw new LogicException('This gambit can only be applied on a DiscussionSearch'); + } + + $posts = $this->posts->findByContent($bit, $search->getActor()); + + $discussions = []; + foreach ($posts as $post) { + $discussions[] = $id = $post->discussion_id; + $search->addRelevantPostId($id, $post->id); + } + $discussions = array_unique($discussions); + + // TODO: implement negate (match for - at start of string) + $search->getQuery()->whereIn('id', $discussions); + + $search->setDefaultSort(['id' => $discussions]); + } +} diff --git a/src/Core/Search/GambitAbstract.php b/src/Core/Discussions/Search/Gambits/RegexGambit.php similarity index 93% rename from src/Core/Search/GambitAbstract.php rename to src/Core/Discussions/Search/Gambits/RegexGambit.php index 6220e90d3..45933bd27 100644 --- a/src/Core/Search/GambitAbstract.php +++ b/src/Core/Discussions/Search/Gambits/RegexGambit.php @@ -1,6 +1,6 @@ discussions = $discussions; + } + + /** + * {@inheritdoc} + */ + protected function conditions(Search $search, array $matches, $negate) + { + if (! $search instanceof DiscussionSearch) { + throw new LogicException('This gambit can only be applied on a DiscussionSearch'); + } + + $actor = $search->getActor(); + + if ($actor->exists) { + $readIds = $this->discussions->getReadIds($actor); + + $search->getQuery()->where(function ($query) use ($readIds, $negate, $actor) { + if (! $negate) { + $query->whereNotIn('id', $readIds)->where('last_time', '>', $actor->read_time ?: 0); + } else { + $query->whereIn('id', $readIds)->orWhere('last_time', '<=', $actor->read_time ?: 0); + } + }); + } + } +} diff --git a/src/Core/Events/AvatarWillBeDeleted.php b/src/Core/Events/AvatarWillBeDeleted.php deleted file mode 100644 index c5128a65e..000000000 --- a/src/Core/Events/AvatarWillBeDeleted.php +++ /dev/null @@ -1,16 +0,0 @@ -user = $user; - $this->command = $command; - } -} diff --git a/src/Core/Events/AvatarWillBeUploaded.php b/src/Core/Events/AvatarWillBeUploaded.php deleted file mode 100644 index efb93c2aa..000000000 --- a/src/Core/Events/AvatarWillBeUploaded.php +++ /dev/null @@ -1,16 +0,0 @@ -user = $user; - $this->command = $command; - } -} diff --git a/src/Core/Events/DiscussionSearchWillBePerformed.php b/src/Core/Events/DiscussionSearchWillBePerformed.php deleted file mode 100644 index f36addf52..000000000 --- a/src/Core/Events/DiscussionSearchWillBePerformed.php +++ /dev/null @@ -1,16 +0,0 @@ -searcher = $searcher; - $this->criteria = $criteria; - } -} diff --git a/src/Core/Events/DiscussionStateWillBeSaved.php b/src/Core/Events/DiscussionStateWillBeSaved.php deleted file mode 100644 index 1ec56107e..000000000 --- a/src/Core/Events/DiscussionStateWillBeSaved.php +++ /dev/null @@ -1,16 +0,0 @@ -state = $state; - $this->command = $command; - } -} diff --git a/src/Core/Events/DiscussionWasDeleted.php b/src/Core/Events/DiscussionWasDeleted.php deleted file mode 100644 index 271cc73d7..000000000 --- a/src/Core/Events/DiscussionWasDeleted.php +++ /dev/null @@ -1,13 +0,0 @@ -discussion = $discussion; - } -} diff --git a/src/Core/Events/DiscussionWasRead.php b/src/Core/Events/DiscussionWasRead.php deleted file mode 100644 index d5b588c5b..000000000 --- a/src/Core/Events/DiscussionWasRead.php +++ /dev/null @@ -1,13 +0,0 @@ -state = $state; - } -} diff --git a/src/Core/Events/DiscussionWasRenamed.php b/src/Core/Events/DiscussionWasRenamed.php deleted file mode 100644 index e34cfdf35..000000000 --- a/src/Core/Events/DiscussionWasRenamed.php +++ /dev/null @@ -1,20 +0,0 @@ -discussion = $discussion; - $this->user = $user; - $this->oldTitle = $oldTitle; - } -} diff --git a/src/Core/Events/DiscussionWasStarted.php b/src/Core/Events/DiscussionWasStarted.php deleted file mode 100644 index 26b1ee2cd..000000000 --- a/src/Core/Events/DiscussionWasStarted.php +++ /dev/null @@ -1,13 +0,0 @@ -discussion = $discussion; - } -} diff --git a/src/Core/Events/DiscussionWillBeDeleted.php b/src/Core/Events/DiscussionWillBeDeleted.php deleted file mode 100644 index cbdd43cc2..000000000 --- a/src/Core/Events/DiscussionWillBeDeleted.php +++ /dev/null @@ -1,16 +0,0 @@ -discussion = $discussion; - $this->command = $command; - } -} diff --git a/src/Core/Events/DiscussionWillBeSaved.php b/src/Core/Events/DiscussionWillBeSaved.php deleted file mode 100644 index 60b39fe17..000000000 --- a/src/Core/Events/DiscussionWillBeSaved.php +++ /dev/null @@ -1,16 +0,0 @@ -discussion = $discussion; - $this->command = $command; - } -} diff --git a/src/Core/Events/NotificationWillBeSent.php b/src/Core/Events/NotificationWillBeSent.php deleted file mode 100644 index 968e147ae..000000000 --- a/src/Core/Events/NotificationWillBeSent.php +++ /dev/null @@ -1,16 +0,0 @@ -notification = $notification; - $this->users = $users; - } -} diff --git a/src/Core/Events/PostWasDeleted.php b/src/Core/Events/PostWasDeleted.php deleted file mode 100644 index c745f0980..000000000 --- a/src/Core/Events/PostWasDeleted.php +++ /dev/null @@ -1,13 +0,0 @@ -post = $post; - } -} diff --git a/src/Core/Events/PostWasHidden.php b/src/Core/Events/PostWasHidden.php deleted file mode 100644 index ebb5c0159..000000000 --- a/src/Core/Events/PostWasHidden.php +++ /dev/null @@ -1,13 +0,0 @@ -post = $post; - } -} diff --git a/src/Core/Events/PostWasPosted.php b/src/Core/Events/PostWasPosted.php deleted file mode 100644 index 3da2ec3c2..000000000 --- a/src/Core/Events/PostWasPosted.php +++ /dev/null @@ -1,13 +0,0 @@ -post = $post; - } -} diff --git a/src/Core/Events/PostWasRestored.php b/src/Core/Events/PostWasRestored.php deleted file mode 100644 index f1207f5d0..000000000 --- a/src/Core/Events/PostWasRestored.php +++ /dev/null @@ -1,13 +0,0 @@ -post = $post; - } -} diff --git a/src/Core/Events/PostWasRevised.php b/src/Core/Events/PostWasRevised.php deleted file mode 100644 index eb3fff4b2..000000000 --- a/src/Core/Events/PostWasRevised.php +++ /dev/null @@ -1,13 +0,0 @@ -post = $post; - } -} diff --git a/src/Core/Events/PostWillBeDeleted.php b/src/Core/Events/PostWillBeDeleted.php deleted file mode 100644 index 0116654e8..000000000 --- a/src/Core/Events/PostWillBeDeleted.php +++ /dev/null @@ -1,16 +0,0 @@ -post = $post; - $this->command = $command; - } -} diff --git a/src/Core/Events/PostWillBeSaved.php b/src/Core/Events/PostWillBeSaved.php deleted file mode 100644 index 0b6b748eb..000000000 --- a/src/Core/Events/PostWillBeSaved.php +++ /dev/null @@ -1,16 +0,0 @@ -post = $post; - $this->command = $command; - } -} diff --git a/src/Core/Events/UserAvatarWasChanged.php b/src/Core/Events/UserAvatarWasChanged.php deleted file mode 100644 index b83c9471d..000000000 --- a/src/Core/Events/UserAvatarWasChanged.php +++ /dev/null @@ -1,13 +0,0 @@ -user = $user; - } -} diff --git a/src/Core/Events/UserBioWasChanged.php b/src/Core/Events/UserBioWasChanged.php deleted file mode 100644 index 6b88e3bc5..000000000 --- a/src/Core/Events/UserBioWasChanged.php +++ /dev/null @@ -1,13 +0,0 @@ -user = $user; - } -} diff --git a/src/Core/Events/UserEmailChangeWasRequested.php b/src/Core/Events/UserEmailChangeWasRequested.php deleted file mode 100644 index e83e249ab..000000000 --- a/src/Core/Events/UserEmailChangeWasRequested.php +++ /dev/null @@ -1,16 +0,0 @@ -user = $user; - $this->email = $email; - } -} diff --git a/src/Core/Events/UserEmailWasChanged.php b/src/Core/Events/UserEmailWasChanged.php deleted file mode 100644 index 774d783be..000000000 --- a/src/Core/Events/UserEmailWasChanged.php +++ /dev/null @@ -1,13 +0,0 @@ -user = $user; - } -} diff --git a/src/Core/Events/UserEmailWasConfirmed.php b/src/Core/Events/UserEmailWasConfirmed.php deleted file mode 100644 index 75e6a92fc..000000000 --- a/src/Core/Events/UserEmailWasConfirmed.php +++ /dev/null @@ -1,13 +0,0 @@ -user = $user; - } -} diff --git a/src/Core/Events/UserPasswordWasChanged.php b/src/Core/Events/UserPasswordWasChanged.php deleted file mode 100644 index f1b58f429..000000000 --- a/src/Core/Events/UserPasswordWasChanged.php +++ /dev/null @@ -1,13 +0,0 @@ -user = $user; - } -} diff --git a/src/Core/Events/UserSearchWillBePerformed.php b/src/Core/Events/UserSearchWillBePerformed.php deleted file mode 100644 index 731a0f990..000000000 --- a/src/Core/Events/UserSearchWillBePerformed.php +++ /dev/null @@ -1,16 +0,0 @@ -searcher = $searcher; - $this->criteria = $criteria; - } -} diff --git a/src/Core/Events/UserWasActivated.php b/src/Core/Events/UserWasActivated.php deleted file mode 100644 index 07342b3da..000000000 --- a/src/Core/Events/UserWasActivated.php +++ /dev/null @@ -1,13 +0,0 @@ -user = $user; - } -} diff --git a/src/Core/Events/UserWasDeleted.php b/src/Core/Events/UserWasDeleted.php deleted file mode 100644 index ff8b716a7..000000000 --- a/src/Core/Events/UserWasDeleted.php +++ /dev/null @@ -1,13 +0,0 @@ -user = $user; - } -} diff --git a/src/Core/Events/UserWasRegistered.php b/src/Core/Events/UserWasRegistered.php deleted file mode 100644 index 958a1d18c..000000000 --- a/src/Core/Events/UserWasRegistered.php +++ /dev/null @@ -1,13 +0,0 @@ -user = $user; - } -} diff --git a/src/Core/Events/UserWasRenamed.php b/src/Core/Events/UserWasRenamed.php deleted file mode 100644 index 42525183d..000000000 --- a/src/Core/Events/UserWasRenamed.php +++ /dev/null @@ -1,13 +0,0 @@ -user = $user; - } -} diff --git a/src/Core/Events/UserWillBeDeleted.php b/src/Core/Events/UserWillBeDeleted.php deleted file mode 100644 index 8fd4ba5ec..000000000 --- a/src/Core/Events/UserWillBeDeleted.php +++ /dev/null @@ -1,16 +0,0 @@ -user = $user; - $this->command = $command; - } -} diff --git a/src/Core/Events/UserWillBeSaved.php b/src/Core/Events/UserWillBeSaved.php deleted file mode 100644 index 8a3bf7e9f..000000000 --- a/src/Core/Events/UserWillBeSaved.php +++ /dev/null @@ -1,16 +0,0 @@ -user = $user; - $this->command = $command; - } -} diff --git a/src/Core/Exceptions/ValidationFailureException.php b/src/Core/Exceptions/ValidationFailureException.php index 0d00c0f4a..dd2534cfc 100644 --- a/src/Core/Exceptions/ValidationFailureException.php +++ b/src/Core/Exceptions/ValidationFailureException.php @@ -1,19 +1,37 @@ errors = new MessageBag; } + /** + * @param MessageBag $errors + * @return $this + */ public function setErrors(MessageBag $errors) { $this->errors = $errors; @@ -21,11 +39,18 @@ class ValidationFailureException extends \InvalidArgumentException return $this; } + /** + * @return MessageBag + */ public function getErrors() { return $this->errors; } + /** + * @param array $input + * @return $this + */ public function setInput(array $input) { $this->input = $input; @@ -33,6 +58,9 @@ class ValidationFailureException extends \InvalidArgumentException return $this; } + /** + * @return array + */ public function getInput() { return $this->input; diff --git a/src/Core/Formatter/FormatterAbstract.php b/src/Core/Formatter/FormatterAbstract.php deleted file mode 100644 index 27cb84b48..000000000 --- a/src/Core/Formatter/FormatterAbstract.php +++ /dev/null @@ -1,48 +0,0 @@ -)/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('``i', $chunks[$i], $matches)) { - $openTag = null; - } - } - } - } - - return implode($chunks); - } -} diff --git a/src/Core/Formatter/FormatterInterface.php b/src/Core/Formatter/FormatterInterface.php index 0a09c5fc8..352e93618 100644 --- a/src/Core/Formatter/FormatterInterface.php +++ b/src/Core/Formatter/FormatterInterface.php @@ -1,10 +1,31 @@ container = $container; - // Studio does not yet merge autoload_files... + // TODO: Studio does not yet merge autoload_files... // https://github.com/franzliedke/studio/commit/4f0f4314db4ed3e36c869a5f79b855c97bdd1be7 require __DIR__.'/../../../vendor/ezyang/htmlpurifier/library/HTMLPurifier.composer.php'; - $this->config = HTMLPurifier_Config::createDefault(); - $this->config->set('Core.Encoding', 'UTF-8'); - $this->config->set('Core.EscapeInvalidTags', true); - $this->config->set('HTML.Doctype', 'HTML 4.01 Strict'); - $this->config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br,hr,img[src|alt]'); - $this->config->set('HTML.Nofollow', true); + $this->htmlPurifierConfig = $this->getDefaultHtmlPurifierConfig(); } - public function add($name, $formatter, $priority = 0) + /** + * Get the HTMLPurifier configuration object. + * + * @return HTMLPurifier_Config + */ + public function getHtmlPurifierConfig() { - $this->formatters[$name] = [$formatter, $priority]; + return $this->htmlPurifierConfig; } - public function remove($name) + /** + * Add a new formatter. + * + * @param string $formatter + */ + public function add($formatter) { - unset($this->formatters[$name]); + $this->formatters[] = $formatter; } - protected function getFormatters() + /** + * Format the given text using the collected formatters. + * + * @param string $text + * @param Model|null $model The entity that owns the text. + * @return string + */ + public function format($text, Model $model = null) { - $sorted = []; + $formatters = $this->getFormatters(); - foreach ($this->formatters as $array) { - list($formatter, $priority) = $array; - $sorted[$priority][] = $formatter; - } - - ksort($sorted); - - $result = []; - - foreach ($sorted as $formatters) { - $result = array_merge($result, $formatters); - } - - return $result; - } - - public function format($text, $post = null) - { - $formatters = []; - foreach ($this->getFormatters() as $formatter) { - $formatters[] = $this->container->make($formatter); + foreach ($formatters as $formatter) { + $formatter->config($this); } foreach ($formatters as $formatter) { - $text = $formatter->beforePurification($text, $post); + $text = $formatter->formatBeforePurification($text, $model); } - $purifier = new HTMLPurifier($this->config); - - $text = $purifier->purify($text); + $text = $this->purify($text); foreach ($formatters as $formatter) { - $text = $formatter->afterPurification($text, $post); + $text = $formatter->formatAfterPurification($text, $model); } return $text; } + + /** + * Instantiate the collected formatters. + * + * @return FormatterInterface[] + */ + protected function getFormatters() + { + $formatters = []; + + foreach ($this->formatters as $formatter) { + $formatter = $this->container->make($formatter); + + if (! $formatter instanceof FormatterInterface) { + throw new LogicException('Formatter ' . get_class($formatter) + . ' does not implement ' . FormatterInterface::class); + } + + $formatters[] = $formatter; + } + + return $formatters; + } + + /** + * Purify the given text, making sure it is safe to be displayed in web + * browsers. + * + * @param string $text + * @return string + */ + protected function purify($text) + { + $purifier = new HTMLPurifier($this->htmlPurifierConfig); + + return $purifier->purify($text); + } + + /** + * Get the default HTMLPurifier config settings. + * + * @return HTMLPurifier_Config + */ + protected function getDefaultHtmlPurifierConfig() + { + $config = HTMLPurifier_Config::createDefault(); + $config->set('Core.Encoding', 'UTF-8'); + $config->set('Core.EscapeInvalidTags', true); + $config->set('HTML.Doctype', 'HTML 4.01 Strict'); + $config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br,hr,img[src|alt]'); + $config->set('HTML.Nofollow', true); + + return $config; + } } diff --git a/src/Core/Formatter/FormatterServiceProvider.php b/src/Core/Formatter/FormatterServiceProvider.php index 76b4ab0a6..2b78a847f 100644 --- a/src/Core/Formatter/FormatterServiceProvider.php +++ b/src/Core/Formatter/FormatterServiceProvider.php @@ -13,7 +13,7 @@ class FormatterServiceProvider extends ServiceProvider public function boot() { $this->extend([ - new Extend\Formatter('linkify', 'Flarum\Core\Formatter\LinkifyFormatter') + new Extend\PostFormatter('Flarum\Core\Formatter\LinkifyFormatter') ]); } diff --git a/src/Core/Formatter/LinkifyFormatter.php b/src/Core/Formatter/LinkifyFormatter.php index 5b616ecd7..76c8214e7 100644 --- a/src/Core/Formatter/LinkifyFormatter.php +++ b/src/Core/Formatter/LinkifyFormatter.php @@ -1,18 +1,27 @@ linkify = $linkify; } - public function beforePurification($text, Post $post = null) + /** + * {@inheritdoc} + */ + protected function formatTextBeforePurification($text, Model $post = null) { return $this->linkify->process($text, ['attr' => ['target' => '_blank']]); } diff --git a/src/Core/Formatter/TextFormatter.php b/src/Core/Formatter/TextFormatter.php new file mode 100644 index 000000000..adc352e31 --- /dev/null +++ b/src/Core/Formatter/TextFormatter.php @@ -0,0 +1,120 @@ + and
 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('``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);
         }
     }
 }