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";
|
2024-09-01 13:26:37 -05:00
|
|
|
|
|
|
|
import { resolveNpubFromHostname } from "./helpers/dns.js";
|
2024-09-26 12:48:13 -05:00
|
|
|
import { getNsiteBlobs, parseNsiteEvent } from "./events.js";
|
2024-09-25 13:37:32 -05:00
|
|
|
import { downloadFile, getUserBlossomServers } from "./blossom.js";
|
2024-10-04 11:46:17 -05:00
|
|
|
import {
|
|
|
|
BLOSSOM_SERVERS,
|
|
|
|
ENABLE_SCREENSHOTS,
|
|
|
|
HOST,
|
|
|
|
NGINX_CACHE_DIR,
|
|
|
|
NSITE_HOST,
|
|
|
|
NSITE_PORT,
|
2024-10-06 10:01:15 -05:00
|
|
|
ONION_HOST,
|
2024-10-04 11:46:17 -05:00
|
|
|
SUBSCRIPTION_RELAYS,
|
|
|
|
} from "./env.js";
|
2024-09-26 12:48:13 -05:00
|
|
|
import { userDomains, userRelays, userServers } from "./cache.js";
|
|
|
|
import { invalidatePubkeyPath } from "./nginx.js";
|
|
|
|
import pool, { getUserOutboxes, subscribeForEvents } from "./nostr.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
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2024-09-26 12:48:13 -05:00
|
|
|
// handle nsite requests
|
2024-09-01 13:26:37 -05:00
|
|
|
app.use(async (ctx, next) => {
|
2024-09-26 12:48:13 -05:00
|
|
|
let pubkey = await userDomains.get<string | undefined>(ctx.hostname);
|
|
|
|
|
|
|
|
// resolve pubkey if not in cache
|
2025-01-22 08:51:46 -06:00
|
|
|
if (pubkey === undefined) {
|
2024-09-26 12:48:13 -05:00
|
|
|
console.log(`${ctx.hostname}: Resolving`);
|
|
|
|
pubkey = await resolveNpubFromHostname(ctx.hostname);
|
|
|
|
|
|
|
|
if (pubkey) {
|
|
|
|
await userDomains.set(ctx.hostname, pubkey);
|
|
|
|
console.log(`${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);
|
2024-09-26 12:48:13 -05:00
|
|
|
ctx.state.pubkey = pubkey;
|
|
|
|
|
2024-09-25 15:28:28 -05:00
|
|
|
let relays = await userRelays.get<string[] | undefined>(pubkey);
|
2024-09-26 12:48:13 -05:00
|
|
|
|
|
|
|
// fetch relays if not in cache
|
2024-09-25 15:28:28 -05:00
|
|
|
if (!relays) {
|
2024-10-18 11:29:29 +01:00
|
|
|
console.log(`${npub}: 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);
|
2024-10-18 11:29:29 +01:00
|
|
|
console.log(`${npub}: Found ${relays.length} relays`);
|
2024-09-26 09:06:08 -05:00
|
|
|
} else {
|
|
|
|
relays = [];
|
|
|
|
await userServers.set(pubkey, [], 30_000);
|
2024-10-18 11:29:29 +01:00
|
|
|
console.log(`${npub}: 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");
|
|
|
|
|
2024-10-18 11:29:29 +01:00
|
|
|
console.log(`${npub}: Searching for ${ctx.path}`);
|
|
|
|
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
|
|
|
|
console.log(`${npub}: Looking for custom 404 page`);
|
|
|
|
blobs = await getNsiteBlobs(pubkey, "/404.html", relays);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (blobs.length === 0) {
|
|
|
|
console.log(`${npub}: 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);
|
2024-09-26 12:48:13 -05:00
|
|
|
|
|
|
|
// fetch blossom servers if not in cache
|
2024-09-25 13:37:32 -05:00
|
|
|
if (!servers) {
|
2024-10-18 11:29:29 +01:00
|
|
|
console.log(`${npub}: Fetching blossom servers`);
|
2024-09-26 08:51:32 -05:00
|
|
|
servers = await getUserBlossomServers(pubkey, relays);
|
|
|
|
|
|
|
|
if (servers) {
|
|
|
|
await userServers.set(pubkey, servers);
|
2024-10-18 11:29:29 +01:00
|
|
|
console.log(`${npub}: Found ${servers.length} servers`);
|
2024-09-26 08:51:32 -05:00
|
|
|
} else {
|
|
|
|
servers = [];
|
|
|
|
await userServers.set(pubkey, [], 30_000);
|
2024-10-18 11:29:29 +01:00
|
|
|
console.log(`${npub}: Failed to find servers`);
|
2024-09-26 08:51:32 -05:00
|
|
|
}
|
2024-09-25 13:37:32 -05:00
|
|
|
}
|
2024-09-26 12:48:13 -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;
|
2024-09-26 08:51:32 -05:00
|
|
|
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
|
2024-10-04 11:46:17 -05:00
|
|
|
if (ENABLE_SCREENSHOTS) {
|
|
|
|
app.use(async (ctx, next) => {
|
|
|
|
if (ctx.method === "GET" && ctx.path.startsWith("/screenshot")) {
|
|
|
|
const [pubkey, etx] = basename(ctx.path).split(".");
|
2024-09-26 12:48:13 -05:00
|
|
|
|
2024-10-04 11:46:17 -05:00
|
|
|
if (pubkey) {
|
2024-10-04 11:59:50 -05:00
|
|
|
const { hasScreenshot, takeScreenshot, getScreenshotPath } = await import("./screenshots.js");
|
2024-10-04 11:46:17 -05:00
|
|
|
if (!(await hasScreenshot(pubkey))) await takeScreenshot(pubkey);
|
2024-09-26 12:48:13 -05:00
|
|
|
|
2024-10-04 11:46:17 -05:00
|
|
|
await send(ctx, getScreenshotPath(pubkey));
|
|
|
|
} else throw Error("Missing pubkey");
|
|
|
|
} else return next();
|
|
|
|
});
|
|
|
|
}
|
2024-10-04 10:32:57 -05:00
|
|
|
|
2025-01-22 08:51:46 -06:00
|
|
|
// serve static files from public
|
|
|
|
const serveOptions: serve.Options = {
|
|
|
|
hidden: true,
|
|
|
|
maxAge: 60 * 60 * 1000,
|
|
|
|
index: "index.html",
|
|
|
|
};
|
|
|
|
try {
|
|
|
|
const www = path.resolve(process.cwd(), "public");
|
|
|
|
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 }, () => {
|
|
|
|
console.log("Started on port", HOST);
|
|
|
|
});
|
|
|
|
|
|
|
|
// invalidate nginx cache and screenshots on new events
|
|
|
|
if (SUBSCRIPTION_RELAYS.length > 0) {
|
|
|
|
console.log(`Listening for new nsite events`);
|
2024-09-26 12:48:13 -05:00
|
|
|
subscribeForEvents(SUBSCRIPTION_RELAYS, async (event) => {
|
|
|
|
try {
|
|
|
|
const nsite = parseNsiteEvent(event);
|
|
|
|
if (nsite) {
|
2024-10-04 10:32:57 -05:00
|
|
|
if (NGINX_CACHE_DIR) {
|
|
|
|
console.log(`${nsite.pubkey}: Invalidating ${nsite.path}`);
|
|
|
|
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);
|
|
|
|
}
|
2024-09-26 12:48:13 -05:00
|
|
|
}
|
|
|
|
} 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() {
|
2024-09-25 13:37:32 -05:00
|
|
|
console.log("Shutting down...");
|
2024-09-26 12:48:13 -05:00
|
|
|
pool.destroy();
|
2024-09-01 13:26:37 -05:00
|
|
|
process.exit(0);
|
|
|
|
}
|
|
|
|
|
|
|
|
process.addListener("SIGTERM", shutdown);
|
|
|
|
process.addListener("SIGINT", shutdown);
|