feat: Initial version of blossom based web server

This commit is contained in:
florian 2024-03-17 13:24:12 +01:00
commit 7322c4781d
12 changed files with 2968 additions and 0 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
.env
node_modules
.github
build

177
.gitignore vendored Normal file
View File

@ -0,0 +1,177 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
build

5
.prettierrc Normal file
View File

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

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1
FROM node:20.11 as builder
WORKDIR /app
# Install dependencies
COPY ./package*.json .
ENV NODE_ENV=development
RUN npm install
COPY . .
RUN npm run build
FROM node:20.11
WORKDIR /app
ENV NODE_ENV=production
COPY ./package*.json .
RUN npm install
COPY --from=builder ./app/build ./build
EXPOSE 3010
ENV DEBUG="web*,koa*,ndk*"
ENTRYPOINT [ "node", "build/index.js" ]

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# 🌸 blossom-drive-webserver
Serves a blossom-drive as a web page.
URL: https://sevrername.com/naddr1qvzqq....getnws2yc6ec/

2437
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "blossom-drive-webserver",
"description": "A web server that servces the content of a blossom drive",
"main": "index.js",
"author": "florian",
"license": "MIT",
"bin": "build/index.js",
"type": "module",
"devDependencies": {
"@types/bun": "latest",
"@types/debug": "^4.1.12",
"@types/follow-redirects": "^1.14.4",
"@types/http-errors": "^2.0.4",
"@types/koa": "^2.15.0",
"@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.4",
"@types/lodash": "^4.17.0",
"@types/node": "^20.11.28",
"prettier": "^3.2.5",
"tsc-watch": "^6.0.4",
"typescript": "^5.4.2"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@koa/cors": "^5.0.0",
"@koa/router": "^12.0.1",
"@nostr-dev-kit/ndk": "^2.5.0",
"debug": "^4.3.4",
"events": "^3.3.0",
"follow-redirects": "^1.15.6",
"http-error": "^0.0.6",
"koa": "^2.15.1",
"lodash": "^4.17.21",
"node-cache": "^5.1.2",
"nostr-tools": "^2.3.1",
"websocket-polyfill": "^0.0.3",
"ws": "^8.16.0"
},
"scripts": {
"start": "node build/index.js",
"build": "tsc",
"dev": "export DEBUG=web*,ndk*,koa*;tsc-watch --onSuccess \"node ./build/index.js\"",
"format": "prettier -w ."
},
"files": [
"build"
]
}

43
src/cdn.ts Normal file
View File

@ -0,0 +1,43 @@
import debug from "debug";
import type { IncomingMessage } from "http";
import followRedirects from "follow-redirects";
import uniq from "lodash/uniq.js";
const log = debug("web:discover:upstream");
export async function searchCdn(servers: string[], hash: string) {
const uniqueSevers = uniq(servers);
log("Looking for", hash);
for (const server of uniqueSevers) {
try {
log("Checking CDN server", server);
return await checkCDN(server, hash);
} catch (e) {
log("Ingoring error", e);
} // Ignore errors during lookup
}
log("No CDN with the content found.");
}
async function checkCDN(cdn: string, hash: string): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
const url = new URL(hash, cdn);
const backend = url.protocol === "https:" ? followRedirects.https : followRedirects.http;
var req = backend
.get(url.toString(), (res) => {
if (!res.statusCode) return reject();
if (res.statusCode < 200 || res.statusCode >= 400) {
res.destroy();
reject(new Error(res.statusMessage));
} else {
resolve(res);
log("Found", hash, "at", cdn);
}
})
.on("error", function (error) {
log("Ignoring error", error);
})
.end();
});
}

62
src/drive.ts Normal file
View File

@ -0,0 +1,62 @@
import NDK from "@nostr-dev-kit/ndk";
import debug from "debug";
import NodeCache from "node-cache";
import { Drive } from "./types.js";
const driveCache = new NodeCache({ stdTTL: 30 }); // 30s for development
const log = debug("web:drive:nostr");
const ndk = new NDK({
explicitRelayUrls: [
"wss://nostrue.com",
"wss://relay.damus.io",
//"wss://nostr.wine",
"wss://nos.lol",
// "wss://nostr-pub.wellorder.net",
],
});
ndk.connect();
export const readDrive = async (kind: number, pubkey: string, driveIdentifier: string): Promise<Drive | undefined> => {
const driveKey = `${kind}-${pubkey}-${driveIdentifier}`;
if (driveCache.has(driveKey)) {
log("using cached drive data for " + driveKey);
return driveCache.get(driveKey);
}
log("Fetching drive event.");
const filter = {
kinds: [kind],
authors: [pubkey],
"#d": [driveIdentifier], // name.toLowerCase().replaceAll(/\s/g, "-") || nanoid(8);
};
log(filter);
const event = await ndk.fetchEvent(filter);
log("fetch finsihed");
if (event) {
const treeTags = event.tags.filter((t) => t[0] === "x" || t[0] === "folder");
const files = treeTags.map((t) => ({
hash: t[1],
path: t[2],
size: parseInt(t[3], 10) || 0,
mimeType: t[4],
}));
// log(files);
const servers = event.tags.filter((t) => t[0] === "r" && t[1]).map((t) => new URL("/", t[1]).toString()) || [];
// log(servers);
const drive = { files, servers };
driveCache.set(driveKey, drive);
return drive;
} else {
log("no drive event found.");
}
};

