#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Server-side search for WiFi GPS Mapper reports.
Search criteria are intentionally represented as a dictionary so that
new search blocks can be added without redesigning the search layer.
"""
import json
from html import escape
from reports.utils import (
format_datetime as format_report_datetime,
)
MAP_STYLE_URL = (
"http://127.0.0.1:8080/styles/server-map/style.json"
)
# ============================================================
# Search criteria
# ============================================================
def normalize_criteria(
vendor=None,
):
criteria = {}
if vendor is not None:
vendor = str(vendor).strip()
if vendor:
criteria["vendor"] = vendor
return criteria
def build_where_clause(criteria):
conditions = []
parameters = []
vendor = criteria.get("vendor")
if vendor:
conditions.append(
"""
LOWER(
REPLACE(
REPLACE(
REPLACE(
REPLACE(
REPLACE(
COALESCE(ap.vendor, ''),
'-',
''
),
' ',
''
),
'_',
''
),
'.',
''
),
'/',
''
)
) LIKE
'%' ||
LOWER(
REPLACE(
REPLACE(
REPLACE(
REPLACE(
REPLACE(
?,
'-',
''
),
' ',
''
),
'_',
''
),
'.',
''
),
'/',
''
)
) ||
'%'
"""
)
parameters.append(
vendor
)
if not conditions:
raise ValueError(
"At least one search criterion is required."
)
return (
" AND ".join(conditions),
parameters
)
# ============================================================
# Popular vendors
# ============================================================
def get_popular_vendors(
conn,
limit=20,
):
rows = conn.execute(
"""
SELECT
vendor,
COUNT(*) AS count
FROM access_points
WHERE
vendor IS NOT NULL
AND TRIM(vendor) != ''
GROUP BY vendor
ORDER BY
count DESC,
vendor COLLATE NOCASE ASC
LIMIT ?
""",
(
limit,
)
).fetchall()
return [
{
"vendor": row["vendor"],
"count": row["count"],
}
for row in rows
]
def get_matching_vendors(
conn,
criteria,
limit=20,
):
where_clause, parameters = (
build_where_clause(criteria)
)
rows = conn.execute(
f"""
SELECT
ap.vendor,
COUNT(*) AS count
FROM access_points AS ap
WHERE
{where_clause}
AND ap.vendor IS NOT NULL
AND TRIM(ap.vendor) != ''
GROUP BY
ap.vendor
ORDER BY
count DESC,
ap.vendor COLLATE NOCASE ASC
LIMIT ?
""",
parameters + [limit]
).fetchall()
return [
{
"vendor": row["vendor"],
"count": row["count"],
}
for row in rows
]
# ============================================================
# Access point search
# ============================================================
def search_access_points(
conn,
criteria,
):
where_clause, parameters = (
build_where_clause(criteria)
)
return conn.execute(
f"""
SELECT
ap.id,
ap.bssid,
ap.essid,
ap.encryption,
ap.cipher,
ap.akm,
ap.country,
ap.channel,
ap.frequency,
ap.vendor,
ap.first_seen,
ap.last_seen,
ap.times_seen,
ap.last_rssi,
ap.last_latitude,
ap.last_longitude,
ap.last_speed,
ap.has_handshake,
ap.has_pmkid,
ap.is_cracked
FROM access_points AS ap
WHERE
{where_clause}
ORDER BY
ap.vendor COLLATE NOCASE,
ap.essid COLLATE NOCASE,
ap.bssid
""",
parameters
).fetchall()
# ============================================================
# Credentials
# ============================================================
def load_credentials(
conn,
criteria,
):
where_clause, parameters = (
build_where_clause(criteria)
)
rows = conn.execute(
f"""
SELECT
c.access_point_id,
c.password,
c.source,
c.created_at,
c.verified
FROM credentials AS c
INNER JOIN access_points AS ap
ON ap.id = c.access_point_id
WHERE
{where_clause}
ORDER BY
c.access_point_id,
c.password
""",
parameters
).fetchall()
result = {}
for row in rows:
access_point_id = row["access_point_id"]
result.setdefault(
access_point_id,
[]
).append(
{
"password": row["password"],
"source": row["source"],
"created_at": row["created_at"],
"verified": bool(
row["verified"]
),
}
)
return result
# ============================================================
# Handshakes / PMKIDs
# ============================================================
def load_handshakes(
conn,
criteria,
):
where_clause, parameters = (
build_where_clause(criteria)
)
rows = conn.execute(
f"""
SELECT
h.id,
h.access_point_id,
h.type,
h.hash22000,
h.message_pair,
h.captured_at
FROM handshakes AS h
INNER JOIN access_points AS ap
ON ap.id = h.access_point_id
WHERE
{where_clause}
ORDER BY
h.access_point_id,
h.captured_at
""",
parameters
).fetchall()
result = {}
for row in rows:
access_point_id = row["access_point_id"]
result.setdefault(
access_point_id,
[]
).append(
{
"id": row["id"],
"type": row["type"],
"hash22000": row["hash22000"],
"message_pair": row["message_pair"],
"captured_at": row["captured_at"],
}
)
return result
# ============================================================
# Category
# ============================================================
def get_category(
access_point,
):
if access_point["is_cracked"]:
return "password"
if (
access_point["has_handshake"]
or
access_point["has_pmkid"]
):
return "handshake"
return "other"
# ============================================================
# Popup helpers
# ============================================================
def safe(value):
if value is None:
return "—"
text = str(value)
if not text:
return "—"
return escape(
text,
quote=True
)
def format_security(
access_point,
):
parts = []
if access_point["encryption"]:
parts.append(
access_point["encryption"]
)
if access_point["cipher"]:
parts.append(
access_point["cipher"]
)
if access_point["akm"]:
parts.append(
access_point["akm"]
)
if not parts:
return "—"
return escape(
" / ".join(
str(item)
for item in parts
),
quote=True
)
def popup_password(
access_point,
credentials,
):
password_items = []
for credential in credentials:
verified = (
"Yes"
if credential["verified"]
else
"No"
)
password_items.append(
"""
""".format(
password=safe(
credential["password"]
),
verified=verified,
source=safe(
credential["source"]
)
)
)
if not password_items:
password_items.append(
"""
"""
)
return """
""".format(
essid=safe(
access_point["essid"]
),
bssid=safe(
access_point["bssid"]
),
vendor=safe(
access_point["vendor"]
),
passwords="".join(
password_items
),
security=format_security(
access_point
),
channel=safe(
access_point["channel"]
),
frequency=safe(
access_point["frequency"]
),
rssi=safe(
access_point["last_rssi"]
),
first_seen=format_report_datetime(
access_point["first_seen"]
),
last_seen=format_report_datetime(
access_point["last_seen"]
),
times_seen=safe(
access_point["times_seen"]
)
)
def popup_handshake(
access_point,
handshakes,
):
eapol_count = 0
pmkid_count = 0
capture_times = []
for handshake in handshakes:
handshake_type = (
str(
handshake["type"]
or ""
).upper()
)
if "PMKID" in handshake_type:
pmkid_count += 1
else:
eapol_count += 1
if handshake["captured_at"]:
capture_times.append(
format_report_datetime(
handshake["captured_at"]
)
)
summary = []
if eapol_count:
summary.append(
f"EAPOL × {eapol_count}"
)
if pmkid_count:
summary.append(
f"PMKID × {pmkid_count}"
)
if not summary:
summary.append(
"Handshake / PMKID"
)
capture_html = ""
if capture_times:
capture_html = """
""".format(
times="".join(
"{}
".format(
safe(timestamp)
)
for timestamp in capture_times
)
)
return """
""".format(
essid=safe(
access_point["essid"]
),
bssid=safe(
access_point["bssid"]
),
vendor=safe(
access_point["vendor"]
),
summary=escape(
" · ".join(summary)
),
capture_html=capture_html,
security=format_security(
access_point
),
channel=safe(
access_point["channel"]
),
frequency=safe(
access_point["frequency"]
),
rssi=safe(
access_point["last_rssi"]
),
first_seen=format_report_datetime(
access_point["first_seen"]
),
last_seen=format_report_datetime(
access_point["last_seen"]
),
times_seen=safe(
access_point["times_seen"]
)
)
def popup_other(
access_point,
):
return """
""".format(
essid=safe(
access_point["essid"]
),
bssid=safe(
access_point["bssid"]
),
vendor=safe(
access_point["vendor"]
),
security=format_security(
access_point
),
channel=safe(
access_point["channel"]
),
frequency=safe(
access_point["frequency"]
),
rssi=safe(
access_point["last_rssi"]
),
first_seen=format_report_datetime(
access_point["first_seen"]
),
last_seen=format_report_datetime(
access_point["last_seen"]
),
times_seen=safe(
access_point["times_seen"]
)
)
# ============================================================
# GeoJSON
# ============================================================
def build_geojson(
access_points,
credentials,
handshakes,
):
features = []
for access_point in access_points:
latitude = access_point[
"last_latitude"
]
longitude = access_point[
"last_longitude"
]
if latitude is None or longitude is None:
continue
if (
latitude == 0
and
longitude == 0
):
continue
access_point_id = (
access_point["id"]
)
category = get_category(
access_point
)
if category == "password":
popup = popup_password(
access_point,
credentials.get(
access_point_id,
[]
)
)
elif category == "handshake":
popup = popup_handshake(
access_point,
handshakes.get(
access_point_id,
[]
)
)
else:
popup = popup_other(
access_point
)
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
float(longitude),
float(latitude)
]
},
"properties": {
"id": access_point_id,
"category": category,
"popup": popup,
}
}
)
return {
"type": "FeatureCollection",
"features": features
}
# ============================================================
# Hash export
# ============================================================
def get_hashes(
conn,
criteria,
):
where_clause, parameters = (
build_where_clause(criteria)
)
rows = conn.execute(
f"""
SELECT DISTINCT
h.hash22000
FROM handshakes AS h
INNER JOIN access_points AS ap
ON ap.id = h.access_point_id
WHERE
{where_clause}
AND h.hash22000 IS NOT NULL
AND TRIM(h.hash22000) != ''
ORDER BY
h.hash22000
""",
parameters
).fetchall()
return [
row["hash22000"]
for row in rows
]
def build_hash_export(
conn,
criteria,
):
hashes = get_hashes(
conn,
criteria
)
if not hashes:
return ""
return (
"\n".join(
str(item)
for item in hashes
)
+
"\n"
)
# ============================================================
# Search data
# ============================================================
def build_search_data(
conn,
criteria,
):
access_points = (
search_access_points(
conn,
criteria
)
)
credentials = (
load_credentials(
conn,
criteria
)
)
handshakes = (
load_handshakes(
conn,
criteria
)
)
geojson = build_geojson(
access_points,
credentials,
handshakes
)
hashes = get_hashes(
conn,
criteria
)
password_access_points = sum(
1
for access_point in access_points
if access_point["is_cracked"]
)
handshake_access_points = sum(
1
for access_point in access_points
if (
access_point["has_handshake"]
or
access_point["has_pmkid"]
)
)
matching_vendors = (
get_matching_vendors(
conn,
criteria
)
)
return {
"criteria": criteria,
"matched_access_points": len(
access_points
),
"mapped_access_points": len(
geojson["features"]
),
"password_access_points":
password_access_points,
"handshake_access_points":
handshake_access_points,
"hash_count": len(hashes),
"matching_vendors":
matching_vendors,
"geojson": geojson,
}
# ============================================================
# Search page
# ============================================================
def render_search_page(
conn,
context=None,
):
from reports.template import (
page_begin,
page_end,
)
if context is None:
from reports.template import (
ReportContext,
)
context = ReportContext(
mode="server"
)
maplibre_css_url = context.asset_url(
"maplibre/maplibre-gl.css"
)
popular_vendors = (
get_popular_vendors(conn)
)
vendor_items = []
for item in popular_vendors:
vendor = escape(
str(
item["vendor"]
),
quote=True
)
count = int(
item["count"]
)
vendor_items.append(
"""
{vendor}
{count}
""".format(
vendor=vendor,
count=count
)
)
if vendor_items:
suggestions_html = "".join(
vendor_items
)
else:
suggestions_html = """
"""
html = page_begin(
"Search",
"Search access points in the WiFi GPS Mapper database.",
"search",
context
)
html += """
Search by Vendor
Popular vendors
{suggestions}
Search by Access Point
Reserved for future search criteria:
ESSID, BSSID and other access point attributes.
""".replace(
"{suggestions}",
suggestions_html
).replace(
"{maplibre_css}",
maplibre_css_url
).replace(
"{maplibre_js}",
context.asset_url(
"maplibre/maplibre-gl.js"
)
).replace(
"{map_style}",
MAP_STYLE_URL
)
html += page_end()
return html