├── .github ├── dependabot.yml └── workflows │ ├── audit.yml │ ├── nightly.yml │ └── rust.yml ├── .gitignore ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── config.example.toml ├── migrations ├── 20200920112046_initial_layout.sql ├── 20201020174156_remote_user.sql ├── 20201022175053_activity_url.sql ├── 20201023130013_partial_unique_index_username.sql ├── 20201025201117_rename_activities_table.sql ├── 20201104191227_authorization.sql ├── 20201106224449_remove_not_null_application_id.sql ├── 20210119130652_remove_url_column.sql ├── 20210614155427_timestamp_to_timestamptz.sql └── 20210622131412_add_email_confirmation.sql ├── tranquility-content-length-limit ├── Cargo.toml └── src │ └── lib.rs ├── tranquility-http-signatures ├── .gitignore ├── Cargo.toml └── src │ ├── alg.rs │ ├── error.rs │ ├── key.rs │ ├── lib.rs │ ├── macros.rs │ ├── pem.rs │ ├── request.rs │ ├── signature.rs │ ├── sigstr.rs │ ├── tests.rs │ └── util.rs ├── tranquility-ratelimit ├── .gitignore ├── Cargo.toml └── src │ └── lib.rs ├── tranquility-types ├── .gitignore ├── Cargo.toml ├── README.md └── src │ ├── activitypub │ ├── activity.rs │ ├── actor.rs │ ├── attachment.rs │ ├── collection.rs │ ├── mod.rs │ ├── object.rs │ ├── tag.rs │ └── traits │ │ ├── mod.rs │ │ └── privacy.rs │ ├── lib.rs │ ├── mastodon │ ├── account.rs │ ├── app.rs │ ├── attachment.rs │ ├── card.rs │ ├── emoji.rs │ ├── field.rs │ ├── instance.rs │ ├── mention.rs │ ├── mod.rs │ ├── poll.rs │ ├── source.rs │ ├── status.rs │ └── tag.rs │ ├── nodeinfo.rs │ ├── tests.rs │ ├── util.rs │ └── webfinger.rs └── tranquility ├── .gitignore ├── Cargo.toml ├── build.rs ├── sqlx-data.json ├── src ├── activitypub │ ├── deliverer.rs │ ├── fetcher.rs │ ├── handler │ │ ├── accept.rs │ │ ├── announce.rs │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── follow.rs │ │ ├── like.rs │ │ ├── mod.rs │ │ ├── reject.rs │ │ ├── undo.rs │ │ └── update.rs │ ├── instantiate.rs │ ├── interactions.rs │ ├── mod.rs │ └── routes │ │ ├── followers.rs │ │ ├── following.rs │ │ ├── inbox.rs │ │ ├── mod.rs │ │ ├── objects.rs │ │ ├── outbox.rs │ │ └── users.rs ├── api │ ├── mastodon │ │ ├── accounts.rs │ │ ├── apps.rs │ │ ├── convert.rs │ │ ├── instance.rs │ │ ├── mod.rs │ │ └── statuses.rs │ ├── mod.rs │ ├── oauth │ │ ├── authorize.rs │ │ ├── mod.rs │ │ └── token.rs │ └── register.rs ├── cli.rs ├── config.rs ├── consts.rs ├── crypto.rs ├── daemon.rs ├── database │ ├── actor.rs │ ├── follow.rs │ ├── inbox_urls.rs │ ├── mod.rs │ ├── oauth │ │ ├── application.rs │ │ ├── authorization.rs │ │ ├── mod.rs │ │ └── token.rs │ ├── object.rs │ └── outbox.rs ├── email.rs ├── error.rs ├── macros.rs ├── main.rs ├── server.rs ├── state.rs ├── tests │ ├── mod.rs │ ├── nodeinfo.rs │ └── register.rs ├── util │ ├── mention.rs │ └── mod.rs └── well_known │ ├── mod.rs │ ├── nodeinfo.rs │ └── webfinger.rs └── templates ├── base.html └── oauth ├── authorize.html └── token.html /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | open-pull-requests-limit: 10 13 | commit-message: 14 | prefix: "chore" 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | push: 4 | paths: 5 | - '**/Cargo.toml' 6 | - '**/Cargo.lock' 7 | 8 | jobs: 9 | security_audit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions-rs/audit-check@v1 14 | with: 15 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | - cron: '0 1 * * *' 4 | 5 | name: Nightly build 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | build: 12 | name: Build (Linux x86) 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout sources 16 | uses: actions/checkout@v2 17 | 18 | - name: Install stable toolchain 19 | uses: actions-rs/toolchain@v1 20 | with: 21 | toolchain: stable 22 | override: true 23 | 24 | - uses: actions/cache@v2 25 | with: 26 | path: | 27 | ~/.cargo/registry 28 | ~/.cargo/git 29 | target 30 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 31 | 32 | - name: Run cargo build 33 | uses: actions-rs/cargo@v1 34 | with: 35 | command: build 36 | args: --release --features email,jaeger,markdown 37 | 38 | - shell: bash 39 | run: strip target/release/tranquility 40 | 41 | - shell: bash 42 | run: mv target/release/tranquility target/release/tranquility-x86 43 | 44 | - uses: actions/upload-artifact@v2 45 | with: 46 | name: tranquility-linux 47 | path: target/release/tranquility-x86 48 | retention-days: 1 49 | 50 | build_armv7: 51 | name: Build (Linux ARMv7) 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v2 55 | - uses: actions-rs/toolchain@v1 56 | with: 57 | toolchain: stable 58 | target: armv7-unknown-linux-gnueabihf 59 | override: true 60 | 61 | - uses: actions/cache@v2 62 | with: 63 | path: | 64 | ~/.cargo/registry 65 | ~/.cargo/git 66 | target 67 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 68 | 69 | - uses: actions-rs/cargo@v1 70 | with: 71 | use-cross: true 72 | command: build 73 | args: --release --target armv7-unknown-linux-gnueabihf --features jaeger,markdown 74 | 75 | - shell: bash 76 | run: mv target/armv7-unknown-linux-gnueabihf/release/tranquility target/armv7-unknown-linux-gnueabihf/release/tranquility-armv7 77 | 78 | - uses: actions/upload-artifact@v2 79 | with: 80 | name: tranquility-linux-armv7 81 | path: target/armv7-unknown-linux-gnueabihf/release/tranquility-armv7 82 | retention-days: 1 83 | 84 | publish: 85 | needs: [build, build_armv7] 86 | runs-on: ubuntu-latest 87 | steps: 88 | - uses: dev-drprasad/delete-tag-and-release@v0.1.3 89 | env: 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | with: 92 | delete_release: true 93 | tag_name: nightly 94 | 95 | - uses: actions/download-artifact@v2 96 | 97 | - name: Upload binaries 98 | uses: meeDamian/github-release@2.0 99 | with: 100 | token: ${{ secrets.GITHUB_TOKEN }} 101 | tag: nightly 102 | prerelease: true 103 | gzip: false 104 | files: > 105 | tranquility-linux-x86:./tranquility-linux/tranquility-x86 106 | tranquility-linux-armv7:./tranquility-linux-armv7/tranquility-armv7 107 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | pull_request: 5 | 6 | push: 7 | branches: 8 | - main 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | check: 15 | name: Check 16 | needs: fmt 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: actions-rs/toolchain@v1 21 | with: 22 | profile: minimal 23 | toolchain: stable 24 | override: true 25 | 26 | - uses: actions/cache@v2 27 | with: 28 | path: | 29 | ~/.cargo/registry 30 | ~/.cargo/git 31 | target 32 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 33 | 34 | - uses: actions-rs/cargo@v1 35 | with: 36 | command: check 37 | 38 | check_all_features: 39 | name: Check (all features) 40 | needs: fmt 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions-rs/toolchain@v1 45 | with: 46 | profile: minimal 47 | toolchain: stable 48 | override: true 49 | 50 | - uses: actions/cache@v2 51 | with: 52 | path: | 53 | ~/.cargo/registry 54 | ~/.cargo/git 55 | target 56 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 57 | 58 | - uses: actions-rs/cargo@v1 59 | with: 60 | command: check 61 | args: --all-features 62 | 63 | check_no_default: 64 | name: Check (no features) 65 | needs: fmt 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v2 69 | - uses: actions-rs/toolchain@v1 70 | with: 71 | profile: minimal 72 | toolchain: stable 73 | override: true 74 | 75 | - uses: actions/cache@v2 76 | with: 77 | path: | 78 | ~/.cargo/registry 79 | ~/.cargo/git 80 | target 81 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 82 | 83 | - uses: actions-rs/cargo@v1 84 | with: 85 | command: check 86 | args: --no-default-features 87 | 88 | test: 89 | name: Test 90 | needs: [ fmt, clippy, check, check_all_features, check_no_default ] 91 | runs-on: ubuntu-latest 92 | 93 | services: 94 | postgres: 95 | image: postgres:alpine 96 | env: 97 | POSTGRES_USER: tranquility 98 | POSTGRES_PASSWORD: tranquility 99 | POSTGRES_DB: tests 100 | ports: 101 | - 5432:5432 102 | 103 | steps: 104 | - uses: actions/checkout@v2 105 | - uses: actions-rs/toolchain@v1 106 | with: 107 | profile: minimal 108 | toolchain: stable 109 | override: true 110 | 111 | - uses: actions/cache@v2 112 | with: 113 | path: | 114 | ~/.cargo/registry 115 | ~/.cargo/git 116 | target 117 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 118 | 119 | - uses: actions-rs/cargo@v1 120 | env: 121 | TEST_DB_URL: postgres://tranquility:tranquility@localhost:5432/tests 122 | with: 123 | command: test 124 | args: -- --show-output 125 | 126 | fmt: 127 | name: Rustfmt 128 | runs-on: ubuntu-latest 129 | steps: 130 | - uses: actions/checkout@v2 131 | - uses: actions-rs/toolchain@v1 132 | with: 133 | profile: minimal 134 | toolchain: stable 135 | override: true 136 | - run: rustup component add rustfmt 137 | - uses: actions-rs/cargo@v1 138 | with: 139 | command: fmt 140 | args: --all -- --check 141 | 142 | clippy: 143 | name: Clippy 144 | needs: fmt 145 | runs-on: ubuntu-latest 146 | steps: 147 | - uses: actions/checkout@v2 148 | - uses: actions-rs/toolchain@v1 149 | with: 150 | profile: minimal 151 | toolchain: stable 152 | override: true 153 | 154 | - uses: actions/cache@v2 155 | with: 156 | path: | 157 | ~/.cargo/registry 158 | ~/.cargo/git 159 | target 160 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 161 | 162 | - run: rustup component add clippy 163 | - uses: actions-rs/cargo@v1 164 | with: 165 | command: clippy 166 | args: --all-features -- -D warnings -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | config.json 3 | config.toml 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.cargo.allFeatures": true 3 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [profile.release] 2 | lto = "thin" 3 | strip = true 4 | 5 | [workspace] 6 | members = [ 7 | "tranquility", 8 | "tranquility-content-length-limit", 9 | "tranquility-http-signatures", 10 | "tranquility-ratelimit", 11 | "tranquility-types", 12 | ] 13 | 14 | # TODO: Remove once SQLx v0.6 is out 15 | [patch.crates-io] 16 | sqlx = { git = "https://github.com/launchbadge/sqlx.git", rev = "826e63fc11fc43ce92099b6844d8a9155bf38356" } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2022 aumetra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Tranquility 4 | 5 | Small ActivityPub server written in Rust 6 | 7 | [![Checks](https://github.com/aumetra/tranquility/actions/workflows/rust.yml/badge.svg)](https://github.com/aumetra/tranquility/actions/workflows/rust.yml) 8 | [![Dependency status](https://deps.rs/repo/github/aumetra/tranquility/status.svg)](https://deps.rs/repo/github/aumetra/tranquility) 9 | 10 |
11 | 12 | ### **Disclaimer** 13 | 14 | Tranquility is far from finished and therefore not ready to be used in any capacity yet 15 | Backwards incompatible changes might occur 16 | 17 | ## Requirements 18 | 19 | - **Rust** (assume it only compiles with the latest stable) 20 | - **PostgreSQL** (9.5+ should be fine) 21 | - **Git** (build-time dependency; see [`build.rs`](tranquility/build.rs)) 22 | 23 | ## Prebuilt binaries 24 | 25 | Release binaries are built daily for Linux x86 and Linux ARMv7 26 | 27 | [**Nightly tag**](https://github.com/aumetra/tranquility/releases/tag/nightly) 28 | 29 | ## Email confirmation 30 | 31 | Tranquility can send confirmation emails to users before they can log into their accounts 32 | 33 | Compile with the `email` feature to enable it 34 | 35 | You also need to configure credentials to a mail server in the configuration file 36 | 37 | ## Markdown formatted statuses 38 | 39 | Tranquility supports posts formatted with Markdown (the posts are parsed via `pulldown-cmark` directly when submitted to the API) 40 | 41 | Compile with the `markdown` feature to enable it 42 | 43 | ## Custom memory allocators 44 | 45 | Tranquility currently supports two custom memory allocators 46 | 47 | Use them by compiling the server with one of the following feature flags: 48 | 49 | - `jemalloc`: Use `jemalloc` as the memory allocator 50 | - `mimalloc`: Use `mimalloc` as the memory allocator 51 | 52 | These features are mutually exclusive 53 | If more than one is activated, all selected allocators are compiled in the binary but neither will be actually used 54 | 55 | ## Jaeger integration 56 | 57 | Tranquility supports exporting the data logged via tracing to a jaeger instance 58 | To enable this feature, compile Tranquility with the `jaeger` feature flag 59 | 60 | ## Progress 61 | 62 | Implementation progress is being tracked [here](https://github.com/aumetra/tranquility/issues/17) 63 | 64 | -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | # The email and jaeger configuration parts are always required, regardless of the enabled features 2 | # 3 | # The mail server has to support TLS, either via regular TLS or STARTTLS 4 | # Using an unencrypted transport is not supported and should not even be used anywhere anymore 5 | [email] 6 | # Activate/Deactivate email functionality 7 | active = false 8 | # Domain of the mail server 9 | server = "smtp.example.com" 10 | # Whether STARTTLS should be used or not 11 | starttls = false 12 | # Email address of the mail account 13 | email = "noreply@example.com" 14 | # Username of the mail account 15 | username = "tranquility" 16 | # Password of the mail account 17 | password = "verysecurepassword" 18 | 19 | [instance] 20 | # Maximum limit of characters per post 21 | character-limit = 1024 22 | # If set to "true", the instance won't allow new users to sign up and always return a "403 Forbidden" status code 23 | # Useful for private/single user instances or instances with a large spam account problem 24 | closed-registrations = false 25 | # This is the description of your instance 26 | # It is delivered via the instance information endpoint (/api/v1/instance) as well as via nodeinfo 27 | description = """ 28 | An ActivityPub instance running Tranquility 29 | """ 30 | # This is a very important value 31 | # Change this to your domain (eg. fedi.my-cool-domain.com) before you start Tranquility for the first time 32 | # This value is used to create the URLs used in activities 33 | # If it is set to a wrong domain, federation will break! 34 | # 35 | # !! ActivityPub entities will not automatically update when this value changes !! 36 | # !! You'll either have to change every activity manually or start from scratch (the latter option is by far easier) !! 37 | domain = "tranquility.example.com" 38 | # Moderators of your instance 39 | # The moderators have the rights to delete any post from the instance 40 | # Specify them by adding their username to the list below 41 | moderators = [ ] 42 | # Upload limit for profile/header pictures, attachments, etc. in kilobytes 43 | # Defaults to 2048 (2MB) 44 | upload-limit = 2048 45 | 46 | [jaeger] 47 | # Activate/Deactivate jaeger functionality 48 | active = false 49 | # Host of the jaeger collector 50 | host = "localhost" 51 | # Port of the collector accepting the compact thrift protocol 52 | port = 6831 53 | 54 | [ratelimit] 55 | # Activates ratelimiting for routes responsible for authentication and for the registration endpoint 56 | active = true 57 | # Those values are quotas per hour respectively 58 | authentication-quota = 50 59 | registration-quota = 10 60 | 61 | [server] 62 | interface = "127.0.0.1" 63 | port = 8080 64 | database-url = "postgres://localhost/tranquility" 65 | 66 | [tls] 67 | # Tranquility doesn't necessarily need a reverse proxy like NGINX for TLS support 68 | # If this option is set to "true", Tranquility will use the files set below 69 | # as the TLS certificate/key to communicate via TLS over the previously specified port 70 | # 71 | # If you need a TLS certificate, get one for free from an authority like Let's Encrypt (https://letsencrypt.org) 72 | # 73 | # !! Setting this to "false" and running Tranquility without any kind of reverse proxy will probably not work !! 74 | # !! Please use a free certificate authority like the aforementioned Let's Encrypt !! 75 | serve-tls-directly = false 76 | 77 | certificate = "tranquility.crt" 78 | secret-key = "tranquility.key" 79 | -------------------------------------------------------------------------------- /migrations/20200920112046_initial_layout.sql: -------------------------------------------------------------------------------- 1 | -- Activate the UUID extension 2 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 3 | 4 | CREATE TABLE actors ( 5 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 6 | username TEXT NOT NULL, 7 | email TEXT, 8 | password_hash TEXT, 9 | private_key TEXT, 10 | 11 | actor JSONB NOT NULL, 12 | 13 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 | 16 | UNIQUE (email) 17 | ); 18 | 19 | CREATE TABLE activities ( 20 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 21 | owner_id UUID NOT NULL REFERENCES actors(id), 22 | 23 | data JSONB NOT NULL, 24 | 25 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 27 | ); 28 | 29 | CREATE OR REPLACE FUNCTION add_updated_at_trigger(_table REGCLASS) RETURNS VOID AS 30 | $$ 31 | BEGIN 32 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s FOR EACH ROW EXECUTE PROCEDURE set_updated_at()', _table); 33 | END; 34 | $$ 35 | LANGUAGE plpgsql; 36 | 37 | CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS 38 | $$ 39 | BEGIN 40 | IF (NEW IS DISTINCT FROM OLD AND NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at) 41 | THEN 42 | NEW.updated_at := CURRENT_TIMESTAMP; 43 | END IF; 44 | RETURN NEW; 45 | END; 46 | $$ 47 | LANGUAGE plpgsql; 48 | 49 | SELECT add_updated_at_trigger('actors'); 50 | SELECT add_updated_at_trigger('activities'); 51 | -------------------------------------------------------------------------------- /migrations/20201020174156_remote_user.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE actors ADD COLUMN remote BOOLEAN NOT NULL DEFAULT FALSE; 2 | -------------------------------------------------------------------------------- /migrations/20201022175053_activity_url.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE activities ADD COLUMN url TEXT NOT NULL; 2 | ALTER TABLE activities ADD CONSTRAINT unique_url_constraint UNIQUE (url); 3 | -------------------------------------------------------------------------------- /migrations/20201023130013_partial_unique_index_username.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX username_local_constraint ON actors (username) WHERE NOT remote; 2 | -------------------------------------------------------------------------------- /migrations/20201025201117_rename_activities_table.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE activities RENAME TO objects; 2 | -------------------------------------------------------------------------------- /migrations/20201104191227_authorization.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE oauth_applications ( 2 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 3 | 4 | client_name TEXT NOT NULL, 5 | client_id UUID NOT NULL, 6 | client_secret TEXT NOT NULL, 7 | 8 | redirect_uris TEXT NOT NULL, 9 | scopes TEXT NOT NULL, 10 | website TEXT NOT NULL, 11 | 12 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | 15 | UNIQUE (client_id) 16 | ); 17 | 18 | CREATE TABLE oauth_authorizations ( 19 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 20 | 21 | application_id UUID NOT NULL REFERENCES oauth_applications(id), 22 | actor_id UUID NOT NULL REFERENCES actors(id), 23 | 24 | code TEXT NOT NULL, 25 | valid_until TIMESTAMP NOT NULL, 26 | 27 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 28 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 29 | 30 | UNIQUE (code) 31 | ); 32 | 33 | CREATE TABLE oauth_tokens ( 34 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 35 | 36 | application_id UUID NOT NULL REFERENCES oauth_applications(id), 37 | actor_id UUID NOT NULL REFERENCES actors(id), 38 | 39 | access_token TEXT NOT NULL, 40 | refresh_token TEXT, 41 | valid_until TIMESTAMP NOT NULL, 42 | 43 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 44 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 45 | 46 | UNIQUE (access_token), 47 | UNIQUE (refresh_token) 48 | ); 49 | 50 | SELECT add_updated_at_trigger('oauth_applications'); 51 | SELECT add_updated_at_trigger('oauth_authorizations'); 52 | SELECT add_updated_at_trigger('oauth_tokens'); 53 | -------------------------------------------------------------------------------- /migrations/20201106224449_remove_not_null_application_id.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE oauth_tokens ALTER COLUMN application_id DROP NOT NULL; 2 | -------------------------------------------------------------------------------- /migrations/20210119130652_remove_url_column.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE objects DROP COLUMN url; 2 | CREATE UNIQUE INDEX unique_object_id ON objects(((data->>'id')::TEXT)); -------------------------------------------------------------------------------- /migrations/20210614155427_timestamp_to_timestamptz.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE actors 2 | ALTER created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', 3 | ALTER updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; 4 | 5 | ALTER TABLE objects 6 | ALTER created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', 7 | ALTER updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; 8 | 9 | ALTER TABLE oauth_applications 10 | ALTER created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', 11 | ALTER updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; 12 | 13 | ALTER TABLE oauth_authorizations 14 | ALTER created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', 15 | ALTER updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC', 16 | ALTER valid_until TYPE TIMESTAMPTZ USING valid_until AT TIME ZONE 'UTC'; 17 | 18 | ALTER TABLE oauth_tokens 19 | ALTER created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', 20 | ALTER updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC', 21 | ALTER valid_until TYPE TIMESTAMPTZ USING valid_until AT TIME ZONE 'UTC'; 22 | -------------------------------------------------------------------------------- /migrations/20210622131412_add_email_confirmation.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE actors 2 | ADD COLUMN is_confirmed BOOLEAN NOT NULL DEFAULT TRUE; 3 | ALTER TABLE actors 4 | ADD COLUMN confirmation_code TEXT; 5 | -------------------------------------------------------------------------------- /tranquility-content-length-limit/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tranquility-content-length-limit" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | axum = { version = "0.5.17", default-features = false } 8 | headers = "0.3.8" 9 | -------------------------------------------------------------------------------- /tranquility-content-length-limit/src/lib.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | async_trait, 3 | extract::{FromRequest, RequestParts}, 4 | http::StatusCode, 5 | response::{IntoResponse, Response}, 6 | }; 7 | use headers::{ContentLength, HeaderMapExt}; 8 | use std::ops::{Deref, DerefMut}; 9 | 10 | const KILOBYTES_BYTES: u64 = 1024; 11 | const MEGABYTES_BYTES: u64 = KILOBYTES_BYTES.pow(2); 12 | 13 | /// Configuration for the content length limiter 14 | /// 15 | /// Put this into the request extensions via the `Extension` layer 16 | #[derive(Clone, Copy, Debug)] 17 | pub struct ContentLengthLimitConfig(u64); 18 | 19 | impl ContentLengthLimitConfig { 20 | /// Set a maximum content length limit in bytes 21 | pub fn bytes(bytes: u64) -> Self { 22 | Self(bytes) 23 | } 24 | 25 | /// Set a maximum content length limit in kilobytes 26 | pub fn kilobytes(kb: u64) -> Self { 27 | Self::bytes(kb * KILOBYTES_BYTES) 28 | } 29 | 30 | /// Set a maximum content length limit in megabytes 31 | pub fn megabytes(mb: u64) -> Self { 32 | Self::bytes(mb * MEGABYTES_BYTES) 33 | } 34 | } 35 | 36 | impl Deref for ContentLengthLimitConfig { 37 | type Target = u64; 38 | 39 | fn deref(&self) -> &Self::Target { 40 | &self.0 41 | } 42 | } 43 | 44 | /// Content length limiting extractor wrapping another extractor 45 | /// 46 | /// This is a pretty primitive implementation. 47 | /// It just reads the `Content-Length` header and compares it with the configured maximum value from the configuration stored in the request extensions. 48 | pub struct ContentLengthLimit(pub T); 49 | 50 | impl Deref for ContentLengthLimit { 51 | type Target = T; 52 | 53 | fn deref(&self) -> &Self::Target { 54 | &self.0 55 | } 56 | } 57 | 58 | impl DerefMut for ContentLengthLimit { 59 | fn deref_mut(&mut self) -> &mut Self::Target { 60 | &mut self.0 61 | } 62 | } 63 | 64 | #[async_trait] 65 | impl FromRequest for ContentLengthLimit 66 | where 67 | T: FromRequest, 68 | B: Send + Sync, 69 | { 70 | type Rejection = Response; 71 | 72 | async fn from_request(req: &mut RequestParts) -> Result { 73 | let content_length_limit = req 74 | .extensions() 75 | .get::() 76 | .expect("Content length limit configuration missing from extensions"); 77 | 78 | if let Some(ContentLength(content_length)) = req.headers().typed_get::() { 79 | if content_length > **content_length_limit { 80 | return Err(( 81 | StatusCode::PAYLOAD_TOO_LARGE, 82 | format!( 83 | "Payload exceeded maximum size of {}", 84 | **content_length_limit 85 | ), 86 | ) 87 | .into_response()); 88 | } 89 | 90 | >::from_request(req) 91 | .await 92 | .map(ContentLengthLimit) 93 | .map_err(IntoResponse::into_response) 94 | } else { 95 | return Err((StatusCode::BAD_REQUEST, "Missing Content-Length header").into_response()); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tranquility-http-signatures/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /tranquility-http-signatures/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tranquility-http-signatures" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "MIT" 6 | 7 | [dependencies] 8 | base64 = "0.13.1" 9 | http = "0.2.8" 10 | pem = "1.1.0" 11 | pkcs8 = { version = "0.9.0", features = ["alloc"] } 12 | ring = { version = "0.16.20", features = ["std"] } 13 | thiserror = "1.0.37" 14 | 15 | [dependencies.reqwest] 16 | version = "0.11.12" 17 | default-features = false 18 | optional = true 19 | 20 | [features] 21 | default = [] 22 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/alg.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use ring::{ 3 | rand, 4 | signature::{RsaKeyPair, UnparsedPublicKey, VerificationAlgorithm}, 5 | }; 6 | 7 | #[cfg(not(test))] 8 | use ring::signature::{ 9 | RSA_PKCS1_2048_8192_SHA256, RSA_PKCS1_2048_8192_SHA384, RSA_PKCS1_2048_8192_SHA512, 10 | RSA_PKCS1_SHA256, RSA_PKCS1_SHA384, RSA_PKCS1_SHA512, 11 | }; 12 | 13 | #[cfg(test)] 14 | use ring::signature::{ 15 | RSA_PKCS1_1024_8192_SHA256_FOR_LEGACY_USE_ONLY, RSA_PKCS1_1024_8192_SHA512_FOR_LEGACY_USE_ONLY, 16 | RSA_PKCS1_SHA256, RSA_PKCS1_SHA384, RSA_PKCS1_SHA512, 17 | }; 18 | 19 | /// Struct holding information about the algorithms used to create the signature 20 | /// 21 | /// Defaults to `rsa-sha256` 22 | pub struct Algorithm<'a> { 23 | /// Cryptographic algorithm being used (eg. `rsa`, `ecdsa`, `hmac` etc.) 24 | crypto: &'a str, 25 | 26 | /// Hash algorithm being used (eg. `sha256`, `sha384`, etc.) 27 | hash: &'a str, 28 | } 29 | 30 | impl<'a> Default for Algorithm<'a> { 31 | fn default() -> Self { 32 | ("rsa", "sha256").into() 33 | } 34 | } 35 | 36 | impl<'a> From<(&'a str, &'a str)> for Algorithm<'a> { 37 | fn from((crypto, hash): (&'a str, &'a str)) -> Self { 38 | Self { crypto, hash } 39 | } 40 | } 41 | 42 | impl<'a> ToString for Algorithm<'a> { 43 | fn to_string(&self) -> String { 44 | format!("{}-{}", self.crypto, self.hash) 45 | } 46 | } 47 | 48 | impl<'a> Algorithm<'a> { 49 | /// Parse the value of the algorithm field of the signature header 50 | /// 51 | /// `Option::None` will return the default algorithm 52 | pub fn parse(raw_str: Option<&'a str>) -> Result { 53 | if let Some(raw_str) = raw_str { 54 | let (crypto, hash) = 55 | raw_str.split_at(raw_str.find('-').ok_or(Error::InvalidAlgorithm)?); 56 | 57 | // Skip the first character of the hash specifier (it's the `-`) 58 | let hash = &hash[1..]; 59 | 60 | Ok((crypto, hash).into()) 61 | } else { 62 | // Assume that the default algorithm applies 63 | Ok(Self::default()) 64 | } 65 | } 66 | 67 | /// Prepare the public key for verification usage 68 | pub fn prepare_public_key(&self, key_bytes: K) -> Result> 69 | where 70 | K: AsRef<[u8]>, 71 | { 72 | let algorithm = get_algorithm(self.crypto, self.hash)?; 73 | 74 | Ok(UnparsedPublicKey::new(algorithm, key_bytes)) 75 | } 76 | 77 | /// Sign the provided data with the algorithm 78 | pub fn sign(&self, key_bytes: K, data: D) -> Result> 79 | where 80 | K: AsRef<[u8]>, 81 | D: AsRef<[u8]>, 82 | { 83 | if self.crypto == "rsa" { 84 | let algorithm = match self.hash { 85 | "sha256" => &RSA_PKCS1_SHA256, 86 | "sha384" => &RSA_PKCS1_SHA384, 87 | "sha512" => &RSA_PKCS1_SHA512, 88 | 89 | _ => return Err(Error::UnknownAlgorithm), 90 | }; 91 | 92 | let key_pair = RsaKeyPair::from_der(key_bytes.as_ref())?; 93 | let rng = rand::SystemRandom::new(); 94 | 95 | let mut signature = vec![0; key_pair.public_modulus_len()]; 96 | key_pair.sign(algorithm, &rng, data.as_ref(), &mut signature)?; 97 | 98 | Ok(signature) 99 | } else { 100 | Err(Error::UnknownAlgorithm) 101 | } 102 | } 103 | } 104 | 105 | /// Get the ring algorithm from the HTTP signatures crypto and hash algorithm identifier 106 | fn get_algorithm(crypto: &str, hash: &str) -> Result<&'static dyn VerificationAlgorithm> { 107 | #[cfg(not(test))] 108 | let algorithm = match (crypto, hash) { 109 | ("rsa", "sha256") => &RSA_PKCS1_2048_8192_SHA256, 110 | ("rsa", "sha384") => &RSA_PKCS1_2048_8192_SHA384, 111 | ("rsa", "sha512") => &RSA_PKCS1_2048_8192_SHA512, 112 | 113 | _ => return Err(Error::UnknownKeyType), 114 | }; 115 | 116 | // Enable unsecure key lengths for the tests because the official RFC examples are created using an RSA-1024 bit key 117 | #[cfg(test)] 118 | let algorithm = match (crypto, hash) { 119 | ("rsa", "sha256") => &RSA_PKCS1_1024_8192_SHA256_FOR_LEGACY_USE_ONLY, 120 | ("rsa", "sha512") => &RSA_PKCS1_1024_8192_SHA512_FOR_LEGACY_USE_ONLY, 121 | 122 | _ => unreachable!(), 123 | }; 124 | 125 | Ok(algorithm) 126 | } 127 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/error.rs: -------------------------------------------------------------------------------- 1 | pub type Result = std::result::Result; 2 | 3 | #[derive(Debug, thiserror::Error)] 4 | #[non_exhaustive] 5 | /// Unified error enum 6 | pub enum Error { 7 | #[error("Base64 decode error: {0}")] 8 | /// Base64 decode error 9 | Base64Decode(#[from] base64::DecodeError), 10 | 11 | #[error("HTTP ToStrError: {0}")] 12 | /// HTTP ToStrError 13 | HttpToStr(#[from] http::header::ToStrError), 14 | 15 | #[error("Invalid algorithm")] 16 | /// Invalid algorithm 17 | InvalidAlgorithm, 18 | 19 | #[error("Invalid header value: {0}")] 20 | /// Invalid HTTP header value 21 | InvalidHeaderValue(#[from] http::header::InvalidHeaderValue), 22 | 23 | #[error("Invalid header")] 24 | /// Invalid HTTP header 25 | InvalidHeader, 26 | 27 | #[error("Missing header")] 28 | /// Missing HTTP header 29 | MissingHeader, 30 | 31 | #[error("Missing signature header")] 32 | /// Missing signature header 33 | MissingSignatureHeader, 34 | 35 | #[error("Missing signature field")] 36 | /// Missing signature field 37 | MissingSignatureField, 38 | 39 | #[error("PEM error: {0}")] 40 | /// PEM decoding error 41 | Pem(#[from] pem::PemError), 42 | 43 | #[error("Ring error")] 44 | /// Ring crypto error 45 | Ring(#[from] ring::error::Unspecified), 46 | 47 | #[error("Invalid key: {0}")] 48 | /// Ring invalid key 49 | RingInvalidKey(#[from] ring::error::KeyRejected), 50 | 51 | #[error("Unknown algorithm")] 52 | /// Unknown algorithm 53 | UnknownAlgorithm, 54 | 55 | #[error("Unknown key type")] 56 | /// Unknown key type 57 | UnknownKeyType, 58 | } 59 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/key.rs: -------------------------------------------------------------------------------- 1 | /// Public key for verifying the request 2 | pub type PublicKey<'a> = &'a [u8]; 3 | 4 | /// Private key for signing the request 5 | pub struct PrivateKey<'a> { 6 | /// ID of the associated public key (this is usually an URL pointing to the key) 7 | pub(crate) key_id: &'a str, 8 | 9 | /// Private key in PKCS#8 PEM format 10 | pub(crate) data: &'a [u8], 11 | } 12 | 13 | impl<'a> PrivateKey<'a> { 14 | #[must_use] 15 | /// Create a new private key 16 | pub fn new(key_id: &'a str, data: &'a [u8]) -> Self { 17 | Self { key_id, data } 18 | } 19 | } 20 | 21 | impl<'a> From<(&'a str, &'a [u8])> for PrivateKey<'a> { 22 | fn from((key_id, data): (&'a str, &'a [u8])) -> Self { 23 | Self::new(key_id, data) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![forbid(rust_2018_idioms, unsafe_code)] 2 | #![warn(clippy::all, clippy::pedantic)] 3 | #![deny(missing_docs)] 4 | #![allow(clippy::missing_errors_doc, clippy::module_name_repetitions)] 5 | // Disable this clippy lint, otherwise clippy will complain when compiled in a test environment 6 | // (for example, with rust-analyzer) 7 | #![cfg_attr(test, allow(clippy::unnecessary_wraps))] 8 | 9 | //! 10 | //! Implementation of the HTTP signatures [spec](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#appendix-C.1) 11 | //! 12 | 13 | use crate::{alg::Algorithm, error::Result, signature::Signature, sigstr::SignatureString}; 14 | use http::header::{HeaderName, HeaderValue}; 15 | 16 | static SIGNATURE: HeaderName = HeaderName::from_static("signature"); 17 | 18 | /// Sign an HTTP request 19 | pub fn sign<'r, 'k, R, K>( 20 | req: R, 21 | headers: &[&str], 22 | priv_key: K, 23 | ) -> Result<(HeaderName, HeaderValue)> 24 | where 25 | R: Into>, 26 | K: Into>, 27 | { 28 | __into!(req, priv_key); 29 | 30 | // Build a signature string 31 | let signature_string = SignatureString::build(&req, headers)?; 32 | let encoded_signature_string = signature_string.to_string(); 33 | let signature_string_bytes = encoded_signature_string.as_bytes(); 34 | 35 | // Use the default algorithm for signing 36 | let algorithm = Algorithm::default(); 37 | 38 | // Decode the private key 39 | let decoded_priv_key = pem::decode(priv_key.data)?; 40 | 41 | // Sign the signature string and base64-encode the signature 42 | let signature = algorithm.sign(decoded_priv_key, signature_string_bytes)?; 43 | let encoded_signature = base64::encode(signature); 44 | 45 | // Build the signature header and encode it into an `HeaderValue` 46 | let signature_header = Signature::new( 47 | priv_key.key_id, 48 | None, 49 | headers.to_vec(), 50 | &encoded_signature, 51 | None, 52 | None, 53 | ); 54 | let signature_header = signature_header.encode()?; 55 | 56 | Ok((SIGNATURE.clone(), signature_header)) 57 | } 58 | 59 | /// Verify an HTTP request 60 | pub fn verify<'r, 'p, R, K>(req: R, pub_key: K) -> Result 61 | where 62 | R: Into>, 63 | K: Into>, 64 | { 65 | __into!(req, pub_key); 66 | 67 | // Parse the signature header 68 | let signature = req.signature()?; 69 | let signature = Signature::parse(signature)?; 70 | 71 | // Build a signature string 72 | let signature_string = SignatureString::build(&req, &signature.headers)?; 73 | let encoded_signature_string = signature_string.to_string(); 74 | let signature_string_bytes = encoded_signature_string.as_bytes(); 75 | 76 | // Parse the algorithm and public key 77 | let algorithm = Algorithm::parse(signature.algorithm)?; 78 | let decoded_pub_key = pem::decode(pub_key)?; 79 | let public_key = algorithm.prepare_public_key(decoded_pub_key)?; 80 | 81 | // Decode the base64-encoded signature 82 | let decoded_signature = base64::decode(signature.signature)?; 83 | 84 | // Prepare the public key and verify the signature 85 | let is_valid = public_key 86 | .verify(signature_string_bytes, &decoded_signature) 87 | .is_ok(); 88 | 89 | Ok(is_valid) 90 | } 91 | 92 | mod alg; 93 | mod error; 94 | mod key; 95 | mod macros; 96 | mod pem; 97 | mod request; 98 | mod signature; 99 | mod sigstr; 100 | mod util; 101 | 102 | #[cfg(test)] 103 | mod tests; 104 | 105 | pub use self::error::Error; 106 | pub use self::key::{PrivateKey, PublicKey}; 107 | pub use self::request::Request; 108 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/macros.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | #[doc(hidden)] 3 | /// Invoke `.into()` on all given idents and shadow the variables with the result 4 | macro_rules! __into { 5 | ($($var:ident),+) => { 6 | $( 7 | let $var = $var.into(); 8 | )+ 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/pem.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use pkcs8::{der::Decode, Document, PrivateKeyInfo, SubjectPublicKeyInfo}; 3 | 4 | /// Convert/Decode PKCS#8 DER to PKCS#1 DER 5 | fn pkcs8_to_pkcs1(data: &[u8], is_public: bool) -> Result> { 6 | // PKCS#8 is nothing else than PKCS#1 with some additional metadata about the key 7 | let der_key = if is_public { 8 | let pub_key = Document::from_der(data).map_err(|_| Error::UnknownKeyType)?; 9 | let pub_key: SubjectPublicKeyInfo<'_> = 10 | pub_key.decode_msg().map_err(|_| Error::UnknownKeyType)?; 11 | 12 | pub_key.subject_public_key.to_vec() 13 | } else { 14 | let priv_key = Document::from_der(data).map_err(|_| Error::UnknownKeyType)?; 15 | let priv_key: PrivateKeyInfo<'_> = 16 | priv_key.decode_msg().map_err(|_| Error::UnknownKeyType)?; 17 | 18 | priv_key.private_key.to_vec() 19 | }; 20 | 21 | Ok(der_key) 22 | } 23 | 24 | /// Decode a PEM-encoded key (PKCS#1 or PKCS#8) to PKCS#1 DER 25 | pub fn decode(data: &[u8]) -> Result> { 26 | let pem = pem::parse(data)?; 27 | 28 | match pem.tag.as_str() { 29 | "PRIVATE KEY" => pkcs8_to_pkcs1(pem.contents.as_slice(), false), 30 | "PUBLIC KEY" => pkcs8_to_pkcs1(pem.contents.as_slice(), true), 31 | "RSA PRIVATE KEY" | "RSA PUBLIC KEY" => Ok(pem.contents), 32 | _ => Err(Error::UnknownKeyType), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/request.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::{Error, Result}, 3 | SIGNATURE, 4 | }; 5 | use http::header::{HeaderMap, AUTHORIZATION}; 6 | 7 | /// HTTP request that is being processed 8 | pub struct Request<'a> { 9 | /// Method of the HTTP request 10 | pub(crate) method: &'a str, 11 | 12 | /// Requested path 13 | pub(crate) path: &'a str, 14 | 15 | /// Optional query of the request 16 | pub(crate) query: Option<&'a str>, 17 | 18 | /// The headers of the HTTP request 19 | pub(crate) headers: &'a HeaderMap, 20 | } 21 | 22 | impl<'a> Request<'a> { 23 | #[must_use] 24 | /// Construct a new request 25 | pub fn new( 26 | method: &'a str, 27 | path: &'a str, 28 | query: Option<&'a str>, 29 | headers: &'a HeaderMap, 30 | ) -> Self { 31 | Self { 32 | method, 33 | path, 34 | query, 35 | headers, 36 | } 37 | } 38 | 39 | /// Get the signature from the HTTP request 40 | pub(crate) fn signature(&self) -> Result<&str> { 41 | // Try to get the signature from the signature header 42 | if let Some(header_value) = self.headers.get(&SIGNATURE) { 43 | return Ok(header_value.to_str()?); 44 | } 45 | 46 | // Try to get the signature from the authorization header 47 | if let Some(header_value) = self.headers.get(AUTHORIZATION) { 48 | let header_value_str = header_value.to_str()?; 49 | 50 | // Split off the `Signature` 51 | let first_space_pos = header_value_str.find(' ').ok_or(Error::InvalidHeader)?; 52 | let (_, header_value_str) = header_value_str.split_at(first_space_pos); 53 | 54 | return Ok(header_value_str); 55 | } 56 | 57 | Err(Error::MissingSignatureHeader) 58 | } 59 | } 60 | 61 | impl<'a, T> From<&'a http::Request> for Request<'a> { 62 | fn from(req: &'a http::Request) -> Self { 63 | let method = req.method().as_str(); 64 | let headers = req.headers(); 65 | 66 | let uri = req.uri(); 67 | let path = uri.path(); 68 | let query = uri.query(); 69 | 70 | Self::new(method, path, query, headers) 71 | } 72 | } 73 | 74 | #[cfg(feature = "reqwest")] 75 | impl<'a> From<&'a reqwest::Request> for Request<'a> { 76 | fn from(req: &'a reqwest::Request) -> Self { 77 | let method = req.method().as_str(); 78 | let headers = req.headers(); 79 | 80 | let uri = req.url(); 81 | let path = uri.path(); 82 | let query = uri.query(); 83 | 84 | Self::new(method, path, query, headers) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/signature.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::Result, 3 | util::{HashMapExt as _, IteratorExt as _}, 4 | }; 5 | use http::header::HeaderValue; 6 | 7 | /// Parsed form of a signature header 8 | pub struct Signature<'a> { 9 | /// ID of the associated public key 10 | pub(crate) key_id: &'a str, 11 | 12 | /// Used algorithm (if empty, default to RSA-SHA256) 13 | pub(crate) algorithm: Option<&'a str>, 14 | 15 | /// Headers used by the signature 16 | pub(crate) headers: Vec<&'a str>, 17 | 18 | /// base64-encoded signature 19 | pub(crate) signature: &'a str, 20 | 21 | /// Timestamp when the signature was created 22 | pub(crate) created: Option<&'a str>, 23 | 24 | /// Timestamp when the signature will expire 25 | pub(crate) expires: Option<&'a str>, 26 | } 27 | 28 | impl<'a> Signature<'a> { 29 | /// Create a new signature 30 | pub fn new( 31 | key_id: &'a str, 32 | algorithm: Option<&'a str>, 33 | headers: Vec<&'a str>, 34 | encoded_signature: &'a str, 35 | created: Option<&'a str>, 36 | expires: Option<&'a str>, 37 | ) -> Self { 38 | Self { 39 | key_id, 40 | algorithm, 41 | headers, 42 | signature: encoded_signature, 43 | created, 44 | expires, 45 | } 46 | } 47 | 48 | /// Encode the signature into a `HeaderValue` 49 | pub fn encode(self) -> Result { 50 | let mut signature = format!( 51 | r#"keyId="{}",headers="{}",signature="{}""#, 52 | self.key_id, 53 | self.headers.join(" "), 54 | self.signature 55 | ); 56 | 57 | if let Some(algorithm) = self.algorithm { 58 | append_key(&mut signature, "algorithm", algorithm); 59 | } 60 | 61 | if let Some(created) = self.created { 62 | append_key(&mut signature, "created", created); 63 | } 64 | 65 | if let Some(expires) = self.expires { 66 | append_key(&mut signature, "expires", expires); 67 | } 68 | 69 | let header_value = HeaderValue::from_str(signature.as_str())?; 70 | Ok(header_value) 71 | } 72 | 73 | /// Parse a raw `&str` into an `Signature` 74 | pub fn parse(raw_str: &'a str) -> Result { 75 | let parsed_header_value = raw_str 76 | .split(',') 77 | .filter_map(|kv_pair| { 78 | let (key, value) = kv_pair.split_at(kv_pair.find('=')?); 79 | 80 | // Skip the first character because the first character is the '=' 81 | let value = &value[1..]; 82 | 83 | // Clean up the key and value 84 | let key = key.trim(); 85 | let value = value.trim_matches('"'); 86 | 87 | Some((key, value)) 88 | }) 89 | .collect_hashmap(); 90 | 91 | let key_id = parsed_header_value.get_signature_field("keyId")?; 92 | 93 | let algorithm = parsed_header_value.get_signature_field("algorithm").ok(); 94 | 95 | // The header field might be absent 96 | let headers = parsed_header_value 97 | .get_signature_field("headers") 98 | .unwrap_or_default() 99 | .split_whitespace() 100 | .collect_vec(); 101 | 102 | let signature = parsed_header_value.get_signature_field("signature")?; 103 | 104 | let created = parsed_header_value.get_signature_field("created").ok(); 105 | let expires = parsed_header_value.get_signature_field("expires").ok(); 106 | 107 | let signature_string = Signature { 108 | key_id, 109 | algorithm, 110 | headers, 111 | signature, 112 | created, 113 | expires, 114 | }; 115 | 116 | Ok(signature_string) 117 | } 118 | } 119 | 120 | /// Append a key-value pair to the signature 121 | fn append_key(sig: &mut String, key: &str, value: &str) { 122 | sig.push(','); 123 | sig.push_str(key); 124 | sig.push_str("=\""); 125 | sig.push_str(value); 126 | sig.push('"'); 127 | } 128 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/sigstr.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::{Error, Result}, 3 | request::Request, 4 | util::{HeaderMapExt as _, IteratorExt as _}, 5 | }; 6 | use http::header::DATE; 7 | 8 | #[derive(Debug, Eq, PartialEq)] 9 | pub enum Part<'a> { 10 | /// Header with key and value 11 | Header(&'a str, &'a str), 12 | 13 | /// Parts needed for the request target (method, path and query) 14 | RequestTarget(&'a str, &'a str, Option<&'a str>), 15 | } 16 | 17 | impl<'a> ToString for Part<'a> { 18 | fn to_string(&self) -> String { 19 | match self { 20 | Part::Header(key, value) => format!("{}: {}", key, value), 21 | Part::RequestTarget(method, path, query) => { 22 | let method = method.to_lowercase(); 23 | let query = query.map(|query| format!("?{}", query)).unwrap_or_default(); 24 | 25 | format!("(request-target): {} {}{}", method, path, query) 26 | } 27 | } 28 | } 29 | } 30 | 31 | pub struct SignatureString<'a> { 32 | parts: Vec>, 33 | } 34 | 35 | impl<'a> SignatureString<'a> { 36 | /// Build a new signature string 37 | pub fn build(request: &'a Request<'a>, requested_fields: &'a [&str]) -> Result { 38 | let mut parts = requested_fields 39 | .iter() 40 | // The `created` and `expires` fields shouldn't be part of the signature string 41 | // (theoretically only if the algorithm field of the signature header starts with `rsa`, `hmac` or `ecdsa`) 42 | .filter(|field| **field != "(created)" && **field != "(expires)") 43 | .map(|field| { 44 | let method = request.method; 45 | let path = request.path; 46 | let query = request.query; 47 | 48 | let part = match *field { 49 | "(request-target)" => Part::RequestTarget(method, path, query), 50 | header_name => { 51 | let header_value = request.headers.get_header(header_name)?; 52 | let header_value = header_value.to_str()?; 53 | 54 | Part::Header(header_name, header_value) 55 | } 56 | }; 57 | 58 | Ok::<_, Error>(part) 59 | }) 60 | .try_collect_vec()?; 61 | 62 | // If a list of headers isn't included, only the "date" header is used 63 | // See draft-cavage-http-signatures-11#Appendix.C.1 64 | if parts.is_empty() { 65 | let header_value = request.headers.get_header(DATE)?; 66 | let header_value = header_value.to_str()?; 67 | 68 | parts.push(Part::Header("date", header_value)); 69 | } 70 | 71 | let signature_string = SignatureString { parts }; 72 | Ok(signature_string) 73 | } 74 | } 75 | 76 | impl<'a> ToString for SignatureString<'a> { 77 | fn to_string(&self) -> String { 78 | self.parts 79 | .iter() 80 | .map(ToString::to_string) 81 | .collect_vec() 82 | .join("\n") 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tranquility-http-signatures/src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use http::header::{AsHeaderName, HeaderMap, HeaderValue}; 3 | use std::{collections::HashMap, hash::Hash, iter::FromIterator}; 4 | 5 | pub trait HeaderMapExt { 6 | /// Convenience function. Is equivalent to `.get().ok_or(Error::MissingHeader)` 7 | fn get_header(&self, key: K) -> Result<&HeaderValue> 8 | where 9 | K: AsHeaderName; 10 | } 11 | 12 | impl<'a> HeaderMapExt for &'a HeaderMap { 13 | fn get_header(&self, key: K) -> Result<&HeaderValue> 14 | where 15 | K: AsHeaderName, 16 | { 17 | self.get(key).ok_or(Error::MissingHeader) 18 | } 19 | } 20 | 21 | pub trait IteratorExt: Iterator + Sized { 22 | /// Convenience function. Is equivalent to `.collect::>()` 23 | fn collect_hashmap(self) -> HashMap 24 | where 25 | HashMap: FromIterator<::Item>, 26 | { 27 | self.collect() 28 | } 29 | 30 | /// Convenience function. Is equivalent to `.collect::>()` 31 | fn collect_vec(self) -> Vec<::Item> { 32 | self.collect() 33 | } 34 | 35 | /// Convenience function. Is equivalent to `.collect::, _>>()` 36 | fn try_collect_vec(self) -> Result, E> 37 | where 38 | Result, E>: FromIterator<::Item>, 39 | { 40 | self.collect() 41 | } 42 | } 43 | 44 | impl IteratorExt for T where T: Iterator + Sized {} 45 | 46 | pub trait HashMapExt { 47 | /// Convenience function. Is equivalent to `.get().copied().ok_or(Error::MissingSigStrField)` 48 | fn get_signature_field(&self, key: K) -> Result; 49 | } 50 | 51 | impl HashMapExt for HashMap 52 | where 53 | K: Eq + Hash, 54 | V: Copy, 55 | { 56 | fn get_signature_field(&self, key: K) -> Result { 57 | self.get(&key).copied().ok_or(Error::MissingSignatureField) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tranquility-ratelimit/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /tranquility-ratelimit/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tranquility-ratelimit" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "MIT" 6 | 7 | [dependencies] 8 | axum = { version = "0.5.17", default-features = false } 9 | futures-util = "0.3.25" 10 | governor = "0.5.0" 11 | thiserror = "1.0.37" 12 | tower-layer = "0.3.2" 13 | tower-service = "0.3.2" 14 | tracing = "0.1.37" 15 | 16 | [features] 17 | -------------------------------------------------------------------------------- /tranquility-types/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /tranquility-types/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tranquility-types" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "MIT" 6 | 7 | [dependencies] 8 | serde = { version = "1.0.147", features = ["derive"] } 9 | serde_json = "1.0.87" 10 | time = { version = "0.3.16", optional = true, features = ["serde", "serde-well-known"] } 11 | 12 | [features] 13 | activitypub = ["time"] 14 | mastodon = ["time"] 15 | nodeinfo = [ ] 16 | webfinger = [ ] 17 | -------------------------------------------------------------------------------- /tranquility-types/README.md: -------------------------------------------------------------------------------- 1 | # tranquility-types 2 | 3 | ## ActivityPub 4 | 5 | The ActivityPub types were created to be compatible with actual instances (the activities used for the tests were created using [Pleroma](https://pleroma.social)) 6 | 7 | ## Mastodon 8 | 9 | The Mastodon types were created to be compatible with the actual Mastodon API (Tranquility is supposed to be API-compatible afterall!) 10 | 11 | ## Nodeinfo 12 | 13 | The Nodeinfo types were created according to the [Nodeinfo 2.1 schema](https://github.com/jhass/nodeinfo/blob/1fcd229a84031253eb73a315e89d3f7f13f117b4/schemas/2.1/schema.json) 14 | 15 | ## Webfinger 16 | 17 | The Webfinger types were created according to [RFC 7033](https://tools.ietf.org/html/rfc7033) 18 | -------------------------------------------------------------------------------- /tranquility-types/src/activitypub/activity.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_json::Value; 3 | use time::OffsetDateTime; 4 | 5 | #[derive(Clone, Debug, Deserialize, Serialize)] 6 | /// Struct representing an [ActivityStreams activity](https://www.w3.org/TR/activitystreams-core/#activities) 7 | pub struct Activity { 8 | #[serde(default = "super::context_field", rename = "@context")] 9 | pub context: Value, 10 | 11 | pub id: String, 12 | pub r#type: String, 13 | /// Link to the actor this activity belongs to 14 | pub actor: String, 15 | 16 | /// This can either be an "Actor", "Object" or an URL to either of those 17 | pub object: ObjectField, 18 | 19 | #[serde(with = "time::serde::rfc3339")] 20 | pub published: OffsetDateTime, 21 | 22 | pub to: Vec, 23 | #[serde(default)] 24 | pub cc: Vec, 25 | } 26 | 27 | impl Default for Activity { 28 | fn default() -> Self { 29 | Self { 30 | context: super::context_field(), 31 | 32 | id: String::default(), 33 | r#type: String::default(), 34 | actor: String::default(), 35 | 36 | object: ObjectField::default(), 37 | published: OffsetDateTime::now_utc(), 38 | 39 | to: Vec::default(), 40 | cc: Vec::default(), 41 | } 42 | } 43 | } 44 | 45 | #[derive(Clone, Debug, Deserialize, Serialize)] 46 | #[serde(untagged)] 47 | pub enum ObjectField { 48 | Actor(super::Actor), 49 | Object(super::Object), 50 | Url(String), 51 | } 52 | 53 | impl Default for ObjectField { 54 | fn default() -> Self { 55 | Self::Object(super::Object::default()) 56 | } 57 | } 58 | 59 | impl ObjectField { 60 | pub fn as_actor(&self) -> Option<&super::Actor> { 61 | match self { 62 | Self::Actor(actor) => Some(actor), 63 | _ => None, 64 | } 65 | } 66 | 67 | pub fn as_object(&self) -> Option<&super::Object> { 68 | match self { 69 | Self::Object(object) => Some(object), 70 | _ => None, 71 | } 72 | } 73 | 74 | pub fn as_url(&self) -> Option<&String> { 75 | match self { 76 | Self::Url(url) => Some(url), 77 | _ => None, 78 | } 79 | } 80 | 81 | pub fn as_mut_actor(&mut self) -> Option<&mut super::Actor> { 82 | match self { 83 | Self::Actor(actor) => Some(actor), 84 | _ => None, 85 | } 86 | } 87 | 88 | pub fn as_mut_object(&mut self) -> Option<&mut super::Object> { 89 | match self { 90 | Self::Object(object) => Some(object), 91 | _ => None, 92 | } 93 | } 94 | 95 | pub fn as_mut_url(&mut self) -> Option<&mut String> { 96 | match self { 97 | Self::Url(url) => Some(url), 98 | _ => None, 99 | } 100 | } 101 | } 102 | 103 | impl From for ObjectField { 104 | fn from(actor: super::Actor) -> Self { 105 | Self::Actor(actor) 106 | } 107 | } 108 | 109 | impl From for ObjectField { 110 | fn from(object: super::Object) -> Self { 111 | Self::Object(object) 112 | } 113 | } 114 | 115 | impl From for ObjectField { 116 | fn from(url: String) -> Self { 117 | Self::Url(url) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tranquility-types/src/activitypub/actor.rs: -------------------------------------------------------------------------------- 1 | use super::{Attachment, Tag}; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_json::Value; 4 | 5 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 6 | #[serde(rename_all = "camelCase")] 7 | /// Struct representing the inofficial `publicKey` field of an ActivityPub actor 8 | pub struct PublicKey { 9 | pub id: String, 10 | pub owner: String, 11 | pub public_key_pem: String, 12 | } 13 | 14 | #[derive(Clone, Debug, Deserialize, Serialize)] 15 | #[serde(rename_all = "camelCase")] 16 | /// Struct representing an [ActivityStreams actor](https://www.w3.org/TR/activitypub/#actor-objects) with ActivityPub specific extensions 17 | pub struct Actor { 18 | #[serde(default = "super::context_field", rename = "@context")] 19 | pub context: Value, 20 | 21 | pub id: String, 22 | // (Should) always equal "Person" 23 | pub r#type: String, 24 | 25 | // Display name 26 | pub name: String, 27 | // Unique username 28 | #[serde(rename = "preferredUsername")] 29 | pub username: String, 30 | 31 | pub summary: String, 32 | // In case you mention someone in your summary 33 | #[serde(default)] 34 | pub tag: Vec, 35 | // Profile picture 36 | pub icon: Option, 37 | // Header image 38 | pub image: Option, 39 | 40 | #[serde(default)] 41 | pub manually_approves_followers: bool, 42 | 43 | pub inbox: String, 44 | pub outbox: String, 45 | pub followers: String, 46 | pub following: String, 47 | pub public_key: PublicKey, 48 | } 49 | 50 | impl Default for Actor { 51 | fn default() -> Self { 52 | Self { 53 | context: super::context_field(), 54 | 55 | id: String::default(), 56 | r#type: String::default(), 57 | 58 | name: String::default(), 59 | username: String::default(), 60 | 61 | summary: String::default(), 62 | tag: Vec::default(), 63 | 64 | icon: None, 65 | image: None, 66 | 67 | manually_approves_followers: false, 68 | 69 | inbox: String::default(), 70 | outbox: String::default(), 71 | followers: String::default(), 72 | following: String::default(), 73 | public_key: PublicKey::default(), 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tranquility-types/src/activitypub/attachment.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 4 | /// Struct representing an [ActivityStreams attachment](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-attachment) 5 | pub struct Attachment { 6 | pub r#type: String, 7 | pub url: String, 8 | } 9 | -------------------------------------------------------------------------------- /tranquility-types/src/activitypub/collection.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_json::Value; 3 | 4 | #[derive(Clone, Debug, Deserialize, Serialize)] 5 | #[serde(rename_all = "camelCase")] 6 | /// Struct representing an [ActivityStreams collection](https://www.w3.org/TR/activitystreams-core/#collections) 7 | pub struct Collection { 8 | #[serde(default = "super::context_field", rename = "@context")] 9 | pub context: Value, 10 | 11 | pub id: String, 12 | pub r#type: String, 13 | 14 | #[serde(skip_serializing_if = "Option::is_none")] 15 | pub first: Option, 16 | 17 | #[serde(default, skip_serializing_if = "String::is_empty")] 18 | pub part_of: String, 19 | #[serde(default, skip_serializing_if = "String::is_empty")] 20 | pub next: String, 21 | 22 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 23 | pub ordered_items: Vec, 24 | } 25 | 26 | #[derive(Clone, Debug, Deserialize, Serialize)] 27 | #[serde(untagged)] 28 | pub enum Item { 29 | Activity(Box), 30 | Url(String), 31 | } 32 | 33 | impl From for Item { 34 | fn from(item: super::Activity) -> Self { 35 | Self::Activity(Box::new(item)) 36 | } 37 | } 38 | 39 | impl From for Item { 40 | fn from(item: String) -> Self { 41 | Self::Url(item) 42 | } 43 | } 44 | 45 | impl Default for Collection { 46 | fn default() -> Self { 47 | Self { 48 | context: super::context_field(), 49 | 50 | id: String::default(), 51 | r#type: String::default(), 52 | 53 | first: None, 54 | 55 | part_of: String::default(), 56 | next: String::default(), 57 | 58 | ordered_items: Vec::default(), 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tranquility-types/src/activitypub/mod.rs: -------------------------------------------------------------------------------- 1 | use serde_json::{json, Value}; 2 | 3 | pub const PUBLIC_IDENTIFIER: &str = "https://www.w3.org/ns/activitystreams#Public"; 4 | 5 | pub const OUTBOX_FOLLOW_COLLECTIONS_TYPE: &str = "OrderedCollection"; 6 | pub const OUTBOX_FOLLOW_COLLECTIONS_PAGE_TYPE: &str = "OrderedCollectionPage"; 7 | 8 | /// Get the `@context` field value 9 | pub fn context_field() -> Value { 10 | json!(["https://www.w3.org/ns/activitystreams"]) 11 | } 12 | 13 | pub mod activity; 14 | pub mod actor; 15 | pub mod attachment; 16 | pub mod collection; 17 | pub mod object; 18 | pub mod tag; 19 | pub mod traits; 20 | 21 | pub use activity::Activity; 22 | pub use actor::{Actor, PublicKey}; 23 | pub use attachment::Attachment; 24 | pub use collection::Collection; 25 | pub use object::Object; 26 | pub use tag::Tag; 27 | pub use traits::{IsPrivate, IsPublic, IsUnlisted}; 28 | -------------------------------------------------------------------------------- /tranquility-types/src/activitypub/object.rs: -------------------------------------------------------------------------------- 1 | use super::{Attachment, Tag}; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_json::Value; 4 | use time::OffsetDateTime; 5 | 6 | #[derive(Clone, Debug, Deserialize, Serialize)] 7 | #[serde(rename_all = "camelCase")] 8 | /// Struct representing an [ActivityStreams object](https://www.w3.org/TR/activitystreams-core/#object) 9 | pub struct Object { 10 | #[serde(default = "super::context_field", rename = "@context")] 11 | pub context: Value, 12 | 13 | pub id: String, 14 | pub r#type: String, 15 | 16 | pub attributed_to: String, 17 | 18 | pub summary: String, 19 | pub content: String, 20 | 21 | #[serde(with = "time::serde::rfc3339")] 22 | pub published: OffsetDateTime, 23 | 24 | #[serde(default)] 25 | pub sensitive: bool, 26 | 27 | #[serde(default)] 28 | pub attachment: Vec, 29 | #[serde(default)] 30 | pub tag: Vec, 31 | 32 | pub to: Vec, 33 | pub cc: Vec, 34 | } 35 | 36 | impl Default for Object { 37 | fn default() -> Self { 38 | Self { 39 | context: super::context_field(), 40 | 41 | id: String::default(), 42 | r#type: String::default(), 43 | 44 | attributed_to: String::default(), 45 | 46 | summary: String::default(), 47 | content: String::default(), 48 | published: OffsetDateTime::now_utc(), 49 | sensitive: false, 50 | 51 | attachment: Vec::default(), 52 | tag: Vec::default(), 53 | 54 | to: Vec::default(), 55 | cc: Vec::default(), 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tranquility-types/src/activitypub/tag.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 4 | /// Struct representing an [ActivityStreams tag](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag) 5 | pub struct Tag { 6 | pub r#type: String, 7 | /// Format: @\@\ 8 | pub name: String, 9 | pub href: String, 10 | } 11 | -------------------------------------------------------------------------------- /tranquility-types/src/activitypub/traits/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod privacy; 2 | 3 | pub use privacy::{IsPrivate, IsPublic, IsUnlisted}; 4 | -------------------------------------------------------------------------------- /tranquility-types/src/activitypub/traits/privacy.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | activitypub::{Activity, Object, PUBLIC_IDENTIFIER}, 3 | util::contains, 4 | }; 5 | 6 | /// Macro that implements the [IsPublic] and [IsUnlisted] trait for an struct 7 | /// 8 | /// The struct has to have `to` and `cc` fields that can be implicitly converted to an `&[String]` when referenced 9 | macro_rules! is_public_unlisted_traits { 10 | ($($type:ty),+) => { 11 | $( 12 | impl IsPublic for $type { 13 | fn is_public(&self) -> bool { 14 | contains(&self.to, PUBLIC_IDENTIFIER) 15 | } 16 | } 17 | 18 | impl IsUnlisted for $type { 19 | fn is_unlisted(&self) -> bool { 20 | contains(&self.cc, PUBLIC_IDENTIFIER) 21 | } 22 | } 23 | )+ 24 | } 25 | } 26 | 27 | /// Trait to check whether the ActivityPub entity is public 28 | pub trait IsPublic { 29 | /// Everyone is allowed to see the post and should appear in every timeline 30 | fn is_public(&self) -> bool; 31 | } 32 | 33 | /// Trait to check whether the ActivityPub entity is unlisted 34 | pub trait IsUnlisted { 35 | /// Everyone is allowed to see the post but it should only appear in the follower's home timelines 36 | fn is_unlisted(&self) -> bool; 37 | } 38 | 39 | is_public_unlisted_traits!(Activity, Object); 40 | 41 | /// Trait to check whether the ActivityPub entity is private 42 | pub trait IsPrivate { 43 | /// Only followers and/or mentioned users are allowed to see the post and it should only appear in the aforementioned user groups home timelines 44 | fn is_private(&self) -> bool; 45 | } 46 | 47 | impl IsPrivate for T 48 | where 49 | T: IsPublic + IsUnlisted, 50 | { 51 | fn is_private(&self) -> bool { 52 | !self.is_public() && !self.is_unlisted() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tranquility-types/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, clippy::pedantic)] 2 | #![allow( 3 | clippy::doc_markdown, 4 | clippy::struct_excessive_bools, 5 | clippy::missing_errors_doc, 6 | clippy::must_use_candidate 7 | )] 8 | 9 | #[cfg(feature = "activitypub")] 10 | pub mod activitypub; 11 | #[cfg(feature = "mastodon")] 12 | pub mod mastodon; 13 | #[cfg(feature = "nodeinfo")] 14 | pub mod nodeinfo; 15 | #[cfg(feature = "webfinger")] 16 | pub mod webfinger; 17 | 18 | #[cfg(test)] 19 | mod tests; 20 | 21 | mod util; 22 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/account.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize)] 4 | /// Struct representing an [Mastodon account](https://docs.joinmastodon.org/entities/account/) 5 | pub struct Account { 6 | pub id: String, 7 | 8 | pub username: String, 9 | pub acct: String, 10 | pub display_name: String, 11 | 12 | pub locked: bool, 13 | pub bot: bool, 14 | 15 | pub created_at: String, 16 | pub note: String, 17 | pub url: String, 18 | 19 | pub avatar: String, 20 | pub avatar_static: String, 21 | 22 | pub header: String, 23 | pub header_static: String, 24 | 25 | pub followers_count: i64, 26 | pub following_count: i64, 27 | pub statuses_count: i64, 28 | 29 | pub last_status_at: String, 30 | 31 | #[serde(skip_serializing_if = "Option::is_none")] 32 | pub source: Option, 33 | 34 | pub emojis: Vec, 35 | pub fields: Vec, 36 | } 37 | 38 | #[derive(Default, Deserialize, Serialize)] 39 | /// Struct representing the answer to a successful follow 40 | pub struct FollowResponse { 41 | pub id: String, 42 | 43 | pub showing_reblogs: bool, 44 | pub notifying: bool, 45 | pub requested: bool, 46 | pub endorsed: bool, 47 | 48 | pub following: bool, 49 | pub followed_by: bool, 50 | 51 | pub blocking: bool, 52 | pub blocked_by: bool, 53 | pub domain_blocking: bool, 54 | 55 | pub muting: bool, 56 | pub muting_notifications: bool, 57 | } 58 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/app.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Default, Deserialize, Serialize)] 4 | /// Struct representing an [Mastodon application](https://docs.joinmastodon.org/entities/application/) 5 | pub struct App { 6 | pub id: String, 7 | 8 | pub name: String, 9 | pub website: Option, 10 | pub redirect_uri: String, 11 | 12 | pub client_id: String, 13 | pub client_secret: String, 14 | 15 | #[serde(skip_serializing_if = "Option::is_none")] 16 | pub vapid_key: Option, 17 | } 18 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/attachment.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize)] 4 | /// Struct representing an [Mastodon attachment](https://docs.joinmastodon.org/entities/attachment/) 5 | pub struct Attachment { 6 | pub id: String, 7 | pub r#type: String, 8 | pub url: String, 9 | pub preview_url: String, 10 | pub remote_url: Option, 11 | pub text_url: String, 12 | pub meta: Option, 13 | pub description: String, 14 | pub blurhash: String, 15 | } 16 | 17 | #[derive(Default, Deserialize, Serialize)] 18 | /// Struct representing the meta field of an [Attachment] struct 19 | pub struct Meta { 20 | pub original: MetaSize, 21 | pub small: MetaSize, 22 | pub focus: MetaFocus, 23 | } 24 | 25 | #[derive(Default, Deserialize, Serialize)] 26 | /// Struct representing the different sizes of a [Meta] struct 27 | pub struct MetaSize { 28 | pub width: i64, 29 | pub height: i64, 30 | pub size: String, 31 | pub aspect: f64, 32 | } 33 | 34 | #[derive(Default, Deserialize, Serialize)] 35 | /// Struct representing the [focal points](https://docs.joinmastodon.org/methods/statuses/media/#focal-points) 36 | pub struct MetaFocus { 37 | pub x: f64, 38 | pub y: f64, 39 | } 40 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/card.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize)] 4 | /// Struct representing a [Mastodon card](https://docs.joinmastodon.org/entities/card/) 5 | pub struct Card { 6 | pub r#type: String, 7 | 8 | pub url: String, 9 | pub title: String, 10 | pub description: String, 11 | 12 | pub author_name: String, 13 | pub author_url: String, 14 | pub provider_name: String, 15 | pub html: String, 16 | 17 | pub width: i64, 18 | pub height: i64, 19 | 20 | pub image: String, 21 | pub embed_url: String, 22 | pub blurhash: String, 23 | } 24 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/emoji.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize)] 4 | /// Struct representing a [Mastodon emoji](https://docs.joinmastodon.org/entities/emoji/) 5 | pub struct Emoji { 6 | pub shortcode: String, 7 | 8 | pub url: String, 9 | pub static_url: String, 10 | 11 | pub visible_in_picker: bool, 12 | } 13 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/field.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize)] 4 | /// Struct representing a [Mastodon field](https://docs.joinmastodon.org/entities/field/) 5 | pub struct Field { 6 | pub name: String, 7 | pub value: String, 8 | pub verified_at: Option, 9 | } 10 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/instance.rs: -------------------------------------------------------------------------------- 1 | use super::Account; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Default, Deserialize, Serialize)] 5 | /// Struct representing the `stats` field of an [Instance] 6 | pub struct Stats { 7 | pub user_count: u64, 8 | pub status_count: u64, 9 | pub domain_count: u64, 10 | } 11 | 12 | #[derive(Default, Deserialize, Serialize)] 13 | /// Struct representing the `urls` field of an [Instance] 14 | pub struct Urls { 15 | pub streaming_api: String, 16 | } 17 | 18 | #[derive(Default, Deserialize, Serialize)] 19 | /// Struct representing an [Mastodon instance](https://docs.joinmastodon.org/entities/instance/) 20 | pub struct Instance { 21 | pub uri: String, 22 | pub title: String, 23 | pub short_description: Option, 24 | pub description: String, 25 | pub email: Option, 26 | pub version: String, 27 | pub urls: Urls, 28 | pub stats: Stats, 29 | pub thumbnail: Option, 30 | pub language: Vec, 31 | pub registrations: bool, 32 | pub approval_required: bool, 33 | pub invites_enabled: bool, 34 | pub contact_account: Option, 35 | } 36 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/mention.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize)] 4 | /// Struct representing a [Mastodon mention](https://docs.joinmastodon.org/entities/mention/) 5 | pub struct Mention { 6 | pub id: String, 7 | 8 | pub username: String, 9 | pub acct: String, 10 | 11 | pub url: String, 12 | } 13 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod app; 3 | pub mod attachment; 4 | pub mod card; 5 | pub mod emoji; 6 | pub mod field; 7 | pub mod instance; 8 | pub mod mention; 9 | pub mod poll; 10 | pub mod source; 11 | pub mod status; 12 | pub mod tag; 13 | 14 | pub use account::{Account, FollowResponse}; 15 | pub use app::App; 16 | pub use attachment::Attachment; 17 | pub use card::Card; 18 | pub use emoji::Emoji; 19 | pub use field::Field; 20 | pub use instance::Instance; 21 | pub use mention::Mention; 22 | pub use poll::Poll; 23 | pub use source::Source; 24 | pub use status::Status; 25 | pub use tag::{History, Tag}; 26 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/poll.rs: -------------------------------------------------------------------------------- 1 | // To avoid name collision between the `Option` and the `PollOption` types 2 | #![allow(clippy::module_name_repetitions)] 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Default, Deserialize, Serialize)] 7 | /// Struct representing a [Mastodon poll](https://docs.joinmastodon.org/entities/poll/) 8 | pub struct Poll { 9 | pub id: String, 10 | 11 | pub expires_at: String, 12 | pub expired: bool, 13 | 14 | pub multiple: bool, 15 | pub votes_count: i64, 16 | pub voters_count: Option, 17 | 18 | pub own_votes: Option>, 19 | 20 | pub options: Vec, 21 | pub emojis: Vec, 22 | } 23 | 24 | #[derive(Default, Deserialize, Serialize)] 25 | /// Struct representing on option of a [Poll] 26 | pub struct PollOption { 27 | pub title: String, 28 | pub votes_count: i64, 29 | } 30 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/source.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize)] 4 | /// Struct representing a [Mastodon source](https://docs.joinmastodon.org/entities/source/) 5 | pub struct Source { 6 | pub privacy: String, 7 | pub sensitive: bool, 8 | pub language: String, 9 | 10 | pub note: String, 11 | pub fields: Vec, 12 | pub follow_requests_count: i64, 13 | } 14 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/status.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use time::OffsetDateTime; 3 | 4 | #[derive(Deserialize, Serialize)] 5 | /// Struct representing a [Mastodon status](https://docs.joinmastodon.org/entities/status/) 6 | pub struct Status { 7 | pub id: String, 8 | 9 | #[serde(with = "time::serde::rfc3339")] 10 | pub created_at: OffsetDateTime, 11 | 12 | pub in_reply_to_id: Option, 13 | pub in_reply_to_account_id: Option, 14 | 15 | pub sensitive: bool, 16 | pub spoiler_text: String, 17 | pub visibility: String, 18 | pub language: String, 19 | 20 | pub uri: String, 21 | pub url: String, 22 | 23 | pub replies_count: i64, 24 | pub reblogs_count: i64, 25 | pub favourites_count: i64, 26 | 27 | #[serde(skip_serializing_if = "Option::is_none")] 28 | pub favourited: Option, 29 | #[serde(skip_serializing_if = "Option::is_none")] 30 | pub reblogged: Option, 31 | #[serde(skip_serializing_if = "Option::is_none")] 32 | pub muted: Option, 33 | #[serde(skip_serializing_if = "Option::is_none")] 34 | pub bookmarked: Option, 35 | 36 | pub content: String, 37 | pub reblog: Option>, 38 | 39 | pub application: super::App, 40 | pub account: super::Account, 41 | 42 | pub media_attachments: Vec, 43 | pub mentions: Vec, 44 | pub tags: Vec, 45 | 46 | pub card: Option, 47 | pub poll: Option, 48 | } 49 | 50 | impl Default for Status { 51 | fn default() -> Self { 52 | Self { 53 | id: String::default(), 54 | created_at: OffsetDateTime::now_utc(), 55 | in_reply_to_id: Option::default(), 56 | in_reply_to_account_id: Option::default(), 57 | sensitive: bool::default(), 58 | spoiler_text: String::default(), 59 | visibility: String::default(), 60 | language: String::default(), 61 | uri: String::default(), 62 | url: String::default(), 63 | replies_count: i64::default(), 64 | reblogs_count: i64::default(), 65 | favourites_count: i64::default(), 66 | favourited: Option::default(), 67 | reblogged: Option::default(), 68 | muted: Option::default(), 69 | bookmarked: Option::default(), 70 | content: String::default(), 71 | reblog: Option::default(), 72 | application: super::App::default(), 73 | account: super::Account::default(), 74 | media_attachments: Vec::default(), 75 | mentions: Vec::default(), 76 | tags: Vec::default(), 77 | card: Option::default(), 78 | poll: Option::default(), 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tranquility-types/src/mastodon/tag.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize)] 4 | /// Struct representing a [Mastodon tag](https://docs.joinmastodon.org/entities/tag/) 5 | pub struct Tag { 6 | pub name: String, 7 | pub url: String, 8 | 9 | pub history: Option, 10 | } 11 | 12 | #[derive(Default, Deserialize, Serialize)] 13 | /// Struct representing a [Mastodon history](https://docs.joinmastodon.org/entities/history/) 14 | pub struct History { 15 | pub day: String, 16 | pub uses: String, 17 | pub accounts: String, 18 | } 19 | -------------------------------------------------------------------------------- /tranquility-types/src/nodeinfo.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_json::{Map, Value}; 3 | 4 | #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] 5 | /// Struct representing an entry in the "links" array 6 | pub struct Link { 7 | pub rel: String, 8 | pub href: String, 9 | } 10 | 11 | impl Link { 12 | /// Initialise a new nodeinfo link 13 | /// 14 | /// The "rel" of this value will point to "http://nodeinfo.diaspora.software/ns/schema/2.1" because the only types available here are 2.1 types 15 | pub fn new(href: String) -> Self { 16 | Self { 17 | rel: "http://nodeinfo.diaspora.software/ns/schema/2.1".into(), 18 | href, 19 | } 20 | } 21 | } 22 | 23 | #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] 24 | /// Struct representing a collection of links pointing to Nodeinfo entities 25 | pub struct LinkCollection { 26 | pub links: Vec, 27 | } 28 | 29 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 30 | #[serde(rename_all = "camelCase")] 31 | /// Struct representing a [Nodeinfo 2.1](https://github.com/jhass/nodeinfo/blob/1fcd229a84031253eb73a315e89d3f7f13f117b4/PROTOCOL.md) entity 32 | pub struct Nodeinfo { 33 | pub version: String, 34 | pub software: Software, 35 | pub protocols: Vec, 36 | pub services: Services, 37 | pub open_registrations: bool, 38 | pub usage: Usage, 39 | pub metadata: Value, 40 | } 41 | 42 | impl Default for Nodeinfo { 43 | fn default() -> Self { 44 | Self { 45 | version: "2.1".into(), 46 | software: Software::default(), 47 | protocols: Vec::new(), 48 | services: Services::default(), 49 | open_registrations: false, 50 | usage: Usage::default(), 51 | 52 | // Has to be an empty map to comply with the schema 53 | metadata: Value::Object(Map::default()), 54 | } 55 | } 56 | } 57 | 58 | #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] 59 | #[serde(rename_all = "camelCase")] 60 | /// Struct representing the `usage` field of a Nodeinfo entity 61 | pub struct Usage { 62 | pub users: UsageUsers, 63 | pub local_posts: u64, 64 | pub local_comments: u64, 65 | } 66 | 67 | #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] 68 | #[serde(rename_all = "camelCase")] 69 | /// Struct representing the `users` field of a "Usage" entity 70 | pub struct UsageUsers { 71 | pub total: u64, 72 | pub active_halfyear: u64, 73 | pub active_month: u64, 74 | } 75 | 76 | #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] 77 | /// Struct representing the `software` field of a Nodeinfo entity 78 | pub struct Software { 79 | pub name: String, 80 | pub version: String, 81 | pub repository: String, 82 | pub homepage: String, 83 | } 84 | 85 | #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] 86 | /// Struct representing the `services` field of a Nodeinfo entity 87 | pub struct Services { 88 | pub inbound: Vec, 89 | pub outbound: Vec, 90 | } 91 | -------------------------------------------------------------------------------- /tranquility-types/src/util.rs: -------------------------------------------------------------------------------- 1 | // Otherwise the compiler will complain when the `activitypub` feature is deactivated 2 | // Since the only code using this function is the ActivityPub code 3 | #![allow(dead_code)] 4 | 5 | #[inline] 6 | /// A replacement for `.contains()` because, for example, the `.contains()` of `Vec` can't be used with an `&str` 7 | pub fn contains(vec: &[String], value: &str) -> bool { 8 | vec.iter().any(|entry| entry == value) 9 | } 10 | -------------------------------------------------------------------------------- /tranquility-types/src/webfinger.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | 4 | pub type KvPairs = HashMap>; 5 | 6 | #[derive(Default, Deserialize, Serialize)] 7 | /// Struct representing a [link object](https://tools.ietf.org/html/rfc7033#section-4.4.4) contained in a JRD object 8 | pub struct Link { 9 | pub rel: String, 10 | 11 | #[serde(skip_serializing_if = "Option::is_none")] 12 | pub r#type: Option, 13 | #[serde(default, skip_serializing_if = "String::is_empty")] 14 | pub href: String, 15 | #[serde(skip_serializing_if = "Option::is_none")] 16 | pub template: Option, 17 | 18 | #[serde(skip_serializing_if = "Option::is_none")] 19 | pub titles: Option, 20 | #[serde(skip_serializing_if = "Option::is_none")] 21 | pub properties: Option, 22 | } 23 | 24 | #[derive(Default, Deserialize, Serialize)] 25 | /// Struct repesenting an [JSON resource descriptor](https://tools.ietf.org/html/rfc7033#section-4.4) 26 | pub struct Resource { 27 | pub subject: String, 28 | 29 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 30 | pub aliases: Vec, 31 | #[serde(skip_serializing_if = "Option::is_none")] 32 | pub properties: Option, 33 | 34 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 35 | pub links: Vec, 36 | } 37 | -------------------------------------------------------------------------------- /tranquility/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /tranquility/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tranquility" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "MIT" 6 | build = "build.rs" 7 | 8 | [dependencies] 9 | ammonia = "3.2.1" 10 | argh = "0.1.9" 11 | askama = "0.11.1" 12 | async-trait = "0.1.58" 13 | axum = "0.5.17" 14 | axum-macros = "0.2.3" 15 | axum-server = { version = "0.4.2", features = ["tls-rustls"] } 16 | base64 = "0.13.1" 17 | cfg-if = "1.0.0" 18 | futures-util = "0.3.25" 19 | headers = "0.3.8" 20 | hex = "0.4.3" 21 | http = "0.2.8" 22 | itertools = "0.10.5" 23 | mime = "0.3.16" 24 | once_cell = "1.16.0" 25 | ormx = { version = "0.10.0", features = ["postgres"] } 26 | paste = "1.0.9" 27 | rand = "0.8.5" 28 | rayon = "1.5.3" 29 | regex = "1.6.0" 30 | reqwest = { version = "0.11.12", default-features = false, features = ["json", "rustls-tls"] } 31 | rsa = "0.7.1" 32 | rust-argon2 = "1.0.0" 33 | serde = { version = "1.0.147", features = ["derive"] } 34 | serde_json = "1.0.87" 35 | serde_qs = "0.10.1" 36 | sha2 = "0.10.6" 37 | sqlx = { version = "0.5.12", features = ["json", "offline", "postgres", "runtime-tokio-rustls", "time", "uuid"] } # Needs to be v0.5.12 for the crates.io patch to work 38 | thiserror = "1.0.37" 39 | time = { version = "0.3.16", features = ["formatting"] } 40 | tokio = { version = "1.21.2", features = ["full", "tracing"] } 41 | toml = "0.5.9" 42 | tower = "0.4.13" 43 | tower-http = { version = "0.3.4", features = ["compression-full", "cors", "trace"] } 44 | tracing = { version = "0.1.37", features = ["attributes"] } 45 | tracing-subscriber = { version = "0.3.16", features = ["env-filter", "parking_lot"] } 46 | url = "2.3.1" 47 | uuid = { version = "1.2.1", features = ["serde", "v4"] } 48 | validator = { version = "0.16.0", features = ["derive"] } 49 | 50 | # Email functionality (optional) 51 | lettre = { version = "0.10.1", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1-rustls-tls", "tracing"], optional = true } 52 | 53 | # Jaeger/OpenTelemetry (optional) 54 | opentelemetry = { version = "0.18.0", features = ["rt-tokio"], optional = true } 55 | opentelemetry-jaeger = { version = "0.17.0", features = ["rt-tokio"], optional = true } 56 | tracing-opentelemetry = { version = "0.18.0", optional = true } 57 | 58 | # Markdown posts (optional) 59 | pulldown-cmark = { version = "0.9.2", default-features = false, optional = true } 60 | 61 | # Memory allocators (optional) 62 | jemalloc = { package = "jemallocator", version = "0.5.0", optional = true } 63 | mimalloc = { version = "0.1.30", optional = true } 64 | 65 | [dependencies.tranquility-content-length-limit] 66 | path = "../tranquility-content-length-limit" 67 | 68 | [dependencies.tranquility-http-signatures] 69 | path = "../tranquility-http-signatures" 70 | features = ["reqwest"] 71 | 72 | [dependencies.tranquility-ratelimit] 73 | path = "../tranquility-ratelimit" 74 | 75 | [dependencies.tranquility-types] 76 | path = "../tranquility-types" 77 | features = ["activitypub", "nodeinfo", "webfinger"] 78 | 79 | [features] 80 | default = ["mastodon-api"] 81 | 82 | email = ["lettre"] 83 | jaeger = ["opentelemetry", "opentelemetry-jaeger", "tracing-opentelemetry"] 84 | markdown = ["pulldown-cmark"] 85 | mastodon-api = ["tranquility-types/mastodon"] 86 | 87 | [dev-dependencies] 88 | jsonschema = { version = "0.16.1", default-features = false } 89 | -------------------------------------------------------------------------------- /tranquility/build.rs: -------------------------------------------------------------------------------- 1 | use std::{process::Command, str}; 2 | 3 | fn main() { 4 | let git_branch = Command::new("git") 5 | .args(&["rev-parse", "--abbrev-ref", "HEAD"]) 6 | .output() 7 | .unwrap(); 8 | let git_branch = str::from_utf8(git_branch.stdout.as_slice()).unwrap(); 9 | 10 | let git_commit = Command::new("git") 11 | .args(&["rev-parse", "--short", "HEAD"]) 12 | .output() 13 | .unwrap(); 14 | let git_commit = str::from_utf8(git_commit.stdout.as_slice()).unwrap(); 15 | 16 | println!("cargo:rustc-env=GIT_BRANCH={}", git_branch); 17 | println!("cargo:rustc-env=GIT_COMMIT={}", git_commit); 18 | } 19 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/accept.rs: -------------------------------------------------------------------------------- 1 | use crate::{activitypub::FollowActivity, database::Object, error::Error, state::ArcState}; 2 | use http::StatusCode; 3 | use ormx::Table; 4 | use tranquility_types::activitypub::Activity; 5 | 6 | pub async fn handle(state: &ArcState, activity: Activity) -> Result { 7 | let follow_activity_url = activity.object.as_url().ok_or(Error::UnknownActivity)?; 8 | let mut follow_activity_db = Object::by_url(&state.db_pool, follow_activity_url).await?; 9 | 10 | let mut follow_activity: FollowActivity = serde_json::from_value(follow_activity_db.data)?; 11 | // Check if the person rejecting the follow is actually the followed person 12 | if &activity.actor != follow_activity.activity.object.as_url().unwrap() { 13 | return Err(Error::Unauthorized); 14 | } 15 | 16 | if follow_activity.activity.r#type != "Follow" { 17 | return Err(Error::UnknownActivity); 18 | } 19 | 20 | follow_activity.approved = true; 21 | 22 | // Update the activity 23 | let follow_activity_value = serde_json::to_value(&follow_activity)?; 24 | follow_activity_db.data = follow_activity_value; 25 | follow_activity_db.update(&state.db_pool).await?; 26 | 27 | Ok(StatusCode::OK) 28 | } 29 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/announce.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | activitypub::fetcher, 3 | database::{Actor, InsertExt, InsertObject}, 4 | error::Error, 5 | state::ArcState, 6 | }; 7 | use http::StatusCode; 8 | use tranquility_types::activitypub::Activity; 9 | use uuid::Uuid; 10 | 11 | pub async fn handle(state: &ArcState, activity: Activity) -> Result { 12 | let object_url = activity.object.as_url().ok_or(Error::UnknownActivity)?; 13 | 14 | // Fetch the object (just in case) 15 | fetcher::fetch_object(state, object_url).await?; 16 | // Fetch the actor (just in case) 17 | fetcher::fetch_actor(state, &activity.actor).await?; 18 | 19 | let actor = Actor::by_url(&state.db_pool, &activity.actor).await?; 20 | let activity_value = serde_json::to_value(&activity)?; 21 | 22 | InsertObject { 23 | id: Uuid::new_v4(), 24 | owner_id: actor.id, 25 | data: activity_value, 26 | } 27 | .insert(&state.db_pool) 28 | .await?; 29 | 30 | Ok(StatusCode::CREATED) 31 | } 32 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/create.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | activitypub::{fetcher, Clean}, 3 | database::{InsertExt, InsertObject}, 4 | error::Error, 5 | state::ArcState, 6 | }; 7 | use http::StatusCode; 8 | use tranquility_types::activitypub::{activity::ObjectField, Activity, Object}; 9 | use uuid::Uuid; 10 | 11 | async fn insert_object(state: &ArcState, activity: &Activity) -> Result { 12 | let (_owner, owner_db) = fetcher::fetch_actor(state, &activity.actor).await?; 13 | 14 | let mut object = activity.object.as_object().unwrap().clone(); 15 | object.clean(); 16 | 17 | let object_value = serde_json::to_value(&object)?; 18 | 19 | InsertObject { 20 | id: Uuid::new_v4(), 21 | owner_id: owner_db.id, 22 | data: object_value, 23 | } 24 | .insert(&state.db_pool) 25 | .await?; 26 | 27 | Ok(object) 28 | } 29 | 30 | pub async fn handle(state: &ArcState, mut activity: Activity) -> Result { 31 | // Save the object in the database 32 | match activity.object { 33 | ObjectField::Object(_) => { 34 | let object = insert_object(state, &activity).await?; 35 | 36 | activity.object = ObjectField::Url(object.id); 37 | } 38 | ObjectField::Url(ref url) => { 39 | fetcher::fetch_object(state, url).await?; 40 | } 41 | ObjectField::Actor(_) => return Err(Error::UnknownActivity), 42 | } 43 | 44 | Ok(StatusCode::CREATED) 45 | } 46 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/delete.rs: -------------------------------------------------------------------------------- 1 | use crate::{activitypub::fetcher, database::Object, error::Error, state::ArcState}; 2 | use http::StatusCode; 3 | use tranquility_types::activitypub::{activity::ObjectField, Activity}; 4 | 5 | pub async fn handle(state: &ArcState, mut activity: Activity) -> Result { 6 | // Normalize activity 7 | match activity.object { 8 | ObjectField::Actor(..) => return Err(Error::UnknownActivity), 9 | ObjectField::Object(..) => (), 10 | ObjectField::Url(ref url) => { 11 | let object = fetcher::fetch_object(state, url).await?; 12 | 13 | activity.object = ObjectField::Object(object); 14 | } 15 | } 16 | 17 | let object = activity.object.as_object().unwrap(); 18 | 19 | Object::delete_by_url(&state.db_pool, &object.id).await?; 20 | 21 | Ok(StatusCode::CREATED) 22 | } 23 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/follow.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | activitypub::{self, deliverer, fetcher, FollowActivity}, 3 | database::{Actor, InsertExt, InsertObject}, 4 | error::Error, 5 | state::ArcState, 6 | }; 7 | use http::StatusCode; 8 | use std::sync::Arc; 9 | use tranquility_types::activitypub::{activity::ObjectField, Activity}; 10 | use uuid::Uuid; 11 | 12 | pub async fn handle(state: &ArcState, mut activity: Activity) -> Result { 13 | let actor_url = match activity.object { 14 | ObjectField::Actor(ref actor) => actor.id.as_str(), 15 | ObjectField::Url(ref url) => url.as_str(), 16 | ObjectField::Object(_) => return Err(Error::UnknownActivity), 17 | }; 18 | 19 | // Fetch the actor (just in case) 20 | let (actor, actor_db) = fetcher::fetch_actor(state, actor_url).await?; 21 | 22 | // Normalize the activity 23 | if let ObjectField::Actor(actor) = activity.object { 24 | activity.object = ObjectField::Url(actor.id); 25 | } 26 | 27 | // Automatically approve the follow (this will be choice based at some point) 28 | let follow_activity = FollowActivity { 29 | activity, 30 | approved: true, 31 | }; 32 | let activity = serde_json::to_value(&follow_activity)?; 33 | 34 | InsertObject { 35 | id: Uuid::new_v4(), 36 | owner_id: actor_db.id, 37 | data: activity, 38 | } 39 | .insert(&state.db_pool) 40 | .await?; 41 | 42 | let followed_url = follow_activity.activity.object.as_url().unwrap(); 43 | let followed_actor = Actor::by_url(&state.db_pool, followed_url).await?; 44 | 45 | // Send out an accept activity if the followed actor is local 46 | if follow_activity.approved { 47 | let (accept_activity_id, accept_activity) = activitypub::instantiate::activity( 48 | &state.config, 49 | "Accept", 50 | followed_url, 51 | follow_activity.activity.id, 52 | vec![actor.id], 53 | Vec::new(), 54 | ); 55 | let accept_activity_value = serde_json::to_value(&accept_activity)?; 56 | 57 | InsertObject { 58 | id: accept_activity_id, 59 | owner_id: followed_actor.id, 60 | data: accept_activity_value, 61 | } 62 | .insert(&state.db_pool) 63 | .await?; 64 | 65 | deliverer::deliver(accept_activity, Arc::clone(state)).await?; 66 | } 67 | 68 | Ok(StatusCode::CREATED) 69 | } 70 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/like.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | activitypub::fetcher, 3 | database::{Actor, InsertExt, InsertObject}, 4 | error::Error, 5 | state::ArcState, 6 | }; 7 | use http::StatusCode; 8 | use tranquility_types::activitypub::Activity; 9 | use uuid::Uuid; 10 | 11 | pub async fn handle(state: &ArcState, activity: Activity) -> Result { 12 | let object_url = activity.object.as_url().ok_or(Error::UnknownActivity)?; 13 | 14 | // Fetch the object (just in case) 15 | fetcher::fetch_object(state, object_url).await?; 16 | // Fetch the actor (just in case) 17 | fetcher::fetch_actor(state, &activity.actor).await?; 18 | let actor = Actor::by_url(&state.db_pool, &activity.actor).await?; 19 | 20 | let activity_value = serde_json::to_value(&activity)?; 21 | 22 | InsertObject { 23 | id: Uuid::new_v4(), 24 | owner_id: actor.id, 25 | data: activity_value, 26 | } 27 | .insert(&state.db_pool) 28 | .await?; 29 | 30 | Ok(StatusCode::CREATED) 31 | } 32 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod accept; 2 | pub mod announce; 3 | pub mod create; 4 | pub mod delete; 5 | pub mod follow; 6 | pub mod like; 7 | pub mod reject; 8 | pub mod undo; 9 | pub mod update; 10 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/reject.rs: -------------------------------------------------------------------------------- 1 | use crate::{database::Object, error::Error, state::ArcState}; 2 | use http::StatusCode; 3 | use ormx::Delete; 4 | use tranquility_types::activitypub::Activity; 5 | 6 | pub async fn handle(state: &ArcState, activity: Activity) -> Result { 7 | let follow_activity_url = activity.object.as_url().ok_or(Error::UnknownActivity)?; 8 | let follow_activity_db = Object::by_url(&state.db_pool, follow_activity_url).await?; 9 | let follow_activity: Activity = serde_json::from_value(follow_activity_db.data.clone())?; 10 | // Check if the person rejecting the follow is actually the followed person 11 | if &activity.actor != follow_activity.object.as_url().unwrap() { 12 | return Ok(StatusCode::UNAUTHORIZED); 13 | } 14 | 15 | if follow_activity.r#type != "Follow" { 16 | return Err(Error::UnknownActivity); 17 | } 18 | 19 | follow_activity_db.delete(&state.db_pool).await?; 20 | 21 | Ok(StatusCode::OK) 22 | } 23 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/undo.rs: -------------------------------------------------------------------------------- 1 | use crate::{database::Object, error::Error, state::ArcState}; 2 | use http::StatusCode; 3 | use tranquility_types::activitypub::Activity; 4 | 5 | pub async fn handle(state: &ArcState, delete_activity: Activity) -> Result { 6 | let activity_url = delete_activity 7 | .object 8 | .as_url() 9 | .ok_or(Error::UnknownActivity)?; 10 | 11 | Object::delete_by_url(&state.db_pool, activity_url).await?; 12 | 13 | Ok(StatusCode::CREATED) 14 | } 15 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/handler/update.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | activitypub::{fetcher, Clean}, 3 | database::Actor, 4 | error::Error, 5 | state::ArcState, 6 | }; 7 | use http::StatusCode; 8 | use ormx::Table; 9 | use tranquility_types::activitypub::Activity; 10 | 11 | pub async fn handle(state: &ArcState, mut activity: Activity) -> Result { 12 | // Update activities are usually only used to update the actor 13 | // (For example, when the user changes their bio or display name) 14 | let ap_actor = activity 15 | .object 16 | .as_mut_actor() 17 | .ok_or(Error::UnknownActivity)?; 18 | ap_actor.clean(); 19 | 20 | // Fetch the actor (just in case) 21 | fetcher::fetch_actor(state, ap_actor.id.as_str()).await?; 22 | 23 | let mut actor = Actor::by_url(&state.db_pool, ap_actor.id.as_str()).await?; 24 | 25 | // Update the actor value 26 | let ap_actor = serde_json::to_value(ap_actor)?; 27 | actor.actor = ap_actor; 28 | 29 | actor.update(&state.db_pool).await?; 30 | 31 | Ok(StatusCode::CREATED) 32 | } 33 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/instantiate.rs: -------------------------------------------------------------------------------- 1 | use crate::{config::Configuration, format_uuid}; 2 | use time::OffsetDateTime; 3 | use tranquility_types::activitypub::{activity::ObjectField, Activity, Actor, Object, PublicKey}; 4 | use uuid::Uuid; 5 | 6 | /// Instantiate an ActivityPub activity 7 | pub fn activity>( 8 | config: &Configuration, 9 | r#type: &str, 10 | owner_url: &str, 11 | object: T, 12 | to: Vec, 13 | cc: Vec, 14 | ) -> (Uuid, Activity) { 15 | let prefix = format!("https://{}", config.instance.domain); 16 | 17 | let uuid = Uuid::new_v4(); 18 | let id = format!("{}/objects/{}", prefix, format_uuid!(uuid)); 19 | 20 | let activity = Activity { 21 | id, 22 | r#type: r#type.into(), 23 | 24 | actor: owner_url.into(), 25 | 26 | object: object.into(), 27 | published: OffsetDateTime::now_utc(), 28 | 29 | to, 30 | cc, 31 | 32 | ..Activity::default() 33 | }; 34 | 35 | (uuid, activity) 36 | } 37 | 38 | /// Instantiate an ActivityPub actor 39 | pub fn actor( 40 | config: &Configuration, 41 | user_id: &str, 42 | username: &str, 43 | public_key_pem: String, 44 | ) -> Actor { 45 | let prefix = format!("https://{}", config.instance.domain); 46 | let id = format!("{}/users/{}", prefix, user_id); 47 | 48 | let inbox = format!("{}/inbox", id); 49 | let outbox = format!("{}/outbox", id); 50 | 51 | let followers = format!("{}/followers", id); 52 | let following = format!("{}/following", id); 53 | 54 | let key_id = format!("{}#main-key", id); 55 | 56 | let public_key = PublicKey { 57 | id: key_id, 58 | owner: id.clone(), 59 | public_key_pem, 60 | }; 61 | 62 | Actor { 63 | id, 64 | r#type: "Person".into(), 65 | 66 | username: username.into(), 67 | 68 | inbox, 69 | outbox, 70 | 71 | followers, 72 | following, 73 | 74 | public_key, 75 | 76 | ..Actor::default() 77 | } 78 | } 79 | 80 | /// Instantiate an ActivityPub object 81 | #[allow(clippy::too_many_arguments)] 82 | pub fn object( 83 | config: &Configuration, 84 | r#type: &str, 85 | owner_url: &str, 86 | summary: &str, 87 | content: &str, 88 | sensitive: bool, 89 | to: Vec, 90 | cc: Vec, 91 | ) -> (Uuid, Object) { 92 | let prefix = format!("https://{}", config.instance.domain); 93 | 94 | let uuid = Uuid::new_v4(); 95 | let id = format!("{}/objects/{}", prefix, format_uuid!(uuid)); 96 | 97 | let object = Object { 98 | id, 99 | r#type: r#type.into(), 100 | 101 | summary: summary.into(), 102 | content: content.into(), 103 | sensitive, 104 | published: OffsetDateTime::now_utc(), 105 | 106 | attributed_to: owner_url.into(), 107 | 108 | to, 109 | cc, 110 | 111 | ..Object::default() 112 | }; 113 | 114 | (uuid, object) 115 | } 116 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/interactions.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | database::{Actor as DbActor, InsertExt, InsertObject, Object as DbObject}, 3 | error::Error, 4 | state::ArcState, 5 | }; 6 | use std::sync::Arc; 7 | use tranquility_types::activitypub::{Activity, Actor}; 8 | 9 | /// Create an Follow activity for a follow, save it and send it out 10 | pub async fn follow(state: &ArcState, db_actor: DbActor, followed: &Actor) -> Result<(), Error> { 11 | let actor: Actor = serde_json::from_value(db_actor.actor)?; 12 | 13 | // Check if there's already a follow activity 14 | // If there's already a follow activity, just say everything was successful 15 | let existing_follow_activity = DbObject::by_type_owner_and_object_url( 16 | &state.db_pool, 17 | "Follow", 18 | &db_actor.id, 19 | followed.id.as_str(), 20 | ) 21 | .await; 22 | if existing_follow_activity.is_ok() { 23 | return Ok(()); 24 | } 25 | 26 | let (follow_activity_id, follow_activity) = crate::activitypub::instantiate::activity( 27 | &state.config, 28 | "Follow", 29 | actor.id.as_str(), 30 | followed.id.clone(), 31 | vec![followed.id.clone()], 32 | vec![], 33 | ); 34 | let follow_activity_value = serde_json::to_value(&follow_activity)?; 35 | 36 | InsertObject { 37 | id: follow_activity_id, 38 | owner_id: db_actor.id, 39 | data: follow_activity_value, 40 | } 41 | .insert(&state.db_pool) 42 | .await?; 43 | 44 | crate::activitypub::deliverer::deliver(follow_activity, Arc::clone(state)).await?; 45 | 46 | Ok(()) 47 | } 48 | 49 | /// Create an Undo activity for the given activity, save it and send it out 50 | pub async fn undo(state: &ArcState, db_actor: DbActor, db_activity: DbObject) -> Result<(), Error> { 51 | // Tried to delete someone else's activity 52 | if db_activity.owner_id != db_actor.id { 53 | return Err(Error::Unauthorized); 54 | } 55 | 56 | let activity: Activity = serde_json::from_value(db_activity.data)?; 57 | let actor: Actor = serde_json::from_value(db_actor.actor)?; 58 | 59 | // Send the undo activity to everyone who received the original activity 60 | let (undo_activity_id, undo_activity) = crate::activitypub::instantiate::activity( 61 | &state.config, 62 | "Undo", 63 | actor.id.as_str(), 64 | activity.id, 65 | activity.to, 66 | activity.cc, 67 | ); 68 | let undo_activity_value = serde_json::to_value(&undo_activity)?; 69 | 70 | InsertObject { 71 | id: undo_activity_id, 72 | owner_id: db_actor.id, 73 | data: undo_activity_value, 74 | } 75 | .insert(&state.db_pool) 76 | .await?; 77 | 78 | crate::activitypub::deliverer::deliver(undo_activity, Arc::clone(state)).await?; 79 | 80 | Ok(()) 81 | } 82 | 83 | /// Search the follow activity in the database and undo it 84 | pub async fn unfollow( 85 | state: &ArcState, 86 | db_actor: DbActor, 87 | followed_db_actor: DbActor, 88 | ) -> Result<(), Error> { 89 | let followed_actor: Actor = serde_json::from_value(followed_db_actor.actor)?; 90 | 91 | let follow_activity = DbObject::by_type_owner_and_object_url( 92 | &state.db_pool, 93 | "Follow", 94 | &db_actor.id, 95 | followed_actor.id.as_str(), 96 | ) 97 | .await?; 98 | 99 | undo(state, db_actor, follow_activity).await 100 | } 101 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use tranquility_types::activitypub::{Activity, Actor, IsPrivate, IsUnlisted, Object}; 3 | 4 | #[derive(Clone, Deserialize)] 5 | #[serde(untagged)] 6 | pub enum ActivityObject { 7 | Activity(Box), 8 | Object(Box), 9 | } 10 | 11 | impl IsPrivate for ActivityObject { 12 | fn is_private(&self) -> bool { 13 | match self { 14 | ActivityObject::Activity(activity) => activity.is_private(), 15 | ActivityObject::Object(object) => object.is_private(), 16 | } 17 | } 18 | } 19 | 20 | impl IsUnlisted for ActivityObject { 21 | fn is_unlisted(&self) -> bool { 22 | match self { 23 | ActivityObject::Activity(activity) => activity.is_unlisted(), 24 | ActivityObject::Object(object) => object.is_unlisted(), 25 | } 26 | } 27 | } 28 | 29 | #[derive(Clone, Default, Deserialize, Serialize)] 30 | pub struct FollowActivity { 31 | #[serde(flatten)] 32 | pub activity: Activity, 33 | 34 | #[serde(default)] 35 | pub approved: bool, 36 | } 37 | 38 | /// Extension trait for cleaning objects from potentially malicious HTML 39 | pub trait Clean { 40 | /// Clean any fields that could potentially contain malicious HTML 41 | fn clean(&mut self); 42 | } 43 | 44 | impl Clean for Actor { 45 | fn clean(&mut self) { 46 | self.name = ammonia::clean(self.name.as_str()); 47 | self.summary = ammonia::clean(self.summary.as_str()); 48 | } 49 | } 50 | 51 | impl Clean for Object { 52 | fn clean(&mut self) { 53 | self.summary = ammonia::clean(self.summary.as_str()); 54 | self.content = ammonia::clean(self.content.as_str()); 55 | } 56 | } 57 | 58 | pub mod deliverer; 59 | pub mod fetcher; 60 | pub mod handler; 61 | pub mod instantiate; 62 | pub mod interactions; 63 | pub mod routes; 64 | 65 | pub use routes::routes; 66 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/routes/followers.rs: -------------------------------------------------------------------------------- 1 | use super::CollectionQuery; 2 | use crate::{ 3 | activitypub::FollowActivity, consts::activitypub::ACTIVITIES_PER_PAGE, 4 | database::Actor as DbActor, error::Error, format_uuid, state::ArcState, 5 | }; 6 | use axum::{ 7 | extract::{Path, Query}, 8 | response::IntoResponse, 9 | Extension, Json, 10 | }; 11 | use itertools::Itertools; 12 | use tranquility_types::activitypub::{ 13 | collection::Item, Actor, Collection, OUTBOX_FOLLOW_COLLECTIONS_PAGE_TYPE, 14 | }; 15 | use uuid::Uuid; 16 | 17 | pub async fn followers( 18 | Path(user_id): Path, 19 | Extension(state): Extension, 20 | Query(query): Query, 21 | ) -> Result { 22 | let latest_follow_activities = crate::database::follow::followers( 23 | &state.db_pool, 24 | user_id, 25 | query.last_id, 26 | ACTIVITIES_PER_PAGE, 27 | ) 28 | .await?; 29 | let last_id = latest_follow_activities 30 | .last() 31 | .map(|activity| format_uuid!(activity.id)) 32 | .unwrap_or_default(); 33 | 34 | let latest_followers = latest_follow_activities 35 | .into_iter() 36 | .filter_map(|activity| { 37 | let follow_activity: FollowActivity = serde_json::from_value(activity.data).ok()?; 38 | let follower_id = follow_activity.activity.id; 39 | 40 | Some(Item::Url(follower_id)) 41 | }) 42 | .collect_vec(); 43 | 44 | let user_db = DbActor::get(&state.db_pool, user_id).await?; 45 | let user: Actor = serde_json::from_value(user_db.actor)?; 46 | 47 | let next = format!("{}?last_id={}", user.followers, last_id); 48 | 49 | let followers_collection = Collection { 50 | r#type: OUTBOX_FOLLOW_COLLECTIONS_PAGE_TYPE.into(), 51 | 52 | id: user.followers.clone(), 53 | part_of: user.followers, 54 | 55 | next, 56 | 57 | ordered_items: latest_followers, 58 | ..Collection::default() 59 | }; 60 | 61 | Ok(Json(followers_collection)) 62 | } 63 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/routes/following.rs: -------------------------------------------------------------------------------- 1 | use super::CollectionQuery; 2 | use crate::error::Error; 3 | use crate::{ 4 | activitypub::FollowActivity, consts::activitypub::ACTIVITIES_PER_PAGE, 5 | database::Actor as DbActor, format_uuid, state::ArcState, 6 | }; 7 | use axum::{ 8 | extract::{Path, Query}, 9 | response::IntoResponse, 10 | Extension, Json, 11 | }; 12 | use itertools::Itertools; 13 | use tranquility_types::activitypub::{ 14 | collection::Item, Actor, Collection, OUTBOX_FOLLOW_COLLECTIONS_PAGE_TYPE, 15 | }; 16 | use uuid::Uuid; 17 | 18 | pub async fn following( 19 | Path(user_id): Path, 20 | Extension(state): Extension, 21 | Query(query): Query, 22 | ) -> Result { 23 | let latest_follow_activities = crate::database::follow::following( 24 | &state.db_pool, 25 | user_id, 26 | query.last_id, 27 | ACTIVITIES_PER_PAGE, 28 | ) 29 | .await?; 30 | let last_id = latest_follow_activities 31 | .last() 32 | .map(|activity| format_uuid!(activity.id)) 33 | .unwrap_or_default(); 34 | 35 | let latest_followed = latest_follow_activities 36 | .into_iter() 37 | .filter_map(|activity| { 38 | let follow_activity: FollowActivity = serde_json::from_value(activity.data).ok()?; 39 | let followed_url = follow_activity.activity.object.as_url()?.clone(); 40 | 41 | Some(Item::Url(followed_url)) 42 | }) 43 | .collect_vec(); 44 | 45 | let user_db = DbActor::get(&state.db_pool, user_id).await?; 46 | let user: Actor = serde_json::from_value(user_db.actor)?; 47 | 48 | let next = format!("{}?last_id={}", user.following, last_id); 49 | 50 | let following_collection = Collection { 51 | r#type: OUTBOX_FOLLOW_COLLECTIONS_PAGE_TYPE.into(), 52 | 53 | id: user.following.clone(), 54 | part_of: user.following, 55 | 56 | next, 57 | 58 | ordered_items: latest_followed, 59 | ..Collection::default() 60 | }; 61 | 62 | Ok(Json(following_collection)) 63 | } 64 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/routes/inbox.rs: -------------------------------------------------------------------------------- 1 | use crate::{activitypub::fetcher, crypto, error::Error, match_handler, state::ArcState}; 2 | use async_trait::async_trait; 3 | use axum::{ 4 | body::HttpBody, 5 | extract::{FromRequest, RequestParts}, 6 | response::{IntoResponse, Response}, 7 | Extension, Json, 8 | }; 9 | use http::StatusCode; 10 | use std::{error::Error as StdError, sync::Arc}; 11 | use tranquility_types::activitypub::{activity::ObjectField, Activity}; 12 | 13 | /// Checks if the activity/object contained/referenced in the activity actually belongs to the author of the activity 14 | async fn verify_ownership(state: ArcState, activity: Activity) -> Result { 15 | // It's fine if the objects or activities don't match in this case 16 | if activity.r#type == "Announce" || activity.r#type == "Follow" { 17 | return Ok(activity); 18 | } 19 | 20 | let identity_match = match activity.object { 21 | ObjectField::Actor(ref actor) => actor.id == activity.actor, 22 | ObjectField::Object(ref object) => object.attributed_to == activity.actor, 23 | ObjectField::Url(ref url) => { 24 | let entity = fetcher::fetch_any(&state, url).await?; 25 | entity.is_owned_by(activity.actor.as_str()) 26 | } 27 | }; 28 | 29 | identity_match 30 | .then_some(activity) 31 | .ok_or(Error::Unauthorized) 32 | } 33 | 34 | /// Inbox payload extractor 35 | /// 36 | /// This extractor also runs additional checks about whether this request is actually valid 37 | pub struct InboxPayload(pub Activity); 38 | 39 | #[async_trait] 40 | impl FromRequest for InboxPayload 41 | where 42 | B: HttpBody + Send + Sync, 43 | B::Data: Send, 44 | B::Error: StdError + Send + Sync + 'static, 45 | { 46 | type Rejection = Response; 47 | 48 | async fn from_request(req: &mut RequestParts) -> Result { 49 | let Json(activity) = Json::::from_request(req) 50 | .await 51 | .map_err(IntoResponse::into_response)?; 52 | 53 | let state = req 54 | .extensions() 55 | .get::() 56 | .expect("[Bug] State missing in request extensions"); 57 | 58 | let (remote_actor, _remote_actor_db) = fetcher::fetch_actor(state, &activity.actor).await?; 59 | 60 | crypto::request::verify( 61 | req.method().as_str().to_string(), 62 | req.uri().path().to_string(), 63 | req.uri().query().map(ToString::to_string), 64 | req.headers().clone(), 65 | remote_actor.public_key.public_key_pem, 66 | ) 67 | .await? 68 | .then_some(()) 69 | .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; 70 | 71 | let activity = verify_ownership(Arc::clone(state), activity).await?; 72 | Ok(Self(activity)) 73 | } 74 | } 75 | 76 | /// Inbox handler 77 | pub async fn inbox( 78 | Extension(state): Extension, 79 | InboxPayload(activity): InboxPayload, 80 | ) -> Result { 81 | match_handler! { 82 | (state, activity); 83 | 84 | Accept, 85 | Announce, 86 | Create, 87 | Delete, 88 | Follow, 89 | Like, 90 | Reject, 91 | Undo, 92 | Update 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/routes/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | routing::{get, post}, 3 | Router, 4 | }; 5 | use serde::Deserialize; 6 | use uuid::Uuid; 7 | 8 | #[derive(Deserialize)] 9 | pub struct CollectionQuery { 10 | last_id: Option, 11 | } 12 | 13 | pub fn routes() -> Router { 14 | Router::new() 15 | .route("/users/:id", get(users::users)) 16 | .route("/users/:id/followers", get(followers::followers)) 17 | .route("/users/:id/following", get(following::following)) 18 | .route("/users/:id/inbox", post(inbox::inbox)) 19 | .route("/users/:id/outbox", get(outbox::outbox)) 20 | .route("/objects/:id", get(objects::objects)) 21 | } 22 | 23 | pub mod followers; 24 | pub mod following; 25 | pub mod inbox; 26 | pub mod objects; 27 | pub mod outbox; 28 | pub mod users; 29 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/routes/objects.rs: -------------------------------------------------------------------------------- 1 | use crate::{activitypub::ActivityObject, database::Object, error::Error, state::ArcState}; 2 | use axum::{extract::Path, response::IntoResponse, Extension, Json}; 3 | use http::StatusCode; 4 | use ormx::Table; 5 | use tranquility_types::activitypub::IsPrivate; 6 | use uuid::Uuid; 7 | 8 | pub async fn objects( 9 | Path(id): Path, 10 | Extension(state): Extension, 11 | ) -> Result { 12 | let object = Object::get(&state.db_pool, id).await?; 13 | let activity_or_object: ActivityObject = serde_json::from_value(object.data.clone())?; 14 | 15 | // Do not expose private activities/object publicly 16 | if activity_or_object.is_private() { 17 | return Ok(StatusCode::NOT_FOUND.into_response()); 18 | } 19 | 20 | Ok(Json(&object.data).into_response()) 21 | } 22 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/routes/outbox.rs: -------------------------------------------------------------------------------- 1 | use super::CollectionQuery; 2 | use crate::{ 3 | consts::activitypub::ACTIVITIES_PER_PAGE, database::Actor as DbActor, error::Error, 4 | format_uuid, state::ArcState, 5 | }; 6 | use axum::{ 7 | extract::{Path, Query}, 8 | response::IntoResponse, 9 | Extension, Json, 10 | }; 11 | use itertools::Itertools; 12 | use std::ops::Not; 13 | use tranquility_types::activitypub::{ 14 | collection::Item, Activity, Actor, Collection, IsPrivate, OUTBOX_FOLLOW_COLLECTIONS_PAGE_TYPE, 15 | }; 16 | use uuid::Uuid; 17 | 18 | pub async fn outbox( 19 | Path(user_id): Path, 20 | Extension(state): Extension, 21 | Query(query): Query, 22 | ) -> Result { 23 | let latest_activities = crate::database::outbox::activities( 24 | &state.db_pool, 25 | user_id, 26 | query.last_id, 27 | ACTIVITIES_PER_PAGE, 28 | ) 29 | .await?; 30 | let last_id = latest_activities 31 | .last() 32 | .map(|activity| format_uuid!(activity.id)) 33 | .unwrap_or_default(); 34 | 35 | let latest_activities = latest_activities 36 | .into_iter() 37 | .filter_map(|activity| { 38 | let create_activity: Activity = serde_json::from_value(activity.data).ok()?; 39 | 40 | create_activity 41 | .is_private() 42 | .not() 43 | .then(|| Item::Activity(Box::new(create_activity))) 44 | }) 45 | .collect_vec(); 46 | 47 | let user_db = DbActor::get(&state.db_pool, user_id).await?; 48 | let user: Actor = serde_json::from_value(user_db.actor)?; 49 | 50 | let next = format!("{}?last_id={}", user.outbox, last_id); 51 | 52 | let outbox_collection = Collection { 53 | r#type: OUTBOX_FOLLOW_COLLECTIONS_PAGE_TYPE.into(), 54 | 55 | id: user.outbox.clone(), 56 | part_of: user.outbox, 57 | 58 | next, 59 | 60 | ordered_items: latest_activities, 61 | ..Collection::default() 62 | }; 63 | 64 | Ok(Json(outbox_collection)) 65 | } 66 | -------------------------------------------------------------------------------- /tranquility/src/activitypub/routes/users.rs: -------------------------------------------------------------------------------- 1 | use crate::{database::Actor, error::Error, state::ArcState}; 2 | use axum::{extract::Path, response::IntoResponse, Extension, Json}; 3 | use uuid::Uuid; 4 | 5 | pub async fn users( 6 | Path(id): Path, 7 | Extension(state): Extension, 8 | ) -> Result { 9 | let actor = Actor::get(&state.db_pool, id).await?; 10 | 11 | Ok(Json(actor.actor)) 12 | } 13 | -------------------------------------------------------------------------------- /tranquility/src/api/mastodon/accounts.rs: -------------------------------------------------------------------------------- 1 | use super::{convert::IntoMastodon, Authorisation}; 2 | use crate::{ 3 | activitypub::interactions, 4 | database::{Actor as DbActor, Object as DbObject}, 5 | error::Error, 6 | format_uuid, 7 | state::ArcState, 8 | }; 9 | use axum::{ 10 | extract::Path, 11 | response::IntoResponse, 12 | routing::{get, post}, 13 | Extension, Json, Router, 14 | }; 15 | use tranquility_types::{ 16 | activitypub::Actor, 17 | mastodon::{Account, FollowResponse, Source}, 18 | }; 19 | use uuid::Uuid; 20 | 21 | async fn accounts( 22 | Path(id): Path, 23 | Extension(state): Extension, 24 | authorized_db_actor: Option, 25 | ) -> Result { 26 | let db_actor = DbActor::get(&state.db_pool, id).await?; 27 | let mut mastodon_account: Account = db_actor.into_mastodon(&state).await?; 28 | 29 | // Add the source field to the returned account if the requested account 30 | // is the account that has authorized itself 31 | if let Some(Authorisation(authorized_db_actor)) = authorized_db_actor { 32 | if id == authorized_db_actor.id { 33 | let source: Source = authorized_db_actor.into_mastodon(&state).await?; 34 | mastodon_account.source = Some(source); 35 | } 36 | } 37 | 38 | Ok(Json(mastodon_account)) 39 | } 40 | 41 | async fn follow( 42 | Path(id): Path, 43 | Extension(state): Extension, 44 | Authorisation(authorized_db_actor): Authorisation, 45 | ) -> Result { 46 | let followed_db_actor = DbActor::get(&state.db_pool, id).await?; 47 | let followed_actor: Actor = serde_json::from_value(followed_db_actor.actor)?; 48 | 49 | interactions::follow(&state, authorized_db_actor, &followed_actor).await?; 50 | 51 | // TODO: Fill in information dynamically (followed by, blocked by, blocking, etc.) 52 | let follow_response = FollowResponse { 53 | id: format_uuid!(followed_db_actor.id), 54 | following: true, 55 | ..FollowResponse::default() 56 | }; 57 | Ok(Json(follow_response)) 58 | } 59 | 60 | async fn following( 61 | Path(id): Path, 62 | Extension(state): Extension, 63 | ) -> Result { 64 | let follow_activities = 65 | DbObject::by_type_and_owner(&state.db_pool, "Follow", &id, 10, 0).await?; 66 | let followed_accounts: Vec = follow_activities.into_mastodon(&state).await?; 67 | 68 | Ok(Json(followed_accounts)) 69 | } 70 | 71 | async fn followers( 72 | Path(id): Path, 73 | Extension(state): Extension, 74 | ) -> Result { 75 | let db_actor = DbActor::get(&state.db_pool, id).await?; 76 | let actor: Actor = serde_json::from_value(db_actor.actor)?; 77 | 78 | let followed_activities = 79 | DbObject::by_type_and_object_url(&state.db_pool, "Follow", actor.id.as_str(), 10, 0) 80 | .await?; 81 | let follower_accounts: Vec = followed_activities.into_mastodon(&state).await?; 82 | 83 | Ok(Json(follower_accounts)) 84 | } 85 | 86 | // TODO: Implement `/api/v1/accounts/:id/statuses` endpoint 87 | /*async fn statuses(Path(id): Path, authorized_db_actor: Option) -> Result { 88 | }*/ 89 | 90 | async fn unfollow( 91 | Path(id): Path, 92 | Extension(state): Extension, 93 | Authorisation(authorized_db_actor): Authorisation, 94 | ) -> Result { 95 | // Fetch the follow activity 96 | let followed_db_actor = DbActor::get(&state.db_pool, id).await?; 97 | let followed_actor_id = format_uuid!(followed_db_actor.id); 98 | 99 | interactions::unfollow(&state, authorized_db_actor, followed_db_actor).await?; 100 | 101 | // TODO: Fill in information dynamically (followed by, blocked by, blocking, etc.) 102 | let unfollow_response = FollowResponse { 103 | id: followed_actor_id, 104 | ..FollowResponse::default() 105 | }; 106 | Ok(Json(unfollow_response)) 107 | } 108 | 109 | async fn verify_credentials( 110 | Extension(state): Extension, 111 | Authorisation(db_actor): Authorisation, 112 | ) -> Result { 113 | let mut mastodon_account: Account = db_actor.clone().into_mastodon(&state).await?; 114 | let mastodon_account_source: Source = db_actor.into_mastodon(&state).await?; 115 | 116 | mastodon_account.source = Some(mastodon_account_source); 117 | 118 | Ok(Json(mastodon_account)) 119 | } 120 | 121 | pub fn routes() -> Router { 122 | Router::new() 123 | .route("/accounts/:id", get(accounts)) 124 | .route("/accounts/:id/follow", post(follow)) 125 | .route("/accounts/:id/following", get(following)) 126 | .route("/accounts/:id/followers", get(followers)) 127 | //.route("/accounts/:id/statuses", get(statuses)) 128 | .route("/accounts/:id/unfollow", post(unfollow)) 129 | .route("/accounts/verify_credentials", get(verify_credentials)) 130 | } 131 | -------------------------------------------------------------------------------- /tranquility/src/api/mastodon/apps.rs: -------------------------------------------------------------------------------- 1 | use super::convert::IntoMastodon; 2 | use crate::{ 3 | consts::MAX_BODY_SIZE, 4 | database::{InsertExt, InsertOAuthApplication}, 5 | error::Error, 6 | state::ArcState, 7 | util::Form, 8 | }; 9 | use axum::{ 10 | extract::ContentLengthLimit, response::IntoResponse, routing::post, Extension, Json, Router, 11 | }; 12 | use serde::Deserialize; 13 | use uuid::Uuid; 14 | 15 | fn default_scopes() -> String { 16 | "read".into() 17 | } 18 | 19 | #[derive(Deserialize)] 20 | pub struct RegisterForm { 21 | client_name: String, 22 | redirect_uris: String, 23 | #[serde(default = "default_scopes")] 24 | scopes: String, 25 | #[serde(default)] 26 | website: String, 27 | } 28 | 29 | async fn create( 30 | Extension(state): Extension, 31 | ContentLengthLimit(Form(form)): ContentLengthLimit, MAX_BODY_SIZE>, 32 | ) -> Result { 33 | debug!("hewwo"); 34 | let client_id = Uuid::new_v4(); 35 | let client_secret = crate::crypto::token::generate(); 36 | 37 | let application = InsertOAuthApplication { 38 | client_name: form.client_name, 39 | client_id, 40 | client_secret, 41 | redirect_uris: form.redirect_uris, 42 | scopes: form.scopes, 43 | website: form.website, 44 | } 45 | .insert(&state.db_pool) 46 | .await?; 47 | let mastodon_application = application.into_mastodon(&state).await?; 48 | 49 | Ok(Json(mastodon_application)) 50 | } 51 | 52 | pub fn routes() -> Router { 53 | Router::new().route("/apps", post(create)) 54 | } 55 | -------------------------------------------------------------------------------- /tranquility/src/api/mastodon/convert.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | database::{Actor as DbActor, OAuthApplication, Object as DbObject}, 3 | error::Error, 4 | format_uuid, 5 | state::ArcState, 6 | }; 7 | use async_trait::async_trait; 8 | use axum::response::IntoResponse; 9 | use itertools::Itertools; 10 | use serde::Serialize; 11 | use tranquility_types::{ 12 | activitypub::{Activity, Actor, Object}, 13 | mastodon::{Account, App, Source, Status}, 14 | }; 15 | use url::Url; 16 | 17 | #[async_trait] 18 | /// Trait for converting any object into an Mastodon API entity 19 | pub trait IntoMastodon: Send + Sync 20 | where 21 | ApiEntity: Serialize + 'static, 22 | { 23 | /// Possible error that can occur 24 | type Error: IntoResponse; 25 | 26 | /// Convert the object into an Mastodon API entity 27 | async fn into_mastodon(self, state: &ArcState) -> Result; 28 | } 29 | 30 | #[async_trait] 31 | impl IntoMastodon for DbActor { 32 | type Error = Error; 33 | 34 | async fn into_mastodon(self, _state: &ArcState) -> Result { 35 | let actor: Actor = serde_json::from_value(self.actor)?; 36 | 37 | let id = format_uuid!(self.id); 38 | let username = actor.username; 39 | let url = actor.id; 40 | 41 | let acct = if self.remote { 42 | let parsed_url = Url::parse(&url)?; 43 | 44 | format!( 45 | "{}@{}", 46 | username, 47 | parsed_url.host_str().ok_or(Error::MalformedUrl)? 48 | ) 49 | } else { 50 | username.clone() 51 | }; 52 | 53 | let display_name = actor.name; 54 | let avatar = actor 55 | .icon 56 | .map(|attachment| attachment.url) 57 | .unwrap_or_default(); 58 | 59 | let header = actor 60 | .image 61 | .map(|attachment| attachment.url) 62 | .unwrap_or_default(); 63 | 64 | let account = Account { 65 | id, 66 | username, 67 | acct, 68 | display_name, 69 | 70 | avatar_static: avatar.clone(), 71 | avatar, 72 | 73 | header_static: header.clone(), 74 | header, 75 | ..Account::default() 76 | }; 77 | 78 | Ok(account) 79 | } 80 | } 81 | 82 | #[async_trait] 83 | impl IntoMastodon for DbActor { 84 | type Error = Error; 85 | 86 | async fn into_mastodon(self, _state: &ArcState) -> Result { 87 | let actor: Actor = serde_json::from_value(self.actor)?; 88 | 89 | let source = Source { 90 | privacy: "public".into(), 91 | language: "en".into(), 92 | 93 | note: actor.summary, 94 | 95 | ..Source::default() 96 | }; 97 | 98 | Ok(source) 99 | } 100 | } 101 | 102 | #[async_trait] 103 | impl IntoMastodon for DbObject { 104 | type Error = Error; 105 | 106 | async fn into_mastodon(self, state: &ArcState) -> Result { 107 | let activity_or_object: Object = serde_json::from_value(self.data)?; 108 | 109 | activity_or_object.into_mastodon(state).await 110 | } 111 | } 112 | 113 | #[async_trait] 114 | impl IntoMastodon> for Vec { 115 | type Error = Error; 116 | 117 | async fn into_mastodon(self, state: &ArcState) -> Result, Self::Error> { 118 | let db_to_url = |object: DbObject| { 119 | let activity: Activity = match serde_json::from_value(object.data) { 120 | Ok(activity) => activity, 121 | Err(err) => { 122 | warn!("Couldn't deserialize activity: {}", err); 123 | return None; 124 | } 125 | }; 126 | 127 | activity.object.as_url().map(ToOwned::to_owned) 128 | }; 129 | 130 | let fetch_account_fn = |url: String| async move { 131 | let account = DbActor::by_url(&state.db_pool, url.as_str()).await?; 132 | let account: Account = account.into_mastodon(state).await?; 133 | 134 | Ok::<_, Error>(account) 135 | }; 136 | let account_futures = self.into_iter().filter_map(db_to_url).map(fetch_account_fn); 137 | 138 | // The `join_all` function has a complexity of O(n^2) because it polls every future whenever one is ready 139 | // This should be fine for this use-case though as not a lot of objects should get converted anyway 140 | let accounts = futures_util::future::join_all(account_futures) 141 | .await 142 | .into_iter() 143 | .try_collect()?; 144 | 145 | Ok(accounts) 146 | } 147 | } 148 | 149 | #[async_trait] 150 | impl IntoMastodon for OAuthApplication { 151 | type Error = Error; 152 | 153 | async fn into_mastodon(self, _state: &ArcState) -> Result { 154 | let id = format_uuid!(self.id); 155 | let client_id = format_uuid!(self.client_id); 156 | let website = if self.website.is_empty() { 157 | None 158 | } else { 159 | Some(self.website) 160 | }; 161 | 162 | let app = App { 163 | id, 164 | name: self.client_name, 165 | client_id, 166 | client_secret: self.client_secret, 167 | redirect_uri: self.redirect_uris, 168 | website, 169 | vapid_key: None, 170 | }; 171 | 172 | Ok(app) 173 | } 174 | } 175 | 176 | #[async_trait] 177 | impl IntoMastodon for Object { 178 | type Error = Error; 179 | 180 | async fn into_mastodon(self, state: &ArcState) -> Result { 181 | let db_object = DbObject::by_url(&state.db_pool, self.id.as_str()).await?; 182 | let (_actor, db_actor) = 183 | crate::activitypub::fetcher::fetch_actor(state, self.attributed_to.as_str()).await?; 184 | 185 | let id = format_uuid!(db_object.id); 186 | let application = super::DEFAULT_APPLICATION.clone(); 187 | let account = db_actor.into_mastodon(state).await?; 188 | 189 | let status = Status { 190 | id, 191 | created_at: self.published, 192 | 193 | sensitive: self.sensitive, 194 | spoiler_text: self.summary, 195 | visibility: "public".into(), 196 | 197 | uri: self.id.clone(), 198 | url: self.id, 199 | 200 | content: self.content, 201 | 202 | application, 203 | account, 204 | 205 | ..Status::default() 206 | }; 207 | 208 | Ok(status) 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /tranquility/src/api/mastodon/instance.rs: -------------------------------------------------------------------------------- 1 | use crate::{consts::VERSION, error::Error, state::ArcState}; 2 | use axum::{response::IntoResponse, routing::get, Extension, Json, Router}; 3 | use tranquility_types::mastodon::{ 4 | instance::{Stats, Urls}, 5 | Instance, 6 | }; 7 | 8 | #[allow(clippy::unused_async)] 9 | async fn instance(Extension(state): Extension) -> Result { 10 | let streaming_api = format!("wss://{}", state.config.instance.domain); 11 | 12 | let instance = Instance { 13 | version: VERSION.into(), 14 | title: state.config.instance.domain.clone(), 15 | uri: state.config.instance.domain.clone(), 16 | short_description: None, 17 | description: state.config.instance.description.clone(), 18 | 19 | urls: Urls { streaming_api }, 20 | stats: Stats { ..Stats::default() }, 21 | 22 | registrations: !state.config.instance.closed_registrations, 23 | invites_enabled: false, 24 | approval_required: false, 25 | 26 | email: None, 27 | contact_account: None, 28 | 29 | ..Instance::default() 30 | }; 31 | 32 | Ok(Json(instance)) 33 | } 34 | 35 | pub fn routes() -> Router { 36 | Router::new().route("/instance", get(instance)) 37 | } 38 | -------------------------------------------------------------------------------- /tranquility/src/api/mastodon/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | consts::cors::API_ALLOWED_METHODS, 3 | database::{Actor, OAuthToken}, 4 | error::Error, 5 | state::ArcState, 6 | }; 7 | use async_trait::async_trait; 8 | use axum::{ 9 | extract::{FromRequest, RequestParts}, 10 | Router, 11 | }; 12 | use headers::{authorization::Bearer, Authorization, HeaderMapExt}; 13 | use once_cell::sync::Lazy; 14 | use std::ops::Deref; 15 | use tower_http::cors::CorsLayer; 16 | use tranquility_types::mastodon::App; 17 | 18 | static DEFAULT_APPLICATION: Lazy = Lazy::new(|| App { 19 | name: "Web".into(), 20 | ..App::default() 21 | }); 22 | 23 | /// Authorisation extractor 24 | /// 25 | /// It takes the `Authorization` header and tries to decodes it as an `Bearer` authorisation. 26 | /// Then it fetches the actor associated with the token 27 | pub struct Authorisation(pub Actor); 28 | 29 | impl Deref for Authorisation { 30 | type Target = Actor; 31 | 32 | fn deref(&self) -> &Self::Target { 33 | &self.0 34 | } 35 | } 36 | 37 | #[async_trait] 38 | impl FromRequest for Authorisation 39 | where 40 | B: Send, 41 | { 42 | type Rejection = Error; 43 | 44 | async fn from_request(req: &mut RequestParts) -> Result { 45 | let credentials = req 46 | .headers() 47 | .typed_get::>() 48 | .ok_or(Error::Unauthorized)?; 49 | let token = credentials.token(); 50 | 51 | let state = req 52 | .extensions() 53 | .get::() 54 | .expect("[Bug] Missing state in extensions"); 55 | 56 | let access_token = OAuthToken::by_access_token(&state.db_pool, token).await?; 57 | let actor = Actor::get(&state.db_pool, access_token.actor_id).await?; 58 | 59 | Ok(Self(actor)) 60 | } 61 | } 62 | 63 | pub fn routes() -> Router { 64 | let v1_router = Router::new() 65 | .merge(accounts::routes()) 66 | .merge(apps::routes()) 67 | .merge(statuses::routes()) 68 | .merge(instance::routes()); 69 | 70 | Router::new() 71 | .nest("/api/v1", v1_router) 72 | .layer(CorsLayer::permissive().allow_methods(API_ALLOWED_METHODS.to_vec())) 73 | } 74 | 75 | pub mod accounts; 76 | pub mod apps; 77 | pub mod convert; 78 | pub mod instance; 79 | pub mod statuses; 80 | -------------------------------------------------------------------------------- /tranquility/src/api/mastodon/statuses.rs: -------------------------------------------------------------------------------- 1 | use super::{convert::IntoMastodon, Authorisation}; 2 | use crate::{ 3 | activitypub::Clean, 4 | consts::MAX_BODY_SIZE, 5 | database::{InsertExt, InsertObject}, 6 | error::Error, 7 | state::ArcState, 8 | util::{mention::FormatMention, Form}, 9 | }; 10 | use axum::{ 11 | extract::ContentLengthLimit, http::StatusCode, response::IntoResponse, routing::post, 12 | Extension, Json, Router, 13 | }; 14 | use serde::Deserialize; 15 | use std::sync::Arc; 16 | use tranquility_types::activitypub::{Actor, PUBLIC_IDENTIFIER}; 17 | 18 | #[cfg(feature = "markdown")] 19 | use crate::api::ParseMarkdown; 20 | 21 | #[derive(Deserialize)] 22 | struct CreateForm { 23 | status: String, 24 | 25 | #[serde(default)] 26 | sensitive: bool, 27 | #[serde(default)] 28 | spoiler_text: String, 29 | } 30 | 31 | async fn create( 32 | Extension(state): Extension, 33 | Authorisation(author_db): Authorisation, 34 | ContentLengthLimit(Form(form)): ContentLengthLimit, MAX_BODY_SIZE>, 35 | ) -> Result { 36 | if state.config.instance.character_limit < form.status.chars().count() { 37 | return Ok((StatusCode::BAD_REQUEST, "Status too long").into_response()); 38 | } 39 | 40 | let author: Actor = serde_json::from_value(author_db.actor)?; 41 | 42 | let (object_id, mut object) = crate::activitypub::instantiate::object( 43 | &state.config, 44 | "Note", 45 | author.id.as_str(), 46 | form.spoiler_text.as_str(), 47 | form.status.as_str(), 48 | form.sensitive, 49 | // TODO: Actually add collections to the to/cc array 50 | vec![PUBLIC_IDENTIFIER.into(), author.followers], 51 | vec![], 52 | ); 53 | 54 | object.format_mentions(Arc::clone(&state)).await; 55 | 56 | // Parse the markdown if the feature is enabled 57 | #[cfg(feature = "markdown")] 58 | object.parse_markdown(); 59 | 60 | object.clean(); 61 | 62 | let object_value = serde_json::to_value(&object)?; 63 | 64 | InsertObject { 65 | id: object_id, 66 | owner_id: author_db.id, 67 | data: object_value, 68 | } 69 | .insert(&state.db_pool) 70 | .await?; 71 | 72 | let (_create_activity_id, create_activity) = crate::activitypub::instantiate::activity( 73 | &state.config, 74 | "Create", 75 | author.id.as_str(), 76 | object.clone(), 77 | object.to.clone(), 78 | object.cc.clone(), 79 | ); 80 | 81 | crate::activitypub::deliverer::deliver(create_activity, Arc::clone(&state)).await?; 82 | 83 | let mastodon_status = object.into_mastodon(&state).await?; 84 | Ok(Json(&mastodon_status).into_response()) 85 | } 86 | 87 | pub fn routes() -> Router { 88 | Router::new().route("/statuses", post(create)) 89 | } 90 | -------------------------------------------------------------------------------- /tranquility/src/api/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::Router; 2 | use cfg_if::cfg_if; 3 | 4 | use crate::state::State; 5 | 6 | pub fn routes(state: &State) -> Router { 7 | let router = Router::new() 8 | .merge(oauth::routes(state)) 9 | .merge(register::routes(state)); 10 | 11 | cfg_if! { 12 | if #[cfg(feature = "mastodon-api")] { 13 | router.merge(mastodon::routes()) 14 | } else { 15 | router 16 | } 17 | } 18 | } 19 | 20 | cfg_if! { 21 | if #[cfg(feature = "markdown")] { 22 | pub trait ParseMarkdown { 23 | fn parse_markdown(&mut self); 24 | } 25 | 26 | impl ParseMarkdown for tranquility_types::activitypub::Object { 27 | fn parse_markdown(&mut self) { 28 | use pulldown_cmark::{html, Options, Parser}; 29 | 30 | let content = self.content.clone(); 31 | let parser = Parser::new_ext(&content, Options::all()); 32 | 33 | html::push_html(&mut self.content, parser); 34 | } 35 | } 36 | } 37 | } 38 | 39 | #[cfg(feature = "mastodon-api")] 40 | pub mod mastodon; 41 | 42 | pub mod oauth; 43 | pub mod register; 44 | -------------------------------------------------------------------------------- /tranquility/src/api/oauth/authorize.rs: -------------------------------------------------------------------------------- 1 | use super::{TokenTemplate, AUTHORIZE_FORM}; 2 | use crate::{ 3 | consts::MAX_BODY_SIZE, 4 | crypto::password, 5 | database::{Actor, InsertExt, InsertOAuthAuthorization, OAuthApplication}, 6 | error::Error, 7 | state::ArcState, 8 | util::Form, 9 | }; 10 | use askama::Template; 11 | use axum::{ 12 | extract::{ContentLengthLimit, Query}, 13 | response::{Html, IntoResponse, Redirect}, 14 | Extension, 15 | }; 16 | use axum_macros::debug_handler; 17 | use serde::Deserialize; 18 | use time::{Duration, OffsetDateTime}; 19 | use uuid::Uuid; 20 | 21 | static AUTHORIZATION_CODE_VALIDITY: Duration = Duration::minutes(5); 22 | 23 | #[derive(Deserialize)] 24 | pub struct AuthoriseForm { 25 | username: String, 26 | password: String, 27 | } 28 | 29 | #[derive(Deserialize)] 30 | pub struct QueryParams { 31 | response_type: String, 32 | client_id: Uuid, 33 | redirect_uri: String, 34 | // scope: Option, 35 | #[serde(default)] 36 | state: String, 37 | } 38 | 39 | #[allow(clippy::unused_async)] 40 | pub async fn get() -> impl IntoResponse { 41 | Html(AUTHORIZE_FORM.as_str()) 42 | } 43 | 44 | #[debug_handler] 45 | pub async fn post( 46 | Extension(state): Extension, 47 | ContentLengthLimit(Form(form)): ContentLengthLimit, MAX_BODY_SIZE>, 48 | Query(query): Query, 49 | ) -> Result { 50 | let actor = Actor::by_username_local(&state.db_pool, &form.username).await?; 51 | if !password::verify(form.password, actor.password_hash.unwrap()).await { 52 | return Err(Error::Unauthorized); 53 | } 54 | 55 | // RFC 6749: 56 | // ``` 57 | // response_type 58 | // REQUIRED. Value MUST be set to "code". 59 | // ``` 60 | if query.response_type != "code" { 61 | return Err(Error::InvalidRequest); 62 | } 63 | 64 | let client = OAuthApplication::by_client_id(&state.db_pool, &query.client_id).await?; 65 | if client.redirect_uris != query.redirect_uri { 66 | return Err(Error::InvalidRequest); 67 | } 68 | 69 | let authorization_code = crate::crypto::token::generate(); 70 | let valid_until = OffsetDateTime::now_utc() + AUTHORIZATION_CODE_VALIDITY; 71 | 72 | let authorization_code = InsertOAuthAuthorization { 73 | application_id: client.id, 74 | actor_id: actor.id, 75 | code: authorization_code, 76 | valid_until, 77 | } 78 | .insert(&state.db_pool) 79 | .await?; 80 | 81 | // Display the code to the user if the redirect URI is "urn:ietf:wg:oauth:2.0:oob" 82 | if query.redirect_uri == "urn:ietf:wg:oauth:2.0:oob" { 83 | let page = TokenTemplate { 84 | token: authorization_code.code, 85 | } 86 | .render()?; 87 | 88 | Ok(Html(page).into_response()) 89 | } else { 90 | let redirect_uri = format!( 91 | "{}?code={}&state={}", 92 | query.redirect_uri, authorization_code.code, query.state, 93 | ); 94 | 95 | Ok(Redirect::temporary(&redirect_uri).into_response()) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tranquility/src/api/oauth/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{consts::cors::OAUTH_TOKEN_ALLOWED_METHODS, ratelimit_layer, state::State}; 2 | use askama::Template; 3 | use axum::{ 4 | routing::{get, post}, 5 | Router, 6 | }; 7 | use once_cell::sync::Lazy; 8 | use tower_http::cors::CorsLayer; 9 | 10 | // This form has no fields. Rendering it every time is a waste 11 | static AUTHORIZE_FORM: Lazy = Lazy::new(|| AuthorizeFormTemplate.render().unwrap()); 12 | 13 | #[derive(Template)] 14 | #[template(path = "oauth/authorize.html")] 15 | struct AuthorizeFormTemplate; 16 | 17 | #[derive(Template)] 18 | #[template(path = "oauth/token.html")] 19 | struct TokenTemplate { 20 | token: String, 21 | } 22 | 23 | pub fn routes(state: &State) -> Router { 24 | let token_router = Router::new() 25 | .route("/token", post(token::token)) 26 | .layer(CorsLayer::permissive().allow_methods(OAUTH_TOKEN_ALLOWED_METHODS.to_vec())); 27 | 28 | let authorize_router = 29 | Router::new().route("/authorize", get(authorize::get).post(authorize::post)); 30 | 31 | let router = Router::new().merge(authorize_router).merge(token_router); 32 | Router::new() 33 | .nest("/oauth", router) 34 | .route_layer(ratelimit_layer!( 35 | state.config.ratelimit.active, 36 | !state.config.tls.serve_tls_directly, 37 | state.config.ratelimit.authentication_quota, 38 | )) 39 | } 40 | 41 | pub mod authorize; 42 | pub mod token; 43 | -------------------------------------------------------------------------------- /tranquility/src/api/oauth/token.rs: -------------------------------------------------------------------------------- 1 | use super::TokenTemplate; 2 | use crate::{ 3 | consts::MAX_BODY_SIZE, 4 | crypto::password, 5 | database::{Actor, InsertExt, InsertOAuthToken, OAuthApplication, OAuthAuthorization}, 6 | error::Error, 7 | state::ArcState, 8 | util::Form, 9 | }; 10 | use askama::Template; 11 | use axum::{ 12 | extract::ContentLengthLimit, 13 | response::{Html, IntoResponse}, 14 | Extension, Json, 15 | }; 16 | use axum_macros::debug_handler; 17 | use serde::{Deserialize, Serialize}; 18 | use time::{Duration, OffsetDateTime}; 19 | use uuid::Uuid; 20 | 21 | static ACCESS_TOKEN_VALID_DURATION: Duration = Duration::hours(1); 22 | 23 | /// Form for password grant authorisation flows 24 | #[derive(Deserialize)] 25 | struct FormPasswordGrant { 26 | username: String, 27 | password: String, 28 | } 29 | 30 | /// Form for code grant authorisation flows 31 | #[derive(Deserialize)] 32 | struct FormCodeGrant { 33 | client_id: Uuid, 34 | client_secret: String, 35 | redirect_uri: String, 36 | // scope: Option, 37 | code: String, 38 | } 39 | 40 | #[derive(Deserialize)] 41 | #[serde(untagged)] 42 | #[non_exhaustive] 43 | enum FormData { 44 | CodeGrant(FormCodeGrant), 45 | PasswordGrant(FormPasswordGrant), 46 | } 47 | 48 | #[derive(Deserialize)] 49 | pub struct TokenForm { 50 | grant_type: String, 51 | 52 | #[serde(flatten)] 53 | data: FormData, 54 | } 55 | 56 | impl FormData { 57 | /// If the form is a code grant form, return it otherwise return a rejection 58 | pub fn code_grant(self) -> Result { 59 | match self { 60 | Self::CodeGrant(form) => Ok(form), 61 | _ => Err(Error::InvalidRequest), 62 | } 63 | } 64 | 65 | /// If the form is a password grant form, return it otherwise return a rejection 66 | pub fn password_grant(self) -> Result { 67 | match self { 68 | Self::PasswordGrant(form) => Ok(form), 69 | _ => Err(Error::InvalidRequest), 70 | } 71 | } 72 | } 73 | 74 | /// Serialisable struct for responding to an access token request 75 | #[derive(Serialize)] 76 | struct AccessTokenResponse { 77 | access_token: String, 78 | token_type: String, 79 | scope: String, 80 | created_at: i64, 81 | } 82 | 83 | impl Default for AccessTokenResponse { 84 | fn default() -> Self { 85 | Self { 86 | token_type: "Bearer".into(), 87 | scope: "read write follow push".into(), 88 | 89 | access_token: String::new(), 90 | created_at: 0, 91 | } 92 | } 93 | } 94 | 95 | async fn code_grant( 96 | state: &ArcState, 97 | FormCodeGrant { 98 | client_id, 99 | client_secret, 100 | redirect_uri, 101 | code, 102 | .. 103 | }: FormCodeGrant, 104 | ) -> Result { 105 | let client = OAuthApplication::by_client_id(&state.db_pool, &client_id).await?; 106 | if client.client_secret != client_secret || client.redirect_uris != redirect_uri { 107 | return Err(Error::Unauthorized); 108 | } 109 | 110 | let authorization_code = OAuthAuthorization::by_code(&state.db_pool, &code).await?; 111 | let valid_until = OffsetDateTime::now_utc() + ACCESS_TOKEN_VALID_DURATION; 112 | let access_token = crate::crypto::token::generate(); 113 | 114 | let access_token = InsertOAuthToken { 115 | application_id: Some(client.id), 116 | actor_id: authorization_code.actor_id, 117 | access_token, 118 | refresh_token: None, 119 | valid_until, 120 | } 121 | .insert(&state.db_pool) 122 | .await?; 123 | 124 | // Display the code to the user if the redirect URI is "urn:ietf:wg:oauth:2.0:oob" 125 | if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" { 126 | let page = TokenTemplate { 127 | token: access_token.access_token, 128 | } 129 | .render()?; 130 | 131 | Ok(Html(page).into_response()) 132 | } else { 133 | let response = AccessTokenResponse { 134 | access_token: access_token.access_token, 135 | created_at: ACCESS_TOKEN_VALID_DURATION.whole_seconds(), 136 | ..AccessTokenResponse::default() 137 | }; 138 | 139 | Ok(Json(&response).into_response()) 140 | } 141 | } 142 | 143 | async fn password_grant( 144 | state: &ArcState, 145 | FormPasswordGrant { 146 | username, password, .. 147 | }: FormPasswordGrant, 148 | ) -> Result { 149 | let actor = Actor::by_username_local(&state.db_pool, username.as_str()).await?; 150 | if !password::verify(password, actor.password_hash.unwrap()).await { 151 | return Err(Error::Unauthorized); 152 | } 153 | 154 | let valid_until = OffsetDateTime::now_utc() + ACCESS_TOKEN_VALID_DURATION; 155 | let access_token = crate::crypto::token::generate(); 156 | 157 | let access_token = InsertOAuthToken { 158 | application_id: None, 159 | actor_id: actor.id, 160 | access_token, 161 | refresh_token: None, 162 | valid_until, 163 | } 164 | .insert(&state.db_pool) 165 | .await?; 166 | 167 | let response = AccessTokenResponse { 168 | access_token: access_token.access_token, 169 | created_at: access_token.created_at.unix_timestamp(), 170 | ..AccessTokenResponse::default() 171 | }; 172 | 173 | Ok(Json(response)) 174 | } 175 | 176 | #[debug_handler] 177 | pub async fn token( 178 | Extension(state): Extension, 179 | ContentLengthLimit(Form(form)): ContentLengthLimit, MAX_BODY_SIZE>, 180 | ) -> Result { 181 | let response = match form.grant_type.as_str() { 182 | "authorization_code" => { 183 | let form_data = form.data.code_grant()?; 184 | code_grant(&state, form_data).await?.into_response() 185 | } 186 | "password" => { 187 | let form_data = form.data.password_grant()?; 188 | password_grant(&state, form_data).await?.into_response() 189 | } 190 | _ => return Err(Error::InvalidRequest), 191 | }; 192 | 193 | Ok(response) 194 | } 195 | -------------------------------------------------------------------------------- /tranquility/src/api/register.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | activitypub, 3 | consts::{regex::USERNAME, MAX_BODY_SIZE}, 4 | database::{InsertActor, InsertExt}, 5 | error::Error, 6 | format_uuid, ratelimit_layer, regex, 7 | state::{ArcState, State}, 8 | util::Form, 9 | }; 10 | use axum::{ 11 | extract::ContentLengthLimit, http::StatusCode, response::IntoResponse, routing::post, 12 | Extension, Router, 13 | }; 14 | use serde::Deserialize; 15 | use uuid::Uuid; 16 | use validator::Validate; 17 | 18 | regex!(USERNAME_REGEX = USERNAME); 19 | 20 | #[derive(Deserialize, Validate)] 21 | pub struct RegistrationForm { 22 | #[validate( 23 | length( 24 | min = 1, 25 | max = 32, 26 | message = "Username has to be between 1 and 32 characters long" 27 | ), 28 | regex( 29 | path = "USERNAME_REGEX", 30 | message = "Username has to consist of [A-Z, a-z, 0-9, _, -]" 31 | ) 32 | )] 33 | username: String, 34 | #[validate(email)] 35 | email: String, 36 | #[validate(length(min = 8, message = "Password has to be at least 8 characters long"))] 37 | password: String, 38 | } 39 | 40 | async fn register( 41 | Extension(state): Extension, 42 | ContentLengthLimit(Form(form)): ContentLengthLimit, MAX_BODY_SIZE>, 43 | ) -> Result { 44 | if state.config.instance.closed_registrations { 45 | return Ok(StatusCode::FORBIDDEN.into_response()); 46 | } 47 | 48 | form.validate()?; 49 | 50 | let user_id = Uuid::new_v4(); 51 | let password_hash = crate::crypto::password::hash(form.password).await?; 52 | 53 | let rsa_private_key = crate::crypto::rsa::generate().await?; 54 | let (public_key_pem, private_key_pem) = crate::crypto::rsa::to_pem(&rsa_private_key)?; 55 | 56 | let actor = activitypub::instantiate::actor( 57 | &state.config, 58 | &format_uuid!(user_id), 59 | &form.username, 60 | public_key_pem, 61 | ); 62 | let actor = serde_json::to_value(&actor)?; 63 | 64 | let _user = InsertActor { 65 | id: user_id, 66 | username: form.username, 67 | actor, 68 | email: Some(form.email), 69 | password_hash: Some(password_hash), 70 | private_key: Some(private_key_pem), 71 | remote: false, 72 | 73 | is_confirmed: true, 74 | confirmation_code: None, 75 | } 76 | .insert(&state.db_pool) 77 | .await?; 78 | 79 | #[cfg(feature = "email")] 80 | crate::email::send_confirmation(&state, _user); 81 | 82 | Ok((StatusCode::CREATED, "Account created").into_response()) 83 | } 84 | 85 | pub fn routes(state: &State) -> Router { 86 | Router::new() 87 | .route("/api/tranquility/v1/register", post(register)) 88 | .route_layer(ratelimit_layer!( 89 | state.config.ratelimit.active, 90 | !state.config.tls.serve_tls_directly, 91 | state.config.ratelimit.registration_quota, 92 | )) 93 | } 94 | -------------------------------------------------------------------------------- /tranquility/src/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::Configuration, 3 | consts::PROPER_VERSION, 4 | state::{ArcState, State}, 5 | }; 6 | use argh::FromArgs; 7 | use std::process; 8 | use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Registry}; 9 | 10 | #[cfg(feature = "jaeger")] 11 | use tracing_opentelemetry::OpenTelemetryLayer; 12 | 13 | #[derive(FromArgs)] 14 | #[argh(description = "An ActivityPub server ^_^")] 15 | pub struct Opts { 16 | #[argh(option, default = "\"config.toml\".into()")] 17 | /// path to the configuration file (defaults to `config.toml`) 18 | config: String, 19 | 20 | #[argh(switch, short = 'v')] 21 | /// print the version 22 | version: bool, 23 | } 24 | 25 | /// Initialise the tracing subscriber 26 | fn init_tracing(_config: &Configuration) { 27 | let subscriber = Registry::default() 28 | .with(EnvFilter::from_default_env()) 29 | .with(fmt::layer()); 30 | 31 | #[cfg(feature = "jaeger")] 32 | { 33 | if _config.jaeger.active { 34 | let host = _config.jaeger.host.as_str(); 35 | let port = _config.jaeger.port; 36 | 37 | let jaeger_endpoint = opentelemetry_jaeger::new_agent_pipeline() 38 | .with_service_name(env!("CARGO_PKG_NAME")) 39 | .with_endpoint((host, port)) 40 | .install_batch(opentelemetry::runtime::Tokio) 41 | .expect("Couldn't install jaeger pipeline"); 42 | 43 | subscriber 44 | .with(OpenTelemetryLayer::new(jaeger_endpoint)) 45 | .init(); 46 | return; 47 | } 48 | } 49 | 50 | subscriber.init(); 51 | } 52 | 53 | /// - Initialises the tracing verbosity levels 54 | /// - Creates a database connection pool 55 | /// - Returns a constructed state 56 | pub async fn run() -> ArcState { 57 | let options = argh::from_env::(); 58 | 59 | if options.version { 60 | println!("{}", PROPER_VERSION); 61 | process::exit(0); 62 | } 63 | 64 | let config = crate::config::load(options.config).await; 65 | init_tracing(&config); 66 | 67 | let db_pool = crate::database::connection::init_pool(&config.server.database_url) 68 | .await 69 | .expect("Couldn't connect to database"); 70 | 71 | State::new(config, db_pool) 72 | } 73 | -------------------------------------------------------------------------------- /tranquility/src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::path::Path; 3 | use tokio::{ 4 | fs::File, 5 | io::{AsyncReadExt, BufReader}, 6 | }; 7 | 8 | #[derive(Deserialize)] 9 | #[serde(rename_all = "kebab-case")] 10 | /// Struct holding the email configuration values 11 | pub struct ConfigurationEmail { 12 | pub active: bool, 13 | 14 | pub server: String, 15 | pub starttls: bool, 16 | 17 | pub email: String, 18 | pub username: String, 19 | pub password: String, 20 | } 21 | 22 | #[derive(Deserialize)] 23 | #[serde(rename_all = "kebab-case")] 24 | /// Struct holding the instance specific configuration values 25 | pub struct ConfigurationInstance { 26 | pub closed_registrations: bool, 27 | pub domain: String, 28 | 29 | pub description: String, 30 | 31 | pub character_limit: usize, 32 | pub upload_limit: u64, 33 | 34 | pub moderators: Vec, 35 | } 36 | 37 | #[derive(Deserialize)] 38 | #[serde(rename_all = "kebab-case")] 39 | /// Struct holding the jaeger specific configuration values 40 | pub struct ConfigurationJaeger { 41 | pub active: bool, 42 | pub host: String, 43 | pub port: u16, 44 | } 45 | 46 | #[derive(Deserialize)] 47 | #[serde(rename_all = "kebab-case")] 48 | /// Struct holding the ratelimit specific configuration values 49 | pub struct ConfigurationRatelimit { 50 | pub active: bool, 51 | 52 | pub authentication_quota: u32, 53 | pub registration_quota: u32, 54 | } 55 | 56 | #[derive(Deserialize)] 57 | #[serde(rename_all = "kebab-case")] 58 | /// Struct holding the HTTP server specific configuration values 59 | pub struct ConfigurationServer { 60 | pub interface: String, 61 | pub port: u16, 62 | 63 | pub database_url: String, 64 | } 65 | 66 | #[derive(Deserialize)] 67 | #[serde(rename_all = "kebab-case")] 68 | /// Struct holding the TLS specific configuration values 69 | pub struct ConfigurationTls { 70 | pub serve_tls_directly: bool, 71 | 72 | pub certificate: String, 73 | pub secret_key: String, 74 | } 75 | 76 | #[derive(Deserialize)] 77 | #[serde(rename_all = "kebab-case")] 78 | /// Struct holding the configuration values 79 | pub struct Configuration { 80 | pub email: ConfigurationEmail, 81 | pub instance: ConfigurationInstance, 82 | pub jaeger: ConfigurationJaeger, 83 | pub ratelimit: ConfigurationRatelimit, 84 | pub server: ConfigurationServer, 85 | pub tls: ConfigurationTls, 86 | } 87 | 88 | /// Load the configuration from the path 89 | pub async fn load

