eve-lite/index.ts

418 lines
14 KiB
TypeScript

import { adventurerNeutral } from "@dicebear/collection";
import { createAvatar } from "@dicebear/core";
import { $ } from "bun";
import { finalizeEvent, getPublicKey, type NostrEvent } from "nostr-tools";
import type { SubCloser } from "nostr-tools/abstract-pool";
import { fetchRemoteArxlets } from "./src/arxlets";
import { CCN } from "./src/ccns";
import arxletDocs from "./src/pages/docs/arxlets/arxlet-docs.html";
import homePage from "./src/pages/home/home.html";
import { getColorFromPubkey } from "./src/utils/color";
import { decryptEvent } from "./src/utils/encryption";
import { loadSeenEvents, saveSeenEvent } from "./src/utils/files";
import {
queryRemoteEvent,
queryRemoteRelays,
sendUnencryptedEventToLocalRelay,
} from "./src/utils/general";
import { DEFAULT_PERIOD_MINUTES, RollingIndex } from "./src/rollingIndex";
let currentActiveSub: SubCloser | undefined;
let currentSubInterval: ReturnType<typeof setInterval> | undefined;
async function restartCCN() {
currentActiveSub?.close();
let ccn = await CCN.getActive();
if (!ccn) {
const allCCNs = await CCN.list();
if (allCCNs.length > 0) {
await allCCNs[0]!.setActive();
ccn = allCCNs[0]!;
} else return;
}
async function handleNewEvent(original: NostrEvent) {
if (!ccn) return process.exit(1);
const seenEvents = await loadSeenEvents();
if (seenEvents.includes(original.id)) return;
await saveSeenEvent(original);
const keyAtTime = ccn.getPrivateKeyAt(
RollingIndex.at(original.created_at * 1000),
);
const decrypted = await decryptEvent(original, keyAtTime);
if (seenEvents.includes(decrypted.id)) return;
await saveSeenEvent(decrypted);
await sendUnencryptedEventToLocalRelay(decrypted);
}
await $`killall -9 strfry`.nothrow().quiet();
await ccn.writeStrfryConfig();
const strfry = Bun.spawn([
"strfry",
"--config",
ccn.strfryConfigPath,
"relay",
]);
process.on("exit", () => strfry.kill());
const allKeysForCCN = ccn.allPubkeys;
function resetActiveSub() {
console.log(`Setting new subscription for ${allKeysForCCN.join(", ")}`);
currentActiveSub?.close();
currentActiveSub = queryRemoteRelays(
{ kinds: [1060], "#p": allKeysForCCN },
handleNewEvent,
);
}
resetActiveSub();
currentSubInterval = setInterval(
() => {
resetActiveSub();
},
DEFAULT_PERIOD_MINUTES * 60 * 1000,
);
}
restartCCN();
class CorsResponse extends Response {
constructor(body?: BodyInit, init?: ResponseInit) {
super(body, init);
this.headers.set("Access-Control-Allow-Origin", "*");
this.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT");
this.headers.set("Access-Control-Allow-Headers", "Content-Type");
}
static override json(json: unknown, init?: ResponseInit) {
const res = Response.json(json, init);
res.headers.set("Access-Control-Allow-Origin", "*");
res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT");
res.headers.set("Access-Control-Allow-Headers", "Content-Type");
return res;
}
}
const invalidRequest = CorsResponse.json(
{ error: "Invalid Request" },
{ status: 400 },
);
const httpServer = Bun.serve({
routes: {
"/": homePage,
"/docs/arxlets": arxletDocs,
"/api/ccns": {
GET: async () => {
const ccns = await CCN.list();
return CorsResponse.json(ccns.map((x) => x.toPublicJson()));
},
},
"/api/ccns/active": {
GET: async () => {
const ccn = await CCN.getActive();
if (!ccn)
return CorsResponse.json({ error: "Not found" }, { status: 404 });
return CorsResponse.json(ccn.toPublicJson());
},
POST: async (req) => {
if (!req.body) return invalidRequest;
const body = await req.body.json();
if (!body.pubkey) return invalidRequest;
const ccn = await CCN.fromPublicKey(body.pubkey);
if (!ccn) return invalidRequest;
await ccn.setActive();
restartCCN();
return CorsResponse.json(ccn.toPublicJson());
},
},
"/api/ccns/active/invite": {
GET: async () => {
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const invite = await ccn.generateInvite();
return CorsResponse.json({
invite,
});
},
},
"/api/ccns/new": {
POST: async (req) => {
if (!req.body) return invalidRequest;
const body = await req.body.json();
if (!body.name || !body.description) return invalidRequest;
const ccn = await CCN.create(body.name, body.description);
const activeCCN = await CCN.getActive();
if (!activeCCN) {
await ccn.setActive();
restartCCN();
}
return CorsResponse.json(ccn.toPublicJson());
},
},
"/api/ccns/join": {
POST: async (req) => {
if (!req.body) return invalidRequest;
const body = await req.body.json();
if (!body.name || !body.description || !body.key) return invalidRequest;
const version = body.version ? body.version : 1;
const startIndex = body.startIndex
? RollingIndex.fromHex(body.startIndex)
: RollingIndex.get();
const ccn = await CCN.join(
version,
startIndex,
body.name,
body.description,
new Uint8Array(body.key),
);
return CorsResponse.json(ccn.toPublicJson());
},
},
"/api/ccns/:pubkey": async (req) => {
const ccns = await CCN.list();
const ccnWithPubkey = ccns.find((x) => x.publicKey === req.params.pubkey);
if (!ccnWithPubkey)
return CorsResponse.json({ error: "Not Found" }, { status: 404 });
return CorsResponse.json(ccnWithPubkey.toPublicJson());
},
"/api/ccns/icon/:pubkey": async (req) => {
const pubkey = req.params.pubkey;
if (!pubkey) return invalidRequest;
const ccn = await CCN.fromPublicKey(pubkey);
if (!ccn) return invalidRequest;
const avatar = ccn.getCommunityIcon();
return new CorsResponse(avatar, {
headers: { "Content-Type": "image/svg+xml" },
});
},
"/api/ccns/name/:pubkey": async (req) => {
const pubkey = req.params.pubkey;
if (!pubkey) return invalidRequest;
const ccn = await CCN.fromPublicKey(pubkey);
if (!ccn) return invalidRequest;
const profile = await ccn.getProfile();
return new CorsResponse(profile.name || ccn.name);
},
"/api/ccns/avatar/:pubkey": async (req) => {
const pubkey = req.params.pubkey;
if (!pubkey) return invalidRequest;
const ccn = await CCN.fromPublicKey(pubkey);
if (!ccn) return invalidRequest;
const profile = await ccn.getProfile();
if (profile.picture) return CorsResponse.redirect(profile.picture);
const avatar = ccn.getCommunityIcon();
return new CorsResponse(avatar, {
headers: { "Content-Type": "image/svg+xml" },
});
},
"/api/profile/:pubkey": async (req) => {
const pubkey = req.params.pubkey;
if (!pubkey) return invalidRequest;
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const profileEvent = await ccn.getFirstEvent({
kinds: [0],
authors: [pubkey],
});
try {
if (!profileEvent) throw "No profile";
return new CorsResponse(profileEvent.content, {
headers: { "Content-Type": "text/json" },
});
} catch {
return CorsResponse.json(
{ error: "profile not found" },
{ headers: { "Content-Type": "text/json" }, status: 404 },
);
}
},
"/api/avatars/:pubkey": async (req) => {
const pubkey = req.params.pubkey;
if (!pubkey) return invalidRequest;
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const profileEvent = await ccn.getFirstEvent({
kinds: [0],
authors: [pubkey],
});
try {
if (!profileEvent) throw "No profile";
const content = JSON.parse(profileEvent.content);
if (!content.picture) throw "No picture";
return CorsResponse.redirect(content.picture);
} catch {
const avatar = createAvatar(adventurerNeutral, {
seed: pubkey,
backgroundColor: [getColorFromPubkey(pubkey)],
});
return new CorsResponse(avatar.toString(), {
headers: { "Content-Type": "image/svg+xml" },
});
}
},
"/api/events": {
POST: async (req) => {
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
if (!req.body) return invalidRequest;
const { search, ...query } = await req.body.json();
let events = await ccn.getEvents(query);
if (search)
events = events.filter(
(e) =>
e.content.includes(search) ||
e.tags.some((t) => t[1]?.includes(search)),
);
return CorsResponse.json(events);
},
PUT: async (req) => {
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
if (!req.body) return invalidRequest;
const event = await req.body.json();
try {
await ccn.publish(event);
return CorsResponse.json({
success: true,
message: "Event published successfully",
});
} catch {
return CorsResponse.json({
success: false,
message: "Failed to publish event",
});
}
},
},
"/api/events/:id": async (req) => {
const id = req.params.id;
if (!id) return invalidRequest;
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const event = await ccn.getFirstEvent({ ids: [id] });
if (!event)
return CorsResponse.json({ error: "Event Not Found" }, { status: 404 });
return CorsResponse.json(event);
},
"/api/sign": {
POST: async (req) => {
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const userKey = await ccn.getUserKey();
if (!req.body) return invalidRequest;
const event = await req.body.json();
const signedEvent = finalizeEvent(event, userKey);
return CorsResponse.json(signedEvent);
},
},
"/api/pubkey": async () => {
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const userKey = await ccn.getUserKey();
return CorsResponse.json({ pubkey: getPublicKey(userKey) });
},
"/api/arxlets": async () => {
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const arxlets = await ccn.getArxlets();
return CorsResponse.json(arxlets);
},
"/api/arxlets/:id": async (req) => {
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const arxlet = await ccn.getArxletById(req.params.id);
if (!arxlet)
return CorsResponse.json(
{ error: "Arxlet not found" },
{ status: 404 },
);
return CorsResponse.json(arxlet);
},
"/api/arxlets-available": async () => {
const remoteArxlets = await fetchRemoteArxlets();
return CorsResponse.json(remoteArxlets);
},
"/api/clone-remote-event/:id": async (req) => {
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const remoteEvent = await queryRemoteEvent(req.params.id);
if (!remoteEvent)
return CorsResponse.json({ error: "Event not found" }, { status: 404 });
await ccn.publish(remoteEvent);
return CorsResponse.json(remoteEvent);
},
"/api/reputation/:user": async (req) => {
const ccn = await CCN.getActive();
if (!ccn) return invalidRequest;
const reputation = await ccn.getReputation(req.params.user);
return CorsResponse.json({ reputation });
},
"/systemapi/timezone": {
GET: async () => {
const timezone = (
await $`timedatectl show --property=Timezone --value`.text()
).trim();
return CorsResponse.json({ timezone });
},
POST: async (req) => {
if (!req.body) return invalidRequest;
const { timezone } = await req.body.json();
await $`sudo timedatectl set-timezone ${timezone}`; // this is fine, bun escapes it
return CorsResponse.json({ timezone });
},
},
"/systemapi/wifi": {
GET: async () => {
const nmcliLines = (
await $`nmcli -f ssid,bssid,mode,freq,chan,rate,signal,security,active -t dev wifi`.text()
)
.trim()
.split("\n")
.map((l) => l.split(/(?<!\\):/));
const wifi = nmcliLines
.map(
([
ssid,
bssid,
mode,
freq,
chan,
rate,
signal,
security,
active,
]) => ({
ssid,
bssid: bssid!.replace(/\\:/g, ":"),
mode,
freq: parseInt(freq!, 10),
chan: parseInt(chan!, 10),
rate,
signal: parseInt(signal!, 10),
security,
active: active === "yes",
}),
)
.filter((network) => network.ssid !== "") // filter out hidden networks
.filter((network) => network.security !== "") // filter out open networks
.sort((a, b) => (a.active ? -1 : b.active ? 1 : b.signal - a.signal));
return CorsResponse.json(wifi);
},
POST: async (req) => {
if (!req.body) return invalidRequest;
const { ssid, password } = await req.body.json();
await $`nmcli device wifi connect ${ssid} password ${password}`; // this is fine, bun escapes it
return CorsResponse.json({ ssid });
},
},
},
fetch() {
return new CorsResponse("Eve Lite v0.0.1");
},
port: 4269,
});
console.log(`Listening on ${httpServer.url.host}`);