Phase 1: security fixes + backup/restore tooling + vat_type migrations

Minimal infrastructure push before the bigger feature release (worker/team/
project management UIs, WeasyPrint migration, new models). Deploying this
first gives us a browser-accessible `/backup-data/` endpoint so we can
snapshot production before the bigger change lands.

SECURITY
  - Remove hardcoded Gmail App Password from settings.py (was leaking via
    git history; new password now lives in Flatlogic's `../.env` file)
  - Remove hardcoded SECRET_KEY default; raise ImproperlyConfigured in
    prod if env var missing; dev fallback only when USE_SQLITE is set
  - Flip DEBUG default from 'true' to 'false' so missing env var doesn't
    silently expose tracebacks
  - Remove hardcoded EMAIL_HOST_USER / DEFAULT_FROM_EMAIL defaults
  - Add startup warning when email vars missing in production
  - Fix CSRF_TRUSTED_ORIGINS double-scheme bug (would break with
    pre-prefixed HOST_FQDN env var)

BACKUP / RESTORE
  - New `backup_data` management command — serialises every core + auth
    row to a timestamped JSON file. Gracefully handles models missing at
    older schema versions (WorkerCertificate/Warning imported optionally).
  - New `restore_data` management command — loads JSON back into the DB
    with a populated-DB safety guard and transactional all-or-nothing
    semantics.
  - New `/backup-data/` admin-only URL — downloads the JSON to browser.
  - New `/restore-data/` admin-only URL — upload form with CSRF and
    explicit confirm checkbox before any data is loaded.

MIGRATIONS
  - Add 0007_vat_type_default + 0008_vat_type_default_none (change
    ExpenseReceipt.vat_type default to 'None').
  - Update models.py to match migration 0008's end state.

HOUSEKEEPING
  - Extend .gitignore: .claude/, .vscode/, .idea/, test_*.pdf,
    test_*.json, nul, backups/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 00:28:21 +02:00
parent a1ac8540ab
commit 0ace7c6786
9 changed files with 535 additions and 16 deletions

17
.gitignore vendored
View File

@ -6,8 +6,23 @@ __pycache__/
*.pyc
*.pyo
.env
.env.*
*.db
*.sqlite3
*.sqlite3-journal
.DS_Store
media/
.venv/
.venv/
# Claude Code / IDE
.claude/
.vscode/
.idea/
# Dev artifacts — test PDFs, backup files, accidental shell artifacts
test_*.pdf
test_*.json
nul
# Local backup downloads — these should never be in git
backups/

View File

@ -12,13 +12,40 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
from pathlib import Path
import os
from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR.parent / ".env")
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
# === DEBUG ===
# DEBUG defaults to FALSE — must be explicitly enabled via env var.
# Previously defaulted to "true" which exposed full tracebacks and
# settings to anyone who hit a 500 error in production.
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
# === DEV MODE DETECTION ===
# Local dev uses SQLite (see run_dev.bat). When USE_SQLITE is set we're
# in dev and can relax a few "must be set in prod" checks.
_IS_DEV = os.getenv("USE_SQLITE", "").lower() == "true"
# === SECRET_KEY ===
# Must be provided via DJANGO_SECRET_KEY env var in any non-dev deploy.
# In dev mode (USE_SQLITE=true) we fall back to a known-insecure key so
# local development works out of the box. In prod the absence of the
# env var raises a startup error rather than silently using a weak key.
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
if _IS_DEV or DEBUG:
# Dev-only key — NEVER set this value in a production env var.
SECRET_KEY = "dev-only-insecure-key-do-not-use-in-production"
else:
raise ImproperlyConfigured(
"DJANGO_SECRET_KEY environment variable is not set. "
"Set it in the deploy platform's environment variables (or .env file). "
"Use `python -c \"import secrets; print(secrets.token_urlsafe(64))\"` "
"to generate a new one."
)
ALLOWED_HOSTS = [
"127.0.0.1",
@ -27,17 +54,29 @@ ALLOWED_HOSTS = [
os.getenv("HOST_FQDN", ""),
]
# === CSRF TRUSTED ORIGINS ===
# Build the list, then normalise each entry to have an https:// prefix.
# Guard against the double-prefix bug: if the user sets HOST_FQDN to
# "https://example.com" (with a scheme), the raw f-string would produce
# "https://https://example.com" which Django rejects.
CSRF_TRUSTED_ORIGINS = [
origin for origin in [
"foxlog.flatlogic.app",
os.getenv("HOST_FQDN", ""),
os.getenv("CSRF_TRUSTED_ORIGIN", "")
os.getenv("CSRF_TRUSTED_ORIGIN", ""),
] if origin
]
CSRF_TRUSTED_ORIGINS = [
f"https://{host}" if not host.startswith(("http://", "https://")) else host
for host in CSRF_TRUSTED_ORIGINS
]
def _normalize_origin(host):
"""Ensure `host` has an http:// or https:// scheme; default to https."""
host = host.strip()
if host.startswith(("http://", "https://")):
return host
return f"https://{host}"
CSRF_TRUSTED_ORIGINS = [_normalize_origin(h) for h in CSRF_TRUSTED_ORIGINS]
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
SESSION_COOKIE_SECURE = True
@ -160,29 +199,52 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# === EMAIL CONFIGURATION ===
# Uses Gmail SMTP with an App Password to send payslip PDFs and receipts.
# The App Password is a 16-character code from Google Account settings —
# it lets the app send email through Gmail without your actual password.
# NO FALLBACKS for credentials — they MUST come from environment variables
# (the Flatlogic .env file at `../.env`). Previous versions had the Gmail
# App Password committed in source as a fallback default, which is a
# critical security leak via git history. In local dev (USE_SQLITE=true)
# empty credentials are fine; email sends will just fail with an auth
# error — which is what you want locally.
EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend"
)
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "konrad@foxfitt.co.za")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "cwvhpcwyijneukax")
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") # set via .env
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set via .env
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "konrad+foxlog@foxfitt.co.za")
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "")
CONTACT_EMAIL_TO = [
item.strip()
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
if item.strip()
]
# Spark Receipt Email — payslip and receipt PDFs are sent here for accounting import
# Spark Receipt Email — payslip and receipt PDFs routed here for accounting import.
# This is a routing address, not a secret, so a default is acceptable.
SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "foxfitt-ed9wc+expense@to.sparkreceipt.com")
# Fail loudly in production if critical email vars are missing — catches the
# "I forgot to set env vars on the new deploy platform" mistake before a user
# triggers a payroll payment and the email silently fails.
if not DEBUG and not _IS_DEV:
_missing_email_vars = [
name for name, val in [
("EMAIL_HOST_USER", EMAIL_HOST_USER),
("EMAIL_HOST_PASSWORD", EMAIL_HOST_PASSWORD),
("DEFAULT_FROM_EMAIL", DEFAULT_FROM_EMAIL),
] if not val
]
if _missing_email_vars:
import logging
logging.getLogger(__name__).warning(
"Email configuration incomplete in production. Missing env vars: %s. "
"Payslip and receipt emails will fail to send until these are set.",
", ".join(_missing_email_vars),
)
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL:
EMAIL_USE_TLS = False

