Compare commits

...

48 commits

Author SHA1 Message Date
osaajani
aaa0fe5701 encode token to prevent bad url 2025-04-15 15:18:43 +02:00
osaajani
01f836108d Fix tokenistA DEPRECATED + fix webhook success 2025-04-15 14:53:04 +02:00
osaajani
580d9595b3 . 2024-12-20 23:44:30 +01:00
osaajani
0e11bcda17 Add strategy for more efficient pagination in API listing of entries by using before_id and after_id wheres instead of pagination if possible 2024-12-20 23:32:56 +01:00
osaajani
04b0f1267a Fix simple sms api + add wait on system v queue (see to improve that later) 2024-11-21 18:04:05 +01:00
osaajani
473fb297f3 disable redis by default 2024-10-31 21:28:30 +01:00
osaajani
36c5d7ec0c Add support for Redis in addition to System V queues 2024-10-31 21:22:56 +01:00
osaajani
5c697b5240 Add a simpler URL to send a sms with nothing but a GET request and an URL 2024-10-29 13:00:24 +01:00
osaajani
2be8242d5e Improve dashboard rendering speed by using ajax for graphs. Improve perfs by using more index on query. Add function to find invalid numbers and export as csv 2024-10-28 21:35:01 +01:00
osaajani
52c849e043 Add phone reliability features 2024-10-26 18:02:11 +02:00
osaajani
44b855dd48 fix nav active link on sms stats 2024-10-22 22:37:54 +02:00
osaajani
d0e17567c3 Update settings page and add options for future phone reliability features 2024-10-22 22:28:24 +02:00
osaajani
50dff066d0 Remove api key from query for sms status, we dont need it as api also support auth by php session 2024-10-22 15:08:58 +02:00
osaajani
ae4f3bd9a7 Fix static url in query for advanced stats 2024-10-22 14:31:06 +02:00
osaajani
54f5be7523 Update version of ovh and firebase 2024-09-14 18:59:35 +02:00
osaajani
4f717ef849 Add page and api for stats about phone sended sms 2024-06-25 21:12:48 +02:00
osaajani
064d6fd941 Fix url parsing 2024-02-26 12:07:19 +01:00
osaajani
6e6c51a9ee Add support for automatic response to SMS stop 2024-02-25 11:11:25 +01:00
osaajani
6321899e02 Update descartes framework to improve env.php constants handling 2024-02-25 11:04:45 +01:00
osaajani
cf4dd2f075 Fix sms stop that whose ignored due to a bug 2023-11-20 15:42:09 +01:00
osaajani
5d1015e190 Fix static function on inbound callback and endcallback for adapters 2023-09-22 18:27:17 +02:00
osaajani
3b2dddbea3 up version 2023-09-19 18:35:13 +02:00
osaajani
490c6499e2 Fix sms settings check on api 2023-09-19 18:34:59 +02:00
osaajani
4e165ec32d Version 3.8.0 add support for http link shortening in sms 2023-09-17 16:27:13 +02:00
osaajani
241d079ffb Add link shortening for http links in sms, using YOURLS 2023-09-17 16:16:35 +02:00
osaajani
fb3f9425d1 User setting update now create setting if it doesn't exists yet instead of returning an error 2023-09-17 16:12:31 +02:00
osaajani
9aa3eca812 Add verification on phone number on contact import 2023-07-18 17:16:34 +02:00
osaajani
347084b5c4 improve perfs on status update by sizing down uid and adding an index 2023-06-06 20:32:04 +02:00
osaajani
aaeb7b64e9 Add index on sms timestamps for perfs 2023-06-06 18:39:48 +02:00
Your Name
7c94c24192 update composer 2023-06-06 12:52:20 +02:00
osaajani
03f7c463a2 jquery v3 incompatible with magicsuggest 2023-06-06 12:22:40 +02:00
osaajani
e95677aec5 . 2023-05-31 18:43:18 +02:00
osaajani
c90da4bd5d Fix type detection in router invocation descartes for php >= 8 2023-05-31 18:34:46 +02:00
osaajani
946e03e500 v3.7.0 add stats about sended sms status, add better support for haproxy, improve gammu ream sms with deletion of read sms 2023-05-30 18:09:06 +02:00
osaajani
4e80a6a3a1 Improve dashboard stats to show sended sms status stats 2023-05-30 18:05:38 +02:00
osaajani
552300a971 fix version check 2023-05-30 17:53:04 +02:00
Pierre-Lin Bonnemaison
01dcd164ec
Merge pull request #195 from deajan/improve-gammu-receiver
Improve Gammu SMS Receiver
2023-05-30 17:50:01 +02:00
osaajani
4fe4d662b7 up dependabot 2023-05-29 21:57:16 +02:00
osaajani
7014f3da68 clean http_pwd forging 2023-05-29 21:53:09 +02:00
Pierre-Lin Bonnemaison
62eb897589
Merge pull request #199 from deajan/make_https_easier
Make HTTPS proxies work
2023-05-29 21:27:16 +02:00
osaajani
49af8f7d94 pump version 2023-03-17 16:10:08 +01:00
osaajani
e7a6c486ee add status limit_reached to phone and check raspisms sms limit when updating phone status 2023-03-17 16:09:32 +01:00
Orsiris de Jong
c202806755
Make descartes work with HTTPS proxies 2022-11-04 20:00:27 +01:00
Orsiris de Jong
f76977e021
Make sure we allow HTTPS request upgrades when behind https proxy 2022-11-04 19:41:53 +01:00
Orsiris de Jong
185d7772f7
Improve logging 2022-10-26 00:07:29 +02:00
Orsiris de Jong
cd5f674164
Move comment to proper line 2022-10-23 11:21:11 +02:00
Orsiris de Jong
3c8061dbbb
Allow get_gamm_version() failure 2022-10-23 11:19:22 +02:00
Orsiris de Jong
2309a0e031
Improve Gammu SMS Receiver
This as a rewrite of `gammu_get_unread_sms.py` script that adds:
- Support for long SMS
- Added proper CLI interface (see --help)
- Added optional --delete parameter which deletes SMS after printing them as JSON
- Added optional --show-read parameter which shows all not Unread marked SMS
- Added logging and --debug option
- Retain retrocompatibility with earlier versions of this script
- Retain retrocompatibility with Python 2.7+ (hopefully)

Fixes #181.
Btw, the interface with RaspiSMS could be improved with a temporary JSON file, using stdout seems lossy.
2022-10-20 23:15:04 +02:00
91 changed files with 4389 additions and 1055 deletions

View file

@ -3,3 +3,7 @@ RewriteRule ^assets - [L]
RewriteRule ^.well-known - [L]
RewriteRule ^data/public/ - [L]
RewriteRule . index.php
<IfModule headers_module>
Header always set Content-Security-Policy "upgrade-insecure-requests;"
</ifModule>

View file

@ -1 +1 @@
v3.6.3
v3.9.4

View file

@ -161,7 +161,7 @@ interface AdapterInterface
/**
* Method called to verify phone status
*
* @return string : Return one phone status among 'available', 'unavailable', 'no_credit'
* @return string : Return one phone status among 'available', 'unavailable', 'no_credit', 'limit_reached'
*/
public function check_phone_status(): string;
@ -207,7 +207,7 @@ interface AdapterInterface
* ]
* ]
*/
public function inbound_call_callback(): array;
public static function inbound_call_callback(): array;
/**
* Method called on reception of a end call notification.
@ -221,5 +221,5 @@ interface AdapterInterface
* ]
* ]
*/
public function end_call_callback(): array;
public static function end_call_callback(): array;
}

View file

@ -259,12 +259,12 @@ namespace adapters;
return true;
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
return [];
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
return [];
}

View file

@ -280,6 +280,7 @@ namespace adapters;
$command_parts = [
PWD . '/bin/gammu_get_unread_sms.py',
escapeshellarg($this->data['config_file']),
'--delete'
];
$return = $this->exec_command($command_parts);
@ -335,12 +336,12 @@ namespace adapters;
return [];
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
return [];
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
return [];
}

View file

@ -508,12 +508,12 @@ class KannelAdapter implements AdapterInterface
return $response;
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
return [];
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
return [];
}

View file

@ -486,12 +486,12 @@ class OctopushShortcodeAdapter implements AdapterInterface
return $response;
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
return [];
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
return [];
}

View file

@ -479,12 +479,12 @@ class OctopushVirtualNumberAdapter implements AdapterInterface
return $response;
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
return [];
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
return [];
}

View file

@ -499,12 +499,12 @@ class OdysseyMessagingAdapter implements AdapterInterface
return $response;
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
return [];
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
return [];
}

View file

@ -406,12 +406,12 @@ namespace adapters;
return [];
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
return [];
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
return [];
}

View file

@ -396,12 +396,12 @@ namespace adapters;
return [];
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
return [];
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
return [];
}

View file

