13 Commits

Author SHA1 Message Date
1e081c83e5 WIP Tags 2024-10-28 15:01:17 +01:00
cea96e170d Don't include deleted or empty articles in list 2024-10-28 13:56:36 +01:00
5906655902 Format inline code blocks in article titles 2024-10-28 12:36:15 +01:00
5f38355d5c Link plain Nostr URIs
Not just explicit Markdown links
2024-10-28 12:28:00 +01:00
010eb3f291 Refactor article fetching
* Apply different limits to profile and feed
* Ensure the limit is the returned amount
* Pre-sort articles
2024-10-26 12:04:06 +02:00
0b1eca87b2 Increase limit for article list
Needs pagination
2024-10-26 11:32:31 +02:00
f1d6ddbc84 Crop profile images to square format for preview 2024-10-25 18:40:22 +02:00
49d5aa4487 Add author pubkey to feed entry IDs 2024-10-25 18:01:03 +02:00
4c68be19fe Verify nip05 address, display status 2024-10-25 17:55:19 +02:00
a6517c61a4 Add user addresses to profile page, hide details by default 2024-10-25 16:25:25 +02:00
2624f2cbf8 Small CSS fix 2024-10-25 15:19:35 +02:00
cb4a4e06c8 Fix feeds 2024-10-25 14:59:20 +02:00
5f5f024ae7 Refactor/fix error handling, add query timeouts 2024-10-25 14:25:41 +02:00
22 changed files with 423 additions and 145 deletions

View File

