setup simple server

This commit is contained in:
hzrd149 2024-09-01 13:26:37 -05:00
commit a262539366
19 changed files with 2625 additions and 0 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
NOSTR_RELAYS=wss://nostrue.com
BLOSSOM_SERVERS=https://cdn.hzrd149.com

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
build
.env

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"printWidth": 120,
"useTabs": false,
"tabWidth": 2
}

22
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "node",
"type": "node",
"request": "launch",
"args": ["./src/index.ts"],
"runtimeArgs": ["--loader", "@swc-node/register/esm"],
"cwd": "${workspaceRoot}",
"protocol": "inspector",
"internalConsoleOptions": "openOnSessionStart",
"outputCapture": "std",
"env": {
"DEBUG": "nsite,nsite:*"
}
}
]
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"javascript.preferences.importModuleSpecifierEnding": "js"
}

64
package.json Normal file
View File

@ -0,0 +1,64 @@
{
"name": "nsite-ts",
"version": "0.1.0",
"description": "A blossom server implementation written in Typescript",
"main": "build/index.js",
"type": "module",
"author": "hzrd149",
"license": "MIT",
"scripts": {
"prepack": "yarn build",
"start": "node build/index.js",
"build": "tsc",
"postbuild": "cd admin && yarn build",
"dev": "nodemon -i '**/data/**' --exec 'node' --loader @swc-node/register/esm src/index.ts",
"format": "prettier -w ."
},
"bin": "build/index.js",
"files": [
"build",
"public",
"admin/dist"
],
"dependencies": {
"@koa/cors": "^5.0.0",
"@koa/router": "^12.0.1",
"@nostr-dev-kit/ndk": "^2.10.0",
"blossom-client-sdk": "^1.1.0",
"debug": "^4.3.5",
"dotenv": "^16.4.5",
"follow-redirects": "^1.15.6",
"http-errors": "1",
"koa": "^2.15.3",
"koa-mount": "^4.0.0",
"koa-static": "^5.0.0",
"mime": "^4.0.4",
"nostr-tools": "^2.7.2",
"socks-proxy-agent": "^8.0.4",
"websocket-polyfill": "^1.0.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@changesets/cli": "^2.27.1",
"@swc-node/register": "^1.9.0",
"@swc/core": "^1.5.0",
"@types/better-sqlite3": "^7.6.9",
"@types/debug": "^4.1.12",
"@types/follow-redirects": "^1.14.4",
"@types/http-errors": "^2.0.4",
"@types/koa": "^2.14.0",
"@types/koa-basic-auth": "^2.0.6",
"@types/koa-mount": "^4.0.5",
"@types/koa-static": "^4.0.4",
"@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.4",
"@types/node": "^20.11.19",
"@types/ws": "^8.5.10",
"nodemon": "^3.0.3",
"prettier": "^3.3.3",
"typescript": "^5.3.3"
},
"resolutions": {
"websocket-polyfill": "1.0.0"
}
}

21
public/index.html Normal file
View File

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nsite</title>
<script type="importmap">
{
"imports": {
"blossom-client-sdk": "https://esm.run/blossom-client-sdk",
"nostr-tools": "https://esm.run/nostr-tools"
}
}
</script>
</head>
<body>
<input type="file" id="files" webkitdirectory directory multiple />
<button id="upload-button">Upload nsite</button>
<script type="module" src="./main.js"></script>
</body>
</html>

137
public/main.js Normal file
View File

