418 lines
14 KiB
TypeScript
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}`);
|