Compare commits
No commits in common. "main" and "v0.1.0-alpha.1" have entirely different histories.
main
...
v0.1.0-alp
|
|
@ -1,6 +0,0 @@
|
|||
.env
|
||||
*.cpuprofile
|
||||
*.swp
|
||||
deno-test.xml
|
||||
|
||||
/data
|
||||
4
.gitignore
vendored
|
|
@ -1,5 +1 @@
|
|||
.env
|
||||
.env.*
|
||||
*.cpuprofile
|
||||
*.swp
|
||||
deno-test.xml
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
image: denoland/deno:2.2.2
|
||||
image: denoland/deno:1.36.1
|
||||
|
||||
default:
|
||||
interruptible: true
|
||||
|
|
@ -6,26 +6,14 @@ default:
|
|||
stages:
|
||||
- test
|
||||
|
||||
fmt:
|
||||
stage: test
|
||||
script: deno fmt --check
|
||||
|
||||
lint:
|
||||
stage: test
|
||||
script: deno lint
|
||||
|
||||
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
|
||||
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
|
||||
script: deno task test
|
||||
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,4 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/hook.sh"
|
||||
|
||||
deno run -A npm:lint-staged
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"*.{ts,tsx,md}": "deno fmt"
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
deno 2.2.2
|
||||
deno 1.36.1
|
||||
|
|
|
|||
23
.vscode/launch.json
vendored
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"type": "node",
|
||||
"program": "${workspaceFolder}/packages/ditto/server.ts",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "deno",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"--inspect-wait",
|
||||
"--allow-all",
|
||||
"--unstable"
|
||||
],
|
||||
"attachSimplePort": 9229
|
||||
}
|
||||
]
|
||||
}
|
||||
5
.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
|
||||
}
|
||||
29
CHANGELOG.md
|
|
@ -1,29 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [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
|
||||
[1.0.0]: https://gitlab.com/soapbox-pub/ditto/-/tags/v1.0.0
|
||||
10
Dockerfile
|
|
@ -1,10 +0,0 @@
|
|||
FROM denoland/deno:2.2.2
|
||||
ENV PORT 5000
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p data && chown -R deno data
|
||||
COPY . .
|
||||
RUN deno cache --allow-import packages/ditto/server.ts
|
||||
RUN apt-get update && apt-get install -y unzip curl
|
||||
RUN deno task soapbox
|
||||
CMD deno task start
|
||||
40
README.md
|
|
@ -1,24 +1,28 @@
|
|||
# Ditto
|
||||
|
||||
Ditto is a Nostr server for building resilient communities online.
|
||||
With Ditto, you can create your own social network that is decentralized, customizable, and free from ads and tracking.
|
||||
|
||||
For more info see: https://docs.soapbox.pub/ditto/
|
||||
Ditto is a tiny but powerful social media server for the decentralized web. With Ditto you will be able to interact across protocols and networks, and build your own social media experience.
|
||||
|
||||
<img width="400" src="ditto-planet.png">
|
||||
|
||||
⚠️ This software is a work in progress.
|
||||
|
||||
## Supported protocols
|
||||
|
||||
- [x] Nostr
|
||||
- [ ] ActivityPub
|
||||
|
||||
## Features
|
||||
|
||||
- [x] Built-in Nostr relay
|
||||
- [ ] Follow users across networks
|
||||
- [ ] Post to multiple networks at once
|
||||
- [x] Log in with any Mastodon app
|
||||
- [x] Like and comment on posts
|
||||
- [x] Share posts
|
||||
- [x] Reposts
|
||||
- [x] Notifications
|
||||
- [ ] Reposts
|
||||
- [ ] Notifications
|
||||
- [x] Profiles
|
||||
- [x] Search
|
||||
- [x] Moderation
|
||||
- [x] Zaps
|
||||
- [ ] Search
|
||||
- [ ] Moderation
|
||||
- [x] Customizable
|
||||
- [x] Open source
|
||||
- [x] Self-hosted
|
||||
|
|
@ -27,15 +31,21 @@ For more info see: https://docs.soapbox.pub/ditto/
|
|||
- [x] No tracking
|
||||
- [x] No censorship
|
||||
|
||||
## Federation
|
||||
|
||||
Ditto is primarily a Nostr client, using a Nostr relay as its database. ActivityPub objects are translated into Nostr events in realtime and cached by the Ditto server. When you submit a post, it sends it to your Nostr relay and then fans it out to the ActivityPub network.
|
||||
|
||||
The main way to use Ditto is with a Mastodon app. Or you can connect directly to the Nostr relay with a Nostr client.
|
||||
|
||||
## Installation
|
||||
|
||||
TODO
|
||||
|
||||
## Development
|
||||
|
||||
1. Install [Deno](https://deno.land).
|
||||
2. Clone this repo.
|
||||
3. Download [Soapbox](https://dl.soapbox.pub/) or another web-based Mastodon client of your choice.
|
||||
4. Put the frontend files inside the `public` directory.
|
||||
5. Create an `.env` file.
|
||||
6. Define `DITTO_NSEC=<value>` in your .env file. You can generate an nsec by running `deno task nsec`.
|
||||
7. Run `deno task dev`.
|
||||
3. Run `deno task dev`
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
129
deno.json
|
|
@ -1,122 +1,27 @@
|
|||
{
|
||||
"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.32.3/cli/schemas/config-file.v1.json",
|
||||
"lock": false,
|
||||
"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",
|
||||
"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",
|
||||
"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"
|
||||
"start": "deno run --allow-read --allow-write=data --allow-env --allow-net src/server.ts",
|
||||
"dev": "deno run --allow-read --allow-write=data --allow-env --allow-net --watch src/server.ts",
|
||||
"debug": "deno run --allow-read --allow-write=data --allow-env --allow-net --inspect src/server.ts",
|
||||
"test": "DB_PATH=\":memory:\" deno test --allow-read --allow-write=data --allow-env src",
|
||||
"check": "deno check src/server.ts",
|
||||
"relays:sync": "deno run -A scripts/relays.ts sync"
|
||||
},
|
||||
"unstable": [
|
||||
"cron",
|
||||
"ffi",
|
||||
"kv",
|
||||
"worker-options"
|
||||
],
|
||||
"exclude": [
|
||||
"./public"
|
||||
],
|
||||
"imports": {
|
||||
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
||||
"@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",
|
||||
"@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",
|
||||
"@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",
|
||||
"@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/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",
|
||||
"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",
|
||||
"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-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",
|
||||
"unfurl.js": "npm:unfurl.js@^6.4.0",
|
||||
"zod": "npm:zod@^3.23.8",
|
||||
"@/": "./src/",
|
||||
"~/fixtures/": "./fixtures/"
|
||||
},
|
||||
"lint": {
|
||||
"include": ["src/", "scripts/"],
|
||||
"rules": {
|
||||
"tags": ["recommended"],
|
||||
"exclude": ["no-explicit-any"]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"include": ["src/", "scripts/"],
|
||||
"useTabs": false,
|
||||
"lineWidth": 120,
|
||||
"indentWidth": 2,
|
||||
|
|
|
|||
13
docs/nip78.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Ditto NIP-78 events
|
||||
|
||||
[NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md) defines events of kind `30078` with a globally unique `d` tag. These events are queried by the `d` tag, which allows Ditto to store custom data on relays. Ditto uses reverse DNS names like `pub.ditto.<thing>` for `d` tags.
|
||||
|
||||
The sections below describe the `content` field. Some are encrypted and some are not, depending on whether the data should be public. Also, some events are user events, and some are admin events.
|
||||
|
||||
## `pub.ditto.blocks`
|
||||
|
||||
An encrypted array of blocked pubkeys, JSON stringified and encrypted with `nip07.encrypt`.
|
||||
|
||||
## `pub.ditto.frontendConfig`
|
||||
|
||||
JSON data for Pleroma frontends served on `/api/pleroma/frontend_configurations`. Each key contains arbitrary data used by a different frontend.
|
||||
|
|
@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "4acbf01269a2b09aaa4559b6d950ceffe37985dc3eb56c3d1bb3200ca93fae3d",
|
||||
"pubkey": "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991",
|
||||
"created_at": 1713452168,
|
||||
"kind": 0,
|
||||
"tags": [],
|
||||
"content": "{\"name\":\"me\",\"about\":\"\",\"nip05\":\"\"}",
|
||||
"sig": "373ca965fc3772804cf448db8da3add6f59653cb1ba8ba89b8d8fc88e4ed326b446e2641ed675dcaab886eb2678cca5293c6312e03ed9e73ccebca14ef47eaaa"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "155ee53d9319d427ac0d5a8f0089654d8db66d0f1b31d8bd0d389b7a5417992f",
|
||||
"pubkey": "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
|
||||
"created_at": 1712507673,
|
||||
"kind": 0,
|
||||
"tags": [],
|
||||
"content": "{\"displayName\":\"ODELL\",\"pubkey\":\"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9\",\"npub\":\"npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx\",\"created_at\":1712419265,\"display_name\":\"ODELL\",\"name\":\"ODELL\",\"website\":\"https://odell.xyz\",\"about\":\"FREEDOM TECH IS HOPE 🫡 | COFOUNDER - OPENSATS AND BITCOIN PARK | MANAGING PARTNER - TEN31 |\",\"lud16\":\"odell@vlt.ge\",\"nip05\":\"odell@werunbtc.com\",\"picture\":\"https://m.primal.net/Hrsv.webp\",\"banner\":\"https://m.primal.net/HqQz.jpg\"}",
|
||||
"sig": "355663a38ebd1cb31b5d2864357c4fdbeecfaedd602133638cf255ea30e6e532e3c927495c5eb5dfc0e77046d2e1d4e8be271f279c2c7eb9c9273028bfc033f4"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "6bc9ca44feb5a261841873def54a81cc328737391dc10f7eada31173a399517d",
|
||||
"pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4",
|
||||
"created_at": 1712851917,
|
||||
"kind": 0,
|
||||
"tags": [],
|
||||
"content": "{\"name\":\"patrickReiis\",\"picture\":\"https://void.cat/d/EMs8Qdn5wsAMrZ5T9T44sz.webp\"}",
|
||||
"sig": "cedbd2585c18c9ee8cbafa4e3b1fefbe68cc15deeabcb0519791c6d715f92d1439ca9ac7584185a94d521709f9023fcbafab47a074a7ce8a247d3ce4dfce8af3"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "343e756c454d1fe623e0ea5a7653e5d0cb643fee49acef4b4e8df7645d27c8e4",
|
||||
"pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
|
||||
"created_at": 1712835974,
|
||||
"kind": 0,
|
||||
"tags": [],
|
||||
"content": "{\"lud16\":\"jack@primal.net\",\"about\":\"bitcoin & chill\",\"picture\":\"https:\\/\\/nostr.build\\/i\\/p\\/nostr.build_6b9909bccf0f4fdaf7aacd9bc01e4ce70dab86f7d90395f2ce925e6ea06ed7cd.jpeg\",\"display_name\":\"\",\"lud06\":\"\",\"banner\":\"https:\\/\\/upload.wikimedia.org\\/wikipedia\\/commons\\/b\\/b4\\/The_Sun_by_the_Atmospheric_Imaging_Assembly_of_NASA%27s_Solar_Dynamics_Observatory_-_20100819.jpg\",\"website\":\"\",\"nip05\":\"\",\"name\":\"jack\"}",
|
||||
"sig": "f3a896b67145eeca606c30375a146055c424ce1216f3e894d720733912aba3a90cf70e018d131246977c85b4ed9491fa43843e7728caab6b28da5d80decf9045"
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"id": "63d38c9b483d2d98a46382eadefd272e0e4bdb106a5b6eddb400c4e76f693d35",
|
||||
"pubkey": "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6",
|
||||
"created_at": 1699398376,
|
||||
"kind": 0,
|
||||
"tags": [
|
||||
[
|
||||
"proxy",
|
||||
"https://gleasonator.com/users/alex",
|
||||
"activitypub"
|
||||
]
|
||||
],
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"id": "00b325bba6de17030091558f439d41d4c08b82e60fbcaee3e0331c4c690ef205",
|
||||
"pubkey": "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991",
|
||||
"created_at": 1713735562,
|
||||
"kind": 1,
|
||||
"tags": [
|
||||
[
|
||||
"q",
|
||||
"f7b82508931bbeeadb901931e3dea8c4f96f0f9e353ba6cf1ec3a93eb8ad7e05"
|
||||
]
|
||||
],
|
||||
"content": "Deus futurus est deus aquae deiectus!",
|
||||
"sig": "72d8365f3c6b6de89fdfd005798c242629145fdc97bfc25e57bb78a4444c2a297bf41a47d7d0e2ee819d77f73fa3fcfcc4b455928ede7fca715e261c567b0b3b"
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"id": "e0c2b45143717d62f85880aa7e26f2c3f4b10ada9ef547ae2479cfdd94ea2ce6",
|
||||
"pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4",
|
||||
"created_at": 1713217672,
|
||||
"kind": 1,
|
||||
"tags": [
|
||||
[
|
||||
"q",
|
||||
"826b28986cbe8196faaddb502bbcfb9d5521981d24e858ad1924a178e18f570f",
|
||||
"wss://relay.mostr.pub/"
|
||||
]
|
||||
],
|
||||
"content": "I like this lottery.\nnostr:nevent1qqsgy6egnpktaqvkl2kak5pthnae64fpnqwjf6zc45vjfgtcux84wrcpzemhxue69uhhyetvv9ujumt0wd68ytnsw43z7q3q08pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmqxpqqqqqqzrvwgz7",
|
||||
"sig": "5a40475e719ad4cf98dd685a268158995c25050057632564d38789ce39a66e9d34b2d4ec9bef650b60bcfe8106415385f28ba291e168a1d02e32e092b8b86615"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "b460c8eb16ce7b3ebe3079240c47bb7cace5ad2bd0246fab1ed36a12fb816b1e",
|
||||
"pubkey": "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
|
||||
"created_at": 1713045404,
|
||||
"kind": 1,
|
||||
"tags": [],
|
||||
"content": "BITCOIN IS THE ONLY GLOBAL FREE MARKET. 24/7/365 LIQUIDITY. NO CIRCUIT BREAKERS. FEATURE NOT A BUG. STAY HUMBLE AND STACK SATS.",
|
||||
"sig": "b56ddd466d00de591f371b1933e73deeeba2f56f4b0ee8179b3f6a6b4e45f6bb850a6802d6be17055d13c699b733f610b69fbfbbfbcd609c526b8c5097b28505"
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"id": "826b28986cbe8196faaddb502bbcfb9d5521981d24e858ad1924a178e18f570f",
|
||||
"pubkey": "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6",
|
||||
"created_at": 1711675519,
|
||||
"kind": 1,
|
||||
"tags": [
|
||||
[
|
||||
"zap",
|
||||
"79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6",
|
||||
"wss://relay.mostr.pub",
|
||||
"0.915"
|
||||
],
|
||||
[
|
||||
"zap",
|
||||
"6be38f8c63df7dbf84db7ec4a6e6fbbd8d19dca3b980efad18585c46f04b26f9",
|
||||
"wss://relay.mostr.pub",
|
||||
"0.085"
|
||||
],
|
||||
[
|
||||
"proxy",
|
||||
"https://gleasonator.com/objects/66216159-a709-431b-81e9-e4e1f86e20e4",
|
||||
"activitypub"
|
||||
]
|
||||
],
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "f7b82508931bbeeadb901931e3dea8c4f96f0f9e353ba6cf1ec3a93eb8ad7e05",
|
||||
"pubkey": "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991",
|
||||
"created_at": 1713735505,
|
||||
"kind": 1,
|
||||
"tags": [],
|
||||
"content": "The present is theirs, the future, for which I really worked, is mine.",
|
||||
"sig": "b27fff3ec821e529e74ceede28ecf368682677de1aa2cc2cc65083b8f4a789f53e6a5da899cb0f03e4e6a3555a0fe4421971c427c5c9dd50758127c4da3e9405"
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "04ee8a34c398ef20bdb56064979aff879f81b6b746232811845eca872e0ebe8d",
|
||||
"pubkey": "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991",
|
||||
"created_at": 1713735600,
|
||||
"kind": 6,
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"00b325bba6de17030091558f439d41d4c08b82e60fbcaee3e0331c4c690ef205"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991"
|
||||
]
|
||||
],
|
||||
"content": "",
|
||||
"sig": "061b741a8d399db4c1151ed003a76afcf04cac25b98f2df4d4b6467ea9e0dcb54de9d5a6f959ef86b82e8c6e547a87596aecb904cf5fa99e7f8b67fefd43c0f6"
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "863185c0b3a18316f438b7920a1f5217ac5a0f2a078dc003f9969512e8c8a5de",
|
||||
"pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
|
||||
"created_at": 1713045438,
|
||||
"kind": 6,
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"b460c8eb16ce7b3ebe3079240c47bb7cace5ad2bd0246fab1ed36a12fb816b1e"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9"
|
||||
]
|
||||
],
|
||||
"content": "{\"content\":\"BITCOIN IS THE ONLY GLOBAL FREE MARKET. 24\\/7\\/365 LIQUIDITY. NO CIRCUIT BREAKERS. FEATURE NOT A BUG. STAY HUMBLE AND STACK SATS.\",\"id\":\"b460c8eb16ce7b3ebe3079240c47bb7cace5ad2bd0246fab1ed36a12fb816b1e\",\"tags\":[],\"sig\":\"b56ddd466d00de591f371b1933e73deeeba2f56f4b0ee8179b3f6a6b4e45f6bb850a6802d6be17055d13c699b733f610b69fbfbbfbcd609c526b8c5097b28505\",\"created_at\":1713045404,\"kind\":1,\"pubkey\":\"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9\"}",
|
||||
"sig": "3bf75ad219c87194679ac08c2d90c1845cb0352633d73e1cf1b7e83e7be61a18a47dcb4746b4b9dfc1d640b7578a4a602d2f68eec605494a335c0732b3b61f37"
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"id": "1264cc4051db59af9a21f7fd001fdf5213424f558ea9ab16a1b014fca2250af5",
|
||||
"pubkey": "6be38f8c63df7dbf84db7ec4a6e6fbbd8d19dca3b980efad18585c46f04b26f9",
|
||||
"created_at": 1716306470,
|
||||
"kind": 1,
|
||||
"tags": [
|
||||
[
|
||||
"imeta",
|
||||
"url https://image.nostr.build/258d978b91e7424cfa43b31f3cfc077d7172ae10b3b45ac956feff9e72175126.png",
|
||||
"m image/png",
|
||||
"x b1ceee58405ef05a41190a0946ca6b6511dff426c68013cdd165514c1ef301f9",
|
||||
"ox 258d978b91e7424cfa43b31f3cfc077d7172ae10b3b45ac956feff9e72175126",
|
||||
"size 114350",
|
||||
"dim 1414x594",
|
||||
"blurhash LDRfkC.8_4_N_3NGR*t8%gIVWBxt"
|
||||
]
|
||||
],
|
||||
"content": "Today we were made aware of multiple Fediverse blog posts incorrectly attributing “vote Trump” spam on Bluesky to the Mostr.pub Bridge. \n\nThis spam is NOT coming from Mostr. From the screenshots used in these blogs, it's clear the spam is coming from an entirely different bridge called momostr.pink. This bridge is not affiliated with Mostr, and is not even a fork of Mostr. We appreciate that the authors of these posts responded quickly to us and have since corrected the blogs. \n\nMostr.pub uses stirfry policies for anti-spam filtering. This includes an anti-duplication policy that prevents spam like the recent “vote Trump” posts we’ve seen repeated over and over. \n\nIt is important to note WHY there are multiple bridges, though. \n\nWhen Mostr.pub launched, multiple major servers immediately blocked Mostr, including Mastodon.social. The moderators of Mastodon.social claimed that this was because Nostr was unregulated, and suggested to one user that if they want to bridge their account they should host their own bridge.\n\nThat is exactly what momostr.pink, the source of this spam, has done. \n\nThe obvious response to the censorship of the Mostr Bridge is to build more bridges. \n\nWhile we have opted for pro-social policies that aim to reduce spam and build better connections between decentralized platforms, other bridges built to get around censorship of the Mostr Bridge may not — as we’re already seeing.\n\nThere will inevitably be multiple bridges, and we’re working on creating solutions to the problems that arise from that. In the meantime, if the Fediverse could do itself a favor and chill with the censorship for two seconds, we might not have so many problems. \n\n\nhttps://image.nostr.build/258d978b91e7424cfa43b31f3cfc077d7172ae10b3b45ac956feff9e72175126.png",
|
||||
"sig": "b950e6e2ff1dc786ef344e7dad3edf8aa315a1053ede146725bde181acf7c2c1a5fcf1e0c796552b743607d6ae161a3ff4eb3af5033ffbfd314e68213d315215"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "5f2236e745f47116ee8d869658ebc732a8a35fdeb30a876f46c8c0fef9ae0309",
|
||||
"pubkey": "32b8276794dec3934fd7ddd2a97b5ee10ecef493441a55e83d838ccd98c58b7a",
|
||||
"created_at": 1713966213,
|
||||
"kind": 0,
|
||||
"tags": [],
|
||||
"content": "{\"name\":\"black\",\"about\":\"\",\"nip05\":\"\"}",
|
||||
"sig": "bab454ca68332663c4c17591eb8ea993f78be7787da9e0c5ed140b21059e9fe3c12a5998fcefa94b1129934f798a649bac222b6051a9ea1ce8a01f9620c825e0"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "2238893aee54bbe9188498a5aa124d62870d5757894bf52cdb362d1a0874ed18",
|
||||
"pubkey": "c9f5508526e213c3bc5468161f1b738a86063a2ece540730f9412e7becd5f0b2",
|
||||
"created_at": 1715517440,
|
||||
"kind": 0,
|
||||
"tags": [],
|
||||
"content": "{\"name\":\"dictator\",\"about\":\"\",\"nip05\":\"\"}",
|
||||
"sig": "a630ba158833eea10289fe077087ccad22c71ddfbe475153958cfc158ae94fb0a5f7b7626e62da6a3ef8bfbe67321e8f993517ed7f1578a45aff11bc2bec484c"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "da4e1e727c6456cee2b0341a1d7a2356e4263523374a2570a7dd318ab5d73f93",
|
||||
"pubkey": "e4d96e951739787e62ada74ee06a9a185af22791a899a6166ec23aab58c5d700",
|
||||
"created_at": 1715517565,
|
||||
"kind": 0,
|
||||
"tags": [],
|
||||
"content": "{\"name\":\"george orwell\",\"about\":\"\",\"nip05\":\"\"}",
|
||||
"sig": "cd375e2065cf452d3bfefa9951b04ab63018ab7c253803256cca1d89d03b38e454c71ed36fdd3c28a8ff2723cc19b21371ce0f9bbd39a92b1d1aa946137237bd"
|
||||
}
|
||||
|
|
@ -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,9 +0,0 @@
|
|||
{
|
||||
"id": "44f19148f5af60b0f43ed8c737fbda31b165e05bb55562003c45d9a9f02e8228",
|
||||
"pubkey": "e4d96e951739787e62ada74ee06a9a185af22791a899a6166ec23aab58c5d700",
|
||||
"created_at": 1715636249,
|
||||
"kind": 1,
|
||||
"tags": [],
|
||||
"content": "I like free speech",
|
||||
"sig": "6b50db9c1c02bd8b0e64512e71d53a0058569f44e8dcff65ad17fce544d6ae79f8f79fa0f9a615446fa8cbc2375709bf835751843b0cd10e62ae5d505fe106d4"
|
||||
}
|
||||
|
|
@ -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,14 +0,0 @@
|
|||
{
|
||||
"id": "4aa67b0b7829d555a42b1f57f8033e6f0cda87a63b5bf5a0d5e10fbb9e7ab107",
|
||||
"pubkey": "32b8276794dec3934fd7ddd2a97b5ee10ecef493441a55e83d838ccd98c58b7a",
|
||||
"created_at": 1713997559,
|
||||
"kind": 10000,
|
||||
"tags": [
|
||||
[
|
||||
"p",
|
||||
"2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991"
|
||||
]
|
||||
],
|
||||
"content": "",
|
||||
"sig": "9fa697cf5353cc82a7d1b3304c3d41e51e30547035a4c8fb25d3f5c9f378d43a30935b523c0a9671a2c61d36eff530f3f3fcfbb77440589649e5f2aa306df039"
|
||||
}
|
||||
|
|
@ -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,24 +0,0 @@
|
|||
{
|
||||
"id": "129b2749330a7f1189d3e74c6764a955851f1e4017a818dfd51ab8e24192b0f3",
|
||||
"pubkey": "c9f5508526e213c3bc5468161f1b738a86063a2ece540730f9412e7becd5f0b2",
|
||||
"created_at": 1715636348,
|
||||
"kind": 1984,
|
||||
"tags": [
|
||||
[
|
||||
"p",
|
||||
"e4d96e951739787e62ada74ee06a9a185af22791a899a6166ec23aab58c5d700",
|
||||
"other"
|
||||
],
|
||||
[
|
||||
"P",
|
||||
"e724b1c1b90eab9cc0f5976b380b80dda050de1820dc143e62d9e4f27a9a0b2c"
|
||||
],
|
||||
[
|
||||
"e",
|
||||
"44f19148f5af60b0f43ed8c737fbda31b165e05bb55562003c45d9a9f02e8228",
|
||||
"other"
|
||||
]
|
||||
],
|
||||
"content": "freedom of speech not freedom of reach",
|
||||
"sig": "cd05a14749cdf0c7664d056e2c02518740000387732218dacd0c71de5b96c0c3c99a0b927b0cd0778f25a211525fa03b4ed4f4f537bb1221c73467780d4ee1bc"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{"id":"63d54e69da65af273683e62a8afe35bb125901d6f9c38817f2db38850dcad38f","kind":1,"pubkey":"17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4","content":"Fran had beef empanadas for breakfast and came after mandatory algos","created_at":1716651366,"tags":[["e","d14564e5f13e7ea2d090fcb301cfc71e214b2e9348f98dbda18f930e1ee91453","","root"],["p","726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11"]],"sig":"4f592599e9adcf4b4160626ebe06f061ade4309ac683ef8f0fa04bf0514ea3f90171993dd8c755ca419f487ded496cce9d40a7b340cb1ed6ca213da2980f3051"}
|
||||
{"id":"d72f4995ebadb841e75afa08d28626c7fb275515e5e8cca29842c34018476522","kind":1,"pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","content":"😂","created_at":1716651307,"tags":[["e","b1ed8ea342d0023a8bedcb79de77633f8f550a21e363e707dd260e411977cff4","","root"],["e","518977b03781fbcf4383cc7fa76f8d3a5326ba687d37d0567db13e7be88e12cb","","reply"],["p","cfe3b4316d905335b6ce056ba0ec230b587a334381e82bf9a02a184f2d068f8d"],["p","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"],["p","4b74667f89358cd582ad82b16a2d24d5bfcb89ac4b1347ee80e5674a13ba78b2"]],"sig":"7c535641d7b405c74857dd6beddb639554e8a8e5cb0b61b1e172b3865a69a7208d38e22558693d8630516e54f23171df00055fc8923ab90a5ad22f2f4453a62a"}
|
||||
{"id":"879784117ef204bacc0fdc7fa306b81e9f76f6793f743639550239074ec28373","kind":1,"pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","content":"Loves me some fried chicken.","created_at":1716651290,"tags":[["e","b1ed8ea342d0023a8bedcb79de77633f8f550a21e363e707dd260e411977cff4","","root"],["e","b1ed8ea342d0023a8bedcb79de77633f8f550a21e363e707dd260e411977cff4","","root"],["e","262e209395ea847fa4ade5e4370394276ae95cf908fff8d29fb86f8a360c00d2","","reply"],["p","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"],["p","af387a6c488c5484088ba715dbb42b55ce72b475e1e2b86be791b24b8d51e215"]],"sig":"dca7b3443d6db6d21eac2a3570f68b8854dc8edd7eb948c4d903569304d9e4865388cd77362ba508dff974d9e684308a454f0f766cdb63ba120e033a3024a9ef"}
|
||||
{"id":"38c755514d9d7e1d8c8fbfd8ff6d475c97d1fba504e2c90431cd3e3e84c71056","kind":1,"pubkey":"17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4","content":"😂👏","created_at":1716651146,"tags":[["e","bd2afa1348221ee72205e77368ad0341f2cedb13150928bf9dadbd7a58d68c5e","","root"],["p","13a9e5d2a683cb4690ffb83f12848adc9c3423e2fcd786e86d35ee25faacbfbd"]],"sig":"0cbb28192f853f879d51f470f7c8a338ae1f759829fd1a88aa1e4b4f166b71b5bacf98f654cf3f9a8e40282b20a21e939b38aa9e445b1c2af535e36658e2149f"}
|
||||
{"id":"e088a883c95ca15c2ac245512fd9b1307136101ecb98eeea9621cd22d9cce854","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","content":"Use a combined key with MuSig to publish.\n\nOr just create a new key that all collaborators know, publish from it, then add it to your list of good wiki people and/or defer from your articles to their articles.","created_at":1716650955,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","wss://relay.damus.io/","PABLOF7z"],["e","cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb","wss://relay.damus.io/","root","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"]],"sig":"b316e9fdba6dbbf547ffea4449bf4c2c06ba880da1b05aa8947633748d1b5ddf06e085d9ca728ebff296c123082ad36178728a3ff075c52bead12cba71fbd93c"}
|
||||
{"id":"c77d684d95f0babf382ccc6d7cf2c01111ad28a6f3892594da1c565d8f42be00","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","content":"Maybe you should just defer at the same time you're pushing your edit? If you trust the person enough to give them \"write access\" to your wiki you probably already trust their article enough and/or trust that they will merge your edit.","created_at":1716650124,"tags":[["p","d91191e30e00444b942c0e82cad470b32af171764c2275bee0bd99377efd4075","wss://relay.damus.io/","gsovereignty"],["p","266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5","wss://nostr.wine/","hzrd149"],["p","dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","wss://theforest.nostr1.com/","Laeserin"],["p","1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411","wss://eden.nostr.land/","Karnage"],["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","wss://relay.damus.io/","PABLOF7z"],["e","cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb","wss://relay.damus.io/","root"],["e","1c9ca83dfdc96bd795e0420904bdfeb81d9434aa69c88ce773f0e6e849b8e6cd","","mention"],["e","ad680fadfc4c0a5e5ab62475c5bdb6a55af05fcd2b2ec7b5e340cf03fdc36a1d","wss://relay.damus.io/","reply","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"]],"sig":"7e9f9ac0e763ea5a3a45f1cbbead272396f508f9fa9abd2677123e98ab49f4d4bac9ee28e8f2d812b75ef2eae2277edca2c3e11eb406ec54e6918075bd3f9558"}
|
||||
{"id":"319b1708200eeea2887981ff4d32a8e8ac0b35ce3b3029936a13a8bd6626b4df","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","content":"For example: how would I list all your articles now? I can't, because some of them are now hosted in a different pubkey, so for every article of yours I have to make a new request.","created_at":1716649794,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","wss://relay.damus.io/","PABLOF7z"],["p","1b5c7f2f8a8f7e7ea7775879fd02d098db6ec588114521f1a1edfccd635c1fe9","wss://nostr.wine/","Zach"],["e","cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb","wss://140.f7z.io/","root"],["e","742ca34a0b51fa3ccc10ef89ae190b7c25c4ece0f0776554c0ca985705dbdd27","wss://relay.nostr.band/","mention"],["e","aba8036a7de241777a7a5a2c7b015450da795ba0275ed589edf98abda683dca2","wss://relay.damus.io/","mention","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","3b0b5ba7c23c1af922c89151b947858bf441163857d2c2bcc113570e73ff9303","wss://pyramid.fiatjaf.com/","reply","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"]],"sig":"449d9a4239dbde9978eb5517143693edcbaa7e437a73283c92b5291d474f55f3311fe6b9dd9438059b50926eec5c289f150bc17e5d102585810bd75e06a0eaa3"}
|
||||
{"id":"3b0b5ba7c23c1af922c89151b947858bf441163857d2c2bcc113570e73ff9303","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","content":"More overhead for people writing the articles, much less overhead for people reading and much less complexity in-protocol.","created_at":1716649678,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","wss://relay.damus.io/","PABLOF7z"],["p","1b5c7f2f8a8f7e7ea7775879fd02d098db6ec588114521f1a1edfccd635c1fe9","wss://nostr.wine/","Zach"],["e","cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb","wss://relay.damus.io/","root"],["e","742ca34a0b51fa3ccc10ef89ae190b7c25c4ece0f0776554c0ca985705dbdd27","wss://relay.nostr.band/","mention"],["e","aba8036a7de241777a7a5a2c7b015450da795ba0275ed589edf98abda683dca2","wss://relay.damus.io/","reply","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"]],"sig":"2609ced7c4dc101d00ebfd6dc6c4255f828af1f0d9ffc876543c815c8d24ce5131f61d8bde0fa184aa06ea1c549b3bd3cfc0d579bcbf6caee86794484d903b64"}
|
||||
{"id":"512992206a039a3079f5e892eb14368e3d29c4609722911e77919c93b7a68b15","kind":6,"pubkey":"c37b6a82a98de368c104bbc6da365571ec5a263b07057d0a3977b4c05afa7e63","content":"{\"pubkey\":\"9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e\",\"content\":\"nostr protocol allows anyone to change their name and profile picture at any time.\\n\\nNostur will show previous name and profile pic if available\\nhttps://media.utxo.nl/wp-content/uploads/nostr/7/f/7fcfe9ea3eb7dc850828cc6906dd91ca839de2184982954b4479952d85a1d447.webp\",\"id\":\"b8d7eff16cd1ead7c28032f9e36fd4ef2e29682f84e2a89f2fca8c2bec13385d\",\"created_at\":1716645465,\"sig\":\"2bc2a9eeffa50c5b48c388bdbf2c9622aa2cfdf7484625ef859a027f788983bbde481be7b647121a784ebd0417f109da84a2e43e124b028028c8e792787f7588\",\"kind\":1,\"tags\":[[\"imeta\",\"url https://media.utxo.nl/wp-content/uploads/nostr/7/f/7fcfe9ea3eb7dc850828cc6906dd91ca839de2184982954b4479952d85a1d447.webp\",\"dim 314x260\",\"sha256 7fcfe9ea3eb7dc850828cc6906dd91ca839de2184982954b4479952d85a1d447\"],[\"client\",\"Nostur\",\"31990:9be0be0fc079548233231614e4e1efc9f28b0db398011efeecf05fe570e5dd33:1685868693432\"]]}","created_at":1716649603,"tags":[["e","b8d7eff16cd1ead7c28032f9e36fd4ef2e29682f84e2a89f2fca8c2bec13385d","wss://relay.damus.io","mention"],["p","9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e"]],"sig":"7af3d238027e58504fe515b8cfb087751485235525a8ce46aa6a43728b97f7681d7b7180cc76cfa5f00a57aafba9ed7b20b301bce59943c3d2e6a9bb7f203e91"}
|
||||
{"id":"ba9b2c033eba0b14108473773d4529c7f2ad5e8f3d3267a90cd161f94b2b948f","kind":1,"pubkey":"c37b6a82a98de368c104bbc6da365571ec5a263b07057d0a3977b4c05afa7e63","content":"this doesnt look good for manchester city, might not be their day","created_at":1716649355,"tags":[["client","Nostur","31990:9be0be0fc079548233231614e4e1efc9f28b0db398011efeecf05fe570e5dd33:1685868693432"]],"sig":"e748efa0c267aee2c58a58f2031ef14abf74eaf104e110902363f26e04aba56e37813ac8d1ab1d02592ee0546a5c5644092f3e74a2e30c236c4de56449731b56"}
|
||||
{"id":"f331dc1c3985cf76b997dc9fadcb46241ba0ccb9d20159b0a3ca6f77f4316f58","kind":1,"pubkey":"c37b6a82a98de368c104bbc6da365571ec5a263b07057d0a3977b4c05afa7e63","content":"woman on top","created_at":1716649278,"tags":[["e","88fddb8d21d8f1198ce2e0e1b24c6897a06458576ac57f93b901c01eda862b21","","root"],["e","e43414005ac14ee53e83ed06ec6292dca5beef24077bb4b33546e348f1fb84b0","","reply"],["p","45f195cffcb8c9724efc248f0507a2fb65b579dfabe7cd35398598163cab7627"],["client","Nostur","31990:9be0be0fc079548233231614e4e1efc9f28b0db398011efeecf05fe570e5dd33:1685868693432"]],"sig":"fb492636ab58e604b66a53635f56f9b62a9162fae700a5296313f30ddfe3a3eac8c2019b5b09cf58db5b961a36da7cfcfc08a79f98af5b16d592fe5f76af8e58"}
|
||||
{"id":"21a8f27145674b0664a4b7ccd0d0ff8c36e4ceb7e35db44df1c3df6f0d593aea","kind":1,"pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","content":"Found some on the shore. https://i.nostr.build/AaRYE.jpg","created_at":1716649252,"tags":[["e","b1ed8ea342d0023a8bedcb79de77633f8f550a21e363e707dd260e411977cff4","","root"],["e","3232c85dcc455e4c323ffc3742a0eb8b4bbe38418b7c430203e694b4416fce50"],["e","6f3f1713f6c82561cd757ba3b2a2233d63f49478f30b90f2eddc71a1a25d49e9","","reply"],["p","cfe3b4316d905335b6ce056ba0ec230b587a334381e82bf9a02a184f2d068f8d"],["p","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"],["p","20d29810d6a5f92b045ade02ebbadc9036d741cc686b00415c42b4236fe4ad2f"],["p","7b394902eeadb8370931f1903d00569545e84113fb6a09634664763be232009c"],["r","https://i.nostr.build/AaRYE.jpg"],["imeta","url https://i.nostr.build/AaRYE.jpg","m image/jpeg","alt Verifiable file url","x c86385263058d896c409ec5644a4a993a52d7decac3b7e052901bd42c2f2cb89","size 603150","dim 2040x1536","blurhash #9Gu5ZJ50f=x,@57I=~BV@0LD*$+I:Io=|M|9axa02RkrrbvNwn$oJozWVR*W-WUofWDazxGxaR+,?kDNxw{niSgf+WAWUJBWARPX9%Mw]t6W?spemWWxvRP$$kXNHRPo2","ox 1951abbc1ee6bf76771f70f1fd305775cec89d30cd5a3947d31656d852efa9bd"]],"sig":"206c40361e578f5cec2c7726bfb99812d706df48057e67eb42f76b7bed8c8269a489be324df9b1f925c241757128394fb60eb494733d3c96cf0ac23569f3680c"}
|
||||
{"id":"b1ed8ea342d0023a8bedcb79de77633f8f550a21e363e707dd260e411977cff4","kind":1,"pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","content":"nostr:npub1el3mgvtdjpfntdkwq446pmprpdv85v6rs85zh7dq9gvy7tgx37xs2kl27r it's that time of year again. #thighstr https://i.nostr.build/BRGYD.jpg","created_at":1716648863,"tags":[["p","cfe3b4316d905335b6ce056ba0ec230b587a334381e82bf9a02a184f2d068f8d","","mention"],["t","thighstr"],["r","https://i.nostr.build/BRGYD.jpg"],["imeta","url https://i.nostr.build/BRGYD.jpg","m image/jpeg","alt Verifiable file url","x 376255322f1798e621985bbf028c70c346287404535edbab0aee830094b46673","size 82827","dim 768x1020","blurhash _AEfTfJI0:x[Q,${fN00q?=Y9bx^ohRkK-9~-C~AS0I;t7#=_3OF9_WUi^%1=DVXI.Io%3t7NGO@NH$%?Gi_ogNHJBo#%MNHS2aJsl-7$gRPWnI:ozs.adobR%ja%2M|Si","ox bf1c31404466531f3d58331b404f1116bc9e7a12d6cb41012275f3d36cb53f98"]],"sig":"cd1e87d85f0fd257eac1195ee5c565023d81aa4d17f2d6c0ac3538179db9112b215b951555dcc7bec3e317ad93b08a9f7635c32c9f101986231e898f64075108"}
|
||||
{"id":"c8d97acccc86babc0884216a3962f4c1beddc9ee16d5dac3b72a2acabad0fdfa","kind":6,"pubkey":"7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19","content":"{\"id\":\"cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb\",\"pubkey\":\"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52\",\"created_at\":1716648312,\"kind\":1,\"tags\":[[\"client\",\"highlighter\",\"31990:73c6bb92440a9344279f7a36aa3de1710c9198b1e9e8a394cd13e0dd5c994c63:1704502265408\"]],\"content\":\"people interested in nostr wiki:\\n\\nthoughts on allowing collaboration on an entry?\\n\",\"sig\":\"eeac1fa0347e3e240d564e04504d2b01900fd90e7eeab94cfd5034777230e3b593569e771ae0ac9077ae705225faa6cd001a8a43a540b69b69240605484e4ae3\"}","created_at":1716648793,"tags":[["e","cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb"],["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["alt","Repost event"]],"sig":"05d131b8bae0756cf54bb961a27a1dc23109c329d2a84e6d826c11ff5f363536e123d7770f26ea88c4f3580b6c1df6e0cd0dea3470b821fd4e23b52da04086ca"}
|
||||
{"id":"e8fae24ac25cd3d2d5f5a8efc08e5fe9f0f45ddd975e960805d020bb5f2eb119","kind":1,"pubkey":"17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4","content":"Voltage is focused on b2b - I am not sure folks use them for a personal node. \n\nThat said managing your own node is a pain in the 🌝.","created_at":1716648296,"tags":[["e","2253e1fed036e0a86eac892477552b8908684fae69696d89275119bb4e5c42c7","","root"],["e","b136b92f53f4b5ac6988755ba8256d227c3b7a7f687c87cfb256932df359cc60","","reply"],["p","49f586188679af2b31e8d62ff153baf767fd0c586939f4142ef65606d236ff75"]],"sig":"f9659da7b43eda0992418d20a219735162bfa81524b4dba4bce42b31aba7ba41d5be84376a98c37d0bca0321f090326896d44b1fbfb964fb3aa5cff667db8d37"}
|
||||
{"id":"52cfedd86e7693e3533900f6d6d444ee5b64e5d571679f1a1c60ae25c4d1fbf8","kind":1,"pubkey":"9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b","content":"He's here in substack and YouTube RSS form lol","created_at":1716648028,"tags":[["e","514341e9cfd55b9ce955897a0f5dd1bc1b165ea45868cc31824dc945fdaa7841","","root"],["e","37183ef6e232453c05b7b9ffe831a76704f07b4ac123484094707d184dadf569","","reply"],["p","9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b"],["p","95c7f867802a9c7cfb1d2b243be152234864e2cf9f60f467ae32bf1cf05f89fb"]],"sig":"97758c7b494fff1fbc3a2e88618eb8d6510dc93a7d3aef2bb5bdda977c34ef34a16ddeb59aefc95c7f6a94f49c0c41a2edede768409a361db5561f76f55aa9d2"}
|
||||
{"id":"2a6860b01ac1cb31c081a6cf93d2d83f7bb4a54d669414db61ead5602688a03e","kind":1,"pubkey":"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322","content":"PSA: DVM feeds on Coracle were broken this week. They may still be broken, but in a different way. I'll continue to refine the latest release this coming week (and make a tutorial for nostr:nprofile1qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcpzemhxue69uhhyetvv9ujumt0wd68ytnsw43z7qgnwaehxw309aex2mrp0yhxvdm69e5k7tcpz9mhxue69uhkummnw3ezuamfdejj7qgkwaehxw309ajkgetw9ehx7um5wghxcctwvshszxthwden5te0wfjkccte9ekk7mt0wd68ytnsd9hxktcprpmhxue69uhkummnv3exjan99eshqup0wfjkccteqyw8wumn8ghj7un9d3shjtngd9nksmrfva58getj9e3k7mf0qqsx8zd7vjg70d5na8ek3m8g3lx3ghc8cp5d9sdm4epy0wd4aape6vsavyafj )","created_at":1716647834,"tags":[["p","6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32","wss://relay.damus.io/","NunyaBidness"],["client","Coracle","31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690"]],"sig":"2b7a5c612f74532e73b967f5d3b4cc9950fb5055873dd1893aeadf58b379cd5e743a45ec321cd36cfd1b62f32bd27ebcea93c640963fbab37ede063d3f5b5c43"}
|
||||
{"id":"b0ca7c5df23236a14fd8949d0b032252fa0090904d36885a314ff16cade03591","kind":1,"pubkey":"9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b","content":"I'll probably just ignore it as best I can lol. Look at a nice tree and a pleasant stream:) \nMaybe take up beekeeping. That sort of thing.","created_at":1716647725,"tags":[["e","386666836de6df61f2104bc7b0552ac8e2c2e841a99d3a70885452df7f0865b0","","root"],["e","49857012475717e98c7713609dc7e4d95b2339d16eba644f95152ad2feb22e3a","","reply"],["p","9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b"],["p","95c7f867802a9c7cfb1d2b243be152234864e2cf9f60f467ae32bf1cf05f89fb"]],"sig":"998c657f78dc112d2b826fed284f78e8059dd65c0e12d55606d3438acd2f2e685f90fca965f0a30018679c82835246a15af0a8faeee262400d9e79bde156771d"}
|
||||
{"id":"ccb4828a0955d366f3479a7e9374416f089b2692c80e7bb2a52da27834dbaed9","kind":1,"pubkey":"9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b","content":"Think so yeah, there was a few hood ones. But yeah sounds like it","created_at":1716647663,"tags":[["e","5013462f3f82a32c0ed1f749c4e90a9073a263ecf505fe373d0549f9575d0115","","root"],["e","c7df680aa4e977ff5130e5f4f6765d95bd92dbcd0c13cbc00b16d7681bd8de70"],["e","563671b66257ccfab60abbc9ef3be63a92765efdc7efa3032de68fd8daa68eb2","","reply"],["p","9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b"],["p","dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"]],"sig":"379bc8f177b927b11f4d6527967d3d703a90f830f6264fb3c131dbc2f07aa269fb6629263f3b19207dc1952e1e9a064841092d0846ed69ca486af67743e88130"}
|
||||
{"id":"88fd33948257b3edbe5fe5e597848252968726d998b81126faff0fbd2eed3a07","kind":1,"pubkey":"17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4","content":"For whom?","created_at":1716647197,"tags":[["e","2253e1fed036e0a86eac892477552b8908684fae69696d89275119bb4e5c42c7","","root"],["p","49f586188679af2b31e8d62ff153baf767fd0c586939f4142ef65606d236ff75"]],"sig":"685ba60871b169f7b732b3ff9c8f0bcdc7c5294ec1b479729d5bc409b3d51029465f7b330bd8f4ecb7625b4e8fb6fbb8cf472f78a38069899bbd2b590d68fda7"}
|
||||
{"id":"cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb","kind":1,"pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","content":"people interested in nostr wiki:\n\nthoughts on allowing collaboration on an entry?\n","created_at":1716648312,"tags":[["client","highlighter","31990:73c6bb92440a9344279f7a36aa3de1710c9198b1e9e8a394cd13e0dd5c994c63:1704502265408"]],"sig":"eeac1fa0347e3e240d564e04504d2b01900fd90e7eeab94cfd5034777230e3b593569e771ae0ac9077ae705225faa6cd001a8a43a540b69b69240605484e4ae3"}
|
||||
{"id":"b8d7eff16cd1ead7c28032f9e36fd4ef2e29682f84e2a89f2fca8c2bec13385d","kind":1,"pubkey":"9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e","content":"nostr protocol allows anyone to change their name and profile picture at any time.\n\nNostur will show previous name and profile pic if available\nhttps://media.utxo.nl/wp-content/uploads/nostr/7/f/7fcfe9ea3eb7dc850828cc6906dd91ca839de2184982954b4479952d85a1d447.webp","created_at":1716645465,"tags":[["imeta","url https://media.utxo.nl/wp-content/uploads/nostr/7/f/7fcfe9ea3eb7dc850828cc6906dd91ca839de2184982954b4479952d85a1d447.webp","dim 314x260","sha256 7fcfe9ea3eb7dc850828cc6906dd91ca839de2184982954b4479952d85a1d447"],["client","Nostur","31990:9be0be0fc079548233231614e4e1efc9f28b0db398011efeecf05fe570e5dd33:1685868693432"]],"sig":"2bc2a9eeffa50c5b48c388bdbf2c9622aa2cfdf7484625ef859a027f788983bbde481be7b647121a784ebd0417f109da84a2e43e124b028028c8e792787f7588"}
|
||||
{"id":"1c9ca83dfdc96bd795e0420904bdfeb81d9434aa69c88ce773f0e6e849b8e6cd","kind":1,"pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","content":"say I create a document about \"Stalin\" and I add [\"p\", \"<nostr:npub1mygerccwqpzyh9pvp6pv44rskv40zutkfs38t0hqhkvnwlhagp6s3psn5p >\", \"editor\" ]\n\nthis would mean that the most recent version of my version of Stalin is whatever comes back from the REQ { \"#d\": [\"stalin\"], authors: [<my-pubkey>, <nostr:npub1mygerccwqpzyh9pvp6pv44rskv40zutkfs38t0hqhkvnwlhagp6s3psn5p >] }\n","created_at":1716648317,"tags":[["e","cc127dc2528ad97eaa88ff37d6c5d6bbe94b163ca873701db64f9e1bcfaa40cb","","root"],["p","d91191e30e00444b942c0e82cad470b32af171764c2275bee0bd99377efd4075"],["client","highlighter","31990:73c6bb92440a9344279f7a36aa3de1710c9198b1e9e8a394cd13e0dd5c994c63:1704502265408"]],"sig":"0756b98ad88cc041c9299922a5da43748c15266640924b7f760897e0386cb0fbdde6338e561ad2af2fee6c72051ee6e6254c6abad9aac6f0d2018d9b07f09cda"}
|
||||
{"id":"9161f8f465fc4b0462995149ceb23d024c885caf66bc9a421f0c0f842325b021","kind":0,"pubkey":"c37b6a82a98de368c104bbc6da365571ec5a263b07057d0a3977b4c05afa7e63","content":"{\"about\":\"#nobridge #noDM #noSTR #noRMIE\\n✉️ no.str@aol.com \\n🔐 https://s.id/no-str\\n🔗 https://gleasonator.dev/@Bac0t.my.id\",\"banner\":\"https://pbs.twimg.com/profile_banners/573176539/1697452071/1500x500\",\"lud16\":\"feelingisrael15@walletofsatoshi.com\",\"name\":\"🦖\",\"nip05\":\"_@Bac0t.my.id\",\"picture\":\"https://pbs.twimg.com/profile_images/1788948029283950592/m9PMKCZO_400x400.png\"}","created_at":1716603430,"tags":[],"sig":"8e955263a526522bbd3a60db8522a0d859fd5bb59a0e0bb03a41cdf2f0e5fb4a43048052871a7338a7f198806107bf005e19767a5719e8aa806ba9cd49183ce8"}
|
||||
{"id":"e50020597a6aebf6b704686206a682121a9612b6294242baa895437752d62010","kind":0,"pubkey":"9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b","content":"{\"name\":\"7fqx\",\"about\":\"𓊝🍀\",\"picture\":\"https://image.nostr.build/f89f134a56d932f91c5b79175c22bc749bace2cf94f7334f8427d24b210cb876.jpg\",\"banner\":\"https://image.nostr.build/c21af2ac126f85f8b1eacdf04ae47df8fcda4f5916e7d27c7f2f0a5f35df3fcc.jpg\",\"lud16\":\"glhf@getalby.com\",\"image\":\"https://image.nostr.build/86b5aee8f3c7bb5293819e7dd9049fcdc2f912d54150b9b24e041eb5b66aef19.jpg\",\"displayName\":\"7fqx\",\"display_name\":\"7fqx\",\"pubkey\":\"9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b\",\"npub\":\"npub1njst6azswskk5gp3ns8r6nr8nj0qg65acu8gaa2u9yz7yszjxs9s6k7fqx\",\"created_at\":1714857730}","created_at":1716498460,"tags":[],"sig":"3920d47a7c6314bafaa24ba876a300c9cf3875c3ac775bc5a4ead1e0643daa7a5f10c49e563d32846e313a82d809a05e7297367d873e746908bf9476df1125c3"}
|
||||
{"id":"ec6b70dcb1714d8a887e468305af0ef2d002466385f2e875cdd306f750521b0f","kind":0,"pubkey":"17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4","content":"{\"website\":\"https://github.com/alltheseas\",\"lud16\":\"elsat@mutiny.plus\",\"display_name\":\"\",\"about\":\"Damus and freedom tech Product Janitor 🧹\",\"picture\":\"https://nostr.build/i/p/nostr.build_7b9579da60a52c32a61cfe48e7b55b9fbd58d389ca29128d6c6851b00bb23d0a.jpg\",\"name\":\"elsat\",\"banner\":\"https://cdn.nostr.build/i/9b853a8461d114ec2c353b7caaab598286406d1ab4e47f9ebffda4db757bdaa5.jpg\",\"reactions\":false}","created_at":1716219324,"tags":[],"sig":"ea0f6c0563fa2bf549f141e6d769d432dae4767d739968814b24f841b4a68cbaf36c848d383453fa2a46d530a08d8c70eb6c9865d941c3ca1ef78057a740f40a"}
|
||||
{"id":"93e845c76ee32784733bc1dbba5e45270fde733c4207eb5832e5ba30a98f20ca","kind":0,"pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","content":"{\"created_at\":1707238393,\"picture\":\"https://i.nostr.build/Z7j8.jpg\",\"name\":\"Derek Ross\",\"about\":\"Building NostrPlebs.com and NostrNests.com. The purple pill helps the orange pill go down. Nostr is the social glue that binds all of your apps together.\",\"lud16\":\"pay@derekross.me\",\"display_name\":\"Derek Ross\",\"banner\":\"https://i.nostr.build/O2JE.jpg\",\"website\":\"https://nostrplebs.com\",\"nip05\":\"derekross@nostrplebs.com\"}","created_at":1715808097,"tags":[["alt","User profile for Derek Ross"],["i","twitter:derekmross","1634343988407726081"],["i","github:derekross","3edaf845975fa4500496a15039323fa3"]],"sig":"2862018e2ca23d9a376691a40c306494786148560d28f9799e4137dfa6a2f1bead2d35b21b68fe09837c4e6cf858f5642f38a5d97234e37b1a5e002ca1d37a8e"}
|
||||
{"id":"0810aed567c2cc7a0caccab17ea51fe30ea71862a71c13516777f9842b1f7532","kind":0,"pubkey":"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322","content":"{\"picture\":\"https://i.nostr.build/AZ0L.jpg\",\"about\":\"Christian Bitcoiner and developer of the coracle.social nostr client.\\nLearn more at https://coracle.tools\",\"name\":\"hodlbod\",\"nip05\":\"hodlbod@coracle.social\",\"nip05_updated_at\":1676671261,\"banner\":\"https://i.nostr.build/axYJ.jpg\",\"lud16\":\"hodlbod@getalby.com\",\"website\":\"coracle.social\"}","created_at":1714776395,"tags":[["client","Coracle","31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690"]],"sig":"04f515ac035b94b940a6c91f876de1532708d1e4118491fd16519d9fbed096d327aa644bd14008629f7572c405e27abab0865805255a0ea526259c802ad31dcb"}
|
||||
{"id":"5967d8b638bf20b305eafcbaf4aa2cf317d38696e9e86853cf4e079c1f463f24","kind":0,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","content":"{\"name\":\"fiatjaf\",\"about\":\"~\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\",\"lud06\":\"\",\"lud16\":\"fiatjaf@zbd.gg\"}","created_at":1714591065,"tags":[],"sig":"066666c5f1f4127816e7634165799cb7780634c96539bb75c56183f59d7a3cd54245df4fce696da320256f5c19746389d75206ff127f6f08a0927c444cf8d38f"}
|
||||
{"id":"22191979d7f21129c97a6740909e53f3df554f39667f7853d087623e5cf1a11d","kind":0,"pubkey":"7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19","content":"{\"name\":\"greenart7c3\",\"nip05\":\"greenart7c3@greenart7c3.com\",\"about\":\"PGP\\n\\n44F0AAEB77F373747E3D5444885822EED3A26A6D\\n\\nDeveloping Amber\\n\\nhttps://github.com/greenart7c3/Amber\",\"lud16\":\"greenart7c3@greenart7c3.com\",\"display_name\":\"greenart7c3\",\"picture\":\"https://pfp.nostr.build/a40c078816657986911bd2ec73cf9db6bd68af60bea6eaddbf14bbce7424feb8.png\",\"website\":\"https://paynym.is/+florallake7D2\"}","created_at":1714136365,"tags":[["alt","User profile for greenart7c3"]],"sig":"ea12a8605e84040a021a6929f5d898aa4537080f10a4e281a5d37bd04b923a02273da1dac1abb14ef39f5588246fab10e631fa9860b49cf459255e00f9324625"}
|
||||
{"id":"5db62e87cbb8dfdea54e61713a1ea6647bf164aea5a58afd620b220d22e20b22","kind":0,"pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","content":"{\"banner\":\"https://pablof7z.com/images/banner.jpg\",\"about\":\"Magical Other Stuff Maximalist\",\"name\":\"PABLOF7z\",\"website\":\"https://pablof7z.com\",\"display_name\":\"PABLOF7z\",\"lud16\":\"pablof7z@primal.net\",\"picture\":\"https://pablof7z.com/images/me.jpg\",\"nip05\":\"_@f7z.io\",\"created_at\":1712782129,\"categories\":[]}","created_at":1712947216,"tags":[["c","Business & Entrepreneurship"],["c","Development & Engineering"],["client","highlighter","31990:73c6bb92440a9344279f7a36aa3de1710c9198b1e9e8a394cd13e0dd5c994c63:1704502265408"]],"sig":"99b38ad955ecb1b05fb6488d9890851f2f86603558d6631e0cf32cb0bfaba1bdcc1c33a2f97c5cd46c811d08a04e97b273bd5677eb89f93f22332ad58d01ac34"}
|
||||
{"id":"f89a514d9d547528e229def6ff869bd9f50963d79a728409c2d43f0207e7ca94","kind":0,"pubkey":"9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e","content":"{\"name\":\"Fabian\",\"nip05\":\"fabian@nostur.com\",\"picture\":\"https:\\/\\/profilepics.nostur.com\\/profilepic_v1\\/9512c8b31c97ec0ebc8e66b44de8bafef498d335fd542a0aa1400bbc19fec9d5\\/profilepic.jpg?1710414069\",\"lud16\":\"weathereddarkness25@getalby.com\",\"about\":\"https:\\/\\/nostur.com\",\"banner\":\"https:\\/\\/profilepics.nostur.com\\/banner_v1\\/e358d89477e2303af113a2c0023f6e77bd5b73d502cf1dbdb432ec59a25bfc0f\\/banner.jpg?1682440972\"}","created_at":1712841341,"tags":[],"sig":"39a9b80361bcf8c6a13504a694037016ac5570b1f49e339521bd7068c46edf64352179ba797190f1ceeb94c0b6809977b65e2cd7e5bfe1367aa967eb614e93ef"}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"status": "success",
|
||||
"message": "Upload successful.",
|
||||
"data": [
|
||||
{
|
||||
"input_name": "APIv2",
|
||||
"name": "e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
|
||||
"sha256": "0a71f1c9dd982079bc52e96403368209cbf9507c5f6956134686f56e684b6377",
|
||||
"original_sha256": "e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3",
|
||||
"type": "picture",
|
||||
"mime": "image/gif",
|
||||
"size": 1796276,
|
||||
"blurhash": "LGH-S^Vwm]x]04kX-qR-R]SL5FxZ",
|
||||
"dimensions": {
|
||||
"width": 360,
|
||||
"height": 216
|
||||
},
|
||||
"dimensionsString": "360x216",
|
||||
"url": "https://image.nostr.build/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
|
||||
"thumbnail": "https://image.nostr.build/thumb/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
|
||||
"responsive": {
|
||||
"240p": "https://image.nostr.build/resp/240p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
|
||||
"360p": "https://image.nostr.build/resp/360p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
|
||||
"480p": "https://image.nostr.build/resp/480p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
|
||||
"720p": "https://image.nostr.build/resp/720p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
|
||||
"1080p": "https://image.nostr.build/resp/1080p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif"
|
||||
},
|
||||
"metadata": {
|
||||
"date:create": "2024-05-18T02:11:39+00:00",
|
||||
"date:modify": "2024-05-18T02:11:39+00:00"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"status": "success",
|
||||
"message": "Upload successful.",
|
||||
"data": [
|
||||
{
|
||||
"id": 0,
|
||||
"input_name": "APIv2",
|
||||
"name": "f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
|
||||
"url": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
|
||||
"thumbnail": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
|
||||
"responsive": {
|
||||
"240p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
|
||||
"360p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
|
||||
"480p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
|
||||
"720p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
|
||||
"1080p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3"
|
||||
},
|
||||
"blurhash": "",
|
||||
"sha256": "f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725",
|
||||
"original_sha256": "f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725",
|
||||
"type": "video",
|
||||
"mime": "audio/mpeg",
|
||||
"size": 1519616,
|
||||
"metadata": [],
|
||||
"dimensions": [],
|
||||
"dimensionsString": "0x0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
# Nginx configuration for Ditto.
|
||||
# Nginx configuration for Ditto with IPFS.
|
||||
#
|
||||
# Edit this file to change occurences of "example.com" to your own domain.
|
||||
|
||||
upstream ditto {
|
||||
server 127.0.0.1:4036;
|
||||
server 127.0.0.1:8000;
|
||||
}
|
||||
|
||||
upstream ipfs_gateway {
|
||||
server 127.0.0.1:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
|
|
@ -14,8 +18,21 @@ server {
|
|||
}
|
||||
|
||||
server {
|
||||
# Uncomment these lines once you acquire a certificate:
|
||||
# listen 443 ssl http2;
|
||||
# listen [::]:443 ssl http2;
|
||||
server_name example.com;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Uncomment these lines once you acquire a certificate:
|
||||
# ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
|
||||
|
||||
keepalive_timeout 70;
|
||||
sendfile on;
|
||||
client_max_body_size 100m;
|
||||
|
|
@ -24,31 +41,42 @@ 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 /packs {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
root /opt/ditto/public;
|
||||
}
|
||||
|
||||
location ~ ^/(instance|sw\.js$|sw\.js\.map$) {
|
||||
root /opt/ditto/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /metrics {
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
proxy_pass http://ditto;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://ditto;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
# Uncomment these lines once you acquire a certificate:
|
||||
# listen 443 ssl http2;
|
||||
# listen [::]:443 ssl http2;
|
||||
server_name media.example.com;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Uncomment these lines once you acquire a certificate:
|
||||
# ssl_certificate /etc/letsencrypt/live/media.example.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/media.example.com/privkey.pem;
|
||||
|
||||
keepalive_timeout 70;
|
||||
sendfile on;
|
||||
client_max_body_size 1m;
|
||||
ignore_invalid_headers off;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
location / {
|
||||
proxy_pass http://ipfs_gateway;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
installation/ipfs.service
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[Unit]
|
||||
Description=IPFS Daemon
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ditto
|
||||
ExecStart=/usr/local/bin/ipfs daemon
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -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>;
|
||||
}
|
||||