Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,19 @@ auth:
# Whether to allow remote password reset via email.
# When disabled, the public password reset endpoint returns 403.
enable_remote_password_reset: false

# Outbound webhook dispatch (optional).
# Sends a signed HTTP POST to each endpoint when a subscribed event occurs,
# e.g. to trigger a Home Assistant, n8n, or Zapier automation on note sync.
# webhooks:
# enabled: true
# endpoints:
# - url: "https://n8n.homelab.local/webhook/supernote"
# # Shared secret used to HMAC-SHA256 sign each payload. Sent to the
# # endpoint in the `X-Supernote-Signature: sha256=<hex>` header so it
# # can verify the request came from this server and was not altered.
# secret: "CHANGE_ME_TO_A_SECURE_RANDOM_STRING"
# # Event names this endpoint wants to receive. Omit (or leave empty)
# # to receive every event.
# events:
# - "note.sync_completed"
1 change: 1 addition & 0 deletions supernote/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@
"summary",
"system",
"user",
"webhook",
]
41 changes: 41 additions & 0 deletions supernote/models/webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Data models for outbound webhook payloads.

See :mod:`supernote.server.services.webhook` for the dispatcher that builds
and signs these payloads before sending them to configured endpoints.
"""

from dataclasses import dataclass

from mashumaro.mixins.json import DataClassJSONMixin


@dataclass
class WebhookFileVO(DataClassJSONMixin):
"""File metadata included in a `note.sync_completed` webhook payload."""

id: int
"""The internal ID of the synced file."""

path: str
"""The full path of the file, relative to the user's root directory."""


@dataclass
class NoteSyncCompletedPayload(DataClassJSONMixin):
"""Payload sent for the `note.sync_completed` outbound webhook event.

Corresponds to `EVENT_NOTE_SYNC_COMPLETED` in
`supernote.server.services.webhook`.

Intentionally excludes the internal `user_id` (a private, per-user
database identifier) -- see that module for discussion.
"""

event: str
"""The webhook event name. Always `note.sync_completed` today."""

timestamp: int
"""Unix timestamp (seconds) of when the event was dispatched."""

file: WebhookFileVO
"""Metadata about the file that finished syncing."""
5 changes: 5 additions & 0 deletions supernote/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from .services.search import SearchService
from .services.summary import SummaryService
from .services.user import UserService
from .services.webhook import WebhookService
from .socket import setup_socketio
from .utils.hashing import get_md5_hash
from .utils.prompt_loader import PROMPT_LOADER
Expand Down Expand Up @@ -365,6 +366,9 @@ def create_app(config: ServerConfig) -> web.Application:
)
app["processor_service"] = processor_service

webhook_service = WebhookService(config.webhooks, event_bus)
app["webhook_service"] = webhook_service

