feat: Initial version of blossom based web server
This commit is contained in:
commit
7322c4781d
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
.env
|
||||
node_modules
|
||||
.github
|
||||
build
|
177
.gitignore
vendored
Normal file
177
.gitignore
vendored
Normal 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
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"useTabs": false,
|
||||
"tabWidth": 2
|
||||
}
|
27
Dockerfile
Normal file
27
Dockerfile
Normal 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
5
README.md
Normal 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
2437
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal 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
43
src/cdn.ts
Normal 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
62
src/drive.ts
Normal 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
134
src/index.ts
Normal 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
12
src/types.ts
Normal 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
12
tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"target": "es2020",
|
||||
"outDir": "build",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user