# APP_LANG will be used if such a header is not provided.
APP_AUTO_LANG_PUBLIC=true
-# Application timezone
-# Used where dates are displayed such as on exported content.
+# Application timezones
+# The first option is used to determine what timezone is used for date storage.
+# Leaving that as "UTC" is advised.
+# The second option is used to set the timezone which will be used for date
+# formatting and display. This defaults to the "APP_TIMEZONE" value.
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
APP_TIMEZONE=UTC
+APP_DISPLAY_TIMEZONE=UTC
# Application theme
# Used to specific a themes/<APP_THEME> folder where BookStack UI
namespace BookStack\App\Providers;
use BookStack\Entities\BreadcrumbsViewComposer;
+use BookStack\Util\DateFormatter;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
class ViewTweaksServiceProvider extends ServiceProvider
{
+ public function register()
+ {
+ $this->app->singleton(DateFormatter::class, function ($app) {
+ return new DateFormatter(
+ $app['config']->get('app.display_timezone'),
+ );
+ });
+ }
+
/**
* Bootstrap services.
*/
// View Composers
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
+ // View Globals
+ View::share('dates', $this->app->make(DateFormatter::class));
+
// Custom blade view directives
Blade::directive('icon', function ($expression) {
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";
// A list of the sources/hostnames that can be reached by application SSR calls.
// This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
// Host-specific functionality (usually controlled via other options) like auth
- // or user avatars for example, won't use this list.
- // Space seperated if multiple. Can use '*' as a wildcard.
+ // or user avatars, for example, won't use this list.
+ // Space separated if multiple. Can use '*' as a wildcard.
// Values will be compared prefix-matched, case-insensitive, against called SSR urls.
// Defaults to allow all hosts.
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
- // Application timezone for back-end date functions.
+ // Application timezone for stored date/time values.
'timezone' => env('APP_TIMEZONE', 'UTC'),
+ // Application timezone for displayed date/time values in the UI.
+ 'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')),
// Default locale to use
// A default variant is also stored since Laravel can overwrite
--- /dev/null
+<?php
+
+namespace BookStack\Util;
+
+use Carbon\Carbon;
+
+class DateFormatter
+{
+ public function __construct(
+ protected string $displayTimezone,
+ ) {
+ }
+
+ public function isoWithTimezone(Carbon $date): string
+ {
+ $withDisplayTimezone = $date->clone()->setTimezone($this->displayTimezone);
+
+ return $withDisplayTimezone->format('Y-m-d H:i:s T');
+ }
+
+ public function relative(Carbon $date): string
+ {
+ return $date->diffForHumans();
+ }
+}
@icon('star')
<div>
{!! trans('entities.meta_created_name', [
- 'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>',
+ 'timeLength' => '<span title="'. $dates->isoWithTimezone($entity->created_at) .'">'. $dates->relative($entity->created_at) . '</span>',
'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".e($entity->createdBy->name). "</a>"
]) !!}
</div>
@else
<div class="entity-meta-item">
@icon('star')
- <span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span>
+ <span title="{{ $dates->isoWithTimezone($entity->created_at) }}">{{ trans('entities.meta_created', ['timeLength' => $dates->relative($entity->created_at)]) }}</span>
</div>
@endif
@icon('edit')
<div>
{!! trans('entities.meta_updated_name', [
- 'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>',
+ 'timeLength' => '<span title="' . $dates->isoWithTimezone($entity->updated_at) .'">' . $dates->relative($entity->updated_at) .'</span>',
'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".e($entity->updatedBy->name). "</a>"
]) !!}
</div>
@elseif (!$entity->isA('revision'))
<div class="entity-meta-item">
@icon('edit')
- <span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span>
+ <span title="{{ $dates->isoWithTimezone($entity->updated_at) }}">{{ trans('entities.meta_updated', ['timeLength' => $dates->relative($entity->updated_at)]) }}</span>
</div>
@endif
--- /dev/null
+<?php
+
+namespace Tests\Util;
+
+use BookStack\Util\DateFormatter;
+use Carbon\Carbon;
+use Tests\TestCase;
+
+class DateFormatterTest extends TestCase
+{
+ public function test_iso_with_timezone_alters_from_stored_to_display_timezone()
+ {
+ $formatter = new DateFormatter('Europe/London');
+ $dateTime = new Carbon('2020-06-01 12:00:00', 'UTC');
+
+ $result = $formatter->isoWithTimezone($dateTime);
+ $this->assertEquals('2020-06-01 13:00:00 BST', $result);
+ }
+
+ public function test_iso_with_timezone_works_from_non_utc_dates()
+ {
+ $formatter = new DateFormatter('Asia/Shanghai');
+ $dateTime = new Carbon('2025-06-10 15:25:00', 'America/New_York');
+
+ $result = $formatter->isoWithTimezone($dateTime);
+ $this->assertEquals('2025-06-11 03:25:00 CST', $result);
+ }
+
+ public function test_relative()
+ {
+ $formatter = new DateFormatter('Europe/London');
+ $dateTime = (new Carbon('now', 'UTC'))->subMinutes(50);
+
+ $result = $formatter->relative($dateTime);
+ $this->assertEquals('50 minutes ago', $result);
+ }
+}