diff --git a/alembic/versions/a0c9c13e118f_add_node_public_key.py b/alembic/versions/a0c9c13e118f_add_node_public_key.py new file mode 100644 index 0000000..c07cde6 --- /dev/null +++ b/alembic/versions/a0c9c13e118f_add_node_public_key.py @@ -0,0 +1,43 @@ +"""Add node_public_key table + +Revision ID: a0c9c13e118f +Revises: d4d7b0c2e1a4 +Create Date: 2026-02-06 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a0c9c13e118f" +down_revision: str | None = "d4d7b0c2e1a4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "node_public_key", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("node_id", sa.BigInteger(), nullable=False), + sa.Column("public_key", sa.String(), nullable=False), + sa.Column("first_seen_us", sa.BigInteger(), nullable=True), + sa.Column("last_seen_us", sa.BigInteger(), nullable=True), + ) + op.create_index("idx_node_public_key_node_id", "node_public_key", ["node_id"], unique=False) + op.create_index( + "idx_node_public_key_public_key", + "node_public_key", + ["public_key"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("idx_node_public_key_public_key", table_name="node_public_key") + op.drop_index("idx_node_public_key_node_id", table_name="node_public_key") + op.drop_table("node_public_key") diff --git a/meshview/lang/en.json b/meshview/lang/en.json index 99f52d7..46b0a23 100644 --- a/meshview/lang/en.json +++ b/meshview/lang/en.json @@ -185,7 +185,14 @@ "statistics": "Statistics", "last_24h": "24h", "packets_sent": "Packets sent", - "times_seen": "Times seen" + "times_seen": "Times seen", + "copy_import_url": "Copy Import URL", + "show_qr_code": "Show QR Code", + "share_contact_qr": "Share Contact QR", + "copy_url": "Copy URL", + "copied": "Copied!", + "potential_impersonation": "Potential Impersonation Detected", + "scan_qr_to_add": "Scan this QR code to add this node as a contact on another device." }, "packet": { "loading": "Loading packet information...", diff --git a/meshview/lang/es.json b/meshview/lang/es.json index 0ff121a..8311fd2 100644 --- a/meshview/lang/es.json +++ b/meshview/lang/es.json @@ -171,7 +171,14 @@ "statistics": "Estadísticas", "last_24h": "24h", "packets_sent": "Paquetes enviados", - "times_seen": "Veces visto" + "times_seen": "Veces visto", + "copy_import_url": "Copiar URL de importación", + "show_qr_code": "Mostrar código QR", + "share_contact_qr": "Compartir contacto QR", + "copy_url": "Copiar URL", + "copied": "¡Copiado!", + "potential_impersonation": "Posible suplantación detectada", + "scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo." }, "packet": { diff --git a/meshview/models.py b/meshview/models.py index 55d7ed7..05765a6 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -100,7 +100,21 @@ class Traceroute(Base): import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True) __table_args__ = ( - Index("idx_traceroute_packet_id", "packet_id"), - Index("idx_traceroute_import_time_us", "import_time_us"), -) + Index("idx_traceroute_packet_id", "packet_id"), + Index("idx_traceroute_import_time_us", "import_time_us"), + ) + +class NodePublicKey(Base): + __tablename__ = "node_public_key" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + node_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + public_key: Mapped[str] = mapped_column(nullable=False) + first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True) + last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True) + + __table_args__ = ( + Index("idx_node_public_key_node_id", "node_id"), + Index("idx_node_public_key_public_key", "public_key"), + ) diff --git a/meshview/mqtt_database.py b/meshview/mqtt_database.py index 957c4c6..71440aa 100644 --- a/meshview/mqtt_database.py +++ b/meshview/mqtt_database.py @@ -1,6 +1,6 @@ +from sqlalchemy import event from sqlalchemy.engine.url import make_url from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from sqlalchemy import event from meshview import models diff --git a/meshview/mqtt_reader.py b/meshview/mqtt_reader.py index df29c73..642034a 100644 --- a/meshview/mqtt_reader.py +++ b/meshview/mqtt_reader.py @@ -86,6 +86,7 @@ if SECONDARY_KEYS: else: logger.info("Secondary keys: []") + # Thank you to "Robert Grizzell" for the decryption code! # https://github.com/rgrizzell def decrypt(packet, key): diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index 1c5015c..a69899b 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -1,4 +1,3 @@ -import datetime import logging import re import time @@ -12,7 +11,7 @@ from meshtastic.protobuf.config_pb2 import Config from meshtastic.protobuf.mesh_pb2 import HardwareModel from meshtastic.protobuf.portnums_pb2 import PortNum from meshview import decode_payload, mqtt_database -from meshview.models import Node, Packet, PacketSeen, Traceroute +from meshview.models import Node, NodePublicKey, Packet, PacketSeen, Traceroute logger = logging.getLogger(__name__) @@ -97,10 +96,9 @@ async def process_envelope(topic, env): "import_time_us": now_us, "channel": env.channel_id, } - utc_time = datetime.datetime.fromtimestamp(now_us / 1_000_000, datetime.UTC) dialect = session.get_bind().dialect.name stmt = None - + if dialect == "sqlite": stmt = ( sqlite_insert(Packet) @@ -208,6 +206,28 @@ async def process_envelope(topic, env): last_seen_us=now_us, ) session.add(node) + + if user.public_key: + public_key_hex = user.public_key.hex() + existing_key = ( + await session.execute( + select(NodePublicKey).where( + NodePublicKey.node_id == node_id, + NodePublicKey.public_key == public_key_hex, + ) + ) + ).scalar_one_or_none() + + if existing_key: + existing_key.last_seen_us = now_us + else: + new_key = NodePublicKey( + node_id=node_id, + public_key=public_key_hex, + first_seen_us=now_us, + last_seen_us=now_us, + ) + session.add(new_key) except Exception as e: print(f"Error processing NODEINFO_APP: {e}") diff --git a/meshview/store.py b/meshview/store.py index 8d7fac6..c85122c 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from sqlalchemy import Text, and_, cast, func, or_, select from sqlalchemy.orm import lazyload @@ -179,7 +179,7 @@ async def get_mqtt_neighbors(since): async def get_total_node_count(channel: str = None) -> int: try: async with database.async_session() as session: - now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) + now_us = int(datetime.now(datetime.UTC).timestamp() * 1_000_000) cutoff_us = now_us - 86400 * 1_000_000 q = select(func.count(Node.id)).where(Node.last_seen_us > cutoff_us) @@ -196,7 +196,7 @@ async def get_total_node_count(channel: str = None) -> int: async def get_top_traffic_nodes(): try: async with database.async_session() as session: - now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) + now_us = int(datetime.now(datetime.UTC).timestamp() * 1_000_000) cutoff_us = now_us - 86400 * 1_000_000 total_packets_sent = func.count(func.distinct(Packet.id)).label("total_packets_sent") total_times_seen = func.count(PacketSeen.packet_id).label("total_times_seen") @@ -244,7 +244,7 @@ async def get_top_traffic_nodes(): async def get_node_traffic(node_id: int): try: async with database.async_session() as session: - now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) + now_us = int(datetime.now(datetime.UTC).timestamp() * 1_000_000) cutoff_us = now_us - 86400 * 1_000_000 packet_count = func.count().label("packet_count") @@ -309,7 +309,7 @@ async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_a query = query.where(Node.hw_model == hw_model) if days_active is not None: - now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) + now_us = int(datetime.now(datetime.UTC).timestamp() * 1_000_000) cutoff_us = now_us - int(timedelta(days_active).total_seconds() * 1_000_000) query = query.where(Node.last_seen_us > cutoff_us) @@ -337,7 +337,7 @@ async def get_packet_stats( to_node: int | None = None, from_node: int | None = None, ): - now = datetime.now(timezone.utc) + now = datetime.now(datetime.UTC) if period_type == "hour": start_time = now - timedelta(hours=length) diff --git a/meshview/templates/node.html b/meshview/templates/node.html index ea75602..b6577bf 100644 --- a/meshview/templates/node.html +++ b/meshview/templates/node.html @@ -131,6 +131,195 @@ color: #9fd4ff; } .inline-link:hover { color: #c7e6ff; } + +/* --- QR Code & Import --- */ +.node-actions { + display: flex; + gap: 10px; + margin-bottom: 14px; + flex-wrap: wrap; +} +.node-actions { + display: flex; + gap: 10px; + margin-bottom: 16px; + flex-wrap: wrap; +} +.node-actions button { + background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%); + border: 1px solid #4a5568; + border-radius: 8px; + color: #e4e9ee; + padding: 8px 16px; + font-size: 0.9rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + transition: all 0.2s; +} +.node-actions button:hover { + background: linear-gradient(135deg, #3d4758 0%, #2a303c 100%); + border-color: #6a7788; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} +.node-actions button.copied { + background: linear-gradient(135deg, #276749 0%, #22543d 100%); + border-color: #48bb78; + color: #fff; +} +.copy-success { + color: #4ade80 !important; + transition: opacity 0.3s; +} + +/* --- QR Modal --- */ +#qrModal { + display:none; + position:fixed; + top:0; left:0; width:100%; height:100%; + background:rgba(0,0,0,0.95); + z-index:10000; + align-items:center; + justify-content:center; + backdrop-filter:blur(4px); +} +#qrModal > div { + background:linear-gradient(145deg, #1e2228, #16191d); + border:1px solid #3a4450; + border-radius:16px; + padding:28px; + max-width:380px; + text-align:center; + color:#e4e9ee; + box-shadow:0 25px 80px rgba(0,0,0,0.6); +} +#qrModal .qr-header { + display:flex; + justify-content:space-between; + align-items:center; + margin-bottom:16px; +} +#qrModal .qr-title { + font-size:1.3rem; + font-weight:600; + margin:0; + color:#9fd4ff; +} +#qrModal .qr-close { + background:rgba(255,255,255,0.05); + border:1px solid #4a5568; + color:#9ca3af; + width:32px; + height:32px; + border-radius:8px; + cursor:pointer; + font-size:1.2rem; + display:flex; + align-items:center; + justify-content:center; + transition:all 0.2s; +} +#qrModal .qr-close:hover { + background:rgba(255,255,255,0.1); + color:#fff; + border-color:#6a7788; +} +#qrModal .qr-node-name { + font-size:1.15rem; + color:#fff; + margin:12px 0 20px; + font-weight:500; +} +#qrModal .qr-image { + background:#fff; + padding:16px; + border-radius:12px; + display:inline-block; + margin-bottom:16px; + box-shadow:0 8px 30px rgba(0,0,0,0.4); +} +#qrModal .qr-image img { + display:block; + border-radius:4px; +} +#qrModal .qr-url-container { + background:rgba(0,0,0,0.4); + border-radius:8px; + padding:12px; + margin-bottom:18px; +} +#qrModal .qr-url { + font-size:0.65rem; + color:#9ca3af; + word-break:break-all; + font-family:'Monaco', 'Menlo', monospace; + line-height:1.4; + max-height:48px; + overflow-y:auto; + display:block; +} +#qrModal .qr-actions { + display:flex; + gap:12px; + justify-content:center; +} +#qrModal .qr-btn { + background:linear-gradient(135deg, #2d3748 0%, #1a202c 100%); + border:1px solid #4a5568; + color:#e4e9ee; + padding:12px 24px; + border-radius:10px; + cursor:pointer; + font-size:0.9rem; + font-weight:500; + transition:all 0.2s; + display:flex; + align-items:center; + gap:8px; + min-width:140px; + justify-content:center; +} +#qrModal .qr-btn:hover { + background:linear-gradient(135deg, #3d4758 0%, #2a303c 100%); + border-color:#6a7788; + transform:translateY(-2px); + box-shadow:0 4px 12px rgba(0,0,0,0.3); +} +#qrModal .qr-btn.copied { + background:linear-gradient(135deg, #276749 0%, #22543d 100%); + border-color:#48bb78; + color:#fff; +} + +/* --- Impersonation Warning --- */ +.impersonation-warning { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 14px; + display: flex; + align-items: flex-start; + gap: 10px; +} +.impersonation-warning .warning-icon { + font-size: 1.2rem; +} +.impersonation-warning .warning-content { + flex: 1; +} +.impersonation-warning .warning-title { + color: #f87171; + font-weight: bold; + margin-bottom: 4px; +} +.impersonation-warning .warning-text { + font-size: 0.85rem; + color: #ccc; +} {% endblock %} {% block body %} @@ -141,6 +330,25 @@ + +
+ + + +