feat: private r2 bucket and secure sockets
This commit is contained in:
@@ -5,13 +5,18 @@
|
|||||||
* WebSocket connection to our Soketi (Pusher-compatible) server so the
|
* WebSocket connection to our Soketi (Pusher-compatible) server so the
|
||||||
* bell badge + a toast update in realtime.
|
* bell badge + a toast update in realtime.
|
||||||
*
|
*
|
||||||
* Transport: subscribes to the PUBLIC channel `notifications.{userId}` and
|
* Transport: subscribes to the PRIVATE channel `private-notifications.{userId}`
|
||||||
* binds the `notification` event the BFF triggers on each follow-request /
|
* and binds the `notification` event the BFF triggers on each follow-request /
|
||||||
* follow-accept. The event is a lightweight nudge — we bump the unread
|
* follow-accept. The event is a lightweight nudge — we bump the unread
|
||||||
* count locally and toast — while the authoritative list/count still come
|
* count locally and toast — while the authoritative list/count still come
|
||||||
* from the REST endpoints. (Public channel for now; once Soketi App auth
|
* from the REST endpoints.
|
||||||
* is configured, switch to a `private-` channel + a /broadcasting/auth
|
*
|
||||||
* endpoint — only the channel name and a Pusher `authEndpoint` change.)
|
* The channel is private so a subscription requires a signature the BFF only
|
||||||
|
* issues to that user; on the old public channel anyone who knew a user's id
|
||||||
|
* could listen in. Signing goes through `channelAuthorizer` (see
|
||||||
|
* lib/realtime.ts). The BFF still publishes to the old public channel too, so
|
||||||
|
* a service-worker-cached bundle running this file's previous version keeps
|
||||||
|
* working until it updates.
|
||||||
*
|
*
|
||||||
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
|
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
|
||||||
* realtime silently disables and the bell falls back to its fetched count.
|
* realtime silently disables and the bell falls back to its fetched count.
|
||||||
@@ -26,6 +31,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { channelAuthorizer } from "@/lib/realtime";
|
||||||
import { feed, notifications } from "@/lib/social";
|
import { feed, notifications } from "@/lib/social";
|
||||||
import { sadhna } from "@/lib/sadhna";
|
import { sadhna } from "@/lib/sadhna";
|
||||||
|
|
||||||
@@ -145,7 +151,7 @@ export function NotificationsProvider({
|
|||||||
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
|
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
|
||||||
const forceTLS =
|
const forceTLS =
|
||||||
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
|
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
|
||||||
const channelName = `notifications.${userId}`;
|
const channelName = `private-notifications.${userId}`;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// Dynamic import keeps pusher-js out of SSR (it touches window) and the
|
// Dynamic import keeps pusher-js out of SSR (it touches window) and the
|
||||||
@@ -162,8 +168,16 @@ export function NotificationsProvider({
|
|||||||
disableStats: true,
|
disableStats: true,
|
||||||
// Ignored when wsHost is set, but required by the type.
|
// Ignored when wsHost is set, but required by the type.
|
||||||
cluster: "",
|
cluster: "",
|
||||||
|
// Private channels are refused without a signature from the BFF.
|
||||||
|
channelAuthorization: { customHandler: channelAuthorizer },
|
||||||
});
|
});
|
||||||
const channel = pusher.subscribe(channelName);
|
const channel = pusher.subscribe(channelName);
|
||||||
|
// Authorization can legitimately fail (expired session, realtime not
|
||||||
|
// configured). Realtime is a nudge, not the source of truth, so degrade
|
||||||
|
// quietly — the bell keeps working off its fetched count.
|
||||||
|
channel.bind("pusher:subscription_error", (status: unknown) => {
|
||||||
|
console.warn("notifications: realtime subscription failed", status);
|
||||||
|
});
|
||||||
channel.bind("notification", (data: IncomingEvent) => {
|
channel.bind("notification", (data: IncomingEvent) => {
|
||||||
setUnread((u) => u + 1);
|
setUnread((u) => u + 1);
|
||||||
setLastEventAt(Date.now());
|
setLastEventAt(Date.now());
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Realtime channel authorization.
|
||||||
|
*
|
||||||
|
* Our Soketi channels are `private-`/`presence-` prefixed, so Soketi refuses a
|
||||||
|
* subscription without a signature. Only the BFF holds the app secret, so the
|
||||||
|
* client asks it to sign each subscription at
|
||||||
|
* POST /api/v1/broadcasting/auth — which first checks the session cookie
|
||||||
|
* identifies someone allowed on that channel.
|
||||||
|
*
|
||||||
|
* We supply a `customHandler` rather than pusher-js's built-in `authEndpoint`
|
||||||
|
* because that built-in uses XMLHttpRequest without setting
|
||||||
|
* `withCredentials`, so it would never send our httpOnly session cookie
|
||||||
|
* cross-origin (prabhupadasays.org → api.prabhupadasays.org) and every
|
||||||
|
* subscription would 401. A plain fetch with `credentials: "include"` matches
|
||||||
|
* how the rest of lib/ talks to the BFF.
|
||||||
|
*
|
||||||
|
* The endpoint answers with a BARE {auth, channel_data} body rather than the
|
||||||
|
* usual {success, data} envelope, because pusher-js reads `auth` off the top
|
||||||
|
* level of the response.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChannelAuthorizationHandler } from "pusher-js";
|
||||||
|
|
||||||
|
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs private/presence subscriptions. Shared by every realtime consumer, so
|
||||||
|
* new channel families need no client-side change — only a rule on the BFF.
|
||||||
|
*/
|
||||||
|
export const channelAuthorizer: ChannelAuthorizationHandler = (
|
||||||
|
{ socketId, channelName },
|
||||||
|
callback,
|
||||||
|
) => {
|
||||||
|
fetch(`${BFF_BASE}/api/v1/broadcasting/auth`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ socket_id: socketId, channel_name: channelName }),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
// 403 is a normal outcome (not a participant / not your channel), so
|
||||||
|
// carry the status rather than presenting it as a network fault.
|
||||||
|
throw new Error(
|
||||||
|
`channel authorization failed for ${channelName}: ${res.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await res.json()) as { auth: string; channel_data?: string };
|
||||||
|
})
|
||||||
|
.then((data) => callback(null, data))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
callback(err instanceof Error ? err : new Error(String(err)), null);
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user