Newer
Older
<?php
/*********************************************************************
class.filter.php
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
class Filter {
var $id;
var $ht;
$this->id=0;
$this->load($id);
}
function load($id=0) {
if(!$id && !($id=$this->getId()))
return false;
$sql='SELECT filter.*,count(rule.id) as rule_count '
.' FROM '.FILTER_TABLE.' filter '
.' LEFT JOIN '.FILTER_RULE_TABLE.' rule ON(rule.filter_id=filter.id) '
.' WHERE filter.id='.db_input($id)
.' GROUP BY filter.id';
if(!($res=db_query($sql)) || !db_num_rows($res))
return false;
return true;
}
function reload() {
return $this->load($this->getId());
}
function getTarget() {
return $this->ht['target'];
}
function getName() {
function getEmailId() {
return $this->ht['email_id'];
}
return ($this->ht['isactive']);
}
function isSystemBanlist() {
return !strcasecmp($this->getName(),'SYSTEM BAN LIST');
}
function getCannedResponse() {
return $this->ht['canned_response_id'];
return ($this->ht['stop_on_match']);
}
return ($this->ht['match_all_rules']);
}
function rejectOnMatch() {
return ($this->ht['reject_ticket']);
return ($this->ht['use_replyto_email']);
}
return ($this->ht['disable_autoresponder']);
}
if (!$this->ht['rules']) {
$rules=array();
//We're getting the rules...live because it gets cleared on update.
$sql='SELECT * FROM '.FILTER_RULE_TABLE.' WHERE filter_id='.db_input($this->getId());
if(($res=db_query($sql)) && db_num_rows($res)) {
while($row=db_fetch_array($res))
$rules[]=array('w'=>$row['what'],'h'=>$row['how'],'v'=>$row['val']);
}
$this->ht['rules'] = $rules;
}
return $this->ht['rules'];
}
function getFlatRules() { //Format used on html... I'm ashamed
if(($rules=$this->getRules())) {
foreach($rules as $k=>$rule) {
$i=$k+1;
$info["rule_w$i"]=$rule['w'];
$info["rule_h$i"]=$rule['h'];
$info["rule_v$i"]=$rule['v'];
}
}
return $info;
}
function addRule($what, $how, $val,$extra=array()) {
$rule= array_merge($extra,array('w'=>$what, 'h'=>$how, 'v'=>$val));
$rule['filter_id']=$this->getId();
return FilterRule::create($rule,$errors); # nolint
}
function removeRule($what, $how, $val) {
.' WHERE filter_id='.db_input($this->getId())
.' AND what='.db_input($what)
.' AND how='.db_input($how)
.' AND val='.db_input($val);
return (db_query($sql) && db_affected_rows());
}
function getRule($id) {
return $this->getRuleById($id);
}
function getRuleById($id) {
return FilterRule::lookup($id,$this->getId());
}
function containsRule($what, $how, $val) {
$match = array("w"=>$what, "h"=>$how, "v"=>$val);
if ($match == $rule)
return true;
} else {
# Fetch from database
return 0 != db_count(
." WHERE filter_id=".db_input($this->id)
." AND what=".db_input($what)." AND how=".db_input($how)
." AND val=".db_input($val)
);
}
}
/**
* Simple true/false if the rules defined for this filter match the
* incoming email
*
* $info is an ARRAY, which has valid keys
* email - FROM email address of the ticket owner
* name - name of ticket owner
* subject - subject line of the ticket
* body - body content of the message (no attachments, please)
* reply-to - reply-to email address
* reply-to-name - name of sender to reply-to
* headers - array of email headers
$how = array(
# how => array(function, null or === this, null or !== this)
'equal' => array('strcmp', 0),
'not_equal' => array('strcmp', null, 0),
'contains' => array('strpos', null, false),
'dn_contain'=> array('strpos', false),
'starts' => array('strpos', 0),
'ends' => array('endsWith', true)
# Respect configured filter email-id
Peter Rotich
committed
&& !strcasecmp($this->getTarget(), 'Email')
soif
committed
foreach ($this->getRules() as $rule) {
list($func, $pos, $neg) = $how[$rule['h']];
# TODO: convert $what and $rule['v'] to mb_strtoupper and do
# case-sensitive, binary-safe comparisons. Would be really
# nice to do $rule['v'] on the database side for
# performance -- but ::getFlatRules() is a blocker
$result = call_user_func($func, strtoupper($what[$rule['w']]),
strtoupper($rule['v']));
if (($pos === null && $result !== $neg) or ($result === $pos)) {
# Match.
$match = true;
if (!$this->matchAllRules()) break;
} else {
# No match. Continue?
* If the matches() method returns TRUE, send the initial ticket to this
* method to apply the filter actions defined
*/
function apply(&$ticket, $info=null) {
# TODO: Disable alerting
# XXX: Does this imply turning it on as well? (via ->sendAlerts())
if ($this->disableAlerts()) $ticket['autorespond']=false;
# Set owning department (?)
if ($this->getDeptId()) $ticket['deptId']=$this->getDeptId();
# Set ticket priority (?)
if ($this->getPriorityId()) $ticket['priorityId']=$this->getPriorityId();
# Set SLA plan (?)
if ($this->getSLAId()) $ticket['slaId']=$this->getSLAId();
# Auto-assign to (?)
# XXX: Unset the other (of staffId or teamId) (?)
if ($this->getStaffId()) $ticket['staffId']=$this->getStaffId();
elseif ($this->getTeamId()) $ticket['teamId']=$this->getTeamId();
# Override name with reply-to information from the TicketFilter
if ($this->useReplyToEmail() && $info['reply-to']) {
$ticket['email'] = $info['reply-to'];
$ticket['name'] = $info['reply-to-name'];
# Use canned response.
if ($this->getCannedResponse())
$ticket['cannedResponseId'] = $this->getCannedResponse();
/* static */ function getSupportedMatches() {
return array(
Peter Rotich
committed
'name'=> 'Name',
'email'=> 'Email',
'subject'=> 'Subject',
'body'=> 'Body/Text'
);
}
/* static */ function getSupportedMatchTypes() {
return array(
'equal'=> 'Equal',
'not_equal'=> 'Not Equal',
'contains'=> 'Contains',
'dn_contain'=> 'Does Not Contain',
'starts'=> 'Starts With',
'ends'=> 'Ends With'
if(!Filter::save($this->getId(),$vars,$errors))
return false;
$this->reload();
$sql='DELETE FROM '.FILTER_TABLE.' WHERE id='.db_input($id).' LIMIT 1';
if(db_query($sql) && ($num=db_affected_rows())) {
db_query('DELETE FROM '.FILTER_RULE_TABLE.' WHERE filter_id='.db_input($id));
}
return $num;
}
/** static functions **/
'API' => 'API Calls',
'Email' => 'Emails');
}
function create($vars,&$errors) {
return Filter::save(0,$vars,$errors);
}
$sql='SELECT id FROM '.FILTER_TABLE.' WHERE name='.db_input($name);
if(($res=db_query($sql)) && db_num_rows($res))
list($id)=db_fetch_row($res);
return $id;
}
return ($id && is_numeric($id) && ($f= new Filter($id)) && $f->getId()==$id)?$f:null;
}
return self::save_rules(0,$vars,$errors);
}
$matches = array_keys(self::getSupportedMatches());
$types = array_keys(self::getSupportedMatchTypes());
$rules=array();
for($i=1; $i<=25; $i++) { //Expecting no more than 25 rules...
if($vars["rule_w$i"] || $vars["rule_h$i"]) {
if(!$vars["rule_w$i"] || !in_array($vars["rule_w$i"],$matches))
$errors["rule_$i"]='Invalid match selection';
elseif(!$vars["rule_h$i"] || !in_array($vars["rule_h$i"],$types))
$errors["rule_$i"]='Invalid match type selection';
elseif(!$vars["rule_v$i"])
$errors["rule_$i"]='Value required';
elseif($vars["rule_w$i"]=='email' && $vars["rule_h$i"]=='equal' && !Validator::is_email($vars["rule_v$i"]))
$errors["rule_$i"]='Valid email required for the match type';
else //for everything-else...we assume it's valid.
$rules[]=array('w'=>$vars["rule_w$i"],'h'=>$vars["rule_h$i"],'v'=>$vars["rule_v$i"]);
$errors["rule_$i"]='Incomplete selection';
}
}
if(!$rules && is_array($vars["rules"]))
# XXX: Validation bypass
$rules = $vars["rules"];
elseif(!$rules && !$errors)
$errors['rules']='You must set at least one rule.';
if($errors) return false;
if(!$id) return true; //When ID is 0 then assume it was just validation...
//Clear existing rules...we're doing mass replace on each save!!
db_query('DELETE FROM '.FILTER_RULE_TABLE.' WHERE filter_id='.db_input($id));
$num=0;
foreach($rules as $rule) {
$rule['filter_id']=$id;
if(FilterRule::create($rule, $errors))
$num++;
}
$errors['execorder'] = 'Must be numeric value';
elseif(($sid=self::getIdByName($vars['name'])) && $sid!=$id)
$errors['name'] = 'Name already in use';
if(!$errors && !self::validate_rules($vars,$errors) && !$errors['rules'])
$errors['rules'] = 'Unable to validate rules as entered';
$targets = self::getTargets();
if(!$vars['target'])
$errors['target'] = 'Target required';
else if(!is_numeric($vars['target']) && !$targets[$vars['target']])
$errors['target'] = 'Unknown or invalid target';
$emailId = 0;
if(is_numeric($vars['target'])) {
$emailId = $vars['target'];
$vars['target'] = 'Email';
}
$sql=' updated=NOW() '
.',isactive='.db_input($vars['isactive'])
.',target='.db_input($vars['target'])
.',name='.db_input($vars['name'])
.',execorder='.db_input($vars['execorder'])
.',email_id='.db_input($emailId)
.',dept_id='.db_input($vars['dept_id'])
.',priority_id='.db_input($vars['priority_id'])
.',sla_id='.db_input($vars['sla_id'])
.',match_all_rules='.db_input($vars['match_all_rules'])
.',stop_onmatch='.db_input(isset($vars['stop_onmatch'])?1:0)
.',reject_ticket='.db_input(isset($vars['reject_ticket'])?1:0)
.',use_replyto_email='.db_input(isset($vars['use_replyto_email'])?1:0)
.',disable_autoresponder='.db_input(isset($vars['disable_autoresponder'])?1:0)
.',canned_response_id='.db_input($vars['canned_response_id'])
.',notes='.db_input($vars['notes']);
//Auto assign ID is overloaded...
if($vars['assign'] && $vars['assign'][0]=='s')
$sql.=',team_id=0,staff_id='.db_input(preg_replace("/[^0-9]/", "",$vars['assign']));
elseif($vars['assign'] && $vars['assign'][0]=='t')
$sql.=',staff_id=0,team_id='.db_input(preg_replace("/[^0-9]/", "",$vars['assign']));
else
$sql.=',staff_id=0,team_id=0 '; //no auto-assignment!
if($id) {
$sql='UPDATE '.FILTER_TABLE.' SET '.$sql.' WHERE id='.db_input($id);
if(!db_query($sql))
$errors['err']='Unable to update the filter. Internal error occurred';
}else{
$sql='INSERT INTO '.FILTER_TABLE.' SET '.$sql.',created=NOW() ';
if(!db_query($sql) || !($id=db_insert_id()))
$errors['err']='Unable to add filter. Internal error';
}
if($errors || !$id) return false;
//Success with update/create...save the rules. We can't recover from any errors at this point.
self::save_rules($id,$vars,$xerrors); # nolint
return true;
}
}
class FilterRule {
var $id;
var $ht;
var $filter;
$this->id=0;
$this->load($id,$filterId);
}
function load($id,$filterId=0) {
$sql='SELECT rule.* FROM '.FILTER_RULE_TABLE.' rule '
.' WHERE rule.id='.db_input($id);
if($filterId)
$sql.=' AND rule.filter_id='.db_input($filterId);
if(!($res=db_query($sql)) || !db_num_rows($res))
return false;
$this->ht=db_fetch_array($res);
$this->id=$this->ht['id'];
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
$this->filter=null;
return true;
}
function reload() {
return $this->load($this->getId());
}
function getId() {
return $this->id;
}
function isActive() {
return ($this->ht['isactive']);
}
function getHashtable() {
return $this->ht;
}
function getInfo() {
return $this->getHashtable();
}
function getFilterId() {
return $this->ht['filter_id'];
}
function getFilter() {
if(!$this->filter && $this->getFilterId())
$this->filter = Filter::lookup($this->getFilterId());
return $this->filter;
}
function update($vars,&$errors) {
if(!$this->save($this->getId(),$vars,$errors))
return false;
$this->reload();
return true;
}
$sql='DELETE FROM '.FILTER_RULE_TABLE.' WHERE id='.db_input($this->getId()).' AND filter_id='.db_input($this->getFilterId());
return (db_query($sql) && db_affected_rows());
}
/* static */ function create($vars,&$errors) {
return self::save(0,$vars,$errors);
}
/* static private */ function save($id,$vars,&$errors) {
if(!$vars['filter_id'])
$errors['err']='Parent filter ID required';
if($errors) return false;
$sql=' updated=NOW() '.
',what='.db_input($vars['w']).
',how='.db_input($vars['h']).
',val='.db_input($vars['v']).
',isactive='.db_input(isset($vars['isactive'])?$vars['isactive']:1);
if(isset($vars['notes']))
$sql.=',notes='.db_input($vars['notes']);
if($id) {
$sql='UPDATE '.FILTER_RULE_TABLE.' SET '.$sql.' WHERE id='.db_input($id).' AND filter_id='.db_input($vars['filter_id']);
if(db_query($sql))
return true;
} else {
$sql='INSERT INTO '.FILTER_RULE_TABLE.' SET created=NOW(), filter_id='.db_input($vars['filter_id']).', '.$sql;
if(db_query($sql) && ($id=db_insert_id()))
return $id;
}
return false;
}
/* static */ function lookup($id,$filterId=0) {
return ($id && is_numeric($id) && ($r= new FilterRule($id,$filterId)) && $r->getId()==$id)?$r:null;
}
}
/**
* Applies rules defined in the admin control panel > Settings tab > "Ticket Filters". Each
* filter can have up to 25 rules (*currently). This will attempt to match
* the incoming tickets against the defined rules, and, if the email matches,
* the ticket will be modified as described in the filter actions.
class TicketFilter {
var $target;
var $vars;
* Construct a list of filters to handle a new ticket
* taking into account the source/origin of the ticket.
* $vars is an ARRAY, which has valid keys
* *email - email address of user
* name - name of user
* subject - subject of the ticket
* emailId - id of osTicket's system email (for emailed tickets)
* ---------------
* @see Filter::matches() for a complete list of supported keys
*
* IF $vars is not provided, every (active) filter will be fetched from the
* database and matched against the incoming ticket. Otherwise, a subset
* of filters from the database that appear to have rules that
* deal with the data in the incoming ticket (based on $vars) will be considered.
* @see ::quickList() for more information.
function TicketFilter($origin, $vars=null) {
//Normalize the target based on ticket's origin.
$this->target = self::origin2target($origin);
//Extract the vars we care about (fields we filter by!).
$this->vars = array_filter(array_map('trim',
array(
'email' => $vars['email'],
'subject' => $vars['subject'],
'name' => $vars['name'],
'body' => $vars['message'],
'emailId' => $vars['emailId'],
'reply-to' => @$vars['reply-to'],
'reply-to-name' => @$vars['reply-to-name'],
)));
function build() {
//Clear any memoized filters
$this->short_list = null;
//Query DB for "possibly" matching filters.
$res = $this->vars?$this->quickList():$this->getAllActive();
if($res) {
while (list($id) = db_fetch_row($res))
array_push($this->filters, new Filter($id));
}
function getTarget() {
return $this->target;
}
* Fetches the short list of filters that match the ticket vars received in the
* constructor. This function is memoized so subsequent calls will
* return immediately.
*/
function getMatchingFilterList() {
if (!isset($this->short_list)) {
$this->short_list = array();
foreach ($this->filters as $filter)
if ($filter->matches($this->vars))
return $this->short_list;
}
/**
* Determine if the filters that match the received vars indicate that
* the email should be rejected
*
* Returns FALSE if the email should be acceptable. If the email should
* be rejected, the first filter that matches and has reject ticket set is
* returned.
*/
function shouldReject() {
foreach ($this->getMatchingFilterList() as $filter) {
# Set reject if this filter indicates that the email should
# be blocked; however, don't unset $reject, because if it
# was set by another rule that did not set stopOnMatch(), we
# should still honor its configuration
if ($filter->rejectOnMatch()) return $filter;
/**
* Determine if any filters match the received email, and if so, apply
* actions defined in those filters to the ticket-to-be-created.
*/
function apply(&$ticket) {
foreach ($this->getMatchingFilterList() as $filter) {
$filter->apply($ticket, $this->vars);
$sql='SELECT id FROM '.FILTER_TABLE
.' WHERE isactive=1 '
.' AND target IN ("Any", '.db_input($this->getTarget()).') ';
#Take into account email ID.
if($this->vars['emailId'])
$sql.=' AND (email_id=0 OR email_id='.db_input($this->vars['emailId']).')';
$sql.=' ORDER BY execorder';
/**
* Fast lookup function to all filters that have at least one rule that
* matches the received address or name or is not defined to match based
* on an email-address or sender-name. This method is meant to retrieve
* all possible filters that could potentially match the given
* arguments. This method will request the database to make a first pass
* and eliminate the filters from being considered that would never
* match the received email.
* Returns an array<Filter::Id> which will need to have their respective
* matches() method queried to determine if the Filter actually matches
* the email.
*
* -----> Disclaimer <------------------
* It would seem that this would not work; however, bear in mind that
* this logic is completely backwards from the database design. Rather
* than determining if the email matches the rules, we're determining if
* the rules *might* apply to the email. This is a "quick" method,
* because it does not request the database to fully verify that the
* rule matches the email. Nor does it fetch the rule or filter
* information from the database. Whether the filter will completely
* match or not is determined in the Filter::matches() method.
*/
if(!$this->vars || !$this->vars['email'])
return $this->getAllActive();
$sql='SELECT DISTINCT filter_id FROM '.FILTER_RULE_TABLE.' rule '
.' INNER JOIN '.FILTER_TABLE.' filter '
.' ON (filter.id=rule.filter_id) '
.' WHERE filter.isactive '
." AND filter.target IN ('Any', ".db_input($this->getTarget()).') ';
# Filter by system's email-id if specified
if($this->vars['emailId'])
$sql.=' AND (filter.email_id=0 OR filter.email_id='.db_input($this->vars['emailId']).')';
# Include rules for sender-email, sender-name and subject as
# requested
$sql.=" AND ((what='email' AND LOCATE(val, ".db_input($this->vars['email']).'))';
$sql.=" OR (what='name' AND LOCATE(val, ".db_input($this->vars['name']).'))';
$sql.=" OR (what='subject' AND LOCATE(val, ".db_input($this->vars['subject']).'))';
# Also include filters that do not have any rules concerning either
# sender-email-addresses or sender-names or subjects
$sql.=") OR filter.id IN ("
." SELECT filter_id "
." FROM ".FILTER_RULE_TABLE." rule"
." INNER JOIN ".FILTER_TABLE." filter"
." WHERE filter.isactive"
." AND filter.target IN('Any', ".db_input($this->getTarget()).")"
." GROUP BY filter_id"
." HAVING COUNT(*)-COUNT(NULLIF(what,'email'))=0";
if (!$this->vars['name']) $sql.=" AND COUNT(*)-COUNT(NULLIF(what,'name'))=0";
if (!$this->vars['subject']) $sql.=" AND COUNT(*)-COUNT(NULLIF(what,'subject'))=0";
# Also include filters that do not have match_all_rules set to and
# have at least one rule 'what' type that wasn't considered e.g body
$sql.=") OR filter.id IN ("
." SELECT filter_id"
." FROM ".FILTER_RULE_TABLE." rule"
." INNER JOIN ".FILTER_TABLE." filter"
." WHERE filter.isactive"
." AND filter.target IN ('Any', ".db_input($this->getTarget()).")"
." AND what NOT IN ('email'"
.((!$this->vars['name'])?",'name'":"")
.((!$this->vars['subject'])?",'subject'":"")
.") AND filter.match_all_rules = 0 "
# Return filters in declared execution order
.") ORDER BY filter.execorder";
return db_query($sql);
}
/**
* Quick function to determine if the received email-address is
* indicated by an active email filter to be banned. Returns the id of
* the filter that has the address blacklisted and FALSE if the email is
* not blacklisted.
*
* XXX: If more detailed matching is to be supported, perhaps this
* should receive an array like the constructor and
* Filter::matches() method.
* Peter - Let's keep it as a quick scan for obviously banned emails.
*/
$sql='SELECT filter.id, what, how, UPPER(val) '
.' FROM '.FILTER_TABLE.' filter'
.' INNER JOIN '.FILTER_RULE_TABLE.' rule'
.' WHERE filter.reject_ticket'
.' AND filter.match_all_rules=0'
.' AND filter.email_id=0'
.' AND filter.isactive'
.' AND rule.isactive '
.' AND rule.what="email"'
.' AND LOCATE(rule.val,'.db_input($addr).')';
if(!($res=db_query($sql)) || !db_num_rows($res))
return false;
# XXX: Use MB_xxx function for proper unicode support
$addr = strtoupper($addr);
$how=array('equal' => array('strcmp', 0),
'contains' => array('strpos', null, false));
while ($row=db_fetch_array($res)) {
list($func, $pos, $neg) = $how[$row['how']];
if (!$func) continue;
$result = call_user_func($func, $addr, $row['val']);
if (($neg === null && $result === $pos) || $result !== $neg)
return $row['id'];
return false;
}
/**
* Simple true/false if the headers of the email indicate that the email
* is an automatic response.
*
* Thanks to http://wiki.exim.org/EximAutoReply
* X-Auto-Response-Supress is outlined here,
* http://msdn.microsoft.com/en-us/library/ee219609(v=exchg.80).aspx
*/
/* static */
function isAutoResponse($headers) {
if($headers && !is_array($headers))
$headers = Mail_Parse::splitHeaders($headers);
$auto_headers = array(
'Auto-Submitted' => 'AUTO-REPLIED',
'Precedence' => array('AUTO_REPLY', 'BULK', 'JUNK', 'LIST'),
'Subject' => array('OUT OF OFFICE', 'AUTO-REPLY:', 'AUTORESPONSE'),
'X-Autoreply' => 'YES',
'X-Auto-Response-Suppress' => array('ALL', 'DR', 'RN', 'NRN', 'OOF', 'AutoReply'),
'X-Autoresponse' => '',
'X-Auto-Reply-From' => ''
);
if(!isset($headers[$header])) continue;
$value = strtoupper($headers[$header]);
# Search text must be found at the beginning of the header
# value. This is especially import for something like the
# subject line, where something like an autoreponse may
# appear somewhere else in the value.
if (is_array($find)) {
foreach ($find as $f)
if (strpos($value, $f) === 0)
return true;
} elseif (strpos($value, $find) === 0) {
return true;
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
# Bounces also counts as auto-responses.
if(self::isAutoBounce($headers))
return true;
return false;
}
function isAutoBounce($headers) {
if($headers && !is_array($headers))
$headers = Mail_Parse::splitHeaders($headers);
$bounce_headers = array(
'From' => array('<MAILER-DAEMON@MAILER-DAEMON>', 'MAILER-DAEMON', '<>'),
'Subject' => array('DELIVERY FAILURE', 'DELIVERY STATUS', 'UNDELIVERABLE:'),
);
foreach ($bounce_headers as $header => $find) {
if(!isset($headers[$header])) continue;
$value = strtoupper($headers[$header]);
if (is_array($find)) {
foreach ($find as $f)
if (strpos($value, $f) === 0)
return true;
} elseif (strpos($value, $find) === 0) {
return true;
}
}
/**
* Normalize ticket source to supported filter target
*
*/
function origin2target($origin) {
$sources=array('web' => 'Web', 'email' => 'Email', 'phone' => 'Web', 'staff' => 'Web', 'api' => 'API');
return $sources[strtolower($origin)];
}
/**
* Function: endsWith
*
* Returns TRUE if the haystack ends with needle and FALSE otherwise.
* Thanks, http://stackoverflow.com/a/834355
*/
function endsWith($haystack, $needle)
{
$length = strlen($needle);
if ($length == 0) {
return true;
}
return (substr($haystack, -$length) === $needle);
}