@ -0,0 +1,137 @@
import { multiServerUpload, BlossomClient } from "blossom-client-sdk";
import { SimplePool } from "nostr-tools";
const uploadButton = document.getElementById("upload-button");
/** @type {HTMLInputElement} */
const filesInput = document.getElementById("files");
/**
* @param {FileSystemFileEntry} fileEntry
* @returns {File}
*/
export function readFileSystemFile(fileEntry) {
return new Promise((res, rej) => {
fileEntry.file(
(file) => res(file),
(err) => rej(err),
);
});
}
/**
* @param {FileSystemDirectoryEntry} directory
* @returns {FileSystemEntry[]}
*/
export function readFileSystemDirectory(directory) {
return new Promise((res, rej) => {
directory.createReader().readEntries(
(entries) => res(entries),
(err) => rej(err),
);
});
}
/**
* uploads a file system entry to blossom servers
* @param {FileSystemEntry} entry
* @returns {{file: File, path: string, sha256: string}[]}
*/
async function readFileSystemEntry(entry) {
const files = [];
if (entry instanceof FileSystemFileEntry && entry.isFile) {
try {
const file = await readFileSystemFile(entry);
const sha256 = await BlossomClient.getFileSha256(file);
const path = entry.fullPath;
files.push({ file, path, sha256 });
} catch (e) {
console.log("Failed to add" + entry.fullPath);
console.log(e);
}
} else if (entry instanceof FileSystemDirectoryEntry && entry.isDirectory) {
const entries = await readFileSystemDirectory(entry);
for (const e of entries) files.push(...(await readFileSystemEntry(e)));
}
return files;
}
/**
* uploads a file system entry to blossom servers
* @param {FileList} list
* @returns {{file: File, path: string, sha256: string}[]}
*/
async function readFileList(list) {
const files = [];
for (const file of list) {
const path = file.webkitRelativePath ? file.webkitRelativePath : file.name;
const sha256 = await BlossomClient.getFileSha256(file);
files.push({ file, path, sha256 });
}
return files;
}
const pool = new SimplePool();
/**
* uploads a file system entry to blossom servers
* @param {{file:File, path:string}} files
* @param {import("blossom-client-sdk").Signer} signer
*/
async function uploadFiles(files, signer, auth) {
for (const { file, path, sha256 } of files) {
try {
const upload = multiServerUpload(["https://cdn.hzrd149.com", "https://cdn.satellite.earth"], file, signer, auth);
let published = false;
for await (let { blob } of upload) {
if (!published) {
const signed = await signer({
kind: 34128,
content: "",
created_at: Math.round(Date.now() / 1000),
tags: [
["d", path],
["x", sha256],
],
});
await pool.publish(["wss://nostrue.com"], signed);
console.log("Published", path, sha256, signed);
}
}
} catch (error) {
console.warn(`Failed to upload ${path}`, error);
}
}
}
uploadButton.addEventListener("click", async () => {
if (!window.nostr) return alert("Missing NIP-07 signer");
const signer = (draft) => window.nostr.signEvent(draft);
try {
if (filesInput.files) {
const files = await readFileList(filesInput.files);
// strip leading dir
for (const file of files) file.path = file.path.replace(/^[^\/]+\//, "/");
console.log(`Found files`, files);
// const auth = await BlossomClient.createUploadAuth(
// files.map((f) => f.sha256),
// signer,
// );
// console.log("Created upload auth", auth);
await uploadFiles(files, signer);
}
} catch (error) {
alert(`Failed to upload files: ${error.message}`);
}
});

1
src/const.ts Normal file
View File

@ -0,0 +1 @@
export const NSITE_KIND = 34128 as number;

9
src/env.ts Normal file
View File

@ -0,0 +1,9 @@
import "dotenv/config";
const NOSTR_RELAYS = process.env.NOSTR_RELAYS?.split(",") ?? [];
const BLOSSOM_SERVERS = process.env.BLOSSOM_SERVERS?.split(",") ?? [];
if (NOSTR_RELAYS.length === 0) throw new Error("Requires at least one relay in NOSTR_RELAYS");
if (BLOSSOM_SERVERS.length === 0) throw new Error("Requires at least one server in BLOSSOM_SERVERS");
export { NOSTR_RELAYS, BLOSSOM_SERVERS };

59
src/helpers/dns.ts Normal file
View File

@ -0,0 +1,59 @@
import dns from "node:dns";
import { nip19 } from "nostr-tools";
export function getCnameRecords(hostname: string) {
return new Promise<string[]>((res, rej) => {
dns.resolveCname(hostname, (err, records) => {
if (err) rej(err);
else res(records);
});
});
}
export function getTxtRecords(hostname: string) {
return new Promise<string[][]>((res, rej) => {
dns.resolveTxt(hostname, (err, records) => {
if (err) rej(err);
else res(records);
});
});
}
function extractNpubFromHostname(hostname: string) {
const [npub] = hostname.split(".");
if (npub.startsWith("npub")) {
const parsed = nip19.decode(npub);
if (parsed.type !== "npub") throw new Error("Expected npub");
return parsed.data;
}
}
export async function resolveNpubFromHostname(hostname: string) {
// check if domain contains an npub
let pubkey = extractNpubFromHostname(hostname);
if (pubkey) return pubkey;
if (hostname === "localhost") return undefined;
// try to get npub from CNAME or TXT records
try {
const cnameRecords = await getCnameRecords(hostname);
for (const cname of cnameRecords) {
const p = extractNpubFromHostname(cname);
if (p) return p;
}
} catch (error) {}
try {
const txtRecords = await getTxtRecords(hostname);
for (const txt of txtRecords) {
for (const entry of txt) {
const p = extractNpubFromHostname(entry);
if (p) return p;
}
}
} catch (error) {}
}

7
src/helpers/error.ts Normal file
View File

@ -0,0 +1,7 @@
import HttpErrors from "http-errors";
export function isHttpError(error: unknown): error is HttpErrors.HttpError {
if (!error) return false;
// @ts-expect-error
return error instanceof HttpErrors.HttpError || !!error.status || !!error.headers;
}

20
src/helpers/http.ts Normal file
View File

@ -0,0 +1,20 @@
import { IncomingMessage } from "http";
import followRedirects from "follow-redirects";
const { http, https } = followRedirects;
export function makeRequestWithAbort(url: URL) {
return new Promise<{ response: IncomingMessage; controller: AbortController }>((res, rej) => {
const cancelController = new AbortController();
const request = (url.protocol === "https:" ? https : http).get(
url,
{
signal: cancelController.signal,
},
(response) => {
res({ response, controller: cancelController });
},
);
request.on("error", (err) => rej(err));
request.end();
});
}

109
src/index.ts Normal file
View File

@ -0,0 +1,109 @@
#!/usr/bin/env node
import "./polyfill.js";
import Koa from "koa";
import serve from "koa-static";
import path from "node:path";
import cors from "@koa/cors";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import HttpErrors from "http-errors";
import logger from "./logger.js";
import { isHttpError } from "./helpers/error.js";
import { resolveNpubFromHostname } from "./helpers/dns.js";
import ndk from "./ndk.js";
import { NSITE_KIND } from "./const.js";
import { BLOSSOM_SERVERS } from "./env.js";
import { makeRequestWithAbort } from "./helpers/http.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = new Koa();
// set CORS headers
app.use(
cors({
origin: "*",
allowMethods: "*",
allowHeaders: "Authorization,*",
exposeHeaders: "*",
}),
);
// handle errors
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
if (isHttpError(err)) {
const status = (ctx.status = err.status || 500);
if (status >= 500) console.error(err.stack);
ctx.body = status > 500 ? { message: "Something went wrong" } : { message: err.message };
} else {
console.log(err);
ctx.status = 500;
ctx.body = { message: "Something went wrong" };
}
}
});
// serve nsite files
app.use(async (ctx, next) => {
const pubkey = (ctx.state.pubkey = await resolveNpubFromHostname(ctx.hostname));
if (pubkey) {
const event = await ndk.fetchEvent([
{ kinds: [NSITE_KIND], "#d": [ctx.path, ctx.path.replace(/^\//, "")], authors: [pubkey] },
]);
if (!event) throw new HttpErrors.NotFound("Failed to find event for path");
const sha256 = event.tags.find((t) => t[0] === "x" || t[0] === "sha256")?.[1];
if (!sha256) throw new HttpErrors.BadGateway("Failed to find file for path");
for (const server of BLOSSOM_SERVERS) {
try {
const { response } = await makeRequestWithAbort(new URL(sha256, server));
const { headers, statusCode } = response;
if (!headers || !statusCode) throw new Error("Missing headers or status code");
if (statusCode >= 200 && statusCode < 300) {
ctx.status = statusCode;
// @ts-expect-error
ctx.set(headers);
ctx.response.body = response;
} else {
// Consume response data to free up memory
response.resume();
}
} catch (error) {
// ignore error, try next server
}
}
// throw new HttpErrors.NotFound(`Unable to find ${sha256} on blossom servers`);
} else await next();
});
// serve static files from public
try {
const www = path.resolve(process.cwd(), "public");
fs.statSync(www);
app.use(serve(www));
} catch (error) {
const www = path.resolve(__dirname, "../public");
app.use(serve(www));
}
app.listen(process.env.PORT || 3000);
logger("Started on port", process.env.PORT || 3000);
async function shutdown() {
logger("Shutting down...");
process.exit(0);
}
process.addListener("SIGTERM", shutdown);
process.addListener("SIGINT", shutdown);

7
src/logger.ts Normal file
View File

@ -0,0 +1,7 @@
import debug from "debug";
if (!process.env.DEBUG) debug.enable("nsite, nsite:*");
const logger = debug("nsite");
export default logger;

10
src/ndk.ts Normal file
View File

@ -0,0 +1,10 @@
import NDK from "@nostr-dev-kit/ndk";
import { NOSTR_RELAYS } from "./env.js";
const ndk = new NDK({
explicitRelayUrls: NOSTR_RELAYS,
});
ndk.connect();
export default ndk;

3
src/polyfill.ts Normal file
View File

@ -0,0 +1,3 @@
import { WebSocket } from "ws";
global.WebSocket = global.WebSocket || WebSocket;

12
tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "es2020",
"moduleResolution": "NodeNext",
"outDir": "build",
"skipLibCheck": true,
"strict": true,
"sourceMap": true
},
"include": ["src"]
}

2131
yarn.lock Normal file

File diff suppressed because it is too large Load Diff