From faf74150e9481ad9338eb6cc2428d02b24e9fc43 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sun, 6 Oct 2019 21:03:20 +0200 Subject: [PATCH 01/17] Mailer: Use users locale for translation --- .../TranslationServiceProvider.php | 6 +- src/Mail/EngelsystemMailer.php | 59 +++++++++- .../TranslationServiceProviderTest.php | 18 +-- tests/Unit/Mail/EngelsystemMailerTest.php | 107 ++++++++++++------ tests/Unit/TestCase.php | 4 +- 5 files changed, 141 insertions(+), 53 deletions(-) diff --git a/src/Helpers/Translation/TranslationServiceProvider.php b/src/Helpers/Translation/TranslationServiceProvider.php index 09337dad..05e782ec 100644 --- a/src/Helpers/Translation/TranslationServiceProvider.php +++ b/src/Helpers/Translation/TranslationServiceProvider.php @@ -40,8 +40,10 @@ class TranslationServiceProvider extends ServiceProvider 'localeChangeCallback' => [$this, 'setLocale'], ] ); - $this->app->instance(Translator::class, $translator); - $this->app->instance('translator', $translator); + $this->app->singleton(Translator::class, function () use ($translator) { + return $translator; + }); + $this->app->alias(Translator::class, 'translator'); } /** diff --git a/src/Mail/EngelsystemMailer.php b/src/Mail/EngelsystemMailer.php index 81660681..87915d67 100644 --- a/src/Mail/EngelsystemMailer.php +++ b/src/Mail/EngelsystemMailer.php @@ -2,6 +2,8 @@ namespace Engelsystem\Mail; +use Engelsystem\Helpers\Translation\Translator; +use Engelsystem\Models\User\User; use Engelsystem\Renderer\Renderer; use Swift_Mailer as SwiftMailer; @@ -10,30 +12,75 @@ class EngelsystemMailer extends Mailer /** @var Renderer|null */ protected $view; + /** @var Translator|null */ + protected $translation; + /** @var string */ protected $subjectPrefix = null; /** * @param SwiftMailer $mailer * @param Renderer $view + * @param Translator $translation */ - public function __construct(SwiftMailer $mailer, Renderer $view = null) + public function __construct(SwiftMailer $mailer, Renderer $view = null, Translator $translation = null) { parent::__construct($mailer); + $this->translation = $translation; $this->view = $view; } + /** + * @param string|string[]|User $to + * @param string $subject + * @param string $template + * @param array $data + * @param string|null $locale + * @return int + */ + public function sendViewTranslated( + $to, + string $subject, + string $template, + array $data = [], + ?string $locale = null + ): int { + if ($to instanceof User) { + $locale = $locale ?: $to->settings->language; + $to = $to->contact->email ? $to->contact->email : $to->email; + } + + $activeLocale = null; + if ( + $locale + && $this->translation + && isset($this->translation->getLocales()[$locale]) + ) { + $activeLocale = $this->translation->getLocale(); + $this->translation->setLocale($locale); + } + + $subject = $this->translation ? $this->translation->translate($subject) : $subject; + $sentMails = $this->sendView($to, $subject, $template, $data); + + if ($activeLocale) { + $this->translation->setLocale($activeLocale); + } + + return $sentMails; + } + /** * Send a template * - * @param string $to - * @param string $subject - * @param string $template - * @param array $data + * @param string|string[] $to + * @param string $subject + * @param string $template + * @param array $data * @return int */ - public function sendView($to, $subject, $template, $data = []): int + public function sendView($to, string $subject, string $template, array $data = []): int { $body = $this->view->render($template, $data); diff --git a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php index 91307bdd..1822f353 100644 --- a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php +++ b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php @@ -21,7 +21,7 @@ class TranslationServiceProviderTest extends ServiceProviderTest $locales = ['fo_OO' => 'Foo', 'fo_OO.BAR' => 'Foo (Bar)', 'te_ST.WTF-9' => 'WTF\'s Testing?']; $config = new Config(['locales' => $locales, 'default_locale' => $defaultLocale]); - $app = $this->getApp(['make', 'instance', 'get']); + $app = $this->getApp(['make', 'singleton', 'alias', 'get']); /** @var Session|MockObject $session */ $session = $this->createMock(Session::class); /** @var Translator|MockObject $translator */ @@ -60,12 +60,16 @@ class TranslationServiceProviderTest extends ServiceProviderTest ) ->willReturn($translator); - $app->expects($this->exactly(2)) - ->method('instance') - ->withConsecutive( - [Translator::class, $translator], - ['translator', $translator] - ); + $app->expects($this->once()) + ->method('singleton') + ->willReturnCallback(function (string $abstract, callable $callback) use ($translator) { + $this->assertEquals(Translator::class, $abstract); + $this->assertEquals($translator, $callback()); + }); + + $app->expects($this->once()) + ->method('alias') + ->with(Translator::class, 'translator'); $serviceProvider->register(); } diff --git a/tests/Unit/Mail/EngelsystemMailerTest.php b/tests/Unit/Mail/EngelsystemMailerTest.php index 12dc3b0b..cdbdf435 100644 --- a/tests/Unit/Mail/EngelsystemMailerTest.php +++ b/tests/Unit/Mail/EngelsystemMailerTest.php @@ -2,15 +2,22 @@ namespace Engelsystem\Test\Unit\Mail; +use Engelsystem\Helpers\Translation\Translator; use Engelsystem\Mail\EngelsystemMailer; +use Engelsystem\Models\User\Contact; +use Engelsystem\Models\User\Settings; +use Engelsystem\Models\User\User; use Engelsystem\Renderer\Renderer; +use Engelsystem\Test\Unit\HasDatabase; +use Engelsystem\Test\Unit\TestCase; use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; use Swift_Mailer as SwiftMailer; use Swift_Message as SwiftMessage; class EngelsystemMailerTest extends TestCase { + use HasDatabase; + /** * @covers \Engelsystem\Mail\EngelsystemMailer::__construct * @covers \Engelsystem\Mail\EngelsystemMailer::sendView @@ -24,21 +31,69 @@ class EngelsystemMailerTest extends TestCase /** @var EngelsystemMailer|MockObject $mailer */ $mailer = $this->getMockBuilder(EngelsystemMailer::class) ->setConstructorArgs(['mailer' => $swiftMailer, 'view' => $view]) - ->setMethods(['send']) + ->onlyMethods(['send']) ->getMock(); - $mailer->expects($this->once()) - ->method('send') - ->with('foo@bar.baz', 'Lorem dolor', 'Rendered Stuff!') - ->willReturn(1); - $view->expects($this->once()) - ->method('render') - ->with('test/template.tpl', ['dev' => true]) - ->willReturn('Rendered Stuff!'); + $this->setExpects($mailer, 'send', ['foo@bar.baz', 'Lorem dolor', 'Rendered Stuff!'], 1); + $this->setExpects($view, 'render', ['test/template.tpl', ['dev' => true]], 'Rendered Stuff!'); $return = $mailer->sendView('foo@bar.baz', 'Lorem dolor', 'test/template.tpl', ['dev' => true]); $this->equalTo(1, $return); } + /** + * @covers \Engelsystem\Mail\EngelsystemMailer::sendViewTranslated + */ + public function testSendViewTranslated() + { + $this->initDatabase(); + + $settings = new Settings([ + 'language' => 'de_DE', + 'theme' => '', + ]); + $contact = new Contact(['email' => null]); + $user = new User([ + 'id' => 42, + 'name' => 'username', + 'email' => 'foo@bar.baz', + 'password' => '', + 'api_key' => '', + ]); + $user->save(); + $settings->user()->associate($user)->save(); + $contact->user()->associate($user)->save(); + + /** @var Renderer|MockObject $view */ + $view = $this->createMock(Renderer::class); + /** @var SwiftMailer|MockObject $swiftMailer */ + $swiftMailer = $this->createMock(SwiftMailer::class); + /** @var Translator|MockObject $translator */ + $translator = $this->createMock(Translator::class); + + /** @var EngelsystemMailer|MockObject $mailer */ + $mailer = $this->getMockBuilder(EngelsystemMailer::class) + ->setConstructorArgs(['mailer' => $swiftMailer, 'view' => $view, 'translation' => $translator]) + ->onlyMethods(['sendView']) + ->getMock(); + + $this->setExpects($mailer, 'sendView', ['foo@bar.baz', 'Lorem dolor', 'test/template.tpl', ['dev' => true]], 1); + $this->setExpects($translator, 'getLocales', null, ['de_DE' => 'de_DE', 'en_US' => 'en_US']); + $this->setExpects($translator, 'getLocale', null, 'en_US'); + $this->setExpects($translator, 'translate', ['translatable.text'], 'Lorem dolor'); + $translator->expects($this->exactly(2)) + ->method('setLocale') + ->withConsecutive(['de_DE'], ['en_US']); + + $return = $mailer->sendViewTranslated( + $user, + 'translatable.text', + 'test/template.tpl', + ['dev' => true], + 'de_DE' + ); + $this->equalTo(1, $return); + } + /** * @covers \Engelsystem\Mail\EngelsystemMailer::getSubjectPrefix * @covers \Engelsystem\Mail\EngelsystemMailer::send @@ -50,32 +105,12 @@ class EngelsystemMailerTest extends TestCase $message = $this->createMock(SwiftMessage::class); /** @var SwiftMailer|MockObject $swiftMailer */ $swiftMailer = $this->createMock(SwiftMailer::class); - $swiftMailer->expects($this->once()) - ->method('createMessage') - ->willReturn($message); - $swiftMailer->expects($this->once()) - ->method('send') - ->willReturn(1); - - $message->expects($this->once()) - ->method('setTo') - ->with(['to@xam.pel']) - ->willReturn($message); - - $message->expects($this->once()) - ->method('setFrom') - ->with('foo@bar.baz', 'Lorem Ipsum') - ->willReturn($message); - - $message->expects($this->once()) - ->method('setSubject') - ->with('[Mail test] Foo Bar') - ->willReturn($message); - - $message->expects($this->once()) - ->method('setBody') - ->with('Lorem Ipsum!') - ->willReturn($message); + $this->setExpects($swiftMailer, 'createMessage', null, $message); + $this->setExpects($swiftMailer, 'send', null, 1); + $this->setExpects($message, 'setTo', [['to@xam.pel']], $message); + $this->setExpects($message, 'setFrom', ['foo@bar.baz', 'Lorem Ipsum'], $message); + $this->setExpects($message, 'setSubject', ['[Mail test] Foo Bar'], $message); + $this->setExpects($message, 'setBody', ['Lorem Ipsum!'], $message); $mailer = new EngelsystemMailer($swiftMailer); $mailer->setFromAddress('foo@bar.baz'); diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index d09104d4..e1a704d9 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -2,8 +2,8 @@ namespace Engelsystem\Test\Unit; -use PHPUnit\Framework\MockObject\Matcher\InvokedRecorder; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Rule\InvocationOrder; use PHPUnit\Framework\TestCase as PHPUnitTestCase; abstract class TestCase extends PHPUnitTestCase @@ -13,7 +13,7 @@ abstract class TestCase extends PHPUnitTestCase * @param string $method * @param array $arguments * @param mixed $return - * @param InvokedRecorder $times + * @param InvocationOrder $times */ protected function setExpects($object, $method, $arguments = null, $return = null, $times = null) { From 8d090438b659b641dd0f6cbc99193f3b48b2fc4b Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Mon, 7 Oct 2019 21:59:40 +0200 Subject: [PATCH 02/17] Validation rules: min/max/between: Use string length to compare strings --- src/Http/Validation/Rules/Between.php | 10 +++++ src/Http/Validation/Rules/Max.php | 10 +++++ src/Http/Validation/Rules/Min.php | 10 +++++ .../Validation/Rules/StringInputLength.php | 44 +++++++++++++++++++ .../Http/Validation/Rules/BetweenTest.php | 28 ++++++++++++ tests/Unit/Http/Validation/Rules/MaxTest.php | 26 +++++++++++ tests/Unit/Http/Validation/Rules/MinTest.php | 26 +++++++++++ .../Rules/StringInputLengthTest.php | 37 ++++++++++++++++ .../Rules/Stub/ParentClassImplementation.php | 23 ++++++++++ .../Rules/Stub/UsesStringInputLength.php | 10 +++++ tests/Unit/Http/Validation/ValidatorTest.php | 5 ++- 11 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/Http/Validation/Rules/Between.php create mode 100644 src/Http/Validation/Rules/Max.php create mode 100644 src/Http/Validation/Rules/Min.php create mode 100644 src/Http/Validation/Rules/StringInputLength.php create mode 100644 tests/Unit/Http/Validation/Rules/BetweenTest.php create mode 100644 tests/Unit/Http/Validation/Rules/MaxTest.php create mode 100644 tests/Unit/Http/Validation/Rules/MinTest.php create mode 100644 tests/Unit/Http/Validation/Rules/StringInputLengthTest.php create mode 100644 tests/Unit/Http/Validation/Rules/Stub/ParentClassImplementation.php create mode 100644 tests/Unit/Http/Validation/Rules/Stub/UsesStringInputLength.php diff --git a/src/Http/Validation/Rules/Between.php b/src/Http/Validation/Rules/Between.php new file mode 100644 index 00000000..106a93ac --- /dev/null +++ b/src/Http/Validation/Rules/Between.php @@ -0,0 +1,10 @@ +isDateTime($input) + ) { + $input = Str::length($input); + } + + return parent::validate($input); + } + + /** + * @param mixed $input + * @return bool + */ + protected function isDateTime($input): bool + { + try { + new DateTime($input); + } catch (Throwable $e) { + return false; + } + + return true; + } +} diff --git a/tests/Unit/Http/Validation/Rules/BetweenTest.php b/tests/Unit/Http/Validation/Rules/BetweenTest.php new file mode 100644 index 00000000..130d2f93 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/BetweenTest.php @@ -0,0 +1,28 @@ +assertFalse($rule->validate(1)); + $this->assertFalse($rule->validate('11')); + $this->assertTrue($rule->validate(5)); + $this->assertFalse($rule->validate('AS')); + $this->assertFalse($rule->validate('TestContentThatCounts')); + $this->assertTrue($rule->validate('TESTING')); + + $rule = new Between('2042-01-01', '2042-10-10'); + $this->assertFalse($rule->validate('2000-01-01')); + $this->assertFalse($rule->validate('3000-01-01')); + $this->assertTrue($rule->validate('2042-05-11')); + } +} diff --git a/tests/Unit/Http/Validation/Rules/MaxTest.php b/tests/Unit/Http/Validation/Rules/MaxTest.php new file mode 100644 index 00000000..3f4d9516 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/MaxTest.php @@ -0,0 +1,26 @@ +assertFalse($rule->validate(10)); + $this->assertFalse($rule->validate('22')); + $this->assertTrue($rule->validate(3)); + $this->assertFalse($rule->validate('TEST')); + $this->assertTrue($rule->validate('AS')); + + $rule = new Max('2042-01-01'); + $this->assertFalse($rule->validate('2100-01-01')); + $this->assertTrue($rule->validate('2000-01-01')); + } +} diff --git a/tests/Unit/Http/Validation/Rules/MinTest.php b/tests/Unit/Http/Validation/Rules/MinTest.php new file mode 100644 index 00000000..56350802 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/MinTest.php @@ -0,0 +1,26 @@ +assertFalse($rule->validate(1)); + $this->assertFalse($rule->validate('2')); + $this->assertTrue($rule->validate(3)); + $this->assertFalse($rule->validate('AS')); + $this->assertTrue($rule->validate('TEST')); + + $rule = new Min('2042-01-01'); + $this->assertFalse($rule->validate('2000-01-01')); + $this->assertTrue($rule->validate('2345-01-01')); + } +} diff --git a/tests/Unit/Http/Validation/Rules/StringInputLengthTest.php b/tests/Unit/Http/Validation/Rules/StringInputLengthTest.php new file mode 100644 index 00000000..5c4dc512 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/StringInputLengthTest.php @@ -0,0 +1,37 @@ +validate($input); + + $this->assertEquals($expectedInput, $rule->lastInput); + } + + /** + * @return array[] + */ + public function validateProvider() + { + return [ + ['TEST', 4], + ['?', 1], + ['2042-01-01 00:00', '2042-01-01 00:00'], + ['3', '3'], + ]; + } +} diff --git a/tests/Unit/Http/Validation/Rules/Stub/ParentClassImplementation.php b/tests/Unit/Http/Validation/Rules/Stub/ParentClassImplementation.php new file mode 100644 index 00000000..1b6aaaf5 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/Stub/ParentClassImplementation.php @@ -0,0 +1,23 @@ +lastInput = $input; + + return $this->validateResult; + } +} diff --git a/tests/Unit/Http/Validation/Rules/Stub/UsesStringInputLength.php b/tests/Unit/Http/Validation/Rules/Stub/UsesStringInputLength.php new file mode 100644 index 00000000..3522304c --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/Stub/UsesStringInputLength.php @@ -0,0 +1,10 @@ +assertFalse($val->validate( - ['lorem' => 2], - ['lorem' => 'required|min:3|max:10'] + ['lorem' => 'OMG'], + ['lorem' => 'required|min:4|max:10'] )); + $this->assertEquals(['lorem' => ['validation.lorem.min']], $val->getErrors()); $this->assertFalse($val->validate( ['lorem' => 42], ['lorem' => 'required|min:3|max:10'] From 1b3e3b1d65126da909d0013c9c0a0cb3f41639c1 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Mon, 7 Oct 2019 22:02:28 +0200 Subject: [PATCH 03/17] Exceptions: Added HttpNotFound exception --- src/Http/Exceptions/HttpNotFound.php | 23 +++++++++++++++++++ .../Unit/Http/Exceptions/HttpNotFoundTest.php | 22 ++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/Http/Exceptions/HttpNotFound.php create mode 100644 tests/Unit/Http/Exceptions/HttpNotFoundTest.php diff --git a/src/Http/Exceptions/HttpNotFound.php b/src/Http/Exceptions/HttpNotFound.php new file mode 100644 index 00000000..324adaf9 --- /dev/null +++ b/src/Http/Exceptions/HttpNotFound.php @@ -0,0 +1,23 @@ +assertEquals(404, $exception->getStatusCode()); + $this->assertEquals('', $exception->getMessage()); + + $exception = new HttpNotFound('Nothing to see here!'); + $this->assertEquals('Nothing to see here!', $exception->getMessage()); + } +} From e124b41977504067f6e1b6b3bf2e39c9b1d31324 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 8 Oct 2019 13:57:50 +0200 Subject: [PATCH 04/17] Tests: TestCase: Initialize $this->app on every run --- tests/Unit/Controllers/AuthControllerTest.php | 2 +- tests/Unit/HasDatabase.php | 8 +++----- .../Http/SessionHandlers/DatabaseHandlerTest.php | 3 ++- tests/Unit/Models/EventConfigTest.php | 6 ++++-- tests/Unit/Models/LogEntryTest.php | 3 ++- tests/Unit/Models/User/HasUserModelTest.php | 3 ++- tests/Unit/Models/User/UserTest.php | 3 ++- tests/Unit/TestCase.php | 12 ++++++++++++ 8 files changed, 28 insertions(+), 12 deletions(-) diff --git a/tests/Unit/Controllers/AuthControllerTest.php b/tests/Unit/Controllers/AuthControllerTest.php index 6c237264..50ce014d 100644 --- a/tests/Unit/Controllers/AuthControllerTest.php +++ b/tests/Unit/Controllers/AuthControllerTest.php @@ -12,9 +12,9 @@ use Engelsystem\Http\Validation\Validator; use Engelsystem\Models\User\Settings; use Engelsystem\Models\User\User; use Engelsystem\Test\Unit\HasDatabase; +use Engelsystem\Test\Unit\TestCase; use Illuminate\Support\Collection; use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; diff --git a/tests/Unit/HasDatabase.php b/tests/Unit/HasDatabase.php index 7a58bb2b..dbaa253e 100644 --- a/tests/Unit/HasDatabase.php +++ b/tests/Unit/HasDatabase.php @@ -2,7 +2,6 @@ namespace Engelsystem\Test\Unit; -use Engelsystem\Application; use Engelsystem\Database\Database; use Engelsystem\Database\Migration\Migrate; use Engelsystem\Database\Migration\MigrationServiceProvider; @@ -27,12 +26,11 @@ trait HasDatabase $connection->getPdo()->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->database = new Database($connection); - $app = new Application(); - $app->instance(Database::class, $this->database); - $app->register(MigrationServiceProvider::class); + $this->app->instance(Database::class, $this->database); + $this->app->register(MigrationServiceProvider::class); /** @var Migrate $migration */ - $migration = $app->get('db.migration'); + $migration = $this->app->get('db.migration'); $migration->initMigration(); $this->database diff --git a/tests/Unit/Http/SessionHandlers/DatabaseHandlerTest.php b/tests/Unit/Http/SessionHandlers/DatabaseHandlerTest.php index 14f23c00..0325ccfe 100644 --- a/tests/Unit/Http/SessionHandlers/DatabaseHandlerTest.php +++ b/tests/Unit/Http/SessionHandlers/DatabaseHandlerTest.php @@ -4,7 +4,7 @@ namespace Engelsystem\Test\Unit\Http\SessionHandlers; use Engelsystem\Http\SessionHandlers\DatabaseHandler; use Engelsystem\Test\Unit\HasDatabase; -use PHPUnit\Framework\TestCase; +use Engelsystem\Test\Unit\TestCase; class DatabaseHandlerTest extends TestCase { @@ -90,6 +90,7 @@ class DatabaseHandlerTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/Models/EventConfigTest.php b/tests/Unit/Models/EventConfigTest.php index e2ab5d10..18d27007 100644 --- a/tests/Unit/Models/EventConfigTest.php +++ b/tests/Unit/Models/EventConfigTest.php @@ -5,7 +5,7 @@ namespace Engelsystem\Test\Unit\Models; use Carbon\Carbon; use Engelsystem\Models\EventConfig; use Engelsystem\Test\Unit\HasDatabase; -use PHPUnit\Framework\TestCase; +use Engelsystem\Test\Unit\TestCase; class EventConfigTest extends TestCase { @@ -102,7 +102,8 @@ class EventConfigTest extends TestCase */ protected function getEventConfig() { - return new class extends EventConfig { + return new class extends EventConfig + { /** * @param string $value * @param string $type @@ -122,6 +123,7 @@ class EventConfigTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/Models/LogEntryTest.php b/tests/Unit/Models/LogEntryTest.php index 0a0efa3c..4b772cd0 100644 --- a/tests/Unit/Models/LogEntryTest.php +++ b/tests/Unit/Models/LogEntryTest.php @@ -4,7 +4,7 @@ namespace Engelsystem\Test\Unit\Models; use Engelsystem\Models\LogEntry; use Engelsystem\Test\Unit\HasDatabase; -use PHPUnit\Framework\TestCase; +use Engelsystem\Test\Unit\TestCase; use Psr\Log\LogLevel; class LogEntryTest extends TestCase @@ -38,6 +38,7 @@ class LogEntryTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/Models/User/HasUserModelTest.php b/tests/Unit/Models/User/HasUserModelTest.php index 58c01e1e..4f6da9ad 100644 --- a/tests/Unit/Models/User/HasUserModelTest.php +++ b/tests/Unit/Models/User/HasUserModelTest.php @@ -5,8 +5,8 @@ namespace Engelsystem\Test\Unit\Models; use Engelsystem\Models\User\HasUserModel; use Engelsystem\Test\Unit\HasDatabase; use Engelsystem\Test\Unit\Models\User\Stub\HasUserModelImplementation; +use Engelsystem\Test\Unit\TestCase; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use PHPUnit\Framework\TestCase; class HasUserModelTest extends TestCase { @@ -28,6 +28,7 @@ class HasUserModelTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/Models/User/UserTest.php b/tests/Unit/Models/User/UserTest.php index 0e17d137..3e793832 100644 --- a/tests/Unit/Models/User/UserTest.php +++ b/tests/Unit/Models/User/UserTest.php @@ -10,7 +10,7 @@ use Engelsystem\Models\User\Settings; use Engelsystem\Models\User\State; use Engelsystem\Models\User\User; use Engelsystem\Test\Unit\HasDatabase; -use PHPUnit\Framework\TestCase; +use Engelsystem\Test\Unit\TestCase; class UserTest extends TestCase { @@ -95,6 +95,7 @@ class UserTest extends TestCase */ protected function setUp(): void { + parent::setUp(); $this->initDatabase(); } } diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index e1a704d9..dba8c989 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -2,12 +2,16 @@ namespace Engelsystem\Test\Unit; +use Engelsystem\Application; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Rule\InvocationOrder; use PHPUnit\Framework\TestCase as PHPUnitTestCase; abstract class TestCase extends PHPUnitTestCase { + /** @var Application */ + protected $app; + /** * @param MockObject $object * @param string $method @@ -34,4 +38,12 @@ abstract class TestCase extends PHPUnitTestCase $invocation->willReturn($return); } } + + /** + * Called before each test run + */ + protected function setUp(): void + { + $this->app = new Application(__DIR__ . '/../../'); + } } From ae0816ce8de46c38cd6f4b6d95abb17a6703d280 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 8 Oct 2019 14:07:28 +0200 Subject: [PATCH 05/17] Response: Fixed naming to use renderer instead of view and added setter --- src/Http/Response.php | 27 +++++++++++++++++++-------- tests/Unit/Http/ResponseTest.php | 12 ++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/Http/Response.php b/src/Http/Response.php index 1a7c8209..a6b4ab74 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -3,6 +3,7 @@ namespace Engelsystem\Http; use Engelsystem\Renderer\Renderer; +use InvalidArgumentException; use Psr\Http\Message\ResponseInterface; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -11,21 +12,21 @@ class Response extends SymfonyResponse implements ResponseInterface use MessageTrait; /** @var Renderer */ - protected $view; + protected $renderer; /** * @param string $content * @param int $status * @param array $headers - * @param Renderer $view + * @param Renderer $renderer */ public function __construct( $content = '', int $status = 200, array $headers = [], - Renderer $view = null + Renderer $renderer = null ) { - $this->view = $view; + $this->renderer = $renderer; parent::__construct($content, $status, $headers); } @@ -47,7 +48,7 @@ class Response extends SymfonyResponse implements ResponseInterface * provided status code; if none is provided, implementations MAY * use the defaults as suggested in the HTTP specification. * @return static - * @throws \InvalidArgumentException For invalid status code arguments. + * @throws InvalidArgumentException For invalid status code arguments. */ public function withStatus($code, $reasonPhrase = '') { @@ -107,12 +108,12 @@ class Response extends SymfonyResponse implements ResponseInterface */ public function withView($view, $data = [], $status = 200, $headers = []) { - if (!$this->view instanceof Renderer) { - throw new \InvalidArgumentException('Renderer not defined'); + if (!$this->renderer instanceof Renderer) { + throw new InvalidArgumentException('Renderer not defined'); } $new = clone $this; - $new->setContent($this->view->render($view, $data)); + $new->setContent($this->renderer->render($view, $data)); $new->setStatusCode($status, ($status == $this->getStatusCode() ? $this->statusText : null)); foreach ($headers as $key => $values) { @@ -144,4 +145,14 @@ class Response extends SymfonyResponse implements ResponseInterface return $response; } + + /** + * Set the renderer to use + * + * @param Renderer $renderer + */ + public function setRenderer(Renderer $renderer) + { + $this->renderer = $renderer; + } } diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index 34f76513..b8e6e527 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -55,6 +55,7 @@ class ResponseTest extends TestCase /** * @covers \Engelsystem\Http\Response::withView + * @covers \Engelsystem\Http\Response::setRenderer */ public function testWithView() { @@ -73,6 +74,17 @@ class ResponseTest extends TestCase $this->assertEquals('Foo ipsum!', $newResponse->getContent()); $this->assertEquals(505, $newResponse->getStatusCode()); $this->assertArraySubset(['test' => ['er']], $newResponse->getHeaders()); + + /** @var REnderer|MockObject $renderer */ + $anotherRenderer = $this->createMock(Renderer::class); + $anotherRenderer->expects($this->once()) + ->method('render') + ->with('bar') + ->willReturn('Stuff'); + + $response->setRenderer($anotherRenderer); + $response = $response->withView('bar'); + $this->assertEquals('Stuff', $response->getContent()); } /** From 810068dcf811b17805ee9958b877a827bf8125ed Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 8 Oct 2019 14:18:51 +0200 Subject: [PATCH 06/17] Fixes: AuthController session error merging and StatsTest --- src/Controllers/AuthController.php | 2 +- tests/Unit/Controllers/AuthControllerTest.php | 4 ++-- tests/Unit/Controllers/Metrics/StatsTest.php | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Controllers/AuthController.php b/src/Controllers/AuthController.php index c69c2377..7892064b 100644 --- a/src/Controllers/AuthController.php +++ b/src/Controllers/AuthController.php @@ -88,7 +88,7 @@ class AuthController extends BaseController $user = $this->auth->authenticate($data['login'], $data['password']); if (!$user instanceof User) { - $this->session->set('errors', $this->session->get('errors', []) + ['auth.not-found']); + $this->session->set('errors', array_merge($this->session->get('errors', []), ['auth.not-found'])); return $this->showLogin(); } diff --git a/tests/Unit/Controllers/AuthControllerTest.php b/tests/Unit/Controllers/AuthControllerTest.php index 50ce014d..a12ed6d6 100644 --- a/tests/Unit/Controllers/AuthControllerTest.php +++ b/tests/Unit/Controllers/AuthControllerTest.php @@ -13,7 +13,6 @@ use Engelsystem\Models\User\Settings; use Engelsystem\Models\User\User; use Engelsystem\Test\Unit\HasDatabase; use Engelsystem\Test\Unit\TestCase; -use Illuminate\Support\Collection; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -66,6 +65,7 @@ class AuthControllerTest extends TestCase $session = new Session(new MockArraySessionStorage()); /** @var Validator|MockObject $validator */ $validator = new Validator(); + $session->set('errors', [['bar' => 'some.bar.error']]); $user = new User([ 'name' => 'foo', @@ -89,7 +89,7 @@ class AuthControllerTest extends TestCase $response->expects($this->once()) ->method('withView') - ->with('pages/login', ['errors' => Collection::make(['auth.not-found'])]) + ->with('pages/login', ['errors' => collect(['some.bar.error', 'auth.not-found'])]) ->willReturn($response); $response->expects($this->once()) ->method('redirectTo') diff --git a/tests/Unit/Controllers/Metrics/StatsTest.php b/tests/Unit/Controllers/Metrics/StatsTest.php index fa78d8c3..9204f7db 100644 --- a/tests/Unit/Controllers/Metrics/StatsTest.php +++ b/tests/Unit/Controllers/Metrics/StatsTest.php @@ -155,8 +155,8 @@ class StatsTest extends TestCase $this->initDatabase(); $this->addUsers(); - (new PasswordReset(['use_id' => 1, 'token' => 'loremIpsum123']))->save(); - (new PasswordReset(['use_id' => 3, 'token' => '5omeR4nd0mTok3N']))->save(); + (new PasswordReset(['user_id' => 1, 'token' => 'loremIpsum123']))->save(); + (new PasswordReset(['user_id' => 3, 'token' => '5omeR4nd0mTok3N']))->save(); $stats = new Stats($this->database); $this->assertEquals(2, $stats->passwordResets()); From 8f8130634e40f6a24295b7bab449a43ed7c5aa80 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 8 Oct 2019 15:23:46 +0200 Subject: [PATCH 07/17] Show normal login page after registration, added msg() template function --- includes/pages/guest_login.php | 4 +- includes/view/User_view.php | 58 +++++-------------- resources/views/pages/login.twig | 1 + src/Renderer/Twig/Extensions/Legacy.php | 1 + .../Renderer/Twig/Extensions/LegacyTest.php | 1 + 5 files changed, 18 insertions(+), 47 deletions(-) diff --git a/includes/pages/guest_login.php b/includes/pages/guest_login.php index 280743e5..170572e4 100644 --- a/includes/pages/guest_login.php +++ b/includes/pages/guest_login.php @@ -242,9 +242,9 @@ function guest_register() redirect(page_link_to('register')); } - // If a welcome message is present, display registration success page. + // If a welcome message is present, display it on the next page if ($message = $config->get('welcome_msg')) { - return User_registration_success_view($message); + info((new Parsedown())->text($message)); } redirect(page_link_to('/')); diff --git a/includes/view/User_view.php b/includes/view/User_view.php index bfe7e02c..b38a5062 100644 --- a/includes/view/User_view.php +++ b/includes/view/User_view.php @@ -107,46 +107,6 @@ function User_settings_view( ]); } -/** - * Displays the welcome message to the user and shows a login form. - * - * @param string $event_welcome_message - * @return string - */ -function User_registration_success_view($event_welcome_message) -{ - $parsedown = new Parsedown(); - $event_welcome_message = $parsedown->text($event_welcome_message); - - return page_with_title(__('Registration successful'), [ - msg(), - div('row', [ - div('col-md-4', [ - $event_welcome_message - ]), - div('col-md-4', [ - '

' . __('Login') . '

', - form([ - form_text('login', __('Nick'), ''), - form_password('password', __('Password')), - form_submit('submit', __('Login')), - buttons([ - button(page_link_to('user_password_recovery'), __('I forgot my password')) - ]), - info(__('Please note: You have to activate cookies!'), true) - ], page_link_to('login')) - ]), - div('col-md-4', [ - '

' . __('What can I do?') . '

', - '

' . __('Please read about the jobs you can do to help us.') . '

', - buttons([ - button(page_link_to('angeltypes', ['action' => 'about']), __('Teams/Job description') . ' »') - ]) - ]) - ]) - ]); -} - /** * Gui for deleting user with password field. * @@ -255,13 +215,13 @@ function Users_view( ]; $user_table_headers = [ - 'name' => Users_table_header_link('name', __('Nick'), $order_by) + 'name' => Users_table_header_link('name', __('Nick'), $order_by) ]; - if(config('enable_user_name')) { + if (config('enable_user_name')) { $user_table_headers['first_name'] = Users_table_header_link('first_name', __('Prename'), $order_by); $user_table_headers['last_name'] = Users_table_header_link('last_name', __('Name'), $order_by); } - if(config('enable_dect')) { + if (config('enable_dect')) { $user_table_headers['dect'] = Users_table_header_link('dect', __('DECT'), $order_by); } $user_table_headers['arrived'] = Users_table_header_link('arrived', __('Arrived'), $order_by); @@ -271,8 +231,16 @@ function Users_view( $user_table_headers['force_active'] = Users_table_header_link('force_active', __('Forced'), $order_by); $user_table_headers['got_shirt'] = Users_table_header_link('got_shirt', __('T-Shirt'), $order_by); $user_table_headers['shirt_size'] = Users_table_header_link('shirt_size', __('Size'), $order_by); - $user_table_headers['arrival_date'] = Users_table_header_link('planned_arrival_date', __('Planned arrival'), $order_by); - $user_table_headers['departure_date'] = Users_table_header_link('planned_departure_date', __('Planned departure'), $order_by); + $user_table_headers['arrival_date'] = Users_table_header_link( + 'planned_arrival_date', + __('Planned arrival'), + $order_by + ); + $user_table_headers['departure_date'] = Users_table_header_link( + 'planned_departure_date', + __('Planned departure'), + $order_by + ); $user_table_headers['last_login_at'] = Users_table_header_link('last_login_at', __('Last login'), $order_by); $user_table_headers['actions'] = ''; diff --git a/resources/views/pages/login.twig b/resources/views/pages/login.twig index 88326429..6160508f 100644 --- a/resources/views/pages/login.twig +++ b/resources/views/pages/login.twig @@ -32,6 +32,7 @@
+ {{ msg() }} {% for message in errors|default([]) %} {{ m.alert(__(message), 'danger') }} {% endfor %} diff --git a/src/Renderer/Twig/Extensions/Legacy.php b/src/Renderer/Twig/Extensions/Legacy.php index 79de32cb..55c095fc 100644 --- a/src/Renderer/Twig/Extensions/Legacy.php +++ b/src/Renderer/Twig/Extensions/Legacy.php @@ -32,6 +32,7 @@ class Legacy extends TwigExtension new TwigFunction('menuUserHints', 'header_render_hints', $isSafeHtml), new TwigFunction('menuUserSubmenu', 'make_user_submenu', $isSafeHtml), new TwigFunction('page', [$this, 'getPage']), + new TwigFunction('msg', 'msg', $isSafeHtml), ]; } diff --git a/tests/Unit/Renderer/Twig/Extensions/LegacyTest.php b/tests/Unit/Renderer/Twig/Extensions/LegacyTest.php index b6c19d14..7190c979 100644 --- a/tests/Unit/Renderer/Twig/Extensions/LegacyTest.php +++ b/tests/Unit/Renderer/Twig/Extensions/LegacyTest.php @@ -26,6 +26,7 @@ class LegacyTest extends ExtensionTest $this->assertExtensionExists('menuUserHints', 'header_render_hints', $functions, $isSafeHtml); $this->assertExtensionExists('menuUserSubmenu', 'make_user_submenu', $functions, $isSafeHtml); $this->assertExtensionExists('page', [$extension, 'getPage'], $functions); + $this->assertExtensionExists('msg', 'msg', $functions, $isSafeHtml); } /** From dd0366296893a0e8da8ae0365387dd4823d53451 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 8 Oct 2019 16:17:06 +0200 Subject: [PATCH 08/17] Rebuild password reset --- config/routes.php | 6 + includes/controller/users_controller.php | 115 -------- includes/model/User_model.php | 19 -- includes/view/User_view.php | 35 --- resources/lang/de_DE/default.mo | Bin 46206 -> 46693 bytes resources/lang/de_DE/default.po | 22 +- resources/lang/en_US/default.mo | Bin 770 -> 1240 bytes resources/lang/en_US/default.po | 18 ++ resources/lang/pt_BR/default.mo | Bin 41129 -> 41132 bytes resources/lang/pt_BR/default.po | 2 +- resources/views/emails/mail.twig | 8 +- resources/views/emails/password-reset.twig | 3 + resources/views/macros/form.twig | 18 ++ resources/views/pages/login.twig | 2 +- .../views/pages/password/reset-form.twig | 18 ++ .../views/pages/password/reset-success.twig | 12 + resources/views/pages/password/reset.twig | 32 +++ src/Controllers/PasswordResetController.php | 167 +++++++++++ src/Middleware/LegacyMiddleware.php | 6 - .../PasswordResetControllerTest.php | 266 ++++++++++++++++++ 20 files changed, 566 insertions(+), 183 deletions(-) create mode 100644 resources/views/emails/password-reset.twig create mode 100644 resources/views/macros/form.twig create mode 100644 resources/views/pages/password/reset-form.twig create mode 100644 resources/views/pages/password/reset-success.twig create mode 100644 resources/views/pages/password/reset.twig create mode 100644 src/Controllers/PasswordResetController.php create mode 100644 tests/Unit/Controllers/PasswordResetControllerTest.php diff --git a/config/routes.php b/config/routes.php index 02fd3abd..e57d3079 100644 --- a/config/routes.php +++ b/config/routes.php @@ -13,6 +13,12 @@ $route->get('/login', 'AuthController@login'); $route->post('/login', 'AuthController@postLogin'); $route->get('/logout', 'AuthController@logout'); +// Password recovery +$route->get('/password/reset', 'PasswordResetController@reset'); +$route->post('/password/reset', 'PasswordResetController@postReset'); +$route->get('/password/reset/{token:.+}', 'PasswordResetController@resetPassword'); +$route->post('/password/reset/{token:.+}', 'PasswordResetController@postResetPassword'); + // Stats $route->get('/metrics', 'Metrics\\Controller@metrics'); $route->get('/stats', 'Metrics\\Controller@stats'); diff --git a/includes/controller/users_controller.php b/includes/controller/users_controller.php index 892089e7..3ad2ffd9 100644 --- a/includes/controller/users_controller.php +++ b/includes/controller/users_controller.php @@ -1,7 +1,6 @@ input('token'))->first(); - if (!$passwordReset) { - error(__('Token is not correct.')); - redirect(page_link_to('login')); - } - - if ($request->hasPostData('submit')) { - $valid = true; - - if ( - $request->has('password') - && strlen($request->postData('password')) >= config('min_password_length') - ) { - if ($request->postData('password') != $request->postData('password2')) { - $valid = false; - error(__('Your passwords don\'t match.')); - } - } else { - $valid = false; - error(__('Your password is to short (please use at least 6 characters).')); - } - - if ($valid) { - auth()->setPassword($passwordReset->user, $request->postData('password')); - success(__('Password saved.')); - $passwordReset->delete(); - redirect(page_link_to('login')); - } - } - - return User_password_set_view(); -} - -/** - * First step of password recovery: display a form that asks for your email and send email with recovery link - * - * @return string - */ -function user_password_recovery_start_controller() -{ - $request = request(); - if ($request->hasPostData('submit')) { - $valid = true; - - $user_source = null; - if ($request->has('email') && strlen(strip_request_item('email')) > 0) { - $email = strip_request_item('email'); - if (check_email($email)) { - /** @var User $user_source */ - $user_source = User::whereEmail($email)->first(); - if (!$user_source) { - $valid = false; - error(__('E-mail address is not correct.')); - } - } else { - $valid = false; - error(__('E-mail address is not correct.')); - } - } else { - $valid = false; - error(__('Please enter your e-mail.')); - } - - if ($valid) { - $token = User_generate_password_recovery_token($user_source); - engelsystem_email_to_user( - $user_source, - __('Password recovery'), - sprintf( - __('Please visit %s to recover your password.'), - page_link_to('user_password_recovery', ['token' => $token]) - ) - ); - success(__('We sent an email containing your password recovery link.')); - redirect(page_link_to('login')); - } - } - - return User_password_recovery_view(); -} - -/** - * User password recovery in 2 steps. - * (By email) - * - * @return string - */ -function user_password_recovery_controller() -{ - if (request()->has('token')) { - return user_password_recovery_set_new_controller(); - } - - return user_password_recovery_start_controller(); -} - -/** - * Menu title for password recovery. - * - * @return string - */ -function user_password_recovery_title() -{ - return __('Password recovery'); -} - /** * Loads a user from param user_id. * diff --git a/includes/model/User_model.php b/includes/model/User_model.php index 1994bc47..681e70aa 100644 --- a/includes/model/User_model.php +++ b/includes/model/User_model.php @@ -2,7 +2,6 @@ use Carbon\Carbon; use Engelsystem\Database\DB; -use Engelsystem\Models\User\PasswordReset; use Engelsystem\Models\User\User; use Engelsystem\ValidationResult; use Illuminate\Database\Query\JoinClause; @@ -227,24 +226,6 @@ function User_reset_api_key($user, $log = true) } } -/** - * Generates a new password recovery token for given user. - * - * @param User $user - * @return string - */ -function User_generate_password_recovery_token($user) -{ - $reset = PasswordReset::findOrNew($user->id); - $reset->user_id = $user->id; - $reset->token = md5($user->name . time() . rand()); - $reset->save(); - - engelsystem_log('Password recovery for ' . User_Nick_render($user, true) . ' started.'); - - return $reset->token; -} - /** * @param User $user * @return float diff --git a/includes/view/User_view.php b/includes/view/User_view.php index b38a5062..95ecb626 100644 --- a/includes/view/User_view.php +++ b/includes/view/User_view.php @@ -759,41 +759,6 @@ function User_view_state_admin($freeloader, $user_source) return $state; } -/** - * View for password recovery step 1: E-Mail - * - * @return string - */ -function User_password_recovery_view() -{ - return page_with_title(user_password_recovery_title(), [ - msg(), - __('We will send you an e-mail with a password recovery link. Please use the email address you used for registration.'), - form([ - form_text('email', __('E-Mail'), ''), - form_submit('submit', __('Recover')) - ]) - ]); -} - -/** - * View for password recovery step 2: New password - * - * @return string - */ -function User_password_set_view() -{ - return page_with_title(user_password_recovery_title(), [ - msg(), - __('Please enter a new password.'), - form([ - form_password('password', __('Password')), - form_password('password2', __('Confirm password')), - form_submit('submit', __('Save')) - ]) - ]); -} - /** * @param array[] $user_angeltypes * @return string diff --git a/resources/lang/de_DE/default.mo b/resources/lang/de_DE/default.mo index fb93d59098f243fc9f3d8f04dc65da27eb7ce8d5..d4b7885bdb60dad5663232db1d20a4ced7afa4b8 100644 GIT binary patch delta 10236 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 delta 9860 zcmXZi33yId9>?+fB9Rq|NFs;`B9TNSNMcWk68jokQDfhtv6kXhgV?v%zO{(m*wPk* z&M>vBmQjkSWm>9Kjb)f>%Y1)1$MbkT=YQ_GXZfFVZc;OMe=hR+ei8T0;9|=h{dkn(fSQ6jEN;nl`a3fa6GuB6_ z{(@2p$8$TiDfm;-0ZU;o^urvijuWi;=uP~U^$Kdh?@$wYgvIeUWd2UkT800!{W2lZ#p(c0^eentw!`m2zcTpMeu5I3r z#c<*_SQZDO7CIFp8Q)noq}cd!f�Z>(YQF+C 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 diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po index 1ff16c83..a2d56fd1 100644 --- a/resources/lang/en_US/default.po +++ b/resources/lang/en_US/default.po @@ -30,3 +30,21 @@ msgstr "The password is required." msgid "validation.login.required" msgstr "The login name is required." + +msgid "form.submit" +msgstr "Submit" + +msgid "validation.email.required" +msgstr "The email address is required." + +msgid "validation.email.email" +msgstr "This email address is not valid." + +msgid "validation.password.min" +msgstr "Your password is too short." + +msgid "validation.password.confirmed" +msgstr "Your passwords are not equal." + +msgid "validation.password_confirmation.required" +msgstr "You have to confirm your password." diff --git a/resources/lang/pt_BR/default.mo b/resources/lang/pt_BR/default.mo index 8b8641565705f92b5dbcf78956dbb887cd62b6bb..d15826e6b0ee3c286dca910b7223e6c2b67156cf 100644 GIT binary patch delta 4947 zcmXZedvuRS9>?*Kgoq?aBIG8bkr0{-(I{~_Zc(M~w``;oO=yW)5|?TpBt#W)EfL$0 z?nR^Br8$d((>8W%MXI(fcBN;P6@NG_OBc-^#lAmg{_&cb-}B6T=R4nd;=@{>k7|7m z%?bAU8)N=F$(Tj>6K3F?$;P~d6&Q*)u{HjJ9WZc;F~Jy*&9Nu?Vjm30Ow_#Nu{UnU zO#BO`;}d)q)2ABG&^9xt8nXhAVrT3%&6rr6gfHV_jK*)U0B_@Kn44|PGk6B$@ds3( zZGL6U8yJi8une{E|4yN}l`jcIMrE@*%xgDtY%iVY-2GKu=)Rt$C(NHAysGVFu71wvDh;O1MXhKc+7?nET znfATbsPPUMf-$I!rr@<^1db}UcC(C0z`>|_^D%_=P0`baG3&8AKkUJ-_ys26EoX<> zcBg}#b5WTqb5^1z{uB1WPf^wX0JX6mb6h4-8F(IBu)Y~iBLGKZ1Wv&iT!`&(BgWx= z)a^KhDy~LUX2M^!MmsyB0_lnhFatGj7V3y+U`KotJym@<4PA>vsQY^cBk>9B5cBmy6m!J}Q$1n2PIA&mTc;=x?ZbFQEdu?$OZBe#QU{ z;r6pqWFRVILr^;&f%>C06I`usbScgHYqyco-L;7Vh+h-FZB=rau&Qy(XY`T7U|y5Eal0 zR7N(THo6N#bpL-#Lsfjl{csX>1a;U7{T5l(`;4`#b8n%cz}SM?KeS zsXg;h45Z(|_2V&=em7?(DpO-T8trM!LKVw$)K1@aZb3~{g__`xsGZlM0yyu+FFSuk z-Igb)dD<GJc=pUh)Qi3-%$k?g?cU# zbz6p@CZ3K8G#|CoLR3+{gMDxha#WuAhK3gWCn^(nP&@EnVN)H0+DQVc==xwJPRA%L zLQT9C|E6IMPQZ^b70uf=Q)#FwpMkw_6Q<$k7=w3lweEjw{&^R-IAd1XqB?-FjGsp> zcpLRcsOM^XJ+o00m7zYpAE7?kXHn1HK^i7loLs9RKqs)x>x*onGc2T%bXK?Qim_0OX+bPYA%ebn=T@7N7Q zqH4?QO+$aZCZb-Lg*t*FRODMQ01u!)e&RfiE$N?f{d!a;zeQ#8iu=9Mjo)(pCJf;B zW*eRswPylpXyP%bvz>wpWU;dpU!Y%s1=xt`IFTzOOHsvo0*B)x?2kh?+WsmWMgKH* z!O%_iKQetVSNFewhEn(?Dic>wFWka-?7P`6kc(u|RALTxE3+Sz667kIlc=-L++r7) zifQyWqiW_eR1r3yYUO+MXMNK|L#Z@d?F0d+j3l4}NJgFccvP*-K?SNN=Im@!w`M-4;DXoJ^xTj>J%0f{L&d_2NEMaUI7%tVIQQ4u{}HT!j(a z`CE_WI06MEWi!_gm(YJ1wb5@;NA(~401I}Le_gN8a{IwaLtU3~I2ZGfPlGv+ zBXPHzv|QhdFr9^~ZU8?H`w=*q0wppsvS# z%)lq8>dyF$P4zNV3QwS(Yj8HAj^sA#s2-pKdW10;_P)(P8ZM?&=b`3Xg!+9M`e}xBG*o== zqN={k^$%>%j{B_H!J5d>y?t0QZxIN HtU><=wBNr< delta 4944 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 + {% if label %} + + {% endif %} + +
+{% endmacro %} + +{% macro hidden(name, value) %} + +{% endmacro %} + +{% macro submit(label) %} + +{% endmacro %} diff --git a/resources/views/pages/login.twig b/resources/views/pages/login.twig index 6160508f..34dbd63f 100644 --- a/resources/views/pages/login.twig +++ b/resources/views/pages/login.twig @@ -62,7 +62,7 @@
diff --git a/resources/views/pages/password/reset-form.twig b/resources/views/pages/password/reset-form.twig new file mode 100644 index 00000000..60eb2499 --- /dev/null +++ b/resources/views/pages/password/reset-form.twig @@ -0,0 +1,18 @@ +{% extends "pages/password/reset.twig" %} +{% import 'macros/base.twig' as m %} +{% import 'macros/form.twig' as f %} + +{% block row_content %} +
+
+ {{ csrf() }} + + {{ f.input('password', __('Password'), 'password', true) }} + {{ f.input('password_confirmation', __('Confirm password'), 'password', true) }} + +
+ {{ f.submit(__('Save')) }} +
+
+
+{% endblock %} diff --git a/resources/views/pages/password/reset-success.twig b/resources/views/pages/password/reset-success.twig new file mode 100644 index 00000000..19b8a93e --- /dev/null +++ b/resources/views/pages/password/reset-success.twig @@ -0,0 +1,12 @@ +{% extends "pages/password/reset.twig" %} +{% import 'macros/base.twig' as m %} + +{% block row_content %} +
+ {% if type == 'email' %} + {{ m.alert(__('We sent you an email containing your password recovery link.'), 'info') }} + {% elseif type == 'reset' %} + {{ m.alert(__('Password saved.'), 'success') }} + {% endif %} +
+{% endblock %} diff --git a/resources/views/pages/password/reset.twig b/resources/views/pages/password/reset.twig new file mode 100644 index 00000000..289152ea --- /dev/null +++ b/resources/views/pages/password/reset.twig @@ -0,0 +1,32 @@ +{% extends 'layouts/app.twig' %} +{% import 'macros/base.twig' as m %} +{% import 'macros/form.twig' as f %} + +{% block title %}{{ __('Password recovery') }}{% endblock %} + +{% block content %} +
+

{{ __('Password recovery') }}

+ + {% for message in errors|default([]) %} + {{ m.alert(__(message), 'danger') }} + {% endfor %} + +
+ {% block row_content %} +
+
+ {{ csrf() }} + + {{ __('We will send you an e-mail with a password recovery link. Please use the email address you used for registration.') }} + {{ f.input('email', __('E-Mail'), 'email', true) }} + +
+ {{ f.submit(__('Recover')) }} +
+
+
+ {% endblock %} +
+
+{% endblock %} diff --git a/src/Controllers/PasswordResetController.php b/src/Controllers/PasswordResetController.php new file mode 100644 index 00000000..505ed8eb --- /dev/null +++ b/src/Controllers/PasswordResetController.php @@ -0,0 +1,167 @@ + 'login', + 'postReset' => 'login', + 'resetPassword' => 'login', + 'postResetPassword' => 'login', + ]; + + /** + * @param Response $response + * @param SessionInterface $session + * @param EngelsystemMailer $mail + * @param LoggerInterface $log + */ + public function __construct( + Response $response, + SessionInterface $session, + EngelsystemMailer $mail, + LoggerInterface $log + ) { + $this->log = $log; + $this->mail = $mail; + $this->response = $response; + $this->session = $session; + } + + /** + * @return Response + */ + public function reset(): Response + { + return $this->showView('pages/password/reset'); + } + + /** + * @param Request $request + * @return Response + */ + public function postReset(Request $request): Response + { + $data = $this->validate($request, [ + 'email' => 'required|email', + ]); + + /** @var User $user */ + $user = User::whereEmail($data['email'])->first(); + if ($user) { + $reset = PasswordReset::findOrNew($user->id); + $reset->user_id = $user->id; + $reset->token = md5(random_bytes(64)); + $reset->save(); + + $this->log->info( + sprintf('Password recovery for %s (%u)', $user->name, $user->id), + ['user' => $user->toJson()] + ); + + $this->mail->sendViewTranslated( + $user, + 'Password recovery', + 'emails/password-reset', + ['username' => $user->name, 'reset' => $reset] + ); + } + + return $this->showView('pages/password/reset-success', ['type' => 'email']); + } + + /** + * @param Request $request + * @return Response + */ + public function resetPassword(Request $request): Response + { + $this->requireToken($request); + + return $this->showView('pages/password/reset-form'); + } + + /** + * @param Request $request + * @return Response + */ + public function postResetPassword(Request $request): Response + { + $reset = $this->requireToken($request); + + $data = $this->validate($request, [ + 'password' => 'required|min:' . config('min_password_length'), + 'password_confirmation' => 'required', + ]); + + if ($data['password'] !== $data['password_confirmation']) { + $this->session->set('errors', + array_merge($this->session->get('errors', []), ['validation.password.confirmed'])); + + return $this->showView('pages/password/reset-form'); + } + + auth()->setPassword($reset->user, $data['password']); + $reset->delete(); + + return $this->showView('pages/password/reset-success', ['type' => 'reset']); + } + + /** + * @param string $view + * @param array $data + * @return Response + */ + protected function showView($view = 'pages/password/reset', $data = []): Response + { + $errors = Collection::make(Arr::flatten($this->session->get('errors', []))); + $this->session->remove('errors'); + + return $this->response->withView( + $view, + array_merge_recursive(['errors' => $errors], $data) + ); + } + + /** + * @param Request $request + * @return PasswordReset + */ + protected function requireToken(Request $request): PasswordReset + { + $token = $request->getAttribute('token'); + /** @var PasswordReset|null $reset */ + $reset = PasswordReset::whereToken($token)->first(); + + if (!$reset) { + throw new HttpNotFound(); + } + + return $reset; + } +} diff --git a/src/Middleware/LegacyMiddleware.php b/src/Middleware/LegacyMiddleware.php index 27a15faa..11508e1c 100644 --- a/src/Middleware/LegacyMiddleware.php +++ b/src/Middleware/LegacyMiddleware.php @@ -26,7 +26,6 @@ class LegacyMiddleware implements MiddlewareInterface 'shifts_json_export', 'users', 'user_driver_licenses', - 'user_password_recovery', 'user_worklog', ]; @@ -112,11 +111,6 @@ class LegacyMiddleware implements MiddlewareInterface case 'shifts_json_export': require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php'); shifts_json_export_controller(); - case 'user_password_recovery': - require_once realpath(__DIR__ . '/../../includes/controller/users_controller.php'); - $title = user_password_recovery_title(); - $content = user_password_recovery_controller(); - return [$title, $content]; case 'public_dashboard': return public_dashboard_controller(); case 'angeltypes': diff --git a/tests/Unit/Controllers/PasswordResetControllerTest.php b/tests/Unit/Controllers/PasswordResetControllerTest.php new file mode 100644 index 00000000..54046cef --- /dev/null +++ b/tests/Unit/Controllers/PasswordResetControllerTest.php @@ -0,0 +1,266 @@ +getController('pages/password/reset'); + $response = $controller->reset(); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postReset + */ + public function testPostReset(): void + { + $this->initDatabase(); + $request = new Request([], ['email' => 'foo@bar.batz']); + $user = $this->createUser(); + + $controller = $this->getController( + 'pages/password/reset-success', + ['type' => 'email', 'errors' => collect()] + ); + /** @var TestLogger $log */ + $log = $this->args['log']; + /** @var EngelsystemMailer|MockObject $mailer */ + $mailer = $this->args['mailer']; + $this->setExpects($mailer, 'sendViewTranslated'); + + $controller->postReset($request); + + $this->assertNotEmpty(PasswordReset::find($user->id)->first()); + $this->assertTrue($log->hasInfoThatContains($user->name)); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postReset + */ + public function testPostResetInvalidRequest(): void + { + $request = new Request(); + + $controller = $this->getController(); + + $this->expectException(ValidationException::class); + $controller->postReset($request); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postReset + */ + public function testPostResetNoUser(): void + { + $this->initDatabase(); + $request = new Request([], ['email' => 'foo@bar.batz']); + + $controller = $this->getController( + 'pages/password/reset-success', + ['type' => 'email', 'errors' => collect()] + ); + + $controller->postReset($request); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::resetPassword + * @covers \Engelsystem\Controllers\PasswordResetController::requireToken + */ + public function testResetPassword(): void + { + $this->initDatabase(); + + $user = $this->createUser(); + $token = $this->createToken($user); + $request = new Request([], [], ['token' => $token->token]); + + $controller = $this->getController('pages/password/reset-form'); + + $controller->resetPassword($request); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::resetPassword + * @covers \Engelsystem\Controllers\PasswordResetController::requireToken + */ + public function testResetPasswordNoToken(): void + { + $this->initDatabase(); + $controller = $this->getController(); + + $this->expectException(HttpNotFound::class); + $controller->resetPassword(new Request()); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postResetPassword + */ + public function testPostResetPassword(): void + { + $this->initDatabase(); + + $this->app->instance('config', new Config(['min_password_length' => 3])); + $user = $this->createUser(); + $token = $this->createToken($user); + $password = 'SomeRandomPasswordForAmazingSecurity'; + $request = new Request( + [], + ['password' => $password, 'password_confirmation' => $password], + ['token' => $token->token] + ); + + $controller = $this->getController( + 'pages/password/reset-success', + ['type' => 'reset', 'errors' => collect()] + ); + + $auth = new Authenticator($request, $this->args['session'], $user); + $this->app->instance('authenticator', $auth); + + $response = $controller->postResetPassword($request); + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertEmpty(PasswordReset::find($user->id)); + $this->assertNotNull(auth()->authenticate($user->name, $password)); + } + + /** + * @covers \Engelsystem\Controllers\PasswordResetController::postResetPassword + * @covers \Engelsystem\Controllers\PasswordResetController::showView + */ + public function testPostResetPasswordNotMatching(): void + { + $this->initDatabase(); + + $this->app->instance('config', new Config(['min_password_length' => 3])); + $user = $this->createUser(); + $token = $this->createToken($user); + $password = 'SomeRandomPasswordForAmazingSecurity'; + $request = new Request( + [], + ['password' => $password, 'password_confirmation' => $password . 'OrNot'], + ['token' => $token->token] + ); + + $controller = $this->getController( + 'pages/password/reset-form', + ['errors' => collect(['some.other.error', 'validation.password.confirmed'])] + ); + /** @var Session $session */ + $session = $this->args['session']; + $session->set('errors', ['foo' => ['bar' => 'some.other.error']]); + + $controller->postResetPassword($request); + $this->assertEmpty($session->get('errors')); + } + + /** + * @return array + */ + protected function getControllerArgs(): array + { + $response = new Response(); + $session = new Session(new MockArraySessionStorage()); + /** @var EngelsystemMailer|MockObject $mailer */ + $mailer = $this->createMock(EngelsystemMailer::class); + $log = new TestLogger(); + $renderer = $this->createMock(Renderer::class); + $response->setRenderer($renderer); + + return $this->args = [ + 'response' => $response, + 'session' => $session, + 'mailer' => $mailer, + 'log' => $log, + 'renderer' => $renderer + ]; + } + + /** + * @param string $view + * @param array $data + * @return PasswordResetController + */ + protected function getController(?string $view = null, ?array $data = null): PasswordResetController + { + /** @var Response $response */ + /** @var Session $session */ + /** @var EngelsystemMailer|MockObject $mailer */ + /** @var TestLogger $log */ + /** @var Renderer|MockObject $renderer */ + list($response, $session, $mailer, $log, $renderer) = array_values($this->getControllerArgs()); + $controller = new PasswordResetController($response, $session, $mailer, $log); + $controller->setValidator(new Validator()); + + if ($view) { + $args = [$view]; + if ($data) { + $args[] = $data; + } + + $this->setExpects($renderer, 'render', $args, 'Foo'); + } + + return $controller; + } + + /** + * @return User + */ + protected function createUser(): User + { + $user = new User([ + 'name' => 'foo', + 'password' => '', + 'email' => 'foo@bar.batz', + 'api_key' => '', + ]); + $user->save(); + + return $user; + } + + /** + * @param User $user + * @return PasswordReset + */ + protected function createToken(User $user): PasswordReset + { + $reset = new PasswordReset(['user_id' => $user->id, 'token' => 'SomeTestToken123']); + $reset->save(); + + return $reset; + } +} From c9ebaa972cb2a16e16ffc78080f03342eae5d874 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Fri, 6 Sep 2019 03:45:56 +0200 Subject: [PATCH 09/17] Shifts view: Persist hidden filters --- resources/assets/js/forms.js | 35 +++++++++++++++++++++++--- resources/views/pages/user-shifts.html | 8 +++--- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/resources/assets/js/forms.js b/resources/assets/js/forms.js index f5818e97..9970b907 100644 --- a/resources/assets/js/forms.js +++ b/resources/assets/js/forms.js @@ -16,7 +16,7 @@ global.checkAll = (id, checked) => { * Sets the checkboxes according to the given type * * @param {string} id The elements ID - * @param {list} shifts_list A list of numbers + * @param {list} shiftsList A list of numbers */ global.checkOwnTypes = (id, shiftsList) => { $('#' + id + ' input[type="checkbox"]').each(function () { @@ -144,10 +144,10 @@ $(function () { elem.children('input').on('click', function (ev) { ev.stopImmediatePropagation(); if (typeof elem.data('DateTimePicker') === 'undefined') { - elem.datetimepicker(opts); - elem.data('DateTimePicker').show(); + elem.datetimepicker(opts); + elem.data('DateTimePicker').show(); } else { - elem.data('DateTimePicker').toggle(); + elem.data('DateTimePicker').toggle(); } }); }); @@ -173,3 +173,30 @@ $(function () { }); }); }); + +/** + * Set the filter selects to latest state + * + * Uses DOMContentLoaded to prevent flickering + */ +window.addEventListener('DOMContentLoaded', () => { + const filter = document.getElementById('collapseShiftsFilterSelect'); + if (!filter || localStorage.getItem('collapseShiftsFilterSelect') !== 'hidden') { + return; + } + + filter.classList.remove('in'); +}); +$(() => { + if (typeof (localStorage) === 'undefined') { + return; + } + + const onChange = (e) => { + localStorage.setItem('collapseShiftsFilterSelect', e.type); + }; + + $('#collapseShiftsFilterSelect') + .on('hidden.bs.collapse', onChange) + .on('shown.bs.collapse', onChange); +}); diff --git a/resources/views/pages/user-shifts.html b/resources/views/pages/user-shifts.html index 9ac501da..d5a98f80 100644 --- a/resources/views/pages/user-shifts.html +++ b/resources/views/pages/user-shifts.html @@ -55,12 +55,12 @@
-
+
%room_select%
%type_select%
@@ -79,5 +79,5 @@ %shifts_table%
-%ical_text% + %ical_text%
\ No newline at end of file From fc773b25b3de455f7e74334156926f644f04db98 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Wed, 18 Sep 2019 14:09:30 +0200 Subject: [PATCH 10/17] Use 403 forbidden on shifts json, atom export and ical export --- includes/controller/shifts_controller.php | 18 ++++++++++-------- includes/helper/error_helper.php | 11 ----------- includes/includes.php | 1 - includes/pages/user_atom.php | 18 ++++++++++-------- includes/pages/user_ical.php | 19 +++++++++++-------- 5 files changed, 31 insertions(+), 36 deletions(-) delete mode 100644 includes/helper/error_helper.php diff --git a/includes/controller/shifts_controller.php b/includes/controller/shifts_controller.php index caf124ba..726814cf 100644 --- a/includes/controller/shifts_controller.php +++ b/includes/controller/shifts_controller.php @@ -1,5 +1,6 @@ has('key') || !preg_match('/^[\da-f]{32}$/', $request->input('key'))) { - engelsystem_error('Missing key.'); - } - $user = auth()->apiUser('key'); - if (!$user) { - engelsystem_error('Key invalid.'); + + if ( + !$request->has('key') + || !preg_match('/^[\da-f]{32}$/', $request->input('key')) + || !$user + ) { + throw new HttpForbidden('{"error":"Missing or invalid key"}', ['content-type' => 'application/json']); } + if (!auth()->can('shifts_json_export')) { - engelsystem_error('No privilege for shifts_json_export.'); + throw new HttpForbidden('{"error":"Not allowed"}', ['content-type' => 'application/json']); } $shifts = load_ical_shifts(); diff --git a/includes/helper/error_helper.php b/includes/helper/error_helper.php deleted file mode 100644 index 9314a57a..00000000 --- a/includes/helper/error_helper.php +++ /dev/null @@ -1,11 +0,0 @@ -has('key') || !preg_match('/^[\da-f]{32}$/', $request->input('key'))) { - engelsystem_error('Missing key.'); - } - $user = auth()->apiUser('key'); - if (empty($user)) { - engelsystem_error('Key invalid.'); + + if ( + !$request->has('key') + || !preg_match('/^[\da-f]{32}$/', $request->input('key')) + || empty($user) + ) { + throw new HttpForbidden('Missing or invalid key', ['content-type' => 'text/text']); } + if (!auth()->can('atom')) { - engelsystem_error('No privilege for atom.'); + throw new HttpForbidden('Not allowed', ['content-type' => 'text/text']); } $news = DB::select(' diff --git a/includes/pages/user_ical.php b/includes/pages/user_ical.php index ee3a8340..2f3a7ccc 100644 --- a/includes/pages/user_ical.php +++ b/includes/pages/user_ical.php @@ -1,22 +1,25 @@ has('key') || !preg_match('/^[\da-f]{32}$/', $request->input('key'))) { - engelsystem_error('Missing key.'); - } - $user = auth()->apiUser('key'); - if (!$user) { - engelsystem_error('Key invalid.'); + + if ( + !$request->has('key') + || !preg_match('/^[\da-f]{32}$/', $request->input('key')) + || !$user + ) { + throw new HttpForbidden('Missing or invalid key', ['content-type' => 'text/text']); } + if (!auth()->can('ical')) { - engelsystem_error('No privilege for ical.'); + throw new HttpForbidden('Not allowed', ['content-type' => 'text/text']); } $ical_shifts = load_ical_shifts(); From bf191ef22232a2937d474a1c8c1c3fbc2bbd94ae Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sun, 6 Oct 2019 15:50:45 +0200 Subject: [PATCH 11/17] CI: Build translation files for container and release archive --- contrib/Dockerfile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/contrib/Dockerfile b/contrib/Dockerfile index a7fd403b..013ccf1d 100644 --- a/contrib/Dockerfile +++ b/contrib/Dockerfile @@ -4,7 +4,12 @@ COPY ./ /app/ RUN composer --no-ansi install --no-dev --ignore-platform-reqs RUN composer --no-ansi dump-autoload --optimize -# Intermediate container for less layers +# Intermediate containers for less layers +FROM alpine as translation +RUN apk add gettext +COPY resources/lang/ /data +RUN find /data -type f -name '*.po' -exec sh -c 'file="{}"; msgfmt "${file%.*}.po" -o "${file%.*}.mo"' \; + FROM alpine as data COPY .babelrc .browserslistrc composer.json LICENSE package.json README.md webpack.config.js yarn.lock /app/ COPY bin/ /app/bin @@ -13,11 +18,11 @@ COPY db/ /app/db RUN mkdir /app/import/ COPY includes/ /app/includes COPY public/ /app/public -COPY resources/lang /app/resources/lang COPY resources/views /app/resources/views COPY src/ /app/src COPY storage/ /app/storage +COPY --from=translation /data/ /app/resources/lang COPY --from=composer /app/vendor/ /app/vendor COPY --from=composer /app/composer.lock /app/ From 7c9910677e776aafe744ecbf41ade5e5b795c3a7 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sun, 6 Oct 2019 16:53:10 +0200 Subject: [PATCH 12/17] Frontend: Show language selects on every page --- resources/views/layouts/parts/navbar.twig | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/resources/views/layouts/parts/navbar.twig b/resources/views/layouts/parts/navbar.twig index 61c6a10b..0b2eee63 100644 --- a/resources/views/layouts/parts/navbar.twig +++ b/resources/views/layouts/parts/navbar.twig @@ -55,16 +55,14 @@ {{ elements.toolbar_item(user.name, url('users', {'action': 'view'}), 'users', 'icon icon-icon_angel') }} {% endif %} - {% if has_permission_to('user_settings') or has_permission_to('logout') %} - - {% endif %} + {% endblock %} From 973c108b153fb1d8be3576935c58e92865d19e7a Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sun, 6 Oct 2019 17:38:23 +0200 Subject: [PATCH 13/17] credits: Make them translatable and use markdown --- resources/views/pages/credits.twig | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/resources/views/pages/credits.twig b/resources/views/pages/credits.twig index 3bb04895..8bd1f694 100644 --- a/resources/views/pages/credits.twig +++ b/resources/views/pages/credits.twig @@ -4,28 +4,28 @@ {% block content %}
-

Credits

+

{{ __('Credits') }}

{% for title, credit in credits %}
-

{{ title }}

- {{ credit|markdown }} +

{{ __(title) }}

+ {{ __(credit)|markdown }}
{% endfor %}
-

Source code

-

Version: {{ version }}

+

{{ __('Source code') }}

+

{{ __('Version: _%s_', [version])|markdown }}

- The original engelsystem was written by - cookie. - It was then completely rewritten and enhanced by - msquare (maintainer) and - MyIgel. + {{ __('The original engelsystem was written by +[cookie](https://github.com/cookieBerlin/engelsystem). +It was then completely rewritten and enhanced by [msquare](https://notrademark.de) (maintainer) and +[MyIgel](https://myigel.name).')|markdown }}

- Please look at the - contributor list on GitHub for a complete list. + {{ __('Please have a look at the +[contributors list on GitHub](https://github.com/engelsystem/engelsystem/graphs/contributors) +for a complete list.')|markdown }}

From fa35187795734ad4f4dc33f74cc0fe020dd9ff32 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Wed, 9 Oct 2019 13:54:04 +0200 Subject: [PATCH 14/17] Removed .mo translation files from version control, use .po as fallback --- .gitignore | 1 + README.md | 5 ++++ resources/lang/de_DE/default.mo | Bin 46693 -> 0 bytes resources/lang/en_US/default.mo | Bin 1240 -> 0 bytes resources/lang/pt_BR/default.mo | Bin 41132 -> 0 bytes .../TranslationServiceProvider.php | 25 ++++++++++++++++-- .../Translation/Assets/ba_RR/default.po | 3 +++ .../TranslationServiceProviderTest.php | 22 ++++++++++++--- 8 files changed, 51 insertions(+), 5 deletions(-) delete mode 100644 resources/lang/de_DE/default.mo delete mode 100644 resources/lang/en_US/default.mo delete mode 100644 resources/lang/pt_BR/default.mo create mode 100644 tests/Unit/Helpers/Translation/Assets/ba_RR/default.po diff --git a/.gitignore b/.gitignore index 77a62c1e..cda5cf92 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ _vimrc_local.vim /public/coverage /coverage /unittests.xml +/resources/lang/*/*.mo # Composer files /vendor/ diff --git a/README.md b/README.md index 02508157..594011b7 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,11 @@ The following instructions explain how to get, build and run the latest engelsys ```bash yarn build ``` + * Optionally (for better performance) + * Generate translation files + ```bash + find resources/lang/ -type f -name '*.po' -exec sh -c 'file="{}"; msgfmt "${file%.*}.po" -o "${file%.*}.mo"' \; + ``` ### Configuration and Setup * The webserver must have write access to the ```import``` and ```storage``` directories and read access for all other directories diff --git a/resources/lang/de_DE/default.mo b/resources/lang/de_DE/default.mo deleted file mode 100644 index d4b7885bdb60dad5663232db1d20a4ced7afa4b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46693 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 MNM{CgakxqNe_0Tk+yDRo diff --git a/src/Helpers/Translation/TranslationServiceProvider.php b/src/Helpers/Translation/TranslationServiceProvider.php index 05e782ec..6df9b0fe 100644 --- a/src/Helpers/Translation/TranslationServiceProvider.php +++ b/src/Helpers/Translation/TranslationServiceProvider.php @@ -5,6 +5,7 @@ namespace Engelsystem\Helpers\Translation; use Engelsystem\Config\Config; use Engelsystem\Container\ServiceProvider; use Gettext\Translations; +use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Session\Session; class TranslationServiceProvider extends ServiceProvider @@ -69,14 +70,18 @@ class TranslationServiceProvider extends ServiceProvider public function getTranslator(string $locale): GettextTranslator { if (!isset($this->translators[$locale])) { - $file = $this->app->get('path.lang') . '/' . $locale . '/default.mo'; + $file = $this->getFile($locale); /** @var GettextTranslator $translator */ $translator = $this->app->make(GettextTranslator::class); /** @var Translations $translations */ $translations = $this->app->make(Translations::class); - $translations->addFromMoFile($file); + if (Str::endsWith($file, '.mo')) { + $translations->addFromMoFile($file); + } else { + $translations->addFromPoFile($file); + } $translator->loadTranslations($translations); @@ -85,4 +90,20 @@ class TranslationServiceProvider extends ServiceProvider return $this->translators[$locale]; } + + /** + * @param string $locale + * @return string + */ + protected function getFile(string $locale): string + { + $filepath = $file = $this->app->get('path.lang') . '/' . $locale . '/default'; + $file = $filepath . '.mo'; + + if (!file_exists($file)) { + $file = $filepath . '.po'; + } + + return $file; + } } diff --git a/tests/Unit/Helpers/Translation/Assets/ba_RR/default.po b/tests/Unit/Helpers/Translation/Assets/ba_RR/default.po new file mode 100644 index 00000000..887e2daa --- /dev/null +++ b/tests/Unit/Helpers/Translation/Assets/ba_RR/default.po @@ -0,0 +1,3 @@ +# Testing content +msgid "foo.bar" +msgstr "B Arr!" diff --git a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php index 1822f353..e55fdf02 100644 --- a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php +++ b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php @@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Session\Session; class TranslationServiceProviderTest extends ServiceProviderTest { /** - * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::register() + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::register */ public function testRegister(): void { @@ -30,7 +30,7 @@ class TranslationServiceProviderTest extends ServiceProviderTest /** @var TranslationServiceProvider|MockObject $serviceProvider */ $serviceProvider = $this->getMockBuilder(TranslationServiceProvider::class) ->setConstructorArgs([$app]) - ->setMethods(['setLocale']) + ->onlyMethods(['setLocale']) ->getMock(); $app->expects($this->exactly(2)) @@ -75,7 +75,7 @@ class TranslationServiceProviderTest extends ServiceProviderTest } /** - * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getTranslator() + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getTranslator */ public function testGetTranslator(): void { @@ -91,4 +91,20 @@ class TranslationServiceProviderTest extends ServiceProviderTest // Retry from cache $serviceProvider->getTranslator('fo_OO'); } + + /** + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getTranslator + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getFile + */ + public function testGetTranslatorFromPo(): void + { + $app = $this->getApp(['get']); + $this->setExpects($app, 'get', ['path.lang'], __DIR__ . '/Assets'); + + $serviceProvider = new TranslationServiceProvider($app); + + // Get translator using a .po file + $translator = $serviceProvider->getTranslator('ba_RR'); + $this->assertEquals('B Arr!', $translator->gettext('foo.bar')); + } } From 5e2cb6e5c92e85ac84b171c19fcd16643501b3e0 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 11 Oct 2019 21:15:54 +0200 Subject: [PATCH 15/17] Updates moment js --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5a8a5a44..8a623a3b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "eonasdan-bootstrap-datetimepicker": "^4.17.47", "jquery": "^3.3.1", "jquery-ui": "^1.11.2", - "moment": "^2.8.2", + "moment": "^2.12.0", "moment-timezone": "^0.4.0", "select2": "^4.0.6-rc.1", "select2-bootstrap-theme": "0.1.0-beta.10" diff --git a/yarn.lock b/yarn.lock index cec94e2e..c49eb596 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3328,7 +3328,7 @@ moment-timezone@^0.4.0: dependencies: moment ">= 2.6.0" -"moment@>= 2.6.0", moment@^2.10, moment@^2.10.2, moment@^2.8.2: +"moment@>= 2.6.0", moment@^2.10, moment@^2.10.2, moment@^2.12.0: version "2.24.0" resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== From 75f04507aedc6cea020da93fc579351cfb0f4f2c Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 11 Oct 2019 21:16:24 +0200 Subject: [PATCH 16/17] Makes Monday first day of the week even if English language is selected --- resources/assets/js/vendor.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/resources/assets/js/vendor.js b/resources/assets/js/vendor.js index bf3807f7..b4b6487d 100644 --- a/resources/assets/js/vendor.js +++ b/resources/assets/js/vendor.js @@ -10,6 +10,13 @@ require('./forms'); require('./sticky-headers'); require('./moment-countdown'); +moment.updateLocale('en', { + week : { + dow : 1, // Monday is the first day of the week. + doy : 4 // The week that contains Jan 4th is the first week of the year. + } +}); + $.ajaxSetup({ headers: {'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')} }); From 7a2427e70296ef652f76fe2e2edc47d2e0f70f5a Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Fri, 11 Oct 2019 22:56:38 +0200 Subject: [PATCH 17/17] docker-compose: Set mariadb version and added local dev env to readme --- README.md | 14 ++++++++++++++ contrib/docker-compose.yml | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 594011b7..9b4a3aaf 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,20 @@ Import database docker exec -it engelsystem bin/migrate ``` +#### Local development +To use the working directory in the container the docker-compose file has to be changed: +```yaml +[...] + nginx: + volumes: + - ../public/assets:/var/www/public/assets +[...] + engelsystem: + volumes: + - ../:/var/www +[...] +``` + #### Scripts ##### bin/deploy.sh The `bin/deploy.sh` script can be used to deploy the engelsystem. It uses rsync to deploy the application to a server over ssh. diff --git a/contrib/docker-compose.yml b/contrib/docker-compose.yml index 197e2281..4624cce4 100644 --- a/contrib/docker-compose.yml +++ b/contrib/docker-compose.yml @@ -27,12 +27,13 @@ services: depends_on: - database database: - image: mariadb:latest + image: mariadb:10.2 environment: MYSQL_DATABASE: engelsystem MYSQL_USER: engelsystem MYSQL_PASSWORD: engelsystem MYSQL_RANDOM_ROOT_PASSWORD: 1 + MYSQL_INITDB_SKIP_TZINFO: "yes" volumes: - db:/var/lib/mysql networks: