├── .dockerignore ├── .editorconfig ├── .env.example ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .rustfmt.toml ├── .sqlx ├── query-054e7087a364b951829758b37893685213a7bd8a4efc250f1dd2ab73c3901c0a.json ├── query-1b4ba5c77b6829190feaf03fb972089ac21d63d691fc84eb4b0f0f6c85779b98.json ├── query-1e70e58c3dfc94fa04c6a550954186b15e2dcb1e0e4806a9a649e1ee119a9ab0.json ├── query-212735e57340956e6176c65b4ce06aedcf278052f90bc02d1ddf9d5b6f5c04dc.json ├── query-2ba6f5558e04ca1eb9dee3e9c528271da22595b60016a81d8a18f7c6d30011a4.json ├── query-2d365994a9cd3b6356a374e1f4a7b28394af28bc1fed88739393a7d20433d3e3.json ├── query-2e12e297b51ef897c883019ec664c1effb7572cbe480ba344c12466009763f9c.json ├── query-388d80fd9813f9bb740f8344b75068ec8852b9c8b0f75646bbb6e2267305c588.json ├── query-39b4e79e2c6df2bb2c6dafae57f730209b963373cfa3a715aaf35e612d626a7f.json ├── query-428714129ac2a0b701ed574ba62a234d313b149fb22dd45e0a305ca2772d54bf.json ├── query-4b838eb44c8c6fb60069894f80e9b4702faca004c4daf9d140cc9152929119eb.json ├── query-5374c239237e1f03aa004a43b34425a02327685406b156449d4616a8128d865e.json ├── query-53e53d82e6168965c0f2d9f117850ae72aeed047fc0af12d00fc100fb6172d66.json ├── query-55a06e706a68d350a7ac6d583a4dc3ba75c887dab8fba0a9e78ebb54c881121c.json ├── query-566c83c8679bc170c195b8adf3e681c864873de7bc707619fdeedea4969236f2.json ├── query-65d89b57f59e390b85ad12f17c025fa47101e1053a1086320161094ec730ce5a.json ├── query-68abcbcc0dff6c66ac85e5cf975879206a5956e8bb91c219932432f7679c8aae.json ├── query-74c7730c58901fd72e0ab92f0c6f6282041ac5cefb7462f6c0d3256c32800418.json ├── query-84dd7e7a0b0c0ab301981ad5f824a328901dfaaffd95499000a0914045fa33ae.json ├── query-8ea4609ecabc6dbafc262aea24eab363ee25e0dc76c19721907a7768e1e1f8c3.json ├── query-8f5b92e11b09856c823f584c1224f505751e5342dcef5f25a6eba48d12f5fbae.json ├── query-92a32628aa4dbf1408257586d8bcd6366b7da7b3d62a3c20053106cbe64e4365.json ├── query-93c7a7586d32b00b3dcd571e20bf5e8b5d720e06f9f3b5ab4e1ef7da2e21f9aa.json ├── query-a0accff69fb69735855056703fb25d34bb158f762413ed7771930d55b84d1798.json ├── query-b1b2fb79267b5a924e32faa3f3dcd4151925864aac8404f3ad8df6afdb696601.json ├── query-b3e6a2f6d38821cea1853eee400a58341abe8551852352cd462fbb936b9044c5.json ├── query-b45b5ee0e47e5fd71b1d9efc93402150f5d9376f28def714e0fa2926424f7f44.json ├── query-bd021bad7c5345082cd794a54b46dedca969f9d16d9bcaaf55de3d0dcdd38894.json ├── query-dced076a8e72c6a98fee5a26fcc87090098469b1e03103fc931838610e647d33.json ├── query-e86e4ed62ba5e929bcc360debc487341f9fba87408367383951caf6944e168cd.json ├── query-f7f8696a9ee61ae1fa4e3df38f1422e223d36befcb7c178623bf68d335a1cf74.json └── query-f9aad0e60b717a8cb71aa74dd158fdd018e0e1ba324f876f70f55b178d8069d4.json ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── Containerfile ├── Dockerfile ├── Justfile ├── LICENSES ├── 0BSD.txt ├── AGPL-3.0-only.txt ├── CC-BY-SA-4.0.txt ├── CC0-1.0.txt └── MIT.txt ├── README.md ├── REUSE.toml ├── assets ├── htmx.1.9.9.js └── preflight.css ├── build.rs ├── doc ├── federation.md ├── ngi_zero.svg └── nlnet.svg ├── linkblocks.cdx.json ├── migrations ├── 20231224122154_users.sql ├── 20240201113323_basics.sql ├── 20240312104935_rename_notes.sql ├── 20240424165115_add_users_oauth.sql ├── 20240426170949_public_lists.sql ├── 20240923130043_add_list_pinned.sql ├── 20250124155026_mandatory_username.sql └── 20250127102308_users_activitypub_columns.sql ├── pre-commit.sh ├── src ├── authentication.rs ├── cli.rs ├── date_time.rs ├── db │ ├── all.rs │ ├── ap_users.rs │ ├── bookmarks.rs │ ├── items.rs │ ├── layout.rs │ ├── links.rs │ ├── lists.rs │ ├── mod.rs │ └── users.rs ├── extract │ ├── mod.rs │ └── qs_form.rs ├── federation │ ├── config.rs │ ├── context.rs │ ├── mod.rs │ ├── person.rs │ └── signing.rs ├── form_errors.rs ├── forms │ ├── ap_users.rs │ ├── bookmarks.rs │ ├── links.rs │ ├── lists.rs │ ├── mod.rs │ ├── url.rs │ └── users.rs ├── htmf_response.rs ├── insert_demo_data.rs ├── lib.rs ├── main.rs ├── oidc.rs ├── response_error.rs ├── routes │ ├── assets.rs │ ├── bookmarks.rs │ ├── federation.rs │ ├── index.rs │ ├── links.rs │ ├── lists.rs │ ├── mod.rs │ └── users.rs ├── server.rs ├── tests │ ├── bookmarks.rs │ ├── federation.rs │ ├── index.rs │ ├── lists.rs │ ├── mod.rs │ ├── snapshots │ │ ├── linkblocks__tests__bookmarks__get_unsorted_bookmarks.snap │ │ ├── linkblocks__tests__index__index.snap │ │ ├── linkblocks__tests__lists__get_create_list.snap │ │ └── linkblocks__tests__users__can_login.snap │ ├── users.rs │ └── util │ │ ├── db.rs │ │ ├── dom.rs │ │ ├── mod.rs │ │ ├── request_builder.rs │ │ └── test_app.rs └── views │ ├── base_document.rs │ ├── content.rs │ ├── create_bookmark.rs │ ├── create_link.rs │ ├── create_list.rs │ ├── edit_list_title.rs │ ├── form.rs │ ├── index.rs │ ├── layout.rs │ ├── list.rs │ ├── list_unpinned_lists.rs │ ├── login.rs │ ├── login_demo.rs │ ├── mod.rs │ ├── oidc_select_username.rs │ ├── unsorted_bookmarks.rs │ └── users.rs └── tailwind.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # Compiled artifacts 2 | target 3 | 4 | # Configuration 5 | **/.env 6 | 7 | # TLS certificates created by mkcert 8 | **/development_cert 9 | 10 | # cargo-run-bin 11 | **/.bin 12 | 13 | # deployment stuff 14 | fly.toml 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | insert_final_newline = true 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | 9 | [*.{yml,yaml}] 10 | indent_style=space 11 | indent_size=2 12 | 13 | [*.rs] 14 | indent_size=4 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Run-time configuration 2 | DATABASE_NAME=linkblocks 3 | DATABASE_PORT=55432 4 | DATABASE_URL=postgres://postgres@localhost:${DATABASE_PORT}/${DATABASE_NAME} 5 | 6 | BASE_URL=https://127.0.0.1:4040 7 | DEMO_MODE=false 8 | 9 | ADMIN_USERNAME= 10 | ADMIN_PASSWORD= 11 | 12 | # Used for Single Sign On (SSO). 13 | OIDC_CLIENT_ID= 14 | OIDC_CLIENT_SECRET= 15 | # Only used for spinning up a rauthy container in 16 | # local dev environments 17 | RAUTHY_PORT=55434 18 | OIDC_ISSUER_URL=http://localhost:${RAUTHY_PORT}/auth/v1 19 | OIDC_ISSUER_NAME=Rauthy 20 | 21 | TLS_KEY=development_cert/localhost.key 22 | TLS_CERT=development_cert/localhost.crt 23 | 24 | RUST_LOG=linkblocks=debug,tower_http=debug,tower_http::trace::on_request=info,axum::rejection=trace 25 | 26 | # These values are only relevant for compiling 27 | SQLX_OFFLINE=true 28 | 29 | # These values are only relevant for development 30 | DATABASE_NAME_TEST=linkblocks_test 31 | DATABASE_PORT_TEST=55433 32 | DATABASE_URL_TEST=postgres://postgres@localhost:${DATABASE_PORT_TEST}/${DATABASE_NAME_TEST} 33 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .sqlx/*.json -diff 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | all-dependencies: 9 | patterns: 10 | - "*" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | env: 9 | CARGO_INCREMENTAL: 0 10 | RUSTFLAGS: "-D warnings" 11 | 12 | jobs: 13 | build-lint: 14 | name: Build & Lint 15 | runs-on: ubuntu-latest 16 | 17 | services: 18 | postgres: 19 | image: postgres:16 20 | env: 21 | POSTGRES_PASSWORD: postgres 22 | options: >- 23 | --health-cmd pg_isready 24 | --health-interval 10s 25 | --health-timeout 5s 26 | --health-retries 5 27 | ports: 28 | - 5432:5432 29 | 30 | steps: 31 | - name: Checkout code 32 | uses: actions/checkout@v4 33 | with: 34 | persist-credentials: false 35 | 36 | - name: Setup Rust toolchain 37 | uses: dtolnay/rust-toolchain@stable 38 | 39 | - name: Setup rust cache 40 | uses: Swatinem/rust-cache@v2 41 | with: 42 | # For cargo-run-bin 43 | cache-directories: ".bin" 44 | 45 | - name: Install REUSE tool 46 | run: pipx install reuse 47 | 48 | - name: Install cargo-run-bin 49 | run: cargo install cargo-run-bin 50 | 51 | - run: cargo bin sqlx-cli migrate run 52 | env: 53 | DATABASE_URL: postgres://postgres:postgres@localhost:5432 54 | 55 | - run: cargo bin sqlx-cli prepare 56 | env: 57 | DATABASE_URL: postgres://postgres:postgres@localhost:5432 58 | 59 | - run: cargo bin just lint 60 | 61 | - run: cargo bin just generate-sbom 62 | 63 | - name: Check for file changes 64 | run: | 65 | if [[ -n "$(git status --porcelain)" ]]; then 66 | echo "::error::Detected changes in the following files:" 67 | git status --porcelain 68 | echo "Diff:" 69 | git diff 70 | exit 1 71 | fi 72 | 73 | - run: cargo build --release 74 | env: 75 | SQLX_OFFLINE: true 76 | 77 | - name: podman login 78 | env: 79 | USER: ${{ github.actor }} 80 | PASSWORD: ${{ secrets.GITHUB_TOKEN }} 81 | run: podman login --username "$USER" --password "$PASSWORD" ghcr.io 82 | 83 | - run: podman system migrate 84 | 85 | - name: podman build linux/amd64 86 | run: podman build --format docker --platform linux/amd64 --manifest linkblocks -f Containerfile target/release 87 | 88 | - name: podman manifest push latest 89 | run: podman manifest push linkblocks ghcr.io/raffomania/linkblocks:latest 90 | if: github.ref == 'refs/heads/main' 91 | 92 | format-nightly: 93 | name: Check Formatting (nightly) 94 | runs-on: ubuntu-latest 95 | 96 | steps: 97 | - name: Checkout code 98 | uses: actions/checkout@v4 99 | with: 100 | persist-credentials: false 101 | 102 | - name: Setup Rust toolchain 103 | uses: dtolnay/rust-toolchain@nightly 104 | with: 105 | components: "rustfmt" 106 | 107 | - name: Setup rust cache 108 | uses: Swatinem/rust-cache@v2 109 | with: 110 | # For cargo-run-bin 111 | cache-directories: ".bin" 112 | 113 | - name: Install cargo-run-bin 114 | run: cargo install cargo-run-bin 115 | 116 | - run: cargo bin just format 117 | 118 | test: 119 | name: Test 120 | runs-on: ubuntu-latest 121 | 122 | services: 123 | postgres: 124 | image: postgres:16 125 | env: 126 | POSTGRES_PASSWORD: postgres 127 | options: >- 128 | --health-cmd pg_isready 129 | --health-interval 10s 130 | --health-timeout 5s 131 | --health-retries 5 132 | ports: 133 | - 5432:5432 134 | 135 | steps: 136 | - name: Checkout code 137 | uses: actions/checkout@v4 138 | with: 139 | persist-credentials: false 140 | 141 | - name: Setup Rust toolchain 142 | uses: dtolnay/rust-toolchain@stable 143 | 144 | - name: Setup rust cache 145 | uses: Swatinem/rust-cache@v2 146 | 147 | - name: cargo test 148 | run: cargo test 149 | env: 150 | DATABASE_URL: postgres://postgres:postgres@localhost:5432 151 | SQLX_OFFLINE: true 152 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled artifacts 2 | /target 3 | /target_ci 4 | 5 | # Configuration 6 | .env 7 | 8 | # TLS certificates created by mkcert 9 | development_cert 10 | 11 | # cargo-run-bin 12 | .bin/ 13 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | wrap_comments = true 2 | format_strings = true 3 | imports_granularity = "Crate" 4 | group_imports = "StdExternalCrate" 5 | use_field_init_shorthand = true 6 | -------------------------------------------------------------------------------- /.sqlx/query-054e7087a364b951829758b37893685213a7bd8a4efc250f1dd2ab73c3901c0a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n insert into bookmarks\n (user_id, url, title)\n values ($1, $2, $3)\n returning *", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "url", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "title", 29 | "type_info": "Text" 30 | } 31 | ], 32 | "parameters": { 33 | "Left": [ 34 | "Uuid", 35 | "Text", 36 | "Text" 37 | ] 38 | }, 39 | "nullable": [ 40 | false, 41 | false, 42 | false, 43 | false, 44 | false 45 | ] 46 | }, 47 | "hash": "054e7087a364b951829758b37893685213a7bd8a4efc250f1dd2ab73c3901c0a" 48 | } 49 | -------------------------------------------------------------------------------- /.sqlx/query-1b4ba5c77b6829190feaf03fb972089ac21d63d691fc84eb4b0f0f6c85779b98.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select * from ap_users\n where ap_id = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "ap_id", 14 | "type_info": "Varchar" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "username", 19 | "type_info": "Varchar" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "inbox_url", 24 | "type_info": "Varchar" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "public_key", 29 | "type_info": "Varchar" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private_key", 34 | "type_info": "Varchar" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "last_refreshed_at", 39 | "type_info": "Timestamptz" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "display_name", 44 | "type_info": "Varchar" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "bio", 49 | "type_info": "Varchar" 50 | } 51 | ], 52 | "parameters": { 53 | "Left": [ 54 | "Text" 55 | ] 56 | }, 57 | "nullable": [ 58 | false, 59 | false, 60 | false, 61 | false, 62 | false, 63 | true, 64 | false, 65 | true, 66 | true 67 | ] 68 | }, 69 | "hash": "1b4ba5c77b6829190feaf03fb972089ac21d63d691fc84eb4b0f0f6c85779b98" 70 | } 71 | -------------------------------------------------------------------------------- /.sqlx/query-1e70e58c3dfc94fa04c6a550954186b15e2dcb1e0e4806a9a649e1ee119a9ab0.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select coalesce(username, email) from users\n where id = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "coalesce", 9 | "type_info": "Text" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Uuid" 15 | ] 16 | }, 17 | "nullable": [ 18 | null 19 | ] 20 | }, 21 | "hash": "1e70e58c3dfc94fa04c6a550954186b15e2dcb1e0e4806a9a649e1ee119a9ab0" 22 | } 23 | -------------------------------------------------------------------------------- /.sqlx/query-212735e57340956e6176c65b4ce06aedcf278052f90bc02d1ddf9d5b6f5c04dc.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n delete from bookmarks\n where id = $1\n returning *;\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "url", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "title", 29 | "type_info": "Text" 30 | } 31 | ], 32 | "parameters": { 33 | "Left": [ 34 | "Uuid" 35 | ] 36 | }, 37 | "nullable": [ 38 | false, 39 | false, 40 | false, 41 | false, 42 | false 43 | ] 44 | }, 45 | "hash": "212735e57340956e6176c65b4ce06aedcf278052f90bc02d1ddf9d5b6f5c04dc" 46 | } 47 | -------------------------------------------------------------------------------- /.sqlx/query-2ba6f5558e04ca1eb9dee3e9c528271da22595b60016a81d8a18f7c6d30011a4.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n update lists\n set title = $1\n where id = $2", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Uuid" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "2ba6f5558e04ca1eb9dee3e9c528271da22595b60016a81d8a18f7c6d30011a4" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-2d365994a9cd3b6356a374e1f4a7b28394af28bc1fed88739393a7d20433d3e3.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select *\n from bookmarks\n where user_id = $1\n and not exists (\n select null from links\n where dest_bookmark_id = bookmarks.id\n );\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "url", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "title", 29 | "type_info": "Text" 30 | } 31 | ], 32 | "parameters": { 33 | "Left": [ 34 | "Uuid" 35 | ] 36 | }, 37 | "nullable": [ 38 | false, 39 | false, 40 | false, 41 | false, 42 | false 43 | ] 44 | }, 45 | "hash": "2d365994a9cd3b6356a374e1f4a7b28394af28bc1fed88739393a7d20433d3e3" 46 | } 47 | -------------------------------------------------------------------------------- /.sqlx/query-2e12e297b51ef897c883019ec664c1effb7572cbe480ba344c12466009763f9c.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select * from users\n where oidc_id = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "password_hash", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "username", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "email", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "oidc_id", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "ap_user_id", 34 | "type_info": "Uuid" 35 | } 36 | ], 37 | "parameters": { 38 | "Left": [ 39 | "Text" 40 | ] 41 | }, 42 | "nullable": [ 43 | false, 44 | true, 45 | false, 46 | true, 47 | true, 48 | true 49 | ] 50 | }, 51 | "hash": "2e12e297b51ef897c883019ec664c1effb7572cbe480ba344c12466009763f9c" 52 | } 53 | -------------------------------------------------------------------------------- /.sqlx/query-388d80fd9813f9bb740f8344b75068ec8852b9c8b0f75646bbb6e2267305c588.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n insert into links\n (\n user_id,\n src_list_id,\n dest_bookmark_id,\n dest_list_id\n )\n values ($1,\n (select id from lists where id = $2),\n (select id from bookmarks where id = $3),\n (select id from lists where id = $3)\n )\n returning *", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "src_list_id", 24 | "type_info": "Uuid" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "dest_bookmark_id", 29 | "type_info": "Uuid" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "dest_list_id", 34 | "type_info": "Uuid" 35 | } 36 | ], 37 | "parameters": { 38 | "Left": [ 39 | "Uuid", 40 | "Uuid", 41 | "Uuid" 42 | ] 43 | }, 44 | "nullable": [ 45 | false, 46 | false, 47 | false, 48 | false, 49 | true, 50 | true 51 | ] 52 | }, 53 | "hash": "388d80fd9813f9bb740f8344b75068ec8852b9c8b0f75646bbb6e2267305c588" 54 | } 55 | -------------------------------------------------------------------------------- /.sqlx/query-39b4e79e2c6df2bb2c6dafae57f730209b963373cfa3a715aaf35e612d626a7f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select lists.*\n from lists\n left join links as src_links on lists.id = src_links.src_list_id\n left join links as dest_links on lists.id = dest_links.dest_list_id\n where lists.user_id = $1\n group by lists.id\n order by\n max(src_links.created_at) desc nulls last,\n max(dest_links.created_at) nulls last,\n max(lists.created_at) desc\n limit 500\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "title", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "content", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private", 34 | "type_info": "Bool" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "pinned", 39 | "type_info": "Bool" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "Uuid" 45 | ] 46 | }, 47 | "nullable": [ 48 | false, 49 | false, 50 | false, 51 | false, 52 | true, 53 | false, 54 | false 55 | ] 56 | }, 57 | "hash": "39b4e79e2c6df2bb2c6dafae57f730209b963373cfa3a715aaf35e612d626a7f" 58 | } 59 | -------------------------------------------------------------------------------- /.sqlx/query-428714129ac2a0b701ed574ba62a234d313b149fb22dd45e0a305ca2772d54bf.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n delete from links\n where id = $1\n returning *\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "src_list_id", 24 | "type_info": "Uuid" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "dest_bookmark_id", 29 | "type_info": "Uuid" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "dest_list_id", 34 | "type_info": "Uuid" 35 | } 36 | ], 37 | "parameters": { 38 | "Left": [ 39 | "Uuid" 40 | ] 41 | }, 42 | "nullable": [ 43 | false, 44 | false, 45 | false, 46 | false, 47 | true, 48 | true 49 | ] 50 | }, 51 | "hash": "428714129ac2a0b701ed574ba62a234d313b149fb22dd45e0a305ca2772d54bf" 52 | } 53 | -------------------------------------------------------------------------------- /.sqlx/query-4b838eb44c8c6fb60069894f80e9b4702faca004c4daf9d140cc9152929119eb.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n insert into lists\n (user_id, title, content, private)\n values ($1, $2, $3, $4)\n returning *", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "title", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "content", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private", 34 | "type_info": "Bool" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "pinned", 39 | "type_info": "Bool" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "Uuid", 45 | "Text", 46 | "Text", 47 | "Bool" 48 | ] 49 | }, 50 | "nullable": [ 51 | false, 52 | false, 53 | false, 54 | false, 55 | true, 56 | false, 57 | false 58 | ] 59 | }, 60 | "hash": "4b838eb44c8c6fb60069894f80e9b4702faca004c4daf9d140cc9152929119eb" 61 | } 62 | -------------------------------------------------------------------------------- /.sqlx/query-5374c239237e1f03aa004a43b34425a02327685406b156449d4616a8128d865e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select to_jsonb(bookmarks.*) as item\n from bookmarks\n where bookmarks.title ilike '%' || $1 || '%'\n and bookmarks.user_id = $2\n union\n select to_jsonb(lists.*) as item\n from lists\n where lists.title ilike '%' || $1 || '%'\n and lists.user_id = $2\n limit 10\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "item", 9 | "type_info": "Jsonb" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Text", 15 | "Uuid" 16 | ] 17 | }, 18 | "nullable": [ 19 | null 20 | ] 21 | }, 22 | "hash": "5374c239237e1f03aa004a43b34425a02327685406b156449d4616a8128d865e" 23 | } 24 | -------------------------------------------------------------------------------- /.sqlx/query-53e53d82e6168965c0f2d9f117850ae72aeed047fc0af12d00fc100fb6172d66.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select to_jsonb(bookmarks.*) as item\n from bookmarks\n where bookmarks.id = $1\n union\n select to_jsonb(lists.*) as item\n from lists\n where lists.id = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "item", 9 | "type_info": "Jsonb" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Uuid" 15 | ] 16 | }, 17 | "nullable": [ 18 | null 19 | ] 20 | }, 21 | "hash": "53e53d82e6168965c0f2d9f117850ae72aeed047fc0af12d00fc100fb6172d66" 22 | } 23 | -------------------------------------------------------------------------------- /.sqlx/query-55a06e706a68d350a7ac6d583a4dc3ba75c887dab8fba0a9e78ebb54c881121c.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select *\n from lists\n where (lists.title ilike '%' || $1 || '%')\n and lists.user_id = $2\n limit 10\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "title", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "content", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private", 34 | "type_info": "Bool" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "pinned", 39 | "type_info": "Bool" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "Text", 45 | "Uuid" 46 | ] 47 | }, 48 | "nullable": [ 49 | false, 50 | false, 51 | false, 52 | false, 53 | true, 54 | false, 55 | false 56 | ] 57 | }, 58 | "hash": "55a06e706a68d350a7ac6d583a4dc3ba75c887dab8fba0a9e78ebb54c881121c" 59 | } 60 | -------------------------------------------------------------------------------- /.sqlx/query-566c83c8679bc170c195b8adf3e681c864873de7bc707619fdeedea4969236f2.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "truncate table bookmarks cascade;", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [] 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "566c83c8679bc170c195b8adf3e681c864873de7bc707619fdeedea4969236f2" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-65d89b57f59e390b85ad12f17c025fa47101e1053a1086320161094ec730ce5a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n insert into ap_users\n (\n ap_id,\n username,\n inbox_url,\n public_key,\n private_key,\n last_refreshed_at,\n display_name,\n bio\n )\n values ($1, $2, $3, $4, $5, $6, $7, $8)\n on conflict(ap_id) do update set\n ap_id = $1,\n username = $2,\n inbox_url = $3,\n public_key = $4,\n private_key = $5,\n last_refreshed_at = $6,\n display_name = $7,\n bio = $8\n returning *\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "ap_id", 14 | "type_info": "Varchar" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "username", 19 | "type_info": "Varchar" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "inbox_url", 24 | "type_info": "Varchar" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "public_key", 29 | "type_info": "Varchar" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private_key", 34 | "type_info": "Varchar" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "last_refreshed_at", 39 | "type_info": "Timestamptz" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "display_name", 44 | "type_info": "Varchar" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "bio", 49 | "type_info": "Varchar" 50 | } 51 | ], 52 | "parameters": { 53 | "Left": [ 54 | "Varchar", 55 | "Varchar", 56 | "Varchar", 57 | "Varchar", 58 | "Varchar", 59 | "Timestamptz", 60 | "Varchar", 61 | "Varchar" 62 | ] 63 | }, 64 | "nullable": [ 65 | false, 66 | false, 67 | false, 68 | false, 69 | false, 70 | true, 71 | false, 72 | true, 73 | true 74 | ] 75 | }, 76 | "hash": "65d89b57f59e390b85ad12f17c025fa47101e1053a1086320161094ec730ce5a" 77 | } 78 | -------------------------------------------------------------------------------- /.sqlx/query-68abcbcc0dff6c66ac85e5cf975879206a5956e8bb91c219932432f7679c8aae.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select\n links.id as link_id,\n links.created_at as link_created_at,\n links.user_id as link_user_id,\n\n case when lists.id is not null then\n jsonb_build_object(\n 'list', to_jsonb(lists.*),\n 'links',\n coalesce(\n jsonb_agg(lists_bookmarks.*)\n filter (where lists_bookmarks.id is not null),\n jsonb_build_array())\n || coalesce(\n jsonb_agg(lists_lists.*)\n filter (where lists_lists.id is not null),\n jsonb_build_array())\n )\n when bookmarks.id is not null then\n to_jsonb(bookmarks.*)\n else null end as dest\n from links\n\n left join lists on lists.id = links.dest_list_id\n left join links as lists_links on lists_links.src_list_id = lists.id\n left join bookmarks as lists_bookmarks on lists_bookmarks.id = lists_links.dest_bookmark_id\n left join lists as lists_lists on lists_lists.id = lists_links.dest_list_id\n\n left join bookmarks on bookmarks.id = links.dest_bookmark_id\n\n where links.src_list_id = $1\n and (lists is null or not lists.private or lists.user_id = $2)\n and (lists_lists is null or not lists_lists.private or lists.user_id = $2)\n group by links.id, lists.id, bookmarks.id\n order by links.created_at desc\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "link_id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "link_created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "link_user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "dest", 24 | "type_info": "Jsonb" 25 | } 26 | ], 27 | "parameters": { 28 | "Left": [ 29 | "Uuid", 30 | "Uuid" 31 | ] 32 | }, 33 | "nullable": [ 34 | false, 35 | false, 36 | false, 37 | null 38 | ] 39 | }, 40 | "hash": "68abcbcc0dff6c66ac85e5cf975879206a5956e8bb91c219932432f7679c8aae" 41 | } 42 | -------------------------------------------------------------------------------- /.sqlx/query-74c7730c58901fd72e0ab92f0c6f6282041ac5cefb7462f6c0d3256c32800418.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "truncate table links cascade;", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [] 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "74c7730c58901fd72e0ab92f0c6f6282041ac5cefb7462f6c0d3256c32800418" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-84dd7e7a0b0c0ab301981ad5f824a328901dfaaffd95499000a0914045fa33ae.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "truncate table users cascade;", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [] 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "84dd7e7a0b0c0ab301981ad5f824a328901dfaaffd95499000a0914045fa33ae" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-8ea4609ecabc6dbafc262aea24eab363ee25e0dc76c19721907a7768e1e1f8c3.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select * from users\n where username = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "password_hash", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "username", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "email", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "oidc_id", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "ap_user_id", 34 | "type_info": "Uuid" 35 | } 36 | ], 37 | "parameters": { 38 | "Left": [ 39 | "Text" 40 | ] 41 | }, 42 | "nullable": [ 43 | false, 44 | true, 45 | false, 46 | true, 47 | true, 48 | true 49 | ] 50 | }, 51 | "hash": "8ea4609ecabc6dbafc262aea24eab363ee25e0dc76c19721907a7768e1e1f8c3" 52 | } 53 | -------------------------------------------------------------------------------- /.sqlx/query-8f5b92e11b09856c823f584c1224f505751e5342dcef5f25a6eba48d12f5fbae.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "truncate table lists cascade;", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [] 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "8f5b92e11b09856c823f584c1224f505751e5342dcef5f25a6eba48d12f5fbae" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-92a32628aa4dbf1408257586d8bcd6366b7da7b3d62a3c20053106cbe64e4365.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select * from lists\n where user_id = $1 and pinned\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "title", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "content", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private", 34 | "type_info": "Bool" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "pinned", 39 | "type_info": "Bool" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "Uuid" 45 | ] 46 | }, 47 | "nullable": [ 48 | false, 49 | false, 50 | false, 51 | false, 52 | true, 53 | false, 54 | false 55 | ] 56 | }, 57 | "hash": "92a32628aa4dbf1408257586d8bcd6366b7da7b3d62a3c20053106cbe64e4365" 58 | } 59 | -------------------------------------------------------------------------------- /.sqlx/query-93c7a7586d32b00b3dcd571e20bf5e8b5d720e06f9f3b5ab4e1ef7da2e21f9aa.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select * from lists\n where id = any($1)\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "title", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "content", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private", 34 | "type_info": "Bool" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "pinned", 39 | "type_info": "Bool" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "UuidArray" 45 | ] 46 | }, 47 | "nullable": [ 48 | false, 49 | false, 50 | false, 51 | false, 52 | true, 53 | false, 54 | false 55 | ] 56 | }, 57 | "hash": "93c7a7586d32b00b3dcd571e20bf5e8b5d720e06f9f3b5ab4e1ef7da2e21f9aa" 58 | } 59 | -------------------------------------------------------------------------------- /.sqlx/query-a0accff69fb69735855056703fb25d34bb158f762413ed7771930d55b84d1798.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n insert into ap_users\n (\n ap_id,\n username,\n inbox_url,\n public_key,\n private_key,\n last_refreshed_at,\n display_name,\n bio\n )\n values ($1, $2, $3, $4, $5, $6, $7, $8)\n returning *\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "ap_id", 14 | "type_info": "Varchar" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "username", 19 | "type_info": "Varchar" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "inbox_url", 24 | "type_info": "Varchar" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "public_key", 29 | "type_info": "Varchar" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private_key", 34 | "type_info": "Varchar" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "last_refreshed_at", 39 | "type_info": "Timestamptz" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "display_name", 44 | "type_info": "Varchar" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "bio", 49 | "type_info": "Varchar" 50 | } 51 | ], 52 | "parameters": { 53 | "Left": [ 54 | "Varchar", 55 | "Varchar", 56 | "Varchar", 57 | "Varchar", 58 | "Varchar", 59 | "Timestamptz", 60 | "Varchar", 61 | "Varchar" 62 | ] 63 | }, 64 | "nullable": [ 65 | false, 66 | false, 67 | false, 68 | false, 69 | false, 70 | true, 71 | false, 72 | true, 73 | true 74 | ] 75 | }, 76 | "hash": "a0accff69fb69735855056703fb25d34bb158f762413ed7771930d55b84d1798" 77 | } 78 | -------------------------------------------------------------------------------- /.sqlx/query-b1b2fb79267b5a924e32faa3f3dcd4151925864aac8404f3ad8df6afdb696601.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n insert into users\n (email, oidc_id, username)\n values ($1, $2, $3)\n returning *", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "password_hash", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "username", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "email", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "oidc_id", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "ap_user_id", 34 | "type_info": "Uuid" 35 | } 36 | ], 37 | "parameters": { 38 | "Left": [ 39 | "Text", 40 | "Text", 41 | "Text" 42 | ] 43 | }, 44 | "nullable": [ 45 | false, 46 | true, 47 | false, 48 | true, 49 | true, 50 | true 51 | ] 52 | }, 53 | "hash": "b1b2fb79267b5a924e32faa3f3dcd4151925864aac8404f3ad8df6afdb696601" 54 | } 55 | -------------------------------------------------------------------------------- /.sqlx/query-b3e6a2f6d38821cea1853eee400a58341abe8551852352cd462fbb936b9044c5.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select * from ap_users\n where username = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "ap_id", 14 | "type_info": "Varchar" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "username", 19 | "type_info": "Varchar" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "inbox_url", 24 | "type_info": "Varchar" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "public_key", 29 | "type_info": "Varchar" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private_key", 34 | "type_info": "Varchar" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "last_refreshed_at", 39 | "type_info": "Timestamptz" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "display_name", 44 | "type_info": "Varchar" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "bio", 49 | "type_info": "Varchar" 50 | } 51 | ], 52 | "parameters": { 53 | "Left": [ 54 | "Text" 55 | ] 56 | }, 57 | "nullable": [ 58 | false, 59 | false, 60 | false, 61 | false, 62 | false, 63 | true, 64 | false, 65 | true, 66 | true 67 | ] 68 | }, 69 | "hash": "b3e6a2f6d38821cea1853eee400a58341abe8551852352cd462fbb936b9044c5" 70 | } 71 | -------------------------------------------------------------------------------- /.sqlx/query-b45b5ee0e47e5fd71b1d9efc93402150f5d9376f28def714e0fa2926424f7f44.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select\n coalesce(users.username, users.email) as \"user_description!\",\n count(links.dest_bookmark_id) as \"linked_bookmark_count!\",\n count(links.dest_list_id) as \"linked_list_count!\"\n from lists\n join users on lists.user_id = users.id\n left join links\n on lists.id = links.src_list_id\n where lists.id = $1\n group by users.username, users.email\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "user_description!", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "linked_bookmark_count!", 14 | "type_info": "Int8" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "linked_list_count!", 19 | "type_info": "Int8" 20 | } 21 | ], 22 | "parameters": { 23 | "Left": [ 24 | "Uuid" 25 | ] 26 | }, 27 | "nullable": [ 28 | null, 29 | null, 30 | null 31 | ] 32 | }, 33 | "hash": "b45b5ee0e47e5fd71b1d9efc93402150f5d9376f28def714e0fa2926424f7f44" 34 | } 35 | -------------------------------------------------------------------------------- /.sqlx/query-bd021bad7c5345082cd794a54b46dedca969f9d16d9bcaaf55de3d0dcdd38894.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select * from lists\n where id = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "title", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "content", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private", 34 | "type_info": "Bool" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "pinned", 39 | "type_info": "Bool" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "Uuid" 45 | ] 46 | }, 47 | "nullable": [ 48 | false, 49 | false, 50 | false, 51 | false, 52 | true, 53 | false, 54 | false 55 | ] 56 | }, 57 | "hash": "bd021bad7c5345082cd794a54b46dedca969f9d16d9bcaaf55de3d0dcdd38894" 58 | } 59 | -------------------------------------------------------------------------------- /.sqlx/query-dced076a8e72c6a98fee5a26fcc87090098469b1e03103fc931838610e647d33.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n select lists.id, title, content,\n count(links.dest_bookmark_id) as \"bookmark_count!\",\n count(links.dest_list_id) as \"linked_list_count!\"\n from lists\n left join links\n on lists.id = links.src_list_id\n where lists.user_id = $1 and not pinned\n group by lists.id\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "title", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "content", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "bookmark_count!", 24 | "type_info": "Int8" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "linked_list_count!", 29 | "type_info": "Int8" 30 | } 31 | ], 32 | "parameters": { 33 | "Left": [ 34 | "Uuid" 35 | ] 36 | }, 37 | "nullable": [ 38 | false, 39 | false, 40 | true, 41 | null, 42 | null 43 | ] 44 | }, 45 | "hash": "dced076a8e72c6a98fee5a26fcc87090098469b1e03103fc931838610e647d33" 46 | } 47 | -------------------------------------------------------------------------------- /.sqlx/query-e86e4ed62ba5e929bcc360debc487341f9fba87408367383951caf6944e168cd.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n update lists\n set pinned = $1\n where id = $2\n returning *\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "title", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "content", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private", 34 | "type_info": "Bool" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "pinned", 39 | "type_info": "Bool" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "Bool", 45 | "Uuid" 46 | ] 47 | }, 48 | "nullable": [ 49 | false, 50 | false, 51 | false, 52 | false, 53 | true, 54 | false, 55 | false 56 | ] 57 | }, 58 | "hash": "e86e4ed62ba5e929bcc360debc487341f9fba87408367383951caf6944e168cd" 59 | } 60 | -------------------------------------------------------------------------------- /.sqlx/query-f7f8696a9ee61ae1fa4e3df38f1422e223d36befcb7c178623bf68d335a1cf74.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n insert into users\n (username, password_hash, ap_user_id)\n values ($1, $2, $3)\n returning *\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "password_hash", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "username", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "email", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "oidc_id", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "ap_user_id", 34 | "type_info": "Uuid" 35 | } 36 | ], 37 | "parameters": { 38 | "Left": [ 39 | "Text", 40 | "Text", 41 | "Uuid" 42 | ] 43 | }, 44 | "nullable": [ 45 | false, 46 | true, 47 | false, 48 | true, 49 | true, 50 | true 51 | ] 52 | }, 53 | "hash": "f7f8696a9ee61ae1fa4e3df38f1422e223d36befcb7c178623bf68d335a1cf74" 54 | } 55 | -------------------------------------------------------------------------------- /.sqlx/query-f9aad0e60b717a8cb71aa74dd158fdd018e0e1ba324f876f70f55b178d8069d4.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n update lists\n set private = $1\n where id = $2\n returning *\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "created_at", 14 | "type_info": "Timestamptz" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "title", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "content", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "private", 34 | "type_info": "Bool" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "pinned", 39 | "type_info": "Bool" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "Bool", 45 | "Uuid" 46 | ] 47 | }, 48 | "nullable": [ 49 | false, 50 | false, 51 | false, 52 | false, 53 | true, 54 | false, 55 | false 56 | ] 57 | }, 58 | "hash": "f9aad0e60b717a8cb71aa74dd158fdd018e0e1ba324f876f70f55b178d8069d4" 59 | } 60 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "linkblocks" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "AGPL-3.0-or-later" 6 | publish = false 7 | 8 | [lib] 9 | # Doctests are slow, so we don't use them 10 | doctest = false 11 | 12 | [dependencies] 13 | anyhow = { version = "1.0.97" } 14 | argon2 = "0.5.3" 15 | axum = { version = "0.8.3", features = ["macros", "tracing"] } 16 | axum-server = { version = "0.7.2", features = ["tls-rustls-no-provider"] } 17 | clap = { version = "4.5.34", features = ["derive", "env"] } 18 | fake = { version = "4.2.0", default-features = false } 19 | friendly-zoo = "1.1.0" 20 | garde = { version = "0.22.0", default-features = false, features = [ 21 | "derive", 22 | "url", 23 | "regex", 24 | ] } 25 | include_dir = "0.7.4" 26 | listenfd = "1.0.2" 27 | mime_guess = "2.0.5" 28 | rand = { version = "0.9.0", default-features = false } 29 | serde = "1.0.219" 30 | serde-aux = { version = "4.6.0", default-features = false } 31 | serde_json = "1.0.140" 32 | serde_qs = "0.14.0" 33 | sqlx = { version = "0.8.3", features = [ 34 | "runtime-tokio", 35 | "postgres", 36 | "migrate", 37 | "uuid", 38 | "time", 39 | "json", 40 | ], default-features = false } 41 | thiserror = "2.0.12" 42 | openidconnect = "4.0.0" 43 | time = { version = "0.3.41", default-features = false, features = ["serde"] } 44 | tokio = { version = "1.44.2", features = [ 45 | "macros", 46 | "rt-multi-thread", 47 | "signal", 48 | ] } 49 | tower = { version = "0.5.2", features = ["util"] } 50 | tower-http = { version = "0.6.2", features = ["tracing", "trace"] } 51 | tower-sessions = { version = "0.14" } 52 | tracing = "0.1.41" 53 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 54 | uuid = { version = "1.16.0", features = ["v4", "serde"] } 55 | visdom = "1.0.3" 56 | tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] } 57 | rustls = { version = "0.23.25", default-features = false, features = ["ring"] } 58 | htmf = { git = "https://github.com/raffomania/htmf", features = [ 59 | "pretty-print", 60 | ] } 61 | percent-encoding = "2.3.1" 62 | activitypub_federation = { version = "0.6.5", default-features = false, features = [ 63 | "axum", 64 | ] } 65 | url = { version = "2.5.4", features = ["serde"] } 66 | async-trait = "0.1.85" 67 | chrono = "0.4.39" 68 | 69 | [patch.crates-io] 70 | garde = { git = "https://github.com/raffomania/garde", branch = "url-length" } 71 | activitypub_federation = { git = "https://github.com/raffomania/activitypub-federation-rust", branch = "update-axum" } 72 | 73 | [build-dependencies] 74 | railwind = "0.1.5" 75 | walkdir = "2" 76 | regex = "1.11.1" 77 | 78 | [dev-dependencies] 79 | http-body-util = "0.1.3" 80 | serde_json = "1.0.140" 81 | test-log = { version = "0.2.17", features = [ 82 | "trace", 83 | ], default-features = false } 84 | itertools = "0.14.0" 85 | insta = "1.42.2" 86 | 87 | [package.metadata.bin] 88 | just = { version = "1.38.0", locked = true } 89 | cargo-watch = { version = "8.5.3", locked = true } 90 | systemfd = { version = "0.4.3", locked = true } 91 | sqlx-cli = { version = "0.8.3", locked = true, bins = ["sqlx"] } 92 | cargo-cyclonedx = { version = "0.5.7", locked = true } 93 | 94 | [profile.dev.package] 95 | insta.opt-level = 3 96 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM debian:testing-slim 2 | COPY --chmod=755 linkblocks /app/ 3 | ENTRYPOINT ["/app/linkblocks", "start"] 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1-bookworm as builder 2 | 3 | WORKDIR /usr/src/app 4 | COPY . . 5 | 6 | ENV SQLX_OFFLINE=true 7 | # Will build and cache the binary and dependent crates in release mode 8 | RUN --mount=type=cache,target=/usr/local/cargo,from=rust:latest,source=/usr/local/cargo \ 9 | --mount=type=cache,target=target \ 10 | cargo build --release && mv ./target/release/linkblocks ./linkblocks 11 | 12 | # Runtime image 13 | FROM debian:bookworm-slim 14 | 15 | # Run as "app" user 16 | RUN useradd -ms /bin/bash app 17 | 18 | USER app 19 | WORKDIR /app 20 | 21 | # Get compiled binaries from builder's cargo install directory 22 | COPY --from=builder /usr/src/app/linkblocks /app/linkblocks 23 | 24 | # Run the app 25 | CMD ./linkblocks start 26 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load := true 2 | set export := true 3 | 4 | watch *args: development-cert start-database 5 | cargo bin systemfd --no-pid -s http::4040 -- cargo bin cargo-watch -- cargo run start --listenfd {{args}} 6 | 7 | run *args: development-cert 8 | cargo run -- {{args}} 9 | 10 | insert-demo-data: migrate-database 11 | RUST_LOG=error cargo run -- insert-demo-data 12 | 13 | start-database: 14 | #!/usr/bin/env bash 15 | set -euxo pipefail 16 | 17 | if podman ps --format "{{{{.Names}}" | grep -wq linkblocks_postgres; then 18 | echo "Database is running." 19 | exit 20 | fi 21 | 22 | if ! podman inspect linkblocks_postgres &> /dev/null; then 23 | podman create \ 24 | --name linkblocks_postgres \ 25 | -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_DB=${DATABASE_NAME} \ 26 | -p ${DATABASE_PORT}:5432 docker.io/postgres:15 \ 27 | postgres 28 | fi 29 | 30 | podman start linkblocks_postgres 31 | 32 | for i in {1..20}; do 33 | pg_isready -h localhost -p $DATABASE_PORT && break 34 | sleep 2 35 | done 36 | 37 | start-rauthy: 38 | #!/usr/bin/env bash 39 | set -euxo pipefail 40 | 41 | # TODO: extract helpers for repetitive podman tasks. 42 | if podman ps --format "{{{{.Names}}" | grep -wq linkblocks_rauthy; then 43 | echo "Rauthy is running." 44 | exit 45 | fi 46 | 47 | if ! podman inspect linkblocks_rauthy &> /dev/null; then 48 | podman create \ 49 | --replace --name linkblocks_rauthy \ 50 | -e COOKIE_MODE=danger-insecure \ 51 | -e PUB_URL=localhost:${RAUTHY_PORT} \ 52 | -e LOG_LEVEL=info \ 53 | -e BOOTSTRAP_ADMIN_PASSWORD_PLAIN="test" \ 54 | -e DATABASE_URL=sqlite:data/rauthy.db \ 55 | -p ${RAUTHY_PORT}:8080 \ 56 | ghcr.io/sebadob/rauthy:0.25.0-lite 57 | fi 58 | 59 | podman start linkblocks_rauthy 60 | 61 | stop-rauthy: 62 | podman stop linkblocks_rauthy 63 | 64 | wipe-rauthy: stop-rauthy 65 | podman rm linkblocks_rauthy 66 | 67 | stop-database: 68 | podman stop linkblocks_postgres 69 | 70 | wipe-database: stop-database && migrate-database 71 | podman rm linkblocks_postgres 72 | 73 | migrate-database: start-database 74 | cargo bin sqlx-cli migrate run 75 | 76 | generate-database-info: start-database migrate-database 77 | cargo bin sqlx-cli prepare 78 | 79 | start-test-database: 80 | #!/usr/bin/env bash 81 | set -euxo pipefail 82 | 83 | if podman ps --format "{{{{.Names}}" | grep -wq linkblocks_postgres_test; then 84 | echo "Test database is running." 85 | exit 86 | fi 87 | 88 | if ! podman inspect linkblocks_postgres_test &> /dev/null; then 89 | podman create \ 90 | --replace --name linkblocks_postgres_test --image-volume tmpfs \ 91 | --health-cmd pg_isready --health-interval 10s \ 92 | -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_DB=${DATABASE_NAME_TEST} \ 93 | -p ${DATABASE_PORT_TEST}:5432 --rm docker.io/postgres:16 \ 94 | postgres \ 95 | -c fsync=off \ 96 | -c synchronous_commit=off \ 97 | -c full_page_writes=off \ 98 | -c autovacuum=off 99 | fi 100 | 101 | podman start linkblocks_postgres_test 102 | 103 | for i in {1..20}; do 104 | pg_isready -h localhost -p $DATABASE_PORT_TEST && break 105 | sleep 2 106 | done 107 | 108 | test *args: start-test-database 109 | RUST_BACKTRACE=1 DATABASE_URL=${DATABASE_URL_TEST} SQLX_OFFLINE=true cargo test {{args}} 110 | 111 | development-cert: 112 | mkdir -p development_cert 113 | test -f development_cert/localhost.crt || mkcert -cert-file development_cert/localhost.crt -key-file development_cert/localhost.key localhost 127.0.0.1 ::1 114 | 115 | ci-dev : migrate-database start-test-database && generate-sbom 116 | #!/usr/bin/env bash 117 | export RUSTFLAGS="-D warnings" 118 | # Prevent full recompilations in the normal dev setup which has different rustflags 119 | export CARGO_TARGET_DIR="target_ci" 120 | 121 | cargo build --release 122 | 123 | just lint 124 | just format 125 | just test 126 | 127 | lint *args: reuse-lint 128 | cargo clippy {{args}} -- -D warnings 129 | 130 | lint-fix *args: reuse-lint 131 | cargo clippy --fix {{args}} 132 | 133 | reuse-lint: 134 | reuse --root . lint 135 | 136 | format: 137 | cargo +nightly fmt --all 138 | 139 | generate-sbom: 140 | cargo bin cargo-cyclonedx --format json --describe binaries 141 | # Remove some fields that make the sbom non-reproducible. 142 | # https://github.com/CycloneDX/cyclonedx-rust-cargo/issues/556 143 | # https://github.com/CycloneDX/cyclonedx-rust-cargo/issues/514 144 | jq --sort-keys '.components |= sort_by(.purl) | del(.serialNumber) | del(.metadata.timestamp) | del(..|select(type == "string" and test("^path\\+file")))' linkblocks_bin.cdx.json > linkblocks.cdx.json 145 | rm linkblocks_bin.cdx.json 146 | 147 | install-git-hooks: 148 | ln -srf pre-commit.sh .git/hooks/pre-commit 149 | -------------------------------------------------------------------------------- /LICENSES/0BSD.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) YEAR by AUTHOR EMAIL 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # linkblocks 2 | 3 | **📚 A federated network to bookmark, share and discuss good web pages with your friends.** 4 | 5 | It's getting harder and harder to find good web pages. When you do find good ones, it's worth hanging onto them. Linkblocks is your own small corner of the web, where you can keep your favorite pages, and share them with your friends to help them find good web pages too. 6 | 7 | 🔭 Linkblocks is in an exploratory phase where we're trying out different ways to make it work well. You can try it out, but big and small things might change with every update. 8 | 9 | ## Vision 10 | 11 | - On linkblocks, you can organize, connect, browse and search your favorite web pages. 12 | - Share carefully curated or wildly chaotic collections of the stuff you really really like with other linkblocks users and the whole world wide web. 13 | - Follow users with a similar taste and get a feed of fresh good web pages every day. Browse others' collections to discover new web pages from topics you like. 14 | - Annotate, highlight and discuss web pages together with your friends. 15 | - Mark users as trusted whose standards for web pages match yours - and then search through all trusted bookmarks to find good pages on a specific topic. Add trusted users of your trusted users to your search range to cast a wider net. 16 | 17 | [See this blog post for more on the vision behind linkblocks.](https://www.rafa.ee/articles/introducing-linkblocks-federated-bookmark-manager/) 18 | 19 | ## Related Reading 20 | 21 | - [Where have all the Websites gone?](https://www.fromjason.xyz/p/notebook/where-have-all-the-websites-gone/) talks about the importance of website curation. Linkblocks is for publicly curating websites. 22 | - [The Small Website Discoverability Crisis](https://www.marginalia.nu/log/19-website-discoverability-crisis/) similar to the previous link, it encourages everyone to share reading lists. By the author of the amazing [marginalia search engine](https://search.marginalia.nu/). 23 | 24 | ## Development Setup 25 | 26 | Install the dependencies: 27 | 28 | - [Latest stable version of Rust](https://www.rust-lang.org/learn/get-started) (An older version might work as well, but is not tested) 29 | - [mkcert](https://github.com/FiloSottile/mkcert#installation) 30 | - Don't forget to run `mkcert -install` 31 | - Optional: [podman](http://podman.io/docs/installation), for conveniently running postgres for development and tests 32 | 33 | Install dependencies available via cargo: 34 | 35 | ```sh 36 | cargo install cargo-run-bin 37 | ``` 38 | 39 | Copy `.env.example` to `.env` and edit it to your liking. 40 | 41 | Optional: run `cargo bin just install-git-hooks` to automatically run checks before committing. 42 | 43 | In the root of the repository, launch the server: 44 | 45 | ```sh 46 | cargo bin just watch 47 | ``` 48 | 49 | Then, open [http://localhost:4040] in your browser. 50 | 51 | ### Testing SSO with Rauthy 52 | 53 | 1. Run `just start-rauthy` to run [rauthy](https://github.com/sebadob/rauthy) in development mode in a container. 54 | 1. Open rauthy in your browser by going to localhost with the port specified by `RAUTHY_PORT` in your `.env` file. 55 | 1. Go to the admin area and log in as `admin@localhost.de` with the password `test`. 56 | 1. Create a new client. Use `{BASE_URL}/login_oidc_redirect` as your redirect URI, with the base URL defined in your `.env` file. Set access and id algorithm to "EdDSA". 57 | 1. Enter your client ID and secret in your `.env` file. 58 | 1. Restart the linkblocks server. On the login page, there should be a "Sign in with Rauthy" button. If it's not there, check the server logs to see if something related to OIDC went wrong. 59 | 60 | ## Hosting Your Own Instance 61 | 62 | ⚠️ linkblocks is in a pre-alpha stage. There are no versions and no changelog. All data in the system will be publicly available. There are no authorization checks. Expect data loss. 63 | 64 | You can run the container at `ghcr.io/raffomania/linkblocks:latest`. It's automatically updated to contain the latest version of the `main` branch. 65 | 66 | Linkblocks is configured through environment variables or command line options. 67 | Run `linkblocks --help` to for documentation on the available options. 68 | The [.env.example] file contains an example configuration for a development environment. 69 | 70 | ## Technical Details 71 | 72 | This web app is implemented using technologies hand-picked for a smooth development and deployment workflow. Here are some of the features of the stack: 73 | 74 | - Type-safe and fast, implemented in [Rust](https://www.rust-lang.org/) using the [axum framework](https://github.com/tokio-rs/axum) 75 | - Snappy interactivity using [htmx](https://htmx.org/) with almost zero client-side code 76 | - [Tailwind styles without NodeJS](https://github.com/pintariching/railwind), integrated into the cargo build process using [build scripts](https://doc.rust-lang.org/cargo/reference/build-scripts.html) 77 | - Compile-time verified HTML templates using [htmf](https://github.com/raffomania/htmf) 78 | - Compile-time verified database queries using [SQLx](https://github.com/launchbadge/sqlx) 79 | - Concurrent, isolated integration tests with per-test in-memory postgres databases 80 | - Single-binary deployment; all assets baked in 81 | - Integrated TLS; can run without a reverse proxy 82 | - PostgreSQL as the only service dependency 83 | - Built-in CLI for production maintenance 84 | - Auto-reload in development [without dropped connections](https://github.com/mitsuhiko/listenfd) 85 | 86 | ## Software Bill of Materials 87 | 88 | An up-to-date Software Bill of Materials can be found in the [linkblocks.cdx.json](linkblocks.cdx.json) file. 89 | 90 | ## Acknowledgements 91 | 92 | NLnet logo NGI Zero Commons logo 93 | 94 | linkblocks is made possible with a [donation](https://nlnet.nl/commonsfund/acknowledgement.pdf) from NGI Zero Commons Fund. 95 | NGI Zero Commons Fund is part of the [European Commission](https://ec.europa.eu/)'s [Next Generation Internet](https://ngi.eu/) initiative, established under the aegis of the [DG Communications Networks, Content and Technology](https://ec.europa.eu/info/departments/communications-networks-content-and-technology_en). 96 | Additional funding is made available by the Swiss State Secretariat for Education, Research and Innovation (SERI). 97 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # NOTE: This project does not attribute contributors individually. Instead refer to `git log --format="%an <%aE>" | sort -u` for a list of individual contributors. 2 | version = 1 3 | SPDX-PackageName = "linkblocks" 4 | SPDX-PackageDownloadLocation = "https://github.com/raffomania/linkblocks" 5 | 6 | # "Proper" Source Code is AGPL-licensed 7 | [[annotations]] 8 | path = [ 9 | "**.rs", 10 | "migrations/*.sql", 11 | "Justfile", 12 | ".github/**.yml", 13 | "pre-commit.sh", 14 | "Cargo.toml", 15 | ".rustfmt.toml", 16 | "Dockerfile", 17 | ".dockerignore", 18 | "Containerfile", 19 | ".gitignore", 20 | ".gitattributes", 21 | ".editorconfig", 22 | ".vscode/settings.json", 23 | "tailwind.config.js", 24 | ] 25 | SPDX-FileCopyrightText = "linkblocks Contributors" 26 | SPDX-License-Identifier = "AGPL-3.0-only" 27 | 28 | # For docs, other prose, and images, a CC-style license is better suited 29 | [[annotations]] 30 | path = ["**.md", "doc/*.svg"] 31 | SPDX-FileCopyrightText = "linkblocks Contributors" 32 | SPDX-License-Identifier = "CC-BY-SA-4.0" 33 | 34 | # People deploying linkblocks can copy .env.example and do whatever they wish with it. 35 | [[annotations]] 36 | path = [".env.example"] 37 | SPDX-FileCopyrightText = "linkblocks Contributors" 38 | SPDX-License-Identifier = "CC0-1.0" 39 | 40 | # Vendored dependencies 41 | [[annotations]] 42 | path = ["assets/htmx.1.9.9.js"] 43 | SPDX-FileCopyrightText = "NONE" 44 | SPDX-License-Identifier = "0BSD" 45 | 46 | [[annotations]] 47 | path = ["assets/preflight.css"] 48 | SPDX-FileCopyrightText = "Tailwind Labs, Inc" 49 | SPDX-License-Identifier = "MIT" 50 | 51 | # Generated files are "uncopyrightable", 52 | # see https://reuse.software/faq/#uncopyrightable for more 53 | [[annotations]] 54 | path = [ 55 | ".sqlx/*.json", 56 | "Cargo.lock", 57 | "src/tests/snapshots/*.snap", 58 | "linkblocks.cdx.json", 59 | ] 60 | SPDX-FileCopyrightText = "linkblocks Contributors" 61 | SPDX-License-Identifier = "CC0-1.0" 62 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, path::Path}; 2 | 3 | use railwind::{Source, SourceOptions}; 4 | use regex::Regex; 5 | 6 | fn main() { 7 | // Without this, adding only a migration will not trigger a re-build 8 | // https://docs.rs/sqlx/latest/sqlx/macro.migrate.html#stable-rust-cargo-build-script 9 | println!("cargo:rerun-if-changed=migrations"); 10 | 11 | println!("cargo:rerun-if-changed=src/views"); 12 | 13 | let out_dir = env::var("OUT_DIR").unwrap(); 14 | 15 | let dest_path = Path::new(&out_dir).join("railwind.css"); 16 | 17 | let paths: Vec<_> = walkdir::WalkDir::new("src/views") 18 | .into_iter() 19 | .map(|e| e.expect("Error while searching for views")) 20 | .filter(|e| e.file_type().is_file()) 21 | .map(|entry| entry.into_path()) 22 | .collect(); 23 | 24 | let sources = paths 25 | .iter() 26 | .map(|p| SourceOptions { 27 | input: p, 28 | option: railwind::CollectionOptions::Regex( 29 | Regex::new(r#"class[\n\s\(]*"([^"]+)""#).unwrap(), 30 | ), 31 | }) 32 | .collect(); 33 | 34 | let source = Source::Files(sources); 35 | railwind::parse_to_file(source, dest_path.to_str().unwrap(), false, &mut Vec::new()); 36 | } 37 | -------------------------------------------------------------------------------- /doc/federation.md: -------------------------------------------------------------------------------- 1 | # Federation 2 | 3 | This document serves as a technical plan for implementing federation in linkblocks, including a survey of how other platforms federate. 4 | 5 | ## Compatibility 6 | 7 | We currently aim for compatibility with Mastodon, Lemmy and Betula. As each of these services has a different feature set, compatibility means a lowest common denominator which both linkblocks and the other service support. 8 | 9 | ## Users 10 | 11 | ## Lists 12 | 13 | We'll probably build something similar to [Lemmy's groups](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). 14 | 15 | ## Bookmarks 16 | 17 | Betula federates bookmarks [as notes](https://git.sr.ht/~bouncepaw/betula/tree/master/item/fediverse/activities/note.go). The bookmark URL is inserted as an `a` tag into the notes' html body, and as a `Link` object into the `attachments` array. 18 | 19 | Lemmy's posts are `Page` objects. It seems like mastodon can ingest both `Note` and `Page` objects as toots. 20 | 21 | ## Comments 22 | 23 | ## Knowledge Graph 24 | 25 | ### Lemmy 26 | 27 | Links from a group to its entries can be listed through its `replies` field. Links from an entry to its group are established via an entries' `audience` field. Links from an entry to its parent comment are establisthed via its `inReplyTo` field. This forms a tree structure, which may be adapted to a graph by: 28 | 29 | - putting lists into the `inReplyTo` field 30 | - putting a link to a collection into the `inReplyTo` field 31 | 32 | After a scan of lemmy's code, it seems like it doesn't currently work with either of these approaches: `inReplyTo` will reject anything that is not a single post or a single comment. 33 | 34 | Lemmy's cross-posts are a little similar to linkblocks' links between bookmarks and multiple lists, but they are duplicated in lemmy: each group gets its own post, and they are fetched query time in each read operation. 35 | 36 | ### Ibis Wiki 37 | 38 | Links between wiki pages are stored as custom markdown syntax, e.g. ` [[Main_Page@ibis.wiki]]`. It seems like they are not represented via any ActivityStreams objects. 39 | -------------------------------------------------------------------------------- /migrations/20231224122154_users.sql: -------------------------------------------------------------------------------- 1 | create table users ( 2 | id uuid primary key 3 | default gen_random_uuid() 4 | not null, 5 | password_hash text 6 | not null, 7 | username text 8 | unique 9 | not null 10 | ); 11 | -------------------------------------------------------------------------------- /migrations/20240201113323_basics.sql: -------------------------------------------------------------------------------- 1 | create table bookmarks ( 2 | id uuid 3 | primary key 4 | default gen_random_uuid() 5 | not null, 6 | created_at timestamp with time zone 7 | default current_timestamp 8 | not null, 9 | user_id uuid 10 | references users(id) 11 | not null, 12 | 13 | url text 14 | not null, 15 | title text 16 | not null 17 | ); 18 | 19 | create table notes ( 20 | id uuid 21 | primary key 22 | default gen_random_uuid() 23 | not null, 24 | created_at timestamp with time zone 25 | default current_timestamp 26 | not null, 27 | user_id uuid 28 | references users(id) 29 | not null, 30 | 31 | title text 32 | not null, 33 | content text 34 | default null 35 | ); 36 | 37 | create table links ( 38 | id uuid 39 | primary key 40 | default gen_random_uuid() 41 | not null, 42 | created_at timestamp with time zone 43 | default current_timestamp 44 | not null, 45 | user_id uuid 46 | references users(id) 47 | not null, 48 | 49 | src_note_id uuid 50 | references notes(id) 51 | not null, 52 | 53 | dest_bookmark_id uuid 54 | references bookmarks(id) 55 | default null, 56 | dest_note_id uuid 57 | references notes(id) 58 | default null, 59 | 60 | check ( 61 | num_nonnulls( 62 | dest_bookmark_id, dest_note_id 63 | ) = 1 64 | ) 65 | ) 66 | -------------------------------------------------------------------------------- /migrations/20240312104935_rename_notes.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | alter table notes rename to lists; 3 | 4 | alter table links rename column src_note_id to src_list_id; 5 | alter table links rename column dest_note_id to dest_list_id; 6 | -------------------------------------------------------------------------------- /migrations/20240424165115_add_users_oauth.sql: -------------------------------------------------------------------------------- 1 | alter table users 2 | add column email text 3 | default null, 4 | add column oidc_id text 5 | default null, 6 | alter column username 7 | drop not null, 8 | alter column password_hash 9 | drop not null; 10 | -------------------------------------------------------------------------------- /migrations/20240426170949_public_lists.sql: -------------------------------------------------------------------------------- 1 | alter table lists 2 | add column private boolean 3 | not null 4 | default false; 5 | -------------------------------------------------------------------------------- /migrations/20240923130043_add_list_pinned.sql: -------------------------------------------------------------------------------- 1 | alter table lists 2 | add column pinned boolean 3 | not null 4 | default true; 5 | -------------------------------------------------------------------------------- /migrations/20250124155026_mandatory_username.sql: -------------------------------------------------------------------------------- 1 | alter table users 2 | alter column username 3 | set not null; 4 | -------------------------------------------------------------------------------- /migrations/20250127102308_users_activitypub_columns.sql: -------------------------------------------------------------------------------- 1 | create table ap_users ( 2 | id uuid 3 | primary key 4 | default gen_random_uuid() 5 | not null, 6 | ap_id varchar(255) 7 | unique 8 | not null, 9 | username varchar(50) 10 | unique 11 | not null, 12 | inbox_url varchar(255) 13 | not null, 14 | public_key varchar(10000) 15 | not null, 16 | private_key varchar(10000) 17 | default null, 18 | last_refreshed_at timestamp with time zone 19 | not null, 20 | display_name varchar(100) 21 | default null, 22 | bio varchar(1000) 23 | default null 24 | ); 25 | 26 | alter table users 27 | add column ap_user_id uuid 28 | references ap_users(id) 29 | default null 30 | ; 31 | -------------------------------------------------------------------------------- /pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | just start-database 4 | just migrate-database 5 | 6 | if ! cargo bin sqlx-cli prepare --check; then 7 | just generate-database-info 8 | exit 1 9 | fi 10 | -------------------------------------------------------------------------------- /src/authentication.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, anyhow}; 2 | use argon2::PasswordVerifier; 3 | use axum::{ 4 | extract::{FromRequestParts, OptionalFromRequestParts, OriginalUri}, 5 | http::request::Parts, 6 | response::Redirect, 7 | }; 8 | use garde::Validate; 9 | use percent_encoding::utf8_percent_encode; 10 | use tower_sessions::Session; 11 | use url::Url; 12 | use uuid::Uuid; 13 | 14 | use crate::{ 15 | db::{self, AppTx, User}, 16 | forms::users::{CreateOidcUser, CreateUser, Credentials}, 17 | response_error::{ResponseError, ResponseResult}, 18 | server::AppState, 19 | }; 20 | 21 | pub fn hash_password(password: &String) -> ResponseResult { 22 | let salt = 23 | argon2::password_hash::SaltString::generate(&mut argon2::password_hash::rand_core::OsRng); 24 | 25 | let argon2 = argon2::Argon2::default(); 26 | 27 | Ok( 28 | argon2::PasswordHasher::hash_password(&argon2, password.as_bytes(), &salt) 29 | .map_err(|e| anyhow!("Failed to hash password: {e}"))? 30 | .to_string(), 31 | ) 32 | } 33 | 34 | pub fn verify_password(user: &db::User, password: &str) -> ResponseResult<()> { 35 | let existing_hash = user 36 | .password_hash 37 | .as_ref() 38 | .context("User has no password set")?; 39 | let password_hash = &argon2::PasswordHash::new(existing_hash) 40 | .map_err(|e| anyhow!("Failed to create password hash: {e}"))?; 41 | 42 | argon2::Argon2::default() 43 | .verify_password(password.as_bytes(), password_hash) 44 | .map_err(|_e| ResponseError::NotAuthenticated)?; 45 | 46 | Ok(()) 47 | } 48 | 49 | pub async fn login(tx: &mut AppTx, session: Session, creds: &Credentials) -> ResponseResult<()> { 50 | let user = db::users::by_username(tx, &creds.username).await?; 51 | 52 | verify_password(&user, &creds.password)?; 53 | 54 | AuthUser::save_in_session(&session, user.id).await?; 55 | 56 | Ok(()) 57 | } 58 | 59 | pub async fn create_and_login_temp_user( 60 | tx: &mut AppTx, 61 | session: Session, 62 | base_url: &Url, 63 | ) -> ResponseResult<()> { 64 | let username = 65 | friendly_zoo::Zoo::new(friendly_zoo::Species::CustomDelimiter('_'), 1).generate(); 66 | let password = Uuid::new_v4().to_string(); 67 | let create = CreateUser { username, password }; 68 | create.validate().context("Invalid demo user generated")?; 69 | let user = db::users::insert(tx, create, base_url).await?; 70 | 71 | AuthUser::save_in_session(&session, user.id).await?; 72 | 73 | Ok(()) 74 | } 75 | 76 | pub async fn create_and_login_oidc_user( 77 | tx: &mut AppTx, 78 | session: &Session, 79 | create_oidc_user: CreateOidcUser, 80 | ) -> ResponseResult<()> { 81 | let user = db::users::user_by_oidc_id(tx, &create_oidc_user.oidc_id).await; 82 | 83 | let user = match user { 84 | Ok(user) => user, 85 | Err(ResponseError::NotFound) => db::users::insert_oidc(tx, create_oidc_user).await?, 86 | Err(_) => return Err(anyhow!("Failed to look up user by OIDC id").into()), 87 | }; 88 | 89 | AuthUser::save_in_session(session, user.id).await?; 90 | 91 | Ok(()) 92 | } 93 | 94 | pub async fn login_oidc_user(session: &Session, user: &User) -> ResponseResult<()> { 95 | AuthUser::save_in_session(session, user.id).await 96 | } 97 | 98 | #[derive(Debug)] 99 | pub struct AuthUser { 100 | pub user_id: Uuid, 101 | session: Session, 102 | } 103 | 104 | impl AuthUser { 105 | const SESSION_KEY: &'static str = "auth_user_id"; 106 | 107 | pub async fn save_in_session(session: &Session, id: Uuid) -> ResponseResult<()> { 108 | session 109 | .insert(Self::SESSION_KEY, id) 110 | .await 111 | .context("Failed to insert id into session")?; 112 | 113 | Ok(()) 114 | } 115 | 116 | pub async fn from_session(session: Session) -> ResponseResult { 117 | let user_id: Uuid = session 118 | .get(Self::SESSION_KEY) 119 | .await 120 | .context("Failed to load authenticated user id")? 121 | .ok_or(ResponseError::NotAuthenticated)?; 122 | 123 | Ok(Self { user_id, session }) 124 | } 125 | 126 | pub async fn logout(self) -> ResponseResult<()> { 127 | self.session 128 | .remove::(Self::SESSION_KEY) 129 | .await 130 | .context("Failed to remove user id from session")?; 131 | Ok(()) 132 | } 133 | } 134 | 135 | impl FromRequestParts for AuthUser { 136 | type Rejection = Redirect; 137 | 138 | async fn from_request_parts( 139 | req: &mut Parts, 140 | state: &AppState, 141 | ) -> std::result::Result { 142 | let uri = OriginalUri::from_request_parts(req, state).await.unwrap(); 143 | 144 | let redirect_after_login = uri 145 | .path_and_query() 146 | .map(|pq| pq.as_str()) 147 | .unwrap_or_default(); 148 | let redirect_after_login = 149 | utf8_percent_encode(redirect_after_login, percent_encoding::NON_ALPHANUMERIC) 150 | .to_string(); 151 | 152 | let redirect_to = format!("/login?previous_uri={redirect_after_login}",); 153 | let error_redirect = Redirect::to(&redirect_to); 154 | 155 | let session = Session::from_request_parts(req, state).await.map_err(|e| { 156 | tracing::error!("Failed to initialize session: {e:?}"); 157 | error_redirect.clone() 158 | })?; 159 | 160 | let auth_user = AuthUser::from_session(session).await; 161 | if let Err(ResponseError::NotAuthenticated) = auth_user { 162 | return Err(error_redirect); 163 | } 164 | 165 | auth_user.map_err(|e| { 166 | tracing::error!("{e:?}"); 167 | error_redirect 168 | }) 169 | } 170 | } 171 | 172 | impl OptionalFromRequestParts for AuthUser { 173 | type Rejection = ResponseError; 174 | 175 | async fn from_request_parts( 176 | req: &mut Parts, 177 | state: &AppState, 178 | ) -> std::result::Result, Self::Rejection> { 179 | let session = Session::from_request_parts(req, state) 180 | .await 181 | .map_err(|(_status, description)| anyhow!(description))?; 182 | 183 | let auth_user = AuthUser::from_session(session).await; 184 | if let Err(ResponseError::NotAuthenticated) = auth_user { 185 | return Ok(None); 186 | } 187 | 188 | Ok(Some(auth_user?)) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/date_time.rs: -------------------------------------------------------------------------------- 1 | pub fn time_to_chrono(time_date: time::OffsetDateTime) -> chrono::DateTime { 2 | #[expect(clippy::cast_possible_truncation)] 3 | chrono::TimeZone::timestamp_nanos( 4 | &chrono::Utc, 5 | time_date.to_utc().unix_timestamp_nanos() as i64, 6 | ) 7 | } 8 | 9 | #[cfg(test)] 10 | #[allow(clippy::unwrap_used)] 11 | mod test { 12 | 13 | use super::time_to_chrono; 14 | 15 | #[test] 16 | fn works_across_timezones() { 17 | let time_date = time::OffsetDateTime::new_in_offset( 18 | time::Date::from_calendar_date(2025, time::Month::February, 15).unwrap(), 19 | time::Time::from_hms(5, 1, 17).unwrap(), 20 | time::UtcOffset::from_hms(1, 0, 0).unwrap(), 21 | ) 22 | .to_offset(time::UtcOffset::UTC); 23 | 24 | let chrono_time = time_to_chrono(time_date); 25 | 26 | assert_eq!(time_date.offset(), time::UtcOffset::UTC); 27 | assert_eq!(chrono_time.offset(), &chrono::Utc); 28 | 29 | assert_eq!(time_date.unix_timestamp(), chrono_time.timestamp()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/db/all.rs: -------------------------------------------------------------------------------- 1 | use sqlx::query; 2 | 3 | use super::AppTx; 4 | use crate::response_error::ResponseResult; 5 | 6 | pub async fn wipe_all_data(tx: &mut AppTx) -> ResponseResult<()> { 7 | query!("truncate table links cascade;") 8 | .execute(&mut **tx) 9 | .await?; 10 | query!("truncate table lists cascade;") 11 | .execute(&mut **tx) 12 | .await?; 13 | query!("truncate table bookmarks cascade;") 14 | .execute(&mut **tx) 15 | .await?; 16 | query!("truncate table users cascade;") 17 | .execute(&mut **tx) 18 | .await?; 19 | 20 | Ok(()) 21 | } 22 | -------------------------------------------------------------------------------- /src/db/ap_users.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::fetch::object_id::ObjectId; 2 | use sqlx::{FromRow, query_as}; 3 | use time::OffsetDateTime; 4 | use url::Url; 5 | use uuid::Uuid; 6 | 7 | use super::AppTx; 8 | use crate::{forms::ap_users::CreateApUser, response_error::ResponseResult}; 9 | 10 | #[derive(FromRow, Debug)] 11 | pub struct ApUser { 12 | pub id: Uuid, 13 | 14 | pub ap_id: ObjectId, 15 | pub username: String, 16 | pub inbox_url: Url, 17 | pub public_key: String, 18 | pub private_key: Option, 19 | pub last_refreshed_at: OffsetDateTime, 20 | pub display_name: Option, 21 | pub bio: Option, 22 | } 23 | 24 | #[derive(FromRow, Debug)] 25 | struct ApUserRow { 26 | pub id: Uuid, 27 | 28 | pub ap_id: String, 29 | pub username: String, 30 | pub inbox_url: String, 31 | pub public_key: String, 32 | pub private_key: Option, 33 | pub last_refreshed_at: OffsetDateTime, 34 | pub display_name: Option, 35 | pub bio: Option, 36 | } 37 | 38 | impl TryFrom for ApUser { 39 | fn try_from(value: ApUserRow) -> anyhow::Result { 40 | Ok(ApUser { 41 | id: value.id, 42 | ap_id: value.ap_id.parse()?, 43 | username: value.username, 44 | inbox_url: value.inbox_url.parse()?, 45 | public_key: value.public_key, 46 | private_key: value.private_key, 47 | last_refreshed_at: value.last_refreshed_at, 48 | display_name: value.display_name, 49 | bio: value.bio, 50 | }) 51 | } 52 | 53 | type Error = anyhow::Error; 54 | } 55 | 56 | pub async fn insert(tx: &mut AppTx, create_user: CreateApUser) -> ResponseResult { 57 | let user = query_as!( 58 | ApUserRow, 59 | r#" 60 | insert into ap_users 61 | ( 62 | ap_id, 63 | username, 64 | inbox_url, 65 | public_key, 66 | private_key, 67 | last_refreshed_at, 68 | display_name, 69 | bio 70 | ) 71 | values ($1, $2, $3, $4, $5, $6, $7, $8) 72 | returning * 73 | "#, 74 | create_user.ap_id.to_string(), 75 | create_user.username, 76 | create_user.inbox_url.to_string(), 77 | create_user.public_key, 78 | create_user.private_key, 79 | create_user.last_refreshed_at, 80 | create_user.display_name, 81 | create_user.bio, 82 | ) 83 | .fetch_one(&mut **tx) 84 | .await? 85 | .try_into()?; 86 | 87 | Ok(user) 88 | } 89 | 90 | pub async fn upsert(tx: &mut AppTx, create_user: CreateApUser) -> ResponseResult { 91 | let user = query_as!( 92 | ApUserRow, 93 | r#" 94 | insert into ap_users 95 | ( 96 | ap_id, 97 | username, 98 | inbox_url, 99 | public_key, 100 | private_key, 101 | last_refreshed_at, 102 | display_name, 103 | bio 104 | ) 105 | values ($1, $2, $3, $4, $5, $6, $7, $8) 106 | on conflict(ap_id) do update set 107 | ap_id = $1, 108 | username = $2, 109 | inbox_url = $3, 110 | public_key = $4, 111 | private_key = $5, 112 | last_refreshed_at = $6, 113 | display_name = $7, 114 | bio = $8 115 | returning * 116 | "#, 117 | create_user.ap_id.to_string(), 118 | create_user.username, 119 | create_user.inbox_url.to_string(), 120 | create_user.public_key, 121 | create_user.private_key, 122 | create_user.last_refreshed_at, 123 | create_user.display_name, 124 | create_user.bio, 125 | ) 126 | .fetch_one(&mut **tx) 127 | .await? 128 | .try_into()?; 129 | 130 | Ok(user) 131 | } 132 | 133 | pub async fn read_by_ap_id(tx: &mut AppTx, ap_id: &Url) -> ResponseResult { 134 | let user = query_as!( 135 | ApUserRow, 136 | r#" 137 | select * from ap_users 138 | where ap_id = $1 139 | "#, 140 | ap_id.to_string() 141 | ) 142 | .fetch_one(&mut **tx) 143 | .await? 144 | .try_into()?; 145 | 146 | Ok(user) 147 | } 148 | 149 | pub async fn read_by_username(tx: &mut AppTx, username: &str) -> ResponseResult { 150 | let user = query_as!( 151 | ApUserRow, 152 | r#" 153 | select * from ap_users 154 | where username = $1 155 | "#, 156 | username 157 | ) 158 | .fetch_one(&mut **tx) 159 | .await? 160 | .try_into()?; 161 | 162 | Ok(user) 163 | } 164 | -------------------------------------------------------------------------------- /src/db/bookmarks.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use sqlx::{FromRow, query_as}; 3 | use time::OffsetDateTime; 4 | use uuid::Uuid; 5 | 6 | use super::AppTx; 7 | use crate::response_error::ResponseResult; 8 | 9 | #[derive(FromRow, Debug, Deserialize, Clone)] 10 | pub struct Bookmark { 11 | pub id: Uuid, 12 | #[serde(with = "time::serde::iso8601")] 13 | #[expect(dead_code)] 14 | pub created_at: OffsetDateTime, 15 | #[expect(dead_code)] 16 | pub user_id: Uuid, 17 | 18 | pub url: String, 19 | pub title: String, 20 | } 21 | 22 | impl Bookmark { 23 | pub fn path(&self) -> String { 24 | let id = self.id; 25 | format!("/bookmarks/{id}") 26 | } 27 | } 28 | 29 | pub struct InsertBookmark { 30 | pub url: String, 31 | pub title: String, 32 | } 33 | 34 | pub async fn insert( 35 | tx: &mut AppTx, 36 | user_id: Uuid, 37 | create_bookmark: InsertBookmark, 38 | ) -> ResponseResult { 39 | let bookmark = query_as!( 40 | Bookmark, 41 | r#" 42 | insert into bookmarks 43 | (user_id, url, title) 44 | values ($1, $2, $3) 45 | returning *"#, 46 | user_id, 47 | create_bookmark.url, 48 | create_bookmark.title 49 | ) 50 | .fetch_one(&mut **tx) 51 | .await?; 52 | 53 | Ok(bookmark) 54 | } 55 | 56 | pub async fn list_unsorted(tx: &mut AppTx, user_id: Uuid) -> ResponseResult> { 57 | let bookmarks = query_as!( 58 | Bookmark, 59 | r#" 60 | select * 61 | from bookmarks 62 | where user_id = $1 63 | and not exists ( 64 | select null from links 65 | where dest_bookmark_id = bookmarks.id 66 | ); 67 | "#, 68 | user_id, 69 | ) 70 | .fetch_all(&mut **tx) 71 | .await?; 72 | 73 | Ok(bookmarks) 74 | } 75 | 76 | pub async fn delete_by_id(tx: &mut AppTx, id: Uuid) -> ResponseResult { 77 | let bookmark = query_as!( 78 | Bookmark, 79 | r#" 80 | delete from bookmarks 81 | where id = $1 82 | returning *; 83 | "#, 84 | id 85 | ) 86 | .fetch_one(&mut **tx) 87 | .await?; 88 | 89 | Ok(bookmark) 90 | } 91 | -------------------------------------------------------------------------------- /src/db/items.rs: -------------------------------------------------------------------------------- 1 | //! todo: better name than "items" 2 | //! maybe "link destinations"? 3 | 4 | use anyhow::Context; 5 | use sqlx::query; 6 | use uuid::Uuid; 7 | 8 | use super::{AppTx, LinkDestination}; 9 | use crate::response_error::ResponseResult; 10 | 11 | // We'll use this for global search later 12 | #[expect(dead_code)] 13 | pub async fn search( 14 | tx: &mut AppTx, 15 | term: &str, 16 | user_id: Uuid, 17 | ) -> ResponseResult> { 18 | let jsons = query!( 19 | r#" 20 | select to_jsonb(bookmarks.*) as item 21 | from bookmarks 22 | where bookmarks.title ilike '%' || $1 || '%' 23 | and bookmarks.user_id = $2 24 | union 25 | select to_jsonb(lists.*) as item 26 | from lists 27 | where lists.title ilike '%' || $1 || '%' 28 | and lists.user_id = $2 29 | limit 10 30 | "#, 31 | term, 32 | user_id 33 | ) 34 | .fetch_all(&mut **tx) 35 | .await?; 36 | 37 | let results = jsons 38 | .into_iter() 39 | .map(|row| Ok(serde_json::from_value(row.item.into())?)) 40 | .collect::>>()?; 41 | 42 | Ok(results) 43 | } 44 | 45 | pub async fn by_id(tx: &mut AppTx, id: Uuid) -> ResponseResult { 46 | let json = query!( 47 | r#" 48 | select to_jsonb(bookmarks.*) as item 49 | from bookmarks 50 | where bookmarks.id = $1 51 | union 52 | select to_jsonb(lists.*) as item 53 | from lists 54 | where lists.id = $1 55 | "#, 56 | id 57 | ) 58 | .fetch_one(&mut **tx) 59 | .await?; 60 | 61 | let results = 62 | serde_json::from_value(json.item.into()).context("Failed to deserialize item from DB")?; 63 | 64 | Ok(results) 65 | } 66 | -------------------------------------------------------------------------------- /src/db/layout.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use sqlx::query; 3 | use uuid::Uuid; 4 | 5 | use super::AppTx; 6 | use crate::{db, response_error::ResponseResult}; 7 | 8 | pub struct AuthedInfo { 9 | pub user_description: String, 10 | pub lists: Vec, 11 | pub user_id: Uuid, 12 | } 13 | 14 | pub async fn by_user_id(tx: &mut AppTx, user_id: Uuid) -> ResponseResult { 15 | let user_description = query!( 16 | r#" 17 | select coalesce(username, email) from users 18 | where id = $1 19 | "#, 20 | user_id 21 | ) 22 | .fetch_one(&mut **tx) 23 | .await? 24 | .coalesce 25 | .context("User has no username or email")?; 26 | 27 | let lists = db::lists::list_pinned_by_user(tx, user_id).await?; 28 | 29 | Ok(AuthedInfo { 30 | user_description, 31 | lists, 32 | user_id, 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /src/db/links.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use sqlx::{FromRow, query, query_as}; 3 | use time::OffsetDateTime; 4 | use uuid::Uuid; 5 | 6 | use super::AppTx; 7 | use crate::{db, forms::links::CreateLink, response_error::ResponseResult}; 8 | 9 | #[derive(FromRow, Debug)] 10 | #[expect(dead_code)] 11 | pub struct Link { 12 | pub id: Uuid, 13 | pub created_at: OffsetDateTime, 14 | pub user_id: Uuid, 15 | 16 | pub src_list_id: Option, 17 | 18 | pub dest_bookmark_id: Option, 19 | pub dest_list_id: Option, 20 | } 21 | 22 | #[derive(Deserialize)] 23 | #[serde(untagged)] 24 | pub enum LinkDestinationWithChildren { 25 | Bookmark(db::Bookmark), 26 | List(db::ListWithLinks), 27 | } 28 | 29 | impl LinkDestinationWithChildren { 30 | pub fn id(&self) -> Uuid { 31 | match self { 32 | LinkDestinationWithChildren::Bookmark(b) => b.id, 33 | LinkDestinationWithChildren::List(l) => l.list.id, 34 | } 35 | } 36 | } 37 | 38 | #[derive(Debug, Deserialize, Clone)] 39 | #[serde(untagged)] 40 | pub enum LinkDestination { 41 | Bookmark(db::Bookmark), 42 | List(db::List), 43 | } 44 | 45 | impl LinkDestination { 46 | pub fn id(&self) -> Uuid { 47 | match self { 48 | LinkDestination::Bookmark(b) => b.id, 49 | LinkDestination::List(n) => n.id, 50 | } 51 | } 52 | 53 | pub fn path(&self) -> String { 54 | match self { 55 | LinkDestination::Bookmark(b) => b.path(), 56 | LinkDestination::List(n) => n.path(), 57 | } 58 | } 59 | } 60 | 61 | pub struct LinkWithContent { 62 | pub id: Uuid, 63 | #[expect(dead_code)] 64 | pub created_at: OffsetDateTime, 65 | #[expect(dead_code)] 66 | pub user_id: Uuid, 67 | 68 | pub dest: LinkDestinationWithChildren, 69 | } 70 | 71 | pub async fn insert( 72 | tx: &mut AppTx, 73 | user_id: Uuid, 74 | create_link: CreateLink, 75 | ) -> ResponseResult { 76 | let list = query_as!( 77 | Link, 78 | r#" 79 | insert into links 80 | ( 81 | user_id, 82 | src_list_id, 83 | dest_bookmark_id, 84 | dest_list_id 85 | ) 86 | values ($1, 87 | (select id from lists where id = $2), 88 | (select id from bookmarks where id = $3), 89 | (select id from lists where id = $3) 90 | ) 91 | returning *"#, 92 | user_id, 93 | create_link.src, 94 | create_link.dest 95 | ) 96 | .fetch_one(&mut **tx) 97 | .await?; 98 | 99 | Ok(list) 100 | } 101 | 102 | pub async fn list_by_list( 103 | tx: &mut AppTx, 104 | list_id: Uuid, 105 | user_id: Option, 106 | ) -> ResponseResult> { 107 | let rows = query!( 108 | r#" 109 | select 110 | links.id as link_id, 111 | links.created_at as link_created_at, 112 | links.user_id as link_user_id, 113 | 114 | case when lists.id is not null then 115 | jsonb_build_object( 116 | 'list', to_jsonb(lists.*), 117 | 'links', 118 | coalesce( 119 | jsonb_agg(lists_bookmarks.*) 120 | filter (where lists_bookmarks.id is not null), 121 | jsonb_build_array()) 122 | || coalesce( 123 | jsonb_agg(lists_lists.*) 124 | filter (where lists_lists.id is not null), 125 | jsonb_build_array()) 126 | ) 127 | when bookmarks.id is not null then 128 | to_jsonb(bookmarks.*) 129 | else null end as dest 130 | from links 131 | 132 | left join lists on lists.id = links.dest_list_id 133 | left join links as lists_links on lists_links.src_list_id = lists.id 134 | left join bookmarks as lists_bookmarks on lists_bookmarks.id = lists_links.dest_bookmark_id 135 | left join lists as lists_lists on lists_lists.id = lists_links.dest_list_id 136 | 137 | left join bookmarks on bookmarks.id = links.dest_bookmark_id 138 | 139 | where links.src_list_id = $1 140 | and (lists is null or not lists.private or lists.user_id = $2) 141 | and (lists_lists is null or not lists_lists.private or lists.user_id = $2) 142 | group by links.id, lists.id, bookmarks.id 143 | order by links.created_at desc 144 | "#, 145 | list_id, 146 | user_id 147 | ) 148 | .fetch_all(&mut **tx) 149 | .await?; 150 | 151 | let results = rows 152 | .into_iter() 153 | .map(|row| { 154 | let dest: LinkDestinationWithChildren = serde_json::from_value(row.dest.into())?; 155 | Ok(LinkWithContent { 156 | id: row.link_id, 157 | created_at: row.link_created_at, 158 | user_id: row.link_user_id, 159 | dest, 160 | }) 161 | }) 162 | .collect::>>()?; 163 | 164 | Ok(results) 165 | } 166 | 167 | pub async fn delete_by_id(tx: &mut AppTx, id: Uuid) -> ResponseResult { 168 | let link = query_as!( 169 | Link, 170 | r#" 171 | delete from links 172 | where id = $1 173 | returning * 174 | "#, 175 | id 176 | ) 177 | .fetch_one(&mut **tx) 178 | .await?; 179 | 180 | Ok(link) 181 | } 182 | -------------------------------------------------------------------------------- /src/db/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use sqlx::PgPool; 3 | 4 | pub mod all; 5 | pub mod ap_users; 6 | pub use ap_users::ApUser; 7 | pub mod items; 8 | pub mod layout; 9 | pub mod links; 10 | pub use links::{LinkDestination, LinkDestinationWithChildren, LinkWithContent}; 11 | pub mod lists; 12 | pub use lists::{List, ListWithLinks}; 13 | pub mod users; 14 | pub use users::User; 15 | pub mod bookmarks; 16 | pub use bookmarks::Bookmark; 17 | 18 | pub async fn migrate(pool: &PgPool) -> Result<()> { 19 | tracing::info!("Migrating the database..."); 20 | sqlx::migrate!("./migrations").run(pool).await?; 21 | tracing::info!("Database migrated."); 22 | 23 | Ok(()) 24 | } 25 | 26 | pub async fn pool(url: &str) -> Result { 27 | sqlx::postgres::PgPoolOptions::new() 28 | .max_connections(5) 29 | .connect(url) 30 | .await 31 | .context("Failed to create database connection pool") 32 | } 33 | 34 | pub type AppTx = sqlx::Transaction<'static, sqlx::Postgres>; 35 | -------------------------------------------------------------------------------- /src/db/users.rs: -------------------------------------------------------------------------------- 1 | use sqlx::{FromRow, query_as}; 2 | use time::OffsetDateTime; 3 | use url::Url; 4 | use uuid::Uuid; 5 | 6 | use super::AppTx; 7 | use crate::{ 8 | authentication::hash_password, 9 | federation, 10 | forms::{ 11 | ap_users::CreateApUser, 12 | users::{CreateOidcUser, CreateUser}, 13 | }, 14 | response_error::{ResponseError, ResponseResult}, 15 | }; 16 | 17 | #[derive(FromRow, Debug)] 18 | pub struct User { 19 | pub id: Uuid, 20 | 21 | // TODO this is only used in tests so far, which breaks 22 | // `#[expect(dead_code)]` for some reason 23 | #[allow(dead_code)] 24 | pub username: String, 25 | 26 | // Password-based login data 27 | pub password_hash: Option, 28 | 29 | // SSO-related data 30 | #[expect(dead_code)] 31 | pub email: Option, 32 | #[expect(dead_code)] 33 | pub oidc_id: Option, 34 | 35 | // ActivityPub data 36 | #[expect(dead_code)] 37 | pub ap_user_id: Option, 38 | } 39 | 40 | pub async fn user_by_oidc_id(tx: &mut AppTx, oidc_id: &str) -> ResponseResult { 41 | let user = query_as!( 42 | User, 43 | r#" 44 | select * from users 45 | where oidc_id = $1 46 | "#, 47 | oidc_id 48 | ) 49 | .fetch_one(&mut **tx) 50 | .await?; 51 | 52 | Ok(user) 53 | } 54 | 55 | pub async fn insert_oidc(tx: &mut AppTx, create_user: CreateOidcUser) -> ResponseResult { 56 | let user = query_as!( 57 | User, 58 | r#" 59 | insert into users 60 | (email, oidc_id, username) 61 | values ($1, $2, $3) 62 | returning *"#, 63 | create_user.email, 64 | create_user.oidc_id, 65 | create_user.username 66 | ) 67 | .fetch_one(&mut **tx) 68 | .await; 69 | match user { 70 | Ok(user) => Ok(user), 71 | Err(e) => { 72 | tracing::warn!("Error inserting user: {:?}", e); 73 | Err(e.into()) 74 | } 75 | } 76 | } 77 | 78 | pub async fn insert( 79 | tx: &mut AppTx, 80 | create_user: CreateUser, 81 | base_url: &Url, 82 | ) -> ResponseResult { 83 | let hashed_password = hash_password(&create_user.password)?; 84 | let ap_keypair = federation::signing::generate_keypair()?; 85 | 86 | let ap_id = base_url.join("/ap/user/")?.join(&create_user.username)?; 87 | let inbox_url = base_url.join("/ap/inbox")?; 88 | 89 | let create_ap_user = CreateApUser { 90 | ap_id, 91 | username: create_user.username.clone(), 92 | inbox_url, 93 | public_key: ap_keypair.public_key, 94 | private_key: Some(ap_keypair.private_key), 95 | last_refreshed_at: OffsetDateTime::now_utc(), 96 | display_name: None, 97 | bio: None, 98 | }; 99 | let ap_user = super::ap_users::insert(tx, create_ap_user).await?; 100 | 101 | let user = query_as!( 102 | User, 103 | r#" 104 | insert into users 105 | (username, password_hash, ap_user_id) 106 | values ($1, $2, $3) 107 | returning * 108 | "#, 109 | create_user.username, 110 | hashed_password, 111 | ap_user.id 112 | ) 113 | .fetch_one(&mut **tx) 114 | .await?; 115 | Ok(user) 116 | } 117 | 118 | pub async fn by_username(tx: &mut AppTx, username: &str) -> ResponseResult { 119 | let user = query_as!( 120 | User, 121 | r#" 122 | select * from users 123 | where username = $1 124 | "#, 125 | username 126 | ) 127 | .fetch_one(&mut **tx) 128 | .await?; 129 | 130 | Ok(user) 131 | } 132 | 133 | pub async fn create_user_if_not_exists( 134 | tx: &mut AppTx, 135 | create: CreateUser, 136 | base_url: &Url, 137 | ) -> ResponseResult { 138 | let username = create.username.clone(); 139 | let user = by_username(tx, &username).await; 140 | let actual_user = match user { 141 | Err(ResponseError::NotFound) => { 142 | tracing::info!("Creating admin user '{username}'"); 143 | insert(tx, create, base_url).await? 144 | } 145 | Ok(actual_user) => { 146 | tracing::info!("Admin user '{username}' already exists"); 147 | actual_user 148 | } 149 | Err(other) => return Err(other), 150 | }; 151 | Ok(actual_user) 152 | } 153 | -------------------------------------------------------------------------------- /src/extract/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use axum::{extract::FromRequestParts, http::request::Parts}; 3 | 4 | use crate::{db::AppTx, response_error::ResponseError, server::AppState}; 5 | 6 | pub mod qs_form; 7 | pub struct Tx(pub AppTx); 8 | 9 | impl FromRequestParts for Tx { 10 | type Rejection = ResponseError; 11 | 12 | async fn from_request_parts( 13 | _parts: &mut Parts, 14 | state: &AppState, 15 | ) -> Result { 16 | let conn = state.pool.begin().await?; 17 | 18 | Ok(Self(conn)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/extract/qs_form.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use axum::{ 3 | RequestExt, 4 | extract::{FromRequest, RawForm, Request}, 5 | }; 6 | 7 | use crate::response_error::ResponseError; 8 | 9 | pub struct QsForm(pub T); 10 | 11 | impl FromRequest for QsForm 12 | where 13 | T: serde::de::DeserializeOwned, 14 | S: std::marker::Sync, 15 | { 16 | type Rejection = ResponseError; 17 | 18 | async fn from_request(req: Request, _state: &S) -> Result { 19 | let RawForm(bytes) = req.extract().await.context("Failed to extract form")?; 20 | Ok(Self( 21 | serde_qs::Config::new(5, false) 22 | .deserialize_bytes(&bytes) 23 | .context("Failed to parse form")?, 24 | )) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/federation/config.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::FederationConfig; 2 | use anyhow::{Context, Result}; 3 | use sqlx::PgPool; 4 | use url::Url; 5 | 6 | pub async fn new_config( 7 | db_pool: PgPool, 8 | base_url: Url, 9 | ) -> Result> { 10 | let context = super::Context { 11 | db_pool, 12 | base_url: base_url.clone(), 13 | }; 14 | let domain = base_url 15 | .domain() 16 | .context("Base URL must contain a domain name")?; 17 | let port = base_url.port().map_or(String::new(), |p| format!(":{p}")); 18 | FederationConfig::builder() 19 | .domain(format!("{domain}{port}")) 20 | .app_data(context) 21 | .http_fetch_limit(1000) 22 | .debug(cfg!(debug_assertions)) 23 | .build() 24 | .await 25 | .context("Failed to build activitypub config") 26 | } 27 | -------------------------------------------------------------------------------- /src/federation/context.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | #[derive(Clone)] 4 | pub struct Context { 5 | pub db_pool: sqlx::PgPool, 6 | pub base_url: Url, 7 | } 8 | 9 | pub type Data = activitypub_federation::config::Data; 10 | -------------------------------------------------------------------------------- /src/federation/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod context; 3 | pub use context::{Context, Data}; 4 | pub mod person; 5 | pub mod signing; 6 | -------------------------------------------------------------------------------- /src/federation/person.rs: -------------------------------------------------------------------------------- 1 | //! Adapter to make [`db::ApUser`] compatible with the 2 | //! [`activitypub_federation`] crate 3 | 4 | use activitypub_federation::{ 5 | config::Data, 6 | fetch::object_id::ObjectId, 7 | kinds::actor::PersonType, 8 | protocol::{public_key::PublicKey, verification::verify_domains_match}, 9 | traits::{Actor, Object}, 10 | }; 11 | use anyhow::{Context, Result}; 12 | use garde::Validate; 13 | use serde::{Deserialize, Serialize}; 14 | use time::OffsetDateTime; 15 | use url::Url; 16 | 17 | use crate::{ 18 | date_time::time_to_chrono, db, forms::ap_users::CreateApUser, response_error::into_option, 19 | }; 20 | 21 | /// Users as we receive from and send to other instances. 22 | #[derive(Deserialize, Serialize, Debug, Clone)] 23 | #[serde(rename_all = "camelCase")] 24 | pub struct Person { 25 | id: ObjectId, 26 | #[serde(rename = "type")] 27 | kind: PersonType, 28 | preferred_username: String, 29 | name: Option, 30 | summary: Option, 31 | inbox: Url, 32 | public_key: PublicKey, 33 | } 34 | 35 | impl TryFrom for CreateApUser { 36 | type Error = anyhow::Error; 37 | 38 | fn try_from(json: Person) -> std::result::Result { 39 | let create_user = CreateApUser { 40 | ap_id: json.id.into_inner(), 41 | username: json.preferred_username, 42 | inbox_url: json.inbox, 43 | public_key: json.public_key.public_key_pem, 44 | private_key: None, 45 | last_refreshed_at: OffsetDateTime::now_utc(), 46 | display_name: json.name, 47 | bio: json.summary, 48 | }; 49 | 50 | create_user.validate()?; 51 | 52 | Ok(create_user) 53 | } 54 | } 55 | 56 | #[async_trait::async_trait] 57 | impl Object for db::ApUser { 58 | type DataType = super::Context; 59 | type Kind = Person; 60 | type Error = anyhow::Error; 61 | 62 | fn last_refreshed_at(&self) -> Option> { 63 | Some(time_to_chrono(self.last_refreshed_at)) 64 | } 65 | 66 | async fn read_from_id( 67 | object_id: Url, 68 | data: &Data, 69 | ) -> Result, Self::Error> { 70 | let mut tx = data.db_pool.begin().await?; 71 | let user = db::ap_users::read_by_ap_id(&mut tx, &object_id).await; 72 | into_option(user).context("Failed to read ActivityPub user") 73 | } 74 | 75 | async fn into_json(self, _context: &Data) -> Result { 76 | let public_key = self.public_key(); 77 | Ok(Person { 78 | id: self.ap_id, 79 | name: self.display_name, 80 | preferred_username: self.username, 81 | kind: PersonType::Person, 82 | inbox: self.inbox_url, 83 | public_key, 84 | summary: self.bio, 85 | }) 86 | } 87 | 88 | async fn verify( 89 | json: &Self::Kind, 90 | expected_domain: &Url, 91 | _data: &Data, 92 | ) -> Result<(), Self::Error> { 93 | verify_domains_match(json.id.inner(), expected_domain)?; 94 | CreateApUser::try_from(json.clone())?.validate()?; 95 | Ok(()) 96 | } 97 | 98 | async fn from_json(json: Self::Kind, data: &Data) -> Result { 99 | let create_user = json.try_into()?; 100 | let mut tx = data.db_pool.begin().await?; 101 | let new_user = db::ap_users::upsert(&mut tx, create_user).await?; 102 | tx.commit().await?; 103 | Ok(new_user) 104 | } 105 | } 106 | 107 | impl Actor for db::ApUser { 108 | fn id(&self) -> Url { 109 | self.ap_id.inner().clone() 110 | } 111 | 112 | fn public_key_pem(&self) -> &str { 113 | &self.public_key 114 | } 115 | 116 | fn private_key_pem(&self) -> Option { 117 | self.private_key.clone() 118 | } 119 | 120 | fn inbox(&self) -> Url { 121 | self.inbox_url.clone() 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/federation/signing.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::http_signatures::{Keypair, generate_actor_keypair}; 2 | use anyhow::Result; 3 | 4 | /// Use a single static keypair during testing which is signficantly faster than 5 | /// generating dozens of keys from scratch. 6 | #[cfg(debug_assertions)] 7 | #[allow(clippy::unnecessary_wraps)] 8 | pub fn generate_keypair() -> Result { 9 | use std::sync::LazyLock; 10 | 11 | #[allow(clippy::expect_used)] 12 | static KEYPAIR: LazyLock = 13 | LazyLock::new(|| generate_actor_keypair().expect("generate keypair")); 14 | 15 | Ok(KEYPAIR.clone()) 16 | } 17 | 18 | #[cfg(not(debug_assertions))] 19 | pub fn generate_keypair() -> Result { 20 | Ok(generate_actor_keypair()?) 21 | } 22 | -------------------------------------------------------------------------------- /src/form_errors.rs: -------------------------------------------------------------------------------- 1 | use crate::views; 2 | 3 | #[derive(Debug)] 4 | pub struct FormErrors(pub garde::Report); 5 | 6 | impl FormErrors { 7 | pub fn filter(&self, target_path: &str) -> Vec { 8 | self.0 9 | .iter() 10 | .filter(|(path, _error)| path.to_string() == target_path) 11 | .map(|(_path, error)| error.to_string()) 12 | .collect() 13 | } 14 | 15 | pub fn view(&self, path: &str) -> htmf::element::Element { 16 | views::form::errors(&self.filter(path)) 17 | } 18 | } 19 | 20 | impl From for FormErrors { 21 | fn from(report: garde::Report) -> Self { 22 | FormErrors(report) 23 | } 24 | } 25 | 26 | impl Default for FormErrors { 27 | fn default() -> Self { 28 | Self(garde::Report::new()) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/forms/ap_users.rs: -------------------------------------------------------------------------------- 1 | use garde::Validate; 2 | use time::OffsetDateTime; 3 | use url::Url; 4 | 5 | #[derive(Validate)] 6 | pub struct CreateApUser { 7 | #[garde(length(max = 255))] 8 | pub ap_id: Url, 9 | #[garde(length(max = 50))] 10 | pub username: String, 11 | #[garde(length(max = 255))] 12 | pub inbox_url: Url, 13 | #[garde(length(max = 10_000))] 14 | pub public_key: String, 15 | #[garde(length(max = 10_000))] 16 | pub private_key: Option, 17 | #[garde(skip)] 18 | pub last_refreshed_at: OffsetDateTime, 19 | #[garde(length(max = 100))] 20 | pub display_name: Option, 21 | #[garde(length(max = 1_000))] 22 | pub bio: Option, 23 | } 24 | -------------------------------------------------------------------------------- /src/forms/bookmarks.rs: -------------------------------------------------------------------------------- 1 | use garde::Validate; 2 | use serde::Deserialize; 3 | use uuid::Uuid; 4 | 5 | use crate::{db::bookmarks::InsertBookmark, form_errors::FormErrors}; 6 | 7 | #[derive(Validate, Default, Deserialize, Clone, Debug)] 8 | pub struct CreateBookmark { 9 | #[garde(skip)] 10 | #[serde(default)] 11 | pub parents: Vec, 12 | #[garde(skip)] 13 | #[serde(default)] 14 | pub create_parents: Vec, 15 | #[garde(url)] 16 | pub url: String, 17 | #[garde(custom(not_empty))] 18 | pub title: String, 19 | #[garde(length(max = 100))] 20 | pub list_search_term: Option, 21 | #[garde(skip)] 22 | #[serde(default)] 23 | pub submitted: bool, 24 | } 25 | 26 | impl TryFrom for InsertBookmark { 27 | type Error = FormErrors; 28 | 29 | fn try_from(value: CreateBookmark) -> Result { 30 | value.validate()?; 31 | 32 | if !value.submitted { 33 | return Err(FormErrors::default()); 34 | } 35 | 36 | Ok(InsertBookmark { 37 | url: value.url, 38 | title: value.title, 39 | }) 40 | } 41 | } 42 | 43 | #[expect(clippy::trivially_copy_pass_by_ref)] 44 | fn not_empty(value: &str, _: &()) -> garde::Result { 45 | if value.is_empty() { 46 | Err(garde::Error::new("cannot be empty")) 47 | } else { 48 | Ok(()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/forms/links.rs: -------------------------------------------------------------------------------- 1 | use garde::Validate; 2 | use serde::Deserialize; 3 | use uuid::Uuid; 4 | 5 | #[derive(Validate, Debug, Deserialize)] 6 | pub struct CreateLink { 7 | #[garde(skip)] 8 | pub src: Uuid, 9 | #[garde(skip)] 10 | pub dest: Uuid, 11 | } 12 | 13 | #[derive(Validate, Debug, Deserialize, Default)] 14 | pub struct PartialCreateLink { 15 | #[garde(length(max = 100))] 16 | pub search_term_src: Option, 17 | #[garde(length(max = 100))] 18 | pub search_term_dest: Option, 19 | #[garde(skip)] 20 | pub src: Option, 21 | #[garde(skip)] 22 | pub dest: Option, 23 | #[garde(skip)] 24 | #[serde(default)] 25 | pub submitted: bool, 26 | } 27 | -------------------------------------------------------------------------------- /src/forms/lists.rs: -------------------------------------------------------------------------------- 1 | use garde::Validate; 2 | use serde::Deserialize; 3 | 4 | #[derive(Validate, Default, Deserialize)] 5 | pub struct CreateList { 6 | #[garde(length(min = 1, max = 100))] 7 | pub title: String, 8 | #[garde(skip)] 9 | pub content: Option, 10 | #[garde(skip)] 11 | #[serde(default)] 12 | pub private: bool, 13 | } 14 | 15 | #[derive(Deserialize, Default)] 16 | pub struct EditTitle { 17 | pub title: String, 18 | } 19 | 20 | #[derive(Deserialize)] 21 | pub struct EditListPrivate { 22 | pub private: bool, 23 | } 24 | 25 | #[derive(Deserialize)] 26 | pub struct EditListPinned { 27 | pub pinned: bool, 28 | } 29 | -------------------------------------------------------------------------------- /src/forms/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod ap_users; 2 | pub mod bookmarks; 3 | pub mod links; 4 | pub mod lists; 5 | pub mod users; 6 | -------------------------------------------------------------------------------- /src/forms/url.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raffomania/linkblocks/f17d4145b0d67c1a1dfc2eb12e9f618e0e4a419a/src/forms/url.rs -------------------------------------------------------------------------------- /src/forms/users.rs: -------------------------------------------------------------------------------- 1 | use garde::Validate; 2 | use openidconnect::{AuthorizationCode, CsrfToken}; 3 | use serde::{Deserialize, Serialize}; 4 | use url::Url; 5 | 6 | #[derive(Validate)] 7 | pub struct CreateUser { 8 | #[garde(pattern("^[a-zA-Z0-9_]+$"), length(min = 3, max = 50))] 9 | pub username: String, 10 | #[garde(length(min = 10, max = 100))] 11 | pub password: String, 12 | } 13 | 14 | #[derive(Validate, Default, Deserialize, Debug)] 15 | pub struct OidcSelectUsername { 16 | #[garde(pattern("^[a-zA-Z0-9_]+$"), ascii, length(min = 3, max = 50))] 17 | pub username: String, 18 | } 19 | 20 | #[derive(Serialize, Deserialize, Validate, Debug, Default)] 21 | pub struct Login { 22 | #[garde(length(max = 1000))] 23 | pub previous_uri: Option, 24 | #[garde(dive)] 25 | pub credentials: Credentials, 26 | } 27 | 28 | #[derive(Serialize, Deserialize, Validate, Debug, Default)] 29 | pub struct Credentials { 30 | #[garde(pattern("^[a-zA-Z0-9_]+$"), length(min = 3, max = 50))] 31 | pub username: String, 32 | #[garde(length(min = 10, max = 100))] 33 | pub password: String, 34 | } 35 | 36 | #[derive(Deserialize)] 37 | pub struct OidcLoginQuery { 38 | pub code: AuthorizationCode, 39 | pub state: CsrfToken, 40 | } 41 | 42 | #[derive(Serialize, Deserialize, Validate, Debug, Default)] 43 | pub struct CreateOidcUser { 44 | #[garde(length(max = 500))] 45 | pub oidc_id: String, 46 | #[garde(length(max = 500))] 47 | pub email: String, 48 | #[garde(pattern("^[a-zA-Z0-9_]+$"), length(min = 3, max = 50))] 49 | pub username: String, 50 | } 51 | -------------------------------------------------------------------------------- /src/htmf_response.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use axum::response::{Html, IntoResponse}; 3 | 4 | use crate::response_error::ResponseError; 5 | 6 | pub struct HtmfResponse(pub htmf::element::Element); 7 | 8 | impl From for HtmfResponse { 9 | fn from(value: htmf::element::Element) -> Self { 10 | HtmfResponse(value) 11 | } 12 | } 13 | 14 | impl IntoResponse for HtmfResponse { 15 | fn into_response(self) -> axum::response::Response { 16 | if cfg!(not(debug_assertions)) { 17 | return Html(self.0.to_html()).into_response(); 18 | } 19 | 20 | let Ok(html) = self.0.to_html_pretty() else { 21 | return ResponseError::Anyhow(anyhow!("Failed to serialize htmf element")) 22 | .into_response(); 23 | }; 24 | 25 | Html(html).into_response() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/insert_demo_data.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, anyhow}; 2 | use fake::Fake; 3 | use rand::{Rng, seq::IndexedRandom}; 4 | use sqlx::PgPool; 5 | use url::Url; 6 | use uuid::Uuid; 7 | 8 | use crate::{ 9 | db::{self, bookmarks::InsertBookmark}, 10 | forms::{ 11 | links::CreateLink, 12 | lists::CreateList, 13 | users::{CreateOidcUser, CreateUser}, 14 | }, 15 | }; 16 | 17 | pub async fn insert_demo_data( 18 | pool: &PgPool, 19 | dev_user_credentials: Option, 20 | base_url: &Url, 21 | ) -> Result<()> { 22 | let mut tx = pool.begin().await?; 23 | 24 | let mut users = Vec::new(); 25 | for _ in 0..5 { 26 | let email: Option = fake::faker::internet::en::SafeEmail().fake(); 27 | let username: String = fake::faker::name::en::Name().fake(); 28 | let username = username.to_lowercase(); 29 | let username = username.replace(' ', ""); 30 | if let Some(email) = email { 31 | let create_oidc_user = CreateOidcUser { 32 | oidc_id: Uuid::new_v4().to_string(), 33 | email, 34 | username, 35 | }; 36 | 37 | users.push(db::users::insert_oidc(&mut tx, create_oidc_user).await?); 38 | } else { 39 | let create_user = CreateUser { 40 | username, 41 | password: "testpassword".to_string(), 42 | }; 43 | 44 | users.push(db::users::insert(&mut tx, create_user, base_url).await?); 45 | } 46 | } 47 | 48 | if let Some(create_dev_user) = dev_user_credentials { 49 | users.push(db::users::create_user_if_not_exists(&mut tx, create_dev_user, base_url).await?); 50 | } 51 | 52 | let mut bookmarks = Vec::new(); 53 | let mut lists = Vec::new(); 54 | 55 | for user in &users { 56 | for _ in 0..500 { 57 | let tld: String = fake::faker::internet::en::DomainSuffix().fake(); 58 | let word: String = fake::faker::lorem::en::Word().fake(); 59 | let title: String = fake::faker::lorem::en::Words(1..5) 60 | .fake::>() 61 | .join(" "); 62 | let insert_bookmark = InsertBookmark { 63 | url: format!("https://{word}.{tld}"), 64 | title, 65 | }; 66 | 67 | let bookmark = db::bookmarks::insert(&mut tx, user.id, insert_bookmark).await?; 68 | bookmarks.push(bookmark); 69 | } 70 | 71 | for _ in 0..100 { 72 | let content: Option> = fake::faker::lorem::en::Paragraphs(1..3).fake(); 73 | let city: String = fake::faker::address::en::CityName().fake(); 74 | let noun: String = fake::faker::company::en::BsNoun().fake(); 75 | let title = format!("{city} {noun}"); 76 | let create_list = CreateList { 77 | title, 78 | content: content.map(|c| c.join("\n\n")), 79 | private: fake::Faker.fake(), 80 | }; 81 | let list = db::lists::insert(&mut tx, user.id, create_list).await?; 82 | 83 | if fake::faker::boolean::en::Boolean(10).fake() { 84 | db::lists::set_pinned(&mut tx, list.id, false).await?; 85 | } 86 | 87 | lists.push(list); 88 | } 89 | } 90 | 91 | for user in users { 92 | for _ in 0..1000 { 93 | let src = lists 94 | .choose(&mut rand::rng()) 95 | .ok_or(anyhow!("Found no random list to put into a link"))? 96 | .id; 97 | let dest = random_link_reference(&bookmarks, &lists)?; 98 | 99 | let create_link = CreateLink { src, dest }; 100 | db::links::insert(&mut tx, user.id, create_link).await?; 101 | } 102 | } 103 | 104 | tx.commit().await?; 105 | 106 | Ok(()) 107 | } 108 | 109 | fn random_link_reference(bookmarks: &[db::Bookmark], lists: &[db::List]) -> Result { 110 | Ok(match rand::rng().random_range(0..=1) { 111 | 0 => { 112 | bookmarks 113 | .choose(&mut rand::rng()) 114 | .ok_or(anyhow!("Found no random bookmark to put into a link"))? 115 | .id 116 | } 117 | 1 => { 118 | lists 119 | .choose(&mut rand::rng()) 120 | .ok_or(anyhow!("Found no random list to put into a link"))? 121 | .id 122 | } 123 | _ => unreachable!(), 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(clippy::unwrap_used)] 2 | #![deny(clippy::expect_used)] 3 | #![warn(clippy::pedantic)] 4 | #![expect(clippy::missing_errors_doc)] 5 | #![expect(clippy::redundant_closure_for_method_calls)] 6 | 7 | mod authentication; 8 | pub mod cli; 9 | mod db; 10 | mod extract; 11 | mod form_errors; 12 | mod forms; 13 | mod oidc; 14 | mod response_error; 15 | mod routes; 16 | pub mod server; 17 | mod views; 18 | 19 | mod date_time; 20 | mod federation; 21 | mod htmf_response; 22 | #[cfg(debug_assertions)] 23 | mod insert_demo_data; 24 | #[cfg(test)] 25 | mod tests; 26 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | #[tokio::main] 4 | async fn main() -> Result<()> { 5 | linkblocks::cli::run().await 6 | } 7 | -------------------------------------------------------------------------------- /src/response_error.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | http::StatusCode, 3 | response::{IntoResponse, Response}, 4 | }; 5 | use thiserror::Error; 6 | 7 | pub type ResponseResult = std::result::Result; 8 | 9 | #[derive(Debug, Error)] 10 | pub enum ResponseError { 11 | #[error("Not Found")] 12 | NotFound, 13 | #[error("Authentication Failed")] 14 | NotAuthenticated, 15 | #[error("Internal Error")] 16 | Anyhow(#[from] anyhow::Error), 17 | #[error("Internal Error")] 18 | UrlParseError(#[from] url::ParseError), 19 | #[error("Internal Error")] 20 | FederationError(#[from] activitypub_federation::error::Error), 21 | } 22 | 23 | impl IntoResponse for ResponseError { 24 | fn into_response(self) -> Response { 25 | tracing::error!("{self:?}"); 26 | let status = match self { 27 | ResponseError::NotFound => StatusCode::NOT_FOUND, 28 | // TODO redirect to login instead of returning an error 29 | ResponseError::NotAuthenticated => StatusCode::UNAUTHORIZED, 30 | ResponseError::Anyhow(_) 31 | | ResponseError::UrlParseError(_) 32 | | ResponseError::FederationError(_) => StatusCode::INTERNAL_SERVER_ERROR, 33 | }; 34 | (status, self.to_string()).into_response() 35 | } 36 | } 37 | 38 | /// Map [`ResponseError::NotFound`] to `None` 39 | pub fn into_option(result: ResponseResult) -> ResponseResult> { 40 | match result { 41 | Ok(val) => Ok(Some(val)), 42 | Err(ResponseError::NotFound) => Ok(None), 43 | Err(e) => Err(e), 44 | } 45 | } 46 | 47 | impl From for ResponseError { 48 | fn from(value: sqlx::Error) -> Self { 49 | match value { 50 | sqlx::Error::RowNotFound => Self::NotFound, 51 | other => Self::Anyhow(other.into()), 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/routes/assets.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{Context, anyhow}; 4 | use axum::{ 5 | Router, 6 | extract::Path, 7 | http::{HeaderMap, header}, 8 | routing::get, 9 | }; 10 | use include_dir::{Dir, include_dir}; 11 | use mime_guess::Mime; 12 | 13 | use crate::response_error::{ResponseError, ResponseResult}; 14 | 15 | pub fn router() -> Router { 16 | Router::new() 17 | .route("/assets/railwind.css", get(railwind_generated_css)) 18 | .route("/assets/{*path}", get(assets)) 19 | } 20 | 21 | static ASSETS_DIR: Dir = include_dir!("assets"); 22 | 23 | async fn assets(Path(path): Path) -> ResponseResult<(HeaderMap, &'static [u8])> { 24 | let body = ASSETS_DIR 25 | .get_file(&path) 26 | .map(include_dir::File::contents) 27 | .ok_or(ResponseError::NotFound)?; 28 | 29 | let mime_type = get_mime(&path)?; 30 | 31 | #[expect(clippy::from_iter_instead_of_collect)] 32 | let headers = HeaderMap::from_iter( 33 | [( 34 | header::CONTENT_TYPE, 35 | mime_type 36 | .to_string() 37 | .parse() 38 | .context("Failed to convert mime type to header")?, 39 | )] 40 | .into_iter(), 41 | ); 42 | 43 | Ok((headers, body)) 44 | } 45 | 46 | async fn railwind_generated_css() -> ResponseResult<(HeaderMap, &'static [u8])> { 47 | let body = include_bytes!(concat!(env!("OUT_DIR"), "/railwind.css")); 48 | 49 | let mime_type = mime_guess::mime::TEXT_CSS; 50 | 51 | #[expect(clippy::from_iter_instead_of_collect)] 52 | let headers = HeaderMap::from_iter( 53 | [( 54 | header::CONTENT_TYPE, 55 | mime_type 56 | .to_string() 57 | .parse() 58 | .context("Failed to convert mime type to header")?, 59 | )] 60 | .into_iter(), 61 | ); 62 | 63 | Ok((headers, body)) 64 | } 65 | 66 | fn get_mime(path: &std::path::Path) -> ResponseResult { 67 | let ext = path 68 | .extension() 69 | .ok_or(anyhow!("Included assets need an extension"))? 70 | .to_str() 71 | .ok_or(anyhow!("Path extension had invalid unicode"))?; 72 | 73 | Ok(mime_guess::from_ext(ext) 74 | .first() 75 | .context("No mime type guessed")?) 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use include_dir::Dir; 81 | 82 | use super::{ASSETS_DIR, ResponseResult, get_mime}; 83 | 84 | #[test] 85 | fn all_assets_have_a_mime_type() -> ResponseResult<()> { 86 | fn check_dir(dir: &Dir) -> ResponseResult<()> { 87 | for asset in dir.files() { 88 | get_mime(asset.path())?; 89 | } 90 | 91 | for dir in ASSETS_DIR.dirs() { 92 | check_dir(dir)?; 93 | } 94 | 95 | Ok(()) 96 | } 97 | 98 | check_dir(&ASSETS_DIR)?; 99 | 100 | Ok(()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/routes/bookmarks.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use axum::{ 3 | Router, 4 | extract::{Path, Query}, 5 | http::HeaderMap, 6 | response::{IntoResponse, Redirect, Response}, 7 | routing::{delete, get}, 8 | }; 9 | use serde::Deserialize; 10 | use uuid::Uuid; 11 | 12 | use crate::{ 13 | authentication::AuthUser, 14 | db::{self, bookmarks::InsertBookmark}, 15 | extract::{self, qs_form::QsForm}, 16 | form_errors::FormErrors, 17 | forms::{bookmarks::CreateBookmark, links::CreateLink, lists::CreateList}, 18 | htmf_response::HtmfResponse, 19 | response_error::ResponseResult, 20 | server::AppState, 21 | views::{self, layout, unsorted_bookmarks}, 22 | }; 23 | 24 | pub fn router() -> Router { 25 | Router::new() 26 | .route("/bookmarks/create", get(get_create).post(post_create)) 27 | .route("/bookmarks/unsorted", get(get_unsorted)) 28 | .route("/bookmarks/{id}", delete(delete_by_id)) 29 | } 30 | 31 | async fn post_create( 32 | extract::Tx(mut tx): extract::Tx, 33 | auth_user: AuthUser, 34 | QsForm(input): QsForm, 35 | ) -> ResponseResult { 36 | let layout = layout::Template::from_db(&mut tx, Some(&auth_user)).await?; 37 | 38 | let selected_parents = db::lists::list_by_id(&mut tx, &input.parents).await?; 39 | 40 | // TODO exclude items that are already linked 41 | let search_results = match input.list_search_term.as_ref() { 42 | None => db::lists::list_recent(&mut tx, auth_user.user_id).await?, 43 | Some(term) => db::lists::search(&mut tx, term, auth_user.user_id).await?, 44 | }; 45 | 46 | let insert_bookmark = match InsertBookmark::try_from(input.clone()) { 47 | Err(errors) => { 48 | return Ok(HtmfResponse(views::create_bookmark::view( 49 | &views::create_bookmark::Data { 50 | layout, 51 | errors, 52 | input, 53 | selected_parents, 54 | search_results, 55 | }, 56 | )) 57 | .into_response()); 58 | } 59 | Ok(i) => i, 60 | }; 61 | 62 | let bookmark = db::bookmarks::insert(&mut tx, auth_user.user_id, insert_bookmark).await?; 63 | 64 | let mut first_created_parent = Option::None; 65 | for parent_title in input.create_parents { 66 | let parent = db::lists::insert( 67 | &mut tx, 68 | auth_user.user_id, 69 | CreateList { 70 | title: parent_title, 71 | content: None, 72 | private: false, 73 | }, 74 | ) 75 | .await?; 76 | db::links::insert( 77 | &mut tx, 78 | auth_user.user_id, 79 | CreateLink { 80 | src: parent.id, 81 | dest: bookmark.id, 82 | }, 83 | ) 84 | .await?; 85 | 86 | if first_created_parent.is_none() { 87 | first_created_parent.replace(parent); 88 | } 89 | } 90 | 91 | for parent in input.parents { 92 | db::links::insert( 93 | &mut tx, 94 | auth_user.user_id, 95 | CreateLink { 96 | src: parent, 97 | dest: bookmark.id, 98 | }, 99 | ) 100 | .await?; 101 | } 102 | 103 | tx.commit().await?; 104 | 105 | let redirect_dest = match selected_parents.first().or(first_created_parent.as_ref()) { 106 | Some(parent) => parent.path(), 107 | None => "/bookmarks/unsorted".to_string(), 108 | }; 109 | Ok(Redirect::to(&redirect_dest).into_response()) 110 | } 111 | 112 | #[derive(Deserialize)] 113 | struct CreateBookmarkQuery { 114 | parent_id: Option, 115 | url: Option, 116 | title: Option, 117 | } 118 | 119 | async fn get_create( 120 | extract::Tx(mut tx): extract::Tx, 121 | auth_user: AuthUser, 122 | Query(query): Query, 123 | ) -> ResponseResult { 124 | let layout = layout::Template::from_db(&mut tx, Some(&auth_user)).await?; 125 | 126 | let selected_parent = match query.parent_id { 127 | Some(id) => Some(db::lists::by_id(&mut tx, id).await?), 128 | _ => None, 129 | }; 130 | 131 | Ok(HtmfResponse(views::create_bookmark::view( 132 | &views::create_bookmark::Data { 133 | layout, 134 | errors: FormErrors::default(), 135 | input: CreateBookmark { 136 | parents: Vec::new(), 137 | url: query.url.unwrap_or_default(), 138 | title: query.title.unwrap_or_default(), 139 | ..Default::default() 140 | }, 141 | selected_parents: selected_parent.into_iter().collect(), 142 | // TODO exclude items that are already linked 143 | search_results: db::lists::list_recent(&mut tx, auth_user.user_id).await?, 144 | }, 145 | ))) 146 | } 147 | 148 | async fn get_unsorted( 149 | extract::Tx(mut tx): extract::Tx, 150 | auth_user: AuthUser, 151 | ) -> ResponseResult { 152 | let layout = layout::Template::from_db(&mut tx, Some(&auth_user)).await?; 153 | let bookmarks = db::bookmarks::list_unsorted(&mut tx, auth_user.user_id).await?; 154 | 155 | Ok(HtmfResponse(unsorted_bookmarks::view( 156 | &unsorted_bookmarks::Data { layout, bookmarks }, 157 | ))) 158 | } 159 | 160 | async fn delete_by_id( 161 | extract::Tx(mut tx): extract::Tx, 162 | Path(id): Path, 163 | ) -> ResponseResult { 164 | db::bookmarks::delete_by_id(&mut tx, id).await?; 165 | 166 | tx.commit().await?; 167 | 168 | let mut headers = HeaderMap::new(); 169 | headers.insert( 170 | "HX-Refresh", 171 | "true".parse().context("Failed to parse header value")?, 172 | ); 173 | 174 | Ok(headers) 175 | } 176 | -------------------------------------------------------------------------------- /src/routes/federation.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{ 2 | axum::json::FederationJson, 3 | fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name}, 4 | protocol::context::WithContext, 5 | traits::Object, 6 | }; 7 | use axum::{ 8 | Json, Router, 9 | extract::{Path, Query, State}, 10 | routing::get, 11 | }; 12 | use serde::Deserialize; 13 | 14 | use crate::{ 15 | db::{self}, 16 | extract, 17 | federation::{self, person::Person}, 18 | response_error::ResponseResult, 19 | server::AppState, 20 | }; 21 | 22 | pub fn router() -> Router { 23 | Router::new() 24 | .route("/ap/user/{name}", get(get_person)) 25 | .route("/.well-known/webfinger", get(webfinger)) 26 | } 27 | 28 | async fn get_person( 29 | extract::Tx(mut tx): extract::Tx, 30 | State(state): State, 31 | Path(name): Path, 32 | ) -> ResponseResult>> { 33 | let ap_user = db::ap_users::read_by_username(&mut tx, &name).await?; 34 | let json_person = ap_user 35 | .into_json(&state.federation_config.to_request_data()) 36 | .await?; 37 | Ok(FederationJson(WithContext::new_default(json_person))) 38 | } 39 | 40 | #[derive(Deserialize)] 41 | pub struct WebfingerQuery { 42 | resource: String, 43 | } 44 | 45 | async fn webfinger( 46 | extract::Tx(mut tx): extract::Tx, 47 | Query(query): Query, 48 | data: federation::Data, 49 | ) -> ResponseResult> { 50 | let username = extract_webfinger_name(&query.resource, &data)?; 51 | let ap_id = db::ap_users::read_by_username(&mut tx, username) 52 | .await? 53 | .ap_id; 54 | Ok(Json(build_webfinger_response( 55 | query.resource, 56 | ap_id.into_inner(), 57 | ))) 58 | } 59 | -------------------------------------------------------------------------------- /src/routes/index.rs: -------------------------------------------------------------------------------- 1 | use axum::{Router, routing::get}; 2 | 3 | use crate::{ 4 | authentication::AuthUser, 5 | extract, 6 | htmf_response::HtmfResponse, 7 | response_error::ResponseResult, 8 | server::AppState, 9 | views::{self, layout}, 10 | }; 11 | 12 | pub fn router() -> Router { 13 | Router::new().route("/", get(index)) 14 | } 15 | 16 | async fn index( 17 | auth_user: AuthUser, 18 | extract::Tx(mut tx): extract::Tx, 19 | ) -> ResponseResult { 20 | Ok(views::index::view(&layout::Template::from_db(&mut tx, Some(&auth_user)).await?).into()) 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/links.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use axum::{ 3 | Router, 4 | extract::{Path, Query}, 5 | http::HeaderMap, 6 | response::{IntoResponse, Redirect, Response}, 7 | routing::{delete, get}, 8 | }; 9 | use garde::Validate; 10 | use serde::Deserialize; 11 | use uuid::Uuid; 12 | 13 | use crate::{ 14 | authentication::AuthUser, 15 | db::{self, LinkDestination}, 16 | extract::{self, qs_form::QsForm}, 17 | form_errors::FormErrors, 18 | forms::links::{CreateLink, PartialCreateLink}, 19 | htmf_response::HtmfResponse, 20 | response_error::ResponseResult, 21 | server::AppState, 22 | views::{self, layout}, 23 | }; 24 | 25 | pub fn router() -> Router { 26 | Router::new() 27 | .route("/links/create", get(get_create).post(post_create)) 28 | .route("/links/{id}", delete(delete_by_id)) 29 | } 30 | 31 | async fn post_create( 32 | extract::Tx(mut tx): extract::Tx, 33 | auth_user: AuthUser, 34 | // TODO handle failed extractors in forms better 35 | QsForm(input): QsForm, 36 | ) -> ResponseResult { 37 | let layout = layout::Template::from_db(&mut tx, Some(&auth_user)).await?; 38 | let src_from_db = match input.src { 39 | Some(id) => Some(db::items::by_id(&mut tx, id).await?), 40 | None => None, 41 | }; 42 | let dest_from_db = match input.dest { 43 | Some(id) => Some(db::items::by_id(&mut tx, id).await?), 44 | None => None, 45 | }; 46 | 47 | if let Err(errors) = input.validate() { 48 | return Ok( 49 | HtmfResponse(views::create_link::view(views::create_link::Data { 50 | layout, 51 | errors: errors.into(), 52 | form_input: input, 53 | search_results: Vec::new(), 54 | src_from_db, 55 | dest_from_db, 56 | })) 57 | .into_response(), 58 | ); 59 | } 60 | 61 | let search_term = match (input.src, input.dest) { 62 | (None, _) => input.search_term_src.as_ref(), 63 | (Some(_), None) => input.search_term_dest.as_ref(), 64 | _ => None, 65 | }; 66 | 67 | // TODO exclude items that are already linked 68 | let search_results = match search_term { 69 | Some(search_term) => db::lists::search(&mut tx, search_term, auth_user.user_id).await?, 70 | None => Vec::new(), 71 | }; 72 | 73 | if let (Some(src), Some(dest), true) = (&src_from_db, &dest_from_db, input.submitted) { 74 | db::links::insert( 75 | &mut tx, 76 | auth_user.user_id, 77 | CreateLink { 78 | src: src.id(), 79 | dest: dest.id(), 80 | }, 81 | ) 82 | .await?; 83 | 84 | tx.commit().await?; 85 | 86 | return Ok(Redirect::to(&src.path()).into_response()); 87 | } 88 | 89 | Ok( 90 | HtmfResponse(views::create_link::view(views::create_link::Data { 91 | layout, 92 | errors: FormErrors::default(), 93 | form_input: input, 94 | search_results, 95 | src_from_db, 96 | dest_from_db, 97 | })) 98 | .into_response(), 99 | ) 100 | } 101 | 102 | #[derive(Deserialize)] 103 | struct CreateLinkQueryString { 104 | src_id: Option, 105 | dest_id: Option, 106 | } 107 | 108 | async fn get_create( 109 | extract::Tx(mut tx): extract::Tx, 110 | auth_user: AuthUser, 111 | Query(query): Query, 112 | ) -> ResponseResult { 113 | let layout = layout::Template::from_db(&mut tx, Some(&auth_user)).await?; 114 | 115 | let src = match query.src_id { 116 | Some(id) => Some(db::items::by_id(&mut tx, id).await?), 117 | _ => None, 118 | }; 119 | 120 | let dest = match query.dest_id { 121 | Some(id) => Some(db::items::by_id(&mut tx, id).await?), 122 | _ => None, 123 | }; 124 | 125 | // TODO exclude items that are already linked 126 | let search_results = match (src.as_ref(), dest.as_ref()) { 127 | (None, _) => db::lists::list_recent(&mut tx, auth_user.user_id).await?, 128 | (_, None) => db::lists::list_recent(&mut tx, auth_user.user_id).await?, 129 | _ => Vec::new(), 130 | }; 131 | 132 | Ok(views::create_link::view(views::create_link::Data { 133 | layout, 134 | errors: FormErrors::default(), 135 | form_input: PartialCreateLink { 136 | src: src.as_ref().map(LinkDestination::id), 137 | dest: dest.as_ref().map(LinkDestination::id), 138 | ..PartialCreateLink::default() 139 | }, 140 | search_results, 141 | src_from_db: src, 142 | dest_from_db: dest, 143 | }) 144 | .into()) 145 | } 146 | 147 | async fn delete_by_id( 148 | extract::Tx(mut tx): extract::Tx, 149 | Path(id): Path, 150 | ) -> ResponseResult { 151 | db::links::delete_by_id(&mut tx, id).await?; 152 | 153 | tx.commit().await?; 154 | 155 | let mut headers = HeaderMap::new(); 156 | headers.insert( 157 | "HX-Refresh", 158 | "true".parse().context("Failed to parse header value")?, 159 | ); 160 | 161 | Ok(headers) 162 | } 163 | -------------------------------------------------------------------------------- /src/routes/lists.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | Form, Router, 3 | extract::Path, 4 | response::{IntoResponse, Redirect, Response}, 5 | routing::{get, post}, 6 | }; 7 | use garde::Validate; 8 | use uuid::Uuid; 9 | 10 | use crate::{ 11 | authentication::AuthUser, 12 | db::{self}, 13 | extract, 14 | form_errors::FormErrors, 15 | forms, 16 | forms::lists::{CreateList, EditListPinned, EditListPrivate}, 17 | htmf_response::HtmfResponse, 18 | response_error::{ResponseError, ResponseResult}, 19 | server::AppState, 20 | views, 21 | views::layout, 22 | }; 23 | 24 | pub fn router() -> Router { 25 | let router: Router = Router::new(); 26 | router 27 | .route("/lists/create", get(get_create).post(post_create)) 28 | .route("/lists/{list_id}", get(list)) 29 | .route("/lists/{list_id}/edit_private", post(edit_private)) 30 | .route("/lists/{list_id}/edit_title", post(post_edit_title)) 31 | .route("/lists/{list_id}/edit_title", get(get_edit_title)) 32 | .route("/lists/{list_id}/edit_pinned", post(edit_pinned)) 33 | .route("/lists/unpinned", get(list_unpinned)) 34 | } 35 | 36 | async fn list( 37 | auth_user: Option, 38 | extract::Tx(mut tx): extract::Tx, 39 | Path(list_id): Path, 40 | ) -> ResponseResult { 41 | let links = 42 | db::links::list_by_list(&mut tx, list_id, auth_user.as_ref().map(|u| u.user_id)).await?; 43 | let list = db::lists::by_id(&mut tx, list_id).await?; 44 | 45 | match auth_user { 46 | Some(ref user) => { 47 | if list.private && list.user_id != user.user_id { 48 | return Err(ResponseError::NotFound); 49 | } 50 | } 51 | None => { 52 | if list.private { 53 | return Err(ResponseError::NotAuthenticated); 54 | } 55 | } 56 | } 57 | 58 | Ok(HtmfResponse(views::list::view(&views::list::Data { 59 | layout: layout::Template::from_db(&mut tx, auth_user.as_ref()).await?, 60 | links, 61 | list, 62 | metadata: db::lists::metadata_by_id(&mut tx, list_id).await?, 63 | }))) 64 | } 65 | 66 | async fn post_create( 67 | extract::Tx(mut tx): extract::Tx, 68 | auth_user: AuthUser, 69 | Form(input): Form, 70 | ) -> ResponseResult { 71 | let user_id = auth_user.user_id; 72 | let layout = layout::Template::from_db(&mut tx, Some(&auth_user)).await?; 73 | 74 | if let Err(errors) = input.validate() { 75 | return Ok(views::create_list::view(&views::create_list::Data { 76 | layout, 77 | input, 78 | errors: errors.into(), 79 | }) 80 | .to_html() 81 | .into_response()); 82 | } 83 | 84 | let list = db::lists::insert(&mut tx, user_id, input).await?; 85 | 86 | tx.commit().await?; 87 | 88 | Ok(Redirect::to(&list.path()).into_response()) 89 | } 90 | 91 | async fn get_create( 92 | extract::Tx(mut tx): extract::Tx, 93 | auth_user: AuthUser, 94 | ) -> ResponseResult { 95 | let layout = layout::Template::from_db(&mut tx, Some(&auth_user)).await?; 96 | 97 | Ok(HtmfResponse(views::create_list::view( 98 | &views::create_list::Data { 99 | layout, 100 | input: CreateList::default(), 101 | errors: FormErrors::default(), 102 | }, 103 | ))) 104 | } 105 | 106 | async fn get_edit_title( 107 | extract::Tx(mut tx): extract::Tx, 108 | auth_user: AuthUser, 109 | Path(list_id): Path, 110 | ) -> ResponseResult { 111 | let list = db::lists::by_id(&mut tx, list_id).await?; 112 | 113 | if list.user_id != auth_user.user_id { 114 | return Err(ResponseError::NotFound); 115 | } 116 | 117 | let layout = layout::Template::from_db(&mut tx, Some(&auth_user)).await?; 118 | 119 | Ok(views::edit_list_title::view(views::edit_list_title::Data { 120 | layout, 121 | errors: FormErrors::default(), 122 | form_input: forms::lists::EditTitle::default(), 123 | list_id, 124 | }) 125 | .into()) 126 | } 127 | 128 | async fn post_edit_title( 129 | auth_user: AuthUser, 130 | extract::Tx(mut tx): extract::Tx, 131 | Path(list_id): Path, 132 | Form(input): Form, 133 | ) -> ResponseResult { 134 | let list = db::lists::by_id(&mut tx, list_id).await?; 135 | 136 | if list.user_id != auth_user.user_id { 137 | return Err(ResponseError::NotFound); 138 | } 139 | 140 | db::lists::edit_title(&mut tx, list_id, input.title).await?; 141 | 142 | tx.commit().await?; 143 | 144 | Ok(Redirect::to(&list.path()).into_response()) 145 | } 146 | 147 | async fn edit_private( 148 | auth_user: AuthUser, 149 | extract::Tx(mut tx): extract::Tx, 150 | Path(list_id): Path, 151 | Form(input): Form, 152 | ) -> ResponseResult { 153 | let list = db::lists::by_id(&mut tx, list_id).await?; 154 | 155 | if list.user_id != auth_user.user_id { 156 | return Err(ResponseError::NotFound); 157 | } 158 | 159 | db::lists::set_private(&mut tx, list_id, input.private).await?; 160 | 161 | tx.commit().await?; 162 | 163 | Ok(Redirect::to(&list.path()).into_response()) 164 | } 165 | 166 | async fn edit_pinned( 167 | auth_user: AuthUser, 168 | extract::Tx(mut tx): extract::Tx, 169 | Path(list_id): Path, 170 | Form(input): Form, 171 | ) -> ResponseResult { 172 | let list = db::lists::by_id(&mut tx, list_id).await?; 173 | 174 | if list.user_id != auth_user.user_id { 175 | return Err(ResponseError::NotFound); 176 | } 177 | 178 | db::lists::set_pinned(&mut tx, list_id, input.pinned).await?; 179 | 180 | tx.commit().await?; 181 | 182 | Ok(Redirect::to(&list.path()).into_response()) 183 | } 184 | 185 | // TODO colocate this with view and db code 186 | async fn list_unpinned( 187 | auth_user: AuthUser, 188 | extract::Tx(mut tx): extract::Tx, 189 | ) -> ResponseResult { 190 | let lists = db::lists::list_unpinned(&mut tx, auth_user.user_id).await?; 191 | 192 | Ok( 193 | views::list_unpinned_lists::view(views::list_unpinned_lists::Data { 194 | layout: layout::Template::from_db(&mut tx, Some(&auth_user)).await?, 195 | lists, 196 | }) 197 | .into(), 198 | ) 199 | } 200 | -------------------------------------------------------------------------------- /src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod assets; 2 | pub mod bookmarks; 3 | pub mod federation; 4 | pub mod index; 5 | pub mod links; 6 | pub mod lists; 7 | pub mod users; 8 | -------------------------------------------------------------------------------- /src/server.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, time::Duration}; 2 | 3 | use activitypub_federation::config::{FederationConfig, FederationMiddleware}; 4 | use anyhow::{Context, anyhow}; 5 | use axum::Router; 6 | use axum_server::tls_rustls::RustlsConfig; 7 | use listenfd::ListenFd; 8 | use sqlx::PgPool; 9 | use tower::ServiceBuilder; 10 | use tower_http::trace::TraceLayer; 11 | use tower_sessions::ExpiredDeletion; 12 | use url::Url; 13 | 14 | use crate::{ 15 | cli::ListenArgs, 16 | db::{self}, 17 | federation, oidc, routes, 18 | }; 19 | 20 | #[derive(Clone)] 21 | pub struct AppState { 22 | pub pool: sqlx::PgPool, 23 | pub base_url: Url, 24 | pub demo_mode: bool, 25 | pub oidc_state: oidc::State, 26 | pub federation_config: FederationConfig, 27 | } 28 | 29 | pub async fn app(state: AppState) -> anyhow::Result { 30 | let session_store = tower_sessions_sqlx_store::PostgresStore::new(state.pool.clone()); 31 | session_store.migrate().await?; 32 | tokio::task::spawn( 33 | session_store 34 | .clone() 35 | .continuously_delete_expired(tokio::time::Duration::from_secs(6 * 60 * 60)), 36 | ); 37 | 38 | if state.demo_mode { 39 | tokio::task::spawn(periodically_wipe_all_data(state.pool.clone())); 40 | } 41 | 42 | let cookie_inactivity_limit = if state.demo_mode { 43 | tower_sessions::cookie::time::Duration::hours(1) 44 | } else { 45 | tower_sessions::cookie::time::Duration::weeks(2) 46 | }; 47 | 48 | let session_service = tower_sessions::SessionManagerLayer::new(session_store) 49 | .with_secure(true) 50 | .with_same_site(tower_sessions::cookie::SameSite::Lax) 51 | .with_expiry(tower_sessions::Expiry::OnInactivity( 52 | cookie_inactivity_limit, 53 | )); 54 | 55 | Ok(Router::new() 56 | .merge(routes::users::router()) 57 | .merge(routes::index::router()) 58 | .merge(routes::lists::router()) 59 | .merge(routes::bookmarks::router()) 60 | .merge(routes::links::router()) 61 | .merge(routes::federation::router()) 62 | .merge(routes::assets::router().with_state(())) 63 | // TODO add layer to use the same URL for AP and HTML 64 | // this should simplify things and be more error tolerant for other services 65 | .layer( 66 | ServiceBuilder::new() 67 | .layer(TraceLayer::new_for_http()) 68 | .layer(session_service) 69 | .layer(FederationMiddleware::new(state.federation_config.clone())), 70 | ) 71 | .with_state(state)) 72 | } 73 | 74 | pub async fn start( 75 | listen: ListenArgs, 76 | app: Router, 77 | tls_cert: Option, 78 | tls_key: Option, 79 | ) -> anyhow::Result<()> { 80 | let handle = axum_server::Handle::new(); 81 | 82 | let listener = if let Some(listen_address) = listen.listen { 83 | tokio::spawn(shutdown_signal(handle.clone(), true)); 84 | tokio::net::TcpListener::bind(format!("{listen_address}")).await? 85 | } else { 86 | // Graceful shutdown is somehow broken with listenfd at the moment 87 | tokio::spawn(shutdown_signal(handle.clone(), false)); 88 | let mut listenfd = ListenFd::from_env(); 89 | let listener = listenfd 90 | .take_tcp_listener(0)? 91 | .ok_or(anyhow!("No systemfd TCP socket found"))?; 92 | listener.set_nonblocking(true)?; 93 | tokio::net::TcpListener::from_std(listener)? 94 | }; 95 | 96 | let listening_on = listener.local_addr()?; 97 | 98 | if let (Some(cert), Some(key)) = (tls_cert, tls_key) { 99 | tracing::info!("Using TLS files at: {cert:?}, {key:?}"); 100 | let config = RustlsConfig::from_pem_file(cert, key).await?; 101 | tracing::info!("Listening on https://{listening_on}"); 102 | axum_server::from_tcp_rustls(listener.into_std()?, config) 103 | .handle(handle) 104 | .serve(app.into_make_service()) 105 | .await?; 106 | } else { 107 | tracing::info!("No TLS certificate specified, not using TLS"); 108 | tracing::info!("Listening on http://{listening_on}"); 109 | axum_server::from_tcp(listener.into_std()?) 110 | .handle(handle) 111 | .serve(app.into_make_service()) 112 | .await?; 113 | } 114 | 115 | Ok(()) 116 | } 117 | 118 | async fn shutdown_signal(handle: axum_server::Handle, graceful: bool) { 119 | let ctrl_c = async { 120 | tokio::signal::ctrl_c() 121 | .await 122 | .context("failed to install Ctrl+C handler") 123 | }; 124 | 125 | #[cfg(unix)] 126 | let terminate = async { 127 | tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) 128 | .context("failed to install signal handler")? 129 | .recv() 130 | .await; 131 | 132 | Ok::<(), anyhow::Error>(()) 133 | }; 134 | 135 | #[cfg(not(unix))] 136 | let terminate = std::future::pending::<()>(); 137 | 138 | tokio::select! { 139 | _ = ctrl_c => {}, 140 | _ = terminate => {}, 141 | } 142 | 143 | tracing::info!( 144 | "Received termination signal - waiting 10 seconds to close existing connections" 145 | ); 146 | if graceful { 147 | handle.graceful_shutdown(Some(Duration::from_secs(10))); 148 | } else { 149 | handle.shutdown(); 150 | } 151 | } 152 | 153 | async fn periodically_wipe_all_data(pool: PgPool) -> anyhow::Result<()> { 154 | // interval: every hour 155 | let period = tokio::time::Duration::from_secs(60 * 60); 156 | let mut interval = tokio::time::interval(period); 157 | // First interval completes immediately, but we want to wait 158 | // before doing the first deletion to give users time 159 | // to react to the warning 160 | interval.tick().await; 161 | tracing::warn!("Demo mode enabled - will periodically wipe ALL DATA every {period:?}."); 162 | 163 | loop { 164 | interval.tick().await; 165 | let res = wipe_all_data(&pool).await; 166 | if let Err(e) = res { 167 | tracing::error!("{e:?}"); 168 | } 169 | } 170 | } 171 | 172 | async fn wipe_all_data(pool: &PgPool) -> anyhow::Result<()> { 173 | tracing::info!("Wiping all data!"); 174 | let mut tx = pool.begin().await?; 175 | db::all::wipe_all_data(&mut tx).await?; 176 | tx.commit().await?; 177 | Ok(()) 178 | } 179 | -------------------------------------------------------------------------------- /src/tests/bookmarks.rs: -------------------------------------------------------------------------------- 1 | use crate::tests::util::test_app::TestApp; 2 | 3 | #[test_log::test(tokio::test)] 4 | async fn get_unsorted_bookmarks() -> anyhow::Result<()> { 5 | let mut app = TestApp::new().await; 6 | app.create_test_user().await; 7 | app.login_test_user().await; 8 | 9 | let unsorted_bookmarks = app.req().get("/bookmarks/unsorted").await.test_page().await; 10 | 11 | insta::assert_snapshot!(unsorted_bookmarks.dom.htmls()); 12 | 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /src/tests/federation.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::fetch::{object_id::ObjectId, webfinger::webfinger_resolve_actor}; 2 | use anyhow::Result; 3 | use axum::http::StatusCode; 4 | 5 | use crate::{ 6 | db, 7 | forms::users::{Credentials, Login}, 8 | tests::util::test_app::TestApp, 9 | }; 10 | 11 | #[test_log::test(tokio::test)] 12 | async fn spin_up_two_instances() -> anyhow::Result<()> { 13 | let mut app_a = TestApp::new().await; 14 | app_a.create_user("testa", "testpassword").await; 15 | 16 | let mut app_b = TestApp::new().await; 17 | app_b.create_user("testb", "testpassword").await; 18 | 19 | let login_page = app_a.req().get("/login").await.test_page().await; 20 | 21 | let input = Login { 22 | credentials: Credentials { 23 | username: "testa".to_string(), 24 | password: "testpassword".to_string(), 25 | }, 26 | previous_uri: None, 27 | }; 28 | 29 | let login_response = login_page 30 | .expect_status(StatusCode::SEE_OTHER) 31 | .fill_form("form", &input) 32 | .await; 33 | 34 | let cookie = login_response.headers().get("Set-Cookie").unwrap(); 35 | assert!(!cookie.is_empty()); 36 | 37 | // Check that we can't access instance B with user A 38 | let login_page = app_b.req().get("/login").await.test_page().await; 39 | 40 | let login_response = login_page 41 | .expect_status(StatusCode::OK) 42 | .fill_form("form", &input) 43 | .await; 44 | 45 | let cookie = login_response.headers().get("Set-Cookie"); 46 | assert!(cookie.is_none()); 47 | 48 | Ok(()) 49 | } 50 | 51 | #[test_log::test(tokio::test)] 52 | async fn can_resolve_user() -> Result<()> { 53 | let app_a = TestApp::new().await; 54 | let user = app_a.create_user("testa", "testpassword").await; 55 | let app_a_ap_user = 56 | db::ap_users::read_by_username(&mut app_a.pool.begin().await?, &user.username).await?; 57 | app_a.serve().await; 58 | 59 | let app_b = TestApp::new().await; 60 | let ap_cx_b = app_b.state.federation_config.to_request_data(); 61 | 62 | // Check that instance B can resolve user on instance A 63 | let user_id = 64 | ObjectId::::parse(&format!("{}ap/user/{}", app_a.base_url, user.username))?; 65 | assert_eq!(user_id, app_a_ap_user.ap_id); 66 | let resolved_ap_user_1 = user_id.dereference(&ap_cx_b).await?; 67 | let resolved_ap_user_2 = app_a_ap_user.ap_id.dereference(&ap_cx_b).await?; 68 | assert_eq!(resolved_ap_user_1.ap_id, resolved_ap_user_2.ap_id); 69 | assert_eq!(resolved_ap_user_1.id, resolved_ap_user_2.id); 70 | 71 | Ok(()) 72 | } 73 | 74 | #[test_log::test(tokio::test)] 75 | async fn can_resolve_webfinger() -> Result<()> { 76 | let app = TestApp::new().await; 77 | let user = app.create_user("testa", "testpassword").await; 78 | let local_ap_user = 79 | db::ap_users::read_by_username(&mut app.pool.begin().await?, &user.username).await?; 80 | app.serve().await; 81 | 82 | let actor: db::ApUser = webfinger_resolve_actor( 83 | &format!("testa@{}", app.state.federation_config.domain()), 84 | &app.state.federation_config.to_request_data(), 85 | ) 86 | .await?; 87 | 88 | assert_eq!(local_ap_user.id, actor.id); 89 | 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /src/tests/index.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | 3 | use crate::tests::util::test_app::TestApp; 4 | 5 | #[test_log::test(tokio::test)] 6 | async fn index() -> anyhow::Result<()> { 7 | let mut app = TestApp::new().await; 8 | 9 | app.req() 10 | .expect_status(StatusCode::SEE_OTHER) 11 | .get("/") 12 | .await; 13 | 14 | app.create_test_user().await; 15 | app.login_test_user().await; 16 | // Check that we can access the index when logged in 17 | let index = app.req().get("/").await.test_page().await; 18 | 19 | insta::assert_snapshot!(index.dom.htmls()); 20 | 21 | Ok(()) 22 | } 23 | -------------------------------------------------------------------------------- /src/tests/lists.rs: -------------------------------------------------------------------------------- 1 | use crate::tests::util::test_app::TestApp; 2 | 3 | #[test_log::test(tokio::test)] 4 | async fn get_create_list() -> anyhow::Result<()> { 5 | let mut app = TestApp::new().await; 6 | app.create_test_user().await; 7 | app.login_test_user().await; 8 | 9 | let create_list = app.req().get("/lists/create").await.test_page().await; 10 | 11 | insta::assert_snapshot!(create_list.dom.htmls()); 12 | 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | //! See 2 | //! for information on why our tests are inside the `src` folder. 3 | #![expect(clippy::unwrap_used)] 4 | #![expect(clippy::expect_used)] 5 | mod bookmarks; 6 | mod federation; 7 | mod index; 8 | mod lists; 9 | mod users; 10 | mod util; 11 | -------------------------------------------------------------------------------- /src/tests/snapshots/linkblocks__tests__bookmarks__get_unsorted_bookmarks.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/tests/bookmarks.rs 3 | expression: unsorted_bookmarks.dom.htmls() 4 | --- 5 | 6 | 7 | 8 | 9 | 10 | 11 | linkblocks 12 | 13 | 14 |
15 |
16 |
17 |

