mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
Merge branch 'main' into translate-status
This commit is contained in:
commit
2ca421bef2
22 changed files with 552 additions and 133 deletions
|
|
@ -1,8 +1,12 @@
|
|||
FROM denoland/deno:1.44.2
|
||||
EXPOSE 4036
|
||||
EXPOSE 5000
|
||||
|
||||
ENV PORT 5000
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p data && chown -R deno data
|
||||
USER deno
|
||||
COPY . .
|
||||
RUN deno cache src/server.ts
|
||||
RUN apt-get update && apt-get install -y unzip curl
|
||||
RUN deno task soapbox
|
||||
CMD deno task start
|
||||
|
|
|
|||
32
deno.json
32
deno.json
|
|
@ -15,14 +15,22 @@
|
|||
"admin:event": "deno run -A scripts/admin-event.ts",
|
||||
"admin:role": "deno run -A scripts/admin-role.ts",
|
||||
"setup": "deno run -A scripts/setup.ts",
|
||||
"setup:kind0": "deno run -A scripts/setup-kind0.ts",
|
||||
"stats:recompute": "deno run -A scripts/stats-recompute.ts",
|
||||
"soapbox": "curl -O https://dl.soapbox.pub/main/soapbox.zip && mkdir -p public && mv soapbox.zip public/ && cd public/ && unzip -o soapbox.zip && rm soapbox.zip",
|
||||
"trends": "deno run -A scripts/trends.ts",
|
||||
"clean:deps": "deno cache --reload src/app.ts",
|
||||
"db:populate-search": "deno run -A scripts/db-populate-search.ts"
|
||||
},
|
||||
"unstable": ["cron", "ffi", "kv", "worker-options"],
|
||||
"exclude": ["./public"],
|
||||
"unstable": [
|
||||
"cron",
|
||||
"ffi",
|
||||
"kv",
|
||||
"worker-options"
|
||||
],
|
||||
"exclude": [
|
||||
"./public"
|
||||
],
|
||||
"imports": {
|
||||
"@/": "./src/",
|
||||
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
||||
|
|
@ -67,10 +75,10 @@
|
|||
"linkify-string": "npm:linkify-string@^4.1.1",
|
||||
"linkifyjs": "npm:linkifyjs@^4.1.1",
|
||||
"lru-cache": "npm:lru-cache@^10.2.2",
|
||||
"nostr-relaypool": "npm:nostr-relaypool2@0.6.34",
|
||||
"nostr-tools": "npm:nostr-tools@2.5.1",
|
||||
"nostr-wasm": "npm:nostr-wasm@^0.1.0",
|
||||
"path-to-regexp": "npm:path-to-regexp@^7.1.0",
|
||||
"png-to-ico": "npm:png-to-ico@^2.1.8",
|
||||
"postgres": "https://gitlab.com/soapbox-pub/postgres.js/-/raw/e79d7d2039446fbf7a37d4eca0d17e94a94b8b53/deno/mod.js",
|
||||
"prom-client": "npm:prom-client@^15.1.2",
|
||||
"question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts",
|
||||
|
|
@ -82,14 +90,24 @@
|
|||
"~/fixtures/": "./fixtures/"
|
||||
},
|
||||
"lint": {
|
||||
"include": ["src/", "scripts/"],
|
||||
"include": [
|
||||
"src/",
|
||||
"scripts/"
|
||||
],
|
||||
"rules": {
|
||||
"tags": ["recommended"],
|
||||
"exclude": ["no-explicit-any"]
|
||||
"tags": [
|
||||
"recommended"
|
||||
],
|
||||
"exclude": [
|
||||
"no-explicit-any"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"include": ["src/", "scripts/"],
|
||||
"include": [
|
||||
"src/",
|
||||
"scripts/"
|
||||
],
|
||||
"useTabs": false,
|
||||
"lineWidth": 120,
|
||||
"indentWidth": 2,
|
||||
|
|
|
|||
57
deno.lock
generated
57
deno.lock
generated
|
|
@ -97,12 +97,12 @@
|
|||
"npm:lint-staged": "npm:lint-staged@15.2.2",
|
||||
"npm:lru-cache@^10.2.0": "npm:lru-cache@10.2.2",
|
||||
"npm:lru-cache@^10.2.2": "npm:lru-cache@10.2.2",
|
||||
"npm:nostr-relaypool2@0.6.34": "npm:nostr-relaypool2@0.6.34",
|
||||
"npm:nostr-tools@2.5.1": "npm:nostr-tools@2.5.1",
|
||||
"npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1",
|
||||
"npm:nostr-tools@^2.7.0": "npm:nostr-tools@2.7.0",
|
||||
"npm:nostr-wasm@^0.1.0": "npm:nostr-wasm@0.1.0",
|
||||
"npm:path-to-regexp@^7.1.0": "npm:path-to-regexp@7.1.0",
|
||||
"npm:png-to-ico@^2.1.8": "npm:png-to-ico@2.1.8",
|
||||
"npm:postgres@3.4.4": "npm:postgres@3.4.4",
|
||||
"npm:prom-client@^15.1.2": "npm:prom-client@15.1.2",
|
||||
"npm:tldts@^6.0.14": "npm:tldts@6.1.18",
|
||||
|
|
@ -582,10 +582,6 @@
|
|||
"integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"@noble/ciphers@0.2.0": {
|
||||
"integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"@noble/ciphers@0.5.3": {
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"dependencies": {}
|
||||
|
|
@ -672,6 +668,10 @@
|
|||
"@types/trusted-types": "@types/trusted-types@2.0.7"
|
||||
}
|
||||
},
|
||||
"@types/node@17.0.45": {
|
||||
"integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"@types/node@18.16.19": {
|
||||
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
|
||||
"dependencies": {}
|
||||
|
|
@ -983,12 +983,6 @@
|
|||
"jsdom": "jsdom@24.0.0"
|
||||
}
|
||||
},
|
||||
"isomorphic-ws@5.0.0_ws@8.17.0": {
|
||||
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
|
||||
"dependencies": {
|
||||
"ws": "ws@8.17.0"
|
||||
}
|
||||
},
|
||||
"jsdom@24.0.0": {
|
||||
"integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==",
|
||||
"dependencies": {
|
||||
|
|
@ -1131,6 +1125,10 @@
|
|||
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"minimist@1.2.8": {
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"ms@2.1.2": {
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"dependencies": {}
|
||||
|
|
@ -1145,25 +1143,6 @@
|
|||
"whatwg-url": "whatwg-url@5.0.0"
|
||||
}
|
||||
},
|
||||
"nostr-relaypool2@0.6.34": {
|
||||
"integrity": "sha512-e3FDh9w/wQkY513mvoJps1Hc/Y5wiWXeBM6MD+YKSyAg+px+/8uHSSHAuHhlavw7oOEOvEsIGlMDMc57DG3MOA==",
|
||||
"dependencies": {
|
||||
"isomorphic-ws": "isomorphic-ws@5.0.0_ws@8.17.0",
|
||||
"nostr-tools": "nostr-tools@1.17.0",
|
||||
"safe-stable-stringify": "safe-stable-stringify@2.4.3"
|
||||
}
|
||||
},
|
||||
"nostr-tools@1.17.0": {
|
||||
"integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "@noble/ciphers@0.2.0",
|
||||
"@noble/curves": "@noble/curves@1.1.0",
|
||||
"@noble/hashes": "@noble/hashes@1.3.1",
|
||||
"@scure/base": "@scure/base@1.1.1",
|
||||
"@scure/bip32": "@scure/bip32@1.3.1",
|
||||
"@scure/bip39": "@scure/bip39@1.2.1"
|
||||
}
|
||||
},
|
||||
"nostr-tools@2.5.1": {
|
||||
"integrity": "sha512-bpkhGGAhdiCN0irfV+xoH3YP5CQeOXyXzUq7SYeM6D56xwTXZCPEmBlUGqFVfQidvRsoVeVxeAiOXW2c2HxoRQ==",
|
||||
"dependencies": {
|
||||
|
|
@ -1240,6 +1219,18 @@
|
|||
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"png-to-ico@2.1.8": {
|
||||
"integrity": "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w==",
|
||||
"dependencies": {
|
||||
"@types/node": "@types/node@17.0.45",
|
||||
"minimist": "minimist@1.2.8",
|
||||
"pngjs": "pngjs@6.0.0"
|
||||
}
|
||||
},
|
||||
"pngjs@6.0.0": {
|
||||
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"postgres@3.4.4": {
|
||||
"integrity": "sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==",
|
||||
"dependencies": {}
|
||||
|
|
@ -1282,10 +1273,6 @@
|
|||
"integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"safe-stable-stringify@2.4.3": {
|
||||
"integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"safer-buffer@2.1.2": {
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dependencies": {}
|
||||
|
|
@ -2162,10 +2149,10 @@
|
|||
"npm:linkify-string@^4.1.1",
|
||||
"npm:linkifyjs@^4.1.1",
|
||||
"npm:lru-cache@^10.2.2",
|
||||
"npm:nostr-relaypool2@0.6.34",
|
||||
"npm:nostr-tools@2.5.1",
|
||||
"npm:nostr-wasm@^0.1.0",
|
||||
"npm:path-to-regexp@^7.1.0",
|
||||
"npm:png-to-ico@^2.1.8",
|
||||
"npm:prom-client@^15.1.2",
|
||||
"npm:tldts@^6.0.14",
|
||||
"npm:tseep@^1.2.1",
|
||||
|
|
|
|||
70
scripts/setup-kind0.ts
Normal file
70
scripts/setup-kind0.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||
import { Command } from 'commander';
|
||||
import { NostrEvent } from 'nostr-tools';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { Conf } from '@/config.ts';
|
||||
import pngToIco from 'png-to-ico';
|
||||
import { Storages } from '@/storages.ts';
|
||||
|
||||
function die(code: number, ...args: any[]) {
|
||||
console.error(...args);
|
||||
Deno.exit(code);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const kind0 = new Command()
|
||||
.name('setup:kind0')
|
||||
.description('Set up / change the kind 0 for a Ditto instance.')
|
||||
.version('0.1.0')
|
||||
.showHelpAfterError();
|
||||
|
||||
kind0
|
||||
.argument('<name>', 'The name of the Ditto instance. Can just be your hostname.')
|
||||
.option(
|
||||
'-l --lightning <lud16 address>',
|
||||
'Lightning address for the server. Can just be your own lightning address.',
|
||||
)
|
||||
.option('-a --about <string>', 'About text. This shows up whenever a description for your server is needed.')
|
||||
.option('-i --image <string>', 'Image URL to use for OpenGraph previews and favicon.')
|
||||
.action(async (name, args) => {
|
||||
const { lightning, about, image } = args;
|
||||
const content: Record<string, string | boolean> = {};
|
||||
if (!name || !name.trim()) die(1, 'You must atleast supply a name!');
|
||||
content.bot = true;
|
||||
content.about = about;
|
||||
content.lud16 = lightning;
|
||||
content.name = name;
|
||||
content.picture = image;
|
||||
content.website = Conf.localDomain;
|
||||
|
||||
const signer = new AdminSigner();
|
||||
const bare: Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> = {
|
||||
created_at: nostrNow(),
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
const signed = await signer.signEvent(bare);
|
||||
if (image) {
|
||||
try {
|
||||
await fetch(image)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Error attempting to fetch favicon.');
|
||||
if (res.headers.get('content-type') !== 'image/png') throw new Error('Non-png images are not supported!');
|
||||
return res.blob();
|
||||
})
|
||||
.then(async (blob) =>
|
||||
await pngToIco(Buffer.from(await blob.arrayBuffer()))
|
||||
.then(async (buf) => await Deno.writeFile('./public/favicon.ico', buf))
|
||||
);
|
||||
} catch (e) {
|
||||
die(1, `Error generating favicon from url ${image}: "${e}". Please check this or try again without --image.`);
|
||||
}
|
||||
}
|
||||
console.log({ content, signed });
|
||||
await Storages.db().then((store) => store.event(signed));
|
||||
});
|
||||
|
||||
await kind0.parseAsync();
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
import '@/config.ts';
|
||||
|
||||
import {
|
||||
updateTrendingEvents,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import os from 'node:os';
|
||||
import ISO6391, { LanguageCode } from 'iso-639-1';
|
||||
import * as dotenv from '@std/dotenv';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -247,6 +248,10 @@ class Conf {
|
|||
static get zapSplitsEnabled(): boolean {
|
||||
return optionalBooleanSchema.parse(Deno.env.get('ZAP_SPLITS_ENABLED')) ?? false;
|
||||
}
|
||||
/** Languages this server wishes to highlight. Used when querying trends.*/
|
||||
static get preferredLanguages(): LanguageCode[] | undefined {
|
||||
return Deno.env.get('DITTO_LANGUAGES')?.split(',')?.filter(ISO6391.validate) as LanguageCode[];
|
||||
}
|
||||
/** Cache settings. */
|
||||
static caches = {
|
||||
/** NIP-05 cache settings. */
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify';
|
||||
import { bech32 } from '@scure/base';
|
||||
import { escape } from 'entities';
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { encryptSecretKey, generateToken } from '@/utils/auth.ts';
|
||||
|
||||
const passwordGrantSchema = z.object({
|
||||
grant_type: z.literal('password'),
|
||||
|
|
@ -82,38 +82,30 @@ async function getToken(
|
|||
{ pubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] },
|
||||
): Promise<`token1${string}`> {
|
||||
const kysely = await Storages.kysely();
|
||||
const token = generateToken();
|
||||
const { token, hash } = await generateToken();
|
||||
|
||||
const serverSeckey = generateSecretKey();
|
||||
const serverPubkey = getPublicKey(serverSeckey);
|
||||
const nip46Seckey = generateSecretKey();
|
||||
|
||||
const signer = new NConnectSigner({
|
||||
pubkey,
|
||||
signer: new NSecSigner(serverSeckey),
|
||||
signer: new NSecSigner(nip46Seckey),
|
||||
relay: await Storages.pubsub(), // TODO: Use the relays from the request.
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
await signer.connect(secret);
|
||||
|
||||
await kysely.insertInto('nip46_tokens').values({
|
||||
api_token: token,
|
||||
user_pubkey: pubkey,
|
||||
server_seckey: serverSeckey,
|
||||
server_pubkey: serverPubkey,
|
||||
relays: JSON.stringify(relays),
|
||||
connected_at: new Date(),
|
||||
await kysely.insertInto('auth_tokens').values({
|
||||
token_hash: hash,
|
||||
pubkey,
|
||||
nip46_sk_enc: await encryptSecretKey(Conf.seckey, nip46Seckey),
|
||||
nip46_relays: relays,
|
||||
created_at: new Date(),
|
||||
}).execute();
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Generate a bech32 token for the API. */
|
||||
function generateToken(): `token1${string}` {
|
||||
const words = bech32.toWords(generateSecretKey());
|
||||
return bech32.encode('token', words);
|
||||
}
|
||||
|
||||
/** Display the OAuth form. */
|
||||
const oauthController: AppController = (c) => {
|
||||
const encodedUri = c.req.query('redirect_uri');
|
||||
|
|
|
|||
|
|
@ -88,14 +88,28 @@ const createStatusController: AppController = async (c) => {
|
|||
return c.json({ error: 'Original post not found.' }, 404);
|
||||
}
|
||||
|
||||
const root = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id;
|
||||
const rootId = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id;
|
||||
const root = rootId === ancestor.id ? ancestor : await getEvent(rootId);
|
||||
|
||||
tags.push(['e', root, Conf.relay, 'root']);
|
||||
tags.push(['e', data.in_reply_to_id, Conf.relay, 'reply']);
|
||||
if (root) {
|
||||
tags.push(['e', root.id, Conf.relay, 'root', root.pubkey]);
|
||||
} else {
|
||||
tags.push(['e', rootId, Conf.relay, 'root']);
|
||||
}
|
||||
|
||||
tags.push(['e', ancestor.id, Conf.relay, 'reply', ancestor.pubkey]);
|
||||
}
|
||||
|
||||
let quoted: DittoEvent | undefined;
|
||||
|
||||
if (data.quote_id) {
|
||||
tags.push(['q', data.quote_id]);
|
||||
quoted = await getEvent(data.quote_id);
|
||||
|
||||
if (!quoted) {
|
||||
return c.json({ error: 'Quoted post not found.' }, 404);
|
||||
}
|
||||
|
||||
tags.push(['q', quoted.id, Conf.relay, '', quoted.pubkey]);
|
||||
}
|
||||
|
||||
if (data.sensitive && data.spoiler_text) {
|
||||
|
|
@ -143,7 +157,7 @@ const createStatusController: AppController = async (c) => {
|
|||
}
|
||||
|
||||
try {
|
||||
return `nostr:${nip19.npubEncode(pubkey)}`;
|
||||
return `nostr:${nip19.nprofileEncode({ pubkey, relays: [Conf.relay] })}`;
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
|
|
@ -159,7 +173,7 @@ const createStatusController: AppController = async (c) => {
|
|||
}
|
||||
|
||||
for (const pubkey of pubkeys) {
|
||||
tags.push(['p', pubkey]);
|
||||
tags.push(['p', pubkey, Conf.relay]);
|
||||
}
|
||||
|
||||
for (const link of linkify.find(data.status ?? '')) {
|
||||
|
|
@ -175,10 +189,16 @@ const createStatusController: AppController = async (c) => {
|
|||
.map(({ url }) => url)
|
||||
.filter((url): url is string => Boolean(url));
|
||||
|
||||
const quoteCompat = data.quote_id ? `\n\nnostr:${nip19.noteEncode(data.quote_id)}` : '';
|
||||
const quoteCompat = quoted
|
||||
? `\n\nnostr:${
|
||||
nip19.neventEncode({ id: quoted.id, kind: quoted.kind, author: quoted.pubkey, relays: [Conf.relay] })
|
||||
}`
|
||||
: '';
|
||||
|
||||
const mediaCompat = mediaUrls.length ? `\n\n${mediaUrls.join('\n')}` : '';
|
||||
|
||||
const author = await getAuthor(await c.get('signer')?.getPublicKey()!);
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const author = pubkey ? await getAuthor(pubkey) : undefined;
|
||||
|
||||
if (Conf.zapSplitsEnabled) {
|
||||
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
|
||||
|
|
@ -191,7 +211,7 @@ const createStatusController: AppController = async (c) => {
|
|||
tags.push(['zap', pubkey, Conf.relay, dittoZapSplit[pubkey].weight.toString(), dittoZapSplit[pubkey].message]);
|
||||
}
|
||||
if (totalSplit) {
|
||||
tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]);
|
||||
tags.push(['zap', pubkey, Conf.relay, Math.max(0, 100 - totalSplit).toString()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -223,7 +243,7 @@ const deleteStatusController: AppController = async (c) => {
|
|||
if (event.pubkey === pubkey) {
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
tags: [['e', id, Conf.relay]],
|
||||
tags: [['e', id, Conf.relay, '', pubkey]],
|
||||
}, c);
|
||||
|
||||
const author = await getAuthor(event.pubkey);
|
||||
|
|
@ -281,7 +301,7 @@ const favouriteController: AppController = async (c) => {
|
|||
kind: 7,
|
||||
content: '+',
|
||||
tags: [
|
||||
['e', target.id, Conf.relay],
|
||||
['e', target.id, Conf.relay, '', target.pubkey],
|
||||
['p', target.pubkey, Conf.relay],
|
||||
],
|
||||
}, c);
|
||||
|
|
@ -324,7 +344,7 @@ const reblogStatusController: AppController = async (c) => {
|
|||
const reblogEvent = await createEvent({
|
||||
kind: 6,
|
||||
tags: [
|
||||
['e', event.id, Conf.relay],
|
||||
['e', event.id, Conf.relay, '', event.pubkey],
|
||||
['p', event.pubkey, Conf.relay],
|
||||
],
|
||||
}, c);
|
||||
|
|
@ -361,7 +381,7 @@ const unreblogStatusController: AppController = async (c) => {
|
|||
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
tags: [['e', repostEvent.id, Conf.relay]],
|
||||
tags: [['e', repostEvent.id, Conf.relay, '', repostEvent.pubkey]],
|
||||
}, c);
|
||||
|
||||
return c.json(await renderStatus(event, { viewerPubkey: pubkey }));
|
||||
|
|
@ -413,7 +433,7 @@ const bookmarkController: AppController = async (c) => {
|
|||
if (event) {
|
||||
await updateListEvent(
|
||||
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
||||
(tags) => addTag(tags, ['e', eventId, Conf.relay]),
|
||||
(tags) => addTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
|
|
@ -440,7 +460,7 @@ const unbookmarkController: AppController = async (c) => {
|
|||
if (event) {
|
||||
await updateListEvent(
|
||||
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
||||
(tags) => deleteTag(tags, ['e', eventId, Conf.relay]),
|
||||
(tags) => deleteTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
|
|
@ -467,7 +487,7 @@ const pinController: AppController = async (c) => {
|
|||
if (event) {
|
||||
await updateListEvent(
|
||||
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
||||
(tags) => addTag(tags, ['e', eventId, Conf.relay]),
|
||||
(tags) => addTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
|
|
@ -496,7 +516,7 @@ const unpinController: AppController = async (c) => {
|
|||
if (event) {
|
||||
await updateListEvent(
|
||||
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
||||
(tags) => deleteTag(tags, ['e', eventId, Conf.relay]),
|
||||
(tags) => deleteTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
|
|
@ -540,8 +560,8 @@ const zapController: AppController = async (c) => {
|
|||
lnurl = getLnurl(meta);
|
||||
if (target && lnurl) {
|
||||
tags.push(
|
||||
['e', target.id, Conf.relay],
|
||||
['p', target.pubkey],
|
||||
['e', target.id, Conf.relay, '', target.pubkey],
|
||||
['p', target.pubkey, Conf.relay],
|
||||
['amount', amount.toString()],
|
||||
['relays', Conf.relay],
|
||||
['lnurl', lnurl],
|
||||
|
|
@ -553,7 +573,7 @@ const zapController: AppController = async (c) => {
|
|||
lnurl = getLnurl(meta);
|
||||
if (target && lnurl) {
|
||||
tags.push(
|
||||
['p', target.pubkey],
|
||||
['p', target.pubkey, Conf.relay],
|
||||
['amount', amount.toString()],
|
||||
['relays', Conf.relay],
|
||||
['lnurl', lnurl],
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
|
|||
import { getFeedPubkeys } from '@/queries.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getTokenHash } from '@/utils/auth.ts';
|
||||
import { bech32ToPubkey, Time } from '@/utils.ts';
|
||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||
|
|
@ -233,14 +234,15 @@ async function topicToFilter(
|
|||
async function getTokenPubkey(token: string): Promise<string | undefined> {
|
||||
if (token.startsWith('token1')) {
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(token as `token1${string}`);
|
||||
|
||||
const { user_pubkey } = await kysely
|
||||
.selectFrom('nip46_tokens')
|
||||
.select(['user_pubkey', 'server_seckey', 'relays'])
|
||||
.where('api_token', '=', token)
|
||||
const { pubkey } = await kysely
|
||||
.selectFrom('auth_tokens')
|
||||
.select('pubkey')
|
||||
.where('token_hash', '=', tokenHash)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return user_pubkey;
|
||||
return pubkey;
|
||||
} else {
|
||||
return bech32ToPubkey(token);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { NPostgresSchema } from '@nostrify/db';
|
|||
|
||||
export interface DittoTables extends NPostgresSchema {
|
||||
nostr_events: NostrEventsRow;
|
||||
nip46_tokens: NIP46TokenRow;
|
||||
auth_tokens: AuthTokenRow;
|
||||
author_stats: AuthorStatsRow;
|
||||
event_stats: EventStatsRow;
|
||||
pubkey_domains: PubkeyDomainRow;
|
||||
|
|
@ -33,13 +33,12 @@ interface EventStatsRow {
|
|||
zaps_amount: number;
|
||||
}
|
||||
|
||||
interface NIP46TokenRow {
|
||||
api_token: string;
|
||||
user_pubkey: string;
|
||||
server_seckey: Uint8Array;
|
||||
server_pubkey: string;
|
||||
relays: string;
|
||||
connected_at: Date;
|
||||
interface AuthTokenRow {
|
||||
token_hash: Uint8Array;
|
||||
pubkey: string;
|
||||
nip46_sk_enc: Uint8Array;
|
||||
nip46_relays: string[];
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface PubkeyDomainRow {
|
||||
|
|
|
|||
62
src/db/migrations/037_auth_tokens.ts
Normal file
62
src/db/migrations/037_auth_tokens.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
import { encryptSecretKey, getTokenHash } from '@/utils/auth.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
|
||||
interface DB {
|
||||
nip46_tokens: {
|
||||
api_token: `token1${string}`;
|
||||
user_pubkey: string;
|
||||
server_seckey: Uint8Array;
|
||||
server_pubkey: string;
|
||||
relays: string;
|
||||
connected_at: Date;
|
||||
};
|
||||
auth_tokens: {
|
||||
token_hash: Uint8Array;
|
||||
pubkey: string;
|
||||
nip46_sk_enc: Uint8Array;
|
||||
nip46_relays: string[];
|
||||
created_at: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export async function up(db: Kysely<DB>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('auth_tokens')
|
||||
.addColumn('token_hash', 'bytea', (col) => col.primaryKey())
|
||||
.addColumn('pubkey', 'char(64)', (col) => col.notNull())
|
||||
.addColumn('nip46_sk_enc', 'bytea', (col) => col.notNull())
|
||||
.addColumn('nip46_relays', 'jsonb', (col) => col.defaultTo('[]'))
|
||||
.addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
|
||||
.execute();
|
||||
|
||||
// There are probably not that many tokens in the database yet, so this should be fine.
|
||||
const tokens = await db.selectFrom('nip46_tokens').selectAll().execute();
|
||||
|
||||
for (const token of tokens) {
|
||||
await db.insertInto('auth_tokens').values({
|
||||
token_hash: await getTokenHash(token.api_token),
|
||||
pubkey: token.user_pubkey,
|
||||
nip46_sk_enc: await encryptSecretKey(Conf.seckey, token.server_seckey),
|
||||
nip46_relays: JSON.parse(token.relays),
|
||||
created_at: token.connected_at,
|
||||
}).execute();
|
||||
}
|
||||
|
||||
await db.schema.dropTable('nip46_tokens').execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<DB>): Promise<void> {
|
||||
await db.schema.dropTable('auth_tokens').execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('nip46_tokens')
|
||||
.addColumn('api_token', 'text', (col) => col.primaryKey().unique().notNull())
|
||||
.addColumn('user_pubkey', 'text', (col) => col.notNull())
|
||||
.addColumn('server_seckey', 'bytea', (col) => col.notNull())
|
||||
.addColumn('server_pubkey', 'text', (col) => col.notNull())
|
||||
.addColumn('relays', 'text', (col) => col.defaultTo('[]'))
|
||||
.addColumn('connected_at', 'timestamp', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
|
||||
.execute();
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@ import { NSecSigner } from '@nostrify/nostrify';
|
|||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { AppMiddleware } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { decryptSecretKey, getTokenHash } from '@/utils/auth.ts';
|
||||
|
||||
/** We only accept "Bearer" type. */
|
||||
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
|
||||
|
|
@ -21,14 +23,17 @@ export const signerMiddleware: AppMiddleware = async (c, next) => {
|
|||
if (bech32.startsWith('token1')) {
|
||||
try {
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(bech32 as `token1${string}`);
|
||||
|
||||
const { user_pubkey, server_seckey, relays } = await kysely
|
||||
.selectFrom('nip46_tokens')
|
||||
.select(['user_pubkey', 'server_seckey', 'relays'])
|
||||
.where('api_token', '=', bech32)
|
||||
const { pubkey, nip46_sk_enc, nip46_relays } = await kysely
|
||||
.selectFrom('auth_tokens')
|
||||
.select(['pubkey', 'nip46_sk_enc', 'nip46_relays'])
|
||||
.where('token_hash', '=', tokenHash)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
c.set('signer', new ConnectSigner(user_pubkey, new NSecSigner(server_seckey), JSON.parse(relays)));
|
||||
const nep46Seckey = await decryptSecretKey(Conf.seckey, nip46_sk_enc);
|
||||
|
||||
c.set('signer', new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays));
|
||||
} catch {
|
||||
throw new HTTPException(401);
|
||||
}
|
||||
|
|
|
|||
105
src/trends.test.ts
Normal file
105
src/trends.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
import { generateSecretKey, NostrEvent } from 'nostr-tools';
|
||||
|
||||
import { getTrendingTagValues } from '@/trends.ts';
|
||||
import { createTestDB, genEvent } from '@/test.ts';
|
||||
|
||||
Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", async () => {
|
||||
await using db = await createTestDB();
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
let sk = generateSecretKey();
|
||||
const post1 = genEvent({ kind: 1, content: 'SHOW ME THE MONEY' }, sk);
|
||||
const numberOfAuthorsWhoLikedPost1 = 100;
|
||||
const post1multiplier = 2;
|
||||
const post1uses = numberOfAuthorsWhoLikedPost1 * post1multiplier;
|
||||
for (let i = 0; i < numberOfAuthorsWhoLikedPost1; i++) {
|
||||
const sk = generateSecretKey();
|
||||
events.push(
|
||||
genEvent({ kind: 7, content: '+', tags: Array(post1multiplier).fill([...['e', post1.id]]) }, sk),
|
||||
);
|
||||
}
|
||||
events.push(post1);
|
||||
|
||||
sk = generateSecretKey();
|
||||
const post2 = genEvent({ kind: 1, content: 'Ithaca' }, sk);
|
||||
const numberOfAuthorsWhoLikedPost2 = 100;
|
||||
const post2multiplier = 1;
|
||||
const post2uses = numberOfAuthorsWhoLikedPost2 * post2multiplier;
|
||||
for (let i = 0; i < numberOfAuthorsWhoLikedPost2; i++) {
|
||||
const sk = generateSecretKey();
|
||||
events.push(
|
||||
genEvent({ kind: 7, content: '+', tags: Array(post2multiplier).fill([...['e', post2.id]]) }, sk),
|
||||
);
|
||||
}
|
||||
events.push(post2);
|
||||
|
||||
for (const event of events) {
|
||||
await db.store.event(event);
|
||||
}
|
||||
|
||||
const trends = await getTrendingTagValues(db.kysely, ['e'], { kinds: [1, 7] });
|
||||
|
||||
const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }, {
|
||||
value: post2.id,
|
||||
authors: numberOfAuthorsWhoLikedPost2,
|
||||
uses: post2uses,
|
||||
}];
|
||||
|
||||
assertEquals(trends, expected);
|
||||
});
|
||||
|
||||
Deno.test("getTrendingTagValues(): 'e' tag and WITH language parameter", async () => {
|
||||
await using db = await createTestDB();
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
let sk = generateSecretKey();
|
||||
const post1 = genEvent({ kind: 1, content: 'Irei cortar o cabelo.' }, sk);
|
||||
const numberOfAuthorsWhoLikedPost1 = 100;
|
||||
const post1multiplier = 2;
|
||||
const post1uses = numberOfAuthorsWhoLikedPost1 * post1multiplier;
|
||||
for (let i = 0; i < numberOfAuthorsWhoLikedPost1; i++) {
|
||||
const sk = generateSecretKey();
|
||||
events.push(
|
||||
genEvent({ kind: 7, content: '+', tags: Array(post1multiplier).fill([...['e', post1.id]]) }, sk),
|
||||
);
|
||||
}
|
||||
events.push(post1);
|
||||
|
||||
sk = generateSecretKey();
|
||||
const post2 = genEvent({ kind: 1, content: 'Ithaca' }, sk);
|
||||
const numberOfAuthorsWhoLikedPost2 = 100;
|
||||
const post2multiplier = 1;
|
||||
for (let i = 0; i < numberOfAuthorsWhoLikedPost2; i++) {
|
||||
const sk = generateSecretKey();
|
||||
events.push(
|
||||
genEvent({ kind: 7, content: '+', tags: Array(post2multiplier).fill([...['e', post2.id]]) }, sk),
|
||||
);
|
||||
}
|
||||
events.push(post2);
|
||||
|
||||
for (const event of events) {
|
||||
await db.store.event(event);
|
||||
}
|
||||
|
||||
await db.kysely.updateTable('nostr_events')
|
||||
.set('language', 'pt')
|
||||
.where('id', '=', post1.id)
|
||||
.execute();
|
||||
|
||||
await db.kysely.updateTable('nostr_events')
|
||||
.set('language', 'en')
|
||||
.where('id', '=', post2.id)
|
||||
.execute();
|
||||
|
||||
const languagesIds = (await db.store.query([{ search: 'language:pt' }])).map((event) => event.id);
|
||||
|
||||
const trends = await getTrendingTagValues(db.kysely, ['e'], { kinds: [1, 7] }, languagesIds);
|
||||
|
||||
// portuguese post
|
||||
const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }];
|
||||
|
||||
assertEquals(trends, expected);
|
||||
});
|
||||
|
|
@ -19,6 +19,8 @@ export async function getTrendingTagValues(
|
|||
tagNames: string[],
|
||||
/** Filter of eligible events. */
|
||||
filter: NostrFilter,
|
||||
/** If present, only tag values in this list are permitted to trend. */
|
||||
values?: string[],
|
||||
): Promise<{ value: string; authors: number; uses: number }[]> {
|
||||
let query = kysely
|
||||
.selectFrom([
|
||||
|
|
@ -33,7 +35,7 @@ export async function getTrendingTagValues(
|
|||
])
|
||||
.where('kv.key', '=', (eb) => eb.fn.any(eb.val(tagNames)))
|
||||
.groupBy((eb) => eb.fn<string>('lower', ['element.value']))
|
||||
.orderBy((eb) => eb.fn.agg('count', ['nostr_events.pubkey']).distinct(), 'desc');
|
||||
.orderBy('authors desc').orderBy('uses desc');
|
||||
|
||||
if (filter.kinds) {
|
||||
query = query.where('nostr_events.kind', '=', ({ fn, val }) => fn.any(val(filter.kinds)));
|
||||
|
|
@ -47,6 +49,9 @@ export async function getTrendingTagValues(
|
|||
if (typeof filter.until === 'number') {
|
||||
query = query.where('nostr_events.created_at', '<=', filter.until);
|
||||
}
|
||||
if (values) {
|
||||
query = query.where('element.value', 'in', values);
|
||||
}
|
||||
if (typeof filter.limit === 'number') {
|
||||
query = query.limit(filter.limit);
|
||||
}
|
||||
|
|
@ -68,6 +73,7 @@ export async function updateTrendingTags(
|
|||
limit: number,
|
||||
extra = '',
|
||||
aliases?: string[],
|
||||
values?: string[],
|
||||
) {
|
||||
console.info(`Updating trending ${l}...`);
|
||||
const kysely = await Storages.kysely();
|
||||
|
|
@ -84,8 +90,9 @@ export async function updateTrendingTags(
|
|||
since: yesterday,
|
||||
until: now,
|
||||
limit,
|
||||
});
|
||||
}, values);
|
||||
|
||||
console.log(trends);
|
||||
if (!trends.length) {
|
||||
console.info(`No trending ${l} found. Skipping.`);
|
||||
return;
|
||||
|
|
@ -122,8 +129,31 @@ export function updateTrendingZappedEvents(): Promise<void> {
|
|||
}
|
||||
|
||||
/** Update trending events. */
|
||||
export function updateTrendingEvents(): Promise<void> {
|
||||
return updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']);
|
||||
export async function updateTrendingEvents(): Promise<void> {
|
||||
const results: Promise<void>[] = [
|
||||
updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']),
|
||||
];
|
||||
|
||||
const kysely = await Storages.kysely();
|
||||
|
||||
for (const language of Conf.preferredLanguages ?? []) {
|
||||
const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const rows = await kysely
|
||||
.selectFrom('nostr_events')
|
||||
.select('nostr_events.id')
|
||||
.where('nostr_events.language', '=', language)
|
||||
.where('nostr_events.created_at', '>=', yesterday)
|
||||
.where('nostr_events.created_at', '<=', now)
|
||||
.execute();
|
||||
|
||||
const ids = rows.map((row) => row.id);
|
||||
|
||||
results.push(updateTrendingTags(`#e.${language}`, 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q'], ids));
|
||||
}
|
||||
|
||||
await Promise.allSettled(results);
|
||||
}
|
||||
|
||||
/** Update trending hashtags. */
|
||||
|
|
|
|||
16
src/utils.ts
16
src/utils.ts
|
|
@ -64,20 +64,6 @@ function findTag(tags: string[][], name: string): string[] | undefined {
|
|||
return tags.find((tag) => tag[0] === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sha256 hash (hex) of some text.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
|
||||
*/
|
||||
async function sha256(message: string): Promise<string> {
|
||||
const msgUint8 = new TextEncoder().encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
/** Test whether the value is a Nostr ID. */
|
||||
function isNostrId(value: unknown): boolean {
|
||||
return n.id().safeParse(value).success;
|
||||
|
|
@ -88,6 +74,6 @@ function isURL(value: unknown): boolean {
|
|||
return z.string().url().safeParse(value).success;
|
||||
}
|
||||
|
||||
export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05, sha256 };
|
||||
export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05 };
|
||||
|
||||
export { Time } from '@/utils/time.ts';
|
||||
|
|
|
|||
28
src/utils/auth.bench.ts
Normal file
28
src/utils/auth.bench.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
import { decryptSecretKey, encryptSecretKey, generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||
|
||||
Deno.bench('generateToken', async () => {
|
||||
await generateToken();
|
||||
});
|
||||
|
||||
Deno.bench('getTokenHash', async (b) => {
|
||||
const { token } = await generateToken();
|
||||
b.start();
|
||||
await getTokenHash(token);
|
||||
});
|
||||
|
||||
Deno.bench('encryptSecretKey', async (b) => {
|
||||
const sk = generateSecretKey();
|
||||
const decrypted = generateSecretKey();
|
||||
b.start();
|
||||
await encryptSecretKey(sk, decrypted);
|
||||
});
|
||||
|
||||
Deno.bench('decryptSecretKey', async (b) => {
|
||||
const sk = generateSecretKey();
|
||||
const decrypted = generateSecretKey();
|
||||
const encrypted = await encryptSecretKey(sk, decrypted);
|
||||
b.start();
|
||||
await decryptSecretKey(sk, encrypted);
|
||||
});
|
||||
29
src/utils/auth.test.ts
Normal file
29
src/utils/auth.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
import { decodeHex, encodeHex } from '@std/encoding/hex';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
import { decryptSecretKey, encryptSecretKey, generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||
|
||||
Deno.test('generateToken', async () => {
|
||||
const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95');
|
||||
|
||||
const { token, hash } = await generateToken(sk);
|
||||
|
||||
assertEquals(token, 'token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
|
||||
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
|
||||
});
|
||||
|
||||
Deno.test('getTokenHash', async () => {
|
||||
const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
|
||||
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
|
||||
});
|
||||
|
||||
Deno.test('encryptSecretKey & decryptSecretKey', async () => {
|
||||
const sk = generateSecretKey();
|
||||
const data = generateSecretKey();
|
||||
|
||||
const encrypted = await encryptSecretKey(sk, data);
|
||||
const decrypted = await decryptSecretKey(sk, encrypted);
|
||||
|
||||
assertEquals(encodeHex(decrypted), encodeHex(data));
|
||||
});
|
||||
54
src/utils/auth.ts
Normal file
54
src/utils/auth.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { bech32 } from '@scure/base';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
/**
|
||||
* Generate an auth token for the API.
|
||||
*
|
||||
* Returns a bech32 encoded API token and the SHA-256 hash of the bytes.
|
||||
* The token should be presented to the user, but only the hash should be stored in the database.
|
||||
*/
|
||||
export async function generateToken(sk = generateSecretKey()): Promise<{ token: `token1${string}`; hash: Uint8Array }> {
|
||||
const words = bech32.toWords(sk);
|
||||
const token = bech32.encode('token', words);
|
||||
|
||||
const buffer = await crypto.subtle.digest('SHA-256', sk);
|
||||
const hash = new Uint8Array(buffer);
|
||||
|
||||
return { token, hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SHA-256 hash of an API token.
|
||||
* First decodes from bech32 then hashes the bytes.
|
||||
* Used to identify the user in the database by the hash of their token.
|
||||
*/
|
||||
export async function getTokenHash(token: `token1${string}`): Promise<Uint8Array> {
|
||||
const { bytes: sk } = bech32.decodeToBytes(token);
|
||||
const buffer = await crypto.subtle.digest('SHA-256', sk);
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a secret key with AES-GCM.
|
||||
* This function is used to store the secret key in the database.
|
||||
*/
|
||||
export async function encryptSecretKey(sk: Uint8Array, decrypted: Uint8Array): Promise<Uint8Array> {
|
||||
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, decrypted);
|
||||
|
||||
return new Uint8Array([...iv, ...new Uint8Array(buffer)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a secret key with AES-GCM.
|
||||
* This function is used to retrieve the secret key from the database.
|
||||
*/
|
||||
export async function decryptSecretKey(sk: Uint8Array, encrypted: Uint8Array): Promise<Uint8Array> {
|
||||
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
const iv = encrypted.slice(0, 12);
|
||||
const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, encrypted.slice(12));
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { LNURL, LNURLDetails } from '@nostrify/nostrify/ln';
|
||||
import Debug from '@soapbox/stickynotes/debug';
|
||||
import { Stickynotes } from '@soapbox/stickynotes';
|
||||
|
||||
import { cachedLnurlsSizeGauge } from '@/metrics.ts';
|
||||
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
|
||||
|
|
@ -7,17 +7,17 @@ import { Time } from '@/utils/time.ts';
|
|||
import { fetchWorker } from '@/workers/fetch.ts';
|
||||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const debug = Debug('ditto:lnurl');
|
||||
const console = new Stickynotes('ditto:lnurl');
|
||||
|
||||
const lnurlCache = new SimpleLRU<string, LNURLDetails>(
|
||||
async (lnurl, { signal }) => {
|
||||
debug(`Lookup ${lnurl}`);
|
||||
console.debug(`Lookup ${lnurl}`);
|
||||
try {
|
||||
const result = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal });
|
||||
debug(`Found: ${lnurl}`);
|
||||
console.debug(`Found: ${lnurl}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
debug(`Not found: ${lnurl}`);
|
||||
console.debug(`Not found: ${lnurl}`);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ Deno.test('getUrlMediaType', () => {
|
|||
assertEquals(getUrlMediaType('https://example.com/index.html'), 'text/html');
|
||||
assertEquals(getUrlMediaType('https://example.com/yolo'), undefined);
|
||||
assertEquals(getUrlMediaType('https://example.com/'), undefined);
|
||||
assertEquals(
|
||||
getUrlMediaType('https://gitlab.com/soapbox-pub/nostrify/-/blob/main/packages/policies/WoTPolicy.ts'),
|
||||
'application/typescript',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('isPermittedMediaType', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { typeByExtension } from '@std/media-types';
|
||||
import { typeByExtension as _typeByExtension } from '@std/media-types';
|
||||
|
||||
/** Get media type of the filename in the URL by its extension, if any. */
|
||||
export function getUrlMediaType(url: string): string | undefined {
|
||||
|
|
@ -22,3 +22,13 @@ export function isPermittedMediaType(mediaType: string, permitted: string[]): bo
|
|||
const [baseType, _subType] = mediaType.split('/');
|
||||
return permitted.includes(baseType);
|
||||
}
|
||||
|
||||
/** Custom type-by-extension with overrides. */
|
||||
function typeByExtension(ext: string): string | undefined {
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
return 'application/typescript';
|
||||
default:
|
||||
return _typeByExtension(ext);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
||||
import { encodeHex } from '@std/encoding/hex';
|
||||
import { EventTemplate, nip13 } from 'nostr-tools';
|
||||
|
||||
import { decode64Schema } from '@/schema.ts';
|
||||
import { signedEventSchema } from '@/schemas/nostr.ts';
|
||||
import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts';
|
||||
import { eventAge, findTag, nostrNow } from '@/utils.ts';
|
||||
import { Time } from '@/utils/time.ts';
|
||||
|
||||
/** Decode a Nostr event from a base64 encoded string. */
|
||||
|
|
@ -41,11 +42,10 @@ function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthReque
|
|||
.refine((event) => pow ? nip13.getPow(event.id) >= pow : true, 'Insufficient proof of work')
|
||||
.refine(validateBody, 'Event payload does not match request body');
|
||||
|
||||
function validateBody(event: NostrEvent) {
|
||||
async function validateBody(event: NostrEvent): Promise<boolean> {
|
||||
if (!validatePayload) return true;
|
||||
return req.clone().text()
|
||||
.then(sha256)
|
||||
.then((hash) => hash === tagValue(event, 'payload'));
|
||||
const payload = await getPayload(req);
|
||||
return payload === tagValue(event, 'payload');
|
||||
}
|
||||
|
||||
return schema.safeParseAsync(event);
|
||||
|
|
@ -62,7 +62,7 @@ async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts =
|
|||
];
|
||||
|
||||
if (validatePayload) {
|
||||
const payload = await req.clone().text().then(sha256);
|
||||
const payload = await getPayload(req);
|
||||
tags.push(['payload', payload]);
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +74,14 @@ async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts =
|
|||
};
|
||||
}
|
||||
|
||||
/** Get a SHA-256 hash of the request body encoded as a hex string. */
|
||||
async function getPayload(req: Request): Promise<string> {
|
||||
const text = await req.clone().text();
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const buffer = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return encodeHex(buffer);
|
||||
}
|
||||
|
||||
/** Get the value for the first matching tag name in the event. */
|
||||
function tagValue(event: NostrEvent, tagName: string): string | undefined {
|
||||
return findTag(event.tags, tagName)?.[1];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue