diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/deno.json b/deno.json index 85ed625..f06ec69 100644 --- a/deno.json +++ b/deno.json @@ -1,12 +1,14 @@ { "tasks": { - "dev": "deno run --allow-net --deny-env --watch main.ts" + "dev": "deno run --allow-net --allow-read --allow-env --deny-env --watch main.ts" }, "imports": { "@deno/gfm": "jsr:@deno/gfm@^0.9.0", "@nostr/tools": "jsr:@nostr/tools@^2.3.1", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.1", "@oak/oak": "jsr:@oak/oak@^17.1.0", - "@std/assert": "jsr:@std/assert@1" + "@std/assert": "jsr:@std/assert@1", + "@std/dotenv": "jsr:@std/dotenv@^0.225.2", + "ldapts": "npm:ldapts@^7.2.1" } } diff --git a/deno.lock b/deno.lock index 2e1cf6b..12a0e5f 100644 --- a/deno.lock +++ b/deno.lock @@ -12,6 +12,7 @@ "jsr:@std/bytes@1": "1.0.2", "jsr:@std/bytes@^1.0.2": "1.0.2", "jsr:@std/crypto@1": "1.0.3", + "jsr:@std/dotenv@~0.225.2": "0.225.2", "jsr:@std/encoding@1": "1.0.5", "jsr:@std/encoding@^1.0.5": "1.0.5", "jsr:@std/encoding@~0.224.1": "0.224.3", @@ -29,6 +30,7 @@ "npm:github-slugger@2": "2.0.0", "npm:he@^1.2.0": "1.2.0", "npm:katex@0.16": "0.16.11", + "npm:ldapts@^7.2.1": "7.2.1", "npm:lru-cache@^10.2.0": "10.2.2", "npm:marked-alert@2": "2.1.0_marked@12.0.2", "npm:marked-footnote@^1.2.0": "1.2.4_marked@12.0.2", @@ -123,6 +125,9 @@ "@std/crypto@1.0.3": { "integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f" }, + "@std/dotenv@0.225.2": { + "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" + }, "@std/encoding@0.224.3": { "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" }, @@ -218,9 +223,33 @@ "@scure/base@1.1.7" ] }, + "@types/asn1@0.2.4": { + "integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==", + "dependencies": [ + "@types/node" + ] + }, + "@types/node@22.5.4": { + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "dependencies": [ + "undici-types" + ] + }, + "asn1@0.2.6": { + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": [ + "safer-buffer" + ] + }, "commander@8.3.0": { "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==" }, + "debug@4.3.7": { + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": [ + "ms" + ] + }, "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, @@ -279,6 +308,17 @@ "commander" ] }, + "ldapts@7.2.1": { + "integrity": "sha512-2NSA9drjHdRiApF+TO18c+Hy/uyBLs96OS6Gia4+dPQWPxvqDbu3Ji2beCbNCXTvvgxDj4cLZ0WoOZLt5ojfAg==", + "dependencies": [ + "@types/asn1", + "asn1", + "debug", + "strict-event-emitter-types", + "uuid", + "whatwg-url" + ] + }, "lru-cache@10.2.2": { "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==" }, @@ -304,6 +344,9 @@ "marked@12.0.2": { "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==" }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "nanoid@3.3.7": { "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==" }, @@ -342,6 +385,12 @@ "prismjs@1.29.0": { "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==" }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "sanitize-html@2.13.1": { "integrity": "sha512-ZXtKq89oue4RP7abL9wp/9URJcqQNABB5GGJ2acW1sdO8JTVl92f4ygD7Yc9Ze09VAZhnt2zegeU0tbNsdcLYg==", "dependencies": [ @@ -356,9 +405,34 @@ "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, + "strict-event-emitter-types@2.0.0": { + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==" + }, + "tr46@5.0.0": { + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dependencies": [ + "punycode" + ] + }, + "undici-types@6.19.8": { + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "uuid@10.0.0": { + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==" + }, + "webidl-conversions@7.0.0": { + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, "websocket-ts@2.1.5": { "integrity": "sha512-rCNl9w6Hsir1azFm/pbjBEFzLD/gi7Th5ZgOxMifB6STUfTSovYAzryWw0TRvSZ1+Qu1Z5Plw4z42UfTNA9idA==" }, + "whatwg-url@14.0.0": { + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, "zod@3.23.8": { "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" } @@ -381,7 +455,9 @@ "jsr:@nostr/tools@^2.3.1", "jsr:@nostrify/nostrify@~0.36.1", "jsr:@oak/oak@^17.1.0", - "jsr:@std/assert@1" + "jsr:@std/assert@1", + "jsr:@std/dotenv@~0.225.2", + "npm:ldapts@^7.2.1" ] } } diff --git a/handlers/naddr.ts b/handlers/naddr.ts index ee0d6c8..b6716c5 100644 --- a/handlers/naddr.ts +++ b/handlers/naddr.ts @@ -1,4 +1,4 @@ -import { ctx } from "@oak/oak"; +import { Context } from "@oak/oak"; import { nip19 } from "@nostr/tools"; import { log } from "../log.ts"; import { articleHtml } from "../html.ts" @@ -7,12 +7,12 @@ import { fetchProfileEvent } from "../nostr.ts"; -const naddrHandler = async function (ctx: ctx) { +const naddrHandler = async function (ctx: Context) { const { request } = ctx; - const { path } = ctx.params; + const naddr = ctx.params.path; try { - const r = nip19.decode(path); + const r = nip19.decode(naddr); const articleEvent = await fetchReplaceableEvent(r.data.pubkey, r.data.identifier); const profileEvent = await fetchProfileEvent(r.data.pubkey); let profile; diff --git a/handlers/nprofile.ts b/handlers/nprofile.ts index d0d5ac4..36e4adb 100644 --- a/handlers/nprofile.ts +++ b/handlers/nprofile.ts @@ -1,10 +1,30 @@ import { Context } from "@oak/oak"; +import { nip19 } from "@nostr/tools"; +import { log } from "../log.ts"; +import { fetchProfileEvent } from "../nostr.ts"; +import { profilePageHtml } from "../html.ts" -const nprofileHandler = function (context: Context) { - const { request, response } = context; - const fullPath = request.url.pathname; +const nprofileHandler = async function (ctx: Context) { + const { request } = ctx; + const nprofile = ctx.params.path; - response.body = `You are viewing an nprofile with address: ${fullPath}`; + try { + const r = nip19.decode(nprofile); + const profileEvent = await fetchProfileEvent(r.data.pubkey); + + if (profileEvent) { + const html = profilePageHtml(profileEvent); + + ctx.response.body = html; + } else { + ctx.response.status = 404; + ctx.response.body = "Not Found"; + } + } catch (e) { + log(e, "yellow"); + ctx.response.status = 404; + ctx.response.body = "Not Found"; + } }; export default nprofileHandler; diff --git a/handlers/npub.ts b/handlers/npub.ts new file mode 100644 index 0000000..d47a9b0 --- /dev/null +++ b/handlers/npub.ts @@ -0,0 +1,30 @@ +import { Context } from "@oak/oak"; +import { nip19 } from "@nostr/tools"; +import { log } from "../log.ts"; +import { fetchProfileEvent } from "../nostr.ts"; +import { profilePageHtml } from "../html.ts" + +const npubHandler = async function (ctx: Context) { + const { request } = ctx; + const npub = ctx.params.path; + + try { + const r = nip19.decode(npub); + const profileEvent = await fetchProfileEvent(r.data); + + if (profileEvent) { + const html = profilePageHtml(profileEvent); + + ctx.response.body = html; + } else { + ctx.response.status = 404; + ctx.response.body = "Not Found"; + } + } catch (e) { + log(e, "yellow"); + ctx.response.status = 404; + ctx.response.body = "Not Found"; + } +}; + +export default npubHandler; diff --git a/handlers/username.ts b/handlers/username.ts new file mode 100644 index 0000000..ddccee8 --- /dev/null +++ b/handlers/username.ts @@ -0,0 +1,31 @@ +import { Context } from "@oak/oak"; +import { nip19 } from "@nostr/tools"; +import { log } from "../log.ts"; +import { lookupPubkeyByUsername } from "../ldap.ts"; +import { fetchProfileEvent } from "../nostr.ts"; +import { profilePageHtml } from "../html.ts" + +const usernameHandler = async function (ctx: Context) { + const { request } = ctx; + const username = ctx.params.path.replace(/^@/, '');; + const pubkey = await lookupPubkeyByUsername(username); + + try { + const profileEvent = await fetchProfileEvent(pubkey); + + if (profileEvent) { + const html = profilePageHtml(profileEvent); + + ctx.response.body = html; + } else { + ctx.response.status = 404; + ctx.response.body = "Not Found"; + } + } catch (e) { + log(e, "yellow"); + ctx.response.status = 404; + ctx.response.body = "Not Found"; + } +}; + +export default usernameHandler; diff --git a/html.ts b/html.ts index 2840e5a..05707a2 100644 --- a/html.ts +++ b/html.ts @@ -31,6 +31,11 @@ export function htmlLayout(title: string, body: string) { border-radius: 50%; } + .profile-page img.avatar { + height: 128px; + width: 128px; + } + a.anchor { display: none; } @@ -143,3 +148,32 @@ export function articleHtml(articleEvent: object, profile: object) { return htmlLayout(title, body); } + +export function profilePageHtml(profileEvent: object) { + const profile = JSON.parse(profileEvent.content); + const name = profile.name || "Anonymous"; + const title = `${name} on Nostr` + // const date = new Date(articleEvent.created_at * 1000); + // const formattedDate = date.toLocaleDateString("en-US", { + // year: "numeric", + // month: "long", + // day: "numeric", + // }); + + const body = ` +
+
+ User Avatar +
+

${profile.name}

+

+ ${profile.about} +

+
+
+

Articles

+
+ `; + + return htmlLayout(title, body); +} diff --git a/ldap.ts b/ldap.ts new file mode 100644 index 0000000..e390316 --- /dev/null +++ b/ldap.ts @@ -0,0 +1,35 @@ +import { load } from "@std/dotenv"; +import { Client } from 'ldapts'; +import { log } from "./log.ts"; + +const dirname = new URL('.', import.meta.url).pathname; +await load({ envPath: `${dirname}/.env`, export: true }); + +const config = { + url: Deno.env.get("LDAP_URL"), + bindDN: Deno.env.get("LDAP_BIND_DN"), + password: Deno.env.get("LDAP_PASSWORD"), + searchDN: Deno.env.get("LDAP_SEARCH_DN") +} + +const client = new Client({ url: config.url }); + +export async function lookupPubkeyByUsername (username) { + let pubkey; + + try { + await client.bind(config.bindDN, config.password); + + const { searchEntries } = await client.search(config.searchDN, { + filter: `(cn=${username})`, + attributes: ['nostrKey'] + }); + + pubkey = searchEntries[0]?.nostrKey; + } catch (ex) { + log(ex, "red"); + } finally { + await client.unbind(); + return pubkey; + } +} diff --git a/main.ts b/main.ts index f0a539e..0f25cf7 100644 --- a/main.ts +++ b/main.ts @@ -2,16 +2,22 @@ import { Application, Router } from "@oak/oak"; import { log } from "./log.ts"; import naddrHandler from "./handlers/naddr.ts"; import nprofileHandler from "./handlers/nprofile.ts"; +import npubHandler from "./handlers/npub.ts"; +import usernameHandler from "./handlers/username.ts"; const router = new Router(); router.get("/:path", async (ctx: ctx) => { const { path } = ctx.params; - if (path && path.startsWith("naddr")) { + if (path.startsWith("naddr")) { await naddrHandler(ctx); - } else if (prefix && prefix.startsWith("nprofile")) { + } else if (path.startsWith("nprofile")) { await nprofileHandler(ctx); + } else if (path.startsWith("npub")) { + await npubHandler(ctx); + } else if (path.startsWith("@")) { + await usernameHandler(ctx); } else { ctx.response.status = 404; ctx.response.body = "Not Found";