DEV Community

Alex Neamtu
Alex Neamtu

Posted on • Originally published at sendrec.eu

How We Built a Nextcloud Integration for SendRec

Nextcloud has 400,000+ servers in the wild, most of them self-hosted by people who care about owning their data. That's exactly the audience for SendRec. If someone already runs Nextcloud on their own infrastructure, convincing them to self-host a video tool alongside it is a much shorter conversation than starting from scratch.

So we built a Nextcloud integration app. Paste a SendRec watch URL in Nextcloud Talk and it renders as a rich card with thumbnail, title, and duration. Use the Smart Picker to search your SendRec videos from anywhere in Nextcloud. No iframes, no redirects — it feels native.

Here's how we built it.

Two repos, one integration

The integration lives in two places:

  1. SendRec API additions — an oEmbed endpoint, per-user API keys, and configurable CSP frame-ancestors, all in the existing Go app
  2. Nextcloud app — a PHP app (integration_sendrec) that calls those APIs, following the same patterns as Nextcloud's PeerTube integration

We modeled our app after PeerTube's because it does exactly what we need: link previews via a Reference Provider, video search via a Search Provider, and a Vue.js widget for rendering rich cards. No need to reinvent the pattern.

The oEmbed endpoint

When someone pastes a SendRec URL in Nextcloud Talk, the app needs metadata about that video. We added a public endpoint that returns what Nextcloud needs:

func (h *Handler) OEmbed(w http.ResponseWriter, r *http.Request) {
    shareToken := chi.URLParam(r, "shareToken")

    var title string
    var duration int
    var creator string
    var createdAt time.Time
    var shareExpiresAt time.Time
    var thumbnailKey *string

    err := h.db.QueryRow(r.Context(),
        `SELECT v.title, v.duration, u.name, v.created_at,
                v.share_expires_at, v.thumbnail_key
         FROM videos v
         JOIN users u ON u.id = v.user_id
         WHERE v.share_token = $1
           AND v.status IN ('ready', 'processing')`,
        shareToken,
    ).Scan(&title, &duration, &creator, &createdAt,
           &shareExpiresAt, &thumbnailKey)

    // ... handle not found, expired, generate thumbnail URL

    httputil.WriteJSON(w, http.StatusOK, oEmbedResponse{
        Title:        title,
        Duration:     duration,
        ThumbnailURL: thumbnailURL,
        AuthorName:   creator,
        WatchURL:     h.baseURL + "/watch/" + shareToken,
        CreatedAt:    createdAt.Format(time.RFC3339),
    })
}
Enter fullscreen mode Exit fullscreen mode

This endpoint is public — no auth required. If you have the share token, you can already view the video, so returning its title and thumbnail doesn't leak anything new.

We handle two edge cases: videos that don't exist return 404, and expired share links return 410 Gone. The Nextcloud app falls back gracefully for both.

Per-user API keys

Link previews work without auth because they use public share tokens. But the Smart Picker needs to search across a user's video library, which requires authenticated access.

Our first attempt was a single API_KEY environment variable — one key for the whole instance. It worked, but it was clunky. The key wasn't scoped to any user, required a server restart to change, and returned every user's videos.

We reworked it into per-user API keys stored in the database:

func GenerateAPIKey(db database.DBTX) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        userID := auth.UserIDFromContext(r.Context())

        // Generate key: sr_ prefix + 32 random hex bytes
        raw := make([]byte, 32)
        if _, err := rand.Read(raw); err != nil {
            httputil.WriteError(w, http.StatusInternalServerError, "failed to generate key")
            return
        }
        plaintext := apiKeyPrefix + hex.EncodeToString(raw)

        // Store SHA-256 hash, not plaintext
        keyHash := HashAPIKey(plaintext)

        // ... insert into api_keys table

        // Return plaintext once — we don't store it
        httputil.WriteJSON(w, http.StatusCreated, generateResponse{
            ID:   id,
            Key:  plaintext,
            Name: name,
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Each key is scoped to the user who created it. The API key middleware hashes the incoming Bearer token and looks it up in the database. If it matches, the request continues with that user's ID — so GET /api/videos returns only their videos, just like a normal logged-in session.

The plaintext key is returned exactly once on creation. We store only the SHA-256 hash. Users can create up to 10 keys, name them (e.g., "Nextcloud"), and delete them from the Settings page.

The Nextcloud side

On the PHP side, the Reference Provider matches URLs and calls our oEmbed endpoint:

public function matchReference(string $referenceText): bool {
    $instanceUrl = $this->apiService->getInstanceUrl();
    if ($instanceUrl === '') {
        return false;
    }
    return preg_match(
        '#^' . preg_quote($instanceUrl, '#') . '/watch/[a-zA-Z0-9]+$#',
        $referenceText
    ) === 1;
}
Enter fullscreen mode Exit fullscreen mode

When a match is found, resolveReference() calls the oEmbed endpoint and returns a rich object that the Vue widget renders as a card:

$reference->setRichObject('integration_sendrec_video', [
    'title' => $data['title'] ?? '',
    'duration' => $duration,
    'authorName' => $data['authorName'] ?? '',
    'thumbnailUrl' => $data['thumbnailUrl'] ?? '',
    'watchUrl' => $data['watchUrl'] ?? $referenceText,
]);
Enter fullscreen mode Exit fullscreen mode

The Search Provider handles Smart Picker queries by calling GET /api/videos?q=... with the API key. Type a few characters, get back matching videos, pick one, and it inserts the watch URL into your message.

Admin setup

Nextcloud admins configure two things in Settings > Connected accounts:

  1. SendRec instance URL — where the API lives (e.g., https://videos.example.com)
  2. API key — generated in SendRec's Settings page

Users can individually toggle search and link previews on or off in their personal settings.

On the SendRec side, if you want Nextcloud to embed watch pages in iframes (for richer previews), set the ALLOWED_FRAME_ANCESTORS environment variable to your Nextcloud URL:

ALLOWED_FRAME_ANCESTORS=https://nextcloud.example.com
Enter fullscreen mode Exit fullscreen mode

What's next

The app is on GitHub and we're working on getting it into the Nextcloud App Store. Once it's there, installation is one click from the Nextcloud admin panel.

We deliberately kept the first version minimal — link previews and search cover the two highest-value use cases. If there's demand, deeper integration is possible: saving recordings to Nextcloud Files via WebDAV, posting videos in Talk conversations, or SSO via Nextcloud's OpenID Connect.

If you run Nextcloud and want to try it, self-host SendRec alongside it. The integration is designed so both tools stay fully independent — SendRec doesn't require Nextcloud, and Nextcloud doesn't require SendRec. They just work better together.

Top comments (0)