Skip to content

Commit 97d881c

Browse files
authored
Merge pull request #18 from GoobyFRS/v050-beta
Spam, Brute Force, Logging, and more!
2 parents 08e8a1c + bac1117 commit 97d881c

14 files changed

+150
-69
lines changed

CHANGELOG

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# CHANGELOG
22

3+
**Mar.06.2025-1:** Implement standard logging and Cloudflare Turnstile spam prevention.
4+
35
**Feb.28.2025-1:** Implementing a method of posting notes to a ticket in Ticket Commander.
46

57
**Feb.27.2025-1:** Implement "Logged In As" and other navigation links.

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
Simple, Lightweight, Databaseless Service Desk for Home Labbers, Families, and One Man MSPs.
44

5-
**Current Version:** v0.4.2
5+
**Current Version:** v0.5.0
66

7-
[GoobyDesk Repo Wiki](https://github.com/GoobyFRS/GoobyDesk/wiki) & [Production Deployment Guide](https://github.com/GoobyFRS/GoobyDesk/wiki/Production-Deployment-Guide) you can find information on my code standards, my variables, and other data I think is important for an open source project to be successful after the creator moves on.
7+
[GoobyDesk Repo Wiki](https://github.com/GoobyFRS/GoobyDesk/wiki) & [Production Deployment Guide](https://github.com/GoobyFRS/GoobyDesk/wiki/Production-Deployment-Guide).
88

99
## What is GoobyDesk
1010

11-
GoobyDesk is a Python3, Flask-based web application.
11+
GoobyDesk is a Python3, Flask-based web application. Leverages Cloudflare Turnstile for Anti-Spam/Brute force protection.
1212

1313
- By default, the Flask app will run at ```http://127.0.0.1:5000``` during local development.
1414
- Production instances should be ran behind a Python3 WSGI server such as [Gunicorn](https://gunicorn.org/).
@@ -50,7 +50,6 @@ Closed Tickets are hidden from the Dashboard by default.
5050

5151
## Goals and Roadmap to Production v1.0
5252

53-
- Implement standardized ```/var/log/goobydesk``` logging. (Not Started)
5453
- Tweak Discord Webhook message content. (Not Started)
5554
- Implement rate-limiting (Test env was subject to abuse)
5655

app.py

Lines changed: 108 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
11
#!/usr/bin/env python3
2-
from flask import Flask, render_template, request, redirect, url_for, session, jsonify
2+
from flask import Flask, render_template, request, redirect, url_for, session, jsonify, flash
33
import json # My preffered method of "database" replacements.
44
import smtplib # Required protocol for sending emails by code.
55
import imaplib # Required protocol for receiving/logging into email provider.
66
import re # Regex support for reading emails and subject lines.
77
import email # Required to read the content of the emails.
88
import threading # Background process.
99
import time # Used for script sleeping.
10+
import logging
11+
import requests # CF Turnstiles.
1012
import os # Required to load DOTENV files.
1113
import fcntl # Unix file locking support.
12-
from dotenv import load_dotenv # Dependant on OS module
14+
from dotenv import load_dotenv # Dependant on OS module.
1315
from email.mime.text import MIMEText
1416
from email.mime.multipart import MIMEMultipart # Required for new-ticket-email.html
1517
from email.header import decode_header
16-
from datetime import datetime # Timestamps on tickets.
18+
from datetime import datetime # Timestamps.
1719
from local_webhook_handler import send_discord_notification # Webhook handler, local to this repo.
1820
from local_webhook_handler import send_TktUpdate_discord_notification # I need to find a better way to handle this import but I learned this new thing!
1921

2022
app = Flask(__name__)
21-
app.secret_key = "secretdemokey"
23+
app.secret_key = "thegardenisfullofcolorstosee"
2224

2325
# Load environment variables from .env in the local folder.
2426
load_dotenv(dotenv_path=".env")
@@ -30,6 +32,12 @@
3032
SMTP_SERVER = os.getenv("SMTP_SERVER") # Provider SMTP Server Address.
3133
SMTP_PORT = os.getenv("SMTP_PORT") # Provider SMTP Server Port. Default is TCP/587.
3234
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL")
35+
LOG_FILE = os.getenv("LOG_FILE")
36+
CF_TURNSTILE_SITE_KEY = os.getenv("CF_TURNSTILE_SITE_KEY")
37+
CF_TURNSTILE_SECRET_KEY = os.getenv("CF_TURNSTILE_SECRET_KEY")
38+
39+
# Standard Logging. basicConfig makes it reusable in other local py modules.
40+
logging.basicConfig(filename="LOG_FILE", level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
3341

3442
# Read/Loads the ticket file into memory. This is the original load_tickets function that works on Windows and Unix.
3543
#def load_tickets():
@@ -51,9 +59,11 @@ def load_tickets(retries=5, delay=0.2):
5159
fcntl.flock(file, fcntl.LOCK_UN) # Unlock the file.
5260
return tickets
5361
except (json.JSONDecodeError, FileNotFoundError) as e:
62+
logging.critical(f"Error loading tickets: {e}")
5463
print(f"ERROR - Error loading tickets: {e}")
5564
return []
5665
except BlockingIOError:
66+
logging.warning(f"File is locked, retrying... ({attempt+1}/{retries})")
5767
print(f"DEBUG - File is locked, retrying... ({attempt+1}/{retries})")
5868
time.sleep(delay) # Wait before retrying
5969
raise Exception("ERROR - Failed to load tickets after multiple attempts.")
@@ -97,7 +107,9 @@ def send_email(requestor_email, ticket_subject, ticket_message, html=True):
97107
server.login(EMAIL_ACCOUNT, EMAIL_PASSWORD)
98108
server.sendmail(EMAIL_ACCOUNT, requestor_email, msg.as_string())
99109
except Exception as e:
110+
logging.error(f"Email sending failed: {e}")
100111
print(f"ERROR - Email sending failed: {e}")
112+
logging.info(f"Confirmation Email sent to {requestor_email}")
101113
print(f"INFO - Confirmation Email sent to {requestor_email}")
102114

103115
# extract_email_body is attempting to scrape the content of the "valid" TKT email replies. It skips attachments. I do not currently need this feature.
@@ -116,18 +128,21 @@ def extract_email_body(msg):
116128
try:
117129
body = part.get_payload(decode=True).decode(errors="ignore").strip()
118130
except Exception as e:
131+
logging.error(f"Error decoding email part: {e}")
119132
print(f"ERROR - Error decoding email part: {e}")
120133
continue
121134
elif content_type == "text/html" and not body:
122135
try:
123136
body = part.get_payload(decode=True).decode(errors="ignore").strip()
124137
except Exception as e:
138+
logging.error(f"Error decoding HTML part: {e}")
125139
print(f"ERROR - Error decoding HTML part: {e}")
126140
continue
127141
else:
128142
try:
129143
body = msg.get_payload(decode=True).decode(errors="ignore").strip()
130144
except Exception as e:
145+
logging.error(f"Error decoding single-part email: {e}")
131146
print(f"ERROR - Error decoding single-part email: {e}")
132147

133148
return body
@@ -141,6 +156,7 @@ def fetch_email_replies():
141156

142157
mail_server_response_status, messages = mail.search(None, "UNSEEN")
143158
if mail_server_response_status != "OK":
159+
logging.debug("Reading Inbox via IMAP failed for an unknown reason.")
144160
print("DEBUG - Reading Inbox via IMAP failed for an unknown reason.")
145161
return
146162

@@ -151,6 +167,7 @@ def fetch_email_replies():
151167
email_id = email_id.decode() # Ensure it's a string
152168
mail_server_response_status, msg_data = mail.fetch(email_id, "(RFC822)")
153169
if mail_server_response_status != "OK" or not msg_data:
170+
logging.error(f"Unable to fetch email {email_id}")
154171
print(f"ERROR - Unable to fetch email {email_id}")
155172
continue
156173

@@ -163,7 +180,8 @@ def fetch_email_replies():
163180

164181
from_email = msg.get("From")
165182
match_ticket_reply = re.search(r"(?i)\bTKT-\d{4}-\d+\b", subject)
166-
ticket_id = match_ticket_reply.group(0) if match_ticket_reply else None
183+
ticket_id = match_ticket_reply.group(0) if match_ticket_reply else None
184+
logging.debug(f"Extracted ticket ID: {ticket_id} from subject: {subject}")
167185
print(f"DEBUG - Extracted ticket ID: {ticket_id} from subject: {subject}")
168186

169187
if not ticket_id:
@@ -174,68 +192,108 @@ def fetch_email_replies():
174192
for ticket in tickets:
175193
if ticket["ticket_number"] == ticket_id:
176194
ticket["ticket_notes"].append({"ticket_message": body})
177-
save_tickets(tickets)
195+
save_tickets(tickets)
196+
logging.debug(f"Updated ticket {ticket_id} with reply from {from_email}")
178197
print(f"DEBUG - Updated ticket {ticket_id} with reply from {from_email}")
179198
break
180199

181-
mail.logout()
200+
mail.logout()
201+
logging.info("Email fetch job completed successfully.")
182202
print("INFO - Email fetch job completed successfully.")
183203
except Exception as e:
204+
logging.error(f"Error fetching emails: {e}")
184205
print(f"ERROR - Error fetching emails: {e}")
185206

186-
# Background email monitoring
207+
# Background email monitoring. This is a running process using modules above.
187208
def background_email_monitor():
188209
while True:
189210
fetch_email_replies()
190211
time.sleep(300) # Wait for emails every 5 minutes.
191212

192213
threading.Thread(target=background_email_monitor, daemon=True).start()
193214

194-
# BELOW THIS LINE IS RESERVED FOR FLASK APP ROUTES
195-
# This is the "default" route for the home/index/landing page. This is where users submit a ticket.
196215
@app.route("/", methods=["GET", "POST"])
197216
def home():
198217
if request.method == "POST":
199-
requestor_name = request.form["requestor_name"]
200-
requestor_email = request.form["requestor_email"]
201-
ticket_subject = request.form["ticket_subject"]
202-
ticket_message = request.form["ticket_message"]
203-
ticket_impact = request.form["ticket_impact"]
204-
ticket_urgency = request.form["ticket_urgency"]
205-
request_type = request.form["request_type"]
206-
ticket_number = generate_ticket_number()
207-
208-
new_ticket = {
209-
"ticket_number": ticket_number,
210-
"requestor_name": requestor_name,
211-
"requestor_email": requestor_email,
212-
"ticket_subject": ticket_subject,
213-
"ticket_message": ticket_message,
214-
"request_type": request_type,
215-
"ticket_impact": ticket_impact,
216-
"ticket_urgency": ticket_urgency,
217-
"ticket_status": "Open",
218-
"submission_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
219-
"ticket_notes": []
220-
}
221-
222-
tickets = load_tickets()
223-
tickets.append(new_ticket)
224-
print(f"INFO - a new {ticket_number} has been created")
225-
save_tickets(tickets)
226-
227-
# Render the HTML email template
228-
email_body = render_template("/new-ticket-email.html", ticket=new_ticket)
218+
try:
219+
# Cloudflare Turnstile CAPTCHA validation
220+
turnstile_token = request.form.get("cf-turnstile-response")
221+
if not turnstile_token:
222+
flash("CAPTCHA verification failed. Please try again.", "danger")
223+
return redirect(url_for("home"))
224+
225+
turnstile_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
226+
turnstile_data = {
227+
"secret": CF_TURNSTILE_SECRET_KEY,
228+
"response": turnstile_token,
229+
"remoteip": request.remote_addr
230+
}
231+
232+
try:
233+
turnstile_response = requests.post(turnstile_url, data=turnstile_data)
234+
result = turnstile_response.json()
235+
if not result.get("success"):
236+
logging.warning(f"Turnstile verification failed: {result}")
237+
flash("CAPTCHA verification failed. Please try again.", "danger")
238+
return redirect(url_for("home"))
239+
except Exception as e:
240+
logging.error(f"Turnstile verification error: {str(e)}")
241+
flash("Error verifying CAPTCHA. Please try again later.", "danger")
242+
return redirect(url_for("home"))
243+
244+
# Process ticket submission
245+
requestor_name = request.form["requestor_name"]
246+
requestor_email = request.form["requestor_email"]
247+
ticket_subject = request.form["ticket_subject"]
248+
ticket_message = request.form["ticket_message"]
249+
ticket_impact = request.form["ticket_impact"]
250+
ticket_urgency = request.form["ticket_urgency"]
251+
request_type = request.form["request_type"]
252+
ticket_number = generate_ticket_number()
253+
254+
new_ticket = {
255+
"ticket_number": ticket_number,
256+
"requestor_name": requestor_name,
257+
"requestor_email": requestor_email,
258+
"ticket_subject": ticket_subject,
259+
"ticket_message": ticket_message,
260+
"request_type": request_type,
261+
"ticket_impact": ticket_impact,
262+
"ticket_urgency": ticket_urgency,
263+
"ticket_status": "Open",
264+
"submission_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
265+
"ticket_notes": []
266+
}
267+
268+
tickets = load_tickets()
269+
tickets.append(new_ticket)
270+
logging.info(f"{ticket_number} has been created.")
271+
print(f"INFO - a new {ticket_number} has been created.")
272+
save_tickets(tickets)
273+
274+
# Send the email with error han dling
275+
try:
276+
email_body = render_template("/new-ticket-email.html", ticket=new_ticket)
277+
send_email(requestor_email, f"{ticket_number} - {ticket_subject}", email_body, html=True)
278+
except Exception as e:
279+
logging.error(f"Failed to send email for {ticket_number}: {str(e)}")
280+
print(f"ERROR - Failed to send email for {ticket_number}: {str(e)}")
281+
282+
# Send a Discord webhook notification with error handling
283+
try:
284+
send_discord_notification(ticket_number, ticket_message)
285+
except Exception as e:
286+
logging.error(f"Failed to send Discord notification for {ticket_number}: {str(e)}")
287+
print(f"ERROR - Failed to send Discord notification for {ticket_number}: {str(e)}")
288+
289+
return redirect(url_for("home"))
229290

230-
# Send the email with HTML format
231-
send_email(requestor_email, f"{ticket_number} - {ticket_subject}", email_body, html=True)
291+
except Exception as e:
292+
logging.critical(f"Failed to process ticket submission: {str(e)}")
293+
print(f"CRITICAL ERROR - Failed to process ticket submission: {str(e)}")
294+
return "An error occurred while submitting your ticket. Please try again later.", 500
232295

233-
# Send a Discord webhook notification.
234-
send_discord_notification(ticket_number, ticket_message)
235-
236-
return redirect(url_for("home"))
237-
238-
return render_template("index.html")
296+
return render_template("index.html", sitekey=CF_TURNSTILE_SITE_KEY)
239297

240298
# Route/routine for the technician login page/process.
241299
@app.route("/login", methods=["GET", "POST"])
@@ -254,7 +312,7 @@ def login():
254312
else:
255313
return render_template("404.html"), 404 # Send our custom 404 page.
256314

257-
return render_template("login.html")
315+
return render_template("login.html", sitekey=CF_TURNSTILE_SITE_KEY)
258316

259317
# Route/routine for rendering the core technician dashboard. Displays all Open and In-Progress tickets.
260318
@app.route("/dashboard")
@@ -283,7 +341,7 @@ def ticket_detail(ticket_number):
283341

284342
# Route/routine for updating a ticket. Called from Dashboard and Ticket Commander.
285343
@app.route("/ticket/<ticket_number>/update_status/<ticket_status>", methods=["POST"])
286-
def update_ticket_status(ticket_number, ticket_status):
344+
def update_ticket_status(ticket_number, ticket_status):
287345
if not session.get("technician"): # Ensure only authenticated techs can update tickets.
288346
return render_template("403.html"), 403
289347

example-env

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@ SMTP_SERVER=smtp.example.com
55
SMTP_PORT=587
66
TICKETS_FILE=tickets.json
77
EMPLOYEE_FILE=employee.json
8-
DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/XXXXX/YYYYY"
8+
DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/XXXXX/YYYYY"
9+
LOG_FILE=/var/log/goobyDesk.log
10+
CF_TURNSTILE_SITE_KEY=01234567890
11+
CF_TURNSTILE_SECRET_KEY=abcdefghijklmnopqrstuvwxyz

local_webhook_handler.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import json
44
import requests
5+
import logging
56
from dotenv import load_dotenv
67

78
# Load environment variables from .env file
@@ -11,7 +12,8 @@
1112
# Sends a Discord webhook notification when a new ticket is created.
1213
def send_discord_notification(ticket_number, ticket_message):
1314
if not DISCORD_WEBHOOK_URL:
14-
print("ERROR - WEBHOOK HANDLER - DISCORD_WEBHOOK_URL is not set. Check your .env file.")
15+
logging.warning("WEBHOOK HANDLER - DISCORD_WEBHOOK_URL is not set. Check your .env file.")
16+
print("WARNING - WEBHOOK HANDLER - DISCORD_WEBHOOK_URL is not set. Check your .env file.")
1517
return
1618

1719
data = {
@@ -33,20 +35,26 @@ def send_discord_notification(ticket_number, ticket_message):
3335
response.raise_for_status() # Raise exception for HTTP errors
3436

3537
if response.status_code == 204:
38+
logging.info(f"INFO - WEBHOOK HANDLER - New Ticket {ticket_number} notification sent to Discord.")
3639
print(f"INFO - WEBHOOK HANDLER - New Ticket {ticket_number} notification sent to Discord.")
3740
else:
41+
logging.warning(f"WARNING - WEBHOOK HANDLER - Unexpected response code: {response.status_code}")
3842
print(f"WARNING - WEBHOOK HANDLER - Unexpected response code: {response.status_code}")
3943

4044
except requests.exceptions.ConnectionError:
45+
logging.error("WEBHOOK HANDLER - Failed to connect to Discord. Check internet and webhook URL.")
4146
print("ERROR - WEBHOOK HANDLER - Failed to connect to Discord. Check internet and webhook URL.")
4247
except requests.exceptions.Timeout:
48+
logging.error("WEBHOOK HANDLER - Request to Discord timed out.")
4349
print("ERROR - WEBHOOK HANDLER - Request to Discord timed out.")
4450
except requests.exceptions.RequestException as e:
51+
logging.critical(f"WEBHOOK HANDLER - Unexpected error: {e}")
4552
print(f"ERROR - WEBHOOK HANDLER - Unexpected error: {e}")
4653

4754
# send_TktUpdate_discord_notification will send a webhook when the status becomes In-Progress or Closed..
4855
def send_TktUpdate_discord_notification(ticket_number, ticket_status):
4956
if not DISCORD_WEBHOOK_URL:
57+
logging.warning("WEBHOOK HANDLER - DISCORD_WEBHOOK_URL is not set. Check your .env file.")
5058
print("ERROR - WEBHOOK HANDLER - DISCORD_WEBHOOK_URL is not set. Check your .env file.")
5159
return
5260

@@ -68,13 +76,18 @@ def send_TktUpdate_discord_notification(ticket_number, ticket_status):
6876
response.raise_for_status() # Raise exception for HTTP errors
6977

7078
if response.status_code == 204:
79+
logging.info(f"WEBHOOK HANDLER - Ticket {ticket_number} status change notification sent to Discord.")
7180
print(f"INFO - WEBHOOK HANDLER - Ticket {ticket_number} status change notification sent to Discord.")
7281
else:
82+
logging.warning(f"WEBHOOK HANDLER - Unexpected response code: {response.status_code}")
7383
print(f"WARNING - WEBHOOK HANDLER - Unexpected response code: {response.status_code}")
7484

7585
except requests.exceptions.ConnectionError:
86+
logging.error("WEBHOOK HANDLER - Failed to connect to Discord. Check internet and webhook URL.")
7687
print("ERROR - WEBHOOK HANDLER - Failed to connect to Discord. Check internet and webhook URL.")
7788
except requests.exceptions.Timeout:
89+
logging.error("WEBHOOK HANDLER - Request to Discord timed out.")
7890
print("ERROR - WEBHOOK HANDLER - Request to Discord timed out.")
7991
except requests.exceptions.RequestException as e:
92+
logging.critical(f"WEBHOOK HANDLER - Unexpected error: {e}")
8093
print(f"ERROR - WEBHOOK HANDLER - Unexpected error: {e}")

0 commit comments

Comments
 (0)