View File

@ -0,0 +1,141 @@
# === BACKUP DATA MANAGEMENT COMMAND ===
# Exports every row of every core model to a single JSON file that can
# be restored later via `python manage.py restore_data <file.json>`.
#
# WHY THIS EXISTS:
# Flatlogic doesn't expose MySQL directly — no mysqldump, no SSH, no
# DB console. Django's built-in `dumpdata` / `loaddata` give us a
# platform-independent backup format that travels with the code.
#
# WHY NOT JUST USE `dumpdata`?
# This command is a thin wrapper around dumpdata that:
# - Pins the exact set of app+model rows we want to back up
# - Writes to a timestamped file so you never overwrite a backup
# - Includes Users + Groups + auth content types (so permissions
# restore correctly too)
# - Prints a row-count summary so you can confirm it worked
#
# USAGE (local):
# python manage.py backup_data → backups/foxlog_YYYYMMDD_HHMMSS.json
# python manage.py backup_data --output=my.json → my.json
#
# USAGE (Flatlogic, via browser):
# Visit /backup-data/ as admin — downloads the backup file to your browser.
import datetime
import io
import json
from pathlib import Path
from django.core import serializers
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User, Group, Permission
from django.contrib.contenttypes.models import ContentType
from core.models import (
UserProfile, Project, Worker, Team, WorkLog,
PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
)
# WorkerCertificate and WorkerWarning were added in a later migration.
# Import them optionally so this backup command works during a multi-phase
# deploy where the backup tool ships before those models do.
try:
from core.models import WorkerCertificate, WorkerWarning
_HAS_WORKER_CERTS_WARNINGS = True
except ImportError:
_HAS_WORKER_CERTS_WARNINGS = False
# === BACKUP SCOPE ===
# The exact list of models we back up. Order matters for restore —
# we list models in dependency order (no FK should point at something
# that comes later in the list). Django's loaddata handles this
# correctly regardless, but keeping it sorted helps humans read it.
MODELS_TO_BACKUP = [
# Auth fundamentals — restore these first so FKs from UserProfile
# etc. find their user rows.
ContentType,
Permission,
Group,
User,
# Core app
UserProfile,
Project,
Worker,
Team,
WorkLog,
PayrollRecord,
Loan,
PayrollAdjustment,
ExpenseReceipt,
ExpenseLineItem,
]
# Append the cert/warning models only if they're available in this deploy
if _HAS_WORKER_CERTS_WARNINGS:
MODELS_TO_BACKUP.extend([WorkerCertificate, WorkerWarning])
def build_backup_payload():
"""Return (json_str, summary_dict) for the current DB state.
Separated from the Command class so the browser view can reuse it
to stream the backup to the user's browser.
"""
# Pull every row of every model we care about, serialise as JSON.
# serializers.serialize("json", queryset) returns a JSON string.
# We concatenate by building one big list first, then dumping once.
all_rows = []
summary = {}
for model in MODELS_TO_BACKUP:
qs = list(model.objects.all())
summary[f"{model._meta.app_label}.{model._meta.model_name}"] = len(qs)
# Use the built-in Django serializer for proper natural-key support
serialized = serializers.serialize("python", qs)
all_rows.extend(serialized)
payload = {
"version": 1,
"exported_at": datetime.datetime.now().isoformat(),
"row_counts": summary,
"data": all_rows,
}
return json.dumps(payload, indent=2, default=str), summary
class Command(BaseCommand):
help = (
"Export every core-app row to a JSON file for backup/restore. "
"Writes to backups/foxlog_<timestamp>.json unless --output is given."
)
def add_arguments(self, parser):
parser.add_argument(
"--output",
type=str,
default=None,
help="Output filepath. Default: backups/foxlog_<timestamp>.json",
)
def handle(self, *args, **options):
json_str, summary = build_backup_payload()
# Default path: ./backups/foxlog_<timestamp>.json
if options["output"]:
output_path = Path(options["output"])
else:
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = Path("backups") / f"foxlog_{ts}.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json_str, encoding="utf-8")
# Print a summary so you can verify at a glance
self.stdout.write(self.style.SUCCESS(
f"Backup written to: {output_path}"
))
self.stdout.write(f"File size: {output_path.stat().st_size:,} bytes")
self.stdout.write("Row counts by model:")
for model_name, count in sorted(summary.items()):
self.stdout.write(f" {model_name:<40} {count:>6}")

