feat(nc-webhook): receive Nextcloud file events instead of polling

The watchfiles-based watcher works but duplicates Nextcloud's own
notion of "this file changed." NC has a webhook_listeners app that
can POST file events to an external URL. This adds the mule side of
that handshake.

- POST /api/v1/internal/nc-webhook authenticates a Bearer token
  (NEXTCLOUD_WEBHOOK_SECRET, hmac.compare_digest) and dispatches the
  same scan_folder / handle_file_deletion machinery the watcher used.
- Handles NodeCreated, NodeWritten, NodeDeleted, NodeRenamed.
  Renamed is mapped to delete-old + scan-new-parent. Maps NC's
  /admin/files/... path to the bind-mounted /nextcloud-users/admin/files/...
- backend/scripts/register_nc_webhooks.py is the idempotent
  registrar: lists existing webhooks, deletes any pointing at the
  target URL, then POSTs four fresh ones via OCS.
- Sets the env passthrough on backend + all workers in compose so
  the same secret is available wherever the registrar might run.

watch_folders stays in place for now — webhooks become primary, the
watcher is a belt-and-suspenders fallback. Drop the watcher in a
follow-up once webhooks are proven reliable on this NC instance.
This commit is contained in:
Claudio
2026-05-11 12:19:59 +02:00
parent d24c64e0a0
commit 362fbc6d83
4 changed files with 358 additions and 1 deletions

View File

@@ -12,7 +12,7 @@ import os
from app.config import settings
from app.database import init_db
from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing, upload, download, features, nextcloud
from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing, upload, download, features, nextcloud, nc_webhook
from app.services.scanner import start_initial_scan, bootstrap_default_source_root
from app.services.cleanup import cleanup_data_integrity
@@ -113,6 +113,7 @@ app.include_router(upload.router, prefix="/api/v1/upload", tags=["upload"])
app.include_router(download.router, prefix="/api/v1/download", tags=["download"])
app.include_router(features.router, prefix="/api/v1/features", tags=["features"])
app.include_router(nextcloud.router, prefix="/api/v1/nextcloud", tags=["nextcloud"])
app.include_router(nc_webhook.router, prefix="/api/v1/internal", tags=["nc-webhook"])
@app.get("/")
async def root():