Compare commits

...

31 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
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
28 changed files with 759 additions and 198 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"

3
.gitignore vendored
View File

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

View File

@ -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%;
}

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);
}

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) {
@ -47,6 +52,15 @@ const config = {
},
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,22 +1,26 @@
{
"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": [
"magick.ts"
"magick.ts",
"assets/css/prism.css",
"tests/fixtures/"
]
}
}

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,11 @@
// 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 +23,9 @@ 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

@ -20,6 +20,8 @@ export async function profileAtomFeed(
<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>
@ -32,13 +34,17 @@ export async function profileAtomFeed(
return `
<?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>
<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:uri>
</author>
${articlesXml}
</feed>

View File

@ -9,7 +9,6 @@ const nprofileHandler = async function (ctx: Context) {
try {
data = nip19.decode(nprofile).data as nip19.ProfilePointer;
console.log(data);
} catch (_e) {
notFoundHandler(ctx);
return;

View File

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

View File

@ -27,7 +27,7 @@ const userEventHandler = async function (ctx: Context) {
const article = new Article(articleEvent);
const profile = new Profile(profileEvent, username);
const html = await articleHtml(article, profile);
generateOgProfileImage(profile);
await generateOgProfileImage(profile);
ctx.response.body = html;
} else {

View File

@ -21,7 +21,7 @@ const userProfileHandler = async function (ctx: Context) {
const profile = new Profile(profileEvent, username);
const articles = await fetchArticlesByAuthor(pubkey, 210);
const html = await profilePageHtml(profile, articles);
generateOgProfileImage(profile);
await generateOgProfileImage(profile);
ctx.response.body = html;
} else {

15
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>
@ -55,7 +56,7 @@ export async function articleHtml(
${draftLabel}
<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>
@ -113,9 +114,6 @@ export function articleListHtml(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`;
}
@ -148,7 +146,7 @@ export async function profilePageHtml(
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}
@ -211,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">
@ -229,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}">

18
ldap.ts
View File

@ -8,8 +8,10 @@ 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,7 +25,7 @@ export async function lookupPubkeyByUsername(username: string) {
searchEntries.length > 0 &&
typeof searchEntries[0].nostrKey === "string"
) {
pubkey = searchEntries[0].nostrKey;
pubkey = searchEntries[0].nostrKey.toString();
}
await client.unbind();
@ -35,8 +37,10 @@ export async function lookupPubkeyByUsername(username: string) {
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);
@ -47,8 +51,10 @@ export async function lookupUsernameByPubkey(pubkey: string) {
});
if (searchEntries.length > 0) {
username = searchEntries[0].cn;
username = searchEntries[0].cn.toString();
}
await client.unbind();
} catch (e) {
await client.unbind();
throw e;

View File

@ -8,7 +8,7 @@ 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 = [
@ -16,24 +16,44 @@ function createRoundedImage(profile: Profile) {
'-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`
`${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 = [
@ -51,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;
@ -58,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;

View File

@ -59,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`;

View File

@ -1,6 +1,4 @@
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";
@ -79,49 +77,6 @@ export async function fetchProfileEvent(pubkey: string) {
return events.length > 0 ? events[0] : null;
}
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 nostrUriRegex = /(nostr:|nprofile|naddr|nevent|nrelay|npub)[a-z0-9]+/g;
const matches = markdown.match(nostrUriRegex);
if (!matches) return markdown;
for (const uri of matches) {
const url = await nostrUriToUrl(uri);
markdown = markdown.replace(uri, url);
}
return markdown;
}
export async function verifyNip05Address(
address: string,
pubkey: string,

137
nostr/links.ts Normal file
View File

@ -0,0 +1,137 @@
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;
}

View File

@ -93,7 +93,7 @@ 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 });

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`

View File

@ -69,7 +69,7 @@ describe("Article", () => {
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/,
);
});
});
});

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

@ -0,0 +1,97 @@
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