MVP
This commit is contained in:
parent
87792c5089
commit
fa98e90210
5
.env.sample
Normal file
5
.env.sample
Normal 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
16
config.ts
Normal 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
13
dates.ts
Normal 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();
|
||||||
|
}
|
@ -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
41
deno.lock
generated
@ -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
38
feeds.ts
Normal 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();
|
||||||
|
}
|
41
handlers/user-atom-feed.ts
Normal file
41
handlers/user-atom-feed.ts
Normal 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;
|
@ -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 {
|
||||||
|
@ -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
72
html.ts
@ -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
23
ldap.ts
@ -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
21
main.ts
@ -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";
|
||||||
|
@ -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
54
models/profile.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
13
nostr.ts
13
nostr.ts
@ -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
19
tests/dates_test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
30
tests/fixtures/article-1.json
vendored
30
tests/fixtures/article-1.json
vendored
File diff suppressed because one or more lines are too long
9
tests/fixtures/profile-1.json
vendored
Normal file
9
tests/fixtures/profile-1.json
vendored
Normal 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": []
|
||||||
|
}
|
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
43
tests/models/profile_test.ts
Normal file
43
tests/models/profile_test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user