Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ services:
test: [CMD, curl, -f, http://localhost:7700/health]
volumes:
- meilisearch_data:/meili_data
ports:
- "7700:7700"

mysql:
image: mariadb:11
Expand Down
16 changes: 16 additions & 0 deletions docs/docs/hosting/hub-setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,19 @@ having to go through the email verification process.
:::caution
Only enable this if you trust a login provider completely.
:::

### `FEATURE_LIST_POLLING_INTERVAL`

Configures the background polling interval used by content listing views (such as search and user content lists) to dynamically update edit lock statuses across displayed items via HTMX.

* **Default**: `5s`
* **Format**: A time duration string (e.g. `5s`, `10s`, `1m`) or numeric seconds (e.g. `5`, `10`).
* **Disabling**: Set to `false`, `0`, `none`, or `disabled` to disable list polling entirely and minimize server requests.

### `FEATURE_DETAILS_POLLING_INTERVAL`

Configures the background polling interval used by the content details view to dynamically update edit locks and action button states via HTMX.

* **Default**: `5s`
* **Format**: A time duration string (e.g. `5s`, `10s`, `1m`) or numeric seconds (e.g. `5`, `10`).
* **Disabling**: Set to `false`, `0`, `none`, or `disabled` to disable details polling entirely and minimize server requests.
3 changes: 3 additions & 0 deletions sourcecode/hub/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ FEATURE_SIGNUP_ENABLED=true
FEATURE_RESET_PASSWORD_ENABLED=true
FEATURE_NOINDEX=false
FEATURE_SOCIAL_USERS_ARE_VERIFIED=false
FEATURE_CA_CONTENT_TYPE_DISPLAY=h5p
FEATURE_LIST_POLLING_INTERVAL=5s
FEATURE_DETAILS_POLLING_INTERVAL=5s

LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
Expand Down
4 changes: 4 additions & 0 deletions sourcecode/hub/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ yarn-error.log
.env.staging
/.vite
/tools/vapor-cli/vendor

.junie
boost.json
AGENTS.md
47 changes: 47 additions & 0 deletions sourcecode/hub/app/Configuration/Features.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,51 @@ public function socialUsersAreVerified(): bool
{
return $this->enabled('social-users-are-verified');
}

public function getListPollingInterval(): string|null
{
return self::listPollingInterval();
}

public static function listPollingInterval(): string|null
{
$raw = config('features.list-polling-interval') ?? config('features.content-list-polling-interval');

return self::formatPollingInterval($raw);
}

public function getDetailsPollingInterval(): string|null
{
return self::detailsPollingInterval();
}

public static function detailsPollingInterval(): string|null
{
$raw = config('features.details-polling-interval') ?? config('features.content-details-polling-interval');

return self::formatPollingInterval($raw);
}

public static function formatPollingInterval(mixed $value): string|null
{
if ($value === null || $value === false || $value === '' || $value === 0 || $value === '0' || $value === '0s') {
return null;
}

$str = trim((string) $value);
if ($str === '' || $str === '0' || $str === '0s' || in_array(strtolower($str), ['none', 'disabled', 'false', 'off', 'null'], true)) {
return null;
}

if (is_numeric($str)) {
$num = (float) $str;
if ($num <= 0) {
return null;
}

return $str . 's';
}

return $str;
}
}
62 changes: 62 additions & 0 deletions sourcecode/hub/app/DataObjects/ContentDisplayItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@

namespace App\DataObjects;

use App\Models\Content;
use App\Models\ContentVersion;
use App\Support\SessionScope;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

use function app;
use function locale_get_display_language;
use function route;
use function strtoupper;

