Compare commits
54 Commits
849410bb64
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
1e081c83e5
|
|||
|
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
|
@@ -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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
.env
|
||||
users.yaml
|
||||
substr
|
||||
|
||||
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;
|
||||
}
|
||||
244
assets/css/layout.css
Normal file
244
assets/css/layout.css
Normal file
@@ -0,0 +1,244 @@
|
||||
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;
|
||||
}
|
||||
|
||||
main article footer .actions {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
main article footer p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.nip05 .verified,
|
||||
.nip05 .not-verified {
|
||||
margin-left: 0.3rem;
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 5px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
|
||||
.dropdown {
|
||||
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;
|
||||
}
|
||||
}
|
||||
133
assets/css/themes/default-light.css
Normal file
133
assets/css/themes/default-light.css
Normal file
@@ -0,0 +1,133 @@
|
||||
@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);
|
||||
}
|
||||
|
||||
main article footer a,
|
||||
main article footer a:visited {
|
||||
text-decoration: none;
|
||||
color: var(--text-color-discreet);
|
||||
}
|
||||
|
||||
main article footer a:hover,
|
||||
main article footer a:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nip05 .verified {
|
||||
background-color: #e8e3da;
|
||||
color: #027739;
|
||||
}
|
||||
|
||||
.nip05 .not-verified {
|
||||
background-color: #e8e3da;
|
||||
color: #770202;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
|
||||
.dropdown {
|
||||
--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.
85
config.ts
85
config.ts
@@ -1,17 +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 = {
|
||||
port: Deno.env.get("PORT") || 8000,
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-net --allow-read --allow-env --watch server.ts"
|
||||
"dev": "deno run --allow-all --watch server.ts",
|
||||
"server": "deno run --allow-all server.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@deno/gfm": "jsr:@deno/gfm@^0.9.0",
|
||||
@@ -10,6 +11,12 @@
|
||||
"@std/dotenv": "jsr:@std/dotenv@^0.225.2",
|
||||
"@std/expect": "jsr:@std/expect@^1.0.5",
|
||||
"@std/testing": "jsr:@std/testing@^1.0.3",
|
||||
"@std/yaml": "jsr:@std/yaml@^1.0.5",
|
||||
"ldapts": "npm:ldapts@^7.2.1"
|
||||
},
|
||||
"fmt": {
|
||||
"exclude": [
|
||||
"magick.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
6
deno.lock
generated
6
deno.lock
generated
@@ -27,6 +27,8 @@
|
||||
"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/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",
|
||||
@@ -186,6 +188,9 @@
|
||||
"jsr:@std/internal",
|
||||
"jsr:@std/path@^1.0.6"
|
||||
]
|
||||
},
|
||||
"@std/yaml@1.0.5": {
|
||||
"integrity": "71ba3d334305ee2149391931508b2c293a8490f94a337eef3a09cade1a2a2742"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
@@ -490,6 +495,7 @@
|
||||
"jsr:@std/dotenv@~0.225.2",
|
||||
"jsr:@std/expect@^1.0.5",
|
||||
"jsr:@std/testing@^1.0.3",
|
||||
"jsr:@std/yaml@^1.0.5",
|
||||
"npm:ldapts@^7.2.1"
|
||||
]
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
feeds.ts
32
feeds.ts
@@ -2,30 +2,38 @@ 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>
|
||||
@@ -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,26 @@
|
||||
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;
|
||||
console.log(data);
|
||||
} 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);
|
||||
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,33 @@
|
||||
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);
|
||||
|
||||
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";
|
||||
const articles = await fetchArticlesByAuthor(pubkey, 210, ctx.state.tags);
|
||||
|
||||
const html = await profilePageHtml(profile, articles);
|
||||
generateOgProfileImage(profile);
|
||||
|
||||
ctx.response.body = html;
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
316
html.ts
316
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,88 @@ 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 });
|
||||
}
|
||||
|
||||
function articleTagsHtml(article: Article, profile: Profile): string {
|
||||
if (article.tags.length === 0) return "";
|
||||
|
||||
const tags = article.tags.map((tag) => {
|
||||
return `<a href="/@${profile.username}?tags=${tag}">${tag}</a>`;
|
||||
});
|
||||
|
||||
return `Tags: ${tags.join(", ")}</p>\n`;
|
||||
}
|
||||
|
||||
export async function articleHtml(
|
||||
article: Article,
|
||||
profile: Profile,
|
||||
): 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">
|
||||
${draftLabel}
|
||||
<h1>${titleHtml(article.title)}</h1>
|
||||
<div class="meta">
|
||||
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
|
||||
<span class="content">
|
||||
<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>
|
||||
<div class="actions">
|
||||
${openWithNostrAppHtml(article.naddr)}
|
||||
</div>
|
||||
<p class="tags">
|
||||
${articleTagsHtml(article, profile)}
|
||||
</p>
|
||||
</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,8 +125,40 @@ 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">
|
||||
@@ -193,14 +166,93 @@ export function profilePageHtml(profile: Profile, articles: Article[]) {
|
||||
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
|
||||
<div class="bio">
|
||||
<h1>${profile.name}</h1>
|
||||
${nip05Html}
|
||||
<p class="about">
|
||||
${profile.about}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
${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 `
|
||||
<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 `
|
||||
<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;
|
||||
|
||||
69
magick.ts
Normal file
69
magick.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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 createRoundedImage(profile: Profile) {
|
||||
if (!magick || !profile.picture) return false;
|
||||
|
||||
const args = [
|
||||
profile.picture,
|
||||
'-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);
|
||||
}
|
||||
|
||||
async function createOgImage(profile: Profile, ogImagePath: string, backgroundColor: string) {
|
||||
if (!magick) return false;
|
||||
|
||||
const status = await createRoundedImage(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 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) {
|
||||
const status = await createOgImage(profile, ogImagePath, backgroundColor);
|
||||
if (status && status.success) {
|
||||
log(`Created OG image for ${profile.username}: ${ogImagePath}`, "blue")
|
||||
} else {
|
||||
log(`Could not create OG image for ${profile.username}`, "yellow")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,16 +16,36 @@ 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] : "";
|
||||
}
|
||||
|
||||
get tags(): string[] {
|
||||
return this.event.tags
|
||||
.filter((t) => t[0] === "t")
|
||||
.filter((t) => t[1] !== "")
|
||||
.map((t) => t[1]);
|
||||
}
|
||||
|
||||
get publishedAt(): number {
|
||||
const tag = this.event.tags.find((t) => t[0] === "published_at");
|
||||
return tag ? parseInt(tag[1]) : this.event.created_at;
|
||||
@@ -33,8 +55,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 +68,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,24 @@ export default class Profile {
|
||||
get npub(): string {
|
||||
return nip19.npubEncode(this.pubkey);
|
||||
}
|
||||
|
||||
get profileUrl(): string {
|
||||
return `${config.base_url}/@${this.username}`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
147
nostr.ts
147
nostr.ts
@@ -1,48 +1,153 @@
|
||||
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) => [],
|
||||
});
|
||||
|
||||
export async function fetchWithTimeout(filters: NostrFilter[]) {
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("relay timeout")), config.query_timeout)
|
||||
);
|
||||
const eventsPromise = relayPool.query(filters);
|
||||
|
||||
const events = await Promise.race([eventsPromise, timeoutPromise]);
|
||||
return events;
|
||||
}
|
||||
|
||||
export 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 function createTagList(
|
||||
articles: Article[],
|
||||
): Record<string, number> {
|
||||
return articles.flatMap((a) => a.tags).reduce((acc, tag) => {
|
||||
acc[tag] = (acc[tag] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
export async function fetchArticlesByAuthor(
|
||||
pubkey: string,
|
||||
limit: number = 10,
|
||||
tags?: string[],
|
||||
) {
|
||||
const filter = {
|
||||
authors: [pubkey],
|
||||
kinds: [30023],
|
||||
limit: 10,
|
||||
}]);
|
||||
limit: limit,
|
||||
};
|
||||
if (typeof tags !== "undefined") filter["#t"] = tags;
|
||||
|
||||
return events;
|
||||
const events = await fetchWithTimeout([filter]) as NostrEvent[];
|
||||
const articles = events.map((a) => new Article(a));
|
||||
|
||||
return articles
|
||||
.filter((a) => !a.isDeleted)
|
||||
.filter((a) => a.content.trim() !== "")
|
||||
.sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
.slice(0, limit); // The limit seems to apply per relay, not per pool query
|
||||
}
|
||||
|
||||
export async function fetchProfileEvent(pubkey: string) {
|
||||
const events = await 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;
|
||||
}
|
||||
}
|
||||
|
||||
136
server.ts
136
server.ts
@@ -1,68 +1,120 @@
|
||||
import { Application, Router } from "@oak/oak";
|
||||
import { log } from "./log.ts";
|
||||
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: ctx) => {
|
||||
const { path } = ctx.params;
|
||||
router.get("/:path", async (ctx) => {
|
||||
const path = ctx.state.path = ctx.params.path;
|
||||
|
||||
if (path.startsWith("naddr")) {
|
||||
await naddrHandler(ctx);
|
||||
} else if (path.startsWith("nprofile")) {
|
||||
await nprofileHandler(ctx);
|
||||
} else if (path.startsWith("npub")) {
|
||||
await npubHandler(ctx);
|
||||
} else if (path.startsWith("@") || path.startsWith("~")) {
|
||||
await userProfileHandler(ctx);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
try {
|
||||
if (path.startsWith("naddr")) {
|
||||
await naddrHandler(ctx);
|
||||
} else if (path.startsWith("nprofile")) {
|
||||
await nprofileHandler(ctx);
|
||||
} else if (path.startsWith("npub")) {
|
||||
await npubHandler(ctx);
|
||||
} else if (path.startsWith("@") || path.startsWith("~")) {
|
||||
const tags = ctx.request.url.searchParams.get("tags");
|
||||
if (typeof tags === "string" && tags !== "") {
|
||||
ctx.state.tags = tags.split(",");
|
||||
}
|
||||
await userProfileHandler(ctx);
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
|
||||
badGatewayHandler(ctx);
|
||||
} else {
|
||||
internalServerErrorHandler(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
|
||||
"gray",
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/:user/:kind.atom", async (ctx: ctx) => {
|
||||
const { user } = ctx.params;
|
||||
router.get("/:username/:kind.atom", async (ctx) => {
|
||||
ctx.state.username = ctx.params.username.replace(/^(@|~)/, "");
|
||||
ctx.state.kind = ctx.params.kind;
|
||||
|
||||
if (user.startsWith("@") || user.startsWith("~") || kind === "articles") {
|
||||
await userAtomFeedHandler(ctx);
|
||||
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 {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
|
||||
log(
|
||||
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
|
||||
"gray",
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/:user/:identifier", async (ctx: ctx) => {
|
||||
const { user } = ctx.params;
|
||||
router.get("/:username/:identifier", async (ctx) => {
|
||||
const username = ctx.state.username = ctx.params.username;
|
||||
ctx.state.identifier = ctx.params.identifier;
|
||||
|
||||
if (user.startsWith("@") || user.startsWith("~")) {
|
||||
await userEventHandler(ctx);
|
||||
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 {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
|
||||
log(
|
||||
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
|
||||
"gray",
|
||||
);
|
||||
});
|
||||
|
||||
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 = `${Deno.cwd()}/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());
|
||||
|
||||
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(
|
||||
@@ -34,6 +47,16 @@ describe("Article", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#tags", () => {
|
||||
it("returns a flattened tag list", () => {
|
||||
expect(article.tags).toEqual([
|
||||
"lightning",
|
||||
"lightning network",
|
||||
"howto",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#publishedAt", () => {
|
||||
it("returns the value of the first 'published_at' tag", () => {
|
||||
expect(article.publishedAt).toEqual(1726402055);
|
||||
@@ -46,17 +69,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", () => {
|
||||
|
||||
27
tests/nostr_test.ts
Normal file
27
tests/nostr_test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// import { describe, it } from "@std/testing/bdd";
|
||||
// import { stub } from "@std/testing/mock";
|
||||
// import { expect } from "@std/expect";
|
||||
// import { NostrEvent, NostrFilter } from "@nostrify/nostrify";
|
||||
// import * as nostr from "../nostr.ts";
|
||||
//
|
||||
// async function fetchWithTimeout(filters: NostrFilter[]) {
|
||||
// console.log("================")
|
||||
// const events = [];
|
||||
// const fixtures = [ "article-1.json", "article-deleted.json" ]
|
||||
// for (const filename of fixtures) {
|
||||
// const event = JSON.parse(Deno.readTextFileSync(`tests/fixtures/${filename}`));
|
||||
// events.push(event);
|
||||
// }
|
||||
// return Promise.resolve(events);
|
||||
// }
|
||||
//
|
||||
// describe("Nostr", () => {
|
||||
// describe("#fetchArticlesByAuthor", () => {
|
||||
// it("removes the anchor links for headlines", async () => {
|
||||
// stub(nostr, "fetchArticlesByAuthor");
|
||||
//
|
||||
// const articles = await nostr.fetchArticlesByAuthor("123456abcdef");
|
||||
// expect(articles.length).toEqual(2);
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
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