1
0
mirror of https://github.com/flarum/core.git synced 2025-08-02 22:47:33 +02:00

Merge branch 'master' into compact-posts

This commit is contained in:
Toby Zerner
2015-09-15 11:27:49 +09:30
55 changed files with 1465 additions and 461 deletions

44
CHANGELOG.md Normal file
View File

@@ -0,0 +1,44 @@
# Change Log
All notable changes to Flarum and its bundled extensions will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased][unreleased]
*nothing yet*
## [0.1.0-beta.2] - 2015-09-15
### Added
- Check prerequisites (PHP version, extensions, etc.) before installation (#364)
- Enforce maximum title and post length through validation (#53, #338)
- Ctrl+Enter submits posts (#276)
- Syntax highlighting for code blocks (#248)
- All links open in new window, receive rel=nofollow attribute (#247)
- Default build script for extensions (#438)
- Input validation in installer
### Changed
- Ask for admin password confirmation in installer (#405)
- Increased some text contrasts for accessibility (#390)
### Fixed
- Discussion list did not work with non-empty database prefix (#269, #380)
- Non-admins could not reset their password (#229)
- Requests ending with a slash resulted in a 404 (#334)
- In rare cases, posts did not load correctly (#295)
- Avatars did not show up when installed in a subfolder (#291)
- Installer crashed when views directory was not writable (#376)
- Table prefix could not be set in web installer (#269)
- Enabling an extension disabled all other extensions (#402)
- Invalid custom CSS could crash the application (#400)
- First posts could not be restored or deleted
- Several design bugs
- Set cookies to be HTTP-only
- Tags: Sometimes, tags could not be dragged for reordering in the admin panel (#341)
- Suspend: Use correct column name in when migrating database
- Lock: Check for correct permission when displaying lock control
- Likes: Allow liking permissions to be configured
## 0.1.0-beta - 2015-08-27
First Version
[unreleased]: https://github.com/flarum/core/compare/v0.1.0-beta.2...HEAD
[0.1.0-beta.2]: https://github.com/flarum/core/compare/v0.1.0-beta...v0.1.0-beta.2

View File

@@ -26,13 +26,14 @@
"tobscure/json-api": "^0.1.1", "tobscure/json-api": "^0.1.1",
"oyejorge/less.php": "~1.5", "oyejorge/less.php": "~1.5",
"intervention/image": "^2.3.0", "intervention/image": "^2.3.0",
"s9e/text-formatter": "^0.1.0", "s9e/text-formatter": "^0.3.2",
"psr/http-message": "^1.0", "psr/http-message": "^1.0",
"zendframework/zend-diactoros": "^1.1", "zendframework/zend-diactoros": "^1.1",
"nikic/fast-route": "^0.6", "nikic/fast-route": "^0.6",
"dflydev/fig-cookies": "^1.0", "dflydev/fig-cookies": "^1.0",
"symfony/console": "^2.7", "symfony/console": "^2.7",
"symfony/yaml": "^2.7" "symfony/yaml": "^2.7",
"doctrine/dbal": "^2.5"
}, },
"require-dev": { "require-dev": {
"squizlabs/php_codesniffer": "2.*", "squizlabs/php_codesniffer": "2.*",

852
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@ export default class AppearancePage extends Component {
{Button.component({ {Button.component({
className: 'Button Button--primary', className: 'Button Button--primary',
type: 'submit',
children: 'Save Changes', children: 'Save Changes',
loading: this.loading loading: this.loading
})} })}

View File

@@ -30,6 +30,7 @@ export default class EditCustomCssModal extends Modal {
<div className="Form-group"> <div className="Form-group">
{Button.component({ {Button.component({
className: 'Button Button--primary', className: 'Button Button--primary',
type: 'submit',
children: 'Save Changes', children: 'Save Changes',
loading: this.loading loading: this.loading
})} })}

View File

@@ -4,6 +4,7 @@ import Search from 'flarum/components/Search';
import Composer from 'flarum/components/Composer'; import Composer from 'flarum/components/Composer';
import ReplyComposer from 'flarum/components/ReplyComposer'; import ReplyComposer from 'flarum/components/ReplyComposer';
import DiscussionPage from 'flarum/components/DiscussionPage'; import DiscussionPage from 'flarum/components/DiscussionPage';
import SignUpModal from 'flarum/components/SignUpModal';
export default class ForumApp extends App { export default class ForumApp extends App {
constructor(...args) { constructor(...args) {
@@ -76,4 +77,27 @@ export default class ForumApp extends App {
return this.current instanceof DiscussionPage && return this.current instanceof DiscussionPage &&
this.current.discussion === discussion; this.current.discussion === discussion;
} }
/**
* Callback for when an external authenticator (social login) action has
* completed.
*
* If the payload indicates that the user has been logged in, then the page
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
* with the provided details.
*
* @param {Object} payload A dictionary of props to pass into the sign up
* modal. A truthy `authenticated` prop indicates that the user has logged
* in, and thus the page is reloaded.
* @public
*/
authenticationComplete(payload) {
if (payload.authenticated) {
window.location.reload();
} else {
const modal = new SignUpModal(payload);
this.modal.show(modal);
modal.$('[name=password]').focus();
}
}
} }

View File

@@ -1,66 +0,0 @@
import Component from 'flarum/Component';
import humanTime from 'flarum/helpers/humanTime';
import avatar from 'flarum/helpers/avatar';
/**
* The `Activity` component represents a piece of activity of a user's activity
* feed. Subclasses should implement the `description` and `content` methods.
*
* ### Props
*
* - `activity`
*
* @abstract
*/
export default class Activity extends Component {
view() {
const activity = this.props.activity;
return (
<div className="Activity">
{avatar(this.user(), {className: 'Activity-avatar'})}
<div className="Activity-header">
<strong className="Activity-description">{this.description()}</strong>
{humanTime(this.time())}
</div>
{this.content()}
</div>
);
}
/**
* Get the user whose avatar should be displayed.
*
* @return {User}
* @abstract
*/
user() {
}
/**
* Get the time of the activity.
*
* @return {Date}
* @abstract
*/
time() {
}
/**
* Get the description of the activity.
*
* @return {VirtualElement}
*/
description() {
}
/**
* Get the content to show below the activity description.
*
* @return {VirtualElement}
*/
content() {
}
}

View File

@@ -0,0 +1,30 @@
import Button from 'flarum/components/Button';
/**
* The `LogInButton` component displays a social login button which will open
* a popup window containing the specified path.
*
* ### Props
*
* - `path`
*/
export default class LogInButton extends Button {
static initProps(props) {
props.className = (props.className || '') + ' LogInButton';
props.onclick = function() {
const width = 620;
const height = 400;
const $window = $(window);
window.open(app.forum.attribute('baseUrl') + props.path, 'logInPopup',
`width=${width},` +
`height=${height},` +
`top=${$window.height() / 2 - height / 2},` +
`left=${$window.width() / 2 - width / 2},` +
'status=no,scrollbars=no,resizable=no');
};
super.initProps(props);
}
}

View File

@@ -0,0 +1,25 @@
import Component from 'flarum/Component';
import ItemList from 'flarum/utils/ItemList';
/**
* The `LogInButtons` component displays a collection of social login buttons.
*/
export default class LogInButtons extends Component {
view() {
return (
<div className="LogInButtons">
{this.items().toArray()}
</div>
);
}
/**
* Build a list of LogInButton components.
*
* @return {ItemList}
* @public
*/
items() {
return new ItemList();
}
}

View File

@@ -3,6 +3,7 @@ import ForgotPasswordModal from 'flarum/components/ForgotPasswordModal';
import SignUpModal from 'flarum/components/SignUpModal'; import SignUpModal from 'flarum/components/SignUpModal';
import Alert from 'flarum/components/Alert'; import Alert from 'flarum/components/Alert';
import Button from 'flarum/components/Button'; import Button from 'flarum/components/Button';
import LogInButtons from 'flarum/components/LogInButtons';
/** /**
* The `LogInModal` component displays a modal dialog with a login form. * The `LogInModal` component displays a modal dialog with a login form.
@@ -42,6 +43,8 @@ export default class LogInModal extends Modal {
content() { content() {
return [ return [
<div className="Modal-body"> <div className="Modal-body">
<LogInButtons/>
<div className="Form Form--centered"> <div className="Form Form--centered">
<div className="Form-group"> <div className="Form-group">
<input className="FormControl" name="email" placeholder={app.trans('core.username_or_email')} <input className="FormControl" name="email" placeholder={app.trans('core.username_or_email')}
@@ -71,6 +74,7 @@ export default class LogInModal extends Modal {
<p className="LogInModal-forgotPassword"> <p className="LogInModal-forgotPassword">
<a onclick={this.forgotPassword.bind(this)}>{app.trans('core.forgot_password_link')}</a> <a onclick={this.forgotPassword.bind(this)}>{app.trans('core.forgot_password_link')}</a>
</p> </p>
{app.forum.attribute('allowSignUp') ? ( {app.forum.attribute('allowSignUp') ? (
<p className="LogInModal-signUp"> <p className="LogInModal-signUp">
{app.trans('core.before_sign_up_link')}{' '} {app.trans('core.before_sign_up_link')}{' '}
@@ -84,6 +88,8 @@ export default class LogInModal extends Modal {
/** /**
* Open the forgot password modal, prefilling it with an email if the user has * Open the forgot password modal, prefilling it with an email if the user has
* entered one. * entered one.
*
* @public
*/ */
forgotPassword() { forgotPassword() {
const email = this.email(); const email = this.email();
@@ -95,6 +101,8 @@ export default class LogInModal extends Modal {
/** /**
* Open the sign up modal, prefilling it with an email/username/password if * Open the sign up modal, prefilling it with an email/username/password if
* the user has entered one. * the user has entered one.
*
* @public
*/ */
signUp() { signUp() {
const props = {password: this.password()}; const props = {password: this.password()};

View File

@@ -2,6 +2,7 @@ import Modal from 'flarum/components/Modal';
import LogInModal from 'flarum/components/LogInModal'; import LogInModal from 'flarum/components/LogInModal';
import avatar from 'flarum/helpers/avatar'; import avatar from 'flarum/helpers/avatar';
import Button from 'flarum/components/Button'; import Button from 'flarum/components/Button';
import LogInButtons from 'flarum/components/LogInButtons';
/** /**
* The `SignUpModal` component displays a modal dialog with a singup form. * The `SignUpModal` component displays a modal dialog with a singup form.
@@ -11,6 +12,7 @@ import Button from 'flarum/components/Button';
* - `username` * - `username`
* - `email` * - `email`
* - `password` * - `password`
* - `token` An email token to sign up with.
*/ */
export default class SignUpModal extends Modal { export default class SignUpModal extends Modal {
constructor(...args) { constructor(...args) {
@@ -65,7 +67,9 @@ export default class SignUpModal extends Modal {
} }
body() { body() {
const body = [( const body = [
this.props.token ? '' : <LogInButtons/>,
<div className="Form Form--centered"> <div className="Form Form--centered">
<div className="Form-group"> <div className="Form-group">
<input className="FormControl" name="username" placeholder={app.trans('core.username')} <input className="FormControl" name="username" placeholder={app.trans('core.username')}
@@ -78,26 +82,28 @@ export default class SignUpModal extends Modal {
<input className="FormControl" name="email" type="email" placeholder={app.trans('core.email')} <input className="FormControl" name="email" type="email" placeholder={app.trans('core.email')}
value={this.email()} value={this.email()}
onchange={m.withAttr('value', this.email)} onchange={m.withAttr('value', this.email)}
disabled={this.loading} /> disabled={this.loading || this.props.token} />
</div> </div>
{this.props.token ? '' : (
<div className="Form-group"> <div className="Form-group">
<input className="FormControl" name="password" type="password" placeholder={app.trans('core.password')} <input className="FormControl" name="password" type="password" placeholder={app.trans('core.password')}
value={this.password()} value={this.password()}
onchange={m.withAttr('value', this.password)} onchange={m.withAttr('value', this.password)}
disabled={this.loading} /> disabled={this.loading} />
</div> </div>
)}
<div className="Form-group"> <div className="Form-group">
{Button.component({ <Button
className: 'Button Button--primary Button--block', className="Button Button--primary Button--block"
type: 'submit', type="submit"
loading: this.loading, loading={this.loading}>
children: app.trans('core.sign_up') {app.trans('core.sign_up')}
})} </Button>
</div> </div>
</div> </div>
)]; ];
if (this.welcomeUser) { if (this.welcomeUser) {
const user = this.welcomeUser; const user = this.welcomeUser;
@@ -115,20 +121,12 @@ export default class SignUpModal extends Modal {
{avatar(user)} {avatar(user)}
<h3>{app.trans('core.welcome_user', {user})}</h3> <h3>{app.trans('core.welcome_user', {user})}</h3>
{!user.isActivated() ? [
<p>{app.trans('core.confirmation_email_sent', {email: <strong>{user.email()}</strong>})}</p>, <p>{app.trans('core.confirmation_email_sent', {email: <strong>{user.email()}</strong>})}</p>,
<p> <p>
<a href={`http://${emailProviderName}`} className="Button Button--primary" target="_blank"> <a href={`http://${emailProviderName}`} className="Button Button--primary" target="_blank">
{app.trans('core.go_to', {location: emailProviderName})} {app.trans('core.go_to', {location: emailProviderName})}
</a> </a>
</p> </p>
] : (
<p>
<button className="Button Button--primary" onclick={this.hide.bind(this)}>
{app.trans('core.dismiss')}
</button>
</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -150,6 +148,8 @@ export default class SignUpModal extends Modal {
/** /**
* Open the log in modal, prefilling it with an email/username/password if * Open the log in modal, prefilling it with an email/username/password if
* the user has entered one. * the user has entered one.
*
* @public
*/ */
logIn() { logIn() {
const props = { const props = {
@@ -161,7 +161,7 @@ export default class SignUpModal extends Modal {
} }
onready() { onready() {
if (this.props.username) { if (this.props.username && !this.props.token) {
this.$('[name=email]').select(); this.$('[name=email]').select();
} else { } else {
super.onready(); super.onready();
@@ -175,24 +175,50 @@ export default class SignUpModal extends Modal {
const data = this.submitData(); const data = this.submitData();
app.store.createRecord('users').save(data).then( app.request({
user => { url: app.forum.attribute('baseUrl') + '/register',
method: 'POST',
data
}).then(
payload => {
const user = app.store.pushPayload(payload);
// If the user's new account has been activated, then we can assume
// that they have been logged in too. Thus, we will reload the page.
// Otherwise, we will show a message asking them to check their email.
if (user.isActivated()) {
window.location.reload();
} else {
this.welcomeUser = user; this.welcomeUser = user;
this.loading = false; this.loading = false;
m.redraw(); m.redraw();
}
}, },
response => { response => {
this.loading = false; this.loading = false;
this.handleErrors(response.errors); this.handleErrors(response);
} }
); );
} }
/**
* Get the data that should be submitted in the sign-up request.
*
* @return {Object}
* @public
*/
submitData() { submitData() {
return { const data = {
username: this.username(), username: this.username(),
email: this.email(), email: this.email()
password: this.password()
}; };
if (this.props.token) {
data.token = this.props.token;
} else {
data.password = this.password();
}
return data;
} }
} }

View File

@@ -16,6 +16,7 @@ export default class Session {
* The token that was used for authentication. * The token that was used for authentication.
* *
* @type {String|null} * @type {String|null}
* @public
*/ */
this.token = token; this.token = token;
} }
@@ -26,6 +27,7 @@ export default class Session {
* @param {String} identification The username/email. * @param {String} identification The username/email.
* @param {String} password * @param {String} password
* @return {Promise} * @return {Promise}
* @public
*/ */
login(identification, password) { login(identification, password) {
return app.request({ return app.request({
@@ -38,6 +40,8 @@ export default class Session {
/** /**
* Log the user out. * Log the user out.
*
* @public
*/ */
logout() { logout() {
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.token; window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.token;
@@ -48,8 +52,11 @@ export default class Session {
* XMLHttpRequest object. * XMLHttpRequest object.
* *
* @param {XMLHttpRequest} xhr * @param {XMLHttpRequest} xhr
* @public
*/ */
authorize(xhr) { authorize(xhr) {
if (this.token) {
xhr.setRequestHeader('Authorization', 'Token ' + this.token); xhr.setRequestHeader('Authorization', 'Token ' + this.token);
} }
} }
}

View File

@@ -25,7 +25,8 @@ export default class Button extends Component {
delete attrs.children; delete attrs.children;
attrs.className = (attrs.className || ''); attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button';
const iconName = extract(attrs, 'icon'); const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon'; if (iconName) attrs.className += ' hasIcon';

View File

@@ -129,7 +129,7 @@ export default class Modal extends Component {
m.redraw(); m.redraw();
if (errors) { if (errors) {
this.$('form [name=' + errors[0].path + ']').select(); this.$('form [name=' + errors[0].source.pointer.replace('/data/attributes/', '') + ']').select();
} else { } else {
this.$('form :input:first').select(); this.$('form :input:first').select();
} }

View File

@@ -45,7 +45,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
.read & { .read & {
color: mix(@heading-color, @body-bg, 60%); color: mix(@heading-color, @body-bg, 55%);
} }
.unread & { .unread & {
font-weight: 600; font-weight: 600;
@@ -59,6 +59,12 @@
> li { > li {
display: inline; display: inline;
opacity: 0.7;
.transition(opacity 0.2s);
.DiscussionListItem:hover &, .DiscussionListItem.active & {
opacity: 1;
}
} }
.username { .username {
font-weight: bold; font-weight: bold;

View File

@@ -0,0 +1,11 @@
.LogInButton {
&:extend(.Button--block);
}
.LogInButtons {
width: 200px;
margin: 0 auto 20px;
.LogInButton {
margin-bottom: 5px;
}
}

View File

@@ -139,7 +139,7 @@
font-size: 100%; font-size: 100%;
font-weight: bold; font-weight: bold;
} }
img { img, iframe {
max-width: 100%; max-width: 100%;
} }
} }

View File

@@ -126,6 +126,9 @@
& .icon { & .icon {
font-size: 14px; font-size: 14px;
} }
&.online .fa-circle {
color: @online-user-circle-color;
}
&.online .icon { &.online .icon {
font-size: 12px; font-size: 12px;
} }

View File

@@ -10,6 +10,7 @@
@import "EditUserModal.less"; @import "EditUserModal.less";
@import "Hero.less"; @import "Hero.less";
@import "IndexPage.less"; @import "IndexPage.less";
@import "LogInButton.less";
@import "LogInModal.less"; @import "LogInModal.less";
@import "NotificationGrid.less"; @import "NotificationGrid.less";
@import "NotificationList.less"; @import "NotificationList.less";

View File

@@ -27,8 +27,7 @@
&[disabled], &[disabled],
&[readonly], &[readonly],
fieldset[disabled] & { fieldset[disabled] & {
// background-color: @input-bg-disabled; opacity: 0.5;
opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655
} }
&[disabled], &[disabled],

View File

@@ -27,7 +27,7 @@
@text-color: #111; @text-color: #111;
@link-color: saturate(@primary-color, 10%); @link-color: saturate(@primary-color, 10%);
@heading-color: @text-color; @heading-color: @text-color;
@muted-color: hsl(@secondary-hue, min(25%, @secondary-sat), 60%); @muted-color: hsl(@secondary-hue, min(25%, @secondary-sat), 55%);
@muted-more-color: #aaa; @muted-more-color: #aaa;
@shadow-color: rgba(0, 0, 0, 0.35); @shadow-color: rgba(0, 0, 0, 0.35);
@@ -130,3 +130,5 @@
@tooltip-bg: rgba(0, 0, 0, 0.9); @tooltip-bg: rgba(0, 0, 0, 0.9);
@tooltip-color: #fff; @tooltip-color: #fff;
@online-user-circle-color: #7FBA00;

View File

@@ -28,7 +28,6 @@ core:
discussion_started: "Started {ago} by {username}" discussion_started: "Started {ago} by {username}"
discussion_title: Discussion Title discussion_title: Discussion Title
discussions: Discussions discussions: Discussions
dismiss: Dismiss
edit: Edit edit: Edit
editing_post: "Post #{number} in {discussion}" editing_post: "Post #{number} in {discussion}"
email: Email email: Email

View File

@@ -23,8 +23,8 @@ class CreateEmailTokensTable extends Migration
{ {
$this->schema->create('email_tokens', function (Blueprint $table) { $this->schema->create('email_tokens', function (Blueprint $table) {
$table->string('id', 100)->primary(); $table->string('id', 100)->primary();
$table->integer('user_id')->unsigned();
$table->string('email', 150); $table->string('email', 150);
$table->integer('user_id')->unsigned();
$table->timestamp('created_at'); $table->timestamp('created_at');
}); });
} }

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use Flarum\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class MakeEmailTokensUserIdColumnNullable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$this->schema->table('email_tokens', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->nullable()->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$this->schema->table('email_tokens', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->change();
});
}
}

View File

@@ -15,36 +15,32 @@ use Illuminate\Contracts\Container\Container;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface; use Zend\Stratigility\MiddlewareInterface;
use Flarum\Forum\Middleware\LoginWithCookie;
use Flarum\Core\Exceptions\PermissionDeniedException;
class LoginWithCookieAndCheckAdmin implements MiddlewareInterface class LoginWithCookieAndCheckAdmin extends LoginWithCookie
{ {
/**
* @var Container
*/
protected $app;
/**
* @param Container $app
*/
public function __construct(Container $app)
{
$this->app = $app;
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function __invoke(Request $request, Response $response, callable $out = null) public function __invoke(Request $request, Response $response, callable $out = null)
{ {
if (($token = array_get($request->getCookieParams(), 'flarum_remember')) && if (! $this->logIn($request)) {
($accessToken = AccessToken::valid($token)) && throw new PermissionDeniedException;
$accessToken->user->isAdmin()
) {
$this->app->instance('flarum.actor', $accessToken->user);
} else {
die('Access Denied');
} }
return $out ? $out($request, $response) : $response; return $out ? $out($request, $response) : $response;
} }
/**
* {@inheritdoc}
*/
protected function getToken(Request $request)
{
$token = parent::getToken($request);
if ($token && $token->user && $token->user->isAdmin()) {
return $token;
}
}
} }

View File

@@ -12,6 +12,7 @@ namespace Flarum\Api;
use Flarum\Core\Model; use Flarum\Core\Model;
use DateTime; use DateTime;
use Exception;
/** /**
* @todo document database columns with @property * @todo document database columns with @property
@@ -55,14 +56,13 @@ class AccessToken extends Model
} }
/** /**
* Get the given token only if it is valid. * Check that the token has not expired.
* *
* @param string $token * @return bool
* @return static|null
*/ */
public static function valid($token) public function isValid()
{ {
return static::where('id', $token)->where('expires_at', '>', new DateTime)->first(); return $this->expires_at > new DateTime;
} }
/** /**

View File

@@ -13,7 +13,7 @@ namespace Flarum\Api\Actions;
use Flarum\Api\Request; use Flarum\Api\Request;
use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\EmptyResponse;
abstract class DeleteAction extends JsonApiAction abstract class DeleteAction implements Action
{ {
/** /**
* Delegate deletion of the resource, and return a 204 No Content * Delegate deletion of the resource, and return a 204 No Content
@@ -22,7 +22,7 @@ abstract class DeleteAction extends JsonApiAction
* @param \Flarum\Api\Request $request * @param \Flarum\Api\Request $request
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
*/ */
public function respond(Request $request) public function handle(Request $request)
{ {
$this->delete($request); $this->delete($request);

View File

@@ -10,13 +10,13 @@
namespace Flarum\Api\Actions\Extensions; namespace Flarum\Api\Actions\Extensions;
use Flarum\Api\Actions\JsonApiAction; use Flarum\Api\Actions\Action;
use Flarum\Api\Request; use Flarum\Api\Request;
use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Contracts\Bus\Dispatcher;
use Flarum\Core\Exceptions\PermissionDeniedException; use Flarum\Core\Exceptions\PermissionDeniedException;
use Flarum\Support\ExtensionManager; use Flarum\Support\ExtensionManager;
class UpdateAction extends JsonApiAction class UpdateAction implements Action
{ {
protected $extensions; protected $extensions;
@@ -25,7 +25,7 @@ class UpdateAction extends JsonApiAction
$this->extensions = $extensions; $this->extensions = $extensions;
} }
protected function respond(Request $request) public function handle(Request $request)
{ {
if (! $request->actor->isAdmin()) { if (! $request->actor->isAdmin()) {
throw new PermissionDeniedException; throw new PermissionDeniedException;

View File

@@ -16,7 +16,7 @@ use Flarum\Core\Users\Commands\RequestPasswordReset;
use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Contracts\Bus\Dispatcher;
use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\EmptyResponse;
class ForgotAction extends JsonApiAction class ForgotAction implements Action
{ {
protected $users; protected $users;
@@ -34,7 +34,7 @@ class ForgotAction extends JsonApiAction
* @param \Flarum\Api\Request $request * @param \Flarum\Api\Request $request
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
*/ */
public function respond(Request $request) public function handle(Request $request)
{ {
$email = $request->get('email'); $email = $request->get('email');

View File

@@ -1,63 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api\Actions;
use Flarum\Api\Request;
use Illuminate\Contracts\Validation\ValidationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Flarum\Core\Exceptions\ValidationFailureException;
use Flarum\Core\Exceptions\PermissionDeniedException;
use Zend\Diactoros\Response\JsonResponse;
abstract class JsonApiAction implements Action
{
/**
* Handle an API request and return an API response, handling any relevant
* (API-related) exceptions that are thrown.
*
* @param Request $request
* @return \Psr\Http\Message\ResponseInterface
*/
public function handle(Request $request)
{
// TODO: This is gross. Move this error handling code to middleware?
try {
return $this->respond($request);
} catch (ValidationException $e) {
$errors = [];
foreach ($e->errors()->toArray() as $field => $messages) {
$errors[] = [
'detail' => implode("\n", $messages),
'path' => $field
];
}
return new JsonResponse(['errors' => $errors], 422);
} catch (\Flarum\Core\Exceptions\ValidationException $e) {
$errors = [];
foreach ($e->getMessages() as $path => $detail) {
$errors[] = compact('path', 'detail');
}
return new JsonResponse(['errors' => $errors], 422);
} catch (PermissionDeniedException $e) {
return new JsonResponse(null, 401);
} catch (ModelNotFoundException $e) {
return new JsonResponse(null, 404);
}
}
/**
* Handle an API request and return an API response.
*
* @param Request $request
* @return \Psr\Http\Message\ResponseInterface
*/
abstract protected function respond(Request $request);
}

View File

@@ -19,7 +19,7 @@ use Tobscure\JsonApi\Document;
use Tobscure\JsonApi\SerializerInterface; use Tobscure\JsonApi\SerializerInterface;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;
abstract class SerializeAction extends JsonApiAction abstract class SerializeAction implements Action
{ {
/** /**
* The name of the serializer class to output results with. * The name of the serializer class to output results with.
@@ -77,7 +77,7 @@ abstract class SerializeAction extends JsonApiAction
* @param Request $request * @param Request $request
* @return JsonResponse * @return JsonResponse
*/ */
public function respond(Request $request) public function handle(Request $request)
{ {
$request = $this->buildJsonApiRequest($request); $request = $this->buildJsonApiRequest($request);
$document = new Document(); $document = new Document();

View File

@@ -18,7 +18,7 @@ use Flarum\Events\UserEmailChangeWasRequested;
use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Contracts\Bus\Dispatcher;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;
class TokenAction extends JsonApiAction class TokenAction implements Action
{ {
protected $users; protected $users;
@@ -37,7 +37,7 @@ class TokenAction extends JsonApiAction
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
* @throws PermissionDeniedException * @throws PermissionDeniedException
*/ */
public function respond(Request $request) public function handle(Request $request)
{ {
$identification = $request->get('identification'); $identification = $request->get('identification');
$password = $request->get('password'); $password = $request->get('password');

View File

@@ -73,8 +73,12 @@ class CreateAction extends BaseCreateAction
*/ */
protected function create(JsonApiRequest $request) protected function create(JsonApiRequest $request)
{ {
return $this->bus->dispatch( $user = $this->bus->dispatch(
new RegisterUser($request->actor, $request->get('data')) new RegisterUser($request->actor, $request->get('data'))
); );
$request->actor = $user;
return $user;
} }
} }

View File

@@ -12,6 +12,8 @@ namespace Flarum\Api;
use Flarum\Core\Users\User; use Flarum\Core\Users\User;
use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Container\Container;
use Exception;
use Flarum\Api\Middleware\JsonApiErrors;
class Client class Client
{ {
@@ -38,10 +40,16 @@ class Client
*/ */
public function send(User $actor, $actionClass, array $input = []) public function send(User $actor, $actionClass, array $input = [])
{ {
/** @var \Flarum\Api\Actions\JsonApiAction $action */ /** @var \Flarum\Api\Actions\Action $action */
$action = $this->container->make($actionClass); $action = $this->container->make($actionClass);
try {
$response = $action->handle(new Request($input, $actor)); $response = $action->handle(new Request($input, $actor));
} catch (Exception $e) {
$middleware = new JsonApiErrors();
$response = $middleware->handle($e);
}
return new Response($response); return new Response($response);
} }

View File

@@ -10,35 +10,65 @@
namespace Flarum\Api\Middleware; namespace Flarum\Api\Middleware;
use Flarum\Core\Exceptions\JsonApiSerializable;
use Illuminate\Contracts\Validation\ValidationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;
use Zend\Stratigility\ErrorMiddlewareInterface; use Zend\Stratigility\ErrorMiddlewareInterface;
use Flarum\Core;
use Exception;
class JsonApiErrors implements ErrorMiddlewareInterface class JsonApiErrors implements ErrorMiddlewareInterface
{ {
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function __invoke($error, Request $request, Response $response, callable $out = null) public function __invoke($e, Request $request, Response $response, callable $out = null)
{ {
$errorObject = [ return $this->handle($e);
'title' => $error->getMessage(), }
];
public function handle(Exception $e)
{
if ($e instanceof JsonApiSerializable) {
$status = $e->getStatusCode();
$errors = $e->getErrors();
} else if ($e instanceof ValidationException) {
$status = 422;
$errors = $e->errors()->toArray();
$errors = array_map(function ($field, $messages) {
return [
'detail' => implode("\n", $messages),
'source' => ['pointer' => '/data/attributes/' . $field],
];
}, array_keys($errors), $errors);
} else if ($e instanceof ModelNotFoundException) {
$status = 404;
$errors = [];
} else {
$status = 500; $status = 500;
// If it seems to be a valid HTTP status code, we pass on the $error = [
// exception's status. 'code' => $status,
$errorCode = $error->getCode(); 'title' => 'Internal Server Error'
if (is_int($errorCode) && $errorCode >= 400 && $errorCode < 600) { ];
$status = $errorCode;
if (Core::inDebugMode()) {
$error['detail'] = (string) $e;
}
$errors = [$error];
} }
// JSON API errors must be collected in an array under the // JSON API errors must be collected in an array under the
// "errors" key in the top level of the document // "errors" key in the top level of the document
$data = [ $data = [
'errors' => [$errorObject] 'errors' => $errors,
]; ];
return new JsonResponse($data, $status); return new JsonResponse($data, $status);

View File

@@ -50,7 +50,7 @@ class LoginWithHeader implements MiddlewareInterface
if (isset($parts[0]) && starts_with($parts[0], $this->prefix)) { if (isset($parts[0]) && starts_with($parts[0], $this->prefix)) {
$token = substr($parts[0], strlen($this->prefix)); $token = substr($parts[0], strlen($this->prefix));
if ($accessToken = AccessToken::valid($token)) { if (($accessToken = AccessToken::find($token)) && $accessToken->isValid()) {
$this->app->instance('flarum.actor', $user = $accessToken->user); $this->app->instance('flarum.actor', $user = $accessToken->user);
$user->updateLastSeen()->save(); $user->updateLastSeen()->save();

View File

@@ -27,7 +27,7 @@ class Application extends Container implements ApplicationContract
* *
* @var string * @var string
*/ */
const VERSION = '0.1.0-beta'; const VERSION = '0.1.0-beta.2';
/** /**
* The base path for the Laravel installation. * The base path for the Laravel installation.

View File

@@ -12,6 +12,21 @@ namespace Flarum\Core\Exceptions;
use Exception; use Exception;
class InvalidConfirmationTokenException extends Exception class InvalidConfirmationTokenException extends Exception implements JsonApiSerializable
{ {
/**
* {@inheritdoc}
*/
public function getStatusCode()
{
return 403;
}
/**
* {@inheritdoc}
*/
public function getErrors()
{
return ['code' => 'invalid_confirmation_token'];
}
} }

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Core\Exceptions;
interface JsonApiSerializable
{
/**
* Return the HTTP status code to be used for this exception.
*
* @return int
*/
public function getStatusCode();
/**
* Return an array of errors, formatted as JSON-API error objects.
*
* @see http://jsonapi.org/format/#error-objects
* @return array
*/
public function getErrors();
}

View File

@@ -12,6 +12,26 @@ namespace Flarum\Core\Exceptions;
use Exception; use Exception;
class PermissionDeniedException extends Exception class PermissionDeniedException extends Exception implements JsonApiSerializable
{ {
/**
* Return the HTTP status code to be used for this exception.
*
* @return int
*/
public function getStatusCode()
{
return 401;
}
/**
* Return an array of errors, formatted as JSON-API error objects.
*
* @see http://jsonapi.org/format/#error-objects
* @return array
*/
public function getErrors()
{
return [];
}
} }

View File

@@ -12,17 +12,39 @@ namespace Flarum\Core\Exceptions;
use Exception; use Exception;
class ValidationException extends Exception class ValidationException extends Exception implements JsonApiSerializable
{ {
protected $messages; protected $messages;
public function __construct(array $messages) public function __construct(array $messages)
{ {
$this->messages = $messages; $this->messages = $messages;
parent::__construct(implode("\n", $messages));
} }
public function getMessages() public function getMessages()
{ {
return $this->messages; return $this->messages;
} }
/**
* {@inheritdoc}
*/
public function getStatusCode()
{
return 422;
}
/**
* {@inheritdoc}
*/
public function getErrors()
{
return array_map(function ($path, $detail) {
$source = ['pointer' => '/data/attributes/' . $path];
return compact('source', 'detail');
}, array_keys($this->messages), $this->messages);
}
} }

View File

@@ -10,14 +10,8 @@
namespace Flarum\Core; namespace Flarum\Core;
use Flarum\Core\Exceptions\PermissionDeniedException;
use Flarum\Core\Users\User;
use Flarum\Events\ModelAllow;
use Flarum\Events\ModelDates; use Flarum\Events\ModelDates;
use Flarum\Events\ModelRelationship; use Flarum\Events\ModelRelationship;
use Flarum\Events\ScopeModelVisibility;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model as Eloquent; use Illuminate\Database\Eloquent\Model as Eloquent;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use LogicException; use LogicException;

View File

@@ -13,7 +13,6 @@ namespace Flarum\Core\Users\Commands;
use Flarum\Core\Users\UserRepository; use Flarum\Core\Users\UserRepository;
use Flarum\Events\UserWillBeSaved; use Flarum\Events\UserWillBeSaved;
use Flarum\Core\Support\DispatchesEvents; use Flarum\Core\Support\DispatchesEvents;
use Flarum\Core\Exceptions\InvalidConfirmationTokenException;
use Flarum\Core\Users\EmailToken; use Flarum\Core\Users\EmailToken;
use DateTime; use DateTime;
@@ -36,16 +35,14 @@ class ConfirmEmailHandler
/** /**
* @param ConfirmEmail $command * @param ConfirmEmail $command
* @return \Flarum\Core\Users\User *
* @throws InvalidConfirmationTokenException * @throws InvalidConfirmationTokenException
*
* @return \Flarum\Core\Users\User
*/ */
public function handle(ConfirmEmail $command) public function handle(ConfirmEmail $command)
{ {
$token = EmailToken::find($command->token); $token = EmailToken::validOrFail($command->token);
if (! $token || $token->created_at < new DateTime('-1 day')) {
throw new InvalidConfirmationTokenException;
}
$user = $token->user; $user = $token->user;
$user->changeEmail($token->email); $user->changeEmail($token->email);

View File

@@ -11,17 +11,25 @@
namespace Flarum\Core\Users\Commands; namespace Flarum\Core\Users\Commands;
use Flarum\Core\Users\User; use Flarum\Core\Users\User;
use Flarum\Core\Users\EmailToken;
use Flarum\Events\UserWillBeSaved; use Flarum\Events\UserWillBeSaved;
use Flarum\Core\Support\DispatchesEvents; use Flarum\Core\Support\DispatchesEvents;
use Flarum\Core\Settings\SettingsRepository; use Flarum\Core\Settings\SettingsRepository;
use Flarum\Core\Exceptions\PermissionDeniedException; use Flarum\Core\Exceptions\PermissionDeniedException;
use DateTime;
class RegisterUserHandler class RegisterUserHandler
{ {
use DispatchesEvents; use DispatchesEvents;
/**
* @var SettingsRepository
*/
protected $settings; protected $settings;
/**
* @param SettingsRepository $settings
*/
public function __construct(SettingsRepository $settings) public function __construct(SettingsRepository $settings)
{ {
$this->settings = $settings; $this->settings = $settings;
@@ -29,26 +37,57 @@ class RegisterUserHandler
/** /**
* @param RegisterUser $command * @param RegisterUser $command
*
* @throws PermissionDeniedException if signup is closed and the actor is
* not an administrator.
* @throws \Flarum\Core\Exceptions\InvalidConfirmationTokenException if an
* email confirmation token is provided but is invalid.
*
* @return User * @return User
*/ */
public function handle(RegisterUser $command) public function handle(RegisterUser $command)
{ {
if (! $this->settings->get('allow_sign_up')) {
throw new PermissionDeniedException;
}
$actor = $command->actor; $actor = $command->actor;
$data = $command->data; $data = $command->data;
if (! $this->settings->get('allow_sign_up') && ! $actor->isAdmin()) {
throw new PermissionDeniedException;
}
// If a valid email confirmation token was provided as an attribute,
// then we can create a random password for this user and consider their
// email address confirmed.
if (isset($data['attributes']['token'])) {
$token = EmailToken::whereNull('user_id')->validOrFail($data['attributes']['token']);
$email = $token->email;
$password = array_get($data, 'attributes.password', str_random(20));
} else {
$email = array_get($data, 'attributes.email');
$password = array_get($data, 'attributes.password');
}
// Create the user's new account. If their email was set via token, then
// we can activate their account from the get-go, and they won't need
// to confirm their email address.
$user = User::register( $user = User::register(
array_get($data, 'attributes.username'), array_get($data, 'attributes.username'),
array_get($data, 'attributes.email'), $email,
array_get($data, 'attributes.password') $password
); );
if (isset($token)) {
$user->activate();
}
event(new UserWillBeSaved($user, $actor, $data)); event(new UserWillBeSaved($user, $actor, $data));
$user->save(); $user->save();
if (isset($token)) {
$token->delete();
}
$this->dispatchEventsFor($user); $this->dispatchEventsFor($user);
return $user; return $user;

View File

@@ -11,6 +11,8 @@
namespace Flarum\Core\Users; namespace Flarum\Core\Users;
use Flarum\Core\Model; use Flarum\Core\Model;
use Flarum\Core\Exceptions\InvalidConfirmationTokenException;
use DateTime;
/** /**
* @todo document database columns with @property * @todo document database columns with @property
@@ -37,11 +39,12 @@ class EmailToken extends Model
/** /**
* Generate an email token for the specified user. * Generate an email token for the specified user.
* *
* @param int $userId
* @param string $email * @param string $email
* @param int $userId
*
* @return static * @return static
*/ */
public static function generate($userId, $email) public static function generate($email, $userId = null)
{ {
$token = new static; $token = new static;
@@ -62,4 +65,25 @@ class EmailToken extends Model
{ {
return $this->belongsTo('Flarum\Core\Users\User'); return $this->belongsTo('Flarum\Core\Users\User');
} }
/**
* Find the token with the given ID, and assert that it has not expired.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $id
*
* @throws InvalidConfirmationTokenException
*
* @return static
*/
public function scopeValidOrFail($query, $id)
{
$token = $query->find($id);
if (! $token || $token->created_at < new DateTime('-1 day')) {
throw new InvalidConfirmationTokenException;
}
return $token;
}
} }

View File

@@ -57,6 +57,11 @@ class EmailConfirmationMailer
public function whenUserWasRegistered(UserWasRegistered $event) public function whenUserWasRegistered(UserWasRegistered $event)
{ {
$user = $event->user; $user = $event->user;
if ($user->is_activated) {
return;
}
$data = $this->getEmailData($user, $user->email); $data = $this->getEmailData($user, $user->email);
$this->mailer->send(['text' => 'flarum::emails.activateAccount'], $data, function (Message $message) use ($user) { $this->mailer->send(['text' => 'flarum::emails.activateAccount'], $data, function (Message $message) use ($user) {
@@ -82,11 +87,12 @@ class EmailConfirmationMailer
/** /**
* @param User $user * @param User $user
* @param string $email * @param string $email
*
* @return EmailToken * @return EmailToken
*/ */
protected function generateToken(User $user, $email) protected function generateToken(User $user, $email)
{ {
$token = EmailToken::generate($user->id, $email); $token = EmailToken::generate($email, $user->id);
$token->save(); $token->save();
return $token; return $token;
@@ -97,6 +103,7 @@ class EmailConfirmationMailer
* *
* @param User $user * @param User $user
* @param string $email * @param string $email
*
* @return array * @return array
*/ */
protected function getEmailData(User $user, $email) protected function getEmailData(User $user, $email)

View File

@@ -56,7 +56,6 @@ class ClientAction extends BaseClientAction
'core.discussion_started', 'core.discussion_started',
'core.discussion_title', 'core.discussion_title',
'core.discussions', 'core.discussions',
'core.dismiss',
'core.edit', 'core.edit',
'core.editing_post', 'core.editing_post',
'core.email', 'core.email',

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Forum\Actions;
use Flarum\Core\Users\User;
use Zend\Diactoros\Response\HtmlResponse;
use Flarum\Api\Commands\GenerateAccessToken;
use Flarum\Core\Users\EmailToken;
trait ExternalAuthenticatorTrait
{
use WritesRememberCookie;
/**
* @var \Illuminate\Contracts\Bus\Dispatcher
*/
protected $bus;
/**
* Respond with JavaScript to tell the Flarum app that the user has been
* authenticated, or with information about their sign up status.
*
* @param string $email The email of the user's account.
* @param string $username A suggested username for the user's account.
* @return HtmlResponse
*/
protected function authenticated($email, $username)
{
$user = User::where('email', $email)->first();
// If a user with this email address doesn't already exist, then we will
// generate a unique confirmation token for this email address and add
// it to the response, along with the email address and a suggested
// username. Otherwise, we will log in the existing user by generating
// an access token.
if (! $user) {
$token = EmailToken::generate($email);
$token->save();
$payload = compact('email', 'username');
$payload['token'] = $token->id;
} else {
$accessToken = $this->bus->dispatch(new GenerateAccessToken($user->id));
$payload = ['authenticated' => true];
}
$content = sprintf('<script>
window.opener.app.authenticationComplete(%s);
window.close();
</script>', json_encode($payload));
$response = new HtmlResponse($content);
if (isset($accessToken)) {
$response = $this->withRememberCookie($response, $accessToken->id);
}
return $response;
}
}

View File

@@ -47,7 +47,7 @@ class LoginAction extends Action
/** /**
* @param Request $request * @param Request $request
* @param array $routeParams * @param array $routeParams
* @return \Psr\Http\Message\ResponseInterface|EmptyResponse * @return JsonResponse|EmptyResponse
*/ */
public function handle(Request $request, array $routeParams = []) public function handle(Request $request, array $routeParams = [])
{ {

View File

@@ -0,0 +1,80 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Forum\Actions;
use Flarum\Api\Client;
use Flarum\Api\AccessToken;
use Flarum\Events\UserLoggedIn;
use Flarum\Support\Action;
use Flarum\Api\Commands\GenerateAccessToken;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use Illuminate\Contracts\Bus\Dispatcher;
use DateTime;
class RegisterAction extends Action
{
use WritesRememberCookie;
/**
* @var Dispatcher
*/
protected $bus;
/**
* @var Client
*/
protected $apiClient;
/**
* @param Dispatcher $bus
* @param Client $apiClient
*/
public function __construct(Dispatcher $bus, Client $apiClient)
{
$this->bus = $bus;
$this->apiClient = $apiClient;
}
/**
* @param Request $request
* @param array $routeParams
*
* @return JsonResponse
*/
public function handle(Request $request, array $routeParams = [])
{
$params = ['data' => ['attributes' => $request->getAttributes()]];
$apiResponse = $this->apiClient->send(app('flarum.actor'), 'Flarum\Api\Actions\Users\CreateAction', $params);
$body = $apiResponse->getBody();
$statusCode = $apiResponse->getStatusCode();
$response = new JsonResponse($body, $statusCode);
if (! empty($body->data->attributes->isActivated)) {
$token = $this->bus->dispatch(new GenerateAccessToken($body->data->id));
// Extend the token's expiry to 2 weeks so that we can set a
// remember cookie
AccessToken::where('id', $token->id)->update(['expires_at' => new DateTime('+2 weeks')]);
return $this->withRememberCookie(
$response,
$token->id
);
}
return $response;
}
}

View File

@@ -24,6 +24,7 @@ trait WritesRememberCookie
SetCookie::create('flarum_remember', $token) SetCookie::create('flarum_remember', $token)
->withMaxAge(14 * 24 * 60 * 60) ->withMaxAge(14 * 24 * 60 * 60)
->withPath('/') ->withPath('/')
->withHttpOnly(true)
); );
} }
@@ -35,6 +36,7 @@ trait WritesRememberCookie
SetCookie::create('flarum_remember') SetCookie::create('flarum_remember')
->withMaxAge(-2628000) ->withMaxAge(-2628000)
->withPath('/') ->withPath('/')
->withHttpOnly(true)
); );
} }
} }

View File

@@ -102,6 +102,12 @@ class ForumServiceProvider extends ServiceProvider
$this->action('Flarum\Forum\Actions\LoginAction') $this->action('Flarum\Forum\Actions\LoginAction')
); );
$routes->post(
'/register',
'flarum.forum.register',
$this->action('Flarum\Forum\Actions\RegisterAction')
);
$routes->get( $routes->get(
'/confirm/{token}', '/confirm/{token}',
'flarum.forum.confirmEmail', 'flarum.forum.confirmEmail',

View File

@@ -36,14 +36,46 @@ class LoginWithCookie implements MiddlewareInterface
*/ */
public function __invoke(Request $request, Response $response, callable $out = null) public function __invoke(Request $request, Response $response, callable $out = null)
{ {
if (($token = array_get($request->getCookieParams(), 'flarum_remember')) && $this->logIn($request);
($accessToken = AccessToken::valid($token))
) {
$this->app->instance('flarum.actor', $user = $accessToken->user);
$user->updateLastSeen()->save();
}
return $out ? $out($request, $response) : $response; return $out ? $out($request, $response) : $response;
} }
/**
* Set the application's actor instance according to the request token.
*
* @param Request $request
* @return bool
*/
protected function logIn(Request $request)
{
if ($token = $this->getToken($request)) {
if (! $token->isValid()) {
// TODO: https://github.com/flarum/core/issues/253
} elseif ($token->user) {
$this->app->instance('flarum.actor', $user = $token->user);
$user->updateLastSeen()->save();
return true;
}
}
return false;
}
/**
* Get the access token referred to by the request cookie.
*
* @param Request $request
* @return AccessToken|null
*/
protected function getToken(Request $request)
{
$token = array_get($request->getCookieParams(), 'flarum_remember');
if ($token) {
return AccessToken::find($token);
}
}
} }

View File

@@ -11,12 +11,14 @@
namespace Flarum\Install\Console; namespace Flarum\Install\Console;
use Flarum\Console\Command; use Flarum\Console\Command;
use Flarum\Core\Exceptions\ValidationException;
use Flarum\Core\Model; use Flarum\Core\Model;
use Flarum\Core\Users\User; use Flarum\Core\Users\User;
use Flarum\Core\Groups\Group; use Flarum\Core\Groups\Group;
use Flarum\Core\Groups\Permission; use Flarum\Core\Groups\Permission;
use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Validation\Factory;
use PDO; use PDO;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
@@ -100,6 +102,43 @@ class InstallCommand extends Command
protected function install() protected function install()
{ {
try { try {
$this->dbConfig = $this->dataSource->getDatabaseConfiguration();
$validation = $this->getValidator()->make(
$this->dbConfig,
[
'driver' => 'required|in:mysql',
'host' => 'required',
'database' => 'required|alpha_dash',
'username' => 'required|alpha_dash',
'prefix' => 'alpha_dash|max:10'
]
);
if ($validation->fails()) {
throw new Exception(implode("\n", call_user_func_array('array_merge', $validation->getMessageBag()->toArray())));
}
$this->baseUrl = $this->dataSource->getBaseUrl();
$this->settings = $this->dataSource->getSettings();
$this->adminUser = $admin = $this->dataSource->getAdminUser();
if (strlen($admin['password']) < 8) {
throw new Exception('Password must be at least 8 characters.');
}
if ($admin['password'] !== $admin['password_confirmation']) {
throw new Exception('The password did not match its confirmation.');
}
if (! filter_var($admin['email'], FILTER_VALIDATE_EMAIL)) {
throw new Exception('You must enter a valid email.');
}
if (! $admin['username'] || preg_match('/[^a-z0-9_-]/i', $admin['username'])) {
throw new Exception('Username can only contain letters, numbers, underscores, and dashes.');
}
$this->storeConfiguration(); $this->storeConfiguration();
$this->runMigrations(); $this->runMigrations();
@@ -127,7 +166,7 @@ class InstallCommand extends Command
protected function storeConfiguration() protected function storeConfiguration()
{ {
$dbConfig = $this->dataSource->getDatabaseConfiguration(); $dbConfig = $this->dbConfig;
$config = [ $config = [
'debug' => true, 'debug' => true,
@@ -142,7 +181,7 @@ class InstallCommand extends Command
'prefix' => $dbConfig['prefix'], 'prefix' => $dbConfig['prefix'],
'strict' => false 'strict' => false
], ],
'url' => $this->dataSource->getBaseUrl(), 'url' => $this->baseUrl,
'paths' => [ 'paths' => [
'api' => 'api', 'api' => 'api',
'admin' => 'admin', 'admin' => 'admin',
@@ -186,12 +225,11 @@ class InstallCommand extends Command
protected function writeSettings() protected function writeSettings()
{ {
$data = $this->dataSource->getSettings();
$settings = $this->application->make('Flarum\Core\Settings\SettingsRepository'); $settings = $this->application->make('Flarum\Core\Settings\SettingsRepository');
$this->info('Writing default settings'); $this->info('Writing default settings');
foreach ($data as $k => $v) { foreach ($this->settings as $k => $v) {
$settings->set($k, $v); $settings->set($k, $v);
} }
} }
@@ -246,7 +284,7 @@ class InstallCommand extends Command
protected function createAdminUser() protected function createAdminUser()
{ {
$admin = $this->dataSource->getAdminUser(); $admin = $this->adminUser;
if ($admin['password'] !== $admin['password_confirmation']) { if ($admin['password'] !== $admin['password_confirmation']) {
throw new Exception('The password did not match its confirmation.'); throw new Exception('The password did not match its confirmation.');
@@ -254,11 +292,13 @@ class InstallCommand extends Command
$this->info('Creating admin user '.$admin['username']); $this->info('Creating admin user '.$admin['username']);
User::unguard(); $user = User::register(
$admin['username'],
$admin['email'],
$admin['password']
);
$user = new User(Arr::except($admin, 'password_confirmation'));
$user->is_activated = 1; $user->is_activated = 1;
$user->join_time = time();
$user->save(); $user->save();
$user->groups()->sync([1]); $user->groups()->sync([1]);
@@ -300,6 +340,14 @@ class InstallCommand extends Command
return $this->application->make('Flarum\Install\Prerequisites\Prerequisite'); return $this->application->make('Flarum\Install\Prerequisites\Prerequisite');
} }
/**
* @return \Illuminate\Contracts\Validation\Factory
*/
protected function getValidator()
{
return new Factory($this->application->make('Symfony\Component\Translation\TranslatorInterface'));
}
protected function showErrors($errors) protected function showErrors($errors)
{ {
foreach ($errors as $error) { foreach ($errors as $error) {

View File

@@ -162,7 +162,7 @@
<body> <body>
<div class="container"> <div class="container">
<h1> <h1>
<?php include 'logo.svg'; ?> <?php echo file_get_contents(__DIR__ . '/logo.svg'); ?>
</h1> </h1>
<div class="animated fadeIn"> <div class="animated fadeIn">