Compare commits
65 Commits
9ec8cec302
...
v1.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
275fb73896
|
|||
| 204b4f44c7 | |||
|
8e802f314a
|
|||
|
f4d1ba897b
|
|||
| 7f975f2dbb | |||
|
5e1e249052
|
|||
|
b376bbd2aa
|
|||
|
51ae16e57f
|
|||
|
2ac3180c0f
|
|||
|
cea96e170d
|
|||
|
5906655902
|
|||
|
5f38355d5c
|
|||
|
010eb3f291
|
|||
|
0b1eca87b2
|
|||
|
f1d6ddbc84
|
|||
|
49d5aa4487
|
|||
|
4c68be19fe
|
|||
|
a6517c61a4
|
|||
|
2624f2cbf8
|
|||
|
cb4a4e06c8
|
|||
|
5f5f024ae7
|
|||
|
ec7c775e25
|
|||
|
28e9d14aae
|
|||
|
eadc40392a
|
|||
|
6ec9f51d77
|
|||
|
afcb99356c
|
|||
|
266913c369
|
|||
|
aec35d9eb3
|
|||
|
c1c9a37914
|
|||
|
30a74acf3b
|
|||
|
b7974c8610
|
|||
|
0096f3cae3
|
|||
|
fc711c2194
|
|||
|
fdd16d8236
|
|||
|
d5793d47ff
|
|||
|
32f39685a1
|
|||
|
062ded9e6d
|
|||
|
ba80792cc4
|
|||
|
a9f13310ab
|
|||
|
e921fb2d84
|
|||
|
52d56c387d
|
|||
|
46ad9813eb
|
|||
|
edaf5f5c71
|
|||
|
f15e845825
|
|||
|
453a0f14d3
|
|||
|
856b10358c
|
|||
|
fdf16227d3
|
|||
|
a0d5cba339
|
|||
|
be1b9f21ec
|
|||
|
c0a02295c1
|
|||
|
fdb13bc65d
|
|||
|
a0f0b06ad2
|
|||
|
ce469bc37f
|
|||
|
28520c59b9
|
|||
|
ba7336b4ee
|
|||
|
ea58c1f60b
|
|||
|
3914ca25a1
|
|||
|
4534147050
|
|||
|
96254b38be
|
|||
|
ffd709d2f9
|
|||
|
74f7b89c15
|
|||
|
48fcb7eac5
|
|||
|
849410bb64
|
|||
|
bab9b74090
|
|||
|
fa98e90210
|
@@ -1,4 +1,4 @@
|
||||
HOME_RELAY_URL=wss://nostr.kosmos.org
|
||||
RELAY_URLS=wss://nostr.kosmos.org,wss://nostr.x0f.org
|
||||
LDAP_URL=ldap://10.1.1.116:389
|
||||
LDAP_BIND_DN=uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org
|
||||
LDAP_PASSWORD=123456abcdef
|
||||
|
||||
18
.gitea/workflows/ci.yml
Normal file
18
.gitea/workflows/ci.yml
Normal 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
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
.env
|
||||
users.yaml
|
||||
build/
|
||||
|
||||
35
assets/css/fonts/merriweather.css
Normal file
35
assets/css/fonts/merriweather.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@font-face {
|
||||
font-family: "Merriweather";
|
||||
src:
|
||||
url("/assets/fonts/Merriweather-Regular.woff2") format("woff2"),
|
||||
url("/assets/fonts/Merriweather-Regular.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Merriweather";
|
||||
src:
|
||||
url("/assets/fonts/Merriweather-Italic.woff2") format("woff2"),
|
||||
url("/assets/fonts/Merriweather-Italic.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Merriweather";
|
||||
src:
|
||||
url("/assets/fonts/Merriweather-Bold.woff2") format("woff2"),
|
||||
url("/assets/fonts/Merriweather-Bold.woff") format("woff");
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Merriweather";
|
||||
src:
|
||||
url("/assets/fonts/Merriweather-BoldItalic.woff2") format("woff2"),
|
||||
url("/assets/fonts/Merriweather-BoldItalic.woff") format("woff");
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
236
assets/css/layout.css
Normal file
236
assets/css/layout.css
Normal file
@@ -0,0 +1,236 @@
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 4rem 0 1.6rem 0;
|
||||
}
|
||||
|
||||
h2, h3, h4 {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
p, pre, ul, ol, dl, blockquote, hr {
|
||||
line-height: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
}
|
||||
|
||||
a.anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.9em;
|
||||
padding: 0.1em 0.3em;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
img.avatar {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
dl dt {
|
||||
font-weight: bold;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
dl dd {
|
||||
margin: 0 0 1.6rem 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
main {
|
||||
display: block;
|
||||
max-width: 44rem;
|
||||
margin: 12rem auto 24rem auto;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
main header {
|
||||
display: block;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
main header .draft-label {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
main header .meta {
|
||||
display: flex;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
main header .meta .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6rem;
|
||||
}
|
||||
|
||||
main header .meta .name a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main.profile-page {
|
||||
margin-top: 6rem;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
main .article-list p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
main .article-list p.meta {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
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 {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
min-width: 12rem;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
padding: 0.6rem 1rem;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-content h4.title {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-top: 1px solid;
|
||||
}
|
||||
|
||||
.dropdown:hover .dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 100%;
|
||||
margin: 4rem 1rem 8rem 1rem !important;
|
||||
}
|
||||
|
||||
main.profile-page h1 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
bottom: 100%;
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
padding: 0.8rem 1.2rem;
|
||||
}
|
||||
|
||||
.dropdown-content h4.title {
|
||||
padding: 0.8rem 1.2rem;
|
||||
}
|
||||
}
|
||||
122
assets/css/themes/default-light.css
Normal file
122
assets/css/themes/default-light.css
Normal file
@@ -0,0 +1,122 @@
|
||||
@import url("/assets/css/fonts/merriweather.css");
|
||||
|
||||
:root {
|
||||
--font-family: "Merriweather", serif;
|
||||
--background-color-body: #f5f2eb;
|
||||
--background-color-dark: #333;
|
||||
--text-color-body: #3b3a38;
|
||||
--text-color-headings: #191818;
|
||||
--text-color-discreet: #888;
|
||||
--text-color-dark-bg: #ccc;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--background-color-body);
|
||||
color: var(--text-color-body);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
color: var(--text-color-headings);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #023b77;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #3b0277;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #e8e3da;
|
||||
color: #027739;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--background-color-dark);
|
||||
color: var(--text-color-dark-bg);
|
||||
}
|
||||
|
||||
pre code {
|
||||
background-color: var(--background-color-dark);
|
||||
color: var(--text-color-dark-bg);
|
||||
}
|
||||
|
||||
dl dt {
|
||||
color: var(--text-color-discreet);
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: #e8e3da;
|
||||
}
|
||||
|
||||
main header .draft-label {
|
||||
color: #770202;
|
||||
border-color: #770202;
|
||||
}
|
||||
|
||||
main header .meta .date {
|
||||
color: var(--text-color-discreet);
|
||||
}
|
||||
|
||||
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 {
|
||||
--background-color: #fff;
|
||||
--background-color-highlight: #f1f1f1;
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
background-color: var(--background-color-body);
|
||||
color: var(--text-color-body);
|
||||
border-color: var(--text-color-dark-bg);
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
color: var(--text-color-body) !important;
|
||||
}
|
||||
|
||||
.dropdown-content a:hover {
|
||||
background-color: var(--background-color-highlight);
|
||||
}
|
||||
|
||||
.dropdown-content h4.title {
|
||||
color: var(--text-color-discreet);
|
||||
border-color: var(--background-color-highlight);
|
||||
}
|
||||
|
||||
.dropdown:hover .dropdown-button {
|
||||
background-color: var(--background-color);
|
||||
border-color: var(--background-color);
|
||||
}
|
||||
BIN
assets/fonts/Merriweather-Bold.woff
Normal file
BIN
assets/fonts/Merriweather-Bold.woff
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Bold.woff2
Normal file
BIN
assets/fonts/Merriweather-Bold.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-BoldItalic.woff
Normal file
BIN
assets/fonts/Merriweather-BoldItalic.woff
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-BoldItalic.woff2
Normal file
BIN
assets/fonts/Merriweather-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Italic.woff
Normal file
BIN
assets/fonts/Merriweather-Italic.woff
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Italic.woff2
Normal file
BIN
assets/fonts/Merriweather-Italic.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Regular.woff
Normal file
BIN
assets/fonts/Merriweather-Regular.woff
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Regular.woff2
Normal file
BIN
assets/fonts/Merriweather-Regular.woff2
Normal file
Binary file not shown.
84
config.ts
84
config.ts
@@ -1,16 +1,94 @@
|
||||
import { load } from "@std/dotenv";
|
||||
import { parse as parseYaml } from "jsr:@std/yaml";
|
||||
import { checkFileExists } from "./utils.ts";
|
||||
import { log } from "./log.ts";
|
||||
|
||||
const dirname = Deno.cwd();
|
||||
|
||||
const dirname = new URL(".", import.meta.url).pathname;
|
||||
await load({ envPath: `${dirname}/.env`, export: true });
|
||||
|
||||
let userConfigPath: string = "";
|
||||
let staticUsers: { [key: string]: string } = {};
|
||||
|
||||
const defaultUserConfigPaths = [
|
||||
"/etc/substr/users.yaml",
|
||||
`${dirname}/users.yaml`,
|
||||
];
|
||||
|
||||
for (const path of defaultUserConfigPaths) {
|
||||
const fileExists = await checkFileExists(path);
|
||||
if (fileExists) {
|
||||
userConfigPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = await Deno.readTextFile(userConfigPath);
|
||||
const parsedContent = parseYaml(fileContent);
|
||||
if (parsedContent !== null && typeof parsedContent === "object") {
|
||||
staticUsers = parsedContent as { [key: string]: string };
|
||||
}
|
||||
} catch {
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
const config = {
|
||||
home_relay_url: Deno.env.get("HOME_RELAY_URL"),
|
||||
port: parseInt(Deno.env.get("PORT") || "30023"),
|
||||
base_url: Deno.env.get("BASE_URL") || `http://localhost:30023`,
|
||||
relay_urls: Deno.env.get("RELAY_URLS")?.split(",") || [],
|
||||
staticUsers,
|
||||
ldapEnabled: !!Deno.env.get("LDAP_URL"),
|
||||
ldap: {
|
||||
url: Deno.env.get("LDAP_URL"),
|
||||
bindDN: Deno.env.get("LDAP_BIND_DN"),
|
||||
password: Deno.env.get("LDAP_PASSWORD"),
|
||||
searchDN: Deno.env.get("LDAP_SEARCH_DN"),
|
||||
}
|
||||
},
|
||||
query_timeout: parseInt(Deno.env.get("RELAY_TIMEOUT_MS") || "5000"),
|
||||
njump_url: Deno.env.get("NJUMP_URL") || "https://njump.me",
|
||||
};
|
||||
|
||||
const staticUsersConfigured = Object.keys(staticUsers).length > 0;
|
||||
|
||||
export function ensureNecessaryConfigs() {
|
||||
if (config.relay_urls.length === 0) {
|
||||
log(
|
||||
`No relays configured. Please add at least one relay to RELAY_URLS.`,
|
||||
"yellow",
|
||||
);
|
||||
Deno.exit(1);
|
||||
} else {
|
||||
log(`Relays: ${config.relay_urls.join(", ")}`, "green");
|
||||
}
|
||||
|
||||
if (staticUsersConfigured) {
|
||||
log(`Serving content for pubkeys in users.yaml`, "blue");
|
||||
} else {
|
||||
log(`Could not find or parse a users.yaml config`, "gray");
|
||||
}
|
||||
|
||||
if (config.ldapEnabled) {
|
||||
if (
|
||||
config.ldap.url && config.ldap.bindDN && config.ldap.password &&
|
||||
config.ldap.searchDN
|
||||
) {
|
||||
log(`Serving content for pubkeys from ${config.ldap.url}`, "blue");
|
||||
} else {
|
||||
log(`The LDAP config is incomplete`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
} else {
|
||||
log(`LDAP not enabled`, "blue");
|
||||
}
|
||||
|
||||
if (!staticUsersConfigured && !config.ldapEnabled) {
|
||||
log(
|
||||
`Neither static users nor LDAP configured. Nothing to serve.`,
|
||||
"yellow",
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
26
deno.json
26
deno.json
@@ -1,16 +1,24 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-net --allow-read --allow-env --deny-env --watch main.ts"
|
||||
"dev": "deno run --allow-all --watch server.ts",
|
||||
"server": "deno run --allow-all server.ts",
|
||||
"compile": "deno compile --allow-all --include ./assets/ --output ./build/substr_x86_64-unknown-linux-gnu server.ts",
|
||||
"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",
|
||||
"@std/assert": "jsr:@std/assert@1",
|
||||
"@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",
|
||||
"ldapts": "npm:ldapts@^7.2.1"
|
||||
"@std/expect": "jsr:@std/expect@^1.0.8",
|
||||
"@std/testing": "jsr:@std/testing@^1.0.5",
|
||||
"@std/yaml": "jsr:@std/yaml@^1.0.5",
|
||||
"ldapts": "npm:ldapts@^7.2.2"
|
||||
},
|
||||
"fmt": {
|
||||
"exclude": [
|
||||
"magick.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
222
deno.lock
generated
222
deno.lock
generated
@@ -1,58 +1,67 @@
|
||||
{
|
||||
"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.10.4",
|
||||
"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.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/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.8",
|
||||
"jsr:@std/fs@^1.0.5": "1.0.5",
|
||||
"jsr:@std/http@1": "1.0.10",
|
||||
"jsr:@std/internal@^1.0.5": "1.0.5",
|
||||
"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.5": "1.0.5",
|
||||
"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: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",
|
||||
@@ -69,22 +78,27 @@
|
||||
"@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",
|
||||
"@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",
|
||||
@@ -99,34 +113,44 @@
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"@std/bytes@1.0.2": {
|
||||
"integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57"
|
||||
"@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"
|
||||
@@ -143,27 +167,27 @@
|
||||
"@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/assert@^1.0.8",
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/fs@1.0.4": {
|
||||
"integrity": "2907d32d8d1d9e540588fd5fe0ec21ee638134bd51df327ad4e443aaef07123c",
|
||||
"@std/fs@1.0.5": {
|
||||
"integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e",
|
||||
"dependencies": [
|
||||
"jsr:@std/path@^1.0.6"
|
||||
"jsr:@std/path@^1.0.7"
|
||||
]
|
||||
},
|
||||
"@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/io@0.224.9": {
|
||||
"integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3",
|
||||
@@ -171,21 +195,24 @@
|
||||
"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.5": {
|
||||
"integrity": "6e693cbec94c81a1ad3df668685c7ba8e20742bb10305bc7137faa5cf16d2ec4",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.6",
|
||||
"jsr:@std/assert@^1.0.8",
|
||||
"jsr:@std/data-structures",
|
||||
"jsr:@std/fs",
|
||||
"jsr:@std/internal",
|
||||
"jsr:@std/path@^1.0.6"
|
||||
"jsr:@std/path@^1.0.8"
|
||||
]
|
||||
},
|
||||
"@std/yaml@1.0.5": {
|
||||
"integrity": "71ba3d334305ee2149391931508b2c293a8490f94a337eef3a09cade1a2a2742"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
@@ -204,10 +231,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": {
|
||||
@@ -216,43 +243,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": {
|
||||
@@ -340,8 +370,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",
|
||||
@@ -351,11 +381,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"
|
||||
]
|
||||
@@ -382,8 +412,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",
|
||||
@@ -449,8 +479,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=="
|
||||
@@ -483,15 +513,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:@std/assert@1",
|
||||
"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",
|
||||
"npm:ldapts@^7.2.1"
|
||||
"jsr:@std/expect@^1.0.8",
|
||||
"jsr:@std/testing@^1.0.5",
|
||||
"jsr:@std/yaml@^1.0.5",
|
||||
"npm:ldapts@^7.2.2"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
33
directory.ts
Normal file
33
directory.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import config from "./config.ts";
|
||||
import { lookupUsernameByPubkey as ldapLookupUsername } from "./ldap.ts";
|
||||
import { lookupPubkeyByUsername as ldapLookupPubkey } from "./ldap.ts";
|
||||
|
||||
export function lookupUsernameByPubkey(pubkey: string) {
|
||||
let username;
|
||||
for (const [key, value] of Object.entries(config.staticUsers)) {
|
||||
if (value === pubkey) {
|
||||
username = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (username) {
|
||||
return username;
|
||||
} else {
|
||||
if (config.ldapEnabled) {
|
||||
return ldapLookupUsername(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function lookupPubkeyByUsername(username: string) {
|
||||
const pubkey = config.staticUsers[username];
|
||||
|
||||
if (pubkey) {
|
||||
return pubkey;
|
||||
} else {
|
||||
if (config.ldapEnabled) {
|
||||
return ldapLookupPubkey(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
feeds.ts
34
feeds.ts
@@ -2,33 +2,41 @@ import Article from "./models/article.ts";
|
||||
import Profile from "./models/profile.ts";
|
||||
import { isoDate } from "./dates.ts";
|
||||
|
||||
export function profileAtomFeed(profile: Profile, articles: Article[]) {
|
||||
export async function profileAtomFeed(
|
||||
profile: Profile,
|
||||
articles: Article[],
|
||||
): Promise<string> {
|
||||
const feedId = `tag:${profile.nip05},nostr-p-${profile.pubkey}-k-30023`;
|
||||
const lastUpdate = articles.sort((a, b) => b.updatedAt - a.updatedAt)[0]
|
||||
?.updatedAt;
|
||||
let articlesXml = "";
|
||||
|
||||
const articlesXml = articles.map((article) => {
|
||||
for (const article of articles) {
|
||||
const contentHtml = await article.buildContentHtml();
|
||||
const articleId =
|
||||
`tag:${profile.nip05},nostr-d-${article.identifier}-k-30023`;
|
||||
return `
|
||||
`tag:${profile.nip05},nostr-p-${profile.pubkey}-d-${article.identifier}-k-30023`;
|
||||
articlesXml += `
|
||||
<entry>
|
||||
<id>${articleId}</id>
|
||||
<title>${article.title}</title>
|
||||
<link href="/${article.naddr}" />
|
||||
<link href="${article.url}" />
|
||||
<updated>${isoDate(article.updatedAt)}</updated>
|
||||
<published>${isoDate(article.publishedAt)}</published>
|
||||
<summary>${article.summary}</summary>
|
||||
<content type="html">${article.html}</content>
|
||||
<content type="html"><![CDATA[
|
||||
${cleanContentHtml(contentHtml)}
|
||||
]]></content>
|
||||
</entry>
|
||||
`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
return `
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>${profile.name} on Nostr</title>
|
||||
<title>${profile.name} on Nostr (Articles)</title>
|
||||
<id>${feedId}</id>
|
||||
<updated>${isoDate(lastUpdate)}</updated>
|
||||
<icon>${profile.picture}</icon>
|
||||
<icon>${profile.avatarImageUrl}</icon>
|
||||
<author>
|
||||
<name>${name}</name>
|
||||
</author>
|
||||
@@ -36,3 +44,11 @@ export function profileAtomFeed(profile: Profile, articles: Article[]) {
|
||||
</feed>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export function cleanContentHtml(html: string) {
|
||||
const cleanHtml = html.replace(
|
||||
/<a class="anchor" aria-hidden="true"[^>]*>.*?<\/a>/gs,
|
||||
"",
|
||||
);
|
||||
return cleanHtml;
|
||||
}
|
||||
|
||||
20
handlers/errors.ts
Normal file
20
handlers/errors.ts
Normal 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;
|
||||
};
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupUsernameByPubkey } from "../ldap.ts";
|
||||
import { lookupUsernameByPubkey } from "../directory.ts";
|
||||
import { notFoundHandler } from "../handlers/errors.ts";
|
||||
|
||||
const naddrHandler = async function (ctx: Context) {
|
||||
const naddr = ctx.params.path;
|
||||
const naddr = ctx.state.path;
|
||||
let data: nip19.AddressPointer;
|
||||
|
||||
try {
|
||||
const r = nip19.decode(naddr);
|
||||
const username = await lookupUsernameByPubkey(r.data.pubkey);
|
||||
data = nip19.decode(naddr).data as nip19.AddressPointer;
|
||||
} catch (_e) {
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (username && r.data.identifier) {
|
||||
ctx.response.redirect(`/@${username}/${r.data.identifier}`);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
const username = await lookupUsernameByPubkey(data.pubkey);
|
||||
|
||||
if (username && data.identifier) {
|
||||
ctx.response.redirect(`/@${username}/${data.identifier}`);
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupUsernameByPubkey } from "../ldap.ts";
|
||||
import { lookupUsernameByPubkey } from "../directory.ts";
|
||||
import { notFoundHandler } from "../handlers/errors.ts";
|
||||
|
||||
const nprofileHandler = async function (ctx: Context) {
|
||||
const nprofile = ctx.params.path;
|
||||
const nprofile = ctx.state.path;
|
||||
let data: nip19.ProfilePointer;
|
||||
|
||||
try {
|
||||
const r = nip19.decode(nprofile);
|
||||
const username = await lookupUsernameByPubkey(r.data.pubkey);
|
||||
data = nip19.decode(nprofile).data as nip19.ProfilePointer;
|
||||
} catch (_e) {
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (username) {
|
||||
ctx.response.redirect(`/@${username}`);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
const username = await lookupUsernameByPubkey(data.pubkey);
|
||||
|
||||
if (username) {
|
||||
ctx.response.redirect(`/@${username}`);
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupUsernameByPubkey } from "../ldap.ts";
|
||||
import { lookupUsernameByPubkey } from "../directory.ts";
|
||||
import { notFoundHandler } from "../handlers/errors.ts";
|
||||
|
||||
const npubHandler = async function (ctx: Context) {
|
||||
const npub = ctx.params.path;
|
||||
const npub = ctx.state.path;
|
||||
let pubkey: string;
|
||||
|
||||
try {
|
||||
const r = nip19.decode(npub);
|
||||
const username = await lookupUsernameByPubkey(r.data);
|
||||
pubkey = nip19.decode(npub).data as string;
|
||||
} catch (_e) {
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (username) {
|
||||
ctx.response.redirect(`/@${username}`);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
const username = await lookupUsernameByPubkey(pubkey);
|
||||
|
||||
if (username) {
|
||||
ctx.response.redirect(`/@${username}`);
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupPubkeyByUsername } from "../ldap.ts";
|
||||
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/errors.ts";
|
||||
|
||||
const userAtomFeedHandler = async function (ctx: Context) {
|
||||
const username = ctx.params.user.replace(/^(@|~)/, "");
|
||||
const pubkey = await lookupPubkeyByUsername(username);
|
||||
const username = ctx.state.username;
|
||||
const pubkey = await lookupPubkeyByUsername(ctx.state.username);
|
||||
|
||||
if (!pubkey) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profileEvent = await fetchProfileEvent(pubkey);
|
||||
const profileEvent = await fetchProfileEvent(pubkey);
|
||||
|
||||
if (profileEvent) {
|
||||
const profile = new Profile(profileEvent, username);
|
||||
|
||||
if (profileEvent && profile.nip05) {
|
||||
const articleEvents = await fetchArticlesByAuthor(pubkey);
|
||||
const articles = articleEvents.map((a) => new Article(a));
|
||||
const atom = profileAtomFeed(profile, articles);
|
||||
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;
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
notFoundHandler(ctx);
|
||||
};
|
||||
|
||||
export default userAtomFeedHandler;
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupPubkeyByUsername } from "../ldap.ts";
|
||||
import { lookupPubkeyByUsername } from "../directory.ts";
|
||||
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/errors.ts";
|
||||
import { generateOgProfileImage } from "../magick.ts";
|
||||
|
||||
const userEventHandler = async function (ctx: Context) {
|
||||
const username = ctx.params.user.replace(/^(@|~)/, "");
|
||||
const identifier = ctx.params.identifier;
|
||||
const username = ctx.state.username.replace(/^(@|~)/, "");
|
||||
const identifier = ctx.state.identifier;
|
||||
const pubkey = await lookupPubkeyByUsername(username);
|
||||
|
||||
if (!pubkey) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
notFoundHandler(ctx);
|
||||
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 = articleHtml(article, 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 {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
ctx.response.body = html;
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupPubkeyByUsername } from "../ldap.ts";
|
||||
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/errors.ts";
|
||||
import { generateOgProfileImage } from "../magick.ts";
|
||||
|
||||
const userProfileHandler = async function (ctx: Context) {
|
||||
const username = ctx.params.path.replace(/^(@|~)/, "");
|
||||
const username = ctx.state.path.replace(/^(@|~)/, "");
|
||||
const pubkey = await lookupPubkeyByUsername(username);
|
||||
|
||||
if (!pubkey) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
notFoundHandler(ctx);
|
||||
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);
|
||||
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 {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
ctx.response.body = html;
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
307
html.ts
307
html.ts
@@ -2,7 +2,13 @@ import { localizeDate } from "./dates.ts";
|
||||
import Article from "./models/article.ts";
|
||||
import Profile from "./models/profile.ts";
|
||||
|
||||
export function htmlLayout(title: string, body: string, profile: Profile) {
|
||||
interface HtmlLayoutOptions {
|
||||
title: string;
|
||||
body: string;
|
||||
metaHtml?: string;
|
||||
}
|
||||
|
||||
function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -11,122 +17,9 @@ export function htmlLayout(title: string, body: string, profile: Profile) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>${title}</title>
|
||||
<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,400;0,700;0,900;1,400;1,700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: #f5f2eb;
|
||||
color: #3b3a38;
|
||||
font-size: 18px;
|
||||
font-family: "Merriweather", serif;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
img.avatar {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.profile-page img.avatar {
|
||||
height: 128px;
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
a.anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 2em 0 0 0;
|
||||
}
|
||||
|
||||
h2, h3, h4 {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
color: #191818;
|
||||
}
|
||||
|
||||
p, pre, ul, ol, blockquote {
|
||||
line-height: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #023b77
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #3b0277
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 1rem;
|
||||
background-color: #e8e3da;
|
||||
color: #027739;
|
||||
padding: 0.1em 0.3em;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: #333;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
main {
|
||||
display: block;
|
||||
max-width: 728px;
|
||||
margin: 12rem auto;
|
||||
}
|
||||
|
||||
main header {
|
||||
display: block;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
main header h1 {
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
|
||||
p.meta {
|
||||
display: flex;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
p.meta .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6rem;
|
||||
}
|
||||
|
||||
p.meta .date {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
p.meta .name a {
|
||||
color: #3b3a38;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-list .item {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.article-list .item h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
${metaHtml || ""}
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/themes/default-light.css" />
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
@@ -135,40 +28,73 @@ export function htmlLayout(title: string, body: string, profile: Profile) {
|
||||
`;
|
||||
}
|
||||
|
||||
export function articleHtml(article: Article, profile: Profile) {
|
||||
export function errorPageHtml(statusCode: number, title: string): string {
|
||||
const body = `
|
||||
<main>
|
||||
<h1>${statusCode} - ${title}</h1>
|
||||
</main>
|
||||
`;
|
||||
|
||||
return htmlLayout({ title, body });
|
||||
}
|
||||
|
||||
export async function articleHtml(
|
||||
article: Article,
|
||||
profile: Profile,
|
||||
): Promise<string> {
|
||||
const publishedAtFormatted = localizeDate(article.publishedAt);
|
||||
const pageTitle = article.isDraft ? `Draft: ${article.title}` : article.title;
|
||||
let draftLabel = ``;
|
||||
if (article.isDraft) {
|
||||
draftLabel = `<p class="draft-label">Draft version</p>`;
|
||||
}
|
||||
|
||||
const body = `
|
||||
<main>
|
||||
<header>
|
||||
<h1>${article.title}</h1>
|
||||
<p class="meta">
|
||||
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
|
||||
<span class="content">
|
||||
${draftLabel}
|
||||
<h1>${titleHtml(article.title)}</h1>
|
||||
<div class="meta">
|
||||
<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>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
${article.html}
|
||||
<article>
|
||||
${await article.buildContentHtml()}
|
||||
<footer>
|
||||
${openWithNostrAppHtml(article.naddr)}
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
`;
|
||||
|
||||
return htmlLayout(article.title, body, profile);
|
||||
let metaHtml = articleMetaHtml(article, profile);
|
||||
metaHtml += feedLinksHtml(profile);
|
||||
|
||||
return htmlLayout({ title: pageTitle, body, metaHtml });
|
||||
}
|
||||
|
||||
function articleListItemHtml(article: Article) {
|
||||
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>
|
||||
<p>${formattedDate}</p>
|
||||
<h3><a href="/${article.naddr}">${titleHtml(article.title)}</a></h3>
|
||||
<p class="meta">
|
||||
${formattedDate}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function articleListHtml(articles: Article[]) {
|
||||
export function articleListHtml(articles: Article[]): string {
|
||||
if (articles.length === 0) return "";
|
||||
let html = "";
|
||||
|
||||
@@ -184,23 +110,136 @@ export function articleListHtml(articles: Article[]) {
|
||||
`;
|
||||
}
|
||||
|
||||
export function profilePageHtml(profile: Profile, articles: Article[]) {
|
||||
function userAddressHtml(profile: Profile) {
|
||||
let html = "";
|
||||
|
||||
if (profile.nip05) {
|
||||
html += `<dt>Nostr address</dt><dd>${profile.nip05}</dd>\n`;
|
||||
}
|
||||
if (profile.lud16) {
|
||||
html += `<dt>Lightning address</dt><dd>${profile.lud16}</dd>\n`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function nip05VerifiedHtml(verified: boolean): string {
|
||||
if (verified) {
|
||||
return ` <span class="verified" title="Verified">✔</span>`;
|
||||
} else {
|
||||
return ` <span class="not-verified" title="Verification failed">✕</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function profilePageHtml(
|
||||
profile: Profile,
|
||||
articles: Article[],
|
||||
): Promise<string> {
|
||||
const title = `${profile.name} on Nostr`;
|
||||
let nip05Html = "";
|
||||
|
||||
if (profile.nip05) {
|
||||
const nip05Verified = await profile.verifyNip05();
|
||||
nip05Html += `<p class="nip05">${profile.nip05}${
|
||||
nip05VerifiedHtml(nip05Verified)
|
||||
}</p>\n`;
|
||||
}
|
||||
|
||||
const body = `
|
||||
<main class="profile-page">
|
||||
<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>
|
||||
${articleListHtml(articles)}
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<dl>
|
||||
<dt>Public key</dt>
|
||||
<dd>${profile.npub}</dd>
|
||||
${userAddressHtml(profile)}
|
||||
</dl>
|
||||
</details>
|
||||
<section>
|
||||
${articleListHtml(articles)}
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
|
||||
return htmlLayout(title, body, profile);
|
||||
let metaHtml = profileMetaHtml(profile);
|
||||
metaHtml += feedLinksHtml(profile);
|
||||
|
||||
return htmlLayout({ title, body, metaHtml });
|
||||
}
|
||||
|
||||
function openWithNostrAppHtml(bech32Id: string): string {
|
||||
let appLinksHtml = "";
|
||||
const appLinks = [
|
||||
{ title: "Habla", href: `https://habla.news/a/${bech32Id}` },
|
||||
{
|
||||
title: "noStrudel",
|
||||
href: `https://nostrudel.ninja/#/articles/${bech32Id}`,
|
||||
},
|
||||
{ title: "Coracle", href: `https://coracle.social/${bech32Id}` },
|
||||
{ title: "YakiHonne", href: `https://yakihonne.com/article/${bech32Id}` },
|
||||
];
|
||||
|
||||
for (const link of appLinks) {
|
||||
appLinksHtml += `<a href="${link.href}" target="_blank">${link.title}</a>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="open-with dropdown">
|
||||
<button class="dropdown-button">Open with Nostr app</button>
|
||||
<div class="dropdown-content">
|
||||
<a href="nostr:${bech32Id}" target="_blank">🔗 Nostr Link</a>
|
||||
<h4 class="title">Apps</h4>
|
||||
${appLinksHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function feedLinksHtml(profile: Profile) {
|
||||
return `<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
|
||||
}
|
||||
|
||||
function profileMetaHtml(profile: Profile) {
|
||||
return `
|
||||
<link rel="icon" href="${profile.avatarImageUrl}" type="image/png">
|
||||
<meta property="og:url" content="${profile.profileUrl}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="${profile.name} on Nostr">
|
||||
<meta property="og:description" content="">
|
||||
<meta property="og:image" content="${profile.ogImageUrl}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:domain" content="nostr.kosmos.org">
|
||||
<meta property="twitter:url" content="${profile.profileUrl}">
|
||||
<meta name="twitter:title" content="${profile.name} on Nostr">
|
||||
<meta name="twitter:description" content="">
|
||||
<meta name="twitter:image" content="${profile.ogImageUrl}">
|
||||
`;
|
||||
}
|
||||
|
||||
function articleMetaHtml(article: Article, profile: Profile) {
|
||||
const imageUrl = article.image || profile.ogImageUrl;
|
||||
|
||||
return `
|
||||
<link rel="icon" href="${profile.avatarImageUrl}" type="image/png">
|
||||
<meta property="og:url" content="${article.url}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="${article.title}">
|
||||
<meta property="og:description" content="${article.summary}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="${article.url}">
|
||||
<meta name="twitter:title" content="${article.title}">
|
||||
<meta name="twitter:description" content="${article.summary}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
`;
|
||||
}
|
||||
|
||||
40
ldap.ts
40
ldap.ts
@@ -1,26 +1,35 @@
|
||||
import { Client } from "ldapts";
|
||||
import { log } from "./log.ts";
|
||||
import config from "./config.ts";
|
||||
|
||||
const { ldap } = config;
|
||||
const client = new Client({ url: ldap.url });
|
||||
const { ldap, ldapEnabled } = config;
|
||||
|
||||
let client: Client;
|
||||
if (ldapEnabled) {
|
||||
client = new Client({ url: ldap.url as string });
|
||||
}
|
||||
|
||||
export async function lookupPubkeyByUsername(username: string) {
|
||||
let pubkey;
|
||||
|
||||
try {
|
||||
await client.bind(ldap.bindDN, ldap.password);
|
||||
await client.bind(ldap.bindDN as string, ldap.password as string);
|
||||
|
||||
const { searchEntries } = await client.search(ldap.searchDN, {
|
||||
const { searchEntries } = await client.search(ldap.searchDN as string, {
|
||||
filter: `(cn=${username})`,
|
||||
attributes: ["nostrKey"],
|
||||
});
|
||||
|
||||
pubkey = searchEntries[0]?.nostrKey;
|
||||
} catch (ex) {
|
||||
log(ex, "red");
|
||||
} finally {
|
||||
if (
|
||||
searchEntries.length > 0 &&
|
||||
typeof searchEntries[0].nostrKey === "string"
|
||||
) {
|
||||
pubkey = searchEntries[0].nostrKey;
|
||||
}
|
||||
|
||||
await client.unbind();
|
||||
} catch (e) {
|
||||
await client.unbind();
|
||||
throw e;
|
||||
}
|
||||
|
||||
return pubkey;
|
||||
@@ -30,18 +39,19 @@ export async function lookupUsernameByPubkey(pubkey: string) {
|
||||
let username;
|
||||
|
||||
try {
|
||||
await client.bind(ldap.bindDN, ldap.password);
|
||||
await client.bind(ldap.bindDN as string, ldap.password as string);
|
||||
|
||||
const { searchEntries } = await client.search(ldap.searchDN, {
|
||||
const { searchEntries } = await client.search(ldap.searchDN as string, {
|
||||
filter: `(nostrKey=${pubkey})`,
|
||||
attributes: ["cn"],
|
||||
});
|
||||
|
||||
username = searchEntries[0]?.cn;
|
||||
} catch (ex) {
|
||||
log(ex, "red");
|
||||
} finally {
|
||||
if (searchEntries.length > 0) {
|
||||
username = searchEntries[0].cn;
|
||||
}
|
||||
} catch (e) {
|
||||
await client.unbind();
|
||||
throw e;
|
||||
}
|
||||
|
||||
return username;
|
||||
|
||||
111
magick.ts
Normal file
111
magick.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import Profile from "./models/profile.ts";
|
||||
import { checkFileExists, getImageMagickCommand, runCommand } from "./utils.ts";
|
||||
import { log } from "./log.ts";
|
||||
|
||||
const tmpImgDir = "/tmp/substr/img";
|
||||
const magick = await getImageMagickCommand();
|
||||
if (!magick) {
|
||||
log("ImageMagick is not installed. Cannot generate preview images", "yellow")
|
||||
}
|
||||
|
||||
function createProfileImage(profile: Profile) {
|
||||
if (!magick || !profile.picture) return false;
|
||||
|
||||
const args = [
|
||||
profile.picture,
|
||||
'-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 createRoundedProfileImage(profile);
|
||||
|
||||
if (status && status.success) {
|
||||
const args = [
|
||||
`${tmpImgDir}/p-${profile.event.id}-rounded.png`,
|
||||
'-resize', '256x256',
|
||||
'-background', backgroundColor,
|
||||
'-gravity', 'center',
|
||||
'-extent', '1200x630',
|
||||
'-size', '1200x630',
|
||||
"-format", "png",
|
||||
ogImagePath
|
||||
];
|
||||
|
||||
return runCommand(magick, args);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const ogImagePath = `${tmpImgDir}/og-p-${profile.event.id}.png`;
|
||||
const backgroundColor = "#333333";
|
||||
const fileExists = await checkFileExists(ogImagePath);
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
73
main.ts
73
main.ts
@@ -1,73 +0,0 @@
|
||||
import { Application, Router } from "@oak/oak";
|
||||
import { log } from "./log.ts";
|
||||
import naddrHandler from "./handlers/naddr.ts";
|
||||
import nprofileHandler from "./handlers/nprofile.ts";
|
||||
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";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get("/:path", async (ctx: ctx) => {
|
||||
const { path } = ctx.params;
|
||||
|
||||
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 {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
|
||||
log(
|
||||
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
|
||||
"gray",
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/:user/:kind.atom", async (ctx: ctx) => {
|
||||
const { user } = ctx.params;
|
||||
|
||||
if (user.startsWith("@") || user.startsWith("~") || kind === "articles") {
|
||||
await userAtomFeedHandler(ctx);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
|
||||
log(
|
||||
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
|
||||
"gray",
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/:user/:identifier", async (ctx: ctx) => {
|
||||
const { user } = ctx.params;
|
||||
|
||||
if (user.startsWith("@") || user.startsWith("~")) {
|
||||
await userEventHandler(ctx);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
|
||||
log(
|
||||
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
|
||||
"gray",
|
||||
);
|
||||
});
|
||||
|
||||
const app = new Application();
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
|
||||
const PORT = 8000;
|
||||
app.listen({ port: PORT });
|
||||
|
||||
console.log(`App listening on http://localhost:${PORT}`);
|
||||
@@ -1,6 +1,8 @@
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { NEvent } from "../nostr.ts";
|
||||
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 config from "../config.ts";
|
||||
|
||||
export default class Article {
|
||||
event: NEvent;
|
||||
@@ -14,11 +16,24 @@ export default class Article {
|
||||
return tag ? tag[1] : "";
|
||||
}
|
||||
|
||||
get isDraft(): boolean {
|
||||
return this.event.kind === 30024;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return `${config.base_url}/${this.naddr}`;
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
const tag = this.event.tags.find((t) => t[0] === "title");
|
||||
return tag ? tag[1] : "Untitled";
|
||||
}
|
||||
|
||||
get image(): string | undefined {
|
||||
const tag = this.event.tags.find((t) => t[0] === "image");
|
||||
return tag ? tag[1] : undefined;
|
||||
}
|
||||
|
||||
get summary(): string {
|
||||
const tag = this.event.tags.find((t) => t[0] === "summary");
|
||||
return tag ? tag[1] : "";
|
||||
@@ -33,8 +48,12 @@ export default class Article {
|
||||
return this.event.created_at;
|
||||
}
|
||||
|
||||
get html(): string {
|
||||
return renderMarkdown(this.event.content);
|
||||
get content(): string {
|
||||
return this.event.content;
|
||||
}
|
||||
|
||||
get isDeleted(): boolean {
|
||||
return !!this.event.tags.find((t) => t[0] === "deleted");
|
||||
}
|
||||
|
||||
get naddr(): string {
|
||||
@@ -42,6 +61,14 @@ export default class Article {
|
||||
identifier: this.identifier,
|
||||
pubkey: this.event.pubkey,
|
||||
kind: this.event.kind,
|
||||
relays: [config.relay_urls[0]],
|
||||
});
|
||||
}
|
||||
|
||||
async buildContentHtml(): Promise<string> {
|
||||
let md = this.event.content.trim();
|
||||
md = md.replace(`# ${this.title}\n`, "");
|
||||
md = await replaceNostrUris(md);
|
||||
return renderMarkdown(md);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { NEvent } from "../nostr.ts";
|
||||
import { nip19, NostrEvent as NEvent } from "@nostr/tools";
|
||||
import { verifyNip05Address } from "../nostr.ts";
|
||||
import { getImageMagickCommand } from "../utils.ts";
|
||||
import config from "../config.ts";
|
||||
|
||||
const magick = await getImageMagickCommand();
|
||||
|
||||
export interface ProfileData {
|
||||
name: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
displayName?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
@@ -10,8 +16,8 @@ export interface ProfileData {
|
||||
}
|
||||
|
||||
export default class Profile {
|
||||
event: NEvent;
|
||||
private data: ProfileData;
|
||||
event: NEvent;
|
||||
username?: string;
|
||||
|
||||
constructor(event: NEvent, username?: string) {
|
||||
@@ -25,7 +31,8 @@ export default class Profile {
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.data.name || "Anonymous";
|
||||
return this.data.display_name || this.data.displayName ||
|
||||
this.data.name || "Anonymous";
|
||||
}
|
||||
|
||||
get about(): string {
|
||||
@@ -37,7 +44,7 @@ export default class Profile {
|
||||
}
|
||||
|
||||
get nip05(): string | undefined {
|
||||
return this.data.nip05;
|
||||
return this.data.nip05?.replace("_@", "");
|
||||
}
|
||||
|
||||
get lud16(): string | undefined {
|
||||
@@ -51,4 +58,32 @@ export default class Profile {
|
||||
get npub(): string {
|
||||
return nip19.npubEncode(this.pubkey);
|
||||
}
|
||||
|
||||
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`;
|
||||
} else {
|
||||
return this.picture || "";
|
||||
}
|
||||
}
|
||||
|
||||
verifyNip05(): Promise<boolean> {
|
||||
if (typeof this.data.nip05 !== "undefined") {
|
||||
return verifyNip05Address(this.data.nip05, this.pubkey);
|
||||
} else {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
135
nostr.ts
135
nostr.ts
@@ -1,48 +1,141 @@
|
||||
import { NRelay1 } from "@nostrify/nostrify";
|
||||
import { NostrEvent, NostrFilter, NPool, NRelay1 } from "@nostrify/nostrify";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { lookupUsernameByPubkey } from "./directory.ts";
|
||||
import config from "./config.ts";
|
||||
import Article from "./models/article.ts";
|
||||
|
||||
export interface NEvent {
|
||||
content: string;
|
||||
created_at: number;
|
||||
id: string;
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
sig: string;
|
||||
tags: Array<[string, string, string?]>;
|
||||
const relayPool = new NPool({
|
||||
open: (url) => new NRelay1(url),
|
||||
// deno-lint-ignore require-await
|
||||
reqRouter: async (filters) =>
|
||||
new Map(
|
||||
config.relay_urls.map((url) => [url, filters]),
|
||||
),
|
||||
// deno-lint-ignore require-await
|
||||
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 const relay = new NRelay1(config.home_relay_url);
|
||||
|
||||
export async function fetchReplaceableEvent(
|
||||
pubkey: string,
|
||||
identifier: string,
|
||||
) {
|
||||
const events = await relay.query([{
|
||||
let events = await fetchWithTimeout([{
|
||||
authors: [pubkey],
|
||||
kinds: [30023],
|
||||
"#d": [identifier],
|
||||
limit: 1,
|
||||
}]);
|
||||
}]) as NostrEvent[];
|
||||
|
||||
return events.length > 0 ? events[0] : null;
|
||||
if (events.length > 0) {
|
||||
return events[0];
|
||||
} else {
|
||||
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 relay.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 relay.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:/, "");
|
||||
|
||||
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,
|
||||
): Promise<boolean> {
|
||||
const [username, host] = address.split("@");
|
||||
const url = `https://${host}/.well-known/nostr.json?name=${username}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.status === 404 || !res.ok) return false;
|
||||
const data = await res.json();
|
||||
|
||||
return data.names && data.names[username] === pubkey;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
120
server.ts
Normal file
120
server.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Application, Router, send } from "@oak/oak";
|
||||
import { createSubtrTmpDirectories } from "./utils.ts";
|
||||
import config, { ensureNecessaryConfigs } from "./config.ts";
|
||||
import naddrHandler from "./handlers/naddr.ts";
|
||||
import nprofileHandler from "./handlers/nprofile.ts";
|
||||
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 {
|
||||
badGatewayHandler,
|
||||
internalServerErrorHandler,
|
||||
notFoundHandler,
|
||||
} from "./handlers/errors.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get("/:path", async (ctx) => {
|
||||
const path = ctx.state.path = ctx.params.path;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:username/:kind.atom", async (ctx) => {
|
||||
ctx.state.username = ctx.params.username.replace(/^(@|~)/, "");
|
||||
ctx.state.kind = ctx.params.kind;
|
||||
|
||||
if (
|
||||
ctx.state.kind === "articles" &&
|
||||
(ctx.params.username.startsWith("@") ||
|
||||
ctx.params.username.startsWith("~"))
|
||||
) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:username/:identifier", async (ctx) => {
|
||||
const username = ctx.state.username = ctx.params.username;
|
||||
ctx.state.identifier = ctx.params.identifier;
|
||||
|
||||
if (username.startsWith("@") || username.startsWith("~")) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/assets/:path*", async (ctx) => {
|
||||
try {
|
||||
let filePath = ctx.params.path || "";
|
||||
let root: string;
|
||||
|
||||
if (filePath.startsWith("g/img/")) {
|
||||
filePath = filePath.replace(/^g\//, "");
|
||||
root = "/tmp/substr";
|
||||
} else {
|
||||
root = `${import.meta.dirname}/assets`;
|
||||
}
|
||||
|
||||
await send(ctx, filePath, { root });
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === "NotFoundError") {
|
||||
notFoundHandler(ctx);
|
||||
} else {
|
||||
console.error(e);
|
||||
badGatewayHandler(ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ensureNecessaryConfigs();
|
||||
|
||||
await createSubtrTmpDirectories();
|
||||
|
||||
const app = new Application();
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
|
||||
app.listen({ port: config.port });
|
||||
|
||||
console.log(`App listening on http://localhost:${config.port}`);
|
||||
17
tests/feeds_test.ts
Normal file
17
tests/feeds_test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it } from "@std/testing/bdd";
|
||||
import { expect } from "@std/expect";
|
||||
import { cleanContentHtml } from "../feeds.ts";
|
||||
|
||||
describe("Feeds", () => {
|
||||
describe("#cleanContentHtml", () => {
|
||||
const articleHtml = Deno.readTextFileSync(
|
||||
"tests/fixtures/gfm-content-1.html",
|
||||
);
|
||||
|
||||
it("removes the anchor links for headlines", () => {
|
||||
const cleanHtml = cleanContentHtml(articleHtml);
|
||||
expect(cleanHtml).not.toMatch(/<a class="anchor" aria-hidden="true"/);
|
||||
expect(cleanHtml).not.toMatch(/<svg class="octicon octicon-link"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
tests/fixtures/article-deleted.json
vendored
Normal file
9
tests/fixtures/article-deleted.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"content": "",
|
||||
"created_at": 1716761766,
|
||||
"id": "ae83c3e23e11db5fd0e6dacaece38847451e81d1429e4182a0cadd409bdce30f",
|
||||
"kind": 30023,
|
||||
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
||||
"sig": "6c21eba5324af302dfbfb9dadfc2d067646a3594ffed02d2528cca8ee0f7c16b9ffb3dc640420304882be94e789d93191d830108ac52d57b2e72445025e433b2",
|
||||
"tags": [["d", "66674915"], ["deleted"]]
|
||||
}
|
||||
165
tests/fixtures/gfm-content-1.html
vendored
Normal file
165
tests/fixtures/gfm-content-1.html
vendored
Normal file
@@ -0,0 +1,165 @@
|
||||
<p>
|
||||
This week, it finally happened: I still had a Lightning channel open with a
|
||||
node that hadn't been online for the better part of a year now, so I decided
|
||||
to close the channel unilaterally. But force-closing a channel means you have
|
||||
to broadcast the latest commitment transaction, the pre-set fee of which was
|
||||
only ~1 sat/vB for this one.
|
||||
</p>
|
||||
<p>
|
||||
With LND, if the channel is created as an <a
|
||||
href="https://lightning.engineering/posts/2021-01-28-lnd-v0.12/"
|
||||
rel="noopener noreferrer"
|
||||
>anchor channel</a> (by default only since version 0.12), then the commitment
|
||||
transaction contains small extra outputs (currently 330 sats), which let
|
||||
either channel partner spend one of them into a child transaction that can be
|
||||
created with higher fees to pay for the parent transaction (CPFP). LND even
|
||||
has a built-in command for that: <code>lncli wallet bumpclosefee</code>
|
||||
</p>
|
||||
<p>
|
||||
However, this channel was created in the old-school way, and was thus stuck
|
||||
with its low fee. In fact, even the local bitcoin node refused to accept the
|
||||
transaction into its own mempool, so the bitcoin p2p network didn't even know
|
||||
it existed. So how do we get out of this pickle?
|
||||
</p>
|
||||
<h2 id="the-solution">
|
||||
<a class="anchor" aria-hidden="true" tabindex="-1" href="#the-solution"><svg
|
||||
class="octicon octicon-link"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
|
||||
>
|
||||
</path>
|
||||
</svg></a>The solution
|
||||
</h2>
|
||||
<p>
|
||||
Enter the <a
|
||||
href="https://mempool.space/accelerator"
|
||||
rel="noopener noreferrer"
|
||||
>mempool.space Accelerator</a>. It is essentially an automated way to create
|
||||
agreements with various mining pools to mine your low-fee transaction in
|
||||
exchange for an out-of-band payment. Mempool.space coordinates these
|
||||
agreements and out-of-band payments with miners and gets a share from the
|
||||
overall fee for that.
|
||||
</p>
|
||||
<p>
|
||||
Now, if you're in the same situation as I was, you might search for the ID of
|
||||
your closing transaction and find that mempool.space cannot find it. Remember
|
||||
how the local bitcoin node (with mostly default settings) didn't accept it in
|
||||
the first place?
|
||||
</p>
|
||||
<h3 id="1-get-the-transaction-to-be-broadcast">
|
||||
<a
|
||||
class="anchor"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
href="#1-get-the-transaction-to-be-broadcast"
|
||||
><svg
|
||||
class="octicon octicon-link"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
|
||||
>
|
||||
</path>
|
||||
</svg></a>1. Get the transaction to be broadcast
|
||||
</h3>
|
||||
<p>In your <code>bitcoin.conf</code>, add the following line:</p>
|
||||
<pre><code>minrelaytxfee=0</code></pre><p>
|
||||
This sets the minimum fee to 0, meaning it will accept and broadcast your
|
||||
transactions, no matter how low the fee is. Restart <code>bitcoind</code> and
|
||||
wait a little bit. LND will retry broadcasting the closing transaction every
|
||||
minute or so until it succeeds. At some point you should be able to find it on
|
||||
mempool.space.
|
||||
</p>
|
||||
<h3 id="2-use-the-accelerator-to-confirm-it">
|
||||
<a
|
||||
class="anchor"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
href="#2-use-the-accelerator-to-confirm-it"
|
||||
><svg
|
||||
class="octicon octicon-link"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
|
||||
>
|
||||
</path>
|
||||
</svg></a>2. Use the Accelerator to confirm it
|
||||
</h3>
|
||||
<p>
|
||||
Once you can see the transaction on <a
|
||||
href="https://mempool.space"
|
||||
rel="noopener noreferrer"
|
||||
>mempool.space</a>, you can just click the "Accelerate" button next to the
|
||||
ETA. This will bring you to a page that shows you the estimated share of
|
||||
miners that will include your transaction in their blocks, as well as some
|
||||
acceleration fee options for various transaction fee levels, which you can pay
|
||||
for via the Lightning Network, of course.
|
||||
</p>
|
||||
<p>
|
||||
If you haven't looked into this service before (which I had), then the fees
|
||||
might be a bit of a surprise to you. This thing is <strong>not</strong> cheap!
|
||||
Bumping my fee from 1 sat/vB to <del>9 sats/vB cost a whopping 51,500 sats
|
||||
(</del>31 USD that day). Bumping it higher only seemed to add the difference
|
||||
in the transaction fee itself, so the service seems to have cost a flat 50K
|
||||
sats at the time.
|
||||
</p>
|
||||
<p>
|
||||
Unfortunately, this channel wasn't particularly large, so the acceleration fee
|
||||
amounted to ~9% of my remaining channel balance. But 91% of something is
|
||||
better than 100% of nothing, so I actually felt pretty good about it.
|
||||
</p>
|
||||
<p>Next, you will see something like this:</p>
|
||||
<p>
|
||||
<img
|
||||
src="https://image.nostr.build/76151cc2ae06a93a8fcd97102bf4fa63541f8f3bd19800b96ff1070c9450945c.png"
|
||||
alt="Screenshot of an accelerated transaction on mempool.space"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
Time to lean back and let the miners work for you. In my case, the ETA was
|
||||
eerily precise. It told me that it would take ~56 minutes to confirm the
|
||||
transaction, and almost exactly an hour later it was mined.
|
||||
</p>
|
||||
<h3 id="3-wait">
|
||||
<a class="anchor" aria-hidden="true" tabindex="-1" href="#3-wait"><svg
|
||||
class="octicon octicon-link"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
|
||||
>
|
||||
</path>
|
||||
</svg></a>3. Wait
|
||||
</h3>
|
||||
<p>
|
||||
Now that our transaction is confirmed, our channel is not closed immediately,
|
||||
of course. The <a
|
||||
href="https://docs.lightning.engineering/the-lightning-network/multihop-payments/hash-time-lock-contract-htlc"
|
||||
rel="noopener noreferrer"
|
||||
>time lock of the HTLC</a> protects our channel partner from us broadcasting
|
||||
an old channel state in which our balance might be higher than in the latest
|
||||
state.
|
||||
</p>
|
||||
<p>
|
||||
In my case, it was set to 144 blocks, i.e. ~24 hours. So I checked back the
|
||||
next day, et voilá: channel closed and balance restored. 🥳
|
||||
</p>
|
||||
@@ -1,17 +1,18 @@
|
||||
import { beforeAll, describe, it } from "@std/testing/bdd";
|
||||
import { expect } from "@std/expect";
|
||||
import { NEvent } from "../../nostr.ts";
|
||||
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", () => {
|
||||
@@ -20,6 +21,18 @@ describe("Article", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#isDraft", () => {
|
||||
it("is false when kind is 30023", () => {
|
||||
expect(article.isDraft).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when kind is 30024", () => {
|
||||
article.event.kind = 30024;
|
||||
expect(article.isDraft).toBe(true);
|
||||
article.event.kind = 30023;
|
||||
});
|
||||
});
|
||||
|
||||
describe("#title", () => {
|
||||
it("returns the content of the 'title' tag", () => {
|
||||
expect(article.title).toMatch(
|
||||
@@ -46,17 +59,25 @@ describe("Article", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#html", () => {
|
||||
it("returns a rendered HTML version of the 'content'", () => {
|
||||
expect(article.html).toMatch(/<h2 id="the-solution">/);
|
||||
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 bech32 addressable event ID", () => {
|
||||
expect(article.naddr).toEqual(
|
||||
"naddr1qvzqqqr4gupzq8meqkx80g3yuklzymy0qfx2ekk56aqc2ht4ak03z3em4r4cdcwtqqxnzdejxcenjd3hx5urgwp4676hkz",
|
||||
it("returns a bech32 addressable event ID", () => {
|
||||
expect(article.naddr).toMatch(
|
||||
/naddr1qvzqqqr4gupzq8meqkx80g3yuklzymy0qf/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#buildContentHtml", () => {
|
||||
it("returns a rendered HTML version of the 'content'", async () => {
|
||||
const html = await article.buildContentHtml();
|
||||
expect(html).toMatch(/<h2 id="the-solution">/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeAll, describe, it } from "@std/testing/bdd";
|
||||
import { expect } from "@std/expect";
|
||||
import { NEvent } from "../../nostr.ts";
|
||||
import { NostrEvent as NEvent } from "@nostrify/nostrify";
|
||||
import Profile from "../../models/profile.ts";
|
||||
|
||||
describe("Profile", () => {
|
||||
|
||||
2
users.yaml.sample
Normal file
2
users.yaml.sample
Normal file
@@ -0,0 +1,2 @@
|
||||
_: b3e1b7c0ef48294bd856203bfd460625de95d3afb894e5f09b14cd1f0e7097cf
|
||||
bitcoincore: 47750177bb6bb113784e4973f6b2e3dd27ef1eff227d6e38d0046d618969e41a
|
||||
54
utils.ts
Normal file
54
utils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export async function checkFileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await Deno.lstat(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
return false;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkExecutableExists(name: string): Promise<boolean> {
|
||||
try {
|
||||
const command = new Deno.Command("which", { args: [name] });
|
||||
const { success } = await command.output();
|
||||
return success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSubtrTmpDirectories(): Promise<void> {
|
||||
const dirs = [
|
||||
"/tmp/substr/img/",
|
||||
];
|
||||
|
||||
for (const path of dirs) {
|
||||
await Deno.mkdir(path, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCommand(cmd: string, args: string[]) {
|
||||
const command = new Deno.Command(cmd, { args });
|
||||
const { code, success, stdout, stderr } = await command.output();
|
||||
|
||||
if (code === 1) {
|
||||
console.log(new TextDecoder().decode(stdout));
|
||||
console.log(new TextDecoder().decode(stderr));
|
||||
}
|
||||
|
||||
return { success, stdout, stderr };
|
||||
}
|
||||
|
||||
export async function getImageMagickCommand(): Promise<string | undefined> {
|
||||
if (await checkExecutableExists("magick")) {
|
||||
return "magick";
|
||||
} else if (await checkExecutableExists("convert")) {
|
||||
return "convert";
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user