readonly class ContentDisplayItem
{
Expand All @@ -29,5 +39,57 @@ public function __construct(
public string|null $copyUrl,
public string|null $deleteUrl,
public string|null $lockedByUserName,
public string|null $actionButtonsUrl = null,
public string|null $id = null,
) {}

public static function fromContent(
Content $content,
ContentVersion|null $version = null,
bool $forUser = false,
bool $showDrafts = false,
int $viewsCount = 0,
string|null $contentType = null,
bool $includeActionButtonsUrl = false,
): self {
$version ??= ($showDrafts ? $content->latestVersion : $content->latestPublishedVersion)
?? $content->latestVersion
?? throw new NotFoundHttpException();

$canUse = Gate::allows('use', [$content, $version]);
$canEdit = Gate::allows('edit', [$content, $version]);
$canView = Gate::allows('view', $content);
$canDelete = $forUser && Gate::allows('delete', $content);
$canCopy = Gate::allows('copy', $content);

$languageName = locale_get_display_language($version->language_iso_639_3, app()->getLocale());
$languageName = (!empty($languageName) && $languageName !== $version->language_iso_639_3) ? $languageName : null;

return new self(
title: $version->title,
createdAt: $version->created_at?->toImmutable(),
isPublished: $version->published,
viewsCount: $viewsCount,
contentType: $contentType ?? $version->displayed_content_type,
languageIso639_3: strtoupper($version->language_iso_639_3),
languageDisplayName: $languageName,
users: $content->users->map(fn($user) => $user->name)->join(', '),
detailsUrl: $showDrafts ? route('content.version-details', [$content, $version]) : route('content.details', [$content]),
previewUrl: route('content.preview', [$content, $version]),
useUrl: $canUse ? route('content.use', [$content, $version]) : null,
editUrl: $canEdit ? route('content.edit', [$content, $version]) : null,
shareUrl: $canView ? route('content.share', [$content, SessionScope::TOKEN_PARAM => null]) : null,
shareDialogUrl: $canView ? route('content.share-dialog', [$content]) : null,
copyUrl: $canCopy ? route('content.copy', [$content]) : null,
deleteUrl: $canDelete ? route('content.delete', [$content]) : null,
lockedByUserName: $content->getActiveLock()?->user?->name,
actionButtonsUrl: ($includeActionButtonsUrl && $canEdit) ? route('content.action-buttons', [
$content,
'version' => $version->id,
'forUser' => $forUser ? 1 : 0,
'showDrafts' => $showDrafts ? 1 : 0,
]) : null,
id: $content->id,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ public function update(
});
assert($version instanceof ContentVersion);

$content->releaseLock($user);

return response()->json(
[
'id' => $version->content_id,
Expand Down
99 changes: 99 additions & 0 deletions sourcecode/hub/app/Http/Controllers/ContentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Http\Controllers;

use App\DataObjects\ContentDisplayItem;
use App\DataObjects\LtiCreateInfo;
use App\Enums\ContentRole;
use App\Enums\ContentViewSource;
Expand All @@ -30,6 +31,7 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Session;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
Expand Down Expand Up @@ -426,6 +428,8 @@ public function ltiUpdate(
});
assert($version instanceof ContentVersion);

$content->releaseLock($user);

// return to platform consuming Edlib
if ($request->session()->get('lti.lti_message_type') === 'ContentItemSelectionRequest') {
$ltiRequest = $version->toItemSelectionRequest();
Expand Down Expand Up @@ -523,4 +527,99 @@ public function releaseLock(Content $content): Response

return response()->noContent();
}

public function actionButtons(Content $content, Request $request): View
{
$version = null;
if ($request->filled('version')) {
$version = $content->versions()->whereKey($request->query('version'))->first();
}
$forUser = $request->boolean('forUser');
$showDrafts = $request->boolean('showDrafts');
$version ??= ($showDrafts ? $content->latestVersion : $content->latestPublishedVersion)
?? $content->latestVersion
?? throw new NotFoundHttpException();

$this->authorize('view', [$content, $version]);

$content->loadMissing(['users', 'locks.user']);

$displayItem = ContentDisplayItem::fromContent(
$content,
$version,
forUser: $forUser,
showDrafts: $showDrafts,
includeActionButtonsUrl: true,
);

return view('components.content.action-buttons', [
'content' => $displayItem,
]);
}

public function bulkActionButtons(Request $request): View
{
$ids = (array) $request->input('ids', []);
$forUser = $request->boolean('forUser');
$showDrafts = $request->boolean('showDrafts');

if (empty($ids)) {
return view('content.bulk-action-buttons', [
'displayItems' => collect(),
]);
}

$eagerLoad = ['users', 'locks.user'];
if ($showDrafts) {
$eagerLoad[] = 'latestVersion';
}
if (!$showDrafts || $forUser) {
$eagerLoad[] = 'latestPublishedVersion';
}

$contents = Content::whereIn('id', $ids)
->with($eagerLoad)
->get();

$displayItems = $contents->map(function (Content $content) use ($forUser, $showDrafts) {
$version = ($showDrafts ? $content->latestVersion : $content->latestPublishedVersion)
?? $content->latestVersion;

if (!$version || !Gate::allows('view', [$content, $version]) || !Gate::allows('edit', [$content, $version])) {
return null;
}

return ContentDisplayItem::fromContent(
content: $content,
version: $version,
forUser: $forUser,
showDrafts: $showDrafts,
);
})->filter()->values();

return view('content.bulk-action-buttons', [
'displayItems' => $displayItems,
]);
}

public function detailsActionButtons(Content $content, Request $request): View
{
$version = null;
if ($request->filled('version')) {
$version = $content->versions()->whereKey($request->query('version'))->first();
}
$version ??= $content->getCachedLatestPublishedVersion() ?? $content->latestVersion ?? throw new NotFoundHttpException();
$explicitVersion = $request->boolean('explicitVersion');

$this->authorize('view', [$content, $version]);

$content->loadMissing(['locks.user']);

return view('components.content.details.action-buttons', [
'content' => $content,
'version' => $version,
'explicitVersion' => $explicitVersion,
'includeOob' => true,
]);
}
}
34 changes: 6 additions & 28 deletions sourcecode/hub/app/Http/Requests/ContentFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@

use App\DataObjects\ContentDisplayItem;
use App\Models\Content;
use App\Support\SessionScope;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Laravel\Scout\Builder;
use Override;
Expand Down Expand Up @@ -334,33 +332,13 @@ private function attachModel(array $hits, bool $forUser, bool $showDrafts): Coll
$version = ($showDrafts ? $content->latestVersion : $content->latestPublishedVersion)
?? throw new NotFoundHttpException();

$canUse = Gate::allows('use', [$content, $version]);
$canEdit = Gate::allows('edit', [$content, $version]);
$canView = Gate::allows('view', $content);
$canDelete = $forUser && Gate::allows('delete', $content);
$canCopy = Gate::allows('copy', $content);

$languageName = locale_get_display_language($version->language_iso_639_3, app()->getLocale());
$languageName = (!empty($languageName) && $languageName !== $version->language_iso_639_3) ? $languageName : null;

return new ContentDisplayItem(
title: $version->title,
createdAt: $version->created_at?->toImmutable(),
isPublished: $version->published,
return ContentDisplayItem::fromContent(
content: $content,
version: $version,
forUser: $forUser,
showDrafts: $showDrafts,
viewsCount: $item['views'] ?? 0,
contentType: $item['content_type'] ?? $version->displayed_content_type,
languageIso639_3: strtoupper($version->language_iso_639_3),
languageDisplayName: $languageName,
users: $content->users->map(fn($user) => $user->name)->join(', '),
detailsUrl: $showDrafts ? route('content.version-details', [$content, $version]) : route('content.details', [$content]),
previewUrl: route('content.preview', [$content, $version]),
useUrl: $canUse ? route('content.use', [$content, $version]) : null,
editUrl: $canEdit ? route('content.edit', [$content, $version]) : null,
shareUrl: $canView ? route('content.share', [$content, SessionScope::TOKEN_PARAM => null]) : null,
shareDialogUrl: $canView ? route('content.share-dialog', [$content]) : null,
copyUrl: $canCopy ? route('content.copy', [$content]) : null,
deleteUrl: $canDelete ? route('content.delete', [$content]) : null,
lockedByUserName: $content->getActiveLock()?->user?->name,
contentType: $item['content_type'] ?? null,
);
});
}
Expand Down
1 change: 1 addition & 0 deletions sourcecode/hub/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"ekino/phpstan-banned-code": "^3.0",
"fakerphp/faker": "^1.24.1",
"larastan/larastan": "^3",
"laravel/boost": "^2.9",
"laravel/dusk": "^8.2.12",
"laravel/telescope": "^5.2.6",
"mockery/mockery": "^1.6.12",
Expand Down
Loading
Loading