mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 11:02:56 +02:00
402 lines
14 KiB
Python
402 lines
14 KiB
Python
import logging
|
|
import re
|
|
from hashlib import sha256
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.channel_constants import (
|
|
PUBLIC_CHANNEL_KEY,
|
|
PUBLIC_CHANNEL_NAME,
|
|
is_public_channel_key,
|
|
is_public_channel_name,
|
|
)
|
|
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
|
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
|
from app.packet_processor import create_message_from_decrypted
|
|
from app.region_scope import normalize_region_scope
|
|
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
|
from app.websocket import broadcast_event, broadcast_success
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/channels", tags=["channels"])
|
|
|
|
|
|
def _broadcast_channel_update(channel: Channel) -> None:
|
|
broadcast_event("channel", channel.model_dump())
|
|
|
|
|
|
class CreateChannelRequest(BaseModel):
|
|
name: str = Field(min_length=1, max_length=32)
|
|
key: str | None = Field(
|
|
default=None,
|
|
description="Channel key as hex string (32 chars = 16 bytes). If omitted or name starts with #, key is derived from name hash.",
|
|
)
|
|
|
|
|
|
class BulkCreateHashtagChannelsRequest(BaseModel):
|
|
channel_names: list[str] = Field(
|
|
min_length=1,
|
|
description="List of hashtag room names. Leading # is optional per entry.",
|
|
)
|
|
try_historical: bool = Field(
|
|
default=False,
|
|
description="Attempt one background historical decrypt sweep for the newly added rooms.",
|
|
)
|
|
|
|
|
|
class BulkCreateHashtagChannelsResponse(BaseModel):
|
|
created_channels: list[Channel]
|
|
existing_count: int
|
|
invalid_names: list[str]
|
|
decrypt_started: bool = False
|
|
decrypt_total_packets: int = 0
|
|
message: str
|
|
|
|
|
|
class ChannelFloodScopeOverrideRequest(BaseModel):
|
|
flood_scope_override: str = Field(
|
|
description="Blank clears the override; non-empty values temporarily override flood scope"
|
|
)
|
|
|
|
|
|
class ChannelPathHashModeOverrideRequest(BaseModel):
|
|
path_hash_mode_override: int | None = Field(
|
|
default=None,
|
|
ge=0,
|
|
le=2,
|
|
description="Path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
|
)
|
|
|
|
|
|
def _derive_channel_identity(
|
|
requested_name: str,
|
|
request_key: str | None = None,
|
|
) -> tuple[str, str, bool]:
|
|
is_hashtag = requested_name.startswith("#")
|
|
|
|
if is_public_channel_name(requested_name):
|
|
if request_key:
|
|
try:
|
|
key_bytes = bytes.fromhex(request_key)
|
|
if len(key_bytes) != 16:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
|
)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
|
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
|
)
|
|
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
|
|
|
|
if request_key and not is_hashtag:
|
|
try:
|
|
key_bytes = bytes.fromhex(request_key)
|
|
if len(key_bytes) != 16:
|
|
raise HTTPException(
|
|
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
|
)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
|
key_hex = key_bytes.hex().upper()
|
|
if is_public_channel_key(key_hex):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
|
)
|
|
return key_hex, requested_name, False
|
|
|
|
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
|
return key_bytes.hex().upper(), requested_name, is_hashtag
|
|
|
|
|
|
def _normalize_bulk_hashtag_name(name: str) -> str | None:
|
|
trimmed = name.strip()
|
|
if not trimmed:
|
|
return None
|
|
normalized = trimmed.lstrip("#").strip()
|
|
if not normalized:
|
|
return None
|
|
if len(normalized) > 31:
|
|
return None
|
|
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
|
|
return None
|
|
return f"#{normalized}"
|
|
|
|
|
|
async def _run_historical_channel_decryption_for_channels(
|
|
channels: list[tuple[bytes, str, str]],
|
|
) -> None:
|
|
total = await RawPacketRepository.get_undecrypted_count()
|
|
decrypted_count = 0
|
|
matched_channel_names: set[str] = set()
|
|
|
|
if total == 0:
|
|
logger.info("No undecrypted packets to process for bulk channel decrypt")
|
|
return
|
|
|
|
logger.info(
|
|
"Starting bulk historical channel decryption of %d packets across %d channels",
|
|
total,
|
|
len(channels),
|
|
)
|
|
|
|
async for (
|
|
packet_id,
|
|
packet_data,
|
|
packet_timestamp,
|
|
) in RawPacketRepository.stream_all_undecrypted():
|
|
packet_info = parse_packet(packet_data)
|
|
path_hex = packet_info.path.hex() if packet_info else None
|
|
path_len = packet_info.path_length if packet_info else None
|
|
|
|
for channel_key_bytes, channel_key_hex, channel_name in channels:
|
|
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
|
if result is None:
|
|
continue
|
|
|
|
msg_id = await create_message_from_decrypted(
|
|
packet_id=packet_id,
|
|
channel_key=channel_key_hex,
|
|
channel_name=channel_name,
|
|
sender=result.sender,
|
|
message_text=result.message,
|
|
timestamp=result.timestamp,
|
|
received_at=packet_timestamp,
|
|
path=path_hex,
|
|
path_len=path_len,
|
|
realtime=False,
|
|
)
|
|
if msg_id is not None:
|
|
decrypted_count += 1
|
|
matched_channel_names.add(channel_name)
|
|
break
|
|
|
|
logger.info(
|
|
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
|
|
decrypted_count,
|
|
total,
|
|
len(matched_channel_names),
|
|
)
|
|
|
|
if decrypted_count > 0:
|
|
broadcast_success(
|
|
"Bulk historical decrypt complete",
|
|
(
|
|
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
|
|
f"across {len(matched_channel_names)} room"
|
|
f"{'s' if len(matched_channel_names) != 1 else ''}"
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("", response_model=list[Channel])
|
|
async def list_channels() -> list[Channel]:
|
|
"""List all channels from the database."""
|
|
return await ChannelRepository.get_all()
|
|
|
|
|
|
@router.get("/{key}/detail", response_model=ChannelDetail)
|
|
async def get_channel_detail(key: str) -> ChannelDetail:
|
|
"""Get comprehensive channel profile data with message statistics."""
|
|
channel = await ChannelRepository.get_by_key(key)
|
|
if not channel:
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
stats = await MessageRepository.get_channel_stats(channel.key)
|
|
|
|
return ChannelDetail(
|
|
channel=channel,
|
|
message_counts=ChannelMessageCounts(**stats["message_counts"]),
|
|
first_message_at=stats["first_message_at"],
|
|
unique_sender_count=stats["unique_sender_count"],
|
|
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
|
|
path_hash_width_24h=stats["path_hash_width_24h"],
|
|
)
|
|
|
|
|
|
@router.post("", response_model=Channel)
|
|
async def create_channel(request: CreateChannelRequest) -> Channel:
|
|
"""Create a channel in the database.
|
|
|
|
Channels are NOT pushed to radio on creation. They are loaded to the radio
|
|
automatically when sending a message (see messages.py send_channel_message).
|
|
"""
|
|
requested_name = request.name
|
|
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
|
|
|
|
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
|
|
|
# Store in database only - radio sync happens at send time
|
|
await ChannelRepository.upsert(
|
|
key=key_hex,
|
|
name=channel_name,
|
|
is_hashtag=is_hashtag,
|
|
on_radio=False,
|
|
)
|
|
|
|
stored = await ChannelRepository.get_by_key(key_hex)
|
|
if stored is None:
|
|
raise HTTPException(status_code=500, detail="Channel was created but could not be reloaded")
|
|
|
|
_broadcast_channel_update(stored)
|
|
return stored
|
|
|
|
|
|
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
|
|
async def bulk_create_hashtag_channels(
|
|
request: BulkCreateHashtagChannelsRequest,
|
|
background_tasks: BackgroundTasks,
|
|
response: Response,
|
|
) -> BulkCreateHashtagChannelsResponse:
|
|
created_channels: list[Channel] = []
|
|
existing_count = 0
|
|
invalid_names: list[str] = []
|
|
decrypt_started = False
|
|
decrypt_total_packets = 0
|
|
decrypt_targets: list[tuple[bytes, str, str]] = []
|
|
|
|
for raw_name in request.channel_names:
|
|
normalized_name = _normalize_bulk_hashtag_name(raw_name)
|
|
if normalized_name is None:
|
|
invalid_names.append(raw_name)
|
|
continue
|
|
|
|
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
|
|
existing = await ChannelRepository.get_by_key(key_hex)
|
|
if existing is not None:
|
|
existing_count += 1
|
|
continue
|
|
|
|
await ChannelRepository.upsert(
|
|
key=key_hex,
|
|
name=channel_name,
|
|
is_hashtag=is_hashtag,
|
|
on_radio=False,
|
|
)
|
|
stored = await ChannelRepository.get_by_key(key_hex)
|
|
if stored is None:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Channel was created but could not be reloaded",
|
|
)
|
|
|
|
created_channels.append(stored)
|
|
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
|
|
_broadcast_channel_update(stored)
|
|
|
|
if request.try_historical and decrypt_targets:
|
|
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
|
|
if decrypt_total_packets > 0:
|
|
background_tasks.add_task(
|
|
_run_historical_channel_decryption_for_channels, decrypt_targets
|
|
)
|
|
decrypt_started = True
|
|
response.status_code = status.HTTP_202_ACCEPTED
|
|
|
|
message = (
|
|
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
|
|
if created_channels
|
|
else "No new rooms were added"
|
|
)
|
|
if request.try_historical and decrypt_targets:
|
|
if decrypt_started:
|
|
message += (
|
|
f" and started background decrypt of {decrypt_total_packets} packet"
|
|
f"{'s' if decrypt_total_packets != 1 else ''}"
|
|
)
|
|
else:
|
|
message += "; no undecrypted packets were available"
|
|
|
|
return BulkCreateHashtagChannelsResponse(
|
|
created_channels=created_channels,
|
|
existing_count=existing_count,
|
|
invalid_names=invalid_names,
|
|
decrypt_started=decrypt_started,
|
|
decrypt_total_packets=decrypt_total_packets,
|
|
message=message,
|
|
)
|
|
|
|
|
|
@router.post("/{key}/mark-read")
|
|
async def mark_channel_read(key: str) -> dict:
|
|
"""Mark a channel as read (update last_read_at timestamp)."""
|
|
channel = await ChannelRepository.get_by_key(key)
|
|
if not channel:
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
updated = await ChannelRepository.update_last_read_at(key)
|
|
if not updated:
|
|
raise HTTPException(status_code=500, detail="Failed to update read state")
|
|
|
|
return {"status": "ok", "key": channel.key}
|
|
|
|
|
|
@router.post("/{key}/flood-scope-override", response_model=Channel)
|
|
async def set_channel_flood_scope_override(
|
|
key: str, request: ChannelFloodScopeOverrideRequest
|
|
) -> Channel:
|
|
"""Set or clear a per-channel flood-scope override."""
|
|
channel = await ChannelRepository.get_by_key(key)
|
|
if not channel:
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
override = normalize_region_scope(request.flood_scope_override) or None
|
|
updated = await ChannelRepository.update_flood_scope_override(channel.key, override)
|
|
if not updated:
|
|
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
|
|
|
|
refreshed = await ChannelRepository.get_by_key(channel.key)
|
|
if refreshed is None:
|
|
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
|
|
|
broadcast_event("channel", refreshed.model_dump())
|
|
return refreshed
|
|
|
|
|
|
@router.post("/{key}/path-hash-mode-override", response_model=Channel)
|
|
async def set_channel_path_hash_mode_override(
|
|
key: str, request: ChannelPathHashModeOverrideRequest
|
|
) -> Channel:
|
|
"""Set or clear a per-channel path hash mode override."""
|
|
channel = await ChannelRepository.get_by_key(key)
|
|
if not channel:
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
updated = await ChannelRepository.update_path_hash_mode_override(
|
|
channel.key, request.path_hash_mode_override
|
|
)
|
|
if not updated:
|
|
raise HTTPException(status_code=500, detail="Failed to update path-hash-mode override")
|
|
|
|
refreshed = await ChannelRepository.get_by_key(channel.key)
|
|
if refreshed is None:
|
|
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
|
|
|
broadcast_event("channel", refreshed.model_dump())
|
|
return refreshed
|
|
|
|
|
|
@router.delete("/{key}")
|
|
async def delete_channel(key: str) -> dict:
|
|
"""Delete a channel from the database by key.
|
|
|
|
Note: This does not clear the channel from the radio. The radio's channel
|
|
slots are managed separately (channels are loaded temporarily when sending).
|
|
"""
|
|
if is_public_channel_key(key):
|
|
raise HTTPException(
|
|
status_code=400, detail="The canonical Public channel cannot be deleted"
|
|
)
|
|
|
|
logger.info("Deleting channel %s from database", key)
|
|
await ChannelRepository.delete(key)
|
|
|
|
broadcast_event("channel_deleted", {"key": key})
|
|
|
|
return {"status": "ok"}
|