Compare commits
No commits in common. "main" and "v1.0.0" have entirely different histories.
1
.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
|||
.env
|
||||
.env.*
|
||||
*.cpuprofile
|
||||
*.swp
|
||||
deno-test.xml
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
image: denoland/deno:2.2.2
|
||||
image: denoland/deno:1.44.2
|
||||
|
||||
default:
|
||||
interruptible: true
|
||||
|
|
@ -6,26 +6,36 @@ default:
|
|||
stages:
|
||||
- test
|
||||
|
||||
fmt:
|
||||
stage: test
|
||||
script: deno fmt --check
|
||||
|
||||
lint:
|
||||
stage: test
|
||||
script: deno lint
|
||||
|
||||
check:
|
||||
stage: test
|
||||
script: deno task check
|
||||
|
||||
test:
|
||||
stage: test
|
||||
timeout: 2 minutes
|
||||
script:
|
||||
- deno fmt --check
|
||||
- deno task lint
|
||||
- deno task check
|
||||
- deno task test --ignore=packages/transcode --coverage=cov_profile
|
||||
- deno coverage cov_profile
|
||||
coverage: /All files[^\|]*\|[^\|]*\s+([\d\.]+)/
|
||||
services:
|
||||
- postgres:16
|
||||
script: deno task test
|
||||
variables:
|
||||
DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz
|
||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
RUST_BACKTRACE: 1
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- deno-test.xml
|
||||
reports:
|
||||
junit: deno-test.xml
|
||||
|
||||
postgres:
|
||||
stage: test
|
||||
script: deno task db:migrate
|
||||
services:
|
||||
- postgres:16
|
||||
variables:
|
||||
DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz
|
||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
45
.goosehints
|
|
@ -1,45 +0,0 @@
|
|||
# Ditto
|
||||
|
||||
This project is called Ditto, a self-hosted social media server written in TypeScript with Deno. It implements the [Nostr Protocol](https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/README.md), and parts of the [Mastodon API](https://docs.joinmastodon.org/methods/) and [Pleroma API](https://git.pleroma.social/pleroma/pleroma/-/raw/develop/docs/development/API/pleroma_api.md).
|
||||
|
||||
## Project Structure
|
||||
|
||||
Ditto is a monorepo with a `packages` directory. The main package is `packages/ditto`, and the main API definition is in `packages/ditto/app.ts`.
|
||||
|
||||
## Deno, npm, and jsr
|
||||
|
||||
Ditto uses Deno 2.x
|
||||
|
||||
Dependencies are managed in `deno.json`, which are added with the `deno add` command. This command also updates the `deno.lock` file. npm packages can be added by using `deno add` and prefixing the package name with an `npm:` protocol. For example, `deno add npm:kysely` would add the `kysely` package from npm.
|
||||
|
||||
[jsr](https://jsr.io/) is a modern alternative to npm. It's a completely different registry with different packages available. jsr packages can be added by using `deno add` and prefixing the package name with a `jsr:` protocol. For example, `deno add jsr:@std/assert` would add the `@std/assert` package from jsr.
|
||||
|
||||
## Nostr
|
||||
|
||||
Nostr is a decentralized social media protocol involving clients, relays, keys, and a unified Nostr event format.
|
||||
|
||||
Specifications on Nostr are called "NIPs". NIP stands for "Nostr Implementation Possibilities". NIPs are numbered like `NIP-XX` where `XX` are two capitalized hexadecimal digits, eg `NIP-01` and `NIP-C7`.
|
||||
|
||||
To learn about Nostr, use the fetch tool to read [NIP-01](https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/01.md).
|
||||
|
||||
To read a specific NIP, construct the NIP URL following this template: `https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/{nip}.md` (replace `{nip}` in the URL template with the relevant NIP name, eg `07` for NIP-07, or `C7` for NIP-C7). Then use the fetch tool to read the URL.
|
||||
|
||||
To read the definition of a specific kind, construct a URL following this template: `https://nostrbook.dev/kinds/{kind}.md` (replace `{kind}` in the template with the kind number, eg `https://nostrbook.dev/kinds/0.md` for kind 0).
|
||||
|
||||
To discover the full list of NIPs, use the fetch tool to read the [NIPs README](https://raw.githubusercontent.com/nostr-protocol/nips/refs/heads/master/README.md).
|
||||
|
||||
It's important that Ditto conforms to Nostr standards. Please read as much of the NIPs as you need to have a full understanding before adding or modifying Nostr events and filters. It is possible to add new ideas to Nostr that don't exist yet in the NIPs, but only after other options have been explored. Care must be taken when adding new Nostr ideas, to ensure they fit seamlessly within the existing Nostr ecosystem.
|
||||
|
||||
## How Ditto uses Nostr and Mastodon API
|
||||
|
||||
Ditto implements a full Nostr relay, available at `/relay` of the Ditto server.
|
||||
|
||||
Mastodon API functionality, available at `/api/*`, is built around the Nostr relay's storage implementation.
|
||||
|
||||
Ditto's goal is to enable Mastodon API clients to interact directly with Nostr. It achieves this by implementing most of Mastodon's API, and "pretending" to be a Mastodon server to client applications, while in actuality it uses Nostr as its decentralized protocol layer.
|
||||
|
||||
## Testing Changes
|
||||
|
||||
After making changes, please run `deno task check` to check for type errors. If there are any type errors, please try to fix them.
|
||||
|
||||
Afterwards, run `deno fmt` to format the code, and then you are done. Please do not try to run the server, or run any other tests.
|
||||
|
|
@ -1 +1 @@
|
|||
deno 2.2.2
|
||||
deno 1.44.2
|
||||
2
.vscode/extensions.json
vendored
|
|
@ -2,4 +2,4 @@
|
|||
"recommendations": [
|
||||
"denoland.vscode-deno"
|
||||
]
|
||||
}
|
||||
}
|
||||
4
.vscode/launch.json
vendored
|
|
@ -8,7 +8,7 @@
|
|||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"type": "node",
|
||||
"program": "${workspaceFolder}/packages/ditto/server.ts",
|
||||
"program": "${workspaceFolder}/src/server.ts",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "deno",
|
||||
"runtimeArgs": [
|
||||
|
|
@ -20,4 +20,4 @@
|
|||
"attachSimplePort": 9229
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
7
.vscode/settings.json
vendored
|
|
@ -2,8 +2,5 @@
|
|||
"deno.enable": true,
|
||||
"deno.lint": true,
|
||||
"editor.defaultFormatter": "denoland.vscode-deno",
|
||||
"path-intellisense.extensionOnImport": true,
|
||||
"files.associations": {
|
||||
".goosehints": "markdown"
|
||||
}
|
||||
}
|
||||
"path-intellisense.extensionOnImport": true
|
||||
}
|
||||
16
CHANGELOG.md
|
|
@ -7,23 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.0] - 2024-07-15
|
||||
|
||||
### Added
|
||||
|
||||
- Prometheus support (`/metrics` endpoint).
|
||||
- Sort zaps by amount; add pagination.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added IP rate-limiting of HTTP requests and WebSocket messages.
|
||||
- Added database query timeouts.
|
||||
- Fixed nos2x compatibility.
|
||||
|
||||
## [1.0.0] - 2024-06-14
|
||||
|
||||
- Initial release
|
||||
|
||||
[unreleased]: https://gitlab.com/soapbox-pub/ditto/-/compare/v1.1.0...HEAD
|
||||
[1.1.0]: https://gitlab.com/soapbox-pub/ditto/-/compare/v1.0.0...v1.1.0
|
||||
[unreleased]: https://gitlab.com/soapbox-pub/ditto/-/compare/v1.0.0...HEAD
|
||||
[1.0.0]: https://gitlab.com/soapbox-pub/ditto/-/tags/v1.0.0
|
||||
|
|
|
|||
10
Dockerfile
|
|
@ -1,10 +1,8 @@
|
|||
FROM denoland/deno:2.2.2
|
||||
ENV PORT 5000
|
||||
|
||||
FROM denoland/deno:1.44.2
|
||||
EXPOSE 4036
|
||||
WORKDIR /app
|
||||
RUN mkdir -p data && chown -R deno data
|
||||
USER deno
|
||||
COPY . .
|
||||
RUN deno cache --allow-import packages/ditto/server.ts
|
||||
RUN apt-get update && apt-get install -y unzip curl
|
||||
RUN deno task soapbox
|
||||
RUN deno cache src/server.ts
|
||||
CMD deno task start
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ For more info see: https://docs.soapbox.pub/ditto/
|
|||
|
||||
<img width="400" src="ditto-planet.png">
|
||||
|
||||
⚠️ This software is a work in progress.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] Built-in Nostr relay
|
||||
|
|
@ -16,9 +18,9 @@ For more info see: https://docs.soapbox.pub/ditto/
|
|||
- [x] Reposts
|
||||
- [x] Notifications
|
||||
- [x] Profiles
|
||||
- [x] Search
|
||||
- [ ] Search
|
||||
- [x] Moderation
|
||||
- [x] Zaps
|
||||
- [ ] Zaps
|
||||
- [x] Customizable
|
||||
- [x] Open source
|
||||
- [x] Self-hosted
|
||||
|
|
|
|||
117
deno.json
|
|
@ -1,114 +1,61 @@
|
|||
{
|
||||
"version": "1.1.0",
|
||||
"workspace": [
|
||||
"./packages/captcha",
|
||||
"./packages/conf",
|
||||
"./packages/db",
|
||||
"./packages/ditto",
|
||||
"./packages/lang",
|
||||
"./packages/mastoapi",
|
||||
"./packages/metrics",
|
||||
"./packages/nip98",
|
||||
"./packages/policies",
|
||||
"./packages/ratelimiter",
|
||||
"./packages/transcode",
|
||||
"./packages/translators",
|
||||
"./packages/uploaders",
|
||||
"./packages/cashu"
|
||||
],
|
||||
"$schema": "https://deno.land/x/deno@v1.41.0/cli/schemas/config-file.v1.json",
|
||||
"tasks": {
|
||||
"start": "deno run -A --env-file --deny-read=.env packages/ditto/server.ts",
|
||||
"dev": "deno run -A --env-file --deny-read=.env --watch packages/ditto/server.ts",
|
||||
"start": "deno run -A src/server.ts",
|
||||
"dev": "deno run -A --watch src/server.ts",
|
||||
"hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts",
|
||||
"db:export": "deno run -A --env-file --deny-read=.env scripts/db-export.ts",
|
||||
"db:import": "deno run -A --env-file --deny-read=.env scripts/db-import.ts",
|
||||
"db:cleanup": "deno run -A --env-file --deny-read=.env scripts/db-policy.ts",
|
||||
"db:migrate": "deno run -A --env-file --deny-read=.env scripts/db-migrate.ts",
|
||||
"nostr:pull": "deno run -A --env-file --deny-read=.env scripts/nostr-pull.ts",
|
||||
"debug": "deno run -A --env-file --deny-read=.env --inspect packages/ditto/server.ts",
|
||||
"test": "deno test -A --env-file=.env.test --deny-read=.env --junit-path=./deno-test.xml",
|
||||
"check": "deno check --allow-import .",
|
||||
"lint": "deno lint --allow-import",
|
||||
"db:migrate": "deno run -A scripts/db-migrate.ts",
|
||||
"debug": "deno run -A --inspect src/server.ts",
|
||||
"test": "DATABASE_URL=\"sqlite://:memory:\" deno test -A --junit-path=./deno-test.xml",
|
||||
"check": "deno check src/server.ts",
|
||||
"nsec": "deno run scripts/nsec.ts",
|
||||
"admin:event": "deno run -A --env-file --deny-read=.env scripts/admin-event.ts",
|
||||
"admin:role": "deno run -A --env-file --deny-read=.env scripts/admin-role.ts",
|
||||
"setup": "deno run -A --env-file scripts/setup.ts",
|
||||
"setup:kind0": "deno run -A --env-file --deny-read=.env scripts/setup-kind0.ts",
|
||||
"stats:recompute": "deno run -A --env-file --deny-read=.env 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 --env-file --deny-read=.env scripts/trends.ts",
|
||||
"clean:deps": "deno cache --reload packages/ditto/app.ts",
|
||||
"db:populate:nip05": "deno run -A --env-file --deny-read=.env scripts/db-populate-nip05.ts",
|
||||
"db:populate-search": "deno run -A --env-file --deny-read=.env scripts/db-populate-search.ts",
|
||||
"db:populate-extensions": "deno run -A --env-file --deny-read=.env scripts/db-populate-extensions.ts",
|
||||
"db:streak:recompute": "deno run -A --env-file --deny-read=.env scripts/db-streak-recompute.ts",
|
||||
"vapid": "deno run scripts/vapid.ts"
|
||||
"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",
|
||||
"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 soapbox.zip && rm soapbox.zip"
|
||||
},
|
||||
"unstable": [
|
||||
"cron",
|
||||
"ffi",
|
||||
"kv",
|
||||
"worker-options"
|
||||
],
|
||||
"exclude": [
|
||||
"./public"
|
||||
],
|
||||
"unstable": ["cron", "ffi", "kv", "worker-options"],
|
||||
"exclude": ["./public"],
|
||||
"imports": {
|
||||
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
||||
"@/": "./src/",
|
||||
"@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
||||
"@cashu/cashu-ts": "npm:@cashu/cashu-ts@^2.2.0",
|
||||
"@core/asyncutil": "jsr:@core/asyncutil@^1.2.0",
|
||||
"@electric-sql/pglite": "npm:@electric-sql/pglite@^0.2.8",
|
||||
"@esroyo/scoped-performance": "jsr:@esroyo/scoped-performance@^3.1.0",
|
||||
"@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2",
|
||||
"@hono/hono": "jsr:@hono/hono@^4.4.6",
|
||||
"@db/sqlite": "jsr:@db/sqlite@^0.11.1",
|
||||
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
|
||||
"@negrel/webpush": "jsr:@negrel/webpush@^0.3.0",
|
||||
"@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
|
||||
"@nostrify/db": "jsr:@nostrify/db@^0.39.4",
|
||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.39.1",
|
||||
"@nostrify/policies": "jsr:@nostrify/policies@^0.36.1",
|
||||
"@nostrify/types": "jsr:@nostrify/types@^0.36.0",
|
||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.23.2",
|
||||
"@scure/base": "npm:@scure/base@^1.1.6",
|
||||
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
|
||||
"@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0",
|
||||
"@soapbox/logi": "jsr:@soapbox/logi@^0.3.0",
|
||||
"@soapbox/safe-fetch": "jsr:@soapbox/safe-fetch@^2.0.0",
|
||||
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
|
||||
"@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0",
|
||||
"@std/assert": "jsr:@std/assert@^0.225.1",
|
||||
"@std/async": "jsr:@std/async@^1.0.10",
|
||||
"@std/cli": "jsr:@std/cli@^0.223.0",
|
||||
"@std/crypto": "jsr:@std/crypto@^0.224.0",
|
||||
"@std/dotenv": "jsr:@std/dotenv@^0.224.0",
|
||||
"@std/encoding": "jsr:@std/encoding@^0.224.0",
|
||||
"@std/fs": "jsr:@std/fs@^0.229.3",
|
||||
"@std/json": "jsr:@std/json@^0.223.0",
|
||||
"@std/media-types": "jsr:@std/media-types@^0.224.1",
|
||||
"@std/streams": "jsr:@std/streams@^0.223.0",
|
||||
"@std/testing": "jsr:@std/testing@^1.0.9",
|
||||
"blurhash": "npm:blurhash@2.0.5",
|
||||
"comlink": "npm:comlink@^4.4.1",
|
||||
"comlink-async-generator": "npm:comlink-async-generator@^0.0.1",
|
||||
"commander": "npm:commander@12.1.0",
|
||||
"deno-safe-fetch/load": "https://gitlab.com/soapbox-pub/deno-safe-fetch/-/raw/v1.0.0/load.ts",
|
||||
"entities": "npm:entities@^4.5.0",
|
||||
"fast-stable-stringify": "npm:fast-stable-stringify@^1.0.0",
|
||||
"formdata-helper": "npm:formdata-helper@^0.3.0",
|
||||
"hono-rate-limiter": "npm:hono-rate-limiter@^0.3.0",
|
||||
"iso-639-1": "npm:iso-639-1@^3.1.5",
|
||||
"isomorphic-dompurify": "npm:isomorphic-dompurify@^2.16.0",
|
||||
"kysely": "npm:kysely@^0.27.4",
|
||||
"kysely-postgres-js": "npm:kysely-postgres-js@2.0.0",
|
||||
"lande": "npm:lande@^1.0.10",
|
||||
"light-bolt11-decoder": "npm:light-bolt11-decoder",
|
||||
"hono": "https://deno.land/x/hono@v3.10.1/mod.ts",
|
||||
"hono/middleware": "https://deno.land/x/hono@v3.10.1/middleware.ts",
|
||||
"iso-639-1": "npm:iso-639-1@2.1.15",
|
||||
"isomorphic-dompurify": "npm:isomorphic-dompurify@^2.11.0",
|
||||
"kysely": "npm:kysely@^0.27.3",
|
||||
"kysely_deno_postgres": "https://gitlab.com/soapbox-pub/kysely-deno-postgres/-/raw/main/mod.ts",
|
||||
"linkify-plugin-hashtag": "npm:linkify-plugin-hashtag@^4.1.1",
|
||||
"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",
|
||||
"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",
|
||||
"sharp": "npm:sharp@^0.33.5",
|
||||
"tldts": "npm:tldts@^6.0.14",
|
||||
"tseep": "npm:tseep@^1.2.1",
|
||||
"type-fest": "npm:type-fest@^4.3.0",
|
||||
|
|
@ -116,7 +63,15 @@
|
|||
"zod": "npm:zod@^3.23.8",
|
||||
"~/fixtures/": "./fixtures/"
|
||||
},
|
||||
"lint": {
|
||||
"include": ["src/", "scripts/"],
|
||||
"rules": {
|
||||
"tags": ["recommended"],
|
||||
"exclude": ["no-explicit-any"]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"include": ["src/", "scripts/"],
|
||||
"useTabs": false,
|
||||
"lineWidth": 120,
|
||||
"indentWidth": 2,
|
||||
|
|
|
|||
23
docs/auth.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Authentication in Ditto
|
||||
|
||||
One of the main benefits of Nostr is that users control their keys. Instead of a username and password, the user has a public key (`npub` or `pubkey`) and private key (`nsec`). The public key is a globally-unique identifier for the user, and the private key can be used to sign events, producing a signature that only the pubkey could have produced.
|
||||
|
||||
With keys, users have full control over their identity. They can move between servers freely, and post to multiple servers at once. But with such power comes great responsibilities. Users cannot lose control of their key, or they'll lose control over their account forever.
|
||||
|
||||
## Managing Keys
|
||||
|
||||
There are several ways to manage keys in Nostr, and they all come with trade-offs. It's new territory, and people are still coming up with new ideas.
|
||||
|
||||
The main concerns are how to **conveniently log in on multiple devices**, and **who/what to trust with your key.**
|
||||
|
||||
### Current Solutions
|
||||
|
||||
1. **Private key text.** Users copy their key between devices/apps, giving apps full control over their key. Users might email the key to themselves, or better yet use a password manager, or apps might even provide a QR code for other apps to scan. This method is convenient, but it's not secure. Keys can get compromised in transit, or by a malicious or vulnerable app.
|
||||
|
||||
2. **Browser extension.** For web clients, an extension can expose `getPublicKey` and `signEvent` functions to web-pages without exposing the private key directly. This option is secure, but it only works well for laptop/desktop devices. On mobile, only FireFox can do it, with no support from Safari or Chrome. It also offers no way to share a key across devices on its own.
|
||||
|
||||
3. **Remote signer**. Users can run a remote signer program and then connect apps to it. The signer should be running 24/7, so it's best suited for running on a server. This idea has evolved into the creation of "bunker" services. Bunkers allow users to have a traditional username and password and login from anywhere. This method solves a lot of problems, but it also creates some problems. Users have to create an account on a separate website before they can log into your website. This makes it an option for more advanced users. Also, it's concerning that the administrator of the bunker server has full control over your keys. None of this is a problem if you run your own remote signer, but it's not a mainstream option.
|
||||
|
||||
4. **Custodial**. Apps which make you log you in with a username/password, and then keep Nostr keys for each user in their database. You might not even be able to export your keys. This option may be easier for users at first, but it puts a whole lot of liability on the server, since leaks can cause permanent damage. It also gives up a lot of the benefits of Nostr.
|
||||
|
||||
Each of these ideas could be improved upon greatly with new experiments and technical progress. But to Ditto, user freedom matters the most, so we're focusing on non-custodial solution. Even though there are security risks to copying around keys, the onus is on the user. The user may fall victim to a targeted attack (or make a stupid mistake), whereas custodial servers have the ability to wipe out entire demographics of users at once. Therefore we believe that custodial solutions are actually _less_ secure than users copying around keys. Users must take precautions about which apps to trust with their private key until we improve upon the area to make it more secure (likely with better support of browser extensions, OS key management, and more).
|
||||
27
docs/debugging.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Debugging Ditto
|
||||
|
||||
Running the command `deno task debug` will start the Ditto server in debug mode, making it possible to inspect with Chromium-based browsers by visiting `chrome://inspect`.
|
||||
|
||||
From there, go to the "Performance" tab and click "Start profiling". Perform the actions you want to profile, then click "Stop profiling". You can then inspect the call stack and see where the time is being spent.
|
||||
|
||||
## Remote debugging
|
||||
|
||||
If the Ditto server is on a separate machine, you will first need to put it into debug mode. Edit its systemd file (usually located at `/etc/systemd/system/ditto.service`) and change `deno task start` to `deno task debug` in the `ExecStart` line. Then run `systemctl daemon-reload` and `systemctl restart ditto`.
|
||||
|
||||
To access the debugger remotely, you can use SSH port forwarding. Run this command on your local machine, replacing `<user>@<host>` with the SSH login for the remote machine:
|
||||
|
||||
```sh
|
||||
ssh -L 9229:localhost:9229 <user>@<host>
|
||||
```
|
||||
|
||||
Then, in Chromium, go to `chrome://inspect` and the Ditto server should be available.
|
||||
|
||||
## SQLite performance
|
||||
|
||||
To track slow queries, first set `DEBUG=ditto:sqlite.worker` in the environment so only SQLite logs are shown.
|
||||
|
||||
Then, grep for any logs above 0.001s:
|
||||
|
||||
```sh
|
||||
journalctl -fu ditto | grep -v '(0.00s)'
|
||||
```
|
||||
15
docs/installation.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Installing Ditto
|
||||
|
||||
First, install Deno:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://deno.land/x/install/install.sh | sudo DENO_INSTALL=/usr/local sh
|
||||
```
|
||||
|
||||
Now, run Ditto:
|
||||
|
||||
```sh
|
||||
deno run -A https://gitlab.com/soapbox-pub/ditto/-/raw/main/src/server.ts
|
||||
```
|
||||
|
||||
That's it! Ditto is now running on your machine.
|
||||
9
docs/mastodon-api.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Mastodon API
|
||||
|
||||
Ditto implements Mastodon's client-server API, a REST API used by Mastodon mobile apps and frontends to interact with Mastodon servers. While it was originally designed for Mastodon, it has been adopted by other ActivityPub servers such as Pleroma, Mitra, Friendica, and many others.
|
||||
|
||||
Note that Mastodon API is **not** ActivityPub. It is not the API used to federate between servers. Instead, it enables user interfaces, mobile apps, bots, and other clients to interact with Mastodon servers.
|
||||
|
||||
Mastodon is built in Ruby on Rails, and its API is inspired by Twitter's legacy REST API. Rails, being an MVC framework, has "models", which it maps directly to "Entities" in its API.
|
||||
|
||||
Endpoints return either a single Entity, or an array of Entities. Entities Entities are JSON objects with a specific structure, and are documented in the [Mastodon API documentation](https://docs.joinmastodon.org/api/).
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
{
|
||||
"configs": [{
|
||||
"db": [
|
||||
":soapbox_fe"
|
||||
],
|
||||
"group": ":pleroma",
|
||||
"key": ":frontend_configurations",
|
||||
"value": [
|
||||
{
|
||||
"tuple": [
|
||||
":pleroma_fe",
|
||||
{
|
||||
":alwaysShowSubjectInput": true,
|
||||
":background": "/images/city.jpg",
|
||||
":collapseMessageWithSubject": false,
|
||||
":disableChat": false,
|
||||
":greentext": false,
|
||||
":hideFilteredStatuses": false,
|
||||
":hideMutedPosts": false,
|
||||
":hidePostStats": false,
|
||||
":hideSitename": false,
|
||||
":hideUserStats": false,
|
||||
":loginMethod": "password",
|
||||
":logo": "/static/logo.svg",
|
||||
":logoMargin": ".1em",
|
||||
":logoMask": true,
|
||||
":minimalScopesMode": false,
|
||||
":noAttachmentLinks": false,
|
||||
":nsfwCensorImage": "",
|
||||
":postContentType": "text/plain",
|
||||
":redirectRootLogin": "/main/friends",
|
||||
":redirectRootNoLogin": "/main/all",
|
||||
":scopeCopy": true,
|
||||
":showFeaturesPanel": true,
|
||||
":showInstanceSpecificPanel": false,
|
||||
":sidebarRight": false,
|
||||
":subjectLineBehavior": "email",
|
||||
":theme": "pleroma-dark",
|
||||
":webPushNotifications": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tuple": [
|
||||
":soapbox_fe",
|
||||
{
|
||||
"aboutPages": {},
|
||||
"ads": [
|
||||
{
|
||||
"card": {
|
||||
"height": 564,
|
||||
"image": "https://media.gleasonator.com/3c331456d0d0f9f9ad91eab0efbb4df22a044f92bdf6ef349b26de97db5ca3bd.png",
|
||||
"type": "link",
|
||||
"url": "https://www.veganbodybuilding.com/",
|
||||
"width": 564
|
||||
}
|
||||
},
|
||||
{
|
||||
"card": {
|
||||
"height": 250,
|
||||
"image": "https://media.gleasonator.com/22590c7cb3edd8ac82660301be980c6fcad6b96a320e24f709ad0571a29ea0aa.png",
|
||||
"type": "link",
|
||||
"url": "https://poa.st",
|
||||
"width": 300
|
||||
}
|
||||
}
|
||||
],
|
||||
"allowedEmoji": [
|
||||
"👍",
|
||||
"⚡",
|
||||
"❤",
|
||||
"😂",
|
||||
"😯",
|
||||
"😢",
|
||||
"😡"
|
||||
],
|
||||
"authenticatedProfile": false,
|
||||
"banner": "",
|
||||
"betaPages": {},
|
||||
"brandColor": "#1ca82b",
|
||||
"colors": {
|
||||
"accent": {
|
||||
"100": "#eafae7",
|
||||
"200": "#caf4c3",
|
||||
"300": "#6bdf58",
|
||||
"400": "#55da40",
|
||||
"50": "#f4fdf3",
|
||||
"500": "#2bd110",
|
||||
"600": "#27bc0e",
|
||||
"700": "#209d0c",
|
||||
"800": "#0d3f05",
|
||||
"900": "#082803"
|
||||
},
|
||||
"accent-blue": "#199727",
|
||||
"danger": {
|
||||
"100": "#fee2e2",
|
||||
"200": "#fecaca",
|
||||
"300": "#fca5a5",
|
||||
"400": "#f87171",
|
||||
"50": "#fef2f2",
|
||||
"500": "#ef4444",
|
||||
"600": "#dc2626",
|
||||
"700": "#b91c1c",
|
||||
"800": "#991b1b",
|
||||
"900": "#7f1d1d"
|
||||
},
|
||||
"gradient-end": "#2bd110",
|
||||
"gradient-start": "#1ca82b",
|
||||
"gray": {
|
||||
"100": "#f1f6f2",
|
||||
"200": "#dde8de",
|
||||
"300": "#9ebfa2",
|
||||
"400": "#91b595",
|
||||
"50": "#f8faf8",
|
||||
"500": "#75a37a",
|
||||
"600": "#69936e",
|
||||
"700": "#4c504c",
|
||||
"800": "#233125",
|
||||
"900": "#161f17"
|
||||
},
|
||||
"greentext": "#789922",
|
||||
"primary": {
|
||||
"100": "#e8f6ea",
|
||||
"200": "#c6e9ca",
|
||||
"300": "#60c26b",
|
||||
"400": "#49b955",
|
||||
"50": "#f4fbf4",
|
||||
"500": "#1ca82b",
|
||||
"600": "#199727",
|
||||
"700": "#157e20",
|
||||
"800": "#08320d",
|
||||
"900": "#052008"
|
||||
},
|
||||
"secondary": {
|
||||
"100": "#eafae7",
|
||||
"200": "#cef4c3",
|
||||
"300": "#7cdf58",
|
||||
"400": "#71da40",
|
||||
"50": "#f9fdf3",
|
||||
"500": "#359713",
|
||||
"600": "#4ebc0e",
|
||||
"700": "#2f9d0c",
|
||||
"800": "#173f05",
|
||||
"900": "#282828"
|
||||
},
|
||||
"success": {
|
||||
"100": "#dcfce7",
|
||||
"200": "#bbf7d0",
|
||||
"300": "#86efac",
|
||||
"400": "#4ade80",
|
||||
"50": "#f0fdf4",
|
||||
"500": "#22c55e",
|
||||
"600": "#16a34a",
|
||||
"700": "#15803d",
|
||||
"800": "#166534",
|
||||
"900": "#14532d"
|
||||
}
|
||||
},
|
||||
"copyright": "♥2022. Copying is an act of love. Please copy and share.",
|
||||
"cryptoAddresses": [
|
||||
{
|
||||
"address": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n",
|
||||
"ticker": "btc"
|
||||
},
|
||||
{
|
||||
"address": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717",
|
||||
"ticker": "eth"
|
||||
},
|
||||
{
|
||||
"address": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D",
|
||||
"ticker": "doge"
|
||||
},
|
||||
{
|
||||
"address": "0x541a45cb212b57f41393427fb15335fc89c35851",
|
||||
"ticker": "ubq"
|
||||
},
|
||||
{
|
||||
"address": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK",
|
||||
"ticker": "xmr"
|
||||
},
|
||||
{
|
||||
"address": "ltc1qda645jdf4jszwxcvsn32ykdhemvlx7yl9n5gz9",
|
||||
"ticker": "ltc"
|
||||
},
|
||||
{
|
||||
"address": "bitcoincash:qpcfnm9w8uemax38yqhyg58zn2ptpf6szvkr0n48a7",
|
||||
"ticker": "bch"
|
||||
},
|
||||
{
|
||||
"address": "XnB5p4JvL3So91A1c1MERozZEjeMSsAD7J",
|
||||
"ticker": "dash"
|
||||
},
|
||||
{
|
||||
"address": "t1PHZX5ZjY7y61iC19A958W9hdyH3SiLJuF",
|
||||
"ticker": "zec"
|
||||
},
|
||||
{
|
||||
"address": "0xB81BAEE10d163404a1c60045a872a0da9E258465",
|
||||
"ticker": "etc"
|
||||
},
|
||||
{
|
||||
"address": "AGTLRXapPYpxt3PLdiXEs8y4kLw6Qy3C4t",
|
||||
"ticker": "btg"
|
||||
},
|
||||
{
|
||||
"address": "SbQcFUDi7kKyxkmskzW3w74x68H5eUrg76",
|
||||
"ticker": "dgb"
|
||||
},
|
||||
{
|
||||
"address": "N7nompUVxz5ATrzRVTzw7CaAJoSiVtEcQx",
|
||||
"ticker": "nmc"
|
||||
},
|
||||
{
|
||||
"address": "3AQcUgCbF6ymiR4HGCU8ANx9SqbzL6nx8r",
|
||||
"ticker": "vtc"
|
||||
}
|
||||
],
|
||||
"cryptoDonatePanel": {
|
||||
"limit": 1
|
||||
},
|
||||
"customCss": [],
|
||||
"defaultSettings": {
|
||||
"themeMode": "system"
|
||||
},
|
||||
"displayFqn": true,
|
||||
"extensions": {
|
||||
"ads": {
|
||||
"enabled": false,
|
||||
"interval": 40,
|
||||
"provider": "soapbox"
|
||||
},
|
||||
"patron": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"accountAliases": true
|
||||
},
|
||||
"feedInjection": true,
|
||||
"gdpr": false,
|
||||
"greentext": true,
|
||||
"logo": "https://media.gleasonator.com/0c760b3ecdbc993ba47b785d0adecf0ec71fd9c59808e27d0665b9f77a32d8de.png",
|
||||
"mediaPreview": false,
|
||||
"mobilePages": {},
|
||||
"navlinks": {
|
||||
"homeFooter": [
|
||||
{
|
||||
"title": "About",
|
||||
"url": "/about"
|
||||
},
|
||||
{
|
||||
"title": "Terms of Service",
|
||||
"url": "/about/tos"
|
||||
},
|
||||
{
|
||||
"title": "Privacy Policy",
|
||||
"url": "/about/privacy"
|
||||
},
|
||||
{
|
||||
"title": "DMCA",
|
||||
"url": "/about/dmca"
|
||||
},
|
||||
{
|
||||
"title": "Source Code",
|
||||
"url": "/about#opensource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"promoPanel": {
|
||||
"items": [
|
||||
{
|
||||
"icon": "music",
|
||||
"text": "Gleasonator theme song",
|
||||
"url": "https://media.gleasonator.com/custom/261905_gleasonator_song.mp3"
|
||||
}
|
||||
]
|
||||
},
|
||||
"redirectRootNoLogin": "",
|
||||
"sentryDsn": "https://95c1dd3284d7284134928059844ba086@o4505999744499712.ingest.sentry.io/4505999904931840",
|
||||
"singleUserMode": false,
|
||||
"singleUserModeProfile": "",
|
||||
"tileServer": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"tileServerAttribution": "© OpenStreetMap Contributors",
|
||||
"verifiedCanEditName": true,
|
||||
"verifiedIcon": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
|
|
@ -6,4 +6,4 @@
|
|||
"tags": [],
|
||||
"content": "{\"name\":\"patrickReiis\",\"picture\":\"https://void.cat/d/EMs8Qdn5wsAMrZ5T9T44sz.webp\"}",
|
||||
"sig": "cedbd2585c18c9ee8cbafa4e3b1fefbe68cc15deeabcb0519791c6d715f92d1439ca9ac7584185a94d521709f9023fcbafab47a074a7ce8a247d3ce4dfce8af3"
|
||||
}
|
||||
}
|
||||
|
|
@ -12,4 +12,4 @@
|
|||
],
|
||||
"content": "{\"name\":\"Alex Gleason\",\"about\":\"I create Fediverse software that empowers people online.\\n\\nI'm vegan btw.\\n\\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.\",\"picture\":\"https://media.gleasonator.com/aae0071188681629f200ab41502e03b9861d2754a44c008d3869c8a08b08d1f1.png\",\"banner\":\"https://media.gleasonator.com/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif\",\"nip05\":\"alex_at_gleasonator.com@mostr.pub\",\"lud16\":\"alex@alexgleason.me\"}",
|
||||
"sig": "9d48bbb600aab44abaeee11c97f1753f1d7de08378e9b33d84f9be893a09270aeceecfde3cfb698c555ae1bde3e4e54b3463a61bb99bdf673d64c2202f98b0e9"
|
||||
}
|
||||
}
|
||||
|
|
@ -24,4 +24,4 @@
|
|||
],
|
||||
"content": "The Bitcoin Lottery is free to play, and you can win millions! Unlimited tries!\n\nJust guess 12 words mnemonic seed phrase words.",
|
||||
"sig": "b76264f9a7ec0860a9dd3b72f94e81ed6c0d848eee2bc5cc89b78b1cb1b4e00243f0f354c0185824fe16eb16cfcab511275388b6acd29e0d05d97dea1564d5be"
|
||||
}
|
||||
}
|
||||
|
|
@ -12,4 +12,4 @@
|
|||
"created_at": 1691091365,
|
||||
"id": "55920b758b9c7b17854b6e3d44e6a02a83d1cb49e1227e75a30426dea94d4cb2",
|
||||
"sig": "a72f12c08f18e85d98fb92ae89e2fe63e48b8864c5e10fbdd5335f3c9f936397a6b0a7350efe251f8168b1601d7012d4a6d0ee6eec958067cf22a14f5a5ea579"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"kind": 0,
|
||||
"id": "f7b1a3ca3fa77bffded2024568da939e8cd3ed2403004e1ecb56d556f299ad2a",
|
||||
"pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
|
||||
"created_at": 1715441226,
|
||||
"tags": [],
|
||||
"content": "{\"banner\":\"https:\\/\\/m.primal.net\\/IBZO.jpg\",\"website\":\"\",\"picture\":\"https:\\/\\/image.nostr.build\\/26867ce34e4b11f0a1d083114919a9f4eca699f3b007454c396ef48c43628315.jpg\",\"lud06\":\"\",\"display_name\":\"\",\"lud16\":\"jack@primal.net\",\"nip05\":\"\",\"name\":\"jack\",\"about\":\"bitcoin \u0026 chill\"}",
|
||||
"sig": "9792ceb1e9c73a6c2140540ddbac4279361cae4cc41888019d9dd47d09c1e7cee55948f6e1af824fa0f856d892686352bc757ad157f766f0da656d5e80b38bc7"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"kind": 0,
|
||||
"id": "34bc588a4ff5ca8570a1ad4114485239f83c135b09636dbc16df338f73079e42",
|
||||
"pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4",
|
||||
"created_at": 1726076335,
|
||||
"tags": [],
|
||||
"content": "{\"about\":\"Coding with nature's inspiration, embracing solitude's wisdom. Team Soapbox.\",\"bot\":false,\"lud16\":\"patrickreiis@getalby.com\",\"name\":\"patrickReiis\",\"nip05\":\"patrick@patrickdosreis.com\",\"picture\":\"https://image.nostr.build/2177817a323ed8a58d508fb25160e1c2f38f60256125b764c82c988869916e84.jpg\",\"website\":\"https://patrickdosreis.com/\",\"pubkey\":\"47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4\",\"npub\":\"npub1gujeqakgt7fyp6zjggxhyy7ft623qtcaay5lkc8n8gkry4cvnrzqd3f67z\",\"created_at\":1717600965}",
|
||||
"sig": "2780887e58d6e59cc9c03cca8a583bc121d2c74d98cc434d22e65c1f56da1bb09d79fc7cc3c4ee5b829773c17d6f482b114dc951c1683c3908cedff783d785ad"
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"kind": 1,
|
||||
"id": "02e52f80e2e6a3ad0993e9c4a7b4e6afc79d067c6ff9c6df3fb2896342dee2df",
|
||||
"pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4",
|
||||
"created_at": 1724609131,
|
||||
"tags": [
|
||||
["e", "677c701036eae20632a7677ee6eece0c62e259d5c72864d78a3bbe419c0d2d2c", "wss://gleasonator.dev/relay", "root"],
|
||||
["e", "677c701036eae20632a7677ee6eece0c62e259d5c72864d78a3bbe419c0d2d2c", "wss://gleasonator.dev/relay", "reply"],
|
||||
["p", "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"]
|
||||
],
|
||||
"content": "Please I don't want to go back to the shoe factory",
|
||||
"sig": "ce6ca329701eec5db0b182bd52c48777b9eccaac298180a6601d8c5156060d944768d71376e7d24c24cefb6619d1467f6a30e0ca574d68f748b38c784e4ced59"
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"kind": 10002,
|
||||
"id": "68fc04e23b07219f153a10947663b9dd7b271acbc03b82200e364e35de3e0bdd",
|
||||
"pubkey": "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd",
|
||||
"created_at": 1714969354,
|
||||
"tags": [
|
||||
[
|
||||
"r",
|
||||
"wss://gleasonator.dev/relay"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://nosdrive.app/relay"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://relay.mostr.pub/"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://relay.primal.net/"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://relay.snort.social/"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
],
|
||||
"content": "",
|
||||
"sig": "cb7b1a75fe015d5c9481651379365bd5d098665b1bc7a453522177e2686eaa83581ec36f7a17429aad2541dad02c2c81023b81612f87f28fc57447fef1efab13"
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"kind": 9735,
|
||||
"id": "a57d30d59e7442f9a2ad329400a6cbf29c2b34b1e69e4cdce8bc2fe751d9268f",
|
||||
"pubkey": "79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432",
|
||||
"created_at": 1724610766,
|
||||
"tags": [
|
||||
["p", "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4"],
|
||||
["e", "02e52f80e2e6a3ad0993e9c4a7b4e6afc79d067c6ff9c6df3fb2896342dee2df"],
|
||||
["P", "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"],
|
||||
[
|
||||
"bolt11",
|
||||
"lnbc52250n1pnvk7xvpp5l776w7354zz9mh7sf3dlq8znkfjhysse9dwda9c7se7jwpglng0qhp5jp5cqy7n7wz9jlvd0aa40ws0d3e78l4ug2pzfen2m56mwg0qahrscqzzsxqyz5vqsp5v30pn2u86h3mz69wlvmu9vam9wudlnt4fv9wcxn24s6vrkj842gq9qxpqysgqw9mfxpyce3fhfue8p88exx8g6gn5ut9c2tz8awnw377dmhqymszrsjg49waxprkd6ggdzn90dwpgjwhdtx45052ukylkwvu5q05w5lspyjpg37"
|
||||
],
|
||||
["preimage", "18264e7cce0b91bfd2016362e8a239591674c0f51ffa152acf5d73edac675432"],
|
||||
[
|
||||
"description",
|
||||
"{\"id\":\"092cd6341b42604b8e908f5bed45cbd60d98bff33258ab4f83f24a7fad445065\",\"pubkey\":\"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2\",\"created_at\":1724610762,\"kind\":9734,\"tags\":[[\"p\",\"47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4\"],[\"e\",\"02e52f80e2e6a3ad0993e9c4a7b4e6afc79d067c6ff9c6df3fb2896342dee2df\"],[\"amount\",\"5225000\"],[\"relays\",\"wss://relay.exit.pub\",\"wss://relay.damus.io\",\"wss://nos.lol\",\"wss://relay.mostr.pub\",\"wss://relay.primal.net\"]],\"content\":\"🫂\",\"sig\":\"84a36873000d5003c85c56996be856c598e91f66bf2cae9ee9d984892a11774310acf81eae2b40e9fbf25040b91239e840f856c44b68be2d23e4451fa6c5762a\"}"
|
||||
]
|
||||
],
|
||||
"content": "🫂",
|
||||
"sig": "087adfe3c5831e2d760678b2929f35340c35662929acb8050f0956a2a95ba2917bf610f921e3d3fc0c08a123c6f721574eb80ca469fe7e33b6581e976844bfcc"
|
||||
}
|
||||
|
|
@ -31,4 +31,4 @@
|
|||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -26,4 +26,4 @@
|
|||
"dimensionsString": "0x0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +1 @@
|
|||
{
|
||||
"authors": [
|
||||
{
|
||||
"pubkey": "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4",
|
||||
"followers_count": 1386,
|
||||
"following_count": 2108,
|
||||
"notes_count": 805
|
||||
},
|
||||
{
|
||||
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
||||
"followers_count": 7420,
|
||||
"following_count": 478,
|
||||
"notes_count": 446
|
||||
},
|
||||
{
|
||||
"pubkey": "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24",
|
||||
"followers_count": 6999,
|
||||
"following_count": 1428,
|
||||
"notes_count": 801
|
||||
},
|
||||
{
|
||||
"pubkey": "7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19",
|
||||
"followers_count": 535,
|
||||
"following_count": 962,
|
||||
"notes_count": 59
|
||||
},
|
||||
{
|
||||
"pubkey": "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322",
|
||||
"followers_count": 4199,
|
||||
"following_count": 398,
|
||||
"notes_count": 176
|
||||
},
|
||||
{
|
||||
"pubkey": "9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e",
|
||||
"followers_count": 695,
|
||||
"following_count": 242,
|
||||
"notes_count": 49
|
||||
},
|
||||
{
|
||||
"pubkey": "9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b",
|
||||
"followers_count": 614,
|
||||
"following_count": 301,
|
||||
"notes_count": 566
|
||||
},
|
||||
{
|
||||
"pubkey": "c37b6a82a98de368c104bbc6da365571ec5a263b07057d0a3977b4c05afa7e63",
|
||||
"followers_count": 270,
|
||||
"following_count": 361,
|
||||
"notes_count": 589
|
||||
},
|
||||
{
|
||||
"pubkey": "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
|
||||
"followers_count": 6902,
|
||||
"following_count": 1,
|
||||
"notes_count": 536
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"event_id": "1c9ca83dfdc96bd795e0420904bdfeb81d9434aa69c88ce773f0e6e849b8e6cd",
|
||||
"reposts_count": 0,
|
||||
"replies_count": 0,
|
||||
"reactions_count": 3,
|
||||
"reactions": "{\"🔥\":2,\"🤙\":1}"
|
||||
},
|
||||
{
|
||||
"event_id": "2a6860b01ac1cb31c081a6cf93d2d83f7bb4a54d669414db61ead5602688a03e",
|
||||
"reposts_count": 0,
|
||||
"replies_count": 0,
|
||||
"reactions_count": 2,
|
||||
"reactions": "{\"🧡\":1,\"+\":1}"
|
||||
},
|
||||
{
|
||||
"event_id": "b1ed8ea342d0023a8bedcb79de77633f8f550a21e363e707dd260e411977cff4",
|
||||
"reposts_count": 0,
|
||||
"replies_count": 0,
|
||||
"reactions_count": 4,
|
||||
"reactions": "{\"🔥\":2,\"+\":2}"
|
||||
},
|
||||
{
|
||||
"event_id": "b8d7eff16cd1ead7c28032f9e36fd4ef2e29682f84e2a89f2fca8c2bec13385d",
|
||||
"reposts_count": 1,
|
||||
"replies_count": 0,
|
||||
"reactions_count": 4,
|
||||
"reactions": "{\"🤙\":1,\"+\":2,\"👌\":1}"
|
||||
},
|
||||
{
|
||||
"event_id": "cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb",
|
||||
"reposts_count": 2,
|
||||
"replies_count": 0,
|
||||
"reactions_count": 5,
|
||||
"reactions": "{\"💜\":1,\"🤙\":3,\"+\":1}"
|
||||
},
|
||||
{
|
||||
"event_id": "f331dc1c3985cf76b997dc9fadcb46241ba0ccb9d20159b0a3ca6f77f4316f58",
|
||||
"reposts_count": 0,
|
||||
"replies_count": 0,
|
||||
"reactions_count": 1,
|
||||
"reactions": "{\"+\":1}"
|
||||
}
|
||||
]
|
||||
}
|
||||
{"authors":[{"pubkey":"17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4","followers_count":1386,"following_count":2108,"notes_count":805},{"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","followers_count":7420,"following_count":478,"notes_count":446},{"pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","followers_count":6999,"following_count":1428,"notes_count":801},{"pubkey":"7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19","followers_count":535,"following_count":962,"notes_count":59},{"pubkey":"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322","followers_count":4199,"following_count":398,"notes_count":176},{"pubkey":"9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e","followers_count":695,"following_count":242,"notes_count":49},{"pubkey":"9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b","followers_count":614,"following_count":301,"notes_count":566},{"pubkey":"c37b6a82a98de368c104bbc6da365571ec5a263b07057d0a3977b4c05afa7e63","followers_count":270,"following_count":361,"notes_count":589},{"pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","followers_count":6902,"following_count":1,"notes_count":536}],"events":[{"event_id":"1c9ca83dfdc96bd795e0420904bdfeb81d9434aa69c88ce773f0e6e849b8e6cd","reposts_count":0,"replies_count":0,"reactions_count":3,"reactions":"{\"🔥\":2,\"🤙\":1}"},{"event_id":"2a6860b01ac1cb31c081a6cf93d2d83f7bb4a54d669414db61ead5602688a03e","reposts_count":0,"replies_count":0,"reactions_count":2,"reactions":"{\"🧡\":1,\"+\":1}"},{"event_id":"b1ed8ea342d0023a8bedcb79de77633f8f550a21e363e707dd260e411977cff4","reposts_count":0,"replies_count":0,"reactions_count":4,"reactions":"{\"🔥\":2,\"+\":2}"},{"event_id":"b8d7eff16cd1ead7c28032f9e36fd4ef2e29682f84e2a89f2fca8c2bec13385d","reposts_count":1,"replies_count":0,"reactions_count":4,"reactions":"{\"🤙\":1,\"+\":2,\"👌\":1}"},{"event_id":"cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb","reposts_count":2,"replies_count":0,"reactions_count":5,"reactions":"{\"💜\":1,\"🤙\":3,\"+\":1}"},{"event_id":"f331dc1c3985cf76b997dc9fadcb46241ba0ccb9d20159b0a3ca6f77f4316f58","reposts_count":0,"replies_count":0,"reactions_count":1,"reactions":"{\"+\":1}"}]}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# Cloudflare real IP configuration for rate-limiting
|
||||
# {
|
||||
# servers {
|
||||
# # https://www.cloudflare.com/ips/
|
||||
# trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32
|
||||
# trusted_proxies_strict
|
||||
# }
|
||||
# }
|
||||
|
||||
example.com {
|
||||
log
|
||||
request_header X-Real-IP {client_ip}
|
||||
|
||||
@public path /packs/* /instance/* /images/* /favicon.ico /sw.js /sw.js.map
|
||||
|
||||
handle /packs/* {
|
||||
root * /opt/ditto/public
|
||||
header Cache-Control "max-age=31536000, public, immutable"
|
||||
file_server
|
||||
}
|
||||
|
||||
handle @public {
|
||||
root * /opt/ditto/public
|
||||
file_server
|
||||
}
|
||||
|
||||
handle /metrics {
|
||||
respond "Access denied" 403
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy :4036
|
||||
}
|
||||
}
|
||||
|
|
@ -24,13 +24,24 @@ server {
|
|||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
root /opt/ditto/public;
|
||||
|
||||
location @spa {
|
||||
try_files /index.html /dev/null;
|
||||
}
|
||||
|
||||
location @frontend {
|
||||
try_files $uri @ditto-static;
|
||||
}
|
||||
|
||||
location @ditto-static {
|
||||
root /opt/ditto/static;
|
||||
try_files $uri @spa;
|
||||
}
|
||||
|
||||
location /packs {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
|
@ -42,13 +53,16 @@ server {
|
|||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /metrics {
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
location = /favicon.ico {
|
||||
root /opt/ditto/static;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/(api|relay|oauth|manifest.json|nodeinfo|.well-known/(nodeinfo|nostr.json)) {
|
||||
proxy_pass http://ditto;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://ditto;
|
||||
try_files /dev/null @frontend;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ After=network-online.target
|
|||
[Service]
|
||||
Type=simple
|
||||
User=ditto
|
||||
SyslogIdentifier=ditto
|
||||
WorkingDirectory=/opt/ditto
|
||||
ExecStart=/usr/local/bin/deno task start
|
||||
Restart=on-failure
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { assert } from '@std/assert';
|
||||
|
||||
import { getCaptchaImages } from './assets.ts';
|
||||
|
||||
Deno.test('getCaptchaImages', async () => {
|
||||
// If this function runs at all, it most likely worked.
|
||||
const { bgImages } = await getCaptchaImages();
|
||||
assert(bgImages.length);
|
||||
});
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { type Image, loadImage } from '@gfx/canvas-wasm';
|
||||
|
||||
export interface CaptchaImages {
|
||||
bgImages: Image[];
|
||||
puzzleMask: Image;
|
||||
puzzleHole: Image;
|
||||
}
|
||||
|
||||
export async function getCaptchaImages(): Promise<CaptchaImages> {
|
||||
const bgImages = await getBackgroundImages();
|
||||
|
||||
const puzzleMask = await loadImage(
|
||||
await Deno.readFile(new URL('./assets/puzzle/puzzle-mask.png', import.meta.url)),
|
||||
);
|
||||
const puzzleHole = await loadImage(
|
||||
await Deno.readFile(new URL('./assets/puzzle/puzzle-hole.png', import.meta.url)),
|
||||
);
|
||||
|
||||
return { bgImages, puzzleMask, puzzleHole };
|
||||
}
|
||||
|
||||
async function getBackgroundImages(): Promise<Image[]> {
|
||||
const path = new URL('./assets/bg/', import.meta.url);
|
||||
|
||||
const images: Image[] = [];
|
||||
|
||||
for await (const dirEntry of Deno.readDir(path)) {
|
||||
if (dirEntry.isFile && dirEntry.name.endsWith('.jpg')) {
|
||||
const file = await Deno.readFile(new URL(dirEntry.name, path));
|
||||
const image = await loadImage(file);
|
||||
images.push(image);
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
|
@ -1,22 +0,0 @@
|
|||
Unsplash photos published before June 8, 2017 are CC0 (public domain):
|
||||
|
||||
Ashim D'Silva <https://unsplash.com/photos/WeYamle9fDM>
|
||||
Canazei Granite Ridges <https://unsplash.com/photos/yrwpJwDNSHE>
|
||||
Mr. Lee <https://unsplash.com/photos/v7r8kZStqFw>
|
||||
Photo by SpaceX <https://unsplash.com/photos/VBNb52J8Trk>
|
||||
Sunset by the Pier <https://unsplash.com/photos/ces8_Bo7bhQ>
|
||||
|
||||
Unsplash photos published on or after June 8, 2017 are free to use, modify, and redistribute subject to the Unsplash license <https://unsplash.com/license>:
|
||||
|
||||
Martin Adams <https://unsplash.com/photos/MpTdvXlAsVE>
|
||||
Morskie Oko <https://unsplash.com/photos/_1UF_3TlKcQ>
|
||||
Nattu Adnan <https://unsplash.com/photos/Ai2TRdvI6gM>
|
||||
Tj Holowaychuk <https://unsplash.com/photos/iGrsa9rL11o>
|
||||
Viktor Forgacs <https://unsplash.com/photos/q8XSCZYh6D8>
|
||||
“A Large Body of Water Surrounded By Mountains” by Peter Thomas <https://unsplash.com/photos/Dxod5pdRtsk>
|
||||
“A Trail of Footprints In The Sand” by David Emrich <https://unsplash.com/photos/A9mr3TPoj0k>
|
||||
“Photo of Valley” by Aniket Doele <https://unsplash.com/photos/M6XC789HLe8>
|
||||
|
||||
Pexels photos are free to use, modify, and redistribute subject to the Pexels license <https://www.pexels.com/license/>:
|
||||
|
||||
Snow-Capped Mountain <https://www.pexels.com/photo/photo-of-snow-capped-mountain-during-evening-2440024/>
|
||||
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
|
@ -1,23 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="icon icon-tabler icons-tabler-filled icon-tabler-puzzle"
|
||||
version="1.1"
|
||||
id="svg2"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs2" /><path
|
||||
stroke="none"
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
id="path1" /><path
|
||||
d="M10 2a3 3 0 0 1 2.995 2.824l.005 .176v1h3a2 2 0 0 1 1.995 1.85l.005 .15v3h1a3 3 0 0 1 .176 5.995l-.176 .005h-1v3a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-1a1 1 0 0 0 -1.993 -.117l-.007 .117v1a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h1a1 1 0 0 0 .117 -1.993l-.117 -.007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h3v-1a3 3 0 0 1 3 -3z"
|
||||
id="path2"
|
||||
style="opacity:0.7" /><path
|
||||
id="path2-3"
|
||||
style="color:#000000;fill:#ffffff;-inkscape-stroke:none;opacity:0.9"
|
||||
d="M 10,1 C 7.80271,1 6,2.80271 6,5 H 3.9824219 l -0.1875,0.00586 -0.019531,0.00195 C 2.2191757,5.1248607 0.99950612,6.4393901 1,8 v 3.017578 l 0.00586,0.1875 0.00195,0.01953 C 1.1244168,12.774924 2.429603,13.991286 3.9824219,14 l -0.1875,0.0059 -0.019531,0.002 C 2.2191756,14.12477 0.99950612,15.43939 1,17 v 3.017578 l 0.00586,0.1875 0.00195,0.01953 C 1.1248607,21.780826 2.4393901,23.000494 4,23 h 3.0175781 l 0.1875,-0.0059 0.019531,-0.002 C 8.7749229,22.875673 9.9912857,21.570397 10,20.017578 l 0.0059,0.1875 0.002,0.01953 C 10.12477,21.780824 11.43939,23.000494 13,23 h 3.017578 l 0.1875,-0.0059 0.01953,-0.002 C 17.780826,22.87523 19.000494,21.56061 19,20 v -2 h 0.01367 l 0.205078,-0.0059 h 0.01563 c 1.21751,-0.07037 2.231032,-0.615044 2.871094,-1.396485 0.64006,-0.78144 0.924537,-1.759302 0.896484,-2.714844 C 22.973903,12.92727 22.63402,11.969108 21.949219,11.226562 21.264413,10.484057 20.219542,9.9988247 19,10 V 7.9824219 l -0.0059,-0.1875 -0.002,-0.019531 C 18.87523,6.2191757 17.56061,4.9995061 16,5 H 14 V 4.9863281 L 13.994141,4.78125 V 4.765625 C 13.870457,2.6610112 12.108245,0.99988256 10,1 Z m 0,1 a 3,3 0 0 1 2.994141,2.8242188 L 13,5 v 1 h 3 a 2,2 0 0 1 1.994141,1.8496094 L 18,8 v 3 h 1 a 3,3 0 0 1 0.175781,5.994141 L 19,17 h -1 v 3 a 2,2 0 0 1 -1.849609,1.994141 L 16,22 H 13 A 2,2 0 0 1 11.005859,20.150391 L 11,20 V 19 A 1,1 0 0 0 9.0078125,18.882812 L 9,19 v 1 A 2,2 0 0 1 7.1503906,21.994141 L 7,22 H 4 A 2,2 0 0 1 2.0058594,20.150391 L 2,20 V 17 A 2,2 0 0 1 3.8496094,15.005859 L 4,15 H 5 A 1,1 0 0 0 5.1171875,13.007812 L 5,13 H 4 A 2,2 0 0 1 2.0058594,11.150391 L 2,11 V 8 A 2,2 0 0 1 3.8496094,6.0058594 L 4,6 H 7 V 5 a 3,3 0 0 1 3,-3 z" /></svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 997 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-puzzle"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 2a3 3 0 0 1 2.995 2.824l.005 .176v1h3a2 2 0 0 1 1.995 1.85l.005 .15v3h1a3 3 0 0 1 .176 5.995l-.176 .005h-1v3a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-1a1 1 0 0 0 -1.993 -.117l-.007 .117v1a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h1a1 1 0 0 0 .117 -1.993l-.117 -.007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h3v-1a3 3 0 0 1 3 -3z" /></svg>
|
||||
|
Before Width: | Height: | Size: 696 B |
|
|
@ -1,22 +0,0 @@
|
|||
import { createCanvas } from '@gfx/canvas-wasm';
|
||||
import { assertNotEquals } from '@std/assert';
|
||||
import { encodeHex } from '@std/encoding/hex';
|
||||
|
||||
import { addNoise } from './canvas.ts';
|
||||
|
||||
// This is almost impossible to truly test,
|
||||
// but we can at least check that the image on the canvas changes.
|
||||
Deno.test('addNoise', async () => {
|
||||
const canvas = createCanvas(100, 100);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const dataBefore = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const hashBefore = await crypto.subtle.digest('SHA-256', dataBefore.data);
|
||||
|
||||
addNoise(ctx, canvas.width, canvas.height);
|
||||
|
||||
const dataAfter = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const hashAfter = await crypto.subtle.digest('SHA-256', dataAfter.data);
|
||||
|
||||
assertNotEquals(encodeHex(hashBefore), encodeHex(hashAfter));
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import type { CanvasRenderingContext2D } from '@gfx/canvas-wasm';
|
||||
|
||||
/**
|
||||
* Add a small amount of noise to the image.
|
||||
* This protects against an attacker pregenerating every possible solution and then doing a reverse-lookup.
|
||||
*/
|
||||
export function addNoise(ctx: CanvasRenderingContext2D, width: number, height: number): void {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
|
||||
// Loop over every pixel.
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
// Add/subtract a small amount from each color channel.
|
||||
// We skip i+3 because that's the alpha channel, which we don't want to modify.
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const alteration = Math.floor(Math.random() * 11) - 5; // Vary between -5 and +5
|
||||
imageData.data[i + j] = Math.min(Math.max(imageData.data[i + j] + alteration, 0), 255);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { getCaptchaImages } from './assets.ts';
|
||||
import { generateCaptcha, verifyCaptchaSolution } from './captcha.ts';
|
||||
|
||||
Deno.test('generateCaptcha', async () => {
|
||||
const images = await getCaptchaImages();
|
||||
generateCaptcha(images, { w: 370, h: 400 }, { w: 65, h: 65 });
|
||||
});
|
||||
|
||||
Deno.test('verifyCaptchaSolution', () => {
|
||||
verifyCaptchaSolution({ w: 65, h: 65 }, { x: 0, y: 0 }, { x: 0, y: 0 });
|
||||
verifyCaptchaSolution({ w: 65, h: 65 }, { x: 0, y: 0 }, { x: 10, y: 10 });
|
||||
});
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { createCanvas, type EmulatedCanvas2D } from '@gfx/canvas-wasm';
|
||||
|
||||
import { addNoise } from './canvas.ts';
|
||||
import { areIntersecting, type Dimensions, type Point } from './geometry.ts';
|
||||
|
||||
import type { CaptchaImages } from './assets.ts';
|
||||
|
||||
/** Generate a puzzle captcha, returning canvases for the board and piece. */
|
||||
export function generateCaptcha(
|
||||
{ bgImages, puzzleMask, puzzleHole }: CaptchaImages,
|
||||
bgSize: Dimensions,
|
||||
puzzleSize: Dimensions,
|
||||
): {
|
||||
bg: EmulatedCanvas2D;
|
||||
puzzle: EmulatedCanvas2D;
|
||||
solution: Point;
|
||||
} {
|
||||
const bg = createCanvas(bgSize.w, bgSize.h);
|
||||
const puzzle = createCanvas(puzzleSize.w, puzzleSize.h);
|
||||
|
||||
const ctx = bg.getContext('2d');
|
||||
const pctx = puzzle.getContext('2d');
|
||||
|
||||
const solution = generateSolution(bgSize, puzzleSize);
|
||||
const bgImage = bgImages[Math.floor(Math.random() * bgImages.length)];
|
||||
|
||||
// Draw the background image.
|
||||
ctx.drawImage(bgImage, 0, 0, bg.width, bg.height);
|
||||
addNoise(ctx, bg.width, bg.height);
|
||||
|
||||
// Draw the puzzle piece.
|
||||
pctx.drawImage(puzzleMask, 0, 0, puzzle.width, puzzle.height);
|
||||
pctx.globalCompositeOperation = 'source-in';
|
||||
pctx.drawImage(bg, solution.x, solution.y, puzzle.width, puzzle.height, 0, 0, puzzle.width, puzzle.height);
|
||||
|
||||
// Draw the hole.
|
||||
ctx.globalCompositeOperation = 'source-atop';
|
||||
ctx.drawImage(puzzleHole, solution.x, solution.y, puzzle.width, puzzle.height);
|
||||
|
||||
return {
|
||||
bg,
|
||||
puzzle,
|
||||
solution,
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyCaptchaSolution(puzzleSize: Dimensions, point: Point, solution: Point): boolean {
|
||||
return areIntersecting(
|
||||
{ ...point, ...puzzleSize },
|
||||
{ ...solution, ...puzzleSize },
|
||||
);
|
||||
}
|
||||
|
||||
/** Random coordinates such that the piece fits within the canvas. */
|
||||
function generateSolution(bgSize: Dimensions, puzzleSize: Dimensions): Point {
|
||||
return {
|
||||
x: Math.floor(Math.random() * (bgSize.w - puzzleSize.w)),
|
||||
y: Math.floor(Math.random() * (bgSize.h - puzzleSize.h)),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"name": "@ditto/captcha",
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { areIntersecting } from './geometry.ts';
|
||||
|
||||
Deno.test('areIntersecting', () => {
|
||||
assertEquals(areIntersecting({ x: 0, y: 0, w: 10, h: 10 }, { x: 5, y: 5, w: 10, h: 10 }), true);
|
||||
assertEquals(areIntersecting({ x: 0, y: 0, w: 10, h: 10 }, { x: 15, y: 15, w: 10, h: 10 }), false);
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Dimensions {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
type Rectangle = Point & Dimensions;
|
||||
|
||||
/** Check if the two rectangles intersect by at least `threshold` percent. */
|
||||
export function areIntersecting(rect1: Rectangle, rect2: Rectangle, threshold = 0.5): boolean {
|
||||
const r1cx = rect1.x + rect1.w / 2;
|
||||
const r2cx = rect2.x + rect2.w / 2;
|
||||
|
||||
const r1cy = rect1.y + rect1.h / 2;
|
||||
const r2cy = rect2.y + rect2.h / 2;
|
||||
|
||||
const dist = Math.sqrt((r2cx - r1cx) ** 2 + (r2cy - r1cy) ** 2);
|
||||
|
||||
const e1 = Math.sqrt(rect1.h ** 2 + rect1.w ** 2) / 2;
|
||||
const e2 = Math.sqrt(rect2.h ** 2 + rect2.w ** 2) / 2;
|
||||
|
||||
return dist <= (e1 + e2) * threshold;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { getCaptchaImages } from './assets.ts';
|
||||
export { generateCaptcha, verifyCaptchaSolution } from './captcha.ts';
|
||||
|
|
@ -1,457 +0,0 @@
|
|||
import { type NostrFilter, NSecSigner } from '@nostrify/nostrify';
|
||||
import { NPostgres } from '@nostrify/db';
|
||||
import { genEvent } from '@nostrify/nostrify/test';
|
||||
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||
import { bytesToString, stringToBytes } from '@scure/base';
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { DittoPolyPg, TestDB } from '@ditto/db';
|
||||
import { DittoConf } from '@ditto/conf';
|
||||
|
||||
import { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts';
|
||||
|
||||
Deno.test('validateAndParseWallet function returns valid data', async () => {
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const orig = new DittoPolyPg(conf.databaseUrl);
|
||||
|
||||
await using db = new TestDB(orig);
|
||||
await db.migrate();
|
||||
await db.clear();
|
||||
|
||||
const store = new NPostgres(orig.kysely);
|
||||
|
||||
const sk = generateSecretKey();
|
||||
const signer = new NSecSigner(sk);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const privkey = bytesToString('hex', sk);
|
||||
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||
|
||||
// Wallet
|
||||
const wallet = genEvent({
|
||||
kind: 17375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['privkey', privkey],
|
||||
['mint', 'https://mint.soul.com'],
|
||||
]),
|
||||
),
|
||||
}, sk);
|
||||
await store.event(wallet);
|
||||
|
||||
// Nutzap information
|
||||
const nutzapInfo = genEvent({
|
||||
kind: 10019,
|
||||
tags: [
|
||||
['pubkey', p2pk],
|
||||
['mint', 'https://mint.soul.com'],
|
||||
['relay', conf.relay],
|
||||
],
|
||||
}, sk);
|
||||
await store.event(nutzapInfo);
|
||||
|
||||
const { data, error } = await validateAndParseWallet(store, signer, pubkey);
|
||||
|
||||
assertEquals(error, null);
|
||||
assertEquals(data, {
|
||||
wallet,
|
||||
nutzapInfo,
|
||||
privkey,
|
||||
p2pk,
|
||||
mints: ['https://mint.soul.com'],
|
||||
relays: [conf.relay],
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('organizeProofs function is working', async () => {
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const orig = new DittoPolyPg(conf.databaseUrl);
|
||||
|
||||
await using db = new TestDB(orig);
|
||||
await db.migrate();
|
||||
await db.clear();
|
||||
|
||||
const store = new NPostgres(orig.kysely);
|
||||
|
||||
const sk = generateSecretKey();
|
||||
const signer = new NSecSigner(sk);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const event1 = genEvent({
|
||||
kind: 7375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify({
|
||||
mint: 'https://mint.soul.com',
|
||||
proofs: [
|
||||
{
|
||||
id: '005c2502034d4f12',
|
||||
amount: 25,
|
||||
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
|
||||
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
|
||||
},
|
||||
{
|
||||
id: '005c2502034d4f12',
|
||||
amount: 25,
|
||||
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
|
||||
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
|
||||
},
|
||||
{
|
||||
id: '005c2502034d4f12',
|
||||
amount: 25,
|
||||
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
|
||||
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
|
||||
},
|
||||
{
|
||||
id: '005c2502034d4f12',
|
||||
amount: 25,
|
||||
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
|
||||
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
|
||||
},
|
||||
],
|
||||
del: [],
|
||||
}),
|
||||
),
|
||||
}, sk);
|
||||
await store.event(event1);
|
||||
|
||||
const proof1 = {
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 1,
|
||||
'secret': '6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0',
|
||||
'C': '03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3',
|
||||
'dleq': {
|
||||
'e': 'bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351',
|
||||
's': 'a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67',
|
||||
'r': 'b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df',
|
||||
},
|
||||
};
|
||||
const token1 = JSON.stringify({
|
||||
mint: 'https://mint-fashion.com',
|
||||
proofs: [proof1],
|
||||
del: [],
|
||||
});
|
||||
|
||||
const event2 = genEvent({
|
||||
kind: 7375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
token1,
|
||||
),
|
||||
}, sk);
|
||||
await store.event(event2);
|
||||
|
||||
const proof2 = {
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 123,
|
||||
'secret': '6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0',
|
||||
'C': '03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3',
|
||||
'dleq': {
|
||||
'e': 'bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351',
|
||||
's': 'a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67',
|
||||
'r': 'b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df',
|
||||
},
|
||||
};
|
||||
|
||||
const token2 = JSON.stringify({
|
||||
mint: 'https://mint-fashion.com',
|
||||
proofs: [proof2],
|
||||
del: [],
|
||||
});
|
||||
|
||||
const event3 = genEvent({
|
||||
kind: 7375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
token2,
|
||||
),
|
||||
}, sk);
|
||||
await store.event(event3);
|
||||
|
||||
const unspentProofs = await store.query([{ kinds: [7375], authors: [pubkey] }]);
|
||||
|
||||
const organizedProofs = await organizeProofs(unspentProofs, signer);
|
||||
|
||||
assertEquals(organizedProofs, {
|
||||
'https://mint.soul.com': {
|
||||
totalBalance: 100,
|
||||
[event1.id]: { event: event1, balance: 100 },
|
||||
},
|
||||
'https://mint-fashion.com': {
|
||||
totalBalance: 124,
|
||||
[event2.id]: { event: event2, balance: 1 },
|
||||
[event3.id]: { event: event3, balance: 123 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('getLastRedeemedNutzap function is working', async () => {
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const orig = new DittoPolyPg(conf.databaseUrl);
|
||||
|
||||
await using db = new TestDB(orig);
|
||||
await db.migrate();
|
||||
await db.clear();
|
||||
|
||||
const store = new NPostgres(orig.kysely);
|
||||
|
||||
const sk = generateSecretKey();
|
||||
const signer = new NSecSigner(sk);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const event1 = genEvent({
|
||||
kind: 7376,
|
||||
content: '<nip-44-encrypted>',
|
||||
created_at: Math.floor(Date.now() / 1000), // now
|
||||
tags: [
|
||||
['e', '<event-id-of-created-token>', '', 'redeemed'],
|
||||
],
|
||||
}, sk);
|
||||
await store.event(event1);
|
||||
|
||||
const event2 = genEvent({
|
||||
kind: 7376,
|
||||
content: '<nip-44-encrypted>',
|
||||
created_at: Math.floor((Date.now() - 86400000) / 1000), // yesterday
|
||||
tags: [
|
||||
['e', '<event-id-of-created-token>', '', 'redeemed'],
|
||||
],
|
||||
}, sk);
|
||||
await store.event(event2);
|
||||
|
||||
const event3 = genEvent({
|
||||
kind: 7376,
|
||||
content: '<nip-44-encrypted>',
|
||||
created_at: Math.floor((Date.now() - 86400000) / 1000), // yesterday
|
||||
tags: [
|
||||
['e', '<event-id-of-created-token>', '', 'redeemed'],
|
||||
],
|
||||
}, sk);
|
||||
await store.event(event3);
|
||||
|
||||
const event4 = genEvent({
|
||||
kind: 7376,
|
||||
content: '<nip-44-encrypted>',
|
||||
created_at: Math.floor((Date.now() + 86400000) / 1000), // tomorrow
|
||||
tags: [
|
||||
['e', '<event-id-of-created-token>', '', 'redeemed'],
|
||||
],
|
||||
}, sk);
|
||||
await store.event(event4);
|
||||
|
||||
const event = await getLastRedeemedNutzap(store, pubkey);
|
||||
|
||||
assertEquals(event, event4);
|
||||
});
|
||||
|
||||
Deno.test('getMintsToProofs function is working', async () => {
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const orig = new DittoPolyPg(conf.databaseUrl);
|
||||
|
||||
await using db = new TestDB(orig);
|
||||
await db.migrate();
|
||||
await db.clear();
|
||||
|
||||
const store = new NPostgres(orig.kysely);
|
||||
|
||||
const sk = generateSecretKey();
|
||||
const signer = new NSecSigner(sk);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const redeemedNutzap = genEvent({
|
||||
created_at: Math.floor(Date.now() / 1000), // now
|
||||
kind: 9321,
|
||||
content: 'Thanks buddy! Nice idea.',
|
||||
tags: [
|
||||
[
|
||||
'proof',
|
||||
JSON.stringify({
|
||||
id: '005c2502034d4f12',
|
||||
amount: 25,
|
||||
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
|
||||
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
|
||||
}),
|
||||
],
|
||||
['u', 'https://mint.soul.com'],
|
||||
['e', 'nutzapped-post'],
|
||||
['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'],
|
||||
],
|
||||
}, sk);
|
||||
|
||||
await store.event(redeemedNutzap);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
const history = genEvent({
|
||||
created_at: Math.floor(Date.now() / 1000), // now
|
||||
kind: 7376,
|
||||
content: 'nip-44-encrypted',
|
||||
tags: [
|
||||
['e', redeemedNutzap.id, conf.relay, 'redeemed'],
|
||||
['p', redeemedNutzap.pubkey],
|
||||
],
|
||||
}, sk);
|
||||
|
||||
await store.event(history);
|
||||
|
||||
const nutzap = genEvent({
|
||||
created_at: Math.floor(Date.now() / 1000), // now
|
||||
kind: 9321,
|
||||
content: 'Thanks buddy! Nice idea.',
|
||||
tags: [
|
||||
[
|
||||
'proof',
|
||||
JSON.stringify({
|
||||
id: '005c2502034d4f12',
|
||||
amount: 50,
|
||||
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
|
||||
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
|
||||
}),
|
||||
],
|
||||
['u', 'https://mint.soul.com'],
|
||||
['e', 'nutzapped-post'],
|
||||
['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'],
|
||||
],
|
||||
}, sk);
|
||||
|
||||
await store.event(nutzap);
|
||||
|
||||
const nutzapsFilter: NostrFilter = {
|
||||
kinds: [9321],
|
||||
'#p': ['47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'],
|
||||
'#u': ['https://mint.soul.com'],
|
||||
};
|
||||
|
||||
const lastRedeemedNutzap = await getLastRedeemedNutzap(store, pubkey);
|
||||
if (lastRedeemedNutzap) {
|
||||
nutzapsFilter.since = lastRedeemedNutzap.created_at;
|
||||
}
|
||||
|
||||
const mintsToProofs = await getMintsToProofs(store, nutzapsFilter, conf.relay);
|
||||
|
||||
assertEquals(mintsToProofs, {
|
||||
'https://mint.soul.com': {
|
||||
proofs: [{
|
||||
id: '005c2502034d4f12',
|
||||
amount: 50,
|
||||
secret: 'z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg=',
|
||||
C: '0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46',
|
||||
}],
|
||||
toBeRedeemed: [
|
||||
['e', nutzap.id, conf.relay, 'redeemed'],
|
||||
['p', nutzap.pubkey],
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('getWallet function is working', async () => {
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const orig = new DittoPolyPg(conf.databaseUrl);
|
||||
|
||||
await using db = new TestDB(orig);
|
||||
await db.migrate();
|
||||
await db.clear();
|
||||
|
||||
const sk = generateSecretKey();
|
||||
const signer = new NSecSigner(sk);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const privkey = bytesToString('hex', sk);
|
||||
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||
|
||||
const relay = new NPostgres(orig.kysely);
|
||||
|
||||
const proofs = genEvent({
|
||||
kind: 7375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify({
|
||||
mint: 'https://cuiaba.mint.com',
|
||||
proofs: [
|
||||
{
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 2,
|
||||
'secret': '700312ccba84cb15d6a008c1d01b0dbf00025d3f2cb01f030a756553aca52de3',
|
||||
'C': '02f0ff21fdd19a547d66d9ca09df5573ad88d28e4951825130708ba53cbed19561',
|
||||
'dleq': {
|
||||
'e': '9c44a58cb429be619c474b97216009bd96ff1b7dd145b35828a14f180c03a86f',
|
||||
's': 'a11b8f616dfee5157a2c7c36da0ee181fe71b28729bee56b789e472c027ceb3b',
|
||||
'r': 'c51b9ade8cfd3939b78d509c9723f86b43b432680f55a6791e3e252b53d4b465',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 4,
|
||||
'secret': '5936f22d486734c03bd50b89aaa34be8e99f20d199bcebc09da8716890e95fb3',
|
||||
'C': '039b55f92c02243e31b04e964f2ad0bcd2ed3229e334f4c7a81037392b8411d6e7',
|
||||
'dleq': {
|
||||
'e': '7b7be700f2515f1978ca27bc1045d50b9d146bb30d1fe0c0f48827c086412b9e',
|
||||
's': 'cf44b08c7e64fd2bd9199667327b10a29b7c699b10cb7437be518203b25fe3fa',
|
||||
'r': 'ec0cf54ce2d17fae5db1c6e5e5fd5f34d7c7df18798b8d92bcb7cb005ec2f93b',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 16,
|
||||
'secret': '89e2315c058f3a010972dc6d546b1a2e81142614d715c28d169c6afdba5326bd',
|
||||
'C': '02bc1c3756e77563fe6c7769fc9d9bc578ea0b84bf4bf045cf31c7e2d3f3ad0818',
|
||||
'dleq': {
|
||||
'e': '8dfa000c9e2a43d35d2a0b1c7f36a96904aed35457ca308c6e7d10f334f84e72',
|
||||
's': '9270a914b1a53e32682b1277f34c5cfa931a6fab701a5dbee5855b68ddf621ab',
|
||||
'r': 'ae71e572839a3273b0141ea2f626915592b4b3f5f91b37bbeacce0d3396332c9',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 16,
|
||||
'secret': '06f2209f313d92505ae5c72087263f711b7a97b1b29a71886870e672a1b180ac',
|
||||
'C': '02fa2ad933b62449e2765255d39593c48293f10b287cf7036b23570c8f01c27fae',
|
||||
'dleq': {
|
||||
'e': 'e696d61f6259ae97f8fe13a5af55d47f526eea62a7998bf888626fd1ae35e720',
|
||||
's': 'b9f1ef2a8aec0e73c1a4aaff67e28b3ca3bc4628a532113e0733643c697ed7ce',
|
||||
'r': 'b66ed62852811d14e9bf822baebfda92ba47c5c4babc4f2499d9ce81fbbbd3f2',
|
||||
},
|
||||
},
|
||||
],
|
||||
del: [],
|
||||
}),
|
||||
),
|
||||
created_at: Math.floor(Date.now() / 1000), // now
|
||||
}, sk);
|
||||
|
||||
await relay.event(proofs);
|
||||
|
||||
await relay.event(genEvent({
|
||||
kind: 10019,
|
||||
tags: [
|
||||
['pubkey', p2pk],
|
||||
['mint', 'https://mint.soul.com'],
|
||||
['mint', 'https://cuiaba.mint.com'],
|
||||
['relay', conf.relay],
|
||||
],
|
||||
}, sk));
|
||||
|
||||
const wallet = genEvent({
|
||||
kind: 17375,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['privkey', privkey],
|
||||
['mint', 'https://mint.soul.com'],
|
||||
]),
|
||||
),
|
||||
}, sk);
|
||||
|
||||
await relay.event(wallet);
|
||||
|
||||
const { wallet: walletEntity } = await getWallet(relay, pubkey, signer);
|
||||
|
||||
assertEquals(walletEntity, {
|
||||
balance: 38,
|
||||
mints: ['https://mint.soul.com', 'https://cuiaba.mint.com'],
|
||||
relays: [conf.relay],
|
||||
pubkey_p2pk: p2pk,
|
||||
});
|
||||
});
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
import type { Proof } from '@cashu/cashu-ts';
|
||||
import { type NostrEvent, type NostrFilter, type NostrSigner, NSchema as n, type NStore } from '@nostrify/nostrify';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
import { stringToBytes } from '@scure/base';
|
||||
import { logi } from '@soapbox/logi';
|
||||
import type { SetRequired } from 'type-fest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { proofSchema, tokenEventSchema, type Wallet } from './schemas.ts';
|
||||
|
||||
type Data = {
|
||||
wallet: NostrEvent;
|
||||
nutzapInfo: NostrEvent;
|
||||
privkey: string;
|
||||
p2pk: string;
|
||||
mints: string[];
|
||||
relays: string[];
|
||||
};
|
||||
|
||||
type CustomError =
|
||||
| { message: 'Wallet not found'; code: 'wallet-not-found' }
|
||||
| { message: 'Could not decrypt wallet content'; code: 'fail-decrypt-wallet' }
|
||||
| { message: 'Could not parse wallet content'; code: 'fail-parse-wallet' }
|
||||
| { message: 'Wallet does not contain privkey or privkey is not a valid nostr id'; code: 'privkey-missing' }
|
||||
| { message: 'Nutzap information event not found'; code: 'nutzap-info-not-found' }
|
||||
| {
|
||||
message:
|
||||
"You do not have a 'pubkey' tag in your nutzap information event or the one you have does not match the one derivated from the wallet.";
|
||||
code: 'pubkey-mismatch';
|
||||
}
|
||||
| { message: 'You do not have any mints in your nutzap information event.'; code: 'mints-missing' };
|
||||
|
||||
/** Ensures that the wallet event and nutzap information event are correct. */
|
||||
async function validateAndParseWallet(
|
||||
store: NStore,
|
||||
signer: SetRequired<NostrSigner, 'nip44'>,
|
||||
pubkey: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<{ data: Data; error: null } | { data: null; error: CustomError }> {
|
||||
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal: opts?.signal });
|
||||
if (!wallet) {
|
||||
return { error: { message: 'Wallet not found', code: 'wallet-not-found' }, data: null };
|
||||
}
|
||||
|
||||
let decryptedContent: string;
|
||||
try {
|
||||
decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content);
|
||||
} catch (e) {
|
||||
logi({
|
||||
level: 'error',
|
||||
ns: 'ditto.api.cashu.wallet',
|
||||
id: wallet.id,
|
||||
kind: wallet.kind,
|
||||
error: errorJson(e),
|
||||
});
|
||||
return { data: null, error: { message: 'Could not decrypt wallet content', code: 'fail-decrypt-wallet' } };
|
||||
}
|
||||
|
||||
let contentTags: string[][];
|
||||
try {
|
||||
contentTags = n.json().pipe(z.string().array().array()).parse(decryptedContent);
|
||||
} catch {
|
||||
return { data: null, error: { message: 'Could not parse wallet content', code: 'fail-parse-wallet' } };
|
||||
}
|
||||
|
||||
const privkey = contentTags.find(([value]) => value === 'privkey')?.[1];
|
||||
if (!privkey || !isNostrId(privkey)) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: 'Wallet does not contain privkey or privkey is not a valid nostr id', code: 'privkey-missing' },
|
||||
};
|
||||
}
|
||||
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||
|
||||
const [nutzapInfo] = await store.query([{ authors: [pubkey], kinds: [10019] }], { signal: opts?.signal });
|
||||
if (!nutzapInfo) {
|
||||
return { data: null, error: { message: 'Nutzap information event not found', code: 'nutzap-info-not-found' } };
|
||||
}
|
||||
|
||||
const nutzapInformationPubkey = nutzapInfo.tags.find(([name]) => name === 'pubkey')?.[1];
|
||||
if (!nutzapInformationPubkey || (nutzapInformationPubkey !== p2pk)) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message:
|
||||
"You do not have a 'pubkey' tag in your nutzap information event or the one you have does not match the one derivated from the wallet.",
|
||||
code: 'pubkey-mismatch',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mints = [...new Set(nutzapInfo.tags.filter(([name]) => name === 'mint').map(([_, value]) => value))];
|
||||
if (mints.length < 1) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: 'You do not have any mints in your nutzap information event.', code: 'mints-missing' },
|
||||
};
|
||||
}
|
||||
|
||||
const relays = [...new Set(nutzapInfo.tags.filter(([name]) => name === 'relay').map(([_, value]) => value))];
|
||||
|
||||
return { data: { wallet, nutzapInfo, privkey, p2pk, mints, relays }, error: null };
|
||||
}
|
||||
|
||||
type OrganizedProofs = {
|
||||
[mintUrl: string]: {
|
||||
/** Total balance in this mint */
|
||||
totalBalance: number;
|
||||
/** Event id */
|
||||
[eventId: string]: {
|
||||
event: NostrEvent;
|
||||
/** Total balance in this event */
|
||||
balance: number;
|
||||
} | number;
|
||||
};
|
||||
};
|
||||
async function organizeProofs(
|
||||
events: NostrEvent[],
|
||||
signer: SetRequired<NostrSigner, 'nip44'>,
|
||||
): Promise<OrganizedProofs> {
|
||||
const organizedProofs: OrganizedProofs = {};
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
for (const event of events) {
|
||||
const decryptedContent = await signer.nip44.decrypt(pubkey, event.content);
|
||||
const { data: token, success } = n.json().pipe(tokenEventSchema).safeParse(decryptedContent);
|
||||
if (!success) {
|
||||
continue;
|
||||
}
|
||||
const { mint, proofs } = token;
|
||||
|
||||
const balance = proofs.reduce((prev, current) => prev + current.amount, 0);
|
||||
|
||||
if (!organizedProofs[mint]) {
|
||||
organizedProofs[mint] = { totalBalance: 0 };
|
||||
}
|
||||
|
||||
organizedProofs[mint] = { ...organizedProofs[mint], [event.id]: { event, balance } };
|
||||
organizedProofs[mint].totalBalance += balance;
|
||||
}
|
||||
return organizedProofs;
|
||||
}
|
||||
|
||||
/** Returns a spending history event that contains the last redeemed nutzap. */
|
||||
async function getLastRedeemedNutzap(
|
||||
store: NStore,
|
||||
pubkey: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<NostrEvent | undefined> {
|
||||
const events = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal: opts?.signal });
|
||||
|
||||
for (const event of events) {
|
||||
const nutzap = event.tags.find(([name]) => name === 'e');
|
||||
const redeemed = nutzap?.[3];
|
||||
if (redeemed === 'redeemed') {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* toBeRedeemed are the nutzaps that will be redeemed into a kind 7375 and saved in the kind 7376 tags
|
||||
* The tags format is: [
|
||||
* [ "e", "<9321-event-id>", "<relay-hint>", "redeemed" ], // nutzap event that has been redeemed
|
||||
* [ "p", "<sender-pubkey>" ] // pubkey of the author of the 9321 event (nutzap sender)
|
||||
* ]
|
||||
* https://github.com/nostr-protocol/nips/blob/master/61.md#updating-nutzap-redemption-history
|
||||
*/
|
||||
type MintsToProofs = { [key: string]: { proofs: Proof[]; toBeRedeemed: string[][] } };
|
||||
|
||||
/**
|
||||
* Gets proofs from nutzaps that have not been redeemed yet.
|
||||
* Each proof is associated with a specific mint.
|
||||
* @param store Store used to query for the nutzaps
|
||||
* @param nutzapsFilter Filter used to query for the nutzaps, most useful when
|
||||
* it contains a 'since' field so it saves time and resources
|
||||
* @param relay Relay hint where the new kind 7376 will be saved
|
||||
* @returns MintsToProofs An object where each key is a mint url and the values are an array of proofs
|
||||
* and an array of redeemed tags in this format:
|
||||
* ```
|
||||
* [
|
||||
* ...,
|
||||
* [ "e", "<9321-event-id>", "<relay-hint>", "redeemed" ], // nutzap event that has been redeemed
|
||||
* [ "p", "<sender-pubkey>" ] // pubkey of the author of the 9321 event (nutzap sender)
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
async function getMintsToProofs(
|
||||
store: NStore,
|
||||
nutzapsFilter: NostrFilter,
|
||||
relay: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<MintsToProofs> {
|
||||
const mintsToProofs: MintsToProofs = {};
|
||||
|
||||
const nutzaps = await store.query([nutzapsFilter], { signal: opts?.signal });
|
||||
|
||||
for (const event of nutzaps) {
|
||||
try {
|
||||
const mint = event.tags.find(([name]) => name === 'u')?.[1];
|
||||
if (!mint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const proofs = event.tags.filter(([name]) => name === 'proof').map((tag) => tag[1]).filter(Boolean);
|
||||
if (proofs.length < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!mintsToProofs[mint]) {
|
||||
mintsToProofs[mint] = { proofs: [], toBeRedeemed: [] };
|
||||
}
|
||||
|
||||
const parsed = n.json().pipe(
|
||||
proofSchema,
|
||||
).array().safeParse(proofs);
|
||||
|
||||
if (!parsed.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mintsToProofs[mint].proofs = [...mintsToProofs[mint].proofs, ...parsed.data];
|
||||
mintsToProofs[mint].toBeRedeemed = [
|
||||
...mintsToProofs[mint].toBeRedeemed,
|
||||
[
|
||||
'e', // nutzap event that has been redeemed
|
||||
event.id,
|
||||
relay,
|
||||
'redeemed',
|
||||
],
|
||||
['p', event.pubkey], // pubkey of the author of the 9321 event (nutzap sender)
|
||||
];
|
||||
} catch (e) {
|
||||
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) });
|
||||
}
|
||||
}
|
||||
|
||||
return mintsToProofs;
|
||||
}
|
||||
|
||||
/** Returns a wallet entity with the latest balance. */
|
||||
async function getWallet(
|
||||
store: NStore,
|
||||
pubkey: string,
|
||||
signer: SetRequired<NostrSigner, 'nip44'>,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<{ wallet: Wallet; error: null } | { wallet: null; error: CustomError }> {
|
||||
const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal: opts?.signal });
|
||||
|
||||
if (error) {
|
||||
logi({ level: 'error', ns: 'ditto.cashu.get_wallet', error: errorJson(error) });
|
||||
return { wallet: null, error };
|
||||
}
|
||||
|
||||
const { p2pk, mints, relays } = data;
|
||||
|
||||
let balance = 0;
|
||||
|
||||
const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal: opts?.signal });
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse(
|
||||
await signer.nip44.decrypt(pubkey, token.content),
|
||||
);
|
||||
|
||||
if (!mints.includes(decryptedContent.mint)) {
|
||||
mints.push(decryptedContent.mint);
|
||||
}
|
||||
|
||||
balance += decryptedContent.proofs.reduce((accumulator, current) => {
|
||||
return accumulator + current.amount;
|
||||
}, 0);
|
||||
} catch (e) {
|
||||
logi({ level: 'error', ns: 'dtto.cashu.get_wallet', error: errorJson(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint
|
||||
const walletEntity: Wallet = {
|
||||
pubkey_p2pk: p2pk,
|
||||
mints,
|
||||
relays,
|
||||
balance,
|
||||
};
|
||||
|
||||
return { wallet: walletEntity, error: null };
|
||||
}
|
||||
|
||||
/** Serialize an error into JSON for JSON logging. */
|
||||
export function errorJson(error: unknown): Error | null {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isNostrId(value: unknown): boolean {
|
||||
return n.id().safeParse(value).success;
|
||||
}
|
||||
|
||||
export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet };
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"name": "@ditto/cashu",
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts';
|
||||
export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts';
|
||||
export { renderTransaction, type Transaction } from './views.ts';
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { NSchema as n } from '@nostrify/nostrify';
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { proofSchema } from './schemas.ts';
|
||||
import { tokenEventSchema } from './schemas.ts';
|
||||
|
||||
Deno.test('Parse proof', () => {
|
||||
const proof =
|
||||
'{"id":"004f7adf2a04356c","amount":1,"secret":"6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0","C":"03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3","dleq":{"e":"bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351","s":"a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67","r":"b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df"}}';
|
||||
|
||||
assertEquals(n.json().pipe(proofSchema).safeParse(proof).success, true);
|
||||
assertEquals(n.json().pipe(proofSchema).safeParse(JSON.parse(proof)).success, false);
|
||||
assertEquals(proofSchema.safeParse(JSON.parse(proof)).success, true);
|
||||
assertEquals(proofSchema.safeParse(proof).success, false);
|
||||
});
|
||||
|
||||
Deno.test('Parse token', () => {
|
||||
const proof = {
|
||||
'id': '004f7adf2a04356c',
|
||||
'amount': 1,
|
||||
'secret': '6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0',
|
||||
'C': '03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3',
|
||||
'dleq': {
|
||||
'e': 'bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351',
|
||||
's': 'a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67',
|
||||
'r': 'b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df',
|
||||
},
|
||||
};
|
||||
const token = JSON.stringify({
|
||||
mint: 'https://mint-fashion.com',
|
||||
proofs: [proof],
|
||||
del: [],
|
||||
});
|
||||
|
||||
assertEquals(n.json().pipe(tokenEventSchema).safeParse(token).success, true);
|
||||
assertEquals(n.json().pipe(tokenEventSchema).safeParse(JSON.parse(token)).success, false);
|
||||
assertEquals(tokenEventSchema.safeParse(JSON.parse(token)).success, true);
|
||||
assertEquals(tokenEventSchema.safeParse(tokenEventSchema).success, false);
|
||||
});
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { NSchema as n } from '@nostrify/nostrify';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const proofSchema: z.ZodType<{
|
||||
id: string;
|
||||
amount: number;
|
||||
secret: string;
|
||||
C: string;
|
||||
dleq?: { s: string; e: string; r?: string };
|
||||
dleqValid?: boolean;
|
||||
}> = z.object({
|
||||
id: z.string(),
|
||||
amount: z.number(),
|
||||
secret: z.string(),
|
||||
C: z.string(),
|
||||
dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() })
|
||||
.optional(),
|
||||
dleqValid: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Decrypted content of a kind 7375 */
|
||||
export const tokenEventSchema: z.ZodType<{
|
||||
mint: string;
|
||||
proofs: Array<z.infer<typeof proofSchema>>;
|
||||
del?: string[];
|
||||
}> = z.object({
|
||||
mint: z.string().url(),
|
||||
proofs: proofSchema.array(),
|
||||
del: z.string().array().optional(),
|
||||
});
|
||||
|
||||
/** Ditto Cashu wallet */
|
||||
export const walletSchema: z.ZodType<{
|
||||
pubkey_p2pk: string;
|
||||
mints: string[];
|
||||
relays: string[];
|
||||
balance: number;
|
||||
}> = z.object({
|
||||
pubkey_p2pk: n.id(),
|
||||
mints: z.array(z.string().url()).nonempty().transform((val) => {
|
||||
return [...new Set(val)];
|
||||
}),
|
||||
relays: z.array(z.string()).nonempty().transform((val) => {
|
||||
return [...new Set(val)];
|
||||
}),
|
||||
/** Unit in sats */
|
||||
balance: z.number(),
|
||||
});
|
||||
|
||||
export type Wallet = z.infer<typeof walletSchema>;
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { NSecSigner } from '@nostrify/nostrify';
|
||||
import { NPostgres } from '@nostrify/db';
|
||||
import { genEvent } from '@nostrify/nostrify/test';
|
||||
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { DittoPolyPg, TestDB } from '@ditto/db';
|
||||
import { DittoConf } from '@ditto/conf';
|
||||
import { renderTransaction } from './views.ts';
|
||||
|
||||
Deno.test('renderTransaction function is working', async () => {
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const orig = new DittoPolyPg(conf.databaseUrl);
|
||||
|
||||
await using db = new TestDB(orig);
|
||||
await db.migrate();
|
||||
await db.clear();
|
||||
|
||||
const sk = generateSecretKey();
|
||||
const signer = new NSecSigner(sk);
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const relay = new NPostgres(orig.kysely);
|
||||
|
||||
const history1 = genEvent({
|
||||
kind: 7376,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'in'],
|
||||
['amount', '33'],
|
||||
]),
|
||||
),
|
||||
created_at: Math.floor(Date.now() / 1000), // now
|
||||
}, sk);
|
||||
await relay.event(history1);
|
||||
|
||||
const history2 = genEvent({
|
||||
kind: 7376,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'out'],
|
||||
['amount', '29'],
|
||||
]),
|
||||
),
|
||||
created_at: Math.floor(Date.now() / 1000) - 1, // now - 1 second
|
||||
}, sk);
|
||||
await relay.event(history2);
|
||||
|
||||
const history3 = genEvent({
|
||||
kind: 7376,
|
||||
content: await signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'ouch'],
|
||||
['amount', 'yolo'],
|
||||
]),
|
||||
),
|
||||
created_at: Math.floor(Date.now() / 1000) - 2, // now - 2 second
|
||||
}, sk);
|
||||
await relay.event(history3);
|
||||
|
||||
const events = await relay.query([{ kinds: [7376], authors: [pubkey], since: history2.created_at }]);
|
||||
|
||||
const transactions = await Promise.all(
|
||||
events.map((event) => {
|
||||
return renderTransaction(event, pubkey, signer);
|
||||
}),
|
||||
);
|
||||
|
||||
assertEquals(transactions, [
|
||||
{
|
||||
direction: 'in',
|
||||
amount: 33,
|
||||
created_at: history1.created_at,
|
||||
},
|
||||
{
|
||||
direction: 'out',
|
||||
amount: 29,
|
||||
created_at: history2.created_at,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { type NostrEvent, type NostrSigner, NSchema as n } from '@nostrify/nostrify';
|
||||
import type { SetRequired } from 'type-fest';
|
||||
import { z } from 'zod';
|
||||
|
||||
type Transaction = {
|
||||
amount: number;
|
||||
created_at: number;
|
||||
direction: 'in' | 'out';
|
||||
};
|
||||
|
||||
/** Renders one history of transaction. */
|
||||
async function renderTransaction(
|
||||
event: NostrEvent,
|
||||
viewerPubkey: string,
|
||||
signer: SetRequired<NostrSigner, 'nip44'>,
|
||||
): Promise<Transaction | undefined> {
|
||||
if (event.kind !== 7376) return;
|
||||
|
||||
const { data: contentTags, success } = n.json().pipe(z.coerce.string().array().min(2).array()).safeParse(
|
||||
await signer.nip44.decrypt(viewerPubkey, event.content),
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = contentTags.find(([name]) => name === 'direction')?.[1];
|
||||
if (direction !== 'out' && direction !== 'in') {
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = parseInt(contentTags.find(([name]) => name === 'amount')?.[1] ?? '', 10);
|
||||
if (isNaN(amount)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
created_at: event.created_at,
|
||||
direction,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
export { renderTransaction, type Transaction };
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { assertEquals, assertThrows } from '@std/assert';
|
||||
|
||||
import { DittoConf } from './DittoConf.ts';
|
||||
|
||||
Deno.test('DittoConfig', async (t) => {
|
||||
const env = new Map<string, string>([
|
||||
['DITTO_NSEC', 'nsec19shyxpuzd0cq2p5078fwnws7tyykypud6z205fzhlmlrs2vpz6hs83zwkw'],
|
||||
]);
|
||||
|
||||
const config = new DittoConf(env);
|
||||
|
||||
await t.step('signer', async () => {
|
||||
assertEquals(
|
||||
await config.signer.getPublicKey(),
|
||||
'1ba0c5ed1bbbf3b7eb0d7843ba16836a0201ea68a76bafcba507358c45911ff6',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('DittoConfig defaults', async (t) => {
|
||||
const env = new Map<string, string>();
|
||||
const config = new DittoConf(env);
|
||||
|
||||
await t.step('signer throws', () => {
|
||||
assertThrows(() => config.signer);
|
||||
});
|
||||
|
||||
await t.step('port', () => {
|
||||
assertEquals(config.port, 4036);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('DittoConfig with insecure media host', () => {
|
||||
const env = new Map<string, string>([
|
||||
['LOCAL_DOMAIN', 'https://ditto.test'],
|
||||
['MEDIA_DOMAIN', 'https://ditto.test'],
|
||||
]);
|
||||
|
||||
assertThrows(
|
||||
() => new DittoConf(env),
|
||||
Error,
|
||||
'For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('DittoConfig with insecure media host and precheck disabled', () => {
|
||||
const env = new Map<string, string>([
|
||||
['LOCAL_DOMAIN', 'https://ditto.test'],
|
||||
['MEDIA_DOMAIN', 'https://ditto.test'],
|
||||
['DITTO_PRECHECK', 'false'],
|
||||
]);
|
||||
|
||||
new DittoConf(env);
|
||||
});
|
||||
|
|
@ -1,516 +0,0 @@
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NSecSigner } from '@nostrify/nostrify';
|
||||
import { decodeBase64 } from '@std/encoding/base64';
|
||||
import { encodeBase64Url } from '@std/encoding/base64url';
|
||||
import ISO6391, { type LanguageCode } from 'iso-639-1';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { getEcdsaPublicKey } from './utils/crypto.ts';
|
||||
import { optionalBooleanSchema, optionalNumberSchema } from './utils/schema.ts';
|
||||
import { mergeURLPath } from './utils/url.ts';
|
||||
|
||||
/** Ditto application-wide configuration. */
|
||||
export class DittoConf {
|
||||
constructor(private env: { get(key: string): string | undefined }) {
|
||||
if (this.precheck) {
|
||||
const mediaUrl = new URL(this.mediaDomain);
|
||||
|
||||
if (this.url.host === mediaUrl.host) {
|
||||
throw new Error(
|
||||
'For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN.\n\nTo disable this check, set DITTO_PRECHECK="false"',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cached parsed admin signer. */
|
||||
private _signer: NSecSigner | undefined;
|
||||
|
||||
/** Cached parsed VAPID public key value. */
|
||||
private _vapidPublicKey: Promise<string | undefined> | undefined;
|
||||
|
||||
/**
|
||||
* Ditto admin secret key in hex format.
|
||||
* @deprecated Use `signer` instead. TODO: handle auth tokens.
|
||||
*/
|
||||
get seckey(): Uint8Array {
|
||||
const nsec = this.env.get('DITTO_NSEC');
|
||||
|
||||
if (!nsec) {
|
||||
throw new Error('Missing DITTO_NSEC');
|
||||
}
|
||||
|
||||
if (!nsec.startsWith('nsec1')) {
|
||||
throw new Error('Invalid DITTO_NSEC');
|
||||
}
|
||||
|
||||
return nip19.decode(nsec as `nsec1${string}`).data;
|
||||
}
|
||||
|
||||
/** Ditto admin signer. */
|
||||
get signer(): NSecSigner {
|
||||
if (!this._signer) {
|
||||
this._signer = new NSecSigner(this.seckey);
|
||||
}
|
||||
return this._signer;
|
||||
}
|
||||
|
||||
/** Port to use when serving the HTTP server. */
|
||||
get port(): number {
|
||||
return parseInt(this.env.get('PORT') || '4036');
|
||||
}
|
||||
|
||||
/** IP addresses not affected by rate limiting. */
|
||||
get ipWhitelist(): string[] {
|
||||
return this.env.get('IP_WHITELIST')?.split(',') || [];
|
||||
}
|
||||
|
||||
/** Relay URL to the Ditto server's relay. */
|
||||
get relay(): `wss://${string}` | `ws://${string}` {
|
||||
const { protocol, host } = this.url;
|
||||
return `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}/relay`;
|
||||
}
|
||||
|
||||
/** Relay to use for NIP-50 `search` queries. */
|
||||
get searchRelay(): string | undefined {
|
||||
return this.env.get('SEARCH_RELAY');
|
||||
}
|
||||
|
||||
/** Origin of the Ditto server, including the protocol and port. */
|
||||
get localDomain(): string {
|
||||
return this.env.get('LOCAL_DOMAIN') || `http://localhost:${this.port}`;
|
||||
}
|
||||
|
||||
/** Link to an external nostr viewer. */
|
||||
get externalDomain(): string {
|
||||
return this.env.get('NOSTR_EXTERNAL') || 'https://njump.me';
|
||||
}
|
||||
|
||||
/** Get a link to a nip19-encoded entity in the configured external viewer. */
|
||||
external(path: string): string {
|
||||
return new URL(path, this.externalDomain).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Heroku-style database URL. This is used in production to connect to the
|
||||
* database.
|
||||
*
|
||||
* Follows the format:
|
||||
*
|
||||
* ```txt
|
||||
* protocol://username:password@host:port/database_name
|
||||
* ```
|
||||
*/
|
||||
get databaseUrl(): string {
|
||||
return this.env.get('DATABASE_URL') ?? 'file://data/pgdata';
|
||||
}
|
||||
|
||||
/** PGlite debug level. 0 disables logging. */
|
||||
get pgliteDebug(): 0 | 1 | 2 | 3 | 4 | 5 {
|
||||
return Number(this.env.get('PGLITE_DEBUG') || 0) as 0 | 1 | 2 | 3 | 4 | 5;
|
||||
}
|
||||
|
||||
get vapidPublicKey(): Promise<string | undefined> {
|
||||
if (!this._vapidPublicKey) {
|
||||
this._vapidPublicKey = (async () => {
|
||||
const keys = await this.vapidKeys;
|
||||
if (keys) {
|
||||
const { publicKey } = keys;
|
||||
const bytes = await crypto.subtle.exportKey('raw', publicKey);
|
||||
return encodeBase64Url(bytes);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return this._vapidPublicKey;
|
||||
}
|
||||
|
||||
get vapidKeys(): Promise<CryptoKeyPair | undefined> {
|
||||
return (async () => {
|
||||
const encoded = this.env.get('VAPID_PRIVATE_KEY');
|
||||
|
||||
if (!encoded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyData = decodeBase64(encoded);
|
||||
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyData,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign'],
|
||||
);
|
||||
const publicKey = await getEcdsaPublicKey(privateKey, true);
|
||||
|
||||
return { privateKey, publicKey };
|
||||
})();
|
||||
}
|
||||
|
||||
get db(): { timeouts: { default: number; relay: number; timelines: number } } {
|
||||
const env = this.env;
|
||||
return {
|
||||
/** Database query timeout configurations. */
|
||||
timeouts: {
|
||||
/** Default query timeout when another setting isn't more specific. */
|
||||
get default(): number {
|
||||
return Number(env.get('DB_TIMEOUT_DEFAULT') || 5_000);
|
||||
},
|
||||
/** Timeout used for queries made through the Nostr relay. */
|
||||
get relay(): number {
|
||||
return Number(env.get('DB_TIMEOUT_RELAY') || 1_000);
|
||||
},
|
||||
/** Timeout used for timelines such as home, notifications, hashtag, etc. */
|
||||
get timelines(): number {
|
||||
return Number(env.get('DB_TIMEOUT_TIMELINES') || 15_000);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Time-to-live for captchas in milliseconds. */
|
||||
get captchaTTL(): number {
|
||||
return Number(this.env.get('CAPTCHA_TTL') || 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
/** Character limit to enforce for posts made through Mastodon API. */
|
||||
get postCharLimit(): number {
|
||||
return Number(this.env.get('POST_CHAR_LIMIT') || 5000);
|
||||
}
|
||||
|
||||
/** S3 media storage configuration. */
|
||||
get s3(): {
|
||||
endPoint?: string;
|
||||
region?: string;
|
||||
accessKey?: string;
|
||||
secretKey?: string;
|
||||
bucket?: string;
|
||||
pathStyle?: boolean;
|
||||
port?: number;
|
||||
sessionToken?: string;
|
||||
useSSL?: boolean;
|
||||
} {
|
||||
const env = this.env;
|
||||
|
||||
return {
|
||||
get endPoint(): string | undefined {
|
||||
return env.get('S3_ENDPOINT');
|
||||
},
|
||||
get region(): string | undefined {
|
||||
return env.get('S3_REGION');
|
||||
},
|
||||
get accessKey(): string | undefined {
|
||||
return env.get('S3_ACCESS_KEY');
|
||||
},
|
||||
get secretKey(): string | undefined {
|
||||
return env.get('S3_SECRET_KEY');
|
||||
},
|
||||
get bucket(): string | undefined {
|
||||
return env.get('S3_BUCKET');
|
||||
},
|
||||
get pathStyle(): boolean | undefined {
|
||||
return optionalBooleanSchema.parse(env.get('S3_PATH_STYLE'));
|
||||
},
|
||||
get port(): number | undefined {
|
||||
return optionalNumberSchema.parse(env.get('S3_PORT'));
|
||||
},
|
||||
get sessionToken(): string | undefined {
|
||||
return env.get('S3_SESSION_TOKEN');
|
||||
},
|
||||
get useSSL(): boolean | undefined {
|
||||
return optionalBooleanSchema.parse(env.get('S3_USE_SSL'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** IPFS uploader configuration. */
|
||||
get ipfs(): { apiUrl: string } {
|
||||
const env = this.env;
|
||||
|
||||
return {
|
||||
/** Base URL for private IPFS API calls. */
|
||||
get apiUrl(): string {
|
||||
return env.get('IPFS_API_URL') || 'http://localhost:5001';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The logging configuration for the Ditto server. The config is derived from
|
||||
* the DEBUG environment variable and it is parsed as follows:
|
||||
*
|
||||
* `DEBUG='<jsonl|pretty>:<minimum log level to show>:comma-separated scopes to show'`.
|
||||
* If the scopes are empty (e.g. in 'pretty:warn:', then all scopes are shown.)
|
||||
*/
|
||||
get logConfig(): {
|
||||
fmt: 'jsonl' | 'pretty';
|
||||
level: string;
|
||||
scopes: string[];
|
||||
} {
|
||||
let [fmt, level, scopes] = (this.env.get('LOG_CONFIG') || '').split(':');
|
||||
fmt ||= 'jsonl';
|
||||
level ||= 'debug';
|
||||
scopes ||= '';
|
||||
|
||||
if (fmt !== 'jsonl' && fmt !== 'pretty') fmt = 'jsonl';
|
||||
|
||||
return {
|
||||
fmt: fmt as 'jsonl' | 'pretty',
|
||||
level,
|
||||
scopes: scopes.split(',').filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
/** nostr.build API endpoint when the `nostrbuild` uploader is used. */
|
||||
get nostrbuildEndpoint(): string {
|
||||
return this.env.get('NOSTRBUILD_ENDPOINT') || 'https://nostr.build/api/v2/upload/files';
|
||||
}
|
||||
|
||||
/** Default Blossom servers to use when the `blossom` uploader is set. */
|
||||
get blossomServers(): string[] {
|
||||
return this.env.get('BLOSSOM_SERVERS')?.split(',') || ['https://blossom.primal.net/'];
|
||||
}
|
||||
|
||||
/** Module to upload files with. */
|
||||
get uploader(): string | undefined {
|
||||
return this.env.get('DITTO_UPLOADER');
|
||||
}
|
||||
|
||||
/** Location to use for local uploads. */
|
||||
get uploadsDir(): string {
|
||||
return this.env.get('UPLOADS_DIR') || 'data/uploads';
|
||||
}
|
||||
|
||||
/** Media base URL for uploads. */
|
||||
get mediaDomain(): string {
|
||||
const value = this.env.get('MEDIA_DOMAIN');
|
||||
|
||||
if (!value) {
|
||||
const url = this.url;
|
||||
url.host = `media.${url.host}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to analyze media metadata with [blurhash](https://www.npmjs.com/package/blurhash) and [sharp](https://www.npmjs.com/package/sharp).
|
||||
* This is prone to security vulnerabilities, which is why it's not enabled by default.
|
||||
*/
|
||||
get mediaAnalyze(): boolean {
|
||||
return optionalBooleanSchema.parse(this.env.get('MEDIA_ANALYZE')) ?? false;
|
||||
}
|
||||
|
||||
/** Whether to transcode uploaded video files with ffmpeg. */
|
||||
get mediaTranscode(): boolean {
|
||||
return optionalBooleanSchema.parse(this.env.get('MEDIA_TRANSCODE')) ?? false;
|
||||
}
|
||||
|
||||
/** Max upload size for files in number of bytes. Default 100MiB. */
|
||||
get maxUploadSize(): number {
|
||||
return Number(this.env.get('MAX_UPLOAD_SIZE') || 100 * 1024 * 1024);
|
||||
}
|
||||
|
||||
/** Usernames that regular users cannot sign up with. */
|
||||
get forbiddenUsernames(): string[] {
|
||||
return this.env.get('FORBIDDEN_USERNAMES')?.split(',') || [
|
||||
'_',
|
||||
'admin',
|
||||
'administrator',
|
||||
'root',
|
||||
'sysadmin',
|
||||
'system',
|
||||
];
|
||||
}
|
||||
|
||||
/** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */
|
||||
get url(): URL {
|
||||
return new URL(this.localDomain);
|
||||
}
|
||||
|
||||
/** Merges the path with the localDomain. */
|
||||
local(path: string): string {
|
||||
return mergeURLPath(this.localDomain, path);
|
||||
}
|
||||
|
||||
/** URL to send Sentry errors to. */
|
||||
get sentryDsn(): string | undefined {
|
||||
return this.env.get('SENTRY_DSN');
|
||||
}
|
||||
|
||||
/** Postgres settings. */
|
||||
get pg(): { poolSize: number } {
|
||||
const env = this.env;
|
||||
|
||||
return {
|
||||
/** Number of connections to use in the pool. */
|
||||
get poolSize(): number {
|
||||
return Number(env.get('PG_POOL_SIZE') ?? 20);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether to enable requesting events from known relays. */
|
||||
get firehoseEnabled(): boolean {
|
||||
return optionalBooleanSchema.parse(this.env.get('FIREHOSE_ENABLED')) ?? true;
|
||||
}
|
||||
|
||||
/** Number of events the firehose is allowed to process at one time before they have to wait in a queue. */
|
||||
get firehoseConcurrency(): number {
|
||||
return Math.ceil(Number(this.env.get('FIREHOSE_CONCURRENCY') ?? 1));
|
||||
}
|
||||
|
||||
/** Nostr event kinds of events to listen for on the firehose. */
|
||||
get firehoseKinds(): number[] {
|
||||
return (this.env.get('FIREHOSE_KINDS') ?? '0, 1, 3, 5, 6, 7, 20, 9735, 10002')
|
||||
.split(/[, ]+/g)
|
||||
.map(Number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Ditto should subscribe to Nostr events from the Postgres database itself.
|
||||
* This would make Nostr events inserted directly into Postgres available to the streaming API and relay.
|
||||
*/
|
||||
get notifyEnabled(): boolean {
|
||||
return optionalBooleanSchema.parse(this.env.get('NOTIFY_ENABLED')) ?? true;
|
||||
}
|
||||
|
||||
/** Whether to enable Ditto cron jobs. */
|
||||
get cronEnabled(): boolean {
|
||||
return optionalBooleanSchema.parse(this.env.get('CRON_ENABLED')) ?? true;
|
||||
}
|
||||
|
||||
/** User-Agent to use when fetching link previews. Pretend to be Facebook by default. */
|
||||
get fetchUserAgent(): string {
|
||||
return this.env.get('DITTO_FETCH_USER_AGENT') ?? 'facebookexternalhit';
|
||||
}
|
||||
|
||||
/** Path to the custom policy module. Must be an absolute path, https:, npm:, or jsr: URI. */
|
||||
get policy(): string {
|
||||
return this.env.get('DITTO_POLICY') || path.join(this.dataDir, 'policy.ts');
|
||||
}
|
||||
|
||||
/** Absolute path to the data directory used by Ditto. */
|
||||
get dataDir(): string {
|
||||
return this.env.get('DITTO_DATA_DIR') || path.join(Deno.cwd(), 'data');
|
||||
}
|
||||
|
||||
/** Absolute path of the Deno directory. */
|
||||
get denoDir(): string {
|
||||
return this.env.get('DENO_DIR') || `${os.userInfo().homedir}/.cache/deno`;
|
||||
}
|
||||
|
||||
/** Whether zap splits should be enabled. */
|
||||
get zapSplitsEnabled(): boolean {
|
||||
return optionalBooleanSchema.parse(this.env.get('ZAP_SPLITS_ENABLED')) ?? false;
|
||||
}
|
||||
|
||||
/** Languages this server wishes to highlight. Used when querying trends.*/
|
||||
get preferredLanguages(): LanguageCode[] | undefined {
|
||||
return this.env.get('DITTO_LANGUAGES')?.split(',')?.filter(ISO6391.validate);
|
||||
}
|
||||
|
||||
/** Mints to be displayed in the UI when the user decides to create a wallet.*/
|
||||
get cashuMints(): string[] {
|
||||
return this.env.get('CASHU_MINTS')?.split(',') ?? [];
|
||||
}
|
||||
|
||||
/** Translation provider used to translate posts. */
|
||||
get translationProvider(): string | undefined {
|
||||
return this.env.get('TRANSLATION_PROVIDER');
|
||||
}
|
||||
|
||||
/** DeepL URL endpoint. */
|
||||
get deeplBaseUrl(): string | undefined {
|
||||
return this.env.get('DEEPL_BASE_URL');
|
||||
}
|
||||
|
||||
/** DeepL API KEY. */
|
||||
get deeplApiKey(): string | undefined {
|
||||
return this.env.get('DEEPL_API_KEY');
|
||||
}
|
||||
|
||||
/** LibreTranslate URL endpoint. */
|
||||
get libretranslateBaseUrl(): string | undefined {
|
||||
return this.env.get('LIBRETRANSLATE_BASE_URL');
|
||||
}
|
||||
|
||||
/** LibreTranslate API KEY. */
|
||||
get libretranslateApiKey(): string | undefined {
|
||||
return this.env.get('LIBRETRANSLATE_API_KEY');
|
||||
}
|
||||
|
||||
/** Cache settings. */
|
||||
get caches(): {
|
||||
nip05: { max: number; ttl: number };
|
||||
favicon: { max: number; ttl: number };
|
||||
translation: { max: number; ttl: number };
|
||||
} {
|
||||
const env = this.env;
|
||||
|
||||
return {
|
||||
/** NIP-05 cache settings. */
|
||||
get nip05(): { max: number; ttl: number } {
|
||||
return {
|
||||
max: Number(env.get('DITTO_CACHE_NIP05_MAX') || 3000),
|
||||
ttl: Number(env.get('DITTO_CACHE_NIP05_TTL') || 1 * 60 * 60 * 1000),
|
||||
};
|
||||
},
|
||||
/** Favicon cache settings. */
|
||||
get favicon(): { max: number; ttl: number } {
|
||||
return {
|
||||
max: Number(env.get('DITTO_CACHE_FAVICON_MAX') || 500),
|
||||
ttl: Number(env.get('DITTO_CACHE_FAVICON_TTL') || 1 * 60 * 60 * 1000),
|
||||
};
|
||||
},
|
||||
/** Translation cache settings. */
|
||||
get translation(): { max: number; ttl: number } {
|
||||
return {
|
||||
max: Number(env.get('DITTO_CACHE_TRANSLATION_MAX') || 1000),
|
||||
ttl: Number(env.get('DITTO_CACHE_TRANSLATION_TTL') || 6 * 60 * 60 * 1000),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Custom profile fields configuration. */
|
||||
get profileFields(): { maxFields: number; nameLength: number; valueLength: number } {
|
||||
const env = this.env;
|
||||
|
||||
return {
|
||||
get maxFields(): number {
|
||||
return Number(env.get('PROFILE_FIELDS_MAX_FIELDS') || 10);
|
||||
},
|
||||
get nameLength(): number {
|
||||
return Number(env.get('PROFILE_FIELDS_NAME_LENGTH') || 255);
|
||||
},
|
||||
get valueLength(): number {
|
||||
return Number(env.get('PROFILE_FIELDS_VALUE_LENGTH') || 2047);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Maximum time between events before a streak is broken, *in seconds*. */
|
||||
get streakWindow(): number {
|
||||
return Number(this.env.get('STREAK_WINDOW') || 129600);
|
||||
}
|
||||
|
||||
/** Whether to perform security/configuration checks on startup. */
|
||||
get precheck(): boolean {
|
||||
return optionalBooleanSchema.parse(this.env.get('DITTO_PRECHECK')) ?? true;
|
||||
}
|
||||
|
||||
/** Path to `ffmpeg` executable. */
|
||||
get ffmpegPath(): string {
|
||||
return this.env.get('FFMPEG_PATH') || 'ffmpeg';
|
||||
}
|
||||
|
||||
/** Path to `ffprobe` executable. */
|
||||
get ffprobePath(): string {
|
||||
return this.env.get('FFPROBE_PATH') || 'ffprobe';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"name": "@ditto/conf",
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { DittoConf } from './DittoConf.ts';
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { getEcdsaPublicKey } from './crypto.ts';
|
||||
|
||||
Deno.test('getEcdsaPublicKey', async () => {
|
||||
const { publicKey, privateKey } = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
const result = await getEcdsaPublicKey(privateKey, true);
|
||||
|
||||
assertKeysEqual(result, publicKey);
|
||||
});
|
||||
|
||||
/** Assert that two CryptoKey objects are equal by value. Keys must be exportable. */
|
||||
async function assertKeysEqual(a: CryptoKey, b: CryptoKey): Promise<void> {
|
||||
const [jwk1, jwk2] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', a),
|
||||
crypto.subtle.exportKey('jwk', b),
|
||||
]);
|
||||
|
||||
assertEquals(jwk1, jwk2);
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/**
|
||||
* Convert an ECDSA private key into a public key.
|
||||
* https://stackoverflow.com/a/72153942
|
||||
*/
|
||||
export async function getEcdsaPublicKey(
|
||||
privateKey: CryptoKey,
|
||||
extractable: boolean,
|
||||
): Promise<CryptoKey> {
|
||||
if (privateKey.type !== 'private') {
|
||||
throw new Error('Expected a private key.');
|
||||
}
|
||||
if (privateKey.algorithm.name !== 'ECDSA') {
|
||||
throw new Error('Expected a private key with the ECDSA algorithm.');
|
||||
}
|
||||
|
||||
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
|
||||
const keyUsages: KeyUsage[] = ['verify'];
|
||||
|
||||
// Remove the private property from the JWK.
|
||||
delete jwk.d;
|
||||
jwk.key_ops = keyUsages;
|
||||
jwk.ext = extractable;
|
||||
|
||||
return crypto.subtle.importKey('jwk', jwk, privateKey.algorithm, extractable, keyUsages);
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { assertEquals, assertThrows } from '@std/assert';
|
||||
|
||||
import { optionalBooleanSchema, optionalNumberSchema } from './schema.ts';
|
||||
|
||||
Deno.test('optionalBooleanSchema', () => {
|
||||
assertEquals(optionalBooleanSchema.parse('true'), true);
|
||||
assertEquals(optionalBooleanSchema.parse('false'), false);
|
||||
assertEquals(optionalBooleanSchema.parse(undefined), undefined);
|
||||
|
||||
assertThrows(() => optionalBooleanSchema.parse('invalid'));
|
||||
});
|
||||
|
||||
Deno.test('optionalNumberSchema', () => {
|
||||
assertEquals(optionalNumberSchema.parse('123'), 123);
|
||||
assertEquals(optionalNumberSchema.parse('invalid'), NaN); // maybe this should throw?
|
||||
assertEquals(optionalNumberSchema.parse(undefined), undefined);
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const optionalBooleanSchema = z
|
||||
.enum(['true', 'false'])
|
||||
.optional()
|
||||
.transform((value) => value !== undefined ? value === 'true' : undefined);
|
||||
|
||||
export const optionalNumberSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value !== undefined ? Number(value) : undefined);
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { mergeURLPath } from './url.ts';
|
||||
|
||||
Deno.test('mergeURLPath', () => {
|
||||
assertEquals(mergeURLPath('https://mario.com', '/path'), 'https://mario.com/path');
|
||||
assertEquals(mergeURLPath('https://mario.com', 'https://luigi.com/path'), 'https://mario.com/path');
|
||||
assertEquals(mergeURLPath('https://mario.com', 'https://luigi.com/path?q=1'), 'https://mario.com/path?q=1');
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/**
|
||||
* Produce a URL whose origin is guaranteed to be the same as the base URL.
|
||||
* The path is either an absolute path (starting with `/`), or a full URL. In either case, only its path is used.
|
||||
*/
|
||||
export function mergeURLPath(
|
||||
/** Base URL. Result is guaranteed to use this URL's origin. */
|
||||
base: string,
|
||||
/** Either an absolute path (starting with `/`), or a full URL. If a full URL, its path */
|
||||
path: string,
|
||||
): string {
|
||||
const url = new URL(
|
||||
path.startsWith('/') ? path : new URL(path).pathname,
|
||||
base,
|
||||
);
|
||||
|
||||
if (!path.startsWith('/')) {
|
||||
// Copy query parameters from the original URL to the new URL
|
||||
const originalUrl = new URL(path);
|
||||
url.search = originalUrl.search;
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import type { Kysely } from 'kysely';
|
||||
|
||||
import type { DittoTables } from './DittoTables.ts';
|
||||
|
||||
export interface DittoDB extends AsyncDisposable {
|
||||
readonly kysely: Kysely<DittoTables>;
|
||||
readonly poolSize: number;
|
||||
readonly availableConnections: number;
|
||||
migrate(): Promise<void>;
|
||||
listen(channel: string, callback: (payload: string) => void): void;
|
||||
}
|
||||
|
||||
export interface DittoDBOpts {
|
||||
poolSize?: number;
|
||||
debug?: 0 | 1 | 2 | 3 | 4 | 5;
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { logi } from '@soapbox/logi';
|
||||
import { FileMigrationProvider, type Kysely, Migrator } from 'kysely';
|
||||
|
||||
import type { JsonValue } from '@std/json';
|
||||
|
||||
export class DittoPgMigrator {
|
||||
private migrator: Migrator;
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
constructor(private kysely: Kysely<any>) {
|
||||
this.migrator = new Migrator({
|
||||
db: this.kysely,
|
||||
provider: new FileMigrationProvider({
|
||||
fs,
|
||||
path,
|
||||
migrationFolder: new URL(import.meta.resolve('./migrations')).pathname,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async migrate(): Promise<void> {
|
||||
logi({ level: 'info', ns: 'ditto.db.migration', msg: 'Running migrations...', state: 'started' });
|
||||
const { results, error } = await this.migrator.migrateToLatest();
|
||||
|
||||
if (error) {
|
||||
logi({
|
||||
level: 'fatal',
|
||||
ns: 'ditto.db.migration',
|
||||
msg: 'Migration failed.',
|
||||
state: 'failed',
|
||||
results: results as unknown as JsonValue,
|
||||
error: error instanceof Error ? error : null,
|
||||
});
|
||||
throw new Error('Migration failed.');
|
||||
} else {
|
||||
if (!results?.length) {
|
||||
logi({ level: 'info', ns: 'ditto.db.migration', msg: 'Everything up-to-date.', state: 'skipped' });
|
||||
} else {
|
||||
logi({
|
||||
level: 'info',
|
||||
ns: 'ditto.db.migration',
|
||||
msg: 'Migrations finished!',
|
||||
state: 'migrated',
|
||||
results: results as unknown as JsonValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import type { NPostgresSchema } from '@nostrify/db';
|
||||
import type { Generated } from 'kysely';
|
||||
|
||||
import type { MastodonPreviewCard } from '@ditto/mastoapi/types';
|
||||
|
||||
export interface DittoTables extends NPostgresSchema {
|
||||
auth_tokens: AuthTokenRow;
|
||||
author_stats: AuthorStatsRow;
|
||||
domain_favicons: DomainFaviconRow;
|
||||
event_stats: EventStatsRow;
|
||||
event_zaps: EventZapRow;
|
||||
push_subscriptions: PushSubscriptionRow;
|
||||
/** This is a materialized view of `author_stats` pre-sorted by followers_count. */
|
||||
top_authors: Pick<AuthorStatsRow, 'pubkey' | 'followers_count' | 'search'>;
|
||||
}
|
||||
|
||||
interface AuthorStatsRow {
|
||||
pubkey: string;
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
notes_count: number;
|
||||
search: string;
|
||||
streak_start: number | null;
|
||||
streak_end: number | null;
|
||||
nip05: string | null;
|
||||
nip05_domain: string | null;
|
||||
nip05_hostname: string | null;
|
||||
nip05_last_verified_at: number | null;
|
||||
}
|
||||
|
||||
interface EventStatsRow {
|
||||
event_id: string;
|
||||
replies_count: number;
|
||||
reposts_count: number;
|
||||
reactions_count: number;
|
||||
quotes_count: number;
|
||||
reactions: string;
|
||||
zaps_amount: number;
|
||||
zaps_amount_cashu: number;
|
||||
link_preview?: MastodonPreviewCard;
|
||||
}
|
||||
|
||||
interface AuthTokenRow {
|
||||
token_hash: Uint8Array;
|
||||
pubkey: string;
|
||||
bunker_pubkey: string;
|
||||
nip46_sk_enc: Uint8Array;
|
||||
nip46_relays: string[];
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface DomainFaviconRow {
|
||||
domain: string;
|
||||
favicon: string;
|
||||
last_updated_at: number;
|
||||
}
|
||||
|
||||
interface EventZapRow {
|
||||
receipt_id: string;
|
||||
target_event_id: string;
|
||||
sender_pubkey: string;
|
||||
amount_millisats: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
interface PushSubscriptionRow {
|
||||
id: Generated<bigint>;
|
||||
pubkey: string;
|
||||
token_hash: Uint8Array;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
data: {
|
||||
alerts?: {
|
||||
mention?: boolean;
|
||||
status?: boolean;
|
||||
reblog?: boolean;
|
||||
follow?: boolean;
|
||||
follow_request?: boolean;
|
||||
favourite?: boolean;
|
||||
poll?: boolean;
|
||||
update?: boolean;
|
||||
'admin.sign_up'?: boolean;
|
||||
'admin.report'?: boolean;
|
||||
};
|
||||
policy?: 'all' | 'followed' | 'follower' | 'none';
|
||||
} | null;
|
||||
created_at: Generated<Date>;
|
||||
updated_at: Generated<Date>;
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { dbQueriesCounter, dbQueryDurationHistogram } from '@ditto/metrics';
|
||||
import { logi, type LogiValue } from '@soapbox/logi';
|
||||
|
||||
import type { Logger } from 'kysely';
|
||||
|
||||
/** Log the SQL for queries. */
|
||||
export const KyselyLogger: Logger = (event) => {
|
||||
const { query, queryDurationMillis } = event;
|
||||
const { parameters, sql } = query;
|
||||
|
||||
const duration = queryDurationMillis / 1000;
|
||||
|
||||
dbQueriesCounter.inc();
|
||||
dbQueryDurationHistogram.observe(duration);
|
||||
|
||||
if (event.level === 'query') {
|
||||
logi({ level: 'trace', ns: 'ditto.sql', sql, parameters: parameters as LogiValue, duration });
|
||||
}
|
||||
|
||||
if (event.level === 'error') {
|
||||
if (event.error instanceof Error) {
|
||||
switch (event.error.message) {
|
||||
case 'duplicate key value violates unique constraint "nostr_events_pkey"':
|
||||
case 'duplicate key value violates unique constraint "author_stats_pkey"':
|
||||
case 'duplicate key value violates unique constraint "event_stats_pkey"':
|
||||
case 'duplicate key value violates unique constraint "event_zaps_pkey"':
|
||||
case 'insert or update on table "event_stats" violates foreign key constraint "event_stats_event_id_fkey"':
|
||||
return; // Don't log expected errors
|
||||
}
|
||||
}
|
||||
|
||||
logi({
|
||||
level: 'error',
|
||||
ns: 'ditto.sql',
|
||||
sql,
|
||||
parameters: parameters as LogiValue,
|
||||
error: event.error instanceof Error ? event.error : null,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { assertEquals, assertRejects } from '@std/assert';
|
||||
|
||||
import { DittoPglite } from './DittoPglite.ts';
|
||||
|
||||
Deno.test('DittoPglite', async () => {
|
||||
await using db = new DittoPglite('memory://');
|
||||
await db.migrate();
|
||||
|
||||
assertEquals(db.poolSize, 1);
|
||||
assertEquals(db.availableConnections, 1);
|
||||
});
|
||||
|
||||
Deno.test('DittoPglite query after closing', async () => {
|
||||
const db = new DittoPglite('memory://');
|
||||
await db[Symbol.asyncDispose]();
|
||||
|
||||
await assertRejects(
|
||||
() => db.kysely.selectFrom('nostr_events').selectAll().execute(),
|
||||
Error,
|
||||
'PGlite is closed',
|
||||
);
|
||||
});
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { PGlite } from '@electric-sql/pglite';
|
||||
import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm';
|
||||
import { PgliteDialect } from '@soapbox/kysely-pglite';
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
import { KyselyLogger } from '../KyselyLogger.ts';
|
||||
import { DittoPgMigrator } from '../DittoPgMigrator.ts';
|
||||
import { isWorker } from '../utils/worker.ts';
|
||||
|
||||
import type { DittoDB, DittoDBOpts } from '../DittoDB.ts';
|
||||
import type { DittoTables } from '../DittoTables.ts';
|
||||
|
||||
export class DittoPglite implements DittoDB {
|
||||
readonly poolSize = 1;
|
||||
readonly availableConnections = 1;
|
||||
readonly kysely: Kysely<DittoTables>;
|
||||
|
||||
private pglite: PGlite;
|
||||
private migrator: DittoPgMigrator;
|
||||
|
||||
constructor(databaseUrl: string, opts?: DittoDBOpts) {
|
||||
const url = new URL(databaseUrl);
|
||||
|
||||
if (url.protocol === 'file:' && isWorker()) {
|
||||
throw new Error('PGlite is not supported in worker threads.');
|
||||
}
|
||||
|
||||
this.pglite = new PGlite(databaseUrl, {
|
||||
extensions: { pg_trgm },
|
||||
debug: opts?.debug,
|
||||
});
|
||||
|
||||
this.kysely = new Kysely<DittoTables>({
|
||||
dialect: new PgliteDialect({ database: this.pglite }),
|
||||
log: KyselyLogger,
|
||||
});
|
||||
|
||||
this.migrator = new DittoPgMigrator(this.kysely);
|
||||
}
|
||||
|
||||
listen(channel: string, callback: (payload: string) => void): void {
|
||||
this.pglite.listen(channel, callback);
|
||||
}
|
||||
|
||||
async migrate(): Promise<void> {
|
||||
await this.migrator.migrate();
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
try {
|
||||
// FIXME: `kysely.destroy()` calls `pglite.close()` internally, but it doesn't work.
|
||||
await this.pglite.close();
|
||||
await this.kysely.destroy();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === 'PGlite is closed') {
|
||||
// Make dispose idempotent.
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { DittoPolyPg } from './DittoPolyPg.ts';
|
||||
|
||||
Deno.test('DittoPolyPg', async () => {
|
||||
const db = new DittoPolyPg('memory://');
|
||||
await db.migrate();
|
||||
});
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { DittoPglite } from './DittoPglite.ts';
|
||||
import { DittoPostgres } from './DittoPostgres.ts';
|
||||
|
||||
import type { Kysely } from 'kysely';
|
||||
import type { DittoDB, DittoDBOpts } from '../DittoDB.ts';
|
||||
import type { DittoTables } from '../DittoTables.ts';
|
||||
|
||||
/** Creates either a PGlite or Postgres connection depending on the databaseUrl. */
|
||||
export class DittoPolyPg implements DittoDB {
|
||||
private adapter: DittoDB;
|
||||
|
||||
/** Open a new database connection. */
|
||||
constructor(databaseUrl: string, opts?: DittoDBOpts) {
|
||||
const { protocol } = new URL(databaseUrl);
|
||||
|
||||
switch (protocol) {
|
||||
case 'file:':
|
||||
case 'memory:':
|
||||
this.adapter = new DittoPglite(databaseUrl, opts);
|
||||
break;
|
||||
case 'postgres:':
|
||||
case 'postgresql:':
|
||||
this.adapter = new DittoPostgres(databaseUrl, opts);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unsupported database URL.');
|
||||
}
|
||||
}
|
||||
|
||||
get kysely(): Kysely<DittoTables> {
|
||||
return this.adapter.kysely;
|
||||
}
|
||||
|
||||
async migrate(): Promise<void> {
|
||||
await this.adapter.migrate();
|
||||
}
|
||||
|
||||
listen(channel: string, callback: (payload: string) => void): void {
|
||||
this.adapter.listen(channel, callback);
|
||||
}
|
||||
|
||||
get poolSize(): number {
|
||||
return this.adapter.poolSize;
|
||||
}
|
||||
|
||||
get availableConnections(): number {
|
||||
return this.adapter.availableConnections;
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.adapter[Symbol.asyncDispose]();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { DittoConf } from '@ditto/conf';
|
||||
|
||||
import { DittoPostgres } from './DittoPostgres.ts';
|
||||
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const isPostgres = /^postgres(?:ql)?:/.test(conf.databaseUrl);
|
||||
|
||||
Deno.test('DittoPostgres', { ignore: !isPostgres }, async () => {
|
||||
await using db = new DittoPostgres(conf.databaseUrl);
|
||||
await db.migrate();
|
||||
});
|
||||
|
||||
// FIXME: There is a problem with postgres-js where queries just hang after the database is closed.
|
||||
|
||||
// Deno.test('DittoPostgres query after closing', { ignore: !isPostgres }, async () => {
|
||||
// const db = new DittoPostgres(conf.databaseUrl);
|
||||
// await db[Symbol.asyncDispose]();
|
||||
//
|
||||
// await assertRejects(
|
||||
// () => db.kysely.selectFrom('nostr_events').selectAll().execute(),
|
||||
// );
|
||||
// });
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import {
|
||||
type BinaryOperationNode,
|
||||
FunctionNode,
|
||||
Kysely,
|
||||
OperatorNode,
|
||||
PostgresAdapter,
|
||||
PostgresIntrospector,
|
||||
PostgresQueryCompiler,
|
||||
PrimitiveValueListNode,
|
||||
ValueNode,
|
||||
} from 'kysely';
|
||||
import { type PostgresJSDialectConfig, PostgresJSDriver } from 'kysely-postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
import { DittoPgMigrator } from '../DittoPgMigrator.ts';
|
||||
import { KyselyLogger } from '../KyselyLogger.ts';
|
||||
|
||||
import type { DittoDB, DittoDBOpts } from '../DittoDB.ts';
|
||||
import type { DittoTables } from '../DittoTables.ts';
|
||||
|
||||
export class DittoPostgres implements DittoDB {
|
||||
private pg: ReturnType<typeof postgres>;
|
||||
private migrator: DittoPgMigrator;
|
||||
|
||||
readonly kysely: Kysely<DittoTables>;
|
||||
|
||||
constructor(databaseUrl: string, opts?: DittoDBOpts) {
|
||||
this.pg = postgres(databaseUrl, { max: opts?.poolSize });
|
||||
|
||||
this.kysely = new Kysely<DittoTables>({
|
||||
dialect: {
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createDriver: () =>
|
||||
new PostgresJSDriver({ postgres: this.pg as unknown as PostgresJSDialectConfig['postgres'] }),
|
||||
createIntrospector: (db) => new PostgresIntrospector(db),
|
||||
createQueryCompiler: () => new DittoPostgresQueryCompiler(),
|
||||
},
|
||||
log: KyselyLogger,
|
||||
});
|
||||
|
||||
this.migrator = new DittoPgMigrator(this.kysely);
|
||||
}
|
||||
|
||||
listen(channel: string, callback: (payload: string) => void): void {
|
||||
this.pg.listen(channel, callback);
|
||||
}
|
||||
|
||||
async migrate(): Promise<void> {
|
||||
await this.migrator.migrate();
|
||||
}
|
||||
|
||||
get poolSize(): number {
|
||||
return this.pg.connections.open;
|
||||
}
|
||||
|
||||
get availableConnections(): number {
|
||||
return this.pg.connections.idle;
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.pg.end({ timeout: 0 }); // force-close the connections
|
||||
await this.kysely.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts `in` queries to `any` to improve prepared statements on Postgres. */
|
||||
class DittoPostgresQueryCompiler extends PostgresQueryCompiler {
|
||||
protected override visitBinaryOperation(node: BinaryOperationNode): void {
|
||||
if (
|
||||
OperatorNode.is(node.operator) && node.operator.operator === 'in' && PrimitiveValueListNode.is(node.rightOperand)
|
||||
) {
|
||||
this.visitNode(node.leftOperand);
|
||||
this.append(' = ');
|
||||
this.visitNode(FunctionNode.create('any', [ValueNode.create(node.rightOperand.values)]));
|
||||
} else {
|
||||
super.visitBinaryOperation(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
import { DummyDB } from './DummyDB.ts';
|
||||
|
||||
Deno.test('DummyDB', async () => {
|
||||
const db = new DummyDB();
|
||||
await db.migrate();
|
||||
|
||||
const rows = await db.kysely.selectFrom('nostr_events').selectAll().execute();
|
||||
|
||||
assertEquals(rows, []);
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely';
|
||||
|
||||
import type { DittoDB } from '../DittoDB.ts';
|
||||
import type { DittoTables } from '../DittoTables.ts';
|
||||
|
||||
export class DummyDB implements DittoDB {
|
||||
readonly kysely: Kysely<DittoTables>;
|
||||
readonly poolSize = 0;
|
||||
readonly availableConnections = 0;
|
||||
|
||||
constructor() {
|
||||
this.kysely = new Kysely<DittoTables>({
|
||||
dialect: {
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createDriver: () => new DummyDriver(),
|
||||
createIntrospector: (db) => new PostgresIntrospector(db),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
listen(): void {
|
||||
// noop
|
||||
}
|
||||
|
||||
migrate(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
[Symbol.asyncDispose](): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { DittoConf } from '@ditto/conf';
|
||||
import { NPostgres } from '@nostrify/db';
|
||||
import { genEvent } from '@nostrify/nostrify/test';
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { DittoPolyPg } from './DittoPolyPg.ts';
|
||||
import { TestDB } from './TestDB.ts';
|
||||
|
||||
Deno.test('TestDB', async () => {
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const orig = new DittoPolyPg(conf.databaseUrl);
|
||||
|
||||
await using db = new TestDB(orig);
|
||||
await db.migrate();
|
||||
await db.clear();
|
||||
|
||||
const store = new NPostgres(orig.kysely);
|
||||
await store.event(genEvent());
|
||||
|
||||
assertEquals((await store.count([{}])).count, 1);
|
||||
|
||||
await db.clear();
|
||||
|
||||
assertEquals((await store.count([{}])).count, 0);
|
||||
});
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
import type { DittoDB } from '../DittoDB.ts';
|
||||
import type { DittoTables } from '../DittoTables.ts';
|
||||
|
||||
/** Wraps another DittoDB implementation to clear all data when disposed. */
|
||||
export class TestDB implements DittoDB {
|
||||
constructor(private db: DittoDB) {}
|
||||
|
||||
get kysely(): Kysely<DittoTables> {
|
||||
return this.db.kysely;
|
||||
}
|
||||
|
||||
get poolSize(): number {
|
||||
return this.db.poolSize;
|
||||
}
|
||||
|
||||
get availableConnections(): number {
|
||||
return this.db.availableConnections;
|
||||
}
|
||||
|
||||
migrate(): Promise<void> {
|
||||
return this.db.migrate();
|
||||
}
|
||||
|
||||
listen(channel: string, callback: (payload: string) => void): void {
|
||||
return this.db.listen(channel, callback);
|
||||
}
|
||||
|
||||
/** Truncate all tables. */
|
||||
async clear(): Promise<void> {
|
||||
const query = sql<{ tablename: string }>`select tablename from pg_tables where schemaname = current_schema()`;
|
||||
|
||||
const { rows } = await query.execute(this.db.kysely);
|
||||
|
||||
for (const { tablename } of rows) {
|
||||
if (tablename.startsWith('kysely_')) {
|
||||
continue; // Skip Kysely's internal tables
|
||||
} else {
|
||||
await sql`truncate table ${sql.ref(tablename)} cascade`.execute(this.db.kysely);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.clear();
|
||||
await this.db[Symbol.asyncDispose]();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"name": "@ditto/db",
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import type { Kysely } from 'kysely';
|
||||
|
||||
export async function up(_db: Kysely<unknown>): Promise<void> {
|
||||
// This migration used to create an FTS table for SQLite, but SQLite support was removed.
|
||||
}
|
||||
|
||||
export async function down(_db: Kysely<unknown>): Promise<void> {
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import type { Kysely } from 'kysely';
|
||||
|
||||
export async function up(_db: Kysely<unknown>): Promise<void> {
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema.alterTable('users').dropColumn('admin').execute();
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import type { Kysely } from 'kysely';
|
||||
|
||||
export async function up(_db: Kysely<unknown>): Promise<void> {
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema.dropIndex('idx_users_pubkey').execute();
|
||||
await db.schema.dropIndex('idx_users_username').execute();
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import type { Kysely } from 'kysely';
|
||||
|
||||
export async function up(_db: Kysely<unknown>): Promise<void> {
|
||||
}
|
||||
|
||||
export async function down(_db: Kysely<unknown>): Promise<void> {
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import type { Kysely } from 'kysely';
|
||||
|
||||
export async function up(_db: Kysely<unknown>): Promise<void> {
|
||||
}
|
||||
|
||||
export async function down(_db: Kysely<unknown>): Promise<void> {
|
||||
}
|
||||