@@ -7,7 +7,7 @@ body {
}
h1 {
margin: 4rem 0 0 0;
margin: 4rem 0 1.6rem 0;
}
h2, h3, h4 {
@@ -84,10 +84,6 @@ main header .draft-label {
font-weight: bold;
}
main header h1 {
margin-bottom: 1.6rem;
}
main header .meta {
display: flex;
column-gap: 1rem;
@@ -112,11 +108,24 @@ main.profile-page header {
margin-bottom: 2rem;
}
main.profile-page header h1 {
margin-bottom: 0;
}
main.profile-page header .nip05 {
font-size: 1rem;
}
main.profile-page img.avatar {
height: 8rem;
width: 8rem;
}
main.profile-page details summary {
cursor: pointer;
margin-bottom: 1.6rem;
}
main .article-list .item h3 {
margin-bottom: 0.5rem;
}
@@ -133,6 +142,22 @@ main article footer {
margin-top: 5rem;
}
main article footer .actions {
margin-bottom: 3rem;
}
main article footer p {
font-size: 1rem;
}
.nip05 .verified,
.nip05 .not-verified {
margin-left: 0.3rem;
padding: 0.1em 0.3em;
border-radius: 5px;
cursor: default;
}
/* Dropdown menu */
.dropdown {
@@ -201,7 +226,7 @@ main article footer {
margin: 4rem 1rem 8rem 1rem !important;
}
.profile-page h1 {
main.profile-page h1 {
margin-top: 2rem;
}

View File

@@ -68,10 +68,35 @@ main header .meta .name a {
color: var(--text-color-body);
}
main.profile-page header .nip05 {
color: var(--text-color-discreet);
}
main.profile-page .pubkey {
color: var(--text-color-discreet);
}
main article footer a,
main article footer a:visited {
text-decoration: none;
color: var(--text-color-discreet);
}
main article footer a:hover,
main article footer a:active {
text-decoration: underline;
}
.nip05 .verified {
background-color: #e8e3da;
color: #027739;
}
.nip05 .not-verified {
background-color: #e8e3da;
color: #770202;
}
/* Dropdown menu */
.dropdown {

View File

@@ -45,6 +45,7 @@ const config = {
password: Deno.env.get("LDAP_PASSWORD"),
searchDN: Deno.env.get("LDAP_SEARCH_DN"),
},
query_timeout: parseInt(Deno.env.get("RELAY_TIMEOUT_MS") || "5000"),
njump_url: Deno.env.get("NJUMP_URL") || "https://njump.me",
};

View File

@@ -14,7 +14,7 @@ export async function profileAtomFeed(
for (const article of articles) {
const contentHtml = await article.buildContentHtml();
const articleId =
`tag:${profile.nip05},nostr-d-${article.identifier}-k-30023`;
`tag:${profile.nip05},nostr-p-${profile.pubkey}-d-${article.identifier}-k-30023`;
articlesXml += `
<entry>
<id>${articleId}</id>

20
handlers/errors.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Context } from "@oak/oak";
import { errorPageHtml } from "../html.ts";
export const notFoundHandler = function (ctx: Context) {
const html = errorPageHtml(404, "Not found");
ctx.response.body = html;
ctx.response.status = 404;
};
export const badGatewayHandler = function (ctx: Context) {
const html = errorPageHtml(502, "Bad gateway");
ctx.response.body = html;
ctx.response.status = 502;
};
export const internalServerErrorHandler = function (ctx: Context) {
const html = errorPageHtml(500, "Internal server error");
ctx.response.body = html;
ctx.response.status = 500;
};

View File

@@ -1,22 +1,25 @@
import { Context } from "@oak/oak";
import { nip19 } from "@nostr/tools";
import { lookupUsernameByPubkey } from "../directory.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
const naddrHandler = async function (ctx: Context) {
const naddr = ctx.state.path;
let data: nip19.AddressPointer;
try {
const data = nip19.decode(naddr).data as nip19.AddressPointer;
const username = await lookupUsernameByPubkey(data.pubkey);
if (username && data.identifier) {
ctx.response.redirect(`/@${username}/${data.identifier}`);
} else {
notFoundHandler(ctx);
}
data = nip19.decode(naddr).data as nip19.AddressPointer;
} catch (_e) {
notFoundHandler(ctx);
return;
}
const username = await lookupUsernameByPubkey(data.pubkey);
if (username && data.identifier) {
ctx.response.redirect(`/@${username}/${data.identifier}`);
} else {
notFoundHandler(ctx);
}
};

View File

@@ -1,10 +0,0 @@
import { Context } from "@oak/oak";
import { errorPageHtml } from "../html.ts";
const notFoundHandler = function (ctx: Context) {
const html = errorPageHtml(404, "Not found");
ctx.response.body = html;
ctx.response.status = 404;
};
export default notFoundHandler;

View File

@@ -1,22 +1,26 @@
import { Context } from "@oak/oak";
import { nip19 } from "@nostr/tools";
import { lookupUsernameByPubkey } from "../directory.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
const nprofileHandler = async function (ctx: Context) {
const nprofile = ctx.state.path;
let data: nip19.ProfilePointer;
try {
const data = nip19.decode(nprofile).data as nip19.ProfilePointer;
const username = await lookupUsernameByPubkey(data.pubkey);
if (username) {
ctx.response.redirect(`/@${username}`);
} else {
notFoundHandler(ctx);
}
data = nip19.decode(nprofile).data as nip19.ProfilePointer;
console.log(data);
} catch (_e) {
notFoundHandler(ctx);
return;
}
const username = await lookupUsernameByPubkey(data.pubkey);
if (username) {
ctx.response.redirect(`/@${username}`);
} else {
notFoundHandler(ctx);
}
};

View File

@@ -1,22 +1,25 @@
import { Context } from "@oak/oak";
import { nip19 } from "@nostr/tools";
import { lookupUsernameByPubkey } from "../directory.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
const npubHandler = async function (ctx: Context) {
const npub = ctx.state.path;
let pubkey: string;
try {
const pubkey = nip19.decode(npub).data as string;
const username = await lookupUsernameByPubkey(pubkey);
if (username) {
ctx.response.redirect(`/@${username}`);
} else {
notFoundHandler(ctx);
}
pubkey = nip19.decode(npub).data as string;
} catch (_e) {
notFoundHandler(ctx);
return;
}
const username = await lookupUsernameByPubkey(pubkey);
if (username) {
ctx.response.redirect(`/@${username}`);
} else {
notFoundHandler(ctx);
}
};

View File

@@ -2,9 +2,8 @@ import { Context } from "@oak/oak";
import { lookupPubkeyByUsername } from "../directory.ts";
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
import { profileAtomFeed } from "../feeds.ts";
import Article from "../models/article.ts";
import Profile from "../models/profile.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
const userAtomFeedHandler = async function (ctx: Context) {
const username = ctx.state.username;
@@ -15,26 +14,21 @@ const userAtomFeedHandler = async function (ctx: Context) {
return;
}
try {
const profileEvent = await fetchProfileEvent(pubkey);
const profileEvent = await fetchProfileEvent(pubkey);
if (profileEvent) {
const profile = new Profile(profileEvent, username);
if (profileEvent) {
const profile = new Profile(profileEvent, username);
if (profile.nip05) {
const articleEvents = await fetchArticlesByAuthor(pubkey);
const articles = articleEvents.map((a) => new Article(a));
const atom = profileAtomFeed(profile, articles);
if (profile.nip05) {
const articles = await fetchArticlesByAuthor(pubkey, 10);
const atom = await profileAtomFeed(profile, articles);
ctx.response.headers.set("Content-Type", "application/atom+xml");
ctx.response.body = atom;
return;
}
ctx.response.headers.set("Content-Type", "application/atom+xml");
ctx.response.body = atom;
return;
}
notFoundHandler(ctx);
} catch (_e) {
notFoundHandler(ctx);
}
notFoundHandler(ctx);
};
export default userAtomFeedHandler;

View File

@@ -4,7 +4,7 @@ import { fetchProfileEvent, fetchReplaceableEvent } from "../nostr.ts";
import Article from "../models/article.ts";
import Profile from "../models/profile.ts";
import { articleHtml } from "../html.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
import { generateOgProfileImage } from "../magick.ts";
const userEventHandler = async function (ctx: Context) {
@@ -17,24 +17,20 @@ const userEventHandler = async function (ctx: Context) {
return;
}
try {
const articleEvent = await fetchReplaceableEvent(
pubkey,
identifier,
);
const profileEvent = await fetchProfileEvent(pubkey);
const articleEvent = await fetchReplaceableEvent(
pubkey,
identifier,
);
const profileEvent = await fetchProfileEvent(pubkey);
if (articleEvent && profileEvent) {
const article = new Article(articleEvent);
const profile = new Profile(profileEvent, username);
const html = await articleHtml(article, profile);
generateOgProfileImage(profile);
if (articleEvent && profileEvent) {
const article = new Article(articleEvent);
const profile = new Profile(profileEvent, username);
const html = await articleHtml(article, profile);
generateOgProfileImage(profile);
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
} catch (_e) {
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
};

View File

@@ -1,10 +1,9 @@
import { Context } from "@oak/oak";
import { lookupPubkeyByUsername } from "../directory.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 notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
import { generateOgProfileImage } from "../magick.ts";
const userProfileHandler = async function (ctx: Context) {
@@ -16,21 +15,18 @@ const userProfileHandler = async function (ctx: Context) {
return;
}
try {
const profileEvent = await fetchProfileEvent(pubkey);
const profileEvent = await fetchProfileEvent(pubkey);
if (profileEvent) {
const profile = new Profile(profileEvent, username);
const articleEvents = await fetchArticlesByAuthor(pubkey);
const articles = articleEvents.map((a) => new Article(a));
const html = profilePageHtml(profile, articles);
generateOgProfileImage(profile);
if (profileEvent) {
const profile = new Profile(profileEvent, username);
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
} catch (_e) {
const articles = await fetchArticlesByAuthor(pubkey, 210, ctx.state.tags);
const html = await profilePageHtml(profile, articles);
generateOgProfileImage(profile);
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
};

69
html.ts
View File

@@ -38,6 +38,16 @@ export function errorPageHtml(statusCode: number, title: string): string {
return htmlLayout({ title, body });
}
function articleTagsHtml(article: Article, profile: Profile): string {
if (article.tags.length === 0) return "";
const tags = article.tags.map((tag) => {
return `<a href="/@${profile.username}?tags=${tag}">${tag}</a>`;
});
return `Tags: ${tags.join(", ")}</p>\n`;
}
export async function articleHtml(
article: Article,
profile: Profile,
@@ -53,7 +63,7 @@ export async function articleHtml(
<main>
<header>
${draftLabel}
<h1>${article.title}</h1>
<h1>${titleHtml(article.title)}</h1>
<div class="meta">
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
<div class="content">
@@ -65,7 +75,12 @@ export async function articleHtml(
<article>
${await article.buildContentHtml()}
<footer>
${openWithNostrAppHtml(article.naddr)}
<div class="actions">
${openWithNostrAppHtml(article.naddr)}
</div>
<p class="tags">
${articleTagsHtml(article, profile)}
</p>
</footer>
</article>
</main>
@@ -77,12 +92,16 @@ export async function articleHtml(
return htmlLayout({ title: pageTitle, body, metaHtml });
}
function titleHtml(title: string) {
return title.replace(/`([^`]+)`/g, "<code>$1</code>");
}
function articleListItemHtml(article: Article): string {
const formattedDate = localizeDate(article.publishedAt);
return `
<div class="item">
<h3><a href="/${article.naddr}">${article.title}</a></h3>
<h3><a href="/${article.naddr}">${titleHtml(article.title)}</a></h3>
<p class="meta">
${formattedDate}
</p>
@@ -92,10 +111,9 @@ function articleListItemHtml(article: Article): string {
export function articleListHtml(articles: Article[]): string {
if (articles.length === 0) return "";
const sortedArticles = articles.sort((a, b) => b.publishedAt - a.publishedAt);
let html = "";
for (const article of sortedArticles) {
for (const article of articles) {
html += articleListItemHtml(article);
}
@@ -107,8 +125,40 @@ export function articleListHtml(articles: Article[]): string {
`;
}
export function profilePageHtml(profile: Profile, articles: Article[]): string {
function userAddressHtml(profile: Profile) {
let html = "";
if (profile.nip05) {
html += `<dt>Nostr address</dt><dd>${profile.nip05}</dd>\n`;
}
if (profile.lud16) {
html += `<dt>Lightning address</dt><dd>${profile.lud16}</dd>\n`;
}
return html;
}
function nip05VerifiedHtml(verified: boolean): string {
if (verified) {
return ` <span class="verified" title="Verified">✔</span>`;
} else {
return ` <span class="not-verified" title="Verification failed">✕</span>`;
}
}
export async function profilePageHtml(
profile: Profile,
articles: Article[],
): Promise<string> {
const title = `${profile.name} on Nostr`;
let nip05Html = "";
if (profile.nip05) {
const nip05Verified = await profile.verifyNip05();
nip05Html += `<p class="nip05">${profile.nip05}${
nip05VerifiedHtml(nip05Verified)
}</p>\n`;
}
const body = `
<main class="profile-page">
@@ -116,17 +166,20 @@ export function profilePageHtml(profile: Profile, articles: Article[]): string {
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
<div class="bio">
<h1>${profile.name}</h1>
${nip05Html}
<p class="about">
${profile.about}
</p>
</div>
</header>
<section>
<details>
<summary>Details</summary>
<dl>
<dt>Public key</dt>
<dd>${profile.npub}</dd>
${userAddressHtml(profile)}
</dl>
</section>
</details>
<section>
${articleListHtml(articles)}
</section>

10
ldap.ts
View File

@@ -25,10 +25,11 @@ export async function lookupPubkeyByUsername(username: string) {
) {
pubkey = searchEntries[0].nostrKey;
}
} catch (e) {
console.error(e);
} finally {
await client.unbind();
} catch (e) {
await client.unbind();
throw e;
}
return pubkey;
@@ -49,9 +50,8 @@ export async function lookupUsernameByPubkey(pubkey: string) {
username = searchEntries[0].cn;
}
} catch (e) {
console.error(e);
} finally {
await client.unbind();
throw e;
}
return username;

View File

@@ -13,7 +13,9 @@ function createRoundedImage(profile: Profile) {
const args = [
profile.picture,
'-resize', '256x256',
'-resize', '256x256^',
'-gravity', 'center',
'-extent', '256x256',
'(', '+clone', '-alpha', 'extract',
'-draw', "fill black polygon 0,0 0,128 128,0 fill white circle 128,128 128,0",
'(', '+clone', '-flip', ')', '-compose', 'Multiply', '-composite',

View File

@@ -39,6 +39,13 @@ export default class Article {
return tag ? tag[1] : "";
}
get tags(): string[] {
return this.event.tags
.filter((t) => t[0] === "t")
.filter((t) => t[1] !== "")
.map((t) => t[1]);
}
get publishedAt(): number {
const tag = this.event.tags.find((t) => t[0] === "published_at");
return tag ? parseInt(tag[1]) : this.event.created_at;
@@ -48,6 +55,14 @@ export default class Article {
return this.event.created_at;
}
get content(): string {
return this.event.content;
}
get isDeleted(): boolean {
return !!this.event.tags.find((t) => t[0] === "deleted");
}
get naddr(): string {
return nip19.naddrEncode({
identifier: this.identifier,

View File

@@ -1,4 +1,5 @@
import { nip19, NostrEvent as NEvent } from "@nostr/tools";
import { verifyNip05Address } from "../nostr.ts";
import { getImageMagickCommand } from "../utils.ts";
import config from "../config.ts";
@@ -43,7 +44,7 @@ export default class Profile {
}
get nip05(): string | undefined {
return this.data.nip05;
return this.data.nip05?.replace("_@", "");
}
get lud16(): string | undefined {
@@ -69,4 +70,12 @@ export default class Profile {
return this.picture || "";
}
}
verifyNip05(): Promise<boolean> {
if (typeof this.data.nip05 !== "undefined") {
return verifyNip05Address(this.data.nip05, this.pubkey);
} else {
return Promise.resolve(false);
}
}
}

View File

@@ -1,7 +1,8 @@
import { NPool, NRelay1 } from "@nostrify/nostrify";
import { NostrEvent, NostrFilter, NPool, NRelay1 } from "@nostrify/nostrify";
import { nip19 } from "@nostr/tools";
import { lookupUsernameByPubkey } from "./directory.ts";
import config from "./config.ts";
import Article from "./models/article.ts";
const relayPool = new NPool({
open: (url) => new NRelay1(url),
@@ -14,47 +15,78 @@ const relayPool = new NPool({
eventRouter: async (_event) => [],
});
export async function fetchWithTimeout(filters: NostrFilter[]) {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("relay timeout")), config.query_timeout)
);
const eventsPromise = relayPool.query(filters);
const events = await Promise.race([eventsPromise, timeoutPromise]);
return events;
}
export async function fetchReplaceableEvent(
pubkey: string,
identifier: string,
) {
let events = await relayPool.query([{
let events = await fetchWithTimeout([{
authors: [pubkey],
kinds: [30023],
"#d": [identifier],
limit: 1,
}]);
}]) as NostrEvent[];
if (events.length > 0) {
return events[0];
} else {
events = await relayPool.query([{
events = await fetchWithTimeout([{
authors: [pubkey],
kinds: [30024],
"#d": [identifier],
limit: 1,
}]);
}]) as NostrEvent[];
return events.length > 0 ? events[0] : null;
}
}
export async function fetchArticlesByAuthor(pubkey: string) {
const events = await relayPool.query([{
export function createTagList(
articles: Article[],
): Record<string, number> {
return articles.flatMap((a) => a.tags).reduce((acc, tag) => {
acc[tag] = (acc[tag] || 0) + 1;
return acc;
}, {} as Record<string, number>);
}
export async function fetchArticlesByAuthor(
pubkey: string,
limit: number = 10,
tags?: string[],
) {
const filter = {
authors: [pubkey],
kinds: [30023],
limit: 10,
}]);
limit: limit,
};
if (typeof tags !== "undefined") filter["#t"] = tags;
return events;
const events = await fetchWithTimeout([filter]) as NostrEvent[];
const articles = events.map((a) => new Article(a));
return articles
.filter((a) => !a.isDeleted)
.filter((a) => a.content.trim() !== "")
.sort((a, b) => b.publishedAt - a.publishedAt)
.slice(0, limit); // The limit seems to apply per relay, not per pool query
}
export async function fetchProfileEvent(pubkey: string) {
const events = await relayPool.query([{
const events = await fetchWithTimeout([{
authors: [pubkey],
kinds: [0],
limit: 1,
}]);
}]) as NostrEvent[];
return events.length > 0 ? events[0] : null;
}
@@ -90,16 +122,32 @@ export async function nostrUriToUrl(uri: string): Promise<string> {
}
export async function replaceNostrUris(markdown: string): Promise<string> {
const nostrUriRegex =
/\((nostr:|nprofile|naddr|nevent|nrelay|npub)[a-z0-9]+\)/g;
const nostrUriRegex = /(nostr:|nprofile|naddr|nevent|nrelay|npub)[a-z0-9]+/g;
const matches = markdown.match(nostrUriRegex);
if (!matches) return markdown;
for (const match of matches) {
const uri = match.slice(1, -1);
for (const uri of matches) {
const url = await nostrUriToUrl(uri);
markdown = markdown.replace(match, `(${url})`);
markdown = markdown.replace(uri, url);
}
return markdown;
}
export async function verifyNip05Address(
address: string,
pubkey: string,
): Promise<boolean> {
const [username, host] = address.split("@");
const url = `https://${host}/.well-known/nostr.json?name=${username}`;
try {
const res = await fetch(url);
if (res.status === 404 || !res.ok) return false;
const data = await res.json();
return data.names && data.names[username] === pubkey;
} catch (_e) {
return false;
}
}

View File

@@ -7,23 +7,40 @@ import npubHandler from "./handlers/npub.ts";
import userProfileHandler from "./handlers/user-profile.ts";
import userEventHandler from "./handlers/user-event.ts";
import userAtomFeedHandler from "./handlers/user-atom-feed.ts";
import notFoundHandler from "./handlers/not-found.ts";
import {
badGatewayHandler,
internalServerErrorHandler,
notFoundHandler,
} from "./handlers/errors.ts";
const router = new Router();
router.get("/:path", async (ctx) => {
const path = ctx.state.path = ctx.params.path;
if (path.startsWith("naddr")) {
await naddrHandler(ctx);
} else if (path.startsWith("nprofile")) {
await nprofileHandler(ctx);
} else if (path.startsWith("npub")) {
await npubHandler(ctx);
} else if (path.startsWith("@") || path.startsWith("~")) {
await userProfileHandler(ctx);
} else {
notFoundHandler(ctx);
try {
if (path.startsWith("naddr")) {
await naddrHandler(ctx);
} else if (path.startsWith("nprofile")) {
await nprofileHandler(ctx);
} else if (path.startsWith("npub")) {
await npubHandler(ctx);
} else if (path.startsWith("@") || path.startsWith("~")) {
const tags = ctx.request.url.searchParams.get("tags");
if (typeof tags === "string" && tags !== "") {
ctx.state.tags = tags.split(",");
}
await userProfileHandler(ctx);
} else {
notFoundHandler(ctx);
}
} catch (e) {
console.error(e);
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
badGatewayHandler(ctx);
} else {
internalServerErrorHandler(ctx);
}
}
});
@@ -36,7 +53,16 @@ router.get("/:username/:kind.atom", async (ctx) => {
(ctx.params.username.startsWith("@") ||
ctx.params.username.startsWith("~"))
) {
await userAtomFeedHandler(ctx);
try {
await userAtomFeedHandler(ctx);
} catch (e) {
console.error(e);
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
badGatewayHandler(ctx);
} else {
internalServerErrorHandler(ctx);
}
}
} else {
notFoundHandler(ctx);
}
@@ -47,7 +73,16 @@ router.get("/:username/:identifier", async (ctx) => {
ctx.state.identifier = ctx.params.identifier;
if (username.startsWith("@") || username.startsWith("~")) {
await userEventHandler(ctx);
try {
await userEventHandler(ctx);
} catch (e) {
console.error(e);
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
badGatewayHandler(ctx);
} else {
internalServerErrorHandler(ctx);
}
}
} else {
notFoundHandler(ctx);
}
@@ -66,8 +101,13 @@ router.get("/assets/:path*", async (ctx) => {
}
await send(ctx, filePath, { root });
} catch (_e) {
notFoundHandler(ctx);
} catch (e) {
if (e instanceof Error && e.name === "NotFoundError") {
notFoundHandler(ctx);
} else {
console.error(e);
badGatewayHandler(ctx);
}
}
});

9
tests/fixtures/article-deleted.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"content": "",
"created_at": 1716761766,
"id": "ae83c3e23e11db5fd0e6dacaece38847451e81d1429e4182a0cadd409bdce30f",
"kind": 30023,
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"sig": "6c21eba5324af302dfbfb9dadfc2d067646a3594ffed02d2528cca8ee0f7c16b9ffb3dc640420304882be94e789d93191d830108ac52d57b2e72445025e433b2",
"tags": [["d", "66674915"], ["deleted"]]
}

View File

@@ -1,17 +1,18 @@
import { beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { NostrEvent as NEvent } from "@nostrify/nostrify";
import Article from "../../models/article.ts";
describe("Article", () => {
let articleEvent: NEvent;
let article: Article;
let deletedArticle: Article;
beforeAll(() => {
articleEvent = JSON.parse(
article = new Article(JSON.parse(
Deno.readTextFileSync("tests/fixtures/article-1.json"),
);
article = new Article(articleEvent);
));
deletedArticle = new Article(JSON.parse(
Deno.readTextFileSync("tests/fixtures/article-deleted.json"),
));
});
describe("#identifier", () => {
@@ -46,6 +47,16 @@ describe("Article", () => {
});
});
describe("#tags", () => {
it("returns a flattened tag list", () => {
expect(article.tags).toEqual([
"lightning",
"lightning network",
"howto",
]);
});
});
describe("#publishedAt", () => {
it("returns the value of the first 'published_at' tag", () => {
expect(article.publishedAt).toEqual(1726402055);
@@ -58,6 +69,13 @@ describe("Article", () => {
});
});
describe("#isDeleted", () => {
it("returns a boolean based on the 'deleted' tag", () => {
expect(article.isDeleted).toEqual(false);
expect(deletedArticle.isDeleted).toEqual(true);
});
});
describe("#naddr", () => {
it("returns a bech32 addressable event ID", () => {
expect(article.naddr).toMatch(

27
tests/nostr_test.ts Normal file
View File

@@ -0,0 +1,27 @@
// import { describe, it } from "@std/testing/bdd";
// import { stub } from "@std/testing/mock";
// import { expect } from "@std/expect";
// import { NostrEvent, NostrFilter } from "@nostrify/nostrify";
// import * as nostr from "../nostr.ts";
//
// async function fetchWithTimeout(filters: NostrFilter[]) {
// console.log("================")
// const events = [];
// const fixtures = [ "article-1.json", "article-deleted.json" ]
// for (const filename of fixtures) {
// const event = JSON.parse(Deno.readTextFileSync(`tests/fixtures/${filename}`));
// events.push(event);
// }
// return Promise.resolve(events);
// }
//
// describe("Nostr", () => {
// describe("#fetchArticlesByAuthor", () => {
// it("removes the anchor links for headlines", async () => {
// stub(nostr, "fetchArticlesByAuthor");
//
// const articles = await nostr.fetchArticlesByAuthor("123456abcdef");
// expect(articles.length).toEqual(2);
// });
// });
// });