38 Commits

Author SHA1 Message Date
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
7c2549cbfe Add more link rel elements, use nprofile
All checks were successful
CI / Test and lint (push) Successful in 17s
2025-04-30 12:28:10 +04:00
e3bd385c96 Add rel=author Nostr links to article pages 2025-04-30 12:22:39 +04:00
038ce15908 Fix and complete author element in Atom feed 2025-04-30 12:22:16 +04:00
7aebcfc43f Add nprofile property to profile model 2025-04-30 12:21:51 +04:00
b7eccde9d0 Add alternate HTML links to feeds
Some checks failed
CI / Test and lint (push) Failing after 10m38s
See https://github.com/feedbin/feedbin/issues/747
2025-04-27 11:57:44 +04:00
9305e9f718 Add code highlighting
All checks were successful
CI / Test and lint (push) Successful in 19s
2025-04-26 18:28:40 +04:00
b907cc2f65 Remove redundant nostr address from details
All checks were successful
CI / Test and lint (push) Successful in 20s
It's already shown under the name, including the verification status
2025-04-26 17:44:56 +04:00
29e2fca2a5 Strict syntax in XML
All checks were successful
CI / Test and lint (push) Successful in 15s
2025-04-26 00:04:44 +04:00
d3e908b2b0 Add alternate links to Atom feeds
All checks were successful
CI / Test and lint (push) Successful in 15s
2025-04-25 11:58:26 +04:00
5608176a20 Add some margin to list items
All checks were successful
CI / Test and lint (push) Successful in 16s
2025-04-23 15:30:07 +04:00
fa21e72b3f Add alternative nostr links to HTML meta tags
All checks were successful
CI / Test and lint (push) Successful in 19s
2025-04-22 13:09:03 +04:00
5b0397268b Improve Nostr link handling
Some checks failed
CI / Test and lint (push) Failing after 10m22s
Fixes a bunch of problems with how Nostr links are created and replaced
in Markdown content
2025-04-21 16:53:22 +04:00
fb37db8583 Typing 2025-04-21 16:44:13 +04:00
07f881d543 Load test configs when running tests 2025-04-21 16:44:13 +04:00
3f9dad8f9a Don't link nrelay in content
All checks were successful
CI / Test and lint (push) Successful in 19s
It's deprecated
2024-12-17 14:40:44 +04:00
275fb73896 Move executables to build/ dir
All checks were successful
CI / Test and lint (push) Successful in 17s
2024-12-09 18:58:17 +04:00
204b4f44c7 Merge pull request 'Store and serve local profile icons for user pages' (#10) from feature/icons into master
All checks were successful
CI / Test and lint (push) Successful in 18s
Reviewed-on: #10
2024-12-03 17:51:56 +00:00
8e802f314a Remove debug statements
All checks were successful
CI / Test and lint (push) Successful in 16s
CI / Test and lint (pull_request) Successful in 16s
2024-12-03 18:50:40 +01:00
f4d1ba897b Store and serve local profile icons for user pages
Shown e.g. in browser tabs and as RSS icon
2024-12-03 18:50:10 +01:00
7f975f2dbb Merge pull request 'Set up CI' (#9) from dev/3-ci into master
All checks were successful
CI / Test and lint (push) Successful in 15s
Reviewed-on: #9
2024-12-03 16:59:51 +00:00
5e1e249052 Rename workflow
All checks were successful
CI / Test and lint (push) Successful in 16s
CI / Test and lint (pull_request) Successful in 14s
2024-12-03 17:58:22 +01:00
b376bbd2aa Set up CI
All checks were successful
Test and lint / Test and lint (push) Successful in 17s
Test and lint / Test and lint (pull_request) Successful in 14s
closes #3
2024-12-03 17:56:22 +01:00
51ae16e57f Update dependencies 2024-12-03 17:30:23 +01:00
2ac3180c0f Support compiling static assets, add compile task 2024-12-03 17:25:19 +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
34 changed files with 1151 additions and 326 deletions

1
.env.test Normal file
View File

@@ -0,0 +1 @@
RELAY_URLS=wss://nostr.kosmos.org

18
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,18 @@
name: CI
on:
push:
pull_request:
jobs:
test_and_lint:
name: Test and lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- uses: denoland/setup-deno@v2
with:
deno-version: v2.1.x
- run: "deno task test"
- run: "deno lint"

2
.gitignore vendored
View File

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

View File

@@ -7,7 +7,7 @@ body {
}
h1 {
margin: 4rem 0 0 0;
margin: 4rem 0 1.6rem 0;
}
h2, h3, h4 {
@@ -21,6 +21,10 @@ p, pre, ul, ol, dl, blockquote, hr {
margin-bottom: 1.6em;
}
li {
margin-bottom: 0.8em;
}
a.anchor {
display: none;
}
@@ -41,6 +45,11 @@ pre code {
padding: 0.6rem 1rem;
}
.highlight pre {
font-size: 0.9em;
padding: 0.6rem 1rem;
}
img {
max-width: 100%;
}
@@ -84,10 +93,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 +117,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 +151,14 @@ main article footer {
margin-top: 5rem;
}
.nip05 .verified,
.nip05 .not-verified {
margin-left: 0.3rem;
padding: 0.1em 0.3em;
border-radius: 5px;
cursor: default;
}
/* Dropdown menu */
.dropdown {
@@ -201,7 +227,7 @@ main article footer {
margin: 4rem 1rem 8rem 1rem !important;
}
.profile-page h1 {
main.profile-page h1 {
margin-top: 2rem;
}

140
assets/css/prism.css Normal file
View File

@@ -0,0 +1,140 @@
/**
* 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

@@ -47,6 +47,13 @@ pre code {
color: var(--text-color-dark-bg);
}
.highlight pre,
.highlight code {
background-color: var(--background-color-body);
border: 2px solid #e8e3da;
color: var(--text-color-body);
}
dl dt {
color: var(--text-color-discreet);
}
@@ -68,10 +75,24 @@ 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);
}
.nip05 .verified {
background-color: #e8e3da;
color: #027739;
}
.nip05 .not-verified {
background-color: #e8e3da;
color: #770202;
}
/* Dropdown menu */
.dropdown {

View File

@@ -3,16 +3,21 @@ import { parse as parseYaml } from "jsr:@std/yaml";
import { checkFileExists } from "./utils.ts";
import { log } from "./log.ts";
const denoEnv = Deno.env.get("DENO_ENV");
const dirname = Deno.cwd();
await load({ envPath: `${dirname}/.env`, export: true });
function envAwarePath(path: string): string {
return denoEnv ? `${path}.${denoEnv}` : path;
}
await load({ envPath: envAwarePath(`${dirname}/.env`), export: true });
let userConfigPath: string = "";
let staticUsers: { [key: string]: string } = {};
const defaultUserConfigPaths = [
"/etc/substr/users.yaml",
`${dirname}/users.yaml`,
envAwarePath("/etc/substr/users.yaml"),
envAwarePath(`${dirname}/users.yaml`),
];
for (const path of defaultUserConfigPaths) {
@@ -45,7 +50,17 @@ 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",
prism: {
// TODO make configurable via ENV
// Supported languages: https://app.unpkg.com/prismjs@1.29.0/files/components
extraLanguages: [
"bash",
"markdown",
"typescript"
]
}
};
const staticUsersConfigured = Object.keys(staticUsers).length > 0;

View File

@@ -1,18 +1,20 @@
{
"tasks": {
"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/ --exclude ./tests/ --output ./build/substr_x86_64-unknown-linux-gnu server.ts",
"test": "DENO_ENV=test deno test --allow-read --allow-env"
},
"imports": {
"@deno/gfm": "jsr:@deno/gfm@^0.9.0",
"@nostr/tools": "jsr:@nostr/tools@^2.3.1",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.1",
"@oak/oak": "jsr:@oak/oak@^17.1.0",
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
"@nostr/tools": "jsr:@nostr/tools@^2.10.4",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.2",
"@oak/oak": "jsr:@oak/oak@^17.1.3",
"@std/dotenv": "jsr:@std/dotenv@^0.225.2",
"@std/expect": "jsr:@std/expect@^1.0.5",
"@std/testing": "jsr:@std/testing@^1.0.3",
"@std/expect": "jsr:@std/expect@^1.0.8",
"@std/testing": "jsr:@std/testing@^1.0.11",
"@std/yaml": "jsr:@std/yaml@^1.0.5",
"ldapts": "npm:ldapts@^7.2.1"
"ldapts": "npm:ldapts@^7.2.2"
},
"fmt": {
"exclude": [

254
deno.lock generated
View File

@@ -1,60 +1,68 @@
{
"version": "4",
"specifiers": {
"jsr:@deno/gfm@0.9": "0.9.0",
"jsr:@deno/gfm@0.10": "0.10.0",
"jsr:@denosaurs/emoji@0.3": "0.3.1",
"jsr:@nostr/tools@^2.3.1": "2.3.1",
"jsr:@nostrify/nostrify@~0.36.1": "0.36.1",
"jsr:@nostr/tools@^2.10.4": "2.12.0",
"jsr:@nostrify/nostrify@~0.36.2": "0.36.2",
"jsr:@nostrify/types@0.35": "0.35.0",
"jsr:@oak/commons@1": "1.0.0",
"jsr:@oak/oak@^17.1.0": "17.1.0",
"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.0.2": "1.0.2",
"jsr:@oak/oak@^17.1.3": "17.1.3",
"jsr:@std/assert@0.224": "0.224.0",
"jsr:@std/assert@1": "1.0.8",
"jsr:@std/assert@^1.0.12": "1.0.12",
"jsr:@std/assert@^1.0.8": "1.0.8",
"jsr:@std/bytes@1": "1.0.4",
"jsr:@std/bytes@^1.0.2": "1.0.4",
"jsr:@std/crypto@0.224": "0.224.0",
"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.3",
"jsr:@std/encoding@0.224": "0.224.3",
"jsr:@std/encoding@1": "1.0.5",
"jsr:@std/encoding@^1.0.5": "1.0.5",
"jsr:@std/encoding@~0.224.1": "0.224.3",
"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/internal@^1.0.4": "1.0.4",
"jsr:@std/expect@^1.0.8": "1.0.15",
"jsr:@std/http@1": "1.0.10",
"jsr:@std/internal@^1.0.5": "1.0.5",
"jsr:@std/internal@^1.0.6": "1.0.6",
"jsr:@std/io@0.224": "0.224.9",
"jsr:@std/media-types@1": "1.0.3",
"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",
"jsr:@std/media-types@1": "1.1.0",
"jsr:@std/path@1": "1.0.8",
"jsr:@std/path@^1.0.7": "1.0.8",
"jsr:@std/path@^1.0.8": "1.0.8",
"jsr:@std/testing@^1.0.11": "1.0.11",
"jsr:@std/yaml@*": "1.0.5",
"jsr:@std/yaml@^1.0.5": "1.0.5",
"npm:@noble/ciphers@~0.5.1": "0.5.3",
"npm:@noble/curves@1.2.0": "1.2.0",
"npm:@noble/hashes@1.3.1": "1.3.1",
"npm:@scure/base@1.1.1": "1.1.1",
"npm:@scure/bip32@^1.4.0": "1.4.0",
"npm:@scure/bip39@^1.3.0": "1.3.0",
"npm:@scure/base@^1.1.6": "1.2.1",
"npm:@scure/bip32@1.3.1": "1.3.1",
"npm:@scure/bip32@^1.4.0": "1.6.0",
"npm:@scure/bip39@1.2.1": "1.2.1",
"npm:@scure/bip39@^1.3.0": "1.5.0",
"npm:github-slugger@2": "2.0.0",
"npm:he@^1.2.0": "1.2.0",
"npm:katex@0.16": "0.16.11",
"npm:ldapts@^7.2.1": "7.2.1",
"npm:lru-cache@^10.2.0": "10.2.2",
"npm:marked-alert@2": "2.1.0_marked@12.0.2",
"npm:ldapts@^7.2.2": "7.2.2",
"npm:lru-cache@^10.2.0": "10.4.3",
"npm:marked-alert@2": "2.1.2_marked@12.0.2",
"npm:marked-footnote@^1.2.0": "1.2.4_marked@12.0.2",
"npm:marked-gfm-heading-id@^3.1.0": "3.2.0_marked@12.0.2",
"npm:marked@12": "12.0.2",
"npm:nostr-tools@^2.7.0": "2.7.0",
"npm:path-to-regexp@*": "6.2.1",
"npm:nostr-tools@^2.7.0": "2.10.4",
"npm:nostr-wasm@0.1.0": "0.1.0",
"npm:path-to-regexp@6.2.1": "6.2.1",
"npm:prismjs@1.29.0": "1.29.0",
"npm:prismjs@^1.29.0": "1.29.0",
"npm:sanitize-html@^2.11.0": "2.13.1",
"npm:sanitize-html@^2.13.0": "2.13.1",
"npm:websocket-ts@^2.1.5": "2.1.5",
"npm:zod@^3.23.8": "3.23.8"
},
"jsr": {
"@deno/gfm@0.9.0": {
"integrity": "9002dbdb6e382e247509edfeae3afdb9232f5ca98a8210ef186d42084e9ded30",
"@deno/gfm@0.10.0": {
"integrity": "51708205e3559a4aeb6afb29d07c5bfafe7941f91bb360351ef6621de9a39527",
"dependencies": [
"jsr:@denosaurs/emoji",
"npm:github-slugger",
@@ -64,29 +72,43 @@
"npm:marked-alert",
"npm:marked-footnote",
"npm:marked-gfm-heading-id",
"npm:prismjs",
"npm:prismjs@^1.29.0",
"npm:sanitize-html"
]
},
"@denosaurs/emoji@0.3.1": {
"integrity": "b0aed5f55dec99e83da7c9637fe0a36d1d6252b7c99deaaa3fc5dea3fcf3da8b"
},
"@nostr/tools@2.3.1": {
"integrity": "af01dc45cb28784c584d7a0699707196f397bcc53946efa582a01b11ddde4d61",
"@nostr/tools@2.10.4": {
"integrity": "7fda015c96b4f674727843aecb990e2af1989e4724588415ccf6f69066abfd4f",
"dependencies": [
"npm:@noble/ciphers",
"npm:@noble/curves",
"npm:@noble/hashes",
"npm:@scure/base"
"npm:@scure/base@1.1.1",
"npm:@scure/bip32@1.3.1",
"npm:@scure/bip39@1.2.1",
"npm:nostr-wasm"
]
},
"@nostrify/nostrify@0.36.1": {
"integrity": "f76c803c0bda5df1c172f25d2313980344b0431df2a973ab3e1dd61e9e7b4b1a",
"@nostr/tools@2.12.0": {
"integrity": "0584d5197682c6eabaded17bae10e765f215ef051ae70aa463f994abf90f295a",
"dependencies": [
"npm:@noble/ciphers",
"npm:@noble/curves",
"npm:@noble/hashes",
"npm:@scure/base@1.1.1"
]
},
"@nostrify/nostrify@0.36.2": {
"integrity": "cc4787ca170b623a2e5dfed1baa4426077daa6143af728ea7dd325d58f4d04d6",
"dependencies": [
"jsr:@nostrify/types",
"jsr:@std/crypto@0.224",
"jsr:@std/encoding@~0.224.1",
"npm:@scure/bip32",
"npm:@scure/bip39",
"npm:@scure/base@^1.1.6",
"npm:@scure/bip32@^1.4.0",
"npm:@scure/bip39@^1.3.0",
"npm:lru-cache",
"npm:nostr-tools",
"npm:websocket-ts",
@@ -101,71 +123,91 @@
"dependencies": [
"jsr:@std/assert@1",
"jsr:@std/bytes@1",
"jsr:@std/crypto",
"jsr:@std/crypto@1",
"jsr:@std/encoding@1",
"jsr:@std/http",
"jsr:@std/media-types"
]
},
"@oak/oak@17.1.0": {
"integrity": "14ffb400c3c268bdc7b3a838664fab782b4ed35bb0dfe7669013c95bb12a9503",
"@oak/oak@17.1.3": {
"integrity": "d89296c22db91681dd3a2a1e1fd14e258d0d5a9654de55637aee5b661c159f33",
"dependencies": [
"jsr:@oak/commons",
"jsr:@std/assert@1",
"jsr:@std/bytes@1",
"jsr:@std/crypto",
"jsr:@std/crypto@1",
"jsr:@std/http",
"jsr:@std/io",
"jsr:@std/media-types",
"jsr:@std/path@1",
"npm:path-to-regexp@6.2.1"
"npm:path-to-regexp"
]
},
"@std/assert@1.0.6": {
"integrity": "1904c05806a25d94fe791d6d883b685c9e2dcd60e4f9fc30f4fc5cf010c72207",
"@std/assert@0.224.0": {
"integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f"
},
"@std/assert@1.0.8": {
"integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b",
"dependencies": [
"jsr:@std/internal"
"jsr:@std/internal@^1.0.5"
]
},
"@std/bytes@1.0.2": {
"integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57"
"@std/assert@1.0.12": {
"integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a",
"dependencies": [
"jsr:@std/internal@^1.0.6"
]
},
"@std/bytes@1.0.4": {
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
},
"@std/crypto@0.224.0": {
"integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d",
"dependencies": [
"jsr:@std/assert@0.224",
"jsr:@std/encoding@0.224"
]
},
"@std/crypto@1.0.3": {
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
},
"@std/data-structures@1.0.4": {
"integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0"
},
"@std/dotenv@0.225.2": {
"integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23"
},
"@std/dotenv@0.225.3": {
"integrity": "a95e5b812c27b0854c52acbae215856d9cce9d4bbf774d938c51d212711e8d4a"
},
"@std/encoding@0.224.3": {
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
},
"@std/encoding@1.0.5": {
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
},
"@std/expect@1.0.5": {
"integrity": "8c7ac797e2ffe57becc6399c0f2fd06230cb9ef124d45229c6e592c563824af1",
"@std/expect@1.0.8": {
"integrity": "27e40d8f3aefb372fc6a703fb0b69e34560e72a2f78705178babdffa00119a5f",
"dependencies": [
"jsr:@std/assert@^1.0.6",
"jsr:@std/internal"
"jsr:@std/assert@^1.0.8",
"jsr:@std/internal@^1.0.5"
]
},
"@std/fs@1.0.4": {
"integrity": "2907d32d8d1d9e540588fd5fe0ec21ee638134bd51df327ad4e443aaef07123c",
"@std/expect@1.0.15": {
"integrity": "eca360007b5a7f13dbfa1294224baee7fb98dcd460d8461fe64eeae302902945",
"dependencies": [
"jsr:@std/path@^1.0.6"
"jsr:@std/assert@^1.0.12",
"jsr:@std/internal@^1.0.6"
]
},
"@std/http@1.0.8": {
"integrity": "6ea1b2e8d33929967754a3b6d6c6f399ad6647d7bbb5a466c1eaf9b294a6ebcd",
"@std/http@1.0.10": {
"integrity": "4e32d11493ab04e3ef09f104f0cb9beb4228b1d4b47c5469573c2c294c0d3692",
"dependencies": [
"jsr:@std/encoding@^1.0.5"
]
},
"@std/internal@1.0.4": {
"integrity": "62e8e4911527e5e4f307741a795c0b0a9e6958d0b3790716ae71ce085f755422"
"@std/internal@1.0.5": {
"integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba"
},
"@std/internal@1.0.6": {
"integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4"
},
"@std/io@0.224.9": {
"integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3",
@@ -173,20 +215,17 @@
"jsr:@std/bytes@^1.0.2"
]
},
"@std/media-types@1.0.3": {
"integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159"
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/path@1.0.6": {
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
},
"@std/testing@1.0.3": {
"integrity": "f98c2bee53860a5916727d7e7d3abe920dd6f9edace022e2d059f00d05c2cf42",
"@std/testing@1.0.11": {
"integrity": "12b3db12d34f0f385a26248933bde766c0f8c5ad8b6ab34d4d38f528ab852f48",
"dependencies": [
"jsr:@std/assert@^1.0.6",
"jsr:@std/data-structures",
"jsr:@std/fs",
"jsr:@std/internal",
"jsr:@std/path@^1.0.6"
"jsr:@std/assert@^1.0.12",
"jsr:@std/internal@^1.0.6"
]
},
"@std/yaml@1.0.5": {
@@ -209,10 +248,10 @@
"@noble/hashes@1.3.2"
]
},
"@noble/curves@1.4.0": {
"integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==",
"@noble/curves@1.7.0": {
"integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==",
"dependencies": [
"@noble/hashes@1.4.0"
"@noble/hashes@1.6.0"
]
},
"@noble/hashes@1.3.1": {
@@ -221,43 +260,46 @@
"@noble/hashes@1.3.2": {
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
},
"@noble/hashes@1.4.0": {
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="
"@noble/hashes@1.6.0": {
"integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ=="
},
"@noble/hashes@1.6.1": {
"integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w=="
},
"@scure/base@1.1.1": {
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
},
"@scure/base@1.1.7": {
"integrity": "sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g=="
"@scure/base@1.2.1": {
"integrity": "sha512-DGmGtC8Tt63J5GfHgfl5CuAXh96VF/LD8K9Hr/Gv0J2lAoRGlPOMpqMpMbCTOoOJMZCk2Xt+DskdDyn6dEFdzQ=="
},
"@scure/bip32@1.3.1": {
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"dependencies": [
"@noble/curves@1.1.0",
"@noble/hashes@1.3.2",
"@scure/base@1.1.7"
"@scure/base@1.1.1"
]
},
"@scure/bip32@1.4.0": {
"integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==",
"@scure/bip32@1.6.0": {
"integrity": "sha512-82q1QfklrUUdXJzjuRU7iG7D7XiFx5PHYVS0+oeNKhyDLT7WPqs6pBcM2W5ZdwOwKCwoE1Vy1se+DHjcXwCYnA==",
"dependencies": [
"@noble/curves@1.4.0",
"@noble/hashes@1.4.0",
"@scure/base@1.1.7"
"@noble/curves@1.7.0",
"@noble/hashes@1.6.1",
"@scure/base@1.2.1"
]
},
"@scure/bip39@1.2.1": {
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"dependencies": [
"@noble/hashes@1.3.2",
"@scure/base@1.1.7"
"@scure/base@1.1.1"
]
},
"@scure/bip39@1.3.0": {
"integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==",
"@scure/bip39@1.5.0": {
"integrity": "sha512-Dop+ASYhnrwm9+HA/HwXg7j2ZqM6yk2fyLWb5znexjctFY3+E+eU8cIWI0Pql0Qx4hPZCijlGq4OL71g+Uz30A==",
"dependencies": [
"@noble/hashes@1.4.0",
"@scure/base@1.1.7"
"@noble/hashes@1.6.1",
"@scure/base@1.2.1"
]
},
"@types/asn1@0.2.4": {
@@ -345,8 +387,8 @@
"commander"
]
},
"ldapts@7.2.1": {
"integrity": "sha512-2NSA9drjHdRiApF+TO18c+Hy/uyBLs96OS6Gia4+dPQWPxvqDbu3Ji2beCbNCXTvvgxDj4cLZ0WoOZLt5ojfAg==",
"ldapts@7.2.2": {
"integrity": "sha512-UotAq24/vJEz0m3w/jgwZm7JGNw8M6vexL/5KU5pe3aIZWBkT/HRhjsPw/buRqKSK5Y0vTu5Zv8iyPgQF7ozzg==",
"dependencies": [
"@types/asn1",
"asn1",
@@ -356,11 +398,11 @@
"whatwg-url"
]
},
"lru-cache@10.2.2": {
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="
"lru-cache@10.4.3": {
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"marked-alert@2.1.0_marked@12.0.2": {
"integrity": "sha512-X95Z8PCDgWa0bBfM70GxZG3LD/leUrhXc3cx3w1eFExBhswd1oXn/S4S+9H8ypPdCY7okREb4dItUOc+VJq4jQ==",
"marked-alert@2.1.2_marked@12.0.2": {
"integrity": "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w==",
"dependencies": [
"marked"
]
@@ -387,8 +429,8 @@
"nanoid@3.3.7": {
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
},
"nostr-tools@2.7.0": {
"integrity": "sha512-jJoL2J1CBiKDxaXZww27nY/Wsuxzx7AULxmGKFce4sskDu1tohNyfnzYQ8BvDyvkstU8kNZUAXPL32tre33uig==",
"nostr-tools@2.10.4": {
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
"dependencies": [
"@noble/ciphers",
"@noble/curves@1.2.0",
@@ -454,8 +496,8 @@
"undici-types@6.19.8": {
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"uuid@10.0.0": {
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="
"uuid@11.0.3": {
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg=="
},
"webidl-conversions@7.0.0": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
@@ -488,15 +530,15 @@
},
"workspace": {
"dependencies": [
"jsr:@deno/gfm@0.9",
"jsr:@nostr/tools@^2.3.1",
"jsr:@nostrify/nostrify@~0.36.1",
"jsr:@oak/oak@^17.1.0",
"jsr:@deno/gfm@0.10",
"jsr:@nostr/tools@^2.10.4",
"jsr:@nostrify/nostrify@~0.36.2",
"jsr:@oak/oak@^17.1.3",
"jsr:@std/dotenv@~0.225.2",
"jsr:@std/expect@^1.0.5",
"jsr:@std/testing@^1.0.3",
"jsr:@std/expect@^1.0.8",
"jsr:@std/testing@^1.0.11",
"jsr:@std/yaml@^1.0.5",
"npm:ldapts@^7.2.1"
"npm:ldapts@^7.2.2"
]
}
}

View File

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

View File

@@ -14,12 +14,14 @@ 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>
<title>${article.title}</title>
<link href="${article.url}" />
<link rel="alternate" type="text/html" href="${article.url}" />
<link rel="alternate" type="application/nostr+json" href="nostr:${article.naddr}" />
<updated>${isoDate(article.updatedAt)}</updated>
<published>${isoDate(article.publishedAt)}</published>
<summary>${article.summary}</summary>
@@ -34,11 +36,15 @@ export async function profileAtomFeed(
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${profile.name} on Nostr (Articles)</title>
<link rel="alternate" type="text/html" href="${profile.profileUrl}" />
<link rel="alternate" type="application/nostr+json" href="nostr:${profile.npub}" />
<id>${feedId}</id>
<updated>${isoDate(lastUpdate)}</updated>
<icon>${profile.picture}</icon>
<icon>${profile.avatarImageUrl}</icon>
<author>
<name>${name}</name>
<name>${profile.name}</name>
<uri>${profile.profileUrl}</uri>
<nostr:uri>nostr:${profile.nprofile}</nostr>
</author>
${articlesXml}
</feed>

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,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 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;
} 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);
await 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,16 @@ 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);
const articles = await fetchArticlesByAuthor(pubkey, 210);
const html = await profilePageHtml(profile, articles);
await generateOgProfileImage(profile);
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
} catch (_e) {
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
};

61
html.ts
View File

@@ -19,6 +19,7 @@ function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
<title>${title}</title>
${metaHtml || ""}
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/prism.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/themes/default-light.css" />
</head>
<body>
@@ -53,9 +54,9 @@ 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" />
<img class="avatar" src="${profile.avatarImageUrl}" alt="User Avatar" />
<div class="content">
<span class="name"><a href="/@${profile.username}">${profile.name}</a></span>
<span class="date">${publishedAtFormatted}</span>
@@ -77,12 +78,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 +97,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,26 +111,58 @@ export function articleListHtml(articles: Article[]): string {
`;
}
export function profilePageHtml(profile: Profile, articles: Article[]): string {
function userAddressHtml(profile: Profile) {
let html = "";
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">
<header>
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
<img class="avatar" src="${profile.avatarImageUrl}" 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>
@@ -173,6 +209,9 @@ function feedLinksHtml(profile: Profile) {
function profileMetaHtml(profile: Profile) {
return `
<link rel="icon" href="${profile.avatarImageUrl}" type="image/png">
<link rel="me" type="application/nostr+json" href="nostr:${profile.nprofile}" title="${profile.name}">
<link rel="alternate" type="application/nostr+json" href="nostr:${profile.nprofile}" title="${profile.name} on Nostr">
<meta property="og:url" content="${profile.profileUrl}">
<meta property="og:type" content="website">
<meta property="og:title" content="${profile.name} on Nostr">
@@ -191,6 +230,10 @@ function articleMetaHtml(article: Article, profile: Profile) {
const imageUrl = article.image || profile.ogImageUrl;
return `
<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="author" type="text/html" href="${profile.profileUrl}" 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:type" content="website">
<meta property="og:title" content="${article.title}">

22
ldap.ts
View File

@@ -8,8 +8,8 @@ if (ldapEnabled) {
client = new Client({ url: ldap.url as string });
}
export async function lookupPubkeyByUsername(username: string) {
let pubkey;
export async function lookupPubkeyByUsername(username: string): Promise<string | undefined> {
let pubkey: string | undefined;
try {
await client.bind(ldap.bindDN as string, ldap.password as string);
@@ -23,19 +23,20 @@ export async function lookupPubkeyByUsername(username: string) {
searchEntries.length > 0 &&
typeof searchEntries[0].nostrKey === "string"
) {
pubkey = searchEntries[0].nostrKey;
pubkey = searchEntries[0].nostrKey.toString();
}
} catch (e) {
console.error(e);
} finally {
await client.unbind();
} catch (e) {
await client.unbind();
throw e;
}
return pubkey;
}
export async function lookupUsernameByPubkey(pubkey: string) {
let username;
export async function lookupUsernameByPubkey(pubkey: string): Promise<string | undefined> {
let username: string | undefined;
try {
await client.bind(ldap.bindDN as string, ldap.password as string);
@@ -46,12 +47,11 @@ export async function lookupUsernameByPubkey(pubkey: string) {
});
if (searchEntries.length > 0) {
username = searchEntries[0].cn;
username = searchEntries[0].cn.toString();
}
} catch (e) {
console.error(e);
} finally {
await client.unbind();
throw e;
}
return username;

View File

@@ -8,30 +8,52 @@ if (!magick) {
log("ImageMagick is not installed. Cannot generate preview images", "yellow")
}
function createRoundedImage(profile: Profile) {
function createProfileImage(profile: Profile) {
if (!magick || !profile.picture) return false;
const args = [
profile.picture,
'-resize', '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',
'(', '+clone', '-flop', ')', '-compose', 'Multiply', '-composite',
')',
'-alpha', 'off',
'-compose', 'CopyOpacity',
'-composite',
`${tmpImgDir}/p-${profile.event.id}-rounded.png`
'-resize', '256x256^',
'-gravity', 'center',
'-extent', '256x256',
`${tmpImgDir}/p-${profile.event.id}.png`
];
return runCommand(magick, args);
}
async function createRoundedProfileImage(profile: Profile) {
if (!magick || !profile.picture) return false;
const status = await generateProfileImage(profile);
if (status && status.success) {
const args = [
`${tmpImgDir}/p-${profile.event.id}.png`,
'-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',
'(', '+clone', '-flop', ')', '-compose', 'Multiply', '-composite',
')',
'-alpha', 'off',
'-compose', 'CopyOpacity',
'-composite',
`${tmpImgDir}/p-${profile.event.id}-rounded.png`
];
return runCommand(magick, args);
} else {
return false;
}
}
async function createOgImage(profile: Profile, ogImagePath: string, backgroundColor: string) {
if (!magick) return false;
const status = await createRoundedImage(profile);
const status = await createRoundedProfileImage(profile);
if (status && status.success) {
const args = [
@@ -49,6 +71,25 @@ async function createOgImage(profile: Profile, ogImagePath: string, backgroundCo
}
};
export async function generateProfileImage(profile: Profile) {
if (!magick || !profile.picture) return false;
const imagePath = `${tmpImgDir}/p-${profile.event.id}.png`;
const fileExists = await checkFileExists(imagePath);
if (fileExists) {
return { success: true };
} else {
const status = await createProfileImage(profile);
if (status && status.success) {
log(`Created avatar image for ${profile.username}: ${imagePath}`, "blue")
return status;
} else {
log(`Could not create avatar image for ${profile.username}`, "yellow")
}
}
}
export async function generateOgProfileImage(profile: Profile) {
if (!magick || !profile.picture) return false;
@@ -56,10 +97,13 @@ export async function generateOgProfileImage(profile: Profile) {
const backgroundColor = "#333333";
const fileExists = await checkFileExists(ogImagePath);
if (!fileExists) {
if (fileExists) {
return { success: true };
} else {
const status = await createOgImage(profile, ogImagePath, backgroundColor);
if (status && status.success) {
log(`Created OG image for ${profile.username}: ${ogImagePath}`, "blue")
return status;
} else {
log(`Could not create OG image for ${profile.username}`, "yellow")
}

View File

@@ -1,9 +1,13 @@
import { render as renderMarkdown } from "@deno/gfm";
import { nip19 } from "@nostr/tools";
import { NostrEvent as NEvent } from "@nostrify/nostrify";
import { replaceNostrUris } from "../nostr.ts";
import { replaceNostrUris } from "../nostr/links.ts";
import config from "../config.ts";
for (const language of config.prism.extraLanguages) {
await import(`npm:prismjs@1.29.0/components/prism-${language}.js`);
}
export default class Article {
event: NEvent;
@@ -48,6 +52,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 {
@@ -58,10 +59,25 @@ export default class Profile {
return nip19.npubEncode(this.pubkey);
}
get nprofile(): string {
return nip19.nprofileEncode({
pubkey: this.pubkey,
relays: [config.relay_urls[0]],
});
}
get profileUrl(): string {
return `${config.base_url}/@${this.username}`;
}
get avatarImageUrl(): string {
if (magick) {
return `${config.base_url}/assets/g/img/p-${this.event.id}.png`;
} else {
return this.picture || "";
}
}
get ogImageUrl(): string {
if (magick) {
return `${config.base_url}/assets/g/img/og-p-${this.event.id}.png`;
@@ -69,4 +85,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,6 @@
import { NPool, NRelay1 } from "@nostrify/nostrify";
import { nip19 } from "@nostr/tools";
import { lookupUsernameByPubkey } from "./directory.ts";
import { NostrEvent, NostrFilter, NPool, NRelay1 } from "@nostrify/nostrify";
import config from "./config.ts";
import Article from "./models/article.ts";
const relayPool = new NPool({
open: (url) => new NRelay1(url),
@@ -14,92 +13,84 @@ const relayPool = new NPool({
eventRouter: async (_event) => [],
});
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 async function fetchArticlesByAuthor(
pubkey: string,
limit: number = 10,
) {
const events = await fetchWithTimeout([{
authors: [pubkey],
kinds: [30023],
limit: 10,
}]);
limit: limit,
}]) as NostrEvent[];
return events;
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;
}
export async function nostrUriToUrl(uri: string): Promise<string> {
const bech32 = uri.replace(/^nostr:/, "");
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}`;
if (bech32.match(/^(naddr|nprofile|npub)/)) {
try {
const r = nip19.decode(bech32);
let username;
try {
const res = await fetch(url);
if (res.status === 404 || !res.ok) return false;
const data = await res.json();
switch (r.type) {
case "naddr":
username = await lookupUsernameByPubkey(r.data.pubkey);
if (username) return `/${bech32}`;
break;
case "nprofile":
username = await lookupUsernameByPubkey(r.data.pubkey);
if (username) return `/@${username}`;
break;
case "npub":
username = await lookupUsernameByPubkey(r.data);
if (username) return `/@${username}`;
break;
}
} catch (e) {
console.error(e);
}
return data.names && data.names[username] === pubkey;
} catch (_e) {
return false;
}
return `${config.njump_url}/${bech32}`;
}
export async function replaceNostrUris(markdown: string): Promise<string> {
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);
const url = await nostrUriToUrl(uri);
markdown = markdown.replace(match, `(${url})`);
}
return markdown;
}

129
nostr/links.ts Normal file
View File

@@ -0,0 +1,129 @@
import { lookupUsernameByPubkey } from "../directory.ts";
import { nip19 } from "@nostr/tools";
import config from "../config.ts";
const nostrUriRegex = /(nostr:|@)(nprofile|naddr|nevent|npub)[a-z0-9]+/g;
export async function nostrUriToUrl(uri: string): Promise<string> {
const bech32 = uri.replace(/^(nostr:|@)/, "");
if (bech32.match(/^(naddr|nprofile|npub)/)) {
try {
const r = nip19.decode(bech32);
let username;
switch (r.type) {
case "naddr":
username = await lookupUsernameByPubkey(r.data.pubkey);
if (username) return `/${bech32}`;
break;
case "nprofile":
username = await lookupUsernameByPubkey(r.data.pubkey);
if (username) return `/@${username}`;
break;
case "npub":
username = await lookupUsernameByPubkey(r.data);
if (username) return `/@${username}`;
break;
}
} catch (e) {
console.error(e);
}
}
return `${config.njump_url}/${bech32}`;
}
export async function replaceNostrUris(markdown: string): Promise<string> {
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)
const segments: string[] = [];
let lastIndex = 0;
markdown.replace(protectedRegex, (match, _fencedCode, _inlineCode, _preTag, _url, index) => {
segments.push(markdown.slice(lastIndex, index));
segments.push(match);
lastIndex = index + match.length;
return match;
});
segments.push(markdown.slice(lastIndex));
// Process each segment
let result = '';
for (let i = 0; i < segments.length; i++) {
if (i % 2 === 1 || protectedRegex.test(segments[i])) {
// Protected segment (code block or URL), leave unchanged
result += segments[i];
} else {
// Unprotected text, replace URIs and handle markdown links
result += await processUnprotectedText(segments[i]);
}
}
return result;
}
async function processUnprotectedText(text: string): Promise<string> {
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
const parts: string[] = [];
// Process markdown links first
let match;
while ((match = markdownLinkRegex.exec(text)) !== null) {
const [fullMatch, linkText, target] = match;
// Add text before the link
parts.push(await replaceUrisInText(text.slice(lastIndex, match.index)));
// Process the link target
if (nostrUriRegex.test(target) && target.match(nostrUriRegex)![0] === target) {
// Target is a Nostr URI, replace with resolved URL
const resolvedUrl = await nostrUriToUrl(target);
parts.push(`[${linkText}](${resolvedUrl})`);
} else {
// Not a Nostr URI, keep the original link
parts.push(fullMatch);
}
lastIndex = match.index + fullMatch.length;
}
// Add any remaining text after the last link
parts.push(await replaceUrisInText(text.slice(lastIndex)));
return parts.join('');
}
async function replaceUrisInText(text: string): Promise<string> {
let modifiedText = text;
const replacements: { start: number; end: number; replacement: string }[] = [];
// Collect all replacements for bare Nostr URIs
let match;
while ((match = nostrUriRegex.exec(modifiedText)) !== null) {
const fullUri = match[0];
const url = await nostrUriToUrl(fullUri);
const linkTitle = cleanUriForTitle(fullUri);
const markdownLink = `[${linkTitle}](${url})`;
replacements.push({
start: match.index,
end: match.index + fullUri.length,
replacement: markdownLink,
});
}
// Apply replacements from right to left to avoid index shifting
for (let i = replacements.length - 1; i >= 0; i--) {
const { start, end, replacement } = replacements[i];
modifiedText = modifiedText.slice(0, start) + replacement + modifiedText.slice(end);
}
return modifiedText;
}
function cleanUriForTitle(uri: string): string {
// Remove "nostr:" prefix, keep "@" for the title
return uri.startsWith('nostr:') ? uri.replace(/^nostr:/, '') : uri;
}

140
prism.css Normal file
View File

@@ -0,0 +1,140 @@
/**
* 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

@@ -7,23 +7,36 @@ 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("~")) {
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 +49,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 +69,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);
}
@@ -62,12 +93,17 @@ router.get("/assets/:path*", async (ctx) => {
filePath = filePath.replace(/^g\//, "");
root = "/tmp/substr";
} else {
root = `${Deno.cwd()}/assets`;
root = `${import.meta.dirname}/assets`;
}
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);
}
}
});

41
tests/fixtures/article-2.md vendored Normal file
View File

@@ -0,0 +1,41 @@
I think we should agree on an HTML attribute for pointing to the Nostr representation of a document/URL on the Web. We could use the existing one for link relations for example. Something like:
```html
<link rel="alternate" type="application/nostr+json"
href="nostr:naddr1qvzqqqr4..."
title="This article on Nostr" />
```
This would be useful in multiple ways:
1. Existing Web publications can retroactively create Nostr versions of their content and easily link the Nostr articles on all of their existing article pages.
2. Nostr clients, when fetching meta/preview information for a URL that is linked in a note, can detect that there's a Nostr representation of the content, and then render it in Nostr-native ways (whatever that may be depending on the client)
3. User agents, usually a browser or browser extension, when opening a URL on the Web, can offer opening the alternative representation of a page in a Nostr client. And/or they could offer to follow the author's pubkey on Nostr. And/or they could offer to zap the content.
4. When publishing a new article on Nostr, authors can share their preferred Web URL everywhere, without having to consider if the recipients' clients support Nostr IDs and content or not. This makes it easy for the reader to share the author's preferred Web URL on any medium, instead of sharing a link to whatever their own Nostr client prefers (usually its own Web UI).
### Testing Nostr IDs
Receive [special badges](https://badges.page/p/npub1cpmvpsqtzxl4px44dp4544xwgu0ryv2lscl3qexq42dfakuza02s4fsapc)
raucao scheme 1: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
raucao at 1: @npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
raucao scheme 2: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
raucao at 2: @npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
raucao scheme link 1: [raucao](nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees)
raucao scheme link 2: [raucao](nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees)
Amber scheme 1: nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
Amber at 1: @npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
Amber scheme 2: nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
Amber at 2: @npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
Amber scheme link 1: [Amber](nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7)
Amber scheme link 2: [Amber](nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7)
## More protected text
```
Follow nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
```
Inline: `raucao: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees`

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", () => {
@@ -58,10 +59,17 @@ 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(
/naddr1qvzqqqr4gupzq8meqkx80g3yuklzymy0qf/,
/^naddr1qvzqqqr4gupzq8meqkx80g3yuklzymy0qf/,
);
});
});

View File

@@ -40,4 +40,12 @@ describe("Profile", () => {
);
});
});
describe("#nprofile", () => {
it("returns a bech32 profile ID", () => {
expect(profile.nprofile).toMatch(
/^nprofile1qyt8wumn8ghj7mn0wd68ytntdaek6mmn9ehhyecqyq0hjpvvw73zfed7yf/,
);
});
});
});

62
tests/nostr/links_test.ts Normal file
View File

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

3
users.yaml.test Normal file
View File

@@ -0,0 +1,3 @@
_: b3e1b7c0ef48294bd856203bfd460625de95d3afb894e5f09b14cd1f0e7097cf
accounts: b3e1b7c1660b7db0ecb93ec55c09e67961171a5c4e9e2602f1b47477ea61c50a
raucao: 1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb