Compare commits

...

7 Commits

Author SHA1 Message Date
d3ad136eab
Formatting
All checks were successful
CI / Test and lint (push) Successful in 16s
2025-06-03 11:38:31 +04:00
cd335a366e
Add XML namespace definition, fix wrong closing tag
Some checks failed
CI / Test and lint (push) Has been cancelled
2025-06-03 11:30:40 +04:00
b307d4de03
Generate profile image for feeds if missing 2025-06-03 11:23:46 +04:00
91eff4ad07
Add missing unbind
All checks were successful
CI / Test and lint (push) Successful in 24s
2025-05-21 16:34:12 +04:00
994053e080
Ignore test coverage results dir
All checks were successful
CI / Test and lint (push) Successful in 16s
2025-05-02 13:22:41 +04:00
bc37756097
Add exclude option to compile command
All checks were successful
CI / Test and lint (push) Successful in 18s
2025-05-02 13:09:11 +04:00
3a5733eeee
Use nprofile for article author link 2025-05-02 13:04:15 +04:00
11 changed files with 100 additions and 182 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.env .env
users.yaml users.yaml
build/ build/
coverage/

View File

@ -58,9 +58,9 @@ const config = {
extraLanguages: [ extraLanguages: [
"bash", "bash",
"markdown", "markdown",
"typescript" "typescript",
] ],
} },
}; };
const staticUsersConfigured = Object.keys(staticUsers).length > 0; const staticUsersConfigured = Object.keys(staticUsers).length > 0;

View File

@ -2,7 +2,7 @@
"tasks": { "tasks": {
"dev": "deno run --allow-all --watch server.ts", "dev": "deno run --allow-all --watch server.ts",
"server": "deno run --allow-all server.ts", "server": "deno run --allow-all server.ts",
"compile": "deno compile --allow-all --include ./assets/ --output ./build/substr_x86_64-unknown-linux-gnu server.ts", "compile": "deno compile --allow-all --include ./assets/ --exclude ./tests/ --output ./build/substr_x86_64-unknown-linux-gnu server.ts",
"test": "DENO_ENV=test deno test --allow-read --allow-env" "test": "DENO_ENV=test deno test --allow-read --allow-env"
}, },
"imports": { "imports": {
@ -18,7 +18,9 @@
}, },
"fmt": { "fmt": {
"exclude": [ "exclude": [
"magick.ts" "magick.ts",
"assets/css/prism.css",
"tests/fixtures/"
] ]
} }
} }

View File