(config_path: P) -> Configuration 90 | where 91 | P: AsRef, 92 | { 93 | let config_file = File::open(config_path) 94 | .await 95 | .expect("Couldn't open configuration file"); 96 | let mut config_file = BufReader::new(config_file); 97 | 98 | let mut data = Vec::new(); 99 | config_file 100 | .read_to_end(&mut data) 101 | .await 102 | .expect("Couldn't read configuration file"); 103 | 104 | toml::from_slice(data.as_slice()).expect("Invalid TOML") 105 | } 106 | -------------------------------------------------------------------------------- /tranquility/src/consts.rs: -------------------------------------------------------------------------------- 1 | pub mod activitypub { 2 | pub const ACTIVITIES_PER_PAGE: i64 = 10; 3 | } 4 | 5 | pub mod cors { 6 | use http::Method; 7 | 8 | pub const API_ALLOWED_METHODS: &[Method] = &[ 9 | Method::POST, 10 | Method::PUT, 11 | Method::DELETE, 12 | Method::GET, 13 | Method::PATCH, 14 | Method::OPTIONS, 15 | ]; 16 | pub const GENERAL_ALLOWED_METHODS: &[Method] = &[Method::GET]; 17 | pub const OAUTH_TOKEN_ALLOWED_METHODS: &[Method] = &[Method::POST]; 18 | } 19 | 20 | pub mod crypto { 21 | pub const KEY_SIZE: usize = 2048; 22 | pub const TOKEN_LENGTH: usize = 40; 23 | } 24 | 25 | pub mod daemon { 26 | use std::time::Duration; 27 | 28 | pub const DELETE_INTERVAL: Duration = Duration::from_secs(60); 29 | } 30 | 31 | pub mod regex { 32 | use crate::r#const; 33 | 34 | r#const!(USERNAME_BASE: &str = r#"[\w]+"#); 35 | 36 | pub const USERNAME: &str = concat!("^", USERNAME_BASE!(), "$"); 37 | // Regex101 link (for explaination of the regex): https://regex101.com/r/pyTTsW/1 38 | pub const MENTION: &str = concat!( 39 | r#"(?:^|\W)@(?P"#, 40 | USERNAME_BASE!(), 41 | r#")(?:@(?P[\w\.\-]+[[:alnum:]]+))?"# 42 | ); 43 | } 44 | 45 | // Default to 5MB 46 | pub const MAX_BODY_SIZE: u64 = 5 * MB_BYTES; 47 | pub const MB_BYTES: u64 = 1024_u64.pow(2); 48 | 49 | pub const SOFTWARE_NAME: &str = env!("CARGO_PKG_NAME"); 50 | 51 | pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); 52 | pub const PROPER_VERSION: &str = concat!( 53 | "v", 54 | env!("CARGO_PKG_VERSION"), 55 | "-", 56 | env!("GIT_BRANCH"), 57 | "-", 58 | env!("GIT_COMMIT") 59 | ); 60 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 61 | -------------------------------------------------------------------------------- /tranquility/src/crypto.rs: -------------------------------------------------------------------------------- 1 | use rand::{ 2 | distributions::{Distribution, Standard}, 3 | rngs::OsRng, 4 | Rng, 5 | }; 6 | 7 | /// Generate a type that supports being generated via `rand::Rng::gen()` using `OsRng` 8 | #[inline] 9 | pub fn gen_secure_rand() -> T 10 | where 11 | Standard: Distribution, 12 | { 13 | OsRng.gen::() 14 | } 15 | 16 | pub mod digest { 17 | use crate::{error::Error, util::cpu_intensive_task}; 18 | use reqwest::header::HeaderValue; 19 | use sha2::{Digest, Sha256}; 20 | 21 | /// Calculate the digest HTTP header 22 | pub async fn http_header(data: Vec) -> Result { 23 | cpu_intensive_task(move || { 24 | let sha_hash = Sha256::digest(&data); 25 | let base64_encoded_hash = base64::encode(&sha_hash); 26 | 27 | Ok(HeaderValue::from_str(&format!( 28 | "SHA-256={}", 29 | base64_encoded_hash 30 | ))?) 31 | }) 32 | .await 33 | } 34 | } 35 | 36 | pub mod password { 37 | use crate::{error::Error, util::cpu_intensive_task}; 38 | use argon2::Config; 39 | 40 | /// Hash the password using the standard rust-argon2 config 41 | pub async fn hash(password: String) -> Result { 42 | cpu_intensive_task(move || { 43 | let salt = crate::crypto::gen_secure_rand::<[u8; 32]>(); 44 | let config = Config::default(); 45 | 46 | Ok(argon2::hash_encoded(password.as_bytes(), &salt, &config)?) 47 | }) 48 | .await 49 | } 50 | 51 | /// Verify an encoded password 52 | pub async fn verify(password: String, hash: String) -> bool { 53 | cpu_intensive_task(move || { 54 | argon2::verify_encoded(hash.as_str(), password.as_bytes()).unwrap_or(false) 55 | }) 56 | .await 57 | } 58 | } 59 | 60 | pub mod rsa { 61 | use crate::{consts::crypto::KEY_SIZE, error::Error, util::cpu_intensive_task}; 62 | use rand::rngs::OsRng; 63 | use rsa::{ 64 | pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}, 65 | RsaPrivateKey, 66 | }; 67 | 68 | /// Generate an RSA key pair (key size defined in the `consts` file) 69 | pub async fn generate() -> Result { 70 | cpu_intensive_task(|| Ok(RsaPrivateKey::new(&mut OsRng, KEY_SIZE)?)).await 71 | } 72 | 73 | /// Get the public key from the private key and encode both in the PKCS#8 PEM format 74 | pub fn to_pem(rsa_key: &RsaPrivateKey) -> Result<(String, String), Error> { 75 | let public_key = rsa_key.to_public_key_pem(LineEnding::LF)?; 76 | let private_key = rsa_key.to_pkcs8_pem(LineEnding::LF)?.to_string(); // Having this zeroised would be nice but SQLx has no support at the moment. Maybe there's some workaround? 77 | 78 | Ok((public_key, private_key)) 79 | } 80 | } 81 | 82 | pub mod token { 83 | use crate::consts::crypto::TOKEN_LENGTH; 84 | 85 | /// Generate a cryptographically random token (length defined in the `consts` file) 86 | pub fn generate() -> String { 87 | // Two characters are needed to encode one byte as hex 88 | let token = crate::crypto::gen_secure_rand::<[u8; TOKEN_LENGTH / 2]>(); 89 | 90 | hex::encode(token) 91 | } 92 | } 93 | 94 | pub mod request { 95 | use crate::{error::Error, util::cpu_intensive_task}; 96 | use http::{ 97 | self, 98 | header::{HeaderName, HeaderValue}, 99 | HeaderMap, 100 | }; 101 | use std::future::Future; 102 | use tranquility_http_signatures::Request; 103 | 104 | /// Sign a reqwest HTTP request 105 | pub fn sign( 106 | request: reqwest::Request, 107 | key_id: String, 108 | // The public key is provided in the PEM format 109 | // That's why the function takes a `String` 110 | private_key: String, 111 | ) -> impl Future> + Send { 112 | cpu_intensive_task(move || { 113 | let request = &request; 114 | let key_id = key_id.as_str(); 115 | let private_key = private_key.as_bytes(); 116 | 117 | Ok(tranquility_http_signatures::sign( 118 | request, 119 | &["(request-target)", "date", "digest"], 120 | (key_id, private_key), 121 | )?) 122 | }) 123 | } 124 | 125 | /// Verify an HTTP request using parameters obtained from warp 126 | pub fn verify( 127 | method: String, 128 | path: String, 129 | query: Option, 130 | headers: HeaderMap, 131 | // The public key is provided in the PEM format 132 | // That's why the function takes a `String` 133 | public_key: String, 134 | ) -> impl Future> + Send { 135 | cpu_intensive_task(move || { 136 | let request = Request::new(&method, &path, query.as_deref(), &headers); 137 | let public_key = public_key.as_bytes(); 138 | 139 | Ok(tranquility_http_signatures::verify(request, public_key)?) 140 | }) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tranquility/src/daemon.rs: -------------------------------------------------------------------------------- 1 | use crate::{consts::daemon::DELETE_INTERVAL, database::OAuthAuthorization, state::ArcState}; 2 | use std::{future::Future, sync::Arc}; 3 | use tokio::time; 4 | 5 | // Keeping this for future use 6 | #[allow(dead_code)] 7 | fn bulk_spawn(futures: Vec + Send + Sync + 'static>) { 8 | for future in futures { 9 | tokio::spawn(future); 10 | } 11 | } 12 | 13 | /// Delete all expired authorisation codes from the database 14 | async fn delete_expired_authorisation_codes(state: ArcState) { 15 | let mut query_interval = time::interval(DELETE_INTERVAL); 16 | 17 | loop { 18 | match OAuthAuthorization::delete_expired(&state.db_pool).await { 19 | Ok(_) => (), 20 | Err(err) => warn!(error = ?err, "Couldn't delete expired tokens"), 21 | } 22 | 23 | query_interval.tick().await; 24 | } 25 | } 26 | 27 | pub fn start(state: &ArcState) { 28 | let state = Arc::clone(state); 29 | 30 | tokio::spawn(delete_expired_authorisation_codes(state)); 31 | } 32 | -------------------------------------------------------------------------------- /tranquility/src/database/actor.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use ormx::Table; 3 | use serde_json::Value; 4 | use sqlx::PgPool; 5 | use time::OffsetDateTime; 6 | use uuid::Uuid; 7 | 8 | #[derive(Clone, Table)] 9 | #[ormx(id = id, table = "actors", deletable, insertable)] 10 | pub struct Actor { 11 | pub id: Uuid, 12 | 13 | pub username: String, 14 | #[ormx(get_optional(&str))] 15 | pub email: Option, 16 | pub password_hash: Option, 17 | pub private_key: Option, 18 | 19 | pub is_confirmed: bool, 20 | #[ormx(get_one(&str))] 21 | pub confirmation_code: Option, 22 | 23 | pub actor: Value, 24 | pub remote: bool, 25 | 26 | #[ormx(default)] 27 | pub created_at: OffsetDateTime, 28 | 29 | #[ormx(default)] 30 | pub updated_at: OffsetDateTime, 31 | } 32 | 33 | impl Actor { 34 | /// Get an confirmed actor by their ID 35 | pub async fn get(conn_pool: &PgPool, id: Uuid) -> Result { 36 | let actor = sqlx::query_as!( 37 | Actor, 38 | r#" 39 | SELECT * FROM actors 40 | WHERE id = $1 41 | AND is_confirmed = TRUE 42 | "#, 43 | id 44 | ) 45 | .fetch_one(conn_pool) 46 | .await?; 47 | 48 | Ok(actor) 49 | } 50 | 51 | /// Get an actor by their URL 52 | pub async fn by_url(conn_pool: &PgPool, url: &str) -> Result { 53 | let actor = sqlx::query_as!( 54 | Actor, 55 | r#" 56 | SELECT * FROM actors 57 | WHERE actor->>'id' = $1 58 | "#, 59 | url 60 | ) 61 | .fetch_one(conn_pool) 62 | .await?; 63 | 64 | Ok(actor) 65 | } 66 | 67 | /// Get an confirmed local actor by their username 68 | pub async fn by_username_local(conn_pool: &PgPool, username: &str) -> Result { 69 | let actor = sqlx::query_as!( 70 | Actor, 71 | r#" 72 | SELECT * FROM actors 73 | WHERE username = $1 74 | AND remote = FALSE 75 | AND is_confirmed = TRUE 76 | "#, 77 | username 78 | ) 79 | .fetch_one(conn_pool) 80 | .await?; 81 | 82 | Ok(actor) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tranquility/src/database/follow.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | database::{last_activity_timestamp, Object}, 3 | error::Error, 4 | }; 5 | use sqlx::PgPool; 6 | use uuid::Uuid; 7 | 8 | /// Get follow activities addressed to the user 9 | pub async fn followers( 10 | conn_pool: &PgPool, 11 | user_id: Uuid, 12 | last_activity_id: Option, 13 | limit: i64, 14 | ) -> Result, Error> { 15 | let last_activity_timestamp = last_activity_timestamp(conn_pool, last_activity_id).await?; 16 | let follow_activities = sqlx::query_as!( 17 | Object, 18 | r#" 19 | SELECT * FROM objects 20 | WHERE data->>'type' = 'Follow' 21 | AND data->>'object' = ( 22 | SELECT actor->>'id' FROM actors 23 | WHERE id = $1 24 | ) 25 | AND created_at < $2 26 | LIMIT $3 27 | "#, 28 | user_id, 29 | last_activity_timestamp, 30 | limit, 31 | ) 32 | .fetch_all(conn_pool) 33 | .await?; 34 | 35 | Ok(follow_activities) 36 | } 37 | 38 | /// Get follow activities created by the user 39 | pub async fn following( 40 | conn_pool: &PgPool, 41 | user_id: Uuid, 42 | last_activity_id: Option, 43 | limit: i64, 44 | ) -> Result, Error> { 45 | let last_activity_timestamp = last_activity_timestamp(conn_pool, last_activity_id).await?; 46 | let follow_activities = sqlx::query_as!( 47 | Object, 48 | r#" 49 | SELECT * FROM objects 50 | WHERE data->>'type' = 'Follow' 51 | AND owner_id = $1 52 | AND created_at < $2 53 | LIMIT $3 54 | "#, 55 | user_id, 56 | last_activity_timestamp, 57 | limit, 58 | ) 59 | .fetch_all(conn_pool) 60 | .await?; 61 | 62 | Ok(follow_activities) 63 | } 64 | -------------------------------------------------------------------------------- /tranquility/src/database/inbox_urls.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use futures_util::stream::{StreamExt, TryStreamExt}; 3 | use sqlx::PgPool; 4 | 5 | // Required because of the "query_as" macro 6 | // Otherwise we couldn't use compile-time verified SQL queries 7 | struct InboxUrl { 8 | inbox_url: String, 9 | } 10 | 11 | impl From for String { 12 | fn from(inbox_url: InboxUrl) -> Self { 13 | inbox_url.inbox_url 14 | } 15 | } 16 | 17 | /// Get the inbox URLs of the actors who are following the actor 18 | pub async fn resolve_followers( 19 | conn_pool: &PgPool, 20 | followed_url: &str, 21 | ) -> Result, Error> { 22 | let inbox_urls = sqlx::query_as!( 23 | InboxUrl, 24 | r#" 25 | SELECT actors.actor->>'inbox' as "inbox_url!" 26 | FROM actors, objects 27 | WHERE objects.data->>'type' = 'Follow' 28 | AND objects.data->>'object' = $1 29 | AND objects.data->>'object' = actors.actor->>'id' 30 | "#, 31 | followed_url 32 | ) 33 | .fetch(conn_pool) 34 | .map(|row_result| row_result.map(Into::into)) 35 | .try_collect() 36 | .await?; 37 | 38 | Ok(inbox_urls) 39 | } 40 | 41 | /// Get the inbox URL of an actor 42 | pub async fn resolve_one(conn_pool: &PgPool, url: &str) -> Result { 43 | let inbox_url = sqlx::query_as!( 44 | InboxUrl, 45 | // The `as "inbox_url!"` is needed here for the `query_as` macro to be 46 | // able to bind the result to the `inbox_url` field of the `InboxUrl` struct 47 | r#" 48 | SELECT actor->>'inbox' as "inbox_url!" 49 | FROM actors 50 | WHERE actor->>'id' = $1 51 | "#, 52 | url, 53 | ) 54 | .fetch_one(conn_pool) 55 | .await?; 56 | 57 | Ok(inbox_url.into()) 58 | } 59 | -------------------------------------------------------------------------------- /tranquility/src/database/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use async_trait::async_trait; 3 | use sqlx::PgPool; 4 | use time::OffsetDateTime; 5 | use uuid::Uuid; 6 | 7 | pub mod connection { 8 | use sqlx::PgPool; 9 | use std::future::Future; 10 | 11 | pub fn init_pool(db_url: &'_ str) -> impl Future> + '_ { 12 | PgPool::connect(db_url) 13 | } 14 | } 15 | 16 | #[async_trait] 17 | /// Convenience extension trait. Allows insertion via an immutable reference to a database pool 18 | pub trait InsertExt: ormx::Insert { 19 | /// Insert a row into the database, returning the inserted row 20 | async fn insert( 21 | self, 22 | conn_pool: &sqlx::Pool, 23 | ) -> Result<::Table, Error> { 24 | // Acquire a connection from the database pool 25 | let mut db_conn = conn_pool.acquire().await?; 26 | 27 | Ok(ormx::Insert::insert(self, &mut db_conn).await?) 28 | } 29 | } 30 | 31 | #[async_trait] 32 | impl InsertExt for T where T: ormx::Insert {} 33 | 34 | /// Wrapper struct for the query of the [last_activity_timestamp] function 35 | struct ObjectTimestamp { 36 | timestamp: OffsetDateTime, 37 | } 38 | 39 | impl From for OffsetDateTime { 40 | fn from(timestamp: ObjectTimestamp) -> Self { 41 | timestamp.timestamp 42 | } 43 | } 44 | 45 | #[inline] 46 | /// Get the timestamp of the activity 47 | /// 48 | /// If the activity doesn't exist the current time is returned 49 | async fn last_activity_timestamp( 50 | conn_pool: &PgPool, 51 | last_activity_id: Option, 52 | ) -> Result { 53 | let last_timestamp = sqlx::query_as!( 54 | ObjectTimestamp, 55 | r#" 56 | SELECT created_at as "timestamp!" FROM objects 57 | WHERE id = $1 58 | "#, 59 | last_activity_id, 60 | ) 61 | .fetch_one(conn_pool) 62 | .await 63 | // Either return the current time or convert it via the `Into` trait 64 | .map_or_else(|_| OffsetDateTime::now_utc(), Into::into); 65 | 66 | Ok(last_timestamp) 67 | } 68 | 69 | /// Execute the embedded database migrations 70 | pub async fn migrate(conn_pool: &PgPool) -> Result<(), Error> { 71 | sqlx::migrate!("../migrations").run(conn_pool).await?; 72 | 73 | Ok(()) 74 | } 75 | 76 | pub mod actor; 77 | pub mod follow; 78 | pub mod inbox_urls; 79 | pub mod oauth; 80 | pub mod object; 81 | pub mod outbox; 82 | 83 | pub use actor::*; 84 | pub use oauth::*; 85 | pub use object::*; 86 | -------------------------------------------------------------------------------- /tranquility/src/database/oauth/application.rs: -------------------------------------------------------------------------------- 1 | use ormx::Table; 2 | use time::OffsetDateTime; 3 | use uuid::Uuid; 4 | 5 | #[derive(Clone, Table)] 6 | #[ormx(id = id, table = "oauth_applications", deletable, insertable)] 7 | pub struct OAuthApplication { 8 | #[ormx(default)] 9 | pub id: Uuid, 10 | 11 | pub client_name: String, 12 | #[ormx(get_one)] 13 | pub client_id: Uuid, 14 | pub client_secret: String, 15 | 16 | pub redirect_uris: String, 17 | pub scopes: String, 18 | pub website: String, 19 | 20 | #[ormx(default)] 21 | pub created_at: OffsetDateTime, 22 | 23 | #[ormx(default)] 24 | pub updated_at: OffsetDateTime, 25 | } 26 | -------------------------------------------------------------------------------- /tranquility/src/database/oauth/authorization.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use ormx::Table; 3 | use sqlx::PgPool; 4 | use time::OffsetDateTime; 5 | use uuid::Uuid; 6 | 7 | #[derive(Clone, Table)] 8 | #[ormx(id = id, table = "oauth_authorizations", deletable, insertable)] 9 | pub struct OAuthAuthorization { 10 | #[ormx(default)] 11 | pub id: Uuid, 12 | 13 | pub application_id: Uuid, 14 | pub actor_id: Uuid, 15 | 16 | #[ormx(get_one(&str))] 17 | pub code: String, 18 | 19 | pub valid_until: OffsetDateTime, 20 | 21 | #[ormx(default)] 22 | pub created_at: OffsetDateTime, 23 | 24 | #[ormx(default)] 25 | pub updated_at: OffsetDateTime, 26 | } 27 | 28 | impl OAuthAuthorization { 29 | /// Delete all expired authorisation codes 30 | pub async fn delete_expired(conn_pool: &PgPool) -> Result<(), Error> { 31 | sqlx::query!("DELETE FROM oauth_authorizations WHERE valid_until < NOW()") 32 | .execute(conn_pool) 33 | .await?; 34 | 35 | Ok(()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tranquility/src/database/oauth/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod application; 2 | pub mod authorization; 3 | pub mod token; 4 | 5 | pub use application::*; 6 | pub use authorization::*; 7 | pub use token::*; 8 | -------------------------------------------------------------------------------- /tranquility/src/database/oauth/token.rs: -------------------------------------------------------------------------------- 1 | use ormx::Table; 2 | use time::OffsetDateTime; 3 | use uuid::Uuid; 4 | 5 | #[derive(Clone, Table)] 6 | #[ormx(id = id, table = "oauth_tokens", deletable, insertable)] 7 | pub struct OAuthToken { 8 | #[ormx(default)] 9 | pub id: Uuid, 10 | 11 | pub application_id: Option, 12 | pub actor_id: Uuid, 13 | 14 | #[ormx(get_one(&str))] 15 | pub access_token: String, 16 | 17 | pub refresh_token: Option, 18 | pub valid_until: OffsetDateTime, 19 | 20 | #[ormx(default)] 21 | pub created_at: OffsetDateTime, 22 | 23 | #[ormx(default)] 24 | pub updated_at: OffsetDateTime, 25 | } 26 | -------------------------------------------------------------------------------- /tranquility/src/database/object.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use ormx::Table; 3 | use serde_json::Value; 4 | use sqlx::PgPool; 5 | use time::OffsetDateTime; 6 | use uuid::Uuid; 7 | 8 | #[derive(Clone, Table)] 9 | #[ormx(id = id, table = "objects", deletable, insertable)] 10 | pub struct Object { 11 | pub id: Uuid, 12 | 13 | pub owner_id: Uuid, 14 | pub data: Value, 15 | 16 | #[ormx(default)] 17 | pub created_at: OffsetDateTime, 18 | 19 | #[ormx(default)] 20 | pub updated_at: OffsetDateTime, 21 | } 22 | 23 | impl Object { 24 | /// Get activities by its type and object URL 25 | pub async fn by_type_and_object_url( 26 | conn_pool: &PgPool, 27 | r#type: &str, 28 | object_url: &str, 29 | limit: i64, 30 | offset: i64, 31 | ) -> Result, Error> { 32 | let objects = sqlx::query_as!( 33 | Object, 34 | r#" 35 | SELECT * FROM objects 36 | WHERE data->>'type' = $1 37 | AND data->>'object' = $2 38 | 39 | ORDER BY created_at DESC 40 | LIMIT $3 41 | OFFSET $4 42 | "#, 43 | r#type, 44 | object_url, 45 | limit, 46 | offset 47 | ) 48 | .fetch_all(conn_pool) 49 | .await?; 50 | 51 | Ok(objects) 52 | } 53 | 54 | /// Get objects by its type and owner 55 | pub async fn by_type_and_owner( 56 | conn_pool: &PgPool, 57 | r#type: &str, 58 | owner_id: &Uuid, 59 | limit: i64, 60 | offset: i64, 61 | ) -> Result, Error> { 62 | let objects = sqlx::query_as!( 63 | Object, 64 | r#" 65 | SELECT * FROM objects 66 | WHERE owner_id = $1 67 | AND data->>'type' = $2 68 | 69 | ORDER BY created_at DESC 70 | LIMIT $3 71 | OFFSET $4 72 | "#, 73 | owner_id, 74 | r#type, 75 | limit, 76 | offset 77 | ) 78 | .fetch_all(conn_pool) 79 | .await?; 80 | 81 | Ok(objects) 82 | } 83 | 84 | /// Get an activity by its type, owner and object URL 85 | pub async fn by_type_owner_and_object_url( 86 | conn_pool: &PgPool, 87 | r#type: &str, 88 | owner_id: &Uuid, 89 | object_url: &str, 90 | ) -> Result { 91 | let object = sqlx::query_as!( 92 | Object, 93 | r#" 94 | SELECT * FROM objects 95 | WHERE data->>'type' = $1 96 | AND owner_id = $2 97 | AND data->>'object' = $3 98 | 99 | ORDER BY created_at DESC 100 | "#, 101 | r#type, 102 | owner_id, 103 | object_url, 104 | ) 105 | .fetch_one(conn_pool) 106 | .await?; 107 | 108 | Ok(object) 109 | } 110 | 111 | /// Get an object by its URL 112 | pub async fn by_url(conn_pool: &PgPool, url: &str) -> Result { 113 | let object = sqlx::query_as!( 114 | Object, 115 | r#" 116 | SELECT * FROM objects 117 | WHERE data->>'id' = $1 118 | "#, 119 | url 120 | ) 121 | .fetch_one(conn_pool) 122 | .await?; 123 | 124 | Ok(object) 125 | } 126 | 127 | /// Delete an object identified by its URL 128 | pub async fn delete_by_url(conn_pool: &PgPool, url: &str) -> Result<(), Error> { 129 | sqlx::query!( 130 | r#" 131 | DELETE FROM objects 132 | WHERE data->>'id' = $1 133 | "#, 134 | url 135 | ) 136 | .execute(conn_pool) 137 | .await?; 138 | 139 | Ok(()) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tranquility/src/database/outbox.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | database::{last_activity_timestamp, Object}, 3 | error::Error, 4 | }; 5 | use sqlx::PgPool; 6 | use uuid::Uuid; 7 | 8 | /// Get activities for displaying on the outbox 9 | pub async fn activities( 10 | conn_pool: &PgPool, 11 | user_id: Uuid, 12 | last_activity_id: Option, 13 | limit: i64, 14 | ) -> Result, Error> { 15 | let last_activity_timestamp = last_activity_timestamp(conn_pool, last_activity_id).await?; 16 | let create_activities = sqlx::query_as!( 17 | Object, 18 | r#" 19 | SELECT * FROM objects 20 | WHERE owner_id = $1 21 | AND data->>'type' = 'Create' 22 | AND created_at < $2 23 | LIMIT $3 24 | "#, 25 | user_id, 26 | last_activity_timestamp, 27 | limit 28 | ) 29 | .fetch_all(conn_pool) 30 | .await?; 31 | 32 | Ok(create_activities) 33 | } 34 | -------------------------------------------------------------------------------- /tranquility/src/email.rs: -------------------------------------------------------------------------------- 1 | use crate::{database::Actor, error::Error as OtherError, state::ArcState}; 2 | use axum::{extract::Path, response::IntoResponse, routing::get, Extension, Router}; 3 | use axum_macros::debug_handler; 4 | use lettre::{ 5 | error::Error as ContentError, 6 | transport::smtp::{authentication::Credentials, Error as SmtpError}, 7 | AsyncTransport, Message, Tokio1Executor, 8 | }; 9 | use once_cell::sync::OnceCell; 10 | use ormx::Table; 11 | use std::sync::Arc; 12 | 13 | type AsyncSmtpTransport = lettre::AsyncSmtpTransport; 14 | 15 | #[derive(Debug, thiserror::Error)] 16 | enum Error { 17 | #[error("Content error: {0}")] 18 | Content(#[from] ContentError), 19 | 20 | #[error("Database error: {0}")] 21 | Database(#[from] sqlx::Error), 22 | 23 | #[error("Other error: {0}")] 24 | Other(#[from] OtherError), 25 | 26 | #[error("SMTP transport error: {0}")] 27 | Smtp(#[from] SmtpError), 28 | } 29 | 30 | #[inline] 31 | /// Initialise the SMTP transport 32 | fn init_transport(state: &ArcState) -> Result { 33 | let transport_builder = if state.config.email.starttls { 34 | AsyncSmtpTransport::relay(&state.config.email.server) 35 | } else { 36 | AsyncSmtpTransport::starttls_relay(&state.config.email.server) 37 | }?; 38 | 39 | let username = state.config.email.username.to_string(); 40 | let password = state.config.email.password.to_string(); 41 | let transport = transport_builder 42 | .credentials(Credentials::new(username, password)) 43 | .build(); 44 | 45 | Ok(transport) 46 | } 47 | 48 | #[inline] 49 | /// Get a reference to the global SMTP transport (or initialise one if there isn't one already) 50 | fn get_transport(state: &ArcState) -> Result<&'static AsyncSmtpTransport, Error> { 51 | static SMTP_TRANSPORT: OnceCell = OnceCell::new(); 52 | 53 | SMTP_TRANSPORT.get_or_try_init::<_, Error>(|| init_transport(state)) 54 | } 55 | 56 | pub fn send_confirmation(state: &ArcState, mut user: Actor) { 57 | if !state.config.email.active { 58 | return; 59 | } 60 | 61 | let state = Arc::clone(state); 62 | 63 | // Spawn off here since we don't want to delay the request processing 64 | tokio::spawn(async move { 65 | // Run the actual logic inside an own async block to be able to take advantage of 66 | // the try syntax and to handle all the errors in a single place 67 | let result: Result<(), Error> = async move { 68 | // Generate and save the confirmation code 69 | let confirmation_code = crate::crypto::token::generate(); 70 | user.confirmation_code = Some(confirmation_code.clone()); 71 | user.is_confirmed = false; 72 | user.update(&state.db_pool).await?; 73 | 74 | let domain = &state.config.instance.domain; 75 | let confirmation_url = format!("https://{}/confirm-account/{}", domain, confirmation_code); 76 | let message_body = format!( 77 | "Hello, thank you for creating an account on {}!\nTo confirm your account, please visit the URL below:\n{}", 78 | domain, 79 | confirmation_url 80 | ); 81 | 82 | let from_mailbox = state.config.email.email.parse().unwrap(); 83 | let to_mailbox = user.email.unwrap().parse().unwrap(); 84 | let message = Message::builder().subject("Account confirmation").from(from_mailbox).to(to_mailbox).body(message_body)?; 85 | 86 | let transport = get_transport(&state)?; 87 | transport.send(message).await?; 88 | 89 | Ok(()) 90 | } 91 | .await; 92 | 93 | if let Err(err) = result { 94 | error!(error = ?err, "Couldn't send confirmation email"); 95 | } 96 | }); 97 | } 98 | 99 | #[debug_handler] 100 | async fn confirm_account( 101 | Path(confirmation_code): Path, 102 | Extension(state): Extension, 103 | ) -> Result { 104 | let mut user = Actor::by_confirmation_code(&state.db_pool, &confirmation_code).await?; 105 | user.is_confirmed = true; 106 | user.update(&state.db_pool).await?; 107 | 108 | Ok("Account confirmed!") 109 | } 110 | 111 | pub fn routes() -> Router { 112 | Router::new().route("/confirm-account/:confirmation_code", get(confirm_account)) 113 | } 114 | -------------------------------------------------------------------------------- /tranquility/src/error.rs: -------------------------------------------------------------------------------- 1 | use argon2::Error as Argon2Error; 2 | use askama::Error as AskamaError; 3 | use axum::{ 4 | http::StatusCode, 5 | response::{IntoResponse, Response}, 6 | Json, 7 | }; 8 | use reqwest::{header::InvalidHeaderValue as ReqwestInvalidHeaderValue, Error as ReqwestError}; 9 | use rsa::{ 10 | errors::Error as RsaError, 11 | pkcs8::{spki::Error as SpkiError, Error as Pkcs8Error}, 12 | }; 13 | use serde_json::Error as SerdeJsonError; 14 | use sqlx::{migrate::MigrateError as SqlxMigrationError, Error as SqlxError}; 15 | use time::Error as TimeError; 16 | use tranquility_http_signatures::Error as HttpSignaturesError; 17 | use url::ParseError as UrlParseError; 18 | use uuid::Error as UuidError; 19 | use validator::ValidationErrors; 20 | 21 | #[derive(Debug, thiserror::Error)] 22 | /// Combined error enum for converting errors into rejections 23 | pub enum Error { 24 | #[error("argon2 operation failed")] 25 | Argon2(#[from] Argon2Error), 26 | 27 | #[error("Template formatting failed: {0}")] 28 | Askama(#[from] AskamaError), 29 | 30 | #[error("Remote content fetch failed")] 31 | Fetch, 32 | 33 | #[error("HTTP signature operation failed: {0}")] 34 | HttpSignatures(#[from] HttpSignaturesError), 35 | 36 | #[error("Invalid request")] 37 | InvalidRequest, 38 | 39 | #[error("Malformed URL")] 40 | MalformedUrl, 41 | 42 | #[error("Unauthorized")] 43 | Unauthorized, 44 | 45 | #[error("PKCS#8 operation failed: {0}")] 46 | Pkcs8(#[from] Pkcs8Error), 47 | 48 | #[error("reqwest operation failed: {0}")] 49 | Reqwest(#[from] ReqwestError), 50 | 51 | #[error("Invalid reqwest HeaderValue: {0}")] 52 | ReqwestInvalidHeaderValue(#[from] ReqwestInvalidHeaderValue), 53 | 54 | #[error("RSA operation failed: {0}")] 55 | Rsa(#[from] RsaError), 56 | 57 | #[error("SPKI operation failed: {0}")] 58 | Spki(#[from] SpkiError), 59 | 60 | #[error("Database operation failed: {0}")] 61 | Sqlx(#[from] SqlxError), 62 | 63 | #[error("Database migration failed: {0}")] 64 | SqlxMigration(#[from] SqlxMigrationError), 65 | 66 | #[error("serde-json operation failed: {0}")] 67 | SerdeJson(#[from] SerdeJsonError), 68 | 69 | #[error("time operation failed: {0}")] 70 | Time(#[from] TimeError), 71 | 72 | #[error("Unexpected webfinger resource")] 73 | UnexpectedWebfingerResource, 74 | 75 | #[error("Unknown activity")] 76 | UnknownActivity, 77 | 78 | #[error("URL couldn't be parsed: {0}")] 79 | UrlParse(#[from] UrlParseError), 80 | 81 | #[error("UUID operation failed: {0}")] 82 | Uuid(#[from] UuidError), 83 | 84 | #[error("Validation error")] 85 | Validation(#[from] ValidationErrors), 86 | } 87 | 88 | impl From for Response { 89 | fn from(err: Error) -> Self { 90 | err.into_response() 91 | } 92 | } 93 | 94 | impl IntoResponse for Error { 95 | fn into_response(self) -> Response { 96 | let error_text = self.to_string(); 97 | 98 | match self { 99 | Error::InvalidRequest 100 | | Error::UnknownActivity 101 | | Error::MalformedUrl 102 | | Error::Uuid(..) => (StatusCode::BAD_REQUEST, error_text).into_response(), 103 | 104 | // Add special case to send the previously defined error messages 105 | Error::Validation(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(), 106 | 107 | Error::Unauthorized => (StatusCode::UNAUTHORIZED, error_text).into_response(), 108 | 109 | Error::Argon2(..) 110 | | Error::Pkcs8(..) 111 | | Error::Sqlx(..) 112 | | Error::SqlxMigration(..) 113 | | Error::Rsa(..) => { 114 | error!(error = ?self, "Internal error occurred"); 115 | 116 | StatusCode::INTERNAL_SERVER_ERROR.into_response() 117 | } 118 | 119 | _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tranquility/src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![deny(rust_2018_idioms)] 3 | #![warn(clippy::all, clippy::pedantic)] 4 | #![allow(clippy::doc_markdown, clippy::module_name_repetitions)] 5 | // Needed because of conditional compilation 6 | #![allow(clippy::used_underscore_binding)] 7 | 8 | #[macro_use] 9 | extern crate tracing; 10 | 11 | cfg_if::cfg_if! { 12 | if #[cfg(feature = "jemalloc")] { 13 | #[global_allocator] 14 | static GLOBAL: jemalloc::Jemalloc = jemalloc::Jemalloc; 15 | } else if #[cfg(feature = "mimalloc")] { 16 | #[global_allocator] 17 | static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; 18 | } 19 | } 20 | 21 | use std::io; 22 | 23 | #[tokio::main] 24 | async fn main() -> io::Result<()> { 25 | let state = cli::run().await; 26 | 27 | database::migrate(&state.db_pool) 28 | .await 29 | .expect("Database migration failed"); 30 | daemon::start(&state); 31 | 32 | server::run(state).await?; 33 | 34 | Ok(()) 35 | } 36 | 37 | mod activitypub; 38 | mod api; 39 | mod cli; 40 | mod config; 41 | mod consts; 42 | mod crypto; 43 | mod daemon; 44 | mod database; 45 | 46 | #[cfg(feature = "email")] 47 | mod email; 48 | 49 | mod error; 50 | mod macros; 51 | mod server; 52 | mod state; 53 | mod util; 54 | mod well_known; 55 | 56 | #[cfg(test)] 57 | mod tests; 58 | -------------------------------------------------------------------------------- /tranquility/src/server.rs: -------------------------------------------------------------------------------- 1 | use crate::state::ArcState; 2 | use axum::{extract::connect_info::IntoMakeServiceWithConnectInfo, Extension, Router}; 3 | use axum_server::tls_rustls::RustlsConfig; 4 | use std::{ 5 | io, 6 | net::{IpAddr, SocketAddr}, 7 | sync::Arc, 8 | }; 9 | use tower_http::{compression::CompressionLayer, trace::TraceLayer}; 10 | 11 | /// Construct the combined router 12 | pub fn create_router_make_service( 13 | state: &ArcState, 14 | ) -> IntoMakeServiceWithConnectInfo { 15 | let router = Router::new() 16 | .merge(crate::activitypub::routes()) 17 | .merge(crate::api::routes(state)) 18 | .merge(crate::well_known::routes()); 19 | 20 | #[cfg(feature = "email")] 21 | let router = router.merge(crate::email::routes()); 22 | 23 | let router = router 24 | .layer(Extension(Arc::clone(state))) 25 | .layer(TraceLayer::new_for_http()) 26 | .layer(CompressionLayer::new()); 27 | 28 | router.into_make_service_with_connect_info() 29 | } 30 | 31 | /// Combine all routers and start the webserver 32 | pub async fn run(state: ArcState) -> io::Result<()> { 33 | let router_service = create_router_make_service(&state); 34 | let interface = state.config.server.interface.parse::().unwrap(); 35 | let addr = (interface, state.config.server.port); 36 | 37 | if state.config.tls.serve_tls_directly { 38 | let config = RustlsConfig::from_pem_file( 39 | &state.config.tls.certificate, 40 | &state.config.tls.secret_key, 41 | ) 42 | .await?; 43 | 44 | axum_server::bind_rustls(addr.into(), config) 45 | .serve(router_service) 46 | .await?; 47 | } else { 48 | axum_server::bind(addr.into()).serve(router_service).await?; 49 | } 50 | 51 | Ok(()) 52 | } 53 | -------------------------------------------------------------------------------- /tranquility/src/state.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Configuration; 2 | use sqlx::PgPool; 3 | use std::sync::Arc; 4 | 5 | #[allow(clippy::module_name_repetitions)] 6 | /// State wrapped into an arc 7 | pub type ArcState = Arc; 8 | 9 | /// Application-wide state 10 | pub struct State { 11 | pub config: Configuration, 12 | pub db_pool: PgPool, 13 | } 14 | 15 | impl State { 16 | /// Create a new state instance wrapped into an Arc 17 | pub fn new(config: Configuration, db_pool: PgPool) -> ArcState { 18 | Arc::new(Self::new_arcless(config, db_pool)) 19 | } 20 | 21 | /// Create a new state instance 22 | pub fn new_arcless(config: Configuration, db_pool: PgPool) -> Self { 23 | Self { config, db_pool } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tranquility/src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | activitypub::FollowActivity, 3 | config::{ 4 | Configuration, ConfigurationEmail, ConfigurationInstance, ConfigurationJaeger, 5 | ConfigurationRatelimit, ConfigurationServer, ConfigurationTls, 6 | }, 7 | server::create_router_make_service, 8 | state::{ArcState, State}, 9 | }; 10 | use axum::Server; 11 | use mime::Mime; 12 | use sqlx::PgPool; 13 | use std::{env, net::SocketAddr}; 14 | 15 | const FOLLOW_ACTIVITY: &str = r#" 16 | { 17 | "cc": ["https://www.w3.org/ns/activitystreams#Public"], 18 | "id": "https://a.example.com/activities/8dcc256a-8c3f-49ee-ab22-bb51c9082260", 19 | "to": ["https://b.example.com/users/test"], 20 | "type": "Follow", 21 | "actor": "https://a.example.com/users/test", 22 | "state": "pending", 23 | "object": "https://b.example.com/users/test", 24 | "context": "https://a.example.com/contexts/9c3b4420-dd74-454b-8124-c4759b849f3a", 25 | "published": "2019-08-20T14:02:09.995388Z", 26 | "context_id": 8 27 | } 28 | "#; 29 | 30 | fn test_config() -> Configuration { 31 | Configuration { 32 | email: ConfigurationEmail { 33 | active: false, 34 | server: "smtp.example.com".into(), 35 | starttls: false, 36 | email: "noreply@example.com".into(), 37 | username: "tranquility".into(), 38 | password: "tranquility-acct-password".into(), 39 | }, 40 | instance: ConfigurationInstance { 41 | closed_registrations: false, 42 | domain: "tranquility.example.com".into(), 43 | description: "Tranquility instance".into(), 44 | character_limit: 1024, 45 | upload_limit: 4096, 46 | moderators: Vec::new(), 47 | }, 48 | jaeger: ConfigurationJaeger { 49 | active: false, 50 | host: "localhost".into(), 51 | port: 6831, 52 | }, 53 | ratelimit: ConfigurationRatelimit { 54 | active: false, 55 | authentication_quota: 1, 56 | registration_quota: 1, 57 | }, 58 | server: ConfigurationServer { 59 | database_url: String::new(), 60 | interface: "127.0.0.1".into(), 61 | port: 8080, 62 | }, 63 | tls: ConfigurationTls { 64 | serve_tls_directly: false, 65 | certificate: String::new(), 66 | secret_key: String::new(), 67 | }, 68 | } 69 | } 70 | 71 | async fn init_db() -> PgPool { 72 | let conn_url = env::var("TEST_DB_URL").unwrap(); 73 | 74 | let conn_pool = PgPool::connect(&conn_url).await.unwrap(); 75 | crate::database::migrate(&conn_pool).await.ok(); 76 | 77 | conn_pool 78 | } 79 | 80 | async fn test_state() -> State { 81 | let config = test_config(); 82 | let db_pool = init_db().await; 83 | 84 | State::new_arcless(config, db_pool) 85 | } 86 | 87 | struct TestClient { 88 | address: SocketAddr, 89 | client: reqwest::Client, 90 | } 91 | 92 | impl TestClient { 93 | /// Construct a new test client 94 | fn new(address: SocketAddr) -> Self { 95 | Self { 96 | address, 97 | client: reqwest::Client::new(), 98 | } 99 | } 100 | 101 | fn format_url(&self, uri: &str) -> String { 102 | format!("http://{}{uri}", self.address) 103 | } 104 | 105 | /// Send a GET request 106 | async fn get(&self, uri: &str) -> reqwest::Result { 107 | self.client.get(self.format_url(uri)).send().await 108 | } 109 | 110 | /// Send a POST request 111 | /// 112 | /// If `None` is passed as the content type it defaults to `application/x-www-form-urlencoded` 113 | async fn post( 114 | &self, 115 | uri: &str, 116 | content_type: Option, 117 | body: B, 118 | ) -> reqwest::Result 119 | where 120 | B: Into, 121 | { 122 | let content_type = content_type.unwrap_or(mime::APPLICATION_WWW_FORM_URLENCODED); 123 | 124 | self.client 125 | .post(self.format_url(uri)) 126 | .header("Content-Type", content_type.as_ref()) 127 | .body(body) 128 | .send() 129 | .await 130 | } 131 | } 132 | 133 | /// Start an axum server bound to a random port 134 | /// 135 | /// # Returns 136 | /// 137 | /// Returns a client that can send HTTP requests to the test server 138 | fn start_test_server(state: S) -> TestClient 139 | where 140 | S: Into, 141 | { 142 | let state = state.into(); 143 | let router_service = create_router_make_service(&state); 144 | 145 | let server = Server::bind(&SocketAddr::from(([127, 0, 0, 1], 0))).serve(router_service); 146 | let bound_address = server.local_addr(); 147 | 148 | tokio::spawn(server); 149 | 150 | TestClient::new(bound_address) 151 | } 152 | 153 | #[test] 154 | fn decode_follow_activity() { 155 | let follow_activity: FollowActivity = serde_json::from_str(FOLLOW_ACTIVITY).unwrap(); 156 | 157 | assert_eq!(follow_activity.activity.r#type, "Follow"); 158 | assert!(!follow_activity.approved); 159 | } 160 | 161 | mod nodeinfo; 162 | mod register; 163 | -------------------------------------------------------------------------------- /tranquility/src/tests/register.rs: -------------------------------------------------------------------------------- 1 | use crate::tests::{start_test_server, test_state}; 2 | use http::StatusCode; 3 | 4 | #[tokio::test] 5 | async fn closed_registrations() { 6 | let mut state = test_state().await; 7 | state.config.instance.closed_registrations = true; 8 | let test_client = start_test_server(state); 9 | 10 | let test_response = test_client 11 | .post( 12 | "/api/tranquility/v1/register", 13 | None, 14 | "username=test&email=test@example.com&password=1234.", 15 | ) 16 | .await 17 | .expect("Failed to send registration request"); 18 | assert_eq!(test_response.status(), StatusCode::FORBIDDEN); 19 | } 20 | 21 | #[tokio::test] 22 | async fn register_endpoint() { 23 | let state = test_state().await; 24 | let test_client = start_test_server(state); 25 | 26 | let test_response = test_client 27 | .post( 28 | "/api/tranquility/v1/register", 29 | None, 30 | "username=test&email=test@example.com&password=test1234.", 31 | ) 32 | .await 33 | .expect("Failed to send registration request"); 34 | assert_eq!(test_response.status(), StatusCode::CREATED); 35 | 36 | let body_data = test_response 37 | .text() 38 | .await 39 | .expect("Failed to get text response"); 40 | assert_eq!(body_data, "Account created"); 41 | } 42 | -------------------------------------------------------------------------------- /tranquility/src/util/mention.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | consts::regex::MENTION, database::Actor as DbActor, error::Error, regex, state::ArcState, 3 | well_known::webfinger, 4 | }; 5 | use async_trait::async_trait; 6 | use regex::{Captures, Match}; 7 | use tokio::runtime::Handle; 8 | use tranquility_types::activitypub::{Actor, Object, Tag}; 9 | 10 | regex!(MENTION_REGEX = MENTION); 11 | 12 | /// Struct representing a mention 13 | /// 14 | /// If it's a remote mention (mentions a user from a different instance), a `domain` value is present 15 | #[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] 16 | pub struct Mention<'a> { 17 | pub username: &'a str, 18 | pub domain: Option<&'a str>, 19 | } 20 | 21 | impl<'a> Mention<'a> { 22 | fn new(username: &'a str, domain: Option<&'a str>) -> Self { 23 | Self { username, domain } 24 | } 25 | } 26 | 27 | /// Trait for getting mentions 28 | pub trait ExtractMention { 29 | /// Get the mentions contained in the value 30 | fn mentions(&self) -> Vec>; 31 | } 32 | 33 | impl ExtractMention for T 34 | where 35 | T: AsRef, 36 | { 37 | fn mentions(&self) -> Vec> { 38 | MENTION_REGEX 39 | .captures_iter(self.as_ref()) 40 | .map(|capture| { 41 | let username = capture.name("username").unwrap().as_str(); 42 | let domain = capture.name("domain").as_ref().map(Match::as_str); 43 | 44 | Mention::new(username, domain) 45 | }) 46 | .collect() 47 | } 48 | } 49 | 50 | #[async_trait] 51 | /// Trait for formatting mentions 52 | 53 | pub trait FormatMention { 54 | /// Format the mentions to links 55 | async fn format_mentions(&mut self, state: ArcState) -> Vec; 56 | } 57 | 58 | #[async_trait] 59 | impl FormatMention for Object { 60 | async fn format_mentions(&mut self, state: ArcState) -> Vec { 61 | let tags = self.content.format_mentions(state).await; 62 | self.tag = tags.clone(); 63 | 64 | tags 65 | } 66 | } 67 | 68 | #[async_trait] 69 | impl FormatMention for String { 70 | async fn format_mentions(&mut self, state: ArcState) -> Vec { 71 | let handle = Handle::current(); 72 | let text = self.clone(); 73 | 74 | // We have to do those moves (async -> sync -> async) because we can't run futures to completion inside a synchronous closure without blocking 75 | // and we can't access our database without an async executor because SQLx is async-only 76 | // 77 | // That's why we use `spawn_blocking` to be allowed to block and then use the handle to the runtime we created earlier 78 | // to spawn a future onto the already existing runtime for the networking/database interactions and block until the future has resolved 79 | let format_result = tokio::task::spawn_blocking(move || { 80 | let mut tags = Vec::new(); 81 | 82 | let output = MENTION_REGEX.replace_all(text.as_str(), |capture: &Captures<'_>| { 83 | let username = capture.name("username").unwrap().as_str(); 84 | let domain = capture.name("domain").as_ref().map(Match::as_str); 85 | 86 | // Block until the future has resolved 87 | // This is fine because we are inside the `spawn_blocking` context where blocking is allowed 88 | let actor_result: Result = handle.block_on(async { 89 | // If a domain is present, use webfinger 90 | // Otherwise query the local accounts 91 | let actor = if let Some(domain) = domain { 92 | let (actor, _db_actor) = 93 | webfinger::fetch_actor(&state, username, domain).await?; 94 | 95 | actor 96 | } else { 97 | let db_actor = DbActor::by_username_local(&state.db_pool, username).await?; 98 | let actor: Actor = serde_json::from_value(db_actor.actor)?; 99 | 100 | actor 101 | }; 102 | 103 | Ok(actor) 104 | }); 105 | 106 | let mut mention = capture.get(0).unwrap().as_str().to_string(); 107 | if let Ok(actor) = actor_result { 108 | // Create a new ActivityPub tag object 109 | tags.push(Tag { 110 | r#type: "Mention".into(), 111 | name: mention.clone(), 112 | href: actor.id.clone(), 113 | }); 114 | 115 | mention = format!(r#"{}"#, actor.id, mention); 116 | } 117 | 118 | mention 119 | }); 120 | 121 | (output.to_string(), tags) 122 | }) 123 | .await 124 | // Log the error and move on 125 | // The user will most likely delete and redraft when the mentions don't work 126 | .map_err(|err| error!(error = ?err)); 127 | 128 | if let Ok((content, tags)) = format_result { 129 | *self = content; 130 | 131 | tags 132 | } else { 133 | Vec::new() 134 | } 135 | } 136 | } 137 | 138 | #[cfg(test)] 139 | mod test { 140 | use super::{ExtractMention, Mention}; 141 | 142 | const LOCAL_MENTION: &str = "@alice"; 143 | const MULTIPLE_MENTIONS: &str = "@alice @bob@example.com\n@carol@the.third.example.com\t@dave@fourth.example.com hello@example.com"; 144 | const REMOTE_MENTION: &str = "@bob@example.com"; 145 | 146 | #[test] 147 | fn local_mention() { 148 | let mentions = LOCAL_MENTION.mentions(); 149 | 150 | assert_eq!(mentions, [Mention::new("alice", None)]); 151 | } 152 | 153 | #[test] 154 | fn multiple_mentions() { 155 | let mentions = MULTIPLE_MENTIONS.mentions(); 156 | 157 | assert_eq!( 158 | mentions, 159 | [ 160 | Mention::new("alice", None), 161 | Mention::new("bob", Some("example.com")), 162 | Mention::new("carol", Some("the.third.example.com")), 163 | Mention::new("dave", Some("fourth.example.com")), 164 | ] 165 | ); 166 | } 167 | 168 | #[test] 169 | fn remote_mention() { 170 | let mentions = REMOTE_MENTION.mentions(); 171 | 172 | assert_eq!(mentions, [Mention::new("bob", Some("example.com"))]); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /tranquility/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::consts::USER_AGENT; 2 | use async_trait::async_trait; 3 | use axum::{ 4 | body::HttpBody, 5 | extract::{Form as AxumForm, FromRequest, RequestParts}, 6 | Json, 7 | }; 8 | use futures_util::FutureExt; 9 | use once_cell::sync::Lazy; 10 | use reqwest::Client; 11 | use serde::de::DeserializeOwned; 12 | use std::{error::Error as StdError, future::Future}; 13 | use tokio::sync::oneshot; 14 | 15 | pub static HTTP_CLIENT: Lazy = 16 | Lazy::new(|| Client::builder().user_agent(USER_AGENT).build().unwrap()); 17 | 18 | /// Specialised form that deserialises both, JSON and URL-encoded form data 19 | pub struct Form(pub T); 20 | 21 | #[async_trait] 22 | impl FromRequest for Form 23 | where 24 | B: HttpBody + Send, 25 | B::Data: Send, 26 | B::Error: StdError + Send + Sync + 'static, 27 | T: DeserializeOwned + Send, 28 | { 29 | type Rejection = as FromRequest>::Rejection; 30 | 31 | async fn from_request(req: &mut RequestParts) -> Result { 32 | match Json::from_request(req).await { 33 | Ok(Json(val)) => Ok(Self(val)), 34 | Err(err) => { 35 | trace!(error = %err, "Form could not get deserialised as JSON. Attempting URL encoded"); 36 | let AxumForm(val) = AxumForm::from_request(req).await?; 37 | Ok(Self(val)) 38 | } 39 | } 40 | } 41 | } 42 | 43 | /// Run any CPU intensive tasks (RSA key generation, password hashing, etc.) via this function 44 | pub fn cpu_intensive_task(func: F) -> impl Future + Send + Sync + 'static 45 | where 46 | T: Send + 'static, 47 | F: FnOnce() -> T + Send + 'static, 48 | { 49 | let (sender, receiver) = oneshot::channel(); 50 | 51 | rayon::spawn(move || { 52 | let span = info_span!( 53 | "CPU intensive task", 54 | worker_id = rayon::current_thread_index().unwrap() 55 | ); 56 | let _enter_guard = span.enter(); 57 | 58 | let result = func(); 59 | 60 | if sender.send(result).is_err() { 61 | warn!("Couldn't send result back to async task"); 62 | } 63 | }); 64 | 65 | receiver.map(Result::unwrap) 66 | } 67 | 68 | pub mod mention; 69 | -------------------------------------------------------------------------------- /tranquility/src/well_known/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::Router; 2 | 3 | pub fn routes() -> Router { 4 | let router = Router::new() 5 | .merge(nodeinfo::routes()) 6 | .merge(webfinger::routes()); 7 | 8 | Router::new().nest("/.well-known", router) 9 | } 10 | 11 | pub mod nodeinfo; 12 | pub mod webfinger; 13 | -------------------------------------------------------------------------------- /tranquility/src/well_known/nodeinfo.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | consts::{SOFTWARE_NAME, VERSION}, 3 | state::ArcState, 4 | }; 5 | use axum::{response::IntoResponse, routing::get, Extension, Json, Router}; 6 | use tranquility_types::nodeinfo::{Link, LinkCollection, Nodeinfo, Services, Software, Usage}; 7 | 8 | #[allow(clippy::unused_async)] 9 | async fn nodeinfo(Extension(state): Extension) -> impl IntoResponse { 10 | let info = Nodeinfo { 11 | protocols: vec!["activitypub".into()], 12 | software: Software { 13 | name: SOFTWARE_NAME.into(), 14 | version: VERSION.into(), 15 | ..Software::default() 16 | }, 17 | services: Services { 18 | inbound: Vec::new(), 19 | outbound: Vec::new(), 20 | }, 21 | open_registrations: !state.config.instance.closed_registrations, 22 | usage: Usage::default(), 23 | ..Nodeinfo::default() 24 | }; 25 | 26 | Json(info) 27 | } 28 | 29 | #[allow(clippy::unused_async)] 30 | async fn nodeinfo_links(Extension(state): Extension) -> impl IntoResponse { 31 | let entity_link = format!( 32 | "https://{}/.well-known/nodeinfo/2.1", 33 | state.config.instance.domain 34 | ); 35 | 36 | let link = Link::new(entity_link); 37 | let link_collection = LinkCollection { links: vec![link] }; 38 | 39 | Json(link_collection) 40 | } 41 | 42 | pub fn routes() -> Router { 43 | Router::new() 44 | .route("/nodeinfo", get(nodeinfo_links)) 45 | .route("/nodeinfo/2.1", get(nodeinfo)) 46 | } 47 | -------------------------------------------------------------------------------- /tranquility/src/well_known/webfinger.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | consts::cors::GENERAL_ALLOWED_METHODS, database::Actor as DbActor, error::Error, 3 | state::ArcState, util::HTTP_CLIENT, 4 | }; 5 | use axum::{ 6 | extract::Query, http::StatusCode, response::IntoResponse, routing::get, Extension, Json, Router, 7 | }; 8 | use serde::Deserialize; 9 | use tower_http::cors::CorsLayer; 10 | use tranquility_types::{ 11 | activitypub::Actor, 12 | webfinger::{Link, Resource}, 13 | }; 14 | 15 | // Keeping this for future use 16 | pub async fn fetch_actor( 17 | state: &ArcState, 18 | username: &str, 19 | domain: &str, 20 | ) -> Result<(Actor, DbActor), Error> { 21 | let resource = format!("acct:{}@{}", username, domain); 22 | let url = format!( 23 | "https://{}/.well-known/webfinger?resource={}", 24 | domain, resource 25 | ); 26 | 27 | let request = HTTP_CLIENT 28 | .get(&url) 29 | .header("Accept", "application/jrd+json") 30 | .build()?; 31 | let resource: Resource = HTTP_CLIENT.execute(request).await?.json().await?; 32 | 33 | let actor_url = resource 34 | .links 35 | .iter() 36 | .find(|link| link.rel == "self") 37 | .ok_or(Error::UnexpectedWebfingerResource)?; 38 | 39 | crate::activitypub::fetcher::fetch_actor(state, &actor_url.href).await 40 | } 41 | 42 | #[derive(Deserialize)] 43 | /// Query struct for a webfinger request 44 | pub struct QueryParams { 45 | resource: String, 46 | } 47 | 48 | pub async fn webfinger( 49 | Extension(state): Extension, 50 | Query(QueryParams { resource }): Query, 51 | ) -> Result { 52 | let mut resource_tokens = resource.trim_start_matches("acct:").split('@'); 53 | let username = resource_tokens.next().ok_or(Error::InvalidRequest)?; 54 | 55 | if resource_tokens.next().ok_or(Error::InvalidRequest)? != state.config.instance.domain { 56 | return Ok(StatusCode::NOT_FOUND.into_response()); 57 | } 58 | 59 | let actor_db = DbActor::by_username_local(&state.db_pool, username).await?; 60 | let actor: Actor = serde_json::from_value(actor_db.actor)?; 61 | 62 | let link = Link { 63 | rel: "self".into(), 64 | r#type: Some("application/activity+json".into()), 65 | href: actor.id.clone(), 66 | ..Link::default() 67 | }; 68 | let resource = Resource { 69 | subject: resource, 70 | 71 | aliases: vec![actor.id], 72 | 73 | links: vec![link], 74 | ..Resource::default() 75 | }; 76 | 77 | Ok(([("Content-Type", "application/jrd+json")], Json(resource)).into_response()) 78 | } 79 | 80 | pub fn routes() -> Router { 81 | Router::new() 82 | .route("/webfinger", get(webfinger)) 83 | .layer(CorsLayer::very_permissive().allow_methods(GENERAL_ALLOWED_METHODS.to_vec())) 84 | } 85 | -------------------------------------------------------------------------------- /tranquility/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} - Tranquility 5 | 6 | 12 | 13 | 14 | {% block content %} 15 | {% endblock %} 16 | 17 | -------------------------------------------------------------------------------- /tranquility/templates/oauth/authorize.html: -------------------------------------------------------------------------------- 1 | {% extends "../base.html" %} 2 | 3 | {% block title %} 4 | OAuth Authorize 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

9 | OAuth Authorize 10 |

11 |
12 | 15 | 16 |
17 | 20 | 21 |

22 | 23 |

24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /tranquility/templates/oauth/token.html: -------------------------------------------------------------------------------- 1 | {% extends "../base.html" %} 2 | 3 | {% block title %} 4 | OAuth Token 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

9 | OAuth Token 10 |

11 | Copy the code below into the application: 12 |
13 | {% endblock %} 14 | --------------------------------------------------------------------------------