Unsorted Bookmarks

18 |
19 |
20 | 46 |
47 | 48 | -------------------------------------------------------------------------------- /src/tests/snapshots/linkblocks__tests__index__index.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/tests/index.rs 3 | expression: index.dom.htmls() 4 | --- 5 | 6 | 7 | 8 | 9 | 10 | 11 | linkblocks 12 | 13 | 14 |
15 |
16 |
17 |

Welcome to linkblocks!

18 |
19 | 30 |
31 | 57 |
58 | 59 | -------------------------------------------------------------------------------- /src/tests/snapshots/linkblocks__tests__lists__get_create_list.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/tests/lists.rs 3 | expression: create_list.dom.htmls() 4 | --- 5 | 6 | 7 | 8 | 9 | 10 | 11 | linkblocks 12 | 13 | 14 |
15 |
16 |
17 |
18 |

Create a list

19 |
20 | 21 | 22 | 23 |
24 | 25 |
26 | 29 |
30 |
31 | 57 |
58 | 59 | -------------------------------------------------------------------------------- /src/tests/snapshots/linkblocks__tests__users__can_login.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/tests/users.rs 3 | expression: login_page.dom.htmls() 4 | --- 5 | 6 | 7 | 8 | 9 | 10 | 11 | linkblocks 12 | 13 | 14 |
15 |
16 |

