feat(http-endpoints): introduce HTTP endpoint output target stack
New output kind that POSTs the current strip frame to a user-configured HTTP endpoint, alongside WLED / MQTT / Hue. Stack mirrors the existing output-target shape end-to-end: storage model + store, FastAPI router + Pydantic schemas, JS feature module + modal template, router wiring in api/__init__.py and the modal include in index.html. Tests cover both the routes and the store.
This commit is contained in:
@@ -27,6 +27,7 @@ from .routes.update import router as update_router
|
||||
from .routes.assets import router as assets_router
|
||||
from .routes.home_assistant import router as home_assistant_router
|
||||
from .routes.mqtt import router as mqtt_router
|
||||
from .routes.http_endpoints import router as http_endpoints_router
|
||||
from .routes.game_integration import router as game_integration_router
|
||||
from .routes.audio_processing_templates import router as audio_processing_templates_router
|
||||
from .routes.audio_filters import router as audio_filters_router
|
||||
@@ -59,6 +60,7 @@ router.include_router(update_router)
|
||||
router.include_router(assets_router)
|
||||
router.include_router(home_assistant_router)
|
||||
router.include_router(mqtt_router)
|
||||
router.include_router(http_endpoints_router)
|
||||
router.include_router(game_integration_router)
|
||||
router.include_router(audio_processing_templates_router)
|
||||
router.include_router(audio_filters_router)
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""HTTP endpoint routes: CRUD + one-shot test."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_http_endpoint_store,
|
||||
)
|
||||
from ledgrab.api.schemas.http_endpoints import (
|
||||
HTTPEndpointCreate,
|
||||
HTTPEndpointListResponse,
|
||||
HTTPEndpointResponse,
|
||||
HTTPEndpointUpdate,
|
||||
HTTPTestRequest,
|
||||
HTTPTestResponse,
|
||||
)
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.http_endpoint import HTTPEndpoint
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.safe_source import safe_request_bounded, validate_polling_url
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _warn_if_plaintext_token(url: str, auth_token: str, *, action: str) -> None:
|
||||
"""Log a warning when an auth token would be sent over plaintext http://."""
|
||||
if auth_token and url.lower().startswith("http://"):
|
||||
logger.warning(
|
||||
"HTTP endpoint %s: auth_token will be sent over plaintext http:// to %s. "
|
||||
"Anyone on the network path can read it. Consider https:// if the "
|
||||
"target supports TLS.",
|
||||
action,
|
||||
url,
|
||||
)
|
||||
|
||||
|
||||
def _to_response(endpoint: HTTPEndpoint) -> HTTPEndpointResponse:
|
||||
return HTTPEndpointResponse(
|
||||
id=endpoint.id,
|
||||
name=endpoint.name,
|
||||
url=endpoint.url,
|
||||
method=endpoint.method,
|
||||
auth_token_set=bool(endpoint.auth_token),
|
||||
headers=dict(endpoint.headers),
|
||||
timeout_s=endpoint.timeout_s,
|
||||
description=endpoint.description,
|
||||
tags=endpoint.tags,
|
||||
icon=getattr(endpoint, "icon", "") or "",
|
||||
icon_color=getattr(endpoint, "icon_color", "") or "",
|
||||
created_at=endpoint.created_at,
|
||||
updated_at=endpoint.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/http/endpoints",
|
||||
response_model=HTTPEndpointListResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def list_http_endpoints(
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
endpoints = store.get_all_endpoints()
|
||||
return HTTPEndpointListResponse(
|
||||
endpoints=[_to_response(e) for e in endpoints],
|
||||
count=len(endpoints),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints",
|
||||
response_model=HTTPEndpointResponse,
|
||||
status_code=201,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def create_http_endpoint(
|
||||
data: HTTPEndpointCreate,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
validate_polling_url(data.url)
|
||||
_warn_if_plaintext_token(data.url, data.auth_token, action="create")
|
||||
try:
|
||||
endpoint = store.create_endpoint(
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
method=data.method,
|
||||
auth_token=data.auth_token,
|
||||
headers=data.headers,
|
||||
timeout_s=data.timeout_s,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
fire_entity_event("http_endpoint", "created", endpoint.id)
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
response_model=HTTPEndpointResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def get_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
try:
|
||||
endpoint = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
response_model=HTTPEndpointResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def update_http_endpoint(
|
||||
endpoint_id: str,
|
||||
data: HTTPEndpointUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
if data.url is not None:
|
||||
validate_polling_url(data.url)
|
||||
final_url = data.url
|
||||
final_token = data.auth_token
|
||||
if final_url is None or final_token is None:
|
||||
try:
|
||||
existing = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
if final_url is None:
|
||||
final_url = existing.url
|
||||
if final_token is None:
|
||||
final_token = existing.auth_token
|
||||
_warn_if_plaintext_token(final_url, final_token, action="update")
|
||||
try:
|
||||
endpoint = store.update_endpoint(
|
||||
endpoint_id,
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
method=data.method,
|
||||
auth_token=data.auth_token,
|
||||
headers=data.headers,
|
||||
timeout_s=data.timeout_s,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
fire_entity_event("http_endpoint", "updated", endpoint.id)
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
status_code=204,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def delete_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
try:
|
||||
store.delete_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
fire_entity_event("http_endpoint", "deleted", endpoint_id)
|
||||
|
||||
|
||||
async def _run_http_test(
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
timeout_s: float,
|
||||
) -> HTTPTestResponse:
|
||||
"""Shared one-shot fetch + response shaping for both test endpoints."""
|
||||
try:
|
||||
status, body_bytes, error = await safe_request_bounded(
|
||||
method, url, headers=headers, timeout=timeout_s
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return HTTPTestResponse(success=False, error=f"Unexpected error: {type(exc).__name__}")
|
||||
|
||||
if error and status == 0:
|
||||
return HTTPTestResponse(success=False, error=error)
|
||||
|
||||
try:
|
||||
body_text = body_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
body_text = body_bytes.decode("utf-8", errors="replace")
|
||||
try:
|
||||
body_json = json.loads(body_text) if body_text else None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
body_json = None
|
||||
|
||||
preview = body_text[:500] if body_text else None
|
||||
is_success = 200 <= status < 300
|
||||
return HTTPTestResponse(
|
||||
success=is_success,
|
||||
status_code=status,
|
||||
body_preview=preview,
|
||||
body_json=body_json,
|
||||
error=None if is_success else f"HTTP {status}",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints/test",
|
||||
response_model=HTTPTestResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def test_http_endpoint(
|
||||
data: HTTPTestRequest,
|
||||
_auth: AuthRequired,
|
||||
):
|
||||
"""One-shot fetch to validate URL + auth before saving."""
|
||||
headers = dict(data.headers)
|
||||
if data.auth_token and not any(k.lower() == "authorization" for k in headers):
|
||||
headers["Authorization"] = f"Bearer {data.auth_token}"
|
||||
return await _run_http_test(data.method, data.url, headers, data.timeout_s)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints/{endpoint_id}/test",
|
||||
response_model=HTTPTestResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def test_saved_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
"""Run the stored endpoint configuration (URL + auth + headers + timeout).
|
||||
|
||||
Useful for the "test" button on the endpoint card: avoids the user
|
||||
having to open the editor and re-enter the auth token (which is
|
||||
never returned to the client).
|
||||
"""
|
||||
try:
|
||||
endpoint = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
return await _run_http_test(
|
||||
endpoint.method,
|
||||
endpoint.url,
|
||||
endpoint.build_request_headers(),
|
||||
endpoint.timeout_s,
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""HTTP endpoint schemas (CRUD + one-shot test)."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# RFC 7230 token chars for header names + reject any control character in values.
|
||||
_HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
|
||||
_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def _validate_headers(value: Dict[str, str]) -> Dict[str, str]:
|
||||
"""Reject header names/values that could enable CRLF injection."""
|
||||
if value is None:
|
||||
return {}
|
||||
cleaned: Dict[str, str] = {}
|
||||
for name, raw in value.items():
|
||||
if not isinstance(name, str) or not isinstance(raw, str):
|
||||
raise ValueError(f"Header name/value must be strings: {name!r}")
|
||||
if not _HEADER_NAME_RE.match(name):
|
||||
raise ValueError(f"Invalid HTTP header name: {name!r}")
|
||||
if _HEADER_CONTROL_CHARS_RE.search(raw):
|
||||
raise ValueError(
|
||||
f"Invalid HTTP header value for {name!r} (contains control characters)"
|
||||
)
|
||||
cleaned[name] = raw
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_url(value: str) -> str:
|
||||
"""Reject URLs that embed ``user:pass@`` so credentials can't leak
|
||||
into server logs (e.g. via the plaintext-token warning helper).
|
||||
|
||||
Schemes + IP-block checks are enforced later by
|
||||
:func:`ledgrab.utils.safe_source.validate_polling_url`.
|
||||
"""
|
||||
if not value:
|
||||
return value
|
||||
parsed = urlparse(value)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError(
|
||||
"URL must not embed credentials (user:pass@). "
|
||||
"Use the auth_token field or a custom Authorization header instead."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class HTTPEndpointCreate(BaseModel):
|
||||
"""Request to create an HTTP endpoint."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
url: str = Field(min_length=1, description="http or https URL")
|
||||
method: Literal["GET", "HEAD"] = "GET"
|
||||
auth_token: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Optional bearer token — sent as 'Authorization: Bearer <token>'. "
|
||||
"Add a custom Authorization entry in `headers` to override."
|
||||
),
|
||||
)
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float = Field(default=10.0, gt=0)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPEndpointUpdate(BaseModel):
|
||||
"""Request to update an HTTP endpoint.
|
||||
|
||||
All fields optional — ``None`` keeps the existing value. Sending an
|
||||
empty string for ``auth_token`` CLEARS the stored token; omit the
|
||||
field (or send ``null``) to keep it.
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
url: Optional[str] = Field(None, min_length=1)
|
||||
method: Optional[Literal["GET", "HEAD"]] = None
|
||||
auth_token: Optional[str] = Field(None, description="null = keep existing; '' = clear.")
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
timeout_s: Optional[float] = Field(None, gt=0)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPEndpointResponse(BaseModel):
|
||||
"""HTTP endpoint response. Note: ``auth_token`` is NEVER returned —
|
||||
use ``auth_token_set`` to know whether one is configured."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
method: str
|
||||
auth_token_set: bool = False
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HTTPEndpointListResponse(BaseModel):
|
||||
endpoints: List[HTTPEndpointResponse]
|
||||
count: int
|
||||
|
||||
|
||||
class HTTPTestRequest(BaseModel):
|
||||
"""One-shot test request to validate URL + auth before saving."""
|
||||
|
||||
url: str
|
||||
method: Literal["GET", "HEAD"] = "GET"
|
||||
auth_token: str = ""
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float = Field(default=10.0, gt=0)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPTestResponse(BaseModel):
|
||||
success: bool
|
||||
status_code: Optional[int] = None
|
||||
body_preview: Optional[str] = Field(None, description="First 500 chars of the body")
|
||||
body_json: Any = None
|
||||
error: Optional[str] = None
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* HTTP Endpoints — CRUD, test, cards.
|
||||
*
|
||||
* An HTTP endpoint is a connection definition only (URL + auth +
|
||||
* headers + timeout). Polling cadence is owned by HTTPValueSource —
|
||||
* one endpoint can back many value sources at different intervals.
|
||||
*
|
||||
* Structurally mirrors `home-assistant-sources.ts` (HA-source CRUD UI):
|
||||
* register icon adapter → modal subclass with dirty-check → save/edit/
|
||||
* clone/delete handlers → card builder → event delegation.
|
||||
*/
|
||||
|
||||
import {
|
||||
_cachedHTTPEndpoints, httpEndpointsCache,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import {
|
||||
ICON_EDIT, ICON_TRASH, ICON_EYE, ICON_EYE_OFF,
|
||||
ICON_TEST, ICON_OK, ICON_WARNING,
|
||||
} from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { makeCardIconFields } from '../core/card-icon.ts';
|
||||
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import type { HTTPEndpoint, HTTPEndpointWritePayload, HTTPMethod, HTTPTestResponse } from '../types.ts';
|
||||
|
||||
registerIconEntityType('http_endpoint', makeSimpleIconAdapter<HTTPEndpoint>({
|
||||
cache: httpEndpointsCache,
|
||||
endpointPrefix: '/http/endpoints',
|
||||
reload: async () => {
|
||||
if (typeof (window as any).loadIntegrations === 'function') {
|
||||
await (window as any).loadIntegrations();
|
||||
}
|
||||
},
|
||||
typeLabelKey: 'device.icon.entity.http_endpoint',
|
||||
typeLabelFallback: 'HTTP endpoint',
|
||||
cardSelectors: (id) => [
|
||||
`[data-card-section="http-endpoints"] [data-id="${CSS.escape(id)}"]`,
|
||||
],
|
||||
}));
|
||||
|
||||
const ICON_HTTP = `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`;
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────
|
||||
|
||||
let _httpTagsInput: TagInput | null = null;
|
||||
let _httpMethodIconSelect: IconSelect | null = null;
|
||||
/** In-memory headers; mirrored into hidden snapshot field. */
|
||||
let _httpHeaders: Array<{ name: string; value: string }> = [];
|
||||
|
||||
class HTTPEndpointModal extends Modal {
|
||||
constructor() { super('http-endpoint-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; }
|
||||
if (_httpMethodIconSelect) { _httpMethodIconSelect.destroy(); _httpMethodIconSelect = null; }
|
||||
_httpHeaders = [];
|
||||
const out = document.getElementById('http-endpoint-test-output');
|
||||
if (out) { out.innerHTML = ''; out.style.display = 'none'; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
// Headers are compared as a sorted snapshot so the dirty-check is
|
||||
// immune to a backend serialization that emits keys in a different
|
||||
// order than the user originally entered them (false-positive
|
||||
// "discard changes?" on reopen of multi-header endpoints).
|
||||
const headersSnapshot = [..._httpHeaders]
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return {
|
||||
name: (document.getElementById('http-endpoint-name') as HTMLInputElement).value,
|
||||
url: (document.getElementById('http-endpoint-url') as HTMLInputElement).value,
|
||||
method: (document.getElementById('http-endpoint-method') as HTMLSelectElement).value,
|
||||
auth_token: (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value,
|
||||
timeout_s: (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value,
|
||||
description: (document.getElementById('http-endpoint-description') as HTMLInputElement).value,
|
||||
headers: JSON.stringify(headersSnapshot),
|
||||
tags: JSON.stringify(_httpTagsInput ? _httpTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const httpEndpointModal = new HTTPEndpointModal();
|
||||
|
||||
// ── Method IconSelect (GET / HEAD) ────────────────────────────
|
||||
|
||||
function _ensureMethodIconSelect() {
|
||||
const sel = document.getElementById('http-endpoint-method') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{
|
||||
value: 'GET',
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.download}</svg>`,
|
||||
label: 'GET',
|
||||
desc: t('http_endpoint.method.get.desc'),
|
||||
},
|
||||
{
|
||||
value: 'HEAD',
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`,
|
||||
label: 'HEAD',
|
||||
desc: t('http_endpoint.method.head.desc'),
|
||||
},
|
||||
];
|
||||
if (_httpMethodIconSelect) { _httpMethodIconSelect.updateItems(items); return; }
|
||||
_httpMethodIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||
}
|
||||
|
||||
// ── Headers list (key/value rows) ─────────────────────────────
|
||||
// Visual model mirrors `.group-child-row` (cards.css): bordered rows on
|
||||
// `--bg-color` with a subtle hover ring, inputs share the same height
|
||||
// and rounded corners, trash button on the right. Empty state matches
|
||||
// the `.pp-filter-empty` dashed-border pattern.
|
||||
|
||||
function _renderHeaderRows() {
|
||||
const list = document.getElementById('http-endpoint-headers-list');
|
||||
if (!list) return;
|
||||
if (_httpHeaders.length === 0) {
|
||||
list.innerHTML = `<div class="http-headers-empty">${escapeHtml(t('http_endpoint.headers.empty'))}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = _httpHeaders.map((h, idx) => `
|
||||
<div class="http-header-row" data-idx="${idx}">
|
||||
<span class="http-header-index" aria-hidden="true">${idx + 1}</span>
|
||||
<div class="http-header-fields">
|
||||
<input type="text" class="http-header-name" placeholder="${escapeHtml(t('http_endpoint.headers.name_placeholder'))}" value="${escapeHtml(h.name)}" spellcheck="false" autocomplete="off">
|
||||
<input type="text" class="http-header-value" placeholder="${escapeHtml(t('http_endpoint.headers.value_placeholder'))}" value="${escapeHtml(h.value)}" spellcheck="false" autocomplete="off">
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon btn-secondary http-header-remove" title="${escapeHtml(t('common.delete'))}" aria-label="${escapeHtml(t('common.delete'))}">${ICON_TRASH}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
list.querySelectorAll<HTMLInputElement>('.http-header-name').forEach((inp) => {
|
||||
inp.addEventListener('input', () => {
|
||||
const row = inp.closest<HTMLElement>('.http-header-row')!;
|
||||
const idx = parseInt(row.dataset.idx || '0', 10);
|
||||
if (_httpHeaders[idx]) _httpHeaders[idx].name = inp.value;
|
||||
});
|
||||
});
|
||||
list.querySelectorAll<HTMLInputElement>('.http-header-value').forEach((inp) => {
|
||||
inp.addEventListener('input', () => {
|
||||
const row = inp.closest<HTMLElement>('.http-header-row')!;
|
||||
const idx = parseInt(row.dataset.idx || '0', 10);
|
||||
if (_httpHeaders[idx]) _httpHeaders[idx].value = inp.value;
|
||||
});
|
||||
});
|
||||
list.querySelectorAll<HTMLButtonElement>('.http-header-remove').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest<HTMLElement>('.http-header-row')!;
|
||||
const idx = parseInt(row.dataset.idx || '0', 10);
|
||||
_httpHeaders.splice(idx, 1);
|
||||
_renderHeaderRows();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function addHTTPEndpointHeader() {
|
||||
_httpHeaders.push({ name: '', value: '' });
|
||||
_renderHeaderRows();
|
||||
// Focus the newest row so the user can immediately start typing.
|
||||
const list = document.getElementById('http-endpoint-headers-list');
|
||||
if (list) {
|
||||
const names = list.querySelectorAll<HTMLInputElement>('.http-header-name');
|
||||
names[names.length - 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Show / Close ──────────────────────────────────────────────
|
||||
|
||||
export async function showHTTPEndpointModal(editData: HTTPEndpoint | null = null): Promise<void> {
|
||||
const isEdit = !!editData;
|
||||
const titleKey = isEdit ? 'http_endpoint.edit' : 'http_endpoint.add';
|
||||
document.getElementById('http-endpoint-modal-title')!.innerHTML = `${ICON_HTTP} ${t(titleKey)}`;
|
||||
(document.getElementById('http-endpoint-id') as HTMLInputElement).value = editData?.id || '';
|
||||
(document.getElementById('http-endpoint-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
const nameInput = document.getElementById('http-endpoint-name') as HTMLInputElement;
|
||||
const urlInput = document.getElementById('http-endpoint-url') as HTMLInputElement;
|
||||
const methodSel = document.getElementById('http-endpoint-method') as HTMLSelectElement;
|
||||
const tokenInput = document.getElementById('http-endpoint-auth-token') as HTMLInputElement;
|
||||
const timeoutInput = document.getElementById('http-endpoint-timeout') as HTMLInputElement;
|
||||
const descInput = document.getElementById('http-endpoint-description') as HTMLInputElement;
|
||||
|
||||
nameInput.value = editData?.name || '';
|
||||
urlInput.value = editData?.url || '';
|
||||
methodSel.value = editData?.method || 'GET';
|
||||
tokenInput.value = ''; // never expose stored token
|
||||
tokenInput.type = 'password';
|
||||
timeoutInput.value = String(editData?.timeout_s ?? 10);
|
||||
descInput.value = editData?.description || '';
|
||||
|
||||
// Headers: snapshot from data into our editable buffer
|
||||
_httpHeaders = editData?.headers
|
||||
? Object.entries(editData.headers).map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
_renderHeaderRows();
|
||||
|
||||
_ensureMethodIconSelect();
|
||||
if (_httpMethodIconSelect) _httpMethodIconSelect.setValue(editData?.method || 'GET');
|
||||
|
||||
// Show "leave blank to keep" hint only when editing an endpoint
|
||||
// that already has a token configured.
|
||||
const tokenHint = document.getElementById('http-endpoint-token-hint');
|
||||
if (tokenHint) tokenHint.style.display = (isEdit && editData?.auth_token_set) ? '' : 'none';
|
||||
|
||||
// Reveal password toggle
|
||||
const revealBtn = document.getElementById('http-endpoint-token-reveal');
|
||||
if (revealBtn) revealBtn.innerHTML = ICON_EYE;
|
||||
|
||||
// Inject icon into the inline Test button
|
||||
const testBtnIcon = document.querySelector('#http-endpoint-test-btn .http-endpoint-test-btn-icon');
|
||||
if (testBtnIcon) testBtnIcon.innerHTML = ICON_TEST;
|
||||
|
||||
// Reset test output
|
||||
const out = document.getElementById('http-endpoint-test-output');
|
||||
if (out) { out.innerHTML = ''; out.style.display = 'none'; }
|
||||
|
||||
// Tags
|
||||
if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; }
|
||||
_httpTagsInput = new TagInput(
|
||||
document.getElementById('http-endpoint-tags-container'),
|
||||
{ placeholder: t('tags.placeholder') }
|
||||
);
|
||||
_httpTagsInput.setValue(isEdit ? (editData?.tags || []) : []);
|
||||
|
||||
httpEndpointModal.open();
|
||||
httpEndpointModal.snapshot();
|
||||
}
|
||||
|
||||
export async function closeHTTPEndpointModal(): Promise<void> {
|
||||
await httpEndpointModal.close();
|
||||
}
|
||||
|
||||
export function toggleHTTPEndpointTokenVisibility() {
|
||||
const inp = document.getElementById('http-endpoint-auth-token') as HTMLInputElement;
|
||||
const btn = document.getElementById('http-endpoint-token-reveal');
|
||||
if (!inp || !btn) return;
|
||||
if (inp.type === 'password') {
|
||||
inp.type = 'text';
|
||||
btn.innerHTML = ICON_EYE_OFF;
|
||||
} else {
|
||||
inp.type = 'password';
|
||||
btn.innerHTML = ICON_EYE;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Header collection (commits live <input> values to buffer) ─
|
||||
|
||||
function _collectHeaders(): Record<string, string> {
|
||||
// Pull current input values to be safe (in case of paste / IME).
|
||||
const list = document.getElementById('http-endpoint-headers-list');
|
||||
if (list) {
|
||||
list.querySelectorAll<HTMLElement>('.http-header-row').forEach((row) => {
|
||||
const idx = parseInt(row.dataset.idx || '0', 10);
|
||||
const name = (row.querySelector('.http-header-name') as HTMLInputElement)?.value || '';
|
||||
const value = (row.querySelector('.http-header-value') as HTMLInputElement)?.value || '';
|
||||
if (_httpHeaders[idx]) { _httpHeaders[idx].name = name; _httpHeaders[idx].value = value; }
|
||||
});
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const h of _httpHeaders) {
|
||||
const name = h.name.trim();
|
||||
if (!name) continue;
|
||||
out[name] = h.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
|
||||
export async function saveHTTPEndpoint(): Promise<void> {
|
||||
const id = (document.getElementById('http-endpoint-id') as HTMLInputElement).value;
|
||||
if (httpEndpointModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('http-endpoint-name') as HTMLInputElement).value.trim();
|
||||
const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim();
|
||||
const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod;
|
||||
const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value;
|
||||
const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value;
|
||||
const description = (document.getElementById('http-endpoint-description') as HTMLInputElement).value.trim() || undefined;
|
||||
const headers = _collectHeaders();
|
||||
const timeout_s = parseFloat(timeoutRaw);
|
||||
|
||||
if (!name) {
|
||||
httpEndpointModal.showError(t('http_endpoint.error.name_required'));
|
||||
return;
|
||||
}
|
||||
if (!url) {
|
||||
httpEndpointModal.showError(t('http_endpoint.error.url_required'));
|
||||
return;
|
||||
}
|
||||
if (isNaN(timeout_s) || timeout_s <= 0) {
|
||||
httpEndpointModal.showError(t('http_endpoint.error.timeout_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: HTTPEndpointWritePayload = {
|
||||
name, url, method, headers, timeout_s, description,
|
||||
tags: _httpTagsInput ? _httpTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
// Auth token semantics (per backend schema):
|
||||
// POST — empty string means "no token"
|
||||
// PUT new — non-empty replaces; empty string CLEARS; omit to KEEP existing.
|
||||
// For PUT we omit the field unless the user typed something so we don't
|
||||
// accidentally clear a previously-configured token.
|
||||
if (!id) {
|
||||
payload.auth_token = token;
|
||||
} else if (token) {
|
||||
payload.auth_token = token;
|
||||
}
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/http/endpoints/${id}` : '/http/endpoints';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success');
|
||||
httpEndpointModal.forceClose();
|
||||
httpEndpointsCache.invalidate();
|
||||
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
httpEndpointModal.showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit / Clone / Delete ─────────────────────────────────────
|
||||
|
||||
export async function editHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
||||
const data: HTTPEndpoint = await resp.json();
|
||||
await showHTTPEndpointModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
// Cloning never reveals the token — user must re-enter if needed.
|
||||
data.auth_token_set = false;
|
||||
await showHTTPEndpointModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('http_endpoint.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('http_endpoint.deleted'), 'success');
|
||||
httpEndpointsCache.invalidate();
|
||||
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test (in-form, pre-save) ──────────────────────────────────
|
||||
|
||||
/** Builds a `HTTPTestRequest` from the live form values and renders
|
||||
* the response inline. Works for both new and edit modes. */
|
||||
export async function testHTTPEndpoint(): Promise<void> {
|
||||
const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim();
|
||||
const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod;
|
||||
const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value;
|
||||
const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value;
|
||||
const headers = _collectHeaders();
|
||||
const timeout_s = parseFloat(timeoutRaw);
|
||||
|
||||
const out = document.getElementById('http-endpoint-test-output');
|
||||
if (!out) return;
|
||||
|
||||
// Render validation errors inline next to the Test button — the
|
||||
// modal-level banner at the top of the form is invisible from here.
|
||||
const renderValidationFail = (msg: string): void => {
|
||||
out.style.display = '';
|
||||
out.innerHTML = `<div class="http-test-result http-test-fail">
|
||||
<span class="http-test-badge http-test-badge-fail">${escapeHtml(t('http_endpoint.test.failed'))}</span>
|
||||
<code class="http-test-error">${escapeHtml(msg)}</code>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
if (!url) {
|
||||
renderValidationFail(t('http_endpoint.error.url_required'));
|
||||
return;
|
||||
}
|
||||
if (isNaN(timeout_s) || timeout_s <= 0) {
|
||||
renderValidationFail(t('http_endpoint.error.timeout_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const testBtn = document.getElementById('http-endpoint-test-btn');
|
||||
if (testBtn) testBtn.classList.add('loading');
|
||||
out.style.display = '';
|
||||
out.innerHTML = `<div class="http-test-pending">
|
||||
<span class="http-test-pending-spinner" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(t('http_endpoint.test.pending'))}</span>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/http/endpoints/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, method, auth_token: token, headers, timeout_s }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data: HTTPTestResponse = await resp.json();
|
||||
_renderTestResult(out, data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
out.innerHTML = _renderTestErrorHtml(e.message);
|
||||
} finally {
|
||||
if (testBtn) testBtn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
function _renderTestErrorHtml(message: string): string {
|
||||
return `<div class="http-test-result http-test-fail">
|
||||
<div class="http-test-line">
|
||||
<span class="http-test-badge http-test-badge-fail">${ICON_WARNING}<span>${escapeHtml(t('http_endpoint.test.failed'))}</span></span>
|
||||
</div>
|
||||
<code class="http-test-error">${escapeHtml(message)}</code>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) {
|
||||
const statusClass = data.success ? 'http-test-ok' : 'http-test-fail';
|
||||
const badgeClass = data.success ? 'http-test-badge-ok' : 'http-test-badge-fail';
|
||||
const badgeIcon = data.success ? ICON_OK : ICON_WARNING;
|
||||
const badgeText = data.success
|
||||
? t('http_endpoint.test.success')
|
||||
: t('http_endpoint.test.failed');
|
||||
const statusLine = data.status_code != null
|
||||
? `<span class="http-test-status" title="HTTP ${data.status_code}">${data.status_code}</span>`
|
||||
: '';
|
||||
const errLine = data.error && !data.success
|
||||
? `<code class="http-test-error">${escapeHtml(data.error)}</code>`
|
||||
: '';
|
||||
// Show JSON body when available — easier to copy a json_path from than raw text.
|
||||
let bodyHtml = '';
|
||||
let bodyLabel = '';
|
||||
if (data.body_json != null) {
|
||||
let pretty: string;
|
||||
try { pretty = JSON.stringify(data.body_json, null, 2); }
|
||||
catch { pretty = String(data.body_json); }
|
||||
bodyLabel = `<div class="http-test-body-label">${escapeHtml(t('http_endpoint.test.body.json'))}</div>`;
|
||||
bodyHtml = `<pre class="http-test-body">${escapeHtml(pretty)}</pre>`;
|
||||
} else if (data.body_preview) {
|
||||
bodyLabel = `<div class="http-test-body-label">${escapeHtml(t('http_endpoint.test.body.text'))}</div>`;
|
||||
bodyHtml = `<pre class="http-test-body">${escapeHtml(data.body_preview)}</pre>`;
|
||||
}
|
||||
out.innerHTML = `<div class="http-test-result ${statusClass}">
|
||||
<div class="http-test-line">
|
||||
<span class="http-test-badge ${badgeClass}">${badgeIcon}<span>${escapeHtml(badgeText)}</span></span>
|
||||
${statusLine}
|
||||
</div>
|
||||
${errLine}
|
||||
${bodyLabel}
|
||||
${bodyHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Card-level test (uses stored config, no form needed) ──────
|
||||
|
||||
async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const data: HTTPTestResponse = await resp.json();
|
||||
if (data.success) {
|
||||
const status = data.status_code != null ? ` (${data.status_code})` : '';
|
||||
showToast(`${t('http_endpoint.test.success')}${status}`, 'success');
|
||||
} else {
|
||||
const detail = data.error || `HTTP ${data.status_code ?? '?'}`;
|
||||
showToast(`${t('http_endpoint.test.failed')}: ${detail}`, 'error');
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(`${t('http_endpoint.test.failed')}: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card rendering ────────────────────────────────────────────
|
||||
|
||||
export function createHTTPEndpointCard(endpoint: HTTPEndpoint) {
|
||||
const hasAuth = !!endpoint.auth_token_set;
|
||||
const headerCount = Object.keys(endpoint.headers || {}).length;
|
||||
const leds: LedState[] = ['on'];
|
||||
|
||||
const chips: ModChipOpts[] = [
|
||||
{
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`,
|
||||
text: endpoint.url,
|
||||
title: endpoint.url,
|
||||
},
|
||||
{
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.refreshCw}</svg>`,
|
||||
text: endpoint.method,
|
||||
},
|
||||
];
|
||||
if (hasAuth) {
|
||||
chips.push({
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg>`,
|
||||
text: t('http_endpoint.auth.set'),
|
||||
});
|
||||
}
|
||||
if (headerCount > 0) {
|
||||
chips.push({
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`,
|
||||
text: t('http_endpoint.headers.count').replace('{n}', String(headerCount)),
|
||||
});
|
||||
}
|
||||
|
||||
const mod: ModCardOpts = {
|
||||
head: {
|
||||
badge: { text: 'HTTP · ENDPOINT' },
|
||||
name: endpoint.name,
|
||||
metaHtml: escapeHtml(endpoint.url),
|
||||
leds,
|
||||
...makeCardIconFields('http_endpoint', endpoint.id, endpoint),
|
||||
menu: {
|
||||
duplicateOnclick: `cloneHTTPEndpoint('${endpoint.id}')`,
|
||||
hideOnclick: `toggleCardHidden('http-endpoints','${endpoint.id}')`,
|
||||
deleteOnclick: `deleteHTTPEndpoint('${endpoint.id}')`,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
desc: endpoint.description || undefined,
|
||||
chips,
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: `${endpoint.method} · ${Math.round(endpoint.timeout_s)}s`,
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: '', title: t('http_endpoint.test'), dataAttrs: { 'data-action': 'test' } },
|
||||
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
|
||||
],
|
||||
},
|
||||
running: false,
|
||||
};
|
||||
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: endpoint.id, mod });
|
||||
const tagsHtml = renderTagChips(endpoint.tags);
|
||||
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
||||
}
|
||||
|
||||
// ── Event delegation ──────────────────────────────────────────
|
||||
|
||||
const _httpEndpointActions: Record<string, (id: string) => void> = {
|
||||
clone: cloneHTTPEndpoint,
|
||||
edit: editHTTPEndpoint,
|
||||
test: _testHTTPEndpointFromCard,
|
||||
};
|
||||
|
||||
export function initHTTPEndpointDelegation(container: HTMLElement): void {
|
||||
container.addEventListener('click', (e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const section = btn.closest<HTMLElement>('[data-card-section="http-endpoints"]');
|
||||
if (!section) return;
|
||||
const card = btn.closest<HTMLElement>('[data-id]');
|
||||
if (!card) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const id = card.getAttribute('data-id');
|
||||
if (!action || !id) return;
|
||||
|
||||
const handler = _httpEndpointActions[action];
|
||||
if (handler) {
|
||||
e.stopPropagation();
|
||||
handler(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Expose to global scope for HTML onclick handlers ──────────
|
||||
|
||||
window.showHTTPEndpointModal = showHTTPEndpointModal;
|
||||
window.closeHTTPEndpointModal = closeHTTPEndpointModal;
|
||||
window.saveHTTPEndpoint = saveHTTPEndpoint;
|
||||
window.editHTTPEndpoint = editHTTPEndpoint;
|
||||
window.cloneHTTPEndpoint = cloneHTTPEndpoint;
|
||||
window.deleteHTTPEndpoint = deleteHTTPEndpoint;
|
||||
window.testHTTPEndpoint = testHTTPEndpoint;
|
||||
window.addHTTPEndpointHeader = addHTTPEndpointHeader;
|
||||
window.toggleHTTPEndpointTokenVisibility = toggleHTTPEndpointTokenVisibility;
|
||||
@@ -0,0 +1,126 @@
|
||||
"""HTTP endpoint data model.
|
||||
|
||||
An ``HTTPEndpoint`` is a *connection definition*: where to fetch (URL),
|
||||
how to authenticate (bearer token), what headers to send, and how long
|
||||
to wait. It owns nothing about *what* to extract or *how often* to poll
|
||||
— those concerns live on consumers (HTTPValueSource for periodic polling
|
||||
+ extraction; potentially other consumers in future).
|
||||
|
||||
Mirrors the MQTT/HA source pattern (storage/mqtt_source.py,
|
||||
storage/home_assistant_source.py).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ledgrab.utils import secret_box
|
||||
|
||||
|
||||
def _parse_common(data: dict) -> dict:
|
||||
"""Extract common fields from a dict, parsing timestamps."""
|
||||
created = data.get("created_at", "")
|
||||
updated = data.get("updated_at", "")
|
||||
return {
|
||||
"id": data["id"],
|
||||
"name": data["name"],
|
||||
"created_at": (
|
||||
datetime.fromisoformat(created)
|
||||
if isinstance(created, str) and created
|
||||
else datetime.now(timezone.utc)
|
||||
),
|
||||
"updated_at": (
|
||||
datetime.fromisoformat(updated)
|
||||
if isinstance(updated, str) and updated
|
||||
else datetime.now(timezone.utc)
|
||||
),
|
||||
"description": data.get("description"),
|
||||
"tags": data.get("tags") or [],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTTPEndpoint:
|
||||
"""HTTP endpoint connection configuration."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
url: str = ""
|
||||
method: str = "GET" # "GET" | "HEAD"
|
||||
auth_token: str = "" # convenience: becomes Authorization: Bearer <token>
|
||||
headers: Dict[str, str] = field(default_factory=dict)
|
||||
timeout_s: float = 10.0
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
icon: str = ""
|
||||
icon_color: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Invariant: ``self.auth_token`` is always plaintext at runtime.
|
||||
# If a caller constructed this from a raw dict that still holds the
|
||||
# encrypted envelope, decrypt now so ``build_request_headers``
|
||||
# doesn't accidentally send ``Authorization: Bearer <envelope>``.
|
||||
if self.auth_token and secret_box.is_encrypted(self.auth_token):
|
||||
try:
|
||||
self.auth_token = secret_box.decrypt(self.auth_token)
|
||||
except Exception:
|
||||
self.auth_token = ""
|
||||
|
||||
@property
|
||||
def plaintext_token(self) -> str:
|
||||
return secret_box.decrypt(self.auth_token) if self.auth_token else ""
|
||||
|
||||
def build_request_headers(self) -> Dict[str, str]:
|
||||
"""Compose the headers actually sent on a fetch.
|
||||
|
||||
Order: explicit ``headers`` first, ``Authorization`` derived from
|
||||
``auth_token`` only if the caller did not already supply one.
|
||||
The check is case-insensitive so a user-supplied ``authorization``
|
||||
(lower) wins over the bearer-token shortcut.
|
||||
"""
|
||||
result: Dict[str, str] = dict(self.headers)
|
||||
already_has_auth = any(k.lower() == "authorization" for k in result)
|
||||
if self.auth_token and not already_has_auth:
|
||||
result["Authorization"] = f"Bearer {self.auth_token}"
|
||||
return result
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
# Always persist auth_token in encrypted envelope form. If the field
|
||||
# already contains an envelope, encrypt() is a no-op.
|
||||
stored_token = secret_box.encrypt(self.auth_token) if self.auth_token else ""
|
||||
d = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"url": self.url,
|
||||
"method": self.method,
|
||||
"auth_token": stored_token,
|
||||
"headers": dict(self.headers),
|
||||
"timeout_s": self.timeout_s,
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
if self.icon:
|
||||
d["icon"] = self.icon
|
||||
if self.icon_color:
|
||||
d["icon_color"] = self.icon_color
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "HTTPEndpoint":
|
||||
common = _parse_common(data)
|
||||
raw_token = data.get("auth_token", "")
|
||||
token = secret_box.decrypt(raw_token) if raw_token else ""
|
||||
return HTTPEndpoint(
|
||||
**common,
|
||||
url=data.get("url", ""),
|
||||
method=data.get("method", "GET"),
|
||||
auth_token=token,
|
||||
headers=dict(data.get("headers") or {}),
|
||||
timeout_s=float(data.get("timeout_s", 10.0)),
|
||||
icon=data.get("icon", ""),
|
||||
icon_color=data.get("icon_color", ""),
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""HTTP endpoint storage using SQLite. Mirrors MQTTSourceStore."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.storage.http_endpoint import HTTPEndpoint
|
||||
from ledgrab.utils import get_logger, secret_box
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HTTPEndpointStore(BaseSqliteStore[HTTPEndpoint]):
|
||||
"""Persistent storage for HTTP endpoint connection definitions."""
|
||||
|
||||
_table_name = "http_endpoints"
|
||||
_entity_name = "HTTP endpoint"
|
||||
|
||||
def __init__(self, db: Database):
|
||||
super().__init__(db, HTTPEndpoint.from_dict)
|
||||
self._migrate_plaintext_tokens()
|
||||
|
||||
def _migrate_plaintext_tokens(self) -> None:
|
||||
"""Encrypt any stored auth tokens still in plaintext at rest."""
|
||||
migrated = 0
|
||||
try:
|
||||
rows = self._db.load_all(self._table_name)
|
||||
except Exception as exc:
|
||||
logger.error("Could not inspect rows for HTTP token migration: %s", exc)
|
||||
return
|
||||
for row in rows:
|
||||
sid = row.get("id")
|
||||
raw = row.get("auth_token", "") or ""
|
||||
if not sid or not raw:
|
||||
continue
|
||||
if secret_box.is_encrypted(raw):
|
||||
continue
|
||||
endpoint = self._items.get(sid)
|
||||
if endpoint is None:
|
||||
continue
|
||||
try:
|
||||
self._save_item(sid, endpoint)
|
||||
migrated += 1
|
||||
except Exception as exc:
|
||||
logger.error("Failed to migrate HTTP token for %s: %s", sid, exc)
|
||||
if migrated:
|
||||
logger.warning(
|
||||
"MIGRATION: encrypted %d plaintext HTTP auth token(s) at rest.",
|
||||
migrated,
|
||||
)
|
||||
|
||||
# Backward-compatible aliases (mirror MQTT store)
|
||||
get_all_endpoints = BaseSqliteStore.get_all
|
||||
get_endpoint = BaseSqliteStore.get
|
||||
delete_endpoint = BaseSqliteStore.delete
|
||||
|
||||
def create_endpoint(
|
||||
self,
|
||||
name: str,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
auth_token: str = "",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout_s: float = 10.0,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
icon: Optional[str] = None,
|
||||
icon_color: Optional[str] = None,
|
||||
) -> HTTPEndpoint:
|
||||
if not url:
|
||||
raise ValueError("url is required")
|
||||
if method not in ("GET", "HEAD"):
|
||||
raise ValueError(f"Unsupported method: {method!r}. Use GET or HEAD.")
|
||||
if timeout_s <= 0:
|
||||
raise ValueError("timeout_s must be > 0")
|
||||
|
||||
self._check_name_unique(name)
|
||||
|
||||
eid = f"htep_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
endpoint = HTTPEndpoint(
|
||||
id=eid,
|
||||
name=name,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
url=url,
|
||||
method=method,
|
||||
auth_token=auth_token,
|
||||
headers=dict(headers or {}),
|
||||
timeout_s=timeout_s,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
icon=icon or "",
|
||||
icon_color=icon_color or "",
|
||||
)
|
||||
|
||||
self._items[eid] = endpoint
|
||||
self._save_item(eid, endpoint)
|
||||
logger.info(f"Created HTTP endpoint: {name} ({eid})")
|
||||
return endpoint
|
||||
|
||||
def update_endpoint(
|
||||
self,
|
||||
endpoint_id: str,
|
||||
name: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
method: Optional[str] = None,
|
||||
auth_token: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout_s: Optional[float] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
icon: Optional[str] = None,
|
||||
icon_color: Optional[str] = None,
|
||||
) -> HTTPEndpoint:
|
||||
existing = self.get(endpoint_id)
|
||||
|
||||
if name is not None and name != existing.name:
|
||||
self._check_name_unique(name)
|
||||
if method is not None and method not in ("GET", "HEAD"):
|
||||
raise ValueError(f"Unsupported method: {method!r}. Use GET or HEAD.")
|
||||
if timeout_s is not None and timeout_s <= 0:
|
||||
raise ValueError("timeout_s must be > 0")
|
||||
|
||||
updated = HTTPEndpoint(
|
||||
id=existing.id,
|
||||
name=name if name is not None else existing.name,
|
||||
created_at=existing.created_at,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
url=url if url is not None else existing.url,
|
||||
method=method if method is not None else existing.method,
|
||||
auth_token=auth_token if auth_token is not None else existing.auth_token,
|
||||
headers=dict(headers) if headers is not None else dict(existing.headers),
|
||||
timeout_s=timeout_s if timeout_s is not None else existing.timeout_s,
|
||||
description=description if description is not None else existing.description,
|
||||
tags=tags if tags is not None else existing.tags,
|
||||
icon=icon if icon is not None else existing.icon,
|
||||
icon_color=icon_color if icon_color is not None else existing.icon_color,
|
||||
)
|
||||
|
||||
self._items[endpoint_id] = updated
|
||||
self._save_item(endpoint_id, updated)
|
||||
logger.info(f"Updated HTTP endpoint: {updated.name} ({endpoint_id})")
|
||||
return updated
|
||||
@@ -256,6 +256,7 @@
|
||||
{% include 'modals/weather-source-editor.html' %}
|
||||
{% include 'modals/ha-source-editor.html' %}
|
||||
{% include 'modals/mqtt-source-editor.html' %}
|
||||
{% include 'modals/http-endpoint-editor.html' %}
|
||||
{% include 'modals/ha-light-editor.html' %}
|
||||
{% include 'modals/z2m-light-editor.html' %}
|
||||
{% include 'modals/asset-upload.html' %}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<!-- HTTP Endpoint Editor Modal — sectioned rack-panel layout
|
||||
matching the settings-modal-redesign vocabulary. Channels: signal
|
||||
(identity), cyan (request: URL/method/auth/timeout), amber
|
||||
(headers), notes (description). Test button lives in the request
|
||||
section and renders results inline. -->
|
||||
<div id="http-endpoint-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="http-endpoint-modal-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="http-endpoint-modal-title" data-i18n="http_endpoint.add">Add HTTP Endpoint</h2>
|
||||
<button class="modal-close-btn" onclick="closeHTTPEndpointModal()" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="http-endpoint-form" onsubmit="return false;">
|
||||
<input type="hidden" id="http-endpoint-id">
|
||||
|
||||
<div id="http-endpoint-error" class="error-message" style="display: none;"></div>
|
||||
|
||||
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
|
||||
<section class="ds-section" data-ds-key="identity" data-ch="signal">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
|
||||
<span class="ds-section-index" aria-hidden="true">01</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<div class="form-group ds-name-group">
|
||||
<div class="label-row">
|
||||
<label for="http-endpoint-name" data-i18n="http_endpoint.name">Name:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="http_endpoint.name.hint">A descriptive name for this endpoint</small>
|
||||
<input type="text" id="http-endpoint-name" data-i18n-placeholder="http_endpoint.name.placeholder" placeholder="Plex now-playing" required>
|
||||
<div id="http-endpoint-tags-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 02 · REQUEST ────────────────────────────────── -->
|
||||
<section class="ds-section" data-ds-key="request" data-ch="cyan">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="http_endpoint.section.request">Request</span>
|
||||
<span class="ds-section-index" aria-hidden="true">02</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="http-endpoint-url" data-i18n="http_endpoint.url">URL:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="http_endpoint.url.hint">Full http(s) URL to poll. Local addresses are allowed.</small>
|
||||
<input type="text" id="http-endpoint-url" placeholder="https://192.168.1.100/api/status" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="http-endpoint-method" data-i18n="http_endpoint.method">Method:</label>
|
||||
</div>
|
||||
<select id="http-endpoint-method">
|
||||
<option value="GET">GET</option>
|
||||
<option value="HEAD">HEAD</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="http-endpoint-auth-token" data-i18n="http_endpoint.auth_token">Auth Token (optional):</label>
|
||||
<button type="button" class="btn btn-icon btn-secondary" id="http-endpoint-token-reveal" onclick="toggleHTTPEndpointTokenVisibility()" data-i18n-title="http_endpoint.auth_token.reveal" title="Show/hide"></button>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="http_endpoint.auth_token.hint">Sent as 'Authorization: Bearer <token>'. Add a custom Authorization header to override.</small>
|
||||
<small id="http-endpoint-token-hint" class="input-hint" style="display:none" data-i18n="http_endpoint.auth_token.edit_hint">Leave blank to keep the current token</small>
|
||||
<input type="password" id="http-endpoint-auth-token" placeholder="Bearer token" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="http-endpoint-timeout" data-i18n="http_endpoint.timeout">Timeout (s):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="http_endpoint.timeout.hint">Maximum seconds to wait for a single request.</small>
|
||||
<input type="number" id="http-endpoint-timeout" min="0.5" step="0.5" value="10">
|
||||
</div>
|
||||
|
||||
<div class="form-group http-endpoint-test-group">
|
||||
<button type="button" class="btn btn-secondary http-endpoint-test-btn" id="http-endpoint-test-btn" onclick="testHTTPEndpoint()">
|
||||
<span class="http-endpoint-test-btn-icon" aria-hidden="true"></span>
|
||||
<span data-i18n="http_endpoint.test">Test request</span>
|
||||
</button>
|
||||
<div id="http-endpoint-test-output" class="http-test-output" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 03 · HEADERS ────────────────────────────────── -->
|
||||
<section class="ds-section" data-ds-key="headers" data-ch="amber">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="http_endpoint.section.headers">Headers</span>
|
||||
<span class="ds-section-index" aria-hidden="true">03</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="http_endpoint.headers">Custom Headers:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="http_endpoint.headers.hint">Optional request headers (e.g. X-API-Key, Accept).</small>
|
||||
<div id="http-endpoint-headers-list" class="http-headers-list"></div>
|
||||
<button type="button" class="btn btn-secondary btn-add-header" onclick="addHTTPEndpointHeader()">
|
||||
<span class="btn-add-header-icon" aria-hidden="true">+</span>
|
||||
<span data-i18n="http_endpoint.headers.add">Add header</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 04 · NOTES ──────────────────────────────────── -->
|
||||
<section class="ds-section" data-ds-key="notes" data-ch="amber">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="settings.section.notes">Notes</span>
|
||||
<span class="ds-section-index" aria-hidden="true">04</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="http-endpoint-description" data-i18n="http_endpoint.description">Description (optional):</label>
|
||||
</div>
|
||||
<input type="text" id="http-endpoint-description" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeHTTPEndpointModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveHTTPEndpoint()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tests for HTTP endpoint routes — CRUD, no-leak, /test, LAN policy."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ledgrab.api import dependencies as deps
|
||||
from ledgrab.api.routes.http_endpoints import router
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _route_db(tmp_path):
|
||||
from ledgrab.storage.database import Database
|
||||
|
||||
db = Database(tmp_path / "test.db")
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_store(_route_db):
|
||||
return HTTPEndpointStore(_route_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(http_store):
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
from ledgrab.api.auth import verify_api_key
|
||||
|
||||
app.dependency_overrides[verify_api_key] = lambda: "test-user"
|
||||
app.dependency_overrides[deps.get_http_endpoint_store] = lambda: http_store
|
||||
|
||||
# Stub fire_entity_event's processor_manager
|
||||
deps._deps["processor_manager"] = MagicMock()
|
||||
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD shape + no-leak guarantee
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCRUDRoutes:
|
||||
def test_create_and_list(self, client):
|
||||
r = client.post(
|
||||
"/api/v1/http/endpoints",
|
||||
json={
|
||||
"name": "Plex",
|
||||
"url": "https://example.com/status/sessions",
|
||||
"auth_token": "very-secret",
|
||||
"headers": {"Accept": "application/json"},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["id"].startswith("htep_")
|
||||
assert body["auth_token_set"] is True
|
||||
assert "very-secret" not in r.text
|
||||
|
||||
lst = client.get("/api/v1/http/endpoints").json()
|
||||
assert lst["count"] == 1
|
||||
|
||||
def test_response_never_exposes_auth_token(self, client):
|
||||
"""Defence-in-depth: scan every CRUD response for the token string."""
|
||||
token = "uniq-token-12345-no-collisions"
|
||||
post = client.post(
|
||||
"/api/v1/http/endpoints",
|
||||
json={
|
||||
"name": "x",
|
||||
"url": "https://example.com",
|
||||
"auth_token": token,
|
||||
},
|
||||
)
|
||||
assert post.status_code == 201
|
||||
endpoint_id = post.json()["id"]
|
||||
|
||||
get = client.get(f"/api/v1/http/endpoints/{endpoint_id}")
|
||||
put = client.put(f"/api/v1/http/endpoints/{endpoint_id}", json={"name": "renamed"})
|
||||
lst = client.get("/api/v1/http/endpoints")
|
||||
|
||||
for resp in (post, get, put, lst):
|
||||
assert token not in resp.text, f"token leaked in {resp.url}"
|
||||
|
||||
def test_method_allowlist_at_schema_layer(self, client):
|
||||
r = client.post(
|
||||
"/api/v1/http/endpoints",
|
||||
json={"name": "x", "url": "https://example.com", "method": "POST"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_crlf_in_header_rejected(self, client):
|
||||
r = client.post(
|
||||
"/api/v1/http/endpoints",
|
||||
json={
|
||||
"name": "x",
|
||||
"url": "https://example.com",
|
||||
"headers": {"X-Inject": "value\r\nX-Smuggle: yes"},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_invalid_header_name_rejected(self, client):
|
||||
r = client.post(
|
||||
"/api/v1/http/endpoints",
|
||||
json={
|
||||
"name": "x",
|
||||
"url": "https://example.com",
|
||||
"headers": {"Bad Name With Space": "v"},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /test one-shot endpoint + LAN policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOneShotEndpoint:
|
||||
@respx.mock
|
||||
def test_test_success(self, client):
|
||||
respx.get("https://example.com/status").mock(
|
||||
return_value=httpx.Response(200, json={"ok": True})
|
||||
)
|
||||
r = client.post(
|
||||
"/api/v1/http/endpoints/test",
|
||||
json={"url": "https://example.com/status"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["success"] is True
|
||||
assert body["status_code"] == 200
|
||||
assert body["body_json"] == {"ok": True}
|
||||
|
||||
@respx.mock
|
||||
def test_test_sends_auth_header(self, client):
|
||||
route = respx.get("https://example.com/secure").mock(
|
||||
return_value=httpx.Response(200, json={})
|
||||
)
|
||||
client.post(
|
||||
"/api/v1/http/endpoints/test",
|
||||
json={
|
||||
"url": "https://example.com/secure",
|
||||
"auth_token": "tok-abc",
|
||||
},
|
||||
)
|
||||
sent = route.calls.last.request
|
||||
assert sent.headers.get("authorization") == "Bearer tok-abc"
|
||||
|
||||
def test_test_rejects_loopback_url(self, client):
|
||||
# Loopback is always blocked, even with the relaxed polling policy.
|
||||
r = client.post(
|
||||
"/api/v1/http/endpoints/test",
|
||||
json={"url": "http://127.0.0.1:9000/"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_test_rejects_metadata_link_local(self, client):
|
||||
# AWS/GCP metadata at 169.254.169.254 stays blocked.
|
||||
r = client.post(
|
||||
"/api/v1/http/endpoints/test",
|
||||
json={"url": "http://169.254.169.254/latest/meta-data/"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
@respx.mock
|
||||
def test_test_allows_private_lan_ip(self, client):
|
||||
"""Relaxed polling policy MUST permit private IPs — primary use case."""
|
||||
respx.get("http://192.168.1.100/status").mock(
|
||||
return_value=httpx.Response(200, json={"ok": True})
|
||||
)
|
||||
r = client.post(
|
||||
"/api/v1/http/endpoints/test",
|
||||
json={"url": "http://192.168.1.100/status"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["success"] is True
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Tests for HTTPEndpointStore — CRUD + auth_token encryption + header policy."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.utils import secret_box
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_store(tmp_path):
|
||||
db = Database(tmp_path / "test.db")
|
||||
store = HTTPEndpointStore(db)
|
||||
yield store
|
||||
db.close()
|
||||
|
||||
|
||||
class TestCRUD:
|
||||
def test_create_basic(self, http_store):
|
||||
e = http_store.create_endpoint(
|
||||
name="Plex",
|
||||
url="https://plex.example.com/status/sessions",
|
||||
)
|
||||
assert e.id.startswith("htep_")
|
||||
assert e.url == "https://plex.example.com/status/sessions"
|
||||
assert e.method == "GET"
|
||||
assert e.timeout_s == 10.0
|
||||
assert e.headers == {}
|
||||
assert e.auth_token == ""
|
||||
|
||||
def test_create_with_auth_and_headers(self, http_store):
|
||||
e = http_store.create_endpoint(
|
||||
name="Plex",
|
||||
url="https://plex.example.com/status/sessions",
|
||||
auth_token="my-secret-token",
|
||||
headers={"Accept": "application/json"},
|
||||
timeout_s=5.0,
|
||||
)
|
||||
assert e.auth_token == "my-secret-token"
|
||||
assert e.headers == {"Accept": "application/json"}
|
||||
assert e.timeout_s == 5.0
|
||||
|
||||
def test_create_requires_url(self, http_store):
|
||||
with pytest.raises(ValueError, match="url is required"):
|
||||
http_store.create_endpoint(name="x", url="")
|
||||
|
||||
def test_create_rejects_unsupported_method(self, http_store):
|
||||
with pytest.raises(ValueError, match="Unsupported method"):
|
||||
http_store.create_endpoint(name="x", url="https://example.com", method="POST")
|
||||
|
||||
def test_create_rejects_zero_timeout(self, http_store):
|
||||
with pytest.raises(ValueError, match="timeout_s"):
|
||||
http_store.create_endpoint(name="x", url="https://example.com", timeout_s=0)
|
||||
|
||||
def test_unique_name(self, http_store):
|
||||
http_store.create_endpoint(name="dup", url="https://a")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
http_store.create_endpoint(name="dup", url="https://b")
|
||||
|
||||
def test_update(self, http_store):
|
||||
e = http_store.create_endpoint(name="Plex", url="https://a")
|
||||
updated = http_store.update_endpoint(
|
||||
e.id,
|
||||
url="https://b",
|
||||
auth_token="new-token",
|
||||
)
|
||||
assert updated.url == "https://b"
|
||||
assert updated.auth_token == "new-token"
|
||||
assert updated.created_at == e.created_at
|
||||
assert updated.updated_at > e.updated_at
|
||||
|
||||
def test_update_with_empty_token_clears(self, tmp_path):
|
||||
"""Sending ``auth_token=""`` MUST clear the stored token (the
|
||||
endpoint route distinguishes None=keep from ""=clear; the store
|
||||
layer is the source of truth for the on-disk state)."""
|
||||
db = Database(tmp_path / "test.db")
|
||||
store = HTTPEndpointStore(db)
|
||||
e = store.create_endpoint(name="x", url="https://a", auth_token="initial-secret")
|
||||
assert e.auth_token == "initial-secret"
|
||||
|
||||
cleared = store.update_endpoint(e.id, auth_token="")
|
||||
assert cleared.auth_token == ""
|
||||
|
||||
# Re-open the DB to confirm the change is persisted, not just in
|
||||
# the in-memory cache.
|
||||
db.close()
|
||||
db2 = Database(tmp_path / "test.db")
|
||||
store2 = HTTPEndpointStore(db2)
|
||||
reloaded = store2.get_endpoint(e.id)
|
||||
assert reloaded.auth_token == ""
|
||||
db2.close()
|
||||
|
||||
def test_delete(self, http_store):
|
||||
e = http_store.create_endpoint(name="x", url="https://a")
|
||||
http_store.delete_endpoint(e.id)
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
|
||||
with pytest.raises(EntityNotFoundError):
|
||||
http_store.get_endpoint(e.id)
|
||||
|
||||
|
||||
class TestTokenEncryption:
|
||||
"""auth_token must be encrypted at rest, decrypted on load."""
|
||||
|
||||
def test_token_encrypted_at_rest(self, tmp_path):
|
||||
db = Database(tmp_path / "test.db")
|
||||
store = HTTPEndpointStore(db)
|
||||
store.create_endpoint(name="x", url="https://a", auth_token="plaintext-secret")
|
||||
|
||||
rows = db.load_all("http_endpoints")
|
||||
assert len(rows) == 1
|
||||
raw = rows[0]["auth_token"]
|
||||
assert raw != "plaintext-secret"
|
||||
assert secret_box.is_encrypted(raw)
|
||||
db.close()
|
||||
|
||||
def test_token_decrypted_on_load(self, tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
db = Database(db_path)
|
||||
store = HTTPEndpointStore(db)
|
||||
eid = store.create_endpoint(name="x", url="https://a", auth_token="round-trip").id
|
||||
db.close()
|
||||
|
||||
db2 = Database(db_path)
|
||||
store2 = HTTPEndpointStore(db2)
|
||||
loaded = store2.get_endpoint(eid)
|
||||
assert loaded.auth_token == "round-trip"
|
||||
db2.close()
|
||||
|
||||
|
||||
class TestHeadersAndAuth:
|
||||
def test_build_request_headers_with_token(self, http_store):
|
||||
e = http_store.create_endpoint(
|
||||
name="x",
|
||||
url="https://a",
|
||||
auth_token="abc",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
h = e.build_request_headers()
|
||||
assert h["Authorization"] == "Bearer abc"
|
||||
assert h["Accept"] == "application/json"
|
||||
|
||||
def test_custom_authorization_header_wins(self, http_store):
|
||||
e = http_store.create_endpoint(
|
||||
name="x",
|
||||
url="https://a",
|
||||
auth_token="bearer-token",
|
||||
headers={"Authorization": "Basic dXNlcjpwYXNz"},
|
||||
)
|
||||
h = e.build_request_headers()
|
||||
assert h["Authorization"] == "Basic dXNlcjpwYXNz"
|
||||
|
||||
def test_case_insensitive_authorization_check(self, http_store):
|
||||
"""Lowercase 'authorization' supplied by the user must win too."""
|
||||
e = http_store.create_endpoint(
|
||||
name="x",
|
||||
url="https://a",
|
||||
auth_token="should-not-be-sent",
|
||||
headers={"authorization": "Basic xyz"},
|
||||
)
|
||||
h = e.build_request_headers()
|
||||
# No second Authorization key — user's lowercase variant is preserved as-is
|
||||
assert h.get("authorization") == "Basic xyz"
|
||||
assert "Authorization" not in h
|
||||
|
||||
def test_no_auth_no_header(self, http_store):
|
||||
e = http_store.create_endpoint(name="x", url="https://a")
|
||||
assert "Authorization" not in e.build_request_headers()
|
||||
Reference in New Issue
Block a user