mirror of
https://github.com/hzrd149/nsite-gateway.git
synced 2025-06-23 03:55:02 +00:00
setup simple server
This commit is contained in:
commit
a262539366
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
NOSTR_RELAYS=wss://nostrue.com
|
||||
BLOSSOM_SERVERS=https://cdn.hzrd149.com
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
build
|
||||
.env
|
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"useTabs": false,
|
||||
"tabWidth": 2
|
||||
}
|
22
.vscode/launch.json
vendored
Normal file
22
.vscode/launch.json
vendored
Normal 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
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"javascript.preferences.importModuleSpecifierEnding": "js"
|
||||
}
|
64
package.json
Normal file
64
package.json
Normal 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
21
public/index.html
Normal 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
137
public/main.js
Normal 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
1
src/const.ts
Normal file
@ -0,0 +1 @@
|
||||
export const NSITE_KIND = 34128 as number;
|
9
src/env.ts
Normal file
9
src/env.ts
Normal 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
59
src/helpers/dns.ts
Normal 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
7
src/helpers/error.ts
Normal 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
20
src/helpers/http.ts
Normal 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
109
src/index.ts
Normal 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
7
src/logger.ts
Normal 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
10
src/ndk.ts
Normal 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
3
src/polyfill.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
global.WebSocket = global.WebSocket || WebSocket;
|
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"target": "es2020",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "build",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user