17 | Sign in to your account 18 |

19 | 20 | 21 | 22 | 23 | 26 |
27 |
28 | 29 | -------------------------------------------------------------------------------- /src/tests/users.rs: -------------------------------------------------------------------------------- 1 | use axum::http::{StatusCode, header}; 2 | 3 | use crate::{ 4 | forms::users::{Credentials, Login}, 5 | tests::util::test_app::TestApp, 6 | }; 7 | 8 | #[test_log::test(tokio::test)] 9 | async fn can_login() -> anyhow::Result<()> { 10 | let mut app = TestApp::new().await; 11 | app.create_user("test", "testpassword").await; 12 | 13 | let login_page = app.req().get("/login").await.test_page().await; 14 | insta::assert_snapshot!(login_page.dom.htmls()); 15 | 16 | let input = Login { 17 | credentials: Credentials { 18 | username: "test".to_string(), 19 | password: "testpassword".to_string(), 20 | }, 21 | previous_uri: None, 22 | }; 23 | 24 | let login_response = login_page 25 | .expect_status(StatusCode::SEE_OTHER) 26 | .fill_form("form", &input) 27 | .await; 28 | 29 | let cookie = login_response.headers().get("Set-Cookie").unwrap(); 30 | assert!(!cookie.is_empty()); 31 | let cookie = cookie.to_str()?.split_once(';').unwrap().0; 32 | 33 | app.req().header(header::COOKIE, cookie).get("/").await; 34 | 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /src/tests/util/db.rs: -------------------------------------------------------------------------------- 1 | use sqlx::{ 2 | ConnectOptions, Connection, Pool, Postgres, 3 | testing::{TestArgs, TestSupport}, 4 | }; 5 | use uuid::Uuid; 6 | 7 | pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); 8 | 9 | pub async fn new_pool() -> Pool { 10 | let test_path = Uuid::new_v4().to_string(); 11 | let args = TestArgs { 12 | // We might want to use the real test path here for debuggability 13 | // but that requires some macro magic that I'm not willing to investigate 14 | // for now, leaking is fine since we're in a test context 15 | test_path: test_path.leak(), 16 | migrator: Some(&MIGRATOR), 17 | fixtures: &[], 18 | }; 19 | let cx_a = Postgres::test_context(&args) 20 | .await 21 | .expect("Failed to create DB test context"); 22 | 23 | let mut conn = cx_a 24 | .connect_opts 25 | .connect() 26 | .await 27 | .expect("failed to connect to test database"); 28 | MIGRATOR 29 | .run_direct(&mut conn) 30 | .await 31 | .expect("failed to apply migrations"); 32 | 33 | conn.close() 34 | .await 35 | .expect("Failed to close test migration connection"); 36 | 37 | let pool_a: Pool = Pool::connect_with(cx_a.connect_opts.clone()) 38 | .await 39 | .expect("Failed to connect to database"); 40 | 41 | pool_a 42 | } 43 | -------------------------------------------------------------------------------- /src/tests/util/dom.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use serde_json::Value; 3 | 4 | pub fn assert_form_matches(form: &visdom::types::Elements, input: &I) { 5 | let json_input = serde_json::to_value(input).unwrap(); 6 | let json_input = json_input.as_object().unwrap(); 7 | for field in form.find("input") { 8 | let html = field.outer_html(); 9 | tracing::debug!("Checking form field {html}"); 10 | let value = json_input.get(&field.get_attribute("name").unwrap().to_string()); 11 | 12 | let Some(value) = value else { 13 | assert!( 14 | field.get_attribute("required").is_some(), 15 | "Missing value for required form field {html}" 16 | ); 17 | continue; 18 | }; 19 | tracing::debug!("Found input value: {value}"); 20 | 21 | let field_type = field.get_attribute("type").unwrap().to_string(); 22 | 23 | let types_match = matches!( 24 | (field_type.as_str(), value), 25 | ("text" | "password", Value::String(_)) 26 | | ("number", Value::Number(_)) 27 | | ("checkbox", Value::Bool(_)) 28 | ); 29 | 30 | assert!( 31 | types_match, 32 | r#"Input type "{field_type}" and value {value} don't match!"# 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/tests/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod db; 2 | pub mod dom; 3 | pub mod request_builder; 4 | pub mod test_app; 5 | -------------------------------------------------------------------------------- /src/tests/util/request_builder.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | Router, 3 | body::Body, 4 | http::{self, HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode, request}, 5 | }; 6 | use http_body_util::BodyExt; 7 | use mime_guess::mime; 8 | use serde::Serialize; 9 | use tower::{Service, ServiceExt}; 10 | use visdom::Vis; 11 | 12 | use super::dom::assert_form_matches; 13 | 14 | pub struct RequestBuilder { 15 | router: axum::Router, 16 | /// This is the HTTP status that we expect the backend to return. 17 | /// If it returns a different status, we'll panic. 18 | expected_status: StatusCode, 19 | request: request::Builder, 20 | } 21 | 22 | impl RequestBuilder { 23 | pub fn new(router: &Router) -> Self { 24 | RequestBuilder { 25 | router: router.clone(), 26 | expected_status: StatusCode::OK, 27 | request: Request::builder(), 28 | } 29 | } 30 | 31 | pub fn expect_status(mut self, expected: StatusCode) -> Self { 32 | self.expected_status = expected; 33 | self 34 | } 35 | 36 | pub fn header(mut self, key: HeaderName, val: V) -> Self 37 | where 38 | HeaderValue: TryFrom, 39 | >::Error: Into, 40 | { 41 | self.request = self.request.header(key, val); 42 | self 43 | } 44 | 45 | pub async fn post(mut self, url: &str, input: &Input) -> TestResponse 46 | where 47 | Input: Serialize, 48 | { 49 | let request = self 50 | .request 51 | .method(http::Method::POST) 52 | .uri(url) 53 | .header( 54 | http::header::CONTENT_TYPE, 55 | mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), 56 | ) 57 | .body(serde_qs::to_string(input).unwrap()) 58 | .unwrap(); 59 | 60 | let response = ServiceExt::>::ready(&mut self.router) 61 | .await 62 | .unwrap() 63 | .call(request) 64 | .await 65 | .unwrap(); 66 | 67 | tracing::debug!("{:?}", response.headers()); 68 | 69 | Self::assert_expected_status(self.expected_status, &response, "GET", url); 70 | 71 | TestResponse { 72 | response, 73 | router: self.router, 74 | original_url: url.to_string(), 75 | } 76 | } 77 | 78 | pub async fn get(mut self, url: &str) -> TestResponse { 79 | let request = self.request.uri(url).body(Body::empty()).unwrap(); 80 | 81 | let response = ServiceExt::>::ready(&mut self.router) 82 | .await 83 | .unwrap() 84 | .call(request) 85 | .await 86 | .unwrap(); 87 | 88 | tracing::debug!("{:?}", response.headers()); 89 | 90 | Self::assert_expected_status(self.expected_status, &response, "GET", url); 91 | 92 | TestResponse { 93 | response, 94 | router: self.router, 95 | original_url: url.to_string(), 96 | } 97 | } 98 | 99 | fn assert_expected_status( 100 | expected_status: StatusCode, 101 | response: &Response, 102 | method: &str, 103 | url: &str, 104 | ) { 105 | assert_eq!( 106 | response.status(), 107 | expected_status, 108 | "expected {expected_status}: {method} {url}" 109 | ); 110 | } 111 | } 112 | 113 | pub struct TestResponse { 114 | response: Response, 115 | original_url: String, 116 | router: axum::Router, 117 | } 118 | 119 | impl TestResponse { 120 | #[expect(dead_code)] 121 | pub async fn dom(self) -> visdom::types::Elements<'static> { 122 | let body = self 123 | .response 124 | .into_body() 125 | .collect() 126 | .await 127 | .unwrap() 128 | .to_bytes() 129 | .to_vec(); 130 | Vis::load(String::from_utf8(body).unwrap()).unwrap() 131 | } 132 | 133 | pub fn headers(&self) -> &HeaderMap { 134 | self.response.headers() 135 | } 136 | 137 | pub async fn test_page(self) -> TestPage { 138 | let body = self 139 | .response 140 | .into_body() 141 | .collect() 142 | .await 143 | .unwrap() 144 | .to_bytes() 145 | .to_vec(); 146 | let dom = Vis::load(String::from_utf8(body).unwrap()).unwrap(); 147 | 148 | TestPage { 149 | dom, 150 | url: self.original_url, 151 | request_builder: RequestBuilder::new(&self.router), 152 | } 153 | } 154 | } 155 | 156 | pub struct TestPage { 157 | pub dom: visdom::types::Elements<'static>, 158 | pub url: String, 159 | pub request_builder: RequestBuilder, 160 | } 161 | 162 | impl TestPage { 163 | pub async fn fill_form(self, form_selector: &str, input: &I) -> TestResponse { 164 | let form = self.dom.find(form_selector); 165 | assert_form_matches(&form, &input); 166 | 167 | self.request_builder.post(&self.url, input).await 168 | } 169 | 170 | pub fn expect_status(mut self, expected: StatusCode) -> Self { 171 | self.request_builder = self.request_builder.expect_status(expected); 172 | self 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/tests/util/test_app.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::AtomicUsize; 2 | 3 | use axum::{Router, http::StatusCode}; 4 | use sqlx::{Pool, Postgres}; 5 | use tokio::net::TcpListener; 6 | use url::Url; 7 | 8 | use super::request_builder::RequestBuilder; 9 | use crate::{ 10 | db, federation, 11 | forms::users::CreateUser, 12 | server::{AppState, app}, 13 | }; 14 | 15 | const TEST_USER_USERNAME: &str = "testuser"; 16 | const TEST_USER_PASSWORD: &str = "testpassword"; 17 | 18 | static NEXT_TEST_APP_PORT: AtomicUsize = AtomicUsize::new(4041); 19 | 20 | pub struct TestApp { 21 | pub logged_in_cookie: Option, 22 | pub router: Router, 23 | pub pool: Pool, 24 | pub base_url: Url, 25 | pub state: AppState, 26 | pub port: usize, 27 | } 28 | 29 | impl TestApp { 30 | pub async fn new() -> Self { 31 | let pool = super::db::new_pool().await; 32 | let port = NEXT_TEST_APP_PORT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); 33 | let base_url = Url::parse(&format!("http://localhost:{port}",)) 34 | .expect("Failed to parse URL for test instance"); 35 | let state = AppState { 36 | pool: pool.clone(), 37 | base_url: base_url.clone(), 38 | demo_mode: false, 39 | oidc_state: crate::oidc::State::NotConfigured, 40 | federation_config: federation::config::new_config(pool.clone(), base_url.clone()) 41 | .await 42 | .unwrap(), 43 | }; 44 | 45 | TestApp { 46 | router: app(state.clone()).await.unwrap(), 47 | pool, 48 | logged_in_cookie: None, 49 | base_url, 50 | state, 51 | port, 52 | } 53 | } 54 | 55 | pub fn req(&mut self) -> RequestBuilder { 56 | let mut req = RequestBuilder::new(&self.router); 57 | if let Some(cookie) = &self.logged_in_cookie { 58 | req = req.header(axum::http::header::COOKIE, cookie); 59 | } 60 | req 61 | } 62 | 63 | /// Since there's no route for creating users yet, we're doing this via the 64 | /// DB for now. 65 | pub async fn create_user(&self, username: &str, password: &str) -> db::User { 66 | let mut tx = self 67 | .pool 68 | .begin() 69 | .await 70 | .expect("Failed to create transaction"); 71 | let user = crate::db::users::create_user_if_not_exists( 72 | &mut tx, 73 | CreateUser { 74 | username: username.to_string(), 75 | password: password.to_string(), 76 | }, 77 | &self.base_url, 78 | ) 79 | .await 80 | .expect("Failed to create new user"); 81 | tx.commit().await.expect("Failed to commit transaction"); 82 | 83 | user 84 | } 85 | 86 | pub async fn create_test_user(&self) { 87 | self.create_user(TEST_USER_USERNAME, TEST_USER_PASSWORD) 88 | .await; 89 | } 90 | 91 | pub async fn login_test_user(&mut self) { 92 | self.login_user(TEST_USER_USERNAME, TEST_USER_PASSWORD) 93 | .await; 94 | } 95 | 96 | pub async fn login_user(&mut self, username: &str, password: &str) { 97 | let login_page = self.req().get("/login").await.test_page().await; 98 | 99 | let input = crate::forms::users::Login { 100 | credentials: crate::forms::users::Credentials { 101 | username: username.to_string(), 102 | password: password.to_string(), 103 | }, 104 | previous_uri: None, 105 | }; 106 | 107 | let login_response = login_page 108 | .expect_status(StatusCode::SEE_OTHER) 109 | .fill_form("form", &input) 110 | .await; 111 | 112 | let cookie = login_response.headers().get("Set-Cookie").unwrap(); 113 | let cookie = cookie.to_str().unwrap().split_once(';').unwrap().0; 114 | assert!(!cookie.is_empty()); 115 | 116 | self.logged_in_cookie = Some(cookie.to_string()); 117 | } 118 | 119 | pub async fn serve(&self) { 120 | let listener = TcpListener::bind(format!("localhost:{}", self.port)) 121 | .await 122 | .unwrap(); 123 | let router = self.router.clone(); 124 | 125 | tokio::spawn(async move { 126 | axum::serve(listener, router).await.unwrap(); 127 | }); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/views/base_document.rs: -------------------------------------------------------------------------------- 1 | use htmf::{declare::*, element::Element, into_elements::IntoElements}; 2 | 3 | pub fn base_document(children: impl IntoElements) -> Element { 4 | document().with( 5 | html(class("w-full h-full")) 6 | .with(head([]).with([ 7 | link([rel("stylesheet"), href("/assets/preflight.css")]), 8 | link([rel("stylesheet"), href("/assets/railwind.css")]), 9 | script(src("/assets/htmx.1.9.9.js")), 10 | meta([name("color-scheme"), content("dark")]), 11 | meta([ 12 | name("viewport"), 13 | content("width=device-width,initial-scale=1"), 14 | ]), 15 | title_tag([]).with("linkblocks"), 16 | ])) 17 | .with(body(class("w-full h-full text-gray-200 bg-neutral-800")).with(children)), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/views/content.rs: -------------------------------------------------------------------------------- 1 | use htmf::prelude::*; 2 | 3 | pub fn link_url(url: &str) -> Element { 4 | p(class( 5 | "w-full max-w-sm overflow-hidden text-sm text-neutral-400 whitespace-nowrap text-ellipsis", 6 | )) 7 | .with(url) 8 | } 9 | -------------------------------------------------------------------------------- /src/views/create_list.rs: -------------------------------------------------------------------------------- 1 | use htmf::{into_attrs::IntoAttrs, prelude::*}; 2 | 3 | use super::layout; 4 | use crate::{form_errors::FormErrors, forms::lists::CreateList}; 5 | 6 | pub struct Data { 7 | pub layout: layout::Template, 8 | pub input: CreateList, 9 | pub errors: FormErrors, 10 | } 11 | 12 | pub fn view( 13 | Data { 14 | layout, 15 | input: input_data, 16 | errors, 17 | }: &Data, 18 | ) -> Element { 19 | layout::layout( 20 | fragment().with([form([ 21 | action("/lists/create"), 22 | method("POST"), 23 | class("flex flex-col max-w-xl mx-4 mb-4 grow"), 24 | ]) 25 | .with([ 26 | header(class("mt-3 mb-4")).with([h1(class("text-xl font-bold")).with("Create a list")]), 27 | label(for_("title")).with("Title"), 28 | errors.view("title"), 29 | input([ 30 | required(""), 31 | name("title"), 32 | type_("text"), 33 | value(&input_data.title), 34 | class("rounded py-1.5 px-3 mt-2 bg-neutral-900"), 35 | ]), 36 | label(class("mt-4")).with([ 37 | text("Note"), 38 | errors.view("content"), 39 | textarea([ 40 | name("content"), 41 | placeholder(""), 42 | value(input_data.content.as_deref().unwrap_or("")), 43 | class("rounded py-1.5 px-3 mt-2 bg-neutral-900 block w-full"), 44 | ]), 45 | ]), 46 | div(class("mt-3 mb-5")).with([label(()).with([ 47 | input([ 48 | type_("checkbox"), 49 | name("private"), 50 | value("true"), 51 | input_data.private.then(checked).into_attrs(), 52 | ]), 53 | text("Private"), 54 | ])]), 55 | errors.view("root"), 56 | button([ 57 | type_("submit"), 58 | class("bg-neutral-300 py-1.5 px-3 text-neutral-900 rounded mt-4 self-end"), 59 | ]) 60 | .with("Add List"), 61 | ])]), 62 | layout, 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/views/edit_list_title.rs: -------------------------------------------------------------------------------- 1 | use htmf::prelude_inline::*; 2 | use uuid::Uuid; 3 | 4 | use crate::{form_errors::FormErrors, forms}; 5 | 6 | pub struct Data { 7 | pub layout: super::layout::Template, 8 | pub form_input: forms::lists::EditTitle, 9 | pub errors: FormErrors, 10 | pub list_id: Uuid, 11 | } 12 | 13 | pub fn view( 14 | Data { 15 | layout, 16 | form_input, 17 | errors, 18 | list_id, 19 | }: Data, 20 | ) -> Element { 21 | super::layout::layout( 22 | [form( 23 | [ 24 | action(format!("/lists/{list_id}/edit_title")), 25 | class("flex flex-col max-w-xl mx-4 mb-4 grow"), 26 | method("POST"), 27 | ], 28 | [ 29 | header( 30 | class("mt-3 mb-4"), 31 | [h1(class("text-xl font-bold"), "Rename list")], 32 | ), 33 | label(for_("title"), "New title"), 34 | errors.view("title"), 35 | input([ 36 | value(form_input.title), 37 | class("rounded py-1.5 px-3 mt-2 bg-neutral-900"), 38 | name("title"), 39 | required(""), 40 | type_("text"), 41 | ]), 42 | errors.view("root"), 43 | button( 44 | [ 45 | class("bg-neutral-300 py-1.5 px-3 text-neutral-900 rounded mt-4 self-end"), 46 | type_("submit"), 47 | ], 48 | "Save Changes", 49 | ), 50 | ], 51 | )], 52 | &layout, 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/views/form.rs: -------------------------------------------------------------------------------- 1 | use htmf::prelude::*; 2 | 3 | pub fn errors(errors: &[String]) -> Element { 4 | fragment().with( 5 | errors 6 | .iter() 7 | .map(|message| p(class("text-red-700")).with(message)) 8 | .collect::>(), 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/views/index.rs: -------------------------------------------------------------------------------- 1 | use htmf::prelude_inline::*; 2 | 3 | use super::layout; 4 | 5 | pub fn view(layout: &layout::Template) -> Element { 6 | super::layout::layout( 7 | fragment([ 8 | header( 9 | class("mx-4 mt-3 mb-4"), 10 | [h1(class("text-xl font-bold"), "Welcome to linkblocks!")], 11 | ), 12 | // TODO add intro text: what can you do with linkblocks? How to get started? Where to 13 | // get help? 14 | ul( 15 | class("flex flex-col max-w-sm gap-2 px-4 pb-4"), 16 | [li( 17 | (), 18 | a( 19 | [ 20 | class( 21 | "block p-4 border rounded border-neutral-700 hover:bg-neutral-700", 22 | ), 23 | href("/bookmarks/create"), 24 | ], 25 | "Add a bookmark", 26 | ), 27 | ) 28 | .with([li( 29 | [], 30 | a( 31 | [ 32 | class( 33 | "block p-4 border rounded border-neutral-700 hover:bg-neutral-700", 34 | ), 35 | href("/lists/create"), 36 | ], 37 | "Create a list", 38 | ), 39 | ) 40 | .with([li( 41 | (), 42 | a( 43 | [ 44 | class( 45 | "block px-4 py-2 border rounded border-neutral-700 \ 46 | hover:bg-neutral-700", 47 | ), 48 | href("/profile"), 49 | ], 50 | "Install the bookmarklet", 51 | ), 52 | )])])], 53 | ), 54 | // TODO add social links here 55 | ]), 56 | layout, 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/views/layout.rs: -------------------------------------------------------------------------------- 1 | use htmf::{into_elements::IntoElements, prelude::*}; 2 | 3 | use super::base_document::base_document; 4 | use crate::{ 5 | authentication::AuthUser, 6 | db::{self, AppTx, List, layout::AuthedInfo}, 7 | response_error::ResponseResult, 8 | }; 9 | 10 | pub struct Template { 11 | pub authed_info: Option, 12 | } 13 | 14 | impl Template { 15 | pub async fn from_db(tx: &mut AppTx, auth_user: Option<&AuthUser>) -> ResponseResult { 16 | let auth_info = if let Some(auth_user) = auth_user { 17 | Some(db::layout::by_user_id(tx, auth_user.user_id).await?) 18 | } else { 19 | None 20 | }; 21 | Ok(Template { 22 | authed_info: auth_info, 23 | }) 24 | } 25 | } 26 | 27 | pub fn layout(children: Children, layout: &Template) -> Element { 28 | base_document(div(class("flex-row-reverse h-full sm:flex")).with([ 29 | main_(class("sm:overflow-y-auto sm:grow")).with(children), 30 | match &layout.authed_info { 31 | Some(info) => sidebar(info), 32 | None => fragment(), 33 | }, 34 | ])) 35 | } 36 | 37 | fn sidebar(authed_info: &AuthedInfo) -> Element { 38 | aside([ 39 | id("nav"), 40 | class( 41 | "bg-neutral-900 sm:max-w-[18rem] sm:w-1/3 sm:max-h-full flex flex-col \ 42 | sm:flex-col-reverse sm:border-r border-neutral-700 border-t sm:border-t-0", 43 | ), 44 | ]) 45 | .with([ 46 | div(class("sm:overflow-y-auto sm:flex-1")).with([lists_header(), lists(authed_info)]), 47 | header(class( 48 | "sticky bottom-0 flex justify-between p-2 leading-8 bg-neutral-900", 49 | )) 50 | .with([ 51 | a([ 52 | href("/"), 53 | class("px-2 font-bold rounded hover:bg-neutral-800"), 54 | ]) 55 | .with(&authed_info.user_description), 56 | form([action("/logout"), method("post")]).with( 57 | button(class("rounded px-3 text-neutral-400 hover:bg-neutral-800")).with("Logout"), 58 | ), 59 | ]), 60 | ]) 61 | } 62 | 63 | fn lists_header() -> Element { 64 | div(class( 65 | "sticky top-0 flex items-center justify-between px-2 pt-2 sm:top-0 bg-neutral-900", 66 | )) 67 | .with([ 68 | h3(class( 69 | "px-2 py-1 text-sm font-bold tracking-tight text-neutral-400", 70 | )) 71 | .with("Lists"), 72 | a([ 73 | href("/lists/create"), 74 | class("block px-3 text-xl rounded hover:bg-neutral-800 text-neutral-400"), 75 | ]) 76 | .with("+"), 77 | ]) 78 | } 79 | 80 | fn lists(authed_info: &AuthedInfo) -> Element { 81 | let lists = authed_info.lists.iter(); 82 | ul(class("pb-2")).with([ 83 | li([]).with( 84 | a([ 85 | class( 86 | "block px-4 py-1 overflow-hidden text-ellipsis whitespace-nowrap \ 87 | hover:bg-neutral-800", 88 | ), 89 | href("/bookmarks/unsorted"), 90 | ]) 91 | .with("Unsorted bookmarks"), 92 | ), 93 | fragment().with(lists.map(list_item).collect::>()), 94 | li([]).with( 95 | a([ 96 | class( 97 | "block px-4 py-1 overflow-hidden text-ellipsis whitespace-nowrap \ 98 | hover:bg-neutral-800 text-neutral-400", 99 | ), 100 | href("/lists/unpinned"), 101 | ]) 102 | .with("Unpinned lists"), 103 | ), 104 | ]) 105 | } 106 | 107 | fn list_item(list: &List) -> Element { 108 | li([]).with( 109 | a([ 110 | class( 111 | "block px-4 py-1 overflow-hidden text-ellipsis whitespace-nowrap \ 112 | hover:bg-neutral-800", 113 | ), 114 | href(format!("/lists/{}", list.id)), 115 | ]) 116 | .with(&list.title), 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /src/views/list_unpinned_lists.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Not; 2 | 3 | use htmf::prelude_inline::*; 4 | 5 | use crate::db; 6 | 7 | pub struct Data { 8 | pub layout: super::layout::Template, 9 | pub lists: Vec, 10 | } 11 | 12 | pub fn view(Data { layout, lists }: Data) -> Element { 13 | super::layout::layout( 14 | [ 15 | header( 16 | class("px-4 pt-3 mb-4"), 17 | [h1(class("text-xl font-bold"), "Unpinned Lists")], 18 | ), 19 | fragment(lists.into_iter().map(list_item).collect::>()), 20 | ], 21 | &layout, 22 | ) 23 | } 24 | 25 | fn list_item(list: db::lists::UnpinnedList) -> Element { 26 | section( 27 | class( 28 | "flex flex-wrap items-end justify-between gap-2 px-4 pt-4 pb-4 border-t \ 29 | border-neutral-700", 30 | ), 31 | [div( 32 | class("overflow-hidden"), 33 | [ 34 | a( 35 | [ 36 | class( 37 | "block overflow-hidden font-semibold leading-8 hover:text-fuchsia-300 \ 38 | text-ellipsis whitespace-nowrap", 39 | ), 40 | href(format!("/lists/{}", list.id)), 41 | ], 42 | list.title, 43 | ), 44 | list.content 45 | .and_then(|content| { 46 | content 47 | .is_empty() 48 | .not() 49 | .then_some(p(class("mt-2"), content)) 50 | }) 51 | .unwrap_or(nothing()), 52 | div( 53 | class("flex flex-wrap text-sm gap-x-2 text-neutral-400"), 54 | [ 55 | p((), format!("{} bookmarks", list.bookmark_count)), 56 | text("∙"), 57 | p((), format!("{} lists", list.linked_list_count)), 58 | ], 59 | ), 60 | ], 61 | )], 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/views/login.rs: -------------------------------------------------------------------------------- 1 | use garde::Report; 2 | #[allow(clippy::wildcard_imports)] 3 | use htmf::prelude::*; 4 | 5 | use super::base_document::base_document; 6 | use crate::{ 7 | form_errors::FormErrors, 8 | forms::users::{Credentials, Login}, 9 | oidc, 10 | }; 11 | 12 | pub enum OidcInfo { 13 | NotConfigured, 14 | Configured { name: String }, 15 | } 16 | 17 | impl Default for OidcInfo { 18 | fn default() -> Self { 19 | Self::NotConfigured 20 | } 21 | } 22 | 23 | impl From for OidcInfo { 24 | fn from(value: oidc::State) -> Self { 25 | match value { 26 | oidc::State::NotConfigured => Self::NotConfigured, 27 | oidc::State::Configured(oidc::Config { name, .. }) => Self::Configured { name }, 28 | } 29 | } 30 | } 31 | 32 | #[derive(Default)] 33 | pub struct Template { 34 | errors: FormErrors, 35 | input: Login, 36 | oidc_info: OidcInfo, 37 | } 38 | 39 | impl Template { 40 | pub fn new(errors: Report, input: Login, oidc_state: oidc::State) -> Self { 41 | Self { 42 | errors: errors.into(), 43 | input: Login { 44 | credentials: Credentials { 45 | username: input.credentials.username, 46 | // Never render the password we got from the user 47 | password: String::new(), 48 | }, 49 | ..input 50 | }, 51 | oidc_info: oidc_state.into(), 52 | } 53 | } 54 | } 55 | 56 | pub fn login(template: &Template) -> Element { 57 | base_document( 58 | div(class( 59 | "flex flex-col justify-center max-w-md min-h-full px-4 mx-auto", 60 | )) 61 | .with([login_form(template), oidc_button(&template.oidc_info)]), 62 | ) 63 | } 64 | 65 | fn login_form(template: &Template) -> Element { 66 | form([ 67 | action("/login"), 68 | method("post"), 69 | attr("hx-boost", "true"), 70 | attr("hx-disabled-elt", "button"), 71 | class("flex flex-col w-full"), 72 | ]) 73 | .with([ 74 | h1(class("text-2xl font-bold tracking-tight text-center")).with("Sign in to your account"), 75 | username_field(&template.errors, &template.input.credentials.username), 76 | password_field(&template.errors), 77 | template 78 | .input 79 | .previous_uri 80 | .as_ref() 81 | .map(|previous_uri| input([type_("hidden"), name("previous_uri"), value(previous_uri)])) 82 | .into(), 83 | submit_button(), 84 | template.errors.view("root"), 85 | ]) 86 | } 87 | 88 | fn username_field(errors: &FormErrors, val: &str) -> Element { 89 | fragment().with([ 90 | label([ 91 | class("mt-10 text-neutral-400"), 92 | for_("credentials[username]"), 93 | ]) 94 | .with("Username"), 95 | errors.view("credentials.username"), 96 | input([ 97 | type_("text"), 98 | name("credentials[username]"), 99 | class("rounded py-1.5 px-3 mt-2 bg-neutral-900"), 100 | value(val), 101 | required("true"), 102 | ]), 103 | ]) 104 | } 105 | 106 | fn password_field(errors: &FormErrors) -> Element { 107 | fragment().with([ 108 | label([ 109 | class("mt-4 text-neutral-400"), 110 | for_("credentials[password]"), 111 | ]) 112 | .with("Password"), 113 | errors.view("credentials.password"), 114 | input([ 115 | type_("password"), 116 | name("credentials[password]"), 117 | class("rounded py-1.5 px-3 mt-2 bg-neutral-900"), 118 | required("true"), 119 | ]), 120 | ]) 121 | } 122 | 123 | fn submit_button() -> Element { 124 | button([ 125 | type_("submit"), 126 | class( 127 | "leading-6 bg-neutral-300 mt-5 font-semibold rounded py-1.5 flex items-center \ 128 | justify-center disabled:bg-neutral-500 text-neutral-900", 129 | ), 130 | ]) 131 | .with([ 132 | span(class("inline-block w-0 h-4")).with(span(class( 133 | "block w-4 h-4 -ml-6 border-2 rounded-full border-neutral-900 animate-spin \ 134 | border-t-transparent htmx-indicator", 135 | ))), 136 | text("Sign in"), 137 | ]) 138 | } 139 | 140 | fn oidc_button(oidc_info: &OidcInfo) -> Element { 141 | if let OidcInfo::Configured { name } = oidc_info { 142 | fragment().with([ 143 | hr(class("my-5 border-neutral-700")), 144 | a([ 145 | class( 146 | "leading-6 border border-neutral-500 font-semibold rounded py-1.5 flex \ 147 | items-center justify-center", 148 | ), 149 | href("/login_oidc"), 150 | ]) 151 | .with(format!("Sign in with {name}")), 152 | ]) 153 | } else { 154 | nothing() 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/views/login_demo.rs: -------------------------------------------------------------------------------- 1 | use htmf::prelude_inline::*; 2 | 3 | use super::base_document::base_document; 4 | 5 | pub fn view() -> Element { 6 | base_document(form( 7 | [ 8 | action("/login_demo"), 9 | class("flex flex-col justify-center flex-1 max-w-md min-h-full px-4 mx-auto"), 10 | attr("hx-boost", "true"), 11 | attr("hx-disabled-elt", "button"), 12 | method("post"), 13 | ], 14 | [ 15 | h1( 16 | class("text-2xl font-bold tracking-tight text-center"), 17 | "Welcome to the linkblocks demo!", 18 | ), 19 | p( 20 | class("mt-10"), 21 | "Here, you can try linkblocks with a temporary account. Every hour, All accounts \ 22 | on this server are permanently deleted.", 23 | ), 24 | button( 25 | [ 26 | class( 27 | "leading-6 bg-neutral-300 mt-5 font-semibold rounded py-1.5 flex \ 28 | items-center justify-center disabled:bg-neutral-500 text-neutral-900", 29 | ), 30 | type_("submit"), 31 | ], 32 | [ 33 | span( 34 | class("inline-block w-0 h-4"), 35 | [span( 36 | class( 37 | "block w-4 h-4 -ml-6 border-2 rounded-full border-neutral-900 \ 38 | animate-spin border-t-transparent htmx-indicator", 39 | ), 40 | (), 41 | )], 42 | ), 43 | text("Try using a temporary account"), 44 | ], 45 | ), 46 | ], 47 | )) 48 | } 49 | -------------------------------------------------------------------------------- /src/views/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::wildcard_imports)] 2 | #![allow(clippy::too_many_lines)] 3 | pub mod base_document; 4 | pub mod content; 5 | pub mod create_bookmark; 6 | pub mod create_link; 7 | pub mod create_list; 8 | pub mod edit_list_title; 9 | pub mod form; 10 | pub mod index; 11 | pub mod layout; 12 | pub mod list; 13 | pub mod list_unpinned_lists; 14 | pub mod login; 15 | pub mod login_demo; 16 | pub mod oidc_select_username; 17 | pub mod unsorted_bookmarks; 18 | pub mod users; 19 | -------------------------------------------------------------------------------- /src/views/oidc_select_username.rs: -------------------------------------------------------------------------------- 1 | use htmf::prelude_inline::*; 2 | 3 | use super::base_document::base_document; 4 | use crate::{form_errors::FormErrors, forms::users::OidcSelectUsername}; 5 | 6 | #[derive(Default)] 7 | pub struct Data { 8 | pub errors: FormErrors, 9 | pub form_input: OidcSelectUsername, 10 | } 11 | 12 | pub fn view(Data { errors, form_input }: Data) -> Element { 13 | base_document([div( 14 | class("flex flex-col justify-center max-w-md min-h-full px-4 mx-auto"), 15 | [form( 16 | [ 17 | class("flex flex-col w-full"), 18 | attr("hx-boost", "true"), 19 | attr("hx-disabled-elt", "button"), 20 | method("post"), 21 | ], 22 | [ 23 | h1( 24 | class("text-2xl font-bold tracking-tight text-center"), 25 | "Welcome to linkblocks! Please select a username.", 26 | ), 27 | p( 28 | (), 29 | "It should consist of letters and numbers, and it can be 3 to 50 characters \ 30 | long. It will be your handle on the fediverse.", 31 | ), 32 | label( 33 | [class("mt-10 text-neutral-400"), name("username")], 34 | "Username", 35 | ), 36 | errors.view("username"), 37 | input([ 38 | class("rounded py-1.5 px-3 mt-2 bg-neutral-900"), 39 | name("username"), 40 | required(""), 41 | type_("text"), 42 | value(form_input.username), 43 | ]), 44 | errors.view("root"), 45 | button( 46 | [ 47 | class( 48 | "leading-6 bg-neutral-300 mt-5 font-semibold rounded py-1.5 flex \ 49 | items-center justify-center disabled:bg-neutral-500 text-neutral-900", 50 | ), 51 | type_("submit"), 52 | ], 53 | [ 54 | span( 55 | class("inline-block w-0 h-4"), 56 | span( 57 | class( 58 | "block w-4 h-4 -ml-6 border-2 rounded-full border-neutral-900 \ 59 | animate-spin border-t-transparent htmx-indicator", 60 | ), 61 | (), 62 | ), 63 | ), 64 | text("Sign in"), 65 | ], 66 | ), 67 | ], 68 | )], 69 | )]) 70 | } 71 | -------------------------------------------------------------------------------- /src/views/unsorted_bookmarks.rs: -------------------------------------------------------------------------------- 1 | use htmf::prelude::*; 2 | 3 | use super::{content, layout}; 4 | use crate::db::{self, Bookmark}; 5 | 6 | pub struct Data { 7 | pub layout: layout::Template, 8 | pub bookmarks: Vec, 9 | } 10 | 11 | pub fn view(data: &Data) -> Element { 12 | layout::layout( 13 | fragment() 14 | .with([header(class("px-4 pt-3 mb-4")) 15 | .with([h1(class("text-xl font-bold")).with("Unsorted Bookmarks")])]) 16 | .with( 17 | data.bookmarks 18 | .iter() 19 | .map(bookmark_entry) 20 | .collect::>(), 21 | ), 22 | &data.layout, 23 | ) 24 | } 25 | 26 | fn bookmark_entry(bookmark: &Bookmark) -> Element { 27 | let bookmark_id = bookmark.id; 28 | 29 | section(class( 30 | "flex flex-wrap items-end justify-between gap-2 py-4 border-t border-neutral-700", 31 | )) 32 | .with([ 33 | a([ 34 | href(&bookmark.url), 35 | class( 36 | "block px-4 overflow-hidden leading-8 text-orange-100 hover:text-orange-300 \ 37 | shrink text-ellipsis whitespace-nowrap", 38 | ), 39 | ]) 40 | .with(&bookmark.title) 41 | .with(content::link_url(&bookmark.url)), 42 | div(class("flex justify-end gap-2 mx-4 grow text-neutral-300")).with([a([ 43 | href(format!("/links/create?dest_id={bookmark_id}")), 44 | class("px-4 py-1 border rounded border-neutral-700 hover:bg-neutral-700"), 45 | ]) 46 | .with([ 47 | text("Add to list"), 48 | a([ 49 | attr("hx-delete", format!("/bookmarks/{bookmark_id}")), 50 | href(format!("/bookmarks/{bookmark_id}")), 51 | class("px-4 py-1 border rounded border-neutral-700 hover:bg-neutral-600"), 52 | ]) 53 | .with([text("Delete")]), 54 | ])]), 55 | ]) 56 | } 57 | -------------------------------------------------------------------------------- /src/views/users.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::wildcard_imports)] 2 | use htmf::prelude::*; 3 | use url::Url; 4 | 5 | use super::layout::{self, layout}; 6 | 7 | pub struct ProfileTemplate { 8 | pub layout: layout::Template, 9 | pub base_url: Url, 10 | } 11 | 12 | pub fn profile(template: &ProfileTemplate) -> Element { 13 | layout( 14 | fragment().with([ 15 | header(class("px-4 pt-3 mb-2")) 16 | .with([h1(class("text-xl font-bold")).with([text("Install Bookmarklet")])]), 17 | section(class("p-4")).with([bookmarklet_help(), bookmarklet(&template.base_url)]), 18 | ]), 19 | &template.layout, 20 | ) 21 | } 22 | 23 | fn bookmarklet_help() -> Element { 24 | fragment().with([ 25 | p(class("mb-2")).with( 26 | "Click the bookmarklet on any website to add it as a bookmark in 27 | linkblocks!", 28 | ), 29 | p([]).with("To install, drag the following link to your bookmarks / favorites toolbar:"), 30 | ]) 31 | } 32 | 33 | fn bookmarklet(base_url: &Url) -> Element { 34 | // window.open( 35 | // "{ base_url }/bookmarks/create?url=" 36 | // +encodeURIComponent(window.location.href) 37 | // +"&title=" 38 | // +encodeURIComponent(document.title) 39 | // ) 40 | a([ 41 | class("block my-2 font-bold text-orange-200"), 42 | href(format!( 43 | "javascript:(function()%7Bwindow.open(%0A%20%20%22{base_url}%2Fbookmarks%2Fcreate%\ 44 | 3Furl%3D%22%0A%20%20%2BencodeURIComponent(window.location.href)%0A%20%20%2B%22%\ 45 | 26title%3D%22%0A%20%20%2BencodeURIComponent(document.title)%0A)%7D)()", 46 | )), 47 | ]) 48 | .with("Add to linkblocks") 49 | } 50 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | // THIS FILE HAS NO EFFECT ON CSS GENERATION 2 | // We only include this file to enable the VSCode Tailwind extension. 3 | // Without it, the extension will disable itself. 4 | // To configure how tailwind CSS gets generated, look at the `build.rs` file. 5 | module.exports = { 6 | content: ["src/views/*.rs"], 7 | }; 8 | --------------------------------------------------------------------------------