Add support for sending SMS
This commit is contained in:
parent
8ab0b78e6e
commit
7abaebe496
5 changed files with 249 additions and 75 deletions
58
comms/email.go
Normal file
58
comms/email.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package comms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/config"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AttachmentRequest struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailRequest struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
CC []string `json:"cc,omitempty"`
|
||||||
|
BCC []string `json:"bcc,omitempty"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
HTML string `json:"html,omitempty"`
|
||||||
|
Attachments []AttachmentRequest `json:"attachments,omitempty"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
ReplyTo string `json:"replyTo,omitempty"`
|
||||||
|
InReplyTo string `json:"inReplyTo,omitempty"`
|
||||||
|
References []string `json:"references,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendEmail(email EmailRequest) error {
|
||||||
|
url := "https://api.forwardemail.net/v1/emails"
|
||||||
|
|
||||||
|
payload, err := json.Marshal(email)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to marshal email request: %w", err)
|
||||||
|
}
|
||||||
|
//payload := strings.NewReader("{\n \"from\": \"\",\n \"to\": \"\",\n \"cc\": \"\",\n \"bcc\": \"\",\n \"subject\": \"\",\n \"text\": \"\",\n \"html\": \"\",\n \"attachments\": [\n {}\n ],\n \"sender\": \"\",\n \"replyTo\": \"\",\n \"inReplyTo\": \"\",\n \"references\": \"\",\n \"attachDataUrls\": true,\n \"watchHtml\": \"\",\n \"amp\": \"\",\n \"icalEvent\": {},\n \"alternatives\": [\n {}\n ],\n \"encoding\": \"\",\n \"raw\": \"\",\n \"textEncoding\": \"quoted-printable\",\n \"priority\": \"high\",\n \"headers\": {\"ANY_ADDITIONAL_PROPERTY\": \"anything\"},\n \"messageId\": \"\",\n \"date\": \"\",\n \"list\": {},\n \"requireTLS\": true\n}")
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", url, bytes.NewReader(payload))
|
||||||
|
req.SetBasicAuth(config.ForwardEmailAPIToken, "")
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
|
||||||
|
res, _ := http.DefaultClient.Do(req)
|
||||||
|
|
||||||
|
defer res.Body.Close()
|
||||||
|
body, _ := io.ReadAll(res.Body)
|
||||||
|
|
||||||
|
log.Info().Str("status", res.Status).Str("request_body", string(payload)).Str("response_body", string(body)).Msg("Attempted to send email")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
58
comms/sms.go
Normal file
58
comms/sms.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package comms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/config"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var VOIP_MS_API = "https://voip.ms/api/v1/rest.php"
|
||||||
|
|
||||||
|
type SendSMSResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
SMS int `json:"sms"`
|
||||||
|
}
|
||||||
|
func SendSMS(to string, content string) error {
|
||||||
|
if len(content) > 160 {
|
||||||
|
return errors.New("Message content is more than 160 characters")
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Add("api_password", config.VoipMSPassword)
|
||||||
|
params.Add("api_username", config.VoipMSUsername)
|
||||||
|
params.Add("method", "sendSMS")
|
||||||
|
params.Add("did", config.VoipMSNumber)
|
||||||
|
params.Add("dst", to)
|
||||||
|
params.Add("message", content)
|
||||||
|
// Construct the URL with query parameters
|
||||||
|
full_url := VOIP_MS_API + "?" + params.Encode()
|
||||||
|
|
||||||
|
// Make the HTTP request
|
||||||
|
resp, err := http.Get(full_url)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("url", full_url).Msg("Failed to make request to Voip.MS")
|
||||||
|
return fmt.Errorf("Error making request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read the response body
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("url", full_url).Msg("Failed to read Voip.MS response body")
|
||||||
|
return fmt.Errorf("Failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
log.Info().Str("response", string(body)).Msg("Response from Voip.MS")
|
||||||
|
|
||||||
|
// Parse the JSON response
|
||||||
|
var response SendSMSResponse
|
||||||
|
err = json.Unmarshal(body, &response)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to unmarshal JSON response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,26 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Bind, ClientID, ClientSecret, Environment, FilesDirectoryPublic, FilesDirectoryUser, FieldseekerSchemaDirectory, MapboxToken, PGDSN, URLReport, URLSync, URLTegola string
|
var (
|
||||||
|
Bind string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
Environment string
|
||||||
|
FilesDirectoryPublic string
|
||||||
|
FilesDirectoryUser string
|
||||||
|
FieldseekerSchemaDirectory string
|
||||||
|
ForwardEmailAPIToken string
|
||||||
|
ForwardEmailReportPassword string
|
||||||
|
ForwardEmailReportUsername string
|
||||||
|
MapboxToken string
|
||||||
|
PGDSN string
|
||||||
|
URLReport string
|
||||||
|
URLSync string
|
||||||
|
URLTegola string
|
||||||
|
VoipMSPassword string
|
||||||
|
VoipMSNumber string
|
||||||
|
VoipMSUsername string
|
||||||
|
)
|
||||||
|
|
||||||
// Build the ArcGIS authorization URL with PKCE
|
// Build the ArcGIS authorization URL with PKCE
|
||||||
func BuildArcGISAuthURL(clientID string) string {
|
func BuildArcGISAuthURL(clientID string) string {
|
||||||
|
|
@ -43,6 +62,10 @@ func MakeURLSync(path string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Parse() error {
|
func Parse() error {
|
||||||
|
Bind = os.Getenv("BIND")
|
||||||
|
if Bind == "" {
|
||||||
|
Bind = ":9001"
|
||||||
|
}
|
||||||
ClientID = os.Getenv("ARCGIS_CLIENT_ID")
|
ClientID = os.Getenv("ARCGIS_CLIENT_ID")
|
||||||
if ClientID == "" {
|
if ClientID == "" {
|
||||||
return fmt.Errorf("You must specify a non-empty ARCGIS_CLIENT_ID")
|
return fmt.Errorf("You must specify a non-empty ARCGIS_CLIENT_ID")
|
||||||
|
|
@ -51,22 +74,6 @@ func Parse() error {
|
||||||
if ClientSecret == "" {
|
if ClientSecret == "" {
|
||||||
return fmt.Errorf("You must specify a non-empty ARCGIS_CLIENT_SECRET")
|
return fmt.Errorf("You must specify a non-empty ARCGIS_CLIENT_SECRET")
|
||||||
}
|
}
|
||||||
URLReport = os.Getenv("URL_REPORT")
|
|
||||||
if URLReport == "" {
|
|
||||||
return fmt.Errorf("You must specify a non-empty URL_REPORT")
|
|
||||||
}
|
|
||||||
URLSync = os.Getenv("URL_SYNC")
|
|
||||||
if URLSync == "" {
|
|
||||||
return fmt.Errorf("You must specify a non-empty URL_SYNC")
|
|
||||||
}
|
|
||||||
URLTegola = os.Getenv("URL_TEGOLA")
|
|
||||||
if URLTegola == "" {
|
|
||||||
return fmt.Errorf("You must specify a non-empty URL_TEGOLA")
|
|
||||||
}
|
|
||||||
Bind = os.Getenv("BIND")
|
|
||||||
if Bind == "" {
|
|
||||||
Bind = ":9001"
|
|
||||||
}
|
|
||||||
Environment = os.Getenv("ENVIRONMENT")
|
Environment = os.Getenv("ENVIRONMENT")
|
||||||
if Environment == "" {
|
if Environment == "" {
|
||||||
return fmt.Errorf("You must specify a non-empty ENVIRONMENT")
|
return fmt.Errorf("You must specify a non-empty ENVIRONMENT")
|
||||||
|
|
@ -74,14 +81,6 @@ func Parse() error {
|
||||||
if !(Environment == "PRODUCTION" || Environment == "DEVELOPMENT") {
|
if !(Environment == "PRODUCTION" || Environment == "DEVELOPMENT") {
|
||||||
return fmt.Errorf("ENVIRONMENT should be either DEVELOPMENT or PRODUCTION")
|
return fmt.Errorf("ENVIRONMENT should be either DEVELOPMENT or PRODUCTION")
|
||||||
}
|
}
|
||||||
MapboxToken = os.Getenv("MAPBOX_TOKEN")
|
|
||||||
if MapboxToken == "" {
|
|
||||||
return fmt.Errorf("You must specify a non-empty MAPBOX_TOKEN")
|
|
||||||
}
|
|
||||||
PGDSN = os.Getenv("POSTGRES_DSN")
|
|
||||||
if PGDSN == "" {
|
|
||||||
return fmt.Errorf("You must specify a non-empty POSTGRES_DSN")
|
|
||||||
}
|
|
||||||
FieldseekerSchemaDirectory = os.Getenv("FIELDSEEKER_SCHEMA_DIRECTORY")
|
FieldseekerSchemaDirectory = os.Getenv("FIELDSEEKER_SCHEMA_DIRECTORY")
|
||||||
if FieldseekerSchemaDirectory == "" {
|
if FieldseekerSchemaDirectory == "" {
|
||||||
return fmt.Errorf("You must specify a non-empty FIELDSEEKER_SCHEMA_DIRECTORY")
|
return fmt.Errorf("You must specify a non-empty FIELDSEEKER_SCHEMA_DIRECTORY")
|
||||||
|
|
@ -94,6 +93,50 @@ func Parse() error {
|
||||||
if FilesDirectoryUser == "" {
|
if FilesDirectoryUser == "" {
|
||||||
return fmt.Errorf("You must specify a non-empty FILES_DIRECTORY_USER")
|
return fmt.Errorf("You must specify a non-empty FILES_DIRECTORY_USER")
|
||||||
}
|
}
|
||||||
|
ForwardEmailAPIToken = os.Getenv("FORWARDEMAIL_API_TOKEN")
|
||||||
|
if ForwardEmailAPIToken == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty FORWARDEMAIL_API_TOKEN")
|
||||||
|
}
|
||||||
|
ForwardEmailReportUsername = os.Getenv("FORWARDEMAIL_REPORT_USERNAME")
|
||||||
|
if ForwardEmailReportUsername == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty FORWARDEMAIL_REPORT_USERNAME")
|
||||||
|
}
|
||||||
|
ForwardEmailReportPassword = os.Getenv("FORWARDEMAIL_REPORT_PASSWORD")
|
||||||
|
if ForwardEmailReportPassword == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty FORWARDEMAIL_REPORT_PASSWORD")
|
||||||
|
}
|
||||||
|
MapboxToken = os.Getenv("MAPBOX_TOKEN")
|
||||||
|
if MapboxToken == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty MAPBOX_TOKEN")
|
||||||
|
}
|
||||||
|
PGDSN = os.Getenv("POSTGRES_DSN")
|
||||||
|
if PGDSN == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty POSTGRES_DSN")
|
||||||
|
}
|
||||||
|
URLReport = os.Getenv("URL_REPORT")
|
||||||
|
if URLReport == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty URL_REPORT")
|
||||||
|
}
|
||||||
|
URLSync = os.Getenv("URL_SYNC")
|
||||||
|
if URLSync == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty URL_SYNC")
|
||||||
|
}
|
||||||
|
URLTegola = os.Getenv("URL_TEGOLA")
|
||||||
|
if URLTegola == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty URL_TEGOLA")
|
||||||
|
}
|
||||||
|
VoipMSNumber = os.Getenv("VOIPMS_NUMBER")
|
||||||
|
if VoipMSNumber == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty VOIPMS_NUMBER")
|
||||||
|
}
|
||||||
|
VoipMSPassword = os.Getenv("VOIPMS_PASSWORD")
|
||||||
|
if VoipMSPassword == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty VOIPMS_PASSWORD")
|
||||||
|
}
|
||||||
|
VoipMSUsername = os.Getenv("VOIPMS_USERNAME")
|
||||||
|
if VoipMSUsername == "" {
|
||||||
|
return fmt.Errorf("You must specify a non-empty VOIPMS_USERNAME")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
package publicreport
|
package publicreport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/db"
|
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
|
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/stephenafamo/bob/dialect/psql"
|
|
||||||
"github.com/stephenafamo/bob/dialect/psql/um"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextRegisterNotificationsComplete struct {
|
type ContextRegisterNotificationsComplete struct {
|
||||||
|
|
@ -29,52 +25,6 @@ func getRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRegisterNotificationsComplete(w http.ResponseWriter, r *http.Request) {
|
|
||||||
report := r.URL.Query().Get("report")
|
|
||||||
htmlpage.RenderOrError(
|
|
||||||
w,
|
|
||||||
RegisterNotificationsComplete,
|
|
||||||
ContextRegisterNotificationsComplete{
|
|
||||||
ReportID: report,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
func postRegisterNotifications(w http.ResponseWriter, r *http.Request) {
|
|
||||||
err := r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, "Failed to parse form", err, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
consent := r.PostFormValue("consent")
|
|
||||||
email := r.PostFormValue("email")
|
|
||||||
phone := r.PostFormValue("phone")
|
|
||||||
report_id := r.PostFormValue("report_id")
|
|
||||||
if consent != "on" {
|
|
||||||
respondError(w, "You must consent", nil, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := psql.Update(
|
|
||||||
um.Table("publicreport.quick"),
|
|
||||||
um.SetCol("reporter_email").ToArg(email),
|
|
||||||
um.SetCol("reporter_phone").ToArg(phone),
|
|
||||||
um.Where(psql.Quote("public_id").EQ(psql.Arg(report_id))),
|
|
||||||
).Exec(r.Context(), db.PGInstance.BobDB)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, "Failed to update report", err, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rowcount, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, "Failed to get rows affected", err, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if rowcount == 0 {
|
|
||||||
http.Redirect(w, r, fmt.Sprintf("/error?code=no-rows-affected&report=%s", report_id), http.StatusFound)
|
|
||||||
} else {
|
|
||||||
http.Redirect(w, r, fmt.Sprintf("/register-notifications-complete?report=%s", report_id), http.StatusFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respond with an error that is visible to the user
|
// Respond with an error that is visible to the user
|
||||||
func respondError(w http.ResponseWriter, m string, e error, s int) {
|
func respondError(w http.ResponseWriter, m string, e error, s int) {
|
||||||
log.Warn().Int("status", s).Err(e).Str("user message", m).Msg("Responding with an error")
|
log.Warn().Int("status", s).Err(e).Str("user message", m).Msg("Responding with an error")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/comms"
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/db"
|
"github.com/Gleipnir-Technology/nidus-sync/db"
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
|
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/db/models"
|
"github.com/Gleipnir-Technology/nidus-sync/db/models"
|
||||||
|
|
@ -46,6 +47,16 @@ func getQuickSubmitComplete(w http.ResponseWriter, r *http.Request) {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
func getRegisterNotificationsComplete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
report := r.URL.Query().Get("report")
|
||||||
|
htmlpage.RenderOrError(
|
||||||
|
w,
|
||||||
|
RegisterNotificationsComplete,
|
||||||
|
ContextRegisterNotificationsComplete{
|
||||||
|
ReportID: report,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
func postQuick(w http.ResponseWriter, r *http.Request) {
|
func postQuick(w http.ResponseWriter, r *http.Request) {
|
||||||
err := r.ParseMultipartForm(32 << 10) // 32 MB buffer
|
err := r.ParseMultipartForm(32 << 10) // 32 MB buffer
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -134,3 +145,57 @@ func postQuick(w http.ResponseWriter, r *http.Request) {
|
||||||
tx.Commit(ctx)
|
tx.Commit(ctx)
|
||||||
http.Redirect(w, r, fmt.Sprintf("/quick-submit-complete?report=%s", u), http.StatusFound)
|
http.Redirect(w, r, fmt.Sprintf("/quick-submit-complete?report=%s", u), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
func postRegisterNotifications(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, "Failed to parse form", err, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
consent := r.PostFormValue("consent")
|
||||||
|
email := r.PostFormValue("email")
|
||||||
|
phone := r.PostFormValue("phone")
|
||||||
|
report_id := r.PostFormValue("report_id")
|
||||||
|
if consent != "on" {
|
||||||
|
respondError(w, "You must consent", nil, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if email == "" && phone == "" {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/quick-submit-complete?report=%s", report_id), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := psql.Update(
|
||||||
|
um.Table("publicreport.quick"),
|
||||||
|
um.SetCol("reporter_email").ToArg(email),
|
||||||
|
um.SetCol("reporter_phone").ToArg(phone),
|
||||||
|
um.Where(psql.Quote("public_id").EQ(psql.Arg(report_id))),
|
||||||
|
).Exec(r.Context(), db.PGInstance.BobDB)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, "Failed to update report", err, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rowcount, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, "Failed to get rows affected", err, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
comms.SendEmail(comms.EmailRequest{
|
||||||
|
From: "website@mosquitoes.online",
|
||||||
|
To: email,
|
||||||
|
Subject: "test email",
|
||||||
|
Text: "This is just testing that I can send email",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if phone != "" {
|
||||||
|
err := comms.SendSMS(phone, "testing 1 2 3")
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to send SMS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rowcount == 0 {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/error?code=no-rows-affected&report=%s", report_id), http.StatusFound)
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/register-notifications-complete?report=%s", report_id), http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue