├── .dockerignore ├── .gitignore ├── .gitlab-ci.yml ├── .goosehints ├── .hooks └── pre-commit ├── .lintstagedrc ├── .tool-versions ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── data └── .gitignore ├── deno.json ├── deno.lock ├── ditto-planet.png ├── fixtures ├── config-db.json ├── events │ ├── event-0-makes-repost-with-quote-repost.json │ ├── event-0-the-one-who-post-and-users-repost.json │ ├── event-0-the-one-who-quote-repost.json │ ├── event-0-the-one-who-repost.json │ ├── event-0.json │ ├── event-1-quote-repost-will-be-reposted.json │ ├── event-1-quote-repost.json │ ├── event-1-reposted.json │ ├── event-1-that-will-be-quote-reposted.json │ ├── event-1-will-be-reposted-with-quote-repost.json │ ├── event-1.json │ ├── event-6-of-quote-repost.json │ ├── event-6.json │ ├── event-imeta.json │ ├── kind-0-black.json │ ├── kind-0-dictator.json │ ├── kind-0-george-orwell.json │ ├── kind-0-jack.json │ ├── kind-0-patrick.json │ ├── kind-1-author-george-orwell.json │ ├── kind-1-being-zapped.json │ ├── kind-10000-black-blocks-user-me.json │ ├── kind-10002-alex.json │ ├── kind-1984-dictator-reports-george-orwell.json │ └── kind-9735-jack-zap-patrick.json ├── hydrated.jsonl ├── nostrbuild-gif.json ├── nostrbuild-mp3.json └── stats.json ├── grafana └── Ditto-Dashboard.json ├── installation ├── Caddyfile ├── ditto.conf └── ditto.service ├── packages ├── captcha │ ├── assets.test.ts │ ├── assets.ts │ ├── assets │ │ ├── bg │ │ │ ├── a-large-body-of-water-surrounded-by-mountains.jpg │ │ │ ├── a-trail-of-footprints-in-the-sand.jpg │ │ │ ├── ashim-dsilva.jpg │ │ │ ├── canazei-granite-ridges.jpg │ │ │ ├── copyright.txt │ │ │ ├── martin-adams.jpg │ │ │ ├── morskie-oko.jpg │ │ │ ├── mr-lee.jpg │ │ │ ├── nattu-adnan.jpg │ │ │ ├── photo-by-spacex.jpg │ │ │ ├── photo-of-valley.jpg │ │ │ ├── snow-capped-mountain.jpg │ │ │ ├── sunset-by-the-pier.jpg │ │ │ ├── tj-holowaychuk.jpg │ │ │ └── viktor-forgacs.jpg │ │ └── puzzle │ │ │ ├── puzzle-hole.png │ │ │ ├── puzzle-hole.svg │ │ │ ├── puzzle-mask.png │ │ │ └── puzzle-mask.svg │ ├── canvas.test.ts │ ├── canvas.ts │ ├── captcha.test.ts │ ├── captcha.ts │ ├── deno.json │ ├── geometry.test.ts │ ├── geometry.ts │ └── mod.ts ├── cashu │ ├── cashu.test.ts │ ├── cashu.ts │ ├── deno.json │ ├── mod.ts │ ├── schemas.test.ts │ ├── schemas.ts │ ├── views.test.ts │ └── views.ts ├── conf │ ├── DittoConf.test.ts │ ├── DittoConf.ts │ ├── deno.json │ ├── mod.ts │ └── utils │ │ ├── crypto.test.ts │ │ ├── crypto.ts │ │ ├── schema.test.ts │ │ ├── schema.ts │ │ ├── url.test.ts │ │ └── url.ts ├── db │ ├── DittoDB.ts │ ├── DittoPgMigrator.ts │ ├── DittoTables.ts │ ├── KyselyLogger.ts │ ├── adapters │ │ ├── DittoPglite.test.ts │ │ ├── DittoPglite.ts │ │ ├── DittoPolyPg.test.ts │ │ ├── DittoPolyPg.ts │ │ ├── DittoPostgres.test.ts │ │ ├── DittoPostgres.ts │ │ ├── DummyDB.test.ts │ │ ├── DummyDB.ts │ │ ├── TestDB.test.ts │ │ └── TestDB.ts │ ├── deno.json │ ├── migrations │ │ ├── 000_create_events.ts │ │ ├── 001_add_relays.ts │ │ ├── 002_events_fts.ts │ │ ├── 003_events_admin.ts │ │ ├── 004_add_user_indexes.ts │ │ ├── 005_rework_tags.ts │ │ ├── 006_pragma.ts │ │ ├── 007_unattached_media.ts │ │ ├── 008_wal.ts │ │ ├── 009_add_stats.ts │ │ ├── 010_drop_users.ts │ │ ├── 011_kind_author_index.ts │ │ ├── 012_tags_composite_index.ts │ │ ├── 013_soft_deletion.ts │ │ ├── 014_stats_indexes.ts.ts │ │ ├── 015_add_pubkey_domains.ts │ │ ├── 016_pubkey_domains_updated_at.ts │ │ ├── 017_rm_relays.ts │ │ ├── 018_events_created_at_kind_index.ts │ │ ├── 019_ndatabase_schema.ts │ │ ├── 020_drop_deleted_at.ts │ │ ├── 020_pgfts.ts │ │ ├── 021_pgfts_index.ts │ │ ├── 022_event_stats_reactions.ts │ │ ├── 023_add_nip46_tokens.ts │ │ ├── 024_event_stats_quotes_count.ts │ │ ├── 025_event_stats_add_zap_count.ts │ │ ├── 026_tags_name_index.ts │ │ ├── 027_add_zap_events.ts │ │ ├── 028_stable_sort.ts │ │ ├── 029_tag_queries.ts │ │ ├── 030_pg_events_jsonb.ts │ │ ├── 031_rm_unattached_media.ts │ │ ├── 032_add_author_search.ts │ │ ├── 033_add_language.ts │ │ ├── 034_move_author_search_to_author_stats.ts │ │ ├── 035_author_stats_followers_index.ts │ │ ├── 036_stats64.ts │ │ ├── 037_auth_tokens.ts │ │ ├── 038_push_subscriptions.ts │ │ ├── 039_pg_notify.ts │ │ ├── 040_add_bunker_pubkey.ts │ │ ├── 041_pg_notify_id_only.ts │ │ ├── 042_add_search_ext.ts │ │ ├── 043_rm_language.ts │ │ ├── 044_search_ext_drop_default.ts │ │ ├── 045_streaks.ts │ │ ├── 046_author_stats_nip05.ts │ │ ├── 047_add_domain_favicons.ts │ │ ├── 048_rm_pubkey_domains.ts │ │ ├── 049_author_stats_sorted.ts │ │ ├── 050_notify_only_insert.ts │ │ ├── 051_notify_replaceable.ts │ │ ├── 052_rename_pkey.ts │ │ ├── 053_link_preview.ts │ │ └── 054_event_stats_add_zap_cashu_count.ts │ ├── mod.ts │ └── utils │ │ ├── worker.test.ts │ │ └── worker.ts ├── ditto │ ├── DittoPush.ts │ ├── DittoUploads.ts │ ├── RelayError.test.ts │ ├── RelayError.ts │ ├── app.ts │ ├── caches │ │ └── translationCache.ts │ ├── config.ts │ ├── controllers │ │ ├── api │ │ │ ├── accounts.ts │ │ │ ├── admin.ts │ │ │ ├── apps.ts │ │ │ ├── blocks.ts │ │ │ ├── bookmarks.ts │ │ │ ├── captcha.ts │ │ │ ├── cashu.test.ts │ │ │ ├── cashu.ts │ │ │ ├── ditto.ts │ │ │ ├── fallback.ts │ │ │ ├── instance.ts │ │ │ ├── markers.ts │ │ │ ├── media.ts │ │ │ ├── mutes.ts │ │ │ ├── notifications.ts │ │ │ ├── oauth.ts │ │ │ ├── pleroma.ts │ │ │ ├── preferences.ts │ │ │ ├── push.ts │ │ │ ├── reports.ts │ │ │ ├── search.ts │ │ │ ├── statuses.ts │ │ │ ├── streaming.ts │ │ │ ├── suggestions.ts │ │ │ ├── timelines.ts │ │ │ ├── translate.ts │ │ │ └── trends.ts │ │ ├── error.ts │ │ ├── frontend.ts │ │ ├── manifest.ts │ │ ├── metrics.ts │ │ ├── nostr │ │ │ ├── relay-info.ts │ │ │ └── relay.ts │ │ └── well-known │ │ │ ├── nodeinfo.ts │ │ │ └── nostr.ts │ ├── cron.ts │ ├── deno.json │ ├── firehose.ts │ ├── interfaces │ │ ├── DittoEvent.ts │ │ └── DittoPagination.ts │ ├── middleware │ │ ├── cacheControlMiddleware.test.ts │ │ ├── cacheControlMiddleware.ts │ │ ├── cspMiddleware.ts │ │ ├── logiMiddleware.ts │ │ ├── metricsMiddleware.ts │ │ ├── notActivitypubMiddleware.ts │ │ ├── rateLimitMiddleware.ts │ │ ├── swapNutzapsMiddleware.ts │ │ ├── translatorMiddleware.ts │ │ └── uploaderMiddleware.ts │ ├── nostr-wasm.ts │ ├── queries.ts │ ├── routes │ │ ├── customEmojisRoute.test.ts │ │ ├── customEmojisRoute.ts │ │ ├── dittoNamesRoute.test.ts │ │ ├── dittoNamesRoute.ts │ │ ├── pleromaAdminPermissionGroupsRoute.test.ts │ │ ├── pleromaAdminPermissionGroupsRoute.ts │ │ ├── pleromaStatusesRoute.test.ts │ │ └── pleromaStatusesRoute.ts │ ├── schema.test.ts │ ├── schema.ts │ ├── schemas │ │ ├── mastodon.ts │ │ ├── nostr.ts │ │ └── pleroma-api.ts │ ├── sentry.ts │ ├── server.ts │ ├── signers │ │ ├── ConnectSigner.ts │ │ └── ReadOnlySigner.ts │ ├── static │ │ ├── favicon.ico │ │ └── images │ │ │ ├── avi.png │ │ │ ├── banner.png │ │ │ └── thumbnail.png │ ├── storages │ │ ├── DittoAPIStore.ts │ │ ├── DittoPgStore.test.ts │ │ ├── DittoPgStore.ts │ │ ├── DittoPool.test.ts │ │ ├── DittoPool.ts │ │ ├── DittoRelayStore.test.ts │ │ ├── DittoRelayStore.ts │ │ ├── hydrate.bench.ts │ │ ├── hydrate.test.ts │ │ └── hydrate.ts │ ├── test.ts │ ├── trends.test.ts │ ├── trends.ts │ ├── types │ │ ├── MastodonPush.ts │ │ └── webmanifest.ts │ ├── utils.ts │ ├── utils │ │ ├── PleromaConfigDB.test.ts │ │ ├── PleromaConfigDB.ts │ │ ├── SimpleLRU.test.ts │ │ ├── SimpleLRU.ts │ │ ├── abort.ts │ │ ├── aes.bench.ts │ │ ├── aes.test.ts │ │ ├── aes.ts │ │ ├── api.ts │ │ ├── auth.bench.ts │ │ ├── auth.test.ts │ │ ├── auth.ts │ │ ├── bolt11.test.ts │ │ ├── bolt11.ts │ │ ├── custom-emoji.test.ts │ │ ├── custom-emoji.ts │ │ ├── favicon.ts │ │ ├── formdata.test.ts │ │ ├── formdata.ts │ │ ├── html.ts │ │ ├── instance.ts │ │ ├── lnurl.ts │ │ ├── log.ts │ │ ├── logi.ts │ │ ├── lookup.test.ts │ │ ├── lookup.ts │ │ ├── media.test.ts │ │ ├── media.ts │ │ ├── nip05.ts │ │ ├── nip89.ts │ │ ├── note.test.ts │ │ ├── note.ts │ │ ├── og-metadata.ts │ │ ├── pleroma.ts │ │ ├── purify.ts │ │ ├── search.test.ts │ │ ├── search.ts │ │ ├── stats.test.ts │ │ ├── stats.ts │ │ ├── tags.test.ts │ │ ├── tags.ts │ │ ├── text.ts │ │ ├── time.test.ts │ │ ├── time.ts │ │ ├── unfurl.ts │ │ ├── upload.ts │ │ └── zap-split.ts │ ├── views.ts │ ├── views │ │ ├── ditto.ts │ │ ├── mastodon │ │ │ ├── accounts.ts │ │ │ ├── admin-accounts.ts │ │ │ ├── attachments.ts │ │ │ ├── emojis.ts │ │ │ ├── notifications.ts │ │ │ ├── push.ts │ │ │ ├── relationships.ts │ │ │ ├── reports.ts │ │ │ └── statuses.ts │ │ └── meta.ts │ └── workers │ │ ├── policy.test.ts │ │ ├── policy.ts │ │ ├── policy.worker.ts │ │ ├── verify.ts │ │ └── verify.worker.ts ├── lang │ ├── deno.json │ ├── language.test.ts │ └── language.ts ├── mastoapi │ ├── auth │ │ ├── aes.bench.ts │ │ ├── aes.test.ts │ │ ├── aes.ts │ │ ├── token.bench.ts │ │ ├── token.test.ts │ │ └── token.ts │ ├── deno.json │ ├── middleware │ │ ├── User.ts │ │ ├── mod.ts │ │ ├── paginationMiddleware.ts │ │ ├── tokenMiddleware.ts │ │ ├── userMiddleware.test.ts │ │ └── userMiddleware.ts │ ├── pagination │ │ ├── link-header.test.ts │ │ ├── link-header.ts │ │ ├── mod.ts │ │ ├── paginate.test.ts │ │ ├── paginate.ts │ │ ├── schema.test.ts │ │ └── schema.ts │ ├── router │ │ ├── DittoApp.test.ts │ │ ├── DittoApp.ts │ │ ├── DittoEnv.ts │ │ ├── DittoMiddleware.ts │ │ ├── DittoRoute.test.ts │ │ ├── DittoRoute.ts │ │ └── mod.ts │ ├── signers │ │ ├── ConnectSigner.ts │ │ └── ReadOnlySigner.ts │ ├── storages │ │ ├── UserStore.test.ts │ │ └── UserStore.ts │ ├── test.ts │ ├── test │ │ └── TestApp.ts │ └── types │ │ ├── MastodonAccount.ts │ │ ├── MastodonAttachment.ts │ │ ├── MastodonMention.ts │ │ ├── MastodonPreviewCard.ts │ │ ├── MastodonStatus.ts │ │ ├── MastodonTranslation.ts │ │ └── mod.ts ├── metrics │ ├── deno.json │ └── metrics.ts ├── nip98 │ ├── deno.json │ ├── nip98.ts │ └── schema.ts ├── policies │ ├── MuteListPolicy.test.ts │ ├── MuteListPolicy.ts │ ├── deno.json │ └── mod.ts ├── ratelimiter │ ├── MemoryRateLimiter.test.ts │ ├── MemoryRateLimiter.ts │ ├── MultiRateLimiter.test.ts │ ├── MultiRateLimiter.ts │ ├── RateLimitError.ts │ ├── deno.json │ ├── mod.ts │ └── types.ts ├── transcode │ ├── .gitignore │ ├── analyze.test.ts │ ├── analyze.ts │ ├── buckbunny.mp4 │ ├── deno.json │ ├── ffmpeg.test.ts │ ├── ffmpeg.ts │ ├── ffprobe.test.ts │ ├── ffprobe.ts │ ├── frame.test.ts │ ├── frame.ts │ ├── mod.ts │ ├── transcode.test.ts │ └── transcode.ts ├── translators │ ├── DeepLTranslator.test.ts │ ├── DeepLTranslator.ts │ ├── DittoTranslator.ts │ ├── LibreTranslateTranslator.test.ts │ ├── LibreTranslateTranslator.ts │ ├── deno.json │ ├── mod.ts │ ├── schema.test.ts │ └── schema.ts └── uploaders │ ├── DenoUploader.ts │ ├── IPFSUploader.ts │ ├── S3Uploader.ts │ ├── deno.json │ └── mod.ts ├── public └── .gitignore └── scripts ├── admin-event.ts ├── admin-role.ts ├── db-export.test.ts ├── db-export.ts ├── db-import.ts ├── db-migrate.ts ├── db-policy.ts ├── db-populate-extensions.ts ├── db-populate-nip05.ts ├── db-populate-search.ts ├── db-streak-recompute.ts ├── deparameterize.ts ├── nostr-pull.ts ├── nsec.ts ├── setup-kind0.ts ├── setup.ts ├── stats-recompute.ts ├── trends.ts └── vapid.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.cpuprofile 3 | *.swp 4 | deno-test.xml 5 | 6 | /data -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.* 3 | *.cpuprofile 4 | *.swp 5 | deno-test.xml -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: denoland/deno:2.2.2 2 | 3 | default: 4 | interruptible: true 5 | 6 | stages: 7 | - test 8 | 9 | test: 10 | stage: test 11 | timeout: 2 minutes 12 | script: 13 | - deno fmt --check 14 | - deno task lint 15 | - deno task check 16 | - deno task test --ignore=packages/transcode --coverage=cov_profile 17 | - deno coverage cov_profile 18 | coverage: /All files[^\|]*\|[^\|]*\s+([\d\.]+)/ 19 | services: 20 | - postgres:16 21 | variables: 22 | DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz 23 | DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres 24 | POSTGRES_HOST_AUTH_METHOD: trust 25 | RUST_BACKTRACE: 1 26 | artifacts: 27 | when: always 28 | paths: 29 | - deno-test.xml 30 | reports: 31 | junit: deno-test.xml 32 | -------------------------------------------------------------------------------- /.hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/hook.sh" 3 | 4 | deno run -A npm:lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx,md}": "deno fmt" 3 | } -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | deno 2.2.2 -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "request": "launch", 9 | "name": "Launch Program", 10 | "type": "node", 11 | "program": "${workspaceFolder}/packages/ditto/server.ts", 12 | "cwd": "${workspaceFolder}", 13 | "runtimeExecutable": "deno", 14 | "runtimeArgs": [ 15 | "run", 16 | "--inspect-wait", 17 | "--allow-all", 18 | "--unstable" 19 | ], 20 | "attachSimplePort": 9229 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "editor.defaultFormatter": "denoland.vscode-deno", 5 | "path-intellisense.extensionOnImport": true, 6 | "files.associations": { 7 | ".goosehints": "markdown" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [1.1.0] - 2024-07-15 11 | 12 | ### Added 13 | 14 | - Prometheus support (`/metrics` endpoint). 15 | - Sort zaps by amount; add pagination. 16 | 17 | ### Fixed 18 | 19 | - Added IP rate-limiting of HTTP requests and WebSocket messages. 20 | - Added database query timeouts. 21 | - Fixed nos2x compatibility. 22 | 23 | ## [1.0.0] - 2024-06-14 24 | 25 | - Initial release 26 | 27 | [unreleased]: https://gitlab.com/soapbox-pub/ditto/-/compare/v1.1.0...HEAD 28 | [1.1.0]: https://gitlab.com/soapbox-pub/ditto/-/compare/v1.0.0...v1.1.0 29 | [1.0.0]: https://gitlab.com/soapbox-pub/ditto/-/tags/v1.0.0 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM denoland/deno:2.2.2 2 | ENV PORT 5000 3 | 4 | WORKDIR /app 5 | RUN mkdir -p data && chown -R deno data 6 | COPY . . 7 | RUN deno cache --allow-import packages/ditto/server.ts 8 | RUN apt-get update && apt-get install -y unzip curl 9 | RUN deno task soapbox 10 | CMD deno task start 11 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /ditto-planet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/ditto-planet.png -------------------------------------------------------------------------------- /fixtures/events/event-0-makes-repost-with-quote-repost.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "4acbf01269a2b09aaa4559b6d950ceffe37985dc3eb56c3d1bb3200ca93fae3d", 3 | "pubkey": "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991", 4 | "created_at": 1713452168, 5 | "kind": 0, 6 | "tags": [], 7 | "content": "{\"name\":\"me\",\"about\":\"\",\"nip05\":\"\"}", 8 | "sig": "373ca965fc3772804cf448db8da3add6f59653cb1ba8ba89b8d8fc88e4ed326b446e2641ed675dcaab886eb2678cca5293c6312e03ed9e73ccebca14ef47eaaa" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/event-0-the-one-who-post-and-users-repost.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "155ee53d9319d427ac0d5a8f0089654d8db66d0f1b31d8bd0d389b7a5417992f", 3 | "pubkey": "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", 4 | "created_at": 1712507673, 5 | "kind": 0, 6 | "tags": [], 7 | "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\"}", 8 | "sig": "355663a38ebd1cb31b5d2864357c4fdbeecfaedd602133638cf255ea30e6e532e3c927495c5eb5dfc0e77046d2e1d4e8be271f279c2c7eb9c9273028bfc033f4" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/event-0-the-one-who-quote-repost.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "6bc9ca44feb5a261841873def54a81cc328737391dc10f7eada31173a399517d", 3 | "pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4", 4 | "created_at": 1712851917, 5 | "kind": 0, 6 | "tags": [], 7 | "content": "{\"name\":\"patrickReiis\",\"picture\":\"https://void.cat/d/EMs8Qdn5wsAMrZ5T9T44sz.webp\"}", 8 | "sig": "cedbd2585c18c9ee8cbafa4e3b1fefbe68cc15deeabcb0519791c6d715f92d1439ca9ac7584185a94d521709f9023fcbafab47a074a7ce8a247d3ce4dfce8af3" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/event-0-the-one-who-repost.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "343e756c454d1fe623e0ea5a7653e5d0cb643fee49acef4b4e8df7645d27c8e4", 3 | "pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", 4 | "created_at": 1712835974, 5 | "kind": 0, 6 | "tags": [], 7 | "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\"}", 8 | "sig": "f3a896b67145eeca606c30375a146055c424ce1216f3e894d720733912aba3a90cf70e018d131246977c85b4ed9491fa43843e7728caab6b28da5d80decf9045" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/event-0.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "63d38c9b483d2d98a46382eadefd272e0e4bdb106a5b6eddb400c4e76f693d35", 3 | "pubkey": "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6", 4 | "created_at": 1699398376, 5 | "kind": 0, 6 | "tags": [ 7 | [ 8 | "proxy", 9 | "https://gleasonator.com/users/alex", 10 | "activitypub" 11 | ] 12 | ], 13 | "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\"}", 14 | "sig": "9d48bbb600aab44abaeee11c97f1753f1d7de08378e9b33d84f9be893a09270aeceecfde3cfb698c555ae1bde3e4e54b3463a61bb99bdf673d64c2202f98b0e9" 15 | } 16 | -------------------------------------------------------------------------------- /fixtures/events/event-1-quote-repost-will-be-reposted.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "00b325bba6de17030091558f439d41d4c08b82e60fbcaee3e0331c4c690ef205", 3 | "pubkey": "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991", 4 | "created_at": 1713735562, 5 | "kind": 1, 6 | "tags": [ 7 | [ 8 | "q", 9 | "f7b82508931bbeeadb901931e3dea8c4f96f0f9e353ba6cf1ec3a93eb8ad7e05" 10 | ] 11 | ], 12 | "content": "Deus futurus est deus aquae deiectus!", 13 | "sig": "72d8365f3c6b6de89fdfd005798c242629145fdc97bfc25e57bb78a4444c2a297bf41a47d7d0e2ee819d77f73fa3fcfcc4b455928ede7fca715e261c567b0b3b" 14 | } 15 | -------------------------------------------------------------------------------- /fixtures/events/event-1-quote-repost.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "e0c2b45143717d62f85880aa7e26f2c3f4b10ada9ef547ae2479cfdd94ea2ce6", 3 | "pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4", 4 | "created_at": 1713217672, 5 | "kind": 1, 6 | "tags": [ 7 | [ 8 | "q", 9 | "826b28986cbe8196faaddb502bbcfb9d5521981d24e858ad1924a178e18f570f", 10 | "wss://relay.mostr.pub/" 11 | ] 12 | ], 13 | "content": "I like this lottery.\nnostr:nevent1qqsgy6egnpktaqvkl2kak5pthnae64fpnqwjf6zc45vjfgtcux84wrcpzemhxue69uhhyetvv9ujumt0wd68ytnsw43z7q3q08pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmqxpqqqqqqzrvwgz7", 14 | "sig": "5a40475e719ad4cf98dd685a268158995c25050057632564d38789ce39a66e9d34b2d4ec9bef650b60bcfe8106415385f28ba291e168a1d02e32e092b8b86615" 15 | } 16 | -------------------------------------------------------------------------------- /fixtures/events/event-1-reposted.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "b460c8eb16ce7b3ebe3079240c47bb7cace5ad2bd0246fab1ed36a12fb816b1e", 3 | "pubkey": "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", 4 | "created_at": 1713045404, 5 | "kind": 1, 6 | "tags": [], 7 | "content": "BITCOIN IS THE ONLY GLOBAL FREE MARKET. 24/7/365 LIQUIDITY. NO CIRCUIT BREAKERS. FEATURE NOT A BUG. STAY HUMBLE AND STACK SATS.", 8 | "sig": "b56ddd466d00de591f371b1933e73deeeba2f56f4b0ee8179b3f6a6b4e45f6bb850a6802d6be17055d13c699b733f610b69fbfbbfbcd609c526b8c5097b28505" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/event-1-that-will-be-quote-reposted.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "826b28986cbe8196faaddb502bbcfb9d5521981d24e858ad1924a178e18f570f", 3 | "pubkey": "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6", 4 | "created_at": 1711675519, 5 | "kind": 1, 6 | "tags": [ 7 | [ 8 | "zap", 9 | "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6", 10 | "wss://relay.mostr.pub", 11 | "0.915" 12 | ], 13 | [ 14 | "zap", 15 | "6be38f8c63df7dbf84db7ec4a6e6fbbd8d19dca3b980efad18585c46f04b26f9", 16 | "wss://relay.mostr.pub", 17 | "0.085" 18 | ], 19 | [ 20 | "proxy", 21 | "https://gleasonator.com/objects/66216159-a709-431b-81e9-e4e1f86e20e4", 22 | "activitypub" 23 | ] 24 | ], 25 | "content": "The Bitcoin Lottery is free to play, and you can win millions! Unlimited tries!\n\nJust guess 12 words mnemonic seed phrase words.", 26 | "sig": "b76264f9a7ec0860a9dd3b72f94e81ed6c0d848eee2bc5cc89b78b1cb1b4e00243f0f354c0185824fe16eb16cfcab511275388b6acd29e0d05d97dea1564d5be" 27 | } 28 | -------------------------------------------------------------------------------- /fixtures/events/event-1-will-be-reposted-with-quote-repost.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "f7b82508931bbeeadb901931e3dea8c4f96f0f9e353ba6cf1ec3a93eb8ad7e05", 3 | "pubkey": "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991", 4 | "created_at": 1713735505, 5 | "kind": 1, 6 | "tags": [], 7 | "content": "The present is theirs, the future, for which I really worked, is mine.", 8 | "sig": "b27fff3ec821e529e74ceede28ecf368682677de1aa2cc2cc65083b8f4a789f53e6a5da899cb0f03e4e6a3555a0fe4421971c427c5c9dd50758127c4da3e9405" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/event-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": 1, 3 | "content": "I'm vegan btw", 4 | "tags": [ 5 | [ 6 | "proxy", 7 | "https://gleasonator.com/objects/8f6fac53-4f66-4c6e-ac7d-92e5e78c3e79", 8 | "activitypub" 9 | ] 10 | ], 11 | "pubkey": "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6", 12 | "created_at": 1691091365, 13 | "id": "55920b758b9c7b17854b6e3d44e6a02a83d1cb49e1227e75a30426dea94d4cb2", 14 | "sig": "a72f12c08f18e85d98fb92ae89e2fe63e48b8864c5e10fbdd5335f3c9f936397a6b0a7350efe251f8168b1601d7012d4a6d0ee6eec958067cf22a14f5a5ea579" 15 | } 16 | -------------------------------------------------------------------------------- /fixtures/events/event-6-of-quote-repost.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "04ee8a34c398ef20bdb56064979aff879f81b6b746232811845eca872e0ebe8d", 3 | "pubkey": "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991", 4 | "created_at": 1713735600, 5 | "kind": 6, 6 | "tags": [ 7 | [ 8 | "e", 9 | "00b325bba6de17030091558f439d41d4c08b82e60fbcaee3e0331c4c690ef205" 10 | ], 11 | [ 12 | "p", 13 | "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991" 14 | ] 15 | ], 16 | "content": "", 17 | "sig": "061b741a8d399db4c1151ed003a76afcf04cac25b98f2df4d4b6467ea9e0dcb54de9d5a6f959ef86b82e8c6e547a87596aecb904cf5fa99e7f8b67fefd43c0f6" 18 | } 19 | -------------------------------------------------------------------------------- /fixtures/events/event-6.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "863185c0b3a18316f438b7920a1f5217ac5a0f2a078dc003f9969512e8c8a5de", 3 | "pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", 4 | "created_at": 1713045438, 5 | "kind": 6, 6 | "tags": [ 7 | [ 8 | "e", 9 | "b460c8eb16ce7b3ebe3079240c47bb7cace5ad2bd0246fab1ed36a12fb816b1e" 10 | ], 11 | [ 12 | "p", 13 | "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9" 14 | ] 15 | ], 16 | "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\"}", 17 | "sig": "3bf75ad219c87194679ac08c2d90c1845cb0352633d73e1cf1b7e83e7be61a18a47dcb4746b4b9dfc1d640b7578a4a602d2f68eec605494a335c0732b3b61f37" 18 | } 19 | -------------------------------------------------------------------------------- /fixtures/events/kind-0-black.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "5f2236e745f47116ee8d869658ebc732a8a35fdeb30a876f46c8c0fef9ae0309", 3 | "pubkey": "32b8276794dec3934fd7ddd2a97b5ee10ecef493441a55e83d838ccd98c58b7a", 4 | "created_at": 1713966213, 5 | "kind": 0, 6 | "tags": [], 7 | "content": "{\"name\":\"black\",\"about\":\"\",\"nip05\":\"\"}", 8 | "sig": "bab454ca68332663c4c17591eb8ea993f78be7787da9e0c5ed140b21059e9fe3c12a5998fcefa94b1129934f798a649bac222b6051a9ea1ce8a01f9620c825e0" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/kind-0-dictator.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "2238893aee54bbe9188498a5aa124d62870d5757894bf52cdb362d1a0874ed18", 3 | "pubkey": "c9f5508526e213c3bc5468161f1b738a86063a2ece540730f9412e7becd5f0b2", 4 | "created_at": 1715517440, 5 | "kind": 0, 6 | "tags": [], 7 | "content": "{\"name\":\"dictator\",\"about\":\"\",\"nip05\":\"\"}", 8 | "sig": "a630ba158833eea10289fe077087ccad22c71ddfbe475153958cfc158ae94fb0a5f7b7626e62da6a3ef8bfbe67321e8f993517ed7f1578a45aff11bc2bec484c" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/kind-0-george-orwell.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "da4e1e727c6456cee2b0341a1d7a2356e4263523374a2570a7dd318ab5d73f93", 3 | "pubkey": "e4d96e951739787e62ada74ee06a9a185af22791a899a6166ec23aab58c5d700", 4 | "created_at": 1715517565, 5 | "kind": 0, 6 | "tags": [], 7 | "content": "{\"name\":\"george orwell\",\"about\":\"\",\"nip05\":\"\"}", 8 | "sig": "cd375e2065cf452d3bfefa9951b04ab63018ab7c253803256cca1d89d03b38e454c71ed36fdd3c28a8ff2723cc19b21371ce0f9bbd39a92b1d1aa946137237bd" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/kind-0-jack.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": 0, 3 | "id": "f7b1a3ca3fa77bffded2024568da939e8cd3ed2403004e1ecb56d556f299ad2a", 4 | "pubkey": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", 5 | "created_at": 1715441226, 6 | "tags": [], 7 | "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\"}", 8 | "sig": "9792ceb1e9c73a6c2140540ddbac4279361cae4cc41888019d9dd47d09c1e7cee55948f6e1af824fa0f856d892686352bc757ad157f766f0da656d5e80b38bc7" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/kind-0-patrick.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": 0, 3 | "id": "34bc588a4ff5ca8570a1ad4114485239f83c135b09636dbc16df338f73079e42", 4 | "pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4", 5 | "created_at": 1726076335, 6 | "tags": [], 7 | "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}", 8 | "sig": "2780887e58d6e59cc9c03cca8a583bc121d2c74d98cc434d22e65c1f56da1bb09d79fc7cc3c4ee5b829773c17d6f482b114dc951c1683c3908cedff783d785ad" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/kind-1-author-george-orwell.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "44f19148f5af60b0f43ed8c737fbda31b165e05bb55562003c45d9a9f02e8228", 3 | "pubkey": "e4d96e951739787e62ada74ee06a9a185af22791a899a6166ec23aab58c5d700", 4 | "created_at": 1715636249, 5 | "kind": 1, 6 | "tags": [], 7 | "content": "I like free speech", 8 | "sig": "6b50db9c1c02bd8b0e64512e71d53a0058569f44e8dcff65ad17fce544d6ae79f8f79fa0f9a615446fa8cbc2375709bf835751843b0cd10e62ae5d505fe106d4" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/events/kind-1-being-zapped.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": 1, 3 | "id": "02e52f80e2e6a3ad0993e9c4a7b4e6afc79d067c6ff9c6df3fb2896342dee2df", 4 | "pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4", 5 | "created_at": 1724609131, 6 | "tags": [ 7 | ["e", "677c701036eae20632a7677ee6eece0c62e259d5c72864d78a3bbe419c0d2d2c", "wss://gleasonator.dev/relay", "root"], 8 | ["e", "677c701036eae20632a7677ee6eece0c62e259d5c72864d78a3bbe419c0d2d2c", "wss://gleasonator.dev/relay", "reply"], 9 | ["p", "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"] 10 | ], 11 | "content": "Please I don't want to go back to the shoe factory", 12 | "sig": "ce6ca329701eec5db0b182bd52c48777b9eccaac298180a6601d8c5156060d944768d71376e7d24c24cefb6619d1467f6a30e0ca574d68f748b38c784e4ced59" 13 | } 14 | -------------------------------------------------------------------------------- /fixtures/events/kind-10000-black-blocks-user-me.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "4aa67b0b7829d555a42b1f57f8033e6f0cda87a63b5bf5a0d5e10fbb9e7ab107", 3 | "pubkey": "32b8276794dec3934fd7ddd2a97b5ee10ecef493441a55e83d838ccd98c58b7a", 4 | "created_at": 1713997559, 5 | "kind": 10000, 6 | "tags": [ 7 | [ 8 | "p", 9 | "2894578b2c8570825f48dc647e746e690f83f92d19446b3051f59cd0196a7991" 10 | ] 11 | ], 12 | "content": "", 13 | "sig": "9fa697cf5353cc82a7d1b3304c3d41e51e30547035a4c8fb25d3f5c9f378d43a30935b523c0a9671a2c61d36eff530f3f3fcfbb77440589649e5f2aa306df039" 14 | } 15 | -------------------------------------------------------------------------------- /fixtures/events/kind-10002-alex.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": 10002, 3 | "id": "68fc04e23b07219f153a10947663b9dd7b271acbc03b82200e364e35de3e0bdd", 4 | "pubkey": "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd", 5 | "created_at": 1714969354, 6 | "tags": [ 7 | [ 8 | "r", 9 | "wss://gleasonator.dev/relay" 10 | ], 11 | [ 12 | "r", 13 | "wss://nosdrive.app/relay" 14 | ], 15 | [ 16 | "r", 17 | "wss://relay.mostr.pub/" 18 | ], 19 | [ 20 | "r", 21 | "wss://relay.primal.net/" 22 | ], 23 | [ 24 | "r", 25 | "wss://relay.snort.social/" 26 | ], 27 | [ 28 | "r", 29 | "wss://relay.damus.io/" 30 | ] 31 | ], 32 | "content": "", 33 | "sig": "cb7b1a75fe015d5c9481651379365bd5d098665b1bc7a453522177e2686eaa83581ec36f7a17429aad2541dad02c2c81023b81612f87f28fc57447fef1efab13" 34 | } 35 | -------------------------------------------------------------------------------- /fixtures/events/kind-1984-dictator-reports-george-orwell.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "129b2749330a7f1189d3e74c6764a955851f1e4017a818dfd51ab8e24192b0f3", 3 | "pubkey": "c9f5508526e213c3bc5468161f1b738a86063a2ece540730f9412e7becd5f0b2", 4 | "created_at": 1715636348, 5 | "kind": 1984, 6 | "tags": [ 7 | [ 8 | "p", 9 | "e4d96e951739787e62ada74ee06a9a185af22791a899a6166ec23aab58c5d700", 10 | "other" 11 | ], 12 | [ 13 | "P", 14 | "e724b1c1b90eab9cc0f5976b380b80dda050de1820dc143e62d9e4f27a9a0b2c" 15 | ], 16 | [ 17 | "e", 18 | "44f19148f5af60b0f43ed8c737fbda31b165e05bb55562003c45d9a9f02e8228", 19 | "other" 20 | ] 21 | ], 22 | "content": "freedom of speech not freedom of reach", 23 | "sig": "cd05a14749cdf0c7664d056e2c02518740000387732218dacd0c71de5b96c0c3c99a0b927b0cd0778f25a211525fa03b4ed4f4f537bb1221c73467780d4ee1bc" 24 | } 25 | -------------------------------------------------------------------------------- /fixtures/nostrbuild-mp3.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "success", 3 | "message": "Upload successful.", 4 | "data": [ 5 | { 6 | "id": 0, 7 | "input_name": "APIv2", 8 | "name": "f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3", 9 | "url": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3", 10 | "thumbnail": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3", 11 | "responsive": { 12 | "240p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3", 13 | "360p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3", 14 | "480p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3", 15 | "720p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3", 16 | "1080p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3" 17 | }, 18 | "blurhash": "", 19 | "sha256": "f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725", 20 | "original_sha256": "f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725", 21 | "type": "video", 22 | "mime": "audio/mpeg", 23 | "size": 1519616, 24 | "metadata": [], 25 | "dimensions": [], 26 | "dimensionsString": "0x0" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /installation/Caddyfile: -------------------------------------------------------------------------------- 1 | # Cloudflare real IP configuration for rate-limiting 2 | # { 3 | # servers { 4 | # # https://www.cloudflare.com/ips/ 5 | # 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 6 | # trusted_proxies_strict 7 | # } 8 | # } 9 | 10 | example.com { 11 | log 12 | request_header X-Real-IP {client_ip} 13 | 14 | @public path /packs/* /instance/* /images/* /favicon.ico /sw.js /sw.js.map 15 | 16 | handle /packs/* { 17 | root * /opt/ditto/public 18 | header Cache-Control "max-age=31536000, public, immutable" 19 | file_server 20 | } 21 | 22 | handle @public { 23 | root * /opt/ditto/public 24 | file_server 25 | } 26 | 27 | handle /metrics { 28 | respond "Access denied" 403 29 | } 30 | 31 | handle { 32 | reverse_proxy :4036 33 | } 34 | } -------------------------------------------------------------------------------- /installation/ditto.conf: -------------------------------------------------------------------------------- 1 | # Nginx configuration for Ditto. 2 | # 3 | # Edit this file to change occurences of "example.com" to your own domain. 4 | 5 | upstream ditto { 6 | server 127.0.0.1:4036; 7 | } 8 | 9 | server { 10 | listen 80; 11 | listen [::]:80; 12 | location /.well-known/acme-challenge/ { allow all; } 13 | location / { return 301 https://$host$request_uri; } 14 | } 15 | 16 | server { 17 | server_name example.com; 18 | 19 | keepalive_timeout 70; 20 | sendfile on; 21 | client_max_body_size 100m; 22 | ignore_invalid_headers off; 23 | 24 | proxy_http_version 1.1; 25 | proxy_set_header Upgrade $http_upgrade; 26 | proxy_set_header Connection "upgrade"; 27 | proxy_set_header Host $host; 28 | proxy_set_header X-Real-IP $remote_addr; 29 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 30 | proxy_set_header X-Forwarded-Proto $scheme; 31 | 32 | root /opt/ditto/public; 33 | 34 | location /packs { 35 | add_header Cache-Control "public, max-age=31536000, immutable"; 36 | add_header Strict-Transport-Security "max-age=31536000" always; 37 | root /opt/ditto/public; 38 | } 39 | 40 | location ~ ^/(instance|sw\.js$|sw\.js\.map$) { 41 | root /opt/ditto/public; 42 | try_files $uri =404; 43 | } 44 | 45 | location /metrics { 46 | allow 127.0.0.1; 47 | deny all; 48 | proxy_pass http://ditto; 49 | } 50 | 51 | location / { 52 | proxy_pass http://ditto; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /installation/ditto.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Ditto 3 | Wants=network-online.target 4 | After=network-online.target 5 | 6 | [Service] 7 | Type=simple 8 | User=ditto 9 | SyslogIdentifier=ditto 10 | WorkingDirectory=/opt/ditto 11 | ExecStart=/usr/local/bin/deno task start 12 | Restart=on-failure 13 | 14 | [Install] 15 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /packages/captcha/assets.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@std/assert'; 2 | 3 | import { getCaptchaImages } from './assets.ts'; 4 | 5 | Deno.test('getCaptchaImages', async () => { 6 | // If this function runs at all, it most likely worked. 7 | const { bgImages } = await getCaptchaImages(); 8 | assert(bgImages.length); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/captcha/assets.ts: -------------------------------------------------------------------------------- 1 | import { type Image, loadImage } from '@gfx/canvas-wasm'; 2 | 3 | export interface CaptchaImages { 4 | bgImages: Image[]; 5 | puzzleMask: Image; 6 | puzzleHole: Image; 7 | } 8 | 9 | export async function getCaptchaImages(): Promise { 10 | const bgImages = await getBackgroundImages(); 11 | 12 | const puzzleMask = await loadImage( 13 | await Deno.readFile(new URL('./assets/puzzle/puzzle-mask.png', import.meta.url)), 14 | ); 15 | const puzzleHole = await loadImage( 16 | await Deno.readFile(new URL('./assets/puzzle/puzzle-hole.png', import.meta.url)), 17 | ); 18 | 19 | return { bgImages, puzzleMask, puzzleHole }; 20 | } 21 | 22 | async function getBackgroundImages(): Promise { 23 | const path = new URL('./assets/bg/', import.meta.url); 24 | 25 | const images: Image[] = []; 26 | 27 | for await (const dirEntry of Deno.readDir(path)) { 28 | if (dirEntry.isFile && dirEntry.name.endsWith('.jpg')) { 29 | const file = await Deno.readFile(new URL(dirEntry.name, path)); 30 | const image = await loadImage(file); 31 | images.push(image); 32 | } 33 | } 34 | 35 | return images; 36 | } 37 | -------------------------------------------------------------------------------- /packages/captcha/assets/bg/a-large-body-of-water-surrounded-by-mountains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/a-large-body-of-water-surrounded-by-mountains.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/a-trail-of-footprints-in-the-sand.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/a-trail-of-footprints-in-the-sand.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/ashim-dsilva.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/ashim-dsilva.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/canazei-granite-ridges.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/canazei-granite-ridges.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/copyright.txt: -------------------------------------------------------------------------------- 1 | Unsplash photos published before June 8, 2017 are CC0 (public domain): 2 | 3 | Ashim D'Silva 4 | Canazei Granite Ridges 5 | Mr. Lee 6 | Photo by SpaceX 7 | Sunset by the Pier 8 | 9 | Unsplash photos published on or after June 8, 2017 are free to use, modify, and redistribute subject to the Unsplash license : 10 | 11 | Martin Adams 12 | Morskie Oko 13 | Nattu Adnan 14 | Tj Holowaychuk 15 | Viktor Forgacs 16 | “A Large Body of Water Surrounded By Mountains” by Peter Thomas 17 | “A Trail of Footprints In The Sand” by David Emrich 18 | “Photo of Valley” by Aniket Doele 19 | 20 | Pexels photos are free to use, modify, and redistribute subject to the Pexels license : 21 | 22 | Snow-Capped Mountain 23 | -------------------------------------------------------------------------------- /packages/captcha/assets/bg/martin-adams.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/martin-adams.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/morskie-oko.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/morskie-oko.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/mr-lee.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/mr-lee.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/nattu-adnan.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/nattu-adnan.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/photo-by-spacex.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/photo-by-spacex.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/photo-of-valley.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/photo-of-valley.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/snow-capped-mountain.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/snow-capped-mountain.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/sunset-by-the-pier.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/sunset-by-the-pier.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/tj-holowaychuk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/tj-holowaychuk.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/bg/viktor-forgacs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/bg/viktor-forgacs.jpg -------------------------------------------------------------------------------- /packages/captcha/assets/puzzle/puzzle-hole.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/puzzle/puzzle-hole.png -------------------------------------------------------------------------------- /packages/captcha/assets/puzzle/puzzle-mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/captcha/assets/puzzle/puzzle-mask.png -------------------------------------------------------------------------------- /packages/captcha/assets/puzzle/puzzle-mask.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/captcha/canvas.test.ts: -------------------------------------------------------------------------------- 1 | import { createCanvas } from '@gfx/canvas-wasm'; 2 | import { assertNotEquals } from '@std/assert'; 3 | import { encodeHex } from '@std/encoding/hex'; 4 | 5 | import { addNoise } from './canvas.ts'; 6 | 7 | // This is almost impossible to truly test, 8 | // but we can at least check that the image on the canvas changes. 9 | Deno.test('addNoise', async () => { 10 | const canvas = createCanvas(100, 100); 11 | const ctx = canvas.getContext('2d'); 12 | 13 | const dataBefore = ctx.getImageData(0, 0, canvas.width, canvas.height); 14 | const hashBefore = await crypto.subtle.digest('SHA-256', dataBefore.data); 15 | 16 | addNoise(ctx, canvas.width, canvas.height); 17 | 18 | const dataAfter = ctx.getImageData(0, 0, canvas.width, canvas.height); 19 | const hashAfter = await crypto.subtle.digest('SHA-256', dataAfter.data); 20 | 21 | assertNotEquals(encodeHex(hashBefore), encodeHex(hashAfter)); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/captcha/canvas.ts: -------------------------------------------------------------------------------- 1 | import type { CanvasRenderingContext2D } from '@gfx/canvas-wasm'; 2 | 3 | /** 4 | * Add a small amount of noise to the image. 5 | * This protects against an attacker pregenerating every possible solution and then doing a reverse-lookup. 6 | */ 7 | export function addNoise(ctx: CanvasRenderingContext2D, width: number, height: number): void { 8 | const imageData = ctx.getImageData(0, 0, width, height); 9 | 10 | // Loop over every pixel. 11 | for (let i = 0; i < imageData.data.length; i += 4) { 12 | // Add/subtract a small amount from each color channel. 13 | // We skip i+3 because that's the alpha channel, which we don't want to modify. 14 | for (let j = 0; j < 3; j++) { 15 | const alteration = Math.floor(Math.random() * 11) - 5; // Vary between -5 and +5 16 | imageData.data[i + j] = Math.min(Math.max(imageData.data[i + j] + alteration, 0), 255); 17 | } 18 | } 19 | 20 | ctx.putImageData(imageData, 0, 0); 21 | } 22 | -------------------------------------------------------------------------------- /packages/captcha/captcha.test.ts: -------------------------------------------------------------------------------- 1 | import { getCaptchaImages } from './assets.ts'; 2 | import { generateCaptcha, verifyCaptchaSolution } from './captcha.ts'; 3 | 4 | Deno.test('generateCaptcha', async () => { 5 | const images = await getCaptchaImages(); 6 | generateCaptcha(images, { w: 370, h: 400 }, { w: 65, h: 65 }); 7 | }); 8 | 9 | Deno.test('verifyCaptchaSolution', () => { 10 | verifyCaptchaSolution({ w: 65, h: 65 }, { x: 0, y: 0 }, { x: 0, y: 0 }); 11 | verifyCaptchaSolution({ w: 65, h: 65 }, { x: 0, y: 0 }, { x: 10, y: 10 }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/captcha/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/captcha", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/captcha/geometry.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { areIntersecting } from './geometry.ts'; 4 | 5 | Deno.test('areIntersecting', () => { 6 | assertEquals(areIntersecting({ x: 0, y: 0, w: 10, h: 10 }, { x: 5, y: 5, w: 10, h: 10 }), true); 7 | assertEquals(areIntersecting({ x: 0, y: 0, w: 10, h: 10 }, { x: 15, y: 15, w: 10, h: 10 }), false); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/captcha/geometry.ts: -------------------------------------------------------------------------------- 1 | export interface Point { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | export interface Dimensions { 7 | w: number; 8 | h: number; 9 | } 10 | 11 | type Rectangle = Point & Dimensions; 12 | 13 | /** Check if the two rectangles intersect by at least `threshold` percent. */ 14 | export function areIntersecting(rect1: Rectangle, rect2: Rectangle, threshold = 0.5): boolean { 15 | const r1cx = rect1.x + rect1.w / 2; 16 | const r2cx = rect2.x + rect2.w / 2; 17 | 18 | const r1cy = rect1.y + rect1.h / 2; 19 | const r2cy = rect2.y + rect2.h / 2; 20 | 21 | const dist = Math.sqrt((r2cx - r1cx) ** 2 + (r2cy - r1cy) ** 2); 22 | 23 | const e1 = Math.sqrt(rect1.h ** 2 + rect1.w ** 2) / 2; 24 | const e2 = Math.sqrt(rect2.h ** 2 + rect2.w ** 2) / 2; 25 | 26 | return dist <= (e1 + e2) * threshold; 27 | } 28 | -------------------------------------------------------------------------------- /packages/captcha/mod.ts: -------------------------------------------------------------------------------- 1 | export { getCaptchaImages } from './assets.ts'; 2 | export { generateCaptcha, verifyCaptchaSolution } from './captcha.ts'; 3 | -------------------------------------------------------------------------------- /packages/cashu/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/cashu", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/cashu/mod.ts: -------------------------------------------------------------------------------- 1 | export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts'; 2 | export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts'; 3 | export { renderTransaction, type Transaction } from './views.ts'; 4 | -------------------------------------------------------------------------------- /packages/cashu/schemas.ts: -------------------------------------------------------------------------------- 1 | import { NSchema as n } from '@nostrify/nostrify'; 2 | import { z } from 'zod'; 3 | 4 | export const proofSchema: z.ZodType<{ 5 | id: string; 6 | amount: number; 7 | secret: string; 8 | C: string; 9 | dleq?: { s: string; e: string; r?: string }; 10 | dleqValid?: boolean; 11 | }> = z.object({ 12 | id: z.string(), 13 | amount: z.number(), 14 | secret: z.string(), 15 | C: z.string(), 16 | dleq: z.object({ s: z.string(), e: z.string(), r: z.string().optional() }) 17 | .optional(), 18 | dleqValid: z.boolean().optional(), 19 | }); 20 | 21 | /** Decrypted content of a kind 7375 */ 22 | export const tokenEventSchema: z.ZodType<{ 23 | mint: string; 24 | proofs: Array>; 25 | del?: string[]; 26 | }> = z.object({ 27 | mint: z.string().url(), 28 | proofs: proofSchema.array(), 29 | del: z.string().array().optional(), 30 | }); 31 | 32 | /** Ditto Cashu wallet */ 33 | export const walletSchema: z.ZodType<{ 34 | pubkey_p2pk: string; 35 | mints: string[]; 36 | relays: string[]; 37 | balance: number; 38 | }> = z.object({ 39 | pubkey_p2pk: n.id(), 40 | mints: z.array(z.string().url()).nonempty().transform((val) => { 41 | return [...new Set(val)]; 42 | }), 43 | relays: z.array(z.string()).nonempty().transform((val) => { 44 | return [...new Set(val)]; 45 | }), 46 | /** Unit in sats */ 47 | balance: z.number(), 48 | }); 49 | 50 | export type Wallet = z.infer; 51 | -------------------------------------------------------------------------------- /packages/cashu/views.ts: -------------------------------------------------------------------------------- 1 | import { type NostrEvent, type NostrSigner, NSchema as n } from '@nostrify/nostrify'; 2 | import type { SetRequired } from 'type-fest'; 3 | import { z } from 'zod'; 4 | 5 | type Transaction = { 6 | amount: number; 7 | created_at: number; 8 | direction: 'in' | 'out'; 9 | }; 10 | 11 | /** Renders one history of transaction. */ 12 | async function renderTransaction( 13 | event: NostrEvent, 14 | viewerPubkey: string, 15 | signer: SetRequired, 16 | ): Promise { 17 | if (event.kind !== 7376) return; 18 | 19 | const { data: contentTags, success } = n.json().pipe(z.coerce.string().array().min(2).array()).safeParse( 20 | await signer.nip44.decrypt(viewerPubkey, event.content), 21 | ); 22 | 23 | if (!success) { 24 | return; 25 | } 26 | 27 | const direction = contentTags.find(([name]) => name === 'direction')?.[1]; 28 | if (direction !== 'out' && direction !== 'in') { 29 | return; 30 | } 31 | 32 | const amount = parseInt(contentTags.find(([name]) => name === 'amount')?.[1] ?? '', 10); 33 | if (isNaN(amount)) { 34 | return; 35 | } 36 | 37 | return { 38 | created_at: event.created_at, 39 | direction, 40 | amount, 41 | }; 42 | } 43 | 44 | export { renderTransaction, type Transaction }; 45 | -------------------------------------------------------------------------------- /packages/conf/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/conf", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/conf/mod.ts: -------------------------------------------------------------------------------- 1 | export { DittoConf } from './DittoConf.ts'; 2 | -------------------------------------------------------------------------------- /packages/conf/utils/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { getEcdsaPublicKey } from './crypto.ts'; 4 | 5 | Deno.test('getEcdsaPublicKey', async () => { 6 | const { publicKey, privateKey } = await crypto.subtle.generateKey( 7 | { 8 | name: 'ECDSA', 9 | namedCurve: 'P-256', 10 | }, 11 | true, 12 | ['sign', 'verify'], 13 | ); 14 | 15 | const result = await getEcdsaPublicKey(privateKey, true); 16 | 17 | assertKeysEqual(result, publicKey); 18 | }); 19 | 20 | /** Assert that two CryptoKey objects are equal by value. Keys must be exportable. */ 21 | async function assertKeysEqual(a: CryptoKey, b: CryptoKey): Promise { 22 | const [jwk1, jwk2] = await Promise.all([ 23 | crypto.subtle.exportKey('jwk', a), 24 | crypto.subtle.exportKey('jwk', b), 25 | ]); 26 | 27 | assertEquals(jwk1, jwk2); 28 | } 29 | -------------------------------------------------------------------------------- /packages/conf/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert an ECDSA private key into a public key. 3 | * https://stackoverflow.com/a/72153942 4 | */ 5 | export async function getEcdsaPublicKey( 6 | privateKey: CryptoKey, 7 | extractable: boolean, 8 | ): Promise { 9 | if (privateKey.type !== 'private') { 10 | throw new Error('Expected a private key.'); 11 | } 12 | if (privateKey.algorithm.name !== 'ECDSA') { 13 | throw new Error('Expected a private key with the ECDSA algorithm.'); 14 | } 15 | 16 | const jwk = await crypto.subtle.exportKey('jwk', privateKey); 17 | const keyUsages: KeyUsage[] = ['verify']; 18 | 19 | // Remove the private property from the JWK. 20 | delete jwk.d; 21 | jwk.key_ops = keyUsages; 22 | jwk.ext = extractable; 23 | 24 | return crypto.subtle.importKey('jwk', jwk, privateKey.algorithm, extractable, keyUsages); 25 | } 26 | -------------------------------------------------------------------------------- /packages/conf/utils/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from '@std/assert'; 2 | 3 | import { optionalBooleanSchema, optionalNumberSchema } from './schema.ts'; 4 | 5 | Deno.test('optionalBooleanSchema', () => { 6 | assertEquals(optionalBooleanSchema.parse('true'), true); 7 | assertEquals(optionalBooleanSchema.parse('false'), false); 8 | assertEquals(optionalBooleanSchema.parse(undefined), undefined); 9 | 10 | assertThrows(() => optionalBooleanSchema.parse('invalid')); 11 | }); 12 | 13 | Deno.test('optionalNumberSchema', () => { 14 | assertEquals(optionalNumberSchema.parse('123'), 123); 15 | assertEquals(optionalNumberSchema.parse('invalid'), NaN); // maybe this should throw? 16 | assertEquals(optionalNumberSchema.parse(undefined), undefined); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/conf/utils/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const optionalBooleanSchema = z 4 | .enum(['true', 'false']) 5 | .optional() 6 | .transform((value) => value !== undefined ? value === 'true' : undefined); 7 | 8 | export const optionalNumberSchema = z 9 | .string() 10 | .optional() 11 | .transform((value) => value !== undefined ? Number(value) : undefined); 12 | -------------------------------------------------------------------------------- /packages/conf/utils/url.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { mergeURLPath } from './url.ts'; 4 | 5 | Deno.test('mergeURLPath', () => { 6 | assertEquals(mergeURLPath('https://mario.com', '/path'), 'https://mario.com/path'); 7 | assertEquals(mergeURLPath('https://mario.com', 'https://luigi.com/path'), 'https://mario.com/path'); 8 | assertEquals(mergeURLPath('https://mario.com', 'https://luigi.com/path?q=1'), 'https://mario.com/path?q=1'); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/conf/utils/url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Produce a URL whose origin is guaranteed to be the same as the base URL. 3 | * The path is either an absolute path (starting with `/`), or a full URL. In either case, only its path is used. 4 | */ 5 | export function mergeURLPath( 6 | /** Base URL. Result is guaranteed to use this URL's origin. */ 7 | base: string, 8 | /** Either an absolute path (starting with `/`), or a full URL. If a full URL, its path */ 9 | path: string, 10 | ): string { 11 | const url = new URL( 12 | path.startsWith('/') ? path : new URL(path).pathname, 13 | base, 14 | ); 15 | 16 | if (!path.startsWith('/')) { 17 | // Copy query parameters from the original URL to the new URL 18 | const originalUrl = new URL(path); 19 | url.search = originalUrl.search; 20 | } 21 | 22 | return url.toString(); 23 | } 24 | -------------------------------------------------------------------------------- /packages/db/DittoDB.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | import type { DittoTables } from './DittoTables.ts'; 4 | 5 | export interface DittoDB extends AsyncDisposable { 6 | readonly kysely: Kysely; 7 | readonly poolSize: number; 8 | readonly availableConnections: number; 9 | migrate(): Promise; 10 | listen(channel: string, callback: (payload: string) => void): void; 11 | } 12 | 13 | export interface DittoDBOpts { 14 | poolSize?: number; 15 | debug?: 0 | 1 | 2 | 3 | 4 | 5; 16 | } 17 | -------------------------------------------------------------------------------- /packages/db/KyselyLogger.ts: -------------------------------------------------------------------------------- 1 | import { dbQueriesCounter, dbQueryDurationHistogram } from '@ditto/metrics'; 2 | import { logi, type LogiValue } from '@soapbox/logi'; 3 | 4 | import type { Logger } from 'kysely'; 5 | 6 | /** Log the SQL for queries. */ 7 | export const KyselyLogger: Logger = (event) => { 8 | const { query, queryDurationMillis } = event; 9 | const { parameters, sql } = query; 10 | 11 | const duration = queryDurationMillis / 1000; 12 | 13 | dbQueriesCounter.inc(); 14 | dbQueryDurationHistogram.observe(duration); 15 | 16 | if (event.level === 'query') { 17 | logi({ level: 'trace', ns: 'ditto.sql', sql, parameters: parameters as LogiValue, duration }); 18 | } 19 | 20 | if (event.level === 'error') { 21 | if (event.error instanceof Error) { 22 | switch (event.error.message) { 23 | case 'duplicate key value violates unique constraint "nostr_events_pkey"': 24 | case 'duplicate key value violates unique constraint "author_stats_pkey"': 25 | case 'duplicate key value violates unique constraint "event_stats_pkey"': 26 | case 'duplicate key value violates unique constraint "event_zaps_pkey"': 27 | case 'insert or update on table "event_stats" violates foreign key constraint "event_stats_event_id_fkey"': 28 | return; // Don't log expected errors 29 | } 30 | } 31 | 32 | logi({ 33 | level: 'error', 34 | ns: 'ditto.sql', 35 | sql, 36 | parameters: parameters as LogiValue, 37 | error: event.error instanceof Error ? event.error : null, 38 | duration, 39 | }); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /packages/db/adapters/DittoPglite.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertRejects } from '@std/assert'; 2 | 3 | import { DittoPglite } from './DittoPglite.ts'; 4 | 5 | Deno.test('DittoPglite', async () => { 6 | await using db = new DittoPglite('memory://'); 7 | await db.migrate(); 8 | 9 | assertEquals(db.poolSize, 1); 10 | assertEquals(db.availableConnections, 1); 11 | }); 12 | 13 | Deno.test('DittoPglite query after closing', async () => { 14 | const db = new DittoPglite('memory://'); 15 | await db[Symbol.asyncDispose](); 16 | 17 | await assertRejects( 18 | () => db.kysely.selectFrom('nostr_events').selectAll().execute(), 19 | Error, 20 | 'PGlite is closed', 21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/db/adapters/DittoPolyPg.test.ts: -------------------------------------------------------------------------------- 1 | import { DittoPolyPg } from './DittoPolyPg.ts'; 2 | 3 | Deno.test('DittoPolyPg', async () => { 4 | const db = new DittoPolyPg('memory://'); 5 | await db.migrate(); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/db/adapters/DittoPostgres.test.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | 3 | import { DittoPostgres } from './DittoPostgres.ts'; 4 | 5 | const conf = new DittoConf(Deno.env); 6 | const isPostgres = /^postgres(?:ql)?:/.test(conf.databaseUrl); 7 | 8 | Deno.test('DittoPostgres', { ignore: !isPostgres }, async () => { 9 | await using db = new DittoPostgres(conf.databaseUrl); 10 | await db.migrate(); 11 | }); 12 | 13 | // FIXME: There is a problem with postgres-js where queries just hang after the database is closed. 14 | 15 | // Deno.test('DittoPostgres query after closing', { ignore: !isPostgres }, async () => { 16 | // const db = new DittoPostgres(conf.databaseUrl); 17 | // await db[Symbol.asyncDispose](); 18 | // 19 | // await assertRejects( 20 | // () => db.kysely.selectFrom('nostr_events').selectAll().execute(), 21 | // ); 22 | // }); 23 | -------------------------------------------------------------------------------- /packages/db/adapters/DummyDB.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | import { DummyDB } from './DummyDB.ts'; 3 | 4 | Deno.test('DummyDB', async () => { 5 | const db = new DummyDB(); 6 | await db.migrate(); 7 | 8 | const rows = await db.kysely.selectFrom('nostr_events').selectAll().execute(); 9 | 10 | assertEquals(rows, []); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/db/adapters/DummyDB.ts: -------------------------------------------------------------------------------- 1 | import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely'; 2 | 3 | import type { DittoDB } from '../DittoDB.ts'; 4 | import type { DittoTables } from '../DittoTables.ts'; 5 | 6 | export class DummyDB implements DittoDB { 7 | readonly kysely: Kysely; 8 | readonly poolSize = 0; 9 | readonly availableConnections = 0; 10 | 11 | constructor() { 12 | this.kysely = new Kysely({ 13 | dialect: { 14 | createAdapter: () => new PostgresAdapter(), 15 | createDriver: () => new DummyDriver(), 16 | createIntrospector: (db) => new PostgresIntrospector(db), 17 | createQueryCompiler: () => new PostgresQueryCompiler(), 18 | }, 19 | }); 20 | } 21 | 22 | listen(): void { 23 | // noop 24 | } 25 | 26 | migrate(): Promise { 27 | return Promise.resolve(); 28 | } 29 | 30 | [Symbol.asyncDispose](): Promise { 31 | return Promise.resolve(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/db/adapters/TestDB.test.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { NPostgres } from '@nostrify/db'; 3 | import { genEvent } from '@nostrify/nostrify/test'; 4 | import { assertEquals } from '@std/assert'; 5 | 6 | import { DittoPolyPg } from './DittoPolyPg.ts'; 7 | import { TestDB } from './TestDB.ts'; 8 | 9 | Deno.test('TestDB', async () => { 10 | const conf = new DittoConf(Deno.env); 11 | const orig = new DittoPolyPg(conf.databaseUrl); 12 | 13 | await using db = new TestDB(orig); 14 | await db.migrate(); 15 | await db.clear(); 16 | 17 | const store = new NPostgres(orig.kysely); 18 | await store.event(genEvent()); 19 | 20 | assertEquals((await store.count([{}])).count, 1); 21 | 22 | await db.clear(); 23 | 24 | assertEquals((await store.count([{}])).count, 0); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/db/adapters/TestDB.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | import type { DittoDB } from '../DittoDB.ts'; 4 | import type { DittoTables } from '../DittoTables.ts'; 5 | 6 | /** Wraps another DittoDB implementation to clear all data when disposed. */ 7 | export class TestDB implements DittoDB { 8 | constructor(private db: DittoDB) {} 9 | 10 | get kysely(): Kysely { 11 | return this.db.kysely; 12 | } 13 | 14 | get poolSize(): number { 15 | return this.db.poolSize; 16 | } 17 | 18 | get availableConnections(): number { 19 | return this.db.availableConnections; 20 | } 21 | 22 | migrate(): Promise { 23 | return this.db.migrate(); 24 | } 25 | 26 | listen(channel: string, callback: (payload: string) => void): void { 27 | return this.db.listen(channel, callback); 28 | } 29 | 30 | /** Truncate all tables. */ 31 | async clear(): Promise { 32 | const query = sql<{ tablename: string }>`select tablename from pg_tables where schemaname = current_schema()`; 33 | 34 | const { rows } = await query.execute(this.db.kysely); 35 | 36 | for (const { tablename } of rows) { 37 | if (tablename.startsWith('kysely_')) { 38 | continue; // Skip Kysely's internal tables 39 | } else { 40 | await sql`truncate table ${sql.ref(tablename)} cascade`.execute(this.db.kysely); 41 | } 42 | } 43 | } 44 | 45 | async [Symbol.asyncDispose](): Promise { 46 | await this.clear(); 47 | await this.db[Symbol.asyncDispose](); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/db/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/db", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/db/migrations/001_add_relays.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable('relays') 6 | .addColumn('url', 'text', (col) => col.primaryKey()) 7 | .addColumn('domain', 'text', (col) => col.notNull()) 8 | .addColumn('active', 'boolean', (col) => col.notNull()) 9 | .execute(); 10 | } 11 | 12 | export async function down(db: Kysely): Promise { 13 | await db.schema.dropTable('relays').execute(); 14 | } 15 | -------------------------------------------------------------------------------- /packages/db/migrations/002_events_fts.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(_db: Kysely): Promise { 4 | // This migration used to create an FTS table for SQLite, but SQLite support was removed. 5 | } 6 | 7 | export async function down(_db: Kysely): Promise { 8 | } 9 | -------------------------------------------------------------------------------- /packages/db/migrations/003_events_admin.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(_db: Kysely): Promise { 4 | } 5 | 6 | export async function down(db: Kysely): Promise { 7 | await db.schema.alterTable('users').dropColumn('admin').execute(); 8 | } 9 | -------------------------------------------------------------------------------- /packages/db/migrations/004_add_user_indexes.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(_db: Kysely): Promise { 4 | } 5 | 6 | export async function down(db: Kysely): Promise { 7 | await db.schema.dropIndex('idx_users_pubkey').execute(); 8 | await db.schema.dropIndex('idx_users_username').execute(); 9 | } 10 | -------------------------------------------------------------------------------- /packages/db/migrations/006_pragma.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(_db: Kysely): Promise { 4 | } 5 | 6 | export async function down(_db: Kysely): Promise { 7 | } 8 | -------------------------------------------------------------------------------- /packages/db/migrations/007_unattached_media.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable('unattached_media') 6 | .addColumn('id', 'text', (c) => c.primaryKey()) 7 | .addColumn('pubkey', 'text', (c) => c.notNull()) 8 | .addColumn('url', 'text', (c) => c.notNull()) 9 | .addColumn('data', 'text', (c) => c.notNull()) 10 | .addColumn('uploaded_at', 'bigint', (c) => c.notNull()) 11 | .execute(); 12 | 13 | await db.schema 14 | .createIndex('unattached_media_id') 15 | .on('unattached_media') 16 | .column('id') 17 | .execute(); 18 | 19 | await db.schema 20 | .createIndex('unattached_media_pubkey') 21 | .on('unattached_media') 22 | .column('pubkey') 23 | .execute(); 24 | 25 | await db.schema 26 | .createIndex('unattached_media_url') 27 | .on('unattached_media') 28 | .column('url') 29 | .execute(); 30 | } 31 | 32 | export async function down(db: Kysely): Promise { 33 | await db.schema.dropTable('unattached_media').execute(); 34 | } 35 | -------------------------------------------------------------------------------- /packages/db/migrations/008_wal.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(_db: Kysely): Promise { 4 | } 5 | 6 | export async function down(_db: Kysely): Promise { 7 | } 8 | -------------------------------------------------------------------------------- /packages/db/migrations/009_add_stats.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable('author_stats') 6 | .addColumn('pubkey', 'text', (col) => col.primaryKey()) 7 | .addColumn('followers_count', 'integer', (col) => col.notNull().defaultTo(0)) 8 | .addColumn('following_count', 'integer', (col) => col.notNull().defaultTo(0)) 9 | .addColumn('notes_count', 'integer', (col) => col.notNull().defaultTo(0)) 10 | .execute(); 11 | 12 | await db.schema 13 | .createTable('event_stats') 14 | .addColumn('event_id', 'text', (col) => col.primaryKey().references('events.id').onDelete('cascade')) 15 | .addColumn('replies_count', 'integer', (col) => col.notNull().defaultTo(0)) 16 | .addColumn('reposts_count', 'integer', (col) => col.notNull().defaultTo(0)) 17 | .addColumn('reactions_count', 'integer', (col) => col.notNull().defaultTo(0)) 18 | .execute(); 19 | } 20 | 21 | export async function down(db: Kysely): Promise { 22 | await db.schema.dropTable('author_stats').execute(); 23 | await db.schema.dropTable('event_stats').execute(); 24 | } 25 | -------------------------------------------------------------------------------- /packages/db/migrations/010_drop_users.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.dropTable('users').ifExists().execute(); 5 | } 6 | 7 | export async function down(_db: Kysely): Promise { 8 | } 9 | -------------------------------------------------------------------------------- /packages/db/migrations/011_kind_author_index.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createIndex('idx_events_kind_pubkey_created_at') 6 | .on('events') 7 | .columns(['kind', 'pubkey', 'created_at desc']) 8 | .execute(); 9 | } 10 | 11 | export async function down(db: Kysely): Promise { 12 | await db.schema.dropIndex('idx_events_kind_pubkey_created_at').execute(); 13 | } 14 | -------------------------------------------------------------------------------- /packages/db/migrations/012_tags_composite_index.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.dropIndex('idx_tags_tag').execute(); 5 | await db.schema.dropIndex('idx_tags_value').execute(); 6 | 7 | await db.schema 8 | .createIndex('idx_tags_tag_value') 9 | .on('tags') 10 | .columns(['tag', 'value']) 11 | .execute(); 12 | } 13 | 14 | export async function down(db: Kysely): Promise { 15 | await db.schema.dropIndex('idx_tags_tag_value').execute(); 16 | 17 | await db.schema 18 | .createIndex('idx_tags_tag') 19 | .on('tags') 20 | .column('tag') 21 | .execute(); 22 | 23 | await db.schema 24 | .createIndex('idx_tags_value') 25 | .on('tags') 26 | .column('value') 27 | .execute(); 28 | } 29 | -------------------------------------------------------------------------------- /packages/db/migrations/013_soft_deletion.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.alterTable('events').addColumn('deleted_at', 'integer').execute(); 5 | } 6 | 7 | export async function down(db: Kysely): Promise { 8 | await db.schema.alterTable('events').dropColumn('deleted_at').execute(); 9 | } 10 | -------------------------------------------------------------------------------- /packages/db/migrations/014_stats_indexes.ts.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.createIndex('idx_author_stats_pubkey').on('author_stats').column('pubkey').execute(); 5 | await db.schema.createIndex('idx_event_stats_event_id').on('event_stats').column('event_id').execute(); 6 | } 7 | 8 | export async function down(db: Kysely): Promise { 9 | await db.schema.dropIndex('idx_author_stats_pubkey').on('author_stats').execute(); 10 | await db.schema.dropIndex('idx_event_stats_event_id').on('event_stats').execute(); 11 | } 12 | -------------------------------------------------------------------------------- /packages/db/migrations/015_add_pubkey_domains.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable('pubkey_domains') 6 | .ifNotExists() 7 | .addColumn('pubkey', 'text', (col) => col.primaryKey()) 8 | .addColumn('domain', 'text', (col) => col.notNull()) 9 | .execute(); 10 | 11 | await db.schema 12 | .createIndex('pubkey_domains_domain_index') 13 | .on('pubkey_domains') 14 | .column('domain') 15 | .ifNotExists() 16 | .execute(); 17 | } 18 | 19 | export async function down(db: Kysely): Promise { 20 | await db.schema.dropTable('pubkey_domains').execute(); 21 | } 22 | -------------------------------------------------------------------------------- /packages/db/migrations/016_pubkey_domains_updated_at.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .alterTable('pubkey_domains') 6 | .addColumn('last_updated_at', 'integer', (col) => col.notNull().defaultTo(0)) 7 | .execute(); 8 | } 9 | 10 | export async function down(db: Kysely): Promise { 11 | await db.schema.alterTable('pubkey_domains').dropColumn('last_updated_at').execute(); 12 | } 13 | -------------------------------------------------------------------------------- /packages/db/migrations/017_rm_relays.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.dropTable('relays').execute(); 5 | } 6 | 7 | export async function down(db: Kysely): Promise { 8 | await db.schema 9 | .createTable('relays') 10 | .addColumn('url', 'text', (col) => col.primaryKey()) 11 | .addColumn('domain', 'text', (col) => col.notNull()) 12 | .addColumn('active', 'boolean', (col) => col.notNull()) 13 | .execute(); 14 | } 15 | -------------------------------------------------------------------------------- /packages/db/migrations/018_events_created_at_kind_index.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createIndex('idx_events_created_at_kind') 6 | .on('events') 7 | .columns(['created_at desc', 'kind']) 8 | .ifNotExists() 9 | .execute(); 10 | } 11 | 12 | export async function down(db: Kysely): Promise { 13 | await db.schema.dropIndex('idx_events_created_at_kind').ifExists().execute(); 14 | } 15 | -------------------------------------------------------------------------------- /packages/db/migrations/019_ndatabase_schema.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.alterTable('events').renameTo('nostr_events').execute(); 5 | await db.schema.alterTable('tags').renameTo('nostr_tags').execute(); 6 | await db.schema.alterTable('nostr_tags').renameColumn('tag', 'name').execute(); 7 | } 8 | 9 | export async function down(db: Kysely): Promise { 10 | await db.schema.alterTable('nostr_events').renameTo('events').execute(); 11 | await db.schema.alterTable('nostr_tags').renameTo('tags').execute(); 12 | await db.schema.alterTable('tags').renameColumn('name', 'tag').execute(); 13 | } 14 | -------------------------------------------------------------------------------- /packages/db/migrations/020_drop_deleted_at.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | // deno-lint-ignore no-explicit-any 4 | export async function up(db: Kysely): Promise { 5 | await db.deleteFrom('nostr_events').where('deleted_at', 'is not', null).execute(); 6 | await db.schema.alterTable('nostr_events').dropColumn('deleted_at').execute(); 7 | } 8 | 9 | export async function down(db: Kysely): Promise { 10 | await db.schema.alterTable('nostr_events').addColumn('deleted_at', 'integer').execute(); 11 | } 12 | -------------------------------------------------------------------------------- /packages/db/migrations/020_pgfts.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.createTable('nostr_pgfts') 5 | .ifNotExists() 6 | .addColumn('event_id', 'text', (c) => c.primaryKey().references('nostr_events.id').onDelete('cascade')) 7 | .addColumn('search_vec', sql`tsvector`, (c) => c.notNull()) 8 | .execute(); 9 | } 10 | 11 | export async function down(db: Kysely): Promise { 12 | await db.schema.dropTable('nostr_pgfts').ifExists().execute(); 13 | } 14 | -------------------------------------------------------------------------------- /packages/db/migrations/021_pgfts_index.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createIndex('nostr_pgfts_gin_search_vec') 6 | .ifNotExists() 7 | .on('nostr_pgfts') 8 | .using('gin') 9 | .column('search_vec') 10 | .execute(); 11 | } 12 | 13 | export async function down(db: Kysely): Promise { 14 | await db.schema.dropIndex('nostr_pgfts_gin_search_vec').ifExists().execute(); 15 | } 16 | -------------------------------------------------------------------------------- /packages/db/migrations/022_event_stats_reactions.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .alterTable('event_stats') 6 | .addColumn('reactions', 'text', (col) => col.defaultTo('{}')) 7 | .execute(); 8 | } 9 | 10 | export async function down(db: Kysely): Promise { 11 | await db.schema.alterTable('event_stats').dropColumn('reactions').execute(); 12 | } 13 | -------------------------------------------------------------------------------- /packages/db/migrations/023_add_nip46_tokens.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable('nip46_tokens') 6 | .addColumn('api_token', 'text', (col) => col.primaryKey().notNull()) 7 | .addColumn('user_pubkey', 'text', (col) => col.notNull()) 8 | .addColumn('server_seckey', 'bytea', (col) => col.notNull()) 9 | .addColumn('server_pubkey', 'text', (col) => col.notNull()) 10 | .addColumn('relays', 'text', (col) => col.defaultTo('[]')) 11 | .addColumn('connected_at', 'timestamp', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) 12 | .execute(); 13 | } 14 | 15 | export async function down(db: Kysely): Promise { 16 | await db.schema.dropTable('nip46_tokens').execute(); 17 | } 18 | -------------------------------------------------------------------------------- /packages/db/migrations/024_event_stats_quotes_count.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .alterTable('event_stats') 6 | .addColumn('quotes_count', 'integer', (col) => col.notNull().defaultTo(0)) 7 | .execute(); 8 | } 9 | 10 | export async function down(db: Kysely): Promise { 11 | await db.schema.alterTable('event_stats').dropColumn('quotes_count').execute(); 12 | } 13 | -------------------------------------------------------------------------------- /packages/db/migrations/025_event_stats_add_zap_count.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .alterTable('event_stats') 6 | .addColumn('zaps_amount', 'integer', (col) => col.notNull().defaultTo(0)) 7 | .execute(); 8 | } 9 | 10 | export async function down(db: Kysely): Promise { 11 | await db.schema.alterTable('event_stats').dropColumn('zaps_amount').execute(); 12 | } 13 | -------------------------------------------------------------------------------- /packages/db/migrations/026_tags_name_index.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createIndex('idx_tags_name') 6 | .on('nostr_tags') 7 | .column('name') 8 | .ifNotExists() 9 | .execute(); 10 | } 11 | 12 | export async function down(db: Kysely): Promise { 13 | await db.schema.dropIndex('idx_tags_name').ifExists().execute(); 14 | } 15 | -------------------------------------------------------------------------------- /packages/db/migrations/027_add_zap_events.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable('event_zaps') 6 | .addColumn('receipt_id', 'text', (col) => col.primaryKey()) 7 | .addColumn('target_event_id', 'text', (col) => col.notNull()) 8 | .addColumn('sender_pubkey', 'text', (col) => col.notNull()) 9 | .addColumn('amount_millisats', 'integer', (col) => col.notNull()) 10 | .addColumn('comment', 'text', (col) => col.notNull()) 11 | .execute(); 12 | 13 | await db.schema 14 | .createIndex('idx_event_zaps_amount_millisats') 15 | .on('event_zaps') 16 | .column('amount_millisats') 17 | .ifNotExists() 18 | .execute(); 19 | 20 | await db.schema 21 | .createIndex('idx_event_zaps_target_event_id') 22 | .on('event_zaps') 23 | .column('target_event_id') 24 | .ifNotExists() 25 | .execute(); 26 | } 27 | 28 | export async function down(db: Kysely): Promise { 29 | await db.schema.dropIndex('idx_event_zaps_amount_millisats').ifExists().execute(); 30 | await db.schema.dropIndex('idx_event_zaps_target_event_id').ifExists().execute(); 31 | await db.schema.dropTable('event_zaps').execute(); 32 | } 33 | -------------------------------------------------------------------------------- /packages/db/migrations/028_stable_sort.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createIndex('nostr_events_created_at_kind') 6 | .on('nostr_events') 7 | .ifNotExists() 8 | .columns(['created_at desc', 'id asc', 'kind']) 9 | .execute(); 10 | 11 | await db.schema 12 | .createIndex('nostr_events_kind_pubkey_created_at') 13 | .on('nostr_events') 14 | .ifNotExists() 15 | .columns(['kind', 'pubkey', 'created_at desc', 'id asc']) 16 | .execute(); 17 | 18 | await db.schema.dropIndex('idx_events_created_at_kind').execute(); 19 | await db.schema.dropIndex('idx_events_kind_pubkey_created_at').execute(); 20 | } 21 | 22 | export async function down(db: Kysely): Promise { 23 | await db.schema.dropIndex('nostr_events_created_at_kind').execute(); 24 | await db.schema.dropIndex('nostr_events_kind_pubkey_created_at').execute(); 25 | 26 | await db.schema 27 | .createIndex('idx_events_created_at_kind') 28 | .on('nostr_events') 29 | .ifNotExists() 30 | .columns(['created_at desc', 'kind']) 31 | .execute(); 32 | 33 | await db.schema 34 | .createIndex('idx_events_kind_pubkey_created_at') 35 | .on('nostr_events') 36 | .ifNotExists() 37 | .columns(['kind', 'pubkey', 'created_at desc']) 38 | .execute(); 39 | } 40 | -------------------------------------------------------------------------------- /packages/db/migrations/031_rm_unattached_media.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.dropTable('unattached_media').execute(); 5 | } 6 | 7 | export async function down(db: Kysely): Promise { 8 | await db.schema 9 | .createTable('unattached_media') 10 | .addColumn('id', 'text', (c) => c.primaryKey()) 11 | .addColumn('pubkey', 'text', (c) => c.notNull()) 12 | .addColumn('url', 'text', (c) => c.notNull()) 13 | .addColumn('data', 'text', (c) => c.notNull()) 14 | .addColumn('uploaded_at', 'bigint', (c) => c.notNull()) 15 | .execute(); 16 | 17 | await db.schema 18 | .createIndex('unattached_media_id') 19 | .on('unattached_media') 20 | .column('id') 21 | .execute(); 22 | 23 | await db.schema 24 | .createIndex('unattached_media_pubkey') 25 | .on('unattached_media') 26 | .column('pubkey') 27 | .execute(); 28 | 29 | await db.schema 30 | .createIndex('unattached_media_url') 31 | .on('unattached_media') 32 | .column('url') 33 | .execute(); 34 | } 35 | -------------------------------------------------------------------------------- /packages/db/migrations/032_add_author_search.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable('author_search') 6 | .addColumn('pubkey', 'char(64)', (col) => col.primaryKey()) 7 | .addColumn('search', 'text', (col) => col.notNull()) 8 | .ifNotExists() 9 | .execute(); 10 | 11 | await sql`CREATE EXTENSION IF NOT EXISTS pg_trgm`.execute(db); 12 | await sql`CREATE INDEX author_search_search_idx ON author_search USING GIN (search gin_trgm_ops)`.execute(db); 13 | } 14 | 15 | export async function down(db: Kysely): Promise { 16 | await db.schema.dropIndex('author_search_search_idx').ifExists().execute(); 17 | await db.schema.dropTable('author_search').execute(); 18 | } 19 | -------------------------------------------------------------------------------- /packages/db/migrations/033_add_language.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.alterTable('nostr_events').addColumn('language', 'char(2)').execute(); 5 | 6 | await db.schema.createIndex('nostr_events_language_created_idx') 7 | .on('nostr_events') 8 | .columns(['language', 'created_at desc', 'id asc', 'kind']) 9 | .execute(); 10 | } 11 | 12 | export async function down(db: Kysely): Promise { 13 | await db.schema.alterTable('nostr_events').dropColumn('language').execute(); 14 | await db.schema.dropIndex('nostr_events_language_created_idx').execute(); 15 | } 16 | -------------------------------------------------------------------------------- /packages/db/migrations/034_move_author_search_to_author_stats.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | // deno-lint-ignore no-explicit-any 4 | export async function up(db: Kysely): Promise { 5 | await db.schema 6 | .alterTable('author_stats') 7 | .addColumn('search', 'text', (col) => col.notNull().defaultTo('')) 8 | .execute(); 9 | 10 | await sql`CREATE INDEX author_stats_search_idx ON author_stats USING GIN (search gin_trgm_ops)`.execute(db); 11 | 12 | await db.insertInto('author_stats') 13 | .columns(['pubkey', 'search']) 14 | .expression( 15 | db.selectFrom('author_search') 16 | .select(['pubkey', 'search']), 17 | ) 18 | .onConflict((oc) => 19 | oc.column('pubkey') 20 | .doUpdateSet((eb) => ({ 21 | search: eb.ref('excluded.search'), 22 | })) 23 | ) 24 | .execute(); 25 | 26 | await db.schema.dropIndex('author_search_search_idx').ifExists().execute(); 27 | await db.schema.dropTable('author_search').execute(); 28 | } 29 | 30 | export async function down(db: Kysely): Promise { 31 | await db.schema.dropIndex('author_stats_search_idx').ifExists().execute(); 32 | await db.schema.alterTable('author_stats').dropColumn('search').execute(); 33 | } 34 | -------------------------------------------------------------------------------- /packages/db/migrations/035_author_stats_followers_index.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createIndex('author_stats_followers_count_idx') 6 | .ifNotExists() 7 | .on('author_stats') 8 | .column('followers_count desc') 9 | .execute(); 10 | 11 | // This index should have never been added, because pubkey is the primary key. 12 | await db.schema.dropIndex('idx_author_stats_pubkey').ifExists().execute(); 13 | } 14 | 15 | export async function down(db: Kysely): Promise { 16 | await db.schema.dropIndex('author_stats_followers_count_idx').ifExists().execute(); 17 | } 18 | -------------------------------------------------------------------------------- /packages/db/migrations/036_stats64.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | // deno-lint-ignore no-explicit-any 4 | export async function up(db: Kysely): Promise { 5 | await db.deleteFrom('event_stats').where(sql`length(event_id)`, '>', 64).execute(); 6 | await db.deleteFrom('author_stats').where(sql`length(pubkey)`, '>', 64).execute(); 7 | 8 | await db.schema.alterTable('event_stats').alterColumn('event_id', (col) => col.setDataType('char(64)')).execute(); 9 | await db.schema.alterTable('author_stats').alterColumn('pubkey', (col) => col.setDataType('char(64)')).execute(); 10 | } 11 | 12 | export async function down(db: Kysely): Promise { 13 | await db.schema.alterTable('event_stats').alterColumn('event_id', (col) => col.setDataType('text')).execute(); 14 | await db.schema.alterTable('author_stats').alterColumn('pubkey', (col) => col.setDataType('text')).execute(); 15 | } 16 | -------------------------------------------------------------------------------- /packages/db/migrations/038_push_subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable('push_subscriptions') 6 | .addColumn('id', 'bigserial', (c) => c.primaryKey()) 7 | .addColumn('pubkey', 'char(64)', (c) => c.notNull()) 8 | .addColumn('token_hash', 'bytea', (c) => c.references('auth_tokens.token_hash').onDelete('cascade').notNull()) 9 | .addColumn('endpoint', 'text', (c) => c.notNull()) 10 | .addColumn('p256dh', 'text', (c) => c.notNull()) 11 | .addColumn('auth', 'text', (c) => c.notNull()) 12 | .addColumn('data', 'jsonb') 13 | .addColumn('created_at', 'timestamp', (c) => c.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)) 14 | .addColumn('updated_at', 'timestamp', (c) => c.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)) 15 | .execute(); 16 | 17 | await db.schema 18 | .createIndex('push_subscriptions_token_hash_idx') 19 | .on('push_subscriptions') 20 | .column('token_hash') 21 | .unique() 22 | .execute(); 23 | } 24 | 25 | export async function down(db: Kysely): Promise { 26 | await db.schema.dropTable('push_subscriptions').execute(); 27 | } 28 | -------------------------------------------------------------------------------- /packages/db/migrations/039_pg_notify.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await sql` 5 | CREATE OR REPLACE FUNCTION notify_nostr_event() 6 | RETURNS TRIGGER AS $$ 7 | DECLARE 8 | payload JSON; 9 | BEGIN 10 | payload := json_build_object( 11 | 'id', NEW.id, 12 | 'kind', NEW.kind, 13 | 'pubkey', NEW.pubkey, 14 | 'content', NEW.content, 15 | 'tags', NEW.tags, 16 | 'created_at', NEW.created_at, 17 | 'sig', NEW.sig 18 | ); 19 | 20 | PERFORM pg_notify('nostr_event', payload::text); 21 | 22 | RETURN NEW; 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | `.execute(db); 26 | 27 | await sql` 28 | CREATE TRIGGER nostr_event_trigger 29 | AFTER INSERT OR UPDATE ON nostr_events 30 | FOR EACH ROW EXECUTE FUNCTION notify_nostr_event() 31 | `.execute(db); 32 | } 33 | 34 | export async function down(db: Kysely): Promise { 35 | await sql`DROP TRIGGER nostr_event_trigger ON nostr_events`.execute(db); 36 | await sql`DROP FUNCTION notify_nostr_event()`.execute(db); 37 | } 38 | -------------------------------------------------------------------------------- /packages/db/migrations/040_add_bunker_pubkey.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | // deno-lint-ignore no-explicit-any 4 | export async function up(db: Kysely): Promise { 5 | await db.schema 6 | .alterTable('auth_tokens') 7 | .addColumn('bunker_pubkey', 'char(64)') 8 | .execute(); 9 | 10 | await db.updateTable('auth_tokens').set((eb) => ({ bunker_pubkey: eb.ref('pubkey') })).execute(); 11 | 12 | await db.schema 13 | .alterTable('auth_tokens') 14 | .alterColumn('bunker_pubkey', (col) => col.setNotNull()) 15 | .execute(); 16 | } 17 | 18 | export async function down(db: Kysely): Promise { 19 | await db.schema 20 | .alterTable('auth_tokens') 21 | .dropColumn('bunker_pubkey') 22 | .execute(); 23 | } 24 | -------------------------------------------------------------------------------- /packages/db/migrations/041_pg_notify_id_only.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await sql`DROP TRIGGER IF EXISTS nostr_event_trigger ON nostr_events`.execute(db); 5 | 6 | await sql` 7 | CREATE OR REPLACE FUNCTION notify_nostr_event() 8 | RETURNS TRIGGER AS $$ 9 | BEGIN 10 | PERFORM pg_notify('nostr_event', NEW.id::text); 11 | 12 | RETURN NEW; 13 | END; 14 | $$ LANGUAGE plpgsql; 15 | `.execute(db); 16 | 17 | await sql` 18 | CREATE TRIGGER nostr_event_trigger 19 | AFTER INSERT OR UPDATE ON nostr_events 20 | FOR EACH ROW EXECUTE FUNCTION notify_nostr_event() 21 | `.execute(db); 22 | } 23 | 24 | export async function down(db: Kysely): Promise { 25 | await sql`DROP TRIGGER nostr_event_trigger ON nostr_events`.execute(db); 26 | await sql`DROP FUNCTION notify_nostr_event()`.execute(db); 27 | } 28 | -------------------------------------------------------------------------------- /packages/db/migrations/042_add_search_ext.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .alterTable('nostr_events') 6 | .addColumn('search_ext', 'jsonb', (col) => col.notNull().defaultTo(sql`'{}'::jsonb`)) 7 | .execute(); 8 | 9 | await db.schema 10 | .alterTable('nostr_events') 11 | .addCheckConstraint('nostr_events_search_ext_chk', sql`jsonb_typeof(search_ext) = 'object'`) 12 | .execute(); 13 | 14 | await db.schema 15 | .createIndex('nostr_events_search_ext_idx').using('gin') 16 | .on('nostr_events') 17 | .column('search_ext') 18 | .ifNotExists() 19 | .execute(); 20 | } 21 | 22 | export async function down(db: Kysely): Promise { 23 | await db.schema 24 | .dropIndex('nostr_events_search_ext_idx') 25 | .on('nostr_events') 26 | .ifExists() 27 | .execute(); 28 | 29 | await db.schema 30 | .alterTable('nostr_events') 31 | .dropConstraint('nostr_events_search_ext_chk') 32 | .execute(); 33 | 34 | await db.schema 35 | .alterTable('nostr_events') 36 | .dropColumn('search_ext') 37 | .execute(); 38 | } 39 | -------------------------------------------------------------------------------- /packages/db/migrations/043_rm_language.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.alterTable('nostr_events').dropColumn('language').execute(); 5 | } 6 | 7 | export async function down(db: Kysely): Promise { 8 | await db.schema.alterTable('nostr_events').addColumn('language', 'char(2)').execute(); 9 | 10 | await db.schema.createIndex('nostr_events_language_created_idx') 11 | .on('nostr_events') 12 | .columns(['language', 'created_at desc', 'id asc', 'kind']) 13 | .execute(); 14 | } 15 | -------------------------------------------------------------------------------- /packages/db/migrations/044_search_ext_drop_default.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.alterTable('nostr_events').alterColumn('search_ext', (col) => col.dropDefault()).execute(); 5 | } 6 | 7 | export async function down(db: Kysely): Promise { 8 | await db.schema 9 | .alterTable('nostr_events') 10 | .alterColumn('search_ext', (col) => col.setDefault("'{}'::jsonb")) 11 | .execute(); 12 | } 13 | -------------------------------------------------------------------------------- /packages/db/migrations/045_streaks.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .alterTable('author_stats') 6 | .addColumn('streak_start', 'integer') 7 | .addColumn('streak_end', 'integer') 8 | .execute(); 9 | } 10 | 11 | export async function down(db: Kysely): Promise { 12 | await db.schema 13 | .alterTable('author_stats') 14 | .dropColumn('streak_start') 15 | .dropColumn('streak_end') 16 | .execute(); 17 | } 18 | -------------------------------------------------------------------------------- /packages/db/migrations/047_add_domain_favicons.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable('domain_favicons') 6 | .addColumn('domain', 'varchar(253)', (col) => col.primaryKey()) 7 | .addColumn('favicon', 'varchar(2048)', (col) => col.notNull()) 8 | .addColumn('last_updated_at', 'integer', (col) => col.notNull()) 9 | .addCheckConstraint('domain_favicons_https_chk', sql`favicon ~* '^https:\\/\\/'`) 10 | .execute(); 11 | } 12 | 13 | export async function down(db: Kysely): Promise { 14 | await db.schema.dropTable('domain_favicons').execute(); 15 | } 16 | -------------------------------------------------------------------------------- /packages/db/migrations/048_rm_pubkey_domains.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.dropTable('pubkey_domains').execute(); 5 | } 6 | 7 | export async function down(db: Kysely): Promise { 8 | await db.schema 9 | .createTable('pubkey_domains') 10 | .ifNotExists() 11 | .addColumn('pubkey', 'text', (col) => col.primaryKey()) 12 | .addColumn('domain', 'text', (col) => col.notNull()) 13 | .addColumn('last_updated_at', 'integer', (col) => col.notNull().defaultTo(0)) 14 | .execute(); 15 | 16 | await db.schema 17 | .createIndex('pubkey_domains_domain_index') 18 | .on('pubkey_domains') 19 | .column('domain') 20 | .ifNotExists() 21 | .execute(); 22 | } 23 | -------------------------------------------------------------------------------- /packages/db/migrations/049_author_stats_sorted.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | // deno-lint-ignore no-explicit-any 4 | export async function up(db: Kysely): Promise { 5 | await db.schema 6 | .createView('top_authors') 7 | .materialized() 8 | .as(db.selectFrom('author_stats').select(['pubkey', 'followers_count', 'search']).orderBy('followers_count desc')) 9 | .execute(); 10 | 11 | await sql`CREATE INDEX top_authors_search_idx ON top_authors USING GIN (search gin_trgm_ops)`.execute(db); 12 | 13 | await db.schema.createIndex('top_authors_pubkey_idx').on('top_authors').column('pubkey').execute(); 14 | 15 | await db.schema.dropIndex('author_stats_search_idx').execute(); 16 | } 17 | 18 | export async function down(db: Kysely): Promise { 19 | await db.schema.dropView('top_authors').execute(); 20 | await sql`CREATE INDEX author_stats_search_idx ON author_stats USING GIN (search gin_trgm_ops)`.execute(db); 21 | } 22 | -------------------------------------------------------------------------------- /packages/db/migrations/050_notify_only_insert.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await sql`DROP TRIGGER IF EXISTS nostr_event_trigger ON nostr_events`.execute(db); 5 | 6 | await sql` 7 | CREATE TRIGGER nostr_event_trigger 8 | AFTER INSERT ON nostr_events 9 | FOR EACH ROW EXECUTE FUNCTION notify_nostr_event() 10 | `.execute(db); 11 | } 12 | 13 | export async function down(db: Kysely): Promise { 14 | await sql`DROP TRIGGER IF EXISTS nostr_event_trigger ON nostr_events`.execute(db); 15 | 16 | await sql` 17 | CREATE TRIGGER nostr_event_trigger 18 | AFTER INSERT OR UPDATE ON nostr_events 19 | FOR EACH ROW EXECUTE FUNCTION notify_nostr_event() 20 | `.execute(db); 21 | } 22 | -------------------------------------------------------------------------------- /packages/db/migrations/051_notify_replaceable.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await sql` 5 | CREATE OR REPLACE FUNCTION notify_nostr_event() 6 | RETURNS TRIGGER AS $$ 7 | BEGIN 8 | IF OLD.id IS DISTINCT FROM NEW.id THEN 9 | PERFORM pg_notify('nostr_event', NEW.id::text); 10 | END IF; 11 | 12 | RETURN NEW; 13 | END; 14 | $$ LANGUAGE plpgsql; 15 | `.execute(db); 16 | 17 | await sql`DROP TRIGGER IF EXISTS nostr_event_trigger ON nostr_events`.execute(db); 18 | 19 | await sql` 20 | CREATE TRIGGER nostr_event_trigger 21 | AFTER INSERT OR UPDATE ON nostr_events 22 | FOR EACH ROW EXECUTE FUNCTION notify_nostr_event() 23 | `.execute(db); 24 | } 25 | 26 | export async function down(db: Kysely): Promise { 27 | await sql` 28 | CREATE OR REPLACE FUNCTION notify_nostr_event() 29 | RETURNS TRIGGER AS $$ 30 | BEGIN 31 | PERFORM pg_notify('nostr_event', NEW.id::text); 32 | 33 | RETURN NEW; 34 | END; 35 | $$ LANGUAGE plpgsql; 36 | `.execute(db); 37 | 38 | await sql`DROP TRIGGER IF EXISTS nostr_event_trigger ON nostr_events`.execute(db); 39 | 40 | await sql` 41 | CREATE TRIGGER nostr_event_trigger 42 | AFTER INSERT ON nostr_events 43 | FOR EACH ROW EXECUTE FUNCTION notify_nostr_event() 44 | `.execute(db); 45 | } 46 | -------------------------------------------------------------------------------- /packages/db/migrations/052_rename_pkey.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely, sql } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | const result = await sql<{ count: number }>` 5 | SELECT COUNT(*) as count 6 | FROM pg_indexes 7 | WHERE indexname = 'nostr_events_new_pkey' 8 | `.execute(db); 9 | 10 | if (result.rows[0].count > 0) { 11 | await sql`ALTER INDEX nostr_events_new_pkey RENAME TO nostr_events_pkey;`.execute(db); 12 | } 13 | } 14 | 15 | export async function down(_db: Kysely): Promise { 16 | } 17 | -------------------------------------------------------------------------------- /packages/db/migrations/053_link_preview.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.alterTable('event_stats').addColumn('link_preview', 'jsonb').execute(); 5 | } 6 | 7 | export async function down(db: Kysely): Promise { 8 | await db.schema.alterTable('event_stats').dropColumn('link_preview').execute(); 9 | } 10 | -------------------------------------------------------------------------------- /packages/db/migrations/054_event_stats_add_zap_cashu_count.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .alterTable('event_stats') 6 | .addColumn('zaps_amount_cashu', 'integer', (col) => col.notNull().defaultTo(0)) 7 | .execute(); 8 | } 9 | 10 | export async function down(db: Kysely): Promise { 11 | await db.schema.alterTable('event_stats').dropColumn('zaps_amount_cashu').execute(); 12 | } 13 | -------------------------------------------------------------------------------- /packages/db/mod.ts: -------------------------------------------------------------------------------- 1 | export { DittoPglite } from './adapters/DittoPglite.ts'; 2 | export { DittoPolyPg } from './adapters/DittoPolyPg.ts'; 3 | export { DittoPostgres } from './adapters/DittoPostgres.ts'; 4 | export { DummyDB } from './adapters/DummyDB.ts'; 5 | export { TestDB } from './adapters/TestDB.ts'; 6 | 7 | export type { DittoDB } from './DittoDB.ts'; 8 | export type { DittoTables } from './DittoTables.ts'; 9 | -------------------------------------------------------------------------------- /packages/db/utils/worker.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { isWorker } from './worker.ts'; 4 | 5 | Deno.test('isWorker from the main thread returns false', () => { 6 | assertEquals(isWorker(), false); 7 | }); 8 | 9 | Deno.test('isWorker from a worker thread returns true', async () => { 10 | const url = new URL('./worker.ts', import.meta.url); 11 | 12 | const script = ` 13 | import { isWorker } from '${url.href}'; 14 | postMessage(isWorker()); 15 | self.close(); 16 | `; 17 | 18 | const worker = new Worker( 19 | URL.createObjectURL(new Blob([script], { type: 'application/javascript' })), 20 | { type: 'module' }, 21 | ); 22 | 23 | const result = await new Promise((resolve) => { 24 | worker.onmessage = (e) => { 25 | resolve(e.data); 26 | }; 27 | }); 28 | 29 | worker.terminate(); 30 | 31 | assertEquals(result, true); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/db/utils/worker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Detect if this thread is running in a Worker context. 3 | * 4 | * https://stackoverflow.com/a/18002694 5 | */ 6 | export function isWorker(): boolean { 7 | // @ts-ignore This is fine. 8 | return typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; 9 | } 10 | -------------------------------------------------------------------------------- /packages/ditto/DittoPush.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { ApplicationServer, PushMessageOptions, PushSubscriber, PushSubscription } from '@negrel/webpush'; 3 | import { NStore } from '@nostrify/types'; 4 | import { logi } from '@soapbox/logi'; 5 | 6 | import { getInstanceMetadata } from '@/utils/instance.ts'; 7 | 8 | interface DittoPushOpts { 9 | conf: DittoConf; 10 | relay: NStore; 11 | } 12 | 13 | export class DittoPush { 14 | private server: Promise; 15 | 16 | constructor(opts: DittoPushOpts) { 17 | const { conf } = opts; 18 | 19 | this.server = (async () => { 20 | const meta = await getInstanceMetadata(opts); 21 | const keys = await conf.vapidKeys; 22 | 23 | if (keys) { 24 | return await ApplicationServer.new({ 25 | contactInformation: `mailto:${meta.email}`, 26 | vapidKeys: keys, 27 | }); 28 | } else { 29 | logi({ 30 | level: 'warn', 31 | ns: 'ditto.push', 32 | msg: 'VAPID keys are not set. Push notifications will be disabled.', 33 | }); 34 | } 35 | })(); 36 | } 37 | 38 | async push( 39 | subscription: PushSubscription, 40 | json: object, 41 | opts: PushMessageOptions = {}, 42 | ): Promise { 43 | const server = await this.server; 44 | 45 | if (!server) { 46 | return; 47 | } 48 | 49 | const subscriber = new PushSubscriber(server, subscription); 50 | const text = JSON.stringify(json); 51 | return subscriber.pushTextMessage(text, opts); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/ditto/DittoUploads.ts: -------------------------------------------------------------------------------- 1 | import { LRUCache } from 'lru-cache'; 2 | 3 | import { Time } from '@/utils/time.ts'; 4 | 5 | export interface DittoUpload { 6 | id: string; 7 | pubkey: string; 8 | url: string; 9 | tags: string[][]; 10 | uploadedAt: Date; 11 | } 12 | 13 | export const dittoUploads = new LRUCache({ 14 | max: 1000, 15 | ttl: Time.hours(6), 16 | }); 17 | -------------------------------------------------------------------------------- /packages/ditto/RelayError.test.ts: -------------------------------------------------------------------------------- 1 | import { assertThrows } from '@std/assert'; 2 | 3 | import { RelayError } from '@/RelayError.ts'; 4 | 5 | Deno.test('Construct a RelayError from the reason message', () => { 6 | assertThrows( 7 | () => { 8 | throw RelayError.fromReason('duplicate: already exists'); 9 | }, 10 | RelayError, 11 | 'duplicate: already exists', 12 | ); 13 | }); 14 | 15 | Deno.test('Throw a new RelayError if the OK message is false', () => { 16 | assertThrows( 17 | () => { 18 | RelayError.assert(['OK', 'yolo', false, 'error: bla bla bla']); 19 | }, 20 | RelayError, 21 | 'error: bla bla bla', 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/ditto/RelayError.ts: -------------------------------------------------------------------------------- 1 | import { NostrRelayOK } from '@nostrify/nostrify'; 2 | 3 | export type RelayErrorPrefix = 'duplicate' | 'pow' | 'blocked' | 'rate-limited' | 'invalid' | 'error'; 4 | 5 | /** NIP-01 command line result. */ 6 | export class RelayError extends Error { 7 | constructor(prefix: RelayErrorPrefix, message: string) { 8 | super(`${prefix}: ${message}`); 9 | } 10 | 11 | /** Construct a RelayError from the reason message. */ 12 | static fromReason(reason: string): RelayError { 13 | const [prefix, ...rest] = reason.split(': '); 14 | return new RelayError(prefix as RelayErrorPrefix, rest.join(': ')); 15 | } 16 | 17 | /** Throw a new RelayError if the OK message is false. */ 18 | static assert(msg: NostrRelayOK): void { 19 | const [, , ok, reason] = msg; 20 | if (!ok) { 21 | throw RelayError.fromReason(reason); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/ditto/caches/translationCache.ts: -------------------------------------------------------------------------------- 1 | import { MastodonTranslation } from '@ditto/mastoapi/types'; 2 | import { LanguageCode } from 'iso-639-1'; 3 | import { LRUCache } from 'lru-cache'; 4 | 5 | import { Conf } from '@/config.ts'; 6 | 7 | /** Translations LRU cache. */ 8 | export const translationCache = new LRUCache<`${LanguageCode}-${string}`, MastodonTranslation>({ 9 | max: Conf.caches.translation.max, 10 | ttl: Conf.caches.translation.ttl, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/ditto/config.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | 3 | /** @deprecated Use middleware to set/get the config instead. */ 4 | export const Conf = new DittoConf(Deno.env); 5 | -------------------------------------------------------------------------------- /packages/ditto/controllers/api/apps.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import type { AppController } from '@/app.ts'; 4 | import { parseBody } from '@/utils/api.ts'; 5 | 6 | /** 7 | * Apps are unnecessary cruft in Mastodon API, but necessary to make clients work. 8 | * So when clients try to "create" an app, pretend they did and return a hardcoded app. 9 | */ 10 | const FAKE_APP = { 11 | id: '1', 12 | name: 'Ditto', 13 | website: null, 14 | redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', 15 | client_id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', // he cry 16 | client_secret: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', // 😱 😱 😱 17 | vapid_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 18 | }; 19 | 20 | const createAppSchema = z.object({ 21 | redirect_uris: z.string().url().optional(), 22 | }); 23 | 24 | const createAppController: AppController = async (c) => { 25 | // TODO: Handle both formData and json. 422 on parsing error. 26 | try { 27 | const { redirect_uris } = createAppSchema.parse(await parseBody(c.req.raw)); 28 | 29 | return c.json({ 30 | ...FAKE_APP, 31 | redirect_uri: redirect_uris || FAKE_APP.redirect_uri, 32 | }); 33 | } catch (_e) { 34 | return c.json(FAKE_APP); 35 | } 36 | }; 37 | 38 | const appCredentialsController: AppController = (c) => { 39 | return c.json(FAKE_APP); 40 | }; 41 | 42 | export { appCredentialsController, createAppController }; 43 | -------------------------------------------------------------------------------- /packages/ditto/controllers/api/blocks.ts: -------------------------------------------------------------------------------- 1 | import { AppController } from '@/app.ts'; 2 | 3 | /** https://docs.joinmastodon.org/methods/blocks/#get */ 4 | export const blocksController: AppController = (c) => { 5 | return c.json({ error: 'Blocking is not supported by Nostr' }, 422); 6 | }; 7 | -------------------------------------------------------------------------------- /packages/ditto/controllers/api/bookmarks.ts: -------------------------------------------------------------------------------- 1 | import { type AppController } from '@/app.ts'; 2 | import { getTagSet } from '@/utils/tags.ts'; 3 | import { renderStatuses } from '@/views.ts'; 4 | 5 | /** https://docs.joinmastodon.org/methods/bookmarks/#get */ 6 | const bookmarksController: AppController = async (c) => { 7 | const { relay, user, signal } = c.var; 8 | 9 | const pubkey = await user!.signer.getPublicKey(); 10 | 11 | const [event10003] = await relay.query( 12 | [{ kinds: [10003], authors: [pubkey], limit: 1 }], 13 | { signal }, 14 | ); 15 | 16 | if (event10003) { 17 | const eventIds = getTagSet(event10003.tags, 'e'); 18 | return renderStatuses(c, [...eventIds].reverse()); 19 | } else { 20 | return c.json([]); 21 | } 22 | }; 23 | 24 | export { bookmarksController }; 25 | -------------------------------------------------------------------------------- /packages/ditto/controllers/api/fallback.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from '@hono/hono'; 2 | 3 | const emptyArrayController: Handler = (c) => { 4 | c.header('Cache-Control', 'max-age=300, public, stale-while-revalidate=60'); 5 | return c.json([]); 6 | }; 7 | 8 | const notImplementedController: Handler = (c) => { 9 | c.header('Cache-Control', 'max-age=300, public, stale-while-revalidate=60'); 10 | return c.json({ error: 'Not implemented' }, 404); 11 | }; 12 | 13 | export { emptyArrayController, notImplementedController }; 14 | -------------------------------------------------------------------------------- /packages/ditto/controllers/api/mutes.ts: -------------------------------------------------------------------------------- 1 | import { type AppController } from '@/app.ts'; 2 | import { getTagSet } from '@/utils/tags.ts'; 3 | import { renderAccounts } from '@/views.ts'; 4 | 5 | /** https://docs.joinmastodon.org/methods/mutes/#get */ 6 | const mutesController: AppController = async (c) => { 7 | const { relay, user, signal } = c.var; 8 | 9 | const pubkey = await user!.signer.getPublicKey(); 10 | 11 | const [event10000] = await relay.query( 12 | [{ kinds: [10000], authors: [pubkey], limit: 1 }], 13 | { signal }, 14 | ); 15 | 16 | if (event10000) { 17 | const pubkeys = getTagSet(event10000.tags, 'p'); 18 | return renderAccounts(c, [...pubkeys]); 19 | } else { 20 | return c.json([]); 21 | } 22 | }; 23 | 24 | export { mutesController }; 25 | -------------------------------------------------------------------------------- /packages/ditto/controllers/api/preferences.ts: -------------------------------------------------------------------------------- 1 | import { AppController } from '@/app.ts'; 2 | 3 | /** 4 | * Return a default set of preferences for compatibilty purposes. 5 | * Clients like Soapbox do not use this. 6 | * 7 | * https://docs.joinmastodon.org/methods/preferences/ 8 | */ 9 | const preferencesController: AppController = (c) => { 10 | return c.json({ 11 | 'posting:default:visibility': 'public', 12 | 'posting:default:sensitive': false, 13 | 'posting:default:language': null, 14 | 'reading:expand:media': 'default', 15 | 'reading:expand:spoilers': false, 16 | }); 17 | }; 18 | 19 | export { preferencesController }; 20 | -------------------------------------------------------------------------------- /packages/ditto/controllers/error.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler } from '@hono/hono'; 2 | import { HTTPException } from '@hono/hono/http-exception'; 3 | import { logi } from '@soapbox/logi'; 4 | 5 | import { errorJson } from '@/utils/log.ts'; 6 | 7 | import type { DittoEnv } from '@ditto/mastoapi/router'; 8 | 9 | export const errorHandler: ErrorHandler = (err, c) => { 10 | const { requestId } = c.var; 11 | const { method } = c.req; 12 | const { pathname } = new URL(c.req.url); 13 | 14 | c.header('Cache-Control', 'no-store'); 15 | 16 | if (err instanceof HTTPException) { 17 | if (err.res) { 18 | return err.res; 19 | } else { 20 | return c.json({ error: err.message }, err.status); 21 | } 22 | } 23 | 24 | if (err.message === 'canceling statement due to statement timeout') { 25 | return c.json({ error: 'The server was unable to respond in a timely manner' }, 500); 26 | } 27 | 28 | logi({ 29 | level: 'error', 30 | ns: 'ditto.http', 31 | msg: 'Unhandled error', 32 | method, 33 | pathname, 34 | requestId, 35 | error: errorJson(err), 36 | }); 37 | 38 | return c.json({ error: 'Something went wrong' }, 500); 39 | }; 40 | -------------------------------------------------------------------------------- /packages/ditto/controllers/manifest.ts: -------------------------------------------------------------------------------- 1 | import { AppController } from '@/app.ts'; 2 | import { WebManifestCombined } from '@/types/webmanifest.ts'; 3 | import { getInstanceMetadata } from '@/utils/instance.ts'; 4 | 5 | export const manifestController: AppController = async (c) => { 6 | const meta = await getInstanceMetadata(c.var); 7 | 8 | const manifest: WebManifestCombined = { 9 | description: meta.about, 10 | display: 'standalone', 11 | icons: [{ 12 | src: meta.picture, 13 | sizes: '192x192', 14 | }, { 15 | src: meta.picture, 16 | sizes: '512x512', 17 | }], 18 | name: meta.name, 19 | scope: '/', 20 | short_name: meta.name, 21 | start_url: '/', 22 | screenshots: meta.screenshots, 23 | }; 24 | 25 | return c.json(manifest, { 26 | headers: { 27 | 'Content-Type': 'application/manifest+json', 28 | }, 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/ditto/controllers/metrics.ts: -------------------------------------------------------------------------------- 1 | import { dbAvailableConnectionsGauge, dbPoolSizeGauge } from '@ditto/metrics'; 2 | import { register } from 'prom-client'; 3 | 4 | import { AppController } from '@/app.ts'; 5 | 6 | /** Prometheus/OpenMetrics controller. */ 7 | export const metricsController: AppController = async (c) => { 8 | const { db } = c.var; 9 | 10 | // Update some metrics at request time. 11 | dbPoolSizeGauge.set(db.poolSize); 12 | dbAvailableConnectionsGauge.set(db.availableConnections); 13 | 14 | // Serve the metrics. 15 | const metrics = await register.metrics(); 16 | 17 | const headers: HeadersInit = { 18 | 'Content-Type': register.contentType, 19 | }; 20 | 21 | return c.text(metrics, 200, headers); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/ditto/controllers/nostr/relay-info.ts: -------------------------------------------------------------------------------- 1 | import denoJson from 'deno.json' with { type: 'json' }; 2 | 3 | import { AppController } from '@/app.ts'; 4 | import { getInstanceMetadata } from '@/utils/instance.ts'; 5 | 6 | const relayInfoController: AppController = async (c) => { 7 | const { conf } = c.var; 8 | 9 | const meta = await getInstanceMetadata(c.var); 10 | 11 | c.res.headers.set('access-control-allow-origin', '*'); 12 | c.res.headers.set('access-control-allow-headers', '*'); 13 | c.res.headers.set('access-control-allow-methods', 'GET, POST, OPTIONS'); 14 | 15 | return c.json({ 16 | name: meta.name, 17 | description: meta.about, 18 | pubkey: await conf.signer.getPublicKey(), 19 | contact: meta.email, 20 | supported_nips: [1, 5, 9, 11, 16, 45, 50, 46, 98], 21 | software: 'https://gitlab.com/soapbox-pub/ditto', 22 | version: denoJson.version, 23 | limitation: { 24 | auth_required: false, 25 | created_at_lower_limit: 0, 26 | created_at_upper_limit: 2_147_483_647, 27 | max_limit: 100, 28 | payment_required: false, 29 | restricted_writes: false, 30 | }, 31 | }); 32 | }; 33 | 34 | export { relayInfoController }; 35 | -------------------------------------------------------------------------------- /packages/ditto/controllers/well-known/nodeinfo.ts: -------------------------------------------------------------------------------- 1 | import type { AppController } from '@/app.ts'; 2 | 3 | const nodeInfoController: AppController = (c) => { 4 | const { conf } = c.var; 5 | 6 | return c.json({ 7 | links: [ 8 | { 9 | rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', 10 | href: conf.local('/nodeinfo/2.0'), 11 | }, 12 | { 13 | rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', 14 | href: conf.local('/nodeinfo/2.1'), 15 | }, 16 | ], 17 | }); 18 | }; 19 | 20 | const nodeInfoSchemaController: AppController = (c) => { 21 | return c.json({ 22 | version: '2.1', 23 | software: { 24 | name: 'ditto', 25 | version: '0.0.0', 26 | repository: 'https://gitlab.com/soapbox-pub/ditto', 27 | homepage: 'https://soapbox.pub', 28 | }, 29 | protocols: [ 30 | 'activitypub', 31 | ], 32 | services: { 33 | inbound: [], 34 | outbound: [], 35 | }, 36 | openRegistrations: true, 37 | usage: { 38 | users: { 39 | total: 0, 40 | activeMonth: 0, 41 | activeHalfyear: 0, 42 | }, 43 | localPosts: 0, 44 | localComments: 0, 45 | }, 46 | metadata: { 47 | features: [ 48 | 'nip05', 49 | 'nostr_bridge', 50 | ], 51 | }, 52 | }); 53 | }; 54 | 55 | export { nodeInfoController, nodeInfoSchemaController }; 56 | -------------------------------------------------------------------------------- /packages/ditto/controllers/well-known/nostr.ts: -------------------------------------------------------------------------------- 1 | import { NostrJson } from '@nostrify/nostrify'; 2 | import { z } from 'zod'; 3 | 4 | import { AppController } from '@/app.ts'; 5 | import { localNip05Lookup } from '@/utils/nip05.ts'; 6 | 7 | const nameSchema = z.string().min(1).regex(/^[\w.-]+$/); 8 | 9 | /** 10 | * Serves NIP-05's nostr.json. 11 | * https://github.com/nostr-protocol/nips/blob/master/05.md 12 | */ 13 | const nostrController: AppController = async (c) => { 14 | const result = nameSchema.safeParse(c.req.query('name')); 15 | 16 | if (!result.success) { 17 | return c.json({ error: 'Invalid name parameter' }, { status: 422 }); 18 | } 19 | 20 | const name = result.data; 21 | const pointer = name ? await localNip05Lookup(name, c.var) : undefined; 22 | 23 | if (!pointer) { 24 | return c.json({ names: {}, relays: {} } satisfies NostrJson, { status: 404 }); 25 | } 26 | 27 | const { pubkey, relays = [] } = pointer; 28 | 29 | // It's found, so cache for 6 hours. 30 | c.header('Cache-Control', 'max-age=21600, public, stale-while-revalidate=3600'); 31 | 32 | return c.json( 33 | { 34 | names: { 35 | [name]: pubkey, 36 | }, 37 | relays: { 38 | [pubkey]: relays, 39 | }, 40 | } satisfies NostrJson, 41 | ); 42 | }; 43 | 44 | export { nostrController }; 45 | -------------------------------------------------------------------------------- /packages/ditto/cron.ts: -------------------------------------------------------------------------------- 1 | import { sql } from 'kysely'; 2 | 3 | import { 4 | type TrendsCtx, 5 | updateTrendingEvents, 6 | updateTrendingHashtags, 7 | updateTrendingLinks, 8 | updateTrendingPubkeys, 9 | updateTrendingZappedEvents, 10 | } from '@/trends.ts'; 11 | 12 | /** Start cron jobs for the application. */ 13 | export function cron(ctx: TrendsCtx) { 14 | Deno.cron('update trending pubkeys', '0 * * * *', () => updateTrendingPubkeys(ctx)); 15 | Deno.cron('update trending zapped events', '7 * * * *', () => updateTrendingZappedEvents(ctx)); 16 | Deno.cron('update trending events', '15 * * * *', () => updateTrendingEvents(ctx)); 17 | Deno.cron('update trending hashtags', '30 * * * *', () => updateTrendingHashtags(ctx)); 18 | Deno.cron('update trending links', '45 * * * *', () => updateTrendingLinks(ctx)); 19 | 20 | Deno.cron('refresh top authors', '20 * * * *', async () => { 21 | const { kysely } = ctx.db; 22 | await sql`refresh materialized view top_authors`.execute(kysely); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /packages/ditto/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/ditto", 3 | "version": "1.1.0", 4 | "exports": {}, 5 | "imports": { 6 | "@/": "./", 7 | "deno.json": "../../deno.json" 8 | }, 9 | "lint": { 10 | "rules": { 11 | "exclude": ["verbatim-module-syntax"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/ditto/firehose.ts: -------------------------------------------------------------------------------- 1 | import { firehoseEventsCounter } from '@ditto/metrics'; 2 | import { Semaphore } from '@core/asyncutil'; 3 | import { NRelay, NStore } from '@nostrify/nostrify'; 4 | import { logi } from '@soapbox/logi'; 5 | 6 | import { nostrNow } from '@/utils.ts'; 7 | 8 | interface FirehoseOpts { 9 | pool: NRelay; 10 | relay: NStore; 11 | concurrency: number; 12 | kinds: number[]; 13 | timeout?: number; 14 | } 15 | 16 | /** 17 | * This function watches events on all known relays and performs 18 | * side-effects based on them, such as trending hashtag tracking 19 | * and storing events for notifications and the home feed. 20 | */ 21 | export async function startFirehose(opts: FirehoseOpts): Promise { 22 | const { pool, relay, kinds, concurrency, timeout = 5000 } = opts; 23 | 24 | const sem = new Semaphore(concurrency); 25 | 26 | for await (const msg of pool.req([{ kinds, limit: 0, since: nostrNow() }])) { 27 | if (msg[0] === 'EVENT') { 28 | const event = msg[2]; 29 | 30 | logi({ level: 'debug', ns: 'ditto.event', source: 'firehose', id: event.id, kind: event.kind }); 31 | firehoseEventsCounter.inc({ kind: event.kind }); 32 | 33 | sem.lock(async () => { 34 | try { 35 | await relay.event(event, { signal: AbortSignal.timeout(timeout) }); 36 | } catch { 37 | // Ignore 38 | } 39 | }); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/ditto/interfaces/DittoPagination.ts: -------------------------------------------------------------------------------- 1 | /** Based on Mastodon pagination. */ 2 | export interface DittoPagination { 3 | /** Lowest Nostr event `created_at` timestamp. */ 4 | since?: number; 5 | /** Highest Nostr event `created_at` timestamp. */ 6 | until?: number; 7 | /** @deprecated Mastodon apps are supposed to use the `Link` header. */ 8 | max_id?: string; 9 | /** @deprecated Mastodon apps are supposed to use the `Link` header. */ 10 | min_id?: string; 11 | /** Maximum number of results to return. Default 20, maximum 40. */ 12 | limit?: number; 13 | /** Used by Ditto to offset tag values in Nostr list events. */ 14 | offset?: number; 15 | } 16 | -------------------------------------------------------------------------------- /packages/ditto/middleware/cacheControlMiddleware.test.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from '@hono/hono'; 2 | import { assertEquals } from '@std/assert'; 3 | 4 | import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; 5 | 6 | Deno.test('cacheControlMiddleware with multiple options', async () => { 7 | const app = new Hono(); 8 | 9 | app.use(cacheControlMiddleware({ 10 | maxAge: 31536000, 11 | public: true, 12 | immutable: true, 13 | })); 14 | 15 | app.get('/', (c) => c.text('OK')); 16 | 17 | const response = await app.request('/'); 18 | const cacheControl = response.headers.get('Cache-Control'); 19 | 20 | assertEquals(cacheControl, 'max-age=31536000, public, immutable'); 21 | }); 22 | 23 | Deno.test('cacheControlMiddleware with no options does not add header', async () => { 24 | const app = new Hono(); 25 | 26 | app.use(cacheControlMiddleware({})); 27 | app.get('/', (c) => c.text('OK')); 28 | 29 | const response = await app.request('/'); 30 | const cacheControl = response.headers.get('Cache-Control'); 31 | 32 | assertEquals(cacheControl, null); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/ditto/middleware/logiMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { logi } from '@soapbox/logi'; 2 | 3 | import type { DittoMiddleware } from '@ditto/mastoapi/router'; 4 | 5 | export const logiMiddleware: DittoMiddleware = async (c, next) => { 6 | const { requestId } = c.var; 7 | const { method } = c.req; 8 | const { pathname } = new URL(c.req.url); 9 | 10 | const ip = c.req.header('x-real-ip'); 11 | 12 | logi({ level: 'info', ns: 'ditto.http.request', method, pathname, ip, requestId }); 13 | 14 | const start = new Date(); 15 | 16 | await next(); 17 | 18 | const end = new Date(); 19 | const duration = (end.getTime() - start.getTime()) / 1000; 20 | const level = c.res.status >= 500 ? 'error' : 'info'; 21 | 22 | logi({ level, ns: 'ditto.http.response', method, pathname, status: c.res.status, duration, ip, requestId }); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/ditto/middleware/metricsMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { httpRequestsCounter, httpResponseDurationHistogram, httpResponsesCounter } from '@ditto/metrics'; 2 | import { ScopedPerformance } from '@esroyo/scoped-performance'; 3 | import { MiddlewareHandler } from '@hono/hono'; 4 | 5 | /** Prometheus metrics middleware that tracks HTTP requests by methods and responses by status code. */ 6 | export const metricsMiddleware: MiddlewareHandler = async (c, next) => { 7 | // Start a timer to measure the duration of the response. 8 | using perf = new ScopedPerformance(); 9 | perf.mark('start'); 10 | 11 | // HTTP Request. 12 | const { method } = c.req; 13 | httpRequestsCounter.inc({ method }); 14 | 15 | // Wait for other handlers to run. 16 | await next(); 17 | 18 | // HTTP Response. 19 | const { status } = c.res; 20 | // Get a parameterized path name like `/posts/:id` instead of `/posts/1234`. 21 | // Tries to find actual route names first before falling back on potential middleware handlers like `app.use('*')`. 22 | const path = c.req.matchedRoutes.find((r) => r.method !== 'ALL')?.path ?? c.req.routePath; 23 | httpResponsesCounter.inc({ method, status, path }); 24 | 25 | // Measure the duration of the response. 26 | const { duration } = perf.measure('total', 'start'); 27 | httpResponseDurationHistogram.observe({ method, status, path }, duration / 1000); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/ditto/middleware/notActivitypubMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareHandler } from '@hono/hono'; 2 | 3 | const ACTIVITYPUB_TYPES = [ 4 | 'application/activity+json', 5 | 'application/ld+json', 6 | 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 7 | ]; 8 | 9 | /** Return 4xx errors on common (unsupported) ActivityPub routes to prevent AP traffic. */ 10 | export const notActivitypubMiddleware: MiddlewareHandler = async (c, next) => { 11 | const accept = c.req.header('accept'); 12 | const types = accept?.split(',')?.map((type) => type.trim()) ?? []; 13 | 14 | if (types.every((type) => ACTIVITYPUB_TYPES.includes(type))) { 15 | return c.text('ActivityPub is not supported', 406); 16 | } 17 | 18 | await next(); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/ditto/middleware/rateLimitMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { type DittoConf } from '@ditto/conf'; 2 | import { MiddlewareHandler } from '@hono/hono'; 3 | import { rateLimiter } from 'hono-rate-limiter'; 4 | 5 | /** 6 | * Rate limit middleware for Hono, based on [`hono-rate-limiter`](https://github.com/rhinobase/hono-rate-limiter). 7 | */ 8 | export function rateLimitMiddleware(limit: number, windowMs: number, includeHeaders?: boolean): MiddlewareHandler { 9 | // @ts-ignore Mismatched hono versions. 10 | return rateLimiter<{ Variables: { conf: DittoConf } }>({ 11 | limit, 12 | windowMs, 13 | standardHeaders: includeHeaders, 14 | handler: (c) => { 15 | c.header('Cache-Control', 'no-store'); 16 | return c.text('Too many requests, please try again later.', 429); 17 | }, 18 | skip: (c) => { 19 | const { conf } = c.var; 20 | const ip = c.req.header('x-real-ip'); 21 | return !ip || conf.ipWhitelist.includes(ip); 22 | }, 23 | keyGenerator: (c) => c.req.header('x-real-ip')!, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /packages/ditto/middleware/translatorMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { DeepLTranslator, LibreTranslateTranslator } from '@ditto/translators'; 2 | import { safeFetch } from '@soapbox/safe-fetch'; 3 | 4 | import { AppMiddleware } from '@/app.ts'; 5 | 6 | /** Set the translator used for translating posts. */ 7 | export const translatorMiddleware: AppMiddleware = async (c, next) => { 8 | const { conf } = c.var; 9 | 10 | switch (conf.translationProvider) { 11 | case 'deepl': { 12 | const { deeplApiKey: apiKey, deeplBaseUrl: baseUrl } = conf; 13 | if (apiKey) { 14 | c.set('translator', new DeepLTranslator({ baseUrl, apiKey, fetch: safeFetch })); 15 | } 16 | break; 17 | } 18 | 19 | case 'libretranslate': { 20 | const { libretranslateApiKey: apiKey, libretranslateBaseUrl: baseUrl } = conf; 21 | if (apiKey) { 22 | c.set('translator', new LibreTranslateTranslator({ baseUrl, apiKey, fetch: safeFetch })); 23 | } 24 | break; 25 | } 26 | } 27 | 28 | await next(); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/ditto/nostr-wasm.ts: -------------------------------------------------------------------------------- 1 | import { setNostrWasm } from 'nostr-tools/wasm'; 2 | import { initNostrWasm } from 'nostr-wasm'; 3 | 4 | await initNostrWasm().then(setNostrWasm); 5 | -------------------------------------------------------------------------------- /packages/ditto/routes/customEmojisRoute.ts: -------------------------------------------------------------------------------- 1 | import { userMiddleware } from '@ditto/mastoapi/middleware'; 2 | import { DittoRoute } from '@ditto/mastoapi/router'; 3 | 4 | import { getCustomEmojis } from '@/utils/custom-emoji.ts'; 5 | 6 | const route = new DittoRoute(); 7 | 8 | interface MastodonCustomEmoji { 9 | shortcode: string; 10 | url: string; 11 | static_url: string; 12 | visible_in_picker: boolean; 13 | category?: string; 14 | } 15 | 16 | route.get('/', userMiddleware({ required: false }), async (c) => { 17 | const { user } = c.var; 18 | 19 | if (!user) { 20 | return c.json([]); 21 | } 22 | 23 | const pubkey = await user.signer.getPublicKey(); 24 | const emojis = await getCustomEmojis(pubkey, c.var); 25 | 26 | return c.json([...emojis.entries()].map(([shortcode, data]): MastodonCustomEmoji => { 27 | return { 28 | shortcode, 29 | url: data.url.toString(), 30 | static_url: data.url.toString(), 31 | visible_in_picker: true, 32 | category: data.category, 33 | }; 34 | })); 35 | }); 36 | 37 | export default route; 38 | -------------------------------------------------------------------------------- /packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts: -------------------------------------------------------------------------------- 1 | import { userMiddleware } from '@ditto/mastoapi/middleware'; 2 | import { DittoRoute } from '@ditto/mastoapi/router'; 3 | import { z } from 'zod'; 4 | 5 | import { parseBody, updateUser } from '@/utils/api.ts'; 6 | import { lookupPubkey } from '@/utils/lookup.ts'; 7 | 8 | const route = new DittoRoute(); 9 | 10 | const pleromaPromoteAdminSchema = z.object({ 11 | nicknames: z.string().array(), 12 | }); 13 | 14 | route.post('/:group', userMiddleware({ role: 'admin' }), async (c) => { 15 | const body = await parseBody(c.req.raw); 16 | const result = pleromaPromoteAdminSchema.safeParse(body); 17 | const group = c.req.param('group'); 18 | 19 | if (!result.success) { 20 | return c.json({ error: 'Bad request', schema: result.error }, 422); 21 | } 22 | 23 | if (!['admin', 'moderator'].includes(group)) { 24 | return c.json({ error: 'Bad request', schema: 'Invalid group' }, 422); 25 | } 26 | 27 | const { data } = result; 28 | const { nicknames } = data; 29 | 30 | for (const nickname of nicknames) { 31 | const pubkey = await lookupPubkey(nickname, c.var); 32 | if (pubkey) { 33 | await updateUser(pubkey, { [group]: true }, c); 34 | } 35 | } 36 | 37 | return c.json({ [`is_${group}`]: true }, 200); 38 | }); 39 | 40 | export default route; 41 | -------------------------------------------------------------------------------- /packages/ditto/schemas/mastodon.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | /** https://docs.joinmastodon.org/entities/Instance/#thumbnail */ 4 | const thumbnailSchema = z.object({ 5 | url: z.string().url(), 6 | blurhash: z.string().optional(), 7 | versions: z.object({ 8 | '@1x': z.string().url().optional(), 9 | '@2x': z.string().url().optional(), 10 | }).optional(), 11 | }); 12 | 13 | export { thumbnailSchema }; 14 | -------------------------------------------------------------------------------- /packages/ditto/schemas/pleroma-api.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | type ElixirValue = 4 | | string 5 | | number 6 | | boolean 7 | | null 8 | | ElixirTuple 9 | | ElixirValue[] 10 | | { [key: string]: ElixirValue }; 11 | 12 | interface ElixirTuple { 13 | tuple: [string, ElixirValue]; 14 | } 15 | 16 | interface PleromaConfig { 17 | group: string; 18 | key: string; 19 | value: ElixirValue; 20 | } 21 | 22 | const baseElixirValueSchema: z.ZodType = z.union([ 23 | z.string(), 24 | z.number(), 25 | z.boolean(), 26 | z.null(), 27 | z.lazy(() => elixirValueSchema.array()), 28 | z.lazy(() => z.record(z.string(), elixirValueSchema)), 29 | ]); 30 | 31 | const elixirTupleSchema: z.ZodType = z.object({ 32 | tuple: z.tuple([z.string(), z.lazy(() => elixirValueSchema)]), 33 | }); 34 | 35 | const elixirValueSchema: z.ZodType = z.union([ 36 | baseElixirValueSchema, 37 | elixirTupleSchema, 38 | ]); 39 | 40 | const configSchema: z.ZodType = z.object({ 41 | group: z.string(), 42 | key: z.string(), 43 | value: elixirValueSchema, 44 | }); 45 | 46 | export { configSchema, type ElixirTuple, elixirTupleSchema, type ElixirValue, type PleromaConfig }; 47 | -------------------------------------------------------------------------------- /packages/ditto/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/deno'; 2 | import { logi } from '@soapbox/logi'; 3 | 4 | import type { DittoConf } from '@ditto/conf'; 5 | 6 | /** Start Sentry, if configured. */ 7 | export function startSentry(conf: DittoConf): void { 8 | if (conf.sentryDsn) { 9 | logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry enabled.', enabled: true }); 10 | Sentry.init({ dsn: conf.sentryDsn }); 11 | } else { 12 | logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry not configured. Skipping.', enabled: false }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/ditto/server.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { logi } from '@soapbox/logi'; 3 | 4 | import app from '@/app.ts'; 5 | 6 | const conf = new DittoConf(Deno.env); 7 | 8 | Deno.serve({ 9 | port: conf.port, 10 | onListen({ hostname, port }): void { 11 | logi({ level: 'info', ns: 'ditto.server', msg: `Listening on http://${hostname}:${port}`, hostname, port }); 12 | }, 13 | }, app.fetch); 14 | -------------------------------------------------------------------------------- /packages/ditto/signers/ReadOnlySigner.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file require-await 2 | import { HTTPException } from '@hono/hono/http-exception'; 3 | import { NostrEvent, NostrSigner } from '@nostrify/nostrify'; 4 | 5 | export class ReadOnlySigner implements NostrSigner { 6 | constructor(private pubkey: string) {} 7 | 8 | async signEvent(): Promise { 9 | throw new HTTPException(401, { 10 | message: 'Log in with Nostr Connect to sign events', 11 | }); 12 | } 13 | 14 | async getPublicKey(): Promise { 15 | return this.pubkey; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/ditto/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/ditto/static/favicon.ico -------------------------------------------------------------------------------- /packages/ditto/static/images/avi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/ditto/static/images/avi.png -------------------------------------------------------------------------------- /packages/ditto/static/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/ditto/static/images/banner.png -------------------------------------------------------------------------------- /packages/ditto/static/images/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/ditto/static/images/thumbnail.png -------------------------------------------------------------------------------- /packages/ditto/storages/hydrate.bench.ts: -------------------------------------------------------------------------------- 1 | import { jsonlEvents } from '@nostrify/nostrify/test'; 2 | 3 | import { assembleEvents } from '@/storages/hydrate.ts'; 4 | 5 | const testEvents = await jsonlEvents('fixtures/hydrated.jsonl'); 6 | const testStats = JSON.parse(await Deno.readTextFile('fixtures/stats.json')); 7 | 8 | // The first 20 events in this file are my home feed. 9 | // The rest are events that would be hydrated by the store. 10 | const events = testEvents.slice(0, 20); 11 | 12 | Deno.bench('assembleEvents with home feed', () => { 13 | assembleEvents('', events, testEvents, testStats); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/ditto/types/MastodonPush.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mastodon push payload. 3 | * 4 | * This is the object the server sends to the client (with the Web Push API) 5 | * to notify of a new push event. 6 | */ 7 | export interface MastodonPush { 8 | access_token: string; 9 | preferred_locale?: string; 10 | notification_id: string; 11 | notification_type: string; 12 | icon?: string; 13 | title?: string; 14 | body?: string; 15 | } 16 | -------------------------------------------------------------------------------- /packages/ditto/utils/PleromaConfigDB.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import data from '~/fixtures/config-db.json' with { type: 'json' }; 4 | 5 | import { PleromaConfig } from '@/schemas/pleroma-api.ts'; 6 | import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts'; 7 | 8 | Deno.test('PleromaConfigDB.getIn', () => { 9 | const configDB = new PleromaConfigDB(data.configs as PleromaConfig[]); 10 | 11 | assertEquals( 12 | configDB.get(':pleroma', ':frontend_configurations')?.value, 13 | configDB.getIn(':pleroma', ':frontend_configurations'), 14 | ); 15 | 16 | assertEquals(configDB.getIn(':pleroma', ':frontend_configurations', ':bleroma'), undefined); 17 | 18 | assertEquals( 19 | configDB.getIn(':pleroma', ':frontend_configurations', ':soapbox_fe', 'colors', 'primary', '500'), 20 | '#1ca82b', 21 | ); 22 | 23 | assertEquals( 24 | configDB.getIn(':pleroma', ':frontend_configurations', ':soapbox_fe', 'colors', 'primary', '99999999'), 25 | undefined, 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/ditto/utils/SimpleLRU.test.ts: -------------------------------------------------------------------------------- 1 | import { SimpleLRU } from '@/utils/SimpleLRU.ts'; 2 | import { assertEquals, assertRejects } from '@std/assert'; 3 | 4 | Deno.test("SimpleLRU doesn't repeat failed calls", async () => { 5 | let calls = 0; 6 | 7 | using cache = new SimpleLRU( 8 | // deno-lint-ignore require-await 9 | async () => { 10 | calls++; 11 | throw new Error('gg'); 12 | }, 13 | { max: 100 }, 14 | ); 15 | 16 | await assertRejects(() => cache.fetch('foo')); 17 | assertEquals(calls, 1); 18 | 19 | await assertRejects(() => cache.fetch('foo')); 20 | assertEquals(calls, 1); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/ditto/utils/abort.ts: -------------------------------------------------------------------------------- 1 | /** Creates an `AbortError` object matching the Fetch API. */ 2 | function abortError() { 3 | return new DOMException('The signal has been aborted', 'AbortError'); 4 | } 5 | 6 | export { abortError }; 7 | -------------------------------------------------------------------------------- /packages/ditto/utils/aes.bench.ts: -------------------------------------------------------------------------------- 1 | import { generateSecretKey } from 'nostr-tools'; 2 | 3 | import { aesDecrypt, aesEncrypt } from '@/utils/aes.ts'; 4 | 5 | Deno.bench('aesEncrypt', async (b) => { 6 | const sk = generateSecretKey(); 7 | const decrypted = generateSecretKey(); 8 | b.start(); 9 | await aesEncrypt(sk, decrypted); 10 | }); 11 | 12 | Deno.bench('aesDecrypt', async (b) => { 13 | const sk = generateSecretKey(); 14 | const decrypted = generateSecretKey(); 15 | const encrypted = await aesEncrypt(sk, decrypted); 16 | b.start(); 17 | await aesDecrypt(sk, encrypted); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/ditto/utils/aes.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | import { encodeHex } from '@std/encoding/hex'; 3 | import { generateSecretKey } from 'nostr-tools'; 4 | 5 | import { aesDecrypt, aesEncrypt } from '@/utils/aes.ts'; 6 | 7 | Deno.test('aesDecrypt & aesEncrypt', async () => { 8 | const sk = generateSecretKey(); 9 | const data = generateSecretKey(); 10 | 11 | const encrypted = await aesEncrypt(sk, data); 12 | const decrypted = await aesDecrypt(sk, encrypted); 13 | 14 | assertEquals(encodeHex(decrypted), encodeHex(data)); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/ditto/utils/aes.ts: -------------------------------------------------------------------------------- 1 | /** Encrypt data with AES-GCM and a secret key. */ 2 | export async function aesEncrypt(sk: Uint8Array, plaintext: Uint8Array): Promise { 3 | const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']); 4 | const iv = crypto.getRandomValues(new Uint8Array(12)); 5 | const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, plaintext); 6 | 7 | return new Uint8Array([...iv, ...new Uint8Array(buffer)]); 8 | } 9 | 10 | /** Decrypt data with AES-GCM and a secret key. */ 11 | export async function aesDecrypt(sk: Uint8Array, ciphertext: Uint8Array): Promise { 12 | const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']); 13 | const iv = ciphertext.slice(0, 12); 14 | const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, ciphertext.slice(12)); 15 | 16 | return new Uint8Array(buffer); 17 | } 18 | -------------------------------------------------------------------------------- /packages/ditto/utils/auth.bench.ts: -------------------------------------------------------------------------------- 1 | import { generateToken, getTokenHash } from '@/utils/auth.ts'; 2 | 3 | Deno.bench('generateToken', async () => { 4 | await generateToken(); 5 | }); 6 | 7 | Deno.bench('getTokenHash', async (b) => { 8 | const { token } = await generateToken(); 9 | b.start(); 10 | await getTokenHash(token); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/ditto/utils/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | import { decodeHex, encodeHex } from '@std/encoding/hex'; 3 | 4 | import { generateToken, getTokenHash } from '@/utils/auth.ts'; 5 | 6 | Deno.test('generateToken', async () => { 7 | const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95'); 8 | 9 | const { token, hash } = await generateToken(sk); 10 | 11 | assertEquals(token, 'token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd'); 12 | assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a'); 13 | }); 14 | 15 | Deno.test('getTokenHash', async () => { 16 | const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd'); 17 | assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a'); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/ditto/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { bech32 } from '@scure/base'; 2 | import { generateSecretKey } from 'nostr-tools'; 3 | 4 | /** 5 | * Generate an auth token for the API. 6 | * 7 | * Returns a bech32 encoded API token and the SHA-256 hash of the bytes. 8 | * The token should be presented to the user, but only the hash should be stored in the database. 9 | */ 10 | export async function generateToken(sk = generateSecretKey()): Promise<{ token: `token1${string}`; hash: Uint8Array }> { 11 | const words = bech32.toWords(sk); 12 | const token = bech32.encode('token', words); 13 | 14 | const buffer = await crypto.subtle.digest('SHA-256', sk); 15 | const hash = new Uint8Array(buffer); 16 | 17 | return { token, hash }; 18 | } 19 | 20 | /** 21 | * Get the SHA-256 hash of an API token. 22 | * First decodes from bech32 then hashes the bytes. 23 | * Used to identify the user in the database by the hash of their token. 24 | */ 25 | export async function getTokenHash(token: `token1${string}`): Promise { 26 | const { bytes: sk } = bech32.decodeToBytes(token); 27 | const buffer = await crypto.subtle.digest('SHA-256', sk); 28 | 29 | return new Uint8Array(buffer); 30 | } 31 | -------------------------------------------------------------------------------- /packages/ditto/utils/bolt11.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | import { getAmount } from '@/utils/bolt11.ts'; 3 | 4 | Deno.test('Invoice is invalid', () => { 5 | assertEquals(getAmount('hello'), undefined); 6 | }); 7 | 8 | Deno.test('Invoice is undefined', () => { 9 | assertEquals(getAmount(undefined), undefined); 10 | }); 11 | 12 | Deno.test('Amount is 200000', () => { 13 | assertEquals( 14 | getAmount( 15 | 'lnbc2u1pn8qatypp5dweqaltlry2vgpxxyc0puxnc50335yznevj2g46wrhfm2694lhgqhp576ekte7lhhtsxdk6tfvkpyp8gdk2xccmuccdxwjd0fqdh34wfseqcqzzsxqyz5vqsp5n44zva7xndawg5l2r9d85v0tszwejtfzkc7v90d6c7d3nsdt0qds9qxpqysgqx2v2artsxmnfkpapdm9f5pahjs8etlpe7kcjue2kffhjg3jrtearstjvenr6lxzhpw3es4hpchzzeet7ul88elurfmvr9v94v0655rgpy7m7r5', 16 | ), 17 | '200000', 18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/ditto/utils/bolt11.ts: -------------------------------------------------------------------------------- 1 | import bolt11 from 'light-bolt11-decoder'; 2 | 3 | /** Decodes the invoice and returns the amount in millisatoshis */ 4 | function getAmount(invoice: string | undefined): string | undefined { 5 | if (!invoice) return; 6 | 7 | try { 8 | const amount = (bolt11.decode(invoice).sections as { name: string; value: string }[]).find( 9 | ({ name }) => name === 'amount', 10 | )?.value; 11 | return amount; 12 | } catch { 13 | return; 14 | } 15 | } 16 | 17 | export { getAmount }; 18 | -------------------------------------------------------------------------------- /packages/ditto/utils/custom-emoji.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { parseEmojiInput } from './custom-emoji.ts'; 4 | 5 | Deno.test('parseEmojiInput', () => { 6 | assertEquals(parseEmojiInput('+'), { type: 'basic', value: '+' }); 7 | assertEquals(parseEmojiInput('🚀'), { type: 'native', native: '🚀' }); 8 | assertEquals(parseEmojiInput(':ditto:'), { type: 'custom', shortcode: 'ditto' }); 9 | assertEquals(parseEmojiInput('x'), undefined); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/ditto/utils/formdata.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from '@std/assert'; 2 | 3 | import { parseFormData } from '@/utils/formdata.ts'; 4 | 5 | Deno.test('parseFormData', () => { 6 | const formData = new FormData(); 7 | 8 | formData.append('foo', 'bar'); 9 | formData.append('fields_attributes[0][name]', 'baz'); 10 | formData.append('fields_attributes[0][value]', 'qux'); 11 | formData.append('fields_attributes[1][name]', 'quux'); 12 | formData.append('fields_attributes[1][value]', 'corge'); 13 | 14 | const result = parseFormData(formData); 15 | 16 | assertEquals(result, { 17 | foo: 'bar', 18 | fields_attributes: [ 19 | { name: 'baz', value: 'qux' }, 20 | { name: 'quux', value: 'corge' }, 21 | ], 22 | }); 23 | 24 | assertThrows(() => { 25 | const formData = new FormData(); 26 | formData.append('fields_attributes[1]', 'unexpected'); 27 | formData.append('fields_attributes[1][extra]', 'extra_value'); 28 | parseFormData(formData); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/ditto/utils/formdata.ts: -------------------------------------------------------------------------------- 1 | import { parseFormData as _parseFormData } from 'formdata-helper'; 2 | 3 | /** Parse formData into JSON, simulating the way Mastodon does it. */ 4 | export function parseFormData(formData: FormData): unknown { 5 | const json = _parseFormData(formData); 6 | 7 | const parsed: Record = {}; 8 | 9 | for (const [key, value] of Object.entries(json)) { 10 | deepSet(parsed, key, value); 11 | } 12 | 13 | return parsed; 14 | } 15 | 16 | /** Deeply sets a value in an object based on a Rails-style nested key. */ 17 | function deepSet( 18 | /** The target object to modify. */ 19 | // deno-lint-ignore no-explicit-any 20 | target: Record, 21 | /** The Rails-style key (e.g., "fields_attributes[0][name]"). */ 22 | key: string, 23 | /** The value to set. */ 24 | // deno-lint-ignore no-explicit-any 25 | value: any, 26 | ): void { 27 | const keys = key.match(/[^[\]]+/g); // Extract keys like ["fields_attributes", "0", "name"] 28 | if (!keys) return; 29 | 30 | let current = target; 31 | 32 | keys.forEach((k, index) => { 33 | const isLast = index === keys.length - 1; 34 | 35 | if (isLast) { 36 | current[k] = value; // Set the value at the final key 37 | } else { 38 | if (!current[k]) { 39 | // Determine if the next key is numeric, then create an array; otherwise, an object 40 | current[k] = /^\d+$/.test(keys[index + 1]) ? [] : {}; 41 | } 42 | current = current[k]; 43 | } 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /packages/ditto/utils/html.ts: -------------------------------------------------------------------------------- 1 | import { escape } from 'entities'; 2 | 3 | /** 4 | * @param strings The constant portions of the template string. 5 | * @param values The templated values. 6 | * @returns The built HTML. 7 | * @example 8 | * ``` 9 | * const unsafe = `oops `; 10 | * testing.innerHTML = html`foo bar baz ${unsafe}`; 11 | * console.assert(testing === "foo bar baz oops<script>alert(1)</script>"); 12 | * ``` 13 | */ 14 | export function html(strings: TemplateStringsArray, ...values: (string | number)[]) { 15 | const built = []; 16 | for (let i = 0; i < strings.length; i++) { 17 | built.push(strings[i] || ''); 18 | const val = values[i]; 19 | built.push(escape((val || '').toString())); 20 | } 21 | return built.join(''); 22 | } 23 | -------------------------------------------------------------------------------- /packages/ditto/utils/log.ts: -------------------------------------------------------------------------------- 1 | /** Serialize an error into JSON for JSON logging. */ 2 | export function errorJson(error: unknown): Error | null { 3 | if (error instanceof Error) { 4 | return error; 5 | } else { 6 | return null; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/ditto/utils/lookup.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { extractIdentifier } from './lookup.ts'; 4 | 5 | Deno.test('extractIdentifier', () => { 6 | assertEquals( 7 | extractIdentifier('https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p'), 8 | 'npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p', 9 | ); 10 | assertEquals( 11 | extractIdentifier('npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p'), 12 | 'npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p', 13 | ); 14 | assertEquals( 15 | extractIdentifier('alex'), 16 | undefined, 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/ditto/utils/media.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; 4 | 5 | Deno.test('getUrlMediaType', () => { 6 | assertEquals(getUrlMediaType('https://example.com/image.png'), 'image/png'); 7 | assertEquals(getUrlMediaType('https://example.com/index.html'), 'text/html'); 8 | assertEquals(getUrlMediaType('https://example.com/yolo'), undefined); 9 | assertEquals(getUrlMediaType('https://example.com/'), undefined); 10 | assertEquals( 11 | getUrlMediaType('https://gitlab.com/soapbox-pub/nostrify/-/blob/main/packages/policies/WoTPolicy.ts'), 12 | 'application/typescript', 13 | ); 14 | }); 15 | 16 | Deno.test('isPermittedMediaType', () => { 17 | assertEquals(isPermittedMediaType('image/png', ['image', 'video']), true); 18 | assertEquals(isPermittedMediaType('video/webm', ['image', 'video']), true); 19 | assertEquals(isPermittedMediaType('audio/ogg', ['image', 'video']), false); 20 | assertEquals(isPermittedMediaType('application/json', ['image', 'video']), false); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/ditto/utils/media.ts: -------------------------------------------------------------------------------- 1 | import { typeByExtension as _typeByExtension } from '@std/media-types'; 2 | 3 | /** Get media type of the filename in the URL by its extension, if any. */ 4 | export function getUrlMediaType(url: string): string | undefined { 5 | try { 6 | const { pathname } = new URL(url); 7 | const ext = pathname.split('.').pop() ?? ''; 8 | return typeByExtension(ext); 9 | } catch { 10 | return undefined; 11 | } 12 | } 13 | 14 | /** 15 | * Check if the base type matches any of the permitted types. 16 | * 17 | * ```ts 18 | * isPermittedMediaType('image/png', ['image', 'video']); // true 19 | * ``` 20 | */ 21 | export function isPermittedMediaType(mediaType: string, permitted: string[]): boolean { 22 | const [baseType, _subType] = mediaType.split('/'); 23 | return permitted.includes(baseType); 24 | } 25 | 26 | /** Custom type-by-extension with overrides. */ 27 | function typeByExtension(ext: string): string | undefined { 28 | switch (ext) { 29 | case 'ts': 30 | return 'application/typescript'; 31 | default: 32 | return _typeByExtension(ext); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/ditto/utils/nip89.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | 3 | import { getInstanceMetadata } from '@/utils/instance.ts'; 4 | 5 | import type { NStore } from '@nostrify/nostrify'; 6 | 7 | interface CreateNip89Opts { 8 | conf: DittoConf; 9 | relay: NStore; 10 | signal?: AbortSignal; 11 | } 12 | 13 | /** 14 | * Creates a NIP-89 application handler event (kind 31990) 15 | * This identifies Ditto as a client that can handle various kinds of events 16 | */ 17 | export async function createNip89(opts: CreateNip89Opts): Promise { 18 | const { conf, relay, signal } = opts; 19 | 20 | const { event: _, ...metadata } = await getInstanceMetadata(opts); 21 | 22 | const event = await conf.signer.signEvent({ 23 | kind: 31990, 24 | tags: [ 25 | ['d', 'ditto'], 26 | ['k', '1'], 27 | ['t', 'ditto'], 28 | ['web', conf.local('/'), 'web'], 29 | ], 30 | content: JSON.stringify(metadata), 31 | created_at: Math.floor(Date.now() / 1000), 32 | }); 33 | 34 | await relay.event(event, { signal }); 35 | } 36 | -------------------------------------------------------------------------------- /packages/ditto/utils/pleroma.ts: -------------------------------------------------------------------------------- 1 | import { NSchema as n, NStore } from '@nostrify/nostrify'; 2 | 3 | import { configSchema } from '@/schemas/pleroma-api.ts'; 4 | import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts'; 5 | 6 | import type { DittoConf } from '@ditto/conf'; 7 | 8 | interface GetPleromaConfigsOpts { 9 | conf: DittoConf; 10 | relay: NStore; 11 | signal?: AbortSignal; 12 | } 13 | 14 | export async function getPleromaConfigs(opts: GetPleromaConfigsOpts): Promise { 15 | const { conf, relay, signal } = opts; 16 | 17 | const signer = conf.signer; 18 | const pubkey = await signer.getPublicKey(); 19 | 20 | const [event] = await relay.query([{ 21 | kinds: [30078], 22 | authors: [pubkey], 23 | '#d': ['pub.ditto.pleroma.config'], 24 | limit: 1, 25 | }], { signal }); 26 | 27 | if (!event) { 28 | return new PleromaConfigDB([]); 29 | } 30 | 31 | try { 32 | const decrypted = await signer.nip44.decrypt(pubkey, event.content); 33 | const configs = n.json().pipe(configSchema.array()).catch([]).parse(decrypted); 34 | return new PleromaConfigDB(configs); 35 | } catch (_e) { 36 | return new PleromaConfigDB([]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/ditto/utils/purify.ts: -------------------------------------------------------------------------------- 1 | import { NostrEvent } from '@nostrify/nostrify'; 2 | 3 | /** Return a normalized event without any non-standard keys. */ 4 | export function purifyEvent(event: NostrEvent): NostrEvent { 5 | return { 6 | id: event.id, 7 | pubkey: event.pubkey, 8 | kind: event.kind, 9 | content: event.content, 10 | tags: event.tags, 11 | sig: event.sig, 12 | created_at: event.created_at, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /packages/ditto/utils/search.ts: -------------------------------------------------------------------------------- 1 | import { DittoTables } from '@ditto/db'; 2 | import { Kysely, sql } from 'kysely'; 3 | 4 | /** Get pubkeys whose name and NIP-05 is similar to 'q' */ 5 | export async function getPubkeysBySearch( 6 | kysely: Kysely, 7 | opts: { q: string; limit: number; offset: number; following: Set }, 8 | ): Promise> { 9 | const { q, limit, following, offset } = opts; 10 | 11 | const pubkeys = new Set(); 12 | 13 | const query = kysely 14 | .selectFrom('top_authors') 15 | .select('pubkey') 16 | .where('search', sql`%>`, q) 17 | .limit(limit) 18 | .offset(offset); 19 | 20 | if (following.size) { 21 | const authorsQuery = query.where('pubkey', 'in', [...following]); 22 | 23 | for (const { pubkey } of await authorsQuery.execute()) { 24 | pubkeys.add(pubkey); 25 | } 26 | } 27 | 28 | if (pubkeys.size >= limit) { 29 | return pubkeys; 30 | } 31 | 32 | for (const { pubkey } of await query.limit(limit - pubkeys.size).execute()) { 33 | pubkeys.add(pubkey); 34 | } 35 | 36 | return pubkeys; 37 | } 38 | -------------------------------------------------------------------------------- /packages/ditto/utils/text.ts: -------------------------------------------------------------------------------- 1 | export async function asyncReplaceAll( 2 | input: string, 3 | regex: RegExp, 4 | replacer: (match: string, ...args: string[]) => Promise, 5 | ): Promise { 6 | const promises: Promise[] = []; 7 | 8 | input.replaceAll(new RegExp(regex), (match, ...args) => { 9 | promises.push(replacer(match, ...args)); 10 | return ''; 11 | }); 12 | 13 | let i = 0; 14 | const replacements = await Promise.all(promises); 15 | return input.replaceAll(new RegExp(regex), () => replacements[i++]); 16 | } 17 | -------------------------------------------------------------------------------- /packages/ditto/utils/time.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { generateDateRange } from './time.ts'; 4 | 5 | Deno.test('generateDateRange', () => { 6 | const since = new Date('2023-07-03T16:30:00.000Z'); 7 | const until = new Date('2023-07-07T09:01:00.000Z'); 8 | 9 | const expected = [ 10 | new Date('2023-07-03T00:00:00.000Z'), 11 | new Date('2023-07-04T00:00:00.000Z'), 12 | new Date('2023-07-05T00:00:00.000Z'), 13 | new Date('2023-07-06T00:00:00.000Z'), 14 | new Date('2023-07-07T00:00:00.000Z'), 15 | ]; 16 | 17 | const result = generateDateRange(since, until); 18 | 19 | assertEquals( 20 | result.map((d) => d.getTime()), 21 | expected.map((d) => d.getTime()), 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/ditto/utils/time.ts: -------------------------------------------------------------------------------- 1 | const Time = { 2 | milliseconds: (ms: number) => ms, 3 | seconds: (s: number) => s * 1000, 4 | minutes: (m: number) => m * Time.seconds(60), 5 | hours: (h: number) => h * Time.minutes(60), 6 | days: (d: number) => d * Time.hours(24), 7 | weeks: (w: number) => w * Time.days(7), 8 | months: (m: number) => m * Time.days(30), 9 | years: (y: number) => y * Time.days(365), 10 | }; 11 | 12 | /** Strips the time off the date, giving 12am UTC. */ 13 | function stripTime(date: Date): Date { 14 | return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); 15 | } 16 | 17 | /** Strips times off the dates and generates all 24h intervals between them, inclusive of both inputs. */ 18 | function generateDateRange(since: Date, until: Date): Date[] { 19 | const dates = []; 20 | 21 | const sinceDate = stripTime(since); 22 | const untilDate = stripTime(until); 23 | 24 | while (sinceDate <= untilDate) { 25 | dates.push(new Date(sinceDate)); 26 | sinceDate.setUTCDate(sinceDate.getUTCDate() + 1); 27 | } 28 | 29 | return dates; 30 | } 31 | 32 | export { generateDateRange, stripTime, Time }; 33 | -------------------------------------------------------------------------------- /packages/ditto/views/ditto.ts: -------------------------------------------------------------------------------- 1 | import { DittoEvent } from '@/interfaces/DittoEvent.ts'; 2 | import { getTagSet } from '@/utils/tags.ts'; 3 | import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; 4 | 5 | /** Renders an Admin::Account entity from a name request event. */ 6 | export async function renderNameRequest(event: DittoEvent) { 7 | const n = getTagSet(event.info?.tags ?? [], 'n'); 8 | const [username, domain] = event.tags.find(([name]) => name === 'r')?.[1]?.split('@') ?? []; 9 | 10 | const adminAccount = event.author 11 | ? await renderAdminAccount(event.author) 12 | : await renderAdminAccountFromPubkey(event.pubkey); 13 | 14 | return { 15 | ...adminAccount, 16 | id: event.id, 17 | approved: n.has('approved'), 18 | username, 19 | domain, 20 | invite_request: event.content, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /packages/ditto/views/mastodon/emojis.ts: -------------------------------------------------------------------------------- 1 | import { UnsignedEvent } from 'nostr-tools'; 2 | 3 | import { EmojiTag, emojiTagSchema } from '@/schemas/nostr.ts'; 4 | import { filteredArray } from '@/schema.ts'; 5 | 6 | function renderEmoji([_, shortcode, url]: EmojiTag) { 7 | return { 8 | shortcode, 9 | static_url: url, 10 | url, 11 | }; 12 | } 13 | 14 | function renderEmojis({ tags }: UnsignedEvent) { 15 | return filteredArray(emojiTagSchema) 16 | .parse(tags) 17 | .map(renderEmoji); 18 | } 19 | 20 | export { renderEmojis }; 21 | -------------------------------------------------------------------------------- /packages/ditto/views/mastodon/relationships.ts: -------------------------------------------------------------------------------- 1 | import { NostrEvent } from '@nostrify/nostrify'; 2 | 3 | import { hasTag } from '@/utils/tags.ts'; 4 | 5 | interface RenderRelationshipOpts { 6 | sourcePubkey: string; 7 | targetPubkey: string; 8 | event3: NostrEvent | undefined; 9 | target3: NostrEvent | undefined; 10 | event10000: NostrEvent | undefined; 11 | } 12 | 13 | function renderRelationship({ sourcePubkey, targetPubkey, event3, target3, event10000 }: RenderRelationshipOpts) { 14 | return { 15 | id: targetPubkey, 16 | following: event3 ? hasTag(event3.tags, ['p', targetPubkey]) : false, 17 | showing_reblogs: true, 18 | notifying: false, 19 | followed_by: target3 ? hasTag(target3?.tags, ['p', sourcePubkey]) : false, 20 | blocking: false, 21 | blocked_by: false, 22 | muting: event10000 ? hasTag(event10000.tags, ['p', targetPubkey]) : false, 23 | muting_notifications: false, 24 | requested: false, 25 | domain_blocking: false, 26 | endorsed: false, 27 | }; 28 | } 29 | 30 | export { renderRelationship }; 31 | -------------------------------------------------------------------------------- /packages/ditto/workers/policy.test.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { generateSecretKey, nip19 } from 'nostr-tools'; 3 | 4 | import { PolicyWorker } from './policy.ts'; 5 | 6 | Deno.test('PolicyWorker', () => { 7 | const conf = new DittoConf( 8 | new Map([ 9 | ['DITTO_NSEC', nip19.nsecEncode(generateSecretKey())], 10 | ]), 11 | ); 12 | 13 | new PolicyWorker(conf); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/ditto/workers/verify.ts: -------------------------------------------------------------------------------- 1 | import { NostrEvent } from '@nostrify/nostrify'; 2 | import * as Comlink from 'comlink'; 3 | 4 | import type { VerifyWorker } from './verify.worker.ts'; 5 | 6 | const worker = Comlink.wrap( 7 | new Worker(new URL('./verify.worker.ts', import.meta.url), { type: 'module', name: 'verifyEventWorker' }), 8 | ); 9 | 10 | function verifyEventWorker(event: NostrEvent): Promise { 11 | return worker.verifyEvent(event); 12 | } 13 | 14 | export { verifyEventWorker }; 15 | -------------------------------------------------------------------------------- /packages/ditto/workers/verify.worker.ts: -------------------------------------------------------------------------------- 1 | import { NostrEvent } from '@nostrify/nostrify'; 2 | import * as Comlink from 'comlink'; 3 | import { VerifiedEvent, verifyEvent } from 'nostr-tools'; 4 | 5 | import '@/nostr-wasm.ts'; 6 | 7 | export const VerifyWorker = { 8 | verifyEvent(event: NostrEvent): event is VerifiedEvent { 9 | return verifyEvent(event); 10 | }, 11 | }; 12 | 13 | Comlink.expose(VerifyWorker); 14 | -------------------------------------------------------------------------------- /packages/lang/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/lang", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./language.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/mastoapi/auth/aes.bench.ts: -------------------------------------------------------------------------------- 1 | import { generateSecretKey } from 'nostr-tools'; 2 | 3 | import { aesDecrypt, aesEncrypt } from './aes.ts'; 4 | 5 | Deno.bench('aesEncrypt', async (b) => { 6 | const sk = generateSecretKey(); 7 | const decrypted = generateSecretKey(); 8 | b.start(); 9 | await aesEncrypt(sk, decrypted); 10 | }); 11 | 12 | Deno.bench('aesDecrypt', async (b) => { 13 | const sk = generateSecretKey(); 14 | const decrypted = generateSecretKey(); 15 | const encrypted = await aesEncrypt(sk, decrypted); 16 | b.start(); 17 | await aesDecrypt(sk, encrypted); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/mastoapi/auth/aes.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | import { encodeHex } from '@std/encoding/hex'; 3 | import { generateSecretKey } from 'nostr-tools'; 4 | 5 | import { aesDecrypt, aesEncrypt } from './aes.ts'; 6 | 7 | Deno.test('aesDecrypt & aesEncrypt', async () => { 8 | const sk = generateSecretKey(); 9 | const data = generateSecretKey(); 10 | 11 | const encrypted = await aesEncrypt(sk, data); 12 | const decrypted = await aesDecrypt(sk, encrypted); 13 | 14 | assertEquals(encodeHex(decrypted), encodeHex(data)); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/mastoapi/auth/aes.ts: -------------------------------------------------------------------------------- 1 | /** Encrypt data with AES-GCM and a secret key. */ 2 | export async function aesEncrypt(sk: Uint8Array, plaintext: Uint8Array): Promise { 3 | const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']); 4 | const iv = crypto.getRandomValues(new Uint8Array(12)); 5 | const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, plaintext); 6 | 7 | return new Uint8Array([...iv, ...new Uint8Array(buffer)]); 8 | } 9 | 10 | /** Decrypt data with AES-GCM and a secret key. */ 11 | export async function aesDecrypt(sk: Uint8Array, ciphertext: Uint8Array): Promise { 12 | const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']); 13 | const iv = ciphertext.slice(0, 12); 14 | const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, ciphertext.slice(12)); 15 | 16 | return new Uint8Array(buffer); 17 | } 18 | -------------------------------------------------------------------------------- /packages/mastoapi/auth/token.bench.ts: -------------------------------------------------------------------------------- 1 | import { generateToken, getTokenHash } from './token.ts'; 2 | 3 | Deno.bench('generateToken', async () => { 4 | await generateToken(); 5 | }); 6 | 7 | Deno.bench('getTokenHash', async (b) => { 8 | const { token } = await generateToken(); 9 | b.start(); 10 | await getTokenHash(token); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/mastoapi/auth/token.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | import { decodeHex, encodeHex } from '@std/encoding/hex'; 3 | 4 | import { generateToken, getTokenHash } from './token.ts'; 5 | 6 | Deno.test('generateToken', async () => { 7 | const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95'); 8 | 9 | const { token, hash } = await generateToken(sk); 10 | 11 | assertEquals(token, 'token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd'); 12 | assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a'); 13 | }); 14 | 15 | Deno.test('getTokenHash', async () => { 16 | const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd'); 17 | assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a'); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/mastoapi/auth/token.ts: -------------------------------------------------------------------------------- 1 | import { bech32 } from '@scure/base'; 2 | import { generateSecretKey } from 'nostr-tools'; 3 | 4 | /** 5 | * Generate an auth token for the API. 6 | * 7 | * Returns a bech32 encoded API token and the SHA-256 hash of the bytes. 8 | * The token should be presented to the user, but only the hash should be stored in the database. 9 | */ 10 | export async function generateToken(sk = generateSecretKey()): Promise<{ token: `token1${string}`; hash: Uint8Array }> { 11 | const words = bech32.toWords(sk); 12 | const token = bech32.encode('token', words); 13 | 14 | const buffer = await crypto.subtle.digest('SHA-256', sk); 15 | const hash = new Uint8Array(buffer); 16 | 17 | return { token, hash }; 18 | } 19 | 20 | /** 21 | * Get the SHA-256 hash of an API token. 22 | * First decodes from bech32 then hashes the bytes. 23 | * Used to identify the user in the database by the hash of their token. 24 | */ 25 | export async function getTokenHash(token: `token1${string}`): Promise { 26 | const { bytes: sk } = bech32.decodeToBytes(token); 27 | const buffer = await crypto.subtle.digest('SHA-256', sk); 28 | 29 | return new Uint8Array(buffer); 30 | } 31 | -------------------------------------------------------------------------------- /packages/mastoapi/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/mastoapi", 3 | "version": "0.1.0", 4 | "exports": { 5 | "./middleware": "./middleware/mod.ts", 6 | "./pagination": "./pagination/mod.ts", 7 | "./router": "./router/mod.ts", 8 | "./test": "./test.ts", 9 | "./types": "./types/mod.ts" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/mastoapi/middleware/User.ts: -------------------------------------------------------------------------------- 1 | import type { NostrSigner, NRelay } from '@nostrify/nostrify'; 2 | 3 | export interface User { 4 | signer: S; 5 | relay: R; 6 | } 7 | -------------------------------------------------------------------------------- /packages/mastoapi/middleware/mod.ts: -------------------------------------------------------------------------------- 1 | export { paginationMiddleware } from './paginationMiddleware.ts'; 2 | export { tokenMiddleware } from './tokenMiddleware.ts'; 3 | export { userMiddleware } from './userMiddleware.ts'; 4 | 5 | export type { User } from './User.ts'; 6 | -------------------------------------------------------------------------------- /packages/mastoapi/pagination/link-header.test.ts: -------------------------------------------------------------------------------- 1 | import { genEvent } from '@nostrify/nostrify/test'; 2 | import { assertEquals } from '@std/assert'; 3 | 4 | import { buildLinkHeader, buildListLinkHeader } from './link-header.ts'; 5 | 6 | Deno.test('buildLinkHeader', () => { 7 | const url = 'https://ditto.test/api/v1/events'; 8 | 9 | const events = [ 10 | genEvent({ created_at: 1 }), 11 | genEvent({ created_at: 2 }), 12 | genEvent({ created_at: 3 }), 13 | ]; 14 | 15 | const link = buildLinkHeader(url, events); 16 | 17 | assertEquals( 18 | link?.toString(), 19 | '; rel="next", ; rel="prev"', 20 | ); 21 | }); 22 | 23 | Deno.test('buildListLinkHeader', () => { 24 | const url = 'https://ditto.test/api/v1/tags'; 25 | 26 | const params = { offset: 0, limit: 3 }; 27 | 28 | const link = buildListLinkHeader(url, params); 29 | 30 | assertEquals( 31 | link?.toString(), 32 | '; rel="next", ; rel="prev"', 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/mastoapi/pagination/link-header.ts: -------------------------------------------------------------------------------- 1 | import type { NostrEvent } from '@nostrify/nostrify'; 2 | 3 | /** Build HTTP Link header for Mastodon API pagination. */ 4 | export function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined { 5 | if (events.length <= 1) return; 6 | 7 | const firstEvent = events[0]; 8 | const lastEvent = events[events.length - 1]; 9 | 10 | const { pathname, search } = new URL(url); 11 | 12 | const next = new URL(pathname + search, url); 13 | const prev = new URL(pathname + search, url); 14 | 15 | next.searchParams.set('until', String(lastEvent.created_at)); 16 | prev.searchParams.set('since', String(firstEvent.created_at)); 17 | 18 | return `<${next}>; rel="next", <${prev}>; rel="prev"`; 19 | } 20 | 21 | /** Build HTTP Link header for paginating Nostr lists. */ 22 | export function buildListLinkHeader( 23 | url: string, 24 | params: { offset: number; limit: number }, 25 | ): string | undefined { 26 | const { pathname, search } = new URL(url); 27 | const { offset, limit } = params; 28 | 29 | const next = new URL(pathname + search, url); 30 | const prev = new URL(pathname + search, url); 31 | 32 | next.searchParams.set('offset', String(offset + limit)); 33 | prev.searchParams.set('offset', String(Math.max(offset - limit, 0))); 34 | 35 | next.searchParams.set('limit', String(limit)); 36 | prev.searchParams.set('limit', String(limit)); 37 | 38 | return `<${next}>; rel="next", <${prev}>; rel="prev"`; 39 | } 40 | -------------------------------------------------------------------------------- /packages/mastoapi/pagination/mod.ts: -------------------------------------------------------------------------------- 1 | export { buildLinkHeader, buildListLinkHeader } from './link-header.ts'; 2 | export { paginated, paginatedList } from './paginate.ts'; 3 | export { paginationSchema } from './schema.ts'; 4 | -------------------------------------------------------------------------------- /packages/mastoapi/pagination/paginate.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/mastoapi/pagination/paginate.test.ts -------------------------------------------------------------------------------- /packages/mastoapi/pagination/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { paginationSchema } from './schema.ts'; 4 | 5 | Deno.test('paginationSchema', () => { 6 | const pagination = paginationSchema().parse({ 7 | limit: '10', 8 | offset: '20', 9 | max_id: '1', 10 | min_id: '2', 11 | since: '3', 12 | until: '4', 13 | }); 14 | 15 | assertEquals(pagination, { 16 | limit: 10, 17 | offset: 20, 18 | max_id: '1', 19 | min_id: '2', 20 | since: 3, 21 | until: 4, 22 | }); 23 | }); 24 | 25 | Deno.test('paginationSchema with custom limit', () => { 26 | const pagination = paginationSchema({ limit: 100 }).parse({}); 27 | assertEquals(pagination.limit, 100); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/mastoapi/pagination/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export interface Pagination { 4 | max_id?: string; 5 | min_id?: string; 6 | since?: number; 7 | until?: number; 8 | limit: number; 9 | offset: number; 10 | } 11 | 12 | export interface PaginationSchemaOpts { 13 | limit?: number; 14 | max?: number; 15 | } 16 | 17 | /** Schema to parse pagination query params. */ 18 | export function paginationSchema(opts: PaginationSchemaOpts = {}): z.ZodType { 19 | let { limit = 20, max = 40 } = opts; 20 | 21 | if (limit > max) { 22 | max = limit; 23 | } 24 | 25 | return z.object({ 26 | max_id: z.string().transform((val) => { 27 | if (!val.includes('-')) return val; 28 | return val.split('-')[1]; 29 | }).optional().catch(undefined), 30 | min_id: z.string().optional().catch(undefined), 31 | since: z.coerce.number().nonnegative().optional().catch(undefined), 32 | until: z.coerce.number().nonnegative().optional().catch(undefined), 33 | limit: z.coerce.number().catch(limit).transform((value) => Math.min(Math.max(value, 0), max)), 34 | offset: z.coerce.number().nonnegative().catch(0), 35 | }) as z.ZodType; 36 | } 37 | -------------------------------------------------------------------------------- /packages/mastoapi/router/DittoApp.test.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { DummyDB } from '@ditto/db'; 3 | import { Hono } from '@hono/hono'; 4 | import { MockRelay } from '@nostrify/nostrify/test'; 5 | import { assertEquals } from '@std/assert'; 6 | 7 | import { DittoApp } from './DittoApp.ts'; 8 | import { DittoRoute } from './DittoRoute.ts'; 9 | 10 | Deno.test('DittoApp', async () => { 11 | await using db = new DummyDB(); 12 | const conf = new DittoConf(new Map()); 13 | const relay = new MockRelay(); 14 | 15 | const app = new DittoApp({ conf, db, relay }); 16 | 17 | const hono = new Hono(); 18 | const route = new DittoRoute(); 19 | 20 | app.route('/', route); 21 | 22 | // @ts-expect-error Passing a non-DittoRoute to route. 23 | app.route('/', hono); 24 | 25 | app.get('/error', () => { 26 | throw new Error('test error'); 27 | }); 28 | 29 | const response = await app.request('/error'); 30 | assertEquals(response.status, 500); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/mastoapi/router/DittoApp.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from '@hono/hono'; 2 | 3 | import type { HonoOptions } from '@hono/hono/hono-base'; 4 | import type { DittoEnv } from './DittoEnv.ts'; 5 | 6 | export type DittoAppOpts = Omit & HonoOptions; 7 | 8 | export class DittoApp extends Hono { 9 | // @ts-ignore Require a DittoRoute for type safety. 10 | declare route: (path: string, app: Hono) => Hono; 11 | 12 | constructor(protected opts: DittoAppOpts) { 13 | super(opts); 14 | 15 | this.use((c, next) => { 16 | c.set('db', opts.db); 17 | c.set('conf', opts.conf); 18 | c.set('relay', opts.relay); 19 | c.set('signal', c.req.raw.signal); 20 | c.set('requestId', c.req.header('X-Request-Id') ?? crypto.randomUUID()); 21 | return next(); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/mastoapi/router/DittoEnv.ts: -------------------------------------------------------------------------------- 1 | import type { DittoConf } from '@ditto/conf'; 2 | import type { DittoDB } from '@ditto/db'; 3 | import type { Env } from '@hono/hono'; 4 | import type { NRelay } from '@nostrify/nostrify'; 5 | 6 | export interface DittoEnv extends Env { 7 | Variables: { 8 | /** Ditto site configuration. */ 9 | conf: DittoConf; 10 | /** Relay store. */ 11 | relay: NRelay; 12 | /** 13 | * Database object. 14 | * @deprecated Store data as Nostr events instead. 15 | */ 16 | db: DittoDB; 17 | /** Abort signal for the request. */ 18 | signal: AbortSignal; 19 | /** Unique ID for the request. */ 20 | requestId: string; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /packages/mastoapi/router/DittoMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareHandler } from '@hono/hono'; 2 | import type { DittoEnv } from './DittoEnv.ts'; 3 | 4 | // deno-lint-ignore ban-types 5 | export type DittoMiddleware = MiddlewareHandler; 6 | -------------------------------------------------------------------------------- /packages/mastoapi/router/DittoRoute.test.ts: -------------------------------------------------------------------------------- 1 | import { assertRejects } from '@std/assert'; 2 | 3 | import { DittoRoute } from './DittoRoute.ts'; 4 | 5 | Deno.test('DittoRoute', async () => { 6 | const route = new DittoRoute(); 7 | 8 | await assertRejects( 9 | async () => { 10 | await route.request('/'); 11 | }, 12 | Error, 13 | 'Missing required variable: db', 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/mastoapi/router/mod.ts: -------------------------------------------------------------------------------- 1 | export { DittoApp } from './DittoApp.ts'; 2 | export { DittoRoute } from './DittoRoute.ts'; 3 | 4 | export type { DittoEnv } from './DittoEnv.ts'; 5 | export type { DittoMiddleware } from './DittoMiddleware.ts'; 6 | -------------------------------------------------------------------------------- /packages/mastoapi/signers/ReadOnlySigner.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file require-await 2 | import { HTTPException } from '@hono/hono/http-exception'; 3 | 4 | import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; 5 | 6 | export class ReadOnlySigner implements NostrSigner { 7 | constructor(private pubkey: string) {} 8 | 9 | async signEvent(): Promise { 10 | throw new HTTPException(401, { 11 | message: 'Log in with Nostr Connect to sign events', 12 | }); 13 | } 14 | 15 | async getPublicKey(): Promise { 16 | return this.pubkey; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/mastoapi/test.ts: -------------------------------------------------------------------------------- 1 | export { TestApp } from './test/TestApp.ts'; 2 | -------------------------------------------------------------------------------- /packages/mastoapi/types/MastodonAttachment.ts: -------------------------------------------------------------------------------- 1 | export interface MastodonAttachment { 2 | id: string; 3 | type: string; 4 | url: string; 5 | preview_url?: string; 6 | remote_url?: string | null; 7 | description?: string; 8 | blurhash?: string | null; 9 | meta?: { 10 | original?: { 11 | width?: number; 12 | height?: number; 13 | }; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /packages/mastoapi/types/MastodonMention.ts: -------------------------------------------------------------------------------- 1 | export interface MastodonMention { 2 | acct: string; 3 | id: string; 4 | url: string; 5 | username: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/mastoapi/types/MastodonPreviewCard.ts: -------------------------------------------------------------------------------- 1 | export interface MastodonPreviewCard { 2 | url: string; 3 | title: string; 4 | description: string; 5 | type: 'link' | 'photo' | 'video' | 'rich'; 6 | author_name: string; 7 | author_url: string; 8 | provider_name: string; 9 | provider_url: string; 10 | html: string; 11 | width: number; 12 | height: number; 13 | image: string | null; 14 | embed_url: string; 15 | blurhash: string | null; 16 | } 17 | -------------------------------------------------------------------------------- /packages/mastoapi/types/MastodonStatus.ts: -------------------------------------------------------------------------------- 1 | import type { MastodonAccount } from './MastodonAccount.ts'; 2 | import type { MastodonAttachment } from './MastodonAttachment.ts'; 3 | import type { MastodonPreviewCard } from './MastodonPreviewCard.ts'; 4 | 5 | export interface MastodonStatus { 6 | id: string; 7 | account: MastodonAccount; 8 | application?: { 9 | name: string; 10 | website: string | null; 11 | }; 12 | card: MastodonPreviewCard | null; 13 | content: string; 14 | created_at: string; 15 | in_reply_to_id: string | null; 16 | in_reply_to_account_id: string | null; 17 | sensitive: boolean; 18 | spoiler_text: string; 19 | visibility: string; 20 | language: string | null; 21 | replies_count: number; 22 | reblogs_count: number; 23 | favourites_count: number; 24 | zaps_amount: number; 25 | zaps_amount_cashu: number; 26 | favourited: boolean; 27 | reblogged: boolean; 28 | muted: boolean; 29 | bookmarked: boolean; 30 | pinned: boolean; 31 | reblog: MastodonStatus | null; 32 | media_attachments: MastodonAttachment[]; 33 | mentions: unknown[]; 34 | tags: unknown[]; 35 | emojis: unknown[]; 36 | poll: unknown; 37 | quote?: MastodonStatus | null; 38 | quote_id: string | null; 39 | uri: string; 40 | url: string; 41 | zapped: boolean; 42 | zapped_cashu: boolean; 43 | pleroma: { 44 | emoji_reactions: { name: string; count: number; me: boolean }[]; 45 | expires_at?: string; 46 | quotes_count: number; 47 | }; 48 | ditto: { 49 | external_url: string; 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /packages/mastoapi/types/MastodonTranslation.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageCode } from 'iso-639-1'; 2 | 3 | /** https://docs.joinmastodon.org/entities/Translation/ */ 4 | export interface MastodonTranslation { 5 | /** HTML-encoded translated content of the status. */ 6 | content: string; 7 | /** The translated spoiler warning of the status. */ 8 | spoiler_text: string; 9 | /** The translated media descriptions of the status. */ 10 | media_attachments: { id: string; description: string }[]; 11 | /** The translated poll of the status. */ 12 | poll: { id: string; options: { title: string }[] } | null; 13 | //** The language of the source text, as auto-detected by the machine translation provider. */ 14 | detected_source_language: LanguageCode; 15 | /** The service that provided the machine translation. */ 16 | provider: string; 17 | } 18 | -------------------------------------------------------------------------------- /packages/mastoapi/types/mod.ts: -------------------------------------------------------------------------------- 1 | export type { MastodonAccount } from './MastodonAccount.ts'; 2 | export type { MastodonAttachment } from './MastodonAttachment.ts'; 3 | export type { MastodonMention } from './MastodonMention.ts'; 4 | export type { MastodonPreviewCard } from './MastodonPreviewCard.ts'; 5 | export type { MastodonStatus } from './MastodonStatus.ts'; 6 | export type { MastodonTranslation } from './MastodonTranslation.ts'; 7 | -------------------------------------------------------------------------------- /packages/metrics/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/metrics", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./metrics.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/nip98/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/nip98", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./nip98.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/nip98/schema.ts: -------------------------------------------------------------------------------- 1 | import { NSchema as n } from '@nostrify/nostrify'; 2 | import { getEventHash, verifyEvent } from 'nostr-tools'; 3 | import z from 'zod'; 4 | 5 | /** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */ 6 | export const decode64Schema = z.string().transform((value, ctx) => { 7 | try { 8 | const binString = atob(value); 9 | const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!); 10 | return new TextDecoder().decode(bytes); 11 | } catch (_e) { 12 | ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid base64', fatal: true }); 13 | return z.NEVER; 14 | } 15 | }); 16 | 17 | /** Nostr event schema that also verifies the event's signature. */ 18 | export const signedEventSchema = n.event() 19 | .refine((event) => event.id === getEventHash(event), 'Event ID does not match hash') 20 | .refine(verifyEvent, 'Event signature is invalid'); 21 | -------------------------------------------------------------------------------- /packages/policies/MuteListPolicy.ts: -------------------------------------------------------------------------------- 1 | import type { NostrEvent, NostrRelayOK, NPolicy, NStore } from '@nostrify/nostrify'; 2 | 3 | export class MuteListPolicy implements NPolicy { 4 | constructor(private pubkey: string, private store: NStore) {} 5 | 6 | async call(event: NostrEvent): Promise { 7 | const pubkeys = new Set(); 8 | 9 | const [muteList] = await this.store.query([{ authors: [this.pubkey], kinds: [10000], limit: 1 }]); 10 | 11 | for (const [name, value] of muteList?.tags ?? []) { 12 | if (name === 'p') { 13 | pubkeys.add(value); 14 | } 15 | } 16 | 17 | if (pubkeys.has(event.pubkey)) { 18 | return ['OK', event.id, false, 'blocked: account blocked']; 19 | } 20 | 21 | return ['OK', event.id, true, '']; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/policies/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/policies", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/policies/mod.ts: -------------------------------------------------------------------------------- 1 | export { MuteListPolicy } from './MuteListPolicy.ts'; 2 | -------------------------------------------------------------------------------- /packages/ratelimiter/MemoryRateLimiter.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from '@std/assert'; 2 | 3 | import { MemoryRateLimiter } from './MemoryRateLimiter.ts'; 4 | import { RateLimitError } from './RateLimitError.ts'; 5 | 6 | Deno.test('MemoryRateLimiter', async (t) => { 7 | const limit = 5; 8 | const window = 100; 9 | 10 | using limiter = new MemoryRateLimiter({ limit, window }); 11 | 12 | await t.step('can hit up to limit', () => { 13 | for (let i = 0; i < limit; i++) { 14 | const client = limiter.client('test'); 15 | assertEquals(client.hits, i); 16 | client.hit(); 17 | } 18 | }); 19 | 20 | await t.step('throws when hit if limit exceeded', () => { 21 | assertThrows(() => limiter.client('test').hit(), RateLimitError); 22 | }); 23 | 24 | await t.step('can hit after window resets', async () => { 25 | await new Promise((resolve) => setTimeout(resolve, window + 1)); 26 | 27 | const client = limiter.client('test'); 28 | assertEquals(client.hits, 0); 29 | client.hit(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/ratelimiter/MultiRateLimiter.ts: -------------------------------------------------------------------------------- 1 | import type { RateLimiter, RateLimiterClient } from './types.ts'; 2 | 3 | export class MultiRateLimiter { 4 | constructor(private limiters: RateLimiter[]) {} 5 | 6 | client(key: string): MultiRateLimiterClient { 7 | return new MultiRateLimiterClient(key, this.limiters); 8 | } 9 | } 10 | 11 | class MultiRateLimiterClient implements RateLimiterClient { 12 | constructor(private key: string, private limiters: RateLimiter[]) { 13 | if (!limiters.length) { 14 | throw new Error('No limiters provided'); 15 | } 16 | } 17 | 18 | /** Returns the _active_ limiter, which is either the first exceeded or the first. */ 19 | get limiter(): RateLimiter { 20 | const exceeded = this.limiters.find((limiter) => limiter.client(this.key).remaining < 0); 21 | return exceeded ?? this.limiters[0]; 22 | } 23 | 24 | get hits(): number { 25 | return this.limiter.client(this.key).hits; 26 | } 27 | 28 | get resetAt(): Date { 29 | return this.limiter.client(this.key).resetAt; 30 | } 31 | 32 | get remaining(): number { 33 | return this.limiter.client(this.key).remaining; 34 | } 35 | 36 | hit(n?: number): void { 37 | let error: unknown; 38 | 39 | for (const limiter of this.limiters) { 40 | try { 41 | limiter.client(this.key).hit(n); 42 | } catch (e) { 43 | error ??= e; 44 | } 45 | } 46 | 47 | if (error instanceof Error) { 48 | throw error; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/ratelimiter/RateLimitError.ts: -------------------------------------------------------------------------------- 1 | import type { RateLimiter, RateLimiterClient } from './types.ts'; 2 | 3 | export class RateLimitError extends Error { 4 | constructor( 5 | readonly limiter: RateLimiter, 6 | readonly client: RateLimiterClient, 7 | ) { 8 | super('Rate limit exceeded'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/ratelimiter/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/ratelimiter", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/ratelimiter/mod.ts: -------------------------------------------------------------------------------- 1 | export { MemoryRateLimiter } from './MemoryRateLimiter.ts'; 2 | export { MultiRateLimiter } from './MultiRateLimiter.ts'; 3 | export { RateLimitError } from './RateLimitError.ts'; 4 | 5 | export type { RateLimiter, RateLimiterClient } from './types.ts'; 6 | -------------------------------------------------------------------------------- /packages/ratelimiter/types.ts: -------------------------------------------------------------------------------- 1 | export interface RateLimiter extends Disposable { 2 | readonly limit: number; 3 | readonly window: number; 4 | client(key: string): RateLimiterClient; 5 | } 6 | 7 | export interface RateLimiterClient { 8 | readonly hits: number; 9 | readonly resetAt: Date; 10 | readonly remaining: number; 11 | hit(n?: number): void; 12 | } 13 | -------------------------------------------------------------------------------- /packages/transcode/.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ -------------------------------------------------------------------------------- /packages/transcode/analyze.test.ts: -------------------------------------------------------------------------------- 1 | import { assertObjectMatch } from '@std/assert'; 2 | 3 | import { analyzeFile } from './analyze.ts'; 4 | 5 | Deno.test('analyzeFile', async () => { 6 | const uri = new URL('./buckbunny.mp4', import.meta.url); 7 | 8 | const { streams } = await analyzeFile(uri); 9 | 10 | const videoStream = streams.find((stream) => stream.codec_type === 'video')!; 11 | 12 | assertObjectMatch(videoStream, { width: 1920, height: 1080 }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/transcode/buckbunny.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soapbox-pub/ditto/370deac1af69148cfc5d9e9a0d7a2a770df25668/packages/transcode/buckbunny.mp4 -------------------------------------------------------------------------------- /packages/transcode/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/transcode", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/transcode/ffmpeg.test.ts: -------------------------------------------------------------------------------- 1 | import { ffmpeg } from './ffmpeg.ts'; 2 | 3 | const uri = new URL('./buckbunny.mp4', import.meta.url); 4 | 5 | Deno.test('ffmpeg', async () => { 6 | await using file = await Deno.open(uri); 7 | 8 | const output = ffmpeg(file.readable, { 9 | 'c:v': 'libx264', 10 | 'preset': 'veryfast', 11 | 'loglevel': 'fatal', 12 | 'movflags': 'frag_keyframe+empty_moov', 13 | 'f': 'mp4', 14 | }); 15 | 16 | await Deno.mkdir(new URL('./tmp', import.meta.url), { recursive: true }); 17 | await Deno.writeFile(new URL('./tmp/transcoded-1.mp4', import.meta.url), output); 18 | }); 19 | 20 | Deno.test('ffmpeg from file URI', async () => { 21 | const output = ffmpeg(uri, { 22 | 'c:v': 'libx264', 23 | 'preset': 'veryfast', 24 | 'loglevel': 'fatal', 25 | 'movflags': 'frag_keyframe+empty_moov', 26 | 'f': 'mp4', 27 | }); 28 | 29 | await Deno.mkdir(new URL('./tmp', import.meta.url), { recursive: true }); 30 | await Deno.writeFile(new URL('./tmp/transcoded-2.mp4', import.meta.url), output); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/transcode/ffprobe.test.ts: -------------------------------------------------------------------------------- 1 | import { assertObjectMatch } from '@std/assert'; 2 | 3 | import { ffprobe } from './ffprobe.ts'; 4 | 5 | const uri = new URL('./buckbunny.mp4', import.meta.url); 6 | 7 | Deno.test('ffprobe from ReadableStream', async () => { 8 | await using file = await Deno.open(uri); 9 | 10 | const stream = ffprobe(file.readable, { 11 | 'v': 'error', 12 | 'select_streams': 'v:0', 13 | 'show_entries': 'stream=width,height', 14 | 'of': 'json', 15 | }); 16 | 17 | const { streams: [dimensions] } = await new Response(stream).json(); 18 | 19 | assertObjectMatch(dimensions, { width: 1920, height: 1080 }); 20 | }); 21 | 22 | Deno.test('ffprobe from file URI', async () => { 23 | const stream = ffprobe(uri, { 24 | 'v': 'error', 25 | 'select_streams': 'v:0', 26 | 'show_entries': 'stream=width,height', 27 | 'of': 'json', 28 | }); 29 | 30 | const { streams: [dimensions] } = await new Response(stream).json(); 31 | 32 | assertObjectMatch(dimensions, { width: 1920, height: 1080 }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/transcode/ffprobe.ts: -------------------------------------------------------------------------------- 1 | export interface FFprobeFlags { 2 | 'v'?: string; 3 | 'select_streams'?: string; 4 | 'show_entries'?: string; 5 | 'of'?: string; 6 | [key: string]: string | undefined; 7 | } 8 | 9 | export function ffprobe( 10 | input: URL | ReadableStream, 11 | flags: FFprobeFlags, 12 | opts?: { ffprobePath?: string | URL }, 13 | ): ReadableStream { 14 | const { ffprobePath = 'ffprobe' } = opts ?? {}; 15 | 16 | const args = []; 17 | 18 | for (const [key, value] of Object.entries(flags)) { 19 | if (typeof value === 'string') { 20 | if (value) { 21 | args.push(`-${key}`, value); 22 | } else { 23 | args.push(`-${key}`); 24 | } 25 | } 26 | } 27 | 28 | if (input instanceof URL) { 29 | args.push('-i', input.href); 30 | } else { 31 | args.push('-i', 'pipe:0'); 32 | } 33 | 34 | // Spawn the FFprobe process 35 | const command = new Deno.Command(ffprobePath, { 36 | args, 37 | stdin: input instanceof ReadableStream ? 'piped' : 'null', 38 | stdout: 'piped', 39 | }); 40 | 41 | const child = command.spawn(); 42 | 43 | // Pipe the input stream into FFmpeg stdin and ensure completion 44 | if (input instanceof ReadableStream) { 45 | input.pipeTo(child.stdin).catch((e: unknown) => { 46 | if (e instanceof Error && e.name === 'BrokenPipe') { 47 | // Ignore. ffprobe closes the pipe once it has read the metadata. 48 | } else { 49 | throw e; 50 | } 51 | }); 52 | } 53 | 54 | // Return the FFmpeg stdout stream 55 | return child.stdout; 56 | } 57 | -------------------------------------------------------------------------------- /packages/transcode/frame.test.ts: -------------------------------------------------------------------------------- 1 | import { extractVideoFrame } from './frame.ts'; 2 | 3 | const uri = new URL('./buckbunny.mp4', import.meta.url); 4 | 5 | Deno.test('extractVideoFrame', async () => { 6 | await using file = await Deno.open(uri); 7 | 8 | const result = await extractVideoFrame(file.readable); 9 | 10 | await Deno.mkdir(new URL('./tmp', import.meta.url), { recursive: true }); 11 | await Deno.writeFile(new URL('./tmp/poster.jpg', import.meta.url), result); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/transcode/frame.ts: -------------------------------------------------------------------------------- 1 | import { ffmpeg } from './ffmpeg.ts'; 2 | 3 | export function extractVideoFrame( 4 | input: URL | ReadableStream, 5 | ss: string = '00:00:01', 6 | opts?: { ffmpegPath?: string | URL }, 7 | ): Promise { 8 | const output = ffmpeg(input, { 9 | 'ss': ss, // Seek to timestamp 10 | 'frames:v': '1', // Extract only 1 frame 11 | 'q:v': '2', // High-quality JPEG (lower = better quality) 12 | 'f': 'image2', // Force image format 13 | 'loglevel': 'fatal', 14 | }, opts); 15 | 16 | return new Response(output).bytes(); 17 | } 18 | -------------------------------------------------------------------------------- /packages/transcode/mod.ts: -------------------------------------------------------------------------------- 1 | export { analyzeFile } from './analyze.ts'; 2 | export { ffmpeg, type FFmpegFlags } from './ffmpeg.ts'; 3 | export { ffprobe, type FFprobeFlags } from './ffprobe.ts'; 4 | export { extractVideoFrame } from './frame.ts'; 5 | export { transcodeVideo } from './transcode.ts'; 6 | -------------------------------------------------------------------------------- /packages/transcode/transcode.test.ts: -------------------------------------------------------------------------------- 1 | import { transcodeVideo } from './transcode.ts'; 2 | 3 | Deno.test('transcodeVideo', async () => { 4 | await using file = await Deno.open(new URL('./buckbunny.mp4', import.meta.url)); 5 | const output = transcodeVideo(file.readable); 6 | 7 | await Deno.mkdir(new URL('./tmp', import.meta.url), { recursive: true }); 8 | await Deno.writeFile(new URL('./tmp/buckbunny-transcoded.mp4', import.meta.url), output); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/transcode/transcode.ts: -------------------------------------------------------------------------------- 1 | import { ffmpeg } from './ffmpeg.ts'; 2 | 3 | export function transcodeVideo( 4 | input: URL | ReadableStream, 5 | opts?: { ffmpegPath?: string | URL }, 6 | ): ReadableStream { 7 | return ffmpeg(input, { 8 | 'safe': '1', // Safe mode 9 | 'nostdin': '', // Disable stdin 10 | 'c:v': 'libx264', // Convert to H.264 11 | 'preset': 'veryfast', // Encoding speed 12 | 'loglevel': 'fatal', // Suppress logs 13 | 'crf': '23', // Compression level (lower = better quality) 14 | 'c:a': 'aac', // Convert to AAC audio 15 | 'b:a': '128k', // Audio bitrate 16 | 'movflags': 'frag_keyframe+empty_moov', // Ensures MP4 streaming compatibility 17 | 'f': 'mp4', // Force MP4 format 18 | }, opts); 19 | } 20 | -------------------------------------------------------------------------------- /packages/translators/DittoTranslator.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageCode } from 'iso-639-1'; 2 | 3 | /** DittoTranslator class, used for status translation. */ 4 | export interface DittoTranslator { 5 | /** Provider name, eg `DeepL.com` */ 6 | provider: string; 7 | /** Translate the 'content' into 'targetLanguage'. */ 8 | translate( 9 | /** Texts to translate. */ 10 | texts: string[], 11 | /** The language of the source texts. */ 12 | sourceLanguage: LanguageCode | undefined, 13 | /** The texts will be translated into this language. */ 14 | targetLanguage: LanguageCode, 15 | /** Custom options. */ 16 | opts?: { signal?: AbortSignal }, 17 | ): Promise<{ results: string[]; sourceLang: LanguageCode }>; 18 | } 19 | -------------------------------------------------------------------------------- /packages/translators/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/translators", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/translators/mod.ts: -------------------------------------------------------------------------------- 1 | export { DeepLTranslator } from './DeepLTranslator.ts'; 2 | export { LibreTranslateTranslator } from './LibreTranslateTranslator.ts'; 3 | 4 | export type { DittoTranslator } from './DittoTranslator.ts'; 5 | -------------------------------------------------------------------------------- /packages/translators/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '@std/assert'; 2 | 3 | import { languageSchema } from './schema.ts'; 4 | 5 | Deno.test('languageSchema', () => { 6 | assertEquals(languageSchema.safeParse('pt').success, true); 7 | assertEquals(languageSchema.safeParse('PT').success, false); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/translators/schema.ts: -------------------------------------------------------------------------------- 1 | import ISO6391 from 'iso-639-1'; 2 | import z from 'zod'; 3 | 4 | /** Value is a ISO-639-1 language code. */ 5 | export const languageSchema = z.string().refine( 6 | (val) => ISO6391.validate(val), 7 | { message: 'Not a valid language in ISO-639-1 format' }, 8 | ); 9 | -------------------------------------------------------------------------------- /packages/uploaders/DenoUploader.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | 3 | import { crypto } from '@std/crypto'; 4 | import { encodeHex } from '@std/encoding/hex'; 5 | import { extensionsByType } from '@std/media-types'; 6 | 7 | import type { NUploader } from '@nostrify/nostrify'; 8 | 9 | export interface DenoUploaderOpts { 10 | baseUrl: string; 11 | dir: string; 12 | } 13 | 14 | /** Local Deno filesystem uploader. */ 15 | export class DenoUploader implements NUploader { 16 | baseUrl: string; 17 | dir: string; 18 | 19 | constructor(opts: DenoUploaderOpts) { 20 | this.baseUrl = opts.baseUrl; 21 | this.dir = opts.dir; 22 | } 23 | 24 | async upload(file: File): Promise<[['url', string], ...string[][]]> { 25 | const sha256 = encodeHex(await crypto.subtle.digest('SHA-256', file.stream())); 26 | const ext = extensionsByType(file.type)?.[0] ?? 'bin'; 27 | const filename = `${sha256}.${ext}`; 28 | 29 | await Deno.mkdir(this.dir, { recursive: true }); 30 | await Deno.writeFile(join(this.dir, filename), file.stream()); 31 | 32 | const url = new URL(this.baseUrl); 33 | const path = url.pathname === '/' ? filename : join(url.pathname, filename); 34 | 35 | return [ 36 | ['url', new URL(path, url).toString()], 37 | ['m', file.type], 38 | ['x', sha256], 39 | ['size', file.size.toString()], 40 | ]; 41 | } 42 | 43 | async delete(filename: string) { 44 | const path = join(this.dir, filename); 45 | await Deno.remove(path); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/uploaders/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ditto/uploaders", 3 | "version": "0.1.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/uploaders/mod.ts: -------------------------------------------------------------------------------- 1 | export { DenoUploader } from './DenoUploader.ts'; 2 | export { IPFSUploader } from './IPFSUploader.ts'; 3 | export { S3Uploader } from './S3Uploader.ts'; 4 | -------------------------------------------------------------------------------- /public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /scripts/admin-event.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { DittoPolyPg } from '@ditto/db'; 3 | import { JsonParseStream } from '@std/json/json-parse-stream'; 4 | import { TextLineStream } from '@std/streams/text-line-stream'; 5 | 6 | import { DittoPgStore } from '../packages/ditto/storages/DittoPgStore.ts'; 7 | import { type EventStub } from '../packages/ditto/utils/api.ts'; 8 | import { nostrNow } from '../packages/ditto/utils.ts'; 9 | 10 | const conf = new DittoConf(Deno.env); 11 | const db = new DittoPolyPg(conf.databaseUrl); 12 | const relay = new DittoPgStore({ db, conf }); 13 | 14 | const { signer } = conf; 15 | 16 | const readable = Deno.stdin.readable 17 | .pipeThrough(new TextDecoderStream()) 18 | .pipeThrough(new TextLineStream()) 19 | .pipeThrough(new JsonParseStream()); 20 | 21 | for await (const t of readable) { 22 | const event = await signer.signEvent({ 23 | content: '', 24 | created_at: nostrNow(), 25 | tags: [], 26 | ...t as EventStub, 27 | }); 28 | 29 | await relay.event(event); 30 | } 31 | 32 | Deno.exit(0); 33 | -------------------------------------------------------------------------------- /scripts/db-import.ts: -------------------------------------------------------------------------------- 1 | import { Semaphore } from '@core/asyncutil'; 2 | import { DittoConf } from '@ditto/conf'; 3 | import { DittoPolyPg } from '@ditto/db'; 4 | import { NostrEvent } from '@nostrify/nostrify'; 5 | import { JsonParseStream } from '@std/json/json-parse-stream'; 6 | import { TextLineStream } from '@std/streams/text-line-stream'; 7 | 8 | import { DittoPgStore } from '../packages/ditto/storages/DittoPgStore.ts'; 9 | 10 | const conf = new DittoConf(Deno.env); 11 | const db = new DittoPolyPg(conf.databaseUrl); 12 | const relay = new DittoPgStore({ db, conf }); 13 | const sem = new Semaphore(conf.pg.poolSize); 14 | 15 | console.warn('Importing events...'); 16 | 17 | let count = 0; 18 | 19 | const readable = Deno.stdin.readable 20 | .pipeThrough(new TextDecoderStream()) 21 | .pipeThrough(new TextLineStream()) 22 | .pipeThrough(new JsonParseStream()); 23 | 24 | for await (const line of readable) { 25 | const event = line as unknown as NostrEvent; 26 | 27 | while (sem.locked) { 28 | await new Promise((resolve) => setTimeout(resolve, 0)); 29 | } 30 | 31 | sem.lock(async () => { 32 | try { 33 | await relay.event(event); 34 | console.warn(`(${count}) Event<${event.kind}> ${event.id}`); 35 | } catch (error) { 36 | if (error instanceof Error && error.message.includes('violates unique constraint')) { 37 | console.warn(`(${count}) Skipping existing event... ${event.id}`); 38 | } else { 39 | console.error(error); 40 | } 41 | } 42 | count++; 43 | }); 44 | } 45 | 46 | console.warn(`Imported ${count} events`); 47 | Deno.exit(); 48 | -------------------------------------------------------------------------------- /scripts/db-migrate.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { DittoPolyPg } from '@ditto/db'; 3 | 4 | const conf = new DittoConf(Deno.env); 5 | await using db = new DittoPolyPg(conf.databaseUrl); 6 | 7 | await db.migrate(); 8 | 9 | Deno.exit(); 10 | -------------------------------------------------------------------------------- /scripts/db-policy.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { DittoPolyPg } from '@ditto/db'; 3 | 4 | import { DittoPgStore } from '../packages/ditto/storages/DittoPgStore.ts'; 5 | import { PolicyWorker } from '../packages/ditto/workers/policy.ts'; 6 | 7 | const conf = new DittoConf(Deno.env); 8 | const db = new DittoPolyPg(conf.databaseUrl); 9 | const relay = new DittoPgStore({ db, conf }); 10 | const policyWorker = new PolicyWorker(conf); 11 | 12 | let count = 0; 13 | 14 | for await (const msg of relay.req([{}])) { 15 | const [type, , event] = msg; 16 | if (type === 'EOSE') console.log('EOSE'); 17 | if (type !== 'EVENT') continue; 18 | const [, , ok] = await policyWorker.call(event, AbortSignal.timeout(5000)); 19 | if (!ok) { 20 | await relay.remove([{ ids: [event.id] }]); 21 | count += 1; 22 | } 23 | } 24 | 25 | console.log(`Cleaned up ${count} events from the db.`); 26 | Deno.exit(0); 27 | -------------------------------------------------------------------------------- /scripts/db-populate-extensions.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { DittoPolyPg } from '@ditto/db'; 3 | import { NostrEvent } from '@nostrify/nostrify'; 4 | 5 | import { DittoPgStore } from '../packages/ditto/storages/DittoPgStore.ts'; 6 | 7 | const conf = new DittoConf(Deno.env); 8 | const db = new DittoPolyPg(conf.databaseUrl); 9 | 10 | const query = db.kysely 11 | .selectFrom('nostr_events') 12 | .select(['id', 'kind', 'content', 'pubkey', 'tags', 'created_at', 'sig']); 13 | 14 | for await (const row of query.stream()) { 15 | const event: NostrEvent = { ...row, created_at: Number(row.created_at) }; 16 | const ext = DittoPgStore.indexExtensions(event); 17 | 18 | try { 19 | await db.kysely 20 | .updateTable('nostr_events') 21 | .set('search_ext', ext) 22 | .where('id', '=', event.id) 23 | .execute(); 24 | } catch { 25 | // do nothing 26 | } 27 | } 28 | 29 | Deno.exit(); 30 | -------------------------------------------------------------------------------- /scripts/db-populate-nip05.ts: -------------------------------------------------------------------------------- 1 | import { Semaphore } from '@core/asyncutil'; 2 | import { NostrEvent } from '@nostrify/nostrify'; 3 | import { MockRelay } from '@nostrify/nostrify/test'; 4 | 5 | import { DittoConf } from '@ditto/conf'; 6 | import { DittoPolyPg } from '@ditto/db'; 7 | 8 | import { DittoPgStore } from '../packages/ditto/storages/DittoPgStore.ts'; 9 | import { DittoRelayStore } from '../packages/ditto/storages/DittoRelayStore.ts'; 10 | 11 | const conf = new DittoConf(Deno.env); 12 | const db = new DittoPolyPg(conf.databaseUrl); 13 | 14 | const pgstore = new DittoPgStore({ db, conf }); 15 | const relaystore = new DittoRelayStore({ conf, db, pool: new MockRelay(), relay: pgstore }); 16 | 17 | const sem = new Semaphore(5); 18 | 19 | const query = db.kysely 20 | .selectFrom('nostr_events') 21 | .select(['id', 'kind', 'content', 'pubkey', 'tags', 'created_at', 'sig']) 22 | .where('kind', '=', 0); 23 | 24 | for await (const row of query.stream(100)) { 25 | while (sem.locked) { 26 | await new Promise((resolve) => setTimeout(resolve, 0)); 27 | } 28 | 29 | sem.lock(async () => { 30 | const event: NostrEvent = { ...row, created_at: Number(row.created_at) }; 31 | await relaystore.updateAuthorData(event, AbortSignal.timeout(3000)); 32 | }); 33 | } 34 | 35 | Deno.exit(); 36 | -------------------------------------------------------------------------------- /scripts/db-populate-search.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { DittoPolyPg } from '@ditto/db'; 3 | import { NSchema as n } from '@nostrify/nostrify'; 4 | 5 | import { DittoPgStore } from '../packages/ditto/storages/DittoPgStore.ts'; 6 | 7 | const conf = new DittoConf(Deno.env); 8 | const db = new DittoPolyPg(conf.databaseUrl); 9 | const relay = new DittoPgStore({ db, conf }); 10 | 11 | for await (const msg of relay.req([{ kinds: [0] }])) { 12 | if (msg[0] === 'EVENT') { 13 | const { pubkey, content } = msg[2]; 14 | 15 | const { name, nip05 } = n.json().pipe(n.metadata()).catch({}).parse(content); 16 | const search = [name, nip05].filter(Boolean).join(' ').trim(); 17 | 18 | try { 19 | await db.kysely.insertInto('author_stats').values({ 20 | pubkey, 21 | search, 22 | followers_count: 0, 23 | following_count: 0, 24 | notes_count: 0, 25 | }).onConflict( 26 | (oc) => 27 | oc.column('pubkey') 28 | .doUpdateSet((eb) => ({ search: eb.ref('excluded.search') })), 29 | ) 30 | .execute(); 31 | } catch { 32 | // do nothing 33 | } 34 | } else { 35 | break; 36 | } 37 | } 38 | 39 | Deno.exit(); 40 | -------------------------------------------------------------------------------- /scripts/db-streak-recompute.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { DittoPolyPg } from '@ditto/db'; 3 | 4 | const conf = new DittoConf(Deno.env); 5 | const db = new DittoPolyPg(conf.databaseUrl); 6 | 7 | const statsQuery = db.kysely.selectFrom('author_stats').select('pubkey'); 8 | const { streakWindow } = conf; 9 | 10 | for await (const { pubkey } of statsQuery.stream(10)) { 11 | const eventsQuery = db.kysely 12 | .selectFrom('nostr_events') 13 | .select('created_at') 14 | .where('pubkey', '=', pubkey) 15 | .where('kind', 'in', [1, 20, 1111, 30023]) 16 | .orderBy('nostr_events.created_at', 'desc') 17 | .orderBy('nostr_events.id', 'asc'); 18 | 19 | let end: number | null = null; 20 | let start: number | null = null; 21 | 22 | for await (const { created_at } of eventsQuery.stream(20)) { 23 | const createdAt = Number(created_at); 24 | 25 | if (!end) { 26 | const now = Math.floor(Date.now() / 1000); 27 | 28 | if (now - createdAt > streakWindow) { 29 | break; // streak broken 30 | } 31 | 32 | end = createdAt; 33 | } 34 | 35 | if (start && (start - createdAt > streakWindow)) { 36 | break; // streak broken 37 | } 38 | 39 | start = createdAt; 40 | } 41 | 42 | if (start && end) { 43 | await db.kysely 44 | .updateTable('author_stats') 45 | .set({ 46 | streak_end: end, 47 | streak_start: start, 48 | }) 49 | .where('pubkey', '=', pubkey) 50 | .execute(); 51 | } 52 | } 53 | 54 | Deno.exit(); 55 | -------------------------------------------------------------------------------- /scripts/deparameterize.ts: -------------------------------------------------------------------------------- 1 | const decoder = new TextDecoder(); 2 | 3 | for await (const chunk of Deno.stdin.readable) { 4 | const text = decoder.decode(chunk); 5 | 6 | const { sql, parameters } = JSON.parse(text) as { sql: string; parameters: unknown[] }; 7 | 8 | let result = sql; 9 | 10 | for (let i = 0; i < parameters.length; i++) { 11 | const param = parameters[i]; 12 | 13 | result = result.replace(`$${i + 1}`, serializeParameter(param)); 14 | } 15 | 16 | console.log(result + ';'); 17 | } 18 | 19 | function serializeParameter(param: unknown): string { 20 | if (param === null) { 21 | return 'null'; 22 | } 23 | 24 | if (typeof param === 'string') { 25 | return `'${param}'`; 26 | } 27 | 28 | if (typeof param === 'number' || typeof param === 'boolean') { 29 | return param.toString(); 30 | } 31 | 32 | if (param instanceof Date) { 33 | return `'${param.toISOString()}'`; 34 | } 35 | 36 | if (Array.isArray(param)) { 37 | return `'{${param.join(',')}}'`; 38 | } 39 | 40 | if (typeof param === 'object') { 41 | return `'${JSON.stringify(param)}'`; 42 | } 43 | 44 | return JSON.stringify(param); 45 | } 46 | -------------------------------------------------------------------------------- /scripts/nsec.ts: -------------------------------------------------------------------------------- 1 | import { generateSecretKey, nip19 } from 'nostr-tools'; 2 | 3 | const sk = generateSecretKey(); 4 | const nsec = nip19.nsecEncode(sk); 5 | 6 | console.log(nsec); 7 | -------------------------------------------------------------------------------- /scripts/stats-recompute.ts: -------------------------------------------------------------------------------- 1 | import { DittoConf } from '@ditto/conf'; 2 | import { DittoPolyPg } from '@ditto/db'; 3 | import { nip19 } from 'nostr-tools'; 4 | 5 | import { DittoPgStore } from '../packages/ditto/storages/DittoPgStore.ts'; 6 | import { refreshAuthorStats } from '../packages/ditto/utils/stats.ts'; 7 | 8 | const conf = new DittoConf(Deno.env); 9 | const db = new DittoPolyPg(conf.databaseUrl); 10 | const relay = new DittoPgStore({ db, conf }); 11 | 12 | const { kysely } = db; 13 | 14 | let pubkey: string; 15 | try { 16 | const result = nip19.decode(Deno.args[0]); 17 | if (result.type === 'npub') { 18 | pubkey = result.data; 19 | } else { 20 | throw new Error('Invalid npub'); 21 | } 22 | } catch { 23 | console.error('Invalid npub'); 24 | Deno.exit(1); 25 | } 26 | 27 | await refreshAuthorStats({ pubkey, kysely, relay }); 28 | -------------------------------------------------------------------------------- /scripts/vapid.ts: -------------------------------------------------------------------------------- 1 | import { generateVapidKeys } from '@negrel/webpush'; 2 | import { encodeBase64 } from '@std/encoding/base64'; 3 | 4 | const { privateKey } = await generateVapidKeys({ extractable: true }); 5 | const bytes = await crypto.subtle.exportKey('pkcs8', privateKey); 6 | 7 | console.log(encodeBase64(bytes)); 8 | --------------------------------------------------------------------------------