diff --git a/backup.sh b/backup.sh
old mode 100644
new mode 100755
index 26ef8f8..0d5f337
--- a/backup.sh
+++ b/backup.sh
@@ -116,6 +116,42 @@ log "Backing up MySQL credentials"
# Document all databases
mysql -e "SHOW DATABASES;" 2>/dev/null | grep -v "^Database\|information_schema\|performance_schema\|sys" > mysql/databases.txt || true
+# ---------------------------------------------------------------------------
+# 9b. Actual database dumps — added 2026-07-07. Previously this only wrote a
+# list of database NAMES (mysql/databases.txt above), never the data
+# itself. If this server were lost, every site's real data — orders,
+# accounts, bookings — would be gone even though the backup "succeeded"
+# every week. Dumps every real business DB (skips cyberpanel's own
+# internal DB and MySQL system schemas).
+# ---------------------------------------------------------------------------
+log "Dumping site databases"
+mkdir -p databases
+for db in $(mysql -N -e "SHOW DATABASES;" 2>/dev/null | grep -vE "^(information_schema|performance_schema|mysql|sys|cyberpanel)$"); do
+ if mysqldump --single-transaction --routines --triggers "$db" 2>/dev/null | gzip > "databases/${db}.sql.gz"; then
+ SIZE=$(du -sh "databases/${db}.sql.gz" | cut -f1)
+ log " $db -> ${db}.sql.gz ($SIZE)"
+ else
+ log " ERROR dumping $db"
+ rm -f "databases/${db}.sql.gz"
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# 9c. Actual website files — added 2026-07-07. Same gap as above: the config
+# backup only ever documented site names + disk usage (site-list.txt
+# below), never copied the files themselves. .git is excluded per site
+# since each site's own repo (on GitHub, mirrored to Gitea) already
+# covers code history — this is a content/uploads/config safety net for
+# whatever isn't (or hasn't yet been) committed there.
+# ---------------------------------------------------------------------------
+log "Backing up website files"
+mkdir -p sites
+for d in /home/*/public_html; do
+ site=$(basename "$(dirname "$d")")
+ mkdir -p "sites/$site"
+ rsync -a --delete --exclude='.git' "$d/" "sites/$site/public_html/"
+done
+
# ---------------------------------------------------------------------------
# 10. /opt/infra snapshot (already a separate git repo — copy contents)
# ---------------------------------------------------------------------------
diff --git a/backup.sh.bak-2026-07-07 b/backup.sh.bak-2026-07-07
new file mode 100644
index 0000000..26ef8f8
--- /dev/null
+++ b/backup.sh.bak-2026-07-07
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# =============================================================================
+# DO Server Config Backup — runs on orbis (165.22.1.228)
+# Backs up all critical configs/scripts to GitHub weekly
+# Install: /usr/local/bin/do-server-backup
+# Cron: 0 4 * * 0 /usr/local/bin/do-server-backup >> /var/log/do-server-backup.log 2>&1
+# =============================================================================
+set -euo pipefail
+
+PAT="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"
+REPO_URL="https://${PAT}@github.com/myronblair/do-server-config.git"
+REPO_DIR="/opt/do-server-config"
+LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')] [orbis]"
+
+log() { echo "$LOG_PREFIX $*"; }
+
+# ---------------------------------------------------------------------------
+# 1. Clone or update repo
+# ---------------------------------------------------------------------------
+if [[ -d "$REPO_DIR/.git" ]]; then
+ log "Pulling latest from GitHub"
+ cd "$REPO_DIR"
+ git config user.email "backup@orbishosting.com"
+ git config user.name "DO Server Backup"
+ git pull --rebase origin main -q || true
+else
+ log "Cloning repo to $REPO_DIR"
+ git clone "$REPO_URL" "$REPO_DIR"
+ cd "$REPO_DIR"
+ git config user.email "backup@orbishosting.com"
+ git config user.name "DO Server Backup"
+fi
+
+cd "$REPO_DIR"
+mkdir -p scripts systemd wireguard network cron ssh ols-vhosts mysql infra
+
+# ---------------------------------------------------------------------------
+# 2. Custom scripts from /usr/local/bin (text only — skip large binaries)
+# ---------------------------------------------------------------------------
+log "Backing up custom scripts"
+for f in /usr/local/bin/jarvis-*.sh \
+ /usr/local/bin/jarvis-*.py \
+ /usr/local/bin/ttg-backup.sh \
+ /usr/local/bin/do-server-backup; do
+ [[ -f "$f" ]] || continue
+ size=$(stat -c%s "$f" 2>/dev/null || echo 0)
+ [[ $size -lt 524288 ]] && cp "$f" scripts/ || log " SKIP (too large): $f"
+done
+# composer is a stock PHP tool — skip it
+
+# ---------------------------------------------------------------------------
+# 3. Custom systemd service units (skip stock DO/system units)
+# ---------------------------------------------------------------------------
+log "Backing up custom systemd units"
+CUSTOM_UNITS="jarvis-agent.service fastapi_ssh_server.service"
+for unit in $CUSTOM_UNITS; do
+ src="/etc/systemd/system/$unit"
+ [[ -f "$src" ]] && cp "$src" systemd/ || true
+done
+
+# ---------------------------------------------------------------------------
+# 4. WireGuard configs (includes private keys — repo is private)
+# ---------------------------------------------------------------------------
+log "Backing up WireGuard configs"
+for f in /etc/wireguard/*.conf; do
+ [[ -f "$f" ]] && cp "$f" wireguard/ || true
+done
+
+# ---------------------------------------------------------------------------
+# 5. Network / netplan
+# ---------------------------------------------------------------------------
+log "Backing up netplan"
+for f in /etc/netplan/*.yaml; do
+ [[ -f "$f" ]] && cp "$f" network/ || true
+done
+cp /etc/hosts network/hosts 2>/dev/null || true
+cp /etc/hostname network/hostname 2>/dev/null || true
+
+# ---------------------------------------------------------------------------
+# 6. Root crontab — custom entries only (strip CyberPanel boilerplate)
+# ---------------------------------------------------------------------------
+log "Backing up crontab"
+crontab -l 2>/dev/null | grep -v "^#\|CyberCP\|acme.sh\|cleansessions\|run_scheduled_scans\|pdnsHealthCheck\|findBWUsage\|postfixSenderPolicy\|upgradeCritical\|renew\.py\|IncScheduler\|e2scrub\|imunify\|sessionclean\|lsws\b" \
+ | sed '/^[[:space:]]*$/d' > cron/root_custom
+# Also keep the full crontab for reference
+crontab -l 2>/dev/null > cron/root_full || echo "# no crontab" > cron/root_full
+
+# ---------------------------------------------------------------------------
+# 7. SSH authorized_keys
+# ---------------------------------------------------------------------------
+log "Backing up SSH keys"
+[[ -f /root/.ssh/authorized_keys ]] && cp /root/.ssh/authorized_keys ssh/ || true
+[[ -f /root/.ssh/id_rsa.pub ]] && cp /root/.ssh/id_rsa.pub ssh/ || true
+
+# ---------------------------------------------------------------------------
+# 8. OpenLiteSpeed vhost configs (CyberPanel-managed)
+# ---------------------------------------------------------------------------
+log "Backing up OLS vhost configs"
+for vdir in /usr/local/lsws/conf/vhosts/*/; do
+ vname=$(basename "$vdir")
+ [[ "$vname" == "Example" ]] && continue
+ mkdir -p "ols-vhosts/$vname"
+ for conf in "$vdir"*.conf; do
+ [[ -f "$conf" ]] && cp "$conf" "ols-vhosts/$vname/" || true
+ done
+done
+# OLS main listener/vhost mapping
+grep -E "^\s*(listener|virtualHost|address |map |vhRoot|vhDomain|configFile)" \
+ /usr/local/lsws/conf/httpd_config.conf 2>/dev/null > ols-vhosts/httpd_vhosts_summary.txt || true
+
+# ---------------------------------------------------------------------------
+# 9. MySQL root credentials file
+# ---------------------------------------------------------------------------
+log "Backing up MySQL credentials"
+[[ -f /root/.my.cnf ]] && cp /root/.my.cnf mysql/my.cnf || true
+# Document all databases
+mysql -e "SHOW DATABASES;" 2>/dev/null | grep -v "^Database\|information_schema\|performance_schema\|sys" > mysql/databases.txt || true
+
+# ---------------------------------------------------------------------------
+# 10. /opt/infra snapshot (already a separate git repo — copy contents)
+# ---------------------------------------------------------------------------
+log "Backing up /opt/infra snapshot"
+if [[ -d /opt/infra ]]; then
+ rsync -a --exclude='.git' /opt/infra/ infra/
+fi
+
+# SMTP config docs
+if [[ -d /opt/smtp-for-websites ]]; then
+ mkdir -p smtp-docs
+ rsync -a --exclude='.git' /opt/smtp-for-websites/ smtp-docs/
+fi
+
+# ---------------------------------------------------------------------------
+# 11. CyberPanel website list (for documentation)
+# ---------------------------------------------------------------------------
+log "Documenting website list"
+{
+ echo "# Websites on DO server — $(date '+%Y-%m-%d')"
+ echo ""
+ for d in /home/*/public_html; do
+ site=$(echo "$d" | sed 's|/home/||;s|/public_html||')
+ diskuse=$(du -sh "$d" 2>/dev/null | cut -f1)
+ echo "- $site ($diskuse)"
+ done
+} > ols-vhosts/site-list.txt
+
+# ---------------------------------------------------------------------------
+# 12. Commit and push
+# ---------------------------------------------------------------------------
+log "Committing changes"
+git add -A
+if git diff --cached --quiet; then
+ log "No changes to commit"
+else
+ CHANGES=$(git diff --cached --stat | tail -1)
+ git commit -m "[orbis] Weekly backup $(date '+%Y-%m-%d') — $CHANGES"
+ log "Pushing to GitHub"
+ git push origin main
+ log "Backup complete"
+fi
diff --git a/databases/epic_epic_db.sql.gz b/databases/epic_epic_db.sql.gz
new file mode 100644
index 0000000..0576ae2
Binary files /dev/null and b/databases/epic_epic_db.sql.gz differ
diff --git a/databases/epic_parkersling.sql.gz b/databases/epic_parkersling.sql.gz
new file mode 100644
index 0000000..fe3b41e
Binary files /dev/null and b/databases/epic_parkersling.sql.gz differ
diff --git a/databases/park_slingshot.sql.gz b/databases/park_slingshot.sql.gz
new file mode 100644
index 0000000..37092e9
Binary files /dev/null and b/databases/park_slingshot.sql.gz differ
diff --git a/databases/toms_tjj_db.sql.gz b/databases/toms_tjj_db.sql.gz
new file mode 100644
index 0000000..aabda19
Binary files /dev/null and b/databases/toms_tjj_db.sql.gz differ
diff --git a/databases/tomt_ttg_db.sql.gz b/databases/tomt_ttg_db.sql.gz
new file mode 100644
index 0000000..11adc40
Binary files /dev/null and b/databases/tomt_ttg_db.sql.gz differ
diff --git a/databases/workt_track_db.sql.gz b/databases/workt_track_db.sql.gz
new file mode 100644
index 0000000..5605378
Binary files /dev/null and b/databases/workt_track_db.sql.gz differ
diff --git a/mysql/databases.txt b/mysql/databases.txt
index 069ed23..a883153 100644
--- a/mysql/databases.txt
+++ b/mysql/databases.txt
@@ -5,3 +5,4 @@ mysql
park_slingshot
toms_tjj_db
tomt_ttg_db
+workt_track_db
diff --git a/ols-vhosts/httpd_vhosts_summary.txt b/ols-vhosts/httpd_vhosts_summary.txt
index b22665c..4afe527 100644
--- a/ols-vhosts/httpd_vhosts_summary.txt
+++ b/ols-vhosts/httpd_vhosts_summary.txt
@@ -3,6 +3,7 @@ virtualHost Example{
vhRoot Example/
configFile conf/vhosts/Example/vhconf.conf
listener Default{
+ map worktracking.orbishosting.com worktracking.orbishosting.com
map orbis.orbishosting.com orbis.orbishosting.com
map mail.parkerslingshotrentals.com mail.parkerslingshotrentals.com
map parkerslingshotrentals.com parkerslingshotrentals.com
@@ -21,6 +22,7 @@ virtualHost tomtomgames.com {
vhRoot /home/$VH_NAME
configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhost.conf
listener SSL {
+ map worktracking.orbishosting.com worktracking.orbishosting.com
map mail.tomtomgames.com mail.tomtomgames.com
map orbis.orbishosting.com orbis.orbishosting.com
map mail.parkerslingshotrentals.com mail.parkerslingshotrentals.com
@@ -37,6 +39,7 @@ virtualHost mail.tomtomgames.com {
vhRoot /home/tomtomgames.com
configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhost.conf
listener SSL IPv6 {
+ map worktracking.orbishosting.com worktracking.orbishosting.com
map mail.tomtomgames.com mail.tomtomgames.com
map orbis.orbishosting.com orbis.orbishosting.com
map mail.parkerslingshotrentals.com mail.parkerslingshotrentals.com
@@ -76,3 +79,6 @@ virtualHost mail.parkerslingshotrentals.com {
virtualHost orbis.orbishosting.com {
vhRoot /home/$VH_NAME
configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhost.conf
+virtualHost worktracking.orbishosting.com {
+ vhRoot /home/$VH_NAME
+ configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhost.conf
diff --git a/ols-vhosts/site-list.txt b/ols-vhosts/site-list.txt
index 2b48571..4613438 100644
--- a/ols-vhosts/site-list.txt
+++ b/ols-vhosts/site-list.txt
@@ -1,8 +1,9 @@
-# Websites on DO server — 2026-07-05
+# Websites on DO server — 2026-07-07
- epictravelexpeditions.com (3.8M)
- orbishosting.com (113M)
- orbis.orbishosting.com (40K)
-- parkerslingshotrentals.com (308K)
-- tomsjavajive.com (3.8M)
-- tomtomgames.com (872K)
+- parkerslingshotrentals.com (300K)
+- tomsjavajive.com (3.9M)
+- tomtomgames.com (868K)
+- worktracking.orbishosting.com (124K)
diff --git a/ols-vhosts/worktracking.orbishosting.com/vhost.conf b/ols-vhosts/worktracking.orbishosting.com/vhost.conf
new file mode 100755
index 0000000..258602e
--- /dev/null
+++ b/ols-vhosts/worktracking.orbishosting.com/vhost.conf
@@ -0,0 +1,91 @@
+docRoot $VH_ROOT/public_html
+vhDomain $VH_NAME
+vhAliases www.$VH_NAME
+adminEmails admin@worktracking.orbishosting.com
+enableGzip 1
+enableIpGeo 1
+
+index {
+ useServer 0
+ indexFiles index.php, index.html
+}
+
+errorlog $VH_ROOT/logs/$VH_NAME.error_log {
+ useServer 0
+ logLevel WARN
+ rollingSize 10M
+}
+
+accesslog $VH_ROOT/logs/$VH_NAME.access_log {
+ useServer 0
+ logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
+ logHeaders 5
+ rollingSize 10M
+ keepDays 10
+ compressArchive 1
+}
+
+scripthandler {
+ add lsapi:workt7085 php
+}
+
+extprocessor workt7085 {
+ type lsapi
+ address UDS://tmp/lshttpd/workt7085.sock
+ maxConns 10
+ env LSAPI_CHILDREN=10
+ initTimeout 600
+ retryTimeout 0
+ persistConn 1
+ pcKeepAliveTimeout 1
+ respBuffer 0
+ autoStart 1
+ path /usr/local/lsws/lsphp85/bin/lsphp
+ extUser workt7085
+ extGroup workt7085
+ memSoftLimit 1024M
+ memHardLimit 1024M
+ procSoftLimit 400
+ procHardLimit 500
+}
+
+phpIniOverride {
+php_admin_value open_basedir "/tmp:$VH_ROOT"
+}
+
+module cache {
+ storagePath /usr/local/lsws/cachedata/$VH_NAME
+}
+
+rewrite {
+ enable 1
+ autoLoadHtaccess 1
+}
+
+context /.well-known/acme-challenge {
+ location /usr/local/lsws/Example/html/.well-known/acme-challenge
+ allowBrowse 1
+
+ rewrite {
+ enable 0
+ }
+ addDefaultCharset off
+
+ phpIniOverride {
+
+ }
+}
+
+
+vhssl {
+ keyFile /etc/letsencrypt/live/worktracking.orbishosting.com/privkey.pem
+ certFile /etc/letsencrypt/live/worktracking.orbishosting.com/fullchain.pem
+ certChain 1
+ sslProtocol 24
+ enableECDHE 1
+ renegProtection 1
+ sslSessionCache 1
+ enableSpdy 15
+ enableStapling 1
+ ocspRespMaxAge 86400
+}
diff --git a/scripts/jarvis-agent.py b/scripts/jarvis-agent.py
index 1dc721a..d237438 100755
--- a/scripts/jarvis-agent.py
+++ b/scripts/jarvis-agent.py
@@ -281,7 +281,7 @@ def get_uptime() -> dict:
return {}
def get_services(cfg: dict) -> list:
- watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"])
+ watch = cfg.get("watch_services", [])
statuses = []
for svc in watch:
try:
diff --git a/scripts/jarvis-backup.sh b/scripts/jarvis-backup.sh
index 3b0f7af..c71fea0 100755
--- a/scripts/jarvis-backup.sh
+++ b/scripts/jarvis-backup.sh
@@ -10,12 +10,11 @@ KEEP_DAYS=7
DB_USER="root"
DB_PASS="b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20"
-DATABASES="jarvis_db toms_tjj_db tomt_ttg_db epic_parkersling epic_epic_db parker_db"
+DATABASES="toms_tjj_db tomt_ttg_db epic_parkersling epic_epic_db park_slingshot"
SITES=(
"/home/epictravelexpeditions.com/public_html"
"/home/epictravelexpeditions.com/parkerslingshot"
- "/home/jarvis.orbishosting.com/public_html"
"/home/orbishosting.com/public_html"
"/home/orbis.orbishosting.com/public_html"
"/home/parkerslingshotrentals.com/public_html"
diff --git a/sites/epictravelexpeditions.com/public_html/.gitignore b/sites/epictravelexpeditions.com/public_html/.gitignore
new file mode 100644
index 0000000..26add4a
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/.gitignore
@@ -0,0 +1,6 @@
+*.log
+.DS_Store
+*.swp
+
+api/config.php
+uploads/
diff --git a/sites/epictravelexpeditions.com/public_html/.htaccess b/sites/epictravelexpeditions.com/public_html/.htaccess
new file mode 100644
index 0000000..ed30591
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/.htaccess
@@ -0,0 +1,66 @@
+### Rewrite Rules Added by CyberPanel Rewrite Rule Generator
+
+RewriteEngine On
+RewriteCond %{HTTPS} !=on
+RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
+
+### End CyberPanel Generated Rules.
+
+# Epic Travel & Expeditions - CyberPanel LiteSpeed Configuration
+DirectoryIndex index.html index.php
+
+
+RewriteEngine On
+RewriteBase /
+
+# Block access to version control metadata and the raw DB schema dump.
+# These were previously reachable directly over HTTP (e.g. /.git/config,
+# /db/schema.sql) and leaked repo/DB internals - deny them outright.
+# Note: a direct RewriteRule pattern (no leading slash) was found NOT to be
+# honored consistently on this LiteSpeed setup for root-level paths; matching
+# on REQUEST_URI via RewriteCond is what's confirmed to work.
+RewriteCond %{REQUEST_URI} ^/(\.git|db/)
+RewriteRule .* - [F,L]
+
+# API Routes - Direct to PHP backend
+RewriteCond %{REQUEST_URI} ^/api/
+RewriteRule ^api/(.*)$ api/api/$1 [L,QSA]
+
+# Static files and directories - serve directly
+RewriteCond %{REQUEST_FILENAME} -f [OR]
+RewriteCond %{REQUEST_FILENAME} -d
+RewriteRule ^ - [L]
+
+# React Router - All other routes to index.html
+RewriteRule ^ index.html [L]
+
+
+# Security Headers
+
+Header set X-Content-Type-Options "nosniff"
+Header set X-Frame-Options "SAMEORIGIN"
+Header set X-XSS-Protection "1; mode=block"
+
+
+# Enable CORS for API
+
+ Header set Access-Control-Allow-Origin "*"
+ Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
+ Header set Access-Control-Allow-Headers "Content-Type, Authorization"
+
+
+# Disable directory browsing
+Options -Indexes +FollowSymLinks
+
+# PHP Settings
+
+php_value upload_max_filesize 10M
+php_value post_max_size 10M
+php_value memory_limit 256M
+php_value max_execution_time 300
+
+
+# Force use of index.html
+
+DirectoryIndex index.html index.php
+
diff --git a/sites/epictravelexpeditions.com/public_html/api/.htaccess b/sites/epictravelexpeditions.com/public_html/api/.htaccess
new file mode 100644
index 0000000..a04bdce
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/.htaccess
@@ -0,0 +1,60 @@
+# Epic Travel & Expeditions - LiteSpeed .htaccess for CyberPanel
+# Optimized for LiteSpeed Web Server
+
+
+ RewriteEngine On
+ RewriteBase /api/
+
+ # Block direct access to config.php / .env - this was reachable at
+ # /api/config.php in production (200 OK) despite the FilesMatch/Require
+ # rule below, which LiteSpeed appears not to honor from .htaccess here.
+ # Matching on REQUEST_URI (not a direct RewriteRule pattern) is what's
+ # confirmed to actually work on this LiteSpeed setup.
+ RewriteCond %{REQUEST_URI} /(config\.php|\.env)$
+ RewriteRule .* - [F,L]
+
+ # Route all requests to index.php
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteCond %{REQUEST_FILENAME} !-d
+ RewriteRule ^(.*)$ index.php/$1 [L,QSA]
+
+
+# LiteSpeed Cache Control
+
+ # Disable caching for API
+ CacheLookup off
+
+
+# Security Headers
+
+ Header set X-Content-Type-Options "nosniff"
+ Header set X-Frame-Options "SAMEORIGIN"
+ Header set X-XSS-Protection "1; mode=block"
+ Header set X-Powered-By "Epic Travel API"
+
+
+# Protect sensitive files
+
+ Require all denied
+
+
+# PHP Settings (LiteSpeed compatible)
+
+ php_value upload_max_filesize 10M
+ php_value post_max_size 10M
+ php_value max_execution_time 300
+ php_value max_input_time 300
+ php_value memory_limit 256M
+
+
+# Compression
+
+ AddOutputFilterByType DEFLATE application/json
+ AddOutputFilterByType DEFLATE text/plain
+ AddOutputFilterByType DEFLATE text/html
+
+
+# Browser Caching
+
+ ExpiresActive Off
+
diff --git a/sites/epictravelexpeditions.com/public_html/api/api/auth.php b/sites/epictravelexpeditions.com/public_html/api/api/auth.php
new file mode 100644
index 0000000..293a04e
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/api/auth.php
@@ -0,0 +1,55 @@
+getConnection();
+
+// Login endpoint
+if ($method === 'POST' && $id === 'login') {
+ $input = getJsonInput();
+
+ // Validate input
+ $errors = validateRequired($input, ['email', 'password']);
+ if (!empty($errors)) {
+ jsonResponse(['error' => implode(', ', $errors)], 400);
+ }
+
+ $email = sanitizeString($input['email']);
+ $password = $input['password'];
+
+ // Find admin user
+ $stmt = $db->prepare("SELECT * FROM admin_users WHERE email = ?");
+ $stmt->execute([$email]);
+ $admin = $stmt->fetch();
+
+ if (!$admin) {
+ jsonResponse(['error' => 'Invalid email or password'], 401);
+ }
+
+ // Verify password
+ if (!password_verify($password, $admin['password_hash'])) {
+ jsonResponse(['error' => 'Invalid email or password'], 401);
+ }
+
+ // Create token
+ $token = JWT::createToken($email);
+
+ jsonResponse([
+ 'access_token' => $token,
+ 'token_type' => 'bearer',
+ 'email' => $email
+ ]);
+}
+
+// Verify token endpoint
+if ($method === 'POST' && $id === 'verify') {
+ $payload = requireAuth();
+
+ jsonResponse([
+ 'valid' => true,
+ 'email' => $payload['sub']
+ ]);
+}
+
+jsonResponse(['error' => 'Invalid auth endpoint'], 404);
diff --git a/sites/epictravelexpeditions.com/public_html/api/api/categories.php b/sites/epictravelexpeditions.com/public_html/api/api/categories.php
new file mode 100644
index 0000000..cf6b4bd
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/api/categories.php
@@ -0,0 +1,64 @@
+getConnection();
+
+// GET all categories (public - needed for destination forms)
+if ($method === 'GET' && !$id) {
+ $stmt = $db->query('SELECT id, name FROM destination_categories ORDER BY name');
+ jsonResponse($stmt->fetchAll());
+}
+
+// POST add category (admin)
+if ($method === 'POST') {
+ requireAuth();
+ $input = getJsonInput();
+ $errors = validateRequired($input, ['name']);
+ if (!empty($errors)) jsonResponse(['error' => implode(', ', $errors)], 400);
+ $name = sanitizeString($input['name']);
+ try {
+ $stmt = $db->prepare('INSERT INTO destination_categories (name) VALUES (?)');
+ $stmt->execute([$name]);
+ $newId = $db->lastInsertId();
+ jsonResponse(['id' => $newId, 'name' => $name], 201);
+ } catch (Exception $e) {
+ jsonResponse(['error' => 'Category already exists'], 409);
+ }
+}
+
+// PUT rename category (admin) - also updates all destinations using it
+if ($method === 'PUT' && $id) {
+ requireAuth();
+ $input = getJsonInput();
+ $errors = validateRequired($input, ['name']);
+ if (!empty($errors)) jsonResponse(['error' => implode(', ', $errors)], 400);
+ $newName = sanitizeString($input['name']);
+ $stmt = $db->prepare('SELECT name FROM destination_categories WHERE id = ?');
+ $stmt->execute([$id]);
+ $old = $stmt->fetch();
+ if (!$old) jsonResponse(['error' => 'Category not found'], 404);
+ try {
+ $db->prepare('UPDATE destination_categories SET name = ? WHERE id = ?')->execute([$newName, $id]);
+ $db->prepare('UPDATE destinations SET category = ? WHERE category = ?')->execute([$newName, $old['name']]);
+ jsonResponse(['id' => $id, 'name' => $newName]);
+ } catch (Exception $e) {
+ jsonResponse(['error' => 'Category name already exists'], 409);
+ }
+}
+
+// DELETE category (admin) - only if no destinations use it
+if ($method === 'DELETE' && $id) {
+ requireAuth();
+ $stmt = $db->prepare('SELECT name FROM destination_categories WHERE id = ?');
+ $stmt->execute([$id]);
+ $cat = $stmt->fetch();
+ if (!$cat) jsonResponse(['error' => 'Category not found'], 404);
+ $stmt = $db->prepare('SELECT COUNT(*) FROM destinations WHERE category = ?');
+ $stmt->execute([$cat['name']]);
+ if ($stmt->fetchColumn() > 0) jsonResponse(['error' => 'Cannot delete — destinations are using this category'], 400);
+ $db->prepare('DELETE FROM destination_categories WHERE id = ?')->execute([$id]);
+ jsonResponse(['message' => 'Category deleted']);
+}
+
+jsonResponse(['error' => 'Invalid categories endpoint'], 404);
diff --git a/sites/epictravelexpeditions.com/public_html/api/api/contact.php b/sites/epictravelexpeditions.com/public_html/api/api/contact.php
new file mode 100644
index 0000000..cdbe424
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/api/contact.php
@@ -0,0 +1,38 @@
+getConnection();
+
+if ($method === 'POST') {
+ $input = getJsonInput();
+
+ $errors = validateRequired($input, ['name', 'email', 'message']);
+ if (!empty($errors)) {
+ jsonResponse(['error' => implode(', ', $errors)], 400);
+ }
+
+ if (!isValidEmail($input['email'])) {
+ jsonResponse(['error' => 'Invalid email address'], 400);
+ }
+
+ $name = sanitizeString($input['name']);
+ $email = sanitizeString($input['email']);
+ $message = $input['message'];
+ $id = generateUuid();
+
+ $stmt = $db->prepare("
+ INSERT INTO contacts (id, name, email, message, created_at)
+ VALUES (?, ?, ?, ?, NOW())
+ ");
+ $stmt->execute([$id, $name, $email, $message]);
+
+ // Send admin alert and customer confirmation via SendGrid
+ sendContactAlert($name, $email, $message);
+ sendContactConfirmation($email, $name);
+
+ jsonResponse(['message' => 'Contact form submitted successfully']);
+}
+
+jsonResponse(['error' => 'Method not allowed'], 405);
diff --git a/sites/epictravelexpeditions.com/public_html/api/api/destinations.php b/sites/epictravelexpeditions.com/public_html/api/api/destinations.php
new file mode 100644
index 0000000..96c1e9c
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/api/destinations.php
@@ -0,0 +1,139 @@
+getConnection();
+
+// GET all destinations or single destination
+if ($method === 'GET') {
+ if ($id) {
+ // Get single destination
+ $stmt = $db->prepare("SELECT * FROM destinations WHERE id = ?");
+ $stmt->execute([$id]);
+ $destination = $stmt->fetch();
+
+ if (!$destination) {
+ jsonResponse(['error' => 'Destination not found'], 404);
+ }
+
+ jsonResponse($destination);
+ } else {
+ // Get all destinations with optional filtering
+ $category = isset($_GET['category']) ? sanitizeString($_GET['category']) : null;
+ $search = isset($_GET['search']) ? sanitizeString($_GET['search']) : null;
+
+ $sql = "SELECT * FROM destinations WHERE 1=1";
+ $params = [];
+
+ if ($category && $category !== 'All') {
+ $sql .= " AND category = ?";
+ $params[] = $category;
+ }
+
+ if ($search) {
+ $sql .= " AND (name LIKE ? OR location LIKE ?)";
+ $params[] = "%$search%";
+ $params[] = "%$search%";
+ }
+
+ $sql .= " LIMIT 100";
+
+ $stmt = $db->prepare($sql);
+ $stmt->execute($params);
+ $destinations = $stmt->fetchAll();
+
+ jsonResponse($destinations);
+ }
+}
+
+// POST create new destination (admin only)
+if ($method === 'POST') {
+ requireAuth();
+
+ $input = getJsonInput();
+
+ $errors = validateRequired($input, ['name', 'location', 'description', 'image', 'category', 'rating', 'price']);
+ if (!empty($errors)) {
+ jsonResponse(['error' => implode(', ', $errors)], 400);
+ }
+
+ $id = generateUuid();
+ $stmt = $db->prepare("
+ INSERT INTO destinations (id, name, location, description, image, category, rating, price, currency, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
+ ");
+
+ $stmt->execute([
+ $id,
+ sanitizeString($input['name']),
+ sanitizeString($input['location']),
+ $input['description'],
+ $input['image'],
+ $input['category'],
+ $input['rating'],
+ $input['price'],
+ isset($input['currency']) ? $input['currency'] : 'USD'
+ ]);
+
+ // Fetch created destination
+ $stmt = $db->prepare("SELECT * FROM destinations WHERE id = ?");
+ $stmt->execute([$id]);
+ $destination = $stmt->fetch();
+
+ jsonResponse($destination, 201);
+}
+
+// PUT update destination (admin only)
+if ($method === 'PUT' && $id) {
+ requireAuth();
+
+ $input = getJsonInput();
+
+ // Build update query dynamically
+ $updates = [];
+ $params = [];
+
+ $allowedFields = ['name', 'location', 'description', 'image', 'category', 'rating', 'price', 'currency'];
+
+ foreach ($allowedFields as $field) {
+ if (isset($input[$field])) {
+ $updates[] = "$field = ?";
+ $params[] = $field === 'description' ? $input[$field] : sanitizeString($input[$field]);
+ }
+ }
+
+ if (empty($updates)) {
+ jsonResponse(['error' => 'No fields to update'], 400);
+ }
+
+ $params[] = $id;
+
+ $sql = "UPDATE destinations SET " . implode(', ', $updates) . " WHERE id = ?";
+ $stmt = $db->prepare($sql);
+ $stmt->execute($params);
+
+ // Fetch updated destination
+ $stmt = $db->prepare("SELECT * FROM destinations WHERE id = ?");
+ $stmt->execute([$id]);
+ $destination = $stmt->fetch();
+
+ jsonResponse($destination);
+}
+
+// DELETE destination (admin only)
+if ($method === 'DELETE' && $id) {
+ requireAuth();
+
+ // Delete destination (cascades to specials)
+ $stmt = $db->prepare("DELETE FROM destinations WHERE id = ?");
+ $stmt->execute([$id]);
+
+ if ($stmt->rowCount() === 0) {
+ jsonResponse(['error' => 'Destination not found'], 404);
+ }
+
+ jsonResponse(['message' => 'Destination deleted successfully']);
+}
+
+jsonResponse(['error' => 'Invalid destinations endpoint'], 404);
diff --git a/sites/epictravelexpeditions.com/public_html/api/api/newsletter.php b/sites/epictravelexpeditions.com/public_html/api/api/newsletter.php
new file mode 100644
index 0000000..c34d4d7
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/api/newsletter.php
@@ -0,0 +1,37 @@
+getConnection();
+
+if ($method === 'POST' && $id === 'subscribe') {
+ $input = getJsonInput();
+
+ if (!isset($input['email']) || !isValidEmail($input['email'])) {
+ jsonResponse(['error' => 'Valid email address is required'], 400);
+ }
+
+ $email = sanitizeString($input['email']);
+
+ // Check if already subscribed
+ $stmt = $db->prepare("SELECT id FROM newsletter_subscribers WHERE email = ?");
+ $stmt->execute([$email]);
+
+ if ($stmt->fetch()) {
+ jsonResponse(['message' => 'Email already subscribed']);
+ }
+
+ $id = generateUuid();
+
+ $stmt = $db->prepare("
+ INSERT INTO newsletter_subscribers (id, email, subscribed_at)
+ VALUES (?, ?, NOW())
+ ");
+
+ $stmt->execute([$id, $email]);
+
+ jsonResponse(['message' => 'Successfully subscribed to newsletter']);
+}
+
+jsonResponse(['error' => 'Invalid newsletter endpoint'], 404);
diff --git a/sites/epictravelexpeditions.com/public_html/api/api/specials.php b/sites/epictravelexpeditions.com/public_html/api/api/specials.php
new file mode 100644
index 0000000..3ca92c1
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/api/specials.php
@@ -0,0 +1,131 @@
+getConnection();
+
+// GET all specials
+if ($method === 'GET' && !$id) {
+ $stmt = $db->query("SELECT * FROM specials LIMIT 100");
+ $specials = $stmt->fetchAll();
+
+ // Parse JSON highlights
+ foreach ($specials as &$special) {
+ $special['highlights'] = json_decode($special['highlights'], true);
+ }
+
+ jsonResponse($specials);
+}
+
+// POST create special (admin only)
+if ($method === 'POST') {
+ requireAuth();
+
+ $input = getJsonInput();
+
+ $errors = validateRequired($input, ['destination_id', 'discount', 'end_date', 'highlights']);
+ if (!empty($errors)) {
+ jsonResponse(['error' => implode(', ', $errors)], 400);
+ }
+
+ // Check if destination exists
+ $stmt = $db->prepare("SELECT id FROM destinations WHERE id = ?");
+ $stmt->execute([$input['destination_id']]);
+ if (!$stmt->fetch()) {
+ jsonResponse(['error' => 'Destination not found'], 404);
+ }
+
+ // Check if special already exists for this destination
+ $stmt = $db->prepare("SELECT id FROM specials WHERE destination_id = ?");
+ $stmt->execute([$input['destination_id']]);
+ if ($stmt->fetch()) {
+ jsonResponse(['error' => 'Special already exists for this destination'], 400);
+ }
+
+ $id = generateUuid();
+ $highlights = json_encode($input['highlights']);
+
+ $stmt = $db->prepare("
+ INSERT INTO specials (id, destination_id, discount, end_date, highlights, image_path, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, NOW())
+ ");
+
+ $stmt->execute([
+ $id,
+ $input['destination_id'],
+ $input['discount'],
+ $input['end_date'],
+ $highlights,
+ isset($input['image_path']) ? $input['image_path'] : null
+ ]);
+
+ // Fetch created special
+ $stmt = $db->prepare("SELECT * FROM specials WHERE id = ?");
+ $stmt->execute([$id]);
+ $special = $stmt->fetch();
+ $special['highlights'] = json_decode($special['highlights'], true);
+
+ jsonResponse($special, 201);
+}
+
+// PUT update special (admin only)
+if ($method === 'PUT' && $id) {
+ requireAuth();
+
+ $input = getJsonInput();
+
+ $updates = [];
+ $params = [];
+
+ if (isset($input['discount'])) {
+ $updates[] = "discount = ?";
+ $params[] = $input['discount'];
+ }
+
+ if (isset($input['end_date'])) {
+ $updates[] = "end_date = ?";
+ $params[] = $input['end_date'];
+ }
+
+ if (isset($input['highlights'])) {
+ $updates[] = "highlights = ?";
+ $params[] = json_encode($input['highlights']);
+ }
+
+ if (empty($updates)) {
+ jsonResponse(['error' => 'No fields to update'], 400);
+ }
+
+ $params[] = $id;
+
+ $sql = "UPDATE specials SET " . implode(', ', $updates) . " WHERE id = ?";
+ $stmt = $db->prepare($sql);
+ $stmt->execute($params);
+
+ // Fetch updated special
+ $stmt = $db->prepare("SELECT * FROM specials WHERE id = ?");
+ $stmt->execute([$id]);
+ $special = $stmt->fetch();
+ $special['highlights'] = json_decode($special['highlights'], true);
+
+ jsonResponse($special);
+}
+
+// DELETE special by destination_id (admin only)
+if ($method === 'DELETE' && isset($pathParts[1]) && $pathParts[1] === 'destination' && isset($pathParts[2])) {
+ requireAuth();
+
+ $destinationId = $pathParts[2];
+
+ $stmt = $db->prepare("DELETE FROM specials WHERE destination_id = ?");
+ $stmt->execute([$destinationId]);
+
+ if ($stmt->rowCount() === 0) {
+ jsonResponse(['error' => 'Special not found for this destination'], 404);
+ }
+
+ jsonResponse(['message' => 'Special removed successfully']);
+}
+
+jsonResponse(['error' => 'Invalid specials endpoint'], 404);
diff --git a/sites/epictravelexpeditions.com/public_html/api/api/testimonials.php b/sites/epictravelexpeditions.com/public_html/api/api/testimonials.php
new file mode 100644
index 0000000..37648f2
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/api/testimonials.php
@@ -0,0 +1,103 @@
+getConnection();
+
+// GET approved testimonials (public)
+if ($method === 'GET' && !$id) {
+ $stmt = $db->query("SELECT id, full_name, location, message, image_path, created_at FROM testimonials WHERE status = 'approved' ORDER BY created_at DESC");
+ jsonResponse($stmt->fetchAll());
+}
+
+// GET all testimonials (admin)
+if ($method === 'GET' && $id === 'all') {
+ requireAuth();
+ $stmt = $db->query("SELECT * FROM testimonials ORDER BY created_at DESC");
+ jsonResponse($stmt->fetchAll());
+}
+
+// POST submit testimonial (public)
+if ($method === 'POST' && !$id) {
+ $input = getJsonInput();
+ $errors = validateRequired($input, ['full_name', 'location', 'message']);
+ if (!empty($errors)) {
+ jsonResponse(['error' => implode(', ', $errors)], 400);
+ }
+
+ $testimonialId = generateUuid();
+ $stmt = $db->prepare("INSERT INTO testimonials (id, full_name, location, message, image_path, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', NOW())");
+ $stmt->execute([
+ $testimonialId,
+ sanitizeString($input['full_name']),
+ sanitizeString($input['location']),
+ sanitizeString($input['message']),
+ isset($input['image_path']) ? $input['image_path'] : null
+ ]);
+ jsonResponse(['message' => 'Testimonial submitted successfully', 'id' => $testimonialId], 201);
+}
+
+// PUT approve/deny/edit testimonial (admin)
+if ($method === 'PUT' && $id) {
+ requireAuth();
+ $input = getJsonInput();
+ $updates = [];
+ $params = [];
+
+ if (isset($input['status']) && in_array($input['status'], ['pending', 'approved', 'denied'])) {
+ $updates[] = 'status = ?';
+ $params[] = $input['status'];
+ }
+ if (isset($input['full_name'])) { $updates[] = 'full_name = ?'; $params[] = sanitizeString($input['full_name']); }
+ if (isset($input['location'])) { $updates[] = 'location = ?'; $params[] = sanitizeString($input['location']); }
+ if (isset($input['message'])) { $updates[] = 'message = ?'; $params[] = sanitizeString($input['message']); }
+
+ if (empty($updates)) jsonResponse(['error' => 'No fields to update'], 400);
+
+ $params[] = $id;
+ $stmt = $db->prepare('UPDATE testimonials SET ' . implode(', ', $updates) . ' WHERE id = ?');
+ $stmt->execute($params);
+
+ $stmt = $db->prepare('SELECT * FROM testimonials WHERE id = ?');
+ $stmt->execute([$id]);
+ jsonResponse($stmt->fetch());
+}
+
+// DELETE testimonial (admin)
+if ($method === 'DELETE' && $id) {
+ requireAuth();
+ $stmt = $db->prepare('DELETE FROM testimonials WHERE id = ?');
+ $stmt->execute([$id]);
+ if ($stmt->rowCount() === 0) jsonResponse(['error' => 'Testimonial not found'], 404);
+ jsonResponse(['message' => 'Testimonial deleted']);
+}
+
+
+// POST upload testimonial image (public) - rate limited per IP to prevent
+// unauthenticated storage/bandwidth abuse (this endpoint has no login requirement).
+if ($method === "POST" && $id === "upload") {
+ $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+ $limitRow = $db->prepare("SELECT upload_count, window_start FROM upload_rate_limits WHERE ip_address = ?");
+ $limitRow->execute([$ip]);
+ $limit = $limitRow->fetch();
+
+ $windowFresh = $limit && (strtotime($limit['window_start']) > time() - 3600);
+ if ($windowFresh && (int)$limit['upload_count'] >= 5) {
+ jsonResponse(["error" => "Too many uploads. Please try again later."], 429);
+ }
+
+ if (!isset($_FILES["file"])) jsonResponse(["error" => "No file uploaded"], 400);
+ $result = handleImageUpload($_FILES["file"]);
+ if (isset($result['error'])) jsonResponse(["error" => $result['error']], $result['status']);
+
+ if ($windowFresh) {
+ $db->prepare("UPDATE upload_rate_limits SET upload_count = upload_count + 1 WHERE ip_address = ?")->execute([$ip]);
+ } else {
+ $db->prepare("REPLACE INTO upload_rate_limits (ip_address, upload_count, window_start) VALUES (?, 1, NOW())")->execute([$ip]);
+ }
+
+ jsonResponse(["url" => $result['url']]);
+}
+
+jsonResponse(['error' => 'Invalid testimonials endpoint'], 404);
diff --git a/sites/epictravelexpeditions.com/public_html/api/api/upload.php b/sites/epictravelexpeditions.com/public_html/api/api/upload.php
new file mode 100644
index 0000000..d2040c4
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/api/upload.php
@@ -0,0 +1,43 @@
+ 'No file uploaded'], 400);
+ }
+
+ $result = handleImageUpload($_FILES['file']);
+ if (isset($result['error'])) {
+ jsonResponse(['error' => $result['error']], $result['status']);
+ }
+
+ jsonResponse([
+ 'url' => $result['url'],
+ 'filename' => $result['filename']
+ ]);
+}
+
+// Serve uploaded images
+if ($method === 'GET' && isset($pathParts[1]) && $pathParts[1] === 'uploads' && isset($pathParts[2])) {
+ $filename = basename($pathParts[2]);
+ $filepath = UPLOAD_DIR . $filename;
+
+ if (!file_exists($filepath)) {
+ jsonResponse(['error' => 'Image not found'], 404);
+ }
+
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mimeType = finfo_file($finfo, $filepath);
+ finfo_close($finfo);
+
+ header('Content-Type: ' . $mimeType);
+ header('Content-Length: ' . filesize($filepath));
+ readfile($filepath);
+ exit;
+}
+
+jsonResponse(['error' => 'Invalid upload endpoint'], 404);
diff --git a/sites/epictravelexpeditions.com/public_html/api/includes/database.php b/sites/epictravelexpeditions.com/public_html/api/includes/database.php
new file mode 100644
index 0000000..4c6b7cf
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/includes/database.php
@@ -0,0 +1,52 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ];
+
+ $this->conn = new PDO($dsn, DB_USER, DB_PASS, $options);
+ } catch (PDOException $e) {
+ $this->handleError($e->getMessage());
+ }
+ }
+
+ public static function getInstance() {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ public function getConnection() {
+ return $this->conn;
+ }
+
+ private function handleError($message) {
+ if (DEBUG_MODE) {
+ die(json_encode(['error' => 'Database Error: ' . $message]));
+ } else {
+ die(json_encode(['error' => 'Database connection failed']));
+ }
+ }
+
+ // Prevent cloning
+ private function __clone() {}
+
+ // Prevent unserialization
+ public function __wakeup() {
+ throw new Exception("Cannot unserialize singleton");
+ }
+}
diff --git a/sites/epictravelexpeditions.com/public_html/api/includes/functions.php b/sites/epictravelexpeditions.com/public_html/api/includes/functions.php
new file mode 100644
index 0000000..6573e1e
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/includes/functions.php
@@ -0,0 +1,124 @@
+ ..., 'filename' => ...] on success, or
+ * ['error' => ..., 'status' => ...] on failure.
+ */
+function handleImageUpload($file) {
+ if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
+ return ['error' => 'File upload failed', 'status' => 400];
+ }
+
+ if ($file['size'] > MAX_UPLOAD_SIZE) {
+ return ['error' => 'File too large. Maximum size is 5MB', 'status' => 400];
+ }
+
+ $allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mimeType = finfo_file($finfo, $file['tmp_name']);
+ finfo_close($finfo);
+
+ if (!in_array($mimeType, $allowedTypes)) {
+ return ['error' => 'Invalid file type. Only JPG, PNG, and WebP allowed', 'status' => 400];
+ }
+
+ $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+ $filename = generateUuid() . '.' . $extension;
+ $filepath = UPLOAD_DIR . $filename;
+
+ if (!move_uploaded_file($file['tmp_name'], $filepath)) {
+ return ['error' => 'Failed to save file', 'status' => 500];
+ }
+
+ return ['url' => '/api/uploads/' . $filename, 'filename' => $filename];
+}
diff --git a/sites/epictravelexpeditions.com/public_html/api/includes/jwt.php b/sites/epictravelexpeditions.com/public_html/api/includes/jwt.php
new file mode 100644
index 0000000..dd8478c
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/includes/jwt.php
@@ -0,0 +1,117 @@
+ 'JWT', 'alg' => 'HS256']);
+ $payload = json_encode($payload);
+
+ $base64UrlHeader = self::base64UrlEncode($header);
+ $base64UrlPayload = self::base64UrlEncode($payload);
+
+ $signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
+ $base64UrlSignature = self::base64UrlEncode($signature);
+
+ return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
+ }
+
+ /**
+ * Decode and verify a JWT token
+ */
+ public static function decode($jwt, $secret) {
+ $parts = explode('.', $jwt);
+
+ if (count($parts) !== 3) {
+ return false;
+ }
+
+ list($base64UrlHeader, $base64UrlPayload, $base64UrlSignature) = $parts;
+
+ $signature = self::base64UrlDecode($base64UrlSignature);
+ $expectedSignature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
+
+ if (!hash_equals($signature, $expectedSignature)) {
+ return false;
+ }
+
+ $payload = json_decode(self::base64UrlDecode($base64UrlPayload), true);
+
+ // Check expiration
+ if (isset($payload['exp']) && $payload['exp'] < time()) {
+ return false;
+ }
+
+ return $payload;
+ }
+
+ /**
+ * Create authentication token
+ */
+ public static function createToken($email) {
+ $payload = [
+ 'sub' => $email,
+ 'iat' => time(),
+ 'exp' => time() + JWT_EXPIRY
+ ];
+
+ return self::encode($payload, JWT_SECRET_KEY);
+ }
+
+ /**
+ * Verify token from Authorization header
+ */
+ public static function verifyToken() {
+ $headers = getallheaders();
+
+ if (!isset($headers['Authorization'])) {
+ return false;
+ }
+
+ $authHeader = $headers['Authorization'];
+
+ if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
+ return false;
+ }
+
+ $token = $matches[1];
+ $payload = self::decode($token, JWT_SECRET_KEY);
+
+ return $payload;
+ }
+
+ /**
+ * Base64 URL encode
+ */
+ private static function base64UrlEncode($data) {
+ return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
+ }
+
+ /**
+ * Base64 URL decode
+ */
+ private static function base64UrlDecode($data) {
+ return base64_decode(str_replace(['-', '_'], ['+', '/'], $data));
+ }
+}
+
+/**
+ * Require authentication middleware
+ */
+function requireAuth() {
+ $payload = JWT::verifyToken();
+
+ if (!$payload) {
+ http_response_code(401);
+ echo json_encode(['error' => 'Unauthorized']);
+ exit;
+ }
+
+ return $payload;
+}
diff --git a/sites/epictravelexpeditions.com/public_html/api/includes/mailer.php b/sites/epictravelexpeditions.com/public_html/api/includes/mailer.php
new file mode 100644
index 0000000..8495a0a
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/includes/mailer.php
@@ -0,0 +1,97 @@
+ defined('MAIL_FROM') ? MAIL_FROM : 'noreply@epictravelexpeditions.com',
+ 'to' => $toEmail,
+ 'subject' => $subject,
+ 'html' => $htmlBody,
+ ];
+ if ($textBody) $payload['text'] = $textBody;
+
+ $ch = curl_init('https://platform.cyberpersons.com/email/v1/send');
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => json_encode($payload),
+ CURLOPT_HTTPHEADER => [
+ 'Authorization: Bearer ' . $apiKey,
+ 'Content-Type: application/json',
+ ],
+ CURLOPT_TIMEOUT => 20,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode === 202) return true;
+ error_log('[EpicTravel mailer] CyberMail HTTP ' . $httpCode . ' — ' . $response);
+ return false;
+}
+
+function sendContactAlert(string $name, string $email, string $message): bool {
+ $adminEmail = defined('ADMIN_EMAIL') ? ADMIN_EMAIL : 'admin@epictravelexpeditions.com';
+ $subject = "New Contact Form Submission from {$name}";
+ $html = '
+
+
+
Epic Travel Expeditions
+
+
+
Thanks for reaching out, ' . htmlspecialchars($toName) . '!
+
We received your message and our team will get back to you within 1–2 business days.
+
In the meantime, feel free to browse our destinations and current travel specials:
+
+
Adventure awaits,
The Epic Travel Team
+
+
+
© ' . date('Y') . ' Epic Travel Expeditions
+
+
';
+
+ return sendgridSend($toEmail, $toName, $subject, $html,
+ "Hi {$toName},\n\nThanks for contacting Epic Travel Expeditions! We'll get back to you within 1-2 business days.\n\nAdventure awaits,\nThe Epic Travel Team");
+}
diff --git a/sites/epictravelexpeditions.com/public_html/api/index.php b/sites/epictravelexpeditions.com/public_html/api/index.php
new file mode 100644
index 0000000..a1c8e4d
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/api/index.php
@@ -0,0 +1,79 @@
+ 'Epic Travel API is running',
+ 'status' => 'healthy'
+ ]);
+}
+
+// Route to appropriate handler
+try {
+ switch ($resource) {
+ case 'auth':
+ require __DIR__ . '/api/auth.php';
+ break;
+
+ case 'destinations':
+ require __DIR__ . '/api/destinations.php';
+ break;
+
+ case 'specials':
+ require __DIR__ . '/api/specials.php';
+ break;
+
+ case 'contact':
+ require __DIR__ . '/api/contact.php';
+ break;
+
+ case 'newsletter':
+ require __DIR__ . '/api/newsletter.php';
+ break;
+
+ case 'upload':
+ require __DIR__ . '/api/upload.php';
+ break;
+
+ case 'testimonials':
+ require __DIR__ . '/api/testimonials.php';
+ break;
+
+ case 'categories':
+ require __DIR__ . '/api/categories.php';
+ break;
+
+ default:
+ jsonResponse(['error' => 'Endpoint not found'], 404);
+ }
+} catch (Exception $e) {
+ if (DEBUG_MODE) {
+ jsonResponse(['error' => $e->getMessage()], 500);
+ } else {
+ jsonResponse(['error' => 'Internal server error'], 500);
+ }
+}
diff --git a/sites/epictravelexpeditions.com/public_html/asset-manifest.json b/sites/epictravelexpeditions.com/public_html/asset-manifest.json
new file mode 100644
index 0000000..c2c6425
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/asset-manifest.json
@@ -0,0 +1,13 @@
+{
+ "files": {
+ "main.css": "/static/css/main.7791f349.css",
+ "main.js": "/static/js/main.b20df6de.js",
+ "index.html": "/index.html",
+ "main.7791f349.css.map": "/static/css/main.7791f349.css.map",
+ "main.b20df6de.js.map": "/static/js/main.b20df6de.js.map"
+ },
+ "entrypoints": [
+ "static/css/main.7791f349.css",
+ "static/js/main.b20df6de.js"
+ ]
+}
\ No newline at end of file
diff --git a/sites/epictravelexpeditions.com/public_html/assets/og-image.jpg b/sites/epictravelexpeditions.com/public_html/assets/og-image.jpg
new file mode 100644
index 0000000..2ed2b32
--- /dev/null
+++ b/sites/epictravelexpeditions.com/public_html/assets/og-image.jpg
@@ -0,0 +1,21 @@
+💬 No testimonials here.
`;
+ list.forEach(t=>{
+ const av=t.image_path?`💬 No testimonials here.
`;
+ list.forEach(t=>{
+ const av=t.image_path?`No testimonials in this category.
';
+ return;
+ }
+ list.forEach(t => {
+ const card = document.createElement('div');
+ card.className = 'et-admin-card';
+ card.dataset.id = t.id;
+ card.innerHTML = `
+ ${avatarHtml(t)}
+
+
${t.full_name}
+
${t.location}
+ ${statusBadge(t.status)}
+
${t.message}
+
+
+ ${t.status !== 'approved' ? '' : ''}
+ ${t.status !== 'denied' ? '' : ''}
+
+
+
+
+ `;
+
+ card.querySelectorAll('[data-action]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const action = btn.dataset.action;
+ const textarea = card.querySelector('.et-edit-area');
+ try {
+ if (action === 'approve') await applyAction(t.id, { status: 'approved' });
+ else if (action === 'deny') await applyAction(t.id, { status: 'denied' });
+ else if (action === 'save') await applyAction(t.id, { message: textarea.value });
+ else if (action === 'delete') { if (!confirm('Delete this testimonial?')) return; await deleteTestimonial(t.id); }
+ reload();
+ } catch (e) { alert('Error: ' + e.message); }
+ });
+ });
+
+ container.appendChild(card);
+ });
+ }
+
+ async function buildAdminPanel(mountEl) {
+ const token = localStorage.getItem('auth_token');
+ const res = await fetch(`${API}/testimonials/all`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ const all = await res.json();
+
+ const section = document.createElement('div');
+ section.id = 'et-admin-section';
+ section.innerHTML = `
+