134
src/index.ts Normal file
View File

@ -0,0 +1,134 @@
import "websocket-polyfill";
import Koa, { HttpError } from "koa";
import debug from "debug";
import Router from "@koa/router";
import { nip19 } from "nostr-tools";
import { AddressPointer } from "nostr-tools/nip19";
import { Drive, FileMeta } from "./types.js";
import { readDrive } from "./drive.js";
import { searchCdn } from "./cdn.js";
const log = debug("web");
const app = new Koa();
const router = new Router();
// const cacheAdapter = new NDKCacheAdapterDexie({ dbName: "ndk-cache" });
const additionalServers = ["https://cdn.hzrd149.com", "https://cdn.satellite.earth"];
const findFile = (drive: Drive, filePathToSearch: string): FileMeta | undefined => {
return drive.files.find((f) => f.path == "/" + filePathToSearch);
};
const findFolderContents = (drive: Drive, filePathToSearch: string): FileMeta[] => {
// TODO filter name
return drive.files.filter((f) => f.path.startsWith("/" + filePathToSearch));
};
// handle errors
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
if (err instanceof HttpError) {
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" };
}
}
});
router.get("(.*)", async (ctx, next) => {
log(`serving: host:${ctx.hostname} path:${ctx.path}`);
if (ctx.path == "/favicon.ico") {
return next();
}
// naddr1qvzqqqrhvvpzpd7x76g4e756vtlldg0syczdazxz83kxcmgm3a3v0nqswj0nql5pqqzhgetnwseqmkcq93
// test2
// 1708411351353.jpeg
// http://localhost:3010/naddr1qvzqqqrhvvpzpd7x76g4e756vtlldg0syczdazxz83kxcmgm3a3v0nqswj0nql5pqqzhgetnwseqmkcq93/test2/1708411351353.jpeg
const [naddr, ...path] = ctx.path.replace(/^\//, "").split("/");
if (!naddr || !naddr.startsWith("naddr")) {
ctx.status = 400;
ctx.message = "A naddr needs to be specific in the url.";
return;
}
let searchPath = decodeURI(path.join("/"));
log(`naddr: ${naddr} path: ${searchPath}`);
/* TODO try lookup of index.html and fallback to list, if not found
if (searchPath.endsWith("/")) {
searchPath = searchPath + "index.html";
}
if (searchPath == "") {
searchPath = searchPath + "/index.html";
}*/
// decode naddr
const { pubkey, kind, identifier } = nip19.decode(naddr).data as AddressPointer;
// fetch drive descriptor -> ndk
const drive = await readDrive(kind, pubkey, identifier);
log(`drive: ${drive}`);
if (drive) {
// lookup file in file-list
const fileMeta = findFile(drive, searchPath);
// log(`fileMeta: ${fileMeta}`);
if (fileMeta) {
const { hash, mimeType } = fileMeta;
// lookup media sevrers for user -> ndk (optional)
const cdnSource = await searchCdn([...drive.servers, ...additionalServers], hash);
//log(cdnSource);
if (cdnSource) {
ctx.set({
"Content-Type": mimeType,
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
ctx.body = cdnSource;
} else {
log("no CDN server found for blob: " + hash);
}
} else {
const folder = findFolderContents(drive, searchPath);
log("file not found in drive: " + searchPath);
ctx.status = 404;
ctx.set("Content-Type", "text/html");
const folderList = folder.map((f) => `<li><a href="${encodeURI("/" + naddr + f.path)}">${f.path}</a></li>`);
ctx.body = `<html><body><h2>Index of ${searchPath || "/"}</h2><ul>${folderList.join("")}</ul></body></html>`;
}
}
// fetch file from mediaserver and return to the caller
});
app.use(router.routes()).use(router.allowedMethods());
const port = process.env.PORT || 3010;
log("Starting blossom-drive-webserver on port " + port);
app.listen(port);
async function shutdown() {
log("Shutting down blossom-drive-webserver...");
process.exit(0);
}
// add this handler before emitting any events
process.on("uncaughtException", function (err) {
console.error("UNCAUGHT EXCEPTION - keeping process alive:", err);
});
process.addListener("SIGTERM", shutdown);
process.addListener("SIGINT", shutdown);

12
src/types.ts Normal file
View File

@ -0,0 +1,12 @@
export type FileMeta = {
hash: string;
path: string;
size: number;
mimeType: string;
};
export type Drive = {
servers: string[];
files: FileMeta[];
};

12
tsconfig.json Normal file
View File

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