nsite-ts/src/index.ts

292 lines
8.1 KiB
TypeScript
Raw Normal View History

2024-09-01 13:26:37 -05:00
#!/usr/bin/env node
import "./polyfill.js";
import Koa from "koa";
import serve from "koa-static";
2024-10-04 10:32:57 -05:00
import path, { basename } from "node:path";
2024-09-01 13:26:37 -05:00
import cors from "@koa/cors";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
2024-09-25 13:37:32 -05:00
import mime from "mime";
import morgan from "koa-morgan";
2024-10-04 10:32:57 -05:00
import send from "koa-send";
2024-10-06 10:01:15 -05:00
import { npubEncode } from "nostr-tools/nip19";
import { spawn } from "node:child_process";
import { nip19 } from "nostr-tools";
2024-09-01 13:26:37 -05:00
import { resolveNpubFromHostname } from "./helpers/dns.js";
import { getNsiteBlobs, parseNsiteEvent } from "./events.js";
2024-09-25 13:37:32 -05:00
import { downloadFile, getUserBlossomServers } from "./blossom.js";
import {
BLOSSOM_SERVERS,
ENABLE_SCREENSHOTS,
HOST,
NGINX_CACHE_DIR,
NSITE_HOMEPAGE,
NSITE_HOMEPAGE_DIR,
NSITE_HOST,
NSITE_PORT,
2024-10-06 10:01:15 -05:00
ONION_HOST,
SUBSCRIPTION_RELAYS,
} from "./env.js";
import { userDomains, userRelays, userServers } from "./cache.js";
import { invalidatePubkeyPath } from "./nginx.js";
import pool, { getUserOutboxes, subscribeForEvents } from "./nostr.js";
2025-01-22 10:32:12 -06:00
import logger from "./logger.js";
2024-09-01 13:26:37 -05:00
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = new Koa();
2024-09-25 13:37:32 -05:00
morgan.token("host", (req) => req.headers.host ?? "");
app.use(morgan(":method :host:url :status :response-time ms - :res[content-length]"));
2024-09-01 13:26:37 -05:00
// set CORS headers
app.use(
cors({
origin: "*",
allowMethods: "*",
allowHeaders: "Authorization,*",
exposeHeaders: "*",
}),
);
// handle errors
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
2024-09-25 13:37:32 -05:00
console.log(err);
ctx.status = 500;
2024-10-04 10:32:57 -05:00
if (err instanceof Error) ctx.body = { message: err.message };
2024-09-01 13:26:37 -05:00
}
});
// handle nsite requests
2024-09-01 13:26:37 -05:00
app.use(async (ctx, next) => {
let pubkey = await userDomains.get<string | undefined>(ctx.hostname);
// resolve pubkey if not in cache
if (pubkey === undefined) {
logger(`${ctx.hostname}: Resolving`);
pubkey = await resolveNpubFromHostname(ctx.hostname);
if (pubkey) {
await userDomains.set(ctx.hostname, pubkey);
logger(`${ctx.hostname}: Found ${pubkey}`);
} else {
await userDomains.set(ctx.hostname, "");
}
}
2024-09-01 13:26:37 -05:00
if (pubkey) {
2024-10-18 11:29:29 +01:00
const npub = npubEncode(pubkey);
2025-01-22 10:32:12 -06:00
const log = logger.extend(npub);
ctx.state.pubkey = pubkey;
2024-09-25 15:28:28 -05:00
let relays = await userRelays.get<string[] | undefined>(pubkey);
// fetch relays if not in cache
2024-09-25 15:28:28 -05:00
if (!relays) {
2025-01-22 10:32:12 -06:00
log(`Fetching relays`);
2024-09-26 09:06:08 -05:00
2024-09-25 15:28:28 -05:00
relays = await getUserOutboxes(pubkey);
2024-09-26 09:06:08 -05:00
if (relays) {
await userRelays.set(pubkey, relays);
2025-01-22 10:32:12 -06:00
log(`Found ${relays.length} relays`);
2024-09-26 09:06:08 -05:00
} else {
relays = [];
await userServers.set(pubkey, [], 30_000);
2025-01-22 10:32:12 -06:00
log(`Failed to find relays`);
2024-09-26 09:06:08 -05:00
}
2024-09-25 15:28:28 -05:00
}
2024-10-04 10:32:57 -05:00
relays.push(...SUBSCRIPTION_RELAYS);
if (relays.length === 0) throw new Error("No nostr relays");
2025-01-22 10:32:12 -06:00
log(`Searching for ${ctx.path}`);
2024-10-18 11:29:29 +01:00
let blobs = await getNsiteBlobs(pubkey, ctx.path, relays);
2024-09-07 17:15:12 -05:00
2024-09-25 13:37:32 -05:00
if (blobs.length === 0) {
2024-10-18 11:29:29 +01:00
// fallback to custom 404 page
2025-01-22 10:32:12 -06:00
log(`Looking for custom 404 page`);
2024-10-18 11:29:29 +01:00
blobs = await getNsiteBlobs(pubkey, "/404.html", relays);
}
if (blobs.length === 0) {
2025-01-22 10:32:12 -06:00
log(`Found 0 events`);
2024-09-25 13:37:32 -05:00
ctx.status = 404;
ctx.body = "Not Found";
return;
2024-09-01 13:26:37 -05:00
}
2024-09-25 15:28:28 -05:00
let servers = await userServers.get<string[] | undefined>(pubkey);
// fetch blossom servers if not in cache
2024-09-25 13:37:32 -05:00
if (!servers) {
2025-01-22 10:32:12 -06:00
log(`Fetching blossom servers`);
servers = await getUserBlossomServers(pubkey, relays);
if (servers) {
await userServers.set(pubkey, servers);
2025-01-22 10:32:12 -06:00
log(`Found ${servers.length} servers`);
} else {
servers = [];
await userServers.set(pubkey, [], 30_000);
2025-01-22 10:32:12 -06:00
log(`Failed to find servers`);
}
2024-09-25 13:37:32 -05:00
}
// always fetch from additional servers
2024-09-25 13:37:32 -05:00
servers.push(...BLOSSOM_SERVERS);
for (const blob of blobs) {
const res = await downloadFile(blob.sha256, servers);
if (res) {
const type = mime.getType(blob.path);
2024-10-06 10:01:15 -05:00
if (type) ctx.set("content-type", type);
2024-09-25 13:37:32 -05:00
else if (res.headers["content-type"]) ctx.set("content-type", res.headers["content-type"]);
// pass headers along
if (res.headers["content-length"]) ctx.set("content-length", res.headers["content-length"]);
2024-10-04 12:17:30 -05:00
if (res.headers["last-modified"]) ctx.set("last-modified", res.headers["last-modified"]);
2024-09-25 13:37:32 -05:00
2024-10-06 10:01:15 -05:00
// set Onion-Location header
if (ONION_HOST) {
const url = new URL(ONION_HOST);
url.hostname = npubEncode(pubkey) + "." + url.hostname;
ctx.set("Onion-Location", url.toString().replace(/\/$/, ""));
}
2024-10-04 12:05:47 -05:00
ctx.status = 200;
2024-09-25 13:37:32 -05:00
ctx.body = res;
return;
}
}
ctx.status = 500;
ctx.body = "Failed to find blob";
2024-09-01 13:26:37 -05:00
} else await next();
});
2024-10-06 10:01:15 -05:00
if (ONION_HOST) {
app.use((ctx, next) => {
// set Onion-Location header if it was not set before
if (!ctx.get("Onion-Location") && ONION_HOST) {
ctx.set("Onion-Location", ONION_HOST);
}
return next();
});
}
2024-10-04 10:32:57 -05:00
// get screenshots for websites
if (ENABLE_SCREENSHOTS) {
app.use(async (ctx, next) => {
if (ctx.method === "GET" && ctx.path.startsWith("/screenshot")) {
const [pubkey, etx] = basename(ctx.path).split(".");
if (pubkey) {
2024-10-04 11:59:50 -05:00
const { hasScreenshot, takeScreenshot, getScreenshotPath } = await import("./screenshots.js");
if (!(await hasScreenshot(pubkey))) await takeScreenshot(pubkey);
await send(ctx, getScreenshotPath(pubkey));
} else throw Error("Missing pubkey");
} else return next();
});
}
2024-10-04 10:32:57 -05:00
// download homepage
if (NSITE_HOMEPAGE) {
try {
const log = logger.extend("homepage");
// create the public dir
try {
fs.mkdirSync(NSITE_HOMEPAGE_DIR);
} catch (error) {}
const bin = (await import.meta.resolve("nsite-cli")).replace("file://", "");
const decode = nip19.decode(NSITE_HOMEPAGE);
if (decode.type !== "nprofile") throw new Error("NSITE_HOMEPAGE must be a valid nprofile");
// use nsite-cli to download the homepage
const args = [bin, "download", NSITE_HOMEPAGE_DIR, nip19.npubEncode(decode.data.pubkey)];
if (decode.data.relays) args.push("--relays", decode.data.relays?.join(","));
const child = spawn("node", args, { stdio: "pipe" });
child.on("spawn", () => log("Downloading..."));
child.stdout.on("data", (line) => log(line.toString("utf-8")));
child.on("error", (e) => log("Failed", e));
child.on("close", (code) => {
if (code === 0) log("Finished");
else log("Failed");
});
} catch (error) {
console.log(`Failed to download homepage`);
console.log(error);
}
}
// serve static files from public
const serveOptions: serve.Options = {
hidden: true,
maxAge: 60 * 60 * 1000,
index: "index.html",
};
try {
const www = NSITE_HOMEPAGE_DIR;
fs.statSync(www);
app.use(serve(www, serveOptions));
} catch (error) {
const www = path.resolve(__dirname, "../public");
app.use(serve(www, serveOptions));
}
2024-10-04 10:32:57 -05:00
app.listen({ host: NSITE_HOST, port: NSITE_PORT }, () => {
logger("Started on port", HOST);
2024-10-04 10:32:57 -05:00
});
// invalidate nginx cache and screenshots on new events
if (SUBSCRIPTION_RELAYS.length > 0) {
logger(`Listening for new nsite events on: ${SUBSCRIPTION_RELAYS.join(", ")}`);
2025-01-22 10:32:12 -06:00
subscribeForEvents(SUBSCRIPTION_RELAYS, async (event) => {
try {
const nsite = parseNsiteEvent(event);
if (nsite) {
2025-01-22 12:13:38 -06:00
const log = logger.extend(nip19.npubEncode(nsite.pubkey));
2024-10-04 10:32:57 -05:00
if (NGINX_CACHE_DIR) {
2025-01-22 12:13:38 -06:00
log(`Invalidating ${nsite.path}`);
2024-10-04 10:32:57 -05:00
await invalidatePubkeyPath(nsite.pubkey, nsite.path);
}
// invalidate screenshot for nsite
2024-10-04 11:59:50 -05:00
if (ENABLE_SCREENSHOTS && (nsite.path === "/" || nsite.path === "/index.html")) {
const { removeScreenshot } = await import("./screenshots.js");
2024-10-04 10:32:57 -05:00
await removeScreenshot(nsite.pubkey);
}
}
} catch (error) {
console.log(`Failed to invalidate ${event.id}`);
}
});
}
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
2024-09-25 13:37:32 -05:00
});
2024-09-01 13:26:37 -05:00
async function shutdown() {
logger("Shutting down...");
pool.destroy();
2024-09-01 13:26:37 -05:00
process.exit(0);
}
process.addListener("SIGTERM", shutdown);
process.addListener("SIGINT", shutdown);