diff --git a/.dockerignore b/.dockerignore index 2233067..5eea1e6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,7 @@ target/ -Dockerfile \ No newline at end of file +.git/ +.env +.env.* +*.env +.pg/ +.claude/ diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..90c85b8 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: envx_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/envx_test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.98.1 + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - run: cargo install sqlx-cli --version 0.8.6 --locked --no-default-features --features postgres,rustls + - run: cargo sqlx migrate run + - run: cargo fmt -- --check + - run: cargo sqlx prepare --check -- --all-targets + - run: cargo test --locked + - run: cargo clippy --locked --all-targets + - name: Verify build without a database connection + run: env -u DATABASE_URL SQLX_OFFLINE=true cargo check --locked --all-targets diff --git a/.gitignore b/.gitignore index 134c25d..c586188 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk diff --git a/.sqlx/query-1c2be9b90755c1d4865b8b713b21e0a430852df4b62768892c16fcd865bc91cf.json b/.sqlx/query-1c2be9b90755c1d4865b8b713b21e0a430852df4b62768892c16fcd865bc91cf.json new file mode 100644 index 0000000..622e0ce --- /dev/null +++ b/.sqlx/query-1c2be9b90755c1d4865b8b713b21e0a430852df4b62768892c16fcd865bc91cf.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM upload_log WHERE created_at < now() - interval '24 hours'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "1c2be9b90755c1d4865b8b713b21e0a430852df4b62768892c16fcd865bc91cf" +} diff --git a/.sqlx/query-27dd1da6157430522aebea48ade14f5a9f96476116f019c816dfec563985b54a.json b/.sqlx/query-27dd1da6157430522aebea48ade14f5a9f96476116f019c816dfec563985b54a.json new file mode 100644 index 0000000..77de7ee --- /dev/null +++ b/.sqlx/query-27dd1da6157430522aebea48ade14f5a9f96476116f019c816dfec563985b54a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, public_key FROM users WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "public_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "27dd1da6157430522aebea48ade14f5a9f96476116f019c816dfec563985b54a" +} diff --git a/.sqlx/query-34f5c6b75b4e839ab0b0a4051c4d18a32870c06c56cd0e23220c0ef496ec6084.json b/.sqlx/query-34f5c6b75b4e839ab0b0a4051c4d18a32870c06c56cd0e23220c0ef496ec6084.json new file mode 100644 index 0000000..3365f0c --- /dev/null +++ b/.sqlx/query-34f5c6b75b4e839ab0b0a4051c4d18a32870c06c56cd0e23220c0ef496ec6084.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (username, public_key) VALUES ($1, $2) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "34f5c6b75b4e839ab0b0a4051c4d18a32870c06c56cd0e23220c0ef496ec6084" +} diff --git a/.sqlx/query-358f243683ea759a066982024b15935c547aac60d82a4e5e2961dce893783853.json b/.sqlx/query-358f243683ea759a066982024b15935c547aac60d82a4e5e2961dce893783853.json new file mode 100644 index 0000000..85cb261 --- /dev/null +++ b/.sqlx/query-358f243683ea759a066982024b15935c547aac60d82a4e5e2961dce893783853.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, username FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "358f243683ea759a066982024b15935c547aac60d82a4e5e2961dce893783853" +} diff --git a/.sqlx/query-5f047dcf55c9a5b707f1259f1581c76c9559c708ccef0fa4c0aeaf2c47ef9181.json b/.sqlx/query-5f047dcf55c9a5b707f1259f1581c76c9559c708ccef0fa4c0aeaf2c47ef9181.json new file mode 100644 index 0000000..6f32397 --- /dev/null +++ b/.sqlx/query-5f047dcf55c9a5b707f1259f1581c76c9559c708ccef0fa4c0aeaf2c47ef9181.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(sum(bytes)::bigint, 0) AS \"bytes!\"\n FROM upload_log\n WHERE ip = $1 AND created_at > now() - interval '24 hours'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bytes!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Inet" + ] + }, + "nullable": [ + null + ] + }, + "hash": "5f047dcf55c9a5b707f1259f1581c76c9559c708ccef0fa4c0aeaf2c47ef9181" +} diff --git a/.sqlx/query-65dd4110495521d9eb2caf72e2d98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5.json b/.sqlx/query-65dd4110495521d9eb2caf72e2d98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5.json new file mode 100644 index 0000000..874e24e --- /dev/null +++ b/.sqlx/query-65dd4110495521d9eb2caf72e2d98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO user_project_relations (user_id, project_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "65dd4110495521d9eb2caf72e2d98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5" +} diff --git a/.sqlx/query-85e38e91113f7666b02d1dc121e425b56cff848cb7a7f16edb414a18667395f8.json b/.sqlx/query-85e38e91113f7666b02d1dc121e425b56cff848cb7a7f16edb414a18667395f8.json new file mode 100644 index 0000000..1ac88a8 --- /dev/null +++ b/.sqlx/query-85e38e91113f7666b02d1dc121e425b56cff848cb7a7f16edb414a18667395f8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT p.id FROM projects p\n JOIN user_project_relations upr ON p.id = upr.project_id\n WHERE upr.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "85e38e91113f7666b02d1dc121e425b56cff848cb7a7f16edb414a18667395f8" +} diff --git a/.sqlx/query-87d7f9d2b7de696a5432ffb7e57b5bf8833d458b586879eaa20119747fd5cd00.json b/.sqlx/query-87d7f9d2b7de696a5432ffb7e57b5bf8833d458b586879eaa20119747fd5cd00.json new file mode 100644 index 0000000..11b1563 --- /dev/null +++ b/.sqlx/query-87d7f9d2b7de696a5432ffb7e57b5bf8833d458b586879eaa20119747fd5cd00.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE projects SET name = COALESCE($1, name) WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "87d7f9d2b7de696a5432ffb7e57b5bf8833d458b586879eaa20119747fd5cd00" +} diff --git a/.sqlx/query-92ec08f24e47a2d182d281f8b7667d0901584436223d3b8ed07f6853a17741d3.json b/.sqlx/query-92ec08f24e47a2d182d281f8b7667d0901584436223d3b8ed07f6853a17741d3.json new file mode 100644 index 0000000..c2021ee --- /dev/null +++ b/.sqlx/query-92ec08f24e47a2d182d281f8b7667d0901584436223d3b8ed07f6853a17741d3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT user_id FROM user_project_relations WHERE user_id = $1 AND project_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "92ec08f24e47a2d182d281f8b7667d0901584436223d3b8ed07f6853a17741d3" +} diff --git a/.sqlx/query-93b244aa1404ee7eb242b75c8a4d11548e1c1f14752866c5a24c3a4972586c9f.json b/.sqlx/query-93b244aa1404ee7eb242b75c8a4d11548e1c1f14752866c5a24c3a4972586c9f.json new file mode 100644 index 0000000..7fc84ff --- /dev/null +++ b/.sqlx/query-93b244aa1404ee7eb242b75c8a4d11548e1c1f14752866c5a24c3a4972586c9f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO projects (name) VALUES ($1) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "93b244aa1404ee7eb242b75c8a4d11548e1c1f14752866c5a24c3a4972586c9f" +} diff --git a/.sqlx/query-99d2006aa414a12d47a69503dde362d088349503320b7a8f351ef973ca9ed411.json b/.sqlx/query-99d2006aa414a12d47a69503dde362d088349503320b7a8f351ef973ca9ed411.json new file mode 100644 index 0000000..37a1f9f --- /dev/null +++ b/.sqlx/query-99d2006aa414a12d47a69503dde362d088349503320b7a8f351ef973ca9ed411.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO projects DEFAULT VALUES RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "99d2006aa414a12d47a69503dde362d088349503320b7a8f351ef973ca9ed411" +} diff --git a/.sqlx/query-a78911e7c75633213cc82367bdf4450c60586632ab90b036788f18bfa5744f08.json b/.sqlx/query-a78911e7c75633213cc82367bdf4450c60586632ab90b036788f18bfa5744f08.json new file mode 100644 index 0000000..2ab36eb --- /dev/null +++ b/.sqlx/query-a78911e7c75633213cc82367bdf4450c60586632ab90b036788f18bfa5744f08.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(sum(octet_length(v.value))::bigint, 0) AS \"bytes!\"\n FROM variables v\n JOIN user_project_relations upr ON v.project_id = upr.project_id\n WHERE upr.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bytes!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a78911e7c75633213cc82367bdf4450c60586632ab90b036788f18bfa5744f08" +} diff --git a/.sqlx/query-b7e9108e3f5c604b0a0f9eb5b3d04c47edb161cdf805421f453592d6a8cc485f.json b/.sqlx/query-b7e9108e3f5c604b0a0f9eb5b3d04c47edb161cdf805421f453592d6a8cc485f.json new file mode 100644 index 0000000..f86c635 --- /dev/null +++ b/.sqlx/query-b7e9108e3f5c604b0a0f9eb5b3d04c47edb161cdf805421f453592d6a8cc485f.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, value, project_id, created_at \n FROM variables \n WHERE project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "b7e9108e3f5c604b0a0f9eb5b3d04c47edb161cdf805421f453592d6a8cc485f" +} diff --git a/.sqlx/query-ba22f4e90a2cc62a3254413b713fcc98e8f393d9d1ecc06dd311ee751e2d94e9.json b/.sqlx/query-ba22f4e90a2cc62a3254413b713fcc98e8f393d9d1ecc06dd311ee751e2d94e9.json new file mode 100644 index 0000000..9959bed --- /dev/null +++ b/.sqlx/query-ba22f4e90a2cc62a3254413b713fcc98e8f393d9d1ecc06dd311ee751e2d94e9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT public_key FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "public_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ba22f4e90a2cc62a3254413b713fcc98e8f393d9d1ecc06dd311ee751e2d94e9" +} diff --git a/.sqlx/query-cb65398a8ab2b4ac27a0e5a80514c4bf2fd3415dcc770ae2f367fed903404620.json b/.sqlx/query-cb65398a8ab2b4ac27a0e5a80514c4bf2fd3415dcc770ae2f367fed903404620.json new file mode 100644 index 0000000..a099f54 --- /dev/null +++ b/.sqlx/query-cb65398a8ab2b4ac27a0e5a80514c4bf2fd3415dcc770ae2f367fed903404620.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT p.id, p.name FROM projects p\n JOIN user_project_relations upr ON p.id = upr.project_id\n WHERE upr.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "cb65398a8ab2b4ac27a0e5a80514c4bf2fd3415dcc770ae2f367fed903404620" +} diff --git a/.sqlx/query-cb8644ac85832a0f697c0160dad5833a8a708f9918c5681ce292836cb2c8a747.json b/.sqlx/query-cb8644ac85832a0f697c0160dad5833a8a708f9918c5681ce292836cb2c8a747.json new file mode 100644 index 0000000..b508308 --- /dev/null +++ b/.sqlx/query-cb8644ac85832a0f697c0160dad5833a8a708f9918c5681ce292836cb2c8a747.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name FROM projects WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cb8644ac85832a0f697c0160dad5833a8a708f9918c5681ce292836cb2c8a747" +} diff --git a/.sqlx/query-d8cdfe44f5fc57f076ffa05badfc7adfb2906596571bf2bcbaa13984c3dc3f1b.json b/.sqlx/query-d8cdfe44f5fc57f076ffa05badfc7adfb2906596571bf2bcbaa13984c3dc3f1b.json new file mode 100644 index 0000000..e68a066 --- /dev/null +++ b/.sqlx/query-d8cdfe44f5fc57f076ffa05badfc7adfb2906596571bf2bcbaa13984c3dc3f1b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.id, v.value, v.project_id, v.created_at\n FROM users u\n JOIN user_project_relations upr ON u.id = upr.user_id\n JOIN variables v ON upr.project_id = v.project_id\n WHERE u.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "d8cdfe44f5fc57f076ffa05badfc7adfb2906596571bf2bcbaa13984c3dc3f1b" +} diff --git a/.sqlx/query-e01cf6176b1d89cd6bdf6f6f97fec2dc395ab918830337ce33953d8727240f81.json b/.sqlx/query-e01cf6176b1d89cd6bdf6f6f97fec2dc395ab918830337ce33953d8727240f81.json new file mode 100644 index 0000000..a2ab144 --- /dev/null +++ b/.sqlx/query-e01cf6176b1d89cd6bdf6f6f97fec2dc395ab918830337ce33953d8727240f81.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n COALESCE(sum(CASE WHEN id = ANY($2::uuid[])\n THEN octet_length(value) ELSE 0 END)::bigint, 0) AS \"replaced!\",\n COALESCE(sum(octet_length(value))::bigint, 0) AS \"total!\"\n FROM variables WHERE project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "replaced!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "total!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "UuidArray" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "e01cf6176b1d89cd6bdf6f6f97fec2dc395ab918830337ce33953d8727240f81" +} diff --git a/.sqlx/query-e5067fb159182957df05a39da468b5aa78b6f90a640841756ee697201cfd04a9.json b/.sqlx/query-e5067fb159182957df05a39da468b5aa78b6f90a640841756ee697201cfd04a9.json new file mode 100644 index 0000000..65920d5 --- /dev/null +++ b/.sqlx/query-e5067fb159182957df05a39da468b5aa78b6f90a640841756ee697201cfd04a9.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT u.id, u.username, u.created_at, u.public_key\n FROM users u\n JOIN user_project_relations upr ON u.id = upr.user_id\n WHERE upr.project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "public_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "e5067fb159182957df05a39da468b5aa78b6f90a640841756ee697201cfd04a9" +} diff --git a/.sqlx/query-ed86ed45d43258e0457084c495840a2537c040667905b583c968e0ef6580dca9.json b/.sqlx/query-ed86ed45d43258e0457084c495840a2537c040667905b583c968e0ef6580dca9.json new file mode 100644 index 0000000..30ad8b4 --- /dev/null +++ b/.sqlx/query-ed86ed45d43258e0457084c495840a2537c040667905b583c968e0ef6580dca9.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n count(*) AS \"count!\",\n COALESCE(sum(octet_length(value))::bigint, 0) AS \"bytes!\"\n FROM variables WHERE project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "bytes!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "ed86ed45d43258e0457084c495840a2537c040667905b583c968e0ef6580dca9" +} diff --git a/.sqlx/query-f40785e5dcd70ab6666252a31d5f6c5bd0c2221dd77f6054883f489ae5f6cdf5.json b/.sqlx/query-f40785e5dcd70ab6666252a31d5f6c5bd0c2221dd77f6054883f489ae5f6cdf5.json new file mode 100644 index 0000000..313fb23 --- /dev/null +++ b/.sqlx/query-f40785e5dcd70ab6666252a31d5f6c5bd0c2221dd77f6054883f489ae5f6cdf5.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, value, project_id, created_at FROM variables WHERE project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f40785e5dcd70ab6666252a31d5f6c5bd0c2221dd77f6054883f489ae5f6cdf5" +} diff --git a/.sqlx/query-f9ee96f7b8a985d46d03de1af0f2b17f1ec44dc13783b989747986ea90bcf770.json b/.sqlx/query-f9ee96f7b8a985d46d03de1af0f2b17f1ec44dc13783b989747986ea90bcf770.json new file mode 100644 index 0000000..1ca32ee --- /dev/null +++ b/.sqlx/query-f9ee96f7b8a985d46d03de1af0f2b17f1ec44dc13783b989747986ea90bcf770.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, value, project_id FROM variables WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "f9ee96f7b8a985d46d03de1af0f2b17f1ec44dc13783b989747986ea90bcf770" +} diff --git a/.sqlx/query-fd14fd3094f6c4b1a06e90157c9e380465575f97e178e700f6872b5272d2ce33.json b/.sqlx/query-fd14fd3094f6c4b1a06e90157c9e380465575f97e178e700f6872b5272d2ce33.json new file mode 100644 index 0000000..0237f79 --- /dev/null +++ b/.sqlx/query-fd14fd3094f6c4b1a06e90157c9e380465575f97e178e700f6872b5272d2ce33.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, public_key FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "public_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "fd14fd3094f6c4b1a06e90157c9e380465575f97e178e700f6872b5272d2ce33" +} diff --git a/.sqlx/query-fd25c7ae11e0f4ed4c9144521a76820b0b91b121cfa97168f1678ab14dffc1ed.json b/.sqlx/query-fd25c7ae11e0f4ed4c9144521a76820b0b91b121cfa97168f1678ab14dffc1ed.json new file mode 100644 index 0000000..e09ba76 --- /dev/null +++ b/.sqlx/query-fd25c7ae11e0f4ed4c9144521a76820b0b91b121cfa97168f1678ab14dffc1ed.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO upload_log (ip, bytes) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Inet", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "fd25c7ae11e0f4ed4c9144521a76820b0b91b121cfa97168f1678ab14dffc1ed" +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3369f29 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3738 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "bytes", + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", + "zeroize", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "axum" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "backtrace" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide 0.7.4", + "object", + "rustc-demangle", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitfields" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f7b84260234ecc8ba5a13cac862569913ee84281f108e6520121de0d50ac4db" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e80ed89419086de767177cc00f0a9af47c9b34646c7c6d2203cd04b2a15c672a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "thiserror", +] + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "buffer-redux" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8acf87c5b9f5897cd3ebb9a327f420e0cae9dd4e5c1d2e36f2c84c571a58f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57b6a275aa2903740dc87da01c62040406b8812552e97129a63ea8850a17c6e6" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.6", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest", +] + +[[package]] +name = "commoncrypto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d056a8586ba25a1e4d61cb090900e495952c7886786fc55f909ab2f819b69007" +dependencies = [ + "commoncrypto-sys", +] + +[[package]] +name = "commoncrypto-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fed34f46747aa73dfaa578069fd8279d2818ade2b55f38f22a9401c7f4083e2" +dependencies = [ + "libc", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-hash" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a77162240fd97248d19a564a565eb563a3f592b386e4136fb300909e67dddca" +dependencies = [ + "commoncrypto", + "hex 0.3.2", + "openssl", + "winapi", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "pkcs8", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2", + "signature", + "zeroize", +] + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead", + "cipher", + "cmac", + "ctr", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect 0.2.0", + "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "libz-rs-sys", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.2", +] + +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805026a5d0141ffc30abb3be3173848ad46a1b1664fe632428479619a3644d77" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower 0.4.13", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +dependencies = [ + "equivalent", + "hashbrown 0.14.5", + "serde", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-rs-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "object" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +dependencies = [ + "memchr", +] + +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pgp" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d918d5da2ce943e4c6088d7694f33f47c19374d6f0f2080a0c5e8010afdfd29" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "aes-kw", + "argon2", + "base64 0.22.1", + "bitfields", + "block-padding", + "blowfish", + "buffer-redux", + "byteorder", + "bytes", + "bzip2", + "camellia", + "cast5", + "cfb-mode", + "chrono", + "cipher", + "const-oid", + "crc24", + "curve25519-dalek", + "cx448", + "derive_builder", + "derive_more", + "des", + "digest", + "dsa", + "eax", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "flate2", + "generic-array", + "hex 0.4.3", + "hkdf", + "idea", + "k256", + "log", + "md-5", + "nom", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.8.5", + "regex", + "replace_with", + "ripemd", + "rsa", + "sha1", + "sha1-checked", + "sha2", + "sha3", + "signature", + "smallvec", + "snafu", + "twofish", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy 0.7.35", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.0", + "zerocopy 0.8.17", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.0", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff" +dependencies = [ + "getrandom 0.3.1", + "zerocopy 0.8.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "rsa" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a85d50532239da68e9addb745ba38ff4612a242c1c7ceea689c4bc7c2f43c36f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + +[[package]] +name = "rusty-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "axum-extra", + "base64 0.22.1", + "chrono", + "crypto-hash", + "dotenv", + "hex 0.4.3", + "pgp", + "rand 0.8.5", + "rand 0.9.0", + "serde", + "serde_json", + "smallvec", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "utoipa-axum", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.209" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.209" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", + "zeroize", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +dependencies = [ + "serde", +] + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" +dependencies = [ + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.2", + "hashlink", + "indexmap", + "ipnetwork", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex 0.4.3", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex 0.4.3", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex 0.4.3", + "hkdf", + "hmac", + "home", + "ipnetwork", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ea75f83c0137a9b98608359a5f1af8144876eb67bcb1ce837368e906a9f524" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utoipa" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161166ec520c50144922a625d8bc4925cc801b2dda958ab69878527c0e5c5d61" +dependencies = [ + "axum", + "base64 0.22.1", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + +[[package]] +name = "uuid" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0" +dependencies = [ + "getrandom 0.3.1", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" + +[[package]] +name = "webpki-roots" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +dependencies = [ + "redox_syscall", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa91407dacce3a68c56de03abe2760159582b846c6a4acd2f456618087f12713" +dependencies = [ + "zerocopy-derive 0.8.17", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06718a168365cad3d5ff0bb133aad346959a2074bd4a85c121255a11304a8626" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 50720dd..6bc4def 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,3 +47,6 @@ utoipa-axum = "0.2.0" tracing = "^0.1" tracing-subscriber = "^0.3" tower-http = { version = "0.6", features = ["trace"] } + +[dev-dependencies] +rand_08 = { package = "rand", version = "0.8" } diff --git a/Dockerfile b/Dockerfile index 80138c8..310a48a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,22 @@ -FROM rust:1-bookworm AS template +FROM rust:1.98.1-bookworm AS builder -ARG DATABASE_URL - -RUN apt-get update && apt-get install -y \ - libssl-dev \ - pkg-config \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl-dev pkg-config \ && rm -rf /var/lib/apt/lists/* -FROM template AS migrator - -WORKDIR /app - -RUN cargo install sqlx-cli - -COPY ./migrations /app/migrations - -RUN cargo sqlx migrate run - -FROM template AS builder - WORKDIR /app - COPY . . - -RUN cargo build --release - -FROM template AS runner - +# Query metadata is generated against a disposable database and checked in. +# Building an image must never connect to, or migrate, a production database. +ENV SQLX_OFFLINE=true +RUN cargo build --release --locked + +FROM debian:bookworm-slim AS runner +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --no-create-home envx WORKDIR /app - COPY --from=builder /app/target/release/rusty-api /app/rusty-api - -CMD [ "/app/rusty-api" ] +USER envx +CMD ["/app/rusty-api"] diff --git a/README.md b/README.md index fd80a84..752fd40 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,21 @@ ```bash make generate ``` + +### Reverse proxy trust + +By default rate limits use the connection's peer IP and ignore forwarded headers. +Set `ENVX_TRUST_PROXY=true` only when all incoming requests pass through a trusted +proxy that overwrites `X-Real-IP`, and untrusted callers cannot connect directly. +This uses a single valid `X-Real-IP`; `X-Forwarded-For` is never trusted. Railway's +HTTP ingress provides `X-Real-IP` (see its public networking specs). Self-hosted +operators must configure their ingress accordingly before enabling this option. + +Railway deployment checklist: + +- Route public requests through Railway HTTP ingress and prevent untrusted direct access. +- Set `ENVX_TRUST_PROXY=true` on the API service before deploying. +- Verify the ingress supplies one valid `X-Real-IP`; missing or malformed values fall back to the peer IP. +- Keep this flag unset for direct connections or proxies that do not overwrite client-supplied `X-Real-IP`. + +Railway documents its client IP header in the [public networking specifications](https://docs.railway.com/networking/public-networking/specs-and-limits). diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..791d192 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,52 @@ +# Building and upgrading the API + +The Docker image builds with `SQLX_OFFLINE=true` and a committed lockfile. +It never connects to the deployment database during the build. Query metadata +in `.sqlx/` must be regenerated against a disposable PostgreSQL database after +query or schema changes (`cargo sqlx migrate run`, then `cargo sqlx prepare -- --all-targets`). +Do not use production credentials for these commands. + +At startup the API establishes a database connection and runs embedded migrations +before accepting HTTP requests. SQLx serializes concurrent migration runners with +its PostgreSQL advisory lock and applies each migration transactionally. Startup +fails if a migration fails or a recorded checksum differs. Railway's health check +only succeeds after initialization has completed. + +## Legacy invitation compatibility + +Published migration `20251025060537` adds a required verifier without a backfill. +It works on an empty invitation table but fails on populated older installations. +Its original SQL and checksum remain unchanged in the migrations directory. + +The startup migrator substitutes `migration-compat/20251025060537_invites_v2.sql` +only when that version is pending. SQLx still validates the original published +checksum for already-applied versions. The replay preserves invitation rows, +expires them, gives them an unusable verifier, and otherwise produces the original +migration's schema. Old signatures cannot be converted into the new verifiers; +users must create new invitations. As in the original migration, the obsolete +`author_signature` column is removed. + +The replay records version, operation, affected-row count, and timestamp in +`envx_migration_compatibility` in the same transaction. Its recorded SQLx checksum +is deliberately the published migration checksum, so databases upgraded by either +path have compatible migration histories. Existing upgraded databases do not +replay this operation. This is a narrowly scoped historical compatibility shim, +not permission to suppress future checksum mismatches. + +For an old populated database, start the API to upgrade; do not run plain +`cargo sqlx migrate run`, which intentionally executes the unmodified historical +migration. Fresh disposable databases can use the standard SQLx CLI for metadata +generation and test setup. + +## OpenAPI export + +Normal startup does not write files and supports a read-only container filesystem. +The schema remains available over HTTP at `/docs/openapi.json`. For SDK generation, +start a local API against a disposable database with an explicit output path: + +```sh +ENVX_OPENAPI_OUTPUT=./openapi.json cargo run --locked +``` + +Alternatively, fetch `/docs/openapi.json` from that running local API. An explicitly +requested export fails startup if its destination cannot be written. diff --git a/docs/project-invite-protocol.md b/docs/project-invite-protocol.md new file mode 100644 index 0000000..05f3dc7 --- /dev/null +++ b/docs/project-invite-protocol.md @@ -0,0 +1,24 @@ +# Version 1 project invitations + +`POST /v2/project/{id}/snapshot` returns all encrypted rows, members, and a SHA-256 snapshot token. Optional `add_user_ids` includes the proposed recipients' public keys in that token. Rows and users are sorted before hashing. The token covers project ID, variable IDs/content/metadata, current membership, and the complete recipient set. + +`POST /v2/invite/new` requires `protocol_version: 1`, the snapshot token, project ID, and a client-encrypted invitation payload. It saves the token only if the snapshot is still current. `POST /v2/invite/prepare` checks the verifier, expiry, unclaimed status, author's membership, and current source snapshot, then returns the payload and a token that also binds the invitee's key. Preparation does not claim the invite or grant membership. + +`POST /v2/invite/accept` requires protocol version, code, verifier, the prepared token, and every `{id, value}` rewrapped row exactly once. In one transaction it checks the source and prepared snapshots, updates all values, claims the invitation, clears its payload, and adds membership. Any failure rolls everything back. Empty projects are valid. `POST /v2/project/{id}/rewrap` provides the same conditional complete rewrap and membership transaction for direct add-users. + +All membership and variable writers acquire the project row lock before variable/membership locks. Cross-project update batches acquire sorted project IDs first. Creating invitations also serializes each author's quota after acquiring the project lock. Payload limits are 1 MiB each, 32 active invitations per author, and 16 MiB active ciphertext per author. Invites expire after one hour; new invitations clear that author's expired payloads. + +Legacy create/accept/add-user requests fail with `invite_upgrade_required`; existing memberships remain intact. Old invitations must be regenerated. Stale snapshots return HTTP 409 with code `project_snapshot_stale`. Flat membership permissions are unchanged: every current member can write and manage membership. Server-side validation treats rewrapped ciphertext as opaque, as ordinary authorized writes already do. + +## Remaining ordinary-write concurrency contract + +Ordinary variable endpoints retain their existing API and do not require a recipient snapshot. This patch protects invitation/add-users rewraps, not all future writes. An exact reproduction of the remaining limitation is: + +1. Existing member A fetches a variable and the project's public keys, and encrypts a replacement for the current recipients. +2. Before A submits it, B successfully accepts a version 1 invitation. The server atomically rewraps the variables and adds B. +3. A submits the previously prepared replacement through `/variables/update-many` or `/v2/variables/update-many` with the same variable/project IDs. +4. The existing API accepts A's authorized write. The replacement lacks B's recipient key even though B is now a member. + +A complete remedy requires snapshot preconditions on every variable write plus migration of ordinary clients; rejecting older variable clients would change the compatibility contract. The invitation transaction does not claim to prevent later authorized writes from replacing its result. The same broader issue affects separately prepared removal rewraps. + +Regression coverage lives in `src/routes/v2/invite/accept/tests.rs` and `src/routes/v2/project/snapshot.rs`. It covers expired/legacy/removed-author failures, changed/added/deleted/replaced rows, membership changes, incomplete/duplicate/failed rewraps, empty projects, concurrent redemption, waiting for a project writer, stale create, storage quotas, expiry cleanup, and conditional direct add-users. Run with a disposable `DATABASE_URL` via `cargo test`. diff --git a/docs/social-api.md b/docs/social-api.md new file mode 100644 index 0000000..ee7caba --- /dev/null +++ b/docs/social-api.md @@ -0,0 +1,92 @@ +# Friends and encrypted handoffs + +All routes are authenticated under `/v2`. Stable UUIDs identify users; usernames +are display text, not unique addresses. Public-key fingerprints are lowercase hex. +The server treats message bodies as opaque ciphertext. Clients must sign and +encrypt an envelope binding the message ID, both identities and fingerprints, +expiry, and payload, and verify the envelope before displaying or importing. + +## Friends and links + +- `GET /friends`: `{user: Identity, created_at, receipts: string[]}[]`. +- `DELETE /friends/{user_id}`: remove the mutual relationship, returning 204. + Existing mailbox copies remain accessible. New sends are forbidden. +- `POST /friend-links`: `{label, target_id?, expires_at?}` returns + `{link: Link, token: string}`. The 256-bit hex token is returned only here; only + its SHA-256 digest is stored. Label is 1–64 lowercase ASCII letters/hyphens and + has no security meaning. Expiry defaults to 24 hours, maximum 30 days. +- `GET /friend-links`: creator-only history, `Link[]`, including redeemed-by + identity and signed receipt. This endpoint never returns the token or hash. +- `DELETE /friend-links/{id}`: creator-only revocation of an unused link, 204. +- `POST /friend-links/preview`: `{id, token}` returns `{link, creator: Identity}` + without consuming the link. Wrong targets and bad tokens return 404. +- `POST /friend-links/redeem`: `{id, token, receipt}` returns the same shape as + preview. `receipt` is an uncompressed armored OpenPGP signed message containing + this JSON object (field order is irrelevant): + +```json +{"version":1,"id":"link UUID","creator_id":"UUID","creator_fingerprint":"hex","redeemer_id":"UUID","redeemer_fingerprint":"hex"} +``` + +The server verifies the receipt using the redeemer's registered key and compares +all fields before committing friendship and consumption in one transaction. +Self-friending is rejected. Existing friendships make redemption a no-op for the +relationship but still consume the link. Concurrent redeemers produce one winner. +The original redeemer can recover a response after expiry; recovery does not +restore a friendship subsequently removed. Clients must verify receipts before +pinning a creator's new friend. First-use identity still depends on the link's +out-of-band fingerprint and the authenticated server directory. + +`Identity` is `{id, username, public_key, fingerprint}`. +`Link` is `{id, creator_id, target_id, label, created_at, expires_at, revoked_at, +redeemed_at, redeemed_by, receipt}`; optional fields are JSON null. `redeemed_by` +is an `Identity`. Timestamps are RFC3339. + +## Messages + +- `POST /messages`: `{id, recipient_id, ciphertext, expires_at?}`. `id` is a + client-generated durable UUID. An identical retry returns the original + metadata, including after deletion or expiry; it never restores ciphertext. + Reusing the ID with different content, recipient, or expiry returns 409. +- `GET /messages`: both incoming and outgoing visible messages, metadata only. +- `GET /messages/{id}`: participant-only metadata plus ciphertext. +- `DELETE /messages/{id}`: delete the caller's mailbox copy, 204. Once both copies + are deleted, ciphertext and public-key snapshots are erased; minimal retry state remains. + +Message fields: `{id, sender_id, recipient_id, created_at, expires_at, +sender_public_key, recipient_public_key, ciphertext}`. Send and list responses +set ciphertext to null and public-key fields to empty strings. Full keys are +returned only by the message detail endpoint. Keys are verified and canonically +armored at send time, discarding arbitrary armor headers. Legacy stored keys +larger than 1 MiB and canonical keys larger than 128 KiB are rejected when +sending; this intentionally bounds previously uncapped registrations. Never trust a server +snapshot in place of a locally pinned fingerprint. + +Expiry makes both copies inaccessible immediately. A bounded cleanup during +subsequent sends erases up to 1,000 expired ciphertexts and their key snapshots. This is request-driven +cleanup, not a promise of physical erasure at the expiry instant; backups may +also retain ciphertext. Database maintenance can clear the remaining expired +ciphertext independently. The message ID and digest remain tombstones so retries +cannot resurrect messages. + +All three lists accept `limit` (1–100, default 50) and `before` (UUID). Messages and links sort +newest first, with UUID descending breaking creation-time ties. Pass the last +item's ID as the next cursor; cursors must belong to the caller. The friends list +sorts by friend UUID descending and uses the friend user ID as its cursor. + +## Limits and concurrency + +- 100 active links and 100 new links per account per UTC day. +- 1,000 preview/redemption attempts per account per UTC day, including failures. +- 1,000 mutual friends per account. +- 128 KiB ciphertext per message; 1,000 sends per account per UTC day. +- 1,000 live messages and 20 MiB of ciphertext plus both canonical key snapshots + per participant's mailbox. + +Daily counters are independent of message deletion. User rows are locked in UUID +order before friendship mutations or quota-sensitive writes. PostgreSQL tests +cover claim races, retry recovery, target/signature/token rejection, revocation, +expiry, authorization, deletion, pagination, send-rate and mailbox-capacity races, creation-time pagination ties, unrelated cursors, and +legacy padded-key canonicalization/accounting/cleanup. +Run them only against a disposable PostgreSQL instance; `sqlx::test` creates +isolated test databases and applies migrations automatically. diff --git a/migration-compat/20251025060537_invites_v2.sql b/migration-compat/20251025060537_invites_v2.sql new file mode 100644 index 0000000..4fe7e14 --- /dev/null +++ b/migration-compat/20251025060537_invites_v2.sql @@ -0,0 +1,27 @@ +-- Compatibility replay for the original pending migration, which cannot add a +-- NOT NULL verifier to a populated legacy table. Legacy signatures cannot be +-- converted into Argon2 verifiers, so retain those invitations as expired rows. +ALTER TABLE project_invites + ADD COLUMN verifier_argon2id TEXT NOT NULL DEFAULT 'legacy-invite-unusable'; + +CREATE TABLE IF NOT EXISTS envx_migration_compatibility ( + migration_version BIGINT PRIMARY KEY, + operation TEXT NOT NULL, + affected_rows BIGINT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +WITH expired AS ( + UPDATE project_invites + SET expires_at = LEAST(expires_at, CURRENT_TIMESTAMP) + RETURNING id +) +INSERT INTO envx_migration_compatibility (migration_version, operation, affected_rows) +SELECT 20251025060537, 'retain-and-expire-legacy-invites-v1', COUNT(*) FROM expired; + +ALTER TABLE project_invites + ALTER COLUMN verifier_argon2id DROP DEFAULT; +ALTER TABLE project_invites + ADD COLUMN ciphertext TEXT; +ALTER TABLE project_invites + DROP COLUMN author_signature; diff --git a/migrations/20260924120000_friends_messages.sql b/migrations/20260924120000_friends_messages.sql new file mode 100644 index 0000000..b771513 --- /dev/null +++ b/migrations/20260924120000_friends_messages.sql @@ -0,0 +1,51 @@ +-- Canonical pairs prevent reverse duplicates; all mutations lock users in UUID order. +CREATE TABLE friendships ( + user_low uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_high uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_low, user_high), + CHECK (user_low < user_high) +); +CREATE TABLE friend_links ( + id uuid PRIMARY KEY, + creator_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_id uuid REFERENCES users(id) ON DELETE CASCADE, + label text NOT NULL CHECK (octet_length(label) BETWEEN 1 AND 64), + token_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + redeemed_at timestamptz, + redeemed_by uuid REFERENCES users(id) ON DELETE CASCADE, + receipt text, + CHECK (target_id IS NULL OR target_id <> creator_id), + CHECK ((redeemed_by IS NULL) = (redeemed_at IS NULL)), + CHECK ((redeemed_by IS NULL) = (receipt IS NULL)) +); +CREATE INDEX friend_links_creator ON friend_links(creator_id, created_at DESC); +CREATE TABLE secret_messages ( + id uuid PRIMARY KEY, + sender_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipient_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ciphertext text, + ciphertext_hash text NOT NULL, + sender_public_key text NOT NULL, + recipient_public_key text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz, + sender_deleted boolean NOT NULL DEFAULT false, + recipient_deleted boolean NOT NULL DEFAULT false, + CHECK(sender_id <> recipient_id), + CHECK(ciphertext IS NULL OR octet_length(ciphertext) BETWEEN 1 AND 131072) +); +CREATE INDEX secret_messages_expiry ON secret_messages(expires_at) WHERE ciphertext IS NOT NULL AND expires_at IS NOT NULL; +CREATE INDEX secret_messages_sender ON secret_messages(sender_id, created_at DESC); +CREATE INDEX secret_messages_recipient ON secret_messages(recipient_id, created_at DESC); +-- Bounded daily counters survive message deletion and failed link claims. +CREATE TABLE social_daily_usage ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + day date NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')::date, + kind text NOT NULL, + count bigint NOT NULL, + PRIMARY KEY (user_id, day, kind) +); diff --git a/migrations/20260924130000_project_invite_snapshots.sql b/migrations/20260924130000_project_invite_snapshots.sql new file mode 100644 index 0000000..80818af --- /dev/null +++ b/migrations/20260924130000_project_invite_snapshots.sql @@ -0,0 +1,5 @@ +-- Existing invitations have no verifiable source snapshot and must be regenerated. +-- Preserve their ciphertext and all existing memberships for explicit safe failure. +ALTER TABLE project_invites ADD COLUMN snapshot_hash TEXT; +CREATE INDEX project_invites_author_live_payload ON project_invites(author_id, expires_at) +WHERE ciphertext IS NOT NULL; diff --git a/railway.json b/railway.json index 71db952..c8b8ac5 100644 --- a/railway.json +++ b/railway.json @@ -2,5 +2,9 @@ "$schema": "https://schema.up.railway.app/railway.schema.json", "build": { "builder": "DOCKERFILE" + }, + "deploy": { + "healthcheckPath": "/.well-known/health-check", + "healthcheckTimeout": 120 } } diff --git a/src/config.rs b/src/config.rs index b0de36f..ee8febf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,8 +43,24 @@ fn env_u64(name: &str, default: u64) -> u64 { } fn env_i64(name: &str, default: i64) -> i64 { - env::var(name) - .ok() - .and_then(|v| v.parse().ok()) + nonnegative_cap(env::var(name).ok().as_deref(), default) +} + +fn nonnegative_cap(value: Option<&str>, default: i64) -> i64 { + value + .and_then(|v| v.parse::().ok()) + .filter(|v| *v >= 0) .unwrap_or(default) } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn negative_or_invalid_caps_do_not_disable_limits() { + assert_eq!(nonnegative_cap(Some("-1"), 256), 256); + assert_eq!(nonnegative_cap(Some("invalid"), 256), 256); + assert_eq!(nonnegative_cap(Some("0"), 256), 0); + assert_eq!(nonnegative_cap(Some("42"), 256), 42); + } +} diff --git a/src/db.rs b/src/db.rs index 5db6739..66b22db 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,11 +1,157 @@ use anyhow::{Context, Result}; -use sqlx::postgres::PgPoolOptions; +use sqlx::{migrate::Migrator, postgres::PgPoolOptions, PgPool}; -pub async fn db() -> Result> { +pub async fn db() -> Result { let db_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; - - Ok(PgPoolOptions::new() + let pool = PgPoolOptions::new() .max_connections(5) .connect(&db_url) - .await?) + .await?; + + migrate(&pool).await?; + Ok(pool) +} + +fn startup_migrator() -> Migrator { + let mut migrator = sqlx::migrate!(); + // The published migration fails on populated legacy databases. Keep its + // checksum unchanged so already-upgraded databases still validate, but use + // a transactionally audited compatibility replay when it is still pending. + // SQLx continues to reject every mismatched applied checksum. + for migration in migrator.migrations.to_mut() { + if migration.version == 20251025060537 { + migration.sql = + include_str!("../migration-compat/20251025060537_invites_v2.sql").into(); + } + } + migrator +} + +async fn migrate(pool: &PgPool) -> Result<()> { + startup_migrator() + .run(pool) + .await + .context("database migrations failed") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::borrow::Cow; + use uuid::Uuid; + + async fn legacy_invite(pool: &PgPool) -> Uuid { + let mut legacy = sqlx::migrate!(); + legacy.migrations = Cow::Owned( + legacy + .iter() + .filter(|m| m.version < 20251025060537) + .cloned() + .collect(), + ); + legacy.run(pool).await.unwrap(); + let project: Uuid = sqlx::query_scalar("INSERT INTO projects DEFAULT VALUES RETURNING id") + .fetch_one(pool) + .await + .unwrap(); + let user: Uuid = sqlx::query_scalar( + "INSERT INTO users(username,public_key) VALUES('legacy','fixture') RETURNING id", + ) + .fetch_one(pool) + .await + .unwrap(); + sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,author_signature,expires_at) VALUES($1,$2,'legacy-signature',CURRENT_TIMESTAMP + INTERVAL '1 day') RETURNING id") + .bind(project).bind(user).fetch_one(pool).await.unwrap() + } + + #[sqlx::test(migrations = false)] + async fn startup_migrates_an_empty_database_and_is_repeatable(pool: PgPool) { + migrate(&pool).await.unwrap(); + migrate(&pool).await.unwrap(); + let table: Option = + sqlx::query_scalar("SELECT to_regclass('public.upload_log')::text") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(table.as_deref(), Some("upload_log")); + } + + #[sqlx::test(migrations = false)] + async fn startup_preserves_but_expires_legacy_invites(pool: PgPool) { + let id = legacy_invite(&pool).await; + migrate(&pool).await.unwrap(); + migrate(&pool).await.unwrap(); + let (expired, verifier): (bool, String) = sqlx::query_as("SELECT expires_at <= CURRENT_TIMESTAMP, verifier_argon2id FROM project_invites WHERE id=$1") + .bind(id).fetch_one(&pool).await.unwrap(); + assert!(expired); + assert_eq!(verifier, "legacy-invite-unusable"); + let affected: i64 = sqlx::query_scalar("SELECT affected_rows FROM envx_migration_compatibility WHERE migration_version=20251025060537") + .fetch_one(&pool).await.unwrap(); + assert_eq!(affected, 1); + let recorded: Vec = sqlx::query_scalar( + "SELECT checksum FROM _sqlx_migrations WHERE version=20251025060537", + ) + .fetch_one(&pool) + .await + .unwrap(); + let original = sqlx::migrate!(); + assert_eq!( + recorded, + original + .iter() + .find(|m| m.version == 20251025060537) + .unwrap() + .checksum + .as_ref() + ); + } + + #[sqlx::test] + async fn startup_accepts_original_migration_checksums(pool: PgPool) { + migrate(&pool).await.unwrap(); + let audit: Option = + sqlx::query_scalar("SELECT to_regclass('public.envx_migration_compatibility')::text") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + audit.is_none(), + "applied original migrations must not be replayed" + ); + } + + #[sqlx::test(migrations = false)] + async fn failed_compatibility_replay_rolls_back_rows_and_schema(pool: PgPool) { + let id = legacy_invite(&pool).await; + let mut broken = startup_migrator(); + let migration = broken + .migrations + .to_mut() + .iter_mut() + .find(|m| m.version == 20251025060537) + .unwrap(); + migration.sql = format!("{}\nSELECT 1 / 0;", migration.sql).into(); + assert!(broken.run(&pool).await.is_err()); + let (signature, valid): (String, bool) = sqlx::query_as("SELECT author_signature, expires_at > CURRENT_TIMESTAMP FROM project_invites WHERE id=$1") + .bind(id).fetch_one(&pool).await.unwrap(); + assert_eq!(signature, "legacy-signature"); + assert!(valid); + let applied: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version=20251025060537)", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!applied); + // Reopening after an interrupted transaction can safely finish migration. + migrate(&pool).await.unwrap(); + } + + #[sqlx::test] + async fn startup_rejects_changed_historical_checksums(pool: PgPool) { + sqlx::query("UPDATE _sqlx_migrations SET checksum = decode('00','hex') WHERE version=20251025060537") + .execute(&pool).await.unwrap(); + let error = migrate(&pool).await.unwrap_err(); + assert!(format!("{error:#}").contains("modified")); + } } diff --git a/src/error.rs b/src/error.rs index 54daac4..6deb539 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,6 +26,7 @@ pub enum AppError { AnyhowError(AnyhowError), Error(Errors), Generic(StatusCode, String), + Protocol(StatusCode, &'static str, &'static str), } impl From<(StatusCode, String)> for AppError { @@ -97,6 +98,11 @@ impl IntoResponse for AppError { Errors::NotFound => (StatusCode::NOT_FOUND, "Not found").into_response(), }, AppError::Generic(status_code, string) => (status_code, string).into_response(), + AppError::Protocol(status, code, message) => ( + status, + axum::Json(serde_json::json!({"code":code,"message":message})), + ) + .into_response(), } } } diff --git a/src/extractors/client_ip.rs b/src/extractors/client_ip.rs index f39164c..9553575 100644 --- a/src/extractors/client_ip.rs +++ b/src/extractors/client_ip.rs @@ -1,44 +1,55 @@ -//! Extract the client IP, honoring X-Forwarded-For when behind a proxy -//! (Railway sets this). Falls back to peer SocketAddr. - -use std::net::{IpAddr, Ipv4Addr}; - +//! Forwarding headers are trusted only when explicitly enabled by the operator. +use crate::error::AppError; use axum::{ extract::{ConnectInfo, FromRequestParts}, - http::request::Parts, + http::{request::Parts, HeaderMap}, }; - -use crate::error::AppError; +use std::net::{IpAddr, Ipv4Addr}; pub struct ClientIp(pub IpAddr); -impl FromRequestParts for ClientIp -where - S: Send + Sync, -{ - type Rejection = AppError; - - async fn from_request_parts(parts: &mut Parts, _s: &S) -> Result { - if let Some(value) = parts.headers.get("x-forwarded-for") { - if let Ok(s) = value.to_str() { - // X-Forwarded-For can be comma-separated; the leftmost entry - // is the original client. - if let Some(first) = s.split(',').next() { - if let Ok(ip) = first.trim().parse::() { - return Ok(ClientIp(ip)); - } - } - } - } - - if let Some(ConnectInfo(addr)) = parts.extensions.get::>() +fn client_ip(headers: &HeaderMap, peer: IpAddr, trust_proxy: bool) -> IpAddr { + if trust_proxy && headers.get_all("x-real-ip").iter().count() == 1 { + if let Some(ip) = headers + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) { - return Ok(ClientIp(addr.ip())); + return ip; } + } + peer +} + +impl FromRequestParts for ClientIp { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, _s: &S) -> Result { + let peer = parts + .extensions + .get::>() + .map(|addr| addr.0.ip()) + .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); + let trust_proxy = std::env::var("ENVX_TRUST_PROXY").as_deref() == Ok("true"); + Ok(ClientIp(client_ip(&parts.headers, peer, trust_proxy))) + } +} - // Fall back to localhost rather than 500 — IP-based rate limiting - // is best-effort; we don't want to break the request entirely if - // we can't identify the client. - Ok(ClientIp(IpAddr::V4(Ipv4Addr::LOCALHOST))) +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn forwarding_requires_explicit_trust_and_single_ip() { + let peer = "127.0.0.1".parse().unwrap(); + let remote: IpAddr = "192.0.2.1".parse().unwrap(); + let mut h = HeaderMap::new(); + h.insert("x-forwarded-for", "203.0.113.1, 192.0.2.1".parse().unwrap()); + assert_eq!(client_ip(&h, peer, true), peer); + h.insert("x-real-ip", "192.0.2.1".parse().unwrap()); + assert_eq!(client_ip(&h, peer, false), peer); + assert_eq!(client_ip(&h, peer, true), remote); + h.append("x-real-ip", "203.0.113.1".parse().unwrap()); + assert_eq!(client_ip(&h, peer, true), peer); + h.insert("x-real-ip", "192.0.2.1, 203.0.113.1".parse().unwrap()); + assert_eq!(client_ip(&h, peer, true), peer); } } diff --git a/src/extractors/user.rs b/src/extractors/user.rs index 70f83e5..ff53daf 100644 --- a/src/extractors/user.rs +++ b/src/extractors/user.rs @@ -44,7 +44,8 @@ where match auth_header { Some(auth_header) => { let auth_header = auth_header.to_str().unwrap_or(""); - let auth_token = auth_header.trim_start_matches("Bearer "); + let auth_token = + bearer_payload(auth_header).ok_or(AppError::Error(Errors::Unauthorized))?; let formatted_token = match serde_json::from_str::(auth_token) { Ok(formatted_token) => formatted_token, @@ -62,39 +63,10 @@ where let user_id = match validate_challenge(auth_token, state.db).await { Ok(user_id) => user_id, Err(e) => match e { - ChallengeError::InvalidChallenge => { - return Err((StatusCode::BAD_REQUEST, "Invalid challenge").into()) - } - ChallengeError::InvalidSignature => { - return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into()) - } - ChallengeError::TooOld => { - return Err((StatusCode::UNAUTHORIZED, "Too old").into()) - } - ChallengeError::TooYoung => { - return Err((StatusCode::UNAUTHORIZED, "Too young").into()) - } - ChallengeError::ChronoParseError(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::PgpError(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::SqlxError(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::Utf8Error(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::UuidError(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::Generic(e) => { - return Err(AppError::Error(Errors::InternalServerError(e))) - } - ChallengeError::IoError(error) => { - return Err(AppError::Error(Errors::InternalServerError(error.into()))) + ChallengeError::SqlxError(e) if !matches!(e, sqlx::Error::RowNotFound) => { + return Err(AppError::Error(Errors::SqlxError(e))); } + _ => return Err((StatusCode::UNAUTHORIZED, "Invalid credentials").into()), }, }; @@ -105,6 +77,7 @@ where } } +#[allow(dead_code)] enum ChallengeError { InvalidChallenge, InvalidSignature, @@ -176,10 +149,10 @@ async fn validate_challenge(challenge: &str, db: DB) -> Result 10 * 60 { - return Err(ChallengeError::TooOld)?; + return Err(ChallengeError::TooOld); } if diff.num_seconds() < 0 { - return Err(ChallengeError::TooYoung)?; + return Err(ChallengeError::TooYoung); } let user_pubkey = sqlx::query!("SELECT public_key FROM users WHERE id = $1", user_id) @@ -192,7 +165,7 @@ async fn validate_challenge(challenge: &str, db: DB) -> Result Option<&str> { + let payload = value.strip_prefix("Bearer ")?; + Some(payload.strip_prefix("Bearer ").unwrap_or(payload)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn malformed_credentials_return_unauthorized_without_database_access() { + use axum::{http::Request, response::IntoResponse}; + use std::sync::Arc; + let state = AppState { + db: Arc::new( + sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://invalid/unused") + .unwrap(), + ), + caps: Arc::new(crate::config::Caps::from_env()), + }; + for value in [ + "{}".to_owned(), + "Bearer not-json".to_owned(), + r#"Bearer {"token":"not-a-uuid","signature":"not-pgp"}"#.to_owned(), + format!( + r#"Bearer {{"token":"{}","signature":"not-pgp"}}"#, + Uuid::new_v4() + ), + ] { + let (mut parts, _) = Request::builder() + .header(header::AUTHORIZATION, value) + .body(()) + .unwrap() + .into_parts(); + let result = UserId::from_request_parts(&mut parts, &state).await; + match result { + Err(error) => assert_eq!(error.into_response().status(), StatusCode::UNAUTHORIZED), + Ok(_) => panic!("malformed credentials accepted"), + } + } + } + + #[test] + fn bearer_scheme_required_with_legacy_compatibility() { + assert_eq!(bearer_payload("Bearer {}"), Some("{}")); + assert_eq!(bearer_payload("Bearer Bearer {}"), Some("{}")); + assert_eq!(bearer_payload("{}"), None); + assert_eq!(bearer_payload("Basic {}"), None); + assert!( + serde_json::from_str::(bearer_payload("Bearer Bearer Bearer {}").unwrap()) + .is_err() + ); + } +} diff --git a/src/helpers/caps.rs b/src/helpers/caps.rs index 807c806..fb6ae32 100644 --- a/src/helpers/caps.rs +++ b/src/helpers/caps.rs @@ -4,7 +4,6 @@ //! the absolute upper bound the user can sustain is bounded by these //! checks even if a single batch slips through. -use std::collections::HashMap; use std::net::IpAddr; use axum::http::StatusCode; @@ -13,8 +12,6 @@ use sqlx::types::Uuid; use crate::config::Caps; use crate::error::AppError; -use crate::state::{AppState, DB}; -use crate::traits::to_uuid::ToUuid; use crate::Context as _; fn too_big(msg: String) -> AppError { @@ -49,9 +46,9 @@ pub fn check_per_value(caps: &Caps, values: &[&str]) -> Result<(), AppError> { /// Reject an INSERT that would push the project past either the count /// cap or the byte cap. -pub async fn check_project_for_insert( +pub async fn check_project_for_insert_on( caps: &Caps, - db: &DB, + connection: &mut sqlx::PgConnection, project_id: Uuid, new_values: &[&str], ) -> Result<(), AppError> { @@ -68,7 +65,7 @@ pub async fn check_project_for_insert( FROM variables WHERE project_id = $1"#, project_id ) - .fetch_one(db.as_ref()) + .fetch_one(&mut *connection) .await .context("Failed to fetch project size")?; @@ -94,9 +91,9 @@ pub async fn check_project_for_insert( /// Reject an UPDATE that would push the project past the byte cap. /// (Count does not change on update.) -pub async fn check_project_for_update( +pub async fn check_project_for_update_on( caps: &Caps, - db: &DB, + connection: &mut sqlx::PgConnection, project_id: Uuid, update_ids: &[Uuid], new_values: &[&str], @@ -115,7 +112,7 @@ pub async fn check_project_for_update( project_id, update_ids ) - .fetch_one(db.as_ref()) + .fetch_one(&mut *connection) .await .context("Failed to fetch project size")?; @@ -135,9 +132,9 @@ pub async fn check_project_for_update( /// projects past the per-user cap. `delta_bytes` is the net bytes the /// pending write would add (new bytes minus replaced bytes; may be /// negative for updates that shrink values). -pub async fn check_user_total( +pub async fn check_user_total_on( caps: &Caps, - db: &DB, + connection: &mut sqlx::PgConnection, user_id: Uuid, delta_bytes: i64, ) -> Result<(), AppError> { @@ -153,7 +150,7 @@ pub async fn check_user_total( WHERE upr.user_id = $1"#, user_id ) - .fetch_one(db.as_ref()) + .fetch_one(&mut *connection) .await .context("Failed to fetch user total")?; @@ -170,9 +167,9 @@ pub async fn check_user_total( /// Reject a write from an IP that has uploaded more than the per-IP cap /// in the last 24 hours, then record this write's byte count in the /// upload_log table. Opportunistically prunes rows older than 24h. -pub async fn check_and_record_ip( +pub async fn check_and_record_ip_on( caps: &Caps, - db: &DB, + connection: &mut sqlx::PgConnection, ip: IpAddr, bytes: i64, ) -> Result<(), AppError> { @@ -185,7 +182,7 @@ pub async fn check_and_record_ip( // Prune old rows opportunistically. Cheap when the index is hot. sqlx::query!("DELETE FROM upload_log WHERE created_at < now() - interval '24 hours'") - .execute(db.as_ref()) + .execute(&mut *connection) .await .context("Failed to prune upload_log")?; @@ -195,7 +192,7 @@ pub async fn check_and_record_ip( WHERE ip = $1 AND created_at > now() - interval '24 hours'"#, net ) - .fetch_one(db.as_ref()) + .fetch_one(&mut *connection) .await .context("Failed to fetch ip upload total")?; @@ -212,53 +209,9 @@ pub async fn check_and_record_ip( net, bytes ) - .execute(db.as_ref()) + .execute(&mut *connection) .await .context("Failed to record upload_log entry")?; Ok(()) } - -/// Cross-project update-many helper: groups the incoming updates by -/// project, runs the per-project byte check on each, then runs the -/// per-user and per-IP checks on the aggregate delta. `triples` is -/// `(project_id_str, variable_id_str, new_value)`. -pub async fn check_update_caps_grouped( - state: &AppState, - user_id: Uuid, - ip: IpAddr, - triples: Vec<(&str, &str, &str)>, -) -> Result<(), AppError> { - let mut by_project: HashMap, Vec<&str>)> = HashMap::new(); - let mut total_new_bytes: i64 = 0; - - for (pid_str, vid_str, value) in &triples { - let pid = pid_str.to_string().to_uuid()?; - let vid = vid_str.to_string().to_uuid()?; - let entry = by_project.entry(pid).or_default(); - entry.0.push(vid); - entry.1.push(*value); - total_new_bytes += value.len() as i64; - } - - let mut total_replaced_bytes: i64 = 0; - for (pid, (ids, values)) in &by_project { - check_project_for_update(&state.caps, &state.db, *pid, ids, values).await?; - - let row = sqlx::query!( - r#"SELECT COALESCE(sum(octet_length(value))::bigint, 0) AS "bytes!" - FROM variables WHERE id = ANY($1::uuid[])"#, - ids - ) - .fetch_one(state.db.as_ref()) - .await - .context("Failed to fetch replaced byte count")?; - total_replaced_bytes += row.bytes; - } - - let delta = total_new_bytes - total_replaced_bytes; - check_user_total(&state.caps, &state.db, user_id, delta).await?; - check_and_record_ip(&state.caps, &state.db, ip, delta.max(0)).await?; - - Ok(()) -} diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 7257150..145544f 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -1,2 +1,5 @@ pub mod caps; pub mod project; +pub mod project_snapshot; +pub mod registration; +pub mod variables; diff --git a/src/helpers/project_snapshot.rs b/src/helpers/project_snapshot.rs new file mode 100644 index 0000000..0bfb5ac --- /dev/null +++ b/src/helpers/project_snapshot.rs @@ -0,0 +1,213 @@ +//! A project row is the serialization lock for every membership and variable writer. +//! Snapshot digests bind encrypted row identities/content and all recipient keys. +use crate::{error::Errors, helpers::caps, AppError, AppState}; +use axum::http::StatusCode; +use serde::{Deserialize, Serialize}; +use sqlx::{PgConnection, Postgres, Transaction}; +use std::{collections::HashSet, net::IpAddr}; +use utoipa::ToSchema; +use uuid::Uuid; + +pub const VERSION: u8 = 1; +pub fn conflict(code: &'static str, message: &'static str) -> AppError { + AppError::Protocol(StatusCode::CONFLICT, code, message) +} +pub fn upgrade() -> AppError { + conflict( + "invite_upgrade_required", + "Upgrade envx and regenerate this invitation using a project snapshot", + ) +} +pub fn stale() -> AppError { + conflict( + "project_snapshot_stale", + "Project or recipients changed; fetch a fresh snapshot and regenerate the invitation", + ) +} +pub fn require_version(version: Option) -> Result<(), AppError> { + if version != Some(VERSION) { + return Err(upgrade()); + } + Ok(()) +} +pub async fn lock_project( + tx: &mut Transaction<'_, Postgres>, + project: Uuid, +) -> Result<(), AppError> { + let found: Option = sqlx::query_scalar("SELECT id FROM projects WHERE id=$1 FOR UPDATE") + .bind(project) + .fetch_optional(&mut **tx) + .await?; + if found.is_none() { + return Err(Errors::NotFound.into()); + } + Ok(()) +} +pub async fn authorize( + connection: &mut PgConnection, + project: Uuid, + user: Uuid, +) -> Result<(), AppError> { + let member: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM user_project_relations WHERE project_id=$1 AND user_id=$2)", + ) + .bind(project) + .bind(user) + .fetch_one(connection) + .await?; + if !member { + return Err(Errors::Unauthorized.into()); + } + Ok(()) +} +#[derive(Clone, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct SnapshotVariable { + pub id: Uuid, + pub project_id: Uuid, + pub value: String, + pub created_at: chrono::DateTime, + pub tag: Option, +} +#[derive(Clone, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct Recipient { + pub id: Uuid, + pub public_key: String, +} +#[derive(Serialize, ToSchema)] +pub struct Snapshot { + pub protocol_version: u8, + pub project_id: Uuid, + pub snapshot: String, + pub variables: Vec, + pub users: Vec, +} +#[derive(Clone, Serialize, Deserialize, ToSchema)] +pub struct RewrappedVariable { + pub id: Uuid, + pub value: String, +} + +/// Call only while holding the project lock. Added recipients are part of the token, +/// so a caller cannot re-use ciphertext prepared for another set of public keys. +pub async fn read( + connection: &mut PgConnection, + project: Uuid, + added: &[Uuid], +) -> Result { + let variables: Vec = sqlx::query_as( + "SELECT id,project_id,value,created_at,tag FROM variables WHERE project_id=$1 ORDER BY id", + ) + .bind(project) + .fetch_all(&mut *connection) + .await?; + let members: Vec = sqlx::query_as("SELECT u.id,u.public_key FROM users u JOIN user_project_relations r ON r.user_id=u.id WHERE r.project_id=$1 ORDER BY u.id") + .bind(project).fetch_all(&mut *connection).await?; + let mut users = members.clone(); + let added_users: Vec = + sqlx::query_as("SELECT id,public_key FROM users WHERE id=ANY($1) ORDER BY id") + .bind(added) + .fetch_all(&mut *connection) + .await?; + if added_users.len() != added.iter().collect::>().len() { + return Err(AppError::Generic( + StatusCode::BAD_REQUEST, + "Unknown recipient".into(), + )); + } + users.extend(added_users); + users.sort_by_key(|user| user.id); + users.dedup_by_key(|user| user.id); + let encoded = serde_json::to_vec(&( + "envx-project-snapshot-v1", + project, + &variables, + &members, + &users, + )) + .map_err(anyhow::Error::from)?; + let snapshot = crypto_hash::hex_digest(crypto_hash::Algorithm::SHA256, &encoded); + Ok(Snapshot { + protocol_version: VERSION, + project_id: project, + snapshot, + variables, + users, + }) +} + +pub async fn rewrap( + state: &AppState, + connection: &mut PgConnection, + current: &Snapshot, + expected: &str, + variables: &[RewrappedVariable], + user: Uuid, + ip: IpAddr, +) -> Result<(), AppError> { + if current.snapshot != expected { + return Err(stale()); + } + let actual: HashSet<_> = variables.iter().map(|v| v.id).collect(); + let required: HashSet<_> = current.variables.iter().map(|v| v.id).collect(); + if actual != required || actual.len() != variables.len() { + return Err(AppError::Generic( + StatusCode::BAD_REQUEST, + "Rewrap must contain every snapshot variable exactly once".into(), + )); + } + let values: Vec<&str> = variables.iter().map(|v| v.value.as_str()).collect(); + caps::check_per_value(&state.caps, &values)?; + let ids: Vec = variables.iter().map(|v| v.id).collect(); + caps::check_project_for_update_on(&state.caps, connection, current.project_id, &ids, &values) + .await?; + let new_bytes: i64 = values.iter().map(|v| v.len() as i64).sum(); + let old_bytes: i64 = current.variables.iter().map(|v| v.value.len() as i64).sum(); + // Invitees are not members yet, so their storage delta is the entire project. + let is_member: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM user_project_relations WHERE project_id=$1 AND user_id=$2)", + ) + .bind(current.project_id) + .bind(user) + .fetch_one(&mut *connection) + .await?; + caps::check_user_total_on( + &state.caps, + connection, + user, + if is_member { + new_bytes - old_bytes + } else { + new_bytes + }, + ) + .await?; + caps::check_and_record_ip_on(&state.caps, connection, ip, (new_bytes - old_bytes).max(0)) + .await?; + for variable in variables { + sqlx::query("UPDATE variables SET value=$1 WHERE id=$2 AND project_id=$3") + .bind(&variable.value) + .bind(variable.id) + .bind(current.project_id) + .execute(&mut *connection) + .await?; + } + Ok(()) +} + +pub async fn remove_members( + state: &AppState, + project: Uuid, + user: Uuid, + removed: &[Uuid], +) -> Result<(), AppError> { + let mut tx = state.db.begin().await?; + lock_project(&mut tx, project).await?; + authorize(&mut tx, project, user).await?; + sqlx::query("DELETE FROM user_project_relations WHERE project_id=$1 AND user_id=ANY($2)") + .bind(project) + .bind(removed) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} diff --git a/src/helpers/registration.rs b/src/helpers/registration.rs new file mode 100644 index 0000000..98a1ae7 --- /dev/null +++ b/src/helpers/registration.rs @@ -0,0 +1,27 @@ +use crate::error::{AppError, Errors}; +use pgp::composed::{Deserializable, SignedPublicKey}; + +pub fn validate_public_key(value: &str) -> Result<(), AppError> { + if value.len() > 128 * 1024 { + return Err(( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "Public key exceeds 128 KiB", + ) + .into()); + } + let (key, _) = SignedPublicKey::from_string(value) + .map_err(|_| AppError::Error(Errors::InvalidPublicKey))?; + key.verify() + .map_err(|_| AppError::Error(Errors::InvalidPublicKey))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_invalid_and_oversized_keys() { + assert!(validate_public_key("not a key").is_err()); + assert!(validate_public_key(&"x".repeat(128 * 1024 + 1)).is_err()); + } +} diff --git a/src/helpers/variables.rs b/src/helpers/variables.rs new file mode 100644 index 0000000..3f313d7 --- /dev/null +++ b/src/helpers/variables.rs @@ -0,0 +1,318 @@ +use super::caps; +use crate::{error::Errors, structs::Variable, AppError, AppState}; +use axum::http::StatusCode; +use std::{collections::HashSet, net::IpAddr}; +use uuid::Uuid; + +pub async fn update_many( + state: &AppState, + user_id: Uuid, + ip: IpAddr, + variables: Vec, +) -> Result, AppError> { + let mut seen = HashSet::new(); + let mut parsed = Vec::new(); + for variable in &variables { + let id = Uuid::parse_str(&variable.id).map_err(|_| { + AppError::Generic(StatusCode::BAD_REQUEST, "Invalid variable ID".into()) + })?; + let project = Uuid::parse_str(&variable.project_id) + .map_err(|_| AppError::Generic(StatusCode::BAD_REQUEST, "Invalid project ID".into()))?; + if !seen.insert(id) { + return Err(AppError::Generic( + StatusCode::BAD_REQUEST, + "Duplicate variable ID".into(), + )); + } + parsed.push((id, project)); + } + // Stable lock order prevents opposing batches from deadlocking. + parsed.sort_unstable(); + let mut tx = state.db.begin().await?; + let projects: std::collections::BTreeSet<_> = + parsed.iter().map(|(_, project)| *project).collect(); + for project in projects { + super::project_snapshot::lock_project(&mut tx, project).await?; + } + for (id, project) in &parsed { + let authorized: Option = sqlx::query_scalar("SELECT v.id FROM variables v JOIN user_project_relations upr ON upr.project_id=v.project_id WHERE v.id=$1 AND v.project_id=$2 AND upr.user_id=$3 FOR UPDATE OF v FOR SHARE OF upr") + .bind(id).bind(project).bind(user_id).fetch_optional(&mut *tx).await?; + if authorized.is_none() { + return Err(Errors::Unauthorized.into()); + } + } + caps::check_per_value( + &state.caps, + &variables + .iter() + .map(|v| v.value.as_str()) + .collect::>(), + )?; + let mut grouped = std::collections::HashMap::, Vec<&str>)>::new(); + for variable in &variables { + let group = grouped + .entry(Uuid::parse_str(&variable.project_id)?) + .or_default(); + group.0.push(Uuid::parse_str(&variable.id)?); + group.1.push(&variable.value); + } + let mut delta = 0; + for (project, (ids, values)) in grouped { + caps::check_project_for_update_on(&state.caps, &mut tx, project, &ids, &values).await?; + let old: i64 = sqlx::query_scalar("SELECT COALESCE(sum(octet_length(value)),0)::bigint FROM variables WHERE project_id=$1 AND id=ANY($2)") + .bind(project).bind(ids).fetch_one(&mut *tx).await?; + delta += values.iter().map(|v| v.len() as i64).sum::() - old; + } + caps::check_user_total_on(&state.caps, &mut tx, user_id, delta).await?; + caps::check_and_record_ip_on(&state.caps, &mut tx, ip, delta.max(0)).await?; + let mut ids = Vec::new(); + for variable in variables { + let id = Uuid::parse_str(&variable.id)?; + sqlx::query("UPDATE variables SET value=$1 WHERE id=$2") + .bind(variable.value) + .bind(id) + .execute(&mut *tx) + .await?; + ids.push(id.to_string()); + } + tx.commit().await?; + Ok(ids) +} + +pub async fn replace_many( + state: &AppState, + user_id: Uuid, + ip: IpAddr, + project: Uuid, + values: Vec, + replace_ids: Vec, +) -> Result, AppError> { + let bad = |message: &str| AppError::Generic(StatusCode::BAD_REQUEST, message.into()); + if replace_ids.iter().collect::>().len() != replace_ids.len() { + return Err(bad("Duplicate replacement ID")); + } + let refs = values.iter().map(String::as_str).collect::>(); + caps::check_per_value(&state.caps, &refs)?; + let mut tx = state.db.begin().await?; + super::project_snapshot::lock_project(&mut tx, project).await?; + super::project_snapshot::authorize(&mut tx, project, user_id).await?; + let removed: Vec = sqlx::query_scalar( + "DELETE FROM variables WHERE project_id=$1 AND id=ANY($2) RETURNING value", + ) + .bind(project) + .bind(&replace_ids) + .fetch_all(&mut *tx) + .await?; + if removed.len() != replace_ids.len() { + return Err(bad( + "Replacement variables changed or do not belong to this project", + )); + } + caps::check_project_for_insert_on(&state.caps, &mut tx, project, &refs).await?; + // The transaction already removed replaced rows, so add the full new size. + let added: i64 = values.iter().map(|v| v.len() as i64).sum(); + caps::check_user_total_on(&state.caps, &mut tx, user_id, added).await?; + caps::check_and_record_ip_on(&state.caps, &mut tx, ip, added).await?; + let ids: Vec = sqlx::query_scalar("INSERT INTO variables(id,project_id,value) SELECT gen_random_uuid(),$1,value FROM UNNEST($2::text[]) AS t(value) RETURNING id") + .bind(project).bind(&values).fetch_all(&mut *tx).await?; + tx.commit().await?; + Ok(ids) +} + +pub async fn insert_many( + state: &AppState, + user: Uuid, + ip: IpAddr, + project: Uuid, + values: Vec, + tags: Vec, +) -> Result, AppError> { + let refs = values.iter().map(String::as_str).collect::>(); + caps::check_per_value(&state.caps, &refs)?; + let mut tx = state.db.begin().await?; + super::project_snapshot::lock_project(&mut tx, project).await?; + super::project_snapshot::authorize(&mut tx, project, user).await?; + caps::check_project_for_insert_on(&state.caps, &mut tx, project, &refs).await?; + let delta = values.iter().map(|v| v.len() as i64).sum(); + caps::check_user_total_on(&state.caps, &mut tx, user, delta).await?; + caps::check_and_record_ip_on(&state.caps, &mut tx, ip, delta).await?; + let ids = sqlx::query_scalar("INSERT INTO variables(id,value,project_id,tag) SELECT gen_random_uuid(),value,$1,tag FROM UNNEST($2::text[],$3::text[]) AS t(value,tag) RETURNING id") + .bind(project).bind(values).bind(tags).fetch_all(&mut *tx).await?; + tx.commit().await?; + Ok(ids) +} +pub async fn delete(state: &AppState, user: Uuid, id: Uuid) -> Result<(), AppError> { + let mut tx = state.db.begin().await?; + let project: Option = sqlx::query_scalar("SELECT project_id FROM variables WHERE id=$1") + .bind(id) + .fetch_optional(&mut *tx) + .await?; + let project = project.ok_or(Errors::NotFound)?; + super::project_snapshot::lock_project(&mut tx, project).await?; + super::project_snapshot::authorize(&mut tx, project, user).await?; + sqlx::query("DELETE FROM variables WHERE id=$1 AND project_id=$2") + .bind(id) + .bind(project) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + #[sqlx::test] + async fn replacement_at_user_cap_and_project_cap_rollback(pool: sqlx::PgPool) { + use axum::response::IntoResponse; + let owner = user(&pool).await; + let project = project(&pool, owner).await; + let id: Uuid = sqlx::query_scalar( + "INSERT INTO variables(project_id,value) VALUES($1,'original') RETURNING id", + ) + .bind(project) + .fetch_one(&pool) + .await + .unwrap(); + let mut state = state(pool.clone()); + let caps = std::sync::Arc::make_mut(&mut state.caps); + caps.max_user_bytes = 8; + caps.max_project_bytes = 8; + caps.max_variables_per_project = 1; + let ids = replace_many( + &state, + owner, + "127.0.0.1".parse().unwrap(), + project, + vec!["newvalue".into()], + vec![id], + ) + .await + .ok() + .expect("same-size overwrite at full cap"); + for values in [vec!["too-large".into()], vec!["one".into(), "two".into()]] { + let error = replace_many( + &state, + owner, + "127.0.0.1".parse().unwrap(), + project, + values, + ids.clone(), + ) + .await + .err() + .expect("project cap must reject replacement"); + assert_eq!( + error.into_response().status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + let stored: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(ids[0]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored, "newvalue"); + } + } + + #[sqlx::test] + async fn failed_insert_rolls_back_deleted_values(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let project = project(&pool, owner).await; + let id: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(&pool).await.unwrap(); + sqlx::query( + "ALTER TABLE variables ADD CONSTRAINT reject_test_value CHECK (value <> 'reject-me')", + ) + .execute(&pool) + .await + .unwrap(); + assert!(replace_many( + &state(pool.clone()), + owner, + "127.0.0.1".parse().unwrap(), + project, + vec!["reject-me".into()], + vec![id] + ) + .await + .is_err()); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "original"); + } + #[sqlx::test] + async fn foreign_replacement_rolls_back_entire_batch(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let victim = user(&pool).await; + let own = project(&pool, owner).await; + let other = project(&pool, victim).await; + let a: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(own).fetch_one(&pool).await.unwrap(); + let b: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'victim') RETURNING id").bind(other).fetch_one(&pool).await.unwrap(); + assert!(replace_many( + &state(pool.clone()), + owner, + "127.0.0.1".parse().unwrap(), + own, + vec!["new".into()], + vec![a, b] + ) + .await + .is_err()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM variables WHERE id=ANY($1)") + .bind(vec![a, b]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 2); + } + #[sqlx::test] + async fn authorized_update_succeeds_but_mixed_batch_is_atomic(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let project = project(&pool, owner).await; + let id:Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(&pool).await.unwrap(); + let item = || Variable { + id: id.to_string(), + project_id: project.to_string(), + value: "updated".into(), + }; + assert!(update_many( + &state(pool.clone()), + owner, + "127.0.0.1".parse().unwrap(), + vec![ + item(), + Variable { + id: Uuid::new_v4().to_string(), + project_id: project.to_string(), + value: "missing".into() + } + ] + ) + .await + .is_err()); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "original"); + assert!(update_many( + &state(pool.clone()), + owner, + "127.0.0.1".parse().unwrap(), + vec![item()] + ) + .await + .is_ok()); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "updated"); + } +} diff --git a/src/main.rs b/src/main.rs index b524b81..2d90a54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,6 +109,10 @@ async fn init_router() -> anyhow::Result { delete(variables::delete_variable), ) .route("/variables/set-many", post(variables::set_many_variables)) + .route( + "/variables/replace-many", + post(variables::set_many_variables), + ) .route( "/variables/set-many/v2", post(variables::set_many_variables_v2), @@ -144,9 +148,19 @@ async fn init_router() -> anyhow::Result { .with_state(state) .split_for_parts(); - std::fs::write("./openapi.json", api.to_json()?)?; + if let Some(path) = std::env::var_os("ENVX_OPENAPI_OUTPUT") { + std::fs::write(&path, api.to_json()?).with_context(|| { + format!( + "could not export OpenAPI schema to {}", + std::path::Path::new(&path).display() + ) + })?; + } let router = router.merge(SwaggerUi::new("/docs").url("/docs/openapi.json", api)); Ok(router) } + +#[cfg(test)] +mod test_support; diff --git a/src/routes/projects.rs b/src/routes/projects.rs index 9b8ebeb..96942da 100644 --- a/src/routes/projects.rs +++ b/src/routes/projects.rs @@ -84,22 +84,8 @@ pub async fn add_user( Path(project_id): Path, Json(body): Json, ) -> Result<(), AppError> { - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let user_to_insert = body.user_id.to_uuid()?; - - sqlx::query!( - "INSERT INTO user_project_relations (user_id, project_id) VALUES ($1, $2)", - user_to_insert, - project_id - ) - .execute(&*state.db) - .await - .context("Failed to add user to project")?; - - Ok(()) + let _ = (state, user_id, project_id, body); + Err(crate::helpers::project_snapshot::upgrade()) } #[derive(Serialize, Deserialize)] @@ -113,26 +99,12 @@ pub async fn remove_user( Path(project_id): Path, Json(body): Json, ) -> Result<(), AppError> { - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let users_to_remove = body + let users = body .users .iter() - .map(|user| user.to_uuid().unwrap()) - .collect::>(); - - sqlx::query!( - "DELETE FROM user_project_relations WHERE user_id = ANY($1::uuid[]) AND project_id = $2", - &users_to_remove, - project_id - ) - .execute(&*state.db) - .await - .context("Failed to remove user from project")?; - - Ok(()) + .map(|user| user.to_uuid()) + .collect::, _>>()?; + crate::helpers::project_snapshot::remove_members(&state, project_id, user_id, &users).await } pub async fn list_projects( diff --git a/src/routes/user.rs b/src/routes/user.rs index a41c473..cb6368c 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -4,7 +4,6 @@ use crate::{ *, }; use axum::extract::Path; -use pgp::composed::{Deserializable, SignedPublicKey}; use uuid::Uuid; #[derive(Serialize, Deserialize)] @@ -17,13 +16,7 @@ pub async fn new_user( State(state): State, Json(body): Json, ) -> Result { - // public key validation - match SignedPublicKey::from_string(&body.public_key) { - Ok(_) => {} - Err(_) => { - return Err(AppError::Error(Errors::InvalidPublicKey)); - } - } + crate::helpers::registration::validate_public_key(&body.public_key)?; let user = sqlx::query!( "INSERT INTO users (username, public_key) VALUES ($1, $2) RETURNING id", diff --git a/src/routes/v2/invite/accept.rs b/src/routes/v2/invite/accept.rs index 4a3bb89..d184b6d 100644 --- a/src/routes/v2/invite/accept.rs +++ b/src/routes/v2/invite/accept.rs @@ -1,104 +1,136 @@ +use super::*; +use crate::{ + extractors::{client_ip::ClientIp, user::UserId}, + helpers::project_snapshot::{self as snapshots, Recipient, RewrappedVariable}, +}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; -use axum::http::StatusCode; use uuid::Uuid; -use crate::{extractors::user::UserId, structs::ProjectInvite}; - -use super::*; - -#[derive(Deserialize, ToSchema)] +#[derive(Clone, Deserialize, ToSchema)] +pub struct PrepareInviteBody { + pub code: Uuid, + pub verifier: Uuid, +} +#[derive(Clone, Deserialize, ToSchema)] pub struct AcceptInviteBody { pub code: Uuid, pub verifier: Uuid, + pub protocol_version: Option, + pub snapshot: Option, + pub variables: Option>, } - #[derive(Serialize, ToSchema)] -pub struct AcceptInviteReturnType { - pub project_id: String, - pub invite_id: String, +pub struct PreparedInvite { + pub protocol_version: u8, + pub project_id: Uuid, + pub invite_id: Uuid, pub ciphertext: String, + pub source_snapshot: String, + pub snapshot: String, + pub users: Vec, } - -#[utoipa::path( - post, - path = "/accept", - tag = INVITE_TAG, - responses( - (status = 200, description = "Success", body = AcceptInviteReturnType), - (status = 400, description = "Invalid public key"), - (status = 404, description = "Invite not found"), - (status = 409, description = "Invite already accepted"), - ), - security( - ("bearer" = []), - ), -)] -pub async fn accept_invite( - State(state): State, - UserId(user_id): UserId, - Json(body): Json, -) -> Result, AppError> { - let invite = sqlx::query_as!( - ProjectInvite, - "SELECT * FROM project_invites WHERE id = $1", - body.code - ) - .fetch_one(&*state.db) - .await - .context("Failed to fetch invite")?; - - let parsed_hash = PasswordHash::new(&invite.verifier_argon2id) +#[derive(Serialize, ToSchema)] +pub struct AcceptInviteReturnType { + pub project_id: Uuid, + pub invite_id: Uuid, +} +#[derive(sqlx::FromRow)] +struct Invite { + project_id: Uuid, + author_id: Uuid, + verifier_argon2id: String, + ciphertext: Option, + snapshot_hash: Option, + invited_id: Option, + expires_at: chrono::DateTime, +} +// The initial lookup only chooses the project lock. All authorization and content +// checks use a fresh locked invitation after that lock has been acquired. +async fn lock_invite( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + code: Uuid, + verifier: Uuid, +) -> Result { + let project: Option = + sqlx::query_scalar("SELECT project_id FROM project_invites WHERE id=$1") + .bind(code) + .fetch_optional(&mut **tx) + .await?; + snapshots::lock_project(tx, project.ok_or(Errors::NotFound)?).await?; + let invite: Invite = sqlx::query_as("SELECT project_id,author_id,verifier_argon2id,ciphertext,snapshot_hash,invited_id,expires_at FROM project_invites WHERE id=$1 FOR UPDATE") + .bind(code).fetch_one(&mut **tx).await?; + let hash = PasswordHash::new(&invite.verifier_argon2id) + .map_err(|_| AppError::Error(Errors::Unauthorized))?; + Argon2::default() + .verify_password(verifier.as_bytes(), &hash) .map_err(|_| AppError::Error(Errors::Unauthorized))?; - let is_valid = Argon2::default() - .verify_password(body.verifier.as_bytes(), &parsed_hash) - .is_ok(); - - if !is_valid { - return Err(AppError::Error(Errors::Unauthorized)); - } - if invite.invited_id.is_some() { - return Err(AppError::Generic( - StatusCode::CONFLICT, - "Invite already accepted".into(), + return Err(snapshots::conflict( + "invite_already_accepted", + "Invitation already accepted", )); } - - let id = sqlx::query!( - "UPDATE project_invites - SET invited_id = $1, - ciphertext = NULL - WHERE id = $2 - AND invited_id IS NULL - AND ciphertext IS NOT NULL - AND expires_at > NOW() - RETURNING id; - ", - user_id, - body.code - ) - .fetch_optional(&*state.db) - .await - .context("Failed to update invite")?; - - sqlx::query!( - "INSERT INTO user_project_relations (user_id, project_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING", - &user_id, - &invite.project_id, - ) - .execute(&*state.db) - .await - .context("Failed to insert user project relations")?; - - if id.is_none() { - return Err(AppError::Error(Errors::Unauthorized)); + if invite.expires_at <= chrono::Utc::now() || invite.ciphertext.is_none() { + return Err(Errors::Unauthorized.into()); } - - Ok(Json(AcceptInviteReturnType { + snapshots::authorize(tx, invite.project_id, invite.author_id).await?; + let expected = invite + .snapshot_hash + .as_deref() + .ok_or_else(snapshots::upgrade)?; + let current = snapshots::read(tx, invite.project_id, &[]).await?; + if current.snapshot != expected { + return Err(snapshots::stale()); + } + Ok(invite) +} +#[utoipa::path(post,path="/prepare",tag=INVITE_TAG,responses((status=200,body=PreparedInvite),(status=409,description="Upgrade or regenerate invitation")),security(("bearer"=[])))] +pub async fn prepare_invite( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result, AppError> { + let mut tx = state.db.begin().await?; + let invite = lock_invite(&mut tx, body.code, body.verifier).await?; + let current = snapshots::read(&mut tx, invite.project_id, &[user]).await?; + tx.commit().await?; + Ok(Json(PreparedInvite { + protocol_version: snapshots::VERSION, + project_id: invite.project_id, + invite_id: body.code, ciphertext: invite.ciphertext.unwrap(), - invite_id: invite.id.to_string(), - project_id: invite.project_id.to_string(), + source_snapshot: invite.snapshot_hash.unwrap(), + snapshot: current.snapshot, + users: current.users, })) } +#[utoipa::path(post,path="/accept",tag=INVITE_TAG,responses((status=200,body=AcceptInviteReturnType),(status=409,description="Upgrade or regenerate invitation")),security(("bearer"=[])))] +pub async fn accept_invite( + State(state): State, + UserId(user): UserId, + ClientIp(ip): ClientIp, + Json(body): Json, +) -> Result, AppError> { + snapshots::require_version(body.protocol_version)?; + let expected = body.snapshot.as_deref().ok_or_else(snapshots::upgrade)?; + let variables = body.variables.as_deref().ok_or_else(snapshots::upgrade)?; + let mut tx = state.db.begin().await?; + let invite = lock_invite(&mut tx, body.code, body.verifier).await?; + let current = snapshots::read(&mut tx, invite.project_id, &[user]).await?; + snapshots::rewrap(&state, &mut tx, ¤t, expected, variables, user, ip).await?; + sqlx::query("UPDATE project_invites SET invited_id=$1,ciphertext=NULL WHERE id=$2") + .bind(user) + .bind(body.code) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO user_project_relations(user_id,project_id) VALUES($1,$2) ON CONFLICT DO NOTHING") + .bind(user).bind(invite.project_id).execute(&mut *tx).await?; + tx.commit().await?; + Ok(Json(AcceptInviteReturnType { + project_id: invite.project_id, + invite_id: body.code, + })) +} + +#[cfg(test)] +mod tests; diff --git a/src/routes/v2/invite/accept/tests.rs b/src/routes/v2/invite/accept/tests.rs new file mode 100644 index 0000000..27fbb74 --- /dev/null +++ b/src/routes/v2/invite/accept/tests.rs @@ -0,0 +1,440 @@ +use super::*; +use crate::{ + routes::v2::invite::new::{new_invite, InviteBody}, + test_support::*, +}; +fn ip() -> ClientIp { + ClientIp("127.0.0.1".parse().unwrap()) +} +async fn issue(pool: &sqlx::PgPool, owner: Uuid, guest: Uuid, project: Uuid) -> AcceptInviteBody { + let mut tx = pool.begin().await.unwrap(); + snapshots::lock_project(&mut tx, project) + .await + .ok() + .unwrap(); + let snapshot = snapshots::read(&mut tx, project, &[]).await.ok().unwrap(); + tx.commit().await.unwrap(); + let invite = new_invite( + State(state(pool.clone())), + UserId(owner), + Json(InviteBody { + project_id: project, + ciphertext: "encrypted-invite".into(), + protocol_version: Some(1), + snapshot: Some(snapshot.snapshot), + }), + ) + .await + .ok() + .unwrap() + .0; + let prepared = prepare_invite( + State(state(pool.clone())), + UserId(guest), + Json(PrepareInviteBody { + code: invite.invite_code, + verifier: invite.verifier, + }), + ) + .await + .ok() + .unwrap() + .0; + AcceptInviteBody { + code: invite.invite_code, + verifier: invite.verifier, + protocol_version: Some(1), + snapshot: Some(prepared.snapshot), + variables: Some( + snapshot + .variables + .into_iter() + .map(|v| RewrappedVariable { + id: v.id, + value: "rewrapped".into(), + }) + .collect(), + ), + } +} +async fn assert_unclaimed(pool: &sqlx::PgPool, code: Uuid, guest: Uuid, project: Uuid) { + let (claimed, ciphertext): (Option, Option) = + sqlx::query_as("SELECT invited_id,ciphertext FROM project_invites WHERE id=$1") + .bind(code) + .fetch_one(pool) + .await + .unwrap(); + assert!(claimed.is_none()); + assert!(ciphertext.is_some()); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_project_relations WHERE project_id=$1 AND user_id=$2", + ) + .bind(project) + .bind(guest) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(count, 0); +} +async fn variable(pool: &sqlx::PgPool, project: Uuid) -> Uuid { + sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES(gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(pool).await.unwrap() +} +#[sqlx::test] +async fn legacy_invite_cannot_join_before_variables_are_rewrapped(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, guest, project).await; + sqlx::query("UPDATE project_invites SET snapshot_hash=NULL WHERE id=$1") + .bind(body.code) + .execute(&pool) + .await + .unwrap(); + assert!(prepare_invite( + State(state(pool.clone())), + UserId(guest), + Json(PrepareInviteBody { + code: body.code, + verifier: body.verifier + }) + ) + .await + .is_err()); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()) + ) + .await + .is_err()); + let legacy = AcceptInviteBody { + protocol_version: None, + snapshot: None, + variables: None, + ..body.clone() + }; + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(legacy) + ) + .await + .is_err()); + assert_unclaimed(&pool, body.code, guest, project).await; +} +#[sqlx::test] +async fn expired_invite_does_not_grant_membership(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, guest, project).await; + sqlx::query("UPDATE project_invites SET expires_at=now()-interval '1 hour' WHERE id=$1") + .bind(body.code) + .execute(&pool) + .await + .unwrap(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()) + ) + .await + .is_err()); + assert_unclaimed(&pool, body.code, guest, project).await; +} +#[sqlx::test] +async fn removed_author_cannot_grant_membership(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, guest, project).await; + snapshots::remove_members(&state(pool.clone()), project, owner, &[owner]) + .await + .ok() + .unwrap(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()) + ) + .await + .is_err()); + assert_unclaimed(&pool, body.code, guest, project).await; +} +#[sqlx::test] +async fn changed_added_deleted_or_replaced_variables_and_membership_reject_stale_invites( + pool: sqlx::PgPool, +) { + for change in ["changed", "added", "deleted", "replaced", "membership"] { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let id = variable(&pool, project).await; + let body = issue(&pool, owner, guest, project).await; + match change { + "changed" => { + sqlx::query("UPDATE variables SET value='changed' WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + } + "added" => { + variable(&pool, project).await; + } + "deleted" => { + sqlx::query("DELETE FROM variables WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + } + "replaced" => { + crate::helpers::variables::replace_many( + &state(pool.clone()), + owner, + ip().0, + project, + vec!["original".into()], + vec![id], + ) + .await + .ok() + .unwrap(); + } + _ => { + let extra = user(&pool).await; + sqlx::query("INSERT INTO user_project_relations(project_id,user_id) VALUES($1,$2)") + .bind(project) + .bind(extra) + .execute(&pool) + .await + .unwrap(); + } + } + let before: Vec<(Uuid, String)> = + sqlx::query_as("SELECT id,value FROM variables WHERE project_id=$1 ORDER BY id") + .bind(project) + .fetch_all(&pool) + .await + .unwrap(); + let result = accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()), + ) + .await; + assert!( + matches!( + result, + Err(AppError::Protocol(_, "project_snapshot_stale", _)) + ), + "{change}" + ); + assert_unclaimed(&pool, body.code, guest, project).await; + let after: Vec<(Uuid, String)> = + sqlx::query_as("SELECT id,value FROM variables WHERE project_id=$1 ORDER BY id") + .bind(project) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(before, after, "{change}"); + } +} +#[sqlx::test] +async fn failed_or_incomplete_rewrap_does_not_join_or_consume(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + variable(&pool, project).await; + variable(&pool, project).await; + let body = issue(&pool, owner, guest, project).await; + let mut incomplete = body.clone(); + incomplete.variables.as_mut().unwrap().pop(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(incomplete) + ) + .await + .is_err()); + let mut duplicate = body.clone(); + let first = duplicate.variables.as_ref().unwrap()[0].clone(); + duplicate.variables.as_mut().unwrap().push(first); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(duplicate) + ) + .await + .is_err()); + sqlx::query("ALTER TABLE variables ADD CONSTRAINT reject_rewrap CHECK(value<>'reject-me')") + .execute(&pool) + .await + .unwrap(); + let mut failed = body.clone(); + failed.variables.as_mut().unwrap()[1].value = "reject-me".into(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(failed) + ) + .await + .is_err()); + assert_unclaimed(&pool, body.code, guest, project).await; + let originals: i64 = sqlx::query_scalar( + "SELECT count(*) FROM variables WHERE project_id=$1 AND value='original'", + ) + .bind(project) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(originals, 2); + assert!( + accept_invite(State(state(pool.clone())), UserId(guest), ip(), Json(body)) + .await + .is_ok() + ); +} +#[sqlx::test] +async fn concurrent_redemption_has_one_winner_and_empty_project_works(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let a = user(&pool).await; + let b = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, a, project).await; + let prepared = prepare_invite( + State(state(pool.clone())), + UserId(b), + Json(PrepareInviteBody { + code: body.code, + verifier: body.verifier, + }), + ) + .await + .ok() + .unwrap() + .0; + let other = AcceptInviteBody { + snapshot: Some(prepared.snapshot), + ..body.clone() + }; + let (ra, rb) = tokio::join!( + accept_invite(State(state(pool.clone())), UserId(a), ip(), Json(body)), + accept_invite(State(state(pool.clone())), UserId(b), ip(), Json(other)) + ); + assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_project_relations WHERE project_id=$1 AND user_id<>$2", + ) + .bind(project) + .bind(owner) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[sqlx::test] +async fn creator_must_submit_the_fetched_snapshot_and_payload_quotas_are_bounded( + pool: sqlx::PgPool, +) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, guest, project).await; + let source: String = + sqlx::query_scalar("SELECT snapshot_hash FROM project_invites WHERE id=$1") + .bind(body.code) + .fetch_one(&pool) + .await + .unwrap(); + let create = |ciphertext: String, snapshot: String| { + new_invite( + State(state(pool.clone())), + UserId(owner), + Json(InviteBody { + project_id: project, + ciphertext, + protocol_version: Some(1), + snapshot: Some(snapshot), + }), + ) + }; + assert!(create("x".repeat(1024 * 1024 + 1), source.clone()) + .await + .is_err()); + sqlx::query("UPDATE project_invites SET expires_at=now()-interval '1 hour' WHERE id=$1") + .bind(body.code) + .execute(&pool) + .await + .unwrap(); + assert!(create("valid".into(), source.clone()).await.is_ok()); + let expired: Option = + sqlx::query_scalar("SELECT ciphertext FROM project_invites WHERE id=$1") + .bind(body.code) + .fetch_one(&pool) + .await + .unwrap(); + assert!(expired.is_none()); + sqlx::query("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext,snapshot_hash) SELECT $1,$2,now()+interval '1 hour','fixture','payload',$3 FROM generate_series(1,31)").bind(project).bind(owner).bind(&source).execute(&pool).await.unwrap(); + assert!(create("valid".into(), source.clone()).await.is_err()); + // Count and byte limits are independent. + sqlx::query("UPDATE project_invites SET ciphertext=repeat('x',16777216) WHERE id=(SELECT id FROM project_invites WHERE author_id=$1 AND expires_at>now() LIMIT 1)").bind(owner).execute(&pool).await.unwrap(); + sqlx::query("DELETE FROM project_invites WHERE author_id=$1 AND length(ciphertext)<16777216") + .bind(owner) + .execute(&pool) + .await + .unwrap(); + assert!(create("valid".into(), source.clone()).await.is_err()); + variable(&pool, project).await; + assert!(matches!( + create("valid".into(), source).await, + Err(AppError::Protocol(_, "project_snapshot_stale", _)) + )); +} + +#[sqlx::test] +async fn writer_holds_project_lock_until_commit_and_acceptance_observes_it(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let id = variable(&pool, project).await; + let body = issue(&pool, owner, guest, project).await; + let mut tx = pool.begin().await.unwrap(); + snapshots::lock_project(&mut tx, project) + .await + .ok() + .unwrap(); + sqlx::query("UPDATE variables SET value='concurrent-newer-value' WHERE id=$1") + .bind(id) + .execute(&mut *tx) + .await + .unwrap(); + let accept = accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()), + ); + tokio::pin!(accept); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(75), &mut accept) + .await + .is_err() + ); + tx.commit().await.unwrap(); + assert!(matches!( + accept.await, + Err(AppError::Protocol(_, "project_snapshot_stale", _)) + )); + assert_unclaimed(&pool, body.code, guest, project).await; +} diff --git a/src/routes/v2/invite/mod.rs b/src/routes/v2/invite/mod.rs index 8f895bf..6e03d97 100644 --- a/src/routes/v2/invite/mod.rs +++ b/src/routes/v2/invite/mod.rs @@ -1,7 +1,7 @@ - use super::*; +use super::*; -mod new; mod accept; +mod new; pub const INVITE_TAG: &str = "invite"; @@ -9,5 +9,6 @@ pub fn router(state: AppState) -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(new::new_invite)) .routes(routes!(accept::accept_invite)) + .routes(routes!(accept::prepare_invite)) .with_state(state) } diff --git a/src/routes/v2/invite/new.rs b/src/routes/v2/invite/new.rs index fea7881..42c0507 100644 --- a/src/routes/v2/invite/new.rs +++ b/src/routes/v2/invite/new.rs @@ -1,77 +1,75 @@ +use super::*; +use crate::{extractors::user::UserId, helpers::project_snapshot as snapshots}; use argon2::{ password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, Argon2, }; use axum::http::StatusCode; -use chrono::Utc; use uuid::Uuid; -use super::{extractors::user::UserId, helpers::project::user_in_project, *}; +const MAX_PAYLOAD_BYTES: usize = 1024 * 1024; +const MAX_ACTIVE_INVITES: i64 = 32; +const MAX_ACTIVE_BYTES: i64 = 16 * 1024 * 1024; #[derive(Serialize, Deserialize, ToSchema)] pub struct InviteBody { - project_id: Uuid, - ciphertext: String, + pub project_id: Uuid, + pub ciphertext: String, + pub protocol_version: Option, + pub snapshot: Option, } - #[derive(Serialize, Deserialize, ToSchema)] pub struct InviteResponse { pub invite_code: Uuid, pub verifier: Uuid, } - -#[utoipa::path( - post, - path = "/new", - tag = INVITE_TAG, - responses( - (status = 200, description = "Success", body = InviteResponse), - (status = 400, description = "Invalid public key"), - ), - security( - ("bearer" = []), - ), -)] +#[utoipa::path(post,path="/new",tag=INVITE_TAG,responses((status=200,body=InviteResponse),(status=409,description="Upgrade or regenerate invitation")),security(("bearer"=[])))] pub async fn new_invite( State(state): State, - UserId(user_id): UserId, + UserId(user): UserId, Json(body): Json, ) -> Result, AppError> { - if !user_in_project(user_id, body.project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); + snapshots::require_version(body.protocol_version)?; + if body.ciphertext.is_empty() || body.ciphertext.len() > MAX_PAYLOAD_BYTES { + return Err(AppError::Generic( + StatusCode::PAYLOAD_TOO_LARGE, + "Invitation ciphertext must be between 1 byte and 1 MiB".into(), + )); } - + let expected = body.snapshot.as_deref().ok_or_else(snapshots::upgrade)?; let verifier = Uuid::new_v4(); - let verifier_hash = Argon2::default() + let hash = Argon2::default() .hash_password(verifier.as_bytes(), &SaltString::generate(&mut OsRng)) .map_err(|e| AppError::Generic(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .to_string(); - - let exp = Utc::now() + chrono::Duration::hours(1); - - let res = sqlx::query!( - "INSERT INTO project_invites ( - project_id, - author_id, - expires_at, - verifier_argon2id, - ciphertext - ) - VALUES ($1, $2, $3, $4, $5) - RETURNING id; - ", - body.project_id, - user_id, - exp, - verifier_hash, - body.ciphertext, - ) - .fetch_one(&*state.db) - .await - .context("Failed to insert project invite")?; - + let mut tx = state.db.begin().await?; + snapshots::lock_project(&mut tx, body.project_id).await?; + snapshots::authorize(&mut tx, body.project_id, user).await?; + let current = snapshots::read(&mut tx, body.project_id, &[]).await?; + if current.snapshot != expected { + return Err(snapshots::stale()); + } + // Serialize this author's invite quota across all their projects. The project + // lock always precedes this lock, and redemption never acquires an author lock. + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('envx-project-invites'),hashtext($1))") + .bind(user.to_string()) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE project_invites SET ciphertext=NULL WHERE author_id=$1 AND ciphertext IS NOT NULL AND expires_at<=clock_timestamp()") + .bind(user).execute(&mut *tx).await?; + let (count,bytes):(i64,i64)=sqlx::query_as("SELECT count(*),COALESCE(sum(octet_length(ciphertext)),0)::bigint FROM project_invites WHERE author_id=$1 AND invited_id IS NULL AND expires_at>clock_timestamp() AND ciphertext IS NOT NULL") + .bind(user).fetch_one(&mut *tx).await?; + if count >= MAX_ACTIVE_INVITES || bytes + body.ciphertext.len() as i64 > MAX_ACTIVE_BYTES { + return Err(AppError::Generic( + StatusCode::TOO_MANY_REQUESTS, + "Active invitation quota exceeded; wait for invitations to expire".into(), + )); + } + let invite_code = sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext,snapshot_hash) VALUES($1,$2,clock_timestamp()+interval '1 hour',$3,$4,$5) RETURNING id") + .bind(body.project_id).bind(user).bind(hash).bind(body.ciphertext).bind(expected).fetch_one(&mut *tx).await?; + tx.commit().await?; Ok(Json(InviteResponse { - invite_code: res.id, + invite_code, verifier, })) } diff --git a/src/routes/v2/mod.rs b/src/routes/v2/mod.rs index ca915fa..96f83cd 100644 --- a/src/routes/v2/mod.rs +++ b/src/routes/v2/mod.rs @@ -1,16 +1,18 @@ - use crate::*; - use utoipa::ToSchema; - use utoipa_axum::router::OpenApiRouter; - use utoipa_axum::routes; +use crate::*; +use utoipa::ToSchema; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; pub mod invite; pub mod project; pub mod projects; +pub mod social; pub mod user; pub mod variables; pub fn router(state: AppState) -> OpenApiRouter { OpenApiRouter::new() + .merge(social::router(state.clone())) .nest("/project", project::router(state.clone())) .nest("/projects", projects::router(state.clone())) .nest("/user", user::router(state.clone())) diff --git a/src/routes/v2/project/add_user.rs b/src/routes/v2/project/add_user.rs index ce91eed..dcf59fd 100644 --- a/src/routes/v2/project/add_user.rs +++ b/src/routes/v2/project/add_user.rs @@ -20,21 +20,6 @@ pub async fn add_user( Path(project_id): Path, Json(users_to_add): Json>, ) -> Result<(), AppError> { - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - sqlx::query!( - "INSERT INTO user_project_relations (user_id, project_id) - SELECT user_id, $2::uuid - FROM UNNEST($1::uuid[]) AS t(user_id) - ON CONFLICT DO NOTHING", - &users_to_add, - project_id, - ) - .execute(&*state.db) - .await - .context("Failed to insert user project relations")?; - - Ok(()) + let _ = (state, user_id, project_id, users_to_add); + Err(crate::helpers::project_snapshot::upgrade()) } diff --git a/src/routes/v2/project/delete.rs b/src/routes/v2/project/delete.rs new file mode 100644 index 0000000..6e59e38 --- /dev/null +++ b/src/routes/v2/project/delete.rs @@ -0,0 +1,112 @@ +use super::*; +use uuid::Uuid; + +#[utoipa::path( + delete, path = "/{project_id}/delete", tag = PROJECT_TAG, + security(("bearer" = [])), + responses((status = 200, description = "Project deleted"), + (status = 403, description = "Not a project member"), + (status = 409, description = "Shared projects cannot be deleted")) +)] +pub async fn delete_project( + State(state): State, + UserId(user_id): UserId, + Path(project_id): Path, +) -> Result<(), AppError> { + let mut tx = state.db.begin().await?; + sqlx::query("SELECT id FROM projects WHERE id = $1 FOR UPDATE") + .bind(project_id) + .fetch_optional(&mut *tx) + .await?; + let members: Vec = + sqlx::query_scalar("SELECT user_id FROM user_project_relations WHERE project_id = $1") + .bind(project_id) + .fetch_all(&mut *tx) + .await?; + if !members.contains(&user_id) { + return Err((axum::http::StatusCode::FORBIDDEN, "Not a project member").into()); + } + if members.len() != 1 { + return Err(( + axum::http::StatusCode::CONFLICT, + "Shared projects cannot be deleted; you must be the sole remaining member", + ) + .into()); + } + sqlx::query("DELETE FROM variables WHERE project_id = $1") + .bind(project_id) + .execute(&mut *tx) + .await?; + // Memberships and outstanding invitations cascade with the project. + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + #[sqlx::test] + async fn deletes_only_sole_member_project(pool: sqlx::PgPool) { + let user: Uuid = sqlx::query_scalar( + "INSERT INTO users(username, public_key) VALUES('a','a') RETURNING id", + ) + .fetch_one(&pool) + .await + .unwrap(); + let other: Uuid = sqlx::query_scalar( + "INSERT INTO users(username, public_key) VALUES('b','b') RETURNING id", + ) + .fetch_one(&pool) + .await + .unwrap(); + let project: Uuid = sqlx::query_scalar("INSERT INTO projects DEFAULT VALUES RETURNING id") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO user_project_relations(user_id,project_id) VALUES($1,$2),($3,$2)") + .bind(user) + .bind(project) + .bind(other) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO variables(value,project_id) VALUES('ciphertext',$1)") + .bind(project) + .execute(&pool) + .await + .unwrap(); + let state = AppState { + db: Arc::new(pool.clone()), + caps: Arc::new(crate::config::Caps::from_env()), + }; + assert!( + delete_project(State(state.clone()), UserId(Uuid::new_v4()), Path(project)) + .await + .is_err() + ); + assert!( + delete_project(State(state.clone()), UserId(user), Path(project)) + .await + .is_err() + ); + sqlx::query("DELETE FROM user_project_relations WHERE user_id=$1") + .bind(other) + .execute(&pool) + .await + .unwrap(); + assert!(delete_project(State(state), UserId(user), Path(project)) + .await + .is_ok()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM variables WHERE project_id=$1") + .bind(project) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + } +} diff --git a/src/routes/v2/project/mod.rs b/src/routes/v2/project/mod.rs index d7f7441..afa1c55 100644 --- a/src/routes/v2/project/mod.rs +++ b/src/routes/v2/project/mod.rs @@ -1,12 +1,14 @@ - use crate::structs::User; - use crate::*; - use crate::{extractors::user::UserId, helpers::project::user_in_project}; - use axum::extract::Path; - use utoipa::ToSchema; +use crate::structs::User; +use crate::*; +use crate::{extractors::user::UserId, helpers::project::user_in_project}; +use axum::extract::Path; +use utoipa::ToSchema; mod add_user; +mod delete; mod info; mod remove_users; +mod snapshot; mod update; mod variables; @@ -15,9 +17,12 @@ pub const PROJECT_TAG: &str = "project"; pub fn router(state: AppState) -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(info::get_project_info_v2)) + .routes(routes!(delete::delete_project)) .routes(routes!(update::update)) .routes(routes!(add_user::add_user)) .routes(routes!(remove_users::remove_users)) .routes(routes!(variables::variables)) + .routes(routes!(snapshot::snapshot)) + .routes(routes!(snapshot::rewrap)) .with_state(state) } diff --git a/src/routes/v2/project/remove_users.rs b/src/routes/v2/project/remove_users.rs index a50d567..005eae7 100644 --- a/src/routes/v2/project/remove_users.rs +++ b/src/routes/v2/project/remove_users.rs @@ -25,24 +25,10 @@ pub async fn remove_users( Path(project_id): Path, Json(body): Json, ) -> Result<(), AppError> { - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let users_to_remove = body + let users = body .user_ids .iter() .map(|user| user.to_uuid()) .collect::, _>>()?; - - sqlx::query!( - "DELETE FROM user_project_relations WHERE user_id = ANY($1::uuid[]) AND project_id = $2", - &users_to_remove, - project_id - ) - .execute(&*state.db) - .await - .context("Failed to remove user from project")?; - - Ok(()) + crate::helpers::project_snapshot::remove_members(&state, project_id, user_id, &users).await } diff --git a/src/routes/v2/project/snapshot.rs b/src/routes/v2/project/snapshot.rs new file mode 100644 index 0000000..64d51d3 --- /dev/null +++ b/src/routes/v2/project/snapshot.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::{ + extractors::client_ip::ClientIp, + helpers::project_snapshot::{self as snapshots, RewrappedVariable, Snapshot}, +}; +use uuid::Uuid; + +#[derive(Deserialize, ToSchema)] +pub struct SnapshotBody { + #[serde(default)] + pub add_user_ids: Vec, +} +#[utoipa::path(post, path="/{project_id}/snapshot", tag=PROJECT_TAG, responses((status=200,body=Snapshot)), security(("bearer"=[])))] +pub async fn snapshot( + State(state): State, + UserId(user): UserId, + Path(project): Path, + Json(body): Json, +) -> Result, AppError> { + let mut tx = state.db.begin().await?; + snapshots::lock_project(&mut tx, project).await?; + snapshots::authorize(&mut tx, project, user).await?; + let result = snapshots::read(&mut tx, project, &body.add_user_ids).await?; + tx.commit().await?; + Ok(Json(result)) +} +#[derive(Deserialize, ToSchema)] +pub struct RewrapBody { + pub protocol_version: Option, + pub snapshot: String, + pub variables: Vec, + pub add_user_ids: Vec, +} +#[utoipa::path(post, path="/{project_id}/rewrap", tag=PROJECT_TAG, responses((status=200),(status=409,description="Snapshot changed")), security(("bearer"=[])))] +pub async fn rewrap( + State(state): State, + UserId(user): UserId, + Path(project): Path, + ClientIp(ip): ClientIp, + Json(body): Json, +) -> Result<(), AppError> { + snapshots::require_version(body.protocol_version)?; + let mut tx = state.db.begin().await?; + snapshots::lock_project(&mut tx, project).await?; + snapshots::authorize(&mut tx, project, user).await?; + let current = snapshots::read(&mut tx, project, &body.add_user_ids).await?; + snapshots::rewrap( + &state, + &mut tx, + ¤t, + &body.snapshot, + &body.variables, + user, + ip, + ) + .await?; + sqlx::query("INSERT INTO user_project_relations(user_id,project_id) SELECT user_id,$1 FROM UNNEST($2::uuid[]) t(user_id) ON CONFLICT DO NOTHING") + .bind(project).bind(body.add_user_ids).execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + fn ip() -> ClientIp { + ClientIp("127.0.0.1".parse().unwrap()) + } + #[sqlx::test] + async fn adding_users_rejects_stale_or_partial_batches_and_joins_atomically( + pool: sqlx::PgPool, + ) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let id:Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES(gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(&pool).await.unwrap(); + let read = || { + snapshot( + State(state(pool.clone())), + UserId(owner), + Path(project), + Json(SnapshotBody { + add_user_ids: vec![guest], + }), + ) + }; + let old = read().await.ok().unwrap().0; + sqlx::query("UPDATE variables SET value='changed' WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + let apply = |snapshot: String, variables: Vec| { + rewrap( + State(state(pool.clone())), + UserId(owner), + Path(project), + ip(), + Json(RewrapBody { + protocol_version: Some(1), + snapshot, + variables, + add_user_ids: vec![guest], + }), + ) + }; + assert!(matches!( + apply( + old.snapshot, + vec![RewrappedVariable { + id, + value: "rewrapped".into() + }] + ) + .await, + Err(AppError::Protocol(_, "project_snapshot_stale", _)) + )); + let fresh = read().await.ok().unwrap().0; + assert!(apply(fresh.snapshot.clone(), vec![]).await.is_err()); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_project_relations WHERE project_id=$1 AND user_id=$2", + ) + .bind(project) + .bind(guest) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + assert!(apply( + fresh.snapshot, + vec![RewrappedVariable { + id, + value: "rewrapped".into() + }] + ) + .await + .is_ok()); + let value:String=sqlx::query_scalar("SELECT value FROM variables v JOIN user_project_relations r ON r.project_id=v.project_id WHERE v.id=$1 AND r.user_id=$2").bind(id).bind(guest).fetch_one(&pool).await.unwrap(); + assert_eq!(value, "rewrapped"); + } +} diff --git a/src/routes/v2/projects/mod.rs b/src/routes/v2/projects/mod.rs index a033557..7c952be 100644 --- a/src/routes/v2/projects/mod.rs +++ b/src/routes/v2/projects/mod.rs @@ -1,6 +1,6 @@ - use crate::extractors::user::UserId; - use crate::*; - use utoipa::ToSchema; +use crate::extractors::user::UserId; +use crate::*; +use utoipa::ToSchema; mod list; mod new; diff --git a/src/routes/v2/social/links.rs b/src/routes/v2/social/links.rs new file mode 100644 index 0000000..317eb21 --- /dev/null +++ b/src/routes/v2/social/links.rs @@ -0,0 +1,309 @@ +use super::*; +use pgp::composed::Message; +use rand::RngCore; + +pub(super) fn router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(create, list)) + .routes(routes!(revoke)) + .routes(routes!(preview)) + .routes(routes!(redeem)) +} +#[derive(Deserialize, ToSchema)] +pub struct CreateLink { + pub label: String, + pub target_id: Option, + pub expires_at: Option>, +} +#[derive(Serialize, ToSchema)] +pub struct Link { + pub id: Uuid, + pub creator_id: Uuid, + pub target_id: Option, + pub label: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub revoked_at: Option>, + pub redeemed_at: Option>, + pub redeemed_by: Option, + pub receipt: Option, +} +#[derive(FromRow)] +struct LinkRow { + id: Uuid, + creator_id: Uuid, + target_id: Option, + label: String, + token_hash: String, + created_at: DateTime, + expires_at: DateTime, + revoked_at: Option>, + redeemed_at: Option>, + redeemed_by: Option, + receipt: Option, +} +impl LinkRow { + pub(super) async fn public(self, pool: &sqlx::PgPool) -> Result { + Ok(Link { + id: self.id, + creator_id: self.creator_id, + target_id: self.target_id, + label: self.label, + created_at: self.created_at, + expires_at: self.expires_at, + revoked_at: self.revoked_at, + redeemed_at: self.redeemed_at, + redeemed_by: match self.redeemed_by { + Some(id) => Some(identity(pool, id).await?), + None => None, + }, + receipt: self.receipt, + }) + } + fn validate(&self, user: Uuid, token: &str) -> Result<(), AppError> { + if token.len() != 64 || hash(token) != self.token_hash { + return Err(not_found()); + } + if self.creator_id == user { + return Err(bad("Cannot redeem your own friend link")); + } + if self.target_id.is_some_and(|id| id != user) { + return Err(not_found()); + } + // The winning redeemer can recover a lost response even after expiry or removal. + if self.redeemed_by == Some(user) { + return Ok(()); + } + if self.redeemed_by.is_some() { + return Err(conflict("Link already redeemed")); + } + if self.revoked_at.is_some() || self.expires_at <= Utc::now() { + return Err(conflict("Link expired or revoked")); + } + Ok(()) + } +} +#[derive(Serialize, ToSchema)] +pub struct CreatedLink { + pub link: Link, + pub token: String, +} +#[derive(Deserialize, ToSchema)] +pub struct Claim { + pub id: Uuid, + pub token: String, +} +#[derive(Deserialize, ToSchema)] +pub struct Redeem { + pub id: Uuid, + pub token: String, + pub receipt: String, +} +#[derive(Serialize, ToSchema)] +pub struct LinkPreview { + pub link: Link, + pub creator: Identity, +} +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Receipt { + pub version: u8, + pub id: Uuid, + pub creator_id: Uuid, + pub creator_fingerprint: String, + pub redeemer_id: Uuid, + pub redeemer_fingerprint: String, +} + +#[utoipa::path(post,path="/friend-links",tag="friends",request_body=CreateLink,responses((status=200,body=CreatedLink)),security(("bearer"=[])))] +pub(super) async fn create( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result, AppError> { + if body.label.is_empty() + || body.label.len() > 64 + || !body + .label + .bytes() + .all(|b| b.is_ascii_lowercase() || b == b'-') + { + return Err(bad("label must contain 1-64 lowercase letters or hyphens")); + } + if body.target_id == Some(user) { + return Err(bad("Cannot target yourself")); + } + let now = Utc::now(); + let expires = body.expires_at.unwrap_or(now + chrono::Duration::hours(24)); + if expires <= now || expires > now + chrono::Duration::days(30) { + return Err(bad("Link expiry must be in the next 30 days")); + } + // Historical registrations only parsed keys; reject unusable identities + // before publishing an invitation that clients cannot safely accept. + identity(&state.db, user).await?; + if let Some(target) = body.target_id { + identity(&state.db, target).await?; + } + let mut random = [0u8; 32]; + rand::rng().fill_bytes(&mut random); + let token = hex::encode(random); + let mut tx = state.db.begin().await?; + lock_users(&mut tx, user, body.target_id.unwrap_or(user)).await?; + rate(&mut tx, user, "link-create", 100).await?; + let active: i64=sqlx::query_scalar("SELECT count(*) FROM friend_links WHERE creator_id=$1 AND redeemed_by IS NULL AND revoked_at IS NULL AND expires_at>now()") + .bind(user).fetch_one(&mut *tx).await?; + if active >= 100 { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + "Too many active friend links", + ) + .into()); + } + let row:LinkRow=sqlx::query_as("INSERT INTO friend_links(id,creator_id,target_id,label,token_hash,expires_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING *") + .bind(Uuid::new_v4()).bind(user).bind(body.target_id).bind(body.label).bind(hash(&token)).bind(expires).fetch_one(&mut *tx).await?; + tx.commit().await?; + Ok(Json(CreatedLink { + link: row.public(&state.db).await?, + token, + })) +} +#[utoipa::path(get,path="/friend-links",tag="friends",params(("before"=Option,Query),("limit"=Option,Query)),responses((status=200,body=Vec)),security(("bearer"=[])))] +pub(super) async fn list( + State(state): State, + UserId(user): UserId, + Query(page): Query, +) -> Result>, AppError> { + let limit = page.limit()?; + let before_time: Option> = match page.before { + Some(id) => Some( + sqlx::query_scalar("SELECT created_at FROM friend_links WHERE id=$1 AND creator_id=$2") + .bind(id) + .bind(user) + .fetch_optional(&*state.db) + .await? + .ok_or_else(not_found)?, + ), + None => None, + }; + let rows:Vec=sqlx::query_as("SELECT * FROM friend_links WHERE creator_id=$1 AND ($2::timestamptz IS NULL OR (created_at,id)<($2,$3)) ORDER BY created_at DESC,id DESC LIMIT $4") + .bind(user).bind(before_time).bind(page.before).bind(limit).fetch_all(&*state.db).await?; + let mut result = Vec::new(); + for row in rows { + result.push(row.public(&state.db).await?); + } + Ok(Json(result)) +} +#[utoipa::path(delete,path="/friend-links/{id}",tag="friends",params(("id"=Uuid,Path)),responses((status=204)),security(("bearer"=[])))] +pub(super) async fn revoke( + State(state): State, + UserId(user): UserId, + Path(id): Path, +) -> Result { + let result=sqlx::query("UPDATE friend_links SET revoked_at=COALESCE(revoked_at,now()) WHERE id=$1 AND creator_id=$2 AND redeemed_by IS NULL") + .bind(id).bind(user).execute(&*state.db).await?; + if result.rows_affected() == 0 { + return Err(not_found()); + } + Ok(StatusCode::NO_CONTENT) +} +// Commit failed-attempt usage separately: rolling it back would make the cap bypassable. +pub(super) async fn claim_attempt(state: &AppState, user: Uuid) -> Result<(), AppError> { + let mut tx = state.db.begin().await?; + rate(&mut tx, user, "link-claim", 1000).await?; + tx.commit().await?; + Ok(()) +} +#[utoipa::path(post,path="/friend-links/preview",tag="friends",request_body=Claim,responses((status=200,body=LinkPreview)),security(("bearer"=[])))] +pub(super) async fn preview( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result, AppError> { + claim_attempt(&state, user).await?; + let row: LinkRow = sqlx::query_as("SELECT * FROM friend_links WHERE id=$1") + .bind(body.id) + .fetch_optional(&*state.db) + .await? + .ok_or_else(not_found)?; + row.validate(user, &body.token)?; + let creator = identity(&state.db, row.creator_id).await?; + Ok(Json(LinkPreview { + link: row.public(&state.db).await?, + creator, + })) +} +#[utoipa::path(post,path="/friend-links/redeem",tag="friends",request_body=Redeem,responses((status=200,body=LinkPreview)),security(("bearer"=[])))] +pub(super) async fn redeem( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result, AppError> { + claim_attempt(&state, user).await?; + if body.receipt.len() > 32 * 1024 { + return Err(bad("Receipt too large")); + } + // Read creator first to preserve the same user-lock order as remove/send. + let creator_id: Uuid = sqlx::query_scalar("SELECT creator_id FROM friend_links WHERE id=$1") + .bind(body.id) + .fetch_optional(&*state.db) + .await? + .ok_or_else(not_found)?; + let creator = identity(&state.db, creator_id).await?; + let redeemer = identity(&state.db, user).await?; + let key = SignedPublicKey::from_string(&redeemer.public_key) + .map_err(|_| bad("Invalid public key"))? + .0; + let mut signed = Message::from_string(&body.receipt) + .map_err(|_| bad("Invalid signed receipt"))? + .0; + let data = signed + .as_data_string() + .map_err(|_| bad("Invalid receipt payload"))?; + signed + .verify(&key) + .map_err(|_| bad("Invalid receipt signature"))?; + let receipt: Receipt = serde_json::from_str(&data).map_err(|_| bad("Invalid receipt JSON"))?; + if receipt.version != 1 + || receipt.id != body.id + || receipt.creator_id != creator_id + || receipt.creator_fingerprint != creator.fingerprint + || receipt.redeemer_id != user + || receipt.redeemer_fingerprint != redeemer.fingerprint + { + return Err(bad("Receipt identity mismatch")); + } + let mut tx = state.db.begin().await?; + lock_users(&mut tx, user, creator_id).await?; + let row: LinkRow = sqlx::query_as("SELECT * FROM friend_links WHERE id=$1 FOR UPDATE") + .bind(body.id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(not_found)?; + row.validate(user, &body.token)?; + if row.redeemed_by == Some(user) { + tx.commit().await?; + return Ok(Json(LinkPreview { + link: row.public(&state.db).await?, + creator, + })); + } + for account in [user, creator_id] { + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM friendships WHERE user_low=$1 OR user_high=$1", + ) + .bind(account) + .fetch_one(&mut *tx) + .await?; + let exists:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM friendships WHERE user_low=LEAST($1,$2) AND user_high=GREATEST($1,$2))").bind(user).bind(creator_id).fetch_one(&mut *tx).await?; + if count >= 1000 && !exists { + return Err((StatusCode::TOO_MANY_REQUESTS, "Friend limit reached").into()); + } + } + sqlx::query("INSERT INTO friendships(user_low,user_high) VALUES(LEAST($1,$2),GREATEST($1,$2)) ON CONFLICT DO NOTHING").bind(user).bind(creator_id).execute(&mut *tx).await?; + let row:LinkRow=sqlx::query_as("UPDATE friend_links SET redeemed_by=$2,redeemed_at=now(),receipt=$3 WHERE id=$1 RETURNING *").bind(body.id).bind(user).bind(&body.receipt).fetch_one(&mut *tx).await?; + tx.commit().await?; + Ok(Json(LinkPreview { + link: row.public(&state.db).await?, + creator, + })) +} diff --git a/src/routes/v2/social/messages.rs b/src/routes/v2/social/messages.rs new file mode 100644 index 0000000..1d3ac14 --- /dev/null +++ b/src/routes/v2/social/messages.rs @@ -0,0 +1,210 @@ +use super::*; +use pgp::composed::ArmorOptions; +pub(super) fn router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(send, list)) + .routes(routes!(get, delete)) +} +#[derive(Deserialize, ToSchema)] +pub struct SendMessage { + pub id: Uuid, + pub recipient_id: Uuid, + pub ciphertext: String, + pub expires_at: Option>, +} +#[derive(Serialize, FromRow, ToSchema)] +pub struct SecretMessage { + pub id: Uuid, + pub sender_id: Uuid, + pub recipient_id: Uuid, + pub created_at: DateTime, + pub expires_at: Option>, + pub sender_public_key: String, + pub recipient_public_key: String, + pub ciphertext: Option, +} +#[derive(FromRow)] +struct StoredMessage { + id: Uuid, + sender_id: Uuid, + recipient_id: Uuid, + created_at: DateTime, + expires_at: Option>, + sender_public_key: String, + recipient_public_key: String, + ciphertext: Option, + ciphertext_hash: String, + sender_deleted: bool, + recipient_deleted: bool, +} +impl StoredMessage { + fn public(self, include_ciphertext: bool) -> SecretMessage { + SecretMessage { + id: self.id, + sender_id: self.sender_id, + recipient_id: self.recipient_id, + created_at: self.created_at, + expires_at: self.expires_at, + sender_public_key: if include_ciphertext { + self.sender_public_key + } else { + String::new() + }, + recipient_public_key: if include_ciphertext { + self.recipient_public_key + } else { + String::new() + }, + ciphertext: if include_ciphertext { + self.ciphertext + } else { + None + }, + } + } + fn visible(&self, user: Uuid) -> bool { + self.expires_at.is_none_or(|at| at > Utc::now()) + && self.ciphertext.is_some() + && ((self.sender_id == user && !self.sender_deleted) + || (self.recipient_id == user && !self.recipient_deleted)) + } +} +#[utoipa::path(post,path="/messages",tag="messages",request_body=SendMessage,responses((status=200,body=SecretMessage)),security(("bearer"=[])))] +pub(super) async fn send( + State(state): State, + UserId(user): UserId, + Json(mut body): Json, +) -> Result, AppError> { + // PostgreSQL timestamps have microsecond precision; normalize before storage. + body.expires_at = body + .expires_at + .and_then(|at| DateTime::from_timestamp_micros(at.timestamp_micros())); + if body.recipient_id == user { + return Err(bad("Cannot send to yourself")); + } + if body.ciphertext.is_empty() || body.ciphertext.len() > 128 * 1024 { + return Err(( + StatusCode::PAYLOAD_TOO_LARGE, + "Ciphertext must contain 1-131072 bytes", + ) + .into()); + } + // Opportunistic bounded cleanup; expiry access checks do not depend on cleanup. + sqlx::query("WITH expired AS (SELECT id FROM secret_messages WHERE expires_at<=now() AND ciphertext IS NOT NULL ORDER BY expires_at LIMIT 1000 FOR UPDATE SKIP LOCKED) UPDATE secret_messages SET ciphertext=NULL,sender_public_key='',recipient_public_key='' FROM expired WHERE secret_messages.id=expired.id") + .execute(&*state.db).await?; + let mut tx = state.db.begin().await?; + lock_users(&mut tx, user, body.recipient_id).await?; + // Retain the digest after deletion so a retried request cannot resurrect a secret. + let existing: Option = + sqlx::query_as("SELECT * FROM secret_messages WHERE id=$1") + .bind(body.id) + .fetch_optional(&mut *tx) + .await?; + if let Some(existing) = existing { + if existing.sender_id != user + || existing.recipient_id != body.recipient_id + || existing.ciphertext_hash != hash(&body.ciphertext) + || existing.expires_at.map(|at| at.timestamp_micros()) + != body.expires_at.map(|at| at.timestamp_micros()) + { + return Err(conflict("Message ID already used")); + } + tx.commit().await?; + return Ok(Json(existing.public(false))); + } + if body.expires_at.is_some_and(|expiry| expiry <= Utc::now()) { + return Err(bad("Message expiry must be in the future")); + } + let friends:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM friendships WHERE user_low=LEAST($1,$2) AND user_high=GREATEST($1,$2))").bind(user).bind(body.recipient_id).fetch_one(&mut *tx).await?; + if !friends { + return Err((StatusCode::FORBIDDEN, "Recipient must be a friend").into()); + } + let mut keys = Vec::new(); + for account in [user, body.recipient_id] { + // Legacy registrations predate key-size caps. Do not parse unbounded armor. + let raw: Option = sqlx::query_scalar("SELECT CASE WHEN octet_length(public_key)<=1048576 THEN public_key ELSE NULL END FROM users WHERE id=$1") + .bind(account).fetch_one(&mut *tx).await?; + keys.push(canonical_key( + raw.as_deref() + .ok_or_else(|| bad("Stored public key exceeds size limit"))?, + )?); + } + let message_bytes = (body.ciphertext.len() + keys[0].len() + keys[1].len()) as i64; + rate(&mut tx, user, "message-send", 1000).await?; + for account in [user, body.recipient_id] { + let (count,bytes):(i64,i64)=sqlx::query_as("SELECT count(*),COALESCE(sum(octet_length(ciphertext)+octet_length(sender_public_key)+octet_length(recipient_public_key)),0)::bigint FROM secret_messages WHERE ((sender_id=$1 AND NOT sender_deleted) OR (recipient_id=$1 AND NOT recipient_deleted)) AND (expires_at IS NULL OR expires_at>now()) AND ciphertext IS NOT NULL") + .bind(account).fetch_one(&mut *tx).await?; + if count >= 1000 || bytes + message_bytes > 20 * 1024 * 1024 { + return Err((StatusCode::TOO_MANY_REQUESTS, "Mailbox quota exceeded").into()); + } + } + let row:StoredMessage=sqlx::query_as("INSERT INTO secret_messages(id,sender_id,recipient_id,ciphertext,ciphertext_hash,sender_public_key,recipient_public_key,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *") + .bind(body.id).bind(user).bind(body.recipient_id).bind(&body.ciphertext).bind(hash(&body.ciphertext)).bind(&keys[0]).bind(&keys[1]).bind(body.expires_at).fetch_one(&mut *tx).await?; + tx.commit().await?; + Ok(Json(row.public(false))) +} +#[utoipa::path(get,path="/messages",tag="messages",params(("before"=Option,Query),("limit"=Option,Query)),responses((status=200,body=Vec)),security(("bearer"=[])))] +pub(super) async fn list( + State(state): State, + UserId(user): UserId, + Query(page): Query, +) -> Result>, AppError> { + let limit = page.limit()?; + // A previously visible cursor remains usable after deletion or expiry, but + // another account's message cannot be used to probe its creation time. + let before_time: Option> = match page.before { + Some(id) => Some(sqlx::query_scalar("SELECT created_at FROM secret_messages WHERE id=$1 AND (sender_id=$2 OR recipient_id=$2)") + .bind(id).bind(user).fetch_optional(&*state.db).await?.ok_or_else(not_found)?), + None => None, + }; + let rows:Vec=sqlx::query_as("SELECT id,sender_id,recipient_id,created_at,expires_at,''::text AS sender_public_key,''::text AS recipient_public_key,NULL::text AS ciphertext FROM secret_messages WHERE ((sender_id=$1 AND NOT sender_deleted) OR (recipient_id=$1 AND NOT recipient_deleted)) AND (expires_at IS NULL OR expires_at>now()) AND ciphertext IS NOT NULL AND ($2::timestamptz IS NULL OR (created_at,id)<($2,$3)) ORDER BY created_at DESC,id DESC LIMIT $4") + .bind(user).bind(before_time).bind(page.before).bind(limit).fetch_all(&*state.db).await?; + Ok(Json(rows)) +} +#[utoipa::path(get,path="/messages/{id}",tag="messages",params(("id"=Uuid,Path)),responses((status=200,body=SecretMessage)),security(("bearer"=[])))] +pub(super) async fn get( + State(state): State, + UserId(user): UserId, + Path(id): Path, +) -> Result, AppError> { + let row: StoredMessage = sqlx::query_as( + "SELECT * FROM secret_messages WHERE id=$1 AND (sender_id=$2 OR recipient_id=$2)", + ) + .bind(id) + .bind(user) + .fetch_optional(&*state.db) + .await? + .ok_or_else(not_found)?; + if !row.visible(user) { + return Err(not_found()); + } + Ok(Json(row.public(true))) +} +#[utoipa::path(delete,path="/messages/{id}",tag="messages",params(("id"=Uuid,Path)),responses((status=204)),security(("bearer"=[])))] +pub(super) async fn delete( + State(state): State, + UserId(user): UserId, + Path(id): Path, +) -> Result { + let result=sqlx::query("UPDATE secret_messages SET sender_deleted=sender_deleted OR sender_id=$2,recipient_deleted=recipient_deleted OR recipient_id=$2,ciphertext=CASE WHEN (sender_deleted OR sender_id=$2) AND (recipient_deleted OR recipient_id=$2) THEN NULL ELSE ciphertext END,sender_public_key=CASE WHEN (sender_deleted OR sender_id=$2) AND (recipient_deleted OR recipient_id=$2) THEN '' ELSE sender_public_key END,recipient_public_key=CASE WHEN (sender_deleted OR sender_id=$2) AND (recipient_deleted OR recipient_id=$2) THEN '' ELSE recipient_public_key END WHERE id=$1 AND (sender_id=$2 OR recipient_id=$2)") + .bind(id).bind(user).execute(&*state.db).await?; + if result.rows_affected() == 0 { + return Err(not_found()); + } + Ok(StatusCode::NO_CONTENT) +} + +fn canonical_key(raw: &str) -> Result { + let key = SignedPublicKey::from_string(raw) + .map_err(|_| bad("Invalid stored public key"))? + .0; + key.verify() + .map_err(|_| bad("Invalid stored public key signatures"))?; + let canonical = key + .to_armored_string(ArmorOptions::default()) + .map_err(|_| bad("Invalid stored public key"))?; + if canonical.len() > 128 * 1024 { + return Err(bad("Canonical public key exceeds size limit")); + } + Ok(canonical) +} diff --git a/src/routes/v2/social/mod.rs b/src/routes/v2/social/mod.rs new file mode 100644 index 0000000..bc8755e --- /dev/null +++ b/src/routes/v2/social/mod.rs @@ -0,0 +1,176 @@ +//! Friend-only handoffs. Locks on user rows serialize pair changes and mailbox quotas. +use crate::{extractors::user::UserId, AppError, AppState, Json, State}; +use axum::{ + extract::{Path, Query}, + http::StatusCode, +}; +use chrono::{DateTime, Utc}; +use pgp::{ + composed::{Deserializable, SignedPublicKey}, + types::KeyDetails, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Postgres, Transaction}; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; +use uuid::Uuid; + +mod links; +mod messages; +#[cfg(test)] +mod tests; + +pub fn router(state: AppState) -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(friends, remove_friend)) + .merge(links::router()) + .merge(messages::router()) + .with_state(state) +} + +#[derive(Serialize, ToSchema)] +pub struct Identity { + pub id: Uuid, + pub username: String, + pub public_key: String, + pub fingerprint: String, +} + +async fn identity(pool: &sqlx::PgPool, id: Uuid) -> Result { + let (username, public_key): (String, String) = + sqlx::query_as("SELECT username, public_key FROM users WHERE id=$1") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(not_found)?; + // Legacy registrations predate the smaller registration limit. Bound parsing + // without rejecting historical keys that still fit the authentication limit. + if public_key.len() > 1024 * 1024 { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid stored public key", + ) + .into()); + } + let key = SignedPublicKey::from_string(&public_key) + .map_err(|_| { + AppError::Generic( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid stored public key".into(), + ) + })? + .0; + key.verify().map_err(|_| { + AppError::Generic( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid stored public key".into(), + ) + })?; + Ok(Identity { + id, + username, + fingerprint: hex::encode(key.fingerprint().as_bytes()), + public_key, + }) +} + +fn not_found() -> AppError { + (StatusCode::NOT_FOUND, "Not found").into() +} +fn bad(message: &str) -> AppError { + (StatusCode::BAD_REQUEST, message.to_string()).into() +} +fn conflict(message: &str) -> AppError { + (StatusCode::CONFLICT, message.to_string()).into() +} +fn hash(value: &str) -> String { + crypto_hash::hex_digest(crypto_hash::Algorithm::SHA256, value.as_bytes()) +} + +async fn lock_users(tx: &mut Transaction<'_, Postgres>, a: Uuid, b: Uuid) -> Result<(), AppError> { + let rows: Vec = + sqlx::query_scalar("SELECT id FROM users WHERE id=$1 OR id=$2 ORDER BY id FOR UPDATE") + .bind(a) + .bind(b) + .fetch_all(&mut **tx) + .await?; + if rows.len() != if a == b { 1 } else { 2 } { + return Err(not_found()); + } + Ok(()) +} +async fn rate( + tx: &mut Transaction<'_, Postgres>, + user: Uuid, + kind: &str, + cap: i64, +) -> Result<(), AppError> { + let count: Option = sqlx::query_scalar("INSERT INTO social_daily_usage(user_id,kind,count) VALUES($1,$2,1) ON CONFLICT(user_id,day,kind) DO UPDATE SET count=social_daily_usage.count+1 WHERE social_daily_usage.count<$3 RETURNING count") + .bind(user).bind(kind).bind(cap).fetch_optional(&mut **tx).await?; + if count.is_none() { + return Err((StatusCode::TOO_MANY_REQUESTS, "Daily limit reached").into()); + } + Ok(()) +} + +#[derive(Deserialize, ToSchema, Default)] +pub struct Page { + pub before: Option, + pub limit: Option, +} +impl Page { + fn limit(&self) -> Result { + let value = self.limit.unwrap_or(50); + if !(1..=100).contains(&value) { + return Err(bad("limit must be between 1 and 100")); + } + Ok(value) + } +} +#[derive(Serialize, ToSchema)] +pub struct Friend { + pub user: Identity, + pub created_at: DateTime, + /// Signed redemption evidence; verify locally before accepting a new key. + pub receipts: Vec, +} + +#[utoipa::path(get, path="/friends", tag="friends", params(("before"=Option,Query),("limit"=Option,Query)), responses((status=200,body=Vec)), security(("bearer"=[])))] +async fn friends( + State(state): State, + UserId(user): UserId, + Query(page): Query, +) -> Result>, AppError> { + let rows: Vec<(Uuid, DateTime)> = sqlx::query_as("SELECT CASE WHEN user_low=$1 THEN user_high ELSE user_low END AS friend_id,created_at FROM friendships WHERE (user_low=$1 OR user_high=$1) AND ($2::uuid IS NULL OR (CASE WHEN user_low=$1 THEN user_high ELSE user_low END)<$2) ORDER BY friend_id DESC LIMIT $3") + .bind(user).bind(page.before).bind(page.limit()?).fetch_all(&*state.db).await?; + let mut result = Vec::new(); + for (friend_id, created_at) in rows { + let receipts: Vec=sqlx::query_scalar("SELECT receipt FROM friend_links WHERE ((creator_id=$1 AND redeemed_by=$2) OR (creator_id=$2 AND redeemed_by=$1)) AND receipt IS NOT NULL ORDER BY redeemed_at DESC LIMIT 10") + .bind(user).bind(friend_id).fetch_all(&*state.db).await?; + result.push(Friend { + user: identity(&state.db, friend_id).await?, + created_at, + receipts, + }); + } + Ok(Json(result)) +} + +#[utoipa::path(delete, path="/friends/{user_id}", tag="friends", params(("user_id"=Uuid,Path)), responses((status=204)), security(("bearer"=[])))] +async fn remove_friend( + State(state): State, + UserId(user): UserId, + Path(friend): Path, +) -> Result { + let mut tx = state.db.begin().await?; + lock_users(&mut tx, user, friend).await?; + sqlx::query( + "DELETE FROM friendships WHERE user_low=LEAST($1,$2) AND user_high=GREATEST($1,$2)", + ) + .bind(user) + .bind(friend) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/routes/v2/social/tests.rs b/src/routes/v2/social/tests.rs new file mode 100644 index 0000000..dd15758 --- /dev/null +++ b/src/routes/v2/social/tests.rs @@ -0,0 +1,646 @@ +use super::*; +use pgp::{ + composed::{ArmorOptions, KeyType, MessageBuilder, SecretKeyParamsBuilder, SignedSecretKey}, + crypto::hash::HashAlgorithm, +}; +use std::sync::Arc; + +fn state(pool: sqlx::PgPool) -> AppState { + AppState { + db: Arc::new(pool), + caps: Arc::new(crate::config::Caps::from_env()), + } +} +async fn user(pool: &sqlx::PgPool) -> (Uuid, SignedSecretKey) { + let key = SecretKeyParamsBuilder::default() + .key_type(KeyType::Ed25519Legacy) + .can_sign(true) + .primary_user_id("test".into()) + .build() + .unwrap() + .generate(rand_08::rngs::OsRng) + .unwrap() + .sign(rand_08::rngs::OsRng, &"".into()) + .unwrap(); + let public = SignedPublicKey::from(key.clone()) + .to_armored_string(ArmorOptions::default()) + .unwrap(); + let id = + sqlx::query_scalar("INSERT INTO users(username,public_key) VALUES('test',$1) RETURNING id") + .bind(public) + .fetch_one(pool) + .await + .unwrap(); + (id, key) +} +fn ok(result: Result) -> T { + match result { + Ok(v) => v, + Err(e) => { + use axum::response::IntoResponse; + panic!("request failed: {}", e.into_response().status()) + } + } +} +async fn link(pool: &sqlx::PgPool, owner: Uuid, target: Option) -> links::CreatedLink { + ok(links::create( + State(state(pool.clone())), + UserId(owner), + Json(links::CreateLink { + label: "amber-otter".into(), + target_id: target, + expires_at: None, + }), + ) + .await) + .0 +} +async fn receipt( + pool: &sqlx::PgPool, + link: &links::CreatedLink, + id: Uuid, + key: &SignedSecretKey, +) -> String { + let creator = ok(identity(pool, link.link.creator_id).await); + let redeemer = ok(identity(pool, id).await); + let payload = links::Receipt { + version: 1, + id: link.link.id, + creator_id: creator.id, + creator_fingerprint: creator.fingerprint, + redeemer_id: id, + redeemer_fingerprint: redeemer.fingerprint, + }; + sign(key, &serde_json::to_string(&payload).unwrap()) +} +fn sign(key: &SignedSecretKey, data: &str) -> String { + let mut builder = MessageBuilder::from_bytes("", data.as_bytes().to_vec()); + builder.sign(&key.primary_key, "".into(), HashAlgorithm::Sha256); + builder + .to_armored_string(rand_08::rngs::OsRng, ArmorOptions::default()) + .unwrap() +} +async fn redeem( + pool: &sqlx::PgPool, + link: &links::CreatedLink, + id: Uuid, + key: &SignedSecretKey, +) -> Result, AppError> { + links::redeem( + State(state(pool.clone())), + UserId(id), + Json(links::Redeem { + id: link.link.id, + token: link.token.clone(), + receipt: receipt(pool, link, id, key).await, + }), + ) + .await +} +async fn befriend(pool: &sqlx::PgPool, a: Uuid, b: Uuid, key: &SignedSecretKey) { + let link = link(pool, a, None).await; + let _ = ok(redeem(pool, &link, b, key).await); +} +fn body(id: Uuid, to: Uuid) -> messages::SendMessage { + messages::SendMessage { + id, + recipient_id: to, + ciphertext: "opaque ciphertext".into(), + expires_at: None, + } +} + +#[sqlx::test] +async fn concurrent_claim_one_winner_and_recovery(pool: sqlx::PgPool) { + let (owner, _) = user(&pool).await; + let (a, ak) = user(&pool).await; + let (b, bk) = user(&pool).await; + let link = link(&pool, owner, None).await; + let (ra, rb) = tokio::join!(redeem(&pool, &link, a, &ak), redeem(&pool, &link, b, &bk)); + assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); + let (winner, key) = if ra.is_ok() { (a, &ak) } else { (b, &bk) }; + let repeated = ok(redeem(&pool, &link, winner, key).await).0; + assert_eq!(repeated.link.redeemed_by.unwrap().id, winner); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM friendships") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + ok(remove_friend(State(state(pool.clone())), UserId(owner), Path(winner)).await); + let _ = ok(redeem(&pool, &link, winner, key).await); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM friendships") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "recovery must not restore a removed friendship"); +} +#[sqlx::test] +async fn wrong_target_invalid_signature_and_expiry_do_not_consume(pool: sqlx::PgPool) { + let (owner, _) = user(&pool).await; + let (a, ak) = user(&pool).await; + let (b, bk) = user(&pool).await; + let link = link(&pool, owner, Some(a)).await; + assert!(redeem(&pool, &link, b, &bk).await.is_err()); + assert!(redeem(&pool, &link, a, &bk).await.is_err()); + assert!(links::preview( + State(state(pool.clone())), + UserId(b), + Json(links::Claim { + id: link.link.id, + token: link.token.clone() + }) + ) + .await + .is_err()); + let row: Option = sqlx::query_scalar("SELECT redeemed_by FROM friend_links WHERE id=$1") + .bind(link.link.id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(row.is_none()); + let _ = ok(redeem(&pool, &link, a, &ak).await); + let expired = super::tests::link(&pool, owner, None).await; + sqlx::query("UPDATE friend_links SET expires_at=now()-interval '1 second' WHERE id=$1") + .bind(expired.link.id) + .execute(&pool) + .await + .unwrap(); + assert!(redeem(&pool, &expired, b, &bk).await.is_err()); + assert!(redeem(&pool, &expired, owner, &bk).await.is_err()); +} +#[sqlx::test] +async fn messages_authorization_idempotency_deletion_and_removal(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + let (c, _) = user(&pool).await; + let id = Uuid::new_v4(); + assert!( + messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))) + .await + .is_err() + ); + befriend(&pool, a, b, &bk).await; + let (r1, r2) = tokio::join!( + messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))), + messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))) + ); + assert!(r1.is_ok() && r2.is_ok()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM secret_messages") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + assert!( + messages::get(State(state(pool.clone())), UserId(c), Path(id)) + .await + .is_err() + ); + assert!( + messages::delete(State(state(pool.clone())), UserId(c), Path(id)) + .await + .is_err() + ); + let listed = ok(messages::list( + State(state(pool.clone())), + UserId(b), + Query(Page::default()), + ) + .await) + .0; + assert_eq!(listed.len(), 1); + assert!(listed[0].ciphertext.is_none()); + ok(messages::delete(State(state(pool.clone())), UserId(a), Path(id)).await); + assert!( + messages::get(State(state(pool.clone())), UserId(a), Path(id)) + .await + .is_err() + ); + assert!( + messages::get(State(state(pool.clone())), UserId(b), Path(id)) + .await + .is_ok() + ); + ok(remove_friend(State(state(pool.clone())), UserId(a), Path(b)).await); + assert!(messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ) + .await + .is_err()); + ok(messages::delete(State(state(pool.clone())), UserId(b), Path(id)).await); + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await); + let ciphertext: Option = + sqlx::query_scalar("SELECT ciphertext FROM secret_messages WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(ciphertext.is_none()); + let mut different = body(id, b); + different.ciphertext = "changed".into(); + assert!( + messages::send(State(state(pool.clone())), UserId(a), Json(different)) + .await + .is_err() + ); +} +#[sqlx::test] +async fn expiry_quota_and_failed_claim_rate(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + befriend(&pool, a, b, &bk).await; + let id = Uuid::new_v4(); + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await); + sqlx::query("UPDATE secret_messages SET expires_at=now()-interval '1 second' WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + assert!( + messages::get(State(state(pool.clone())), UserId(a), Path(id)) + .await + .is_err() + ); + assert!( + messages::get(State(state(pool.clone())), UserId(b), Path(id)) + .await + .is_err() + ); + sqlx::query("INSERT INTO social_daily_usage(user_id,kind,count) VALUES($1,'message-send',999) ON CONFLICT(user_id,day,kind) DO UPDATE SET count=999").bind(a).execute(&pool).await.unwrap(); + let (ra, rb) = tokio::join!( + messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ), + messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ) + ); + assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); + sqlx::query("INSERT INTO social_daily_usage(user_id,kind,count) VALUES($1,'link-claim',999) ON CONFLICT(user_id,day,kind) DO UPDATE SET count=999").bind(a).execute(&pool).await.unwrap(); + for _ in 0..2 { + assert!(links::preview( + State(state(pool.clone())), + UserId(a), + Json(links::Claim { + id: Uuid::new_v4(), + token: "bad".into() + }) + ) + .await + .is_err()); + } + let count: i64 = sqlx::query_scalar( + "SELECT count FROM social_daily_usage WHERE user_id=$1 AND kind='link-claim'", + ) + .bind(a) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1000); +} + +#[sqlx::test] +async fn revoked_links_token_secrecy_and_pagination(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + let created = link(&pool, a, None).await; + let stored: String = sqlx::query_scalar("SELECT token_hash FROM friend_links WHERE id=$1") + .bind(created.link.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!(stored, created.token); + assert_eq!(stored, hash(&created.token)); + assert!(links::preview( + State(state(pool.clone())), + UserId(b), + Json(links::Claim { + id: created.link.id, + token: "0".repeat(64) + }) + ) + .await + .is_err()); + assert!( + links::revoke(State(state(pool.clone())), UserId(b), Path(created.link.id)) + .await + .is_err() + ); + ok(links::revoke(State(state(pool.clone())), UserId(a), Path(created.link.id)).await); + assert!(redeem(&pool, &created, b, &bk).await.is_err()); + let _ = link(&pool, a, None).await; + let first = ok(links::list( + State(state(pool.clone())), + UserId(a), + Query(Page { + before: None, + limit: Some(1), + }), + ) + .await) + .0; + let second = ok(links::list( + State(state(pool.clone())), + UserId(a), + Query(Page { + before: Some(first[0].id), + limit: Some(1), + }), + ) + .await) + .0; + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].id, second[0].id); + let other = ok(links::list( + State(state(pool.clone())), + UserId(b), + Query(Page::default()), + ) + .await) + .0; + assert!(other.is_empty()); +} + +#[sqlx::test] +async fn mailbox_capacity_serializes_and_precise_expiry_retries(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + befriend(&pool, a, b, &bk).await; + let id = Uuid::new_v4(); + let expiry = Utc::now() + chrono::Duration::hours(1); + for _ in 0..2 { + let mut send_body = body(id, b); + send_body.expires_at = Some(expiry); + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(send_body)).await); + } + // Fill all but one slot using fixtures, then contend for the final slot. + sqlx::query("INSERT INTO secret_messages(id,sender_id,recipient_id,ciphertext,ciphertext_hash,sender_public_key,recipient_public_key) SELECT gen_random_uuid(),$1,$2,'fixture','hash','key','key' FROM generate_series(1,998)") + .bind(a).bind(b).execute(&pool).await.unwrap(); + let (ra, rb) = tokio::join!( + messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ), + messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ) + ); + assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM secret_messages") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1000); +} + +#[sqlx::test] +async fn histories_are_chronological_with_owned_cursors_and_ties(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + let (outsider, _) = user(&pool).await; + befriend(&pool, a, b, &bk).await; + let ids = [Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(3)]; + for id in ids { + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await); + } + sqlx::query("UPDATE secret_messages SET created_at='2026-01-01T00:00:00Z'") + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE secret_messages SET created_at='2026-01-02T00:00:00Z' WHERE id=$1") + .bind(ids[0]) + .execute(&pool) + .await + .unwrap(); + let mut cursor = None; + for expected in [ids[0], ids[2], ids[1]] { + let page = ok(messages::list( + State(state(pool.clone())), + UserId(b), + Query(Page { + before: cursor, + limit: Some(1), + }), + ) + .await) + .0; + assert_eq!(page.len(), 1); + assert_eq!(page[0].id, expected); + cursor = Some(expected); + } + assert!(messages::list( + State(state(pool.clone())), + UserId(outsider), + Query(Page { + before: Some(ids[0]), + limit: Some(1) + }) + ) + .await + .is_err()); + let mut link_ids = Vec::new(); + for _ in 0..3 { + link_ids.push(link(&pool, a, None).await.link.id); + } + link_ids.sort(); + sqlx::query( + "UPDATE friend_links SET created_at='2026-01-01T00:00:00Z' WHERE redeemed_by IS NULL", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE friend_links SET created_at='2026-01-02T00:00:00Z' WHERE id=$1") + .bind(link_ids[0]) + .execute(&pool) + .await + .unwrap(); + // Exclude the befriend fixture above from the first page. + sqlx::query( + "UPDATE friend_links SET created_at='2025-01-01T00:00:00Z' WHERE redeemed_by IS NOT NULL", + ) + .execute(&pool) + .await + .unwrap(); + let mut cursor = None; + for expected in [link_ids[0], link_ids[2], link_ids[1]] { + let page = ok(links::list( + State(state(pool.clone())), + UserId(a), + Query(Page { + before: cursor, + limit: Some(1), + }), + ) + .await) + .0; + assert_eq!(page.len(), 1); + assert_eq!(page[0].id, expected); + cursor = Some(expected); + } + assert!(links::list( + State(state(pool.clone())), + UserId(b), + Query(Page { + before: Some(link_ids[0]), + limit: Some(1) + }) + ) + .await + .is_err()); +} + +#[sqlx::test] +async fn key_snapshots_are_canonical_accounted_and_erased(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + befriend(&pool, a, b, &bk).await; + let original: String = sqlx::query_scalar("SELECT public_key FROM users WHERE id=$1") + .bind(a) + .fetch_one(&pool) + .await + .unwrap(); + let padded = original.replacen( + "-----BEGIN PGP PUBLIC KEY BLOCK-----\n", + &format!( + "-----BEGIN PGP PUBLIC KEY BLOCK-----\nComment: {}\n", + "X".repeat(64 * 1024) + ), + 1, + ); + assert!(padded.len() > original.len() + 60 * 1024); + sqlx::query("UPDATE users SET public_key=$2 WHERE id=$1") + .bind(a) + .bind(padded) + .execute(&pool) + .await + .unwrap(); + let id = Uuid::new_v4(); + let sent = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await).0; + assert!(sent.sender_public_key.is_empty()); + assert!(sent.recipient_public_key.is_empty()); + let got = ok(messages::get(State(state(pool.clone())), UserId(b), Path(id)).await).0; + assert_eq!(got.sender_public_key, original); + let listed = ok(messages::list( + State(state(pool.clone())), + UserId(b), + Query(Page::default()), + ) + .await) + .0; + assert!(listed[0].sender_public_key.is_empty()); + assert!(listed[0].recipient_public_key.is_empty()); + // Existing snapshot bytes count even though ciphertext itself is tiny. + sqlx::query("UPDATE secret_messages SET sender_public_key=repeat('k',20971520) WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + assert!(messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ) + .await + .is_err()); + ok(messages::delete(State(state(pool.clone())), UserId(a), Path(id)).await); + ok(messages::delete(State(state(pool.clone())), UserId(b), Path(id)).await); + let (ciphertext, sender, recipient): (Option, String, String) = sqlx::query_as( + "SELECT ciphertext,sender_public_key,recipient_public_key FROM secret_messages WHERE id=$1", + ) + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(ciphertext.is_none()); + assert!(sender.is_empty()); + assert!(recipient.is_empty()); + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await); + let expired = Uuid::new_v4(); + let _ = ok(messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(expired, b)), + ) + .await); + sqlx::query("UPDATE secret_messages SET expires_at=now()-interval '1 hour' WHERE id=$1") + .bind(expired) + .execute(&pool) + .await + .unwrap(); + let _ = ok(messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)), + ) + .await); + let (ciphertext, sender, recipient): (Option, String, String) = sqlx::query_as( + "SELECT ciphertext,sender_public_key,recipient_public_key FROM secret_messages WHERE id=$1", + ) + .bind(expired) + .fetch_one(&pool) + .await + .unwrap(); + assert!(ciphertext.is_none()); + assert!(sender.is_empty()); + assert!(recipient.is_empty()); +} + +#[sqlx::test] +async fn legacy_key_with_invalid_self_signature_cannot_join_social_graph(pool: sqlx::PgPool) { + let (invalid, key) = user(&pool).await; + let (valid, other_key) = user(&pool).await; + let mut public = SignedPublicKey::from(key); + public.details = SignedPublicKey::from(other_key).details; + let armor = public.to_armored_string(ArmorOptions::default()).unwrap(); + let parsed = SignedPublicKey::from_string(&armor).unwrap().0; + assert!( + parsed.verify().is_err(), + "fixture must parse but fail self-signature validation" + ); + sqlx::query("UPDATE users SET public_key=$1 WHERE id=$2") + .bind(armor) + .bind(invalid) + .execute(&pool) + .await + .unwrap(); + assert!(identity(&pool, invalid).await.is_err()); + for (creator, target) in [(invalid, None), (valid, Some(invalid))] { + assert!(links::create( + State(state(pool.clone())), + UserId(creator), + Json(links::CreateLink { + label: "amber-otter".into(), + target_id: target, + expires_at: None, + }) + ) + .await + .is_err()); + } + let invitation = link(&pool, valid, None).await; + assert!(links::redeem( + State(state(pool.clone())), + UserId(invalid), + Json(links::Redeem { + id: invitation.link.id, + token: invitation.token, + receipt: "untrusted".into(), + }) + ) + .await + .is_err()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM friendships") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); +} diff --git a/src/routes/v2/user/mod.rs b/src/routes/v2/user/mod.rs index 68a5766..0a287d5 100644 --- a/src/routes/v2/user/mod.rs +++ b/src/routes/v2/user/mod.rs @@ -1,7 +1,7 @@ - use crate::*; - use crate::extractors::user::UserId; - use utoipa::ToSchema; - use uuid::Uuid; +use crate::extractors::user::UserId; +use crate::*; +use utoipa::ToSchema; +use uuid::Uuid; mod get; mod get_many; diff --git a/src/routes/v2/user/new.rs b/src/routes/v2/user/new.rs index 904fc20..93fa0a1 100644 --- a/src/routes/v2/user/new.rs +++ b/src/routes/v2/user/new.rs @@ -1,5 +1,4 @@ use super::*; -use pgp::composed::{Deserializable, SignedPublicKey}; #[derive(Serialize, Deserialize, ToSchema)] pub struct NewUserBody { @@ -24,13 +23,7 @@ pub async fn new_user_v2( State(state): State, Json(body): Json, ) -> Result { - // public key validation - match SignedPublicKey::from_string(&body.public_key) { - Ok(_) => {} - Err(_) => { - return Err(AppError::Error(Errors::InvalidPublicKey)); - } - } + crate::helpers::registration::validate_public_key(&body.public_key)?; let user = sqlx::query!( "INSERT INTO users (username, public_key) VALUES ($1, $2) RETURNING id", diff --git a/src/routes/v2/variables/delete.rs b/src/routes/v2/variables/delete.rs index 0fd68f1..8c8b23d 100644 --- a/src/routes/v2/variables/delete.rs +++ b/src/routes/v2/variables/delete.rs @@ -18,22 +18,5 @@ pub async fn delete( Path(variable_id): Path, UserId(user_id): UserId, ) -> Result<(), AppError> { - let variable = sqlx::query!( - "SELECT id, value, project_id FROM variables WHERE id = $1", - variable_id - ) - .fetch_one(&*state.db) - .await - .context("Failed to get variable")?; - - if !user_in_project(user_id, variable.project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - sqlx::query!("DELETE FROM variables WHERE id = $1", variable_id) - .execute(&*state.db) - .await - .context("Failed to delete variable")?; - - Ok(()) + crate::helpers::variables::delete(&state, user_id, variable_id).await } diff --git a/src/routes/v2/variables/mod.rs b/src/routes/v2/variables/mod.rs index 763d91d..d139f79 100644 --- a/src/routes/v2/variables/mod.rs +++ b/src/routes/v2/variables/mod.rs @@ -1,8 +1,8 @@ - use crate::*; - use crate::{extractors::user::UserId, helpers::project::user_in_project}; - use axum::extract::Path; - use utoipa::ToSchema; - use uuid::Uuid; +use crate::*; +use crate::{extractors::user::UserId, helpers::project::user_in_project}; +use axum::extract::Path; +use utoipa::ToSchema; +use uuid::Uuid; mod delete; mod get; diff --git a/src/routes/v2/variables/set_many.rs b/src/routes/v2/variables/set_many.rs index b1176d2..a2f2f08 100644 --- a/src/routes/v2/variables/set_many.rs +++ b/src/routes/v2/variables/set_many.rs @@ -1,5 +1,4 @@ use crate::extractors::client_ip::ClientIp; -use crate::helpers::caps; use super::*; @@ -34,37 +33,13 @@ pub async fn set_many( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let project_id = body.project_id; - - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let (values, tags): (Vec<_>, Vec<_>) = body + let (values, tags) = body .variables .into_iter() .map(|v| (v.value, v.tag.unwrap_or_default())) .unzip(); - - let value_refs: Vec<&str> = values.iter().map(String::as_str).collect(); - caps::check_per_value(&state.caps, &value_refs)?; - caps::check_project_for_insert(&state.caps, &state.db, project_id, &value_refs).await?; - let delta: i64 = value_refs.iter().map(|v| v.len() as i64).sum(); - caps::check_user_total(&state.caps, &state.db, user_id, delta).await?; - caps::check_and_record_ip(&state.caps, &state.db, ip, delta).await?; - - let variables = sqlx::query!( - "INSERT INTO variables (value, project_id, tag) - SELECT value, $2::uuid, tag - FROM UNNEST($1::text[], $3::text[]) AS t(value, tag) - RETURNING id", - &values, - project_id, - &tags, - ) - .fetch_all(&*state.db) - .await - .context("Failed to insert variables")?; - - Ok(Json(variables.iter().map(|v| v.id).collect::>())) + Ok(Json( + crate::helpers::variables::insert_many(&state, user_id, ip, body.project_id, values, tags) + .await?, + )) } diff --git a/src/routes/v2/variables/update_many.rs b/src/routes/v2/variables/update_many.rs index 9dd1588..f71e8e7 100644 --- a/src/routes/v2/variables/update_many.rs +++ b/src/routes/v2/variables/update_many.rs @@ -1,9 +1,5 @@ -use std::collections::HashSet; - use crate::extractors::client_ip::ClientIp; -use crate::helpers::caps; -use crate::helpers::caps::check_update_caps_grouped; -use crate::{structs::Variable, traits::to_uuid::ToUuid}; +use crate::structs::Variable; use super::*; @@ -30,69 +26,44 @@ pub async fn update_many( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let project_ids = body - .variables - .iter() - .map(|v| v.project_id.as_str()) - .collect::>() - .iter() - .map(|s| s.to_string().to_uuid()) - .collect::, _>>()?; - - let projects = sqlx::query!( - "SELECT id FROM projects WHERE id = ANY($1::uuid[])", - &project_ids - ) - .fetch_all(&*state.db) - .await - .context("Failed to get projects")?; - - // make sure the user is in all the projects - for project in projects { - if !user_in_project(user_id, project.id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - } - - let all_values: Vec<&str> = body.variables.iter().map(|v| v.value.as_str()).collect(); - caps::check_per_value(&state.caps, &all_values)?; - check_update_caps_grouped( - &state, - user_id, - ip, - body.variables - .iter() - .map(|v| (v.project_id.as_str(), v.id.as_str(), v.value.as_str())) - .collect::>(), - ) - .await?; - - // use UNNEST to update all the variables at once - let variables = sqlx::query!( - "UPDATE variables AS v - SET value = u.value - FROM UNNEST($1::uuid[], $2::text[]) AS u(id, value) - WHERE v.id = u.id - RETURNING v.id", - &body - .variables - .iter() - .map(|v| v.id.to_uuid().unwrap()) - .collect::>(), - &body - .variables - .iter() - .map(|v| v.value.clone()) - .collect::>() - ) - .fetch_all(&*state.db) - .await - .context("Failed to update variables")?; - Ok(Json( - variables - .iter() - .map(|v| v.id.to_string()) - .collect::>(), + crate::helpers::variables::update_many(&state, user_id, ip, body.variables).await?, )) } + +#[cfg(test)] +mod authorization_tests { + use super::*; + use crate::test_support::*; + #[sqlx::test] + async fn mismatched_project_cannot_overwrite_variable(pool: sqlx::PgPool) { + let attacker = user(&pool).await; + let victim = user(&pool).await; + let own = project(&pool, attacker).await; + let other = project(&pool, victim).await; + let id: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(other).fetch_one(&pool).await.unwrap(); + let result = update_many( + State(state(pool.clone())), + UserId(attacker), + ClientIp("127.0.0.1".parse().unwrap()), + Json(UpdateManyBody { + variables: vec![Variable { + id: id.to_string(), + project_id: own.to_string(), + value: "tampered".into(), + }], + }), + ) + .await; + assert!( + result.is_err(), + "must reject a variable belonging to another project" + ); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "original"); + } +} diff --git a/src/routes/variables.rs b/src/routes/variables.rs index 202b70f..729b025 100644 --- a/src/routes/variables.rs +++ b/src/routes/variables.rs @@ -1,8 +1,4 @@ -use std::collections::HashSet; - use crate::extractors::client_ip::ClientIp; -use crate::helpers::caps; -use crate::helpers::caps::check_update_caps_grouped; use crate::structs::Variable; use crate::traits::to_uuid::ToUuid; use crate::*; @@ -28,31 +24,17 @@ pub async fn new_variable( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result, AppError> { - let project_id = body.project_id.to_uuid()?; - - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let values = [body.value.as_str()]; - caps::check_per_value(&state.caps, &values)?; - caps::check_project_for_insert(&state.caps, &state.db, project_id, &values).await?; - let delta = body.value.len() as i64; - caps::check_user_total(&state.caps, &state.db, user_id, delta).await?; - caps::check_and_record_ip(&state.caps, &state.db, ip, delta).await?; - - let variable = sqlx::query!( - "INSERT INTO variables (value, project_id, tag) VALUES ($1, $2, $3) RETURNING id", - body.value, - project_id, - body.tag.unwrap_or_default() + let ids = crate::helpers::variables::insert_many( + &state, + user_id, + ip, + body.project_id.to_uuid()?, + vec![body.value], + vec![body.tag.unwrap_or_default()], ) - .fetch_one(&*state.db) - .await - .context("Failed to insert variable")?; - + .await?; Ok(Json(NewVariableReturnType { - id: variable.id.to_string(), + id: ids[0].to_string(), })) } @@ -60,6 +42,8 @@ pub async fn new_variable( pub struct SetManyBody { project_id: String, variables: Vec, + #[serde(default)] + replace_ids: Vec, } #[derive(Serialize, Deserialize)] @@ -73,35 +57,19 @@ pub async fn set_many_variables( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let project_id = body.project_id.to_uuid()?; - - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let values: Vec<&str> = body.variables.iter().map(String::as_str).collect(); - caps::check_per_value(&state.caps, &values)?; - caps::check_project_for_insert(&state.caps, &state.db, project_id, &values).await?; - let delta: i64 = values.iter().map(|v| v.len() as i64).sum(); - caps::check_user_total(&state.caps, &state.db, user_id, delta).await?; - caps::check_and_record_ip(&state.caps, &state.db, ip, delta).await?; - - let variables = sqlx::query!( - "INSERT INTO variables (value, project_id) SELECT * FROM UNNEST($1::text[], $2::uuid[]) RETURNING id", - &body.variables, - &vec![project_id; body.variables.len()] + let ids = crate::helpers::variables::replace_many( + &state, + user_id, + ip, + body.project_id.to_uuid()?, + body.variables, + body.replace_ids, ) - .fetch_all(&*state.db) - .await - .context("Failed to insert variables")?; - + .await?; Ok(Json( - variables - .iter() - .map(|v| SetManyReturnType { - id: v.id.to_string(), - }) - .collect::>(), + ids.into_iter() + .map(|id| SetManyReturnType { id: id.to_string() }) + .collect(), )) } @@ -137,58 +105,8 @@ pub async fn update_many_variables( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let projects = body - .variables - .iter() - .map(|v| v.project_id.as_str()) - .collect::>() - .iter() - .map(|s| s.to_string().to_uuid().unwrap()) - .collect::>(); - - let projects = sqlx::query!( - "SELECT id FROM projects WHERE id = ANY($1::uuid[])", - &projects - ) - .fetch_all(&*state.db) - .await - .context("Failed to get projects")?; - - // make sure the user is in all the projects - for project in projects { - if !user_in_project(user_id, project.id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - } - - let all_values: Vec<&str> = body.variables.iter().map(|v| v.value.as_str()).collect(); - caps::check_per_value(&state.caps, &all_values)?; - check_update_caps_grouped( - &state, - user_id, - ip, - body.variables - .iter() - .map(|v| (v.project_id.as_str(), v.id.as_str(), v.value.as_str())) - .collect::>(), - ) - .await?; - - // use UNNEST to update all the variables at once - let variables = sqlx::query!( - "UPDATE variables AS v SET value = u.value FROM UNNEST($1::uuid[], $2::text[]) AS u(id, value) WHERE v.id = u.id RETURNING v.id", - &body.variables.iter().map(|v| v.id.to_uuid().unwrap()).collect::>(), - &body.variables.iter().map(|v| v.value.clone()).collect::>() - ) - .fetch_all(&*state.db) - .await - .context("Failed to update variables")?; - Ok(Json( - variables - .iter() - .map(|v| v.id.to_string()) - .collect::>(), + crate::helpers::variables::update_many(&state, user_id, ip, body.variables).await?, )) } @@ -197,24 +115,7 @@ pub async fn delete_variable( Path(variable_id): Path, UserId(user_id): UserId, ) -> Result<(), AppError> { - let variable = sqlx::query!( - "SELECT id, value, project_id FROM variables WHERE id = $1", - variable_id - ) - .fetch_one(&*state.db) - .await - .context("Failed to get variable")?; - - if !user_in_project(user_id, variable.project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - sqlx::query!("DELETE FROM variables WHERE id = $1", variable_id) - .execute(&*state.db) - .await - .context("Failed to delete variable")?; - - Ok(()) + crate::helpers::variables::delete(&state, user_id, variable_id).await } #[derive(Serialize, Deserialize)] @@ -240,54 +141,88 @@ pub async fn set_many_variables_v2( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let project_id = body.project_id.to_uuid()?; - - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let values: Vec<&str> = body.variables.iter().map(|v| v.value.as_str()).collect(); - caps::check_per_value(&state.caps, &values)?; - caps::check_project_for_insert(&state.caps, &state.db, project_id, &values).await?; - let delta: i64 = values.iter().map(|v| v.len() as i64).sum(); - caps::check_user_total(&state.caps, &state.db, user_id, delta).await?; - caps::check_and_record_ip(&state.caps, &state.db, ip, delta).await?; - - let variables = sqlx::query!( - "INSERT INTO variables (value, project_id, tag) SELECT * FROM UNNEST($1::text[], $2::uuid[], $3::text[]) RETURNING id", - &body.variables.iter().map(|v| v.value.clone()).collect::>(), - &vec![project_id; body.variables.len()], - // &body.variables.iter().map(|v| v.tag.clone()).collect::>>() - &body.variables.iter().map(|v| v.tag.clone().unwrap_or_default()).collect::>() + let (values, tags) = body + .variables + .into_iter() + .map(|v| (v.value, v.tag.unwrap_or_default())) + .unzip(); + let ids = crate::helpers::variables::insert_many( + &state, + user_id, + ip, + body.project_id.to_uuid()?, + values, + tags, ) - .fetch_all(&*state.db) - .await - .context("Failed to insert variables")?; - + .await?; Ok(Json( - variables - .iter() - .map(|v| V2SetManyReturnType { - id: v.id.to_string(), - }) - .collect::>(), + ids.into_iter() + .map(|id| V2SetManyReturnType { id: id.to_string() }) + .collect(), )) +} - // let variables = sqlx::query!( - // "INSERT INTO variables (value, project_id) SELECT * FROM UNNEST($1::text[], $2::uuid[]) RETURNING id", - // &body.variables, - // &vec![project_id; body.variables.len()] - // ) - // .fetch_all(&*state.db) - // .await - // .context("Failed to insert variables")?; - // - // Ok(Json( - // variables - // .iter() - // .map(|v| SetManyReturnType { - // id: v.id.to_string(), - // }) - // .collect::>(), - // )) +#[cfg(test)] +mod authorization_tests { + use super::*; + use crate::test_support::*; + #[sqlx::test] + async fn mismatched_project_cannot_overwrite_variable(pool: sqlx::PgPool) { + let attacker = user(&pool).await; + let victim = user(&pool).await; + let own = project(&pool, attacker).await; + let other = project(&pool, victim).await; + let id: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(other).fetch_one(&pool).await.unwrap(); + let result = update_many_variables( + State(state(pool.clone())), + UserId(attacker), + ClientIp("127.0.0.1".parse().unwrap()), + Json(UpdateManyBody { + variables: vec![Variable { + id: id.to_string(), + project_id: own.to_string(), + value: "tampered".into(), + }], + }), + ) + .await; + assert!( + result.is_err(), + "must reject a variable belonging to another project" + ); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "original"); + } +} + +#[cfg(test)] +mod replacement_tests { + use super::*; + use crate::test_support::*; + #[sqlx::test] + async fn replacement_removes_old_values_only_on_success(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let project = project(&pool, owner).await; + let id: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(&pool).await.unwrap(); + let body=serde_json::from_value(serde_json::json!({"project_id":project,"variables":["replacement"],"replace_ids":[id]})).unwrap(); + assert!(set_many_variables( + State(state(pool.clone())), + UserId(owner), + ClientIp("127.0.0.1".parse().unwrap()), + Json(body) + ) + .await + .is_ok()); + let values: Vec = + sqlx::query_scalar("SELECT value FROM variables WHERE project_id=$1") + .bind(project) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(values, vec!["replacement"]); + } } diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..6d3aa83 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,37 @@ +use crate::{config::Caps, state::AppState}; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; +pub fn state(pool: PgPool) -> AppState { + AppState { + db: Arc::new(pool), + caps: Arc::new(Caps { + max_variable_bytes: 1024, + max_variables_per_project: 256, + max_project_bytes: 0, + max_user_bytes: 0, + max_ip_bytes_per_day: 0, + }), + } +} +pub async fn user(pool: &PgPool) -> Uuid { + sqlx::query_scalar( + "INSERT INTO users(username, public_key) VALUES ('test', 'fixture') RETURNING id", + ) + .fetch_one(pool) + .await + .unwrap() +} +pub async fn project(pool: &PgPool, user: Uuid) -> Uuid { + let id = sqlx::query_scalar("INSERT INTO projects DEFAULT VALUES RETURNING id") + .fetch_one(pool) + .await + .unwrap(); + sqlx::query("INSERT INTO user_project_relations(user_id, project_id) VALUES ($1,$2)") + .bind(user) + .bind(id) + .execute(pool) + .await + .unwrap(); + id +}