What is the most secure way to submit large, extensible forms with CakePHP (2.4) and SecurityComponent?
I have a form on my app which creates new fields to store new subrecords (and sub-subrecords) using jQuery. This clashes with Cake's SecurityComponent, which expects all fields on a submitted form to have been created server-side by FormHelper.
In the past, when I've been only saving records across one association, I've been able to limit fields user-side to a high but workable number like 100, and explicitly unlock each and every possible field the form could generate:
while($i < 100){
$this->Form->unlockField('ChildModel.' . $i . '.value'); $i++;
// unlock other fields for that possible record
}
However, with this new form I have to save data across not one but two relationships. Users can potentially create a lot of sub-records or sub-sub-records, so the namespace ChildModel.[1-100].field, ChildModel.[1-100].GrandchildModel.[1-100].field starts to get huge. Unlocking a namespace of tens of thousands of possible fields, very few of which are going to be used but all of which are potentially going to be needed, starts to sound really crazy.
What solutions have other CakePHP devs found to get around this issue? I presume this is something that other people have encountered, where disabling Security for the entire action is simply not an option.
Personally I'm doing it this way:
Use AJAX to send the information of the dynamic fields to create back to the server
Genereate a form with the new inputs
Extract the token values from the generated form HTML and pass them back together with the generated HTML of the new fields
Inject the generated HTML and token values into the existing form
???
Profit!
Here's a very basic stripped example from an older project, it is used to add additional inputs for a single association.
Server side:
App::uses('Xml', 'Utility');
$formHtml = $this->Form->create('Model');
$this->Form->input('some_field');
$this->Form->input('also_a_field');
$dynamicInputs = array();
for($i = 0; $i < $numberOfEntries; $i ++)
{
$dynamicInputs[] = $this->Form->input('AssociatedModel.' . $i . '.field');
}
$formHtml .= $this->Form->end();
$xml = Xml::build($formHtml);
$formData = Xml::toArray($xml);
$data = array
(
'token' => array
(
'key' => array
(
'id' => $formData['form']['div'][0]['input'][1]['#id'],
'value' => $formData['form']['div'][0]['input'][1]['#value']
),
'fields' => array
(
'id' => $formData['form']['div'][2]['input'][0]['#id'],
'value' => $formData['form']['div'][2]['input'][0]['#value']
),
'unlocked' => array
(
'id' => $formData['form']['div'][2]['input'][1]['#id'],
'value' => $formData['form']['div'][2]['input'][1]['#value']
)
),
'dynamicInputs' => $dynamicInputs
);
echo json_encode($data);
Frontend (using jQuery):
var form = $('#my-form');
function addEntry()
{
var inputs = form.find('.associated-model .input');
var numberOfEntries = inputs.length + 1;
$.ajax({
url: '/controller/action/whatever',
type: 'POST',
data: 'numberOfEntries=' + numberOfEntries + '&' + form.serialize(),
dataType: 'json',
success: function(data)
{
updateForm(data);
}
});
}
function updateForm(data)
{
var tokenKey = form.find('input[name=\'data[_Token][key]\']');
tokenKey.attr('id', data.token.key.id);
tokenKey.attr('value', data.token.key.value);
var tokenFields = form.find('input[name=\'data[_Token][fields]\']');
tokenFields.attr('id', data.token.fields.id);
tokenFields.attr('value', data.token.fields.value);
var tokenUnlocked = form.find('input[name=\'data[_Token][unlocked]\']');
tokenUnlocked.attr('id', data.token.unlocked.id);
tokenUnlocked.attr('value', data.token.unlocked.value);
form.find('.associated-model').empty().append(data.dynamicInputs.join('\n'));
}
Related
I need to update an old project - it has its own backend and does deliver row data as an array which gets displayed as a standard html table.
The last attribute in the row array has an "editing column" by default, which means, it contains markup for an edit-icon like this:
$aData = array(
"first_name" => "John",
"last_name" => "Connor",
"edit_columns" => "<a href='#' class='edit'>Edit</a>"
);
The problem - there are also tables in my backend which will deliver several table cells (which works so far as it used to be a html table) with array data like this:
$aData = array(
"first_name" => "John",
"last_name" => "Connor",
"edit_columns" => "<a href='#' class='edit'>Edit</a> </td><td> <a href='#' class='delete'>Delete</a>"
);
The problem: the datatables plugin will treat edit_columns as a single cell and filter the </td><td> markup which results in 2 hyperlinks within one cell.
I know i would need to refactor my data but my customer wants to keep the backend data untouched. So - is there a way to "shift" the cell data as required?
Depending on the version of datatables you are using you could listen to the xhr.dt-event with javascript and then modify/split the data before it gets rendered in your table.
myTable.on('xhr.dt', function (e, settings, json, xhr) {
let data = json.data;
for ( let i=0, dataLength=data.length; i<dataLength; i++ ) {
let edit_columns = data[i].edit_columns.split("</td><td>");
data[i].edit_column = edit_columns[0];
data[i].delete_column = edit_columns[1];
}
});
Now you can use the created data-columns (edit_column and delete_column) in your table like they are regular columns without modifying the code on the backend/serverside.
I'm a noobie of PHP and AngularJS.
I have a webpage that communicates to a web serves with PHP - AJAX. It queries a database, and echoes the result (a big table) in an html placeholder.
I want to print the content of that table in a downloadable PDF file when the user pushes a button.
I want to use PDFmake and now it works well for test purpose, but how can I pass that content of my table to AngularJS' app?
Maybe should I pass table's id to docDefinition content? In that case I don't know how to do that.
Note: Maybe my approach is uncorrent cause I have to relegate PHP to different tasks and use AngularJS to query the Database, but for now I want to mantain this approach.
Thank You
I suggest you use an angular service (as explained in the docs
)
var bigTableApp = angular.module('bigTable',[])
bigTableApp.factory('BigTableSrv', ['$resource',
function($resource) {
return $resource('URL_to_php_backend', {}, {
query: {
method: 'GET',
params: {param1: 'value 1', param2: 'value 2'},
isArray: true
}
});
}
]);
Then, you can use it in a controller to fetch data from the back-end and build a table structure in PDFmake's table format:
bigTableApp.controller('BigTableController', ['$scope', 'BigTableSrv',
function BigTableController($scope, BigTableSrv) {
$scope.bigTable = BigTableSrv.query();
$scope.pdfMakeTable = {
// array of column widths, expand as needed
widths: [10, *, 130],
body: []
};
$scope.printTable = function() {
pdfMakeTable.body = $scope.bigTable.map(el => {
// process each element of your "big table" to one line of the
// pdfMake table, size of return array must match that of the widths array
return [el.prop1, el.prop2, el.prop3]
});
// create the pdfMake document
let docDefinition = {
content: [ pdfMakeTable ]
}
// print your pdf
pdfMake.creatPdf(docDefinition).print()
}
}
]);
I have been trying to get cakephp to suggest input from data that is from my tables like autocomplete. I've done some reading about how some other people have done this but still can't figure it out. Currently it seems that every time my controller is waiting for an ajax request and it is always false. No errors come up from the console some i'm not sure what i'm doing wrong. I tried removing the if ($this->request->is('ajax')) statement but then I get a error about it cannot emit headers.
Here is my search function in InvoicesController which I have taken code from someone else example but failed to implement it.
public function search()
{
if ($this->request->is('ajax')) {
$this->autoRender = false;
pr('b');
$name = $this->request->query['term'];
$results = $this->Invoices->find('all', [
'conditions' => [ 'OR' => [
'id LIKE' => $id . '%',
]]
]);
$resultsArr = [];
foreach ($results as $result) {
$resultsArr[] =['label' => $result['full_name'], 'value' => $result['id']];
}
echo json_encode($resultsArr);
}
}
And here is my search.ctp
<?php use Cake\Routing\Router; ?>
<?php echo $this->Form->input('id', ['type' => 'text']);?>
<script>
jQuery('#id').autocomplete({
source:'<?php echo Router::url(array('controller' => 'Invoices', 'action' => 'search')); ?>',
minLength: 1
});
</script>
This is my invoice table and the ids are what I want to be suggested from what users type in.
I may not be seeing your exact problem but let me point out a few things I see that might help this issue.
Remove this line. It is not necessary
$this->autoRender = false;
Instead you should be doing this at the end. See using the RequestHandler
$this->set('resultsArr', $resultsArr);
// This line is what handles converting your array into json
// To get this to work you must load the request handler
$this->set('_serialize', 'resultsArr');
This will return the data without a root key
[
{"label":"Label Value"},
{"label":"Another Label Value"}
]
Or you can do it like this
$this->set('_serialize', ['resultsArr']);
This will return data like
{"resultArr":[
{"label":"Label Value"},
{"label":"Another Value"}
]}
Replace your finder query with this.
$resultArr = $this->Invoices->find('all')
->where(['id LIKE' => $id . '%'])
// If you want to remap your data use map
// All queries are collections
->map(function ($invoice) {
return ['label' => $invoice->full_name, 'id' => $invoice->id];
});
It seems to me you might want to review the new cakephp 3 orm. A lot of hard work went into writing these docs so that they could be easily read and relevant. I'm not one to push docs on people but it will save you hours of frustration.
Cakephp 3 ORM documentation
A few minor things I noticed that are also problems.
You never define $id.
You define $name but never use it.
pr is a debug statement and I am not sure why you have it.
Based on your comment, here is an update on ajax detection.
// By default the ajax detection is limited to the x-request-with header
// I didn't want to have to set that for every ajax request
// So I overrode that with the accepts header.
// Any request where Accept is application/json the system will assume it is an ajax request
$this->request->addDetector('ajax', function ($request) {
$acceptHeaders = explode(',', $request->env('HTTP_ACCEPT'));
return in_array('application/json', $acceptHeaders);
});
Here's the scenario: I'm building a Wordpress plugin to manage some survey research that I'm involved in. The project admin is able to upload a csv file from the WP admin interface. On the client side, when the file is uploaded it goes through each line of the file, extracts the necessary info about the users and then makes an AJAX call to add the participant to the project. I decided to parse the csv file on the client side and submit the ajax requests one by one so that I could update a progress bar as each returns. The javascript looks like this:
$( '#csv_upload_button' ).click( function() {
// declare the necessary variables
var f = $( '#csv_file_input' )[0].files[0],
fr = new FileReader,
rows, headers, dialog, count, remaining;
// when the file loads
fr.onload = function() {
// get the rows, the count, number remaining to process, and headers
rows = fr.result.split( "\n" );
remaining = count = rows.length - 1; // -1 to account for header row
headers = $.trim( rows[0] ).split( ',' );
// create the dialog box to show the progress bar
dialog = $( '<div></div>' )
.html(
'<p>Loading...</p>' +
'<p><progress id="csv_upload_progress" max="' + count +
'" min="0" value="0"></p>' )
.dialog( { modal: true; } );
// then for each row in the file
$( rows ).each( function( i, r ) {
// create an object to hold the data
var data = {}, row = $.trim( r ).split( ',' ), j;
if ( i > 0 ) { // data starts on the second row
// map the data into our object
for ( j = 0; j < headers.length; j++ ) {
data[ headers[ j ] ] = row[ j ];
}
// send it to the server
$.post(
ajaxurl,
{
action: 'import_panel_member',
data: data,
postid: $( '#post_ID' ).val()
},
function( result ) {
var prog = $( '#csv_upload_progress' );
prog.attr( 'value', prog.attr( 'value' ) + 1 );
if ( 0 == --remaining ) {
// stuff to do when everything has been loaded
}
}
);
}
});
};
// read the csv file
fr.readAsText( f );
});
The PHP looks something like this:
function import_panel_member() {
header( 'content-type: application/json' );
// get the variables sent from the client
$postid = $_POST[ 'postid' ];
$data = $_POST[ 'data' ];
/*
* ...do other things involving talking to a 3rd party server...
*/
// get the WP meta data variable to be updated
$participants = get_post_meta( $postid, '_project_participants', true );
// modify it
$participants[] = $data;
// update the database
update_post_meta( $postid, '_project_participants', $participants );
// return a message to the client
echo json_encode( (object) array( 'success' => 1, 'message' => 'added' ) );
exit;
}
The problem is that since these requests happen asynchronously, it appears that the _project_participants metadata field is only being updated by the last record to be processed. In other words, only the last person in the list shows up in the list of participants. Here are some things that I've tried:
Change $.post() to $.ajax() and set async: false
This works but it's much slower (due to synchronous calls) and for some reason it prevents my dialog box from showing up until after all the ajax calls have finished.
Upload the entire csv file to the server and deal with it there
Instead of parsing the csv on the client side. This works, too, but I don't think I can get intermediate feedback from the server that I can use to update the progress bar. This request can take quite a while, and I don't want users to "give up" on the request before it's complete. When doing it this way, sometimes the server never responds to the ajax call.
So perhaps I am greedy and just want my cake and to eat it, too. How can I take advantage of the speed of asynchronous requests, which give me the opportunity to give the user feedback with a progress bar, but not screw myself up with concurrency issues on the server?
I figured it out. The answer was a hybrid of the two methods. I can use a series of $.post() calls to do the stuff that works better in async mode, and then upload the whole csv to do the stuff that works better in sync mode. Never would have figured this out without typing out the whole explanation in SO!
I am trying to build in a "search" box on a results page in my cakephp app. The page uses the cakePHP pagination component to show and "page" results. This is perfect, but I am having difficulties to get the next part to work.
The desired outcome:
A cakephp form (post) with an input box and a couple of select boxes, including a date selector so that I can select between dates. The user should be able to populate these fields and submit
On submit, the user selection should change the cakePHP pagination conditions in the controller
In the view I want the pagination bar to keep record of the user selection, so that when I filter through different pages, it keeps the users search. I understand this can be achieved using $this->passedArgs, hence why I am using post and not get.
The code:
// Form:
<?php
echo $this->Form->create('search', array('class' => false));
echo $this->Form->input('searchFor');
echo $this->Form->input('dateFrom');
echo $this->Form->input('dateTo');
echo $this->Form->end();
?>
// Controller:
if($this->request->is("post")) {
$filters = $this->request->data["search"];
$this->passedArgs["searchFor"] = $filters["searchFor"];
$this->passedArgs["dateFrom"] = $filters["dateFrom"]." 00:00:00";
$this->passedArgs["dateTo"] = $filters["dateTo"]." 00:00:00";
// Assign search parameters:
if($this->passedArgs["searchFor"] != "") {
$conditions["Model.field LIKE"] = "%".$this->passedArgs["searchFor"]."%";
}
$conditions["Model.created >="] = $this->passedArgs["dateFrom"];
$conditions["Model.created <="] = $this->passedArgs["dateTo"];
} else {
$conditions = array("Result.status_id >=" => 12);
}
$this->paginate = array(
'conditions' => $conditions,
'order' => array('Result.created ASC'),
'limit' => 20
);
$this->set("results",$this->paginate("Model");
// The view file:
<?php
$this->Paginator->options(array('url' => $this->passedArgs));
?>
Where I am now:
The initial page loads with all of the results
When I populate the search boxes it does return my results
The problem:
I am convinced the way I am doing it is incorrect as I now need to do 2 checks, a) being if results has been posted and b) check if there is passedArgs available. I am 100% convinced this is not the right way of doing it.
Let's say I have 2 free form fields for search, say name and surname, if I leave surname blank my url would be written as below, and this does not look or appear to be correct. That means I have to assign default values to ensure the items below does not happen, which does not appear to be very dynamic.
http://localhost/site/controller/action/surname:0/name:John/date:0/
On refresh it says the page does not exist because the posted values is not available anylonger.
usually I proceed like this in the controller:
//transform POST into GET
if($this->request->is("post")) {
$url = array('action'=>'index');
$filters = array();
if(isset($this->data['searchFor']) && $this->data['searchFor']){
//maybe clean up user input here??? or urlencode??
$filters['searchFor'] = $this->data['searchFor'];
}
//redirect user to the index page including the selected filters
$this->redirect(array_merge($url,$filters));
}
$conditions = array();
//check filters on passedArgs
if(isset($this->passedArgs["searchFor"])){
$conditions["Model.field LIKE"] = "%".$this->passedArgs["searchFor"]."%";
}
//paginate as normal
$this->paginate = array(
'conditions' => $conditions,
'order' => array('Result.created ASC'),
'limit' => 20
);
The idea is to transform the POST sent by your form into GET. so you wont have problems with the paginator nor the refresh
Hope this helps
What you want can be done a lot more simple and DRY by using this search plugin.
It automates what you want more or less plus it already can do more than your code.
So I suggest you to use the plugin directly or take a look at it how it does the trick. :)