Laravel 9 Queue Jobs Doesn't Send Web Push Messages - php

I am creating a laravel web app on windows server and IIS.
I want to send web push messages to clients and to boost performance I want to use Laravel Queue. Addition queue or work with queue is okay but web push messages don't come.
I am using IIS service on a azure virtual machine. My codes are here.
Thank you.
This is my notification controller :
<?php
namespace App\Http\Controllers\dashboard\notifications;
use App\Http\Controllers\Controller;
use App\Jobs\SendNotifications;
use App\Models\Notifications;
use Illuminate\Http\Request;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
class indexController extends Controller
{
public function subscription(Request $request)
{
$request->validate([
"endpoint" => "required",
"p256dh" => "required",
"axn" => "required",
"auth" => "required"
]);
if ($request->axn == "subscribe") {
$num = Notifications::where("endpoint", "=", strip_tags($request->endpoint))->count();
if ($num == 0) {
$result = Notifications::create([
"user_id" => auth()->user()->id,
"auth" => $request->auth,
"p256dh" => $request->p256dh,
"endpoint" => strip_tags($request->endpoint)
]);
if ($result) {
$endpoints = Notifications::where("user_id", "=", auth()->user()->id)->get();
foreach ($endpoints as $key => $value){
dispatch(new SendNotifications($value->endpoint,$value->auth,$value->p256dh, __("notifications.New device has been subscribed to our notification service."), __("notifications.New device has been subscribed to our notification service."), LaravelLocalization::localizeUrl('/dashboard')));
}
return response()->json(['success' => true, "data" => "success"], 200);
} else {
return response()->json(['success' => false, "data" => "fail"], 400);
}
} else {
return response()->json(['success' => true, "data" => "success"], 200);
}
} else {
$deletion = Notifications::where("endpoint", "=", strip_tags($request->endpoint))->delete();
if ($deletion) {
return response()->json(['success' => true, "data" => "success"], 200);
} else {
return response()->json(['success' => false, "data" => "fail"], 400);
}
}
}
}
and this is my SendNotification laravel queue :
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
class SendNotifications implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private $contentEncoding = "aesgcm";
private $title;
private $message;
private $url;
private $endpoint;
private $image;
private $auth_key;
private $p256dh;
/**
* Create a new job instance.
*
* #return void
*/
public function __construct($endpoint, $auth_key, $p256dh, $title, $message, $url, $image = "")
{
$this->endpoint = $endpoint;
$this->title = $title;
$this->message = $message;
$this->url = $url;
$this->image = $image;
$this->auth_key = $auth_key;
$this->p256dh = $p256dh;
}
/**
* Execute the job.
*
* #return void
*/
public function handle()
{
$auth = [
'VAPID' => [
'subject' => $this->url, // can be a mailto: or your website address
'publicKey' => env("VAPID_PUBLIC_KEY"), // (recommended) uncompressed public key P-256 encoded in Base64-URL
'privateKey' => env("VAPID_PRIVATE_KEY"), // (recommended) in fact the secret multiplier of the private key encoded in Base64-URL
],
];
$defaultOptions = [
'TTL' => 5000, // defaults to 4 weeks
'urgency' => 'normal', // protocol defaults to "normal"
'topic' => 'new_event', // not defined by default,
'batchSize' => 1000, // defaults to 1000
];
$webPush = new WebPush($auth, $defaultOptions);
$webPush->setDefaultOptions($defaultOptions);
$payload = '{"title":"'.$this->title.'","msg":"'.$this->message.'","icon":"https://diginorm.com.tr/images/icons/icon-96x96.png","badge":"https://diginorm.com.tr/images/icons/icon-96x96.png","image":"'.$this->image.'","openUrl":"'.$this->url.'"}';
$subscription = new Subscription($this->endpoint, $this->p256dh, $this->auth_key, $this->contentEncoding);
$webPush->sendOneNotification($subscription, $payload);
}
}
And this is my ServiceWorker.js
self.addEventListener('notificationclick', function(event) {
var notification = event.notification;
var action = event.action;
console.log(notification);
if (action === 'confirm') {
console.log('Confirm was chosen');
notification.close();
} else {
console.log(action);
event.waitUntil(
clients.matchAll()
.then(function(clis) {
var client = clis.find(function(c) {
return c.visibilityState === 'visible';
});
if (client !== undefined) {
client.navigate(notification.data.url);
client.focus();
} else {
clients.openWindow(notification.data.url);
}
notification.close();
})
);
}
});
self.addEventListener('notificationclose', function(event) {
console.log('Notification was closed', event);
});
self.addEventListener('push', function(event) {
console.log('Push Notification received', event);
var data = {title: 'New!', msg: 'Something new happened!', image: '/images/icons/icon-512x512.png', openUrl: '/'};
if (event.data) {
data = JSON.parse(event.data.text());
}
var options = {
body: data.msg,
icon: "/images/icons/icon-96x96.png",
badge: "/images/icons/icon-96x96.png",
image: data.image,
dir: "ltr",
lang: "en-GP",
vibrate:[200, 100, 200, 100, 200, 100, 200],
data: {
url: data.openUrl
}
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
this is jobs table :
and this is the php artisan command result :

Related

Doxygen does not document one PHP class

I have a small Symfony project I want to document with doxygen. There are two .php files that should be included. One is documented, the other is not, and I cannot figure out why that may be.
Folder structure is:
project
└--src
|--Controller
| └--FormController.php
└--Model
└--Inquiry.php
Doxygen is reading and parsing both files...
Reading /form-handler/src/Controller/FormController.php...
Parsing file /form-handler/src/Controller/FormController.php...
Reading /form-handler/src/Model/Inquiry.php...
Parsing file /form-handler/src/Model/Inquiry.php...
...but only documents FormController.php, not Inquiry.php:
Generating docs for compound App::Controller::FormController...
For some reason doxygen does not seem to recognizeInquiry.php as a class.
What I have tried:
Removed decorators from docstrings that might offend doxygen.
Checked format of docstrings
Enabled/disabled various Doxyfile options
FormController.php:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Model\Inquiry;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Handles incoming requests.
*/
class FormController extends AbstractController
{
/**
* Handles POST requests.
*
* #return Response contains JSON object with 'message' value
*/
#[Route('/', methods: ['POST'])]
public function handleRequest(
HttpClientInterface $client,
Inquiry $inquiry,
LoggerInterface $logger,
RateLimiterFactory $formApiLimiter,
Request $request,
): Response {
$logger->debug('Received a POST request');
// set up a rate limiter by IP
// rules are defined in /config/packages/rate-limiter.yaml
$limiter = $formApiLimiter->create($request->getClientIp());
$limit = $limiter->consume();
// configure headers exposing rate limit info
$headers = [
'Content-Type' => 'application/json',
'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
'X-RateLimit-Retry-After' => $limit->getRetryAfter()->getTimestamp(),
'X-RateLimit-Limit' => $limit->getLimit(),
];
if (false === $limit->isAccepted()) {
return new Response(
content: json_encode(['message' => null]),
status: Response::HTTP_TOO_MANY_REQUESTS,
headers: $headers
);
}
// make sure all required fields are included in request and not empty
$requiredFields = ['subject', 'message', 'consent', 'h-captcha-response'];
$providedFields = $request->request->keys();
foreach ($requiredFields as $field) {
if (!in_array($field, $providedFields)) {
return new Response(
content: json_encode(['message' => "Pflichtfeld '".$field."' fehlt."]),
status: Response::HTTP_BAD_REQUEST,
headers: $headers
);
} elseif ('' == filter_var($request->request->get($field), FILTER_SANITIZE_SPECIAL_CHARS)) {
return new Response(
content: json_encode(['message' => "Pflichtfeld '".$field."' darf nicht leer sein."]),
status: Response::HTTP_BAD_REQUEST,
headers: $headers
);
}
}
// verify captcha success
$captcha = filter_var($request->request->get('h-captcha-response'), FILTER_SANITIZE_SPECIAL_CHARS);
$data = [
'secret' => $this->getParameter('app.captcha.secret'),
'response' => $captcha,
];
try {
$hCaptchaResponse = $client->request(
method: 'POST',
url: 'https://hcaptcha.com/siteverify',
options: [
'body' => $data,
],
);
$hCaptchaResponseJson = json_decode($hCaptchaResponse->getContent(), true);
if (!$hCaptchaResponseJson['success']) {
return new Response(
content: json_encode(['message' => 'Captcha fehlgeschlagen']),
status: Response::HTTP_BAD_REQUEST,
headers: $headers
);
}
// exceptions on the side of hCaptcha are logged, but the request is processed anyway
} catch (TransportExceptionInterface $e) {
$logger->debug('Could not reach hCaptcha verification server: '.$e);
} catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface $e) {
$logger->debug('Error when verifying hCaptcha response: '.$e);
}
// get values from request data
$name = filter_var($request->request->get('name'), FILTER_SANITIZE_SPECIAL_CHARS);
$email = filter_var($request->request->get('email'), FILTER_SANITIZE_EMAIL);
$phone = filter_var($request->request->get('phone'), FILTER_SANITIZE_SPECIAL_CHARS);
$subject = filter_var($request->request->get('subject'), FILTER_SANITIZE_SPECIAL_CHARS);
$message = filter_var($request->request->get('message'), FILTER_SANITIZE_SPECIAL_CHARS);
$consent = filter_var($request->request->get('consent'), FILTER_SANITIZE_SPECIAL_CHARS);
// translate into a boolean (else the string 'false' will be evaluated as true)
$consent = filter_var($consent, FILTER_VALIDATE_BOOLEAN);
// populate Inquiry with request data
$inquiry->createInquiry(
subject: $subject,
message: $message,
consent: $consent,
name: $name,
email: $email,
phone: $phone,
);
// validate Inquiry
$validationResult = $inquiry->validateInquiry();
// if Inquiry is invalid, return validation violation message(s)
if (count($validationResult) > 0) {
$logger->debug($validationResult);
// assemble list of error messages
$validationMessages = [];
foreach ($validationResult as $result) {
$validationMessages += [$result->getPropertyPath() => $result->getMessage()];
}
return new Response(
content: json_encode([
'message' => 'Anfrage enthält ungültige Werte',
'errors' => $validationMessages,
]),
status: Response::HTTP_BAD_REQUEST,
headers: $headers
);
}
// send mail to office
$emailResult = $inquiry->sendOfficeEmail();
$logger->debug(implode(' ,', $emailResult));
$message = 'Die Anfrage war erfolgreich';
if (!$emailResult['success']) {
$message = 'Die Anfrage war nicht erfolgreich.';
}
// TODO compile email to user
$data = [
'message' => $message,
'officeEmail' => $emailResult,
'confirmationEmail' => true,
];
return new Response(
content: json_encode($data),
status: Response::HTTP_OK,
headers: $headers
);
}
/**
* Handles disallowed request methods.
*
* #return Response contains JSON object with 'message' value
*/
#[Route('/', condition: "context.getMethod() not in ['POST']")]
public function handleDisallowedMethods(LoggerInterface $logger): Response
{
$logger->debug('Received a request with a disallowed method.');
return new Response(
content: json_encode(['message' => 'Only POST requests allowed']),
status: Response::HTTP_METHOD_NOT_ALLOWED
);
}
}
Inquiry.php:
<?php
declare(strict_types=1);
namespace App\Model;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Represents an inquiry received from the front end.
*
* The required fields 'subject', 'message', and
* 'consent' must be provided to the constructor.
*/
class Inquiry
{
private string $name;
private string $email;
private string $phone;
private string $subject;
private string $message;
private bool $consent;
private bool $validated = false;
/**
* #param LoggerInterface $logger
* #param MailerInterface $mailer
* #param array $officeRecipients configured in services.yaml
* #param ValidatorInterface $validator
*/
public function __construct(
private readonly LoggerInterface $logger,
private readonly MailerInterface $mailer,
private readonly array $officeRecipients,
private readonly ValidatorInterface $validator,
) {
}
/**
* Populates an inquiry with data.
*
* 'subject', 'message', and 'consent are required,
* all other values are optional.
*
* Sets 'validated' to false, in case createInquiry()
* is called multiple times.
*/
public function createInquiry(
string $subject,
string $message,
bool $consent,
string $name = '',
string $email = '',
string $phone = '',
): void {
$this->subject = $subject;
$this->message = $message;
$this->consent = $consent;
$this->name = $name;
$this->email = $email;
$this->phone = $phone;
$this->validated = false;
}
/**
* Validates the inquiry.
*
* If successful, sets 'validated' to true
*
* #return ConstraintViolationListInterface if valid: empty
* if not valid: list of validation violation messages
*/
public function validateInquiry(): ConstraintViolationListInterface
{
$validationResult = $this->validator->validate($this);
if (0 == count($validationResult)) {
$this->validated = true;
}
return $validationResult;
}
/**
* Sends an email with the customer inquiry data to the office.
*
* #return array containing 'success' boolean and 'message' string
*/
public function sendOfficeEmail(): array
{
if (!$this->validated) {
return [
'success' => false,
'message' => 'Inquiry has not been validated. Use Inquiry->validate() first',
];
}
// convert 'consent' and empty values in to human-readable format
$plainTextConsent = $this->consent ? 'Ja' : 'Nein';
$plainTextName = $this->name ?: 'Keine Angabe';
$plainTextEmail = $this->email ?: 'Keine Angabe';
$plainTextPhone = $this->phone ?: 'Keine Angabe';
$emailBody = <<<END
Das Kontaktformular hat eine Anfrage erhalten.
Betreff: $this->subject
Nachricht: $this->message
Einwilligung: $plainTextConsent
Name: $plainTextName
Email: $plainTextEmail
Telefon: $plainTextPhone
END;
$email = (new Email())
->to(...$this->officeRecipients)
->subject('Anfrage vom Kontaktformular')
->text($emailBody);
try {
$this->mailer->send($email);
$this->logger->debug('Email sent');
return [
'success' => true,
'message' => 'Email wurde gesendet',
];
} catch (TransportExceptionInterface $e) {
$this->logger->debug('Error sending email: '.$e);
return [
'success' => false,
'message' => 'Email konnte nicht gesendet werden: '.$e,
];
}
}
/**
* #codeCoverageIgnore
*/
public function sendConfirmationEmail(): string
{
return '';
}
/**
* Checks whether Inquiry has been validated.
*/
public function isValidated(): bool
{
return $this->validated;
}
}
Doxyfile (EDITED as per #albert's comment):
# Difference with default Doxyfile 1.9.3 (c0b9eafbfb53286ce31e75e2b6c976ee4d345473)
PROJECT_NAME = "Form handler"
PROJECT_BRIEF = "Stand-alone Symfony backend to handle contact forms."
OUTPUT_DIRECTORY = ./doc/
INPUT = ./src/ \
README.md
RECURSIVE = YES
EXCLUDE = ./src/Kernel.php
USE_MDFILE_AS_MAINPAGE = README.md
GENERATE_LATEX = NO
As of PHP version 7.3.0 the syntax of the here document changed slightly, see https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.heredoc
The closing identifier may be indented by space or tab, in which case the indentation will be stripped from all lines in the doc string. Prior to PHP 7.3.0, the closing identifier must begin in the first column of the line.
This has now been corrected in the proposed patch, pull request: https://github.com/doxygen/doxygen/pull/9398
Workarounds:
place the end identifier of the here document at the beginning of the line
place a doxygen conditional block /** \cond / / /* \endcond */ around the here document.

Laravel 7: Verify email using API endpoint + single page application

I have a NuxtJS/Vue SPA and I want to verify the user email with the Laravel API that's my server side.
I create a custom notification called VerifyEmail.php:
<?php
namespace App\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Lang;
use Illuminate\Notifications\Notification;
class VerifyEmail extends Notification {
public function via($notifiable) {
return ['mail'];
}
public function toMail($notifiable) {
$params = [
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
'expiry' => Carbon::now()->addMinutes(60)->timestamp
];
$url = config('app.web_client_url') . '/verify-email?';
foreach ($params as $key => $param) {
$url .= "{$key}={$param}&";
}
$key = config('app.key');
$signature = hash_hmac('sha256', $url, $key);
$url .= "signature=$signature";
return (new MailMessage)
->subject(Lang::get('Verify Email Address'))
->line(Lang::get('Please click the button below to verify your email address.'))
->action(Lang::get('Verify Email Address'), $url)
->line(Lang::get('If you did not create an account, no further action is required.'));
}
}
In my registration controller when a user registers I use:
...
$user->save();
$user->notify(new VerifyEmail());
return response()->json([
'message' => $user
], 201);
and the email gets sent. The URL in the email is something like: https://localhost:7000/verify-email?id=37&hash=4c1691e6db623b85d90cee62f80d6f9085648c92&expiry=1595596017&signature=d6c6374b203b1da66d11818728921a4160e30ebf43c5a8be544220c8eca97bb3 (localhost:7000 is the address of my NuxtJS application).
Upon going to that page, I make the following request in the mounted lifecycle method:
this.signature = this.$route.query.signature
this.expiry = this.$route.query.expiry
this.hash = this.$route.query.hash
this.id = this.$route.query.id
this.$axios.$get(`api/email/verify/${this.id}?hash=${this.hash}&expiry=${this.expiry}&signature=${this.signature}`)
.then(response => {
this.successMessage = response
}).catch(error => {
this.errorMessage = error
})
This request hits the endpoint on my server and the following method runs:
public function verify($user_id, Request $request) {
if (!$request->hasValidSignature()) { // Check always fails and we get a 401
return response()->json(["msg" => "Invalid URL provided."], 401);
}
$user = User::findOrFail($user_id);
if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
return response()->json(["msg" => "Email verified."], 200);
}
The route for laravel endpoint:
Route::get('email/verify/{id}', 'Api\EmailVerificationController#verify')->name('verification.verify');
I can see that the parameters are received in the verify method request object parameter (e.g. setting a breakpoint and checking):
The check for a valid signature always fails and results in a 401 being sent back to the client. What's wrong with the URL/signature that I'm generating?
Here what I did to solve the problem. Go to AuthServiceProvider
/**
* Register any authentication / authorization services.
*
* #return void
*/
public function boot()
{
$this->registerPolicies();
//
VerifyEmail::createUrlUsing(function ($notifiable) {
$params = [
"expires" => Carbon::now()
->addMinutes(60)
->getTimestamp(),
"id" => $notifiable->getKey(),
"hash" => sha1($notifiable->getEmailForVerification()),
];
ksort($params);
// then create API url for verification. my API have `/api` prefix,
// so i don't want to show that url to users
$url = \URL::route("verification.verify", $params, true);
// get APP_KEY from config and create signature
$key = config("app.key");
$signature = hash_hmac("sha256", $url, $key);
// generate url for yous SPA page to send it to user
return env("APP_FRONT") .
"/auth/verify-email/" .
$params["id"] .
"/" .
$params["hash"] .
"?expires=" .
$params["expires"] .
"&signature=" .
$signature;
});
}
}
add this to api.php
Route::get("/verify-email/{id}/{hash}", [
VerifyEmailController::class,
"__invoke",
])
->middleware(["auth:sanctum","signed", "throttle:6,1"])
->name("verification.verify");
add this to VerifyEmailController.php
/**
* Mark the authenticated user's email address as verified.
*
* #param \Illuminate\Foundation\Auth\EmailVerificationRequest $request
* #return \Illuminate\Http\RedirectResponse
*/
public function __invoke(EmailVerificationRequest $request)
{
if ($request->user()->hasVerifiedEmail()) {
return response()->json(
[
"message" => "Your'r email already verified.",
],
Response::HTTP_BAD_REQUEST
);
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return response()->json(
[
"message" => "Verification complete thank you.",
],
Response::HTTP_OK
);
}
}
Front end
async verfyEmail() {
try {
const params = new URLSearchParams(this.$route.query)
let res = await this.$axios.get(
'verify-email/' +
this.$route.params.id +
'/' +
this.$route.params.hash,
{ params }
)
this.$router.push({ name: 'platform-dashboard' })
} catch (error) {
console.log(error.response)
this.$router.push({ name: 'platform-dashboard' })
}
}

Wordpress custom endpoints (WP_REST_Controller) 404 only on mobile

I currently have a working controller that extends WP_REST_Controller in a file under the current theme. These are being called using jQuery ajax. (all code below)
The issue I am facing is that I receive this error ONLY when accessing with a mobile device.
{"code": "rest_no_route", "message": "No route was found matching the URL and request method" "data": {"status": 404}}
settings -> permalinks -> save changes
tried using controller namespace "api/v1" and "wp/v2"
javascript
function getAllClients() {
jQuery.ajax({
url: "http://myurl.com/index.php/wp-json/wp/v2/get_all_clients",
type: "GET",
data: { /*data object*/},
success: function (clientList) {
// success stuff here
},
error: function (jqXHR, textStatus, errorThrown) {
alert(jqXHR.statusText);
}
})
}
api/base.php
<?php
class ApiBaseController extends WP_REST_Controller
{
//The namespace and version for the REST SERVER
var $my_namespace = 'wp/v';
var $my_version = '2';
public function register_routes()
{
$namespace = $this->my_namespace . $this->my_version;
register_rest_route(
$namespace,
'/get_all_clients',
array(
array(
'methods' => 'GET',
'callback' => array(new ApiDefaultController('getAllClients'), 'init'),
)
)
);
$ApiBaseController = new ApiBaseController();
$ApiBaseController->hook_rest_server();
api/func.php
<?php
class ApiDefaultController extends ApiBaseController
{
public $method;
public $response;
public function __construct($method)
{
$this->method = $method;
$this->response = array(
// 'Status' => false,
// 'StatusCode' => 0,
// 'StatusMessage' => 'Default'
// );
}
private $status_codes = array(
'success' => true,
'failure' => 0,
'missing_param' => 150,
);
public function init(WP_REST_Request $request)
{
try {
if (!method_exists($this, $this->method)) {
throw new Exception('No method exists', 500);
}
$data = $this->{$this->method}($request);
$this->response['Status'] = $this->status_codes['success'];
$this->response['StatusMessage'] = 'success';
$this->response['Data'] = $data;
} catch (Exception $e) {
$this->response['Status'] = false;
$this->response['StatusCode'] = $e->getCode();
$this->response['StatusMessage'] = $e->getMessage();
}
return $this->response['Data'];
}
public function getAllClients()
{
// db calls here
return json_encode($stringArr,true);
}
}
These are registered in the Functions.php file
require get_parent_theme_file_path('api/base.php');
require get_parent_theme_file_path('api/func.php');
Turns out the issue was a plugin my client installed called "oBox mobile framework" that was doing some weird routing behind the scenes. Disabling it resolved the issue, though there is probably a way to hack around this and get both to play together.

Testing file uploads in Laravel

I'm using Laravel 5.8 with Dropzone.js to upload files to a library, which I'm able to do successfully. I thought it good practise to write a test to verify this but it always fails.
I've seen a similar situation within this question: Laravel dusk: test file upload with Dropzone.js
My controller method looks like this is just called store and it looks like this:
/**
* Store a new library file in the database
*
* #param StoreArticle $request
* #return void
*/
public function store(StoreLibrary $request)
{
$data = $request->validated();
$category = $data['category'];
$files = $data['file'];
foreach ($files as $file) {
$original_name = $file->getClientOriginalName();
$mime_type = $file->getClientOriginalExtension();
$size = $file->getSize();
// Generate a name for this file
$system_generated_name = sha1(date('YmdHis') . str_random(30)) . '.' . $file->getClientOriginalExtension();
// Store the file on the disk 'library'
$path = Storage::disk('library')->putFileAs(null, $file, $system_generated_name);
// Store a reference to this file in the database
Library::create([
'display_name' => $original_name,
'file_name' => $system_generated_name,
'mime_type' => $mime_type,
'size' => $size,
'disk' => $this->disk,
'storage_location' => $path,
'category' => $category,
]);
}
// Return a JSON response
return response()->json([
'success' => true,
'file' => [
'original_name' => $original_name,
'generated_name' => $system_generated_name,
'path' => $path,
'size' => $size,
]
], 200);
}
The StoreLibrary class is a FormRequest and looks like this:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreLibrary extends FormRequest
{
/**
* Set allowed extensions for each file category
* This can be appended to as necessary as it's somewhat restrictive
*/
private $image_ext = [
'jpg', 'jpeg', 'png', 'gif', 'ai', 'svg', 'eps', 'ps'
];
private $audio_ext = [
'mp3', 'ogg', 'mpga'
];
private $video_ext = [
'mp4', 'mpeg'
];
private $document_ext = [
'doc', 'docx', 'dotx', 'pdf', 'odt', 'xls', 'xlsm', 'xlsx', 'ppt', 'pptx', 'vsd'
];
/**
* Merge all listed extensions into one massive array
*
* #return array Extensions of all file types
*/
private function extension_whitelist()
{
return array_merge($this->image_ext, $this->audio_ext, $this->video_ext, $this->document_ext);
}
/**
* Determine if the user is authorized to make this request.
*
* #return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* #return array
*/
public function rules()
{
return [
'category' => [
'required',
'string'
],
'file.*' => 'required|file|mimes:' . implode(',', $this->extension_whitelist()) . '|max:50000'
];
}
/**
* Get the error messages for the defined validation rules.
*
* #return array
*/
public function messages()
{
return [
'category.required' => 'A category is required when uploading library files.',
'file.*.required' => 'Please select a file to upload',
'file.*.mimes' => 'This type of file is not permitted on the Intranet'
];
}
}
The test I've written looks like this:
/** #test */
public function a_user_with_permission_can_add_files_to_the_library()
{
$this->withoutExceptionHandling();
Storage::fake('library');
$this->setupPermissions();
$user = factory(User::class)->create();
$user->assignRole('admin');
// Assert the uploading an image returns a 200 response
$this->actingAs($user)
->post(route('admin.library.store'), [
'category' => 'Some category',
'file' => UploadedFile::fake()->create("test.jpg", 100)
])->assertStatus(200);
Storage::disk('library')->assertExists("test.jpg");
}
Running the test always returns the following error ErrorException: Undefined index: file however the file input is definitely called file.
The associated blade file section:
<form autocomplete="off">
<div class="form-group">
<label for="category">What category do these files fall into? <span class="required">*</span></label>
<select v-model="category" name="category" id="category" class="form-control form-control--citibase">
<option value="">Select a category</option>
#foreach($categories as $category)
<option value="{{ $category }}">{{ $category }}</option>
#endforeach
</select>
</div>
<div class="form-group">
<vue-dropzone
ref="myVueDropzone"
id="dropzone"
v-bind:options="dropzoneOptions"
v-bind:duplicate-check=true
v-on:vdropzone-sending="sendFile"
v-on:vdropzone-success-multiple="uploadSuccessful"
v-on:vdropzone-queue-complete="queueComplete"
v-on:vdropzone-error="uploadError">
</vue-dropzone>
</div>
<div class="d-flex justify-content-end">
<input type="button" v-on:click="upload_files" class="btn btn-pink" value="Upload files"/>
</div>
</form>
The Vue code:
if (document.getElementById("library-admin")) {
const app = new Vue({
el: "#library-admin",
components: {
vueDropzone: vue2Dropzone
},
data: {
query: "",
category: "",
results: [],
errors: [],
loading: true,
pagination: {},
current_page_url: "",
dropzoneOptions: {
url: "/admin/library",
acceptedFiles: ".jpg, .jpeg, .png, .gif, .svg, .eps, ps, .doc, .docx, .dotx, .pdf, .odt, .xls, .xlsm, .xlsx, .ppt, .pptx, .vsd",
autoProcessQueue: false,
uploadMultiple: true,
parallelUploads: 2,
maxFilesize: 50,
thumbnailWidth: 100,
thumbnailHeight: 100,
dictDefaultMessage: "Drop files here, or click to select them",
addRemoveLinks: true,
headers: {
"x-csrf-token": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content")
}
}
},
computed: {
show_table() {
return true;
},
has_pagination() {
return true;
}
},
created() {
this.getResults();
},
watch: {
query(after, before) {
this.search();
}
},
methods: {
rename_file(file, index) {
this.$swal({
title: 'Rename file',
input: 'text',
inputValue: file.display_name_excluding_extension,
showCancelButton: true,
inputValidator: (value) => {
if (!value) {
return 'You need to write something!'
}
else if(value){
axios
.patch("/admin/api/library/rename/" + file.id, {
name: value
})
.then(response => {
this.$swal({
type: "success",
title: "File successfully renamed."
});
Vue.set(this.results[index], 'display_name', response.data.display_name);
})
.catch(error => {
this.$swal({
type: "error",
title: "File could not be renamed.",
text: "Please ensure that the file name does not include any dots or special characters."
})
});
}
}
});
},
upload_files() {
this.$refs.myVueDropzone.processQueue();
},
sendFile(file, xhr, formData) {
formData.append("category", this.category);
},
uploadError(file, message, xhr) {
console.log(message.errors);
this.errors = message.errors;
},
uploadSuccessful(files, response) {
this.errors = [];
},
queueComplete(files) {
if (this.errors.length == 0) {
this.getResults();
this.$refs.myVueDropzone.removeAllFiles();
}
},
getResults: function (page_url) {
let vm = this;
page_url = page_url || "/admin/api/library";
this.current_page_url = page_url;
this.results = [];
this.errors = [];
this.loading = true;
axios
.get(page_url)
.then(response => [
vm.makePagination(response.data),
response.data.error ?
(this.error = response.data.error) :
(this.results = response.data.data),
(this.loading = false)
])
.catch((this.error = ""));
},
search: _.debounce(function () {
if (this.query !== "") {
let vm = this;
this.results = [];
this.error = "";
this.loading = true;
axios
.get("/admin/api/library/search", {
params: {
q: this.query
}
})
.then(response => [
vm.makePagination(response.data),
response.data.error ?
(this.errors = response.data.error) :
(this.results = response.data.data),
(this.loading = false)
])
.catch(error => {
console.log(error.response.data.errors);
this.errors = error.response.data.errors;
});
} else {
this.getResults();
}
}, 500),
makePagination: function (data) {
var pagination = {
from: data.from,
to: data.to,
total: data.total,
current_page: data.current_page,
last_page: data.last_page,
next_page_url: data.next_page_url,
prev_page_url: data.prev_page_url
};
this.pagination = pagination;
},
delete_file: function (element) {
if (
confirm(
"Do you really want to delete " + element.display_name
)
) {
axios
.delete("/admin/library/" + element.id)
.then(response => [this.getResults()])
.catch(error => {
console.log(error.response.data.errors);
this.errors = error.response.data.error;
});
}
},
archive_file: function (element) {
axios
.patch("/admin/library/" + element.id)
.then(response => [
element.status == "published" ?
(element.status = "draft") :
(element.status = "published")
])
.catch(error => {
console.log(error.response.data.errors);
this.success = false;
this.errors = error.response.data.errors;
});
}
}
});
}
I managed to find a solution to this issue. It was because file is an array, meaning it needed to have keys and indexes when testing.
This is shown in the code below:
/** #test */
public function a_user_with_permission_can_add_files_to_the_library()
{
$this->withoutExceptionHandling();
Storage::fake('library');
$this->setupPermissions();
$user = factory(User::class)->create();
$user->assignRole('admin');
// Assert the uploading an image returns a 200 response
$this->actingAs($user)
->post(route('admin.library.store'), [
'category' => 'Some category',
'file' => [
0 => UploadedFile::fake()->create("test.jpg", 100),
1 => UploadedFile::fake()->create("test.png", 100),
2 => UploadedFile::fake()->create("test.doc", 100),
3 => UploadedFile::fake()->create("test.ppt", 100),
4 => UploadedFile::fake()->create("test.pdf", 100),
]
])->assertStatus(200);
$this->assertEquals(5, Library::count());
}

Yii2 and OAuth2 Plugin Filsh/yii2-oauth2-server: Unauthorized when sending POST data

EDIT: SOLVED
Apparently this plugin, was having some problem missing the request headers. The solution was adding
SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
To the .htaccess file to make Authorizaion variable available as this issue report says:
https://github.com/yiisoft/yii2/issues/6631
I'm currently working with Yii2 and using this OAuth2 plugin (Filsh/yii2-oauth2-server) for login and to work with tokens from a mobile HTML5 app.
I've configured everything, and it's retrieving the token, but when I try to send the token via POST it throws an error.
I start by calling send() to retrieve the token.
function send(){
var url = "http://www.server.org/app/api/oauth2/rest/token";
var data = {
'grant_type':'password',
'username':'user',
'password':'pass',
'client_id':'clientid',
'client_secret':'clientsecret',
};
$.ajax({
type: "POST",
url: url,
data: data,
success:function(data){
console.log(data);
token = data.access_token;
},
})
};
Then when I perform this a call to createuser().
function createuser(){
var url = "http://www.server.org/app/api/v1/users/create";
var data = {
'callback':'asdf',
'username': 'user',
'password':'pass',
'first_name':'name',
'last_name':'lastname'
};
$.ajax({
type: "POST",
url: url,
data: data,
beforeSend: function (xhr) {
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
},
success:function(r){
console.log(r);
},
});
}
It returns
Unauthorized: You are requesting with an invalid credential
When I change to GET instead, it works fine.
This is my controller, I'm already using:
['class' => HttpBearerAuth::className()],
['class' => QueryParamAuth::className(), 'tokenParam' => 'accessToken'],
As authentication method.
<?php
namespace app\api\modules\v1\controllers;
use Yii;
use app\models\OauthUsers;
use yii\rest\ActiveController;
use yii\web\Response;
use yii\helpers\ArrayHelper;
use yii\filters\auth\HttpBearerAuth;
use yii\filters\auth\QueryParamAuth;
use filsh\yii2\oauth2server\filters\ErrorToExceptionFilter;
use filsh\yii2\oauth2server\filters\auth\CompositeAuth;
class UsersController extends \yii\web\Controller
{
public function behaviors()
{
return ArrayHelper::merge(parent::behaviors(), [
'authenticator' => [
'class' => CompositeAuth::className(),
'authMethods' => [
['class' => HttpBearerAuth::className()],
['class' => QueryParamAuth::className(), 'tokenParam' => 'accessToken'],
]
],
'exceptionFilter' => [
'class' => ErrorToExceptionFilter::className()
],
'class' => \yii\filters\ContentNegotiator::className(),
]);
}
/**
* Creates a new model.
* If creation is successful, the browser will be redirected to the 'view' page.
* #return mixed
*/
public function actionCreate()
{
$model = new OauthUsers;
try {
if ($model->load($_POST) && $model->save()) {
return $this->redirect(Url::previous());
} elseif (!\Yii::$app->request->isPost) {
$model->load($_GET);
}
} catch (\Exception $e) {
$msg = (isset($e->errorInfo[2]))?$e->errorInfo[2]:$e->getMessage();
$model->addError('_exception', $msg);
}
return "true";
}
}
This is my configuration object
'oauth2' => [
'class' => 'filsh\yii2\oauth2server\Module',
'tokenParamName' => 'accessToken',
'tokenAccessLifetime' => 3600 * 24,
'storageMap' => [
'user_credentials' => 'app\models\OauthUsers',
],
'grantTypes' => [
'user_credentials' => [
'class' => 'OAuth2\GrantType\UserCredentials',
],
'refresh_token' => [
'class' => 'OAuth2\GrantType\RefreshToken',
'always_issue_new_refresh_token' => true
]
]
]
This is class OauthUsers
<?php
namespace app\models;
use Yii;
use \app\models\base\OauthUsers as BaseOauthUsers;
/**
* This is the model class for table "oauth_users".
*/
class OauthUsers extends BaseOauthUsers
implements \yii\web\IdentityInterface,\OAuth2\Storage\UserCredentialsInterface
{
/**
* #inheritdoc
*/
public static function findIdentity($id) {
$dbUser = OauthUsers::find()
->where([
"id" => $id
])
->one();
if (!count($dbUser)) {
return null;
}
return new static($dbUser);
}
/**
* #inheritdoc
*/
public static function findIdentityByAccessToken($token, $userType = null) {
$at = OauthAccessTokens::find()
->where(["access_token" => $token])
->one();
$dbUser = OauthUsers::find()
->where(["id" => $at->user_id])
->one();
if (!count($dbUser)) {
return null;
}
return new static($dbUser);
}
/**
* Implemented for Oauth2 Interface
*/
public function checkUserCredentials($username, $password)
{
$user = static::findByUsername($username);
if (empty($user)) {
return false;
}
return $user->validatePassword($password);
}
/**
* Implemented for Oauth2 Interface
*/
public function getUserDetails($username)
{
$user = static::findByUsername($username);
return ['user_id' => $user->getId()];
}
/**
* Finds user by username
*
* #param string $username
* #return static|null
*/
public static function findByUsername($username) {
$dbUser = OauthUsers::find()
->where([
"username" => $username
])
->one();
if (!count($dbUser)) {
return null;
}
return new static($dbUser);
}
/**
* #inheritdoc
*/
public function getId()
{
return $this->id;
}
/**
* #inheritdoc
*/
public function getAuthKey()
{
return $this->authKey;
}
/**
* #inheritdoc
*/
public function validateAuthKey($authKey)
{
return $this->authKey === $authKey;
}
/**
* Validates password
*
* #param string $password password to validate
* #return boolean if password provided is valid for current user
*/
public function validatePassword($password)
{
return $this->password === $password;
}
}
I've changed createuser too, but still receiving a 401. I'm not sure it is passing through findIdentityByAccessToken (access_token is in a different table than oauth users, thats why I'm querying it first).
Any thoughts?
I don't know the plugin you are using but what I know is that you can use the Yii2 HttpBearerAuth filter when implementing OAuth 2.0 which means using the HTTP Bearer token. And that token is typically passed with the Authorization header of the HTTP request instead of the body request and it usually looks like :
Authorization: Bearer y-9hFW-NhrI1PK7VAXYdYukwWVrNTkQ1
The idea is about saving the token you received from the server somewhere (should be a safe place) and include it in the headers of the requests that requires server authorization (maybe better explained here, here or here) So the JS code you are using to send a POST request should look more like this :
function createuser(){
var url = "http://www.server.org/app/api/v1/users/create";
var data = {
'callback':'cb',
'username': 'user',
'password':'pass',
'first_name':'name',
'last_name':'lastname'
};
$.ajax({
type: "POST",
url: url,
data: data,
beforeSend: function (xhr) {
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
},
success:function(r){
console.log(r);
},
});
}
Also check in the User class (the one defined in your config files and responsible of authenticating a user) check the implementation of the findIdentityByAccessToken() method. it should usually look like this (see Yii docs for more details) :
public static function findIdentityByAccessToken($token, $type = null)
{
return static::findOne(['auth_key' => $token]);
}
This is the function that will receive the token and it should return null when authentication should fail (by default findOne() returns null when no record is found) or returns a User instance to register it and make it accessible within Yii::$app->user->identity or Yii::$app->user->id anywhere inside your app so you can implement whatever logic you need inside it like checking the validity of an access token or its existence in a different table.
Apparently this plugin (Filsh/yii2-oauth2-server), was having some problem missing the request headers. The solution was adding
SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
To the .htaccess file to make Authorizaion variable available as this says:
https://github.com/yiisoft/yii2/issues/6631

Categories