# Register modules
processor_service.register_modules(
hashing=PageHashingModule(file_service=file_service),
Expand Down Expand Up @@ -467,6 +471,7 @@ async def on_startup_handler(app: web.Application) -> None:

logger.info("Starting background services...")
await processor_service.start()
webhook_service.start()
logger.info("Startup sequence complete.")

app["mcp_task"] = mcp_task
Expand Down
52 changes: 52 additions & 0 deletions supernote/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,49 @@ class Config(BaseConfig):
code_generation_options = [TO_DICT_ADD_OMIT_NONE_FLAG] # type: ignore[list-item]


@dataclass
class WebhookEndpointConfig(DataClassYAMLMixin):
"""A single outbound webhook subscriber."""

url: str = ""
"""The HTTP(S) endpoint that receives the webhook POST request."""

secret: str = ""
"""Shared secret used to HMAC-SHA256 sign the payload.

Never logged. Sent to receivers via the `X-Supernote-Signature` header so
they can verify the payload was not tampered with in transit.
"""

events: list[str] = field(default_factory=list)
"""Event names this endpoint wants to receive, e.g. `note.sync_completed`.

If empty (the default), the endpoint receives every event.
"""

class Config(BaseConfig):
omit_none = True
code_generation_options = [TO_DICT_ADD_OMIT_NONE_FLAG] # type: ignore[list-item]


@dataclass
class WebhookConfig(DataClassYAMLMixin):
"""Outbound webhook dispatch configuration."""

enabled: bool = False
"""Whether outbound webhook dispatch is enabled.

Env Var: `SUPERNOTE_WEBHOOKS_ENABLED`
"""

endpoints: list[WebhookEndpointConfig] = field(default_factory=list)
"""Configured webhook subscribers."""

class Config(BaseConfig):
omit_none = True
code_generation_options = [TO_DICT_ADD_OMIT_NONE_FLAG] # type: ignore[list-item]


@dataclass
class ServerConfig(DataClassYAMLMixin):
host: str = "0.0.0.0"
Expand Down Expand Up @@ -155,6 +198,9 @@ class ServerConfig(DataClassYAMLMixin):
Env Var: `SUPERNOTE_METRICS_PATH`
"""

webhooks: WebhookConfig = field(default_factory=WebhookConfig)
"""Outbound webhook dispatch configuration."""

@property
def configured_base_url(self) -> str | None:
"""Get the explicitly configured base URL, or None if unset.
Expand Down Expand Up @@ -344,6 +390,12 @@ def load(
config.metrics_path = metrics_path
logger.info(f"Using SUPERNOTE_METRICS_PATH: {config.metrics_path}")

if os.getenv("SUPERNOTE_WEBHOOKS_ENABLED"):
config.webhooks.enabled = _get_bool_env(
"SUPERNOTE_WEBHOOKS_ENABLED", config.webhooks.enabled
)
logger.info(f"Webhooks Enabled: {config.webhooks.enabled}")

if config.trace_log_file is None:
config.trace_log_file = str(
Path(config.storage_dir) / "system" / "trace.log"
Expand Down
154 changes: 154 additions & 0 deletions supernote/server/services/webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Outbound webhook dispatch for server events.

Listens on the internal :class:`~supernote.server.events.LocalEventBus` and,
for every configured endpoint subscribed to a given event name, sends a
signed HTTP POST notification. Delivery is best-effort: a slow or failing
endpoint is logged and skipped, it never raises back into the caller and
never blocks or delays the operation that triggered the event (e.g. a
device sync request).
"""

import asyncio
import hashlib
import hmac
import json
import logging
import time
from typing import Any

import aiohttp

from supernote.models.webhook import NoteSyncCompletedPayload, WebhookFileVO

from ..config import WebhookConfig, WebhookEndpointConfig
from ..events import Event, LocalEventBus, NoteUpdatedEvent

logger = logging.getLogger(__name__)

# Name of the event dispatched when a .note file finishes syncing to the
# server. Fired from the same point the processing pipeline is enqueued
# (see NoteUpdatedEvent), i.e. as soon as the device's upload completes.
EVENT_NOTE_SYNC_COMPLETED = "note.sync_completed"

SIGNATURE_HEADER = "X-Supernote-Signature"
EVENT_HEADER = "X-Supernote-Event"

REQUEST_TIMEOUT_SECONDS = 10.0
MAX_ATTEMPTS = 3
RETRY_BACKOFF_SECONDS = 1.0


def sign_payload(secret: str, body: bytes) -> str:
"""Compute the `sha256=<hex>` HMAC signature for a webhook payload body."""
digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"


class WebhookService:
"""Dispatches signed outbound webhook notifications for subscribed events."""

def __init__(self, config: WebhookConfig, event_bus: LocalEventBus) -> None:
self.config = config
self.event_bus = event_bus

def start(self) -> None:
"""Subscribe to the events that can trigger webhook dispatch."""
if not self.config.enabled or not self.config.endpoints:
logger.debug("Webhook dispatch disabled or no endpoints configured.")
return
self.event_bus.subscribe(NoteUpdatedEvent, self._handle_note_updated)
logger.info(
f"WebhookService started with {len(self.config.endpoints)} endpoint(s)."
)

async def _handle_note_updated(self, event: Event) -> None:
"""Translate a NoteUpdatedEvent into a note.sync_completed webhook."""
if not isinstance(event, NoteUpdatedEvent):
return
# Note: intentionally does not include event.user_id. It's an
# internal database identifier, not something we want to hand to
# third-party endpoints. See PR #217 for discussion on how
# per-user webhook delivery might work in a future iteration.
payload = NoteSyncCompletedPayload(
event=EVENT_NOTE_SYNC_COMPLETED,
timestamp=int(time.time()),
file=WebhookFileVO(id=event.file_id, path=event.file_path),
)
await self.dispatch(EVENT_NOTE_SYNC_COMPLETED, payload.to_dict())

async def dispatch(self, event_name: str, payload: dict[str, Any]) -> None:
"""Send `payload` to every configured endpoint subscribed to `event_name`.

An endpoint with no `events` configured is subscribed to everything
(see `WebhookEndpointConfig.events`). Never raises: endpoints are
notified concurrently and independently, and one endpoint failing has
no effect on delivery to the others.
"""
endpoints = [
ep
for ep in self.config.endpoints
if not ep.events or event_name in ep.events
]
if not endpoints:
return
results = await asyncio.gather(
*(self._send(endpoint, event_name, payload) for endpoint in endpoints),
return_exceptions=True,
)
for endpoint, result in zip(endpoints, results, strict=True):
if isinstance(result, BaseException):
logger.error(
f"Webhook {event_name} to {endpoint.url} raised "
f"{type(result).__name__}: {result}"
)

async def _send(
self,
endpoint: WebhookEndpointConfig,
event_name: str,
payload: dict[str, Any],
) -> None:
"""POST `payload` to a single endpoint, retrying transient failures.

Client errors (4xx) are not retried. Network errors, timeouts, and
server errors (5xx) are retried up to MAX_ATTEMPTS with a linear
backoff. All failures are logged (without the secret or signature)
and swallowed.
"""
body = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json", EVENT_HEADER: event_name}
if endpoint.secret:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you expect this to provide security in practice?

headers[SIGNATURE_HEADER] = sign_payload(endpoint.secret, body)

timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SECONDS)
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
endpoint.url, data=body, headers=headers
) as response:
if response.status < 400:
return
if response.status < 500:
logger.warning(
f"Webhook {event_name} to {endpoint.url} rejected "
f"with status {response.status}; not retrying."
)
return
logger.warning(
f"Webhook {event_name} to {endpoint.url} failed with "
f"status {response.status} (attempt {attempt}/{MAX_ATTEMPTS})."
)
except (aiohttp.ClientError, TimeoutError) as e:
logger.warning(
f"Webhook {event_name} to {endpoint.url} failed: "
f"{type(e).__name__} (attempt {attempt}/{MAX_ATTEMPTS})."
)

if attempt < MAX_ATTEMPTS:
await asyncio.sleep(RETRY_BACKOFF_SECONDS * attempt)

logger.error(
f"Webhook {event_name} to {endpoint.url} failed after "
f"{MAX_ATTEMPTS} attempts; giving up."
)
29 changes: 29 additions & 0 deletions tests/models/test_webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Tests for outbound webhook payload models."""

from supernote.models.webhook import NoteSyncCompletedPayload, WebhookFileVO


def test_note_sync_completed_payload_serializes_to_dict() -> None:
payload = NoteSyncCompletedPayload(
event="note.sync_completed",
timestamp=1730000000,
file=WebhookFileVO(id=42, path="Note/Daily/2026-08-04.note"),
)

assert payload.to_dict() == {
"event": "note.sync_completed",
"timestamp": 1730000000,
"file": {"id": 42, "path": "Note/Daily/2026-08-04.note"},
}


def test_note_sync_completed_payload_excludes_user_id() -> None:
"""The payload has no field for the internal, per-user database ID."""
payload = NoteSyncCompletedPayload(
event="note.sync_completed",
timestamp=1730000000,
file=WebhookFileVO(id=1, path="a.note"),
)

assert "user_id" not in payload.to_dict()
assert not hasattr(payload, "user_id")
Loading