Move chat to private authenticated API
This commit is contained in:
44
apps/api/src/chat/routes.ts
Normal file
44
apps/api/src/chat/routes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../auth/middleware.js";
|
||||
import { addMessage, addReaction, listChat } from "./store.js";
|
||||
|
||||
export const chatRouter = Router();
|
||||
|
||||
const MessageBody = z.object({
|
||||
content: z.string().trim().min(1).max(2000),
|
||||
replyToId: z.string().max(128).optional(),
|
||||
replyToPubkey: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const ReactionBody = z.object({
|
||||
messageId: z.string().min(1).max(128),
|
||||
content: z.string().trim().min(1).max(32),
|
||||
});
|
||||
|
||||
chatRouter.use(requireAuth);
|
||||
|
||||
chatRouter.get("/", (_req, res) => {
|
||||
res.json(listChat());
|
||||
});
|
||||
|
||||
chatRouter.post("/messages", (req, res) => {
|
||||
const body = MessageBody.parse(req.body);
|
||||
const message = addMessage({
|
||||
pubkey: req.session!.pubkey,
|
||||
content: body.content,
|
||||
...(body.replyToId ? { replyToId: body.replyToId } : {}),
|
||||
...(body.replyToPubkey ? { replyToPubkey: body.replyToPubkey } : {}),
|
||||
});
|
||||
res.status(201).json(message);
|
||||
});
|
||||
|
||||
chatRouter.post("/reactions", (req, res) => {
|
||||
const body = ReactionBody.parse(req.body);
|
||||
const reaction = addReaction({
|
||||
pubkey: req.session!.pubkey,
|
||||
messageId: body.messageId,
|
||||
content: body.content,
|
||||
});
|
||||
res.status(201).json(reaction);
|
||||
});
|
||||
62
apps/api/src/chat/store.ts
Normal file
62
apps/api/src/chat/store.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type StoredChatMessage = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
replyToId: string;
|
||||
replyToPubkey: string;
|
||||
};
|
||||
|
||||
export type StoredChatReaction = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
const MAX_MESSAGES = 500;
|
||||
const MAX_REACTIONS = 2000;
|
||||
|
||||
const messages: StoredChatMessage[] = [];
|
||||
const reactions: StoredChatReaction[] = [];
|
||||
|
||||
export function listChat(): { messages: StoredChatMessage[]; reactions: StoredChatReaction[] } {
|
||||
return { messages: [...messages], reactions: [...reactions] };
|
||||
}
|
||||
|
||||
export function addMessage(input: {
|
||||
pubkey: string;
|
||||
content: string;
|
||||
replyToId?: string;
|
||||
replyToPubkey?: string;
|
||||
}): StoredChatMessage {
|
||||
const message: StoredChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
pubkey: input.pubkey,
|
||||
content: input.content,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
replyToId: input.replyToId ?? "",
|
||||
replyToPubkey: input.replyToPubkey ?? "",
|
||||
};
|
||||
messages.push(message);
|
||||
if (messages.length > MAX_MESSAGES) messages.splice(0, messages.length - MAX_MESSAGES);
|
||||
return message;
|
||||
}
|
||||
|
||||
export function addReaction(input: {
|
||||
pubkey: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
}): StoredChatReaction {
|
||||
const reaction: StoredChatReaction = {
|
||||
id: crypto.randomUUID(),
|
||||
pubkey: input.pubkey,
|
||||
messageId: input.messageId,
|
||||
content: input.content,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
reactions.push(reaction);
|
||||
if (reactions.length > MAX_REACTIONS) reactions.splice(0, reactions.length - MAX_REACTIONS);
|
||||
return reaction;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import fs from "node:fs";
|
||||
import { config } from "./config.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { authRouter } from "./auth/routes.js";
|
||||
import { chatRouter } from "./chat/routes.js";
|
||||
import { datumRouter } from "./datum/routes.js";
|
||||
import { errorHandler } from "./errors.js";
|
||||
|
||||
@@ -61,6 +62,7 @@ export function buildApp() {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/chat", chatRouter);
|
||||
app.use("/api/datum", datumRouter);
|
||||
|
||||
const staticDir = config.staticDir
|
||||
|
||||
@@ -204,7 +204,7 @@ async function scrollBottom(): Promise<void> {
|
||||
<aside :class="['chat-drawer', { open }]">
|
||||
<header>
|
||||
<div>
|
||||
<div class="label">encrypted nostr chat</div>
|
||||
<div class="label">private nostr-login chat</div>
|
||||
<h2 class="glow-cyan">mining desk heckle box</h2>
|
||||
</div>
|
||||
<button class="thin" aria-label="close chat" @click="emit('close')">×</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApiError, DatumSnapshot, LoginResponse } from "../types";
|
||||
import type { ApiError, ChatSnapshot, DatumSnapshot, LoginResponse } from "../types";
|
||||
import { buildNip98AuthHeader } from "./nip98";
|
||||
|
||||
const TOKEN_KEY = "gashboard.jwt";
|
||||
@@ -61,6 +61,25 @@ async function authedGet<T>(path: string): Promise<T> {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function authedPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const stored = loadToken();
|
||||
if (!stored) throw new Error("Not logged in");
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${stored.token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
if (!res.ok) throw await asError(res);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function fetchSnapshot(): Promise<DatumSnapshot> {
|
||||
return authedGet<DatumSnapshot>("/api/datum/stats");
|
||||
}
|
||||
@@ -69,3 +88,22 @@ export async function fetchChatPubkeys(): Promise<string[]> {
|
||||
const res = await authedGet<{ pubkeys: string[] }>("/api/auth/chat-config");
|
||||
return res.pubkeys;
|
||||
}
|
||||
|
||||
export function fetchChat(): Promise<ChatSnapshot> {
|
||||
return authedGet<ChatSnapshot>("/api/chat");
|
||||
}
|
||||
|
||||
export function postChatMessage(body: {
|
||||
content: string;
|
||||
replyToId?: string;
|
||||
replyToPubkey?: string;
|
||||
}): Promise<ChatSnapshot["messages"][number]> {
|
||||
return authedPost<ChatSnapshot["messages"][number]>("/api/chat/messages", body);
|
||||
}
|
||||
|
||||
export function postChatReaction(body: {
|
||||
messageId: string;
|
||||
content: string;
|
||||
}): Promise<ChatSnapshot["reactions"][number]> {
|
||||
return authedPost<ChatSnapshot["reactions"][number]>("/api/chat/reactions", body);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
|
||||
import type { Event as NostrEvent } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { SimplePool } from "nostr-tools/pool";
|
||||
import { fetchChatPubkeys } from "./api";
|
||||
import { getActiveSigner, hasActiveSigner } from "./signer";
|
||||
import { fetchChat, postChatMessage, postChatReaction } from "./api";
|
||||
import { hasActiveSigner } from "./signer";
|
||||
|
||||
const RELAYS = ["wss://relay.primal.net"];
|
||||
const PRIVATE_CHAT_KIND = 4;
|
||||
const CHAT_ROOM = "gashboard-chat";
|
||||
const POLL_MS = 2500;
|
||||
const pool = new SimplePool();
|
||||
|
||||
export type ChatMessage = {
|
||||
@@ -32,28 +31,7 @@ export type ChatProfile = {
|
||||
picture: string;
|
||||
};
|
||||
|
||||
type PrivatePayload =
|
||||
| {
|
||||
room: typeof CHAT_ROOM;
|
||||
v: 1;
|
||||
type: "message";
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
replyToId?: string;
|
||||
replyToPubkey?: string;
|
||||
}
|
||||
| {
|
||||
room: typeof CHAT_ROOM;
|
||||
v: 1;
|
||||
type: "reaction";
|
||||
id: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
let cachedRecipients: string[] | null = null;
|
||||
let reactionCache: ChatReaction[] = [];
|
||||
|
||||
export function canSendChat(): boolean {
|
||||
return hasActiveSigner();
|
||||
@@ -70,82 +48,65 @@ export function pubkeyFromNpub(npub: string | null): string | null {
|
||||
}
|
||||
|
||||
export async function loadChat(): Promise<ChatMessage[]> {
|
||||
const { messages } = await loadPrivateChat();
|
||||
return dedupe(messages).sort((a, b) => a.createdAt - b.createdAt);
|
||||
const snapshot = await fetchChat();
|
||||
reactionCache = snapshot.reactions;
|
||||
return snapshot.messages.sort((a, b) => a.createdAt - b.createdAt);
|
||||
}
|
||||
|
||||
export async function loadReactions(messageIds: string[]): Promise<ChatReaction[]> {
|
||||
const wanted = new Set(messageIds);
|
||||
const { reactions } = await loadPrivateChat();
|
||||
return dedupeReactions(reactions.filter((r) => wanted.has(r.messageId)));
|
||||
if (!reactionCache.length) reactionCache = (await fetchChat()).reactions;
|
||||
return reactionCache.filter((r) => wanted.has(r.messageId));
|
||||
}
|
||||
|
||||
export function subscribeChat(
|
||||
onMessage: (message: ChatMessage) => void,
|
||||
onReaction?: (reaction: ChatReaction) => void,
|
||||
): { close: () => void } {
|
||||
const signer = getActiveSigner();
|
||||
const seenMessages = new Set<string>();
|
||||
const seenReactions = new Set<string>();
|
||||
let closed = false;
|
||||
let sub: { close: () => void } | null = null;
|
||||
if (!signer) return { close: () => {} };
|
||||
|
||||
void signer.getPublicKey().then((ownPubkey) => {
|
||||
async function poll(): Promise<void> {
|
||||
if (closed) return;
|
||||
const since = Math.floor(Date.now() / 1000) - 60;
|
||||
sub = pool.subscribeMany(RELAYS, [{ kinds: [PRIVATE_CHAT_KIND], "#p": [ownPubkey], since }], {
|
||||
onevent(event) {
|
||||
void decryptEvent(event).then((payload) => {
|
||||
if (!payload || closed) return;
|
||||
if (payload.type === "reaction") onReaction?.(asReaction(payload, event.pubkey));
|
||||
else onMessage(asMessage(payload, event.pubkey));
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
const snapshot = await fetchChat();
|
||||
for (const message of snapshot.messages) {
|
||||
if (seenMessages.has(message.id)) continue;
|
||||
seenMessages.add(message.id);
|
||||
onMessage(message);
|
||||
}
|
||||
for (const reaction of snapshot.reactions) {
|
||||
if (seenReactions.has(reaction.id)) continue;
|
||||
seenReactions.add(reaction.id);
|
||||
onReaction?.(reaction);
|
||||
}
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void poll().catch(() => {});
|
||||
}, POLL_MS);
|
||||
void poll().catch(() => {});
|
||||
|
||||
return {
|
||||
close() {
|
||||
closed = true;
|
||||
sub?.close();
|
||||
window.clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendChat(
|
||||
export function sendChat(
|
||||
content: string,
|
||||
replyTo?: { id: string; pubkey: string },
|
||||
): Promise<ChatMessage> {
|
||||
const signer = getActiveSigner();
|
||||
if (!signer) throw new Error("Private chat needs the active signer. Reconnect your signer to send.");
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const payload: PrivatePayload = {
|
||||
room: CHAT_ROOM,
|
||||
v: 1,
|
||||
type: "message",
|
||||
id: crypto.randomUUID(),
|
||||
return postChatMessage({
|
||||
content,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
...(replyTo ? { replyToId: replyTo.id, replyToPubkey: replyTo.pubkey } : {}),
|
||||
};
|
||||
await publishPrivatePayload(payload);
|
||||
return asMessage(payload, pubkey);
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
|
||||
const signer = getActiveSigner();
|
||||
if (!signer) throw new Error("Private reactions need the active signer. Reconnect your signer to react.");
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const payload: PrivatePayload = {
|
||||
room: CHAT_ROOM,
|
||||
v: 1,
|
||||
type: "reaction",
|
||||
id: crypto.randomUUID(),
|
||||
messageId: message.id,
|
||||
content,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await publishPrivatePayload(payload);
|
||||
return asReaction(payload, pubkey);
|
||||
export function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
|
||||
return postChatReaction({ messageId: message.id, content });
|
||||
}
|
||||
|
||||
export async function loadProfiles(pubkeys: string[]): Promise<Record<string, ChatProfile>> {
|
||||
@@ -176,100 +137,3 @@ export async function loadProfiles(pubkeys: string[]): Promise<Record<string, Ch
|
||||
export function shortPubkey(pubkey: string): string {
|
||||
return `${pubkey.slice(0, 8)}:${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
async function loadPrivateChat(): Promise<{ messages: ChatMessage[]; reactions: ChatReaction[] }> {
|
||||
const signer = getActiveSigner();
|
||||
if (!signer) return { messages: [], reactions: [] };
|
||||
const ownPubkey = await signer.getPublicKey();
|
||||
const events = await pool.querySync(
|
||||
RELAYS,
|
||||
{ kinds: [PRIVATE_CHAT_KIND], "#p": [ownPubkey], limit: 500 },
|
||||
{ maxWait: 3500 },
|
||||
);
|
||||
const payloads = await Promise.all(events.map((event) => decryptEvent(event)));
|
||||
const messages: ChatMessage[] = [];
|
||||
const reactions: ChatReaction[] = [];
|
||||
for (let i = 0; i < events.length; i += 1) {
|
||||
const payload = payloads[i];
|
||||
const event = events[i];
|
||||
if (!payload || !event) continue;
|
||||
if (payload.type === "reaction") reactions.push(asReaction(payload, event.pubkey));
|
||||
else messages.push(asMessage(payload, event.pubkey));
|
||||
}
|
||||
return { messages, reactions };
|
||||
}
|
||||
|
||||
async function publishPrivatePayload(payload: PrivatePayload): Promise<void> {
|
||||
const signer = getActiveSigner();
|
||||
if (!signer) throw new Error("Private chat needs the active signer.");
|
||||
const recipients = await getRecipients();
|
||||
const plaintext = JSON.stringify(payload);
|
||||
const signedEvents = await Promise.all(
|
||||
recipients.map(async (recipient) => {
|
||||
const encrypted = await signer.encrypt(recipient, plaintext);
|
||||
const template: EventTemplate = {
|
||||
kind: PRIVATE_CHAT_KIND,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["p", recipient],
|
||||
["client", "gashboard"],
|
||||
],
|
||||
content: encrypted,
|
||||
};
|
||||
return signer.signEvent(template);
|
||||
}),
|
||||
);
|
||||
const results = await Promise.allSettled(signedEvents.flatMap((event) => pool.publish(RELAYS, event)));
|
||||
if (!results.some((r) => r.status === "fulfilled")) {
|
||||
throw new Error("Could not publish private chat event to relay");
|
||||
}
|
||||
}
|
||||
|
||||
async function getRecipients(): Promise<string[]> {
|
||||
const signer = getActiveSigner();
|
||||
const own = await signer?.getPublicKey();
|
||||
if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys();
|
||||
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
|
||||
}
|
||||
|
||||
async function decryptEvent(event: NostrEvent): Promise<PrivatePayload | null> {
|
||||
const signer = getActiveSigner();
|
||||
if (!signer) return null;
|
||||
try {
|
||||
const plaintext = await signer.decrypt(event.pubkey, event.content);
|
||||
const payload = JSON.parse(plaintext) as PrivatePayload;
|
||||
if (payload.room !== CHAT_ROOM || payload.v !== 1) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asMessage(payload: Extract<PrivatePayload, { type: "message" }>, pubkey: string): ChatMessage {
|
||||
return {
|
||||
id: payload.id,
|
||||
pubkey,
|
||||
content: payload.content,
|
||||
createdAt: payload.createdAt,
|
||||
replyToId: payload.replyToId ?? "",
|
||||
replyToPubkey: payload.replyToPubkey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function asReaction(payload: Extract<PrivatePayload, { type: "reaction" }>, pubkey: string): ChatReaction {
|
||||
return {
|
||||
id: payload.id,
|
||||
pubkey,
|
||||
messageId: payload.messageId,
|
||||
content: payload.content || "+",
|
||||
createdAt: payload.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupe(messages: ChatMessage[]): ChatMessage[] {
|
||||
return [...new Map(messages.map((m) => [m.id, m])).values()];
|
||||
}
|
||||
|
||||
function dedupeReactions(reactions: ChatReaction[]): ChatReaction[] {
|
||||
return [...new Map(reactions.map((r) => [r.id, r])).values()];
|
||||
}
|
||||
|
||||
@@ -86,6 +86,24 @@ export type LoginResponse = {
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type ChatSnapshot = {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
replyToId: string;
|
||||
replyToPubkey: string;
|
||||
}>;
|
||||
reactions: Array<{
|
||||
id: string;
|
||||
pubkey: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ApiError = { error: { code: string; message: string } };
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user