dga_feedback-2.0.0/src/Controller/DgaFeedbackController.php
src/Controller/DgaFeedbackController.php
<?php
namespace Drupal\dga_feedback\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\dga_feedback\Service\DgaFeedbackService;
use Drupal\node\Entity\Node;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Controller for handling feedback AJAX requests.
*/
class DgaFeedbackController extends ControllerBase {
/**
* The feedback service.
*
* @var \Drupal\dga_feedback\Service\DgaFeedbackService
*/
protected $feedbackService;
/**
* Constructs a DgaFeedbackController object.
*
* @param \Drupal\dga_feedback\Service\DgaFeedbackService $feedback_service
* The feedback service.
*/
public function __construct(DgaFeedbackService $feedback_service) {
$this->feedbackService = $feedback_service;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static($container->get('dga_feedback.service'));
}
/**
* Handles feedback submission.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* JSON response.
*/
public function submitFeedback(Request $request) {
$config = $this->config('dga_feedback.settings');
$language_manager = \Drupal::languageManager();
$current_lang = $language_manager->getCurrentLanguage()->getId();
$is_arabic = ($current_lang === 'ar');
$getTranslation = function ($base_key, $default_en, $default_ar = NULL) use ($config, $is_arabic) {
$value_en = $config->get($base_key . '_en');
$value_ar = $config->get($base_key . '_ar');
if ($is_arabic) {
if (!empty($value_ar)) {
return $value_ar;
}
if (!empty($value_en)) {
return $value_en;
}
return $default_ar ?? $default_en;
}
if (!empty($value_en)) {
return $value_en;
}
return $default_en;
};
// Handle CORS preflight (OPTIONS) requests
if ($request->getMethod() === 'OPTIONS') {
$response = new JsonResponse([]);
$response->headers->set('Access-Control-Allow-Origin', '*');
$response->headers->set('Access-Control-Allow-Methods', 'POST, OPTIONS');
$response->headers->set('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token');
return $response;
}
// Reject GET requests - this endpoint is POST only (AJAX)
if ($request->getMethod() !== 'POST') {
return new JsonResponse([
'success' => FALSE,
'message' => $getTranslation('api_method_not_allowed', 'Method not allowed. Use POST.', 'الطريقة غير مسموحة. استخدم POST.'),
], 405);
}
$user = $this->currentUser();
$is_anon = !$user->isAuthenticated();
// Parse JSON from AJAX request
$data = json_decode($request->getContent(), TRUE);
if (!$data) {
return new JsonResponse([
'success' => FALSE,
'message' => $getTranslation('api_invalid_json', 'Invalid JSON data', 'بيانات JSON غير صالحة'),
], 400);
}
// Validate is_useful
if (!isset($data['is_useful']) || !in_array($data['is_useful'], ['yes', 'no'])) {
return new JsonResponse([
'success' => FALSE,
'message' => $getTranslation('api_invalid_useful', 'is_useful must be "yes" or "no"', 'يجب أن يكون is_useful "yes" أو "no"'),
], 400);
}
// Normalize URL
$url = isset($data['url']) ? trim($data['url']) : '/';
if (empty($url) || $url === '/') {
$url = '/';
} else {
if (preg_match('#^/[a-z]{2}(/.*)?$#', $url, $matches)) {
$url = isset($matches[1]) ? $matches[1] : '/';
if (empty($url)) $url = '/';
}
$url = rtrim($url, '/') ?: '/';
}
// Save data
$entity_id = 0;
if ($user->isAuthenticated() && isset($data['entity_id']) && (int) $data['entity_id'] > 0) {
$entity_id = (int) $data['entity_id'];
}
// Get configurable limits from settings
$reason_max_length = (int) ($config->get('reason_max_length') ?? 200);
$reason_max_count = (int) ($config->get('reason_max_count') ?? 10);
$feedback_max_length = (int) ($config->get('feedback_max_length') ?? 5000);
// Validate and sanitize required fields
// SECURITY: Validate reasons array
if (empty($data['reasons']) || !is_array($data['reasons']) || count($data['reasons']) === 0) {
return new JsonResponse([
'success' => FALSE,
'message' => $getTranslation('validation_reason_required', 'Please select at least one reason', 'يرجى اختيار سبب واحد على الأقل'),
], 400);
}
// SECURITY: Sanitize and limit reasons array (configurable limits)
$sanitized_reasons = [];
foreach ($data['reasons'] as $reason) {
if (is_string($reason)) {
$reason = trim(strip_tags($reason));
if (strlen($reason) > 0 && strlen($reason) <= $reason_max_length) {
$sanitized_reasons[] = $reason;
}
}
if (count($sanitized_reasons) >= $reason_max_count) {
break; // Limit to configured max reasons
}
}
if (count($sanitized_reasons) === 0) {
return new JsonResponse([
'success' => FALSE,
'message' => $getTranslation('validation_reason_invalid', 'At least one valid reason must be selected.', 'يجب اختيار سبب واحد صالح على الأقل.'),
], 400);
}
// SECURITY: Validate and sanitize feedback text (configurable max length)
$feedback_text = isset($data['feedback']) ? trim($data['feedback']) : '';
if (empty($feedback_text)) {
return new JsonResponse([
'success' => FALSE,
'message' => $getTranslation('validation_feedback_required', 'Please provide feedback text', 'يرجى تقديم نص التعليق'),
], 400);
}
// SECURITY: Limit feedback length and strip HTML tags
$feedback_text = strip_tags($feedback_text);
if (strlen($feedback_text) > $feedback_max_length) {
$feedback_text = substr($feedback_text, 0, $feedback_max_length);
}
// SECURITY: Validate gender
if (empty($data['gender']) || !in_array($data['gender'], ['male', 'female'])) {
return new JsonResponse([
'success' => FALSE,
'message' => $getTranslation('validation_gender_required', 'Please select your gender', 'يرجى اختيار جنسك'),
], 400);
}
// SECURITY: Rate limiting for anonymous users (configurable)
// This prevents spam while allowing legitimate testing and usage
if ($is_anon) {
$rate_limit_max = (int) ($config->get('rate_limit_max_submissions') ?? 20);
$rate_limit_window = (int) ($config->get('rate_limit_time_window') ?? 3600);
// Only apply rate limiting if enabled (max > 0)
if ($rate_limit_max > 0) {
$ip_address = $request->getClientIp();
$recent_count = \Drupal::database()
->select('dga_feedback', 'f')
->condition('f.ip_address', $ip_address)
->condition('f.created', time() - $rate_limit_window, '>=')
->countQuery()
->execute()
->fetchField();
if ($recent_count >= $rate_limit_max) {
return new JsonResponse([
'success' => FALSE,
'message' => $getTranslation('api_rate_limit', 'Too many submissions. Please try again later.', 'عدد كبير جدًا من الإرسالات. يرجى المحاولة مرة أخرى لاحقًا.'),
], 429);
}
}
}
$save_data = [
'entity_type' => $data['entity_type'] ?? 'node',
'entity_id' => $entity_id,
'is_useful' => $data['is_useful'],
'reasons' => $sanitized_reasons,
'feedback' => $feedback_text,
'gender' => $data['gender'],
'url' => $url,
'user_id' => $is_anon ? NULL : $user->id(),
'ip_address' => $request->getClientIp(),
];
// CSRF token check skipped for anonymous users
// Save feedback
$feedback_id = $this->feedbackService->saveFeedback($save_data);
// Log submission details for debugging (with truncated feedback preview)
if ($feedback_id && is_numeric($feedback_id) && (int) $feedback_id > 0) {
$feedback_preview = $save_data['feedback'];
if (strlen($feedback_preview) > 100) {
$feedback_preview = substr($feedback_preview, 0, 100) . '...';
}
\Drupal::logger('dga_feedback')->info('Feedback saved - ID: @id, User: @user, Useful: @useful, Reasons: @reasons, Feedback: @feedback, Gender: @gender, URL: @url, Entity: @entity', [
'@id' => (int) $feedback_id,
'@user' => $is_anon ? 'anonymous' : 'user_' . $user->id(),
'@useful' => $save_data['is_useful'] === 'yes' ? 'Yes' : 'No',
'@reasons' => implode(', ', $save_data['reasons']),
'@feedback' => $feedback_preview,
'@gender' => $save_data['gender'] ?? 'N/A',
'@url' => $save_data['url'],
'@entity' => $save_data['entity_type'] . ':' . $save_data['entity_id'],
]);
}
// Handle the case where saveFeedback returns FALSE or invalid value
if ($feedback_id === FALSE || !is_numeric($feedback_id) || (int) $feedback_id <= 0) {
$feedback_preview = $save_data['feedback'];
if (strlen($feedback_preview) > 100) {
$feedback_preview = substr($feedback_preview, 0, 100) . '...';
}
\Drupal::logger('dga_feedback')->error('Save FAILED - User: @user, Useful: @useful, Reasons count: @reasons_count, Feedback: @feedback, Gender: @gender, URL: @url', [
'@user' => $is_anon ? 'anonymous' : 'user_' . $user->id(),
'@useful' => $save_data['is_useful'] === 'yes' ? 'Yes' : 'No',
'@reasons_count' => count($save_data['reasons']),
'@feedback' => $feedback_preview,
'@gender' => $save_data['gender'] ?? 'N/A',
'@url' => $save_data['url'],
]);
// FOR ANONYMOUS: Try direct database insert as fallback (EXACTLY like dga_rating)
if ($is_anon) {
try {
$db = \Drupal::database();
// Prepare fields exactly as service would - matching DgaFeedbackService::saveFeedback()
// The service expects reasons as array and encodes it, but for direct insert we need JSON string
$direct_fields = [
'entity_type' => $save_data['entity_type'],
'entity_id' => (int) $save_data['entity_id'],
'is_useful' => $save_data['is_useful'] === 'yes' ? 'yes' : 'no',
'reasons' => isset($save_data['reasons']) && is_array($save_data['reasons']) ? json_encode($save_data['reasons']) : json_encode([]),
'feedback' => isset($save_data['feedback']) ? trim($save_data['feedback']) : '',
'gender' => isset($save_data['gender']) && in_array($save_data['gender'], ['male', 'female']) ? $save_data['gender'] : NULL,
'url' => $save_data['url'],
'user_id' => NULL, // Explicitly NULL for anonymous
'ip_address' => $save_data['ip_address'],
'created' => time(),
];
// Use direct insert - same approach as dga_rating but with properly formatted fields
$direct_id = $db->insert('dga_feedback')
->fields($direct_fields)
->execute();
if ($direct_id && is_numeric($direct_id) && (int) $direct_id > 0) {
$feedback_id = $direct_id;
// Invalidate cache after successful insert
\Drupal::service('cache_tags.invalidator')->invalidateTags(['dga_feedback:submissions']);
}
} catch (\Exception $e) {
\Drupal::logger('dga_feedback')->error('Direct DB insert failed: @msg', [
'@msg' => $e->getMessage(),
]);
}
}
// If still failed after fallback, return error
if ($feedback_id === FALSE || !is_numeric($feedback_id) || (int) $feedback_id <= 0) {
return new JsonResponse([
'success' => FALSE,
'message' => $getTranslation('api_save_failed', 'Failed to save feedback.', 'فشل حفظ التعليق.'),
], 500);
}
}
// Verify the record exists - but don't fail if there's a slight delay
$db = \Drupal::database();
// Try verification up to 3 times with small delay (in case of replication lag)
$verify = NULL;
for ($i = 0; $i < 3; $i++) {
$verify = $db->select('dga_feedback', 'f')
->fields('f', ['id', 'is_useful', 'url', 'user_id'])
->condition('f.id', (int) $feedback_id)
->execute()
->fetchObject();
if ($verify) {
break;
}
// Small delay before retry
if ($i < 2) {
usleep(100000); // 0.1 second
}
}
// Verification is optional - if record not found immediately, continue anyway
// (may be due to replication lag or timing)
// Get stats - wait a moment for database to be ready (especially for anonymous)
if ($is_anon) {
usleep(200000); // 0.2 second delay for anonymous users
}
$route_match = \Drupal::routeMatch();
$node = $route_match->getParameter('node');
$stats = ['yes_percentage' => 0.0, 'total_count' => 0];
if ($node && $node instanceof Node) {
$entity_id_for_query = $node->id();
if ($entity_id_for_query > 0) {
$stats = $this->feedbackService->getStatistics('node', $entity_id_for_query, $url);
}
}
if (($stats['total_count'] ?? 0) == 0) {
$stats = $this->feedbackService->getStatisticsByUrl($url);
}
$success_message = $getTranslation('api_success_message', 'Thank you for your feedback!', 'شكرًا لك على تعليقك!');
return new JsonResponse([
'success' => TRUE,
'message' => $success_message,
'feedback_id' => (int) $feedback_id,
'statistics' => [
'yes_percentage' => (float) ($stats['yes_percentage'] ?? 0.0),
'total_count' => (int) ($stats['total_count'] ?? 0),
],
'yes_percentage' => number_format($stats['yes_percentage'] ?? 0.0, 0, '.', ''),
'total_count' => (int) ($stats['total_count'] ?? 0),
]);
}
/**
* Refreshes the feedback block statistics via AJAX.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* JSON response with updated statistics.
*/
public function refreshBlock(Request $request) {
$url = $request->query->get('url');
$entity_type = $request->query->get('entity_type');
$entity_id = $request->query->get('entity_id');
if (!$url) {
$url = \Drupal::service('path.current')->getPath();
}
$url = trim($url);
if (empty($url) || $url === '/') {
$url = '/';
}
else {
if (preg_match('#^/[a-z]{2}(/.*)?$#', $url, $matches)) {
$url = isset($matches[1]) ? $matches[1] : '/';
if (empty($url)) {
$url = '/';
}
}
$url = rtrim($url, '/') ?: '/';
}
$stats = ['yes_percentage' => 0.0, 'total_count' => 0];
if ($entity_type && $entity_id && (int) $entity_id > 0) {
$stats = $this->feedbackService->getStatistics($entity_type, (int) $entity_id, $url);
}
else {
$stats = $this->feedbackService->getStatisticsByUrl($url);
}
return new JsonResponse([
'success' => TRUE,
'statistics' => [
'yes_percentage' => (float) ($stats['yes_percentage'] ?? 0.0),
'total_count' => (int) ($stats['total_count'] ?? 0),
],
'yes_percentage' => (float) ($stats['yes_percentage'] ?? 0.0),
'total_count' => (int) ($stats['total_count'] ?? 0),
]);
}
/**
* Gets feedback statistics.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* JSON response with statistics.
*/
public function getStats(Request $request) {
$url = $request->query->get('url');
$entity_type = $request->query->get('entity_type');
$entity_id = $request->query->get('entity_id');
$stats = ['yes_percentage' => 0.0, 'total_count' => 0];
if ($entity_type && $entity_id) {
if ($url) {
$stats = $this->feedbackService->getStatistics($entity_type, (int) $entity_id, $url);
} else {
$stats = $this->feedbackService->getStatisticsByEntity($entity_type, (int) $entity_id);
}
} elseif ($url) {
$stats = $this->feedbackService->getStatisticsByUrl($url);
}
return new JsonResponse(['success' => TRUE, 'statistics' => $stats]);
}
}
