<?php
|
namespace JVBase\rest;
|
|
use WP_REST_Request;
|
use WP_Error;
|
use WP_User;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Centralized Permission Handler for REST Routes
|
*
|
* Provides reusable permission callbacks and utilities for route authentication.
|
*/
|
class PermissionHandler
|
{
|
/**
|
* Check if the 'user' parameter in request matches current logged-in user
|
* Common pattern for user-specific endpoints
|
*/
|
public static function userMatch(WP_REST_Request $request): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in to access this resource',
|
['status' => 401]
|
);
|
}
|
|
$requestedUserId = $request->get_param('user');
|
|
// No user param specified - allow (controller will handle)
|
if (empty($requestedUserId)) {
|
return true;
|
}
|
|
$currentUserId = get_current_user_id();
|
|
if ((int) $requestedUserId !== $currentUserId) {
|
return new WP_Error(
|
'forbidden',
|
'You can only access your own resources',
|
['status' => 403]
|
);
|
}
|
|
return true;
|
}
|
|
/**
|
* Check if current user is an administrator
|
*/
|
public static function isAdmin(WP_REST_Request $request): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in',
|
['status' => 401]
|
);
|
}
|
|
if (!current_user_can('manage_options')) {
|
return new WP_Error(
|
'forbidden',
|
'Administrator access required',
|
['status' => 403]
|
);
|
}
|
|
return true;
|
}
|
|
/**
|
* Check if current user is verified (has skip_moderation capability)
|
*/
|
public static function isVerified(WP_REST_Request $request): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in',
|
['status' => 401]
|
);
|
}
|
|
if (!current_user_can('skip_moderation')) {
|
return new WP_Error(
|
'not_verified',
|
'Account verification required',
|
['status' => 403]
|
);
|
}
|
|
return true;
|
}
|
|
/**
|
* Check if current user has a specific role
|
*/
|
public static function hasRole(WP_REST_Request $request, string $role): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in',
|
['status' => 401]
|
);
|
}
|
|
$user = wp_get_current_user();
|
$role = self::normalizeRole($role);
|
|
if (!in_array($role, $user->roles, true)) {
|
return new WP_Error(
|
'forbidden',
|
'You do not have the required role',
|
['status' => 403]
|
);
|
}
|
|
return true;
|
}
|
|
/**
|
* Check if current user has any of the specified roles
|
*/
|
public static function hasAnyRole(WP_REST_Request $request, array $roles): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in',
|
['status' => 401]
|
);
|
}
|
|
$user = wp_get_current_user();
|
$normalizedRoles = array_map([self::class, 'normalizeRole'], $roles);
|
|
foreach ($user->roles as $userRole) {
|
if (in_array($userRole, $normalizedRoles, true)) {
|
return true;
|
}
|
}
|
|
return new WP_Error(
|
'forbidden',
|
'You do not have any of the required roles',
|
['status' => 403]
|
);
|
}
|
|
/**
|
* Check if current user has all specified roles
|
*/
|
public static function hasAllRoles(WP_REST_Request $request, array $roles): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in',
|
['status' => 401]
|
);
|
}
|
|
$user = wp_get_current_user();
|
$normalizedRoles = array_map([self::class, 'normalizeRole'], $roles);
|
|
foreach ($normalizedRoles as $role) {
|
if (!in_array($role, $user->roles, true)) {
|
return new WP_Error(
|
'forbidden',
|
'You do not have all required roles',
|
['status' => 403]
|
);
|
}
|
}
|
|
return true;
|
}
|
|
/**
|
* Check if current user has a specific capability
|
*/
|
public static function hasCapability(WP_REST_Request $request, string $capability): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in',
|
['status' => 401]
|
);
|
}
|
|
if (!current_user_can($capability)) {
|
return new WP_Error(
|
'forbidden',
|
'You do not have the required permission',
|
['status' => 403]
|
);
|
}
|
|
return true;
|
}
|
|
/**
|
* Check if current user has any of the specified capabilities
|
*/
|
public static function hasAnyCapability(WP_REST_Request $request, array $capabilities): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in',
|
['status' => 401]
|
);
|
}
|
|
foreach ($capabilities as $cap) {
|
if (current_user_can($cap)) {
|
return true;
|
}
|
}
|
|
return new WP_Error(
|
'forbidden',
|
'You do not have any of the required permissions',
|
['status' => 403]
|
);
|
}
|
|
/**
|
* Check if user owns a specific post
|
*/
|
public static function ownsPost(WP_REST_Request $request, string $paramName = 'post_id'): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in',
|
['status' => 401]
|
);
|
}
|
|
$postId = $request->get_param($paramName);
|
|
if (empty($postId)) {
|
return new WP_Error(
|
'missing_param',
|
'Post ID is required',
|
['status' => 400]
|
);
|
}
|
|
$post = get_post($postId);
|
|
if (!$post) {
|
return new WP_Error(
|
'not_found',
|
'Post not found',
|
['status' => 404]
|
);
|
}
|
|
if ((int) $post->post_author !== get_current_user_id()) {
|
// Allow admins to bypass ownership check
|
if (!current_user_can('manage_options')) {
|
return new WP_Error(
|
'forbidden',
|
'You can only modify your own content',
|
['status' => 403]
|
);
|
}
|
}
|
|
return true;
|
}
|
|
/**
|
* Check if user can edit a specific post type
|
*/
|
public static function canEditPostType(WP_REST_Request $request, string $postType): bool|WP_Error
|
{
|
if (!is_user_logged_in()) {
|
return new WP_Error(
|
'not_logged_in',
|
'You must be logged in',
|
['status' => 401]
|
);
|
}
|
|
$postTypeObj = get_post_type_object($postType);
|
|
if (!$postTypeObj) {
|
return new WP_Error(
|
'invalid_post_type',
|
'Invalid post type',
|
['status' => 400]
|
);
|
}
|
|
if (!current_user_can($postTypeObj->cap->edit_posts)) {
|
return new WP_Error(
|
'forbidden',
|
'You cannot edit this content type',
|
['status' => 403]
|
);
|
}
|
|
return true;
|
}
|
|
/**
|
* Verify nonce from request header
|
*/
|
public static function verifyNonce(WP_REST_Request $request, string $action = 'wp_rest', string $header = 'X-WP-Nonce'): bool|WP_Error
|
{
|
$nonce = $request->get_header($header);
|
|
if (empty($nonce)) {
|
return new WP_Error(
|
'missing_nonce',
|
'Security token is missing',
|
['status' => 403]
|
);
|
}
|
|
if (!wp_verify_nonce($nonce, $action)) {
|
return new WP_Error(
|
'invalid_nonce',
|
'Invalid or expired security token',
|
['status' => 403]
|
);
|
}
|
|
return true;
|
}
|
|
/**
|
* Verify action-specific nonce (e.g., 'dash-{user_id}')
|
*/
|
public static function verifyActionNonce(WP_REST_Request $request, string $actionPrefix, string $header = 'X-Action-Nonce'): bool|WP_Error
|
{
|
$userId = absint($request->get_param('user'));
|
if ($userId === 0) {
|
return false;
|
}
|
|
$action = $actionPrefix . $userId;
|
|
return self::verifyNonce($request, $action, $header);
|
}
|
|
/**
|
* Create a custom permission callback combining multiple checks
|
*
|
* Usage:
|
* PermissionHandler::combine(['logged_in', 'verified'])
|
* PermissionHandler::combine([['role' => 'artist'], ['capability' => 'edit_posts']])
|
*/
|
public static function combine(array $checks): callable
|
{
|
return function(WP_REST_Request $request) use ($checks) {
|
foreach ($checks as $check) {
|
$result = match (true) {
|
$check === 'logged_in' => is_user_logged_in() ?: new WP_Error('not_logged_in', 'Login required', ['status' => 401]),
|
$check === 'admin' => self::isAdmin($request),
|
$check === 'verified' => self::isVerified($request),
|
$check === 'user' => self::userMatch($request),
|
$check === 'nonce' => self::verifyNonce($request),
|
is_array($check) && isset($check['role']) => self::hasRole($request, $check['role']),
|
is_array($check) && isset($check['roles']) => self::hasAnyRole($request, $check['roles']),
|
is_array($check) && isset($check['capability']) => self::hasCapability($request, $check['capability']),
|
is_array($check) && isset($check['actionNonce']) => self::verifyActionNonce($request, $check['actionNonce']),
|
is_callable($check) => $check($request),
|
default => true,
|
};
|
|
if (is_wp_error($result)) {
|
return $result;
|
}
|
|
if ($result === false) {
|
return new WP_Error('forbidden', 'Access denied', ['status' => 403]);
|
}
|
}
|
|
return true;
|
};
|
}
|
|
/**
|
* Create an OR permission callback (passes if ANY check passes)
|
*/
|
public static function any(array $checks): callable
|
{
|
return function(WP_REST_Request $request) use ($checks) {
|
$lastError = null;
|
|
foreach ($checks as $check) {
|
$result = match (true) {
|
$check === 'logged_in' => is_user_logged_in(),
|
$check === 'admin' => self::isAdmin($request),
|
$check === 'verified' => self::isVerified($request),
|
$check === 'user' => self::userMatch($request),
|
$check === 'nonce' => self::verifyNonce($request),
|
is_array($check) && isset($check['role']) => self::hasRole($request, $check['role']),
|
is_array($check) && isset($check['roles']) => self::hasAnyRole($request, $check['roles']),
|
is_array($check) && isset($check['capability']) => self::hasCapability($request, $check['capability']),
|
is_array($check) && isset($check['actionNonce']) => self::verifyActionNonce($request, $check['actionNonce']),
|
is_callable($check) => $check($request),
|
default => false,
|
};
|
|
if ($result === true) {
|
return true;
|
}
|
|
if (is_wp_error($result)) {
|
$lastError = $result;
|
}
|
}
|
|
return $lastError ?: new WP_Error('forbidden', 'Access denied', ['status' => 403]);
|
};
|
}
|
|
/**
|
* Normalize role name (add BASE prefix if needed)
|
*/
|
private static function normalizeRole(string $role): string
|
{
|
if (defined('BASE') && !str_starts_with($role, BASE)) {
|
// Check if it's a WordPress core role
|
$coreRoles = ['administrator', 'editor', 'author', 'contributor', 'subscriber'];
|
if (!in_array($role, $coreRoles, true)) {
|
return BASE . $role;
|
}
|
}
|
return $role;
|
}
|
|
/**
|
* Get current user for request (cached)
|
*/
|
public static function getCurrentUser(): ?WP_User
|
{
|
static $user = null;
|
|
if ($user === null && is_user_logged_in()) {
|
$user = wp_get_current_user();
|
}
|
|
return $user instanceof WP_User ? $user : null;
|
}
|
|
/**
|
* Check if request is from same origin (basic CSRF protection)
|
*/
|
public static function verifySameOrigin(WP_REST_Request $request): bool|WP_Error
|
{
|
$origin = $request->get_header('Origin');
|
$referer = $request->get_header('Referer');
|
|
$siteUrl = get_site_url();
|
$siteDomain = parse_url($siteUrl, PHP_URL_HOST);
|
|
// Check origin header
|
if ($origin) {
|
$originDomain = parse_url($origin, PHP_URL_HOST);
|
if ($originDomain !== $siteDomain) {
|
return new WP_Error(
|
'cross_origin',
|
'Cross-origin requests not allowed',
|
['status' => 403]
|
);
|
}
|
}
|
|
// Check referer as fallback
|
if (!$origin && $referer) {
|
$refererDomain = parse_url($referer, PHP_URL_HOST);
|
if ($refererDomain !== $siteDomain) {
|
return new WP_Error(
|
'cross_origin',
|
'Cross-origin requests not allowed',
|
['status' => 403]
|
);
|
}
|
}
|
|
return true;
|
}
|
}
|