diff --git a/.changeset/hot-llamas-clap.md b/.changeset/hot-llamas-clap.md new file mode 100644 index 0000000..6bb275b --- /dev/null +++ b/.changeset/hot-llamas-clap.md @@ -0,0 +1,5 @@ +--- +"nsite-ts": minor +--- + +Add simple landing page diff --git a/.env b/.env index bafcc40..30a8ff4 100644 --- a/.env +++ b/.env @@ -1,6 +1,2 @@ CACHE_PATH="in-memory" - -NOSTR_RELAYS=wss://nos.lol,wss://relay.damus.io -BLOSSOM_SERVERS=https://cdn.hzrd149.com - -NGINX_HOST='nginx' +LOOKUP_RELAYS=wss://user.kindpag.es,wss://purplepag.es diff --git a/docker-compose.yml b/docker-compose.yml index 6dd837c..3035b7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,8 @@ services: nsite: build: . + image: ghcr.io/hzrd149/nsite-ts:master environment: - NOSTR_RELAYS: wss://nos.lol,wss://relay.damus.io + LOOKUP_RELAYS: wss://user.kindpag.es,wss://purplepag.es ports: - 3000:80 diff --git a/nginx/default.conf b/nginx/default.conf index 8ff7ac4..c946f5f 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -6,7 +6,7 @@ server { proxy_cache request_cache; proxy_cache_valid 200 60m; proxy_cache_valid 404 10m; - proxy_cache_key $scheme$proxy_host$request_uri; + proxy_cache_key $scheme$host$request_uri; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; add_header X-Cache $upstream_cache_status; diff --git a/public/index.html b/public/index.html index 24ab39d..9e3679a 100644 --- a/public/index.html +++ b/public/index.html @@ -14,33 +14,19 @@ </script> </head> <body> - <label>relays</label> - <br /> - <textarea type="text" id="relays" cols="50" rows="4"></textarea> - <br /> - <br /> - <label>blossom servers</label> - <br /> - <textarea type="text" id="servers" cols="50" rows="4"></textarea> - <br /> - <br /> - <input type="file" id="files" webkitdirectory directory multiple /> - <button id="upload-button">Upload nsite</button> - <div - id="log" - style=" - max-height: 50em; - max-width: 80em; - width: 100%; - border: 1px solid gray; - min-height: 8em; - margin: 0.5em 0; - overflow: auto; - font-size: 0.8em; - gap: 0.1em; - white-space: pre; - " - ></div> + <h1>nsite-ts</h1> + <a href="https://github.com/hzrd149/nsite-ts" target="_blank">Source Code</a> + + <h2>Latest nsites:</h2> + <div id="sites"></div> + + <template id="site"> + <div class="site"> + <a class="pubkey link"></a> + <span class="date"></span> + </div> + </template> + <script type="module" src="./main.js"></script> </body> </html> diff --git a/public/main.js b/public/main.js index a0dda2b..92ba0ed 100644 --- a/public/main.js +++ b/public/main.js @@ -1,142 +1,32 @@ -import { multiServerUpload, BlossomClient } from "blossom-client-sdk"; -import { SimplePool } from "nostr-tools"; +import { nip19, SimplePool } from "nostr-tools"; -const logContainer = document.getElementById("log"); -function log(...args) { - const el = document.createElement("div"); - el.innerText = args.join(" "); - logContainer.appendChild(el); -} +const seen = new Set(); +function addSite(event) { + if (seen.has(event.pubkey)) return; + seen.add(event.pubkey); -const uploadButton = document.getElementById("upload-button"); + try { + const template = document.getElementById("site"); + const site = template.content.cloneNode(true); + const npub = nip19.npubEncode(event.pubkey); -/** @type {HTMLInputElement} */ -const filesInput = document.getElementById("files"); + site.querySelector(".pubkey").textContent = npub; + site.querySelector(".link").href = new URL("/", `${location.protocol}//${npub}.${location.host}`).toString(); -/** - * @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) { - log("Failed to add" + entry.fullPath); - log(e.message); - } - } else if (entry instanceof FileSystemDirectoryEntry && entry.isDirectory) { - const entries = await readFileSystemDirectory(entry); - for (const e of entries) files.push(...(await readFileSystemEntry(e))); + document.getElementById("sites").appendChild(site); + } catch (error) { + console.log("Failed to add site", event); + console.log(error); } - - 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 - * @param {*} auth - * @param {string[]} servers - * @param {string[]} relays - */ -async function uploadFiles(files, signer, auth, servers, relays) { - for (const { file, path, sha256 } of files) { - try { - const upload = multiServerUpload(servers, 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(relays, signed); - - log("Published", path, sha256, signed.id); - } - } - } catch (error) { - log(`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); - const relays = document.getElementById("relays").value.split(/\n|,/); - const servers = document.getElementById("servers").value.split(/\n|,/); - - try { - if (filesInput.files) { - const files = await readFileList(filesInput.files); - - // strip leading dir - for (const file of files) file.path = file.path.replace(/^[^\/]+\//, "/"); - - log(`Found ${files.length} files`); - - await uploadFiles(files, signer, undefined, servers, relays); - } - } catch (error) { - alert(`Failed to upload files: ${error.message}`); - } -}); +console.log("Loading sites"); +pool.subscribeMany( + ["wss://relay.damus.io", "wss://nos.lol", "wss://nostr.wine"], + [{ kinds: [34128], "#d": ["/index.html"] }], + { + onevent: addSite, + }, +); diff --git a/public/upload/index.html b/public/upload/index.html new file mode 100644 index 0000000..57c2ebd --- /dev/null +++ b/public/upload/index.html @@ -0,0 +1,46 @@ +<!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> + <label>relays</label> + <br /> + <textarea type="text" id="relays" cols="50" rows="4"></textarea> + <br /> + <br /> + <label>blossom servers</label> + <br /> + <textarea type="text" id="servers" cols="50" rows="4"></textarea> + <br /> + <br /> + <input type="file" id="files" webkitdirectory directory multiple /> + <button id="upload-button">Upload nsite</button> + <div + id="log" + style=" + max-height: 50em; + max-width: 80em; + width: 100%; + border: 1px solid gray; + min-height: 8em; + margin: 0.5em 0; + overflow: auto; + font-size: 0.8em; + gap: 0.1em; + white-space: pre; + " + ></div> + <script type="module" src="./upload.js"></script> + </body> +</html> diff --git a/public/upload/upload.js b/public/upload/upload.js new file mode 100644 index 0000000..a0dda2b --- /dev/null +++ b/public/upload/upload.js @@ -0,0 +1,142 @@ +import { multiServerUpload, BlossomClient } from "blossom-client-sdk"; +import { SimplePool } from "nostr-tools"; + +const logContainer = document.getElementById("log"); +function log(...args) { + const el = document.createElement("div"); + el.innerText = args.join(" "); + logContainer.appendChild(el); +} + +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) { + log("Failed to add" + entry.fullPath); + log(e.message); + } + } 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 + * @param {*} auth + * @param {string[]} servers + * @param {string[]} relays + */ +async function uploadFiles(files, signer, auth, servers, relays) { + for (const { file, path, sha256 } of files) { + try { + const upload = multiServerUpload(servers, 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(relays, signed); + + log("Published", path, sha256, signed.id); + } + } + } catch (error) { + log(`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); + const relays = document.getElementById("relays").value.split(/\n|,/); + const servers = document.getElementById("servers").value.split(/\n|,/); + + try { + if (filesInput.files) { + const files = await readFileList(filesInput.files); + + // strip leading dir + for (const file of files) file.path = file.path.replace(/^[^\/]+\//, "/"); + + log(`Found ${files.length} files`); + + await uploadFiles(files, signer, undefined, servers, relays); + } + } catch (error) { + alert(`Failed to upload files: ${error.message}`); + } +}); diff --git a/src/env.ts b/src/env.ts index d31b1be..57cdc4f 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,6 +1,7 @@ import "dotenv/config"; import xbytes from "xbytes"; +const LOOKUP_RELAYS = process.env.LOOKUP_RELAYS?.split(",") ?? ["wss://user.kindpag.es/", "wss://purplepag.es/"]; const NOSTR_RELAYS = process.env.NOSTR_RELAYS?.split(",") ?? []; const BLOSSOM_SERVERS = process.env.BLOSSOM_SERVERS?.split(",") ?? []; @@ -9,6 +10,4 @@ const MAX_FILE_SIZE = process.env.MAX_FILE_SIZE ? xbytes.parseSize(process.env.M const NGINX_HOST = process.env.NGINX_HOST; const CACHE_PATH = process.env.CACHE_PATH; -if (NOSTR_RELAYS.length === 0) throw new Error("Requires at least one relay in NOSTR_RELAYS"); - -export { NOSTR_RELAYS, BLOSSOM_SERVERS, MAX_FILE_SIZE, NGINX_HOST, CACHE_PATH }; +export { NOSTR_RELAYS, LOOKUP_RELAYS, BLOSSOM_SERVERS, MAX_FILE_SIZE, NGINX_HOST, CACHE_PATH }; diff --git a/src/events.ts b/src/events.ts index 584de88..417b103 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,6 +1,7 @@ import { extname, isAbsolute, join } from "path"; import { NSITE_KIND } from "./const.js"; import ndk from "./ndk.js"; +import { NDKRelaySet } from "@nostr-dev-kit/ndk"; export function getSearchPaths(path: string) { const paths = [path]; @@ -10,10 +11,10 @@ export function getSearchPaths(path: string) { // also look for relative paths for (const p of Array.from(paths)) { - if (isAbsolute(p)) paths.push(path.replace(/^\//, "")); + if (isAbsolute(p)) paths.push(p.replace(/^\//, "")); } - return paths; + return paths.filter((p) => !!p); } export function parseNsiteEvent(event: { pubkey: string; tags: string[][] }) { @@ -28,9 +29,13 @@ export function parseNsiteEvent(event: { pubkey: string; tags: string[][] }) { }; } -export async function getNsiteBlobs(pubkey: string, path: string) { +export async function getNsiteBlobs(pubkey: string, path: string, relays?: string[]) { const paths = getSearchPaths(path); - const events = await ndk.fetchEvents({ kinds: [NSITE_KIND], "#d": paths, authors: [pubkey] }); + const events = await ndk.fetchEvents( + { kinds: [NSITE_KIND], "#d": paths, authors: [pubkey] }, + {}, + relays && NDKRelaySet.fromRelayUrls(relays, ndk, true), + ); return Array.from(events) .map(parseNsiteEvent) diff --git a/src/index.ts b/src/index.ts index 43d9976..8385149 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,8 @@ import { resolveNpubFromHostname } from "./helpers/dns.js"; import { getNsiteBlobs } from "./events.js"; import { downloadFile, getUserBlossomServers } from "./blossom.js"; import { BLOSSOM_SERVERS } from "./env.js"; -import { userServers } from "./cache.js"; +import { userRelays, userServers } from "./cache.js"; +import { getUserOutboxes } from "./ndk.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -49,8 +50,16 @@ app.use(async (ctx, next) => { const pubkey = (ctx.state.pubkey = await resolveNpubFromHostname(ctx.hostname)); if (pubkey) { + console.log(`${pubkey}: Fetching relays`); + + let relays = await userRelays.get<string[] | undefined>(pubkey); + if (!relays) { + relays = await getUserOutboxes(pubkey); + if (relays) await userRelays.set(pubkey, relays); + } + console.log(`${pubkey}: Searching for ${ctx.path}`); - const blobs = await getNsiteBlobs(pubkey, ctx.path); + const blobs = await getNsiteBlobs(pubkey, ctx.path, relays); if (blobs.length === 0) { ctx.status = 404; @@ -58,7 +67,7 @@ app.use(async (ctx, next) => { return; } - let servers = await userServers.get<string[]>(pubkey); + let servers = await userServers.get<string[] | undefined>(pubkey); if (!servers) { console.log(`${pubkey}: Searching for blossom servers`); servers = (await getUserBlossomServers(pubkey)) ?? []; diff --git a/src/ndk.ts b/src/ndk.ts index 69f4729..4a00495 100644 --- a/src/ndk.ts +++ b/src/ndk.ts @@ -1,10 +1,17 @@ import NDK from "@nostr-dev-kit/ndk"; -import { NOSTR_RELAYS } from "./env.js"; +import { LOOKUP_RELAYS, NOSTR_RELAYS } from "./env.js"; const ndk = new NDK({ - explicitRelayUrls: NOSTR_RELAYS, + explicitRelayUrls: [...LOOKUP_RELAYS, ...NOSTR_RELAYS], }); ndk.connect(); +export async function getUserOutboxes(pubkey: string) { + const mailboxes = await ndk.fetchEvent({ kinds: [10002], authors: [pubkey] }); + if (!mailboxes) return; + + return mailboxes.tags.filter((t) => t[0] === "r" && (t[2] === undefined || t[2] === "write")).map((t) => t[1]); +} + export default ndk;