Skip to content

Commit b0a49ab

Browse files
committed
Merge branch 'release/1.0.9'
2 parents af58f79 + 48e43db commit b0a49ab

11 files changed

Lines changed: 684 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
# v1.0.9
2+
## 07/06/2026
3+
4+
1. [](#new)
5+
* The new admin can now retrieve the Grav core changelog for versions newer than the one installed, so it can show what changed before you upgrade ([getgrav/grav-plugin-admin2#109](https://github.com/getgrav/grav-plugin-admin2/issues/109)).
6+
* Plugins can now add their own columns to the new admin's Users list, contributing safe per-user values that stay scoped to the page you are viewing ([getgrav/grav-plugin-admin2#111](https://github.com/getgrav/grav-plugin-admin2/issues/111)).
7+
1. [](#improved)
8+
* Media listings now include each file's saved alt text and title from its metadata, so the admin can insert an image with proper alt text instead of the filename ([getgrav/grav-plugin-admin2#114](https://github.com/getgrav/grav-plugin-admin2/issues/114)).
9+
1. [](#bugfix)
10+
* The page summary preview now returns clean stripped text instead of a slice of raw Markdown, so leading links and images no longer leave broken fragments in the new admin's page list ([getgrav/grav-plugin-admin2#110](https://github.com/getgrav/grav-plugin-admin2/issues/110)).
11+
* Media files whose extension has any uppercase letters, such as `.JPG`, can now be deleted instead of failing with a not-found error ([getgrav/grav#4196](https://github.com/getgrav/grav/issues/4196)).
12+
113
# v1.0.8
214
## 07/04/2026
315

blueprints.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: API
22
slug: api
33
type: plugin
4-
version: 1.0.8
4+
version: 1.0.9
55
description: RESTful API for Grav CMS. Provides headless access to pages, media, configuration, users, and system management.
66
icon: plug
77
author:

classes/Api/ApiRouter.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,9 +410,15 @@ protected function dispatch(ServerRequestInterface $request): ResponseInterface
410410
// page type (e.g. .md, .txt, .html) before the route is built. Without
411411
// this re-attach, `DELETE /api/v1/media/notes.txt` would arrive as
412412
// `/media/notes` and 404.
413+
//
414+
// RequestProcessor lowercases the extension, but the route path keeps
415+
// the file's original case. Compare case-insensitively so an uppercase
416+
// extension (e.g. `photo.JPG`) is not treated as missing and a duplicate
417+
// `.jpg` appended — which turned the filename into `photo.JPG.jpg` and
418+
// 404'd every media file with a non-lowercase extension (getgrav/grav#4196).
413419
if ($route) {
414420
$extension = (string)$route->getExtension();
415-
if ($extension !== '' && !str_ends_with($gravPath, '.' . $extension)) {
421+
if ($extension !== '' && !str_ends_with(strtolower($gravPath), '.' . strtolower($extension))) {
416422
$gravPath .= '.' . $extension;
417423
}
418424
}
@@ -590,6 +596,7 @@ protected function registerCoreRoutes(RouteCollector $r): void
590596
// Static route registered before the /users/{username} catch-all so the
591597
// tab-discovery endpoint is never swallowed as a username lookup.
592598
$r->addRoute('GET', '/users/filters', [UsersController::class, 'filters']);
599+
$r->addRoute('GET', '/users/columns', [UsersController::class, 'columns']);
593600
$r->addRoute('POST', '/users', [UsersController::class, 'create']);
594601
$r->addRoute('GET', '/users/{username}', [UsersController::class, 'show']);
595602
$r->addRoute('PATCH', '/users/{username}', [UsersController::class, 'update']);
@@ -637,6 +644,7 @@ protected function registerCoreRoutes(RouteCollector $r): void
637644
$r->addRoute('GET', '/gpm/themes/{slug}/fields', [GpmController::class, 'customFieldBundle']);
638645
$r->addRoute('GET', '/gpm/themes/{slug}/field/{type}', [GpmController::class, 'customFieldScript']);
639646
$r->addRoute('GET', '/gpm/updates', [GpmController::class, 'updates']);
647+
$r->addRoute('GET', '/gpm/grav/changelog', [GpmController::class, 'gravChangelog']);
640648
$r->addRoute('POST', '/gpm/install', [GpmController::class, 'install']);
641649
$r->addRoute('POST', '/gpm/remove', [GpmController::class, 'remove']);
642650
$r->addRoute('POST', '/gpm/update', [GpmController::class, 'update']);

classes/Api/Controllers/GpmController.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,41 @@ public function changelog(ServerRequestInterface $request): ResponseInterface
11761176
]);
11771177
}
11781178

1179+
/**
1180+
* GET /gpm/grav/changelog - Get the Grav core changelog for versions newer
1181+
* than the one currently installed, assembled into a single markdown doc.
1182+
*/
1183+
public function gravChangelog(ServerRequestInterface $request): ResponseInterface
1184+
{
1185+
$this->requirePermission($request, self::PERMISSION_READ);
1186+
1187+
$query = $request->getQueryParams();
1188+
$flush = filter_var($query['flush'] ?? false, FILTER_VALIDATE_BOOLEAN);
1189+
1190+
$gravInfo = $this->getGpm($flush)->getGrav();
1191+
1192+
// Only show entries newer than the installed version.
1193+
$changelog = $gravInfo ? (array) $gravInfo->getChangelog(GRAV_VERSION) : [];
1194+
1195+
// Each entry is either a markdown string or ['date' => ..., 'content' => markdown].
1196+
$parts = [];
1197+
foreach ($changelog as $version => $entry) {
1198+
$date = is_array($entry) ? ($entry['date'] ?? '') : '';
1199+
$body = is_array($entry) ? ($entry['content'] ?? '') : $entry;
1200+
$body = is_string($body) ? trim($body) : '';
1201+
1202+
$heading = "# v{$version}";
1203+
if ($date !== '') {
1204+
$heading .= " ({$date})";
1205+
}
1206+
$parts[] = "{$heading}\n\n{$body}";
1207+
}
1208+
1209+
return ApiResponse::create([
1210+
'content' => implode("\n\n", $parts),
1211+
]);
1212+
}
1213+
11791214
/**
11801215
* Resolve the filesystem path for an installed package.
11811216
*/

classes/Api/Controllers/MediaController.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,16 @@ private function serializeSiteFile(string $basePath, string $filename, string $r
16061606
'size' => (int) filesize($filePath),
16071607
];
16081608

1609+
// Alt/title from the `.meta.yaml` sidecar so the media panel can insert
1610+
// `![alt](file)` rather than overwriting alt with the filename.
1611+
$meta = $this->readMetaSidecar($filePath);
1612+
if (isset($meta['alt']) && is_scalar($meta['alt']) && (string) $meta['alt'] !== '') {
1613+
$data['alt'] = (string) $meta['alt'];
1614+
}
1615+
if (isset($meta['title']) && is_scalar($meta['title']) && (string) $meta['title'] !== '') {
1616+
$data['title'] = (string) $meta['title'];
1617+
}
1618+
16091619
if (str_starts_with($mime, 'image/') && $mime !== 'image/svg+xml') {
16101620
if ($imageSize = @getimagesize($filePath)) {
16111621
$data['dimensions'] = [

classes/Api/Controllers/UsersController.php

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ class UsersController extends AbstractApiController
3030
/** 8 MB cap — a profile avatar shouldn't be anywhere near this. */
3131
private const AVATAR_MAX_SIZE = 8_388_608;
3232

33+
/**
34+
* Client-side renderers a plugin column may name. The server only ever
35+
* declares *which* formatter renders a value; the rendering itself is the
36+
* client's job. No renderer function or HTML crosses the wire, so a plugin
37+
* can't smuggle markup or behaviour through a column. Unknown values fall
38+
* back to 'text'.
39+
*/
40+
private const COLUMN_FORMATTERS = ['text', 'link', 'date', 'datetime', 'boolean', 'number', 'badge'];
41+
42+
/** Hard caps so a misbehaving plugin can't bloat a list response. */
43+
private const COLUMN_MAX_FIELDS = 32;
44+
private const COLUMN_VALUE_MAX_LEN = 2048;
45+
3346
private ?UserSerializer $serializer = null;
3447

3548
public function index(ServerRequestInterface $request): ResponseInterface
@@ -102,6 +115,194 @@ public function filters(ServerRequestInterface $request): ResponseInterface
102115
return ApiResponse::create($this->assembleFilterTabs($event, $user));
103116
}
104117

118+
/**
119+
* GET /users/columns — plugin-declared extra columns for the Users list.
120+
*
121+
* The presentation half of the Users extension contract (getgrav/
122+
* grav-plugin-admin2#111). A plugin declares columns via the
123+
* `onApiUserListColumns` event; Admin2 owns the table, and the per-user
124+
* values ride along inside each user's `extra` map on GET /users (populated
125+
* by `onApiUserListColumnData`, scoped to the current page — never a
126+
* parallel all-users fetch).
127+
*
128+
* Column format:
129+
* [
130+
* 'id' => 'my-plugin-valid-till', // required, unique; client key
131+
* 'plugin' => 'my-plugin', // owning plugin slug
132+
* 'label' => 'Valid until', // display name (raw text)
133+
* 'field' => 'subscription.valid_till', // key into each user's `extra`
134+
* 'formatter' => 'datetime', // one of COLUMN_FORMATTERS; else 'text'
135+
* 'sortable' => false, // client-side, current page only
136+
* 'priority' => 50, // optional sort order (higher = earlier)
137+
* 'authorize' => 'api.users.read', // optional — string or array for any-of
138+
* ]
139+
*
140+
* Deliberately narrow: scalar data only, a fixed formatter whitelist, no
141+
* raw HTML or renderer functions. That keeps plugin columns from becoming
142+
* the kind of open-ended surface that let classic-admin plugins break on
143+
* upgrade.
144+
*
145+
* Response shape: { "columns": [ ... ] }
146+
*/
147+
public function columns(ServerRequestInterface $request): ResponseInterface
148+
{
149+
// Columns only mean something to a caller who can list users.
150+
$this->requirePermission($request, 'api.users.read');
151+
152+
$user = $this->getUser($request);
153+
$event = $this->fireEvent('onApiUserListColumns', [
154+
'columns' => [],
155+
'user' => $user,
156+
]);
157+
158+
return ApiResponse::create(['columns' => $this->assembleColumns($event, $user)]);
159+
}
160+
161+
/**
162+
* Validate and normalize plugin-declared columns: drop malformed entries and
163+
* columns the caller isn't authorized for, whitelist the formatter, sanitize
164+
* the field key, then order by descending priority. Mirrors
165+
* assembleFilterTabs() — the `authorize` field is a server-side annotation
166+
* and is stripped before the column reaches the client.
167+
*
168+
* @param Event $event The onApiUserListColumns event after plugins ran
169+
* @return array<int, array<string, mixed>>
170+
*/
171+
private function assembleColumns(Event $event, UserInterface $user): array
172+
{
173+
$isSuperAdmin = $this->isSuperAdmin($user);
174+
175+
$columns = [];
176+
$seen = [];
177+
foreach ((array) ($event['columns'] ?? []) as $column) {
178+
if (!is_array($column) || !isset($column['id']) || !is_string($column['id']) || $column['id'] === '') {
179+
continue;
180+
}
181+
if (isset($seen[$column['id']])) {
182+
continue; // first declaration of an id wins
183+
}
184+
if (!$this->userPassesAuthorize($user, $column['authorize'] ?? null, $isSuperAdmin)) {
185+
continue;
186+
}
187+
188+
// The field is the key looked up in each user's `extra` map. Keep it
189+
// to a safe identifier charset — no traversal or odd keys.
190+
$field = isset($column['field']) && is_string($column['field'])
191+
? preg_replace('/[^A-Za-z0-9_.\-]/', '', $column['field'])
192+
: '';
193+
if ($field === '') {
194+
continue;
195+
}
196+
197+
$formatter = isset($column['formatter']) && is_string($column['formatter'])
198+
&& in_array($column['formatter'], self::COLUMN_FORMATTERS, true)
199+
? $column['formatter']
200+
: 'text';
201+
202+
$seen[$column['id']] = true;
203+
$column['field'] = $field;
204+
$column['formatter'] = $formatter;
205+
$column['sortable'] = (bool) ($column['sortable'] ?? false);
206+
// Strip the authorize field — server-side annotation, not client data.
207+
unset($column['authorize']);
208+
$columns[] = $column;
209+
}
210+
211+
usort($columns, fn($a, $b) => ($b['priority'] ?? 0) <=> ($a['priority'] ?? 0));
212+
213+
return $columns;
214+
}
215+
216+
/**
217+
* Merge plugin-owned scalar column data into an already-serialized,
218+
* already-paginated page of users. Fired ONCE for the whole page (the
219+
* `onApiUserListColumnData` event receives only the usernames already
220+
* selected after search / filter / permission / pagination), so there is no
221+
* N+1 and no incentive to load metadata for every account.
222+
*
223+
* Hard isolation: a throwing or misbehaving subscriber can never 500 or
224+
* stall the listing — failures degrade to missing column values, logged as
225+
* a warning. Plugin values are scalar-only and capped.
226+
*
227+
* @param array<int, array<string, mixed>> $data Serialized users for this page
228+
* @return array<int, array<string, mixed>>
229+
*/
230+
private function applyColumnData(array $data, UserInterface $currentUser): array
231+
{
232+
if ($data === []) {
233+
return $data;
234+
}
235+
236+
$usernames = array_values(array_filter(array_column($data, 'username'), 'is_string'));
237+
if ($usernames === []) {
238+
return $data;
239+
}
240+
241+
try {
242+
$event = $this->fireEvent('onApiUserListColumnData', [
243+
'usernames' => $usernames,
244+
'data' => [], // plugin fills: username => [ field => scalar ]
245+
'user' => $currentUser,
246+
]);
247+
248+
$map = $event['data'] ?? null;
249+
if (!is_array($map) || $map === []) {
250+
return $data;
251+
}
252+
253+
foreach ($data as &$row) {
254+
$extra = $map[$row['username']] ?? null;
255+
if (is_array($extra)) {
256+
$clean = $this->sanitizeColumnValues($extra);
257+
if ($clean !== []) {
258+
$row['extra'] = $clean;
259+
}
260+
}
261+
}
262+
unset($row);
263+
} catch (\Throwable $e) {
264+
// Isolation: a plugin fault must not break the users list.
265+
$this->grav['log']->warning('[api] onApiUserListColumnData failed: ' . $e->getMessage());
266+
}
267+
268+
return $data;
269+
}
270+
271+
/**
272+
* Enforce the column-data contract on one user's plugin values: scalars (or
273+
* null) only — arrays, objects and resources are rejected so a plugin can't
274+
* leak blobs or nested structures — with a safe key charset and per-value
275+
* and per-user size caps.
276+
*
277+
* @param array<mixed, mixed> $extra
278+
* @return array<string, string|int|float|bool|null>
279+
*/
280+
private function sanitizeColumnValues(array $extra): array
281+
{
282+
$clean = [];
283+
foreach ($extra as $key => $value) {
284+
if (count($clean) >= self::COLUMN_MAX_FIELDS) {
285+
break;
286+
}
287+
if (!is_string($key)) {
288+
continue;
289+
}
290+
$key = preg_replace('/[^A-Za-z0-9_.\-]/', '', $key);
291+
if ($key === '') {
292+
continue;
293+
}
294+
if ($value !== null && !is_scalar($value)) {
295+
continue; // scalar-only: drop arrays/objects/resources
296+
}
297+
if (is_string($value) && strlen($value) > self::COLUMN_VALUE_MAX_LEN) {
298+
$value = substr($value, 0, self::COLUMN_VALUE_MAX_LEN);
299+
}
300+
$clean[$key] = $value;
301+
}
302+
303+
return $clean;
304+
}
305+
105306
/**
106307
* Merge plugin-contributed Users tabs with the built-in "All Users" tab,
107308
* dropping malformed entries and tabs the caller isn't authorized for, then
@@ -273,6 +474,11 @@ private function indexViaFlex(ServerRequestInterface $request, FlexDirectory $di
273474
}
274475
}
275476

477+
// Let plugins attach their declared column values to this page of
478+
// users (getgrav/grav-plugin-admin2#111). Scoped to the served page,
479+
// applied after pagination — the indexed fast path above is untouched.
480+
$data = $this->applyColumnData($data, $this->getUser($request));
481+
276482
return ApiResponse::paginated(
277483
data: $data,
278484
total: $total,
@@ -310,6 +516,10 @@ private function indexViaAccounts(ServerRequestInterface $request): ResponseInte
310516
$total = count($allUsers);
311517
$paged = array_slice($allUsers, $pagination['offset'], $pagination['limit']);
312518

519+
// Column data is resolved for the served page only — never $allUsers —
520+
// so a plugin isn't pushed into loading metadata for every account.
521+
$paged = $this->applyColumnData($paged, $this->getUser($request));
522+
313523
return ApiResponse::paginated(
314524
data: $paged,
315525
total: $total,

classes/Api/Serializers/MediaSerializer.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ public function serialize(object $medium, array $options = []): array
2727
'size' => (int) ($medium->get('size') ?? 0),
2828
];
2929

30+
// Alt/title come from the `.meta.yaml` sidecar, which Grav merges into
31+
// the medium's attributes. Exposing them here lets the media panel
32+
// insert `![alt](file)` instead of overwriting alt with the filename.
33+
$alt = $medium->get('alt');
34+
if (is_string($alt) && $alt !== '') {
35+
$data['alt'] = $alt;
36+
}
37+
$title = $medium->get('title');
38+
if (is_string($title) && $title !== '') {
39+
$data['title'] = $title;
40+
}
41+
3042
if (str_starts_with($mime, 'image/')) {
3143
$width = $medium->get('width');
3244
$height = $medium->get('height');

0 commit comments

Comments
 (0)