@ -351,7 +351,7 @@ namespace adapters;
return [];
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
$response = [
'error' => false,
@ -380,7 +380,7 @@ namespace adapters;
return $response;
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
$response = [
'error' => false,

View file

@ -374,12 +374,12 @@ class TwilioVirtualNumberAdapter implements AdapterInterface
return [];
}
public function inbound_call_callback(): array
public static function inbound_call_callback(): array
{
return [];
}
public function end_call_callback(): array
public static function end_call_callback(): array
{
return [];
}

View file

@ -53,6 +53,98 @@
width: 100%;
}
/* Custom utility classes for padding */
.py-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; }
.py-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
.py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; }
.py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; }
.py-5 { padding-top: 3rem !important; padding-bottom: 3rem !important; }
.px-1 { padding-left: 0.25rem !important; padding-right: 0.25rem !important; }
.px-2 { padding-left: 0.5rem !important; padding-right: 0.5rem !important; }
.px-3 { padding-left: 1rem !important; padding-right: 1rem !important; }
.px-4 { padding-left: 1.5rem !important; padding-right: 1.5rem !important; }
.px-5 { padding-left: 3rem !important; padding-right: 3rem !important; }
.pt-1 { padding-top: 0.25rem !important; }
.pt-2 { padding-top: 0.5rem !important; }
.pt-3 { padding-top: 1rem !important; }
.pt-4 { padding-top: 1.5rem !important; }
.pt-5 { padding-top: 3rem !important; }
.pb-1 { padding-bottom: 0.25rem !important; }
.pb-2 { padding-bottom: 0.5rem !important; }
.pb-3 { padding-bottom: 1rem !important; }
.pb-4 { padding-bottom: 1.5rem !important; }
.pb-5 { padding-bottom: 3rem !important; }
.pl-1 { padding-left: 0.25rem !important; }
.pl-2 { padding-left: 0.5rem !important; }
.pl-3 { padding-left: 1rem !important; }
.pl-4 { padding-left: 1.5rem !important; }
.pl-5 { padding-left: 3rem !important; }
.pr-1 { padding-right: 0.25rem !important; }
.pr-2 { padding-right: 0.5rem !important; }
.pr-3 { padding-right: 1rem !important; }
.pr-4 { padding-right: 1.5rem !important; }
.pr-5 { padding-right: 3rem !important; }
/* Custom utility classes for margin */
.my-1 { margin-top: 0.25rem !important; margin-bottom: 0.25rem !important; }
.my-2 { margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; }
.my-3 { margin-top: 1rem !important; margin-bottom: 1rem !important; }
.my-4 { margin-top: 1.5rem !important; margin-bottom: 1.5rem !important; }
.my-5 { margin-top: 3rem !important; margin-bottom: 3rem !important; }
.mx-1 { margin-left: 0.25rem !important; margin-right: 0.25rem !important; }
.mx-2 { margin-left: 0.5rem !important; margin-right: 0.5rem !important; }
.mx-3 { margin-left: 1rem !important; margin-right: 1rem !important; }
.mx-4 { margin-left: 1.5rem !important; margin-right: 1.5rem !important; }
.mx-5 { margin-left: 3rem !important; margin-right: 3rem !important; }
.mt-1 { margin-top: 0.25rem !important; }
.mt-2 { margin-top: 0.5rem !important; }
.mt-3 { margin-top: 1rem !important; }
.mt-4 { margin-top: 1.5rem !important; }
.mt-5 { margin-top: 3rem !important; }
.mb-1 { margin-bottom: 0.25rem !important; }
.mb-2 { margin-bottom: 0.5rem !important; }
.mb-3 { margin-bottom: 1rem !important; }
.mb-4 { margin-bottom: 1.5rem !important; }
.mb-5 { margin-bottom: 3rem !important; }
.ml-1 { margin-left: 0.25rem !important; }
.ml-2 { margin-left: 0.5rem !important; }
.ml-3 { margin-left: 1rem !important; }
.ml-4 { margin-left: 1.5rem !important; }
.ml-5 { margin-left: 3rem !important; }
.mr-1 { margin-right: 0.25rem !important; }
.mr-2 { margin-right: 0.5rem !important; }
.mr-3 { margin-right: 1rem !important; }
.mr-4 { margin-right: 1.5rem !important; }
.mr-5 { margin-right: 3rem !important; }
/* HTML: <div class="loader"></div> */
.loader {
display: inline-block;
width: 50px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(farthest-side,#999 94%,#0000) top/8px 8px no-repeat,
conic-gradient(#0000 30%,#999);
-webkit-mask: radial-gradient(farthest-side,#0000 calc(100% - 8px),#000 0);
animation: l13 1s infinite linear;
}
@keyframes l13{
100%{transform: rotate(1turn)}
}
/** POPUPS ALERT **/
.popup-alerts-container
{

20
assets/js/chart.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,56 +1,236 @@
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
# vim: expandtab sw=4 ts=4 sts=4:
#
# (C) 2003 - 2018 Michal Čihař <michal@cihar.com> - python-gammu
# (C) 2015 - 2021 Raspian France <raspbianfrance@gmail.com> - RaspianFrance/raspisms
# (C) 2022 - Orsiris de Jong <orsiris.dejong@netperfect.fr> - NetInvent SASU
from __future__ import print_function
__intname__ = "gammu_get_unread_sms.py"
__author__ = "Orsiris de Jong - <orsiris.dejong@netperfect.fr>"
__version__ = "2.0.2"
__build__ = "2022102501"
__compat__ = "python2.7+"
import os
import gammu
import sys
import json
import logging
from logging.handlers import RotatingFileHandler
import tempfile
from argparse import ArgumentParser
import subprocess
import re
def main():
LOG_FILE = "/var/log/{}.log".format(__intname__)
_DEBUG = os.environ.get("_DEBUG", False)
logger = logging.getLogger(__name__)
def get_logger(log_file):
# We would normally use ofunctions.logger_utils here with logger_get_logger(), but let's keep no dependencies
try:
try:
filehandler = RotatingFileHandler(
log_file, mode="a", encoding="utf-8", maxBytes=1048576, backupCount=3
)
except OSError:
try:
temp_log_file = tempfile.gettempdir() + os.sep + __name__ + ".log"
filehandler = RotatingFileHandler(
temp_log_file,
mode="a",
encoding="utf-8",
maxBytes=1048576,
backupCount=3,
)
except OSError as exc:
print("Cannot create log file: %s" % exc.__str__())
filehandler = None
_logger = logging.getLogger()
if _DEBUG:
_logger.setLevel(logging.DEBUG)
else:
_logger.setLevel(logging.INFO)
formatter = logging.Formatter("%(asctime)s :: %(levelname)s :: %(message)s")
if filehandler:
filehandler.setFormatter(formatter)
_logger.addHandler(filehandler)
consolehandler = logging.StreamHandler()
consolehandler.setFormatter(formatter)
_logger.addHandler(consolehandler)
return _logger
except Exception as exc:
print("Cannot create logger instance: %s" % exc.__str__())
def get_gammu_version():
# Quite badly coded, i'd use command_runner but I try to not have dependencies here
try:
proc = subprocess.Popen(
["LC_ALL=C gammu", "--version"], shell=True, stdout=subprocess.PIPE
)
stdout, _ = proc.communicate()
version = re.search(r"Gammu version ([0-9]+)\.([0-9]+)\.([0-9]+)", str(stdout))
# dont' bother to return version[0] since it's the whole match
return (int(version[1]), int(version[2]), int(version[3]))
except Exception as exc:
logger.error("Cannot get gammu version: %s" % exc.__str__())
return None
def get_gammu_handle(config_file):
state_machine = gammu.StateMachine()
if len(sys.argv) < 2:
sys.exit(1)
else :
state_machine.ReadConfig(Filename=sys.argv[1])
del sys.argv[1]
if config_file:
state_machine.ReadConfig(Filename=config_file)
else:
state_machine.Readconfig()
state_machine.Init()
return state_machine
def load_sms_from_gammu(state_machine):
"""
The actual function that retrieves SMS via GAMMU from your modem / phone
Also concatenates multiple SMS into single long SMS
"""
status = state_machine.GetSMSStatus()
remain = status['SIMUsed'] + status['PhoneUsed'] + status['TemplatesUsed']
start = True
remaining_sms = status["SIMUsed"] + status["PhoneUsed"] + status["TemplatesUsed"]
logger.debug("Found %s sms" % remaining_sms)
sms_list = []
try:
while remain > 0:
if start:
sms = state_machine.GetNextSMS(Start=True, Folder=0)
start = False
is_first_message = True
while remaining_sms > 0:
if is_first_message:
sms = state_machine.GetNextSMS(Start=is_first_message, Folder=0)
is_first_message = False
else:
sms = state_machine.GetNextSMS(
Location=sms[0]['Location'], Folder=0
)
remain = remain - len(sms)
for m in sms :
if m['State'] != 'UnRead' :
continue
print(json.dumps({
'number': m['Number'],
'at': str(m['DateTime']),
'status': m['State'],
'text': m['Text'],
}))
sms = state_machine.GetNextSMS(Location=sms[0]["Location"], Folder=0)
remaining_sms = remaining_sms - len(sms)
sms_list.append(sms)
except gammu.ERR_EMPTY:
#do noting
return True
logger.debug("Finished reading all messages")
# Concat multiple SMS into list of sms that go together using LinkSMS
return gammu.LinkSMS(sms_list)
if __name__ == '__main__':
main()
def render_sms_as_json(state_machine, sms_list, delete_sms, show_read_sms):
"""
Provided sms_list is a list of lists of sms, eg
sms_list = [
[sms],
[sms1, sms2], # When two sms are in the same list, they form a long sms
[sms],
]
Concatenate long SMS from multiple sends and print them as JSON on stdout
"""
for sms in sms_list:
if sms[0]["State"] == "UnRead" or show_read_sms:
sms_text = ""
for to_concat_sms in sms:
sms_text += to_concat_sms["Text"]
print(
json.dumps(
{
"number": sms[0]["Number"],
"at": str(sms[0]["DateTime"]),
"status": sms[0]["State"],
"text": sms_text,
}
)
)
if delete_sms:
for to_concat_sms in sms:
try:
state_machine.DeleteSMS(
to_concat_sms["Folder"], to_concat_sms["Location"]
)
except Exception as exc:
logger.error("Cannot delete sms: %s" % exc.__str__())
def main(config_file, delete_sms, show_read):
# type: (bool, bool) -> None
logger.debug("Running gammu receiver with config {}".format(config_file))
try:
# Mandatory modem config file
# config_file = sys.argv[1]
state_machine = get_gammu_handle(config_file)
sms_list = load_sms_from_gammu(state_machine)
render_sms_as_json(state_machine, sms_list, delete_sms, show_read)
except Exception as exc:
logger.error("Could not retrieve SMS from Gammu: %s" % exc.__str__())
logger.debug("Trace:", exc_info=True)
if __name__ == "__main__":
parser = ArgumentParser("Gammu SMS retriever")
parser.add_argument(
"gammu_config_file", type=str, nargs="?", help="Gammu config file"
)
parser.add_argument("--debug", action="store_true", help="Activate debugging")
parser.add_argument(
"-l",
"--log-file",
type=str,
dest="log_file",
default=None,
help="Optional path to log file, defaults to /var/log",
)
parser.add_argument(
"--delete", action="store_true", help="Delete messages after they've been read"
)
parser.add_argument(
"--show-read", action="store_true", help="Also show already read messages"
)
args = parser.parse_args()
config_file = args.gammu_config_file
if args.log_file:
LOG_FILE = args.log_file
if args.debug:
_DEBUG = args.debug
_logger = get_logger(LOG_FILE)
if _logger:
logger = _logger
delete = False
if args.delete:
# We need to check if we have gammu >= 1.42.0 since deleting sms with lower versions fail with:
# Cannot delete sms: {'Text': 'The type of memory is not available or has been disabled.', 'Where': 'DeleteSMS', 'Code': 81}
# see https://github.com/gammu/gammu/issues/460
try:
gammu_version = get_gammu_version()
if gammu_version[0] > 1 or (gammu_version[0] == 1 and gammu_version[1] >= 42):
delete = True
else:
logger.warning("Cannot delete SMS. You need gammu >= 1.42.0.")
except TypeError:
logger.warning("Cannot get gammu version. SMS Deleting might not work properly.")
show_read = args.show_read
main(config_file, delete, show_read)

View file

@ -9,12 +9,16 @@
"symfony/expression-language": "^5.0",
"robmorgan/phinx": "^0.11.1",
"monolog/monolog": "^2.0",
"ovh/ovh": "^2.0",
"ovh/ovh": "^3.0",
"twilio/sdk": "^6.1",
"symfony/yaml": "^5.0",
"phpmailer/phpmailer": "^6.1",
"xantios/mimey": ">=2.1",
"kreait/firebase-php": "^5.14",
"benmorel/gsm-charset-converter": "^0.3.0"
"kreait/firebase-php": "^7.0",
"benmorel/gsm-charset-converter": "^0.3.0",
"google/cloud-pubsub": "^1.46"
},
"require-dev": {
"fakerphp/faker": "^1.21"
}
}

Binary file not shown.

View file

@ -11,6 +11,9 @@
namespace controllers\internals;
use DateInterval;
use Faker\Factory;
/**
* Class to call the console scripts.
*/
@ -60,156 +63,415 @@ namespace controllers\internals;
$phone = $internal_phone->get($id_phone);
if (!$phone)
{
exit(1);
}
new \daemons\Phone($phone);
{
exit(1);
}
/**
* Check if a user exists based on email.
*
* @param string $email : User email
*/
public function user_exists(string $email)
new \daemons\Phone($phone);
}
/**
* Check if a user exists based on email.
*
* @param string $email : User email
*/
public function user_exists(string $email)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get_by_email($email);
exit($user ? 0 : 1);
}
/**
* Check if a user exists based on id.
*
* @param string $id : User id
*/
public function user_id_exists(string $id)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get($id);
exit($user ? 0 : 1);
}
/**
* Create a user or update an existing user.
*
* @param $email : User email
* @param $password : User password
* @param $admin : Is user admin
* @param $api_key : User API key, if null random api key is generated
* @param $status : User status, default \models\User::STATUS_ACTIVE
* @param bool $encrypt_password : Should the password be encrypted, by default true
*
* exit code 0 on success | 1 on error
*/
public function create_update_user(string $email, string $password, bool $admin, ?string $api_key = null, string $status = \models\User::STATUS_ACTIVE, bool $encrypt_password = true)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get_by_email($email);
if ($user)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$api_key = $api_key ?? $internal_user->generate_random_api_key();
$update_datas = [
'email' => $email,
'password' => $encrypt_password ? password_hash($password, PASSWORD_DEFAULT) : $password,
'admin' => $admin,
'api_key' => $api_key,
'status' => $status,
];
$user = $internal_user->get_by_email($email);
exit($user ? 0 : 1);
}
/**
* Check if a user exists based on id.
*
* @param string $id : User id
*/
public function user_id_exists(string $id)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get($id);
exit($user ? 0 : 1);
}
/**
* Create a user or update an existing user.
*
* @param $email : User email
* @param $password : User password
* @param $admin : Is user admin
* @param $api_key : User API key, if null random api key is generated
* @param $status : User status, default \models\User::STATUS_ACTIVE
* @param bool $encrypt_password : Should the password be encrypted, by default true
*
* exit code 0 on success | 1 on error
*/
public function create_update_user(string $email, string $password, bool $admin, ?string $api_key = null, string $status = \models\User::STATUS_ACTIVE, bool $encrypt_password = true)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get_by_email($email);
if ($user)
{
$api_key = $api_key ?? $internal_user->generate_random_api_key();
$update_datas = [
'email' => $email,
'password' => $encrypt_password ? password_hash($password, PASSWORD_DEFAULT) : $password,
'admin' => $admin,
'api_key' => $api_key,
'status' => $status,
];
$success = $internal_user->update($user['id'], $update_datas);
echo json_encode(['id' => $user['id']]);
exit($success ? 0 : 1);
}
$new_user_id = $internal_user->create($email, $password, $admin, $api_key, $status, $encrypt_password);
echo json_encode(['id' => $new_user_id]);
exit($new_user_id ? 0 : 1);
}
/**
* Update a user status.
*
* @param string $id : User id
* @param string $status : User status, default \models\User::STATUS_ACTIVE
*/
public function update_user_status(string $id, string $status)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get($id);
if (!$user)
{
exit(1);
}
$success = $internal_user->update_status($user['id'], $status);
$success = $internal_user->update($user['id'], $update_datas);
echo json_encode(['id' => $user['id']]);
exit($success ? 0 : 1);
}
/**
* Delete a user.
*
* @param string $id : User id
*/
public function delete_user(string $id)
$new_user_id = $internal_user->create($email, $password, $admin, $api_key, $status, $encrypt_password);
echo json_encode(['id' => $new_user_id]);
exit($new_user_id ? 0 : 1);
}
/**
* Update a user status.
*
* @param string $id : User id
* @param string $status : User status, default \models\User::STATUS_ACTIVE
*/
public function update_user_status(string $id, string $status)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get($id);
if (!$user)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$success = $internal_user->delete($id);
exit($success ? 0 : 1);
exit(1);
}
/**
* Delete medias that are no longer usefull.
*/
public function clean_unused_medias()
$success = $internal_user->update_status($user['id'], $status);
exit($success ? 0 : 1);
}
/**
* Delete a user.
*
* @param string $id : User id
*/
public function delete_user(string $id)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$success = $internal_user->delete($id);
exit($success ? 0 : 1);
}
/**
* Delete medias that are no longer usefull.
*/
public function clean_unused_medias()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_media = new \controllers\internals\Media($bdd);
$medias = $internal_media->gets_unused();
foreach ($medias as $media)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_media = new \controllers\internals\Media($bdd);
$success = $internal_media->delete_for_user($media['id_user'], $media['id']);
$medias = $internal_media->gets_unused();
foreach ($medias as $media)
{
$success = $internal_media->delete_for_user($media['id_user'], $media['id']);
echo (false === $success ? '[KO]' : '[OK]') . ' - ' . $media['path'] . "\n";
}
}
/**
* Do alerting for quota limits.
*/
public function quota_limit_alerting()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_quota = new \controllers\internals\Quota($bdd);
$internal_quota->alerting_for_limit_close_and_reached();
}
/**
* Do quota renewal.
*/
public function renew_quotas()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_quota = new \controllers\internals\Quota($bdd);
$internal_quota->renew_quotas();
echo (false === $success ? '[KO]' : '[OK]') . ' - ' . $media['path'] . "\n";
}
}
/**
* Do alerting for quota limits.
*/
public function quota_limit_alerting()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_quota = new \controllers\internals\Quota($bdd);
$internal_quota->alerting_for_limit_close_and_reached();
}
/**
* Do quota renewal.
*/
public function renew_quotas()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_quota = new \controllers\internals\Quota($bdd);
$internal_quota->renew_quotas();
}
/**
* Do phone reliability verifications
*/
public function phone_reliability()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$internal_settings = new \controllers\internals\Setting($bdd);
$internal_sended = new \controllers\internals\Sended($bdd);
$internal_phone_reliability = new \controllers\internals\PhoneReliability($bdd);
$internal_phone = new \controllers\internals\Phone($bdd);
$internal_webhook = new \controllers\internals\Webhook($bdd);
$internal_mailer = new \controllers\internals\Mailer();
$users = $internal_user->get_all_active();
foreach ($users as $user)
{
$settings = $internal_settings->gets_for_user($user['id']);
echo "\nCheck phone reliability for user " . $user['id'] . ":\n";
if ($settings['phone_reliability_failed'])
{
$rate_limit = intval($settings['phone_reliability_failed_rate_limit']) / 100;
$min_volume = intval($settings['phone_reliability_failed_volume']);
$period = intval($settings['phone_reliability_failed_period']);
$grace_period = intval($settings['phone_reliability_failed_grace_period']);
echo " Check for failed SMS with rate > " . $rate_limit . " and volume > " . $min_volume . " on period " . $period . "s with grace period of " . $grace_period . "s.\n";
$unreliable_phones = $internal_phone_reliability->find_unreliable_phones($user['id'], \models\Sended::STATUS_FAILED, $rate_limit, $min_volume, $period, $grace_period);
foreach ($unreliable_phones as $unreliable_phone)
{
$phone = $internal_phone->get($unreliable_phone['id_phone']);
if (!$phone)
{
echo ' Cannot find phone: ' . $unreliable_phone['id_phone'] . "\n";
continue;
}
echo "\n Phone " . $phone['id'] . ' - ' . $phone['name'] . " failed rate = " . $unreliable_phone['rate'] . " > " . $rate_limit . " and volume " . $unreliable_phone['total'] . " > " . $min_volume . "\n";
$internal_phone_reliability->create($user['id'], $phone['id'], \models\Sended::STATUS_FAILED);
if ($settings['phone_reliability_failed_email'])
{
$success = $internal_mailer->enqueue($user['email'], EMAIL_PHONE_RELIABILITY_FAILED, [
'phone' => $phone,
'period' => $period,
'total' => $unreliable_phone['total'],
'unreliable' => $unreliable_phone['unreliable'],
'rate' => $unreliable_phone['rate'],
]);
if (!$success)
{
echo ' Cannot enqueue alert for unreliable failed phone: ' . $unreliable_phone['id_phone'] . "\n";
continue;
}
echo " Alert mail for unreliable failed phone " . $phone['id'] . ' - ' . $phone['name'] . " added\n";
}
if ($settings['phone_reliability_failed_webhook'])
{
$webhook = [
'reliability_type' => \models\Sended::STATUS_FAILED,
'id_phone' => $unreliable_phone['id_phone'],
'period' => $period,
'total' => $unreliable_phone['total'],
'unreliable' => $unreliable_phone['unreliable'],
'rate' => $unreliable_phone['rate'],
];
$internal_webhook->trigger($user['id'], \models\Webhook::TYPE_PHONE_RELIABILITY, $webhook);
echo " Webhook for unreliable failed phone " . $phone['id'] . ' - ' . $phone['name'] . " triggered\n";
}
if ($settings['phone_reliability_failed_auto_disable'])
{
$internal_phone->update_status($unreliable_phone['id_phone'], \models\Phone::STATUS_DISABLED);
}
}
}
if ($settings['phone_reliability_unknown'])
{
$rate_limit = intval($settings['phone_reliability_unknown_rate_limit']) / 100;
$min_volume = intval($settings['phone_reliability_unknown_volume']);
$period = intval($settings['phone_reliability_unknown_period']);
$grace_period = intval($settings['phone_reliability_unknown_grace_period']);
echo "\n Check for unknown SMS with rate > " . $rate_limit . " and volume > " . $min_volume . " on period " . $period . "s with grace period of " . $grace_period . "s.\n";
$unreliable_phones = $internal_phone_reliability->find_unreliable_phones($user['id'], \models\Sended::STATUS_UNKNOWN, $rate_limit, $min_volume, $period, $grace_period);
foreach ($unreliable_phones as $unreliable_phone)
{
$phone = $internal_phone->get($unreliable_phone['id_phone']);
if (!$phone)
{
echo ' Cannot find phone: ' . $unreliable_phone['id_phone'] . "\n";
continue;
}
echo "\n Phone " . $phone['id'] . ' - ' . $phone['name'] . " unknown rate = " . $unreliable_phone['rate'] . " > " . $rate_limit . "\n";
$internal_phone_reliability->create($user['id'], $phone['id'], \models\Sended::STATUS_UNKNOWN);
if ($settings['phone_reliability_unknown_email'])
{
$success = $internal_mailer->enqueue($user['email'], EMAIL_PHONE_RELIABILITY_UNKNOWN, [
'phone' => $phone,
'period' => $period,
'total' => $unreliable_phone['total'],
'unreliable' => $unreliable_phone['unreliable'],
'rate' => $unreliable_phone['rate'],
]);
if (!$success)
{
echo ' Cannot enqueue alert for unreliable unknown phone: ' . $unreliable_phone['id_phone'] . "\n";
continue;
}
echo " Alert mail for unreliable unknown phone " . $phone['id'] . ' - ' . $phone['name'] . " added\n";
}
if ($settings['phone_reliability_unknown_webhook'])
{
$webhook = [
'reliability_type' => \models\Sended::STATUS_UNKNOWN,
'id_phone' => $unreliable_phone['id_phone'],
'period' => $period,
'total' => $unreliable_phone['total'],
'unreliable' => $unreliable_phone['unreliable'],
'rate' => $unreliable_phone['rate'],
];
$internal_webhook->trigger($user['id'], \models\Webhook::TYPE_PHONE_RELIABILITY, $webhook);
echo " Webhook for unreliable unknown phone " . $phone['id'] . ' - ' . $phone['name'] . " triggered\n";
}
if ($settings['phone_reliability_unknown_auto_disable'])
{
$internal_phone->update_status($unreliable_phone['id_phone'], \models\Phone::STATUS_DISABLED);
}
}
}
}
}
/**
* Function to easily populate the database with fake data for testing.
*
* @param int $id_user : User ID for whom data is to be generated
* @param int $received_entries : Number of entries to add to the received table
* @param int $sended_entries : Number of entries to add to the sended table
* @param int $contact_entries : Number of entries to add to the contact table
*/
public function seed_database(int $id_user, int $received_entries, int $sended_entries, int $contact_entries)
{
$this->seed_received($id_user, $received_entries);
$this->seed_sended($id_user, $sended_entries);
$this->seed_contact($id_user, $contact_entries);
}
/**
* Fill table received with fake data
*
* @param int $id_user : User to insert received for
* @param int $entries : How many received to insert
*/
public function seed_received(int $id_user, int $entries)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_received = new \controllers\internals\Received($bdd);
$internal_phone = new \controllers\internals\Phone($bdd);
$faker = Factory::create();
$phones = $internal_phone->gets_for_user($id_user);
for ($i = 0; $i < $entries; $i++)
{
$id_phone = $faker->randomElement($phones)['id'];
$at = $faker->dateTimeBetween('-1 year', 'now')->format('Y-m-d H:i:s');
$text = $faker->sentence(rand(5,10), true);
$origin = $faker->e164PhoneNumber;
$status = $faker->randomElement(['read', 'unread']);
$command = false;
$mms = false;
$media_ids = [];
$internal_received->create($id_user, $id_phone, $at, $text, $origin, $status, $command, $mms, $media_ids);
}
}
/**
* Fill table sended with fake data
*
* @param int $id_user : User to insert sended entries for
* @param int $entries : Number of entries to insert
*/
public function seed_sended(int $id_user, int $entries)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_sended = new \controllers\internals\Sended($bdd);
$internal_phone = new \controllers\internals\Phone($bdd);
$faker = Factory::create();
$phones = $internal_phone->gets_for_user($id_user);
for ($i = 0; $i < $entries; $i++)
{
echo $i."\n";
$phone = $faker->randomElement($phones);
$id_phone = $phone['id'];
$at = $faker->dateTimeBetween('-1 year', 'now')->format('Y-m-d H:i:s');
$text = $faker->sentence(rand(5, 10), true);
$destination = $faker->e164PhoneNumber;
$uid = $faker->uuid;
$adapter = $phone['adapter'];
$flash = $faker->boolean;
$mms = $faker->boolean;
$tag = $faker->optional()->word;
$medias = []; // Add logic for media IDs if needed
$originating_scheduled = $faker->numberBetween(1, 100);
$status = $faker->randomElement([\models\Sended::STATUS_UNKNOWN, \models\Sended::STATUS_DELIVERED, \models\Sended::STATUS_FAILED]);
$internal_sended->create($id_user, $id_phone, $at, $text, $destination, $uid, $adapter, $flash, $mms, $tag, $medias, $originating_scheduled, $status);
}
}
/**
* Fill table contact with fake data
*
* @param int $id_user : User to insert contacts for
* @param int $entries : Number of contacts to insert
*/
public function seed_contact(int $id_user, int $entries)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_contact = new \controllers\internals\Contact($bdd);
$faker = Factory::create();
for ($i = 0; $i < $entries; $i++)
{
$name = $faker->name;
$number = $faker->e164PhoneNumber;
$data = '[]';
$internal_contact->create($id_user, $number, $name, $data);
}
}
}

View file