View File

@ -0,0 +1,141 @@
# === RESTORE DATA MANAGEMENT COMMAND ===
# Restores a backup produced by `backup_data` — takes a JSON file and
# loads every row into the database.
#
# SAFETY:
# By default this command REFUSES to run against a non-empty database
# (prevents accidentally overwriting live data). Pass --force to
# bypass — but only when you know the target is empty or already
# matches the backup.
#
# USAGE (local):
# python manage.py restore_data backups/foxlog_20260421_120000.json
# python manage.py restore_data backup.json --force (overwrite existing)
#
# USAGE (Flatlogic, via browser):
# Upload a .json backup file via /restore-data/ (admin only).
#
# BEHAVIOUR:
# Uses Django's built-in `loaddata` under the hood, which:
# - Updates existing rows if their pk matches (no duplicates)
# - Creates new rows for any pk not yet in the DB
# - Respects FK/M2M dependencies
# - Runs inside a transaction — if any row fails, nothing is saved
import json
import sys
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.core.management import call_command
from django.db import transaction
from django.contrib.auth.models import User
from core.models import Worker, WorkLog, PayrollRecord
def check_database_is_populated():
"""Return True if the database already has meaningful data.
Used as a guardrail: by default we refuse to restore into a DB that
already contains workers, work logs, or payroll records, because
that could double-insert and corrupt the state.
"""
has_workers = Worker.objects.exists()
has_logs = WorkLog.objects.exists()
has_payments = PayrollRecord.objects.exists()
return has_workers or has_logs or has_payments
def restore_from_json_string(json_str):
"""Load a JSON backup string into the database.
Returns (success, message_or_summary). Used both by this management
command and by the browser-accessible `/restore-data/` view so the
same logic runs in both places.
Raises no exceptions returns (False, error_message) on failure so
the caller (CLI or web view) can format the error appropriately.
"""
try:
payload = json.loads(json_str)
except json.JSONDecodeError as e:
return False, f"File is not valid JSON: {e}"
# Backups produced by `backup_data` wrap rows in a top-level dict.
# Raw dumpdata output is a bare list — support both for flexibility.
if isinstance(payload, dict) and "data" in payload:
rows = payload["data"]
elif isinstance(payload, list):
rows = payload
else:
return False, "Unexpected JSON structure — expected dict with 'data' key or a list."
if not rows:
return False, "Backup file contains no rows."
# Write the rows to a tmp file then let Django's loaddata do the work
# (it handles FK order, transaction wrapping, and natural keys).
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False,
encoding="utf-8") as tmp:
# loaddata expects the bare list format
json.dump(rows, tmp, default=str)
tmp_path = tmp.name
try:
with transaction.atomic():
call_command("loaddata", tmp_path, verbosity=0)
except Exception as e:
return False, f"Restore failed: {e}"
finally:
try:
Path(tmp_path).unlink()
except Exception:
pass # cleanup best-effort
# Build a summary for the caller to display
summary = {
"users": User.objects.count(),
"workers": Worker.objects.count(),
"work_logs": WorkLog.objects.count(),
"payroll_records": PayrollRecord.objects.count(),
"rows_in_backup": len(rows),
}
return True, summary
class Command(BaseCommand):
help = "Restore a JSON backup produced by `backup_data`."
def add_arguments(self, parser):
parser.add_argument("backup_file", type=str, help="Path to a .json backup file")
parser.add_argument(
"--force",
action="store_true",
help="Allow restore even if the target database already has data",
)
def handle(self, *args, **options):
backup_path = Path(options["backup_file"])
if not backup_path.exists():
raise CommandError(f"Backup file not found: {backup_path}")
if not options["force"] and check_database_is_populated():
raise CommandError(
"Database already contains data (workers/logs/payments). "
"Restoring now could duplicate or corrupt rows.\n"
"If you really want to proceed, run again with --force.\n"
"Or flush first: python manage.py flush (irreversible)."
)
json_str = backup_path.read_text(encoding="utf-8")
ok, result = restore_from_json_string(json_str)
if not ok:
raise CommandError(result)
self.stdout.write(self.style.SUCCESS("Restore complete."))
self.stdout.write("Rows in database after restore:")
for k, v in result.items():
self.stdout.write(f" {k}: {v}")

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-04-20 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_worker_drivers_license_worker_has_drivers_license_and_more'),
]
operations = [
migrations.AlterField(
model_name='expensereceipt',
name='vat_type',
field=models.CharField(choices=[('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None')], default='Included', max_length=20),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-04-20 19:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_vat_type_default'),
]
operations = [
migrations.AlterField(
model_name='expensereceipt',
name='vat_type',
field=models.CharField(choices=[('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None')], default='None', max_length=20),
),
]

View File

@ -195,7 +195,7 @@ class ExpenseReceipt(models.Model):
vendor_name = models.CharField(max_length=200)
description = models.TextField(blank=True)
payment_method = models.CharField(max_length=20, choices=METHOD_CHOICES)
vat_type = models.CharField(max_length=20, choices=VAT_CHOICES)
vat_type = models.CharField(max_length=20, choices=VAT_CHOICES, default='None')
subtotal = models.DecimalField(max_digits=12, decimal_places=2)
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
total_amount = models.DecimalField(max_digits=12, decimal_places=2)

View File

@ -70,4 +70,11 @@ urlpatterns = [
# === TEMPORARY: Run migrations from browser ===
# Visit /run-migrate/ to apply pending database migrations on production.
path('run-migrate/', views.run_migrate, name='run_migrate'),
# === BACKUP / RESTORE (admin-only, browser-accessible) ===
# Flatlogic has no SSH/shell — admins use these to snapshot and
# restore all app data via the browser. See CLAUDE.md "Backup &
# Restore" section for the full procedure.
path('backup-data/', views.backup_data, name='backup_data'),
path('restore-data/', views.restore_data, name='restore_data'),
]

View File

@ -16,6 +16,7 @@ from django.db.models.functions import TruncMonth
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from django.middleware.csrf import get_token
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
@ -2633,3 +2634,119 @@ def run_migrate(request):
'</body></html>',
status=500,
)
# === BACKUP / RESTORE (browser-accessible, admin-only) ===
# Flatlogic has no shell/SSH — admins need to backup and restore via browser.
# These views wrap the `backup_data` and `restore_data` management commands
# and render a minimal HTML UI. Safe to leave in place in production.
@login_required
def backup_data(request):
"""Download the complete app data as a timestamped JSON file.
Admin-only. Serves the backup as a browser download so it lands
safely on the admin's laptop rather than the server filesystem
(which is ephemeral on Flatlogic).
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
from core.management.commands.backup_data import build_backup_payload
import datetime as _dt
json_str, summary = build_backup_payload()
filename = f'foxlog_backup_{_dt.datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
response = HttpResponse(json_str, content_type='application/json')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@login_required
def restore_data(request):
"""Upload a .json backup to restore it into the current database.
GET renders a minimal upload form + warning
POST accepts the file, validates, and loads it (inside a transaction)
Admin-only. Requires explicit `confirm=yes` POST field to proceed,
so a stray click can't wipe production.
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
from core.management.commands.restore_data import (
check_database_is_populated, restore_from_json_string,
)
db_has_data = check_database_is_populated()
if request.method == 'POST':
if request.POST.get('confirm') != 'yes':
return HttpResponse(
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
'<h2>Restore cancelled</h2>'
'<p>You must tick the "Yes, I understand" checkbox to proceed.</p>'
'<a href="/restore-data/">Back</a></body></html>',
status=400,
)
uploaded = request.FILES.get('backup_file')
if not uploaded:
return HttpResponse(
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
'<h2>No file uploaded</h2>'
'<a href="/restore-data/">Back</a></body></html>',
status=400,
)
json_str = uploaded.read().decode('utf-8', errors='replace')
ok, result = restore_from_json_string(json_str)
if not ok:
return HttpResponse(
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
'<h2>Restore failed</h2>'
f'<pre>{result}</pre>'
'<a href="/restore-data/">Back</a></body></html>',
status=500,
)
rows_html = '<br>'.join(f'{k}: {v}' for k, v in result.items())
return HttpResponse(
'<html><body style="font-family: monospace; padding: 20px;">'
'<h2 style="color: #10b981;">Restore complete!</h2>'
f'<div>{rows_html}</div><br><br>'
'<a href="/">Go to Dashboard</a></body></html>'
)
# GET — render the upload form
warning_html = ''
if db_has_data:
warning_html = (
'<p style="color: #e8851a; border-left: 3px solid #e8851a; padding-left: 10px;">'
'<strong>Warning:</strong> this database already contains data '
'(workers / work logs / payroll records). Restoring will UPDATE existing rows '
'by primary key and INSERT missing ones. This will NOT delete data that exists '
'in the DB but not in the backup. If you want a clean restore, run '
'<code>python manage.py flush</code> first (irreversible).'
'</p>'
)
return HttpResponse(
'<html><body style="font-family: monospace; padding: 20px; max-width: 700px;">'
'<h2>Restore from backup</h2>'
+ warning_html +
'<form method="post" enctype="multipart/form-data">'
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
'<p><label>Backup JSON file:<br>'
'<input type="file" name="backup_file" accept="application/json" required></label></p>'
'<p><label><input type="checkbox" name="confirm" value="yes" required> '
'Yes, I understand this will overwrite matching rows in the database.</label></p>'
'<p><button type="submit" style="padding: 10px 20px; background: #e8851a; '
'color: white; border: none; border-radius: 4px; cursor: pointer;">'
'Restore</button>'
' <a href="/" style="margin-left: 10px;">Cancel</a></p>'
'</form></body></html>'
)