This commit is contained in:
Râu Cao 2024-10-21 15:17:14 +02:00
parent 87792c5089
commit fa98e90210
Signed by: raucao
GPG Key ID: 37036C356E56CC51
20 changed files with 462 additions and 91 deletions

5
.env.sample Normal file
View File

@ -0,0 +1,5 @@
HOME_RELAY_URL=wss://nostr.kosmos.org
LDAP_URL=ldap://10.1.1.116:389
LDAP_BIND_DN=uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org
LDAP_PASSWORD=123456abcdef
LDAP_SEARCH_DN=ou=kosmos.org,cn=users,dc=kosmos,dc=org

16
config.ts Normal file
View File

@ -0,0 +1,16 @@
import { load } from "@std/dotenv";
const dirname = new URL(".", import.meta.url).pathname;
await load({ envPath: `${dirname}/.env`, export: true });
const config = {
home_relay_url: Deno.env.get("HOME_RELAY_URL") || "",
ldap: {
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"),
}
};
export default config;

13
dates.ts Normal file
View File

@ -0,0 +1,13 @@
export function localizeDate(timestamp: number) {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function isoDate(timestamp: number) {
const date = new Date(timestamp * 1000);
return date.toISOString();
}

View File

@ -1,14 +1,15 @@
{ {
"tasks": { "tasks": {
"dev": "deno run --allow-net --allow-read --allow-env --deny-env --watch main.ts" "dev": "deno run --allow-net --allow-read --allow-env --watch main.ts"
}, },
"imports": { "imports": {
"@deno/gfm": "jsr:@deno/gfm@^0.9.0", "@deno/gfm": "jsr:@deno/gfm@^0.9.0",
"@nostr/tools": "jsr:@nostr/tools@^2.3.1", "@nostr/tools": "jsr:@nostr/tools@^2.3.1",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.1", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.1",
"@oak/oak": "jsr:@oak/oak@^17.1.0", "@oak/oak": "jsr:@oak/oak@^17.1.0",
"@std/assert": "jsr:@std/assert@1",
"@std/dotenv": "jsr:@std/dotenv@^0.225.2", "@std/dotenv": "jsr:@std/dotenv@^0.225.2",
"@std/expect": "jsr:@std/expect@^1.0.5",
"@std/testing": "jsr:@std/testing@^1.0.3",
"ldapts": "npm:ldapts@^7.2.1" "ldapts": "npm:ldapts@^7.2.1"
} }
} }

41
deno.lock generated
View File

@ -9,18 +9,24 @@
"jsr:@oak/commons@1": "1.0.0", "jsr:@oak/commons@1": "1.0.0",
"jsr:@oak/oak@^17.1.0": "17.1.0", "jsr:@oak/oak@^17.1.0": "17.1.0",
"jsr:@std/assert@1": "1.0.6", "jsr:@std/assert@1": "1.0.6",
"jsr:@std/assert@^1.0.6": "1.0.6",
"jsr:@std/bytes@1": "1.0.2", "jsr:@std/bytes@1": "1.0.2",
"jsr:@std/bytes@^1.0.2": "1.0.2", "jsr:@std/bytes@^1.0.2": "1.0.2",
"jsr:@std/crypto@1": "1.0.3", "jsr:@std/crypto@1": "1.0.3",
"jsr:@std/data-structures@^1.0.4": "1.0.4",
"jsr:@std/dotenv@~0.225.2": "0.225.2", "jsr:@std/dotenv@~0.225.2": "0.225.2",
"jsr:@std/encoding@1": "1.0.5", "jsr:@std/encoding@1": "1.0.5",
"jsr:@std/encoding@^1.0.5": "1.0.5", "jsr:@std/encoding@^1.0.5": "1.0.5",
"jsr:@std/encoding@~0.224.1": "0.224.3", "jsr:@std/encoding@~0.224.1": "0.224.3",
"jsr:@std/expect@^1.0.5": "1.0.5",
"jsr:@std/fs@^1.0.4": "1.0.4",
"jsr:@std/http@1": "1.0.8", "jsr:@std/http@1": "1.0.8",
"jsr:@std/internal@^1.0.4": "1.0.4", "jsr:@std/internal@^1.0.4": "1.0.4",
"jsr:@std/io@0.224": "0.224.9", "jsr:@std/io@0.224": "0.224.9",
"jsr:@std/media-types@1": "1.0.3", "jsr:@std/media-types@1": "1.0.3",
"jsr:@std/path@1": "1.0.6", "jsr:@std/path@1": "1.0.6",
"jsr:@std/path@^1.0.6": "1.0.6",
"jsr:@std/testing@^1.0.3": "1.0.3",
"npm:@noble/ciphers@~0.5.1": "0.5.3", "npm:@noble/ciphers@~0.5.1": "0.5.3",
"npm:@noble/curves@1.2.0": "1.2.0", "npm:@noble/curves@1.2.0": "1.2.0",
"npm:@noble/hashes@1.3.1": "1.3.1", "npm:@noble/hashes@1.3.1": "1.3.1",
@ -91,7 +97,7 @@
"@oak/commons@1.0.0": { "@oak/commons@1.0.0": {
"integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac", "integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac",
"dependencies": [ "dependencies": [
"jsr:@std/assert", "jsr:@std/assert@1",
"jsr:@std/bytes@1", "jsr:@std/bytes@1",
"jsr:@std/crypto", "jsr:@std/crypto",
"jsr:@std/encoding@1", "jsr:@std/encoding@1",
@ -103,13 +109,13 @@
"integrity": "14ffb400c3c268bdc7b3a838664fab782b4ed35bb0dfe7669013c95bb12a9503", "integrity": "14ffb400c3c268bdc7b3a838664fab782b4ed35bb0dfe7669013c95bb12a9503",
"dependencies": [ "dependencies": [
"jsr:@oak/commons", "jsr:@oak/commons",
"jsr:@std/assert", "jsr:@std/assert@1",
"jsr:@std/bytes@1", "jsr:@std/bytes@1",
"jsr:@std/crypto", "jsr:@std/crypto",
"jsr:@std/http", "jsr:@std/http",
"jsr:@std/io", "jsr:@std/io",
"jsr:@std/media-types", "jsr:@std/media-types",
"jsr:@std/path", "jsr:@std/path@1",
"npm:path-to-regexp@6.2.1" "npm:path-to-regexp@6.2.1"
] ]
}, },
@ -125,6 +131,9 @@
"@std/crypto@1.0.3": { "@std/crypto@1.0.3": {
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f" "integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
}, },
"@std/data-structures@1.0.4": {
"integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0"
},
"@std/dotenv@0.225.2": { "@std/dotenv@0.225.2": {
"integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23"
}, },
@ -134,6 +143,19 @@
"@std/encoding@1.0.5": { "@std/encoding@1.0.5": {
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
}, },
"@std/expect@1.0.5": {
"integrity": "8c7ac797e2ffe57becc6399c0f2fd06230cb9ef124d45229c6e592c563824af1",
"dependencies": [
"jsr:@std/assert@^1.0.6",
"jsr:@std/internal"
]
},
"@std/fs@1.0.4": {
"integrity": "2907d32d8d1d9e540588fd5fe0ec21ee638134bd51df327ad4e443aaef07123c",
"dependencies": [
"jsr:@std/path@^1.0.6"
]
},
"@std/http@1.0.8": { "@std/http@1.0.8": {
"integrity": "6ea1b2e8d33929967754a3b6d6c6f399ad6647d7bbb5a466c1eaf9b294a6ebcd", "integrity": "6ea1b2e8d33929967754a3b6d6c6f399ad6647d7bbb5a466c1eaf9b294a6ebcd",
"dependencies": [ "dependencies": [
@ -154,6 +176,16 @@
}, },
"@std/path@1.0.6": { "@std/path@1.0.6": {
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed" "integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
},
"@std/testing@1.0.3": {
"integrity": "f98c2bee53860a5916727d7e7d3abe920dd6f9edace022e2d059f00d05c2cf42",
"dependencies": [
"jsr:@std/assert@^1.0.6",
"jsr:@std/data-structures",
"jsr:@std/fs",
"jsr:@std/internal",
"jsr:@std/path@^1.0.6"
]
} }
}, },
"npm": { "npm": {
@ -455,8 +487,9 @@
"jsr:@nostr/tools@^2.3.1", "jsr:@nostr/tools@^2.3.1",
"jsr:@nostrify/nostrify@~0.36.1", "jsr:@nostrify/nostrify@~0.36.1",
"jsr:@oak/oak@^17.1.0", "jsr:@oak/oak@^17.1.0",
"jsr:@std/assert@1",
"jsr:@std/dotenv@~0.225.2", "jsr:@std/dotenv@~0.225.2",
"jsr:@std/expect@^1.0.5",
"jsr:@std/testing@^1.0.3",
"npm:ldapts@^7.2.1" "npm:ldapts@^7.2.1"
] ]
} }

38
feeds.ts Normal file
View File

@ -0,0 +1,38 @@
import Article from "./models/article.ts";
import Profile from "./models/profile.ts";
import { isoDate } from "./dates.ts";
export function profileAtomFeed(profile: Profile, articles: Article[]) {
const feedId = `tag:${profile.nip05},nostr-p-${profile.pubkey}-k-30023`;
const lastUpdate = articles.sort((a, b) => b.updatedAt - a.updatedAt)[0]
?.updatedAt;
const articlesXml = articles.map((article) => {
const articleId =
`tag:${profile.nip05},nostr-d-${article.identifier}-k-30023`;
return `
<entry>
<id>${articleId}</id>
<title>${article.title}</title>
<link href="/${article.naddr}" />
<updated>${isoDate(article.updatedAt)}</updated>
<summary>${article.summary}</summary>
<content type="html">${article.html}</content>
</entry>
`;
}).join("\n");
return `
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${profile.name} on Nostr</title>
<id>${feedId}</id>
<updated>${isoDate(lastUpdate)}</updated>
<icon>${profile.picture}</icon>
<author>
<name>${name}</name>
</author>
${articlesXml}
</feed>
`.trim();
}

View File

@ -0,0 +1,41 @@
import { Context } from "@oak/oak";
import { log } from "../log.ts";
import { lookupPubkeyByUsername } from "../ldap.ts";
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
import { profileAtomFeed } from "../feeds.ts";
import Article from "../models/article.ts";
import Profile from "../models/profile.ts";
const userAtomFeedHandler = async function (ctx: Context) {
const username = ctx.params.user.replace(/^(@|~)/, "");
const pubkey = await lookupPubkeyByUsername(username);
if (!pubkey) {
ctx.response.status = 404;
ctx.response.body = "Not Found";
return;
}
try {
const profileEvent = await fetchProfileEvent(pubkey);
const profile = new Profile(profileEvent, username);
if (profileEvent && profile.nip05) {
const articleEvents = await fetchArticlesByAuthor(pubkey);
const articles = articleEvents.map((a) => new Article(a));
const atom = profileAtomFeed(profile, articles);
ctx.response.headers.set("Content-Type", "application/atom+xml");
ctx.response.body = atom;
} 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 userAtomFeedHandler;

View File

@ -1,10 +1,9 @@
import { Context } from "@oak/oak"; import { Context } from "@oak/oak";
import { log } from "../log.ts"; import { log } from "../log.ts";
import { lookupPubkeyByUsername } from "../ldap.ts"; import { lookupPubkeyByUsername } from "../ldap.ts";
import { import { fetchProfileEvent, fetchReplaceableEvent } from "../nostr.ts";
fetchProfileEvent, import Article from "../models/article.ts";
fetchReplaceableEvent, import Profile from "../models/profile.ts";
} from "../nostr.ts";
import { articleHtml } from "../html.ts"; import { articleHtml } from "../html.ts";
const userEventHandler = async function (ctx: Context) { const userEventHandler = async function (ctx: Context) {
@ -24,11 +23,11 @@ const userEventHandler = async function (ctx: Context) {
identifier, identifier,
); );
const profileEvent = await fetchProfileEvent(pubkey); const profileEvent = await fetchProfileEvent(pubkey);
let profile;
if (articleEvent && profileEvent) { if (articleEvent && profileEvent) {
profile = JSON.parse(profileEvent.content); const article = new Article(articleEvent);
const html = articleHtml(articleEvent, profile); const profile = new Profile(profileEvent, username);
const html = articleHtml(article, profile);
ctx.response.body = html; ctx.response.body = html;
} else { } else {

View File

@ -2,9 +2,11 @@ import { Context } from "@oak/oak";
import { log } from "../log.ts"; import { log } from "../log.ts";
import { lookupPubkeyByUsername } from "../ldap.ts"; import { lookupPubkeyByUsername } from "../ldap.ts";
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts"; import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
import Article from "../models/article.ts";
import Profile from "../models/profile.ts";
import { profilePageHtml } from "../html.ts"; import { profilePageHtml } from "../html.ts";
const usernameHandler = async function (ctx: Context) { const userProfileHandler = async function (ctx: Context) {
const username = ctx.params.path.replace(/^(@|~)/, ""); const username = ctx.params.path.replace(/^(@|~)/, "");
const pubkey = await lookupPubkeyByUsername(username); const pubkey = await lookupPubkeyByUsername(username);
@ -18,8 +20,10 @@ const usernameHandler = async function (ctx: Context) {
const profileEvent = await fetchProfileEvent(pubkey); const profileEvent = await fetchProfileEvent(pubkey);
if (profileEvent) { if (profileEvent) {
const profile = new Profile(profileEvent, username);
const articleEvents = await fetchArticlesByAuthor(pubkey); const articleEvents = await fetchArticlesByAuthor(pubkey);
const html = profilePageHtml(profileEvent, articleEvents); const articles = articleEvents.map((a) => new Article(a));
const html = profilePageHtml(profile, articles);
ctx.response.body = html; ctx.response.body = html;
} else { } else {
@ -33,4 +37,4 @@ const usernameHandler = async function (ctx: Context) {
} }
}; };
export default usernameHandler; export default userProfileHandler;

72
html.ts
View File

@ -1,7 +1,8 @@
import { render as renderMarkdown } from "@deno/gfm"; import { localizeDate } from "./dates.ts";
import { nip19 } from "@nostr/tools"; import Article from "./models/article.ts";
import Profile from "./models/profile.ts";
export function htmlLayout(title: string, body: string) { export function htmlLayout(title: string, body: string, profile: Profile) {
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -10,6 +11,7 @@ export function htmlLayout(title: string, body: string) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>${title}</title> <title>${title}</title>
<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,400;0,700;0,900;1,400;1,700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,400;0,700;0,900;1,400;1,700&display=swap" rel="stylesheet">
@ -112,6 +114,11 @@ export function htmlLayout(title: string, body: string) {
color: #888; color: #888;
} }
p.meta .name a {
color: #3b3a38;
text-decoration: none;
}
.article-list .item { .article-list .item {
margin-bottom: 3rem; margin-bottom: 3rem;
} }
@ -128,66 +135,45 @@ export function htmlLayout(title: string, body: string) {
`; `;
} }
export function articleHtml(articleEvent: object, profile: object) { export function articleHtml(article: Article, profile: Profile) {
const titleTag = articleEvent.tags.find((t) => t[0] === "title"); const publishedAtFormatted = localizeDate(article.publishedAt);
const title = titleTag ? titleTag[1] : "Untitled";
const content = renderMarkdown(articleEvent.content);
const date = new Date(articleEvent.created_at * 1000);
const formattedDate = date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const body = ` const body = `
<main> <main>
<header> <header>
<h1>${title}</h1> <h1>${article.title}</h1>
<p class="meta"> <p class="meta">
<img class="avatar" src="${profile.picture}" alt="User Avatar" /> <img class="avatar" src="${profile.picture}" alt="User Avatar" />
<span class="content"> <span class="content">
<span class="name">${profile.name}</span> <span class="name"><a href="/@${profile.username}">${profile.name}</a></span>
<span class="date">${formattedDate}</span> <span class="date">${publishedAtFormatted}</span>
</span> </span>
</p> </p>
</header> </header>
${content} ${article.html}
</main> </main>
`; `;
return htmlLayout(title, body); return htmlLayout(article.title, body, profile);
} }
function articleListItemHtml(articleEvent: object) { function articleListItemHtml(article: Article) {
const identifier = articleEvent.tags.find((t) => t[0] === "d")[1]; const formattedDate = localizeDate(article.publishedAt);
const naddr = nip19.naddrEncode({
identifier: identifier,
pubkey: articleEvent.pubkey,
kind: articleEvent.kind,
});
const titleTag = articleEvent.tags.find((t) => t[0] === "title");
const title = titleTag ? titleTag[1] : "Untitled";
const date = new Date(articleEvent.created_at * 1000);
const formattedDate = date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
return ` return `
<div class="item"> <div class="item">
<h3><a href="/${naddr}">${title}</a></h3> <h3><a href="/${article.naddr}">${article.title}</a></h3>
<p>${formattedDate}</p> <p>${formattedDate}</p>
</div> </div>
`; `;
} }
export function articleListHtml(articleEvents: object[]) { export function articleListHtml(articles: Article[]) {
if (articleEvents.length === 0) return ""; if (articles.length === 0) return "";
let html = ""; let html = "";
for (const articleEvent of articleEvents) { for (const article of articles) {
html += articleListItemHtml(articleEvent); html += articleListItemHtml(article);
} }
return ` return `
@ -198,10 +184,8 @@ export function articleListHtml(articleEvents: object[]) {
`; `;
} }
export function profilePageHtml(profileEvent: object, articleEvents: object[]) { export function profilePageHtml(profile: Profile, articles: Article[]) {
const profile = JSON.parse(profileEvent.content); const title = `${profile.name} on Nostr`;
const name = profile.name || "Anonymous";
const title = `${name} on Nostr`;
const body = ` const body = `
<main class="profile-page"> <main class="profile-page">
@ -214,9 +198,9 @@ export function profilePageHtml(profileEvent: object, articleEvents: object[]) {
</p> </p>
</div> </div>
</header> </header>
${articleListHtml(articleEvents)} ${articleListHtml(articles)}
</main> </main>
`; `;
return htmlLayout(title, body); return htmlLayout(title, body, profile);
} }

23
ldap.ts
View File

@ -1,26 +1,17 @@
import { load } from "@std/dotenv";
import { Client } from "ldapts"; import { Client } from "ldapts";
import { log } from "./log.ts"; import { log } from "./log.ts";
import config from "./config.ts";
const dirname = new URL(".", import.meta.url).pathname; const { ldap } = config;
await load({ envPath: `${dirname}/.env`, export: true }); const client = new Client({ url: ldap.url });
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: string) { export async function lookupPubkeyByUsername(username: string) {
let pubkey; let pubkey;
try { try {
await client.bind(config.bindDN, config.password); await client.bind(ldap.bindDN, ldap.password);
const { searchEntries } = await client.search(config.searchDN, { const { searchEntries } = await client.search(ldap.searchDN, {
filter: `(cn=${username})`, filter: `(cn=${username})`,
attributes: ["nostrKey"], attributes: ["nostrKey"],
}); });
@ -39,9 +30,9 @@ export async function lookupUsernameByPubkey(pubkey: string) {
let username; let username;
try { try {
await client.bind(config.bindDN, config.password); await client.bind(ldap.bindDN, ldap.password);
const { searchEntries } = await client.search(config.searchDN, { const { searchEntries } = await client.search(ldap.searchDN, {
filter: `(nostrKey=${pubkey})`, filter: `(nostrKey=${pubkey})`,
attributes: ["cn"], attributes: ["cn"],
}); });

21
main.ts
View File

@ -3,8 +3,9 @@ import { log } from "./log.ts";
import naddrHandler from "./handlers/naddr.ts"; import naddrHandler from "./handlers/naddr.ts";
import nprofileHandler from "./handlers/nprofile.ts"; import nprofileHandler from "./handlers/nprofile.ts";
import npubHandler from "./handlers/npub.ts"; import npubHandler from "./handlers/npub.ts";
import usernameHandler from "./handlers/username.ts"; import userProfileHandler from "./handlers/user-profile.ts";
import userEventHandler from "./handlers/user-event.ts"; import userEventHandler from "./handlers/user-event.ts";
import userAtomFeedHandler from "./handlers/user-atom-feed.ts";
const router = new Router(); const router = new Router();
@ -18,7 +19,23 @@ router.get("/:path", async (ctx: ctx) => {
} else if (path.startsWith("npub")) { } else if (path.startsWith("npub")) {
await npubHandler(ctx); await npubHandler(ctx);
} else if (path.startsWith("@") || path.startsWith("~")) { } else if (path.startsWith("@") || path.startsWith("~")) {
await usernameHandler(ctx); await userProfileHandler(ctx);
} else {
ctx.response.status = 404;
ctx.response.body = "Not Found";
}
log(
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
"gray",
);
});
router.get("/:user/:kind.atom", async (ctx: ctx) => {
const { user } = ctx.params;
if (user.startsWith("@") || user.startsWith("~") || kind === "articles") {
await userAtomFeedHandler(ctx);
} else { } else {
ctx.response.status = 404; ctx.response.status = 404;
ctx.response.body = "Not Found"; ctx.response.body = "Not Found";

View File

@ -1,19 +1,47 @@
import { nip19 } from "@nostr/tools";
import { NEvent } from "../nostr.ts"; import { NEvent } from "../nostr.ts";
import { render as renderMarkdown } from "@deno/gfm"; import { render as renderMarkdown } from "@deno/gfm";
export default class Article { export default class Article {
private event: NEvent; event: NEvent;
constructor(event: NEvent) { constructor(event: NEvent) {
this.event = event; this.event = event;
} }
get identifier(): string | null { get identifier(): string {
const tag = this.event.tags.find((t) => t[0] === "d"); const tag = this.event.tags.find((t) => t[0] === "d");
return tag ? tag[1] : null; return tag ? tag[1] : "";
}
get title(): string {
const tag = this.event.tags.find((t) => t[0] === "title");
return tag ? tag[1] : "Untitled";
}
get summary(): string {
const tag = this.event.tags.find((t) => t[0] === "summary");
return tag ? tag[1] : "";
}
get publishedAt(): number {
const tag = this.event.tags.find((t) => t[0] === "published_at");
return tag ? parseInt(tag[1]) : this.event.created_at;
}
get updatedAt(): number {
return this.event.created_at;
} }
get html(): string { get html(): string {
return renderMarkdown(this.event.content); return renderMarkdown(this.event.content);
} }
get naddr(): string {
return nip19.naddrEncode({
identifier: this.identifier,
pubkey: this.event.pubkey,
kind: this.event.kind,
});
}
} }

54
models/profile.ts Normal file
View File

@ -0,0 +1,54 @@
import { nip19 } from "@nostr/tools";
import { NEvent } from "../nostr.ts";
export interface ProfileData {
name: string;
about?: string;
picture?: string;
nip05?: string;
lud16?: string;
}
export default class Profile {
event: NEvent;
private data: ProfileData;
username?: string;
constructor(event: NEvent, username?: string) {
this.event = event;
this.data = JSON.parse(event.content);
this.username = username;
}
get updatedAt(): number {
return this.event.created_at;
}
get name(): string {
return this.data.name || "Anonymous";
}
get about(): string {
return this.data.about || "";
}
get picture(): string | undefined {
return this.data.picture;
}
get nip05(): string | undefined {
return this.data.nip05;
}
get lud16(): string | undefined {
return this.data.lud16;
}
get pubkey(): string {
return this.event.pubkey;
}
get npub(): string {
return nip19.npubEncode(this.pubkey);
}
}

View File

@ -1,6 +1,17 @@
import { NRelay1 } from "@nostrify/nostrify"; import { NRelay1 } from "@nostrify/nostrify";
import config from "./config.ts";
export const relay = new NRelay1("wss://nostr.kosmos.org"); export interface NEvent {
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
sig: string;
tags: Array<[string, string, string?]>;
}
export const relay = new NRelay1(config.home_relay_url);
export async function fetchReplaceableEvent( export async function fetchReplaceableEvent(
pubkey: string, pubkey: string,

19
tests/dates_test.ts Normal file
View File

@ -0,0 +1,19 @@
import { describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { localizeDate } from "../dates.ts";
describe("Dates", () => {
describe("#localizeDate", () => {
it("returns a human-readable date for timestamp", () => {
const date = localizeDate(1726402055);
expect(date).toEqual("September 15, 2024");
});
});
describe("#isoDate", () => {
it("returns an ISO 8601 date for the timestamp", () => {
const date = localizeDate(1726402055);
expect(date).toEqual("September 15, 2024");
});
});
});

File diff suppressed because one or more lines are too long

9
tests/fixtures/profile-1.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"content": "{\"name\":\"Râu Cao ⚡\",\"nip05\":\"raucao@kosmos.org\",\"about\":\"Traveling full-time since 2010. Working on open-source software daily. Currently integrating Nostr features into Kosmos accounts.\",\"picture\":\"https://storage.kosmos.org/raucao/public/shares/240604-1441-fuerte-256.png\",\"lud16\":\"raucao@kosmos.org\",\"banner\":\"https://storage.kosmos.org/raucao/public/shares/240604-1517-1500x500.jpg\"}",
"created_at": 1728814592,
"id": "d437964cdd87f4b5bd595f47c2c64f4ba02c849ca215ed56fcb5fd3335ae2720",
"kind": 0,
"pubkey": "1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb",
"sig": "ee067f88344fa8380a16154b7d988087c41d6c87ae720dd52947a38e63232ab6998de37f28e8c7115a0604fc184035af543ad354ed7b616b8ba29974653042cc",
"tags": []
}

View File

@ -1,19 +1,16 @@
import { import { beforeAll, describe, it } from "@std/testing/bdd";
beforeAll,
beforeEach,
describe,
it,
} from "@std/testing/bdd";
import { expect } from "@std/expect"; import { expect } from "@std/expect";
import { NEvent } from "../../nostr.ts"; import { NEvent } from "../../nostr.ts";
import Article from "../../models/article.ts"; import Article from "../../models/article.ts";
describe("User", () => { describe("Article", () => {
let articleEvent: NEvent; let articleEvent: NEvent;
let article: Article; let article: Article;
beforeAll(() => { beforeAll(() => {
articleEvent = JSON.parse(Deno.readTextFileSync("tests/fixtures/article-1.json")); articleEvent = JSON.parse(
Deno.readTextFileSync("tests/fixtures/article-1.json"),
);
article = new Article(articleEvent); article = new Article(articleEvent);
}); });
@ -22,4 +19,44 @@ describe("User", () => {
expect(article.identifier).toEqual("1726396758485"); expect(article.identifier).toEqual("1726396758485");
}); });
}); });
describe("#title", () => {
it("returns the content of the 'title' tag", () => {
expect(article.title).toMatch(
/How to confirm a stuck Lightning channel closing transaction/,
);
});
});
describe("#summary", () => {
it("returns the content of the 'summary' tag", () => {
expect(article.summary).toEqual("");
});
});
describe("#publishedAt", () => {
it("returns the value of the first 'published_at' tag", () => {
expect(article.publishedAt).toEqual(1726402055);
});
});
describe("#updatedAt", () => {
it("returns the value of the first 'published_at' tag", () => {
expect(article.updatedAt).toEqual(1729462158);
});
});
describe("#html", () => {
it("returns a rendered HTML version of the 'content'", () => {
expect(article.html).toMatch(/<h2 id="the-solution">/);
});
});
describe("#naddr", () => {
it("returns bech32 addressable event ID", () => {
expect(article.naddr).toEqual(
"naddr1qvzqqqr4gupzq8meqkx80g3yuklzymy0qfx2ekk56aqc2ht4ak03z3em4r4cdcwtqqxnzdejxcenjd3hx5urgwp4676hkz",
);
});
});
}); });

View File

@ -0,0 +1,43 @@
import { beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { NEvent } from "../../nostr.ts";
import Profile from "../../models/profile.ts";
describe("Profile", () => {
let profileEvent: NEvent;
let profile: Profile;
beforeAll(() => {
profileEvent = JSON.parse(
Deno.readTextFileSync("tests/fixtures/profile-1.json"),
);
profile = new Profile(profileEvent);
});
describe("constructor", () => {
it("instantiates the username when given", () => {
profile = new Profile(profileEvent, "raucao");
expect(profile.username).toEqual("raucao");
});
});
describe("#updatedAt", () => {
it("returns the value of the profile event's 'created_at'", () => {
expect(profile.updatedAt).toEqual(1728814592);
});
});
describe("#name", () => {
it("returns the profile's name when present", () => {
expect(profile.name).toEqual("Râu Cao ⚡");
});
});
describe("#npub", () => {
it("returns the bech32-encoded version of the pubkey", () => {
expect(profile.npub).toEqual(
"npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees",
);
});
});
});