@ -3,7 +3,9 @@ import config from "./config.ts";
import { lookupUsernameByPubkey as ldapLookupUsername } from "./ldap.ts"; import { lookupUsernameByPubkey as ldapLookupUsername } from "./ldap.ts";
import { lookupPubkeyByUsername as ldapLookupPubkey } from "./ldap.ts"; import { lookupPubkeyByUsername as ldapLookupPubkey } from "./ldap.ts";
export async function lookupUsernameByPubkey(pubkey: string): Promise<string | undefined> { export async function lookupUsernameByPubkey(
pubkey: string,
): Promise<string | undefined> {
let username; let username;
for (const [key, value] of Object.entries(config.staticUsers)) { for (const [key, value] of Object.entries(config.staticUsers)) {
if (value === pubkey) { if (value === pubkey) {
@ -21,7 +23,9 @@ export async function lookupUsernameByPubkey(pubkey: string): Promise<string | u
} }
} }
export async function lookupPubkeyByUsername(username: string): Promise<string | undefined> { export async function lookupPubkeyByUsername(
username: string,
): Promise<string | undefined> {
const pubkey = config.staticUsers[username]; const pubkey = config.staticUsers[username];
if (pubkey) { if (pubkey) {

View File

@ -34,7 +34,7 @@ export async function profileAtomFeed(
return ` return `
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"> <feed xmlns="http://www.w3.org/2005/Atom" xmlns:nostr="urn:nostr:protocol">
<title>${profile.name} on Nostr (Articles)</title> <title>${profile.name} on Nostr (Articles)</title>
<link rel="alternate" type="text/html" href="${profile.profileUrl}" /> <link rel="alternate" type="text/html" href="${profile.profileUrl}" />
<link rel="alternate" type="application/nostr+json" href="nostr:${profile.npub}" /> <link rel="alternate" type="application/nostr+json" href="nostr:${profile.npub}" />
@ -44,7 +44,7 @@ export async function profileAtomFeed(
<author> <author>
<name>${profile.name}</name> <name>${profile.name}</name>
<uri>${profile.profileUrl}</uri> <uri>${profile.profileUrl}</uri>
<nostr:uri>nostr:${profile.nprofile}</nostr> <nostr:uri>nostr:${profile.nprofile}</nostr:uri>
</author> </author>
${articlesXml} ${articlesXml}
</feed> </feed>

View File

@ -4,6 +4,7 @@ import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
import { profileAtomFeed } from "../feeds.ts"; import { profileAtomFeed } from "../feeds.ts";
import Profile from "../models/profile.ts"; import Profile from "../models/profile.ts";
import { notFoundHandler } from "../handlers/errors.ts"; import { notFoundHandler } from "../handlers/errors.ts";
import { generateOgProfileImage } from "../magick.ts";
const userAtomFeedHandler = async function (ctx: Context) { const userAtomFeedHandler = async function (ctx: Context) {
const username = ctx.state.username; const username = ctx.state.username;
@ -20,6 +21,7 @@ const userAtomFeedHandler = async function (ctx: Context) {
const profile = new Profile(profileEvent, username); const profile = new Profile(profileEvent, username);
if (profile.nip05) { if (profile.nip05) {
await generateOgProfileImage(profile);
const articles = await fetchArticlesByAuthor(pubkey, 10); const articles = await fetchArticlesByAuthor(pubkey, 10);
const atom = await profileAtomFeed(profile, articles); const atom = await profileAtomFeed(profile, articles);

View File

@ -233,7 +233,7 @@ function articleMetaHtml(article: Article, profile: Profile) {
<link rel="icon" href="${profile.avatarImageUrl}" type="image/png"> <link rel="icon" href="${profile.avatarImageUrl}" type="image/png">
<link rel="alternate" type="application/nostr+json" href="nostr:${article.naddr}" title="This article on Nostr"> <link rel="alternate" type="application/nostr+json" href="nostr:${article.naddr}" title="This article on Nostr">
<link rel="author" type="text/html" href="${profile.profileUrl}" title="${profile.name}"> <link rel="author" type="text/html" href="${profile.profileUrl}" title="${profile.name}">
<link rel="author" type="application/nostr+json" href="nostr:${profile.npub}" title="${profile.name}"> <link rel="author" type="application/nostr+json" href="nostr:${profile.nprofile}" title="${profile.name}">
<meta property="og:url" content="${article.url}"> <meta property="og:url" content="${article.url}">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="${article.title}"> <meta property="og:title" content="${article.title}">

10
ldap.ts
View File

@ -8,7 +8,9 @@ if (ldapEnabled) {
client = new Client({ url: ldap.url as string }); client = new Client({ url: ldap.url as string });
} }
export async function lookupPubkeyByUsername(username: string): Promise<string | undefined> { export async function lookupPubkeyByUsername(
username: string,
): Promise<string | undefined> {
let pubkey: string | undefined; let pubkey: string | undefined;
try { try {
@ -35,7 +37,9 @@ export async function lookupPubkeyByUsername(username: string): Promise<string |
return pubkey; return pubkey;
} }
export async function lookupUsernameByPubkey(pubkey: string): Promise<string | undefined> { export async function lookupUsernameByPubkey(
pubkey: string,
): Promise<string | undefined> {
let username: string | undefined; let username: string | undefined;
try { try {
@ -49,6 +53,8 @@ export async function lookupUsernameByPubkey(pubkey: string): Promise<string | u
if (searchEntries.length > 0) { if (searchEntries.length > 0) {
username = searchEntries[0].cn.toString(); username = searchEntries[0].cn.toString();
} }
await client.unbind();
} catch (e) { } catch (e) {
await client.unbind(); await client.unbind();
throw e; throw e;

View File

@ -35,22 +35,26 @@ export async function nostrUriToUrl(uri: string): Promise<string> {
} }
export async function replaceNostrUris(markdown: string): Promise<string> { export async function replaceNostrUris(markdown: string): Promise<string> {
const protectedRegex = /(`{3,}[\s\S]*?`{3,})|(`[^`]*`)|(<pre>[\s\S]*?<\/pre>)|(https?:\/\/[^\s<>"']+)/gi; const protectedRegex =
/(`{3,}[\s\S]*?`{3,})|(`[^`]*`)|(<pre>[\s\S]*?<\/pre>)|(https?:\/\/[^\s<>"']+)/gi;
// Split text into segments: unprotected text and protected areas (code blocks, URLs) // Split text into segments: unprotected text and protected areas (code blocks, URLs)
const segments: string[] = []; const segments: string[] = [];
let lastIndex = 0; let lastIndex = 0;
markdown.replace(protectedRegex, (match, _fencedCode, _inlineCode, _preTag, _url, index) => { markdown.replace(
segments.push(markdown.slice(lastIndex, index)); protectedRegex,
segments.push(match); (match, _fencedCode, _inlineCode, _preTag, _url, index) => {
lastIndex = index + match.length; segments.push(markdown.slice(lastIndex, index));
return match; segments.push(match);
}); lastIndex = index + match.length;
return match;
},
);
segments.push(markdown.slice(lastIndex)); segments.push(markdown.slice(lastIndex));
// Process each segment // Process each segment
let result = ''; let result = "";
for (let i = 0; i < segments.length; i++) { for (let i = 0; i < segments.length; i++) {
if (i % 2 === 1 || protectedRegex.test(segments[i])) { if (i % 2 === 1 || protectedRegex.test(segments[i])) {
// Protected segment (code block or URL), leave unchanged // Protected segment (code block or URL), leave unchanged
@ -78,7 +82,9 @@ async function processUnprotectedText(text: string): Promise<string> {
parts.push(await replaceUrisInText(text.slice(lastIndex, match.index))); parts.push(await replaceUrisInText(text.slice(lastIndex, match.index)));
// Process the link target // Process the link target
if (nostrUriRegex.test(target) && target.match(nostrUriRegex)![0] === target) { if (
nostrUriRegex.test(target) && target.match(nostrUriRegex)![0] === target
) {
// Target is a Nostr URI, replace with resolved URL // Target is a Nostr URI, replace with resolved URL
const resolvedUrl = await nostrUriToUrl(target); const resolvedUrl = await nostrUriToUrl(target);
parts.push(`[${linkText}](${resolvedUrl})`); parts.push(`[${linkText}](${resolvedUrl})`);
@ -93,12 +99,13 @@ async function processUnprotectedText(text: string): Promise<string> {
// Add any remaining text after the last link // Add any remaining text after the last link
parts.push(await replaceUrisInText(text.slice(lastIndex))); parts.push(await replaceUrisInText(text.slice(lastIndex)));
return parts.join(''); return parts.join("");
} }
async function replaceUrisInText(text: string): Promise<string> { async function replaceUrisInText(text: string): Promise<string> {
let modifiedText = text; let modifiedText = text;
const replacements: { start: number; end: number; replacement: string }[] = []; const replacements: { start: number; end: number; replacement: string }[] =
[];
// Collect all replacements for bare Nostr URIs // Collect all replacements for bare Nostr URIs
let match; let match;
@ -117,7 +124,8 @@ async function replaceUrisInText(text: string): Promise<string> {
// Apply replacements from right to left to avoid index shifting // Apply replacements from right to left to avoid index shifting
for (let i = replacements.length - 1; i >= 0; i--) { for (let i = replacements.length - 1; i >= 0; i--) {
const { start, end, replacement } = replacements[i]; const { start, end, replacement } = replacements[i];
modifiedText = modifiedText.slice(0, start) + replacement + modifiedText.slice(end); modifiedText = modifiedText.slice(0, start) + replacement +
modifiedText.slice(end);
} }
return modifiedText; return modifiedText;
@ -125,5 +133,5 @@ async function replaceUrisInText(text: string): Promise<string> {
function cleanUriForTitle(uri: string): string { function cleanUriForTitle(uri: string): string {
// Remove "nostr:" prefix, keep "@" for the title // Remove "nostr:" prefix, keep "@" for the title
return uri.startsWith('nostr:') ? uri.replace(/^nostr:/, '') : uri; return uri.startsWith("nostr:") ? uri.replace(/^nostr:/, "") : uri;
} }

140
prism.css
View File

@ -1,140 +0,0 @@
/**
* prism.js default theme for JavaScript, CSS and HTML
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
code[class*="language-"],
pre[class*="language-"] {
color: black;
background: none;
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
code[class*="language-"]::selection, code[class*="language-"] ::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #f5f2f0;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.token.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #9a6e3a;
/* This background color was intended by the author of this theme. */
background: hsla(0, 0%, 100%, .5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function,
.token.class-name {
color: #DD4A68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

View File

@ -3,59 +3,94 @@ import { expect } from "@std/expect";
import { replaceNostrUris } from "../../nostr/links.ts"; import { replaceNostrUris } from "../../nostr/links.ts";
describe("Nostr links", () => { describe("Nostr links", () => {
describe("#replaceNostrUris", () => { describe("#replaceNostrUris", () => {
let mdContent: string; let mdContent: string;
let result: string; let result: string;
beforeAll(async () => { beforeAll(async () => {
mdContent = Deno.readTextFileSync("tests/fixtures/article-2.md"), mdContent = Deno.readTextFileSync("tests/fixtures/article-2.md");
result = await replaceNostrUris(mdContent); result = await replaceNostrUris(mdContent);
}); });
it("does not replace URIs in URLs", () => { it("does not replace URIs in URLs", () => {
expect(result).toMatch(new RegExp("https://badges.page/p/npub1cpmvpsqtzxl4px44dp4544xwgu0ryv2lscl3qexq42dfakuza02s4fsapc")); expect(result).toMatch(
new RegExp(
"https://badges.page/p/npub1cpmvpsqtzxl4px44dp4544xwgu0ryv2lscl3qexq42dfakuza02s4fsapc",
),
);
}); });
it("does not replace URIs in fenced code blocks", () => { it("does not replace URIs in fenced code blocks", () => {
expect(result).toMatch(new RegExp("Follow nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7")); expect(result).toMatch(
new RegExp(
"Follow nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7",
),
);
}); });
it("does not replace URIs in inline code blocks", () => { it("does not replace URIs in inline code blocks", () => {
expect(result).toMatch(new RegExp("raucao: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees")); expect(result).toMatch(
new RegExp(
"raucao: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees",
),
);
}); });
describe("for unknown usernames", () => { describe("for unknown usernames", () => {
it("replaces plain nostr:id URIs with a markdown link", () => { it("replaces plain nostr:id URIs with a markdown link", () => {
expect(result).toMatch(/Amber scheme 1\: \[npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/); expect(result).toMatch(
expect(result).toMatch(/Amber scheme 2\: \[npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/); /Amber scheme 1\: \[npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/,
);
expect(result).toMatch(
/Amber scheme 2\: \[npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/,
);
}); });
it("replaces @id URIs with a markdown link", () => { it("replaces @id URIs with a markdown link", () => {
expect(result).toMatch(/Amber at 1\: \[@npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/); expect(result).toMatch(
expect(result).toMatch(/Amber at 2\: \[@npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/); /Amber at 1\: \[@npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/,
);
expect(result).toMatch(
/Amber at 2\: \[@npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/,
);
}); });
it("replaces nostr links with external links", () => { it("replaces nostr links with external links", () => {
expect(result).toMatch(/Amber scheme link 1\: \[Amber\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/); expect(result).toMatch(
expect(result).toMatch(/Amber scheme link 2\: \[Amber\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/); /Amber scheme link 1\: \[Amber\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/,
);
expect(result).toMatch(
/Amber scheme link 2\: \[Amber\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/,
);
}); });
}); });
describe("for known usernames", () => { describe("for known usernames", () => {
it("replaces plain nostr:id URIs with a markdown link", () => { it("replaces plain nostr:id URIs with a markdown link", () => {
expect(result).toMatch(/raucao scheme 1\: \[npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/); expect(result).toMatch(
expect(result).toMatch(/raucao scheme 2\: \[npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/); /raucao scheme 1\: \[npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/,
);
expect(result).toMatch(
/raucao scheme 2\: \[npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/,
);
}); });
it("replaces @id URIs with a markdown link", () => { it("replaces @id URIs with a markdown link", () => {
expect(result).toMatch(/raucao at 1\: \[@npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/); expect(result).toMatch(
expect(result).toMatch(/raucao at 2\: \[@npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/); /raucao at 1\: \[@npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/,
);
expect(result).toMatch(
/raucao at 2\: \[@npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/,
);
}); });
it("replaces scheme links with internal links", () => { it("replaces scheme links with internal links", () => {
expect(result).toMatch(/raucao scheme link 1\: \[raucao\]\(\/@raucao\)/); expect(result).toMatch(
expect(result).toMatch(/raucao scheme link 2\: \[raucao\]\(\/@raucao\)/); /raucao scheme link 1\: \[raucao\]\(\/@raucao\)/,
);
expect(result).toMatch(
/raucao scheme link 2\: \[raucao\]\(\/@raucao\)/,
);
}); });
}); });
}); });