@@ -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 ,
0 commit comments