/
Token.php
451 lines (415 loc) · 16.1 KB
/
Token.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
<?php
/**
*
* For code completion we add the available scenario's here
* Attributes
* @property int $tid
* @property string $firstname
* @property string $lastname
* @property string $email
* @property string $emailstatus
* @property string $token
* @property string $language
* @property string $blacklisted
* @property string $sent
* @property string $remindersent
* @property int $remindercount
* @property string $completed
* @property int $usesleft
* @property DateTime $validfrom
* @property DateTime $validuntil
*
* Relations
* @property Survey $survey The survey this token belongs to.
*
* Scopes
* @method Token incomplete() incomplete() Select only uncompleted tokens
* @method Token usable() usable() Select usable tokens: valid daterange and usesleft > 0
*
*/
use \LimeSurvey\PluginManager\PluginEvent;
/**
* Class Token
*
* @property integer $tid Token ID
* @property string $participant_id Participant ID
* @property string $firstname Participant's first name
* @property string $lastname Participant's last name
* @property string $email Participant's e-mail address
* @property string $emailstatus Participant's e-mail address status: OK/bounced/OptOut
* @property string $token Participant's token
* @property string $language Participant's language eg: en
* @property string $blacklisted Whether participant is blacklisted: (Y/N)
* @property string $sent
* @property string $remindersent
* @property integer $remindercount
* @property string $completed Participant completed status (N:Not completed; Q:Locked with quota; 'YYYY-MM-DD hh:mm': date of completion)
* @property integer $usesleft How many uses left to fill questionnaire for this participant
* @property string $validfrom
* @property string $validuntil
* @property integer $mpid //TODO Describe me!
*
* @property Survey $survey
* @property SurveyLink $surveylink
* @property Response[] $responses
* @property CDbTableSchema $tableSchema
*/
abstract class Token extends Dynamic
{
/** @var int Maximum token length */
const MAX_LENGTH = 36;
/**
* Set defaults
* @inheritdoc
*/
public function init()
{
// Set the default values
$this->usesleft = 1;
$this->completed = "N";
}
/** @inheritdoc */
public function attributeLabels()
{
$labels = array(
'tid' => gT('Access code ID'),
'partcipant_id' => gT('Participant ID'),
'firstname' => gT('First name'),
'lastname' => gT('Last name'),
'email' => gT('Email address'),
'emailstatus' => gT('Email status'),
'token' => gT('Access code'),
'language' => gT('Language code'),
'blacklisted' => gT('Blacklisted'),
'sent' => gT('Invitation sent date'),
'remindersent' => gT('Last reminder sent date'),
'remindercount' =>gT('Total numbers of sent reminders'),
'completed' => gT('Completed'),
'usesleft' => gT('Uses left'),
'validfrom' => gT('Valid from'),
'validuntil' => gT('Valid until'),
);
foreach (decodeTokenAttributes($this->survey->attributedescriptions) as $key => $info) {
$labels[$key] = !empty($info['description']) ? $info['description'] : '';
}
return $labels;
}
/** @inheritdoc
* Delete related SurveyLink if it's deleted
*/
public function beforeDelete()
{
$result = parent::beforeDelete();
if ($result && isset($this->surveylink)) {
if (!$this->surveylink->delete()) {
throw new CException('Could not delete survey link. Participant was not deleted.');
}
return true;
}
return $result;
}
/** @inheritdoc
* Delete related SurveyLink at same time
*/
public function deleteAllByAttributes($attributes, $condition = '', $params = array())
{
$builder=$this->getCommandBuilder();
$participantCriteria=$builder->createCriteria($condition,$params);
$participantCriteria->select = array('tid','participant_id');
$participantCriteria->addCondition('participant_id is not null');
$oParticipantToDelete = self::model($this->dynamicId)->findAll($participantCriteria);
$result = parent::deleteAllByAttributes($attributes, $condition, $params);
if($result && !empty($oParticipantToDelete)) {
/* Get the participant not deleted : we must not delete survey link */
$oParticipantNotDeleted = self::model($this->dynamicId)->findAll($participantCriteria);
$tidToDelete = array_diff(CHtml::listData($oParticipantToDelete,'tid','tid'),CHtml::listData($oParticipantNotDeleted,'tid','tid'));
if(!empty($tidToDelete)) {
SurveyLink::model()->deleteAllByAttributes(array('token_id'=>$tidToDelete,'survey_id'=>$this->dynamicId));
}
}
return $result;
}
/**
* @param integer $surveyId
* @param array $extraFields
* @return CDbTableSchema
*/
public static function createTable($surveyId, array $extraFields = array())
{
$surveyId = intval($surveyId);
$options = '';
// Specify case sensitive collations for the token
$sCollation = '';
if (Yii::app()->db->driverName == 'mysql' || Yii::app()->db->driverName == 'mysqli') {
$sCollation = "COLLATE 'utf8mb4_bin'";
if(!empty(Yii::app()->getConfig('mysqlEngine'))) {
$options .= sprintf(" ENGINE = %s ", Yii::app()->getConfig('mysqlEngine'));
}
}
if (Yii::app()->db->driverName == 'sqlsrv'
|| Yii::app()->db->driverName == 'dblib'
|| Yii::app()->db->driverName == 'mssql') {
$sCollation = "COLLATE SQL_Latin1_General_CP1_CS_AS";
}
$fields = array(
'tid' => 'pk',
'participant_id' => 'string(50)',
'firstname' => 'text',
'lastname' => 'text',
'email' => 'text',
'emailstatus' => 'text',
'token' => "string(" . self::MAX_LENGTH . ") {$sCollation}",
'language' => 'string(25)',
'blacklisted' => 'string(17)',
'sent' => "string(17) DEFAULT 'N'",
'remindersent' => "string(17) DEFAULT 'N'",
'remindercount' => 'integer DEFAULT 0',
'completed' => "string(17) DEFAULT 'N'",
'usesleft' => 'integer DEFAULT 1',
'validfrom' => 'datetime',
'validuntil' => 'datetime',
'mpid' => 'integer'
);
foreach ($extraFields as $extraField) {
$fields[$extraField] = 'text';
}
// create fields for the custom token attributes associated with this survey
$oSurvey = Survey::model()->findByPk($surveyId);
foreach ($oSurvey->tokenAttributes as $attrname=>$attrdetails) {
if (!isset($fields[$attrname])) {
$fields[$attrname] = 'text';
}
}
$db = \Yii::app()->db;
$sTableName = $oSurvey->tokensTableName;
$db->createCommand()->createTable($sTableName, $fields, $options);
/**
* @todo Check if this random component in the index name is needed.
* As far as I (sam) know index names need only be unique per table.
*/
$db->createCommand()->createIndex("idx_token_token_{$surveyId}_".rand(1, 50000), $sTableName, 'token');
// Refresh schema cache just in case the table existed in the past, and return if table exist
return $db->schema->getTable($sTableName, true);
}
/**
* @param string $token
* @return Token
*/
public function findByToken($token)
{
return $this->findByAttributes(array(
'token' => $token
));
}
/**
* Generates a token for this object.
* @throws CHttpException
*/
public function generateToken($tokenlength = NULL)
{
$iTokenLength = $tokenlength ? $tokenlength : $this->survey->tokenlength;
$this->token = $this->_generateRandomToken($iTokenLength);
$counter = 0;
while (!$this->validate(array('token'))) {
$this->token = $this->_generateRandomToken($iTokenLength);
$counter++;
// This is extremely unlikely.
if ($counter > 50) {
throw new CHttpException(500, 'Failed to create unique access code in 50 attempts.');
}
}
}
/**
* Creates a random token string without special characters
*
* @param integer $iTokenLength
* @return string
*/
private function _generateRandomToken($iTokenLength)
{
$token = Yii::app()->securityManager->generateRandomString($iTokenLength);
if ($token === false) {
throw new CHttpException(500, gT('Failed to generate random string for token. Please check your configuration and ensure that the openssl or mcrypt extension is enabled.'));
}
$token = str_replace(array('~', '_'), array('a', 'z'), $token);
$event = new PluginEvent('afterGenerateToken');
$event->set('surveyId', $this->getSurveyId());
$event->set('iTokenLength', $iTokenLength);
$event->set('oToken', $this);
$event->set('token', $token);
App()->pluginManager->dispatchEvent($event);
$token = $event->get('token');
return $token;
}
/**
* Sanitize token show to the user (replace sanitize_helper sanitize_token)
* @param string $token to sanitize
* @return string sanitized token
*/
public static function sanitizeToken($token)
{
// According to Yii doc : http://www.yiiframework.com/doc/api/1.1/CSecurityManager#generateRandomString-detail
return preg_replace('/[^0-9a-zA-Z_~]/', '', $token);
}
/**
* Generates a token for all token objects in this survey.
* Syntax: Token::model(12345)->generateTokens();
* @return integer[]
* @throws Exception
*/
public function generateTokens()
{
if ($this->scenario != '') {
throw new \Exception("This function should only be called like: Token::model(12345)->generateTokens");
}
$surveyId = $this->dynamicId;
$iTokenLength = isset($this->survey) && is_numeric($this->survey->oOptions->tokenlength) ? $this->survey->oOptions->tokenlength : 15;
$tkresult = Yii::app()->db->createCommand("SELECT tid FROM {{tokens_{$surveyId}}} WHERE token IS NULL OR token=''")->queryAll();
//Exit early if there are not empty tokens
if (count($tkresult) === 0) {
return array(0, 0);
}
//Add some criteria to select only the token field
$criteria = $this->getDbCriteria();
$criteria->select = 'token';
$ntresult = $this->findAllAsArray($criteria); //Use AsArray to skip active record creation
// select all existing tokens
foreach ($ntresult as $tkrow) {
$existingtokens[$tkrow['token']] = true;
}
$newtokencount = 0;
$invalidtokencount = 0;
$newtoken = null;
foreach ($tkresult as $tkrow) {
$bIsValidToken = false;
while ($bIsValidToken == false && $invalidtokencount < 50) {
$newtoken = $this->_generateRandomToken($iTokenLength);
if (!isset($existingtokens[$newtoken])) {
$existingtokens[$newtoken] = true;
$bIsValidToken = true;
$invalidtokencount = 0;
} else {
$invalidtokencount++;
}
}
if ($bIsValidToken) {
$this->updateByPk($tkrow['tid'], array('token' => $newtoken));
$newtokencount++;
} else {
break;
}
}
return array($newtokencount, count($tkresult));
}
/**
* @inheritdoc
* @return Token
*/
public static function model($className = null)
{
/** @var self $model */
$model = parent::model($className);
return $model;
}
/**
* @param int $surveyId
* @param string $scenario
* @return Token Description
*/
public static function create($surveyId, $scenario = 'insert')
{
return parent::create($surveyId, $scenario);
}
public function relations()
{
$result = array(
'responses' => array(self::HAS_MANY, 'Response_'.$this->dynamicId, array('token' => 'token')),
'survey' => array(self::BELONGS_TO, 'Survey', '', 'on' => "sid = {$this->dynamicId}"),
'surveylink' => array(self::BELONGS_TO, 'SurveyLink', array('participant_id' => 'participant_id'), 'on' => "survey_id = {$this->dynamicId}")
);
return $result;
}
/** @inheritdoc */
public function rules()
{
$aRules = array(
array('token', 'unique', 'allowEmpty' => true),
array('firstname', 'LSYii_Validators'),
array('lastname', 'LSYii_Validators'),
array(implode(',', $this->tableSchema->columnNames), 'safe'),
array('remindercount', 'numerical', 'integerOnly'=>true, 'allowEmpty'=>true),
array('email', 'filter', 'filter'=>'trim'),
array('email', 'LSYii_EmailIDNAValidator', 'allowEmpty'=>true, 'allowMultiple'=>true, 'except'=>'allowinvalidemail'),
array('usesleft', 'numerical', 'integerOnly'=>true, 'allowEmpty'=>true),
array('mpid', 'numerical', 'integerOnly'=>true, 'allowEmpty'=>true),
array('blacklisted', 'in', 'range'=>array('Y', 'N'), 'allowEmpty'=>true),
array('emailstatus', 'default', 'value' => 'OK'),
);
foreach (decodeTokenAttributes($this->survey->attributedescriptions) as $key => $info) {
$aRules[] = array($key, 'LSYii_Validators', 'except'=>'FinalSubmit');
}
return $aRules;
}
/** @inheritdoc */
public function scopes()
{
$now = dateShift(date("Y-m-d H:i:s"), "Y-m-d H:i:s", Yii::app()->getConfig("timeadjust"));
return array(
'incomplete' => array(
'condition' => "completed = 'N'"
),
'usable' => array(
'condition' => "COALESCE(validuntil, '$now') >= '$now' AND COALESCE(validfrom, '$now') <= '$now' AND usesleft > 0"
),
'editable' => array(
'condition' => "COALESCE(validuntil, '$now') >= '$now' AND COALESCE(validfrom, '$now') <= '$now'"
),
'empty' => array(
'condition' => 'token is null or token = ""'
)
);
}
/**
* @return CDbDataReader|mixed
*/
public function summary()
{
$criteria = $this->getDbCriteria();
$criteria->select = array(
"COUNT(*) as count",
"COUNT(CASE WHEN (token IS NULL OR token='') THEN 1 ELSE NULL END) as invalid",
"COUNT(CASE WHEN (sent!='N' AND sent<>'') THEN 1 ELSE NULL END) as sent",
"COUNT(CASE WHEN (emailstatus LIKE 'OptOut%') THEN 1 ELSE NULL END) as optout",
"COUNT(CASE WHEN (completed!='N' and completed<>'') THEN 1 ELSE NULL END) as completed",
"COUNT(CASE WHEN (completed='Q') THEN 1 ELSE NULL END) as screenout",
);
$command = $this->getCommandBuilder()->createFindCommand($this->getTableSchema(), $criteria);
return $command->queryRow();
}
/** @inheritdoc */
public function tableName()
{
return '{{tokens_'.$this->dynamicId.'}}';
}
/**
* Get current surveyId for other model/function
* @return int
*/
public function getSurveyId() {
return $this->getDynamicId();
}
public static function getEncryptedAttributes(){
return self::$aEncryptedAttributes;
}
public static function getDefaultEncryptionOptions(){
$sEncrypted = 'N';
return array(
'enabled' => 'N',
'columns' => array(
'firstname' => $sEncrypted,
'lastname' => $sEncrypted,
'email' => $sEncrypted
)
);
}
}