@ -141,8 +141,11 @@ namespace controllers\internals;
$nb_insert = 0;
$head = null;
$line_nb = 0;
while ($line = fgetcsv($file_handler))
{
$line_nb ++;
if (null === $head)
{
$head = $line;
@ -185,9 +188,16 @@ namespace controllers\internals;
}
$data = json_encode($data);
$contact_name = $line[array_keys($line)[0]];
$phone_number = \controllers\internals\Tool::parse_phone($line[array_keys($line)[1]]);
if (!$phone_number)
{
throw new \Exception('Erreur à la ligne ' . $line_nb . ' colonne 1, numéro de téléphone invalide.');
}
try
{
$success = $this->create($id_user, $line[array_keys($line)[1]], $line[array_keys($line)[0]], $data);
$success = $this->create($id_user, $line[array_keys($line)[1]], $contact_name, $data);
if ($success)
{
++$nb_insert;

View file

@ -0,0 +1,62 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace controllers\internals;
use Exception;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
/**
* Mailing class.
*/
class LinkShortener
{
/**
* Shorten an URL using the configured YOURLS instance
*/
public static function shorten($url)
{
$api_url = URL_SHORTENER['HOST'] . '/yourls-api.php';
$data = [
'action' => 'shorturl',
'format' => 'json',
'username' => URL_SHORTENER['USER'],
'password' => URL_SHORTENER['PASS'],
'url' => $url,
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $api_url);
curl_setopt($ch, CURLOPT_HEADER, 0); // No header in the result
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); // Enable follow location
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Return, do not echo result
curl_setopt($ch, CURLOPT_POST, 1); // This is a POST request
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
$response = curl_exec($ch);
curl_close($ch);
try
{
$response = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
catch (\Exception $e)
{
return false;
}
$shortlink = $response['shorturl'] ?? false;
return $shortlink;
}
}

7
controllers/internals/Mailer.php Executable file → Normal file
View file

@ -117,11 +117,10 @@ class Mailer extends \descartes\Controller
'attachments' => $attachments,
];
$error_code = null;
$queue = msg_get_queue(QUEUE_ID_EMAIL);
$success = msg_send($queue, QUEUE_TYPE_EMAIL, $message, true, true, $error_code);
$queue = new Queue(QUEUE_ID_EMAIL);
$queue->push(json_encode($message), QUEUE_TYPE_EMAIL);
return (bool) $success;
return true;
}
/**

View file

@ -0,0 +1,65 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace controllers\internals;
use Exception;
class PhoneReliability extends StandardController
{
protected $model;
/**
* Create a phone reliability history entry.
*
* @param int $id_user : Id of user to create sended message for
* @param int $id_phone : Id of the number the message was send with
* @param $type : Type of reliability alert
* @return mixed : false on error, new sended id else
*/
public function create(int $id_user, int $id_phone, string $type)
{
return $this->get_model()->insert([
'id_user' => $id_user,
'id_phone' => $id_phone,
'type' => $type,
]);
return $id_sended;
}
/**
* Find all unreliable phones for a user, based on sended sms status, rate limit, etc.
*
* @param int $id_user : User id
* @param string $sms_status : Status of SMS to use to calculate rate
* @param float $rate_limit : Percentage of SMS matching status after which we consider the phone unreliable
* @param int $min_volume : Minimum number of sms we need to have to consider the statistic relevent
* @param int $period : The time span in minutes from which SMS counting should begin.
* @param int $grace_period : How long in minutes should we wait before including a SMS in counting
*
* @return array : A list of unreliable phone for the user, with phone id, total number of sms, and rate of failed sms
*/
public function find_unreliable_phones (int $id_user, string $sms_status, float $rate_limit, int $min_volume, int $period, int $grace_period)
{
return $this->get_model()->find_unreliable_phones($id_user, $sms_status, $rate_limit, $min_volume, $period, $grace_period);
}
/**
* Get the model for the Controller.
*/
protected function get_model(): \models\PhoneReliabilityHistory
{
$this->model = $this->model ?? new \models\PhoneReliabilityHistory($this->bdd);
return $this->model;
}
}

View file

@ -0,0 +1,86 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace controllers\internals;
use Exception;
use models\RedisQueue;
use models\SystemVQueue;
class Queue extends \descartes\InternalController
{
private $queue;
/**
* A class to interact with queue, the class is in charge to choose the type of queue (redis/system v) to use
*/
public function __construct($id)
{
if (USE_REDIS_QUEUES ?? false)
{
$params = [];
if (REDIS_HOST ?? false)
{
$params['host'] = REDIS_HOST;
}
if (REDIS_PORT ?? false)
{
$params['port'] = REDIS_PORT;
}
if (REDIS_PASSWORD ?? false)
{
$params['auth'] = REDIS_PASSWORD;
}
$this->queue = new RedisQueue($id, $params, 'raspisms', 'raspisms');
}
else
{
$this->queue = new SystemVQueue($id);
}
}
/**
* Add a message to the queue
*
* @param string $message : The message to add to the queue
* @param ?string $tag : A tag to associate to the message for routing purposes, if null will add to general queue
*/
public function push($message, ?string $tag = null)
{
return $this->queue->push($message, $tag);
}
/**
* Read the older message in the queue
*
* @return mixed $message : The oldest message or null if no message found, can be anything
* @param ?string $tag : A tag to associate to the message for routing purposes, if null will read from general queue
* @param mixed : The message return from the queue, can be anything, null if no message found
*/
public function read(?string $tag = null)
{
return $this->queue->read($tag);
}
/**
* Function to close system V queue for cleaning resources, usefull only if system V queue
*/
public function close()
{
if ($this->queue instanceof SystemVQueue)
{
$this->queue->close();
}
}
}

View file

@ -11,6 +11,8 @@
namespace controllers\internals;
use Exception;
class Received extends StandardController
{
protected $model;
@ -81,21 +83,13 @@ namespace controllers\internals;
$this->bdd->beginTransaction();
$id_received = $this->get_model()->insert($received);
if (!$id_received)
if (!$id_received)
{
$this->bdd->rollBack();
return false;
}
//Check if the received message is a SMS STOP and we must register it
$internal_smsstop = new SmsStop($this->bdd);
$is_stop = $internal_smsstop->check_for_stop($received['text']);
if ($is_stop)
{
$internal_smsstop->create($id_user, $origin);
}
//Link medias
$internal_media = new Media($this->bdd);
foreach ($media_ids as $media_id)
@ -116,6 +110,32 @@ namespace controllers\internals;
return false;
}
//Check if the received message is a SMS STOP and we must register it
$internal_smsstop = new SmsStop($this->bdd);
$is_stop = $internal_smsstop->check_for_stop($received['text']);
if ($is_stop)
{
$stop_exists = (bool) $internal_smsstop->get_by_number_for_user($id_user, $origin);
if ($stop_exists)
{
return $id_received;
}
$internal_smsstop->create($id_user, $origin);
//If stop response enabled, respond to user
//(this will happen only for first stop, any further stop will not trigger responses)
$internal_setting = new Setting($this->bdd);
$user_settings = $internal_setting->gets_for_user($id_user);
if ((int) ($user_settings['smsstop_respond'] ?? false))
{
$response = $user_settings['smsstop_response'];
$internal_scheduled = new Scheduled($this->bdd);
$internal_scheduled->create($id_user, (new \DateTime())->format('Y-m-d H:i:s'), $response, $id_phone, null, false, false, \models\SmsStop::SMS_STOP_TAG, [['number' => $origin, 'data' => '[]']]);
}
}
return $id_received;
}
@ -233,9 +253,9 @@ namespace controllers\internals;
*
* @return array
*/
public function get_discussions_for_user(int $id_user)
public function get_discussions_for_user(int $id_user, ?int $nb_entry = null, ?int $page = null)
{
return $this->get_model()->get_discussions_for_user($id_user);
return $this->get_model()->get_discussions_for_user($id_user, $nb_entry, $page);
}
/**

View file

@ -450,7 +450,8 @@ use Monolog\Logger;
$users_smsstops = [];
$users_settings = [];
$users_phones = [];
$users_phone_groups = [];
$users_phone_groups = [];
$shortlink_cache = [];
$now = new \DateTime();
$now = $now->format('Y-m-d H:i:s');
@ -601,9 +602,10 @@ use Monolog\Logger;
continue;
}
//Remove messages to smsstops numbers
if (($users_smsstops[$id_user] ?? false) && in_array($target['number'], $users_smsstops[$id_user]))
//Remove messages to smsstops numbers if not with tag SMS_STOP
if ($scheduled['tag'] != \models\SmsStop::SMS_STOP_TAG && ($users_smsstops[$id_user] ?? false) && in_array($target['number'], $users_smsstops[$id_user]))
{
unset($targets[$key]);
continue;
}
@ -643,6 +645,33 @@ use Monolog\Logger;
$text = Tool::convert_to_gsm0338($text);
}
// If the text contain http links we must replace them
if (ENABLE_URL_SHORTENER && ((int) ($users_settings[$id_user]['shorten_url'] ?? false)))
{
$http_links = Tool::search_http_links($text);
if ($http_links !== false)
{
foreach ($http_links as $http_link)
{
if (!array_key_exists($http_link, $shortlink_cache))
{
$shortlkink = LinkShortener::shorten($http_link);
// If link shortening failed, keep original one
if ($shortlkink === false)
{
continue;
}
$shortlink_cache[$http_link] = $shortlkink;
}
$shortlink = $shortlink_cache[$http_link];
$text = str_replace($http_link, $shortlink, $text);
}
}
}
/*
Choose phone if no phone defined for message
Phones are choosen using type, priority and remaining volume :

View file

@ -207,17 +207,9 @@ use Exception;
*
* @return array
*/
public function count_by_day_since_for_user(int $id_user, $date)
public function count_by_day_and_status_since_for_user(int $id_user, $date)
{
$counts_by_day = $this->get_model()->count_by_day_since_for_user($id_user, $date);
$return = [];
foreach ($counts_by_day as $count_by_day)
{
$return[$count_by_day['at_ymd']] = $count_by_day['nb'];
}
return $return;
return $this->get_model()->count_by_day_and_status_since_for_user($id_user, $date);
}
/**
@ -233,6 +225,22 @@ use Exception;
return $this->get_model()->get_last_for_destination_and_user($id_user, $destination);
}
/**
* Get number of sended SMS by day and status between two dates, possibly by sending phone.
*
* @param int $id_user : user id
* @param \DateTime $start_date : Date since which we want the messages
* @param \DateTime $end_date : Date until which we want the messages
* @param ?int $id_phone : Id of the phone to search sended for, null by default get all phones
*
* @return array
*/
public function get_sended_status_stats ($id_user, $start_date, $end_date, ?int $id_phone = null)
{
return $this->get_model()->get_sended_status_stats($id_user, $start_date, $end_date, $id_phone);
}
/**
* Send a SMS message.
*
@ -368,6 +376,22 @@ use Exception;
}
}
/**
* Get list of invalid phone number we've sent message to
*
* @param int $id_user : user id
* @param int $volume : Minimum number of sms sent to the number
* @param float $percent_failed : Minimum ratio of failed message
* @param float $percent_unknown : Minimum ratio of unknown message
* @param int $limit : Limit of results
* @param int $page : Page of results (offset = page * limit)
*
*/
public function get_invalid_numbers (int $id_user, int $volume, float $percent_failed, float $percent_unknown, int $limit, int $page)
{
return $this->get_model()->get_invalid_numbers($id_user, $volume, $percent_failed, $percent_unknown, $limit, $page);
}
/**
* Get the model for the Controller.
*/

View file

@ -35,6 +35,18 @@ namespace controllers\internals;
return $settings_array;
}
/**
* Get a user setting by his name for a user.
*
* @param int $id_user : user id
*
* @return array
*/
public function get_by_name_for_user(int $id_user, string $name)
{
return $this->get_model()->get_by_name_for_user($id_user, $name);
}
/**
* Update a setting by his name and user id.
*
@ -79,6 +91,12 @@ namespace controllers\internals;
$all_success = true;
foreach (USER_DEFAULT_SETTINGS as $name => $value)
{
// Ignore if already existing settings
if (count($this->get_by_name_for_user($id_user, $name)))
{
continue;
}
$success = $this->create($id_user, $name, $value);
$all_success = ($all_success && $success);
}

View file

@ -73,7 +73,8 @@ namespace controllers\internals;
*/
public function check_for_stop(string $str)
{
return 'stop' == trim(mb_strtolower($str));
$str = trim(mb_strtolower($str));
return 'stop' == $str || 'stop sms' == $str;
}
/**

View file

@ -77,12 +77,14 @@ namespace controllers\internals;
* @param int $id_user : User id
* @param ?int $nb_entry : Number of entry to return
* @param ?int $page : Pagination, used to calcul offset, $nb_entry * $page
* @param ?int $after_id : If provided use where id > $after_id instead of offset
* @param ?int $before_id : If provided use where id < $before_id instead of offset
*
* @return array : Entrys list
*/
public function list_for_user(int $id_user, ?int $nb_entry = null, ?int $page = null)
public function list_for_user(int $id_user, ?int $nb_entry = null, ?int $page = null, ?int $after_id = null, ?int $before_id = null)
{
return $this->get_model()->list_for_user($id_user, $nb_entry, $nb_entry * $page);
return $this->get_model()->list_for_user($id_user, $nb_entry, $nb_entry * $page, $after_id, $before_id);
}
/**

View file

@ -85,6 +85,22 @@ use BenMorel\GsmCharsetConverter\Converter;
return '<a href="' . self::s($url, false, true, false) . '">' . self::s($number_format, false, true, false) . '</a>';
}
/**
* Check for http link in a text
*
* @param string $text : Text to search a link in
*
* @return bool|array : False if no link in the text, or an array of all http links
*/
public static function search_http_links($text)
{
$regex = "#http(s?)://\S+#i";
$matches = [];
$nb_matches = preg_match_all($regex, $text, $matches);
return $nb_matches > 0 ? $matches[0] : false;
}
/**
* Cette fonction fait la correspondance entre un type d'evenement et une icone font awesome.
*
@ -152,6 +168,26 @@ use BenMorel\GsmCharsetConverter\Converter;
return $logo;
}
/**
* Check if a string is a valid PHP date
*
* @param string $date : Datestring to validate
*
* @return bool : True if a valid date, false else
*/
public static function is_valid_date($date)
{
try
{
new \DateTime($date);
return true;
}
catch (\Exception $e)
{
return false;
}
}
/**
* Cette fonction vérifie une date.
*
@ -427,4 +463,28 @@ use BenMorel\GsmCharsetConverter\Converter;
$converter = new Converter();
return $converter->cleanUpUtf8String($text, true, '?');
}
/**
* Encode some data into the URL version of Base64 encoding
*
* @param string $data Input data
* @return string A Base64 (URL-safe) encoded string
*/
public static function url_base64_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Decode a URL-safe Base64 encoded string
*
* @param string $data Encoded data
* @return string Decoded original data
*/
public static function url_base64_decode(string $data): string
{
$replaced = strtr($data, '-_', '+/');
$padded = str_pad($replaced, mb_strlen($replaced) % 4 === 0 ? mb_strlen($replaced) : mb_strlen($replaced) + 4 - mb_strlen($replaced) % 4, '=', STR_PAD_RIGHT);
return base64_decode($padded);
}
}

View file

@ -31,6 +31,17 @@ namespace controllers\internals;
$this->internal_phone = new Phone($bdd);
}
/**
* Return all active users.
*
* @return array
*/
public function get_all_active()
{
return $this->model_user->get_all_active();
}
/**
* Return a list of users by their ids.
*

View file

@ -115,6 +115,7 @@ class Webhook extends StandardController
return false;
}
$success = false;
$webhooks = $this->gets_for_type_and_user($id_user, $type);
foreach ($webhooks as $webhook)
{
@ -135,12 +136,11 @@ class Webhook extends StandardController
],
];
$error_code = null;
$queue = msg_get_queue(QUEUE_ID_WEBHOOK);
msg_send($queue, QUEUE_TYPE_WEBHOOK, $message, true, true, $error_code);
$queue = new Queue(QUEUE_ID_WEBHOOK);
$success = $queue->push(json_encode($message), QUEUE_TYPE_WEBHOOK);
}
return true;
return (bool) $success;
}
/**

View file

@ -91,6 +91,10 @@ namespace controllers\publics;
{
$this->user = $this->internal_user->get_by_api_key($api_key);
}
elseif ($_SESSION['user'] ?? false)
{
$this->user = $this->internal_user->get($_SESSION['user']['id']);
}
if (!$this->user)
{
@ -122,10 +126,13 @@ namespace controllers\publics;
*
* @param string $entry_type : Type of entries we want to list ['sended', 'received', 'scheduled', 'contact', 'group', 'conditional_group', 'phone', 'phone_group', 'media']
* @param int $page : Pagination number, Default = 0. Group of 25 results.
* @param ?int $after_id : If provided use where id > $after_id instead of offset based on page, more performant
* @param ?int $before_id : If provided use where id < $before_id instead of offset based on page, more performant
*
*
* @return : List of entries
*/
public function get_entries(string $entry_type, int $page = 0)
public function get_entries(string $entry_type, int $page = 0, ?int $after_id = null, ?int $before_id = null)
{
$entry_types = ['sended', 'received', 'scheduled', 'contact', 'group', 'conditional_group', 'phone', 'phone_group', 'media'];
@ -144,7 +151,7 @@ namespace controllers\publics;
$page = (int) $page;
$limit = 25;
$entries = $controller->list_for_user($this->user['id'], $limit, $page);
$entries = $controller->list_for_user($this->user['id'], $limit, $page, $after_id, $before_id);
//Special case for scheduled, we must add numbers because its a join
if ('scheduled' === $entry_type)
@ -217,14 +224,16 @@ namespace controllers\publics;
$return = self::DEFAULT_RETURN;
$return['response'] = $entries;
if (\count($entries) === $limit)
if (\count($entries) === $limit || ($entries && $before_id))
{
$return['next'] = \descartes\Router::url('Api', __FUNCTION__, ['entry_type' => $entry_type, 'page' => $page + 1], ['api_key' => $this->user['api_key']]);
$last_entry = end($entries);
$return['next'] = \descartes\Router::url('Api', __FUNCTION__, ['entry_type' => $entry_type, 'after_id' => $last_entry['id']], ['api_key' => $this->user['api_key']]);
}
if ($page > 0)
if ($page > 0 || ($entries && ($after_id || $before_id)))
{
$return['prev'] = \descartes\Router::url('Api', __FUNCTION__, ['entry_type' => $entry_type, 'page' => $page - 1], ['api_key' => $this->user['api_key']]);
$first_entry = $entries[0];
$return['prev'] = \descartes\Router::url('Api', __FUNCTION__, ['entry_type' => $entry_type, 'before_id' => $first_entry['id']], ['api_key' => $this->user['api_key']]);
}
$this->auto_http_code(true);
@ -300,6 +309,58 @@ namespace controllers\publics;
return $this->json($return);
}
/**
* Simplest method to send a SMS immediately with nothing but a URL and a GET query
* @param string $_GET['to'] = Phone number to send sms to
* @param string $_GET['text'] = Text of the SMS
* @param ?int $_GET['id_phone'] = Id of the phone to use, if null use a random phone
*/
public function get_send_sms()
{
$to = \controllers\internals\Tool::parse_phone($_GET['to'] ?? '');
$text = $_GET['text'] ?? false;
$id_phone = empty($_GET['id_phone']) ? null : $_GET['id_phone'];
if (!$to || !$text)
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['MISSING_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['MISSING_PARAMETER'] . ($to ? '' : 'to ') . ($text ? '' : 'text');
$this->auto_http_code(false);
return $this->json($return);
}
$at = (new \DateTime())->format('Y-m-d H:i:s');
$scheduled_id = $this->internal_scheduled->create(
$this->user['id'],
$at,
$text,
$id_phone,
null,
false,
false,
null,
[['number' => $to, 'data' => '[]']]
);
if (!$scheduled_id)
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['CANNOT_CREATE'];
$return['message'] = self::ERROR_MESSAGES['CANNOT_CREATE'];
$this->auto_http_code(false);
return $this->json($return);
}
$return = self::DEFAULT_RETURN;
$return['response'] = $scheduled_id;
$this->auto_http_code(true);
return $this->json($return);
}
/**
* Schedule a message to be send.
*
@ -426,7 +487,7 @@ namespace controllers\publics;
$at = (string) $at;
$text = (string) $text;
if (($this->user['settings']['mms'] ?? false) && $mms)
if ($mms && !(int)($this->user['settings']['mms'] ?? false))
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['INVALID_PARAMETER'];
@ -1030,10 +1091,46 @@ namespace controllers\publics;
return $this->json($return);
}
//Check adapter is working correctly with thoses names and data
$adapter_classname = $phone['adapter'];
$adapter_instance = new $adapter_classname($phone['adapter_data']);
$new_status = $adapter_instance->check_phone_status();
if ($phone['status'] === \models\Phone::STATUS_DISABLED)
{
$return['error'] = self::ERROR_CODES['CANNOT_UPDATE'];
$return['message'] = self::ERROR_MESSAGES['CANNOT_UPDATE'] . 'Phone have been manually disabled, you need to re-enable it manually.';
$this->auto_http_code(false);
return $this->json($return);
}
// If user have activated phone limits, check if RaspiSMS phone limit have already been reached
$limit_reached = false;
if ((int) ($this->user['settings']['phone_limit'] ?? false))
{
$limits = $this->internal_phone->get_limits($id);
$remaining_volume = PHP_INT_MAX;
foreach ($limits as $limit)
{
$startpoint = new \DateTime($limit['startpoint']);
$consumed = $this->internal_sended->count_since_for_phone_and_user($this->user['id'], $id, $startpoint);
$remaining_volume = min(($limit['volume'] - $consumed), $remaining_volume);
}
if ($remaining_volume < 1)
{
$limit_reached = true;
}
}
if ($limit_reached)
{
$new_status = \models\Phone::STATUS_LIMIT_REACHED;
}
else
{
//Check status on provider side
$adapter_classname = $phone['adapter'];
$adapter_instance = new $adapter_classname($phone['adapter_data']);
$new_status = $adapter_instance->check_phone_status();
}
$status_update = $this->internal_phone->update_status($id, $new_status);
$return['response'] = $new_status;
@ -1041,4 +1138,176 @@ namespace controllers\publics;
return $this->json($return);
}
/**
* Manually disable/enable phones
* @param int id : id of phone we want to update status
* @param string $_POST['new_status'] : New status of the phone, either 'disabled' or 'available'
* @param $csrf : CSRF token
*/
public function post_change_phone_status ($id)
{
$new_status = $_POST['status'] ?? '';
if (!in_array($new_status, [\models\Phone::STATUS_AVAILABLE, \models\Phone::STATUS_DISABLED]))
{
$return['error'] = self::ERROR_CODES['INVALID_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['INVALID_PARAMETER'] . ' "status" must be "disabled" or "available".';
$this->auto_http_code(false);
return $this->json($return);
}
$phone = $this->internal_phone->get_for_user($this->user['id'], $id);
if (!$phone)
{
$return['error'] = self::ERROR_CODES['CANNOT_UPDATE'];
$return['message'] = self::ERROR_MESSAGES['CANNOT_UPDATE'];
$this->auto_http_code(false);
return $this->json($return);
}
$status_update = $this->internal_phone->update_status($id, $new_status);
$return['response'] = $new_status;
$this->auto_http_code(true);
return $this->json($return);
}
/**
* Return statistics about status of sended sms for a period by phone
*
* @param string $_GET['start'] : Date from which to get sms volume, format Y-m-d H:i:s.
* @param string $_GET['end'] : Date up to which to get sms volume, format Y-m-d H:i:s.
* @param ?int $_GET['id_phone'] : Id of the phone we want to check the status for. Default to null will return stats for all phone.
*
* @return : List of entries
*/
public function get_sms_status_stats()
{
$start = $_GET['start'] ?? null;
$end = $_GET['end'] ?? null;
$id_phone = $_GET['id_phone'] ?? null;
if (!$start || !$end)
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['MISSING_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['MISSING_PARAMETER'] . 'start and end date are required.';
$this->auto_http_code(false);
return $this->json($return);
}
$return = self::DEFAULT_RETURN;
if (!\controllers\internals\Tool::is_valid_date($start))
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['INVALID_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['INVALID_PARAMETER'] . 'start must be a date of format "Y-m-d H:i:s".';
$this->auto_http_code(false);
return $this->json($return);
}
$start = new \DateTime($start);
if (!\controllers\internals\Tool::is_valid_date($end))
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['INVALID_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['INVALID_PARAMETER'] . 'end must be a date of format "Y-m-d H:i:s".';
$this->auto_http_code(false);
return $this->json($return);
}
$end = new \DateTime($end);
if ($id_phone)
{
$phone = $this->internal_phone->get_for_user($this->user['id'], $id_phone);
if (!$phone)
{
$return['error'] = self::ERROR_CODES['INVALID_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['INVALID_PARAMETER'] . 'phone with id ' . $id_phone . ' does not exists.';
$this->auto_http_code(false);
return $this->json($return);
}
}
$stats = $this->internal_sended->get_sended_status_stats($this->user['id'], $start, $end, $id_phone);
$return = self::DEFAULT_RETURN;
$return['response'] = $stats;
$this->auto_http_code(true);
return $this->json($return);
}
/**
* Return statistics about invalid numbers
*
* @param int $page : Pagination number, Default = 0. Group of 25 results.
* @param int $_GET['volume'] : Minimum number of SMS sent to the number
* @param int $_GET['percent_failed'] : Minimum percentage of failed SMS to the number
* @param int $_GET['percent_unknown'] : Minimum percentage of unknown SMS to the number
*
* @return : List of entries
*/
public function get_invalid_numbers($page = 0)
{
$page = (int) $page;
$limit = 25;
$volume = $_GET['volume'] ?? false;
$percent_failed = $_GET['percent_failed'] ?? false;
$percent_unknown = $_GET['percent_unknown'] ?? false;
if ($volume === false || $percent_failed === false || $percent_unknown === false)
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['MISSING_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['MISSING_PARAMETER'] . 'volume, percent_failed and percent_unknown are required.';
$this->auto_http_code(false);
return $this->json($return);
}
$volume = (int) $volume;
$percent_failed = ((float) $percent_failed) / 100;
$percent_unknown = ((float) $percent_unknown) / 100;
$return = self::DEFAULT_RETURN;
$invalid_numbers = $this->internal_sended->get_invalid_numbers($this->user['id'], $volume, $percent_failed, $percent_unknown, $limit, $page);
$return = self::DEFAULT_RETURN;
if (\count($invalid_numbers) === $limit)
{
$return['next'] = \descartes\Router::url('Api', __FUNCTION__, ['page' => $page + 1], [
'api_key' => $this->user['api_key'],
'volume' => $volume,
'percent_failed' => $percent_failed * 100,
'percent_unknown' => $percent_unknown * 100
]);
}
if ($page > 0)
{
$return['prev'] = \descartes\Router::url('Api', __FUNCTION__, ['page' => $page - 1], [
'api_key' => $this->user['api_key'],
'volume' => $volume,
'percent_failed' => $percent_failed * 100,
'percent_unknown' => $percent_unknown * 100
]);
}
$return['response'] = $invalid_numbers;
$this->auto_http_code(true);
return $this->json($return, false);
}
}

View file

@ -11,6 +11,8 @@
namespace controllers\publics;
use controllers\internals\Tool;
/**
* Page de connexion.
*/
@ -117,8 +119,9 @@ namespace controllers\publics;
$Tokenista = new \Ingenerator\Tokenista(APP_SECRET);
$token = $Tokenista->generate(3600, ['id_user' => $user['id']]);
$encoded_token = Tool::url_base64_encode($token);
$reset_link = \descartes\Router::url('Connect', 'reset_password', ['id_user' => $user['id'], 'token' => $token]);
$reset_link = \descartes\Router::url('Connect', 'reset_password', ['id_user' => $user['id'], 'token' => $encoded_token]);
$mailer = new \controllers\internals\Mailer();
$email_send = $mailer->enqueue($email, EMAIL_RESET_PASSWORD, ['reset_link' => $reset_link]);
@ -139,7 +142,8 @@ namespace controllers\publics;
$Tokenista = new \Ingenerator\Tokenista(APP_SECRET);
if (!$Tokenista->isValid($token, ['id_user' => $id_user]))
$decoded_token = Tool::url_base64_decode($token);
if (!$Tokenista->validate($decoded_token, ['id_user' => $id_user]))
{
return $this->render('connect/reset-password-invalid');
}

View file

@ -11,6 +11,8 @@
namespace controllers\publics;
use Exception;
/**
* Page des contacts.
*/
@ -345,40 +347,28 @@ namespace controllers\publics;
return $this->redirect(\descartes\Router::url('Contact', 'list'));
}
//Try to import file
$invalid_type = false;
switch ($read_file['mime_type'])
try
{
case 'text/csv':
$result = $this->internal_contact->import_csv($id_user, $read_file['content']);
break;
case 'application/json':
$result = $this->internal_contact->import_json($id_user, $read_file['content']);
break;
default:
if ('csv' === $read_file['extension'])
{
$result = false;
switch (true)
{
case ($read_file['mime_type'] === 'text/csv' || 'csv' === $read_file['extension']) :
$result = $this->internal_contact->import_csv($id_user, $read_file['content']);
}
elseif ('json' === $read_file['extension'])
{
$result = $this->internal_contact->import_json($id_user, $read_file['content']);
}
else
{
$invalid_type = true;
$result = false;
}
}
if ($invalid_type)
break;
case ($read_file['mime_type'] === 'text/json' || 'json' === $read_file['extension']) :
$result = $this->internal_contact->import_json($id_user, $read_file['content']);
break;
default:
throw new Exception('Le type de fichier n\'est pas valide.');
}
}
catch (\Exception $e)
{
\FlashMessage\FlashMessage::push('danger', 'Le type de fichier n\'est pas valide.');
\FlashMessage\FlashMessage::push('danger', 'Erreur lors de l\'import: ' . $e->getMessage());
return $this->redirect(\descartes\Router::url('Contact', 'list'));
}

View file

@ -87,24 +87,60 @@ namespace controllers\publics;
$stats_start_date_formated = $stats_start_date->format('Y-m-d');
}
$nb_sendeds_by_day = $this->internal_sended->count_by_day_since_for_user($id_user, $stats_start_date_formated);
$nb_receiveds_by_day = $this->internal_received->count_by_day_since_for_user($id_user, $stats_start_date_formated);
$this->render('dashboard/show', [
'nb_contacts' => $nb_contacts,
'nb_groups' => $nb_groups,
'nb_scheduleds' => $nb_scheduleds,
'nb_sendeds' => $nb_sendeds,
'nb_receiveds' => $nb_receiveds,
'nb_unreads' => $nb_unreads,
'quota_unused' => $quota_unused,
'sendeds' => $sendeds,
'receiveds' => $receiveds,
'events' => $events,
'stats_start_date_formated' => $stats_start_date_formated,
]);
}
/**
* Return stats about sended sms
*/
public function stats_sended()
{
$id_user = $_SESSION['user']['id'];
//Création de la date d'il y a 30 jours
$now = new \DateTime();
$one_month = new \DateInterval('P1M');
$stats_start_date = clone $now;
$stats_start_date->sub($one_month);
$stats_start_date_formated = $stats_start_date->format('Y-m-d');
//If user have a quota and the quota start before today, use quota start date instead
$quota = $this->internal_quota->get_user_quota($id_user);
if ($quota && (new \DateTime($quota['start_date']) <= $now) && (new \DateTime($quota['expiration_date']) > $now))
{
$stats_start_date = new \DateTime($quota['start_date']);
$stats_start_date_formated = $stats_start_date->format('Y-m-d');
}
$nb_sendeds_by_day = $this->internal_sended->count_by_day_and_status_since_for_user($id_user, $stats_start_date_formated);
//On va traduire ces données pour les afficher en graphique
$array_area_chart = [];
$array_bar_chart_sended = [];
$date = clone $stats_start_date;
$one_day = new \DateInterval('P1D');
$i = 0;
//On va construire un tableau avec la date en clef, et les données pour chaque date
while ($date <= $now)
{
$date_f = $date->format('Y-m-d');
$array_area_chart[$date_f] = [
$array_bar_chart_sended[$date_f] = [
'period' => $date_f,
'sendeds' => 0,
'receiveds' => 0,
'sendeds_failed' => 0,
'sendeds_unknown' => 0,
'sendeds_delivered' => 0,
];
$date->add($one_day);
@ -113,39 +149,83 @@ namespace controllers\publics;
$total_sendeds = 0;
$total_receiveds = 0;
//0n remplie le tableau avec les données adaptées
foreach ($nb_sendeds_by_day as $date => $nb_sended)
//On remplie le tableau avec les données adaptées
foreach ($nb_sendeds_by_day as $nb_sended)
{
$array_area_chart[$date]['sendeds'] = $nb_sended;
$total_sendeds += $nb_sended;
}
foreach ($nb_receiveds_by_day as $date => $nb_received)
{
$array_area_chart[$date]['receiveds'] = $nb_received;
$total_receiveds += $nb_received;
$array_bar_chart_sended[$nb_sended['at_ymd']]['sendeds_' . $nb_sended['status']] = $nb_sended['nb'];
$array_bar_chart_sended[$nb_sended['at_ymd']]['sendeds_total'] = ($array_bar_chart_sended[$nb_sended['at_ymd']]['sendeds_total'] ?? 0) + $nb_sended['nb'];
$total_sendeds += $nb_sended['nb'];
}
$nb_days = $stats_start_date->diff($now)->days + 1;
$avg_sendeds = round($total_sendeds / $nb_days, 2);
$avg_receiveds = round($total_receiveds / $nb_days, 2);
$array_area_chart = array_values($array_area_chart);
$this->render('dashboard/show', [
'nb_contacts' => $nb_contacts,
'nb_groups' => $nb_groups,
'nb_scheduleds' => $nb_scheduleds,
'nb_sendeds' => $nb_sendeds,
'nb_receiveds' => $nb_receiveds,
'nb_unreads' => $nb_unreads,
$array_bar_chart_sended = array_values($array_bar_chart_sended);
header('content-type:application/json');
echo json_encode([
'data_bar_chart_sended' => $array_bar_chart_sended,
'avg_sendeds' => $avg_sendeds,
'avg_receiveds' => $avg_receiveds,
'quota_unused' => $quota_unused,
'sendeds' => $sendeds,
'receiveds' => $receiveds,
'events' => $events,
'data_area_chart' => json_encode($array_area_chart),
]);
}
/**
* Return stats about received sms
*/
public function stats_received()
{
$id_user = $_SESSION['user']['id'];
//Création de la date d'il y a 30 jours
$now = new \DateTime();
$one_month = new \DateInterval('P1M');
$stats_start_date = clone $now;
$stats_start_date->sub($one_month);
$stats_start_date_formated = $stats_start_date->format('Y-m-d');
$quota = $this->internal_quota->get_user_quota($id_user);
if ($quota && (new \DateTime($quota['start_date']) <= $now) && (new \DateTime($quota['expiration_date']) > $now))
{
$stats_start_date = new \DateTime($quota['start_date']);
$stats_start_date_formated = $stats_start_date->format('Y-m-d');
}
$nb_receiveds_by_day = $this->internal_received->count_by_day_since_for_user($id_user, $stats_start_date_formated);
//On va traduire ces données pour les afficher en graphique
$array_bar_chart_received = [];
$date = clone $stats_start_date;
$one_day = new \DateInterval('P1D');
//On va construire un tableau avec la date en clef, et les données pour chaque date
while ($date <= $now)
{
$date_f = $date->format('Y-m-d');
$array_bar_chart_received[$date_f] = ['period' => $date_f, 'receiveds' => 0];
$date->add($one_day);
}
$total_receiveds = 0;
foreach ($nb_receiveds_by_day as $date => $nb_received)
{
$array_bar_chart_received[$date]['receiveds'] = $nb_received;
$total_receiveds += $nb_received;
}
$nb_days = $stats_start_date->diff($now)->days + 1;
$avg_receiveds = round($total_receiveds / $nb_days, 2);
$array_bar_chart_received = array_values($array_bar_chart_received);
header('content-type:application/json');
echo json_encode([
'data_bar_chart_received' => $array_bar_chart_received,
'avg_receiveds' => $avg_receiveds,
]);
}
}

View file

@ -56,7 +56,7 @@ namespace controllers\publics;
*/
public function list_json()
{
$entities = $this->internal_received->get_discussions_for_user($_SESSION['user']['id']);
$entities = $this->internal_received->get_discussions_for_user($_SESSION['user']['id'], 1000);
foreach ($entities as &$entity)
{

View file

@ -18,11 +18,13 @@ class Phone extends \descartes\Controller
{
private $internal_phone;
private $internal_adapter;
private $internal_sended;
public function __construct()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$this->internal_phone = new \controllers\internals\Phone($bdd);
$this->internal_sended = new \controllers\internals\Sended($bdd);
$this->internal_adapter = new \controllers\internals\Adapter();
\controllers\internals\Tool::verifyconnect();
@ -533,16 +535,53 @@ class Phone extends \descartes\Controller
foreach ($ids as $id)
{
$phone = $this->internal_phone->get_for_user($id_user, $id);
//Check adapter is working correctly with thoses names and data
$adapter_classname = $phone['adapter'];
if (!call_user_func([$adapter_classname, 'meta_support_phone_status']))
if (!$phone)
{
continue;
}
$adapter_instance = new $adapter_classname($phone['adapter_data']);
$new_status = $adapter_instance->check_phone_status();
if ($phone['status'] === \models\Phone::STATUS_DISABLED)
{
\FlashMessage\FlashMessage::push('error', 'Certains téléphones ont été désactivés manuellements, vous devez les réactiver manuellement.');
continue;
}
// If user have activated phone limits, check if RaspiSMS phone limit have already been reached
$limit_reached = false;
if ((int) ($_SESSION['user']['settings']['phone_limit'] ?? false))
{
$limits = $this->internal_phone->get_limits($id);
$remaining_volume = PHP_INT_MAX;
foreach ($limits as $limit)
{
$startpoint = new \DateTime($limit['startpoint']);
$consumed = $this->internal_sended->count_since_for_phone_and_user($_SESSION['user']['id'], $id, $startpoint);
$remaining_volume = min(($limit['volume'] - $consumed), $remaining_volume);
}
if ($remaining_volume < 1)
{
$limit_reached = true;
}
}
if ($limit_reached)
{
$new_status = \models\Phone::STATUS_LIMIT_REACHED;
}
else
{
//Check status on provider side
$adapter_classname = $phone['adapter'];
if (!call_user_func([$adapter_classname, 'meta_support_phone_status']))
{
continue;
}
$adapter_instance = new $adapter_classname($phone['adapter_data']);
$new_status = $adapter_instance->check_phone_status();
}
$status_update = $this->internal_phone->update_status($id, $new_status);
}
@ -552,6 +591,48 @@ class Phone extends \descartes\Controller
return $this->redirect(\descartes\Router::url('Phone', 'list'));
}
/**
* Manually disable/enable phones
* @param array int $_GET['ids'] : ids of phones we want to update status
* @param string $new_status : New status of the phone, either 'disabled' or 'available'
* @param $csrf : CSRF token
*/
public function change_status ($new_status, $csrf)
{
if (!$this->verify_csrf($csrf))
{
\FlashMessage\FlashMessage::push('danger', 'Jeton CSRF invalid !');
return $this->redirect(\descartes\Router::url('Phone', 'add'));
}
if (!in_array($new_status, [\models\Phone::STATUS_AVAILABLE, \models\Phone::STATUS_DISABLED]))
{
\FlashMessage\FlashMessage::push('danger', 'Seul les status disponibles et désactivés peuvent être définis manuellement.');
return $this->redirect(\descartes\Router::url('Phone', 'add'));
}
$ids = $_GET['ids'] ?? [];
$id_user = $_SESSION['user']['id'];
foreach ($ids as $id)
{
$phone = $this->internal_phone->get_for_user($id_user, $id);
if (!$phone)
{
continue;
}
$status_update = $this->internal_phone->update_status($id, $new_status);
}
\FlashMessage\FlashMessage::push('success', 'Les status des téléphones ont bien été mis à jour manuellement.');
return $this->redirect(\descartes\Router::url('Phone', 'list'));
}
/**
* Return a list of phones as a JSON array
*/

View file

@ -75,12 +75,27 @@ namespace controllers\publics;
$setting_value = json_encode($setting_value);
}
$update_setting_result = $this->internal_setting->update_for_user($_SESSION['user']['id'], $setting_name, $setting_value);
if (false === $update_setting_result)
// If setting dont exists yet, create it, else update
$setting = $this->internal_setting->get_by_name_for_user($_SESSION['user']['id'], $setting_name);
if (!$setting)
{
\FlashMessage\FlashMessage::push('danger', 'Impossible de mettre à jour ce réglage.');
$success = $this->internal_setting->create($_SESSION['user']['id'], $setting_name, $setting_value);
if (false === $success)
{
\FlashMessage\FlashMessage::push('danger', 'Impossible de mettre à jour ce réglage.');
return $this->redirect(\descartes\Router::url('Setting', 'show'));
return $this->redirect(\descartes\Router::url('Setting', 'show'));
}
}
else
{
$update_setting_result = $this->internal_setting->update_for_user($_SESSION['user']['id'], $setting_name, $setting_value);
if (false === $update_setting_result)
{
\FlashMessage\FlashMessage::push('danger', 'Impossible de mettre à jour ce réglage.');
return $this->redirect(\descartes\Router::url('Setting', 'show'));
}
}
$settings = $this->internal_setting->gets_for_user($_SESSION['user']['id']);

View file

@ -0,0 +1,53 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace controllers\publics;
/**
* Statistics pages
*/
class Stat extends \descartes\Controller
{
private $internal_sended;
private $internal_phone;
public function __construct()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$this->internal_sended = new \controllers\internals\Sended($bdd);
$this->internal_phone = new \controllers\internals\Phone($bdd);
\controllers\internals\Tool::verifyconnect();
}
/**
* Show the stats about sms status for a period by phone
*
* @return void;
*/
public function sms_status()
{
$id_user = $_SESSION['user']['id'];
$phones = $this->internal_phone->gets_for_user($id_user);
$now = new \DateTime();
$seven_days_interval = new \DateInterval('P7D');
$seven_days_ago = clone($now);
$seven_days_ago->sub($seven_days_interval);
$this->render('stat/sms-status', [
'phones' => $phones,
'now' => $now,
'seven_days_ago' => $seven_days_ago,
]);
}
}

View file

@ -11,6 +11,7 @@
namespace daemons;
use controllers\internals\Queue;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
@ -19,7 +20,7 @@ use Monolog\Logger;
*/
class Mailer extends AbstractDaemon
{
private $mailer_queue;
private ?Queue $mailer_queue;
private $last_message_at;
private $bdd;
@ -49,27 +50,15 @@ class Mailer extends AbstractDaemon
$find_message = true;
while ($find_message)
{
//Call message
$msgtype = null;
$maxsize = 409600;
$message = null;
$message = $this->mailer_queue->read(QUEUE_TYPE_EMAIL);
$error_code = null;
$success = msg_receive($this->mailer_queue, QUEUE_TYPE_EMAIL, $msgtype, $maxsize, $message, true, MSG_IPC_NOWAIT, $error_code); //MSG_IPC_NOWAIT == dont wait if no message found
if (!$success && MSG_ENOMSG !== $error_code)
if ($message === null)
{
$this->logger->critical('Error for mailer queue reading, error code : ' . $error_code);
$find_message = false;
continue;
}
if (!$message)
{
$find_message = false;
continue;
}
$message = json_decode($message, true);
$this->logger->info('Try sending email : ' . json_encode($message));
@ -92,7 +81,7 @@ class Mailer extends AbstractDaemon
public function on_start()
{
//Set last message at to construct time
$this->mailer_queue = msg_get_queue(QUEUE_ID_EMAIL);
$this->mailer_queue = new Queue(QUEUE_ID_EMAIL);
$this->logger->info('Starting Mailer daemon with pid ' . getmypid());
}
@ -101,8 +90,6 @@ class Mailer extends AbstractDaemon
{
//Delete queue on daemon close
$this->logger->info('Closing queue : ' . QUEUE_ID_EMAIL);
msg_remove_queue($this->mailer_queue);
$this->logger->info('Stopping Mailer daemon with pid ' . getmypid());
}

View file

@ -11,6 +11,7 @@
namespace daemons;
use controllers\internals\Queue;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
@ -22,7 +23,7 @@ class Phone extends AbstractDaemon
private $max_inactivity = 5 * 60;
private $read_delay = 20 / 0.5;
private $read_tick = 0;
private $msg_queue;
private ?Queue $queue;
private $webhook_queue;
private $last_message_at;
private $phone;
@ -85,7 +86,7 @@ class Phone extends AbstractDaemon
//Set last message at to construct time
$this->last_message_at = microtime(true);
$this->msg_queue = msg_get_queue(QUEUE_ID_PHONE);
$this->queue = new Queue(QUEUE_ID_PHONE);
//Instanciate adapter
$adapter_class = $this->phone['adapter'];
@ -96,7 +97,7 @@ class Phone extends AbstractDaemon
public function on_stop()
{
$this->logger->info('Stopping Phone daemon with pid ' . getmypid());
$this->logger->info('Stopping Phone daemon with pid ' . getmypid());
}
public function handle_other_signals($signal)
@ -114,30 +115,17 @@ class Phone extends AbstractDaemon
$find_message = true;
while ($find_message)
{
//Call message
$msgtype = null;
$maxsize = 409600;
$message = null;
// Message type is forged from a prefix concat with the phone ID
$message_type = (int) QUEUE_TYPE_SEND_MSG_PREFIX . $this->phone['id'];
$error_code = null;
$success = msg_receive($this->msg_queue, $message_type, $msgtype, $maxsize, $message, true, MSG_IPC_NOWAIT, $error_code); //MSG_IPC_NOWAIT == dont wait if no message found
$message = $this->queue->read($message_type);
if (!$success && MSG_ENOMSG !== $error_code)
{
$this->logger->critical('Error reading MSG SEND Queue, error code : ' . $error_code);
return false;
}
if (!$message)
if ($message === null)
{
$find_message = false;
continue;
}
$message = json_decode($message, true);
//Update last message time
$this->last_message_at = microtime(true);

View file

@ -11,6 +11,8 @@
namespace daemons;
use controllers\internals\Queue;
use Exception;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
@ -19,12 +21,9 @@ use Monolog\Logger;
*/
class Sender extends AbstractDaemon
{
private $internal_phone;
private $internal_scheduled;
private $internal_received;
private $internal_sended;
private $bdd;
private $msg_queue;
private ?Queue $queue;
public function __construct()
{
@ -44,9 +43,7 @@ class Sender extends AbstractDaemon
public function run()
{
//Create the internal controllers
$this->internal_scheduled = new \controllers\internals\Scheduled($this->bdd);
$this->internal_sended = new \controllers\internals\Sended($this->bdd);
//Get smss and transmit order to send to appropriate phone daemon
$smss_per_scheduled = $this->internal_scheduled->get_smss_to_send();
@ -64,12 +61,6 @@ class Sender extends AbstractDaemon
{
foreach ($smss_per_scheduled as $id_scheduled => $smss)
{
//If queue not already exists
if (!msg_queue_exists(QUEUE_ID_PHONE) || !isset($this->msg_queue))
{
$this->msg_queue = msg_get_queue(QUEUE_ID_PHONE);
}
foreach ($smss as $sms)
{
$msg = [
@ -84,9 +75,10 @@ class Sender extends AbstractDaemon
'medias' => $sms['medias'] ?? [],
];
// Message type is forged from a prefix concat with the phone ID
$message_type = (int) QUEUE_TYPE_SEND_MSG_PREFIX . $sms['id_phone'];
msg_send($this->msg_queue, $message_type, $msg);
$this->queue->push(json_encode($msg), $message_type);
$this->logger->info('Transmit sms send signal to phone ' . $sms['id_phone'] . ' on queue ' . QUEUE_ID_PHONE . ' with message type ' . $message_type . '.');
}
@ -97,16 +89,25 @@ class Sender extends AbstractDaemon
public function on_start()
{
$this->logger->info('Starting Sender with pid ' . getmypid());
$this->bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
try
{
$this->logger->info('Starting Sender with pid ' . getmypid());
$this->bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$this->queue = new Queue(QUEUE_ID_PHONE);
}
catch (Exception $e)
{
$this->logger->error('Failed to start sender daemon : ' . $e->getMessage());
}
}
public function on_stop()
{
//Delete queue on daemon close
$this->logger->info('Closing queue : ' . $this->msg_queue);
msg_remove_queue($this->msg_queue);
$this->logger->info('Closing queue : ' . QUEUE_ID_PHONE);
$this->queue->close();
$this->logger->info('Stopping Sender with pid ' . getmypid());
}

View file

@ -11,6 +11,7 @@
namespace daemons;
use controllers\internals\Queue;
use GuzzleHttp\Promise\Utils;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
@ -20,11 +21,7 @@ use Monolog\Logger;
*/
class Webhook extends AbstractDaemon
{
private $webhook_queue;
private $last_message_at;
private $phone;
private $adapter;
private $bdd;
private ?Queue $webhook_queue;
private $guzzle_client;
/**
@ -56,30 +53,17 @@ class Webhook extends AbstractDaemon
$promises = [];
while ($find_message)
{
//Call message
$msgtype = null;
$maxsize = 409600;
$message = null;
$message = $this->webhook_queue->read(QUEUE_TYPE_WEBHOOK);
$error_code = null;
$success = msg_receive($this->webhook_queue, QUEUE_TYPE_WEBHOOK, $msgtype, $maxsize, $message, true, MSG_IPC_NOWAIT, $error_code); //MSG_IPC_NOWAIT == dont wait if no message found
if (!$success && MSG_ENOMSG !== $error_code)
if ($message === null)
{
$this->logger->critical('Error for webhook queue reading, error code : ' . $error_code);
$find_message = false;
continue;
}
if (!$message)
{
$find_message = false;
continue;
}
$this->logger->info('Trigger webhook : ' . json_encode($message));
$this->logger->info('Trigger webhook : ' . $message);
$message = json_decode($message, true);
$promises[] = $this->guzzle_client->postAsync($message['url'], ['form_params' => $message['data']]);
}
@ -97,10 +81,13 @@ class Webhook extends AbstractDaemon
public function on_start()
{
//Set last message at to construct time
$this->last_message_at = microtime(true);
$this->webhook_queue = msg_get_queue(QUEUE_ID_WEBHOOK);
try{
$this->webhook_queue = new Queue(QUEUE_ID_WEBHOOK);
}
catch (\Exception $e)
{
$this->logger->info('Webhook : failed with ' . $e->getMessage());
}
$this->logger->info('Starting Webhook daemon with pid ' . getmypid());
}
@ -109,7 +96,7 @@ class Webhook extends AbstractDaemon
{
//Delete queue on daemon close
$this->logger->info('Closing queue : ' . QUEUE_ID_WEBHOOK);
msg_remove_queue($this->webhook_queue);
unset($this->webhook_queue);
$this->logger->info('Stopping Webhook daemon with pid ' . getmypid());
}

View file

@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddReachedLimitPhoneStatus extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html
*
* The following commands can be used in this method and Phinx will
* automatically reverse them when rolling back:
*
* createTable
* renameTable
* addColumn
* addCustomColumn
* renameColumn
* addIndex
* addForeignKey
*
* Any other destructive changes will result in an error when trying to
* rollback the migration.
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change()
{
$table = $this->table('phone');
$table->changeColumn('status', 'enum', ['values' => ['available', 'unavailable', 'no_credit', 'limit_reached'], 'default' => 'available']);
$table->save();
}
}

View file

@ -0,0 +1,24 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddIndexToSmsTimestamps extends AbstractMigration
{
/**
* Add indexes on most SMS table timestamp (and possibly other fields) to improve perfs on query using date, like stats, sending limits, etc.
*/
public function change()
{
$table = $this->table('sended');
$table->addIndex('at');
$table->update();
$table = $this->table('received');
$table->addIndex('at');
$table->update();
$table = $this->table('scheduled');
$table->addIndex('at');
$table->update();
}
}

View file

@ -0,0 +1,24 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddIndexOnSendedUid extends AbstractMigration
{
/**
* Modify sended uid and call to be 100 char long, we dont need a 500 char uid and too long a char ss hurting perfs
* Add index on sended uid to make status update more efficient
*/
public function change()
{
$table = $this->table('sended');
$table->changeColumn('uid', 'string', ['limit' => 100]);
$table->addIndex('uid');
$table->update();
$table = $this->table('call');
$table->changeColumn('uid', 'string', ['limit' => 100]);
$table->addIndex('uid');
$table->update();
}
}

View file

@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddStatusDisabledPhone extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html
*
* The following commands can be used in this method and Phinx will
* automatically reverse them when rolling back:
*
* createTable
* renameTable
* addColumn
* addCustomColumn
* renameColumn
* addIndex
* addForeignKey
*
* Any other destructive changes will result in an error when trying to
* rollback the migration.
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change()
{
$table = $this->table('phone');
$table->changeColumn('status', 'enum', ['values' => ['available', 'unavailable', 'no_credit', 'limit_reached', 'disabled'], 'default' => 'available']);
$table->save();
}
}

View file

@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddWebhookPhoneReliability extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html
*
* The following commands can be used in this method and Phinx will
* automatically reverse them when rolling back:
*
* createTable
* renameTable
* addColumn
* addCustomColumn
* renameColumn
* addIndex
* addForeignKey
*
* Any other destructive changes will result in an error when trying to
* rollback the migration.
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change()
{
$table = $this->table('webhook');
$table->changeColumn('type', 'enum', ['values' => ['send_sms','send_sms_status_change','receive_sms','inbound_call', 'phone_reliability']]);
$table->save();
}
}

View file

@ -0,0 +1,22 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddTablePhoneReliabilityHistory extends AbstractMigration
{
public function change()
{
// Create the phone_reliability_history table
// This table store history of reliability alert for phones, so we can use last alert as min date
// for surveillance periode, preventing triggering same alert in a loop
$this->table('phone_reliability_history')
->addColumn('id_user', 'integer', ['null' => false])
->addColumn('id_phone', 'integer', ['null' => false])
->addColumn('type', 'string', ['null' => false, 'limit' => 100])
->addColumn('created_at', 'timestamp', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
->addColumn('updated_at', 'timestamp', ['null' => true, 'update' => 'CURRENT_TIMESTAMP'])
->addForeignKey('id_user', 'user', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->addForeignKey('id_phone', 'phone', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->create();
}
}

View file

@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddIndexSendedStatus extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html
*
* The following commands can be used in this method and Phinx will
* automatically reverse them when rolling back:
*
* createTable
* renameTable
* addColumn
* addCustomColumn
* renameColumn
* addIndex
* addForeignKey
*
* Any other destructive changes will result in an error when trying to
* rollback the migration.
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change()
{
$table = $this->table('sended');
$table->addIndex(['id_user', 'status']);
$table->update();
}
}

View file

@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddIndexDestinationStatusSended extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html
*
* The following commands can be used in this method and Phinx will
* automatically reverse them when rolling back:
*
* createTable
* renameTable
* addColumn
* addCustomColumn
* renameColumn
* addIndex
* addForeignKey
*
* Any other destructive changes will result in an error when trying to
* rollback the migration.
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change()
{
$table = $this->table('sended');
$table->addIndex(['destination', 'status']);
$table->update();
}
}

View file

@ -91,21 +91,21 @@
/**
* Cette fonction permet de faire un retour sous forme de json
* @param array $data : Les données à retourner sous forme de json
* @param array $datas : Les données à retourner sous forme de json
* @param boolean $secure : Défini si l'affichage doit être sécurisé contre les XSS, par défaut true
* @return ApiController : On retourne l'API controlleur lui meme pour pouvoir chainer
*/
public function json ($data, $secure = true)
public function json ($datas, $secure = true)
{
header('Content-Type: application/json');
if ($secure)
{
echo htmlspecialchars(json_encode($data), ENT_NOQUOTES);
echo htmlspecialchars(json_encode($datas), ENT_NOQUOTES);
}
else
{
echo json_encode($data);
echo json_encode($datas);
}
return $this;

View file

@ -24,7 +24,7 @@
if (!is_readable($template_path))
{
throw new DescartesTemplateNotReadableException('Template ' . $template_path . ' is not readable.');
throw new exceptions\DescartesExceptionTemplateNotReadable('Template ' . $template_path . ' is not readable.');
}
require $template_path;

View file

@ -72,8 +72,7 @@
protected static function clean_url (string $url)
{
$to_remove = parse_url(HTTP_PWD, PHP_URL_PATH);
$url = mb_strcut($url, mb_strlen($to_remove));
$url = mb_strcut($url, $to_remove ? mb_strlen($to_remove) : 0);
$url = parse_url($url, PHP_URL_PATH);
return $url;
@ -252,6 +251,7 @@
$type = $parameter->getType();
$type = $type ?? false;
$type = ($type instanceof \ReflectionNamedType) ? $type->getName() : $type;
if ($type)
{

View file

@ -4,25 +4,32 @@
/*
* Define Descartes env
*/
$http_dir_path = '/raspisms'; //Path we need to put after servername in url to access app
$http_protocol = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://';
$http_server_name = isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'localhost';
$http_server_port = isset($_SERVER['SERVER_PORT']) ? ($_SERVER['SERVER_PORT'] == 80) ? '' : ':' . $_SERVER['SERVER_PORT'] : '';
$https = $_SERVER['HTTPS'] ?? false;
$http_dir_path = ''; //Path we need to put after servername in url to access app
$https = $_SERVER['HTTPS'] ?? 0;
if ( !isset($_SERVER['SERVER_PORT']) || ($_SERVER['SERVER_PORT'] == 80 && !$https) || ($_SERVER['SERVER_PORT'] == 443 && $https) )
// Check for proxy forward
$forwarded_https = ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? $_SERVER['HTTP_FORWARDED_PROTO'] ?? NULL) == 'https';
$forwarded_ssl = ($_SERVER['HTTP_X_FORWARDED_SSL'] ?? NULL) == 'on';
$proxy = $forwarded_https || $forwarded_ssl;
$http_protocol = 'http://';
if ($https)
{
$http_server_port = '';
}
else
{
$http_server_port = ':' . $_SERVER['SERVER_PORT'];
$http_protocol = 'https://';
}
$http_server_name = $_SERVER['SERVER_NAME'] ?? 'localhost';
// Check port to only set it if not default port
$port = $_SERVER['SERVER_PORT'] ?? '';
$port = ($port == 80 && !$https) ? '' : $port;
$port = ($port == 443 && $https) ? '' : $port;
$port = $proxy ? '' : $port;
$http_server_port = $port ? ':' . $port : '';
$pwd = substr(__DIR__, 0, strrpos(__DIR__, '/'));
$http_pwd = $http_protocol . $http_server_name . $http_server_port . $http_dir_path;
$env = [
//Global http and file path
@ -31,31 +38,25 @@
'HTTP_SERVER_NAME' => $http_server_name,
'HTTP_SERVER_PORT' => $http_server_port,
'PWD' => $pwd,
'HTTP_PWD' => $http_pwd,
//path of back resources
'PWD_CONTROLLER' => $pwd . '/controllers', //Controllers dir
'PWD_MODEL' => $pwd . '/models', //Models dir
'PWD_TEMPLATES' => $pwd . '/templates', //Templates dir
//path of front resources
'PWD_ASSETS' => $pwd . '/assets', //Assets dir
'HTTP_PWD_ASSETS' => $http_pwd . '/assets', //HTTP path of asset dir
'PWD_ASSETS' => $pwd . '/assets', //Assets dir
//images
'PWD_IMG' => $pwd . '/assets' . '/img',
'HTTP_PWD_IMG' => $http_pwd . '/assets' . '/img',
//css
'PWD_CSS' => $pwd . '/assets' . '/css',
'HTTP_PWD_CSS' => $http_pwd . '/assets' . '/css',
//javascript
'PWD_JS' => $pwd . '/assets' . '/js',
'HTTP_PWD_JS' => $http_pwd . '/assets' . '/js',
//fonts
'PWD_FONT' => $pwd . '/assets' . '/font',
'HTTP_PWD_FONT' => $http_pwd . '/assets' . '/font',
];

View file

@ -21,6 +21,7 @@
$environment = [];
$env = [];
// Load descartes base env
require_once(__DIR__ . '/env.php');
$environment = array_merge($environment, $env);
@ -31,11 +32,8 @@
$environment = array_merge($environment, $env);
}
//Define all Descartes constants
define_array($environment);
### GLOBAL ENV ###
$environment = [];
//Load global app env
$env = [];
if (file_exists(__DIR__ . '/../env.php'))
{
@ -43,19 +41,30 @@
$environment = array_merge($environment, $env);
}
define_array($environment);
### SPECIFIC ENV ###
$environment = [];
// Load specific environment env
$env = [];
if (defined('ENV') && file_exists(__DIR__ . '/../env.' . ENV . '.php'))
if (isset($environment['ENV']) && file_exists(__DIR__ . '/../env.' . $environment['ENV'] . '.php'))
{
require_once(__DIR__ . '/../env.' . ENV . '.php');
require_once(__DIR__ . '/../env.' . $environment['ENV'] . '.php');
$environment = array_merge($environment, $env);
}
### BUILD HTTP PWD CONSTS ###
// We compute http pwd at last minute to allow for simple overriding by user
// by simply defining custom HTTP_* (PROTOCOL, SERVER_NAME, SERVER_PORT, DIR_PATH)
$http_pwd = $environment['HTTP_PROTOCOL'] . $environment['HTTP_SERVER_NAME'] . $environment['HTTP_SERVER_PORT'] . $environment['HTTP_DIR_PATH'];
$env = [
"HTTP_PWD" => $http_pwd,
'HTTP_PWD_ASSETS' => $http_pwd . '/assets', //HTTP path of asset dir
'HTTP_PWD_IMG' => $http_pwd . '/assets' . '/img',
'HTTP_PWD_CSS' => $http_pwd . '/assets' . '/css',
'HTTP_PWD_JS' => $http_pwd . '/assets' . '/js',
'HTTP_PWD_FONT' => $http_pwd . '/assets' . '/font',
];
$environment = array_merge($environment, $env);
define_array($environment);
}

View file

@ -2,28 +2,31 @@
/*
This file define constants and options for the app
*/
$dir_path = '/raspisms';
$http_pwd = $environment['HTTP_PROTOCOL'] . $dir_path . $environment['HTTP_SERVER_PORT'] . $environment['HTTP_DIR_PATH'];
$env = [
'ENV' => '%APP_ENV%', #env name (probably 'dev' or 'prod'), this value is used to get the env.XXX.php.dist matching env file
'SESSION_NAME' => 'raspisms',
'HTTP_DIR_PATH' => $dir_path, // Override default dir path
//RaspiSMS settings
'WEBSITE_TITLE' => 'RaspiSMS',
'WEBSITE_DESCRIPTION' => '',
'WEBSITE_AUTHOR' => 'Raspberry Pi FR',
'PWD_SCRIPTS' => PWD . '/scripts',
'PWD_RECEIVEDS' => PWD . '/receiveds',
'HTTP_PWD_SOUND' => HTTP_PWD_ASSETS . '/sounds',
'PWD_ADAPTERS' => PWD . '/adapters',
'PWD_DATA' => PWD . '/data',
'HTTP_PWD_DATA' => HTTP_PWD . '/data',
'PWD_DATA_PUBLIC' => PWD . '/data/public',
'HTTP_PWD_DATA_PUBLIC' => HTTP_PWD . '/data/public',
'PWD_SCRIPTS' => $environment['PWD'] . '/scripts',
'PWD_RECEIVEDS' => $environment['PWD'] . '/receiveds',
'HTTP_PWD_SOUND' => $http_pwd . '/assets' . '/sounds',
'PWD_ADAPTERS' => $environment['PWD'] . '/adapters',
'PWD_DATA' => $environment['PWD'] . '/data',
'HTTP_PWD_DATA' => $http_pwd . '/data',
'PWD_DATA_PUBLIC' => $environment['PWD'] . '/data/public',
'HTTP_PWD_DATA_PUBLIC' => $http_pwd . '/data/public',
'PWD_LOGS' => '/var/log/raspisms',
'PWD_PID' => '/var/run/raspisms',
'APP_SECRET' => '%APP_SECRET%',
'ENABLE_COMMAND' => false,
'ENABLE_ACCOUNT_DELETION' => true,
'ENABLE_URL_SHORTENER' => %APP_URL_SHORTENER%,
//E-mail types
'EMAIL_RESET_PASSWORD' => [
@ -85,6 +88,25 @@
'force_gsm_alphabet' => 0,
'phone_limit' => 0,
'phone_priority' => 0,
'shorten_url' => 0,
'smsstop_respond' => 1,
'smsstop_response' => 'Demande prise en compte, vous ne recevrez plus de messages.',
'phone_reliability_failed' => 1,
'phone_reliability_failed_rate_limit' => 10,
'phone_reliability_failed_volume' => 25,
'phone_reliability_failed_period' => 120,
'phone_reliability_failed_grace_period' => 1,
'phone_reliability_failed_email' => 0,
'phone_reliability_failed_webhook' => 1,
'phone_reliability_failed_auto_disable' => 0,
'phone_reliability_unknown' => 0,
'phone_reliability_unknown_rate_limit' => 25,
'phone_reliability_unknown_volume' => 25,
'phone_reliability_unknown_period' => 120,
'phone_reliability_unknown_grace_period' => 1,
'phone_reliability_unknown_email' => 0,
'phone_reliability_unknown_webhook' => 1,
'phone_reliability_unknown_auto_disable' => 0,
],
];

View file

@ -21,4 +21,16 @@
'FROM' => '%APP_MAIL_FROM%',
],
//YOURLS url shortener settings
'URL_SHORTENER' => [
'HOST' => '%APP_URL_SHORTENER_HOST%',
'USER' => '%APP_URL_SHORTENER_USER%',
'PASS' => '%APP_URL_SHORTENER_PASS%',
],
// Define if we should use a Redis instance instead of System V Queues
'USE_REDIS_QUEUES' => false,
'REDIS_HOST' => '%APP_REDIS_HOST%',
'REDIS_PORT' => '%APP_REDIS_PORT%',
'REDIS_PASSWORD' => '%APP_REDIS_PASSWORD%',
];

View file

@ -26,10 +26,13 @@ namespace models;
* @param int $id_user : user id
* @param ?int $limit : Number of entry to return or null
* @param ?int $offset : Number of entry to ignore or null
* @param ?int $after_id : If provided use where id > $after_id instead of offset
* @param ?int $before_id : If provided use where id < $before_id instead of offset
*
*
* @return array
*/
public function list_for_user(int $id_user, $limit, $offset)
public function list_for_user(int $id_user, $limit, $offset, ?int $after_id = null, ?int $before_id = null)
{
$query = '
SELECT `call`.*, contact.name as contact_name, phone.name as phone_name
@ -42,6 +45,37 @@ namespace models;
WHERE `call`.id_user = :id_user
';
$params = [
'id_user' => $id_user,
];
if ($after_id || $before_id)
{
$offset = null;
}
if ($after_id)
{
$query .= '
AND `call`.id > :after_id
ORDER BY `call`.id
';
$params['after_id'] = $after_id;
}
elseif ($before_id)
{
$query .= '
AND `call`.id < :before_id
ORDER BY `call`.id DESC
';
$params['before_id'] = $before_id;
}
else
{
$query .= ' ORDER BY `call`.id';
}
if (null !== $limit)
{
$limit = (int) $limit;
@ -54,9 +88,10 @@ namespace models;
}
}
$params = [
'id_user' => $id_user,
];
if ($before_id)
{
return array_reverse($this->_run_query($query, $params));
}
return $this->_run_query($query, $params);
}

View file

@ -20,10 +20,12 @@ namespace models;
* @param int $id_user : user id
* @param ?int $limit : Number of entry to return or null
* @param ?int $offset : Number of entry to ignore or null
* @param ?int $after_id : If provided use where id > $after_id instead of offset
* @param ?int $before_id : If provided use where id < $before_id instead of offset
*
* @return array
*/
public function list_for_user(int $id_user, $limit, $offset)
public function list_for_user(int $id_user, $limit, $offset, ?int $after_id = null, ?int $before_id = null)
{
$query = '
SELECT g.*, COUNT(gc.id) as nb_contact
@ -31,9 +33,43 @@ namespace models;
LEFT JOIN group_contact as gc
ON g.id = gc.id_group
WHERE id_user = :id_user
GROUP BY g.id
';
$params = [
'id_user' => $id_user,
];
if ($after_id || $before_id)
{
$offset = null;
}
if ($after_id)
{
$query .= '
AND g.id > :after_id
GROUP BY g.id
ORDER BY g.id
';
$params['after_id'] = $after_id;
}
elseif ($before_id)
{
$query .= '
AND g.id < :before_id
GROUP BY g.id
ORDER BY g.id DESC
';
$params['before_id'] = $before_id;
}
else
{
$query .= '
GROUP BY g.id
ORDER BY g.id
';
}
if (null !== $limit)
{
$limit = (int) $limit;
@ -46,10 +82,11 @@ namespace models;
}
}
$params = [
'id_user' => $id_user,
];
if ($before_id)
{
return array_reverse($this->_run_query($query, $params));
}
return $this->_run_query($query, $params);
}

View file

@ -14,9 +14,11 @@ namespace models;
class Phone extends StandardModel
{
const STATUS_AVAILABLE = 'available';
const STATUS_UNAVAILABLE = 'unavailable';
const STATUS_NO_CREDIT = 'no_credit';
const STATUS_AVAILABLE = 'available'; # Everything OK
const STATUS_UNAVAILABLE = 'unavailable'; # RaspiSMS cannot communication with the phone
const STATUS_DISABLED = 'disabled'; # Phone have been manually or automatically disabled by user/system
const STATUS_NO_CREDIT = 'no_credit'; # Phone have no more credit available
const STATUS_LIMIT_REACHED = 'limit_reached'; # We reached the limit in of SMS in RaspiSMS for this phone
/**
* Return all phones that belongs to active users
@ -108,7 +110,6 @@ namespace models;
return $this->_delete('phone_limit', ['id_phone' => $id_phone]);
}
/**
* Return table name.
*/

View file

@ -20,10 +20,12 @@ namespace models;
* @param int $id_user : user id
* @param ?int $limit : Number of entry to return or null
* @param ?int $offset : Number of entry to ignore or null
* @param ?int $after_id : If provided use where id > $after_id instead of offset
* @param ?int $before_id : If provided use where id < $before_id instead of offset
*
* @return array
*/
public function list_for_user(int $id_user, $limit, $offset)
public function list_for_user(int $id_user, $limit, $offset, ?int $after_id = null, ?int $before_id = null)
{
$query = '
SELECT pg.*, COUNT(pgp.id) as nb_phone
@ -31,9 +33,43 @@ namespace models;
LEFT JOIN phone_group_phone as pgp
ON pg.id = pgp.id_phone_group
WHERE pg.id_user = :id_user
GROUP BY pg.id
';
$params = [
'id_user' => $id_user,
];
if ($after_id || $before_id)
{
$offset = null;
}
if ($after_id)
{
$query .= '
AND pg.id > :after_id
GROUP BY pg.id
ORDER BY g.id
';
$params['after_id'] = $after_id;
}
elseif ($before_id)
{
$query .= '
AND pg.id < :before_id
GROUP BY pg.id
ORDER BY g.id DESC
';
$params['before_'] = $before_id;
}
else
{
$query .= '
GROUP BY pg.id
ORDER BY pg.id
';
}
if (null !== $limit)
{
$limit = (int) $limit;
@ -46,9 +82,10 @@ namespace models;
}
}
$params = [
'id_user' => $id_user,
];
if ($before_id)
{
return array_reverse($this->_run_query($query, $params));
}
return $this->_run_query($query, $params);
}

View file

@ -0,0 +1,95 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace models;
class PhoneReliabilityHistory extends StandardModel
{
/**
* Find all unreliable phones for a user, based on sended sms status, rate limit, etc.
*
* @param int $id_user : User id
* @param string $sms_status : Status of SMS to use to calculate rate
* @param float $rate_limit : Percentage of SMS matching status after which we consider the phone unreliable
* @param int $min_volume : Minimum number of sms we need to have to consider the statistic relevent
* @param int $period : The time span in minutes from which SMS counting should begin.
* @param int $grace_period : How long in minutes should we wait before including a SMS in counting
*
* @return array : A list of unreliable phone for the user, with phone id, total number of sms, and rate of failed sms
*/
public function find_unreliable_phones (int $id_user, string $sms_status, float $rate_limit, int $min_volume, int $period, int $grace_period)
{
return $this->_run_query("
WITH recent_messages AS (
SELECT
sended.id_phone AS id_phone,
COUNT(sended.id) AS total,
SUM(sended.status = :sms_status) AS unreliable
FROM
sended
JOIN
phone
ON
sended.id_phone = phone.id
LEFT JOIN
(
SELECT
id_phone,
MAX(created_at) AS last_alert_time
FROM
phone_reliability_history
WHERE
type = :sms_status
GROUP BY
id_phone
) AS last_alerts
ON
sended.id_phone = last_alerts.id_phone
WHERE
sended.id_user = :id_user
AND
phone.status != 'disabled'
AND
sended.at > IFNULL(last_alerts.last_alert_time, '1970-01-01')
AND
sended.at BETWEEN NOW() - INTERVAL :period MINUTE AND NOW() - INTERVAL :grace_period MINUTE
GROUP BY
id_phone
)
SELECT
id_phone,
total,
unreliable,
(unreliable / total) AS rate
FROM
recent_messages
WHERE
total >= :min_volume
AND
(unreliable / total) >= :rate_limit;
", [
'id_user' => $id_user,
'sms_status' => $sms_status,
'period' => $period,
'grace_period' => $grace_period,
'min_volume' => $min_volume,
'rate_limit' => $rate_limit,
]);
}
/**
* Return table name.
*/
protected function get_table_name(): string
{
return 'phone_reliability_history';
}
}

39
models/Queue.php Normal file
View file

@ -0,0 +1,39 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace models;
/**
*
*/
interface Queue
{
/**
* A FIFO Queue to exchange messages, the backend mechanism can be whatever we want, but the queue take message, tag for routing is optionnal
* @param string $id : A unique identifier for the queue
*/
public function __construct($id);
/**
* Add a message to the queue
*
* @param string $message : The message to add to the queue, must be a string, for complex data just use json
* @param ?string $tag : A tag to associate to the message for routing purposes, if not set will add to general queue
*/
public function push($message, ?string $tag = null);
/**
* Read the older message in the queue (non-blocking)
* @param ?string $tag : A tag to associate to the message for routing purposes, if not set will read from general queue
* @return ?string $message : The oldest message or null if no message found, can be anything
*/
public function read(?string $tag = null);
}

View file

@ -267,27 +267,33 @@ namespace models;
*
* @return array
*/
public function get_discussions_for_user(int $id_user)
public function get_discussions_for_user(int $id_user, ?int $nb_entry = null, ?int $page = null)
{
$query = '
SELECT discussions.at, discussions.number, contact.name as contact_name
FROM (
SELECT at, destination as number FROM sended
WHERE id_user = :id_user
UNION (
SELECT at, origin as number FROM received
WHERE id_user = :id_user
)
) as discussions
LEFT JOIN contact
ON discussions.number = contact.number AND id_user = :id_user
GROUP BY number
ORDER BY at DESC
SELECT at, destination AS number, contact.name AS contact_name
FROM sended
LEFT JOIN contact ON contact.number = sended.destination
WHERE sended.id_user = :id_user
UNION ALL
SELECT at, origin AS number, contact.name AS contact_name
FROM received
LEFT JOIN contact ON contact.number = received.origin
WHERE received.id_user = :id_user
ORDER BY at DESC
';
$params = ['id_user' => $id_user];
return $this->_run_query($query, $params);
if ($nb_entry !== null)
{
$query .= 'LIMIT ' . intval($nb_entry) * intval($page) . ', ' . intval($nb_entry);
}
$results = $this->_run_query($query, $params);
return $results;
}
/**

108
models/RedisQueue.php Normal file
View file

@ -0,0 +1,108 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace models;
use Exception;
/**
*
*/
class RedisQueue implements Queue
{
private \Redis $redis;
private $group;
private $consumer;
private $id;
/**
* A Redis queue to store and exchange messages using redis streams
* routing is based on queue uniq id as stream name, combined with ':tag' if routing is needed, messages are stored as json
* @param string $id : A unique identifier for the queue
* @param array $redis_parameters : Parameters for the redis server, such as host, port, etc. Default to a basic local redis on port 6379
* @param string $group : Name to use for the redis group that must read this queue, default to 'default'
* @param string $consumer : Name to use for the redis consumer in the group that must read this queue, default to 'default'
*/
public function __construct($id, $redis_parameters = [], $group = 'default', $consumer = 'default')
{
$this->id = $id;
$this->redis = new \Redis();
$success = $this->redis->connect($redis_parameters['host'], intval($redis_parameters['port']), 1, '', 0, 0, ['auth' => $redis_parameters['auth']]);
if (!$success)
{
throw new \Exception('Failed to connect to redis server !');
}
$this->group = $group;
$this->consumer = $consumer;
}
/**
* Add a message to the queue
*
* @param string $message : The message to add to the queue
* @param ?string $tag : A tag to associate to the message for routing purposes, if null will add to general queue
*/
public function push($message, ?string $tag = null)
{
$stream = $this->id . ($tag !== null ? ":$tag" : '');
$success = $this->redis->xAdd($stream, '*', ['message' => $message]);
if (!$success)
{
throw new \Exception('Failed to push a message !');
}
return true;
}
/**
* Read the older message in the queue
*
* @return mixed $message : The oldest message or null if no message found, can be anything
* @param ?string $tag : A tag to associate to the message for routing purposes, if null will read from general queue
* @param mixed : The message return from the queue, can be anything, null if no message found
*/
public function read(?string $tag = null)
{
$stream = $this->id . ($tag !== null ? ":$tag" : '');
// Create the consumer group if it doesn't already exist
try
{
$this->redis->xGroup('CREATE', $stream, $this->group, '$', true);
}
catch (Exception $e)
{
// Ignore error if the group already exists
}
// Read a single message starting from the oldest (>)
$messages = $this->redis->xReadGroup($this->group, $this->consumer, [$stream => '>'], 1);
if (!count($messages))
{
return null;
}
// Find the message, acknowledge it and return it
foreach ($messages as $stream_name => $entries)
{
foreach ($entries as $message_id => $message)
{
$success = $this->redis->xAck($stream, $this->group, [$message_id]);
return $message['message'];
}
}
return null;
}
}

View file

@ -222,14 +222,14 @@ namespace models;
*
* @return array
*/
public function count_by_day_since_for_user($id_user, $date)
public function count_by_day_and_status_since_for_user($id_user, $date)
{
$query = "
SELECT COUNT(id) as nb, DATE_FORMAT(at, '%Y-%m-%d') as at_ymd
SELECT COUNT(id) as nb, status, DATE_FORMAT(at, '%Y-%m-%d') as at_ymd
FROM sended
WHERE at > :date
AND id_user = :id_user
GROUP BY at_ymd
GROUP BY at_ymd, status
";
$params = [
@ -294,6 +294,90 @@ namespace models;
return $result[0] ?? [];
}
/**
* Get number of sended SMS by day and status between two dates, possibly by sending phone.
*
* @param int $id_user : user id
* @param \DateTime $start_date : Date since which we want the messages
* @param \DateTime $end_date : Date until which we want the messages
* @param ?int $id_phone : Id of the phone to search sended for, null by default get all phones
*
* @return array
*/
public function get_sended_status_stats ($id_user, $start_date, $end_date, ?int $id_phone = null)
{
$params = [
'start_date' => $start_date->format('y-m-d H:i:s'),
'end_date' => $end_date->format('y-m-d H:i:s'),
'id_user' => $id_user,
];
$query = "
SELECT DATE_FORMAT(at, '%Y-%m-%d') as at_ymd, id_phone, status, COUNT(id) as nb
FROM sended
WHERE id_user = :id_user
AND id_phone IS NOT NULL
AND at >= :start_date
AND at <= :end_date
";
if ($id_phone)
{
$params['id_phone'] = $id_phone;
$query .= "
AND id_phone = :id_phone
";
}
$query .= "
GROUP BY at_ymd, status, id_phone
ORDER BY at_ymd, id_phone, status
";
return $this->_run_query($query, $params);
}
/**
* Get list of invalid phone number we've sent message to
*
* @param int $id_user : user id
* @param int $volume : Minimum number of sms sent to the number
* @param float $percent_failed : Minimum ratio of failed message
* @param float $percent_unknown : Minimum ratio of unknown message
* @param int $limit : Limit of results
* @param int $page : Page of results (offset = page * limit)
*
*/
public function get_invalid_numbers (int $id_user, int $volume, float $percent_failed, float $percent_unknown, int $limit, int $page)
{
$query = "
SELECT
destination,
COUNT(*) AS total_sms_sent,
ROUND(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) / COUNT(*), 2) AS failed_percentage,
ROUND(SUM(CASE WHEN status = 'unknown' THEN 1 ELSE 0 END) / COUNT(*), 2) AS unknown_percentage
FROM
sended
GROUP BY
destination
HAVING
total_sms_sent >= :volume
AND failed_percentage >= :percent_failed
AND unknown_percentage >= :percent_unknown
LIMIT " . intval($page * $limit) . "," . intval($limit) . "
";
$params = [
'volume' => $volume,
'percent_failed' => $percent_failed,
'percent_unknown' => $percent_unknown
];
return $this->_run_query($query, $params);
}
/**
* Return table name.
*/

View file

@ -27,6 +27,18 @@ namespace models;
return $this->_update($this->get_table_name(), ['value' => $value], ['id_user' => $id_user, 'name' => $name]);
}
/**
* Get a user setting by his name for a user.
*
* @param int $id_user : user id
*
* @return array
*/
public function get_by_name_for_user(int $id_user, string $name)
{
return $this->_select_one($this->get_table_name(), ['name' => $name, 'id_user' => $id_user]);
}
/**
* Return table name.
*/

View file

@ -13,6 +13,8 @@ namespace models;
class SmsStop extends StandardModel
{
const SMS_STOP_TAG = 'SMS_STOP';
/**
* Return a smsstop by his number and user.
*

View file

@ -70,12 +70,22 @@ namespace models;
* @param int $id_user : user id
* @param int $limit : Number of entry to return
* @param int $offset : Number of entry to ignore
* @param ?int $after_id : If provided use where id > $after_id instead of offset
* @param ?int $before_id : If provided use where id < $before_id instead of offset
*
* @return array
*/
public function list_for_user(int $id_user, $limit, $offset)
public function list_for_user(int $id_user, $limit, $offset, ?int $after_id = null, ?int $before_id = null)
{
return $this->_select($this->get_table_name(), ['id_user' => $id_user], null, false, $limit, $offset);
if ($after_id !== null) {
return $this->_select($this->get_table_name(), ['id_user' => $id_user, '>id' => $after_id], 'id', false, $limit);
}
if ($before_id !== null) {
return array_reverse($this->_select($this->get_table_name(), ['id_user' => $id_user, '<id' => $before_id], 'id', true, $limit));
}
return $this->_select($this->get_table_name(), ['id_user' => $id_user], 'id', false, $limit, $offset);
}
/**

115
models/SystemVQueue.php Normal file
View file

@ -0,0 +1,115 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace models;
/**
*
*/
class SystemVQueue implements Queue
{
private $id;
private $queue;
/**
* A queue using System V message queues to store and exchange messages
* routing is based on queue id and message type
*
* ** Attention : Instead of string, all ids and tags must be numbers, its the system v queues works, no reliable way arround it**
* @param int $id : A unique identifier for the queue, *this must be generated with ftok*
*/
public function __construct($id)
{
$this->id = (int) $id;
}
/**
* Function to close the system v queue on destruction
*/
public function close()
{
if ($this->queue)
{
msg_remove_queue($this->queue);
}
}
/**
* Function to get the message queue and ensure it is open, we should always call it during push/read just to
* make sure another process didn't close the queue
*/
private function get_queue()
{
$this->queue = msg_get_queue($this->id);
if (!$this->queue)
{
throw new \Exception('Impossible to get a System V message queue for id ' . $this->id);
}
}
/**
* Add a message to the queue
*
* @param string $message : The message to add to the queue
* @param ?string $tag : A tag to associate to the message for routing purposes.
* Though this is a string, we MUST pass a valid number, its the way System V queue works
*/
public function push($message, ?string $tag = '0')
{
$tag = (int) $tag;
$this->get_queue();
$error_code = null;
$success = msg_send($this->queue, $tag, $message, true, true, $error_code);
if (!$success)
{
throw new \Exception('Impossible to send the message on system V queue, error code : ' . $error_code);
}
return true;
}
/**
* Read the older message in the queue
*
* @param ?string $tag : A tag to associate to the message for routing purposes
* Though this is a string, we MUST pass a valid number, its the way System V queue works
*
* @return mixed $message : The oldest message or null if no message found, can be anything
*/
public function read(?string $tag = '0')
{
$tag = (int) $tag;
$msgtype = null;
$maxsize = 409600;
$message = null;
// Message type is forged from a prefix concat with the phone ID
$error_code = null;
$this->get_queue();
$success = msg_receive($this->queue, $tag, $msgtype, $maxsize, $message, true, MSG_IPC_NOWAIT, $error_code); //MSG_IPC_NOWAIT == dont wait if no message found
if (!$success && MSG_ENOMSG !== $error_code)
{
throw new \Exception('Impossible to read messages on system V queue, error code : ' . $error_code);
}
if (!$message)
{
return null;
}
return $message;
}
}

View file

@ -31,6 +31,16 @@ namespace models;
return $this->_select_one('user', ['id' => $id]);
}
/**
* Return all active users.
*
* @return array
*/
public function get_all_active()
{
return $this->_select('user', ['status' => self::STATUS_ACTIVE]);
}
/**
* Find user by ids.
*

View file

@ -19,6 +19,7 @@ namespace models;
const TYPE_INBOUND_CALL = 'inbound_call';
const TYPE_QUOTA_LEVEL_ALERT = 'quota_level';
const TYPE_QUOTA_REACHED = 'quota_reached';
const TYPE_PHONE_RELIABILITY = 'phone_reliability';
/**
* Find all webhooks for a user and for a type of webhook.

View file

@ -11,6 +11,8 @@
'Dashboard' => [
'show' => '/dashboard/',
'stats_sended' => '/dashboard/stats/sended.json/',
'stats_received' => '/dashboard/stats/received.json/',
],
'Account' => [
@ -165,6 +167,7 @@
'edit' => '/phone/edit/',
'update' => '/phone/update/{csrf}/',
'update_status' => '/phone/update_status/{csrf}/',
'change_status' => '/phone/change_status/{new_status}/{csrf}/',
'json_list' => '/phones.json/',
],
@ -202,13 +205,27 @@
'inbound_call' => '/callback/inbound_call/{id_phone}/',
'end_call' => '/callback/end_call/{id_phone}/',
],
'Stat' => [
'sms_status' => '/stats/sms-status/',
],
'Api' => [
'get_entries' => [
'/api/list/{entry_type}/',
'/api/list/{entry_type}/{page}/',
'/api/list/{entry_type}/after/{after_id}/',
'/api/list/{entry_type}/before/{before_id}/',
],
'get_usage' => '/api/usage/',
'get_sms_status_stats' => '/api/stats/sms-status/',
'get_invalid_numbers' => [
'/api/invalid_number/',
'/api/invalid_number/{page}/',
],
'get_send_sms' => [
'/api/send-sms/',
],
'post_scheduled' => [
'/api/scheduled/',
],
@ -224,6 +241,9 @@
'post_update_phone_status' => [
'/api/phone/{id}/status/',
],
'post_change_phone_status' => [
'/api/phone/{id}/status/force/',
],
'delete_phone' => [
'/api/phone/{id}/',
],

View file

@ -1,147 +1,162 @@
<?php
//Template dashboard
$this->render('incs/head')
//Template dashboard
$this->render('incs/head')
?>
<div id="wrapper">
<?php
$this->render('incs/nav', ['page' => 'dashboard'])
$this->render('incs/nav', ['page' => 'dashboard'])
?>
<div id="page-wrapper">
<div class="container-fluid">
<!-- Page Heading -->
<div class="row">
<div class="col-lg-12">
<h1 class="page-header">
Dashboard <small>Statistiques d'utilisation</small>
</h1>
<ol class="breadcrumb">
<li class="active">
<i class="fa fa-dashboard"></i> Dashboard
</li>
</ol>
</div>
</div>
<!-- /.row -->
<div id="page-wrapper">
<div class="container-fluid">
<!-- Page Heading -->
<div class="row">
<div class="col-lg-12">
<h1 class="page-header">
Dashboard <small>Statistiques d'utilisation</small>
</h1>
<ol class="breadcrumb">
<li class="active">
<i class="fa fa-dashboard"></i> Dashboard
</li>
</ol>
</div>
</div>
<!-- /.row -->
<div class="row">
<div class="col-lg-3 col-md-6">
<div class="panel panel-primary">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-user fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge"><?php echo $nb_contacts; ?></div>
<div>Contacts</div>
</div>
</div>
</div>
<a href="<?php echo \descartes\Router::url('Contact', 'list') ?>">
<div class="panel-footer">
<span class="pull-left">Voir vos contacts</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="panel panel-green">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-group fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge"><?php echo $nb_groups; ?></div>
<div>Groupes</div>
</div>
</div>
</div>
<a href="<?php echo \descartes\Router::url('Group', 'list') ?>">
<div class="panel-footer">
<span class="pull-left">Voir les groupes</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="panel panel-yellow">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-calendar fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge"><?php echo $nb_scheduleds; ?></div>
<div>SMS programmés</div>
</div>
</div>
</div>
<a href="<?php echo \descartes\Router::url('Scheduled', 'list') ?>">
<div class="panel-footer">
<span class="pull-left">Voir les SMS programmés</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="panel panel-red">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-eye-slash fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge"><?php echo $nb_unreads; ?></div>
<div>SMS non lus</div>
</div>
</div>
</div>
<a href="<?php echo \descartes\Router::url('Received', 'list_unread') ?>">
<div class="panel-footer">
<span class="pull-left">Voir les SMS non lus</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
</div>
<!-- /.row -->
<div class="row">
<div class="col-lg-3 col-md-6">
<div class="panel panel-primary">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-user fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge"><?php echo $nb_contacts; ?></div>
<div>Contacts</div>
</div>
</div>
</div>
<a href="<?php echo \descartes\Router::url('Contact', 'list') ?>">
<div class="panel-footer">
<span class="pull-left">Voir vos contacts</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="panel panel-green">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-group fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge"><?php echo $nb_groups; ?></div>
<div>Groupes</div>
</div>
</div>
</div>
<a href="<?php echo \descartes\Router::url('Group', 'list') ?>">
<div class="panel-footer">
<span class="pull-left">Voir les groupes</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="panel panel-yellow">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-calendar fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge"><?php echo $nb_scheduleds; ?></div>
<div>SMS programmés</div>
</div>
</div>
</div>
<a href="<?php echo \descartes\Router::url('Scheduled', 'list') ?>">
<div class="panel-footer">
<span class="pull-left">Voir les SMS programmés</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="panel panel-red">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-eye-slash fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge"><?php echo $nb_unreads; ?></div>
<div>SMS non lus</div>
</div>
</div>
</div>
<a href="<?php echo \descartes\Router::url('Received', 'list_unread') ?>">
<div class="panel-footer">
<span class="pull-left">Voir les SMS non lus</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
</div>
<!-- /.row -->
<div class="row">
<div class="col-lg-12">
<div class="panel panel-default dashboard-panel-chart">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-area-chart fa-fw"></i> Activité de la semaine : </h3>
<span style="color: #5CB85C;">SMS envoyés (moyenne = <?php echo $avg_sendeds; ?> par jour).</span><br/>
<span style="color: #EDAB4D">SMS reçus (moyenne = <?php echo $avg_receiveds; ?> par jour).</span>
<div class="row">
<div class="col-lg-12">
<div class="panel panel-default dashboard-panel-chart">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-area-chart fa-fw"></i> SMS envoyés depuis le <?= $stats_start_date_formated; ?> : </h3>
<span style="color: #5CB85C;">SMS envoyés (moyenne = <span id="avg_sendeds">0</span> par jour).</span><br/>
<?php if ($quota_unused) { ?>
<br/>
<span style="color: #d9534f">Crédits restants : <?= $quota_unused; ?>.</span>
<?php } ?>
</div>
<div class="panel-body">
<div id="morris-area-chart"></div>
</div>
</div>
</div>
</div>
<!-- /.row -->
</div>
<div class="panel-body">
<div id="morris-bar-chart-sended-loader" class="text-center"><div class="loader"></div></div>
<div id="morris-bar-chart-sended"></div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-upload fa-fw"></i> SMS Envoyés</h3>
</div>
<div class="col-lg-12">
<div class="panel panel-default dashboard-panel-chart">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-area-chart fa-fw"></i> SMS reçus depuis le <?= $stats_start_date_formated; ?> : </h3>
<span style="color: #EDAB4D">SMS reçus (moyenne = <span id="avg_receiveds">0</span> par jour).</span>
</div>
<div class="panel-body">
<div id="morris-bar-chart-received-loader" class="text-center"><div class="loader"></div></div>
<div id="morris-bar-chart-received"></div>
</div>
</div>
</div>
</div>
<!-- /.row -->
<div class="row">
<div class="col-lg-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-upload fa-fw"></i> SMS Envoyés</h3>
</div>
<div class="panel-body">
<?php if (!$sendeds) { ?>
Vous n'avez envoyé aucun SMS pour l'instant.
@ -168,14 +183,14 @@
<a href="<?php echo \descartes\Router::url('Sended', 'list'); ?>">Voir tous les SMS envoyés <i class="fa fa-arrow-circle-right"></i></a>
</div>
<?php } ?>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-download fa-fw"></i> SMS Reçus</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-download fa-fw"></i> SMS Reçus</h3>
</div>
<div class="panel-body">
<?php if (!$receiveds) { ?>
Vous n'avez reçu aucun SMS pour l'instant.
@ -204,14 +219,14 @@
<a href="<?php echo \descartes\Router::url('Received', 'list'); ?>">Voir tous les SMS reçus <i class="fa fa-arrow-circle-right"></i></a>
</div>
<?php } ?>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-clock-o fa-fw"></i> Évènements survenus</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-clock-o fa-fw"></i> Évènements survenus</h3>
</div>
<div class="panel-body">
<?php if (!$events) { ?>
Aucun évènement n'est encore survenu.
@ -228,41 +243,98 @@
<a href="<?php echo \descartes\Router::url('Event', 'list'); ?>">Voirs tous les évènements survenus <i class="fa fa-arrow-circle-right"></i></a>
</div>
<?php } ?>
</div>
</div>
</div>
</div>
<!-- /.row -->
</div>
</div>
</div>
</div>
<!-- /.row -->
</div>
<!-- /.container-fluid -->
</div>
<!-- /.container-fluid -->
</div>
<!-- /#page-wrapper -->
</div>
<!-- /#page-wrapper -->
</div>
<script>
jQuery(document).ready(function()
{
Morris.Area({
element: 'morris-area-chart',
behaveLikeLine: true,
fillOpacity: 0.4,
data: <?php echo $data_area_chart;?>,
async function drawChartSended() {
let url = <?= json_encode(\descartes\Router::url('Dashboard', 'stats_sended'))?>;
const response = await fetch(url);
const data = await response.json();
document.getElementById('avg_sendeds').textContent = data.avg_sendeds;
Morris.Bar({
element: 'morris-bar-chart-sended',
fillOpacity: 0.4,
data: data.data_bar_chart_sended,
xkey: 'period',
parseTime: false,
ykeys: ['sendeds', 'receiveds'],
labels: ['SMS envoyés', 'SMS reçus'],
lineColors: ['#5CB85C', '#EDAB4D'],
goals: [<?php echo $avg_sendeds; ?>, <?php echo $avg_receiveds; ?>],
goalLineColors: ['#5CB85C', '#EDAB4D'],
goalStrokeWidth: 2,
pointSize: 4,
hideHover: 'auto',
resize: true
});
});
ykeys: ['sendeds_failed', 'sendeds_unknown', 'sendeds_delivered'],
labels: ['SMS échoués', 'SMS inconnus', 'SMS délivrés'],
barColors: ['#D9534F', '#337AB7', '#5CB85C'],
goals: [data.avg_sendeds],
goalLineColors: ['#5CB85C'],
goalStrokeWidth: 2,
pointSize: 4,
hideHover: 'auto',
resize: true,
stacked: true,
hoverCallback: function (index, options, content, row) {
ret = '';
for (i = 0; i < options.ykeys.length; i++)
{
ret += options.labels[i];
ret += ' : ';
ret += row[options.ykeys[i]];
ret += ' (';
ret += (row[options.ykeys[i]] / (row.sendeds_total ? row.sendeds_total : 1) * 100).toFixed(2);
ret += '%)';
ret += "<br/>";
}
return ret;
}
});
document.getElementById('morris-bar-chart-sended-loader').classList.add('hidden');
}
async function drawChartReceived() {
let url = <?= json_encode(\descartes\Router::url('Dashboard', 'stats_received'))?>;
const response = await fetch(url);
const data = await response.json();
console.log(data);
document.getElementById('avg_receiveds').textContent = data.avg_receiveds;
Morris.Bar({
element: 'morris-bar-chart-received',
fillOpacity: 0.4,
data: data.data_bar_chart_received,
xkey: 'period',
parseTime: false,
ykeys: ['receiveds'],
labels: ['SMS reçus'],
barColors: ['#EDAB4D'],
goals: [data.avg_receiveds],
goalLineColors: ['#EDAB4D'],
goalStrokeWidth: 2,
pointSize: 4,
hideHover: 'auto',
resize: true,
});
document.getElementById('morris-bar-chart-received-loader').classList.add('hidden');
}
jQuery(document).ready(function()
{
drawChartSended();
drawChartReceived();
});
</script>
<!-- /#wrapper -->
<?php
$this->render('incs/footer');
$this->render('incs/footer');

View file

@ -64,7 +64,7 @@ jQuery(document).ready(function ()
{
jQuery('.datatable').DataTable({
"pageLength": 25,
"lengthMenu": [[25, 50, 100, 1000, 10000, -1], [25, 50, 100, 1000, 10000, "All"]],
"lengthMenu": [[25, 50, 100, 1000], [25, 50, 100, 1000]],
"language": {
"url": HTTP_PWD + "/assets/js/datatables/french.json",
},
@ -73,7 +73,6 @@ jQuery(document).ready(function ()
'targets': 'checkcolumn',
'orderable': false,
}],
"ajax": {
'url': '<?php echo \descartes\Router::url('Discussion', 'list_json'); ?>',
'dataSrc': 'data',

View file

@ -0,0 +1,10 @@
Le téléphone <?php $this->s($phone['name']); ?> semble rencontrer un taux de SMS échoués anormalement élevé.
Période prise en compte : <?php $this->s($period); ?> dernières minutes
Total de SMS : <?php $this->s($total); ?>
Nombre d'échecs : <?php $this->s($unreliable); ?>
Taux d'échecs : <?php $this->s($rate); ?>%
--------------------------------------------------------------------------------------------
Pour plus d'informations sur le système RaspiSMS, rendez-vous sur le site https://raspisms.fr

View file

@ -0,0 +1,10 @@
Le téléphone <?php $this->s($phone['name']); ?> semble rencontrer un taux de SMS incconnus anormalement élevé.
Période prise en compte : <?php $this->s($period); ?> dernières minutes
Total de SMS : <?php $this->s($total); ?>
Nombre d'inconnus : <?php $this->s($unreliable); ?>
Taux d'inconnus : <?php $this->s($rate); ?>%
--------------------------------------------------------------------------------------------
Pour plus d'informations sur le système RaspiSMS, rendez-vous sur le site https://raspisms.fr

View file

@ -42,6 +42,9 @@
<script src="<?php echo HTTP_PWD_JS; ?>/datatables/datatables.min.js"></script>
<!-- Qrcode lib -->
<script src="<?php echo HTTP_PWD_JS; ?>/qrcode.min.js"></script>
<!-- Chartjs -->
<script src="<?php echo HTTP_PWD_JS; ?>/chart.js"></script>
<!-- Custom JS -->
<script src="<?php echo HTTP_PWD_JS; ?>/custom.js"></script>

View file

@ -122,6 +122,11 @@
</ul>
</li>
<?php } ?>
<?php if (!in_array('stats', json_decode($_SESSION['user']['settings']['hide_menus'], true) ?? [])) { ?>
<li <?php echo $page == 'stats' ? 'class="active"' : ''; ?>>
<a href="<?php echo \descartes\Router::url('Stat', 'sms_status'); ?>"><i class="fa fa-fw fa-area-chart"></i> Statistiques</a>
</li>
<?php } ?>
<?php if (!in_array('settings', json_decode($_SESSION['user']['settings']['hide_menus'], true) ?? [])) { ?>
<li <?php echo $page == 'settings' ? 'class="active"' : ''; ?>>
<a href="<?php echo \descartes\Router::url('Setting', 'show'); ?>"><i class="fa fa-fw fa-cogs"></i> Réglages</a>

View file

@ -161,6 +161,16 @@
'</div>' +
'</div>';
}
else if (field.type == 'textarea')
{
html += '<div class="form-group">' +
'<label>' + field.title + '</label>' +
'<p class="italic small help">' + field.description + '</p>' +
'<div class="form-group">' +
'<textarea name="adapter_data[' + field.name + ']" class="form-control" ' + (field.required ? 'required' : '') + ' >' + (field.default_value ? field.default_value : '') + '</textarea>' +
'</div>' +
'</div>';
}
else
{
html += '<div class="form-group">' +

View file

@ -225,6 +225,16 @@
'</div>' +
'</div>';
}
else if (field.type == 'textarea')
{
html += '<div class="form-group">' +
'<label>' + field.title + '</label>' +
'<p class="italic small help">' + field.description + '</p>' +
'<div class="form-group">' +
'<textarea name="adapter_data[' + field.name + ']" class="form-control" ' + (field.required ? 'required' : '') + ' >' + (value ? value : '') + '</textarea>' +
'</div>' +
'</div>';
}
else
{
html += '' +

View file

@ -62,9 +62,11 @@
</div>
<div class="text-right col-xs-6 no-padding">
<strong>Action pour la séléction :</strong>
<button class="btn btn-default" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'update_status', ['csrf' => $_SESSION['csrf']]); ?>"><span class="fa fa-refresh"></span> Rafraichir le status</button>
<button class="btn btn-default" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'edit'); ?>"><span class="fa fa-edit"></span> Modifier</button>
<button class="btn btn-default btn-confirm" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'delete', ['csrf' => $_SESSION['csrf']]); ?>"><span class="fa fa-trash-o"></span> Supprimer</button>
<button class="btn btn-default mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'update_status', ['csrf' => $_SESSION['csrf']]); ?>"><span class="fa fa-refresh"></span> Rafraichir le status</button>
<button class="btn btn-default mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'change_status', ['csrf' => $_SESSION['csrf'], 'new_status' => 'available']); ?>"><span class="fa fa-toggle-on"></span> Activer</button>
<button class="btn btn-default mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'change_status', ['csrf' => $_SESSION['csrf'], 'new_status' => 'disabled']); ?>"><span class="fa fa-toggle-off"></span> Désactiver</button>
<button class="btn btn-default mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'edit'); ?>"><span class="fa fa-edit"></span> Modifier</button>
<button class="btn btn-default btn-confirm mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'delete', ['csrf' => $_SESSION['csrf']]); ?>"><span class="fa fa-trash-o"></span> Supprimer</button>
</div>
</div>
</form>
@ -105,6 +107,10 @@ jQuery(document).ready(function ()
html += ' - <span class="text-success">Disponible</span>'
break;
case 'disabled':
html += ' - <span class="text-warning">Désactivé</span>'
break;
case 'unavailable':
html += ' - <span class="text-danger">Indisponible</span>'
break;
@ -112,6 +118,10 @@ jQuery(document).ready(function ()
case 'no_credit':
html += ' - <span class="text-warning">Plus de crédit</span>'
break;
case 'limit_reached':
html += ' - <span class="text-warning">Limite RaspiSMS atteinte</span>'
break;
}
return html

View file

@ -14,6 +14,7 @@
<div class="col-lg-12">
<h1 class="page-header">
Dashboard <small>SMS envoyés</small>
<a class="btn btn-warning float-right" id="btn-invalid-numbers" href="#"><span class="fa fa-eraser"></span> Télécharger les numéros invalides</a>
</h1>
<ol class="breadcrumb">
<li>
@ -65,9 +66,122 @@
</div>
</div>
</div>
<div class="modal fade" tabindex="-1" id="invalid-numbers-modal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form id="invalid-numbers-form" action="<?php $this->s(\descartes\Router::url('Api', 'get_invalid_numbers')); ?>" method="GET">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">Télécharger les numéros invalides</h4>
</div>
<div class="modal-body">
<p class="help">Vous pouvez téléchager une liste de destinataires qui affichent un taux d'erreur anormal selon les critères de votre choix (liste limitée à 25 000 numéros).</p>
<div class="form-group">
<label>Volume minimum de SMS envoyés au numéros</label>
<div class="form-group input-group">
<span class="input-group-addon"><span class="fa fa-arrow-circle-up"></span></span>
<input name="volume" class="form-control" type="number" min="1" step="1" placeholder="" autofocus required>
</div>
</div>
<div class="form-group">
<label>Pourcentage d'échecs minimum</label>
<div class="form-group input-group">
<span class="input-group-addon"><span class="fa fa-percent"></span></span>
<input name="percent_failed" class="form-control" type="number" min="0" step="1" placeholder="" autofocus required>
</div>
</div>
<div class="form-group">
<label>Pourcentage d'inconnus minimum</label>
<div class="form-group input-group">
<span class="input-group-addon"><span class="fa fa-percent"></span></span>
<input name="percent_unknown" class="form-control" type="number" min="0" step="1" placeholder="" autofocus required>
</div>
</div>
<div id="invalid-numbers-loader" class="text-center hidden"><div class="loader"></div></div>
</div>
<div class="modal-footer">
<a type="button" class="btn btn-danger" data-dismiss="modal">Annuler</a>
<input type="submit" class="btn btn-success" value="Valider" />
</div>
</form>
</div>
</div>
</div>
<script>
jQuery(document).ready(function ()
{
jQuery('body').on('click', '#btn-invalid-numbers', function ()
{
jQuery('#invalid-numbers-modal').modal({'keyboard': true});
});
jQuery('body').on('submit', '#invalid-numbers-form', function (e)
{
e.preventDefault();
jQuery('#invalid-numbers-loader').removeClass('hidden');
const form = this;
const formData = jQuery(form).serialize();
let invalidNumbers = []; // Array to store cumulative results
// Function to fetch data and handle pagination
const fetchData = (url, limit = -1, params = null) => {
if (params) {
url += '?' + params;
}
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(jsonResponse => {
invalidNumbers = invalidNumbers.concat(jsonResponse.response);
// Check if there is a "next" URL to fetch more data
if (jsonResponse.next && limit != 0) {
fetchData(jsonResponse.next, limit - 1); // Recursive call for next page
} else {
exportToCSV(invalidNumbers);
jQuery('#invalid-numbers-loader').addClass('hidden');
}
})
.catch(error => {
console.error('There was a problem with the fetch operation:', error);
});
};
// Function to export data to CSV
const exportToCSV = (results) => {
// Define the CSV headers
let csvContent = "Destination,Total SMS Sent,Failed Percentage,Unknown Percentage\n";
// Append each row of data to the CSV content
results.forEach(item => {
csvContent += `${item.destination},${item.total_sms_sent},${item.failed_percentage},${item.unknown_percentage}\n`;
});
// Create a downloadable link for the CSV file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = 'invalid_numbers.csv';
// Trigger download
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink); // Clean up
};
// Initial call to fetch data
fetchData(form.action, 1000, formData);
});
jQuery('.datatable').DataTable({
"pageLength": 25,
"lengthMenu": [[25, 50, 100, 1000, 10000, Math.pow(10, 10)], [25, 50, 100, 1000, 10000, "All"]],

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,217 @@
<?php
//Template dashboard
$this->render('incs/head')
?>
<div id="wrapper">
<?php
$this->render('incs/nav', ['page' => 'stats'])
?>
<div id="page-wrapper">
<div class="container-fluid">
<!-- Page Heading -->
<div class="row">
<div class="col-lg-12">
<h1 class="page-header">
Statistiques <small>Statistiques avancées</small>
</h1>
<ol class="breadcrumb">
<li class="active">
<i class="fa fa-dashboard"></i> Statistiques avancées
</li>
</ol>
</div>
</div>
<!-- /.row -->
<div class="row">
<div class="col-lg-12">
<div class="panel panel-default dashboard-panel-chart">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-area-chart fa-fw"></i> Status des SMS envoyés par téléphone : </h3>
</div>
<div class="panel-body">
<form id="sms-status-form" class="form-inline text-right mb-3" action="" method="POST">
<div class="form-group">
<label for="id_phone">Téléphone : </label>
<div class="form-group">
<select id="id_phone" name="id_phone" class="form-control">
<option value="">Tous les téléphones</option>
<?php foreach ($phones as $phone) { ?>
<option value="<?php $this->s($phone['id']); ?>"><?php $this->s($phone['name']); ?></option>
<?php } ?>
</select>
</div>
</div>
<div class="form-group ml-4">
<label for="start">Période : </label>
<input id="start" name="start" class="form-control form-date auto-width" type="date" value="<?php $this->s($seven_days_ago->format('Y-m-d')) ?>">
- <input id="end" name="end" class="form-control form-date auto-width" type="date" value="<?php $this->s($now->format('Y-m-d')) ?>">
</div>
<input type="submit" class="btn btn-success ml-4" value="Valider" />
</form>
<canvas id="bar-chart-sms-status"></canvas>
<div id="bar-chart-sms-status-loader" class="text-center mb-5"><div class="loader"></div></div>
</div>
</div>
</div>
</div>
<!-- /.row -->
</div>
<!-- /.container-fluid -->
</div>
<!-- /#page-wrapper -->
</div>
<script>
smsStatusChart = null;
const phones = {};
for (const phone of <?= json_encode($phones); ?>) {
phones[phone.id] = phone;
};
async function drawChart(e = null) {
const startDate = new Date(document.getElementById('start').value);
const formatedStartDate = startDate.toISOString().split('T')[0]
const endDate = new Date(document.getElementById('end').value);
const formatedEndDate = endDate.toISOString().split('T')[0]
const id_phone = document.getElementById('id_phone').value;
let url = <?= json_encode(\descartes\Router::url('Api', 'get_sms_status_stats'))?>;
url += `?start=${formatedStartDate}&end=${formatedEndDate}`;
url += id_phone ? `&id_phone=${id_phone}` : '';
const response = await fetch(url);
const data = (await response.json()).response;
// Get all dates to avoid holes in data
const dates = [];
let currentDate = new Date(startDate);
while (currentDate <= endDate) {
const formated_date = (new Date(currentDate)).toISOString().split('T')[0]
dates.push(formated_date);
currentDate.setDate(currentDate.getDate() + 1);
}
const empty_dataset = Array(dates.length + 1).fill(0)
const colors = {'failed': '#d9534f', 'unknown': '#337ab7', 'delivered': '#5cb85c'};
let datasets = {};
for (const entry of data) {
if (!datasets[entry.id_phone]) {
datasets[entry.id_phone] = {
'failed': {
'data': [...empty_dataset],
'label': `Phone ${phones[entry.id_phone]['name']} - Failed`,
'backgroundColor': colors['failed'],
'stack': entry.id_phone,
},
'unknown': {
'data': [...empty_dataset],
'label': `Phone ${phones[entry.id_phone]['name']} - Unknown`,
'backgroundColor': colors['unknown'],
'stack': entry.id_phone,
},
'delivered': {
'data': [...empty_dataset],
'label': `Phone ${phones[entry.id_phone]['name']} - Delivered`,
'backgroundColor': colors['delivered'],
'stack': entry.id_phone,
},
};
}
const date_index = dates.indexOf(entry.at_ymd);
// This should never happen, but better be sure
if (date_index == -1) {
throw Error('Data for a date not in dates array');
}
datasets[entry.id_phone][entry.status]['data'][date_index] = entry.nb;
}
// Pass all from dict to array
const formated_datasets = [];
for (const key in datasets) {
formated_datasets.push(datasets[key]['failed']);
formated_datasets.push(datasets[key]['unknown']);
formated_datasets.push(datasets[key]['delivered']);
}
// Custom plugin to display "Pas de données sur cette période"
const noDataPlugin = {
id: 'noDataPlugin',
afterDraw: (chart) => {
const datasets = chart.data.datasets;
const hasData = datasets.some(dataset => dataset.data.some(value => value !== null && value !== undefined && value !== 0));
if (!hasData) {
const ctx = chart.ctx;
const { width, height } = chart;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '3em Helvetica';
ctx.fillText('Pas de données sur cette période', width / 2, height / 2);
ctx.restore();
}
}
};
// Create the chart
const ctx = document.getElementById('bar-chart-sms-status');
const config = {
type: 'bar',
data: {
labels: dates,
datasets: formated_datasets,
},
options: {
responsive: true,
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
beginAtZero: true
}
}
},
plugins: [noDataPlugin],
};
document.getElementById('bar-chart-sms-status-loader').classList.add('hidden');
// On first run create chart, after update
if (!smsStatusChart) {
smsStatusChart = new Chart(ctx, config);
} else {
for (const key in config) {
smsStatusChart[key] = config[key];
}
smsStatusChart.update();
}
}
jQuery(document).ready(function()
{
drawChart();
});
jQuery('#sms-status-form').on('submit', (e) => {
e.preventDefault();
drawChart();
return false;
});
</script>
<!-- /#wrapper -->
<?php
$this->render('incs/footer');

View file

@ -49,6 +49,7 @@
<option value="send_sms" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'send_sms' ? 'selected' : '' ?>>Envoi d'un SMS</option>
<option value="send_sms_status_change" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'send_sms_status_change' ? 'selected' : '' ?>>Mise à jour du statut d'un SMS envoyé</option>
<option value="inbound_call" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'inbound_call' ? 'selected' : '' ?>>Réception d'un appel téléphonique</option>
<option value="phone_reliability" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'phone_reliability' ? 'selected' : '' ?>>Détection d'un problème de fiabilité sur un téléphone</option>
</select>
</div>
<a class="btn btn-danger" href="<?php echo \descartes\Router::url('Webhook', 'list'); ?>">Annuler</a>

View file

@ -52,6 +52,7 @@
<option <?php echo $webhook['type'] == 'send_sms' ? 'selected="selected"' : '' ?> value="send_sms">Envoi d'un SMS</option>
<option <?php echo $webhook['type'] == 'send_sms_status_change' ? 'selected="selected"' : '' ?> value="send_sms_status_change">Mise à jour du statut d'un SMS envoyé</option>
<option <?php echo $webhook['type'] == 'inbound_call' ? 'selected="selected"' : '' ?> value="inbound_call">Réception d'un appel téléphonique</option>
<option <?php echo $webhook['type'] == 'phone_reliability' ? 'selected="selected"' : '' ?> value="phone_reliability">Détection d'un problème de fiabilité sur un téléphone</option>
</select>
</div>
<hr/>

View file

@ -96,6 +96,8 @@ jQuery(document).ready(function ()
return 'Réception de SMS';
case 'inbound_call':
return 'Réception d\'un appel téléphonique';
case 'phone_reliability':
return 'Détection d\'un problème de fiabilité sur un téléphone';
default:
return 'Inconnu';
}