- removed rust apps - renamed go apps - updates docs and workflows
| 6 | 6 | - main |
|
| 7 | 7 | ||
| 8 | 8 | jobs: |
|
| 9 | - | check: |
|
| 10 | - | name: Check |
|
| 11 | - | runs-on: ubuntu-latest |
|
| 12 | - | steps: |
|
| 13 | - | - uses: actions/checkout@v6 |
|
| 14 | - | with: |
|
| 15 | - | submodules: recursive |
|
| 16 | - | ||
| 17 | - | - name: Install Rust toolchain |
|
| 18 | - | uses: dtolnay/rust-toolchain@stable |
|
| 19 | - | ||
| 20 | - | - name: Cache cargo registry & build |
|
| 21 | - | uses: actions/cache@v4 |
|
| 22 | - | with: |
|
| 23 | - | path: | |
|
| 24 | - | ~/.cargo/registry |
|
| 25 | - | ~/.cargo/git |
|
| 26 | - | target |
|
| 27 | - | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} |
|
| 28 | - | restore-keys: | |
|
| 29 | - | ${{ runner.os }}-cargo- |
|
| 30 | - | ||
| 31 | - | - name: cargo check |
|
| 32 | - | run: cargo check --workspace |
|
| 33 | - | ||
| 34 | - | test: |
|
| 35 | - | name: Test |
|
| 36 | - | runs-on: ubuntu-latest |
|
| 37 | - | steps: |
|
| 38 | - | - uses: actions/checkout@v6 |
|
| 39 | - | with: |
|
| 40 | - | submodules: recursive |
|
| 41 | - | ||
| 42 | - | - name: Install Rust toolchain |
|
| 43 | - | uses: dtolnay/rust-toolchain@stable |
|
| 44 | - | ||
| 45 | - | - name: Cache cargo registry & build |
|
| 46 | - | uses: actions/cache@v4 |
|
| 47 | - | with: |
|
| 48 | - | path: | |
|
| 49 | - | ~/.cargo/registry |
|
| 50 | - | ~/.cargo/git |
|
| 51 | - | target |
|
| 52 | - | key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} |
|
| 53 | - | restore-keys: | |
|
| 54 | - | ${{ runner.os }}-cargo-test- |
|
| 55 | - | ||
| 56 | - | - name: cargo test |
|
| 57 | - | run: cargo test --workspace |
|
| 58 | - | ||
| 59 | 9 | go: |
|
| 60 | 10 | name: Go checks |
|
| 61 | 11 | runs-on: ubuntu-latest |
|
| 67 | 17 | with: |
|
| 68 | 18 | go-version: '1.25.x' |
|
| 69 | 19 | cache-dependency-path: | |
|
| 70 | - | apps/*-go/go.sum |
|
| 20 | + | apps/*/go.sum |
|
| 71 | 21 | crates-go/*/go.sum |
|
| 72 | 22 | ||
| 73 | 23 | - name: go test all modules |
|
| 74 | 24 | run: | |
|
| 75 | 25 | set -euo pipefail |
|
| 76 | - | for mod in crates-go/* apps/*-go; do |
|
| 26 | + | for mod in crates-go/* apps/*; do |
|
| 77 | 27 | if [ -f "$mod/go.mod" ]; then |
|
| 78 | 28 | echo "::group::$mod" |
|
| 79 | 29 | (cd "$mod" && go test ./...) |
|
| 84 | 34 | - name: go vet all modules |
|
| 85 | 35 | run: | |
|
| 86 | 36 | set -euo pipefail |
|
| 87 | - | for mod in crates-go/* apps/*-go; do |
|
| 37 | + | for mod in crates-go/* apps/*; do |
|
| 88 | 38 | if [ -f "$mod/go.mod" ]; then |
|
| 89 | 39 | echo "::group::$mod" |
|
| 90 | 40 | (cd "$mod" && go vet ./...) |
|
| 19 | 19 | - name: Determine which apps to build |
|
| 20 | 20 | id: filter |
|
| 21 | 21 | run: | |
|
| 22 | - | ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","bookmarks","easel","bookmarks-go","cellar-go","easel-go","feeds-go","jotts-go","library-go","og-go","posts-go","shrink-go","sipp-go"]' |
|
| 22 | + | ALL='["backup","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]' |
|
| 23 | 23 | ||
| 24 | 24 | changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) |
|
| 25 | 25 | ||
| 26 | - | if echo "$changed" | grep -qE '^(Cargo\.(toml|lock)|crates/|crates-go/|\.github/workflows/docker(-test)?\.yml)'; then |
|
| 26 | + | if echo "$changed" | grep -qE '^(crates-go/|\.github/workflows/docker(-test)?\.yml)'; then |
|
| 27 | 27 | echo "apps=${ALL}" >> "$GITHUB_OUTPUT" |
|
| 28 | 28 | exit 0 |
|
| 29 | 29 | fi |
|
| 30 | 30 | ||
| 31 | 31 | apps=() |
|
| 32 | - | for app in cellar sipp feeds parcels jotts og shrink backup posts library bookmarks easel bookmarks-go cellar-go easel-go feeds-go jotts-go library-go og-go posts-go shrink-go sipp-go; do |
|
| 32 | + | for app in backup bookmarks cellar easel feeds jotts library og posts shrink sipp; do |
|
| 33 | 33 | if echo "$changed" | grep -q "^apps/${app}/"; then |
|
| 34 | 34 | apps+=("\"${app}\"") |
|
| 35 | 35 | fi |
| 25 | 25 | - name: Determine which apps to build |
|
| 26 | 26 | id: filter |
|
| 27 | 27 | run: | |
|
| 28 | - | ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","bookmarks","easel","bookmarks-go","cellar-go","easel-go","feeds-go","jotts-go","library-go","og-go","posts-go","shrink-go","sipp-go"]' |
|
| 29 | - | ||
| 30 | - | # Map cargo package names to directory names |
|
| 31 | - | pkg_to_dir() { |
|
| 32 | - | case "$1" in |
|
| 33 | - | sipp-so) echo "sipp" ;; |
|
| 34 | - | *) echo "$1" ;; |
|
| 35 | - | esac |
|
| 36 | - | } |
|
| 28 | + | ALL='["backup","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]' |
|
| 37 | 29 | ||
| 38 | 30 | # Tags: per-app (app/version) or bare (version) |
|
| 39 | 31 | if [[ "${GITHUB_REF}" == refs/tags/* ]]; then |
|
| 40 | 32 | TAG="${GITHUB_REF#refs/tags/}" |
|
| 41 | 33 | ||
| 42 | 34 | if [[ "$TAG" == */* ]]; then |
|
| 43 | - | PKG="${TAG%/*}" |
|
| 35 | + | APP="${TAG%/*}" |
|
| 44 | 36 | VERSION="${TAG##*/}" |
|
| 45 | - | APP=$(pkg_to_dir "$PKG") |
|
| 46 | 37 | echo "apps=[\"${APP}\"]" >> "$GITHUB_OUTPUT" |
|
| 47 | 38 | echo "version=${VERSION}" >> "$GITHUB_OUTPUT" |
|
| 48 | 39 | else |
|
| 55 | 46 | changed=$(git diff --name-only HEAD~1 HEAD) |
|
| 56 | 47 | ||
| 57 | 48 | # Workspace-level changes (shared deps, CI) rebuild all |
|
| 58 | - | if echo "$changed" | grep -qE '^(Cargo\.(toml|lock)|crates/|crates-go/|\.github/workflows/docker\.yml)'; then |
|
| 49 | + | if echo "$changed" | grep -qE '^(crates-go/|\.github/workflows/docker\.yml)'; then |
|
| 59 | 50 | echo "apps=${ALL}" >> "$GITHUB_OUTPUT" |
|
| 60 | 51 | exit 0 |
|
| 61 | 52 | fi |
|
| 62 | 53 | ||
| 63 | 54 | apps=() |
|
| 64 | - | for app in cellar sipp feeds parcels jotts og shrink backup posts library bookmarks easel bookmarks-go cellar-go easel-go feeds-go jotts-go library-go og-go posts-go shrink-go sipp-go; do |
|
| 55 | + | for app in backup bookmarks cellar easel feeds jotts library og posts shrink sipp; do |
|
| 65 | 56 | if echo "$changed" | grep -q "^apps/${app}/"; then |
|
| 66 | 57 | apps+=("\"${app}\"") |
|
| 67 | 58 | fi |
|
| 1 | - | # This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist |
|
| 2 | - | # |
|
| 3 | - | # Copyright 2022-2024, axodotdev |
|
| 4 | - | # SPDX-License-Identifier: MIT or Apache-2.0 |
|
| 5 | - | # |
|
| 6 | - | # CI that: |
|
| 7 | - | # |
|
| 8 | - | # * checks for a Git Tag that looks like a release |
|
| 9 | - | # * builds artifacts with dist (archives, installers, hashes) |
|
| 10 | - | # * uploads those artifacts to temporary workflow zip |
|
| 11 | - | # * on success, uploads the artifacts to a GitHub Release |
|
| 12 | - | # |
|
| 13 | - | # Note that the GitHub Release will be created with a generated |
|
| 14 | - | # title/body based on your changelogs. |
|
| 15 | - | ||
| 16 | - | name: Release |
|
| 17 | - | permissions: |
|
| 18 | - | "contents": "write" |
|
| 19 | - | ||
| 20 | - | # This task will run whenever you push a git tag that looks like a version |
|
| 21 | - | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. |
|
| 22 | - | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where |
|
| 23 | - | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION |
|
| 24 | - | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). |
|
| 25 | - | # |
|
| 26 | - | # If PACKAGE_NAME is specified, then the announcement will be for that |
|
| 27 | - | # package (erroring out if it doesn't have the given version or isn't dist-able). |
|
| 28 | - | # |
|
| 29 | - | # If PACKAGE_NAME isn't specified, then the announcement will be for all |
|
| 30 | - | # (dist-able) packages in the workspace with that version (this mode is |
|
| 31 | - | # intended for workspaces with only one dist-able package, or with all dist-able |
|
| 32 | - | # packages versioned/released in lockstep). |
|
| 33 | - | # |
|
| 34 | - | # If you push multiple tags at once, separate instances of this workflow will |
|
| 35 | - | # spin up, creating an independent announcement for each one. However, GitHub |
|
| 36 | - | # will hard limit this to 3 tags per commit, as it will assume more tags is a |
|
| 37 | - | # mistake. |
|
| 38 | - | # |
|
| 39 | - | # If there's a prerelease-style suffix to the version, then the release(s) |
|
| 40 | - | # will be marked as a prerelease. |
|
| 41 | - | on: |
|
| 42 | - | push: |
|
| 43 | - | tags: |
|
| 44 | - | - '**[0-9]+.[0-9]+.[0-9]+*' |
|
| 45 | - | ||
| 46 | - | jobs: |
|
| 47 | - | # Run 'dist plan' (or host) to determine what tasks we need to do |
|
| 48 | - | plan: |
|
| 49 | - | runs-on: "ubuntu-22.04" |
|
| 50 | - | outputs: |
|
| 51 | - | val: ${{ steps.plan.outputs.manifest }} |
|
| 52 | - | tag: ${{ !github.event.pull_request && github.ref_name || '' }} |
|
| 53 | - | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} |
|
| 54 | - | publishing: ${{ !github.event.pull_request }} |
|
| 55 | - | env: |
|
| 56 | - | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 57 | - | steps: |
|
| 58 | - | - uses: actions/checkout@v6 |
|
| 59 | - | with: |
|
| 60 | - | persist-credentials: false |
|
| 61 | - | submodules: recursive |
|
| 62 | - | - name: Install dist |
|
| 63 | - | # we specify bash to get pipefail; it guards against the `curl` command |
|
| 64 | - | # failing. otherwise `sh` won't catch that `curl` returned non-0 |
|
| 65 | - | shell: bash |
|
| 66 | - | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" |
|
| 67 | - | - name: Cache dist |
|
| 68 | - | uses: actions/upload-artifact@v6 |
|
| 69 | - | with: |
|
| 70 | - | name: cargo-dist-cache |
|
| 71 | - | path: ~/.cargo/bin/dist |
|
| 72 | - | # sure would be cool if github gave us proper conditionals... |
|
| 73 | - | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible |
|
| 74 | - | # functionality based on whether this is a pull_request, and whether it's from a fork. |
|
| 75 | - | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* |
|
| 76 | - | # but also really annoying to build CI around when it needs secrets to work right.) |
|
| 77 | - | - id: plan |
|
| 78 | - | run: | |
|
| 79 | - | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json |
|
| 80 | - | echo "dist ran successfully" |
|
| 81 | - | cat plan-dist-manifest.json |
|
| 82 | - | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" |
|
| 83 | - | - name: "Upload dist-manifest.json" |
|
| 84 | - | uses: actions/upload-artifact@v6 |
|
| 85 | - | with: |
|
| 86 | - | name: artifacts-plan-dist-manifest |
|
| 87 | - | path: plan-dist-manifest.json |
|
| 88 | - | ||
| 89 | - | # Build and packages all the platform-specific things |
|
| 90 | - | build-local-artifacts: |
|
| 91 | - | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) |
|
| 92 | - | # Let the initial task tell us to not run (currently very blunt) |
|
| 93 | - | needs: |
|
| 94 | - | - plan |
|
| 95 | - | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} |
|
| 96 | - | strategy: |
|
| 97 | - | fail-fast: false |
|
| 98 | - | # Target platforms/runners are computed by dist in create-release. |
|
| 99 | - | # Each member of the matrix has the following arguments: |
|
| 100 | - | # |
|
| 101 | - | # - runner: the github runner |
|
| 102 | - | # - dist-args: cli flags to pass to dist |
|
| 103 | - | # - install-dist: expression to run to install dist on the runner |
|
| 104 | - | # |
|
| 105 | - | # Typically there will be: |
|
| 106 | - | # - 1 "global" task that builds universal installers |
|
| 107 | - | # - N "local" tasks that build each platform's binaries and platform-specific installers |
|
| 108 | - | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} |
|
| 109 | - | runs-on: ${{ matrix.runner }} |
|
| 110 | - | container: ${{ matrix.container && matrix.container.image || null }} |
|
| 111 | - | env: |
|
| 112 | - | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 113 | - | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json |
|
| 114 | - | steps: |
|
| 115 | - | - name: enable windows longpaths |
|
| 116 | - | run: | |
|
| 117 | - | git config --global core.longpaths true |
|
| 118 | - | - uses: actions/checkout@v6 |
|
| 119 | - | with: |
|
| 120 | - | persist-credentials: false |
|
| 121 | - | submodules: recursive |
|
| 122 | - | - name: Install Rust non-interactively if not already installed |
|
| 123 | - | if: ${{ matrix.container }} |
|
| 124 | - | run: | |
|
| 125 | - | if ! command -v cargo > /dev/null 2>&1; then |
|
| 126 | - | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y |
|
| 127 | - | echo "$HOME/.cargo/bin" >> $GITHUB_PATH |
|
| 128 | - | fi |
|
| 129 | - | - name: Install dist |
|
| 130 | - | run: ${{ matrix.install_dist.run }} |
|
| 131 | - | # Get the dist-manifest |
|
| 132 | - | - name: Fetch local artifacts |
|
| 133 | - | uses: actions/download-artifact@v7 |
|
| 134 | - | with: |
|
| 135 | - | pattern: artifacts-* |
|
| 136 | - | path: target/distrib/ |
|
| 137 | - | merge-multiple: true |
|
| 138 | - | - name: Install dependencies |
|
| 139 | - | run: | |
|
| 140 | - | ${{ matrix.packages_install }} |
|
| 141 | - | - name: Build artifacts |
|
| 142 | - | run: | |
|
| 143 | - | # Actually do builds and make zips and whatnot |
|
| 144 | - | dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json |
|
| 145 | - | echo "dist ran successfully" |
|
| 146 | - | - id: cargo-dist |
|
| 147 | - | name: Post-build |
|
| 148 | - | # We force bash here just because github makes it really hard to get values up |
|
| 149 | - | # to "real" actions without writing to env-vars, and writing to env-vars has |
|
| 150 | - | # inconsistent syntax between shell and powershell. |
|
| 151 | - | shell: bash |
|
| 152 | - | run: | |
|
| 153 | - | # Parse out what we just built and upload it to scratch storage |
|
| 154 | - | echo "paths<<EOF" >> "$GITHUB_OUTPUT" |
|
| 155 | - | dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" |
|
| 156 | - | echo "EOF" >> "$GITHUB_OUTPUT" |
|
| 157 | - | ||
| 158 | - | cp dist-manifest.json "$BUILD_MANIFEST_NAME" |
|
| 159 | - | - name: "Upload artifacts" |
|
| 160 | - | uses: actions/upload-artifact@v6 |
|
| 161 | - | with: |
|
| 162 | - | name: artifacts-build-local-${{ join(matrix.targets, '_') }} |
|
| 163 | - | path: | |
|
| 164 | - | ${{ steps.cargo-dist.outputs.paths }} |
|
| 165 | - | ${{ env.BUILD_MANIFEST_NAME }} |
|
| 166 | - | ||
| 167 | - | # Build and package all the platform-agnostic(ish) things |
|
| 168 | - | build-global-artifacts: |
|
| 169 | - | needs: |
|
| 170 | - | - plan |
|
| 171 | - | - build-local-artifacts |
|
| 172 | - | runs-on: "ubuntu-22.04" |
|
| 173 | - | env: |
|
| 174 | - | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 175 | - | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json |
|
| 176 | - | steps: |
|
| 177 | - | - uses: actions/checkout@v6 |
|
| 178 | - | with: |
|
| 179 | - | persist-credentials: false |
|
| 180 | - | submodules: recursive |
|
| 181 | - | - name: Install cached dist |
|
| 182 | - | uses: actions/download-artifact@v7 |
|
| 183 | - | with: |
|
| 184 | - | name: cargo-dist-cache |
|
| 185 | - | path: ~/.cargo/bin/ |
|
| 186 | - | - run: chmod +x ~/.cargo/bin/dist |
|
| 187 | - | # Get all the local artifacts for the global tasks to use (for e.g. checksums) |
|
| 188 | - | - name: Fetch local artifacts |
|
| 189 | - | uses: actions/download-artifact@v7 |
|
| 190 | - | with: |
|
| 191 | - | pattern: artifacts-* |
|
| 192 | - | path: target/distrib/ |
|
| 193 | - | merge-multiple: true |
|
| 194 | - | - id: cargo-dist |
|
| 195 | - | shell: bash |
|
| 196 | - | run: | |
|
| 197 | - | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json |
|
| 198 | - | echo "dist ran successfully" |
|
| 199 | - | ||
| 200 | - | # Parse out what we just built and upload it to scratch storage |
|
| 201 | - | echo "paths<<EOF" >> "$GITHUB_OUTPUT" |
|
| 202 | - | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" |
|
| 203 | - | echo "EOF" >> "$GITHUB_OUTPUT" |
|
| 204 | - | ||
| 205 | - | cp dist-manifest.json "$BUILD_MANIFEST_NAME" |
|
| 206 | - | - name: "Upload artifacts" |
|
| 207 | - | uses: actions/upload-artifact@v6 |
|
| 208 | - | with: |
|
| 209 | - | name: artifacts-build-global |
|
| 210 | - | path: | |
|
| 211 | - | ${{ steps.cargo-dist.outputs.paths }} |
|
| 212 | - | ${{ env.BUILD_MANIFEST_NAME }} |
|
| 213 | - | # Determines if we should publish/announce |
|
| 214 | - | host: |
|
| 215 | - | needs: |
|
| 216 | - | - plan |
|
| 217 | - | - build-local-artifacts |
|
| 218 | - | - build-global-artifacts |
|
| 219 | - | # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) |
|
| 220 | - | if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} |
|
| 221 | - | env: |
|
| 222 | - | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 223 | - | runs-on: "ubuntu-22.04" |
|
| 224 | - | outputs: |
|
| 225 | - | val: ${{ steps.host.outputs.manifest }} |
|
| 226 | - | steps: |
|
| 227 | - | - uses: actions/checkout@v6 |
|
| 228 | - | with: |
|
| 229 | - | persist-credentials: false |
|
| 230 | - | submodules: recursive |
|
| 231 | - | - name: Install cached dist |
|
| 232 | - | uses: actions/download-artifact@v7 |
|
| 233 | - | with: |
|
| 234 | - | name: cargo-dist-cache |
|
| 235 | - | path: ~/.cargo/bin/ |
|
| 236 | - | - run: chmod +x ~/.cargo/bin/dist |
|
| 237 | - | # Fetch artifacts from scratch-storage |
|
| 238 | - | - name: Fetch artifacts |
|
| 239 | - | uses: actions/download-artifact@v7 |
|
| 240 | - | with: |
|
| 241 | - | pattern: artifacts-* |
|
| 242 | - | path: target/distrib/ |
|
| 243 | - | merge-multiple: true |
|
| 244 | - | - id: host |
|
| 245 | - | shell: bash |
|
| 246 | - | run: | |
|
| 247 | - | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json |
|
| 248 | - | echo "artifacts uploaded and released successfully" |
|
| 249 | - | cat dist-manifest.json |
|
| 250 | - | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" |
|
| 251 | - | - name: "Upload dist-manifest.json" |
|
| 252 | - | uses: actions/upload-artifact@v6 |
|
| 253 | - | with: |
|
| 254 | - | # Overwrite the previous copy |
|
| 255 | - | name: artifacts-dist-manifest |
|
| 256 | - | path: dist-manifest.json |
|
| 257 | - | # Create a GitHub Release while uploading all files to it |
|
| 258 | - | - name: "Download GitHub Artifacts" |
|
| 259 | - | uses: actions/download-artifact@v7 |
|
| 260 | - | with: |
|
| 261 | - | pattern: artifacts-* |
|
| 262 | - | path: artifacts |
|
| 263 | - | merge-multiple: true |
|
| 264 | - | - name: Cleanup |
|
| 265 | - | run: | |
|
| 266 | - | # Remove the granular manifests |
|
| 267 | - | rm -f artifacts/*-dist-manifest.json |
|
| 268 | - | - name: Create GitHub Release |
|
| 269 | - | env: |
|
| 270 | - | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" |
|
| 271 | - | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" |
|
| 272 | - | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" |
|
| 273 | - | RELEASE_COMMIT: "${{ github.sha }}" |
|
| 274 | - | run: | |
|
| 275 | - | # Write and read notes from a file to avoid quoting breaking things |
|
| 276 | - | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt |
|
| 277 | - | ||
| 278 | - | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* |
|
| 279 | - | ||
| 280 | - | publish-homebrew-formula: |
|
| 281 | - | needs: |
|
| 282 | - | - plan |
|
| 283 | - | - host |
|
| 284 | - | runs-on: "ubuntu-22.04" |
|
| 285 | - | env: |
|
| 286 | - | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 287 | - | PLAN: ${{ needs.plan.outputs.val }} |
|
| 288 | - | GITHUB_USER: "axo bot" |
|
| 289 | - | GITHUB_EMAIL: "admin+bot@axo.dev" |
|
| 290 | - | if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} |
|
| 291 | - | steps: |
|
| 292 | - | - uses: actions/checkout@v6 |
|
| 293 | - | with: |
|
| 294 | - | persist-credentials: true |
|
| 295 | - | repository: "stevedylandev/homebrew-tap" |
|
| 296 | - | token: ${{ secrets.HOMEBREW_TAP_TOKEN }} |
|
| 297 | - | # So we have access to the formula |
|
| 298 | - | - name: Fetch homebrew formulae |
|
| 299 | - | uses: actions/download-artifact@v7 |
|
| 300 | - | with: |
|
| 301 | - | pattern: artifacts-* |
|
| 302 | - | path: Formula/ |
|
| 303 | - | merge-multiple: true |
|
| 304 | - | # This is extra complex because you can make your Formula name not match your app name |
|
| 305 | - | # so we need to find releases with a *.rb file, and publish with that filename. |
|
| 306 | - | - name: Commit formula files |
|
| 307 | - | run: | |
|
| 308 | - | git config --global user.name "${GITHUB_USER}" |
|
| 309 | - | git config --global user.email "${GITHUB_EMAIL}" |
|
| 310 | - | ||
| 311 | - | for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do |
|
| 312 | - | filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) |
|
| 313 | - | name=$(echo "$filename" | sed "s/\.rb$//") |
|
| 314 | - | version=$(echo "$release" | jq .app_version --raw-output) |
|
| 315 | - | ||
| 316 | - | export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" |
|
| 317 | - | brew update |
|
| 318 | - | # We avoid reformatting user-provided data such as the app description and homepage. |
|
| 319 | - | brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true |
|
| 320 | - | ||
| 321 | - | git add "Formula/${filename}" |
|
| 322 | - | git commit -m "${name} ${version}" |
|
| 323 | - | done |
|
| 324 | - | git push |
|
| 325 | - | ||
| 326 | - | announce: |
|
| 327 | - | needs: |
|
| 328 | - | - plan |
|
| 329 | - | - host |
|
| 330 | - | - publish-homebrew-formula |
|
| 331 | - | # use "always() && ..." to allow us to wait for all publish jobs while |
|
| 332 | - | # still allowing individual publish jobs to skip themselves (for prereleases). |
|
| 333 | - | # "host" however must run to completion, no skipping allowed! |
|
| 334 | - | if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} |
|
| 335 | - | runs-on: "ubuntu-22.04" |
|
| 336 | - | env: |
|
| 337 | - | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 338 | - | steps: |
|
| 339 | - | - uses: actions/checkout@v6 |
|
| 340 | - | with: |
|
| 341 | - | persist-credentials: false |
|
| 342 | - | submodules: recursive |
| 1 | - | # This file is automatically @generated by Cargo. |
|
| 2 | - | # It is not intended for manual editing. |
|
| 3 | - | version = 4 |
|
| 4 | - | ||
| 5 | - | [[package]] |
|
| 6 | - | name = "adler2" |
|
| 7 | - | version = "2.0.1" |
|
| 8 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 9 | - | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" |
|
| 10 | - | ||
| 11 | - | [[package]] |
|
| 12 | - | name = "aes" |
|
| 13 | - | version = "0.8.4" |
|
| 14 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 15 | - | checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" |
|
| 16 | - | dependencies = [ |
|
| 17 | - | "cfg-if", |
|
| 18 | - | "cipher", |
|
| 19 | - | "cpufeatures 0.2.17", |
|
| 20 | - | ] |
|
| 21 | - | ||
| 22 | - | [[package]] |
|
| 23 | - | name = "aho-corasick" |
|
| 24 | - | version = "1.1.4" |
|
| 25 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 26 | - | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" |
|
| 27 | - | dependencies = [ |
|
| 28 | - | "memchr", |
|
| 29 | - | ] |
|
| 30 | - | ||
| 31 | - | [[package]] |
|
| 32 | - | name = "aligned" |
|
| 33 | - | version = "0.4.3" |
|
| 34 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 35 | - | checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" |
|
| 36 | - | dependencies = [ |
|
| 37 | - | "as-slice", |
|
| 38 | - | ] |
|
| 39 | - | ||
| 40 | - | [[package]] |
|
| 41 | - | name = "aligned-vec" |
|
| 42 | - | version = "0.6.4" |
|
| 43 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 44 | - | checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" |
|
| 45 | - | dependencies = [ |
|
| 46 | - | "equator", |
|
| 47 | - | ] |
|
| 48 | - | ||
| 49 | - | [[package]] |
|
| 50 | - | name = "allocator-api2" |
|
| 51 | - | version = "0.2.21" |
|
| 52 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 53 | - | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" |
|
| 54 | - | ||
| 55 | - | [[package]] |
|
| 56 | - | name = "android_system_properties" |
|
| 57 | - | version = "0.1.5" |
|
| 58 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 59 | - | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" |
|
| 60 | - | dependencies = [ |
|
| 61 | - | "libc", |
|
| 62 | - | ] |
|
| 63 | - | ||
| 64 | - | [[package]] |
|
| 65 | - | name = "andromeda-auth" |
|
| 66 | - | version = "0.1.0" |
|
| 67 | - | dependencies = [ |
|
| 68 | - | "axum", |
|
| 69 | - | "rand 0.8.5", |
|
| 70 | - | "subtle", |
|
| 71 | - | ] |
|
| 72 | - | ||
| 73 | - | [[package]] |
|
| 74 | - | name = "andromeda-darkmatter-css" |
|
| 75 | - | version = "0.1.0" |
|
| 76 | - | dependencies = [ |
|
| 77 | - | "axum", |
|
| 78 | - | "rust-embed", |
|
| 79 | - | ] |
|
| 80 | - | ||
| 81 | - | [[package]] |
|
| 82 | - | name = "andromeda-db" |
|
| 83 | - | version = "0.1.0" |
|
| 84 | - | dependencies = [ |
|
| 85 | - | "axum", |
|
| 86 | - | "rusqlite", |
|
| 87 | - | "serde", |
|
| 88 | - | "tracing", |
|
| 89 | - | ] |
|
| 90 | - | ||
| 91 | - | [[package]] |
|
| 92 | - | name = "anstream" |
|
| 93 | - | version = "1.0.0" |
|
| 94 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 95 | - | checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" |
|
| 96 | - | dependencies = [ |
|
| 97 | - | "anstyle", |
|
| 98 | - | "anstyle-parse", |
|
| 99 | - | "anstyle-query", |
|
| 100 | - | "anstyle-wincon", |
|
| 101 | - | "colorchoice", |
|
| 102 | - | "is_terminal_polyfill", |
|
| 103 | - | "utf8parse", |
|
| 104 | - | ] |
|
| 105 | - | ||
| 106 | - | [[package]] |
|
| 107 | - | name = "anstyle" |
|
| 108 | - | version = "1.0.14" |
|
| 109 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 110 | - | checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" |
|
| 111 | - | ||
| 112 | - | [[package]] |
|
| 113 | - | name = "anstyle-parse" |
|
| 114 | - | version = "1.0.0" |
|
| 115 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 116 | - | checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" |
|
| 117 | - | dependencies = [ |
|
| 118 | - | "utf8parse", |
|
| 119 | - | ] |
|
| 120 | - | ||
| 121 | - | [[package]] |
|
| 122 | - | name = "anstyle-query" |
|
| 123 | - | version = "1.1.5" |
|
| 124 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 125 | - | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" |
|
| 126 | - | dependencies = [ |
|
| 127 | - | "windows-sys 0.61.2", |
|
| 128 | - | ] |
|
| 129 | - | ||
| 130 | - | [[package]] |
|
| 131 | - | name = "anstyle-wincon" |
|
| 132 | - | version = "3.0.11" |
|
| 133 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 134 | - | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" |
|
| 135 | - | dependencies = [ |
|
| 136 | - | "anstyle", |
|
| 137 | - | "once_cell_polyfill", |
|
| 138 | - | "windows-sys 0.61.2", |
|
| 139 | - | ] |
|
| 140 | - | ||
| 141 | - | [[package]] |
|
| 142 | - | name = "anyhow" |
|
| 143 | - | version = "1.0.102" |
|
| 144 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 145 | - | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" |
|
| 146 | - | ||
| 147 | - | [[package]] |
|
| 148 | - | name = "arbitrary" |
|
| 149 | - | version = "1.4.2" |
|
| 150 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 151 | - | checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" |
|
| 152 | - | dependencies = [ |
|
| 153 | - | "derive_arbitrary", |
|
| 154 | - | ] |
|
| 155 | - | ||
| 156 | - | [[package]] |
|
| 157 | - | name = "arboard" |
|
| 158 | - | version = "3.6.1" |
|
| 159 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 160 | - | checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" |
|
| 161 | - | dependencies = [ |
|
| 162 | - | "clipboard-win", |
|
| 163 | - | "image", |
|
| 164 | - | "log", |
|
| 165 | - | "objc2", |
|
| 166 | - | "objc2-app-kit", |
|
| 167 | - | "objc2-core-foundation", |
|
| 168 | - | "objc2-core-graphics", |
|
| 169 | - | "objc2-foundation", |
|
| 170 | - | "parking_lot", |
|
| 171 | - | "percent-encoding", |
|
| 172 | - | "windows-sys 0.60.2", |
|
| 173 | - | "x11rb", |
|
| 174 | - | ] |
|
| 175 | - | ||
| 176 | - | [[package]] |
|
| 177 | - | name = "arg_enum_proc_macro" |
|
| 178 | - | version = "0.3.4" |
|
| 179 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 180 | - | checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" |
|
| 181 | - | dependencies = [ |
|
| 182 | - | "proc-macro2", |
|
| 183 | - | "quote", |
|
| 184 | - | "syn 2.0.117", |
|
| 185 | - | ] |
|
| 186 | - | ||
| 187 | - | [[package]] |
|
| 188 | - | name = "arrayvec" |
|
| 189 | - | version = "0.7.6" |
|
| 190 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 191 | - | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" |
|
| 192 | - | ||
| 193 | - | [[package]] |
|
| 194 | - | name = "as-slice" |
|
| 195 | - | version = "0.2.1" |
|
| 196 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 197 | - | checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" |
|
| 198 | - | dependencies = [ |
|
| 199 | - | "stable_deref_trait", |
|
| 200 | - | ] |
|
| 201 | - | ||
| 202 | - | [[package]] |
|
| 203 | - | name = "askama" |
|
| 204 | - | version = "0.12.1" |
|
| 205 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 206 | - | checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" |
|
| 207 | - | dependencies = [ |
|
| 208 | - | "askama_derive 0.12.5", |
|
| 209 | - | "askama_escape", |
|
| 210 | - | "humansize", |
|
| 211 | - | "num-traits", |
|
| 212 | - | "percent-encoding", |
|
| 213 | - | ] |
|
| 214 | - | ||
| 215 | - | [[package]] |
|
| 216 | - | name = "askama" |
|
| 217 | - | version = "0.13.1" |
|
| 218 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 219 | - | checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" |
|
| 220 | - | dependencies = [ |
|
| 221 | - | "askama_derive 0.13.1", |
|
| 222 | - | "itoa", |
|
| 223 | - | "percent-encoding", |
|
| 224 | - | "serde", |
|
| 225 | - | "serde_json", |
|
| 226 | - | ] |
|
| 227 | - | ||
| 228 | - | [[package]] |
|
| 229 | - | name = "askama" |
|
| 230 | - | version = "0.15.6" |
|
| 231 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 232 | - | checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608" |
|
| 233 | - | dependencies = [ |
|
| 234 | - | "askama_macros", |
|
| 235 | - | "itoa", |
|
| 236 | - | "percent-encoding", |
|
| 237 | - | "serde", |
|
| 238 | - | "serde_json", |
|
| 239 | - | ] |
|
| 240 | - | ||
| 241 | - | [[package]] |
|
| 242 | - | name = "askama_axum" |
|
| 243 | - | version = "0.4.0" |
|
| 244 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 245 | - | checksum = "a41603f7cdbf5ac4af60760f17253eb6adf6ec5b6f14a7ed830cf687d375f163" |
|
| 246 | - | dependencies = [ |
|
| 247 | - | "askama 0.12.1", |
|
| 248 | - | "axum-core 0.4.5", |
|
| 249 | - | "http", |
|
| 250 | - | ] |
|
| 251 | - | ||
| 252 | - | [[package]] |
|
| 253 | - | name = "askama_derive" |
|
| 254 | - | version = "0.12.5" |
|
| 255 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 256 | - | checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" |
|
| 257 | - | dependencies = [ |
|
| 258 | - | "askama_parser 0.2.1", |
|
| 259 | - | "basic-toml", |
|
| 260 | - | "mime", |
|
| 261 | - | "mime_guess", |
|
| 262 | - | "proc-macro2", |
|
| 263 | - | "quote", |
|
| 264 | - | "serde", |
|
| 265 | - | "syn 2.0.117", |
|
| 266 | - | ] |
|
| 267 | - | ||
| 268 | - | [[package]] |
|
| 269 | - | name = "askama_derive" |
|
| 270 | - | version = "0.13.1" |
|
| 271 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 272 | - | checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" |
|
| 273 | - | dependencies = [ |
|
| 274 | - | "askama_parser 0.13.0", |
|
| 275 | - | "basic-toml", |
|
| 276 | - | "memchr", |
|
| 277 | - | "proc-macro2", |
|
| 278 | - | "quote", |
|
| 279 | - | "rustc-hash", |
|
| 280 | - | "serde", |
|
| 281 | - | "serde_derive", |
|
| 282 | - | "syn 2.0.117", |
|
| 283 | - | ] |
|
| 284 | - | ||
| 285 | - | [[package]] |
|
| 286 | - | name = "askama_derive" |
|
| 287 | - | version = "0.15.6" |
|
| 288 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 289 | - | checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c" |
|
| 290 | - | dependencies = [ |
|
| 291 | - | "askama_parser 0.15.6", |
|
| 292 | - | "basic-toml", |
|
| 293 | - | "memchr", |
|
| 294 | - | "proc-macro2", |
|
| 295 | - | "quote", |
|
| 296 | - | "rustc-hash", |
|
| 297 | - | "serde", |
|
| 298 | - | "serde_derive", |
|
| 299 | - | "syn 2.0.117", |
|
| 300 | - | ] |
|
| 301 | - | ||
| 302 | - | [[package]] |
|
| 303 | - | name = "askama_escape" |
|
| 304 | - | version = "0.10.3" |
|
| 305 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 306 | - | checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" |
|
| 307 | - | ||
| 308 | - | [[package]] |
|
| 309 | - | name = "askama_macros" |
|
| 310 | - | version = "0.15.6" |
|
| 311 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 312 | - | checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f" |
|
| 313 | - | dependencies = [ |
|
| 314 | - | "askama_derive 0.15.6", |
|
| 315 | - | ] |
|
| 316 | - | ||
| 317 | - | [[package]] |
|
| 318 | - | name = "askama_parser" |
|
| 319 | - | version = "0.2.1" |
|
| 320 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 321 | - | checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" |
|
| 322 | - | dependencies = [ |
|
| 323 | - | "nom 7.1.3", |
|
| 324 | - | ] |
|
| 325 | - | ||
| 326 | - | [[package]] |
|
| 327 | - | name = "askama_parser" |
|
| 328 | - | version = "0.13.0" |
|
| 329 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 330 | - | checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" |
|
| 331 | - | dependencies = [ |
|
| 332 | - | "memchr", |
|
| 333 | - | "serde", |
|
| 334 | - | "serde_derive", |
|
| 335 | - | "winnow 0.7.15", |
|
| 336 | - | ] |
|
| 337 | - | ||
| 338 | - | [[package]] |
|
| 339 | - | name = "askama_parser" |
|
| 340 | - | version = "0.15.6" |
|
| 341 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 342 | - | checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868" |
|
| 343 | - | dependencies = [ |
|
| 344 | - | "rustc-hash", |
|
| 345 | - | "serde", |
|
| 346 | - | "serde_derive", |
|
| 347 | - | "unicode-ident", |
|
| 348 | - | "winnow 1.0.1", |
|
| 349 | - | ] |
|
| 350 | - | ||
| 351 | - | [[package]] |
|
| 352 | - | name = "askama_web" |
|
| 353 | - | version = "0.15.2" |
|
| 354 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 355 | - | checksum = "7eb6d818ce4fb74822f2676eb0047daf25a8b2cb88f0c9fe8ca690170a6cb6cd" |
|
| 356 | - | dependencies = [ |
|
| 357 | - | "askama 0.15.6", |
|
| 358 | - | "askama_web_derive", |
|
| 359 | - | "axum-core 0.5.6", |
|
| 360 | - | "bytes", |
|
| 361 | - | "http", |
|
| 362 | - | ] |
|
| 363 | - | ||
| 364 | - | [[package]] |
|
| 365 | - | name = "askama_web_derive" |
|
| 366 | - | version = "0.2.0" |
|
| 367 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 368 | - | checksum = "9767c17d33a63daf6da5872ffaf2ab0c289cd73ce7ed4f41d5ddf9149c004873" |
|
| 369 | - | dependencies = [ |
|
| 370 | - | "proc-macro2", |
|
| 371 | - | "quote", |
|
| 372 | - | "syn 2.0.117", |
|
| 373 | - | ] |
|
| 374 | - | ||
| 375 | - | [[package]] |
|
| 376 | - | name = "async-trait" |
|
| 377 | - | version = "0.1.89" |
|
| 378 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 379 | - | checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" |
|
| 380 | - | dependencies = [ |
|
| 381 | - | "proc-macro2", |
|
| 382 | - | "quote", |
|
| 383 | - | "syn 2.0.117", |
|
| 384 | - | ] |
|
| 385 | - | ||
| 386 | - | [[package]] |
|
| 387 | - | name = "atomic" |
|
| 388 | - | version = "0.6.1" |
|
| 389 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 390 | - | checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" |
|
| 391 | - | dependencies = [ |
|
| 392 | - | "bytemuck", |
|
| 393 | - | ] |
|
| 394 | - | ||
| 395 | - | [[package]] |
|
| 396 | - | name = "atomic-waker" |
|
| 397 | - | version = "1.1.2" |
|
| 398 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 399 | - | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" |
|
| 400 | - | ||
| 401 | - | [[package]] |
|
| 402 | - | name = "autocfg" |
|
| 403 | - | version = "1.5.0" |
|
| 404 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 405 | - | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" |
|
| 406 | - | ||
| 407 | - | [[package]] |
|
| 408 | - | name = "av-scenechange" |
|
| 409 | - | version = "0.14.1" |
|
| 410 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 411 | - | checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" |
|
| 412 | - | dependencies = [ |
|
| 413 | - | "aligned", |
|
| 414 | - | "anyhow", |
|
| 415 | - | "arg_enum_proc_macro", |
|
| 416 | - | "arrayvec", |
|
| 417 | - | "log", |
|
| 418 | - | "num-rational", |
|
| 419 | - | "num-traits", |
|
| 420 | - | "pastey", |
|
| 421 | - | "rayon", |
|
| 422 | - | "thiserror 2.0.18", |
|
| 423 | - | "v_frame", |
|
| 424 | - | "y4m", |
|
| 425 | - | ] |
|
| 426 | - | ||
| 427 | - | [[package]] |
|
| 428 | - | name = "av1-grain" |
|
| 429 | - | version = "0.2.5" |
|
| 430 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 431 | - | checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" |
|
| 432 | - | dependencies = [ |
|
| 433 | - | "anyhow", |
|
| 434 | - | "arrayvec", |
|
| 435 | - | "log", |
|
| 436 | - | "nom 8.0.0", |
|
| 437 | - | "num-rational", |
|
| 438 | - | "v_frame", |
|
| 439 | - | ] |
|
| 440 | - | ||
| 441 | - | [[package]] |
|
| 442 | - | name = "avif-serialize" |
|
| 443 | - | version = "0.8.8" |
|
| 444 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 445 | - | checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" |
|
| 446 | - | dependencies = [ |
|
| 447 | - | "arrayvec", |
|
| 448 | - | ] |
|
| 449 | - | ||
| 450 | - | [[package]] |
|
| 451 | - | name = "aws-lc-rs" |
|
| 452 | - | version = "1.16.2" |
|
| 453 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 454 | - | checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" |
|
| 455 | - | dependencies = [ |
|
| 456 | - | "aws-lc-sys", |
|
| 457 | - | "zeroize", |
|
| 458 | - | ] |
|
| 459 | - | ||
| 460 | - | [[package]] |
|
| 461 | - | name = "aws-lc-sys" |
|
| 462 | - | version = "0.39.1" |
|
| 463 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 464 | - | checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" |
|
| 465 | - | dependencies = [ |
|
| 466 | - | "cc", |
|
| 467 | - | "cmake", |
|
| 468 | - | "dunce", |
|
| 469 | - | "fs_extra", |
|
| 470 | - | ] |
|
| 471 | - | ||
| 472 | - | [[package]] |
|
| 473 | - | name = "axum" |
|
| 474 | - | version = "0.8.8" |
|
| 475 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 476 | - | checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" |
|
| 477 | - | dependencies = [ |
|
| 478 | - | "axum-core 0.5.6", |
|
| 479 | - | "bytes", |
|
| 480 | - | "form_urlencoded", |
|
| 481 | - | "futures-util", |
|
| 482 | - | "http", |
|
| 483 | - | "http-body", |
|
| 484 | - | "http-body-util", |
|
| 485 | - | "hyper", |
|
| 486 | - | "hyper-util", |
|
| 487 | - | "itoa", |
|
| 488 | - | "matchit", |
|
| 489 | - | "memchr", |
|
| 490 | - | "mime", |
|
| 491 | - | "multer", |
|
| 492 | - | "percent-encoding", |
|
| 493 | - | "pin-project-lite", |
|
| 494 | - | "serde_core", |
|
| 495 | - | "serde_json", |
|
| 496 | - | "serde_path_to_error", |
|
| 497 | - | "serde_urlencoded", |
|
| 498 | - | "sync_wrapper", |
|
| 499 | - | "tokio", |
|
| 500 | - | "tower", |
|
| 501 | - | "tower-layer", |
|
| 502 | - | "tower-service", |
|
| 503 | - | "tracing", |
|
| 504 | - | ] |
|
| 505 | - | ||
| 506 | - | [[package]] |
|
| 507 | - | name = "axum-core" |
|
| 508 | - | version = "0.4.5" |
|
| 509 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 510 | - | checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" |
|
| 511 | - | dependencies = [ |
|
| 512 | - | "async-trait", |
|
| 513 | - | "bytes", |
|
| 514 | - | "futures-util", |
|
| 515 | - | "http", |
|
| 516 | - | "http-body", |
|
| 517 | - | "http-body-util", |
|
| 518 | - | "mime", |
|
| 519 | - | "pin-project-lite", |
|
| 520 | - | "rustversion", |
|
| 521 | - | "sync_wrapper", |
|
| 522 | - | "tower-layer", |
|
| 523 | - | "tower-service", |
|
| 524 | - | ] |
|
| 525 | - | ||
| 526 | - | [[package]] |
|
| 527 | - | name = "axum-core" |
|
| 528 | - | version = "0.5.6" |
|
| 529 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 530 | - | checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" |
|
| 531 | - | dependencies = [ |
|
| 532 | - | "bytes", |
|
| 533 | - | "futures-core", |
|
| 534 | - | "http", |
|
| 535 | - | "http-body", |
|
| 536 | - | "http-body-util", |
|
| 537 | - | "mime", |
|
| 538 | - | "pin-project-lite", |
|
| 539 | - | "sync_wrapper", |
|
| 540 | - | "tower-layer", |
|
| 541 | - | "tower-service", |
|
| 542 | - | "tracing", |
|
| 543 | - | ] |
|
| 544 | - | ||
| 545 | - | [[package]] |
|
| 546 | - | name = "base64" |
|
| 547 | - | version = "0.22.1" |
|
| 548 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 549 | - | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
|
| 550 | - | ||
| 551 | - | [[package]] |
|
| 552 | - | name = "basic-toml" |
|
| 553 | - | version = "0.1.10" |
|
| 554 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 555 | - | checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" |
|
| 556 | - | dependencies = [ |
|
| 557 | - | "serde", |
|
| 558 | - | ] |
|
| 559 | - | ||
| 560 | - | [[package]] |
|
| 561 | - | name = "bincode" |
|
| 562 | - | version = "1.3.3" |
|
| 563 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 564 | - | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" |
|
| 565 | - | dependencies = [ |
|
| 566 | - | "serde", |
|
| 567 | - | ] |
|
| 568 | - | ||
| 569 | - | [[package]] |
|
| 570 | - | name = "bit-set" |
|
| 571 | - | version = "0.5.3" |
|
| 572 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 573 | - | checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" |
|
| 574 | - | dependencies = [ |
|
| 575 | - | "bit-vec", |
|
| 576 | - | ] |
|
| 577 | - | ||
| 578 | - | [[package]] |
|
| 579 | - | name = "bit-vec" |
|
| 580 | - | version = "0.6.3" |
|
| 581 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 582 | - | checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" |
|
| 583 | - | ||
| 584 | - | [[package]] |
|
| 585 | - | name = "bit_field" |
|
| 586 | - | version = "0.10.3" |
|
| 587 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 588 | - | checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" |
|
| 589 | - | ||
| 590 | - | [[package]] |
|
| 591 | - | name = "bitflags" |
|
| 592 | - | version = "1.3.2" |
|
| 593 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 594 | - | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" |
|
| 595 | - | ||
| 596 | - | [[package]] |
|
| 597 | - | name = "bitflags" |
|
| 598 | - | version = "2.11.0" |
|
| 599 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 600 | - | checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" |
|
| 601 | - | ||
| 602 | - | [[package]] |
|
| 603 | - | name = "bitstream-io" |
|
| 604 | - | version = "4.9.0" |
|
| 605 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 606 | - | checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" |
|
| 607 | - | dependencies = [ |
|
| 608 | - | "core2", |
|
| 609 | - | ] |
|
| 610 | - | ||
| 611 | - | [[package]] |
|
| 612 | - | name = "block-buffer" |
|
| 613 | - | version = "0.10.4" |
|
| 614 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 615 | - | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" |
|
| 616 | - | dependencies = [ |
|
| 617 | - | "generic-array", |
|
| 618 | - | ] |
|
| 619 | - | ||
| 620 | - | [[package]] |
|
| 621 | - | name = "block-buffer" |
|
| 622 | - | version = "0.12.0" |
|
| 623 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 624 | - | checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" |
|
| 625 | - | dependencies = [ |
|
| 626 | - | "hybrid-array", |
|
| 627 | - | ] |
|
| 628 | - | ||
| 629 | - | [[package]] |
|
| 630 | - | name = "bookmarks" |
|
| 631 | - | version = "0.1.0" |
|
| 632 | - | dependencies = [ |
|
| 633 | - | "andromeda-auth", |
|
| 634 | - | "andromeda-darkmatter-css", |
|
| 635 | - | "andromeda-db", |
|
| 636 | - | "askama 0.13.1", |
|
| 637 | - | "axum", |
|
| 638 | - | "chrono", |
|
| 639 | - | "dotenvy", |
|
| 640 | - | "mime_guess", |
|
| 641 | - | "nanoid", |
|
| 642 | - | "rand 0.8.5", |
|
| 643 | - | "reqwest 0.12.28", |
|
| 644 | - | "rusqlite", |
|
| 645 | - | "rust-embed", |
|
| 646 | - | "scraper", |
|
| 647 | - | "serde", |
|
| 648 | - | "serde_json", |
|
| 649 | - | "subtle", |
|
| 650 | - | "tokio", |
|
| 651 | - | "tracing", |
|
| 652 | - | "tracing-subscriber", |
|
| 653 | - | "url", |
|
| 654 | - | ] |
|
| 655 | - | ||
| 656 | - | [[package]] |
|
| 657 | - | name = "built" |
|
| 658 | - | version = "0.8.0" |
|
| 659 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 660 | - | checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" |
|
| 661 | - | ||
| 662 | - | [[package]] |
|
| 663 | - | name = "bumpalo" |
|
| 664 | - | version = "3.20.2" |
|
| 665 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 666 | - | checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" |
|
| 667 | - | ||
| 668 | - | [[package]] |
|
| 669 | - | name = "bytemuck" |
|
| 670 | - | version = "1.25.0" |
|
| 671 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 672 | - | checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" |
|
| 673 | - | ||
| 674 | - | [[package]] |
|
| 675 | - | name = "byteorder" |
|
| 676 | - | version = "1.5.0" |
|
| 677 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 678 | - | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" |
|
| 679 | - | ||
| 680 | - | [[package]] |
|
| 681 | - | name = "byteorder-lite" |
|
| 682 | - | version = "0.1.0" |
|
| 683 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 684 | - | checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" |
|
| 685 | - | ||
| 686 | - | [[package]] |
|
| 687 | - | name = "bytes" |
|
| 688 | - | version = "1.11.1" |
|
| 689 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 690 | - | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" |
|
| 691 | - | ||
| 692 | - | [[package]] |
|
| 693 | - | name = "bzip2" |
|
| 694 | - | version = "0.5.2" |
|
| 695 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 696 | - | checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" |
|
| 697 | - | dependencies = [ |
|
| 698 | - | "bzip2-sys", |
|
| 699 | - | ] |
|
| 700 | - | ||
| 701 | - | [[package]] |
|
| 702 | - | name = "bzip2-sys" |
|
| 703 | - | version = "0.1.13+1.0.8" |
|
| 704 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 705 | - | checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" |
|
| 706 | - | dependencies = [ |
|
| 707 | - | "cc", |
|
| 708 | - | "pkg-config", |
|
| 709 | - | ] |
|
| 710 | - | ||
| 711 | - | [[package]] |
|
| 712 | - | name = "castaway" |
|
| 713 | - | version = "0.2.4" |
|
| 714 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 715 | - | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" |
|
| 716 | - | dependencies = [ |
|
| 717 | - | "rustversion", |
|
| 718 | - | ] |
|
| 719 | - | ||
| 720 | - | [[package]] |
|
| 721 | - | name = "cc" |
|
| 722 | - | version = "1.2.58" |
|
| 723 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 724 | - | checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" |
|
| 725 | - | dependencies = [ |
|
| 726 | - | "find-msvc-tools", |
|
| 727 | - | "jobserver", |
|
| 728 | - | "libc", |
|
| 729 | - | "shlex", |
|
| 730 | - | ] |
|
| 731 | - | ||
| 732 | - | [[package]] |
|
| 733 | - | name = "cellar" |
|
| 734 | - | version = "0.2.2" |
|
| 735 | - | dependencies = [ |
|
| 736 | - | "andromeda-auth", |
|
| 737 | - | "andromeda-darkmatter-css", |
|
| 738 | - | "andromeda-db", |
|
| 739 | - | "askama 0.15.6", |
|
| 740 | - | "askama_web", |
|
| 741 | - | "axum", |
|
| 742 | - | "base64", |
|
| 743 | - | "chrono", |
|
| 744 | - | "dotenvy", |
|
| 745 | - | "image", |
|
| 746 | - | "nanoid", |
|
| 747 | - | "rand 0.8.5", |
|
| 748 | - | "reqwest 0.12.28", |
|
| 749 | - | "rusqlite", |
|
| 750 | - | "rust-embed", |
|
| 751 | - | "serde", |
|
| 752 | - | "serde_json", |
|
| 753 | - | "serde_rusqlite", |
|
| 754 | - | "subtle", |
|
| 755 | - | "tokio", |
|
| 756 | - | "tower-http", |
|
| 757 | - | "tracing", |
|
| 758 | - | "tracing-subscriber", |
|
| 759 | - | ] |
|
| 760 | - | ||
| 761 | - | [[package]] |
|
| 762 | - | name = "cesu8" |
|
| 763 | - | version = "1.1.0" |
|
| 764 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 765 | - | checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" |
|
| 766 | - | ||
| 767 | - | [[package]] |
|
| 768 | - | name = "cfg-if" |
|
| 769 | - | version = "1.0.4" |
|
| 770 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 771 | - | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" |
|
| 772 | - | ||
| 773 | - | [[package]] |
|
| 774 | - | name = "cfg_aliases" |
|
| 775 | - | version = "0.2.1" |
|
| 776 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 777 | - | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" |
|
| 778 | - | ||
| 779 | - | [[package]] |
|
| 780 | - | name = "chrono" |
|
| 781 | - | version = "0.4.44" |
|
| 782 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 783 | - | checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" |
|
| 784 | - | dependencies = [ |
|
| 785 | - | "iana-time-zone", |
|
| 786 | - | "js-sys", |
|
| 787 | - | "num-traits", |
|
| 788 | - | "serde", |
|
| 789 | - | "wasm-bindgen", |
|
| 790 | - | "windows-link", |
|
| 791 | - | ] |
|
| 792 | - | ||
| 793 | - | [[package]] |
|
| 794 | - | name = "chrono-tz" |
|
| 795 | - | version = "0.10.4" |
|
| 796 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 797 | - | checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" |
|
| 798 | - | dependencies = [ |
|
| 799 | - | "chrono", |
|
| 800 | - | "phf 0.12.1", |
|
| 801 | - | ] |
|
| 802 | - | ||
| 803 | - | [[package]] |
|
| 804 | - | name = "cipher" |
|
| 805 | - | version = "0.4.4" |
|
| 806 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 807 | - | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" |
|
| 808 | - | dependencies = [ |
|
| 809 | - | "crypto-common 0.1.7", |
|
| 810 | - | "inout", |
|
| 811 | - | ] |
|
| 812 | - | ||
| 813 | - | [[package]] |
|
| 814 | - | name = "clap" |
|
| 815 | - | version = "4.6.0" |
|
| 816 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 817 | - | checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" |
|
| 818 | - | dependencies = [ |
|
| 819 | - | "clap_builder", |
|
| 820 | - | "clap_derive", |
|
| 821 | - | ] |
|
| 822 | - | ||
| 823 | - | [[package]] |
|
| 824 | - | name = "clap_builder" |
|
| 825 | - | version = "4.6.0" |
|
| 826 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 827 | - | checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" |
|
| 828 | - | dependencies = [ |
|
| 829 | - | "anstream", |
|
| 830 | - | "anstyle", |
|
| 831 | - | "clap_lex", |
|
| 832 | - | "strsim", |
|
| 833 | - | ] |
|
| 834 | - | ||
| 835 | - | [[package]] |
|
| 836 | - | name = "clap_derive" |
|
| 837 | - | version = "4.6.0" |
|
| 838 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 839 | - | checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" |
|
| 840 | - | dependencies = [ |
|
| 841 | - | "heck", |
|
| 842 | - | "proc-macro2", |
|
| 843 | - | "quote", |
|
| 844 | - | "syn 2.0.117", |
|
| 845 | - | ] |
|
| 846 | - | ||
| 847 | - | [[package]] |
|
| 848 | - | name = "clap_lex" |
|
| 849 | - | version = "1.1.0" |
|
| 850 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 851 | - | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" |
|
| 852 | - | ||
| 853 | - | [[package]] |
|
| 854 | - | name = "clipboard-win" |
|
| 855 | - | version = "5.4.1" |
|
| 856 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 857 | - | checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" |
|
| 858 | - | dependencies = [ |
|
| 859 | - | "error-code", |
|
| 860 | - | ] |
|
| 861 | - | ||
| 862 | - | [[package]] |
|
| 863 | - | name = "cmake" |
|
| 864 | - | version = "0.1.58" |
|
| 865 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 866 | - | checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" |
|
| 867 | - | dependencies = [ |
|
| 868 | - | "cc", |
|
| 869 | - | ] |
|
| 870 | - | ||
| 871 | - | [[package]] |
|
| 872 | - | name = "cmov" |
|
| 873 | - | version = "0.5.3" |
|
| 874 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 875 | - | checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" |
|
| 876 | - | ||
| 877 | - | [[package]] |
|
| 878 | - | name = "color_quant" |
|
| 879 | - | version = "1.1.0" |
|
| 880 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 881 | - | checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" |
|
| 882 | - | ||
| 883 | - | [[package]] |
|
| 884 | - | name = "colorchoice" |
|
| 885 | - | version = "1.0.5" |
|
| 886 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 887 | - | checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" |
|
| 888 | - | ||
| 889 | - | [[package]] |
|
| 890 | - | name = "combine" |
|
| 891 | - | version = "4.6.7" |
|
| 892 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 893 | - | checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" |
|
| 894 | - | dependencies = [ |
|
| 895 | - | "bytes", |
|
| 896 | - | "memchr", |
|
| 897 | - | ] |
|
| 898 | - | ||
| 899 | - | [[package]] |
|
| 900 | - | name = "compact_str" |
|
| 901 | - | version = "0.9.0" |
|
| 902 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 903 | - | checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" |
|
| 904 | - | dependencies = [ |
|
| 905 | - | "castaway", |
|
| 906 | - | "cfg-if", |
|
| 907 | - | "itoa", |
|
| 908 | - | "rustversion", |
|
| 909 | - | "ryu", |
|
| 910 | - | "static_assertions", |
|
| 911 | - | ] |
|
| 912 | - | ||
| 913 | - | [[package]] |
|
| 914 | - | name = "const-oid" |
|
| 915 | - | version = "0.10.2" |
|
| 916 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 917 | - | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" |
|
| 918 | - | ||
| 919 | - | [[package]] |
|
| 920 | - | name = "constant_time_eq" |
|
| 921 | - | version = "0.3.1" |
|
| 922 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 923 | - | checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" |
|
| 924 | - | ||
| 925 | - | [[package]] |
|
| 926 | - | name = "convert_case" |
|
| 927 | - | version = "0.10.0" |
|
| 928 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 929 | - | checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" |
|
| 930 | - | dependencies = [ |
|
| 931 | - | "unicode-segmentation", |
|
| 932 | - | ] |
|
| 933 | - | ||
| 934 | - | [[package]] |
|
| 935 | - | name = "core-foundation" |
|
| 936 | - | version = "0.9.4" |
|
| 937 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 938 | - | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" |
|
| 939 | - | dependencies = [ |
|
| 940 | - | "core-foundation-sys", |
|
| 941 | - | "libc", |
|
| 942 | - | ] |
|
| 943 | - | ||
| 944 | - | [[package]] |
|
| 945 | - | name = "core-foundation" |
|
| 946 | - | version = "0.10.1" |
|
| 947 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 948 | - | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" |
|
| 949 | - | dependencies = [ |
|
| 950 | - | "core-foundation-sys", |
|
| 951 | - | "libc", |
|
| 952 | - | ] |
|
| 953 | - | ||
| 954 | - | [[package]] |
|
| 955 | - | name = "core-foundation-sys" |
|
| 956 | - | version = "0.8.7" |
|
| 957 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 958 | - | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" |
|
| 959 | - | ||
| 960 | - | [[package]] |
|
| 961 | - | name = "core2" |
|
| 962 | - | version = "0.4.0" |
|
| 963 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 964 | - | checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" |
|
| 965 | - | dependencies = [ |
|
| 966 | - | "memchr", |
|
| 967 | - | ] |
|
| 968 | - | ||
| 969 | - | [[package]] |
|
| 970 | - | name = "cpufeatures" |
|
| 971 | - | version = "0.2.17" |
|
| 972 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 973 | - | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" |
|
| 974 | - | dependencies = [ |
|
| 975 | - | "libc", |
|
| 976 | - | ] |
|
| 977 | - | ||
| 978 | - | [[package]] |
|
| 979 | - | name = "cpufeatures" |
|
| 980 | - | version = "0.3.0" |
|
| 981 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 982 | - | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" |
|
| 983 | - | dependencies = [ |
|
| 984 | - | "libc", |
|
| 985 | - | ] |
|
| 986 | - | ||
| 987 | - | [[package]] |
|
| 988 | - | name = "crc" |
|
| 989 | - | version = "3.4.0" |
|
| 990 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 991 | - | checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" |
|
| 992 | - | dependencies = [ |
|
| 993 | - | "crc-catalog", |
|
| 994 | - | ] |
|
| 995 | - | ||
| 996 | - | [[package]] |
|
| 997 | - | name = "crc-catalog" |
|
| 998 | - | version = "2.4.0" |
|
| 999 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1000 | - | checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" |
|
| 1001 | - | ||
| 1002 | - | [[package]] |
|
| 1003 | - | name = "crc32fast" |
|
| 1004 | - | version = "1.5.0" |
|
| 1005 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1006 | - | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" |
|
| 1007 | - | dependencies = [ |
|
| 1008 | - | "cfg-if", |
|
| 1009 | - | ] |
|
| 1010 | - | ||
| 1011 | - | [[package]] |
|
| 1012 | - | name = "crossbeam-deque" |
|
| 1013 | - | version = "0.8.6" |
|
| 1014 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1015 | - | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" |
|
| 1016 | - | dependencies = [ |
|
| 1017 | - | "crossbeam-epoch", |
|
| 1018 | - | "crossbeam-utils", |
|
| 1019 | - | ] |
|
| 1020 | - | ||
| 1021 | - | [[package]] |
|
| 1022 | - | name = "crossbeam-epoch" |
|
| 1023 | - | version = "0.9.18" |
|
| 1024 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1025 | - | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" |
|
| 1026 | - | dependencies = [ |
|
| 1027 | - | "crossbeam-utils", |
|
| 1028 | - | ] |
|
| 1029 | - | ||
| 1030 | - | [[package]] |
|
| 1031 | - | name = "crossbeam-utils" |
|
| 1032 | - | version = "0.8.21" |
|
| 1033 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1034 | - | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" |
|
| 1035 | - | ||
| 1036 | - | [[package]] |
|
| 1037 | - | name = "crossterm" |
|
| 1038 | - | version = "0.29.0" |
|
| 1039 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1040 | - | checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" |
|
| 1041 | - | dependencies = [ |
|
| 1042 | - | "bitflags 2.11.0", |
|
| 1043 | - | "crossterm_winapi", |
|
| 1044 | - | "derive_more 2.1.1", |
|
| 1045 | - | "document-features", |
|
| 1046 | - | "mio", |
|
| 1047 | - | "parking_lot", |
|
| 1048 | - | "rustix", |
|
| 1049 | - | "signal-hook", |
|
| 1050 | - | "signal-hook-mio", |
|
| 1051 | - | "winapi", |
|
| 1052 | - | ] |
|
| 1053 | - | ||
| 1054 | - | [[package]] |
|
| 1055 | - | name = "crossterm_winapi" |
|
| 1056 | - | version = "0.9.1" |
|
| 1057 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1058 | - | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" |
|
| 1059 | - | dependencies = [ |
|
| 1060 | - | "winapi", |
|
| 1061 | - | ] |
|
| 1062 | - | ||
| 1063 | - | [[package]] |
|
| 1064 | - | name = "crunchy" |
|
| 1065 | - | version = "0.2.4" |
|
| 1066 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1067 | - | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" |
|
| 1068 | - | ||
| 1069 | - | [[package]] |
|
| 1070 | - | name = "crypto-common" |
|
| 1071 | - | version = "0.1.7" |
|
| 1072 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1073 | - | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" |
|
| 1074 | - | dependencies = [ |
|
| 1075 | - | "generic-array", |
|
| 1076 | - | "typenum", |
|
| 1077 | - | ] |
|
| 1078 | - | ||
| 1079 | - | [[package]] |
|
| 1080 | - | name = "crypto-common" |
|
| 1081 | - | version = "0.2.1" |
|
| 1082 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1083 | - | checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" |
|
| 1084 | - | dependencies = [ |
|
| 1085 | - | "hybrid-array", |
|
| 1086 | - | ] |
|
| 1087 | - | ||
| 1088 | - | [[package]] |
|
| 1089 | - | name = "csscolorparser" |
|
| 1090 | - | version = "0.6.2" |
|
| 1091 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1092 | - | checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" |
|
| 1093 | - | dependencies = [ |
|
| 1094 | - | "lab", |
|
| 1095 | - | "phf 0.11.3", |
|
| 1096 | - | ] |
|
| 1097 | - | ||
| 1098 | - | [[package]] |
|
| 1099 | - | name = "cssparser" |
|
| 1100 | - | version = "0.34.0" |
|
| 1101 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1102 | - | checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" |
|
| 1103 | - | dependencies = [ |
|
| 1104 | - | "cssparser-macros", |
|
| 1105 | - | "dtoa-short", |
|
| 1106 | - | "itoa", |
|
| 1107 | - | "phf 0.11.3", |
|
| 1108 | - | "smallvec", |
|
| 1109 | - | ] |
|
| 1110 | - | ||
| 1111 | - | [[package]] |
|
| 1112 | - | name = "cssparser-macros" |
|
| 1113 | - | version = "0.6.1" |
|
| 1114 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1115 | - | checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" |
|
| 1116 | - | dependencies = [ |
|
| 1117 | - | "quote", |
|
| 1118 | - | "syn 2.0.117", |
|
| 1119 | - | ] |
|
| 1120 | - | ||
| 1121 | - | [[package]] |
|
| 1122 | - | name = "ctutils" |
|
| 1123 | - | version = "0.4.2" |
|
| 1124 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1125 | - | checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" |
|
| 1126 | - | dependencies = [ |
|
| 1127 | - | "cmov", |
|
| 1128 | - | ] |
|
| 1129 | - | ||
| 1130 | - | [[package]] |
|
| 1131 | - | name = "darling" |
|
| 1132 | - | version = "0.23.0" |
|
| 1133 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1134 | - | checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" |
|
| 1135 | - | dependencies = [ |
|
| 1136 | - | "darling_core", |
|
| 1137 | - | "darling_macro", |
|
| 1138 | - | ] |
|
| 1139 | - | ||
| 1140 | - | [[package]] |
|
| 1141 | - | name = "darling_core" |
|
| 1142 | - | version = "0.23.0" |
|
| 1143 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1144 | - | checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" |
|
| 1145 | - | dependencies = [ |
|
| 1146 | - | "ident_case", |
|
| 1147 | - | "proc-macro2", |
|
| 1148 | - | "quote", |
|
| 1149 | - | "strsim", |
|
| 1150 | - | "syn 2.0.117", |
|
| 1151 | - | ] |
|
| 1152 | - | ||
| 1153 | - | [[package]] |
|
| 1154 | - | name = "darling_macro" |
|
| 1155 | - | version = "0.23.0" |
|
| 1156 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1157 | - | checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" |
|
| 1158 | - | dependencies = [ |
|
| 1159 | - | "darling_core", |
|
| 1160 | - | "quote", |
|
| 1161 | - | "syn 2.0.117", |
|
| 1162 | - | ] |
|
| 1163 | - | ||
| 1164 | - | [[package]] |
|
| 1165 | - | name = "deflate64" |
|
| 1166 | - | version = "0.1.12" |
|
| 1167 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1168 | - | checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" |
|
| 1169 | - | ||
| 1170 | - | [[package]] |
|
| 1171 | - | name = "deltae" |
|
| 1172 | - | version = "0.3.2" |
|
| 1173 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1174 | - | checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" |
|
| 1175 | - | ||
| 1176 | - | [[package]] |
|
| 1177 | - | name = "deranged" |
|
| 1178 | - | version = "0.5.8" |
|
| 1179 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1180 | - | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" |
|
| 1181 | - | dependencies = [ |
|
| 1182 | - | "powerfmt", |
|
| 1183 | - | ] |
|
| 1184 | - | ||
| 1185 | - | [[package]] |
|
| 1186 | - | name = "derive_arbitrary" |
|
| 1187 | - | version = "1.4.2" |
|
| 1188 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1189 | - | checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" |
|
| 1190 | - | dependencies = [ |
|
| 1191 | - | "proc-macro2", |
|
| 1192 | - | "quote", |
|
| 1193 | - | "syn 2.0.117", |
|
| 1194 | - | ] |
|
| 1195 | - | ||
| 1196 | - | [[package]] |
|
| 1197 | - | name = "derive_more" |
|
| 1198 | - | version = "0.99.20" |
|
| 1199 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1200 | - | checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" |
|
| 1201 | - | dependencies = [ |
|
| 1202 | - | "proc-macro2", |
|
| 1203 | - | "quote", |
|
| 1204 | - | "syn 2.0.117", |
|
| 1205 | - | ] |
|
| 1206 | - | ||
| 1207 | - | [[package]] |
|
| 1208 | - | name = "derive_more" |
|
| 1209 | - | version = "2.1.1" |
|
| 1210 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1211 | - | checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" |
|
| 1212 | - | dependencies = [ |
|
| 1213 | - | "derive_more-impl", |
|
| 1214 | - | ] |
|
| 1215 | - | ||
| 1216 | - | [[package]] |
|
| 1217 | - | name = "derive_more-impl" |
|
| 1218 | - | version = "2.1.1" |
|
| 1219 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1220 | - | checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" |
|
| 1221 | - | dependencies = [ |
|
| 1222 | - | "convert_case", |
|
| 1223 | - | "proc-macro2", |
|
| 1224 | - | "quote", |
|
| 1225 | - | "rustc_version", |
|
| 1226 | - | "syn 2.0.117", |
|
| 1227 | - | ] |
|
| 1228 | - | ||
| 1229 | - | [[package]] |
|
| 1230 | - | name = "digest" |
|
| 1231 | - | version = "0.10.7" |
|
| 1232 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1233 | - | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" |
|
| 1234 | - | dependencies = [ |
|
| 1235 | - | "block-buffer 0.10.4", |
|
| 1236 | - | "crypto-common 0.1.7", |
|
| 1237 | - | "subtle", |
|
| 1238 | - | ] |
|
| 1239 | - | ||
| 1240 | - | [[package]] |
|
| 1241 | - | name = "digest" |
|
| 1242 | - | version = "0.11.3" |
|
| 1243 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1244 | - | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" |
|
| 1245 | - | dependencies = [ |
|
| 1246 | - | "block-buffer 0.12.0", |
|
| 1247 | - | "const-oid", |
|
| 1248 | - | "crypto-common 0.2.1", |
|
| 1249 | - | "ctutils", |
|
| 1250 | - | ] |
|
| 1251 | - | ||
| 1252 | - | [[package]] |
|
| 1253 | - | name = "dispatch2" |
|
| 1254 | - | version = "0.3.1" |
|
| 1255 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1256 | - | checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" |
|
| 1257 | - | dependencies = [ |
|
| 1258 | - | "bitflags 2.11.0", |
|
| 1259 | - | "objc2", |
|
| 1260 | - | ] |
|
| 1261 | - | ||
| 1262 | - | [[package]] |
|
| 1263 | - | name = "displaydoc" |
|
| 1264 | - | version = "0.2.5" |
|
| 1265 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1266 | - | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" |
|
| 1267 | - | dependencies = [ |
|
| 1268 | - | "proc-macro2", |
|
| 1269 | - | "quote", |
|
| 1270 | - | "syn 2.0.117", |
|
| 1271 | - | ] |
|
| 1272 | - | ||
| 1273 | - | [[package]] |
|
| 1274 | - | name = "document-features" |
|
| 1275 | - | version = "0.2.12" |
|
| 1276 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1277 | - | checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" |
|
| 1278 | - | dependencies = [ |
|
| 1279 | - | "litrs", |
|
| 1280 | - | ] |
|
| 1281 | - | ||
| 1282 | - | [[package]] |
|
| 1283 | - | name = "dotenvy" |
|
| 1284 | - | version = "0.15.7" |
|
| 1285 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1286 | - | checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" |
|
| 1287 | - | ||
| 1288 | - | [[package]] |
|
| 1289 | - | name = "dtoa" |
|
| 1290 | - | version = "1.0.11" |
|
| 1291 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1292 | - | checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" |
|
| 1293 | - | ||
| 1294 | - | [[package]] |
|
| 1295 | - | name = "dtoa-short" |
|
| 1296 | - | version = "0.3.5" |
|
| 1297 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1298 | - | checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" |
|
| 1299 | - | dependencies = [ |
|
| 1300 | - | "dtoa", |
|
| 1301 | - | ] |
|
| 1302 | - | ||
| 1303 | - | [[package]] |
|
| 1304 | - | name = "dunce" |
|
| 1305 | - | version = "1.0.5" |
|
| 1306 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1307 | - | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" |
|
| 1308 | - | ||
| 1309 | - | [[package]] |
|
| 1310 | - | name = "easel" |
|
| 1311 | - | version = "0.1.0" |
|
| 1312 | - | dependencies = [ |
|
| 1313 | - | "andromeda-darkmatter-css", |
|
| 1314 | - | "askama 0.13.1", |
|
| 1315 | - | "axum", |
|
| 1316 | - | "chrono", |
|
| 1317 | - | "chrono-tz", |
|
| 1318 | - | "dotenvy", |
|
| 1319 | - | "mime_guess", |
|
| 1320 | - | "rand 0.8.5", |
|
| 1321 | - | "reqwest 0.12.28", |
|
| 1322 | - | "rusqlite", |
|
| 1323 | - | "rust-embed", |
|
| 1324 | - | "serde", |
|
| 1325 | - | "serde_json", |
|
| 1326 | - | "tokio", |
|
| 1327 | - | "tower-http", |
|
| 1328 | - | "tracing", |
|
| 1329 | - | "tracing-subscriber", |
|
| 1330 | - | "urlencoding", |
|
| 1331 | - | ] |
|
| 1332 | - | ||
| 1333 | - | [[package]] |
|
| 1334 | - | name = "ego-tree" |
|
| 1335 | - | version = "0.10.0" |
|
| 1336 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1337 | - | checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" |
|
| 1338 | - | ||
| 1339 | - | [[package]] |
|
| 1340 | - | name = "either" |
|
| 1341 | - | version = "1.15.0" |
|
| 1342 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1343 | - | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" |
|
| 1344 | - | ||
| 1345 | - | [[package]] |
|
| 1346 | - | name = "encoding_rs" |
|
| 1347 | - | version = "0.8.35" |
|
| 1348 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1349 | - | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" |
|
| 1350 | - | dependencies = [ |
|
| 1351 | - | "cfg-if", |
|
| 1352 | - | ] |
|
| 1353 | - | ||
| 1354 | - | [[package]] |
|
| 1355 | - | name = "equator" |
|
| 1356 | - | version = "0.4.2" |
|
| 1357 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1358 | - | checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" |
|
| 1359 | - | dependencies = [ |
|
| 1360 | - | "equator-macro", |
|
| 1361 | - | ] |
|
| 1362 | - | ||
| 1363 | - | [[package]] |
|
| 1364 | - | name = "equator-macro" |
|
| 1365 | - | version = "0.4.2" |
|
| 1366 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1367 | - | checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" |
|
| 1368 | - | dependencies = [ |
|
| 1369 | - | "proc-macro2", |
|
| 1370 | - | "quote", |
|
| 1371 | - | "syn 2.0.117", |
|
| 1372 | - | ] |
|
| 1373 | - | ||
| 1374 | - | [[package]] |
|
| 1375 | - | name = "equivalent" |
|
| 1376 | - | version = "1.0.2" |
|
| 1377 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1378 | - | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" |
|
| 1379 | - | ||
| 1380 | - | [[package]] |
|
| 1381 | - | name = "errno" |
|
| 1382 | - | version = "0.3.14" |
|
| 1383 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1384 | - | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" |
|
| 1385 | - | dependencies = [ |
|
| 1386 | - | "libc", |
|
| 1387 | - | "windows-sys 0.61.2", |
|
| 1388 | - | ] |
|
| 1389 | - | ||
| 1390 | - | [[package]] |
|
| 1391 | - | name = "error-code" |
|
| 1392 | - | version = "3.3.2" |
|
| 1393 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1394 | - | checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" |
|
| 1395 | - | ||
| 1396 | - | [[package]] |
|
| 1397 | - | name = "euclid" |
|
| 1398 | - | version = "0.22.14" |
|
| 1399 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1400 | - | checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" |
|
| 1401 | - | dependencies = [ |
|
| 1402 | - | "num-traits", |
|
| 1403 | - | ] |
|
| 1404 | - | ||
| 1405 | - | [[package]] |
|
| 1406 | - | name = "exr" |
|
| 1407 | - | version = "1.74.0" |
|
| 1408 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1409 | - | checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" |
|
| 1410 | - | dependencies = [ |
|
| 1411 | - | "bit_field", |
|
| 1412 | - | "half", |
|
| 1413 | - | "lebe", |
|
| 1414 | - | "miniz_oxide", |
|
| 1415 | - | "rayon-core", |
|
| 1416 | - | "smallvec", |
|
| 1417 | - | "zune-inflate", |
|
| 1418 | - | ] |
|
| 1419 | - | ||
| 1420 | - | [[package]] |
|
| 1421 | - | name = "fallible-iterator" |
|
| 1422 | - | version = "0.3.0" |
|
| 1423 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1424 | - | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" |
|
| 1425 | - | ||
| 1426 | - | [[package]] |
|
| 1427 | - | name = "fallible-streaming-iterator" |
|
| 1428 | - | version = "0.1.9" |
|
| 1429 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1430 | - | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" |
|
| 1431 | - | ||
| 1432 | - | [[package]] |
|
| 1433 | - | name = "fancy-regex" |
|
| 1434 | - | version = "0.11.0" |
|
| 1435 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1436 | - | checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" |
|
| 1437 | - | dependencies = [ |
|
| 1438 | - | "bit-set", |
|
| 1439 | - | "regex", |
|
| 1440 | - | ] |
|
| 1441 | - | ||
| 1442 | - | [[package]] |
|
| 1443 | - | name = "fastrand" |
|
| 1444 | - | version = "2.3.0" |
|
| 1445 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1446 | - | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" |
|
| 1447 | - | ||
| 1448 | - | [[package]] |
|
| 1449 | - | name = "fax" |
|
| 1450 | - | version = "0.2.6" |
|
| 1451 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1452 | - | checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" |
|
| 1453 | - | dependencies = [ |
|
| 1454 | - | "fax_derive", |
|
| 1455 | - | ] |
|
| 1456 | - | ||
| 1457 | - | [[package]] |
|
| 1458 | - | name = "fax_derive" |
|
| 1459 | - | version = "0.2.0" |
|
| 1460 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1461 | - | checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" |
|
| 1462 | - | dependencies = [ |
|
| 1463 | - | "proc-macro2", |
|
| 1464 | - | "quote", |
|
| 1465 | - | "syn 2.0.117", |
|
| 1466 | - | ] |
|
| 1467 | - | ||
| 1468 | - | [[package]] |
|
| 1469 | - | name = "fdeflate" |
|
| 1470 | - | version = "0.3.7" |
|
| 1471 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1472 | - | checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" |
|
| 1473 | - | dependencies = [ |
|
| 1474 | - | "simd-adler32", |
|
| 1475 | - | ] |
|
| 1476 | - | ||
| 1477 | - | [[package]] |
|
| 1478 | - | name = "feed-rs" |
|
| 1479 | - | version = "2.3.1" |
|
| 1480 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1481 | - | checksum = "e4c0591d23efd0d595099af69a31863ac1823046b1b021e3b06ba3aae7e00991" |
|
| 1482 | - | dependencies = [ |
|
| 1483 | - | "chrono", |
|
| 1484 | - | "mediatype", |
|
| 1485 | - | "quick-xml 0.37.5", |
|
| 1486 | - | "regex", |
|
| 1487 | - | "serde", |
|
| 1488 | - | "serde_json", |
|
| 1489 | - | "siphasher", |
|
| 1490 | - | "url", |
|
| 1491 | - | "uuid", |
|
| 1492 | - | ] |
|
| 1493 | - | ||
| 1494 | - | [[package]] |
|
| 1495 | - | name = "feeds" |
|
| 1496 | - | version = "0.3.0" |
|
| 1497 | - | dependencies = [ |
|
| 1498 | - | "andromeda-auth", |
|
| 1499 | - | "andromeda-darkmatter-css", |
|
| 1500 | - | "andromeda-db", |
|
| 1501 | - | "askama 0.13.1", |
|
| 1502 | - | "axum", |
|
| 1503 | - | "chrono", |
|
| 1504 | - | "dotenvy", |
|
| 1505 | - | "feed-rs", |
|
| 1506 | - | "mime_guess", |
|
| 1507 | - | "quick-xml 0.37.5", |
|
| 1508 | - | "rand 0.8.5", |
|
| 1509 | - | "reqwest 0.12.28", |
|
| 1510 | - | "rusqlite", |
|
| 1511 | - | "rust-embed", |
|
| 1512 | - | "scraper", |
|
| 1513 | - | "serde", |
|
| 1514 | - | "serde_json", |
|
| 1515 | - | "subtle", |
|
| 1516 | - | "tokio", |
|
| 1517 | - | "tower-http", |
|
| 1518 | - | "tracing", |
|
| 1519 | - | "tracing-subscriber", |
|
| 1520 | - | "url", |
|
| 1521 | - | "urlencoding", |
|
| 1522 | - | ] |
|
| 1523 | - | ||
| 1524 | - | [[package]] |
|
| 1525 | - | name = "filedescriptor" |
|
| 1526 | - | version = "0.8.3" |
|
| 1527 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1528 | - | checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" |
|
| 1529 | - | dependencies = [ |
|
| 1530 | - | "libc", |
|
| 1531 | - | "thiserror 1.0.69", |
|
| 1532 | - | "winapi", |
|
| 1533 | - | ] |
|
| 1534 | - | ||
| 1535 | - | [[package]] |
|
| 1536 | - | name = "find-msvc-tools" |
|
| 1537 | - | version = "0.1.9" |
|
| 1538 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1539 | - | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" |
|
| 1540 | - | ||
| 1541 | - | [[package]] |
|
| 1542 | - | name = "finl_unicode" |
|
| 1543 | - | version = "1.4.0" |
|
| 1544 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1545 | - | checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" |
|
| 1546 | - | ||
| 1547 | - | [[package]] |
|
| 1548 | - | name = "fixedbitset" |
|
| 1549 | - | version = "0.4.2" |
|
| 1550 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1551 | - | checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" |
|
| 1552 | - | ||
| 1553 | - | [[package]] |
|
| 1554 | - | name = "flate2" |
|
| 1555 | - | version = "1.1.9" |
|
| 1556 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1557 | - | checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" |
|
| 1558 | - | dependencies = [ |
|
| 1559 | - | "crc32fast", |
|
| 1560 | - | "miniz_oxide", |
|
| 1561 | - | ] |
|
| 1562 | - | ||
| 1563 | - | [[package]] |
|
| 1564 | - | name = "fnv" |
|
| 1565 | - | version = "1.0.7" |
|
| 1566 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1567 | - | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" |
|
| 1568 | - | ||
| 1569 | - | [[package]] |
|
| 1570 | - | name = "foldhash" |
|
| 1571 | - | version = "0.1.5" |
|
| 1572 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1573 | - | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" |
|
| 1574 | - | ||
| 1575 | - | [[package]] |
|
| 1576 | - | name = "foldhash" |
|
| 1577 | - | version = "0.2.0" |
|
| 1578 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1579 | - | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" |
|
| 1580 | - | ||
| 1581 | - | [[package]] |
|
| 1582 | - | name = "foreign-types" |
|
| 1583 | - | version = "0.3.2" |
|
| 1584 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1585 | - | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" |
|
| 1586 | - | dependencies = [ |
|
| 1587 | - | "foreign-types-shared", |
|
| 1588 | - | ] |
|
| 1589 | - | ||
| 1590 | - | [[package]] |
|
| 1591 | - | name = "foreign-types-shared" |
|
| 1592 | - | version = "0.1.1" |
|
| 1593 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1594 | - | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" |
|
| 1595 | - | ||
| 1596 | - | [[package]] |
|
| 1597 | - | name = "form_urlencoded" |
|
| 1598 | - | version = "1.2.2" |
|
| 1599 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1600 | - | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" |
|
| 1601 | - | dependencies = [ |
|
| 1602 | - | "percent-encoding", |
|
| 1603 | - | ] |
|
| 1604 | - | ||
| 1605 | - | [[package]] |
|
| 1606 | - | name = "fs_extra" |
|
| 1607 | - | version = "1.3.0" |
|
| 1608 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1609 | - | checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" |
|
| 1610 | - | ||
| 1611 | - | [[package]] |
|
| 1612 | - | name = "futf" |
|
| 1613 | - | version = "0.1.5" |
|
| 1614 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1615 | - | checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" |
|
| 1616 | - | dependencies = [ |
|
| 1617 | - | "mac", |
|
| 1618 | - | "new_debug_unreachable", |
|
| 1619 | - | ] |
|
| 1620 | - | ||
| 1621 | - | [[package]] |
|
| 1622 | - | name = "futures-channel" |
|
| 1623 | - | version = "0.3.32" |
|
| 1624 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1625 | - | checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" |
|
| 1626 | - | dependencies = [ |
|
| 1627 | - | "futures-core", |
|
| 1628 | - | "futures-sink", |
|
| 1629 | - | ] |
|
| 1630 | - | ||
| 1631 | - | [[package]] |
|
| 1632 | - | name = "futures-core" |
|
| 1633 | - | version = "0.3.32" |
|
| 1634 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1635 | - | checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" |
|
| 1636 | - | ||
| 1637 | - | [[package]] |
|
| 1638 | - | name = "futures-io" |
|
| 1639 | - | version = "0.3.32" |
|
| 1640 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1641 | - | checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" |
|
| 1642 | - | ||
| 1643 | - | [[package]] |
|
| 1644 | - | name = "futures-sink" |
|
| 1645 | - | version = "0.3.32" |
|
| 1646 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1647 | - | checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" |
|
| 1648 | - | ||
| 1649 | - | [[package]] |
|
| 1650 | - | name = "futures-task" |
|
| 1651 | - | version = "0.3.32" |
|
| 1652 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1653 | - | checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" |
|
| 1654 | - | ||
| 1655 | - | [[package]] |
|
| 1656 | - | name = "futures-util" |
|
| 1657 | - | version = "0.3.32" |
|
| 1658 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1659 | - | checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" |
|
| 1660 | - | dependencies = [ |
|
| 1661 | - | "futures-core", |
|
| 1662 | - | "futures-io", |
|
| 1663 | - | "futures-sink", |
|
| 1664 | - | "futures-task", |
|
| 1665 | - | "memchr", |
|
| 1666 | - | "pin-project-lite", |
|
| 1667 | - | "slab", |
|
| 1668 | - | ] |
|
| 1669 | - | ||
| 1670 | - | [[package]] |
|
| 1671 | - | name = "fxhash" |
|
| 1672 | - | version = "0.2.1" |
|
| 1673 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1674 | - | checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" |
|
| 1675 | - | dependencies = [ |
|
| 1676 | - | "byteorder", |
|
| 1677 | - | ] |
|
| 1678 | - | ||
| 1679 | - | [[package]] |
|
| 1680 | - | name = "generic-array" |
|
| 1681 | - | version = "0.14.7" |
|
| 1682 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1683 | - | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" |
|
| 1684 | - | dependencies = [ |
|
| 1685 | - | "typenum", |
|
| 1686 | - | "version_check", |
|
| 1687 | - | ] |
|
| 1688 | - | ||
| 1689 | - | [[package]] |
|
| 1690 | - | name = "gethostname" |
|
| 1691 | - | version = "1.1.0" |
|
| 1692 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1693 | - | checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" |
|
| 1694 | - | dependencies = [ |
|
| 1695 | - | "rustix", |
|
| 1696 | - | "windows-link", |
|
| 1697 | - | ] |
|
| 1698 | - | ||
| 1699 | - | [[package]] |
|
| 1700 | - | name = "getopts" |
|
| 1701 | - | version = "0.2.24" |
|
| 1702 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1703 | - | checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" |
|
| 1704 | - | dependencies = [ |
|
| 1705 | - | "unicode-width", |
|
| 1706 | - | ] |
|
| 1707 | - | ||
| 1708 | - | [[package]] |
|
| 1709 | - | name = "getrandom" |
|
| 1710 | - | version = "0.2.17" |
|
| 1711 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1712 | - | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" |
|
| 1713 | - | dependencies = [ |
|
| 1714 | - | "cfg-if", |
|
| 1715 | - | "js-sys", |
|
| 1716 | - | "libc", |
|
| 1717 | - | "wasi", |
|
| 1718 | - | "wasm-bindgen", |
|
| 1719 | - | ] |
|
| 1720 | - | ||
| 1721 | - | [[package]] |
|
| 1722 | - | name = "getrandom" |
|
| 1723 | - | version = "0.3.4" |
|
| 1724 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1725 | - | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" |
|
| 1726 | - | dependencies = [ |
|
| 1727 | - | "cfg-if", |
|
| 1728 | - | "js-sys", |
|
| 1729 | - | "libc", |
|
| 1730 | - | "r-efi 5.3.0", |
|
| 1731 | - | "wasip2", |
|
| 1732 | - | "wasm-bindgen", |
|
| 1733 | - | ] |
|
| 1734 | - | ||
| 1735 | - | [[package]] |
|
| 1736 | - | name = "getrandom" |
|
| 1737 | - | version = "0.4.2" |
|
| 1738 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1739 | - | checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" |
|
| 1740 | - | dependencies = [ |
|
| 1741 | - | "cfg-if", |
|
| 1742 | - | "libc", |
|
| 1743 | - | "r-efi 6.0.0", |
|
| 1744 | - | "wasip2", |
|
| 1745 | - | "wasip3", |
|
| 1746 | - | ] |
|
| 1747 | - | ||
| 1748 | - | [[package]] |
|
| 1749 | - | name = "gif" |
|
| 1750 | - | version = "0.14.1" |
|
| 1751 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1752 | - | checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" |
|
| 1753 | - | dependencies = [ |
|
| 1754 | - | "color_quant", |
|
| 1755 | - | "weezl", |
|
| 1756 | - | ] |
|
| 1757 | - | ||
| 1758 | - | [[package]] |
|
| 1759 | - | name = "h2" |
|
| 1760 | - | version = "0.4.13" |
|
| 1761 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1762 | - | checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" |
|
| 1763 | - | dependencies = [ |
|
| 1764 | - | "atomic-waker", |
|
| 1765 | - | "bytes", |
|
| 1766 | - | "fnv", |
|
| 1767 | - | "futures-core", |
|
| 1768 | - | "futures-sink", |
|
| 1769 | - | "http", |
|
| 1770 | - | "indexmap", |
|
| 1771 | - | "slab", |
|
| 1772 | - | "tokio", |
|
| 1773 | - | "tokio-util", |
|
| 1774 | - | "tracing", |
|
| 1775 | - | ] |
|
| 1776 | - | ||
| 1777 | - | [[package]] |
|
| 1778 | - | name = "half" |
|
| 1779 | - | version = "2.7.1" |
|
| 1780 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1781 | - | checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" |
|
| 1782 | - | dependencies = [ |
|
| 1783 | - | "cfg-if", |
|
| 1784 | - | "crunchy", |
|
| 1785 | - | "zerocopy", |
|
| 1786 | - | ] |
|
| 1787 | - | ||
| 1788 | - | [[package]] |
|
| 1789 | - | name = "hashbrown" |
|
| 1790 | - | version = "0.15.5" |
|
| 1791 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1792 | - | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" |
|
| 1793 | - | dependencies = [ |
|
| 1794 | - | "foldhash 0.1.5", |
|
| 1795 | - | ] |
|
| 1796 | - | ||
| 1797 | - | [[package]] |
|
| 1798 | - | name = "hashbrown" |
|
| 1799 | - | version = "0.16.1" |
|
| 1800 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1801 | - | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" |
|
| 1802 | - | dependencies = [ |
|
| 1803 | - | "allocator-api2", |
|
| 1804 | - | "equivalent", |
|
| 1805 | - | "foldhash 0.2.0", |
|
| 1806 | - | ] |
|
| 1807 | - | ||
| 1808 | - | [[package]] |
|
| 1809 | - | name = "hashlink" |
|
| 1810 | - | version = "0.11.0" |
|
| 1811 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1812 | - | checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" |
|
| 1813 | - | dependencies = [ |
|
| 1814 | - | "hashbrown 0.16.1", |
|
| 1815 | - | ] |
|
| 1816 | - | ||
| 1817 | - | [[package]] |
|
| 1818 | - | name = "heck" |
|
| 1819 | - | version = "0.5.0" |
|
| 1820 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1821 | - | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" |
|
| 1822 | - | ||
| 1823 | - | [[package]] |
|
| 1824 | - | name = "hex" |
|
| 1825 | - | version = "0.4.3" |
|
| 1826 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1827 | - | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" |
|
| 1828 | - | ||
| 1829 | - | [[package]] |
|
| 1830 | - | name = "hmac" |
|
| 1831 | - | version = "0.12.1" |
|
| 1832 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1833 | - | checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" |
|
| 1834 | - | dependencies = [ |
|
| 1835 | - | "digest 0.10.7", |
|
| 1836 | - | ] |
|
| 1837 | - | ||
| 1838 | - | [[package]] |
|
| 1839 | - | name = "hmac" |
|
| 1840 | - | version = "0.13.0" |
|
| 1841 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1842 | - | checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" |
|
| 1843 | - | dependencies = [ |
|
| 1844 | - | "digest 0.11.3", |
|
| 1845 | - | ] |
|
| 1846 | - | ||
| 1847 | - | [[package]] |
|
| 1848 | - | name = "html5ever" |
|
| 1849 | - | version = "0.29.1" |
|
| 1850 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1851 | - | checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" |
|
| 1852 | - | dependencies = [ |
|
| 1853 | - | "log", |
|
| 1854 | - | "mac", |
|
| 1855 | - | "markup5ever", |
|
| 1856 | - | "match_token", |
|
| 1857 | - | ] |
|
| 1858 | - | ||
| 1859 | - | [[package]] |
|
| 1860 | - | name = "http" |
|
| 1861 | - | version = "1.4.0" |
|
| 1862 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1863 | - | checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" |
|
| 1864 | - | dependencies = [ |
|
| 1865 | - | "bytes", |
|
| 1866 | - | "itoa", |
|
| 1867 | - | ] |
|
| 1868 | - | ||
| 1869 | - | [[package]] |
|
| 1870 | - | name = "http-body" |
|
| 1871 | - | version = "1.0.1" |
|
| 1872 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1873 | - | checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" |
|
| 1874 | - | dependencies = [ |
|
| 1875 | - | "bytes", |
|
| 1876 | - | "http", |
|
| 1877 | - | ] |
|
| 1878 | - | ||
| 1879 | - | [[package]] |
|
| 1880 | - | name = "http-body-util" |
|
| 1881 | - | version = "0.1.3" |
|
| 1882 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1883 | - | checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" |
|
| 1884 | - | dependencies = [ |
|
| 1885 | - | "bytes", |
|
| 1886 | - | "futures-core", |
|
| 1887 | - | "http", |
|
| 1888 | - | "http-body", |
|
| 1889 | - | "pin-project-lite", |
|
| 1890 | - | ] |
|
| 1891 | - | ||
| 1892 | - | [[package]] |
|
| 1893 | - | name = "http-range-header" |
|
| 1894 | - | version = "0.4.2" |
|
| 1895 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1896 | - | checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" |
|
| 1897 | - | ||
| 1898 | - | [[package]] |
|
| 1899 | - | name = "httparse" |
|
| 1900 | - | version = "1.10.1" |
|
| 1901 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1902 | - | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" |
|
| 1903 | - | ||
| 1904 | - | [[package]] |
|
| 1905 | - | name = "httpdate" |
|
| 1906 | - | version = "1.0.3" |
|
| 1907 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1908 | - | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" |
|
| 1909 | - | ||
| 1910 | - | [[package]] |
|
| 1911 | - | name = "humansize" |
|
| 1912 | - | version = "2.1.3" |
|
| 1913 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1914 | - | checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" |
|
| 1915 | - | dependencies = [ |
|
| 1916 | - | "libm", |
|
| 1917 | - | ] |
|
| 1918 | - | ||
| 1919 | - | [[package]] |
|
| 1920 | - | name = "hybrid-array" |
|
| 1921 | - | version = "0.4.11" |
|
| 1922 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1923 | - | checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" |
|
| 1924 | - | dependencies = [ |
|
| 1925 | - | "typenum", |
|
| 1926 | - | ] |
|
| 1927 | - | ||
| 1928 | - | [[package]] |
|
| 1929 | - | name = "hyper" |
|
| 1930 | - | version = "1.9.0" |
|
| 1931 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1932 | - | checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" |
|
| 1933 | - | dependencies = [ |
|
| 1934 | - | "atomic-waker", |
|
| 1935 | - | "bytes", |
|
| 1936 | - | "futures-channel", |
|
| 1937 | - | "futures-core", |
|
| 1938 | - | "h2", |
|
| 1939 | - | "http", |
|
| 1940 | - | "http-body", |
|
| 1941 | - | "httparse", |
|
| 1942 | - | "httpdate", |
|
| 1943 | - | "itoa", |
|
| 1944 | - | "pin-project-lite", |
|
| 1945 | - | "smallvec", |
|
| 1946 | - | "tokio", |
|
| 1947 | - | "want", |
|
| 1948 | - | ] |
|
| 1949 | - | ||
| 1950 | - | [[package]] |
|
| 1951 | - | name = "hyper-rustls" |
|
| 1952 | - | version = "0.27.7" |
|
| 1953 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1954 | - | checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" |
|
| 1955 | - | dependencies = [ |
|
| 1956 | - | "http", |
|
| 1957 | - | "hyper", |
|
| 1958 | - | "hyper-util", |
|
| 1959 | - | "rustls", |
|
| 1960 | - | "rustls-pki-types", |
|
| 1961 | - | "tokio", |
|
| 1962 | - | "tokio-rustls", |
|
| 1963 | - | "tower-service", |
|
| 1964 | - | "webpki-roots", |
|
| 1965 | - | ] |
|
| 1966 | - | ||
| 1967 | - | [[package]] |
|
| 1968 | - | name = "hyper-tls" |
|
| 1969 | - | version = "0.6.0" |
|
| 1970 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1971 | - | checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" |
|
| 1972 | - | dependencies = [ |
|
| 1973 | - | "bytes", |
|
| 1974 | - | "http-body-util", |
|
| 1975 | - | "hyper", |
|
| 1976 | - | "hyper-util", |
|
| 1977 | - | "native-tls", |
|
| 1978 | - | "tokio", |
|
| 1979 | - | "tokio-native-tls", |
|
| 1980 | - | "tower-service", |
|
| 1981 | - | ] |
|
| 1982 | - | ||
| 1983 | - | [[package]] |
|
| 1984 | - | name = "hyper-util" |
|
| 1985 | - | version = "0.1.20" |
|
| 1986 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1987 | - | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" |
|
| 1988 | - | dependencies = [ |
|
| 1989 | - | "base64", |
|
| 1990 | - | "bytes", |
|
| 1991 | - | "futures-channel", |
|
| 1992 | - | "futures-util", |
|
| 1993 | - | "http", |
|
| 1994 | - | "http-body", |
|
| 1995 | - | "hyper", |
|
| 1996 | - | "ipnet", |
|
| 1997 | - | "libc", |
|
| 1998 | - | "percent-encoding", |
|
| 1999 | - | "pin-project-lite", |
|
| 2000 | - | "socket2", |
|
| 2001 | - | "system-configuration", |
|
| 2002 | - | "tokio", |
|
| 2003 | - | "tower-service", |
|
| 2004 | - | "tracing", |
|
| 2005 | - | "windows-registry", |
|
| 2006 | - | ] |
|
| 2007 | - | ||
| 2008 | - | [[package]] |
|
| 2009 | - | name = "iana-time-zone" |
|
| 2010 | - | version = "0.1.65" |
|
| 2011 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2012 | - | checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" |
|
| 2013 | - | dependencies = [ |
|
| 2014 | - | "android_system_properties", |
|
| 2015 | - | "core-foundation-sys", |
|
| 2016 | - | "iana-time-zone-haiku", |
|
| 2017 | - | "js-sys", |
|
| 2018 | - | "log", |
|
| 2019 | - | "wasm-bindgen", |
|
| 2020 | - | "windows-core", |
|
| 2021 | - | ] |
|
| 2022 | - | ||
| 2023 | - | [[package]] |
|
| 2024 | - | name = "iana-time-zone-haiku" |
|
| 2025 | - | version = "0.1.2" |
|
| 2026 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2027 | - | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" |
|
| 2028 | - | dependencies = [ |
|
| 2029 | - | "cc", |
|
| 2030 | - | ] |
|
| 2031 | - | ||
| 2032 | - | [[package]] |
|
| 2033 | - | name = "icu_collections" |
|
| 2034 | - | version = "2.2.0" |
|
| 2035 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2036 | - | checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" |
|
| 2037 | - | dependencies = [ |
|
| 2038 | - | "displaydoc", |
|
| 2039 | - | "potential_utf", |
|
| 2040 | - | "utf8_iter", |
|
| 2041 | - | "yoke", |
|
| 2042 | - | "zerofrom", |
|
| 2043 | - | "zerovec", |
|
| 2044 | - | ] |
|
| 2045 | - | ||
| 2046 | - | [[package]] |
|
| 2047 | - | name = "icu_locale_core" |
|
| 2048 | - | version = "2.2.0" |
|
| 2049 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2050 | - | checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" |
|
| 2051 | - | dependencies = [ |
|
| 2052 | - | "displaydoc", |
|
| 2053 | - | "litemap", |
|
| 2054 | - | "tinystr", |
|
| 2055 | - | "writeable", |
|
| 2056 | - | "zerovec", |
|
| 2057 | - | ] |
|
| 2058 | - | ||
| 2059 | - | [[package]] |
|
| 2060 | - | name = "icu_normalizer" |
|
| 2061 | - | version = "2.2.0" |
|
| 2062 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2063 | - | checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" |
|
| 2064 | - | dependencies = [ |
|
| 2065 | - | "icu_collections", |
|
| 2066 | - | "icu_normalizer_data", |
|
| 2067 | - | "icu_properties", |
|
| 2068 | - | "icu_provider", |
|
| 2069 | - | "smallvec", |
|
| 2070 | - | "zerovec", |
|
| 2071 | - | ] |
|
| 2072 | - | ||
| 2073 | - | [[package]] |
|
| 2074 | - | name = "icu_normalizer_data" |
|
| 2075 | - | version = "2.2.0" |
|
| 2076 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2077 | - | checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" |
|
| 2078 | - | ||
| 2079 | - | [[package]] |
|
| 2080 | - | name = "icu_properties" |
|
| 2081 | - | version = "2.2.0" |
|
| 2082 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2083 | - | checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" |
|
| 2084 | - | dependencies = [ |
|
| 2085 | - | "icu_collections", |
|
| 2086 | - | "icu_locale_core", |
|
| 2087 | - | "icu_properties_data", |
|
| 2088 | - | "icu_provider", |
|
| 2089 | - | "zerotrie", |
|
| 2090 | - | "zerovec", |
|
| 2091 | - | ] |
|
| 2092 | - | ||
| 2093 | - | [[package]] |
|
| 2094 | - | name = "icu_properties_data" |
|
| 2095 | - | version = "2.2.0" |
|
| 2096 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2097 | - | checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" |
|
| 2098 | - | ||
| 2099 | - | [[package]] |
|
| 2100 | - | name = "icu_provider" |
|
| 2101 | - | version = "2.2.0" |
|
| 2102 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2103 | - | checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" |
|
| 2104 | - | dependencies = [ |
|
| 2105 | - | "displaydoc", |
|
| 2106 | - | "icu_locale_core", |
|
| 2107 | - | "writeable", |
|
| 2108 | - | "yoke", |
|
| 2109 | - | "zerofrom", |
|
| 2110 | - | "zerotrie", |
|
| 2111 | - | "zerovec", |
|
| 2112 | - | ] |
|
| 2113 | - | ||
| 2114 | - | [[package]] |
|
| 2115 | - | name = "id-arena" |
|
| 2116 | - | version = "2.3.0" |
|
| 2117 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2118 | - | checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" |
|
| 2119 | - | ||
| 2120 | - | [[package]] |
|
| 2121 | - | name = "ident_case" |
|
| 2122 | - | version = "1.0.1" |
|
| 2123 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2124 | - | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" |
|
| 2125 | - | ||
| 2126 | - | [[package]] |
|
| 2127 | - | name = "idna" |
|
| 2128 | - | version = "1.1.0" |
|
| 2129 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2130 | - | checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" |
|
| 2131 | - | dependencies = [ |
|
| 2132 | - | "idna_adapter", |
|
| 2133 | - | "smallvec", |
|
| 2134 | - | "utf8_iter", |
|
| 2135 | - | ] |
|
| 2136 | - | ||
| 2137 | - | [[package]] |
|
| 2138 | - | name = "idna_adapter" |
|
| 2139 | - | version = "1.2.1" |
|
| 2140 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2141 | - | checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" |
|
| 2142 | - | dependencies = [ |
|
| 2143 | - | "icu_normalizer", |
|
| 2144 | - | "icu_properties", |
|
| 2145 | - | ] |
|
| 2146 | - | ||
| 2147 | - | [[package]] |
|
| 2148 | - | name = "image" |
|
| 2149 | - | version = "0.25.10" |
|
| 2150 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2151 | - | checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" |
|
| 2152 | - | dependencies = [ |
|
| 2153 | - | "bytemuck", |
|
| 2154 | - | "byteorder-lite", |
|
| 2155 | - | "color_quant", |
|
| 2156 | - | "exr", |
|
| 2157 | - | "gif", |
|
| 2158 | - | "image-webp", |
|
| 2159 | - | "moxcms", |
|
| 2160 | - | "num-traits", |
|
| 2161 | - | "png", |
|
| 2162 | - | "qoi", |
|
| 2163 | - | "ravif", |
|
| 2164 | - | "rayon", |
|
| 2165 | - | "rgb", |
|
| 2166 | - | "tiff", |
|
| 2167 | - | "zune-core", |
|
| 2168 | - | "zune-jpeg", |
|
| 2169 | - | ] |
|
| 2170 | - | ||
| 2171 | - | [[package]] |
|
| 2172 | - | name = "image-webp" |
|
| 2173 | - | version = "0.2.4" |
|
| 2174 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2175 | - | checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" |
|
| 2176 | - | dependencies = [ |
|
| 2177 | - | "byteorder-lite", |
|
| 2178 | - | "quick-error", |
|
| 2179 | - | ] |
|
| 2180 | - | ||
| 2181 | - | [[package]] |
|
| 2182 | - | name = "img-parts" |
|
| 2183 | - | version = "0.3.3" |
|
| 2184 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2185 | - | checksum = "6b4e24cfdc6f897b582508e3c382eaf5378076898f80500a80d10d761ae85e90" |
|
| 2186 | - | dependencies = [ |
|
| 2187 | - | "bytes", |
|
| 2188 | - | "crc32fast", |
|
| 2189 | - | "miniz_oxide", |
|
| 2190 | - | ] |
|
| 2191 | - | ||
| 2192 | - | [[package]] |
|
| 2193 | - | name = "imgref" |
|
| 2194 | - | version = "1.12.0" |
|
| 2195 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2196 | - | checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" |
|
| 2197 | - | ||
| 2198 | - | [[package]] |
|
| 2199 | - | name = "indexmap" |
|
| 2200 | - | version = "2.13.0" |
|
| 2201 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2202 | - | checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" |
|
| 2203 | - | dependencies = [ |
|
| 2204 | - | "equivalent", |
|
| 2205 | - | "hashbrown 0.16.1", |
|
| 2206 | - | "serde", |
|
| 2207 | - | "serde_core", |
|
| 2208 | - | ] |
|
| 2209 | - | ||
| 2210 | - | [[package]] |
|
| 2211 | - | name = "indoc" |
|
| 2212 | - | version = "2.0.7" |
|
| 2213 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2214 | - | checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" |
|
| 2215 | - | dependencies = [ |
|
| 2216 | - | "rustversion", |
|
| 2217 | - | ] |
|
| 2218 | - | ||
| 2219 | - | [[package]] |
|
| 2220 | - | name = "inout" |
|
| 2221 | - | version = "0.1.4" |
|
| 2222 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2223 | - | checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" |
|
| 2224 | - | dependencies = [ |
|
| 2225 | - | "generic-array", |
|
| 2226 | - | ] |
|
| 2227 | - | ||
| 2228 | - | [[package]] |
|
| 2229 | - | name = "instability" |
|
| 2230 | - | version = "0.3.12" |
|
| 2231 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2232 | - | checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" |
|
| 2233 | - | dependencies = [ |
|
| 2234 | - | "darling", |
|
| 2235 | - | "indoc", |
|
| 2236 | - | "proc-macro2", |
|
| 2237 | - | "quote", |
|
| 2238 | - | "syn 2.0.117", |
|
| 2239 | - | ] |
|
| 2240 | - | ||
| 2241 | - | [[package]] |
|
| 2242 | - | name = "interpolate_name" |
|
| 2243 | - | version = "0.2.4" |
|
| 2244 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2245 | - | checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" |
|
| 2246 | - | dependencies = [ |
|
| 2247 | - | "proc-macro2", |
|
| 2248 | - | "quote", |
|
| 2249 | - | "syn 2.0.117", |
|
| 2250 | - | ] |
|
| 2251 | - | ||
| 2252 | - | [[package]] |
|
| 2253 | - | name = "ipnet" |
|
| 2254 | - | version = "2.12.0" |
|
| 2255 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2256 | - | checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" |
|
| 2257 | - | ||
| 2258 | - | [[package]] |
|
| 2259 | - | name = "iri-string" |
|
| 2260 | - | version = "0.7.12" |
|
| 2261 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2262 | - | checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" |
|
| 2263 | - | dependencies = [ |
|
| 2264 | - | "memchr", |
|
| 2265 | - | "serde", |
|
| 2266 | - | ] |
|
| 2267 | - | ||
| 2268 | - | [[package]] |
|
| 2269 | - | name = "is-docker" |
|
| 2270 | - | version = "0.2.0" |
|
| 2271 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2272 | - | checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" |
|
| 2273 | - | dependencies = [ |
|
| 2274 | - | "once_cell", |
|
| 2275 | - | ] |
|
| 2276 | - | ||
| 2277 | - | [[package]] |
|
| 2278 | - | name = "is-wsl" |
|
| 2279 | - | version = "0.4.0" |
|
| 2280 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2281 | - | checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" |
|
| 2282 | - | dependencies = [ |
|
| 2283 | - | "is-docker", |
|
| 2284 | - | "once_cell", |
|
| 2285 | - | ] |
|
| 2286 | - | ||
| 2287 | - | [[package]] |
|
| 2288 | - | name = "is_terminal_polyfill" |
|
| 2289 | - | version = "1.70.2" |
|
| 2290 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2291 | - | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" |
|
| 2292 | - | ||
| 2293 | - | [[package]] |
|
| 2294 | - | name = "itertools" |
|
| 2295 | - | version = "0.14.0" |
|
| 2296 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2297 | - | checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" |
|
| 2298 | - | dependencies = [ |
|
| 2299 | - | "either", |
|
| 2300 | - | ] |
|
| 2301 | - | ||
| 2302 | - | [[package]] |
|
| 2303 | - | name = "itoa" |
|
| 2304 | - | version = "1.0.18" |
|
| 2305 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2306 | - | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
|
| 2307 | - | ||
| 2308 | - | [[package]] |
|
| 2309 | - | name = "jiff" |
|
| 2310 | - | version = "0.2.24" |
|
| 2311 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2312 | - | checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" |
|
| 2313 | - | dependencies = [ |
|
| 2314 | - | "jiff-static", |
|
| 2315 | - | "log", |
|
| 2316 | - | "portable-atomic", |
|
| 2317 | - | "portable-atomic-util", |
|
| 2318 | - | "serde_core", |
|
| 2319 | - | ] |
|
| 2320 | - | ||
| 2321 | - | [[package]] |
|
| 2322 | - | name = "jiff-static" |
|
| 2323 | - | version = "0.2.24" |
|
| 2324 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2325 | - | checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" |
|
| 2326 | - | dependencies = [ |
|
| 2327 | - | "proc-macro2", |
|
| 2328 | - | "quote", |
|
| 2329 | - | "syn 2.0.117", |
|
| 2330 | - | ] |
|
| 2331 | - | ||
| 2332 | - | [[package]] |
|
| 2333 | - | name = "jni" |
|
| 2334 | - | version = "0.21.1" |
|
| 2335 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2336 | - | checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" |
|
| 2337 | - | dependencies = [ |
|
| 2338 | - | "cesu8", |
|
| 2339 | - | "cfg-if", |
|
| 2340 | - | "combine", |
|
| 2341 | - | "jni-sys 0.3.1", |
|
| 2342 | - | "log", |
|
| 2343 | - | "thiserror 1.0.69", |
|
| 2344 | - | "walkdir", |
|
| 2345 | - | "windows-sys 0.45.0", |
|
| 2346 | - | ] |
|
| 2347 | - | ||
| 2348 | - | [[package]] |
|
| 2349 | - | name = "jni-sys" |
|
| 2350 | - | version = "0.3.1" |
|
| 2351 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2352 | - | checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" |
|
| 2353 | - | dependencies = [ |
|
| 2354 | - | "jni-sys 0.4.1", |
|
| 2355 | - | ] |
|
| 2356 | - | ||
| 2357 | - | [[package]] |
|
| 2358 | - | name = "jni-sys" |
|
| 2359 | - | version = "0.4.1" |
|
| 2360 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2361 | - | checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" |
|
| 2362 | - | dependencies = [ |
|
| 2363 | - | "jni-sys-macros", |
|
| 2364 | - | ] |
|
| 2365 | - | ||
| 2366 | - | [[package]] |
|
| 2367 | - | name = "jni-sys-macros" |
|
| 2368 | - | version = "0.4.1" |
|
| 2369 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2370 | - | checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" |
|
| 2371 | - | dependencies = [ |
|
| 2372 | - | "quote", |
|
| 2373 | - | "syn 2.0.117", |
|
| 2374 | - | ] |
|
| 2375 | - | ||
| 2376 | - | [[package]] |
|
| 2377 | - | name = "jobserver" |
|
| 2378 | - | version = "0.1.34" |
|
| 2379 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2380 | - | checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" |
|
| 2381 | - | dependencies = [ |
|
| 2382 | - | "getrandom 0.3.4", |
|
| 2383 | - | "libc", |
|
| 2384 | - | ] |
|
| 2385 | - | ||
| 2386 | - | [[package]] |
|
| 2387 | - | name = "jotts" |
|
| 2388 | - | version = "0.2.0" |
|
| 2389 | - | dependencies = [ |
|
| 2390 | - | "andromeda-auth", |
|
| 2391 | - | "andromeda-darkmatter-css", |
|
| 2392 | - | "andromeda-db", |
|
| 2393 | - | "arboard", |
|
| 2394 | - | "askama 0.15.6", |
|
| 2395 | - | "askama_web", |
|
| 2396 | - | "axum", |
|
| 2397 | - | "clap", |
|
| 2398 | - | "crossterm", |
|
| 2399 | - | "dotenvy", |
|
| 2400 | - | "nanoid", |
|
| 2401 | - | "open", |
|
| 2402 | - | "pulldown-cmark", |
|
| 2403 | - | "rand 0.8.5", |
|
| 2404 | - | "ratatui", |
|
| 2405 | - | "reqwest 0.13.2", |
|
| 2406 | - | "rpassword", |
|
| 2407 | - | "rusqlite", |
|
| 2408 | - | "rust-embed", |
|
| 2409 | - | "serde", |
|
| 2410 | - | "serde_json", |
|
| 2411 | - | "subtle", |
|
| 2412 | - | "syntect", |
|
| 2413 | - | "tokio", |
|
| 2414 | - | "toml", |
|
| 2415 | - | "tracing", |
|
| 2416 | - | "tracing-subscriber", |
|
| 2417 | - | ] |
|
| 2418 | - | ||
| 2419 | - | [[package]] |
|
| 2420 | - | name = "js-sys" |
|
| 2421 | - | version = "0.3.94" |
|
| 2422 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2423 | - | checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" |
|
| 2424 | - | dependencies = [ |
|
| 2425 | - | "cfg-if", |
|
| 2426 | - | "futures-util", |
|
| 2427 | - | "once_cell", |
|
| 2428 | - | "wasm-bindgen", |
|
| 2429 | - | ] |
|
| 2430 | - | ||
| 2431 | - | [[package]] |
|
| 2432 | - | name = "kasuari" |
|
| 2433 | - | version = "0.4.12" |
|
| 2434 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2435 | - | checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" |
|
| 2436 | - | dependencies = [ |
|
| 2437 | - | "hashbrown 0.16.1", |
|
| 2438 | - | "portable-atomic", |
|
| 2439 | - | "thiserror 2.0.18", |
|
| 2440 | - | ] |
|
| 2441 | - | ||
| 2442 | - | [[package]] |
|
| 2443 | - | name = "lab" |
|
| 2444 | - | version = "0.11.0" |
|
| 2445 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2446 | - | checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" |
|
| 2447 | - | ||
| 2448 | - | [[package]] |
|
| 2449 | - | name = "lazy_static" |
|
| 2450 | - | version = "1.5.0" |
|
| 2451 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2452 | - | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" |
|
| 2453 | - | ||
| 2454 | - | [[package]] |
|
| 2455 | - | name = "leb128fmt" |
|
| 2456 | - | version = "0.1.0" |
|
| 2457 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2458 | - | checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" |
|
| 2459 | - | ||
| 2460 | - | [[package]] |
|
| 2461 | - | name = "lebe" |
|
| 2462 | - | version = "0.5.3" |
|
| 2463 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2464 | - | checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" |
|
| 2465 | - | ||
| 2466 | - | [[package]] |
|
| 2467 | - | name = "libc" |
|
| 2468 | - | version = "0.2.184" |
|
| 2469 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2470 | - | checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" |
|
| 2471 | - | ||
| 2472 | - | [[package]] |
|
| 2473 | - | name = "libfuzzer-sys" |
|
| 2474 | - | version = "0.4.12" |
|
| 2475 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2476 | - | checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" |
|
| 2477 | - | dependencies = [ |
|
| 2478 | - | "arbitrary", |
|
| 2479 | - | "cc", |
|
| 2480 | - | ] |
|
| 2481 | - | ||
| 2482 | - | [[package]] |
|
| 2483 | - | name = "libm" |
|
| 2484 | - | version = "0.2.16" |
|
| 2485 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2486 | - | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" |
|
| 2487 | - | ||
| 2488 | - | [[package]] |
|
| 2489 | - | name = "library" |
|
| 2490 | - | version = "0.1.0" |
|
| 2491 | - | dependencies = [ |
|
| 2492 | - | "andromeda-auth", |
|
| 2493 | - | "andromeda-darkmatter-css", |
|
| 2494 | - | "andromeda-db", |
|
| 2495 | - | "askama 0.13.1", |
|
| 2496 | - | "axum", |
|
| 2497 | - | "chrono", |
|
| 2498 | - | "dotenvy", |
|
| 2499 | - | "mime_guess", |
|
| 2500 | - | "rand 0.8.5", |
|
| 2501 | - | "reqwest 0.12.28", |
|
| 2502 | - | "rusqlite", |
|
| 2503 | - | "rust-embed", |
|
| 2504 | - | "serde", |
|
| 2505 | - | "serde_json", |
|
| 2506 | - | "subtle", |
|
| 2507 | - | "tokio", |
|
| 2508 | - | "tracing", |
|
| 2509 | - | "tracing-subscriber", |
|
| 2510 | - | "urlencoding", |
|
| 2511 | - | ] |
|
| 2512 | - | ||
| 2513 | - | [[package]] |
|
| 2514 | - | name = "libsqlite3-sys" |
|
| 2515 | - | version = "0.36.0" |
|
| 2516 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2517 | - | checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" |
|
| 2518 | - | dependencies = [ |
|
| 2519 | - | "cc", |
|
| 2520 | - | "pkg-config", |
|
| 2521 | - | "vcpkg", |
|
| 2522 | - | ] |
|
| 2523 | - | ||
| 2524 | - | [[package]] |
|
| 2525 | - | name = "line-clipping" |
|
| 2526 | - | version = "0.3.7" |
|
| 2527 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2528 | - | checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" |
|
| 2529 | - | dependencies = [ |
|
| 2530 | - | "bitflags 2.11.0", |
|
| 2531 | - | ] |
|
| 2532 | - | ||
| 2533 | - | [[package]] |
|
| 2534 | - | name = "linked-hash-map" |
|
| 2535 | - | version = "0.5.6" |
|
| 2536 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2537 | - | checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" |
|
| 2538 | - | ||
| 2539 | - | [[package]] |
|
| 2540 | - | name = "linux-raw-sys" |
|
| 2541 | - | version = "0.12.1" |
|
| 2542 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2543 | - | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" |
|
| 2544 | - | ||
| 2545 | - | [[package]] |
|
| 2546 | - | name = "litemap" |
|
| 2547 | - | version = "0.8.2" |
|
| 2548 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2549 | - | checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" |
|
| 2550 | - | ||
| 2551 | - | [[package]] |
|
| 2552 | - | name = "litrs" |
|
| 2553 | - | version = "1.0.0" |
|
| 2554 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2555 | - | checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" |
|
| 2556 | - | ||
| 2557 | - | [[package]] |
|
| 2558 | - | name = "lock_api" |
|
| 2559 | - | version = "0.4.14" |
|
| 2560 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2561 | - | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" |
|
| 2562 | - | dependencies = [ |
|
| 2563 | - | "scopeguard", |
|
| 2564 | - | ] |
|
| 2565 | - | ||
| 2566 | - | [[package]] |
|
| 2567 | - | name = "log" |
|
| 2568 | - | version = "0.4.29" |
|
| 2569 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2570 | - | checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" |
|
| 2571 | - | ||
| 2572 | - | [[package]] |
|
| 2573 | - | name = "loop9" |
|
| 2574 | - | version = "0.1.5" |
|
| 2575 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2576 | - | checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" |
|
| 2577 | - | dependencies = [ |
|
| 2578 | - | "imgref", |
|
| 2579 | - | ] |
|
| 2580 | - | ||
| 2581 | - | [[package]] |
|
| 2582 | - | name = "lru" |
|
| 2583 | - | version = "0.16.3" |
|
| 2584 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2585 | - | checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" |
|
| 2586 | - | dependencies = [ |
|
| 2587 | - | "hashbrown 0.16.1", |
|
| 2588 | - | ] |
|
| 2589 | - | ||
| 2590 | - | [[package]] |
|
| 2591 | - | name = "lru-slab" |
|
| 2592 | - | version = "0.1.2" |
|
| 2593 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2594 | - | checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" |
|
| 2595 | - | ||
| 2596 | - | [[package]] |
|
| 2597 | - | name = "lzma-rs" |
|
| 2598 | - | version = "0.3.0" |
|
| 2599 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2600 | - | checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" |
|
| 2601 | - | dependencies = [ |
|
| 2602 | - | "byteorder", |
|
| 2603 | - | "crc", |
|
| 2604 | - | ] |
|
| 2605 | - | ||
| 2606 | - | [[package]] |
|
| 2607 | - | name = "lzma-sys" |
|
| 2608 | - | version = "0.1.20" |
|
| 2609 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2610 | - | checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" |
|
| 2611 | - | dependencies = [ |
|
| 2612 | - | "cc", |
|
| 2613 | - | "libc", |
|
| 2614 | - | "pkg-config", |
|
| 2615 | - | ] |
|
| 2616 | - | ||
| 2617 | - | [[package]] |
|
| 2618 | - | name = "mac" |
|
| 2619 | - | version = "0.1.1" |
|
| 2620 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2621 | - | checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" |
|
| 2622 | - | ||
| 2623 | - | [[package]] |
|
| 2624 | - | name = "mac_address" |
|
| 2625 | - | version = "1.1.8" |
|
| 2626 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2627 | - | checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" |
|
| 2628 | - | dependencies = [ |
|
| 2629 | - | "nix", |
|
| 2630 | - | "winapi", |
|
| 2631 | - | ] |
|
| 2632 | - | ||
| 2633 | - | [[package]] |
|
| 2634 | - | name = "markup5ever" |
|
| 2635 | - | version = "0.14.1" |
|
| 2636 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2637 | - | checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" |
|
| 2638 | - | dependencies = [ |
|
| 2639 | - | "log", |
|
| 2640 | - | "phf 0.11.3", |
|
| 2641 | - | "phf_codegen", |
|
| 2642 | - | "string_cache", |
|
| 2643 | - | "string_cache_codegen", |
|
| 2644 | - | "tendril", |
|
| 2645 | - | ] |
|
| 2646 | - | ||
| 2647 | - | [[package]] |
|
| 2648 | - | name = "match_token" |
|
| 2649 | - | version = "0.1.0" |
|
| 2650 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2651 | - | checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" |
|
| 2652 | - | dependencies = [ |
|
| 2653 | - | "proc-macro2", |
|
| 2654 | - | "quote", |
|
| 2655 | - | "syn 2.0.117", |
|
| 2656 | - | ] |
|
| 2657 | - | ||
| 2658 | - | [[package]] |
|
| 2659 | - | name = "matchers" |
|
| 2660 | - | version = "0.2.0" |
|
| 2661 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2662 | - | checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" |
|
| 2663 | - | dependencies = [ |
|
| 2664 | - | "regex-automata", |
|
| 2665 | - | ] |
|
| 2666 | - | ||
| 2667 | - | [[package]] |
|
| 2668 | - | name = "matchit" |
|
| 2669 | - | version = "0.8.4" |
|
| 2670 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2671 | - | checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" |
|
| 2672 | - | ||
| 2673 | - | [[package]] |
|
| 2674 | - | name = "maybe-rayon" |
|
| 2675 | - | version = "0.1.1" |
|
| 2676 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2677 | - | checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" |
|
| 2678 | - | dependencies = [ |
|
| 2679 | - | "cfg-if", |
|
| 2680 | - | "rayon", |
|
| 2681 | - | ] |
|
| 2682 | - | ||
| 2683 | - | [[package]] |
|
| 2684 | - | name = "md-5" |
|
| 2685 | - | version = "0.11.0" |
|
| 2686 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2687 | - | checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" |
|
| 2688 | - | dependencies = [ |
|
| 2689 | - | "cfg-if", |
|
| 2690 | - | "digest 0.11.3", |
|
| 2691 | - | ] |
|
| 2692 | - | ||
| 2693 | - | [[package]] |
|
| 2694 | - | name = "mediatype" |
|
| 2695 | - | version = "0.19.20" |
|
| 2696 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2697 | - | checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" |
|
| 2698 | - | dependencies = [ |
|
| 2699 | - | "serde", |
|
| 2700 | - | ] |
|
| 2701 | - | ||
| 2702 | - | [[package]] |
|
| 2703 | - | name = "memchr" |
|
| 2704 | - | version = "2.8.0" |
|
| 2705 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2706 | - | checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" |
|
| 2707 | - | ||
| 2708 | - | [[package]] |
|
| 2709 | - | name = "memmem" |
|
| 2710 | - | version = "0.1.1" |
|
| 2711 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2712 | - | checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" |
|
| 2713 | - | ||
| 2714 | - | [[package]] |
|
| 2715 | - | name = "memoffset" |
|
| 2716 | - | version = "0.9.1" |
|
| 2717 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2718 | - | checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" |
|
| 2719 | - | dependencies = [ |
|
| 2720 | - | "autocfg", |
|
| 2721 | - | ] |
|
| 2722 | - | ||
| 2723 | - | [[package]] |
|
| 2724 | - | name = "mime" |
|
| 2725 | - | version = "0.3.17" |
|
| 2726 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2727 | - | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" |
|
| 2728 | - | ||
| 2729 | - | [[package]] |
|
| 2730 | - | name = "mime_guess" |
|
| 2731 | - | version = "2.0.5" |
|
| 2732 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2733 | - | checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" |
|
| 2734 | - | dependencies = [ |
|
| 2735 | - | "mime", |
|
| 2736 | - | "unicase", |
|
| 2737 | - | ] |
|
| 2738 | - | ||
| 2739 | - | [[package]] |
|
| 2740 | - | name = "minimal-lexical" |
|
| 2741 | - | version = "0.2.1" |
|
| 2742 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2743 | - | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" |
|
| 2744 | - | ||
| 2745 | - | [[package]] |
|
| 2746 | - | name = "miniz_oxide" |
|
| 2747 | - | version = "0.8.9" |
|
| 2748 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2749 | - | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" |
|
| 2750 | - | dependencies = [ |
|
| 2751 | - | "adler2", |
|
| 2752 | - | "simd-adler32", |
|
| 2753 | - | ] |
|
| 2754 | - | ||
| 2755 | - | [[package]] |
|
| 2756 | - | name = "mio" |
|
| 2757 | - | version = "1.2.0" |
|
| 2758 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2759 | - | checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" |
|
| 2760 | - | dependencies = [ |
|
| 2761 | - | "libc", |
|
| 2762 | - | "log", |
|
| 2763 | - | "wasi", |
|
| 2764 | - | "windows-sys 0.61.2", |
|
| 2765 | - | ] |
|
| 2766 | - | ||
| 2767 | - | [[package]] |
|
| 2768 | - | name = "moxcms" |
|
| 2769 | - | version = "0.8.1" |
|
| 2770 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2771 | - | checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" |
|
| 2772 | - | dependencies = [ |
|
| 2773 | - | "num-traits", |
|
| 2774 | - | "pxfm", |
|
| 2775 | - | ] |
|
| 2776 | - | ||
| 2777 | - | [[package]] |
|
| 2778 | - | name = "multer" |
|
| 2779 | - | version = "3.1.0" |
|
| 2780 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2781 | - | checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" |
|
| 2782 | - | dependencies = [ |
|
| 2783 | - | "bytes", |
|
| 2784 | - | "encoding_rs", |
|
| 2785 | - | "futures-util", |
|
| 2786 | - | "http", |
|
| 2787 | - | "httparse", |
|
| 2788 | - | "memchr", |
|
| 2789 | - | "mime", |
|
| 2790 | - | "spin", |
|
| 2791 | - | "version_check", |
|
| 2792 | - | ] |
|
| 2793 | - | ||
| 2794 | - | [[package]] |
|
| 2795 | - | name = "nanoid" |
|
| 2796 | - | version = "0.4.0" |
|
| 2797 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2798 | - | checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" |
|
| 2799 | - | dependencies = [ |
|
| 2800 | - | "rand 0.8.5", |
|
| 2801 | - | ] |
|
| 2802 | - | ||
| 2803 | - | [[package]] |
|
| 2804 | - | name = "native-tls" |
|
| 2805 | - | version = "0.2.18" |
|
| 2806 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2807 | - | checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" |
|
| 2808 | - | dependencies = [ |
|
| 2809 | - | "libc", |
|
| 2810 | - | "log", |
|
| 2811 | - | "openssl", |
|
| 2812 | - | "openssl-probe", |
|
| 2813 | - | "openssl-sys", |
|
| 2814 | - | "schannel", |
|
| 2815 | - | "security-framework", |
|
| 2816 | - | "security-framework-sys", |
|
| 2817 | - | "tempfile", |
|
| 2818 | - | ] |
|
| 2819 | - | ||
| 2820 | - | [[package]] |
|
| 2821 | - | name = "new_debug_unreachable" |
|
| 2822 | - | version = "1.0.6" |
|
| 2823 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2824 | - | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" |
|
| 2825 | - | ||
| 2826 | - | [[package]] |
|
| 2827 | - | name = "nix" |
|
| 2828 | - | version = "0.29.0" |
|
| 2829 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2830 | - | checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" |
|
| 2831 | - | dependencies = [ |
|
| 2832 | - | "bitflags 2.11.0", |
|
| 2833 | - | "cfg-if", |
|
| 2834 | - | "cfg_aliases", |
|
| 2835 | - | "libc", |
|
| 2836 | - | "memoffset", |
|
| 2837 | - | ] |
|
| 2838 | - | ||
| 2839 | - | [[package]] |
|
| 2840 | - | name = "nom" |
|
| 2841 | - | version = "7.1.3" |
|
| 2842 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2843 | - | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" |
|
| 2844 | - | dependencies = [ |
|
| 2845 | - | "memchr", |
|
| 2846 | - | "minimal-lexical", |
|
| 2847 | - | ] |
|
| 2848 | - | ||
| 2849 | - | [[package]] |
|
| 2850 | - | name = "nom" |
|
| 2851 | - | version = "8.0.0" |
|
| 2852 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2853 | - | checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" |
|
| 2854 | - | dependencies = [ |
|
| 2855 | - | "memchr", |
|
| 2856 | - | ] |
|
| 2857 | - | ||
| 2858 | - | [[package]] |
|
| 2859 | - | name = "noop_proc_macro" |
|
| 2860 | - | version = "0.3.0" |
|
| 2861 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2862 | - | checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" |
|
| 2863 | - | ||
| 2864 | - | [[package]] |
|
| 2865 | - | name = "nu-ansi-term" |
|
| 2866 | - | version = "0.50.3" |
|
| 2867 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2868 | - | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" |
|
| 2869 | - | dependencies = [ |
|
| 2870 | - | "windows-sys 0.61.2", |
|
| 2871 | - | ] |
|
| 2872 | - | ||
| 2873 | - | [[package]] |
|
| 2874 | - | name = "num-bigint" |
|
| 2875 | - | version = "0.4.6" |
|
| 2876 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2877 | - | checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" |
|
| 2878 | - | dependencies = [ |
|
| 2879 | - | "num-integer", |
|
| 2880 | - | "num-traits", |
|
| 2881 | - | ] |
|
| 2882 | - | ||
| 2883 | - | [[package]] |
|
| 2884 | - | name = "num-conv" |
|
| 2885 | - | version = "0.2.1" |
|
| 2886 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2887 | - | checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" |
|
| 2888 | - | ||
| 2889 | - | [[package]] |
|
| 2890 | - | name = "num-derive" |
|
| 2891 | - | version = "0.4.2" |
|
| 2892 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2893 | - | checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" |
|
| 2894 | - | dependencies = [ |
|
| 2895 | - | "proc-macro2", |
|
| 2896 | - | "quote", |
|
| 2897 | - | "syn 2.0.117", |
|
| 2898 | - | ] |
|
| 2899 | - | ||
| 2900 | - | [[package]] |
|
| 2901 | - | name = "num-integer" |
|
| 2902 | - | version = "0.1.46" |
|
| 2903 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2904 | - | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" |
|
| 2905 | - | dependencies = [ |
|
| 2906 | - | "num-traits", |
|
| 2907 | - | ] |
|
| 2908 | - | ||
| 2909 | - | [[package]] |
|
| 2910 | - | name = "num-rational" |
|
| 2911 | - | version = "0.4.2" |
|
| 2912 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2913 | - | checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" |
|
| 2914 | - | dependencies = [ |
|
| 2915 | - | "num-bigint", |
|
| 2916 | - | "num-integer", |
|
| 2917 | - | "num-traits", |
|
| 2918 | - | ] |
|
| 2919 | - | ||
| 2920 | - | [[package]] |
|
| 2921 | - | name = "num-traits" |
|
| 2922 | - | version = "0.2.19" |
|
| 2923 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2924 | - | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" |
|
| 2925 | - | dependencies = [ |
|
| 2926 | - | "autocfg", |
|
| 2927 | - | ] |
|
| 2928 | - | ||
| 2929 | - | [[package]] |
|
| 2930 | - | name = "num_threads" |
|
| 2931 | - | version = "0.1.7" |
|
| 2932 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2933 | - | checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" |
|
| 2934 | - | dependencies = [ |
|
| 2935 | - | "libc", |
|
| 2936 | - | ] |
|
| 2937 | - | ||
| 2938 | - | [[package]] |
|
| 2939 | - | name = "objc2" |
|
| 2940 | - | version = "0.6.4" |
|
| 2941 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2942 | - | checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" |
|
| 2943 | - | dependencies = [ |
|
| 2944 | - | "objc2-encode", |
|
| 2945 | - | ] |
|
| 2946 | - | ||
| 2947 | - | [[package]] |
|
| 2948 | - | name = "objc2-app-kit" |
|
| 2949 | - | version = "0.3.2" |
|
| 2950 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2951 | - | checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" |
|
| 2952 | - | dependencies = [ |
|
| 2953 | - | "bitflags 2.11.0", |
|
| 2954 | - | "objc2", |
|
| 2955 | - | "objc2-core-graphics", |
|
| 2956 | - | "objc2-foundation", |
|
| 2957 | - | ] |
|
| 2958 | - | ||
| 2959 | - | [[package]] |
|
| 2960 | - | name = "objc2-core-foundation" |
|
| 2961 | - | version = "0.3.2" |
|
| 2962 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2963 | - | checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" |
|
| 2964 | - | dependencies = [ |
|
| 2965 | - | "bitflags 2.11.0", |
|
| 2966 | - | "dispatch2", |
|
| 2967 | - | "objc2", |
|
| 2968 | - | ] |
|
| 2969 | - | ||
| 2970 | - | [[package]] |
|
| 2971 | - | name = "objc2-core-graphics" |
|
| 2972 | - | version = "0.3.2" |
|
| 2973 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2974 | - | checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" |
|
| 2975 | - | dependencies = [ |
|
| 2976 | - | "bitflags 2.11.0", |
|
| 2977 | - | "dispatch2", |
|
| 2978 | - | "objc2", |
|
| 2979 | - | "objc2-core-foundation", |
|
| 2980 | - | "objc2-io-surface", |
|
| 2981 | - | ] |
|
| 2982 | - | ||
| 2983 | - | [[package]] |
|
| 2984 | - | name = "objc2-encode" |
|
| 2985 | - | version = "4.1.0" |
|
| 2986 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2987 | - | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" |
|
| 2988 | - | ||
| 2989 | - | [[package]] |
|
| 2990 | - | name = "objc2-foundation" |
|
| 2991 | - | version = "0.3.2" |
|
| 2992 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2993 | - | checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" |
|
| 2994 | - | dependencies = [ |
|
| 2995 | - | "bitflags 2.11.0", |
|
| 2996 | - | "objc2", |
|
| 2997 | - | "objc2-core-foundation", |
|
| 2998 | - | ] |
|
| 2999 | - | ||
| 3000 | - | [[package]] |
|
| 3001 | - | name = "objc2-io-surface" |
|
| 3002 | - | version = "0.3.2" |
|
| 3003 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3004 | - | checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" |
|
| 3005 | - | dependencies = [ |
|
| 3006 | - | "bitflags 2.11.0", |
|
| 3007 | - | "objc2", |
|
| 3008 | - | "objc2-core-foundation", |
|
| 3009 | - | ] |
|
| 3010 | - | ||
| 3011 | - | [[package]] |
|
| 3012 | - | name = "og" |
|
| 3013 | - | version = "0.1.0" |
|
| 3014 | - | dependencies = [ |
|
| 3015 | - | "andromeda-darkmatter-css", |
|
| 3016 | - | "askama 0.13.1", |
|
| 3017 | - | "axum", |
|
| 3018 | - | "dotenvy", |
|
| 3019 | - | "reqwest 0.12.28", |
|
| 3020 | - | "rust-embed", |
|
| 3021 | - | "scraper", |
|
| 3022 | - | "serde", |
|
| 3023 | - | "serde_json", |
|
| 3024 | - | "tokio", |
|
| 3025 | - | "tower-http", |
|
| 3026 | - | "tracing", |
|
| 3027 | - | "tracing-subscriber", |
|
| 3028 | - | "url", |
|
| 3029 | - | ] |
|
| 3030 | - | ||
| 3031 | - | [[package]] |
|
| 3032 | - | name = "once_cell" |
|
| 3033 | - | version = "1.21.4" |
|
| 3034 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3035 | - | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" |
|
| 3036 | - | ||
| 3037 | - | [[package]] |
|
| 3038 | - | name = "once_cell_polyfill" |
|
| 3039 | - | version = "1.70.2" |
|
| 3040 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3041 | - | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" |
|
| 3042 | - | ||
| 3043 | - | [[package]] |
|
| 3044 | - | name = "onig" |
|
| 3045 | - | version = "6.5.1" |
|
| 3046 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3047 | - | checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" |
|
| 3048 | - | dependencies = [ |
|
| 3049 | - | "bitflags 2.11.0", |
|
| 3050 | - | "libc", |
|
| 3051 | - | "once_cell", |
|
| 3052 | - | "onig_sys", |
|
| 3053 | - | ] |
|
| 3054 | - | ||
| 3055 | - | [[package]] |
|
| 3056 | - | name = "onig_sys" |
|
| 3057 | - | version = "69.9.1" |
|
| 3058 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3059 | - | checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" |
|
| 3060 | - | dependencies = [ |
|
| 3061 | - | "cc", |
|
| 3062 | - | "pkg-config", |
|
| 3063 | - | ] |
|
| 3064 | - | ||
| 3065 | - | [[package]] |
|
| 3066 | - | name = "open" |
|
| 3067 | - | version = "5.3.3" |
|
| 3068 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3069 | - | checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" |
|
| 3070 | - | dependencies = [ |
|
| 3071 | - | "is-wsl", |
|
| 3072 | - | "libc", |
|
| 3073 | - | "pathdiff", |
|
| 3074 | - | ] |
|
| 3075 | - | ||
| 3076 | - | [[package]] |
|
| 3077 | - | name = "openssl" |
|
| 3078 | - | version = "0.10.76" |
|
| 3079 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3080 | - | checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" |
|
| 3081 | - | dependencies = [ |
|
| 3082 | - | "bitflags 2.11.0", |
|
| 3083 | - | "cfg-if", |
|
| 3084 | - | "foreign-types", |
|
| 3085 | - | "libc", |
|
| 3086 | - | "once_cell", |
|
| 3087 | - | "openssl-macros", |
|
| 3088 | - | "openssl-sys", |
|
| 3089 | - | ] |
|
| 3090 | - | ||
| 3091 | - | [[package]] |
|
| 3092 | - | name = "openssl-macros" |
|
| 3093 | - | version = "0.1.1" |
|
| 3094 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3095 | - | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" |
|
| 3096 | - | dependencies = [ |
|
| 3097 | - | "proc-macro2", |
|
| 3098 | - | "quote", |
|
| 3099 | - | "syn 2.0.117", |
|
| 3100 | - | ] |
|
| 3101 | - | ||
| 3102 | - | [[package]] |
|
| 3103 | - | name = "openssl-probe" |
|
| 3104 | - | version = "0.2.1" |
|
| 3105 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3106 | - | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" |
|
| 3107 | - | ||
| 3108 | - | [[package]] |
|
| 3109 | - | name = "openssl-sys" |
|
| 3110 | - | version = "0.9.112" |
|
| 3111 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3112 | - | checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" |
|
| 3113 | - | dependencies = [ |
|
| 3114 | - | "cc", |
|
| 3115 | - | "libc", |
|
| 3116 | - | "pkg-config", |
|
| 3117 | - | "vcpkg", |
|
| 3118 | - | ] |
|
| 3119 | - | ||
| 3120 | - | [[package]] |
|
| 3121 | - | name = "ordered-float" |
|
| 3122 | - | version = "4.6.0" |
|
| 3123 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3124 | - | checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" |
|
| 3125 | - | dependencies = [ |
|
| 3126 | - | "num-traits", |
|
| 3127 | - | ] |
|
| 3128 | - | ||
| 3129 | - | [[package]] |
|
| 3130 | - | name = "parcels" |
|
| 3131 | - | version = "0.1.3" |
|
| 3132 | - | dependencies = [ |
|
| 3133 | - | "andromeda-auth", |
|
| 3134 | - | "andromeda-darkmatter-css", |
|
| 3135 | - | "andromeda-db", |
|
| 3136 | - | "anyhow", |
|
| 3137 | - | "askama 0.12.1", |
|
| 3138 | - | "askama_axum", |
|
| 3139 | - | "axum", |
|
| 3140 | - | "rand 0.8.5", |
|
| 3141 | - | "reqwest 0.12.28", |
|
| 3142 | - | "rusqlite", |
|
| 3143 | - | "serde", |
|
| 3144 | - | "serde_json", |
|
| 3145 | - | "subtle", |
|
| 3146 | - | "tokio", |
|
| 3147 | - | "tower-http", |
|
| 3148 | - | "tracing", |
|
| 3149 | - | "tracing-subscriber", |
|
| 3150 | - | ] |
|
| 3151 | - | ||
| 3152 | - | [[package]] |
|
| 3153 | - | name = "parking_lot" |
|
| 3154 | - | version = "0.12.5" |
|
| 3155 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3156 | - | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" |
|
| 3157 | - | dependencies = [ |
|
| 3158 | - | "lock_api", |
|
| 3159 | - | "parking_lot_core", |
|
| 3160 | - | ] |
|
| 3161 | - | ||
| 3162 | - | [[package]] |
|
| 3163 | - | name = "parking_lot_core" |
|
| 3164 | - | version = "0.9.12" |
|
| 3165 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3166 | - | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" |
|
| 3167 | - | dependencies = [ |
|
| 3168 | - | "cfg-if", |
|
| 3169 | - | "libc", |
|
| 3170 | - | "redox_syscall", |
|
| 3171 | - | "smallvec", |
|
| 3172 | - | "windows-link", |
|
| 3173 | - | ] |
|
| 3174 | - | ||
| 3175 | - | [[package]] |
|
| 3176 | - | name = "paste" |
|
| 3177 | - | version = "1.0.15" |
|
| 3178 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3179 | - | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" |
|
| 3180 | - | ||
| 3181 | - | [[package]] |
|
| 3182 | - | name = "pastey" |
|
| 3183 | - | version = "0.1.1" |
|
| 3184 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3185 | - | checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" |
|
| 3186 | - | ||
| 3187 | - | [[package]] |
|
| 3188 | - | name = "pathdiff" |
|
| 3189 | - | version = "0.2.3" |
|
| 3190 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3191 | - | checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" |
|
| 3192 | - | ||
| 3193 | - | [[package]] |
|
| 3194 | - | name = "pbkdf2" |
|
| 3195 | - | version = "0.12.2" |
|
| 3196 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3197 | - | checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" |
|
| 3198 | - | dependencies = [ |
|
| 3199 | - | "digest 0.10.7", |
|
| 3200 | - | "hmac 0.12.1", |
|
| 3201 | - | ] |
|
| 3202 | - | ||
| 3203 | - | [[package]] |
|
| 3204 | - | name = "percent-encoding" |
|
| 3205 | - | version = "2.3.2" |
|
| 3206 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3207 | - | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" |
|
| 3208 | - | ||
| 3209 | - | [[package]] |
|
| 3210 | - | name = "pest" |
|
| 3211 | - | version = "2.8.6" |
|
| 3212 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3213 | - | checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" |
|
| 3214 | - | dependencies = [ |
|
| 3215 | - | "memchr", |
|
| 3216 | - | "ucd-trie", |
|
| 3217 | - | ] |
|
| 3218 | - | ||
| 3219 | - | [[package]] |
|
| 3220 | - | name = "pest_derive" |
|
| 3221 | - | version = "2.8.6" |
|
| 3222 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3223 | - | checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" |
|
| 3224 | - | dependencies = [ |
|
| 3225 | - | "pest", |
|
| 3226 | - | "pest_generator", |
|
| 3227 | - | ] |
|
| 3228 | - | ||
| 3229 | - | [[package]] |
|
| 3230 | - | name = "pest_generator" |
|
| 3231 | - | version = "2.8.6" |
|
| 3232 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3233 | - | checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" |
|
| 3234 | - | dependencies = [ |
|
| 3235 | - | "pest", |
|
| 3236 | - | "pest_meta", |
|
| 3237 | - | "proc-macro2", |
|
| 3238 | - | "quote", |
|
| 3239 | - | "syn 2.0.117", |
|
| 3240 | - | ] |
|
| 3241 | - | ||
| 3242 | - | [[package]] |
|
| 3243 | - | name = "pest_meta" |
|
| 3244 | - | version = "2.8.6" |
|
| 3245 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3246 | - | checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" |
|
| 3247 | - | dependencies = [ |
|
| 3248 | - | "pest", |
|
| 3249 | - | "sha2 0.10.9", |
|
| 3250 | - | ] |
|
| 3251 | - | ||
| 3252 | - | [[package]] |
|
| 3253 | - | name = "phf" |
|
| 3254 | - | version = "0.11.3" |
|
| 3255 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3256 | - | checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" |
|
| 3257 | - | dependencies = [ |
|
| 3258 | - | "phf_macros", |
|
| 3259 | - | "phf_shared 0.11.3", |
|
| 3260 | - | ] |
|
| 3261 | - | ||
| 3262 | - | [[package]] |
|
| 3263 | - | name = "phf" |
|
| 3264 | - | version = "0.12.1" |
|
| 3265 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3266 | - | checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" |
|
| 3267 | - | dependencies = [ |
|
| 3268 | - | "phf_shared 0.12.1", |
|
| 3269 | - | ] |
|
| 3270 | - | ||
| 3271 | - | [[package]] |
|
| 3272 | - | name = "phf_codegen" |
|
| 3273 | - | version = "0.11.3" |
|
| 3274 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3275 | - | checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" |
|
| 3276 | - | dependencies = [ |
|
| 3277 | - | "phf_generator", |
|
| 3278 | - | "phf_shared 0.11.3", |
|
| 3279 | - | ] |
|
| 3280 | - | ||
| 3281 | - | [[package]] |
|
| 3282 | - | name = "phf_generator" |
|
| 3283 | - | version = "0.11.3" |
|
| 3284 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3285 | - | checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" |
|
| 3286 | - | dependencies = [ |
|
| 3287 | - | "phf_shared 0.11.3", |
|
| 3288 | - | "rand 0.8.5", |
|
| 3289 | - | ] |
|
| 3290 | - | ||
| 3291 | - | [[package]] |
|
| 3292 | - | name = "phf_macros" |
|
| 3293 | - | version = "0.11.3" |
|
| 3294 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3295 | - | checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" |
|
| 3296 | - | dependencies = [ |
|
| 3297 | - | "phf_generator", |
|
| 3298 | - | "phf_shared 0.11.3", |
|
| 3299 | - | "proc-macro2", |
|
| 3300 | - | "quote", |
|
| 3301 | - | "syn 2.0.117", |
|
| 3302 | - | ] |
|
| 3303 | - | ||
| 3304 | - | [[package]] |
|
| 3305 | - | name = "phf_shared" |
|
| 3306 | - | version = "0.11.3" |
|
| 3307 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3308 | - | checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" |
|
| 3309 | - | dependencies = [ |
|
| 3310 | - | "siphasher", |
|
| 3311 | - | ] |
|
| 3312 | - | ||
| 3313 | - | [[package]] |
|
| 3314 | - | name = "phf_shared" |
|
| 3315 | - | version = "0.12.1" |
|
| 3316 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3317 | - | checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" |
|
| 3318 | - | dependencies = [ |
|
| 3319 | - | "siphasher", |
|
| 3320 | - | ] |
|
| 3321 | - | ||
| 3322 | - | [[package]] |
|
| 3323 | - | name = "pin-project-lite" |
|
| 3324 | - | version = "0.2.17" |
|
| 3325 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3326 | - | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" |
|
| 3327 | - | ||
| 3328 | - | [[package]] |
|
| 3329 | - | name = "pkg-config" |
|
| 3330 | - | version = "0.3.32" |
|
| 3331 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3332 | - | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" |
|
| 3333 | - | ||
| 3334 | - | [[package]] |
|
| 3335 | - | name = "plist" |
|
| 3336 | - | version = "1.8.0" |
|
| 3337 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3338 | - | checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" |
|
| 3339 | - | dependencies = [ |
|
| 3340 | - | "base64", |
|
| 3341 | - | "indexmap", |
|
| 3342 | - | "quick-xml 0.38.4", |
|
| 3343 | - | "serde", |
|
| 3344 | - | "time", |
|
| 3345 | - | ] |
|
| 3346 | - | ||
| 3347 | - | [[package]] |
|
| 3348 | - | name = "png" |
|
| 3349 | - | version = "0.18.1" |
|
| 3350 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3351 | - | checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" |
|
| 3352 | - | dependencies = [ |
|
| 3353 | - | "bitflags 2.11.0", |
|
| 3354 | - | "crc32fast", |
|
| 3355 | - | "fdeflate", |
|
| 3356 | - | "flate2", |
|
| 3357 | - | "miniz_oxide", |
|
| 3358 | - | ] |
|
| 3359 | - | ||
| 3360 | - | [[package]] |
|
| 3361 | - | name = "portable-atomic" |
|
| 3362 | - | version = "1.13.1" |
|
| 3363 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3364 | - | checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" |
|
| 3365 | - | ||
| 3366 | - | [[package]] |
|
| 3367 | - | name = "portable-atomic-util" |
|
| 3368 | - | version = "0.2.7" |
|
| 3369 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3370 | - | checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" |
|
| 3371 | - | dependencies = [ |
|
| 3372 | - | "portable-atomic", |
|
| 3373 | - | ] |
|
| 3374 | - | ||
| 3375 | - | [[package]] |
|
| 3376 | - | name = "posts" |
|
| 3377 | - | version = "0.2.0" |
|
| 3378 | - | dependencies = [ |
|
| 3379 | - | "andromeda-auth", |
|
| 3380 | - | "andromeda-darkmatter-css", |
|
| 3381 | - | "andromeda-db", |
|
| 3382 | - | "askama 0.15.6", |
|
| 3383 | - | "askama_web", |
|
| 3384 | - | "axum", |
|
| 3385 | - | "chrono", |
|
| 3386 | - | "dotenvy", |
|
| 3387 | - | "nanoid", |
|
| 3388 | - | "pulldown-cmark", |
|
| 3389 | - | "rand 0.8.5", |
|
| 3390 | - | "reqwest 0.12.28", |
|
| 3391 | - | "rusqlite", |
|
| 3392 | - | "rust-embed", |
|
| 3393 | - | "rusty-s3", |
|
| 3394 | - | "serde", |
|
| 3395 | - | "serde_json", |
|
| 3396 | - | "serde_rusqlite", |
|
| 3397 | - | "subtle", |
|
| 3398 | - | "tokio", |
|
| 3399 | - | "tower-http", |
|
| 3400 | - | "tracing", |
|
| 3401 | - | "tracing-subscriber", |
|
| 3402 | - | "url", |
|
| 3403 | - | "zip", |
|
| 3404 | - | ] |
|
| 3405 | - | ||
| 3406 | - | [[package]] |
|
| 3407 | - | name = "potential_utf" |
|
| 3408 | - | version = "0.1.5" |
|
| 3409 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3410 | - | checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" |
|
| 3411 | - | dependencies = [ |
|
| 3412 | - | "zerovec", |
|
| 3413 | - | ] |
|
| 3414 | - | ||
| 3415 | - | [[package]] |
|
| 3416 | - | name = "powerfmt" |
|
| 3417 | - | version = "0.2.0" |
|
| 3418 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3419 | - | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" |
|
| 3420 | - | ||
| 3421 | - | [[package]] |
|
| 3422 | - | name = "ppv-lite86" |
|
| 3423 | - | version = "0.2.21" |
|
| 3424 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3425 | - | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" |
|
| 3426 | - | dependencies = [ |
|
| 3427 | - | "zerocopy", |
|
| 3428 | - | ] |
|
| 3429 | - | ||
| 3430 | - | [[package]] |
|
| 3431 | - | name = "precomputed-hash" |
|
| 3432 | - | version = "0.1.1" |
|
| 3433 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3434 | - | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" |
|
| 3435 | - | ||
| 3436 | - | [[package]] |
|
| 3437 | - | name = "prettyplease" |
|
| 3438 | - | version = "0.2.37" |
|
| 3439 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3440 | - | checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" |
|
| 3441 | - | dependencies = [ |
|
| 3442 | - | "proc-macro2", |
|
| 3443 | - | "syn 2.0.117", |
|
| 3444 | - | ] |
|
| 3445 | - | ||
| 3446 | - | [[package]] |
|
| 3447 | - | name = "proc-macro2" |
|
| 3448 | - | version = "1.0.106" |
|
| 3449 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3450 | - | checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" |
|
| 3451 | - | dependencies = [ |
|
| 3452 | - | "unicode-ident", |
|
| 3453 | - | ] |
|
| 3454 | - | ||
| 3455 | - | [[package]] |
|
| 3456 | - | name = "profiling" |
|
| 3457 | - | version = "1.0.17" |
|
| 3458 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3459 | - | checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" |
|
| 3460 | - | dependencies = [ |
|
| 3461 | - | "profiling-procmacros", |
|
| 3462 | - | ] |
|
| 3463 | - | ||
| 3464 | - | [[package]] |
|
| 3465 | - | name = "profiling-procmacros" |
|
| 3466 | - | version = "1.0.17" |
|
| 3467 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3468 | - | checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" |
|
| 3469 | - | dependencies = [ |
|
| 3470 | - | "quote", |
|
| 3471 | - | "syn 2.0.117", |
|
| 3472 | - | ] |
|
| 3473 | - | ||
| 3474 | - | [[package]] |
|
| 3475 | - | name = "pulldown-cmark" |
|
| 3476 | - | version = "0.12.2" |
|
| 3477 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3478 | - | checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" |
|
| 3479 | - | dependencies = [ |
|
| 3480 | - | "bitflags 2.11.0", |
|
| 3481 | - | "getopts", |
|
| 3482 | - | "memchr", |
|
| 3483 | - | "pulldown-cmark-escape", |
|
| 3484 | - | "unicase", |
|
| 3485 | - | ] |
|
| 3486 | - | ||
| 3487 | - | [[package]] |
|
| 3488 | - | name = "pulldown-cmark-escape" |
|
| 3489 | - | version = "0.11.0" |
|
| 3490 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3491 | - | checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" |
|
| 3492 | - | ||
| 3493 | - | [[package]] |
|
| 3494 | - | name = "pxfm" |
|
| 3495 | - | version = "0.1.28" |
|
| 3496 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3497 | - | checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" |
|
| 3498 | - | ||
| 3499 | - | [[package]] |
|
| 3500 | - | name = "qoi" |
|
| 3501 | - | version = "0.4.1" |
|
| 3502 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3503 | - | checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" |
|
| 3504 | - | dependencies = [ |
|
| 3505 | - | "bytemuck", |
|
| 3506 | - | ] |
|
| 3507 | - | ||
| 3508 | - | [[package]] |
|
| 3509 | - | name = "quick-error" |
|
| 3510 | - | version = "2.0.1" |
|
| 3511 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3512 | - | checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" |
|
| 3513 | - | ||
| 3514 | - | [[package]] |
|
| 3515 | - | name = "quick-xml" |
|
| 3516 | - | version = "0.37.5" |
|
| 3517 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3518 | - | checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" |
|
| 3519 | - | dependencies = [ |
|
| 3520 | - | "encoding_rs", |
|
| 3521 | - | "memchr", |
|
| 3522 | - | ] |
|
| 3523 | - | ||
| 3524 | - | [[package]] |
|
| 3525 | - | name = "quick-xml" |
|
| 3526 | - | version = "0.38.4" |
|
| 3527 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3528 | - | checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" |
|
| 3529 | - | dependencies = [ |
|
| 3530 | - | "memchr", |
|
| 3531 | - | ] |
|
| 3532 | - | ||
| 3533 | - | [[package]] |
|
| 3534 | - | name = "quick-xml" |
|
| 3535 | - | version = "0.39.2" |
|
| 3536 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3537 | - | checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" |
|
| 3538 | - | dependencies = [ |
|
| 3539 | - | "memchr", |
|
| 3540 | - | "serde", |
|
| 3541 | - | ] |
|
| 3542 | - | ||
| 3543 | - | [[package]] |
|
| 3544 | - | name = "quinn" |
|
| 3545 | - | version = "0.11.9" |
|
| 3546 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3547 | - | checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" |
|
| 3548 | - | dependencies = [ |
|
| 3549 | - | "bytes", |
|
| 3550 | - | "cfg_aliases", |
|
| 3551 | - | "pin-project-lite", |
|
| 3552 | - | "quinn-proto", |
|
| 3553 | - | "quinn-udp", |
|
| 3554 | - | "rustc-hash", |
|
| 3555 | - | "rustls", |
|
| 3556 | - | "socket2", |
|
| 3557 | - | "thiserror 2.0.18", |
|
| 3558 | - | "tokio", |
|
| 3559 | - | "tracing", |
|
| 3560 | - | "web-time", |
|
| 3561 | - | ] |
|
| 3562 | - | ||
| 3563 | - | [[package]] |
|
| 3564 | - | name = "quinn-proto" |
|
| 3565 | - | version = "0.11.14" |
|
| 3566 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3567 | - | checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" |
|
| 3568 | - | dependencies = [ |
|
| 3569 | - | "aws-lc-rs", |
|
| 3570 | - | "bytes", |
|
| 3571 | - | "getrandom 0.3.4", |
|
| 3572 | - | "lru-slab", |
|
| 3573 | - | "rand 0.9.2", |
|
| 3574 | - | "ring", |
|
| 3575 | - | "rustc-hash", |
|
| 3576 | - | "rustls", |
|
| 3577 | - | "rustls-pki-types", |
|
| 3578 | - | "slab", |
|
| 3579 | - | "thiserror 2.0.18", |
|
| 3580 | - | "tinyvec", |
|
| 3581 | - | "tracing", |
|
| 3582 | - | "web-time", |
|
| 3583 | - | ] |
|
| 3584 | - | ||
| 3585 | - | [[package]] |
|
| 3586 | - | name = "quinn-udp" |
|
| 3587 | - | version = "0.5.14" |
|
| 3588 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3589 | - | checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" |
|
| 3590 | - | dependencies = [ |
|
| 3591 | - | "cfg_aliases", |
|
| 3592 | - | "libc", |
|
| 3593 | - | "once_cell", |
|
| 3594 | - | "socket2", |
|
| 3595 | - | "tracing", |
|
| 3596 | - | "windows-sys 0.60.2", |
|
| 3597 | - | ] |
|
| 3598 | - | ||
| 3599 | - | [[package]] |
|
| 3600 | - | name = "quote" |
|
| 3601 | - | version = "1.0.45" |
|
| 3602 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3603 | - | checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" |
|
| 3604 | - | dependencies = [ |
|
| 3605 | - | "proc-macro2", |
|
| 3606 | - | ] |
|
| 3607 | - | ||
| 3608 | - | [[package]] |
|
| 3609 | - | name = "r-efi" |
|
| 3610 | - | version = "5.3.0" |
|
| 3611 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3612 | - | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" |
|
| 3613 | - | ||
| 3614 | - | [[package]] |
|
| 3615 | - | name = "r-efi" |
|
| 3616 | - | version = "6.0.0" |
|
| 3617 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3618 | - | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" |
|
| 3619 | - | ||
| 3620 | - | [[package]] |
|
| 3621 | - | name = "rand" |
|
| 3622 | - | version = "0.8.5" |
|
| 3623 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3624 | - | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" |
|
| 3625 | - | dependencies = [ |
|
| 3626 | - | "libc", |
|
| 3627 | - | "rand_chacha 0.3.1", |
|
| 3628 | - | "rand_core 0.6.4", |
|
| 3629 | - | ] |
|
| 3630 | - | ||
| 3631 | - | [[package]] |
|
| 3632 | - | name = "rand" |
|
| 3633 | - | version = "0.9.2" |
|
| 3634 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3635 | - | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" |
|
| 3636 | - | dependencies = [ |
|
| 3637 | - | "rand_chacha 0.9.0", |
|
| 3638 | - | "rand_core 0.9.5", |
|
| 3639 | - | ] |
|
| 3640 | - | ||
| 3641 | - | [[package]] |
|
| 3642 | - | name = "rand_chacha" |
|
| 3643 | - | version = "0.3.1" |
|
| 3644 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3645 | - | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" |
|
| 3646 | - | dependencies = [ |
|
| 3647 | - | "ppv-lite86", |
|
| 3648 | - | "rand_core 0.6.4", |
|
| 3649 | - | ] |
|
| 3650 | - | ||
| 3651 | - | [[package]] |
|
| 3652 | - | name = "rand_chacha" |
|
| 3653 | - | version = "0.9.0" |
|
| 3654 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3655 | - | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" |
|
| 3656 | - | dependencies = [ |
|
| 3657 | - | "ppv-lite86", |
|
| 3658 | - | "rand_core 0.9.5", |
|
| 3659 | - | ] |
|
| 3660 | - | ||
| 3661 | - | [[package]] |
|
| 3662 | - | name = "rand_core" |
|
| 3663 | - | version = "0.6.4" |
|
| 3664 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3665 | - | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" |
|
| 3666 | - | dependencies = [ |
|
| 3667 | - | "getrandom 0.2.17", |
|
| 3668 | - | ] |
|
| 3669 | - | ||
| 3670 | - | [[package]] |
|
| 3671 | - | name = "rand_core" |
|
| 3672 | - | version = "0.9.5" |
|
| 3673 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3674 | - | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" |
|
| 3675 | - | dependencies = [ |
|
| 3676 | - | "getrandom 0.3.4", |
|
| 3677 | - | ] |
|
| 3678 | - | ||
| 3679 | - | [[package]] |
|
| 3680 | - | name = "ratatui" |
|
| 3681 | - | version = "0.30.0" |
|
| 3682 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3683 | - | checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" |
|
| 3684 | - | dependencies = [ |
|
| 3685 | - | "instability", |
|
| 3686 | - | "ratatui-core", |
|
| 3687 | - | "ratatui-crossterm", |
|
| 3688 | - | "ratatui-macros", |
|
| 3689 | - | "ratatui-termwiz", |
|
| 3690 | - | "ratatui-widgets", |
|
| 3691 | - | ] |
|
| 3692 | - | ||
| 3693 | - | [[package]] |
|
| 3694 | - | name = "ratatui-core" |
|
| 3695 | - | version = "0.1.0" |
|
| 3696 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3697 | - | checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" |
|
| 3698 | - | dependencies = [ |
|
| 3699 | - | "bitflags 2.11.0", |
|
| 3700 | - | "compact_str", |
|
| 3701 | - | "hashbrown 0.16.1", |
|
| 3702 | - | "indoc", |
|
| 3703 | - | "itertools", |
|
| 3704 | - | "kasuari", |
|
| 3705 | - | "lru", |
|
| 3706 | - | "strum", |
|
| 3707 | - | "thiserror 2.0.18", |
|
| 3708 | - | "unicode-segmentation", |
|
| 3709 | - | "unicode-truncate", |
|
| 3710 | - | "unicode-width", |
|
| 3711 | - | ] |
|
| 3712 | - | ||
| 3713 | - | [[package]] |
|
| 3714 | - | name = "ratatui-crossterm" |
|
| 3715 | - | version = "0.1.0" |
|
| 3716 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3717 | - | checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" |
|
| 3718 | - | dependencies = [ |
|
| 3719 | - | "cfg-if", |
|
| 3720 | - | "crossterm", |
|
| 3721 | - | "instability", |
|
| 3722 | - | "ratatui-core", |
|
| 3723 | - | ] |
|
| 3724 | - | ||
| 3725 | - | [[package]] |
|
| 3726 | - | name = "ratatui-macros" |
|
| 3727 | - | version = "0.7.0" |
|
| 3728 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3729 | - | checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" |
|
| 3730 | - | dependencies = [ |
|
| 3731 | - | "ratatui-core", |
|
| 3732 | - | "ratatui-widgets", |
|
| 3733 | - | ] |
|
| 3734 | - | ||
| 3735 | - | [[package]] |
|
| 3736 | - | name = "ratatui-termwiz" |
|
| 3737 | - | version = "0.1.0" |
|
| 3738 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3739 | - | checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" |
|
| 3740 | - | dependencies = [ |
|
| 3741 | - | "ratatui-core", |
|
| 3742 | - | "termwiz", |
|
| 3743 | - | ] |
|
| 3744 | - | ||
| 3745 | - | [[package]] |
|
| 3746 | - | name = "ratatui-widgets" |
|
| 3747 | - | version = "0.3.0" |
|
| 3748 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3749 | - | checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" |
|
| 3750 | - | dependencies = [ |
|
| 3751 | - | "bitflags 2.11.0", |
|
| 3752 | - | "hashbrown 0.16.1", |
|
| 3753 | - | "indoc", |
|
| 3754 | - | "instability", |
|
| 3755 | - | "itertools", |
|
| 3756 | - | "line-clipping", |
|
| 3757 | - | "ratatui-core", |
|
| 3758 | - | "strum", |
|
| 3759 | - | "time", |
|
| 3760 | - | "unicode-segmentation", |
|
| 3761 | - | "unicode-width", |
|
| 3762 | - | ] |
|
| 3763 | - | ||
| 3764 | - | [[package]] |
|
| 3765 | - | name = "rav1e" |
|
| 3766 | - | version = "0.8.1" |
|
| 3767 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3768 | - | checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" |
|
| 3769 | - | dependencies = [ |
|
| 3770 | - | "aligned-vec", |
|
| 3771 | - | "arbitrary", |
|
| 3772 | - | "arg_enum_proc_macro", |
|
| 3773 | - | "arrayvec", |
|
| 3774 | - | "av-scenechange", |
|
| 3775 | - | "av1-grain", |
|
| 3776 | - | "bitstream-io", |
|
| 3777 | - | "built", |
|
| 3778 | - | "cfg-if", |
|
| 3779 | - | "interpolate_name", |
|
| 3780 | - | "itertools", |
|
| 3781 | - | "libc", |
|
| 3782 | - | "libfuzzer-sys", |
|
| 3783 | - | "log", |
|
| 3784 | - | "maybe-rayon", |
|
| 3785 | - | "new_debug_unreachable", |
|
| 3786 | - | "noop_proc_macro", |
|
| 3787 | - | "num-derive", |
|
| 3788 | - | "num-traits", |
|
| 3789 | - | "paste", |
|
| 3790 | - | "profiling", |
|
| 3791 | - | "rand 0.9.2", |
|
| 3792 | - | "rand_chacha 0.9.0", |
|
| 3793 | - | "simd_helpers", |
|
| 3794 | - | "thiserror 2.0.18", |
|
| 3795 | - | "v_frame", |
|
| 3796 | - | "wasm-bindgen", |
|
| 3797 | - | ] |
|
| 3798 | - | ||
| 3799 | - | [[package]] |
|
| 3800 | - | name = "ravif" |
|
| 3801 | - | version = "0.13.0" |
|
| 3802 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3803 | - | checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" |
|
| 3804 | - | dependencies = [ |
|
| 3805 | - | "avif-serialize", |
|
| 3806 | - | "imgref", |
|
| 3807 | - | "loop9", |
|
| 3808 | - | "quick-error", |
|
| 3809 | - | "rav1e", |
|
| 3810 | - | "rayon", |
|
| 3811 | - | "rgb", |
|
| 3812 | - | ] |
|
| 3813 | - | ||
| 3814 | - | [[package]] |
|
| 3815 | - | name = "rayon" |
|
| 3816 | - | version = "1.11.0" |
|
| 3817 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3818 | - | checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" |
|
| 3819 | - | dependencies = [ |
|
| 3820 | - | "either", |
|
| 3821 | - | "rayon-core", |
|
| 3822 | - | ] |
|
| 3823 | - | ||
| 3824 | - | [[package]] |
|
| 3825 | - | name = "rayon-core" |
|
| 3826 | - | version = "1.13.0" |
|
| 3827 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3828 | - | checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" |
|
| 3829 | - | dependencies = [ |
|
| 3830 | - | "crossbeam-deque", |
|
| 3831 | - | "crossbeam-utils", |
|
| 3832 | - | ] |
|
| 3833 | - | ||
| 3834 | - | [[package]] |
|
| 3835 | - | name = "redox_syscall" |
|
| 3836 | - | version = "0.5.18" |
|
| 3837 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3838 | - | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" |
|
| 3839 | - | dependencies = [ |
|
| 3840 | - | "bitflags 2.11.0", |
|
| 3841 | - | ] |
|
| 3842 | - | ||
| 3843 | - | [[package]] |
|
| 3844 | - | name = "regex" |
|
| 3845 | - | version = "1.12.3" |
|
| 3846 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3847 | - | checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" |
|
| 3848 | - | dependencies = [ |
|
| 3849 | - | "aho-corasick", |
|
| 3850 | - | "memchr", |
|
| 3851 | - | "regex-automata", |
|
| 3852 | - | "regex-syntax", |
|
| 3853 | - | ] |
|
| 3854 | - | ||
| 3855 | - | [[package]] |
|
| 3856 | - | name = "regex-automata" |
|
| 3857 | - | version = "0.4.14" |
|
| 3858 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3859 | - | checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" |
|
| 3860 | - | dependencies = [ |
|
| 3861 | - | "aho-corasick", |
|
| 3862 | - | "memchr", |
|
| 3863 | - | "regex-syntax", |
|
| 3864 | - | ] |
|
| 3865 | - | ||
| 3866 | - | [[package]] |
|
| 3867 | - | name = "regex-syntax" |
|
| 3868 | - | version = "0.8.10" |
|
| 3869 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3870 | - | checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" |
|
| 3871 | - | ||
| 3872 | - | [[package]] |
|
| 3873 | - | name = "reqwest" |
|
| 3874 | - | version = "0.12.28" |
|
| 3875 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3876 | - | checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" |
|
| 3877 | - | dependencies = [ |
|
| 3878 | - | "base64", |
|
| 3879 | - | "bytes", |
|
| 3880 | - | "encoding_rs", |
|
| 3881 | - | "futures-core", |
|
| 3882 | - | "h2", |
|
| 3883 | - | "http", |
|
| 3884 | - | "http-body", |
|
| 3885 | - | "http-body-util", |
|
| 3886 | - | "hyper", |
|
| 3887 | - | "hyper-rustls", |
|
| 3888 | - | "hyper-tls", |
|
| 3889 | - | "hyper-util", |
|
| 3890 | - | "js-sys", |
|
| 3891 | - | "log", |
|
| 3892 | - | "mime", |
|
| 3893 | - | "native-tls", |
|
| 3894 | - | "percent-encoding", |
|
| 3895 | - | "pin-project-lite", |
|
| 3896 | - | "quinn", |
|
| 3897 | - | "rustls", |
|
| 3898 | - | "rustls-pki-types", |
|
| 3899 | - | "serde", |
|
| 3900 | - | "serde_json", |
|
| 3901 | - | "serde_urlencoded", |
|
| 3902 | - | "sync_wrapper", |
|
| 3903 | - | "tokio", |
|
| 3904 | - | "tokio-native-tls", |
|
| 3905 | - | "tokio-rustls", |
|
| 3906 | - | "tower", |
|
| 3907 | - | "tower-http", |
|
| 3908 | - | "tower-service", |
|
| 3909 | - | "url", |
|
| 3910 | - | "wasm-bindgen", |
|
| 3911 | - | "wasm-bindgen-futures", |
|
| 3912 | - | "web-sys", |
|
| 3913 | - | "webpki-roots", |
|
| 3914 | - | ] |
|
| 3915 | - | ||
| 3916 | - | [[package]] |
|
| 3917 | - | name = "reqwest" |
|
| 3918 | - | version = "0.13.2" |
|
| 3919 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3920 | - | checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" |
|
| 3921 | - | dependencies = [ |
|
| 3922 | - | "base64", |
|
| 3923 | - | "bytes", |
|
| 3924 | - | "encoding_rs", |
|
| 3925 | - | "futures-channel", |
|
| 3926 | - | "futures-core", |
|
| 3927 | - | "futures-util", |
|
| 3928 | - | "h2", |
|
| 3929 | - | "http", |
|
| 3930 | - | "http-body", |
|
| 3931 | - | "http-body-util", |
|
| 3932 | - | "hyper", |
|
| 3933 | - | "hyper-rustls", |
|
| 3934 | - | "hyper-util", |
|
| 3935 | - | "js-sys", |
|
| 3936 | - | "log", |
|
| 3937 | - | "mime", |
|
| 3938 | - | "percent-encoding", |
|
| 3939 | - | "pin-project-lite", |
|
| 3940 | - | "quinn", |
|
| 3941 | - | "rustls", |
|
| 3942 | - | "rustls-pki-types", |
|
| 3943 | - | "rustls-platform-verifier", |
|
| 3944 | - | "serde", |
|
| 3945 | - | "serde_json", |
|
| 3946 | - | "sync_wrapper", |
|
| 3947 | - | "tokio", |
|
| 3948 | - | "tokio-rustls", |
|
| 3949 | - | "tower", |
|
| 3950 | - | "tower-http", |
|
| 3951 | - | "tower-service", |
|
| 3952 | - | "url", |
|
| 3953 | - | "wasm-bindgen", |
|
| 3954 | - | "wasm-bindgen-futures", |
|
| 3955 | - | "web-sys", |
|
| 3956 | - | ] |
|
| 3957 | - | ||
| 3958 | - | [[package]] |
|
| 3959 | - | name = "rgb" |
|
| 3960 | - | version = "0.8.53" |
|
| 3961 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3962 | - | checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" |
|
| 3963 | - | ||
| 3964 | - | [[package]] |
|
| 3965 | - | name = "ring" |
|
| 3966 | - | version = "0.17.14" |
|
| 3967 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3968 | - | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" |
|
| 3969 | - | dependencies = [ |
|
| 3970 | - | "cc", |
|
| 3971 | - | "cfg-if", |
|
| 3972 | - | "getrandom 0.2.17", |
|
| 3973 | - | "libc", |
|
| 3974 | - | "untrusted", |
|
| 3975 | - | "windows-sys 0.52.0", |
|
| 3976 | - | ] |
|
| 3977 | - | ||
| 3978 | - | [[package]] |
|
| 3979 | - | name = "rpassword" |
|
| 3980 | - | version = "7.4.0" |
|
| 3981 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3982 | - | checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" |
|
| 3983 | - | dependencies = [ |
|
| 3984 | - | "libc", |
|
| 3985 | - | "rtoolbox", |
|
| 3986 | - | "windows-sys 0.59.0", |
|
| 3987 | - | ] |
|
| 3988 | - | ||
| 3989 | - | [[package]] |
|
| 3990 | - | name = "rsqlite-vfs" |
|
| 3991 | - | version = "0.1.0" |
|
| 3992 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3993 | - | checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" |
|
| 3994 | - | dependencies = [ |
|
| 3995 | - | "hashbrown 0.16.1", |
|
| 3996 | - | "thiserror 2.0.18", |
|
| 3997 | - | ] |
|
| 3998 | - | ||
| 3999 | - | [[package]] |
|
| 4000 | - | name = "rtoolbox" |
|
| 4001 | - | version = "0.0.3" |
|
| 4002 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4003 | - | checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" |
|
| 4004 | - | dependencies = [ |
|
| 4005 | - | "libc", |
|
| 4006 | - | "windows-sys 0.52.0", |
|
| 4007 | - | ] |
|
| 4008 | - | ||
| 4009 | - | [[package]] |
|
| 4010 | - | name = "rusqlite" |
|
| 4011 | - | version = "0.38.0" |
|
| 4012 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4013 | - | checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" |
|
| 4014 | - | dependencies = [ |
|
| 4015 | - | "bitflags 2.11.0", |
|
| 4016 | - | "fallible-iterator", |
|
| 4017 | - | "fallible-streaming-iterator", |
|
| 4018 | - | "hashlink", |
|
| 4019 | - | "libsqlite3-sys", |
|
| 4020 | - | "smallvec", |
|
| 4021 | - | "sqlite-wasm-rs", |
|
| 4022 | - | ] |
|
| 4023 | - | ||
| 4024 | - | [[package]] |
|
| 4025 | - | name = "rust-embed" |
|
| 4026 | - | version = "8.11.0" |
|
| 4027 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4028 | - | checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" |
|
| 4029 | - | dependencies = [ |
|
| 4030 | - | "rust-embed-impl", |
|
| 4031 | - | "rust-embed-utils", |
|
| 4032 | - | "walkdir", |
|
| 4033 | - | ] |
|
| 4034 | - | ||
| 4035 | - | [[package]] |
|
| 4036 | - | name = "rust-embed-impl" |
|
| 4037 | - | version = "8.11.0" |
|
| 4038 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4039 | - | checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" |
|
| 4040 | - | dependencies = [ |
|
| 4041 | - | "proc-macro2", |
|
| 4042 | - | "quote", |
|
| 4043 | - | "rust-embed-utils", |
|
| 4044 | - | "syn 2.0.117", |
|
| 4045 | - | "walkdir", |
|
| 4046 | - | ] |
|
| 4047 | - | ||
| 4048 | - | [[package]] |
|
| 4049 | - | name = "rust-embed-utils" |
|
| 4050 | - | version = "8.11.0" |
|
| 4051 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4052 | - | checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" |
|
| 4053 | - | dependencies = [ |
|
| 4054 | - | "mime_guess", |
|
| 4055 | - | "sha2 0.10.9", |
|
| 4056 | - | "walkdir", |
|
| 4057 | - | ] |
|
| 4058 | - | ||
| 4059 | - | [[package]] |
|
| 4060 | - | name = "rustc-hash" |
|
| 4061 | - | version = "2.1.2" |
|
| 4062 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4063 | - | checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" |
|
| 4064 | - | ||
| 4065 | - | [[package]] |
|
| 4066 | - | name = "rustc_version" |
|
| 4067 | - | version = "0.4.1" |
|
| 4068 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4069 | - | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" |
|
| 4070 | - | dependencies = [ |
|
| 4071 | - | "semver", |
|
| 4072 | - | ] |
|
| 4073 | - | ||
| 4074 | - | [[package]] |
|
| 4075 | - | name = "rustix" |
|
| 4076 | - | version = "1.1.4" |
|
| 4077 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4078 | - | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" |
|
| 4079 | - | dependencies = [ |
|
| 4080 | - | "bitflags 2.11.0", |
|
| 4081 | - | "errno", |
|
| 4082 | - | "libc", |
|
| 4083 | - | "linux-raw-sys", |
|
| 4084 | - | "windows-sys 0.61.2", |
|
| 4085 | - | ] |
|
| 4086 | - | ||
| 4087 | - | [[package]] |
|
| 4088 | - | name = "rustls" |
|
| 4089 | - | version = "0.23.37" |
|
| 4090 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4091 | - | checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" |
|
| 4092 | - | dependencies = [ |
|
| 4093 | - | "aws-lc-rs", |
|
| 4094 | - | "once_cell", |
|
| 4095 | - | "ring", |
|
| 4096 | - | "rustls-pki-types", |
|
| 4097 | - | "rustls-webpki", |
|
| 4098 | - | "subtle", |
|
| 4099 | - | "zeroize", |
|
| 4100 | - | ] |
|
| 4101 | - | ||
| 4102 | - | [[package]] |
|
| 4103 | - | name = "rustls-native-certs" |
|
| 4104 | - | version = "0.8.3" |
|
| 4105 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4106 | - | checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" |
|
| 4107 | - | dependencies = [ |
|
| 4108 | - | "openssl-probe", |
|
| 4109 | - | "rustls-pki-types", |
|
| 4110 | - | "schannel", |
|
| 4111 | - | "security-framework", |
|
| 4112 | - | ] |
|
| 4113 | - | ||
| 4114 | - | [[package]] |
|
| 4115 | - | name = "rustls-pki-types" |
|
| 4116 | - | version = "1.14.0" |
|
| 4117 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4118 | - | checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" |
|
| 4119 | - | dependencies = [ |
|
| 4120 | - | "web-time", |
|
| 4121 | - | "zeroize", |
|
| 4122 | - | ] |
|
| 4123 | - | ||
| 4124 | - | [[package]] |
|
| 4125 | - | name = "rustls-platform-verifier" |
|
| 4126 | - | version = "0.6.2" |
|
| 4127 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4128 | - | checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" |
|
| 4129 | - | dependencies = [ |
|
| 4130 | - | "core-foundation 0.10.1", |
|
| 4131 | - | "core-foundation-sys", |
|
| 4132 | - | "jni", |
|
| 4133 | - | "log", |
|
| 4134 | - | "once_cell", |
|
| 4135 | - | "rustls", |
|
| 4136 | - | "rustls-native-certs", |
|
| 4137 | - | "rustls-platform-verifier-android", |
|
| 4138 | - | "rustls-webpki", |
|
| 4139 | - | "security-framework", |
|
| 4140 | - | "security-framework-sys", |
|
| 4141 | - | "webpki-root-certs", |
|
| 4142 | - | "windows-sys 0.61.2", |
|
| 4143 | - | ] |
|
| 4144 | - | ||
| 4145 | - | [[package]] |
|
| 4146 | - | name = "rustls-platform-verifier-android" |
|
| 4147 | - | version = "0.1.1" |
|
| 4148 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4149 | - | checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" |
|
| 4150 | - | ||
| 4151 | - | [[package]] |
|
| 4152 | - | name = "rustls-webpki" |
|
| 4153 | - | version = "0.103.10" |
|
| 4154 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4155 | - | checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" |
|
| 4156 | - | dependencies = [ |
|
| 4157 | - | "aws-lc-rs", |
|
| 4158 | - | "ring", |
|
| 4159 | - | "rustls-pki-types", |
|
| 4160 | - | "untrusted", |
|
| 4161 | - | ] |
|
| 4162 | - | ||
| 4163 | - | [[package]] |
|
| 4164 | - | name = "rustversion" |
|
| 4165 | - | version = "1.0.22" |
|
| 4166 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4167 | - | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" |
|
| 4168 | - | ||
| 4169 | - | [[package]] |
|
| 4170 | - | name = "rusty-s3" |
|
| 4171 | - | version = "0.9.1" |
|
| 4172 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4173 | - | checksum = "15ec4851cde7bd44c6b1dbd7e68f70ac50f9dec29bb1f1ffd69426578af02d6b" |
|
| 4174 | - | dependencies = [ |
|
| 4175 | - | "base64", |
|
| 4176 | - | "hmac 0.13.0", |
|
| 4177 | - | "jiff", |
|
| 4178 | - | "md-5", |
|
| 4179 | - | "percent-encoding", |
|
| 4180 | - | "quick-xml 0.39.2", |
|
| 4181 | - | "serde", |
|
| 4182 | - | "serde_json", |
|
| 4183 | - | "sha2 0.11.0", |
|
| 4184 | - | "url", |
|
| 4185 | - | "zeroize", |
|
| 4186 | - | ] |
|
| 4187 | - | ||
| 4188 | - | [[package]] |
|
| 4189 | - | name = "ryu" |
|
| 4190 | - | version = "1.0.23" |
|
| 4191 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4192 | - | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" |
|
| 4193 | - | ||
| 4194 | - | [[package]] |
|
| 4195 | - | name = "same-file" |
|
| 4196 | - | version = "1.0.6" |
|
| 4197 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4198 | - | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" |
|
| 4199 | - | dependencies = [ |
|
| 4200 | - | "winapi-util", |
|
| 4201 | - | ] |
|
| 4202 | - | ||
| 4203 | - | [[package]] |
|
| 4204 | - | name = "schannel" |
|
| 4205 | - | version = "0.1.29" |
|
| 4206 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4207 | - | checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" |
|
| 4208 | - | dependencies = [ |
|
| 4209 | - | "windows-sys 0.61.2", |
|
| 4210 | - | ] |
|
| 4211 | - | ||
| 4212 | - | [[package]] |
|
| 4213 | - | name = "scopeguard" |
|
| 4214 | - | version = "1.2.0" |
|
| 4215 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4216 | - | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" |
|
| 4217 | - | ||
| 4218 | - | [[package]] |
|
| 4219 | - | name = "scraper" |
|
| 4220 | - | version = "0.22.0" |
|
| 4221 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4222 | - | checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15" |
|
| 4223 | - | dependencies = [ |
|
| 4224 | - | "cssparser", |
|
| 4225 | - | "ego-tree", |
|
| 4226 | - | "getopts", |
|
| 4227 | - | "html5ever", |
|
| 4228 | - | "precomputed-hash", |
|
| 4229 | - | "selectors", |
|
| 4230 | - | "tendril", |
|
| 4231 | - | ] |
|
| 4232 | - | ||
| 4233 | - | [[package]] |
|
| 4234 | - | name = "security-framework" |
|
| 4235 | - | version = "3.7.0" |
|
| 4236 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4237 | - | checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" |
|
| 4238 | - | dependencies = [ |
|
| 4239 | - | "bitflags 2.11.0", |
|
| 4240 | - | "core-foundation 0.10.1", |
|
| 4241 | - | "core-foundation-sys", |
|
| 4242 | - | "libc", |
|
| 4243 | - | "security-framework-sys", |
|
| 4244 | - | ] |
|
| 4245 | - | ||
| 4246 | - | [[package]] |
|
| 4247 | - | name = "security-framework-sys" |
|
| 4248 | - | version = "2.17.0" |
|
| 4249 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4250 | - | checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" |
|
| 4251 | - | dependencies = [ |
|
| 4252 | - | "core-foundation-sys", |
|
| 4253 | - | "libc", |
|
| 4254 | - | ] |
|
| 4255 | - | ||
| 4256 | - | [[package]] |
|
| 4257 | - | name = "selectors" |
|
| 4258 | - | version = "0.26.0" |
|
| 4259 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4260 | - | checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" |
|
| 4261 | - | dependencies = [ |
|
| 4262 | - | "bitflags 2.11.0", |
|
| 4263 | - | "cssparser", |
|
| 4264 | - | "derive_more 0.99.20", |
|
| 4265 | - | "fxhash", |
|
| 4266 | - | "log", |
|
| 4267 | - | "new_debug_unreachable", |
|
| 4268 | - | "phf 0.11.3", |
|
| 4269 | - | "phf_codegen", |
|
| 4270 | - | "precomputed-hash", |
|
| 4271 | - | "servo_arc", |
|
| 4272 | - | "smallvec", |
|
| 4273 | - | ] |
|
| 4274 | - | ||
| 4275 | - | [[package]] |
|
| 4276 | - | name = "semver" |
|
| 4277 | - | version = "1.0.27" |
|
| 4278 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4279 | - | checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" |
|
| 4280 | - | ||
| 4281 | - | [[package]] |
|
| 4282 | - | name = "serde" |
|
| 4283 | - | version = "1.0.228" |
|
| 4284 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4285 | - | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" |
|
| 4286 | - | dependencies = [ |
|
| 4287 | - | "serde_core", |
|
| 4288 | - | "serde_derive", |
|
| 4289 | - | ] |
|
| 4290 | - | ||
| 4291 | - | [[package]] |
|
| 4292 | - | name = "serde_core" |
|
| 4293 | - | version = "1.0.228" |
|
| 4294 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4295 | - | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" |
|
| 4296 | - | dependencies = [ |
|
| 4297 | - | "serde_derive", |
|
| 4298 | - | ] |
|
| 4299 | - | ||
| 4300 | - | [[package]] |
|
| 4301 | - | name = "serde_derive" |
|
| 4302 | - | version = "1.0.228" |
|
| 4303 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4304 | - | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" |
|
| 4305 | - | dependencies = [ |
|
| 4306 | - | "proc-macro2", |
|
| 4307 | - | "quote", |
|
| 4308 | - | "syn 2.0.117", |
|
| 4309 | - | ] |
|
| 4310 | - | ||
| 4311 | - | [[package]] |
|
| 4312 | - | name = "serde_json" |
|
| 4313 | - | version = "1.0.149" |
|
| 4314 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4315 | - | checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" |
|
| 4316 | - | dependencies = [ |
|
| 4317 | - | "itoa", |
|
| 4318 | - | "memchr", |
|
| 4319 | - | "serde", |
|
| 4320 | - | "serde_core", |
|
| 4321 | - | "zmij", |
|
| 4322 | - | ] |
|
| 4323 | - | ||
| 4324 | - | [[package]] |
|
| 4325 | - | name = "serde_path_to_error" |
|
| 4326 | - | version = "0.1.20" |
|
| 4327 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4328 | - | checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" |
|
| 4329 | - | dependencies = [ |
|
| 4330 | - | "itoa", |
|
| 4331 | - | "serde", |
|
| 4332 | - | "serde_core", |
|
| 4333 | - | ] |
|
| 4334 | - | ||
| 4335 | - | [[package]] |
|
| 4336 | - | name = "serde_rusqlite" |
|
| 4337 | - | version = "0.41.1" |
|
| 4338 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4339 | - | checksum = "5224145f7ea2188165a3ad4e856c4452474683cd7a72dab3325201f673a2b410" |
|
| 4340 | - | dependencies = [ |
|
| 4341 | - | "rusqlite", |
|
| 4342 | - | "serde_core", |
|
| 4343 | - | ] |
|
| 4344 | - | ||
| 4345 | - | [[package]] |
|
| 4346 | - | name = "serde_spanned" |
|
| 4347 | - | version = "1.1.1" |
|
| 4348 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4349 | - | checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" |
|
| 4350 | - | dependencies = [ |
|
| 4351 | - | "serde_core", |
|
| 4352 | - | ] |
|
| 4353 | - | ||
| 4354 | - | [[package]] |
|
| 4355 | - | name = "serde_urlencoded" |
|
| 4356 | - | version = "0.7.1" |
|
| 4357 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4358 | - | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" |
|
| 4359 | - | dependencies = [ |
|
| 4360 | - | "form_urlencoded", |
|
| 4361 | - | "itoa", |
|
| 4362 | - | "ryu", |
|
| 4363 | - | "serde", |
|
| 4364 | - | ] |
|
| 4365 | - | ||
| 4366 | - | [[package]] |
|
| 4367 | - | name = "servo_arc" |
|
| 4368 | - | version = "0.4.3" |
|
| 4369 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4370 | - | checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" |
|
| 4371 | - | dependencies = [ |
|
| 4372 | - | "stable_deref_trait", |
|
| 4373 | - | ] |
|
| 4374 | - | ||
| 4375 | - | [[package]] |
|
| 4376 | - | name = "sha1" |
|
| 4377 | - | version = "0.10.6" |
|
| 4378 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4379 | - | checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" |
|
| 4380 | - | dependencies = [ |
|
| 4381 | - | "cfg-if", |
|
| 4382 | - | "cpufeatures 0.2.17", |
|
| 4383 | - | "digest 0.10.7", |
|
| 4384 | - | ] |
|
| 4385 | - | ||
| 4386 | - | [[package]] |
|
| 4387 | - | name = "sha2" |
|
| 4388 | - | version = "0.10.9" |
|
| 4389 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4390 | - | checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" |
|
| 4391 | - | dependencies = [ |
|
| 4392 | - | "cfg-if", |
|
| 4393 | - | "cpufeatures 0.2.17", |
|
| 4394 | - | "digest 0.10.7", |
|
| 4395 | - | ] |
|
| 4396 | - | ||
| 4397 | - | [[package]] |
|
| 4398 | - | name = "sha2" |
|
| 4399 | - | version = "0.11.0" |
|
| 4400 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4401 | - | checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" |
|
| 4402 | - | dependencies = [ |
|
| 4403 | - | "cfg-if", |
|
| 4404 | - | "cpufeatures 0.3.0", |
|
| 4405 | - | "digest 0.11.3", |
|
| 4406 | - | ] |
|
| 4407 | - | ||
| 4408 | - | [[package]] |
|
| 4409 | - | name = "sharded-slab" |
|
| 4410 | - | version = "0.1.7" |
|
| 4411 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4412 | - | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" |
|
| 4413 | - | dependencies = [ |
|
| 4414 | - | "lazy_static", |
|
| 4415 | - | ] |
|
| 4416 | - | ||
| 4417 | - | [[package]] |
|
| 4418 | - | name = "shlex" |
|
| 4419 | - | version = "1.3.0" |
|
| 4420 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4421 | - | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" |
|
| 4422 | - | ||
| 4423 | - | [[package]] |
|
| 4424 | - | name = "shrink" |
|
| 4425 | - | version = "0.1.2" |
|
| 4426 | - | dependencies = [ |
|
| 4427 | - | "andromeda-darkmatter-css", |
|
| 4428 | - | "askama 0.15.6", |
|
| 4429 | - | "axum", |
|
| 4430 | - | "image", |
|
| 4431 | - | "img-parts", |
|
| 4432 | - | "serde", |
|
| 4433 | - | "tokio", |
|
| 4434 | - | "tower-http", |
|
| 4435 | - | "tracing", |
|
| 4436 | - | "tracing-subscriber", |
|
| 4437 | - | ] |
|
| 4438 | - | ||
| 4439 | - | [[package]] |
|
| 4440 | - | name = "signal-hook" |
|
| 4441 | - | version = "0.3.18" |
|
| 4442 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4443 | - | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" |
|
| 4444 | - | dependencies = [ |
|
| 4445 | - | "libc", |
|
| 4446 | - | "signal-hook-registry", |
|
| 4447 | - | ] |
|
| 4448 | - | ||
| 4449 | - | [[package]] |
|
| 4450 | - | name = "signal-hook-mio" |
|
| 4451 | - | version = "0.2.5" |
|
| 4452 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4453 | - | checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" |
|
| 4454 | - | dependencies = [ |
|
| 4455 | - | "libc", |
|
| 4456 | - | "mio", |
|
| 4457 | - | "signal-hook", |
|
| 4458 | - | ] |
|
| 4459 | - | ||
| 4460 | - | [[package]] |
|
| 4461 | - | name = "signal-hook-registry" |
|
| 4462 | - | version = "1.4.8" |
|
| 4463 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4464 | - | checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" |
|
| 4465 | - | dependencies = [ |
|
| 4466 | - | "errno", |
|
| 4467 | - | "libc", |
|
| 4468 | - | ] |
|
| 4469 | - | ||
| 4470 | - | [[package]] |
|
| 4471 | - | name = "simd-adler32" |
|
| 4472 | - | version = "0.3.9" |
|
| 4473 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4474 | - | checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" |
|
| 4475 | - | ||
| 4476 | - | [[package]] |
|
| 4477 | - | name = "simd_helpers" |
|
| 4478 | - | version = "0.1.0" |
|
| 4479 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4480 | - | checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" |
|
| 4481 | - | dependencies = [ |
|
| 4482 | - | "quote", |
|
| 4483 | - | ] |
|
| 4484 | - | ||
| 4485 | - | [[package]] |
|
| 4486 | - | name = "siphasher" |
|
| 4487 | - | version = "1.0.2" |
|
| 4488 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4489 | - | checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" |
|
| 4490 | - | ||
| 4491 | - | [[package]] |
|
| 4492 | - | name = "sipp-so" |
|
| 4493 | - | version = "0.2.0" |
|
| 4494 | - | dependencies = [ |
|
| 4495 | - | "andromeda-auth", |
|
| 4496 | - | "andromeda-darkmatter-css", |
|
| 4497 | - | "andromeda-db", |
|
| 4498 | - | "arboard", |
|
| 4499 | - | "askama 0.15.6", |
|
| 4500 | - | "askama_web", |
|
| 4501 | - | "axum", |
|
| 4502 | - | "clap", |
|
| 4503 | - | "crossterm", |
|
| 4504 | - | "dotenvy", |
|
| 4505 | - | "nanoid", |
|
| 4506 | - | "open", |
|
| 4507 | - | "ratatui", |
|
| 4508 | - | "reqwest 0.13.2", |
|
| 4509 | - | "rpassword", |
|
| 4510 | - | "rusqlite", |
|
| 4511 | - | "rust-embed", |
|
| 4512 | - | "serde", |
|
| 4513 | - | "serde_json", |
|
| 4514 | - | "subtle", |
|
| 4515 | - | "syntect", |
|
| 4516 | - | "tempfile", |
|
| 4517 | - | "tokio", |
|
| 4518 | - | "toml", |
|
| 4519 | - | "tower-http", |
|
| 4520 | - | ] |
|
| 4521 | - | ||
| 4522 | - | [[package]] |
|
| 4523 | - | name = "slab" |
|
| 4524 | - | version = "0.4.12" |
|
| 4525 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4526 | - | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" |
|
| 4527 | - | ||
| 4528 | - | [[package]] |
|
| 4529 | - | name = "smallvec" |
|
| 4530 | - | version = "1.15.1" |
|
| 4531 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4532 | - | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" |
|
| 4533 | - | ||
| 4534 | - | [[package]] |
|
| 4535 | - | name = "socket2" |
|
| 4536 | - | version = "0.6.3" |
|
| 4537 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4538 | - | checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" |
|
| 4539 | - | dependencies = [ |
|
| 4540 | - | "libc", |
|
| 4541 | - | "windows-sys 0.61.2", |
|
| 4542 | - | ] |
|
| 4543 | - | ||
| 4544 | - | [[package]] |
|
| 4545 | - | name = "spin" |
|
| 4546 | - | version = "0.9.8" |
|
| 4547 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4548 | - | checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" |
|
| 4549 | - | ||
| 4550 | - | [[package]] |
|
| 4551 | - | name = "sqlite-wasm-rs" |
|
| 4552 | - | version = "0.5.2" |
|
| 4553 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4554 | - | checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" |
|
| 4555 | - | dependencies = [ |
|
| 4556 | - | "cc", |
|
| 4557 | - | "js-sys", |
|
| 4558 | - | "rsqlite-vfs", |
|
| 4559 | - | "wasm-bindgen", |
|
| 4560 | - | ] |
|
| 4561 | - | ||
| 4562 | - | [[package]] |
|
| 4563 | - | name = "stable_deref_trait" |
|
| 4564 | - | version = "1.2.1" |
|
| 4565 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4566 | - | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" |
|
| 4567 | - | ||
| 4568 | - | [[package]] |
|
| 4569 | - | name = "static_assertions" |
|
| 4570 | - | version = "1.1.0" |
|
| 4571 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4572 | - | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" |
|
| 4573 | - | ||
| 4574 | - | [[package]] |
|
| 4575 | - | name = "string_cache" |
|
| 4576 | - | version = "0.8.9" |
|
| 4577 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4578 | - | checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" |
|
| 4579 | - | dependencies = [ |
|
| 4580 | - | "new_debug_unreachable", |
|
| 4581 | - | "parking_lot", |
|
| 4582 | - | "phf_shared 0.11.3", |
|
| 4583 | - | "precomputed-hash", |
|
| 4584 | - | "serde", |
|
| 4585 | - | ] |
|
| 4586 | - | ||
| 4587 | - | [[package]] |
|
| 4588 | - | name = "string_cache_codegen" |
|
| 4589 | - | version = "0.5.4" |
|
| 4590 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4591 | - | checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" |
|
| 4592 | - | dependencies = [ |
|
| 4593 | - | "phf_generator", |
|
| 4594 | - | "phf_shared 0.11.3", |
|
| 4595 | - | "proc-macro2", |
|
| 4596 | - | "quote", |
|
| 4597 | - | ] |
|
| 4598 | - | ||
| 4599 | - | [[package]] |
|
| 4600 | - | name = "strsim" |
|
| 4601 | - | version = "0.11.1" |
|
| 4602 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4603 | - | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" |
|
| 4604 | - | ||
| 4605 | - | [[package]] |
|
| 4606 | - | name = "strum" |
|
| 4607 | - | version = "0.27.2" |
|
| 4608 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4609 | - | checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" |
|
| 4610 | - | dependencies = [ |
|
| 4611 | - | "strum_macros", |
|
| 4612 | - | ] |
|
| 4613 | - | ||
| 4614 | - | [[package]] |
|
| 4615 | - | name = "strum_macros" |
|
| 4616 | - | version = "0.27.2" |
|
| 4617 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4618 | - | checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" |
|
| 4619 | - | dependencies = [ |
|
| 4620 | - | "heck", |
|
| 4621 | - | "proc-macro2", |
|
| 4622 | - | "quote", |
|
| 4623 | - | "syn 2.0.117", |
|
| 4624 | - | ] |
|
| 4625 | - | ||
| 4626 | - | [[package]] |
|
| 4627 | - | name = "subtle" |
|
| 4628 | - | version = "2.6.1" |
|
| 4629 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4630 | - | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" |
|
| 4631 | - | ||
| 4632 | - | [[package]] |
|
| 4633 | - | name = "syn" |
|
| 4634 | - | version = "1.0.109" |
|
| 4635 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4636 | - | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" |
|
| 4637 | - | dependencies = [ |
|
| 4638 | - | "proc-macro2", |
|
| 4639 | - | "quote", |
|
| 4640 | - | "unicode-ident", |
|
| 4641 | - | ] |
|
| 4642 | - | ||
| 4643 | - | [[package]] |
|
| 4644 | - | name = "syn" |
|
| 4645 | - | version = "2.0.117" |
|
| 4646 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4647 | - | checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" |
|
| 4648 | - | dependencies = [ |
|
| 4649 | - | "proc-macro2", |
|
| 4650 | - | "quote", |
|
| 4651 | - | "unicode-ident", |
|
| 4652 | - | ] |
|
| 4653 | - | ||
| 4654 | - | [[package]] |
|
| 4655 | - | name = "sync_wrapper" |
|
| 4656 | - | version = "1.0.2" |
|
| 4657 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4658 | - | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" |
|
| 4659 | - | dependencies = [ |
|
| 4660 | - | "futures-core", |
|
| 4661 | - | ] |
|
| 4662 | - | ||
| 4663 | - | [[package]] |
|
| 4664 | - | name = "synstructure" |
|
| 4665 | - | version = "0.13.2" |
|
| 4666 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4667 | - | checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" |
|
| 4668 | - | dependencies = [ |
|
| 4669 | - | "proc-macro2", |
|
| 4670 | - | "quote", |
|
| 4671 | - | "syn 2.0.117", |
|
| 4672 | - | ] |
|
| 4673 | - | ||
| 4674 | - | [[package]] |
|
| 4675 | - | name = "syntect" |
|
| 4676 | - | version = "5.3.0" |
|
| 4677 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4678 | - | checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" |
|
| 4679 | - | dependencies = [ |
|
| 4680 | - | "bincode", |
|
| 4681 | - | "flate2", |
|
| 4682 | - | "fnv", |
|
| 4683 | - | "once_cell", |
|
| 4684 | - | "onig", |
|
| 4685 | - | "plist", |
|
| 4686 | - | "regex-syntax", |
|
| 4687 | - | "serde", |
|
| 4688 | - | "serde_derive", |
|
| 4689 | - | "serde_json", |
|
| 4690 | - | "thiserror 2.0.18", |
|
| 4691 | - | "walkdir", |
|
| 4692 | - | "yaml-rust", |
|
| 4693 | - | ] |
|
| 4694 | - | ||
| 4695 | - | [[package]] |
|
| 4696 | - | name = "system-configuration" |
|
| 4697 | - | version = "0.7.0" |
|
| 4698 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4699 | - | checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" |
|
| 4700 | - | dependencies = [ |
|
| 4701 | - | "bitflags 2.11.0", |
|
| 4702 | - | "core-foundation 0.9.4", |
|
| 4703 | - | "system-configuration-sys", |
|
| 4704 | - | ] |
|
| 4705 | - | ||
| 4706 | - | [[package]] |
|
| 4707 | - | name = "system-configuration-sys" |
|
| 4708 | - | version = "0.6.0" |
|
| 4709 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4710 | - | checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" |
|
| 4711 | - | dependencies = [ |
|
| 4712 | - | "core-foundation-sys", |
|
| 4713 | - | "libc", |
|
| 4714 | - | ] |
|
| 4715 | - | ||
| 4716 | - | [[package]] |
|
| 4717 | - | name = "tempfile" |
|
| 4718 | - | version = "3.27.0" |
|
| 4719 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4720 | - | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" |
|
| 4721 | - | dependencies = [ |
|
| 4722 | - | "fastrand", |
|
| 4723 | - | "getrandom 0.4.2", |
|
| 4724 | - | "once_cell", |
|
| 4725 | - | "rustix", |
|
| 4726 | - | "windows-sys 0.61.2", |
|
| 4727 | - | ] |
|
| 4728 | - | ||
| 4729 | - | [[package]] |
|
| 4730 | - | name = "tendril" |
|
| 4731 | - | version = "0.4.3" |
|
| 4732 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4733 | - | checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" |
|
| 4734 | - | dependencies = [ |
|
| 4735 | - | "futf", |
|
| 4736 | - | "mac", |
|
| 4737 | - | "utf-8", |
|
| 4738 | - | ] |
|
| 4739 | - | ||
| 4740 | - | [[package]] |
|
| 4741 | - | name = "terminfo" |
|
| 4742 | - | version = "0.9.0" |
|
| 4743 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4744 | - | checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" |
|
| 4745 | - | dependencies = [ |
|
| 4746 | - | "fnv", |
|
| 4747 | - | "nom 7.1.3", |
|
| 4748 | - | "phf 0.11.3", |
|
| 4749 | - | "phf_codegen", |
|
| 4750 | - | ] |
|
| 4751 | - | ||
| 4752 | - | [[package]] |
|
| 4753 | - | name = "termios" |
|
| 4754 | - | version = "0.3.3" |
|
| 4755 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4756 | - | checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" |
|
| 4757 | - | dependencies = [ |
|
| 4758 | - | "libc", |
|
| 4759 | - | ] |
|
| 4760 | - | ||
| 4761 | - | [[package]] |
|
| 4762 | - | name = "termwiz" |
|
| 4763 | - | version = "0.23.3" |
|
| 4764 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4765 | - | checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" |
|
| 4766 | - | dependencies = [ |
|
| 4767 | - | "anyhow", |
|
| 4768 | - | "base64", |
|
| 4769 | - | "bitflags 2.11.0", |
|
| 4770 | - | "fancy-regex", |
|
| 4771 | - | "filedescriptor", |
|
| 4772 | - | "finl_unicode", |
|
| 4773 | - | "fixedbitset", |
|
| 4774 | - | "hex", |
|
| 4775 | - | "lazy_static", |
|
| 4776 | - | "libc", |
|
| 4777 | - | "log", |
|
| 4778 | - | "memmem", |
|
| 4779 | - | "nix", |
|
| 4780 | - | "num-derive", |
|
| 4781 | - | "num-traits", |
|
| 4782 | - | "ordered-float", |
|
| 4783 | - | "pest", |
|
| 4784 | - | "pest_derive", |
|
| 4785 | - | "phf 0.11.3", |
|
| 4786 | - | "sha2 0.10.9", |
|
| 4787 | - | "signal-hook", |
|
| 4788 | - | "siphasher", |
|
| 4789 | - | "terminfo", |
|
| 4790 | - | "termios", |
|
| 4791 | - | "thiserror 1.0.69", |
|
| 4792 | - | "ucd-trie", |
|
| 4793 | - | "unicode-segmentation", |
|
| 4794 | - | "vtparse", |
|
| 4795 | - | "wezterm-bidi", |
|
| 4796 | - | "wezterm-blob-leases", |
|
| 4797 | - | "wezterm-color-types", |
|
| 4798 | - | "wezterm-dynamic", |
|
| 4799 | - | "wezterm-input-types", |
|
| 4800 | - | "winapi", |
|
| 4801 | - | ] |
|
| 4802 | - | ||
| 4803 | - | [[package]] |
|
| 4804 | - | name = "thiserror" |
|
| 4805 | - | version = "1.0.69" |
|
| 4806 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4807 | - | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" |
|
| 4808 | - | dependencies = [ |
|
| 4809 | - | "thiserror-impl 1.0.69", |
|
| 4810 | - | ] |
|
| 4811 | - | ||
| 4812 | - | [[package]] |
|
| 4813 | - | name = "thiserror" |
|
| 4814 | - | version = "2.0.18" |
|
| 4815 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4816 | - | checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" |
|
| 4817 | - | dependencies = [ |
|
| 4818 | - | "thiserror-impl 2.0.18", |
|
| 4819 | - | ] |
|
| 4820 | - | ||
| 4821 | - | [[package]] |
|
| 4822 | - | name = "thiserror-impl" |
|
| 4823 | - | version = "1.0.69" |
|
| 4824 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4825 | - | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" |
|
| 4826 | - | dependencies = [ |
|
| 4827 | - | "proc-macro2", |
|
| 4828 | - | "quote", |
|
| 4829 | - | "syn 2.0.117", |
|
| 4830 | - | ] |
|
| 4831 | - | ||
| 4832 | - | [[package]] |
|
| 4833 | - | name = "thiserror-impl" |
|
| 4834 | - | version = "2.0.18" |
|
| 4835 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4836 | - | checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" |
|
| 4837 | - | dependencies = [ |
|
| 4838 | - | "proc-macro2", |
|
| 4839 | - | "quote", |
|
| 4840 | - | "syn 2.0.117", |
|
| 4841 | - | ] |
|
| 4842 | - | ||
| 4843 | - | [[package]] |
|
| 4844 | - | name = "thread_local" |
|
| 4845 | - | version = "1.1.9" |
|
| 4846 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4847 | - | checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" |
|
| 4848 | - | dependencies = [ |
|
| 4849 | - | "cfg-if", |
|
| 4850 | - | ] |
|
| 4851 | - | ||
| 4852 | - | [[package]] |
|
| 4853 | - | name = "tiff" |
|
| 4854 | - | version = "0.11.3" |
|
| 4855 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4856 | - | checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" |
|
| 4857 | - | dependencies = [ |
|
| 4858 | - | "fax", |
|
| 4859 | - | "flate2", |
|
| 4860 | - | "half", |
|
| 4861 | - | "quick-error", |
|
| 4862 | - | "weezl", |
|
| 4863 | - | "zune-jpeg", |
|
| 4864 | - | ] |
|
| 4865 | - | ||
| 4866 | - | [[package]] |
|
| 4867 | - | name = "time" |
|
| 4868 | - | version = "0.3.47" |
|
| 4869 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4870 | - | checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" |
|
| 4871 | - | dependencies = [ |
|
| 4872 | - | "deranged", |
|
| 4873 | - | "itoa", |
|
| 4874 | - | "libc", |
|
| 4875 | - | "num-conv", |
|
| 4876 | - | "num_threads", |
|
| 4877 | - | "powerfmt", |
|
| 4878 | - | "serde_core", |
|
| 4879 | - | "time-core", |
|
| 4880 | - | "time-macros", |
|
| 4881 | - | ] |
|
| 4882 | - | ||
| 4883 | - | [[package]] |
|
| 4884 | - | name = "time-core" |
|
| 4885 | - | version = "0.1.8" |
|
| 4886 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4887 | - | checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" |
|
| 4888 | - | ||
| 4889 | - | [[package]] |
|
| 4890 | - | name = "time-macros" |
|
| 4891 | - | version = "0.2.27" |
|
| 4892 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4893 | - | checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" |
|
| 4894 | - | dependencies = [ |
|
| 4895 | - | "num-conv", |
|
| 4896 | - | "time-core", |
|
| 4897 | - | ] |
|
| 4898 | - | ||
| 4899 | - | [[package]] |
|
| 4900 | - | name = "tinystr" |
|
| 4901 | - | version = "0.8.3" |
|
| 4902 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4903 | - | checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" |
|
| 4904 | - | dependencies = [ |
|
| 4905 | - | "displaydoc", |
|
| 4906 | - | "zerovec", |
|
| 4907 | - | ] |
|
| 4908 | - | ||
| 4909 | - | [[package]] |
|
| 4910 | - | name = "tinyvec" |
|
| 4911 | - | version = "1.11.0" |
|
| 4912 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4913 | - | checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" |
|
| 4914 | - | dependencies = [ |
|
| 4915 | - | "tinyvec_macros", |
|
| 4916 | - | ] |
|
| 4917 | - | ||
| 4918 | - | [[package]] |
|
| 4919 | - | name = "tinyvec_macros" |
|
| 4920 | - | version = "0.1.1" |
|
| 4921 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4922 | - | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" |
|
| 4923 | - | ||
| 4924 | - | [[package]] |
|
| 4925 | - | name = "tokio" |
|
| 4926 | - | version = "1.50.0" |
|
| 4927 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4928 | - | checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" |
|
| 4929 | - | dependencies = [ |
|
| 4930 | - | "bytes", |
|
| 4931 | - | "libc", |
|
| 4932 | - | "mio", |
|
| 4933 | - | "parking_lot", |
|
| 4934 | - | "pin-project-lite", |
|
| 4935 | - | "signal-hook-registry", |
|
| 4936 | - | "socket2", |
|
| 4937 | - | "tokio-macros", |
|
| 4938 | - | "windows-sys 0.61.2", |
|
| 4939 | - | ] |
|
| 4940 | - | ||
| 4941 | - | [[package]] |
|
| 4942 | - | name = "tokio-macros" |
|
| 4943 | - | version = "2.6.1" |
|
| 4944 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4945 | - | checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" |
|
| 4946 | - | dependencies = [ |
|
| 4947 | - | "proc-macro2", |
|
| 4948 | - | "quote", |
|
| 4949 | - | "syn 2.0.117", |
|
| 4950 | - | ] |
|
| 4951 | - | ||
| 4952 | - | [[package]] |
|
| 4953 | - | name = "tokio-native-tls" |
|
| 4954 | - | version = "0.3.1" |
|
| 4955 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4956 | - | checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" |
|
| 4957 | - | dependencies = [ |
|
| 4958 | - | "native-tls", |
|
| 4959 | - | "tokio", |
|
| 4960 | - | ] |
|
| 4961 | - | ||
| 4962 | - | [[package]] |
|
| 4963 | - | name = "tokio-rustls" |
|
| 4964 | - | version = "0.26.4" |
|
| 4965 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4966 | - | checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" |
|
| 4967 | - | dependencies = [ |
|
| 4968 | - | "rustls", |
|
| 4969 | - | "tokio", |
|
| 4970 | - | ] |
|
| 4971 | - | ||
| 4972 | - | [[package]] |
|
| 4973 | - | name = "tokio-util" |
|
| 4974 | - | version = "0.7.18" |
|
| 4975 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4976 | - | checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" |
|
| 4977 | - | dependencies = [ |
|
| 4978 | - | "bytes", |
|
| 4979 | - | "futures-core", |
|
| 4980 | - | "futures-sink", |
|
| 4981 | - | "pin-project-lite", |
|
| 4982 | - | "tokio", |
|
| 4983 | - | ] |
|
| 4984 | - | ||
| 4985 | - | [[package]] |
|
| 4986 | - | name = "toml" |
|
| 4987 | - | version = "1.1.2+spec-1.1.0" |
|
| 4988 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4989 | - | checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" |
|
| 4990 | - | dependencies = [ |
|
| 4991 | - | "indexmap", |
|
| 4992 | - | "serde_core", |
|
| 4993 | - | "serde_spanned", |
|
| 4994 | - | "toml_datetime", |
|
| 4995 | - | "toml_parser", |
|
| 4996 | - | "toml_writer", |
|
| 4997 | - | "winnow 1.0.1", |
|
| 4998 | - | ] |
|
| 4999 | - | ||
| 5000 | - | [[package]] |
|
| 5001 | - | name = "toml_datetime" |
|
| 5002 | - | version = "1.1.1+spec-1.1.0" |
|
| 5003 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5004 | - | checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" |
|
| 5005 | - | dependencies = [ |
|
| 5006 | - | "serde_core", |
|
| 5007 | - | ] |
|
| 5008 | - | ||
| 5009 | - | [[package]] |
|
| 5010 | - | name = "toml_parser" |
|
| 5011 | - | version = "1.1.2+spec-1.1.0" |
|
| 5012 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5013 | - | checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" |
|
| 5014 | - | dependencies = [ |
|
| 5015 | - | "winnow 1.0.1", |
|
| 5016 | - | ] |
|
| 5017 | - | ||
| 5018 | - | [[package]] |
|
| 5019 | - | name = "toml_writer" |
|
| 5020 | - | version = "1.1.1+spec-1.1.0" |
|
| 5021 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5022 | - | checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" |
|
| 5023 | - | ||
| 5024 | - | [[package]] |
|
| 5025 | - | name = "tower" |
|
| 5026 | - | version = "0.5.3" |
|
| 5027 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5028 | - | checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" |
|
| 5029 | - | dependencies = [ |
|
| 5030 | - | "futures-core", |
|
| 5031 | - | "futures-util", |
|
| 5032 | - | "pin-project-lite", |
|
| 5033 | - | "sync_wrapper", |
|
| 5034 | - | "tokio", |
|
| 5035 | - | "tower-layer", |
|
| 5036 | - | "tower-service", |
|
| 5037 | - | "tracing", |
|
| 5038 | - | ] |
|
| 5039 | - | ||
| 5040 | - | [[package]] |
|
| 5041 | - | name = "tower-http" |
|
| 5042 | - | version = "0.6.8" |
|
| 5043 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5044 | - | checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" |
|
| 5045 | - | dependencies = [ |
|
| 5046 | - | "bitflags 2.11.0", |
|
| 5047 | - | "bytes", |
|
| 5048 | - | "futures-core", |
|
| 5049 | - | "futures-util", |
|
| 5050 | - | "http", |
|
| 5051 | - | "http-body", |
|
| 5052 | - | "http-body-util", |
|
| 5053 | - | "http-range-header", |
|
| 5054 | - | "httpdate", |
|
| 5055 | - | "iri-string", |
|
| 5056 | - | "mime", |
|
| 5057 | - | "mime_guess", |
|
| 5058 | - | "percent-encoding", |
|
| 5059 | - | "pin-project-lite", |
|
| 5060 | - | "tokio", |
|
| 5061 | - | "tokio-util", |
|
| 5062 | - | "tower", |
|
| 5063 | - | "tower-layer", |
|
| 5064 | - | "tower-service", |
|
| 5065 | - | "tracing", |
|
| 5066 | - | ] |
|
| 5067 | - | ||
| 5068 | - | [[package]] |
|
| 5069 | - | name = "tower-layer" |
|
| 5070 | - | version = "0.3.3" |
|
| 5071 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5072 | - | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" |
|
| 5073 | - | ||
| 5074 | - | [[package]] |
|
| 5075 | - | name = "tower-service" |
|
| 5076 | - | version = "0.3.3" |
|
| 5077 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5078 | - | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" |
|
| 5079 | - | ||
| 5080 | - | [[package]] |
|
| 5081 | - | name = "tracing" |
|
| 5082 | - | version = "0.1.44" |
|
| 5083 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5084 | - | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" |
|
| 5085 | - | dependencies = [ |
|
| 5086 | - | "log", |
|
| 5087 | - | "pin-project-lite", |
|
| 5088 | - | "tracing-attributes", |
|
| 5089 | - | "tracing-core", |
|
| 5090 | - | ] |
|
| 5091 | - | ||
| 5092 | - | [[package]] |
|
| 5093 | - | name = "tracing-attributes" |
|
| 5094 | - | version = "0.1.31" |
|
| 5095 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5096 | - | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" |
|
| 5097 | - | dependencies = [ |
|
| 5098 | - | "proc-macro2", |
|
| 5099 | - | "quote", |
|
| 5100 | - | "syn 2.0.117", |
|
| 5101 | - | ] |
|
| 5102 | - | ||
| 5103 | - | [[package]] |
|
| 5104 | - | name = "tracing-core" |
|
| 5105 | - | version = "0.1.36" |
|
| 5106 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5107 | - | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" |
|
| 5108 | - | dependencies = [ |
|
| 5109 | - | "once_cell", |
|
| 5110 | - | "valuable", |
|
| 5111 | - | ] |
|
| 5112 | - | ||
| 5113 | - | [[package]] |
|
| 5114 | - | name = "tracing-log" |
|
| 5115 | - | version = "0.2.0" |
|
| 5116 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5117 | - | checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" |
|
| 5118 | - | dependencies = [ |
|
| 5119 | - | "log", |
|
| 5120 | - | "once_cell", |
|
| 5121 | - | "tracing-core", |
|
| 5122 | - | ] |
|
| 5123 | - | ||
| 5124 | - | [[package]] |
|
| 5125 | - | name = "tracing-subscriber" |
|
| 5126 | - | version = "0.3.23" |
|
| 5127 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5128 | - | checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" |
|
| 5129 | - | dependencies = [ |
|
| 5130 | - | "matchers", |
|
| 5131 | - | "nu-ansi-term", |
|
| 5132 | - | "once_cell", |
|
| 5133 | - | "regex-automata", |
|
| 5134 | - | "sharded-slab", |
|
| 5135 | - | "smallvec", |
|
| 5136 | - | "thread_local", |
|
| 5137 | - | "tracing", |
|
| 5138 | - | "tracing-core", |
|
| 5139 | - | "tracing-log", |
|
| 5140 | - | ] |
|
| 5141 | - | ||
| 5142 | - | [[package]] |
|
| 5143 | - | name = "try-lock" |
|
| 5144 | - | version = "0.2.5" |
|
| 5145 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5146 | - | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" |
|
| 5147 | - | ||
| 5148 | - | [[package]] |
|
| 5149 | - | name = "typenum" |
|
| 5150 | - | version = "1.20.0" |
|
| 5151 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5152 | - | checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" |
|
| 5153 | - | ||
| 5154 | - | [[package]] |
|
| 5155 | - | name = "ucd-trie" |
|
| 5156 | - | version = "0.1.7" |
|
| 5157 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5158 | - | checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" |
|
| 5159 | - | ||
| 5160 | - | [[package]] |
|
| 5161 | - | name = "unicase" |
|
| 5162 | - | version = "2.9.0" |
|
| 5163 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5164 | - | checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" |
|
| 5165 | - | ||
| 5166 | - | [[package]] |
|
| 5167 | - | name = "unicode-ident" |
|
| 5168 | - | version = "1.0.24" |
|
| 5169 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5170 | - | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" |
|
| 5171 | - | ||
| 5172 | - | [[package]] |
|
| 5173 | - | name = "unicode-segmentation" |
|
| 5174 | - | version = "1.13.2" |
|
| 5175 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5176 | - | checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" |
|
| 5177 | - | ||
| 5178 | - | [[package]] |
|
| 5179 | - | name = "unicode-truncate" |
|
| 5180 | - | version = "2.0.1" |
|
| 5181 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5182 | - | checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" |
|
| 5183 | - | dependencies = [ |
|
| 5184 | - | "itertools", |
|
| 5185 | - | "unicode-segmentation", |
|
| 5186 | - | "unicode-width", |
|
| 5187 | - | ] |
|
| 5188 | - | ||
| 5189 | - | [[package]] |
|
| 5190 | - | name = "unicode-width" |
|
| 5191 | - | version = "0.2.2" |
|
| 5192 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5193 | - | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" |
|
| 5194 | - | ||
| 5195 | - | [[package]] |
|
| 5196 | - | name = "unicode-xid" |
|
| 5197 | - | version = "0.2.6" |
|
| 5198 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5199 | - | checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" |
|
| 5200 | - | ||
| 5201 | - | [[package]] |
|
| 5202 | - | name = "untrusted" |
|
| 5203 | - | version = "0.9.0" |
|
| 5204 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5205 | - | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" |
|
| 5206 | - | ||
| 5207 | - | [[package]] |
|
| 5208 | - | name = "url" |
|
| 5209 | - | version = "2.5.8" |
|
| 5210 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5211 | - | checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" |
|
| 5212 | - | dependencies = [ |
|
| 5213 | - | "form_urlencoded", |
|
| 5214 | - | "idna", |
|
| 5215 | - | "percent-encoding", |
|
| 5216 | - | "serde", |
|
| 5217 | - | "serde_derive", |
|
| 5218 | - | ] |
|
| 5219 | - | ||
| 5220 | - | [[package]] |
|
| 5221 | - | name = "urlencoding" |
|
| 5222 | - | version = "2.1.3" |
|
| 5223 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5224 | - | checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" |
|
| 5225 | - | ||
| 5226 | - | [[package]] |
|
| 5227 | - | name = "utf-8" |
|
| 5228 | - | version = "0.7.6" |
|
| 5229 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5230 | - | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" |
|
| 5231 | - | ||
| 5232 | - | [[package]] |
|
| 5233 | - | name = "utf8_iter" |
|
| 5234 | - | version = "1.0.4" |
|
| 5235 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5236 | - | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" |
|
| 5237 | - | ||
| 5238 | - | [[package]] |
|
| 5239 | - | name = "utf8parse" |
|
| 5240 | - | version = "0.2.2" |
|
| 5241 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5242 | - | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" |
|
| 5243 | - | ||
| 5244 | - | [[package]] |
|
| 5245 | - | name = "uuid" |
|
| 5246 | - | version = "1.23.0" |
|
| 5247 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5248 | - | checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" |
|
| 5249 | - | dependencies = [ |
|
| 5250 | - | "atomic", |
|
| 5251 | - | "getrandom 0.4.2", |
|
| 5252 | - | "js-sys", |
|
| 5253 | - | "wasm-bindgen", |
|
| 5254 | - | ] |
|
| 5255 | - | ||
| 5256 | - | [[package]] |
|
| 5257 | - | name = "v_frame" |
|
| 5258 | - | version = "0.3.9" |
|
| 5259 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5260 | - | checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" |
|
| 5261 | - | dependencies = [ |
|
| 5262 | - | "aligned-vec", |
|
| 5263 | - | "num-traits", |
|
| 5264 | - | "wasm-bindgen", |
|
| 5265 | - | ] |
|
| 5266 | - | ||
| 5267 | - | [[package]] |
|
| 5268 | - | name = "valuable" |
|
| 5269 | - | version = "0.1.1" |
|
| 5270 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5271 | - | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" |
|
| 5272 | - | ||
| 5273 | - | [[package]] |
|
| 5274 | - | name = "vcpkg" |
|
| 5275 | - | version = "0.2.15" |
|
| 5276 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5277 | - | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" |
|
| 5278 | - | ||
| 5279 | - | [[package]] |
|
| 5280 | - | name = "version_check" |
|
| 5281 | - | version = "0.9.5" |
|
| 5282 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5283 | - | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" |
|
| 5284 | - | ||
| 5285 | - | [[package]] |
|
| 5286 | - | name = "vtparse" |
|
| 5287 | - | version = "0.6.2" |
|
| 5288 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5289 | - | checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" |
|
| 5290 | - | dependencies = [ |
|
| 5291 | - | "utf8parse", |
|
| 5292 | - | ] |
|
| 5293 | - | ||
| 5294 | - | [[package]] |
|
| 5295 | - | name = "walkdir" |
|
| 5296 | - | version = "2.5.0" |
|
| 5297 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5298 | - | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" |
|
| 5299 | - | dependencies = [ |
|
| 5300 | - | "same-file", |
|
| 5301 | - | "winapi-util", |
|
| 5302 | - | ] |
|
| 5303 | - | ||
| 5304 | - | [[package]] |
|
| 5305 | - | name = "want" |
|
| 5306 | - | version = "0.3.1" |
|
| 5307 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5308 | - | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" |
|
| 5309 | - | dependencies = [ |
|
| 5310 | - | "try-lock", |
|
| 5311 | - | ] |
|
| 5312 | - | ||
| 5313 | - | [[package]] |
|
| 5314 | - | name = "wasi" |
|
| 5315 | - | version = "0.11.1+wasi-snapshot-preview1" |
|
| 5316 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5317 | - | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" |
|
| 5318 | - | ||
| 5319 | - | [[package]] |
|
| 5320 | - | name = "wasip2" |
|
| 5321 | - | version = "1.0.2+wasi-0.2.9" |
|
| 5322 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5323 | - | checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" |
|
| 5324 | - | dependencies = [ |
|
| 5325 | - | "wit-bindgen", |
|
| 5326 | - | ] |
|
| 5327 | - | ||
| 5328 | - | [[package]] |
|
| 5329 | - | name = "wasip3" |
|
| 5330 | - | version = "0.4.0+wasi-0.3.0-rc-2026-01-06" |
|
| 5331 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5332 | - | checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" |
|
| 5333 | - | dependencies = [ |
|
| 5334 | - | "wit-bindgen", |
|
| 5335 | - | ] |
|
| 5336 | - | ||
| 5337 | - | [[package]] |
|
| 5338 | - | name = "wasm-bindgen" |
|
| 5339 | - | version = "0.2.117" |
|
| 5340 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5341 | - | checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" |
|
| 5342 | - | dependencies = [ |
|
| 5343 | - | "cfg-if", |
|
| 5344 | - | "once_cell", |
|
| 5345 | - | "rustversion", |
|
| 5346 | - | "wasm-bindgen-macro", |
|
| 5347 | - | "wasm-bindgen-shared", |
|
| 5348 | - | ] |
|
| 5349 | - | ||
| 5350 | - | [[package]] |
|
| 5351 | - | name = "wasm-bindgen-futures" |
|
| 5352 | - | version = "0.4.67" |
|
| 5353 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5354 | - | checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" |
|
| 5355 | - | dependencies = [ |
|
| 5356 | - | "js-sys", |
|
| 5357 | - | "wasm-bindgen", |
|
| 5358 | - | ] |
|
| 5359 | - | ||
| 5360 | - | [[package]] |
|
| 5361 | - | name = "wasm-bindgen-macro" |
|
| 5362 | - | version = "0.2.117" |
|
| 5363 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5364 | - | checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" |
|
| 5365 | - | dependencies = [ |
|
| 5366 | - | "quote", |
|
| 5367 | - | "wasm-bindgen-macro-support", |
|
| 5368 | - | ] |
|
| 5369 | - | ||
| 5370 | - | [[package]] |
|
| 5371 | - | name = "wasm-bindgen-macro-support" |
|
| 5372 | - | version = "0.2.117" |
|
| 5373 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5374 | - | checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" |
|
| 5375 | - | dependencies = [ |
|
| 5376 | - | "bumpalo", |
|
| 5377 | - | "proc-macro2", |
|
| 5378 | - | "quote", |
|
| 5379 | - | "syn 2.0.117", |
|
| 5380 | - | "wasm-bindgen-shared", |
|
| 5381 | - | ] |
|
| 5382 | - | ||
| 5383 | - | [[package]] |
|
| 5384 | - | name = "wasm-bindgen-shared" |
|
| 5385 | - | version = "0.2.117" |
|
| 5386 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5387 | - | checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" |
|
| 5388 | - | dependencies = [ |
|
| 5389 | - | "unicode-ident", |
|
| 5390 | - | ] |
|
| 5391 | - | ||
| 5392 | - | [[package]] |
|
| 5393 | - | name = "wasm-encoder" |
|
| 5394 | - | version = "0.244.0" |
|
| 5395 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5396 | - | checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" |
|
| 5397 | - | dependencies = [ |
|
| 5398 | - | "leb128fmt", |
|
| 5399 | - | "wasmparser", |
|
| 5400 | - | ] |
|
| 5401 | - | ||
| 5402 | - | [[package]] |
|
| 5403 | - | name = "wasm-metadata" |
|
| 5404 | - | version = "0.244.0" |
|
| 5405 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5406 | - | checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" |
|
| 5407 | - | dependencies = [ |
|
| 5408 | - | "anyhow", |
|
| 5409 | - | "indexmap", |
|
| 5410 | - | "wasm-encoder", |
|
| 5411 | - | "wasmparser", |
|
| 5412 | - | ] |
|
| 5413 | - | ||
| 5414 | - | [[package]] |
|
| 5415 | - | name = "wasmparser" |
|
| 5416 | - | version = "0.244.0" |
|
| 5417 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5418 | - | checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" |
|
| 5419 | - | dependencies = [ |
|
| 5420 | - | "bitflags 2.11.0", |
|
| 5421 | - | "hashbrown 0.15.5", |
|
| 5422 | - | "indexmap", |
|
| 5423 | - | "semver", |
|
| 5424 | - | ] |
|
| 5425 | - | ||
| 5426 | - | [[package]] |
|
| 5427 | - | name = "web-sys" |
|
| 5428 | - | version = "0.3.94" |
|
| 5429 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5430 | - | checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" |
|
| 5431 | - | dependencies = [ |
|
| 5432 | - | "js-sys", |
|
| 5433 | - | "wasm-bindgen", |
|
| 5434 | - | ] |
|
| 5435 | - | ||
| 5436 | - | [[package]] |
|
| 5437 | - | name = "web-time" |
|
| 5438 | - | version = "1.1.0" |
|
| 5439 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5440 | - | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" |
|
| 5441 | - | dependencies = [ |
|
| 5442 | - | "js-sys", |
|
| 5443 | - | "wasm-bindgen", |
|
| 5444 | - | ] |
|
| 5445 | - | ||
| 5446 | - | [[package]] |
|
| 5447 | - | name = "webpki-root-certs" |
|
| 5448 | - | version = "1.0.6" |
|
| 5449 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5450 | - | checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" |
|
| 5451 | - | dependencies = [ |
|
| 5452 | - | "rustls-pki-types", |
|
| 5453 | - | ] |
|
| 5454 | - | ||
| 5455 | - | [[package]] |
|
| 5456 | - | name = "webpki-roots" |
|
| 5457 | - | version = "1.0.6" |
|
| 5458 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5459 | - | checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" |
|
| 5460 | - | dependencies = [ |
|
| 5461 | - | "rustls-pki-types", |
|
| 5462 | - | ] |
|
| 5463 | - | ||
| 5464 | - | [[package]] |
|
| 5465 | - | name = "weezl" |
|
| 5466 | - | version = "0.1.12" |
|
| 5467 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5468 | - | checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" |
|
| 5469 | - | ||
| 5470 | - | [[package]] |
|
| 5471 | - | name = "wezterm-bidi" |
|
| 5472 | - | version = "0.2.3" |
|
| 5473 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5474 | - | checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" |
|
| 5475 | - | dependencies = [ |
|
| 5476 | - | "log", |
|
| 5477 | - | "wezterm-dynamic", |
|
| 5478 | - | ] |
|
| 5479 | - | ||
| 5480 | - | [[package]] |
|
| 5481 | - | name = "wezterm-blob-leases" |
|
| 5482 | - | version = "0.1.1" |
|
| 5483 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5484 | - | checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" |
|
| 5485 | - | dependencies = [ |
|
| 5486 | - | "getrandom 0.3.4", |
|
| 5487 | - | "mac_address", |
|
| 5488 | - | "sha2 0.10.9", |
|
| 5489 | - | "thiserror 1.0.69", |
|
| 5490 | - | "uuid", |
|
| 5491 | - | ] |
|
| 5492 | - | ||
| 5493 | - | [[package]] |
|
| 5494 | - | name = "wezterm-color-types" |
|
| 5495 | - | version = "0.3.0" |
|
| 5496 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5497 | - | checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" |
|
| 5498 | - | dependencies = [ |
|
| 5499 | - | "csscolorparser", |
|
| 5500 | - | "deltae", |
|
| 5501 | - | "lazy_static", |
|
| 5502 | - | "wezterm-dynamic", |
|
| 5503 | - | ] |
|
| 5504 | - | ||
| 5505 | - | [[package]] |
|
| 5506 | - | name = "wezterm-dynamic" |
|
| 5507 | - | version = "0.2.1" |
|
| 5508 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5509 | - | checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" |
|
| 5510 | - | dependencies = [ |
|
| 5511 | - | "log", |
|
| 5512 | - | "ordered-float", |
|
| 5513 | - | "strsim", |
|
| 5514 | - | "thiserror 1.0.69", |
|
| 5515 | - | "wezterm-dynamic-derive", |
|
| 5516 | - | ] |
|
| 5517 | - | ||
| 5518 | - | [[package]] |
|
| 5519 | - | name = "wezterm-dynamic-derive" |
|
| 5520 | - | version = "0.1.1" |
|
| 5521 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5522 | - | checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" |
|
| 5523 | - | dependencies = [ |
|
| 5524 | - | "proc-macro2", |
|
| 5525 | - | "quote", |
|
| 5526 | - | "syn 1.0.109", |
|
| 5527 | - | ] |
|
| 5528 | - | ||
| 5529 | - | [[package]] |
|
| 5530 | - | name = "wezterm-input-types" |
|
| 5531 | - | version = "0.1.0" |
|
| 5532 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5533 | - | checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" |
|
| 5534 | - | dependencies = [ |
|
| 5535 | - | "bitflags 1.3.2", |
|
| 5536 | - | "euclid", |
|
| 5537 | - | "lazy_static", |
|
| 5538 | - | "serde", |
|
| 5539 | - | "wezterm-dynamic", |
|
| 5540 | - | ] |
|
| 5541 | - | ||
| 5542 | - | [[package]] |
|
| 5543 | - | name = "winapi" |
|
| 5544 | - | version = "0.3.9" |
|
| 5545 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5546 | - | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" |
|
| 5547 | - | dependencies = [ |
|
| 5548 | - | "winapi-i686-pc-windows-gnu", |
|
| 5549 | - | "winapi-x86_64-pc-windows-gnu", |
|
| 5550 | - | ] |
|
| 5551 | - | ||
| 5552 | - | [[package]] |
|
| 5553 | - | name = "winapi-i686-pc-windows-gnu" |
|
| 5554 | - | version = "0.4.0" |
|
| 5555 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5556 | - | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" |
|
| 5557 | - | ||
| 5558 | - | [[package]] |
|
| 5559 | - | name = "winapi-util" |
|
| 5560 | - | version = "0.1.11" |
|
| 5561 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5562 | - | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" |
|
| 5563 | - | dependencies = [ |
|
| 5564 | - | "windows-sys 0.61.2", |
|
| 5565 | - | ] |
|
| 5566 | - | ||
| 5567 | - | [[package]] |
|
| 5568 | - | name = "winapi-x86_64-pc-windows-gnu" |
|
| 5569 | - | version = "0.4.0" |
|
| 5570 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5571 | - | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" |
|
| 5572 | - | ||
| 5573 | - | [[package]] |
|
| 5574 | - | name = "windows-core" |
|
| 5575 | - | version = "0.62.2" |
|
| 5576 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5577 | - | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" |
|
| 5578 | - | dependencies = [ |
|
| 5579 | - | "windows-implement", |
|
| 5580 | - | "windows-interface", |
|
| 5581 | - | "windows-link", |
|
| 5582 | - | "windows-result", |
|
| 5583 | - | "windows-strings", |
|
| 5584 | - | ] |
|
| 5585 | - | ||
| 5586 | - | [[package]] |
|
| 5587 | - | name = "windows-implement" |
|
| 5588 | - | version = "0.60.2" |
|
| 5589 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5590 | - | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" |
|
| 5591 | - | dependencies = [ |
|
| 5592 | - | "proc-macro2", |
|
| 5593 | - | "quote", |
|
| 5594 | - | "syn 2.0.117", |
|
| 5595 | - | ] |
|
| 5596 | - | ||
| 5597 | - | [[package]] |
|
| 5598 | - | name = "windows-interface" |
|
| 5599 | - | version = "0.59.3" |
|
| 5600 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5601 | - | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" |
|
| 5602 | - | dependencies = [ |
|
| 5603 | - | "proc-macro2", |
|
| 5604 | - | "quote", |
|
| 5605 | - | "syn 2.0.117", |
|
| 5606 | - | ] |
|
| 5607 | - | ||
| 5608 | - | [[package]] |
|
| 5609 | - | name = "windows-link" |
|
| 5610 | - | version = "0.2.1" |
|
| 5611 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5612 | - | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" |
|
| 5613 | - | ||
| 5614 | - | [[package]] |
|
| 5615 | - | name = "windows-registry" |
|
| 5616 | - | version = "0.6.1" |
|
| 5617 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5618 | - | checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" |
|
| 5619 | - | dependencies = [ |
|
| 5620 | - | "windows-link", |
|
| 5621 | - | "windows-result", |
|
| 5622 | - | "windows-strings", |
|
| 5623 | - | ] |
|
| 5624 | - | ||
| 5625 | - | [[package]] |
|
| 5626 | - | name = "windows-result" |
|
| 5627 | - | version = "0.4.1" |
|
| 5628 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5629 | - | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" |
|
| 5630 | - | dependencies = [ |
|
| 5631 | - | "windows-link", |
|
| 5632 | - | ] |
|
| 5633 | - | ||
| 5634 | - | [[package]] |
|
| 5635 | - | name = "windows-strings" |
|
| 5636 | - | version = "0.5.1" |
|
| 5637 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5638 | - | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" |
|
| 5639 | - | dependencies = [ |
|
| 5640 | - | "windows-link", |
|
| 5641 | - | ] |
|
| 5642 | - | ||
| 5643 | - | [[package]] |
|
| 5644 | - | name = "windows-sys" |
|
| 5645 | - | version = "0.45.0" |
|
| 5646 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5647 | - | checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" |
|
| 5648 | - | dependencies = [ |
|
| 5649 | - | "windows-targets 0.42.2", |
|
| 5650 | - | ] |
|
| 5651 | - | ||
| 5652 | - | [[package]] |
|
| 5653 | - | name = "windows-sys" |
|
| 5654 | - | version = "0.52.0" |
|
| 5655 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5656 | - | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" |
|
| 5657 | - | dependencies = [ |
|
| 5658 | - | "windows-targets 0.52.6", |
|
| 5659 | - | ] |
|
| 5660 | - | ||
| 5661 | - | [[package]] |
|
| 5662 | - | name = "windows-sys" |
|
| 5663 | - | version = "0.59.0" |
|
| 5664 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5665 | - | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" |
|
| 5666 | - | dependencies = [ |
|
| 5667 | - | "windows-targets 0.52.6", |
|
| 5668 | - | ] |
|
| 5669 | - | ||
| 5670 | - | [[package]] |
|
| 5671 | - | name = "windows-sys" |
|
| 5672 | - | version = "0.60.2" |
|
| 5673 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5674 | - | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" |
|
| 5675 | - | dependencies = [ |
|
| 5676 | - | "windows-targets 0.53.5", |
|
| 5677 | - | ] |
|
| 5678 | - | ||
| 5679 | - | [[package]] |
|
| 5680 | - | name = "windows-sys" |
|
| 5681 | - | version = "0.61.2" |
|
| 5682 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5683 | - | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" |
|
| 5684 | - | dependencies = [ |
|
| 5685 | - | "windows-link", |
|
| 5686 | - | ] |
|
| 5687 | - | ||
| 5688 | - | [[package]] |
|
| 5689 | - | name = "windows-targets" |
|
| 5690 | - | version = "0.42.2" |
|
| 5691 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5692 | - | checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" |
|
| 5693 | - | dependencies = [ |
|
| 5694 | - | "windows_aarch64_gnullvm 0.42.2", |
|
| 5695 | - | "windows_aarch64_msvc 0.42.2", |
|
| 5696 | - | "windows_i686_gnu 0.42.2", |
|
| 5697 | - | "windows_i686_msvc 0.42.2", |
|
| 5698 | - | "windows_x86_64_gnu 0.42.2", |
|
| 5699 | - | "windows_x86_64_gnullvm 0.42.2", |
|
| 5700 | - | "windows_x86_64_msvc 0.42.2", |
|
| 5701 | - | ] |
|
| 5702 | - | ||
| 5703 | - | [[package]] |
|
| 5704 | - | name = "windows-targets" |
|
| 5705 | - | version = "0.52.6" |
|
| 5706 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5707 | - | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" |
|
| 5708 | - | dependencies = [ |
|
| 5709 | - | "windows_aarch64_gnullvm 0.52.6", |
|
| 5710 | - | "windows_aarch64_msvc 0.52.6", |
|
| 5711 | - | "windows_i686_gnu 0.52.6", |
|
| 5712 | - | "windows_i686_gnullvm 0.52.6", |
|
| 5713 | - | "windows_i686_msvc 0.52.6", |
|
| 5714 | - | "windows_x86_64_gnu 0.52.6", |
|
| 5715 | - | "windows_x86_64_gnullvm 0.52.6", |
|
| 5716 | - | "windows_x86_64_msvc 0.52.6", |
|
| 5717 | - | ] |
|
| 5718 | - | ||
| 5719 | - | [[package]] |
|
| 5720 | - | name = "windows-targets" |
|
| 5721 | - | version = "0.53.5" |
|
| 5722 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5723 | - | checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" |
|
| 5724 | - | dependencies = [ |
|
| 5725 | - | "windows-link", |
|
| 5726 | - | "windows_aarch64_gnullvm 0.53.1", |
|
| 5727 | - | "windows_aarch64_msvc 0.53.1", |
|
| 5728 | - | "windows_i686_gnu 0.53.1", |
|
| 5729 | - | "windows_i686_gnullvm 0.53.1", |
|
| 5730 | - | "windows_i686_msvc 0.53.1", |
|
| 5731 | - | "windows_x86_64_gnu 0.53.1", |
|
| 5732 | - | "windows_x86_64_gnullvm 0.53.1", |
|
| 5733 | - | "windows_x86_64_msvc 0.53.1", |
|
| 5734 | - | ] |
|
| 5735 | - | ||
| 5736 | - | [[package]] |
|
| 5737 | - | name = "windows_aarch64_gnullvm" |
|
| 5738 | - | version = "0.42.2" |
|
| 5739 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5740 | - | checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" |
|
| 5741 | - | ||
| 5742 | - | [[package]] |
|
| 5743 | - | name = "windows_aarch64_gnullvm" |
|
| 5744 | - | version = "0.52.6" |
|
| 5745 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5746 | - | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" |
|
| 5747 | - | ||
| 5748 | - | [[package]] |
|
| 5749 | - | name = "windows_aarch64_gnullvm" |
|
| 5750 | - | version = "0.53.1" |
|
| 5751 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5752 | - | checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" |
|
| 5753 | - | ||
| 5754 | - | [[package]] |
|
| 5755 | - | name = "windows_aarch64_msvc" |
|
| 5756 | - | version = "0.42.2" |
|
| 5757 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5758 | - | checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" |
|
| 5759 | - | ||
| 5760 | - | [[package]] |
|
| 5761 | - | name = "windows_aarch64_msvc" |
|
| 5762 | - | version = "0.52.6" |
|
| 5763 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5764 | - | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" |
|
| 5765 | - | ||
| 5766 | - | [[package]] |
|
| 5767 | - | name = "windows_aarch64_msvc" |
|
| 5768 | - | version = "0.53.1" |
|
| 5769 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5770 | - | checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" |
|
| 5771 | - | ||
| 5772 | - | [[package]] |
|
| 5773 | - | name = "windows_i686_gnu" |
|
| 5774 | - | version = "0.42.2" |
|
| 5775 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5776 | - | checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" |
|
| 5777 | - | ||
| 5778 | - | [[package]] |
|
| 5779 | - | name = "windows_i686_gnu" |
|
| 5780 | - | version = "0.52.6" |
|
| 5781 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5782 | - | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" |
|
| 5783 | - | ||
| 5784 | - | [[package]] |
|
| 5785 | - | name = "windows_i686_gnu" |
|
| 5786 | - | version = "0.53.1" |
|
| 5787 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5788 | - | checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" |
|
| 5789 | - | ||
| 5790 | - | [[package]] |
|
| 5791 | - | name = "windows_i686_gnullvm" |
|
| 5792 | - | version = "0.52.6" |
|
| 5793 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5794 | - | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" |
|
| 5795 | - | ||
| 5796 | - | [[package]] |
|
| 5797 | - | name = "windows_i686_gnullvm" |
|
| 5798 | - | version = "0.53.1" |
|
| 5799 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5800 | - | checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" |
|
| 5801 | - | ||
| 5802 | - | [[package]] |
|
| 5803 | - | name = "windows_i686_msvc" |
|
| 5804 | - | version = "0.42.2" |
|
| 5805 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5806 | - | checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" |
|
| 5807 | - | ||
| 5808 | - | [[package]] |
|
| 5809 | - | name = "windows_i686_msvc" |
|
| 5810 | - | version = "0.52.6" |
|
| 5811 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5812 | - | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" |
|
| 5813 | - | ||
| 5814 | - | [[package]] |
|
| 5815 | - | name = "windows_i686_msvc" |
|
| 5816 | - | version = "0.53.1" |
|
| 5817 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5818 | - | checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" |
|
| 5819 | - | ||
| 5820 | - | [[package]] |
|
| 5821 | - | name = "windows_x86_64_gnu" |
|
| 5822 | - | version = "0.42.2" |
|
| 5823 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5824 | - | checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" |
|
| 5825 | - | ||
| 5826 | - | [[package]] |
|
| 5827 | - | name = "windows_x86_64_gnu" |
|
| 5828 | - | version = "0.52.6" |
|
| 5829 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5830 | - | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" |
|
| 5831 | - | ||
| 5832 | - | [[package]] |
|
| 5833 | - | name = "windows_x86_64_gnu" |
|
| 5834 | - | version = "0.53.1" |
|
| 5835 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5836 | - | checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" |
|
| 5837 | - | ||
| 5838 | - | [[package]] |
|
| 5839 | - | name = "windows_x86_64_gnullvm" |
|
| 5840 | - | version = "0.42.2" |
|
| 5841 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5842 | - | checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" |
|
| 5843 | - | ||
| 5844 | - | [[package]] |
|
| 5845 | - | name = "windows_x86_64_gnullvm" |
|
| 5846 | - | version = "0.52.6" |
|
| 5847 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5848 | - | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" |
|
| 5849 | - | ||
| 5850 | - | [[package]] |
|
| 5851 | - | name = "windows_x86_64_gnullvm" |
|
| 5852 | - | version = "0.53.1" |
|
| 5853 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5854 | - | checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" |
|
| 5855 | - | ||
| 5856 | - | [[package]] |
|
| 5857 | - | name = "windows_x86_64_msvc" |
|
| 5858 | - | version = "0.42.2" |
|
| 5859 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5860 | - | checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" |
|
| 5861 | - | ||
| 5862 | - | [[package]] |
|
| 5863 | - | name = "windows_x86_64_msvc" |
|
| 5864 | - | version = "0.52.6" |
|
| 5865 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5866 | - | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" |
|
| 5867 | - | ||
| 5868 | - | [[package]] |
|
| 5869 | - | name = "windows_x86_64_msvc" |
|
| 5870 | - | version = "0.53.1" |
|
| 5871 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5872 | - | checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" |
|
| 5873 | - | ||
| 5874 | - | [[package]] |
|
| 5875 | - | name = "winnow" |
|
| 5876 | - | version = "0.7.15" |
|
| 5877 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5878 | - | checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" |
|
| 5879 | - | dependencies = [ |
|
| 5880 | - | "memchr", |
|
| 5881 | - | ] |
|
| 5882 | - | ||
| 5883 | - | [[package]] |
|
| 5884 | - | name = "winnow" |
|
| 5885 | - | version = "1.0.1" |
|
| 5886 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5887 | - | checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" |
|
| 5888 | - | dependencies = [ |
|
| 5889 | - | "memchr", |
|
| 5890 | - | ] |
|
| 5891 | - | ||
| 5892 | - | [[package]] |
|
| 5893 | - | name = "wit-bindgen" |
|
| 5894 | - | version = "0.51.0" |
|
| 5895 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5896 | - | checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" |
|
| 5897 | - | dependencies = [ |
|
| 5898 | - | "wit-bindgen-rust-macro", |
|
| 5899 | - | ] |
|
| 5900 | - | ||
| 5901 | - | [[package]] |
|
| 5902 | - | name = "wit-bindgen-core" |
|
| 5903 | - | version = "0.51.0" |
|
| 5904 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5905 | - | checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" |
|
| 5906 | - | dependencies = [ |
|
| 5907 | - | "anyhow", |
|
| 5908 | - | "heck", |
|
| 5909 | - | "wit-parser", |
|
| 5910 | - | ] |
|
| 5911 | - | ||
| 5912 | - | [[package]] |
|
| 5913 | - | name = "wit-bindgen-rust" |
|
| 5914 | - | version = "0.51.0" |
|
| 5915 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5916 | - | checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" |
|
| 5917 | - | dependencies = [ |
|
| 5918 | - | "anyhow", |
|
| 5919 | - | "heck", |
|
| 5920 | - | "indexmap", |
|
| 5921 | - | "prettyplease", |
|
| 5922 | - | "syn 2.0.117", |
|
| 5923 | - | "wasm-metadata", |
|
| 5924 | - | "wit-bindgen-core", |
|
| 5925 | - | "wit-component", |
|
| 5926 | - | ] |
|
| 5927 | - | ||
| 5928 | - | [[package]] |
|
| 5929 | - | name = "wit-bindgen-rust-macro" |
|
| 5930 | - | version = "0.51.0" |
|
| 5931 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5932 | - | checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" |
|
| 5933 | - | dependencies = [ |
|
| 5934 | - | "anyhow", |
|
| 5935 | - | "prettyplease", |
|
| 5936 | - | "proc-macro2", |
|
| 5937 | - | "quote", |
|
| 5938 | - | "syn 2.0.117", |
|
| 5939 | - | "wit-bindgen-core", |
|
| 5940 | - | "wit-bindgen-rust", |
|
| 5941 | - | ] |
|
| 5942 | - | ||
| 5943 | - | [[package]] |
|
| 5944 | - | name = "wit-component" |
|
| 5945 | - | version = "0.244.0" |
|
| 5946 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5947 | - | checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" |
|
| 5948 | - | dependencies = [ |
|
| 5949 | - | "anyhow", |
|
| 5950 | - | "bitflags 2.11.0", |
|
| 5951 | - | "indexmap", |
|
| 5952 | - | "log", |
|
| 5953 | - | "serde", |
|
| 5954 | - | "serde_derive", |
|
| 5955 | - | "serde_json", |
|
| 5956 | - | "wasm-encoder", |
|
| 5957 | - | "wasm-metadata", |
|
| 5958 | - | "wasmparser", |
|
| 5959 | - | "wit-parser", |
|
| 5960 | - | ] |
|
| 5961 | - | ||
| 5962 | - | [[package]] |
|
| 5963 | - | name = "wit-parser" |
|
| 5964 | - | version = "0.244.0" |
|
| 5965 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5966 | - | checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" |
|
| 5967 | - | dependencies = [ |
|
| 5968 | - | "anyhow", |
|
| 5969 | - | "id-arena", |
|
| 5970 | - | "indexmap", |
|
| 5971 | - | "log", |
|
| 5972 | - | "semver", |
|
| 5973 | - | "serde", |
|
| 5974 | - | "serde_derive", |
|
| 5975 | - | "serde_json", |
|
| 5976 | - | "unicode-xid", |
|
| 5977 | - | "wasmparser", |
|
| 5978 | - | ] |
|
| 5979 | - | ||
| 5980 | - | [[package]] |
|
| 5981 | - | name = "writeable" |
|
| 5982 | - | version = "0.6.2" |
|
| 5983 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5984 | - | checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" |
|
| 5985 | - | ||
| 5986 | - | [[package]] |
|
| 5987 | - | name = "x11rb" |
|
| 5988 | - | version = "0.13.2" |
|
| 5989 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 5990 | - | checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" |
|
| 5991 | - | dependencies = [ |
|
| 5992 | - | "gethostname", |
|
| 5993 | - | "rustix", |
|
| 5994 | - | "x11rb-protocol", |
|
| 5995 | - | ] |
|
| 5996 | - | ||
| 5997 | - | [[package]] |
|
| 5998 | - | name = "x11rb-protocol" |
|
| 5999 | - | version = "0.13.2" |
|
| 6000 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6001 | - | checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" |
|
| 6002 | - | ||
| 6003 | - | [[package]] |
|
| 6004 | - | name = "xz2" |
|
| 6005 | - | version = "0.1.7" |
|
| 6006 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6007 | - | checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" |
|
| 6008 | - | dependencies = [ |
|
| 6009 | - | "lzma-sys", |
|
| 6010 | - | ] |
|
| 6011 | - | ||
| 6012 | - | [[package]] |
|
| 6013 | - | name = "y4m" |
|
| 6014 | - | version = "0.8.0" |
|
| 6015 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6016 | - | checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" |
|
| 6017 | - | ||
| 6018 | - | [[package]] |
|
| 6019 | - | name = "yaml-rust" |
|
| 6020 | - | version = "0.4.5" |
|
| 6021 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6022 | - | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" |
|
| 6023 | - | dependencies = [ |
|
| 6024 | - | "linked-hash-map", |
|
| 6025 | - | ] |
|
| 6026 | - | ||
| 6027 | - | [[package]] |
|
| 6028 | - | name = "yoke" |
|
| 6029 | - | version = "0.8.2" |
|
| 6030 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6031 | - | checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" |
|
| 6032 | - | dependencies = [ |
|
| 6033 | - | "stable_deref_trait", |
|
| 6034 | - | "yoke-derive", |
|
| 6035 | - | "zerofrom", |
|
| 6036 | - | ] |
|
| 6037 | - | ||
| 6038 | - | [[package]] |
|
| 6039 | - | name = "yoke-derive" |
|
| 6040 | - | version = "0.8.2" |
|
| 6041 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6042 | - | checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" |
|
| 6043 | - | dependencies = [ |
|
| 6044 | - | "proc-macro2", |
|
| 6045 | - | "quote", |
|
| 6046 | - | "syn 2.0.117", |
|
| 6047 | - | "synstructure", |
|
| 6048 | - | ] |
|
| 6049 | - | ||
| 6050 | - | [[package]] |
|
| 6051 | - | name = "zerocopy" |
|
| 6052 | - | version = "0.8.48" |
|
| 6053 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6054 | - | checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" |
|
| 6055 | - | dependencies = [ |
|
| 6056 | - | "zerocopy-derive", |
|
| 6057 | - | ] |
|
| 6058 | - | ||
| 6059 | - | [[package]] |
|
| 6060 | - | name = "zerocopy-derive" |
|
| 6061 | - | version = "0.8.48" |
|
| 6062 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6063 | - | checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" |
|
| 6064 | - | dependencies = [ |
|
| 6065 | - | "proc-macro2", |
|
| 6066 | - | "quote", |
|
| 6067 | - | "syn 2.0.117", |
|
| 6068 | - | ] |
|
| 6069 | - | ||
| 6070 | - | [[package]] |
|
| 6071 | - | name = "zerofrom" |
|
| 6072 | - | version = "0.1.7" |
|
| 6073 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6074 | - | checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" |
|
| 6075 | - | dependencies = [ |
|
| 6076 | - | "zerofrom-derive", |
|
| 6077 | - | ] |
|
| 6078 | - | ||
| 6079 | - | [[package]] |
|
| 6080 | - | name = "zerofrom-derive" |
|
| 6081 | - | version = "0.1.7" |
|
| 6082 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6083 | - | checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" |
|
| 6084 | - | dependencies = [ |
|
| 6085 | - | "proc-macro2", |
|
| 6086 | - | "quote", |
|
| 6087 | - | "syn 2.0.117", |
|
| 6088 | - | "synstructure", |
|
| 6089 | - | ] |
|
| 6090 | - | ||
| 6091 | - | [[package]] |
|
| 6092 | - | name = "zeroize" |
|
| 6093 | - | version = "1.8.2" |
|
| 6094 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6095 | - | checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" |
|
| 6096 | - | dependencies = [ |
|
| 6097 | - | "zeroize_derive", |
|
| 6098 | - | ] |
|
| 6099 | - | ||
| 6100 | - | [[package]] |
|
| 6101 | - | name = "zeroize_derive" |
|
| 6102 | - | version = "1.4.3" |
|
| 6103 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6104 | - | checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" |
|
| 6105 | - | dependencies = [ |
|
| 6106 | - | "proc-macro2", |
|
| 6107 | - | "quote", |
|
| 6108 | - | "syn 2.0.117", |
|
| 6109 | - | ] |
|
| 6110 | - | ||
| 6111 | - | [[package]] |
|
| 6112 | - | name = "zerotrie" |
|
| 6113 | - | version = "0.2.4" |
|
| 6114 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6115 | - | checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" |
|
| 6116 | - | dependencies = [ |
|
| 6117 | - | "displaydoc", |
|
| 6118 | - | "yoke", |
|
| 6119 | - | "zerofrom", |
|
| 6120 | - | ] |
|
| 6121 | - | ||
| 6122 | - | [[package]] |
|
| 6123 | - | name = "zerovec" |
|
| 6124 | - | version = "0.11.6" |
|
| 6125 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6126 | - | checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" |
|
| 6127 | - | dependencies = [ |
|
| 6128 | - | "yoke", |
|
| 6129 | - | "zerofrom", |
|
| 6130 | - | "zerovec-derive", |
|
| 6131 | - | ] |
|
| 6132 | - | ||
| 6133 | - | [[package]] |
|
| 6134 | - | name = "zerovec-derive" |
|
| 6135 | - | version = "0.11.3" |
|
| 6136 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6137 | - | checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" |
|
| 6138 | - | dependencies = [ |
|
| 6139 | - | "proc-macro2", |
|
| 6140 | - | "quote", |
|
| 6141 | - | "syn 2.0.117", |
|
| 6142 | - | ] |
|
| 6143 | - | ||
| 6144 | - | [[package]] |
|
| 6145 | - | name = "zip" |
|
| 6146 | - | version = "2.4.2" |
|
| 6147 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6148 | - | checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" |
|
| 6149 | - | dependencies = [ |
|
| 6150 | - | "aes", |
|
| 6151 | - | "arbitrary", |
|
| 6152 | - | "bzip2", |
|
| 6153 | - | "constant_time_eq", |
|
| 6154 | - | "crc32fast", |
|
| 6155 | - | "crossbeam-utils", |
|
| 6156 | - | "deflate64", |
|
| 6157 | - | "displaydoc", |
|
| 6158 | - | "flate2", |
|
| 6159 | - | "getrandom 0.3.4", |
|
| 6160 | - | "hmac 0.12.1", |
|
| 6161 | - | "indexmap", |
|
| 6162 | - | "lzma-rs", |
|
| 6163 | - | "memchr", |
|
| 6164 | - | "pbkdf2", |
|
| 6165 | - | "sha1", |
|
| 6166 | - | "thiserror 2.0.18", |
|
| 6167 | - | "time", |
|
| 6168 | - | "xz2", |
|
| 6169 | - | "zeroize", |
|
| 6170 | - | "zopfli", |
|
| 6171 | - | "zstd", |
|
| 6172 | - | ] |
|
| 6173 | - | ||
| 6174 | - | [[package]] |
|
| 6175 | - | name = "zmij" |
|
| 6176 | - | version = "1.0.21" |
|
| 6177 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6178 | - | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" |
|
| 6179 | - | ||
| 6180 | - | [[package]] |
|
| 6181 | - | name = "zopfli" |
|
| 6182 | - | version = "0.8.3" |
|
| 6183 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6184 | - | checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" |
|
| 6185 | - | dependencies = [ |
|
| 6186 | - | "bumpalo", |
|
| 6187 | - | "crc32fast", |
|
| 6188 | - | "log", |
|
| 6189 | - | "simd-adler32", |
|
| 6190 | - | ] |
|
| 6191 | - | ||
| 6192 | - | [[package]] |
|
| 6193 | - | name = "zstd" |
|
| 6194 | - | version = "0.13.3" |
|
| 6195 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6196 | - | checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" |
|
| 6197 | - | dependencies = [ |
|
| 6198 | - | "zstd-safe", |
|
| 6199 | - | ] |
|
| 6200 | - | ||
| 6201 | - | [[package]] |
|
| 6202 | - | name = "zstd-safe" |
|
| 6203 | - | version = "7.2.4" |
|
| 6204 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6205 | - | checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" |
|
| 6206 | - | dependencies = [ |
|
| 6207 | - | "zstd-sys", |
|
| 6208 | - | ] |
|
| 6209 | - | ||
| 6210 | - | [[package]] |
|
| 6211 | - | name = "zstd-sys" |
|
| 6212 | - | version = "2.0.16+zstd.1.5.7" |
|
| 6213 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6214 | - | checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" |
|
| 6215 | - | dependencies = [ |
|
| 6216 | - | "cc", |
|
| 6217 | - | "pkg-config", |
|
| 6218 | - | ] |
|
| 6219 | - | ||
| 6220 | - | [[package]] |
|
| 6221 | - | name = "zune-core" |
|
| 6222 | - | version = "0.5.1" |
|
| 6223 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6224 | - | checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" |
|
| 6225 | - | ||
| 6226 | - | [[package]] |
|
| 6227 | - | name = "zune-inflate" |
|
| 6228 | - | version = "0.2.54" |
|
| 6229 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6230 | - | checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" |
|
| 6231 | - | dependencies = [ |
|
| 6232 | - | "simd-adler32", |
|
| 6233 | - | ] |
|
| 6234 | - | ||
| 6235 | - | [[package]] |
|
| 6236 | - | name = "zune-jpeg" |
|
| 6237 | - | version = "0.5.15" |
|
| 6238 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 6239 | - | checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" |
|
| 6240 | - | dependencies = [ |
|
| 6241 | - | "zune-core", |
|
| 6242 | - | ] |
| 1 | - | [workspace] |
|
| 2 | - | members = [ |
|
| 3 | - | "apps/sipp", |
|
| 4 | - | "apps/feeds", |
|
| 5 | - | "apps/parcels", |
|
| 6 | - | "apps/jotts", |
|
| 7 | - | "apps/og", |
|
| 8 | - | "apps/shrink", |
|
| 9 | - | "apps/cellar", |
|
| 10 | - | "apps/posts", |
|
| 11 | - | "apps/library", |
|
| 12 | - | "apps/bookmarks", |
|
| 13 | - | "apps/easel", |
|
| 14 | - | "crates/auth", |
|
| 15 | - | "crates/db", |
|
| 16 | - | "crates/darkmatter-css", |
|
| 17 | - | ] |
|
| 18 | - | resolver = "3" |
|
| 19 | - | ||
| 20 | - | [profile.dist] |
|
| 21 | - | inherits = "release" |
|
| 22 | - | lto = "thin" |
|
| 23 | - | ||
| 24 | - | [workspace.dependencies] |
|
| 25 | - | # Core web stack |
|
| 26 | - | axum = "0.8.8" |
|
| 27 | - | tokio = { version = "1", features = ["full"] } |
|
| 28 | - | serde = { version = "1", features = ["derive"] } |
|
| 29 | - | serde_json = "1" |
|
| 30 | - | dotenvy = "0.15" |
|
| 31 | - | ||
| 32 | - | # HTTP / middleware |
|
| 33 | - | tower-http = "0.6.8" |
|
| 34 | - | ||
| 35 | - | # Assets |
|
| 36 | - | rust-embed = "8" |
|
| 37 | - | ||
| 38 | - | # Database |
|
| 39 | - | rusqlite = { version = "0.38", features = ["bundled"] } |
|
| 40 | - | nanoid = "0.4.0" |
|
| 41 | - | ||
| 42 | - | # Auth |
|
| 43 | - | subtle = "2" |
|
| 44 | - | rand = "0.8" |
|
| 45 | - | ||
| 46 | - | # Observability |
|
| 47 | - | tracing = "0.1" |
|
| 48 | - | tracing-subscriber = "0.3" |
|
| 49 | - | ||
| 50 | - | # Archive |
|
| 51 | - | zip = "2" |
|
| 52 | - | ||
| 53 | - | # Testing |
|
| 54 | - | tempfile = "3" |
|
| 55 | - | ||
| 56 | - | # Workspace crates |
|
| 57 | - | andromeda-auth = { path = "crates/auth" } |
|
| 58 | - | andromeda-db = { path = "crates/db" } |
|
| 59 | - | andromeda-darkmatter-css = { path = "crates/darkmatter-css" } |
| 1 | - | GO_APPS := $(shell find apps -maxdepth 1 -type d -name '*-go' | sort) |
|
| 1 | + | GO_APPS := $(shell find apps -maxdepth 2 -name 'go.mod' -exec dirname {} \; | sort) |
|
| 2 | 2 | ||
| 3 | 3 | .PHONY: help go-test go-vet go-fmt go-check go-app-test go-app-vet go-app-fmt |
|
| 4 | 4 | ||
| 5 | 5 | help: |
|
| 6 | 6 | @echo "Available targets:" |
|
| 7 | - | @echo " make go-test Run go test ./... in every apps/*-go module" |
|
| 8 | - | @echo " make go-vet Run go vet ./... in every apps/*-go module" |
|
| 9 | - | @echo " make go-fmt Run gofmt -w . in every apps/*-go module" |
|
| 7 | + | @echo " make go-test Run go test ./... in every Go app module" |
|
| 8 | + | @echo " make go-vet Run go vet ./... in every Go app module" |
|
| 9 | + | @echo " make go-fmt Run gofmt -w . in every Go app module" |
|
| 10 | 10 | @echo " make go-check Run go-fmt, go-test, and go-vet for all Go apps" |
|
| 11 | - | @echo " make go-app-test APP=... Run go test ./... in one Go app, e.g. APP=feeds-go" |
|
| 11 | + | @echo " make go-app-test APP=... Run go test ./... in one Go app, e.g. APP=feeds" |
|
| 12 | 12 | @echo " make go-app-vet APP=... Run go vet ./... in one Go app" |
|
| 13 | 13 | @echo " make go-app-fmt APP=... Run gofmt -w . in one Go app" |
|
| 14 | 14 | ||
| 15 | 15 | ifndef APP |
|
| 16 | 16 | go-app-test go-app-vet go-app-fmt: |
|
| 17 | - | @echo "APP is required, e.g. make $@ APP=feeds-go" >&2 |
|
| 17 | + | @echo "APP is required, e.g. make $@ APP=feeds" >&2 |
|
| 18 | 18 | @exit 1 |
|
| 19 | 19 | else |
|
| 20 | 20 | go-app-test: |
| 1 | - | # Andromeda: Rust vs Go Performance Comparison |
|
| 2 | - | ||
| 3 | - | Comparison of the Rust apps (current production, running in Docker) against the in-progress Go rewrites on the `chore/go-rewrite` branch. |
|
| 4 | - | ||
| 5 | - | Date: 2026-05-16 |
|
| 6 | - | Host: Linux 7.0.3-arch1-2 |
|
| 7 | - | ||
| 8 | - | ## Methodology |
|
| 9 | - | ||
| 10 | - | - **Binary size**: `cargo build --release --workspace` (with `[profile.release]` defaults) vs each `go build` artifact in `apps/*-go/`. Raw stripped/unstripped binaries on disk, no UPX or other post-processing. |
|
| 11 | - | - **Lines of code**: Total lines in `apps/<app>/src/**/*.rs` vs `apps/<app>-go/**/*.go`. Includes blanks and comments. Shared crates counted separately. |
|
| 12 | - | - **Dependencies**: Direct deps parsed from `Cargo.toml` `[dependencies]` vs `require ( ... )` blocks in `go.mod` (separating direct vs total-with-indirect). |
|
| 13 | - | - **RAM**: Two passes. |
|
| 14 | - | 1. *Production snapshot* — `docker stats` against the long-running Rust containers (not apples-to-apples; reflects accumulated state). |
|
| 15 | - | 2. *Fair cold start* — local release binaries (Rust from `target/release/`, Go from `apps/*-go/`) launched on unused ports with `PORT`/`HOST` overrides, sampled `VmRSS` from `/proc/<pid>/status` after a 2s warmup, then terminated. Same conditions, no traffic, fresh sqlite. |
|
| 16 | - | ||
| 17 | - | ## Binary Size |
|
| 18 | - | ||
| 19 | - | | App | Rust | Go | Go vs Rust | |
|
| 20 | - | |-----------|--------|--------|------------| |
|
| 21 | - | | bookmarks | 14M | 20M | +43% | |
|
| 22 | - | | cellar | 16M | 20M | +25% | |
|
| 23 | - | | easel | 15M | 20M | +33% | |
|
| 24 | - | | feeds | 16M | 23M | +44% | |
|
| 25 | - | | jotts | 19M | 21M | +11% | |
|
| 26 | - | | library | 13M | 20M | +54% | |
|
| 27 | - | | og | 12M | 14M | +17% | |
|
| 28 | - | | posts | 16M | 21M | +31% | |
|
| 29 | - | | shrink | 8.2M | 14M | +71% | |
|
| 30 | - | | sipp | 16M | 22M | +38% | |
|
| 31 | - | ||
| 32 | - | **Winner: Rust** — smaller in every app, average ~35% smaller. |
|
| 33 | - | ||
| 34 | - | ## Lines of Code |
|
| 35 | - | ||
| 36 | - | Raw totals: |
|
| 37 | - | ||
| 38 | - | | App | Rust | Go | Go ratio | |
|
| 39 | - | |-----------|-------|-------|----------| |
|
| 40 | - | | bookmarks | 850 | 756 | 0.89x | |
|
| 41 | - | | cellar | 2010 | 1241 | 0.62x | |
|
| 42 | - | | easel | 1156 | 946 | 0.82x | |
|
| 43 | - | | feeds | 2193 | 1981 | 0.90x | |
|
| 44 | - | | jotts | 2248 | 526 | 0.23x | |
|
| 45 | - | | library | 1021 | 882 | 0.86x | |
|
| 46 | - | | og | 399 | 295 | 0.74x | |
|
| 47 | - | | posts | 3010 | 2140 | 0.71x | |
|
| 48 | - | | shrink | 389 | 166 | 0.43x | |
|
| 49 | - | | sipp | 2455 | 655 | 0.27x | |
|
| 50 | - | ||
| 51 | - | ### TUI/CLI adjustment |
|
| 52 | - | ||
| 53 | - | `jotts` and `sipp` Rust binaries include a full TUI/CLI alongside the server. Go ports are server-only. Stripping TUI sources for a fair compare: |
|
| 54 | - | ||
| 55 | - | | App | Rust server only | Rust TUI/CLI | Go | Go ratio (server) | |
|
| 56 | - | |-------|------------------|--------------|-------|--------------------| |
|
| 57 | - | | jotts | 1063 | 1185 | 526 | 0.49x | |
|
| 58 | - | | sipp | 1203 | 1252 | 655 | 0.54x | |
|
| 59 | - | ||
| 60 | - | All other apps are server-only on both sides — raw numbers are already fair. |
|
| 61 | - | ||
| 62 | - | ### Shared modules |
|
| 63 | - | ||
| 64 | - | | Side | Modules | Total LOC | |
|
| 65 | - | |-------|------------------------------------------|-----------| |
|
| 66 | - | | Rust | `crates/auth` (371), `crates/db` (833), `crates/darkmatter-css` (54) | 1258 | |
|
| 67 | - | | Go | `crates-go/auth` (207), `crates-go/config` (58), `crates-go/darkmatter` (54), `crates-go/web` (72) | 391 | |
|
| 68 | - | ||
| 69 | - | **Winner: Go** — consistently fewer lines (~30–50% less for server code). |
|
| 70 | - | ||
| 71 | - | ## Dependencies |
|
| 72 | - | ||
| 73 | - | | App | Rust direct | Go direct | Go +indirect | |
|
| 74 | - | |-----------|-------------|-----------|---------------| |
|
| 75 | - | | bookmarks | 22 | 6 | 17 | |
|
| 76 | - | | cellar | 24 | 5 | 16 | |
|
| 77 | - | | easel | 19 | 4 | 14 | |
|
| 78 | - | | feeds | 25 | 7 | 25 | |
|
| 79 | - | | jotts | 29 | 6 | 17 | |
|
| 80 | - | | library | 20 | 5 | 16 | |
|
| 81 | - | | og | 15 | 4 | 4 | |
|
| 82 | - | | posts | 26 | 6 | 17 | |
|
| 83 | - | | shrink | 11 | 4 | 4 | |
|
| 84 | - | | sipp | 32 | 6 | 18 | |
|
| 85 | - | ||
| 86 | - | **Winner: Go** — far fewer direct deps; stdlib carries weight. Rust transitive trees would be much larger again if expanded via `cargo tree`. |
|
| 87 | - | ||
| 88 | - | ## RAM Usage |
|
| 89 | - | ||
| 90 | - | ### Production snapshot (Rust in Docker, idle) |
|
| 91 | - | ||
| 92 | - | Long-running containers from `docker stats --no-stream`. Includes accumulated state (background pollers in `cellar`/`feeds` inflate RSS). |
|
| 93 | - | ||
| 94 | - | | App | RSS | |
|
| 95 | - | |-----------|--------| |
|
| 96 | - | | og | 7.5M | |
|
| 97 | - | | shrink | 6.3M | |
|
| 98 | - | | jotts | 13.0M | |
|
| 99 | - | | library | 13.1M | |
|
| 100 | - | | sipp | 13.3M | |
|
| 101 | - | | bookmarks | 22.1M | |
|
| 102 | - | | posts | 23.2M | |
|
| 103 | - | | easel | 31.5M | |
|
| 104 | - | | feeds | 52.1M | |
|
| 105 | - | | cellar | 64.6M | |
|
| 106 | - | ||
| 107 | - | Not comparable to a cold Go binary — different uptime and workload. |
|
| 108 | - | ||
| 109 | - | ### Fair cold start (both sides, alt ports, 2s warmup) |
|
| 110 | - | ||
| 111 | - | | App | Rust | Go | Winner | |
|
| 112 | - | |-----------|---------|---------|-----------------| |
|
| 113 | - | | bookmarks | 11.2M | 13.6M | Rust −18% | |
|
| 114 | - | | cellar | 10.5M | 12.9M | Rust −19% | |
|
| 115 | - | | easel | 21.1M | 12.8M | **Go −39%** | |
|
| 116 | - | | feeds | 11.6M | 14.3M | Rust −19% | |
|
| 117 | - | | jotts | 8.6M | 14.2M | Rust −39% | |
|
| 118 | - | | library | 10.1M | 12.6M | Rust −20% | |
|
| 119 | - | | og | 8.1M | 10.1M | Rust −20% | |
|
| 120 | - | | posts | 10.8M | 14.9M | Rust −28% | |
|
| 121 | - | | shrink | 5.6M | 9.9M | Rust −44% | |
|
| 122 | - | | sipp | 10.1M | 19.6M | Rust −48% | |
|
| 123 | - | ||
| 124 | - | **Winner: Rust** — 9 of 10 apps. Average ~28% less RAM cold idle. Easel anomaly: Rust eagerly loads classifications/exclusion lists at boot. |
|
| 125 | - | ||
| 126 | - | ## Summary |
|
| 127 | - | ||
| 128 | - | | Metric | Winner | Magnitude | |
|
| 129 | - | |------------------------------|--------|------------------------| |
|
| 130 | - | | Binary size | Rust | ~35% smaller avg | |
|
| 131 | - | | Lines of code (server only) | Go | ~30–50% fewer | |
|
| 132 | - | | Direct dependencies | Go | 4–7 vs 11–32 | |
|
| 133 | - | | RAM (cold start, single user)| Rust | ~28% less avg, 9/10 | |
|
| 134 | - | ||
| 135 | - | ### Context: single-user deployment |
|
| 136 | - | ||
| 137 | - | Apps are single-user. Cold-start RAM is the right benchmark — no concurrent load spike, no GC pressure differences under traffic, idle is the dominant state. |
|
| 138 | - | ||
| 139 | - | - Rust: smaller binaries, lower idle RAM, more code, more deps. |
|
| 140 | - | - Go: less code, fewer deps, larger binaries, higher idle RAM. |
|
| 141 | - | ||
| 142 | - | Tradeoff is roughly: pay in source-code volume + dep count to get smaller binaries and lower memory; or pay in binary size + RAM to get less code to maintain. |
| 3 | 3 |  |
|
| 4 | 4 | ||
| 5 | 5 | A collection of minimal, self-hosted web apps. Each app compiles to a single |
|
| 6 | - | binary. The original implementation is a Rust workspace (Axum + Askama). A |
|
| 7 | - | parallel Go port lives alongside, sharing the same SQLite schemas and routes |
|
| 8 | - | so either implementation can serve the same data. |
|
| 6 | + | Go binary that embeds its templates and static assets and stores data in |
|
| 7 | + | SQLite. |
|
| 9 | 8 | ||
| 10 | 9 | ## Apps |
|
| 11 | 10 | ||
| 12 | 11 | | App | Description | Deploy | |
|
| 13 | 12 | |---|---|---| |
|
| 14 | - | | [**Sipp**](apps/sipp) | Minimal code sharing with web UI and TUI | [](https://railway.com/deploy/Axcf_D?referralCode=JGcIp6) | |
|
| 13 | + | | [**Sipp**](apps/sipp) | Minimal code sharing with web UI | [](https://railway.com/deploy/Axcf_D?referralCode=JGcIp6) | |
|
| 15 | 14 | | [**Feeds**](apps/feeds) | Minimal RSS reader with OPML import/export and a JSON API | [](https://railway.com/deploy/Ezvmhx?referralCode=JGcIp6) | |
|
| 16 | - | | [**Parcels**](apps/parcels) | Minimal package tracking (USPS) | [](https://railway.com/deploy/HNQUs4?referralCode=JGcIp6) | |
|
| 17 | 15 | | [**Jotts**](apps/jotts) | Minimal markdown notes app | [](https://railway.com/deploy/DLhUhH?referralCode=JGcIp6) | |
|
| 18 | 16 | | [**OG**](apps/og) | Open Graph tag inspector | [](https://railway.com/deploy/OdXBt_?referralCode=JGcIp6) | |
|
| 19 | 17 | | [**Shrink**](apps/shrink) | Image compression and resizing | [](https://railway.com/deploy/enYUFb?referralCode=JGcIp6) | |
|
| 23 | 21 | | [**Library**](apps/library) | Minimal book tracker with Google Books search | [](https://railway.com/deploy/tepdeI?referralCode=JGcIp6) | |
|
| 24 | 22 | | [**Easel**](apps/easel) | Daily public-domain painting from the Art Institute of Chicago | [](https://railway.com/deploy/0DpuRE?referralCode=JGcIp6) | |
|
| 25 | 23 | ||
| 26 | - | ## Go ports |
|
| 27 | - | ||
| 28 | - | The same apps are being rewritten in Go under `apps/<name>-go/`. Each one is |
|
| 29 | - | a separate Go module that embeds its templates and static assets and uses |
|
| 30 | - | shared packages from `crates-go/`. Status: |
|
| 31 | - | ||
| 32 | - | | Go app | Notes | |
|
| 33 | - | |---|---| |
|
| 34 | - | | `apps/feeds-go` | full parity | |
|
| 35 | - | | `apps/jotts-go` | full parity | |
|
| 36 | - | | `apps/og-go` | full parity | |
|
| 37 | - | | `apps/shrink-go` | EXIF reinjection dropped | |
|
| 38 | - | | `apps/bookmarks-go` | full parity | |
|
| 39 | - | | `apps/library-go` | full parity | |
|
| 40 | - | | `apps/easel-go` | full parity | |
|
| 41 | - | | `apps/cellar-go` | EXIF orientation auto-rotate dropped | |
|
| 42 | - | | `apps/posts-go` | local FS only (no R2/S3) | |
|
| 43 | - | | `apps/sipp-go` | server + CLI; interactive TUI not ported | |
|
| 44 | - | ||
| 45 | - | `apps/parcels-go` is intentionally not built (USPS API access has changed). |
|
| 46 | - | ||
| 47 | - | Each Go app references the shared `crates-go/` packages via `replace` |
|
| 48 | - | directives in its `go.mod`, so the source tree is fully self-contained. |
|
| 49 | - | ||
| 50 | - | ## Shared crates |
|
| 51 | - | ||
| 52 | - | Rust: |
|
| 53 | - | ||
| 54 | - | | Crate | Description | |
|
| 55 | - | |---|---| |
|
| 56 | - | | [`andromeda-auth`](crates/auth) | Session-based password authentication | |
|
| 57 | - | | [`andromeda-db`](crates/db) | Shared database types and session management | |
|
| 58 | - | | [`andromeda-darkmatter-css`](crates/darkmatter-css) | Shared CSS + fonts | |
|
| 24 | + | ## Shared packages |
|
| 59 | 25 | ||
| 60 | - | Go (each is its own module under `crates-go/`): |
|
| 26 | + | Under `crates-go/`, each its own Go module: |
|
| 61 | 27 | ||
| 62 | 28 | | Package | Description | |
|
| 63 | 29 | |---|---| |
|
| 66 | 32 | | `crates-go/config` | env + `.env` loading helpers | |
|
| 67 | 33 | | `crates-go/darkmatter` | Embedded CSS + fonts, mountable on any `http.ServeMux` | |
|
| 68 | 34 | ||
| 35 | + | Each app references these via `replace` directives in its `go.mod`, so the |
|
| 36 | + | source tree is fully self-contained. |
|
| 37 | + | ||
| 69 | 38 | ## Stack |
|
| 70 | 39 | ||
| 71 | - | Rust apps: Axum + rusqlite + Askama + rust-embed + tokio. |
|
| 72 | - | Go apps: stdlib `net/http` + `modernc.org/sqlite` (pure Go, no cgo) + |
|
| 73 | - | `html/template` + `embed.FS`. Permitted extras: `goldmark` (markdown), |
|
| 74 | - | `gofeed` (RSS), `golang.org/x/net/html` (HTML parsing), |
|
| 75 | - | `golang.org/x/image/draw` (image resize), `alecthomas/chroma` (highlight), |
|
| 76 | - | `golang.org/x/crypto/bcrypt` (passwords). |
|
| 40 | + | Stdlib `net/http` + `modernc.org/sqlite` (pure Go, no cgo) + `html/template` |
|
| 41 | + | + `embed.FS`. Permitted extras: `goldmark` (markdown), `gofeed` (RSS), |
|
| 42 | + | `golang.org/x/net/html` (HTML parsing), `golang.org/x/image/draw` (image |
|
| 43 | + | resize), `alecthomas/chroma` (highlight), `golang.org/x/crypto/bcrypt` |
|
| 44 | + | (passwords). |
|
| 77 | 45 | ||
| 78 | 46 | ## Getting Started |
|
| 79 | 47 | ||
| 80 | - | Rust: |
|
| 81 | - | ||
| 82 | 48 | ```bash |
|
| 83 | - | # Build all apps |
|
| 84 | - | cargo build --release |
|
| 85 | - | ||
| 86 | - | # Run a specific app |
|
| 87 | - | cargo run -p sipp -- server --port 3000 |
|
| 88 | - | cargo run -p feeds |
|
| 89 | - | cargo run -p jotts |
|
| 90 | - | cargo run -p og |
|
| 91 | - | cargo run -p shrink |
|
| 49 | + | cd apps/feeds && cp .env.example .env && go run . |
|
| 50 | + | cd apps/posts && cp .env.example .env && go run . |
|
| 51 | + | cd apps/sipp && go run . server --port 3000 |
|
| 92 | 52 | ``` |
|
| 93 | 53 | ||
| 94 | - | Go: |
|
| 95 | - | ||
| 96 | - | ```bash |
|
| 97 | - | cd apps/feeds-go && cp .env.example .env && go run . |
|
| 98 | - | cd apps/posts-go && cp .env.example .env && go run . |
|
| 99 | - | # sipp-go has two binaries: |
|
| 100 | - | cd apps/sipp-go && go run ./cmd/server |
|
| 101 | - | ``` |
|
| 102 | - | ||
| 103 | - | Each app has its own README with detailed setup, environment variables, and deployment instructions. |
|
| 54 | + | Each app has its own README with detailed setup, environment variables, and |
|
| 55 | + | deployment instructions. |
|
| 104 | 56 | ||
| 105 | 57 | ## License |
|
| 106 | 58 | ||
| 1 | - | HOST=127.0.0.1 |
|
| 2 | - | PORT=3000 |
|
| 3 | - | ||
| 4 | - | # SQLite file path |
|
| 5 | - | BOOKMARKS_DB_PATH=bookmarks.sqlite |
|
| 6 | - | ||
| 7 | - | # Admin login password |
|
| 8 | - | BOOKMARKS_PASSWORD=changeme |
|
| 9 | - | ||
| 10 | - | # API key for POST /api/links (omit to disable write API) |
|
| 11 | - | BOOKMARKS_API_KEY= |
|
| 12 | - | ||
| 13 | - | # Set true behind HTTPS to mark session cookie Secure |
|
| 14 | - | COOKIE_SECURE=false |
| 1 | - | # Build from repo root: docker build -t bookmarks-go -f apps/bookmarks-go/Dockerfile . |
|
| 2 | - | FROM golang:1.24-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/bookmarks-go/go.mod apps/bookmarks-go/go.sum ./apps/bookmarks-go/ |
|
| 6 | - | WORKDIR /app/apps/bookmarks-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/bookmarks-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /bookmarks-go . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 13 | - | COPY --from=builder /bookmarks-go /usr/local/bin/bookmarks-go |
|
| 14 | - | WORKDIR /data |
|
| 15 | - | ENV HOST=0.0.0.0 |
|
| 16 | - | ENV PORT=3000 |
|
| 17 | - | EXPOSE 3000 |
|
| 18 | - | CMD ["bookmarks-go"] |
| 1 | - | # bookmarks-go |
|
| 2 | - | ||
| 3 | - | Go rewrite of [bookmarks](../bookmarks). Personal link saver organized by |
|
| 4 | - | category. |
|
| 5 | - | ||
| 6 | - | ## Quickstart |
|
| 7 | - | ||
| 8 | - | ```bash |
|
| 9 | - | cp .env.example .env |
|
| 10 | - | go run . |
|
| 11 | - | ``` |
|
| 12 | - | ||
| 13 | - | ### Environment Variables |
|
| 14 | - | ||
| 15 | - | | Variable | Default | Description | |
|
| 16 | - | |---|---|---| |
|
| 17 | - | | `BOOKMARKS_PASSWORD` | — | Admin panel password | |
|
| 18 | - | | `BOOKMARKS_API_KEY` | — | API key for `POST /api/links` | |
|
| 19 | - | | `BOOKMARKS_DB_PATH` | `bookmarks.sqlite` | SQLite path | |
|
| 20 | - | | `HOST` | `127.0.0.1` | Bind host | |
|
| 21 | - | | `PORT` | `3000` | Server port | |
|
| 22 | - | | `COOKIE_SECURE` | `false` | Mark session cookie Secure | |
|
| 23 | - | ||
| 24 | - | ## Routes |
|
| 25 | - | ||
| 26 | - | Public: `GET /`, `GET /api/categories`, `GET /api/links`. Auth: `GET/POST /login`, |
|
| 27 | - | `GET /logout`, `/admin`, `/admin/*`. API-key: `POST /api/links`. |
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/bookmarks-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | - | environment: |
|
| 9 | - | - HOST=0.0.0.0 |
|
| 10 | - | - PORT=${PORT:-3000} |
|
| 11 | - | - BOOKMARKS_DB_PATH=/data/bookmarks-go.sqlite |
|
| 12 | - | - BOOKMARKS_PASSWORD=${BOOKMARKS_PASSWORD:-changeme} |
|
| 13 | - | - BOOKMARKS_API_KEY=${BOOKMARKS_API_KEY:-} |
|
| 14 | - | - COOKIE_SECURE=${COOKIE_SECURE:-false} |
|
| 15 | - | volumes: |
|
| 16 | - | - bookmarks-go-data:/data |
|
| 17 | - | restart: unless-stopped |
|
| 18 | - | ||
| 19 | - | volumes: |
|
| 20 | - | bookmarks-go-data: |
| 11 | 11 | "golang.org/x/net/html" |
|
| 12 | 12 | ) |
|
| 13 | 13 | ||
| 14 | - | const faviconUA = "andromeda-bookmarks-go/0.1 (+https://github.com/stevedylandev/andromeda)" |
|
| 14 | + | const faviconUA = "andromeda-bookmarks/0.1 (+https://github.com/stevedylandev/andromeda)" |
|
| 15 | 15 | ||
| 16 | 16 | func discoverFavicon(ctx context.Context, pageURL string) string { |
|
| 17 | 17 | parsed, err := url.Parse(pageURL) |
| 1 | - | module github.com/stevedylandev/andromeda/apps/bookmarks-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/bookmarks |
|
| 2 | 2 | ||
| 3 | 3 | go 1.24.4 |
|
| 4 | 4 |
| 45 | 45 | go app.faviconBackfill(context.Background()) |
|
| 46 | 46 | ||
| 47 | 47 | addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000") |
|
| 48 | - | logger.Info("bookmarks-go server running", "addr", addr) |
|
| 48 | + | logger.Info("bookmarks server running", "addr", addr) |
|
| 49 | 49 | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 50 | 50 | log.Fatal(err) |
|
| 51 | 51 | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* feeds — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | /* The logo wraps an h1 in feeds markup. */ |
|
| 6 | - | ||
| 7 | - | .logo h1 { |
|
| 8 | - | font-size: 28px; |
|
| 9 | - | font-weight: 700; |
|
| 10 | - | text-transform: uppercase; |
|
| 11 | - | } |
|
| 12 | - | ||
| 13 | - | .about { |
|
| 14 | - | display: flex; |
|
| 15 | - | flex-direction: column; |
|
| 16 | - | gap: 0.5rem; |
|
| 17 | - | font-size: 14px; |
|
| 18 | - | line-height: 1.25rem; |
|
| 19 | - | } |
|
| 20 | - | ||
| 21 | - | /* Feeds list */ |
|
| 22 | - | ||
| 23 | - | .feeds-list { |
|
| 24 | - | width: 100%; |
|
| 25 | - | display: flex; |
|
| 26 | - | flex-direction: column; |
|
| 27 | - | gap: 1.5rem; |
|
| 28 | - | } |
|
| 29 | - | ||
| 30 | - | .feed-item { |
|
| 31 | - | display: flex; |
|
| 32 | - | flex-direction: column; |
|
| 33 | - | gap: 0.5rem; |
|
| 34 | - | padding: 1rem 0; |
|
| 35 | - | border-bottom: 1px solid #333; |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | .feed-item:last-child { |
|
| 39 | - | border-bottom: none; |
|
| 40 | - | } |
|
| 41 | - | ||
| 42 | - | .feed-meta { |
|
| 43 | - | display: flex; |
|
| 44 | - | justify-content: space-between; |
|
| 45 | - | align-items: center; |
|
| 46 | - | font-size: 12px; |
|
| 47 | - | opacity: 0.5; |
|
| 48 | - | } |
|
| 49 | - | ||
| 50 | - | .feed-source { |
|
| 51 | - | font-weight: 700; |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | .feed-title { |
|
| 55 | - | font-size: 16px; |
|
| 56 | - | font-weight: 400; |
|
| 57 | - | line-height: 1.4; |
|
| 58 | - | } |
|
| 59 | - | ||
| 60 | - | .feed-title a { |
|
| 61 | - | text-decoration: none; |
|
| 62 | - | } |
|
| 63 | - | ||
| 64 | - | .feed-author { |
|
| 65 | - | font-size: 12px; |
|
| 66 | - | opacity: 0.5; |
|
| 67 | - | font-style: italic; |
|
| 68 | - | } |
|
| 69 | - | ||
| 70 | - | #feed-urls { |
|
| 71 | - | font-size: 12px; |
|
| 72 | - | opacity: 0.5; |
|
| 73 | - | } |
|
| 74 | - | ||
| 75 | - | .no-feeds, |
|
| 76 | - | #loading { |
|
| 77 | - | text-align: center; |
|
| 78 | - | opacity: 0.5; |
|
| 79 | - | padding: 2rem; |
|
| 80 | - | } |
|
| 81 | - | ||
| 82 | - | #error { |
|
| 83 | - | text-align: center; |
|
| 84 | - | padding: 2rem; |
|
| 85 | - | } |
|
| 86 | - | ||
| 87 | - | /* Admin forms */ |
|
| 88 | - | ||
| 89 | - | .admin-form { |
|
| 90 | - | display: flex; |
|
| 91 | - | flex-direction: column; |
|
| 92 | - | gap: 0.75rem; |
|
| 93 | - | width: 100%; |
|
| 94 | - | } |
|
| 95 | - | ||
| 96 | - | .admin-form h3 { |
|
| 97 | - | font-size: 14px; |
|
| 98 | - | font-weight: 400; |
|
| 99 | - | opacity: 0.5; |
|
| 100 | - | } |
|
| 101 | - | ||
| 102 | - | .admin-notice, |
|
| 103 | - | .hint { |
|
| 104 | - | font-size: 12px; |
|
| 105 | - | opacity: 0.5; |
|
| 106 | - | line-height: 1.4; |
|
| 107 | - | } |
|
| 108 | - | ||
| 109 | - | /* Discover panel */ |
|
| 110 | - | ||
| 111 | - | .discover-row { |
|
| 112 | - | display: flex; |
|
| 113 | - | gap: 0.5rem; |
|
| 114 | - | width: 100%; |
|
| 115 | - | } |
|
| 116 | - | ||
| 117 | - | .discover-row input { |
|
| 118 | - | flex: 1; |
|
| 119 | - | } |
|
| 120 | - | ||
| 121 | - | .discover-status { |
|
| 122 | - | font-size: 12px; |
|
| 123 | - | } |
|
| 124 | - | ||
| 125 | - | .discover-results { |
|
| 126 | - | display: flex; |
|
| 127 | - | flex-direction: column; |
|
| 128 | - | gap: 0.25rem; |
|
| 129 | - | width: 100%; |
|
| 130 | - | } |
|
| 131 | - | ||
| 132 | - | .discover-result-item { |
|
| 133 | - | background: #121113; |
|
| 134 | - | color: #ffffff; |
|
| 135 | - | border: 1px solid #333; |
|
| 136 | - | padding: 8px 10px; |
|
| 137 | - | font-size: 12px; |
|
| 138 | - | text-align: left; |
|
| 139 | - | cursor: pointer; |
|
| 140 | - | width: 100%; |
|
| 141 | - | white-space: nowrap; |
|
| 142 | - | overflow: hidden; |
|
| 143 | - | text-overflow: ellipsis; |
|
| 144 | - | opacity: 0.7; |
|
| 145 | - | border-radius: 0; |
|
| 146 | - | -webkit-appearance: none; |
|
| 147 | - | appearance: none; |
|
| 148 | - | } |
|
| 149 | - | ||
| 150 | - | .discover-result-item:hover { |
|
| 151 | - | border-color: #555; |
|
| 152 | - | opacity: 1; |
|
| 153 | - | } |
|
| 154 | - | ||
| 155 | - | .discover-result-item.active { |
|
| 156 | - | border-color: #ffffff; |
|
| 157 | - | opacity: 1; |
|
| 158 | - | } |
|
| 159 | - | ||
| 160 | - | /* Admin subs */ |
|
| 161 | - | ||
| 162 | - | .admin-subs { |
|
| 163 | - | width: 100%; |
|
| 164 | - | display: flex; |
|
| 165 | - | flex-direction: column; |
|
| 166 | - | gap: 1rem; |
|
| 167 | - | } |
|
| 168 | - | ||
| 169 | - | .admin-subs h3 { |
|
| 170 | - | font-size: 14px; |
|
| 171 | - | opacity: 0.5; |
|
| 172 | - | font-weight: 400; |
|
| 173 | - | } |
|
| 174 | - | ||
| 175 | - | .feed-item form.inline { |
|
| 176 | - | display: flex; |
|
| 177 | - | gap: 0.5rem; |
|
| 178 | - | align-items: center; |
|
| 179 | - | } |
|
| 180 | - | ||
| 181 | - | .feed-item form.inline input { |
|
| 182 | - | flex: 1; |
|
| 183 | - | } |
|
| 184 | - | ||
| 185 | - | /* Generic .danger on buttons (used in admin) */ |
|
| 186 | - | ||
| 187 | - | button.danger, |
|
| 188 | - | .btn.danger { |
|
| 189 | - | opacity: 0.5; |
|
| 190 | - | } |
|
| 191 | - | ||
| 192 | - | button.danger:hover, |
|
| 193 | - | .btn.danger:hover { |
|
| 194 | - | opacity: 0.3; |
|
| 195 | - | } |
|
| 196 | - | ||
| 197 | - | /* Category list (admin) */ |
|
| 198 | - | ||
| 199 | - | .category-list { |
|
| 200 | - | list-style: none; |
|
| 201 | - | margin-left: 0; |
|
| 202 | - | } |
|
| 203 | - | ||
| 204 | - | .category-list li { |
|
| 205 | - | display: flex; |
|
| 206 | - | justify-content: space-between; |
|
| 207 | - | align-items: center; |
|
| 208 | - | padding: 0.25rem 0; |
|
| 209 | - | } |
|
| 210 | - | ||
| 211 | - | @media (max-width: 480px) { |
|
| 212 | - | .feed-meta { |
|
| 213 | - | flex-direction: column; |
|
| 214 | - | align-items: flex-start; |
|
| 215 | - | gap: 0.25rem; |
|
| 216 | - | } |
|
| 217 | - | ||
| 218 | - | .feed-title { |
|
| 219 | - | font-size: 14px; |
|
| 220 | - | } |
|
| 221 | - | } |
| 1 | 1 | HOST=127.0.0.1 |
|
| 2 | 2 | PORT=3000 |
|
| 3 | 3 | ||
| 4 | - | # SQLite file path (default: bookmarks.sqlite) |
|
| 4 | + | # SQLite file path |
|
| 5 | 5 | BOOKMARKS_DB_PATH=bookmarks.sqlite |
|
| 6 | 6 | ||
| 7 | - | # Admin login password (required for /admin) |
|
| 7 | + | # Admin login password |
|
| 8 | 8 | BOOKMARKS_PASSWORD=changeme |
|
| 9 | 9 | ||
| 10 | 10 | # API key for POST /api/links (omit to disable write API) |
| 1 | - | [package] |
|
| 2 | - | name = "bookmarks" |
|
| 3 | - | version = "0.1.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Personal link saver" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | axum = { workspace = true } |
|
| 12 | - | tokio = { workspace = true } |
|
| 13 | - | serde = { workspace = true } |
|
| 14 | - | serde_json = { workspace = true } |
|
| 15 | - | dotenvy = { workspace = true } |
|
| 16 | - | rusqlite = { workspace = true } |
|
| 17 | - | nanoid = { workspace = true } |
|
| 18 | - | rust-embed = { workspace = true } |
|
| 19 | - | mime_guess = "2" |
|
| 20 | - | subtle = { workspace = true } |
|
| 21 | - | rand = { workspace = true } |
|
| 22 | - | tracing = { workspace = true } |
|
| 23 | - | tracing-subscriber = { workspace = true, features = ["env-filter"] } |
|
| 24 | - | andromeda-auth = { workspace = true } |
|
| 25 | - | andromeda-db = { workspace = true, features = ["axum", "session"] } |
|
| 26 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 27 | - | askama = "0.13" |
|
| 28 | - | chrono = "0.4" |
|
| 29 | - | reqwest = { version = "0.12" } |
|
| 30 | - | scraper = "0.22" |
|
| 31 | - | url = "2" |
| 1 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 1 | + | # Build from repo root: docker build -t bookmarks -f apps/bookmarks/Dockerfile . |
|
| 2 | + | FROM golang:1.24-bookworm AS builder |
|
| 2 | 3 | WORKDIR /app |
|
| 3 | - | ||
| 4 | - | FROM chef AS planner |
|
| 5 | - | COPY . . |
|
| 6 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 7 | - | ||
| 8 | - | FROM chef AS builder |
|
| 9 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 10 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 11 | - | RUN cargo chef cook --release --recipe-path recipe.json -p bookmarks |
|
| 12 | - | COPY . . |
|
| 13 | - | RUN cargo build --release -p bookmarks |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/bookmarks/go.mod apps/bookmarks/go.sum ./apps/bookmarks/ |
|
| 6 | + | WORKDIR /app/apps/bookmarks |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/bookmarks/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /bookmarks . |
|
| 14 | 10 | ||
| 15 | 11 | FROM debian:bookworm-slim |
|
| 16 | 12 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 17 | - | COPY --from=builder /app/target/release/bookmarks /usr/local/bin/bookmarks |
|
| 13 | + | COPY --from=builder /bookmarks /usr/local/bin/bookmarks |
|
| 18 | 14 | WORKDIR /data |
|
| 19 | - | EXPOSE 3000 |
|
| 20 | 15 | ENV HOST=0.0.0.0 |
|
| 21 | 16 | ENV PORT=3000 |
|
| 17 | + | EXPOSE 3000 |
|
| 22 | 18 | CMD ["bookmarks"] |
| 1 | - | # Bookmarks |
|
| 1 | + | # bookmarks-go |
|
| 2 | 2 | ||
| 3 | - | Personal link saver organized by category. |
|
| 3 | + | Go rewrite of [bookmarks](../bookmarks). Personal link saver organized by |
|
| 4 | + | category. |
|
| 4 | 5 | ||
| 5 | 6 | ## Quickstart |
|
| 6 | 7 | ||
| 7 | - | 1. Make sure [Rust](https://www.rust-lang.org/tools/install) is installed |
|
| 8 | - | ||
| 9 | 8 | ```bash |
|
| 10 | - | rustc --version |
|
| 11 | - | ``` |
|
| 12 | - | ||
| 13 | - | 2. Clone and build |
|
| 14 | - | ||
| 15 | - | ```bash |
|
| 16 | - | git clone https://github.com/stevedylandev/andromeda |
|
| 17 | - | cd andromeda |
|
| 18 | - | cargo build -p bookmarks |
|
| 19 | - | ``` |
|
| 20 | - | ||
| 21 | - | 3. Run the dev server |
|
| 22 | - | ||
| 23 | - | ```bash |
|
| 24 | - | cargo run -p bookmarks |
|
| 25 | - | # Server running on http://localhost:3000 |
|
| 9 | + | cp .env.example .env |
|
| 10 | + | go run . |
|
| 26 | 11 | ``` |
|
| 27 | 12 | ||
| 28 | 13 | ### Environment Variables |
|
| 29 | 14 | ||
| 30 | - | | Variable | Description | Default | |
|
| 15 | + | | Variable | Default | Description | |
|
| 31 | 16 | |---|---|---| |
|
| 32 | - | | `BOOKMARKS_PASSWORD` | Password for the admin panel | — | |
|
| 33 | - | | `BOOKMARKS_API_KEY` | API key for `POST /api/links` (omit to disable write API) | — | |
|
| 34 | - | | `BOOKMARKS_DB_PATH` | SQLite database path | `bookmarks.sqlite` | |
|
| 35 | - | | `HOST` | Bind address | `127.0.0.1` | |
|
| 36 | - | | `PORT` | Bind port | `3000` | |
|
| 37 | - | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 38 | - | ||
| 39 | - | ## Overview |
|
| 40 | - | ||
| 41 | - | Bookmarks is a single-user link saver. Add links via the admin panel or JSON API, organize them into categories, and view them on a public index page grouped by category. A few highlights: |
|
| 42 | - | ||
| 43 | - | - Single Rust binary with embedded assets |
|
| 44 | - | - Local SQLite storage |
|
| 45 | - | - Password-protected admin panel for managing categories and links |
|
| 46 | - | - JSON read API (open) and write API (key-guarded) |
|
| 47 | - | - Dark themed UI with Commit Mono font |
|
| 17 | + | | `BOOKMARKS_PASSWORD` | — | Admin panel password | |
|
| 18 | + | | `BOOKMARKS_API_KEY` | — | API key for `POST /api/links` | |
|
| 19 | + | | `BOOKMARKS_DB_PATH` | `bookmarks.sqlite` | SQLite path | |
|
| 20 | + | | `HOST` | `127.0.0.1` | Bind host | |
|
| 21 | + | | `PORT` | `3000` | Server port | |
|
| 22 | + | | `COOKIE_SECURE` | `false` | Mark session cookie Secure | |
|
| 48 | 23 | ||
| 49 | - | ## Usage |
|
| 24 | + | ## Routes |
|
| 50 | 25 | ||
| 51 | - | ### Admin Panel |
|
| 52 | - | ||
| 53 | - | Set `BOOKMARKS_PASSWORD` and visit `/login`. From the admin panel you can: |
|
| 54 | - | ||
| 55 | - | - Create and remove categories |
|
| 56 | - | - Add links with title, URL, and category |
|
| 57 | - | - Remove links |
|
| 58 | - | ||
| 59 | - | ### JSON API |
|
| 60 | - | ||
| 61 | - | Read endpoints are open. Write endpoints require `x-api-key: <BOOKMARKS_API_KEY>`. |
|
| 62 | - | ||
| 63 | - | | Method | Path | Auth | Purpose | |
|
| 64 | - | |---|---|---|---| |
|
| 65 | - | | `GET` | `/api/categories` | open | List categories | |
|
| 66 | - | | `GET` | `/api/links` | open | List links grouped by category. Query: `category` to filter by name | |
|
| 67 | - | | `POST` | `/api/links` | api key | Create link. Body: `{category, title, url}` | |
|
| 68 | - | ||
| 69 | - | Example: |
|
| 70 | - | ||
| 71 | - | ```bash |
|
| 72 | - | curl -X POST http://localhost:3000/api/links \ |
|
| 73 | - | -H "x-api-key: $BOOKMARKS_API_KEY" \ |
|
| 74 | - | -H "content-type: application/json" \ |
|
| 75 | - | -d '{"category":"Reading","title":"Example","url":"https://example.com"}' |
|
| 76 | - | ``` |
|
| 77 | - | ||
| 78 | - | ## Structure |
|
| 79 | - | ||
| 80 | - | ``` |
|
| 81 | - | bookmarks/ |
|
| 82 | - | ├── src/ |
|
| 83 | - | │ ├── main.rs # Axum server, admin routes, JSON API, static serving |
|
| 84 | - | │ ├── db.rs # Schema and SQLite queries |
|
| 85 | - | │ └── auth.rs # Session + API-key guards |
|
| 86 | - | ├── templates/ # Askama HTML templates (index, login, admin) |
|
| 87 | - | ├── static/ # Static assets embedded at compile time via rust-embed |
|
| 88 | - | ├── Dockerfile |
|
| 89 | - | └── docker-compose.yml |
|
| 90 | - | ``` |
|
| 91 | - | ||
| 92 | - | ## Deployment |
|
| 93 | - | ||
| 94 | - | Since Bookmarks compiles to a single binary, deployment is straightforward on any platform. |
|
| 95 | - | ||
| 96 | - | ### Docker (recommended) |
|
| 97 | - | ||
| 98 | - | ```bash |
|
| 99 | - | git clone https://github.com/stevedylandev/andromeda |
|
| 100 | - | cd andromeda/apps/bookmarks |
|
| 101 | - | cp .env.example .env |
|
| 102 | - | # Edit .env with your credentials |
|
| 103 | - | docker compose up -d |
|
| 104 | - | ``` |
|
| 105 | - | ||
| 106 | - | Mount a volume at `BOOKMARKS_DB_PATH` to persist the SQLite database. |
|
| 107 | - | ||
| 108 | - | ### Binary |
|
| 109 | - | ||
| 110 | - | ```bash |
|
| 111 | - | cargo build --release -p bookmarks |
|
| 112 | - | ``` |
|
| 113 | - | ||
| 114 | - | The resulting binary at `./target/release/bookmarks` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 115 | - | ||
| 116 | - | ## License |
|
| 117 | - | ||
| 118 | - | [MIT](../../LICENSE) |
|
| 26 | + | Public: `GET /`, `GET /api/categories`, `GET /api/links`. Auth: `GET/POST /login`, |
|
| 27 | + | `GET /logout`, `/admin`, `/admin/*`. API-key: `POST /api/links`. |
| 1 | - | [general] |
|
| 2 | - | dirs = ["src/templates"] |
| 2 | 2 | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/bookmarks/Dockerfile |
|
| 5 | + | dockerfile: apps/bookmarks-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | 7 | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | 8 | environment: |
|
| 9 | - | - BOOKMARKS_PASSWORD=${BOOKMARKS_PASSWORD:-changeme} |
|
| 10 | - | - BOOKMARKS_API_KEY=${BOOKMARKS_API_KEY:-} |
|
| 11 | - | - BOOKMARKS_DB_PATH=/data/bookmarks.sqlite |
|
| 12 | - | - COOKIE_SECURE=false |
|
| 13 | 9 | - HOST=0.0.0.0 |
|
| 14 | 10 | - PORT=${PORT:-3000} |
|
| 11 | + | - BOOKMARKS_DB_PATH=/data/bookmarks-go.sqlite |
|
| 12 | + | - BOOKMARKS_PASSWORD=${BOOKMARKS_PASSWORD:-changeme} |
|
| 13 | + | - BOOKMARKS_API_KEY=${BOOKMARKS_API_KEY:-} |
|
| 14 | + | - COOKIE_SECURE=${COOKIE_SECURE:-false} |
|
| 15 | 15 | volumes: |
|
| 16 | - | - bookmarks-data:/data |
|
| 16 | + | - bookmarks-go-data:/data |
|
| 17 | 17 | restart: unless-stopped |
|
| 18 | 18 | ||
| 19 | 19 | volumes: |
|
| 20 | - | bookmarks-data: |
|
| 20 | + | bookmarks-go-data: |
| 1 | - | use axum::{ |
|
| 2 | - | extract::{FromRef, FromRequestParts}, |
|
| 3 | - | http::request::Parts, |
|
| 4 | - | response::{IntoResponse, Redirect, Response}, |
|
| 5 | - | }; |
|
| 6 | - | use chrono::{Duration, Utc}; |
|
| 7 | - | use std::sync::Arc; |
|
| 8 | - | ||
| 9 | - | use crate::AppState; |
|
| 10 | - | use andromeda_db::session; |
|
| 11 | - | ||
| 12 | - | pub use andromeda_auth::{ |
|
| 13 | - | build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token, |
|
| 14 | - | verify_api_key, verify_password, |
|
| 15 | - | }; |
|
| 16 | - | ||
| 17 | - | const SESSION_DAYS: i64 = 7; |
|
| 18 | - | ||
| 19 | - | pub fn create_session(db: &andromeda_db::Db, token: &str) -> Result<(), andromeda_db::DbError> { |
|
| 20 | - | let expires = (Utc::now() + Duration::days(SESSION_DAYS)) |
|
| 21 | - | .format("%Y-%m-%d %H:%M:%S") |
|
| 22 | - | .to_string(); |
|
| 23 | - | session::insert_session(db, token, &expires) |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | pub fn is_valid_session(db: &andromeda_db::Db, token: &str) -> bool { |
|
| 27 | - | match session::get_session_expiry(db, token) { |
|
| 28 | - | Ok(Some(expires_at)) => chrono::NaiveDateTime::parse_from_str(&expires_at, "%Y-%m-%d %H:%M:%S") |
|
| 29 | - | .map(|exp| exp > Utc::now().naive_utc()) |
|
| 30 | - | .unwrap_or(false), |
|
| 31 | - | _ => false, |
|
| 32 | - | } |
|
| 33 | - | } |
|
| 34 | - | ||
| 35 | - | pub fn delete_session(db: &andromeda_db::Db, token: &str) { |
|
| 36 | - | let _ = session::delete_session(db, token); |
|
| 37 | - | } |
|
| 38 | - | ||
| 39 | - | pub struct AuthSession; |
|
| 40 | - | ||
| 41 | - | impl<S> FromRequestParts<S> for AuthSession |
|
| 42 | - | where |
|
| 43 | - | S: Send + Sync, |
|
| 44 | - | Arc<AppState>: FromRef<S>, |
|
| 45 | - | { |
|
| 46 | - | type Rejection = Response; |
|
| 47 | - | ||
| 48 | - | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
|
| 49 | - | let state = Arc::<AppState>::from_ref(state); |
|
| 50 | - | if let Some(token) = extract_session_cookie(&parts.headers) { |
|
| 51 | - | if is_valid_session(&state.db, &token) { |
|
| 52 | - | return Ok(AuthSession); |
|
| 53 | - | } |
|
| 54 | - | } |
|
| 55 | - | Err(Redirect::to("/login").into_response()) |
|
| 56 | - | } |
|
| 57 | - | } |
| 1 | - | use andromeda_db::{Db, DbError}; |
|
| 2 | - | use nanoid::nanoid; |
|
| 3 | - | use rusqlite::{OptionalExtension, params}; |
|
| 4 | - | use serde::Serialize; |
|
| 5 | - | ||
| 6 | - | pub const SCHEMA: &str = r#" |
|
| 7 | - | CREATE TABLE IF NOT EXISTS categories ( |
|
| 8 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 9 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 10 | - | name TEXT NOT NULL UNIQUE, |
|
| 11 | - | position INTEGER NOT NULL DEFAULT 0, |
|
| 12 | - | created_at INTEGER NOT NULL |
|
| 13 | - | ); |
|
| 14 | - | ||
| 15 | - | CREATE TABLE IF NOT EXISTS links ( |
|
| 16 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 17 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 18 | - | title TEXT NOT NULL, |
|
| 19 | - | url TEXT NOT NULL, |
|
| 20 | - | favicon_url TEXT, |
|
| 21 | - | category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, |
|
| 22 | - | created_at INTEGER NOT NULL |
|
| 23 | - | ); |
|
| 24 | - | ||
| 25 | - | CREATE INDEX IF NOT EXISTS idx_links_category ON links(category_id, created_at DESC); |
|
| 26 | - | "#; |
|
| 27 | - | ||
| 28 | - | #[derive(Debug, Clone, Serialize)] |
|
| 29 | - | pub struct Category { |
|
| 30 | - | pub id: i64, |
|
| 31 | - | pub short_id: String, |
|
| 32 | - | pub name: String, |
|
| 33 | - | pub position: i64, |
|
| 34 | - | } |
|
| 35 | - | ||
| 36 | - | #[derive(Debug, Clone, Serialize)] |
|
| 37 | - | pub struct Link { |
|
| 38 | - | pub id: i64, |
|
| 39 | - | pub short_id: String, |
|
| 40 | - | pub title: String, |
|
| 41 | - | pub url: String, |
|
| 42 | - | pub favicon_url: Option<String>, |
|
| 43 | - | pub category_id: i64, |
|
| 44 | - | pub created_at: i64, |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | pub fn migrate(db: &Db) -> Result<(), DbError> { |
|
| 48 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 49 | - | let has_position: bool = conn |
|
| 50 | - | .prepare("SELECT 1 FROM pragma_table_info('categories') WHERE name = 'position'")? |
|
| 51 | - | .exists([])?; |
|
| 52 | - | if !has_position { |
|
| 53 | - | conn.execute( |
|
| 54 | - | "ALTER TABLE categories ADD COLUMN position INTEGER NOT NULL DEFAULT 0", |
|
| 55 | - | [], |
|
| 56 | - | )?; |
|
| 57 | - | conn.execute( |
|
| 58 | - | "UPDATE categories SET position = id WHERE position = 0", |
|
| 59 | - | [], |
|
| 60 | - | )?; |
|
| 61 | - | } |
|
| 62 | - | let has_favicon: bool = conn |
|
| 63 | - | .prepare("SELECT 1 FROM pragma_table_info('links') WHERE name = 'favicon_url'")? |
|
| 64 | - | .exists([])?; |
|
| 65 | - | if !has_favicon { |
|
| 66 | - | conn.execute("ALTER TABLE links ADD COLUMN favicon_url TEXT", [])?; |
|
| 67 | - | } |
|
| 68 | - | Ok(()) |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | pub fn list_categories(db: &Db) -> Result<Vec<Category>, DbError> { |
|
| 72 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 73 | - | let mut stmt = conn.prepare( |
|
| 74 | - | "SELECT id, short_id, name, position FROM categories ORDER BY position ASC, name COLLATE NOCASE", |
|
| 75 | - | )?; |
|
| 76 | - | let rows = stmt.query_map([], |row| { |
|
| 77 | - | Ok(Category { |
|
| 78 | - | id: row.get(0)?, |
|
| 79 | - | short_id: row.get(1)?, |
|
| 80 | - | name: row.get(2)?, |
|
| 81 | - | position: row.get(3)?, |
|
| 82 | - | }) |
|
| 83 | - | })?; |
|
| 84 | - | Ok(rows.collect::<Result<Vec<_>, _>>()?) |
|
| 85 | - | } |
|
| 86 | - | ||
| 87 | - | pub fn create_category(db: &Db, name: &str) -> Result<Category, DbError> { |
|
| 88 | - | let now = chrono::Utc::now().timestamp(); |
|
| 89 | - | let short_id = nanoid!(10); |
|
| 90 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 91 | - | let next_pos: i64 = conn |
|
| 92 | - | .query_row("SELECT COALESCE(MAX(position), 0) + 1 FROM categories", [], |r| r.get(0))?; |
|
| 93 | - | conn.execute( |
|
| 94 | - | "INSERT INTO categories (short_id, name, position, created_at) VALUES (?1, ?2, ?3, ?4)", |
|
| 95 | - | params![short_id, name, next_pos, now], |
|
| 96 | - | )?; |
|
| 97 | - | Ok(Category { |
|
| 98 | - | id: conn.last_insert_rowid(), |
|
| 99 | - | short_id, |
|
| 100 | - | name: name.to_string(), |
|
| 101 | - | position: next_pos, |
|
| 102 | - | }) |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | pub fn move_category(db: &Db, short_id: &str, direction: i64) -> Result<bool, DbError> { |
|
| 106 | - | let mut conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 107 | - | let tx = conn.transaction()?; |
|
| 108 | - | let current: Option<(i64, i64)> = tx |
|
| 109 | - | .query_row( |
|
| 110 | - | "SELECT id, position FROM categories WHERE short_id = ?1", |
|
| 111 | - | params![short_id], |
|
| 112 | - | |r| Ok((r.get(0)?, r.get(1)?)), |
|
| 113 | - | ) |
|
| 114 | - | .optional()?; |
|
| 115 | - | let Some((cur_id, cur_pos)) = current else { |
|
| 116 | - | return Ok(false); |
|
| 117 | - | }; |
|
| 118 | - | let neighbor: Option<(i64, i64)> = if direction < 0 { |
|
| 119 | - | tx.query_row( |
|
| 120 | - | "SELECT id, position FROM categories WHERE position < ?1 ORDER BY position DESC LIMIT 1", |
|
| 121 | - | params![cur_pos], |
|
| 122 | - | |r| Ok((r.get(0)?, r.get(1)?)), |
|
| 123 | - | ) |
|
| 124 | - | .optional()? |
|
| 125 | - | } else { |
|
| 126 | - | tx.query_row( |
|
| 127 | - | "SELECT id, position FROM categories WHERE position > ?1 ORDER BY position ASC LIMIT 1", |
|
| 128 | - | params![cur_pos], |
|
| 129 | - | |r| Ok((r.get(0)?, r.get(1)?)), |
|
| 130 | - | ) |
|
| 131 | - | .optional()? |
|
| 132 | - | }; |
|
| 133 | - | let Some((nb_id, nb_pos)) = neighbor else { |
|
| 134 | - | return Ok(false); |
|
| 135 | - | }; |
|
| 136 | - | tx.execute( |
|
| 137 | - | "UPDATE categories SET position = ?1 WHERE id = ?2", |
|
| 138 | - | params![nb_pos, cur_id], |
|
| 139 | - | )?; |
|
| 140 | - | tx.execute( |
|
| 141 | - | "UPDATE categories SET position = ?1 WHERE id = ?2", |
|
| 142 | - | params![cur_pos, nb_id], |
|
| 143 | - | )?; |
|
| 144 | - | tx.commit()?; |
|
| 145 | - | Ok(true) |
|
| 146 | - | } |
|
| 147 | - | ||
| 148 | - | pub fn delete_category_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 149 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 150 | - | let n = conn.execute("DELETE FROM categories WHERE short_id = ?1", params![short_id])?; |
|
| 151 | - | Ok(n > 0) |
|
| 152 | - | } |
|
| 153 | - | ||
| 154 | - | pub fn get_category_by_name(db: &Db, name: &str) -> Result<Option<Category>, DbError> { |
|
| 155 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 156 | - | let cat = conn |
|
| 157 | - | .query_row( |
|
| 158 | - | "SELECT id, short_id, name, position FROM categories WHERE name = ?1", |
|
| 159 | - | params![name], |
|
| 160 | - | |row| { |
|
| 161 | - | Ok(Category { |
|
| 162 | - | id: row.get(0)?, |
|
| 163 | - | short_id: row.get(1)?, |
|
| 164 | - | name: row.get(2)?, |
|
| 165 | - | position: row.get(3)?, |
|
| 166 | - | }) |
|
| 167 | - | }, |
|
| 168 | - | ) |
|
| 169 | - | .optional()?; |
|
| 170 | - | Ok(cat) |
|
| 171 | - | } |
|
| 172 | - | ||
| 173 | - | pub fn list_links(db: &Db) -> Result<Vec<Link>, DbError> { |
|
| 174 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 175 | - | let mut stmt = conn.prepare( |
|
| 176 | - | "SELECT id, short_id, title, url, favicon_url, category_id, created_at FROM links ORDER BY created_at DESC", |
|
| 177 | - | )?; |
|
| 178 | - | let rows = stmt.query_map([], |row| { |
|
| 179 | - | Ok(Link { |
|
| 180 | - | id: row.get(0)?, |
|
| 181 | - | short_id: row.get(1)?, |
|
| 182 | - | title: row.get(2)?, |
|
| 183 | - | url: row.get(3)?, |
|
| 184 | - | favicon_url: row.get(4)?, |
|
| 185 | - | category_id: row.get(5)?, |
|
| 186 | - | created_at: row.get(6)?, |
|
| 187 | - | }) |
|
| 188 | - | })?; |
|
| 189 | - | Ok(rows.collect::<Result<Vec<_>, _>>()?) |
|
| 190 | - | } |
|
| 191 | - | ||
| 192 | - | pub fn create_link( |
|
| 193 | - | db: &Db, |
|
| 194 | - | title: &str, |
|
| 195 | - | url: &str, |
|
| 196 | - | favicon_url: Option<&str>, |
|
| 197 | - | category_id: i64, |
|
| 198 | - | ) -> Result<Link, DbError> { |
|
| 199 | - | let now = chrono::Utc::now().timestamp(); |
|
| 200 | - | let short_id = nanoid!(10); |
|
| 201 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 202 | - | conn.execute( |
|
| 203 | - | "INSERT INTO links (short_id, title, url, favicon_url, category_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", |
|
| 204 | - | params![short_id, title, url, favicon_url, category_id, now], |
|
| 205 | - | )?; |
|
| 206 | - | Ok(Link { |
|
| 207 | - | id: conn.last_insert_rowid(), |
|
| 208 | - | short_id, |
|
| 209 | - | title: title.to_string(), |
|
| 210 | - | url: url.to_string(), |
|
| 211 | - | favicon_url: favicon_url.map(|s| s.to_string()), |
|
| 212 | - | category_id, |
|
| 213 | - | created_at: now, |
|
| 214 | - | }) |
|
| 215 | - | } |
|
| 216 | - | ||
| 217 | - | pub fn list_links_missing_favicon(db: &Db) -> Result<Vec<(i64, String)>, DbError> { |
|
| 218 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 219 | - | let mut stmt = conn.prepare( |
|
| 220 | - | "SELECT id, url FROM links WHERE favicon_url IS NULL OR favicon_url = ''", |
|
| 221 | - | )?; |
|
| 222 | - | let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; |
|
| 223 | - | Ok(rows.collect::<Result<Vec<_>, _>>()?) |
|
| 224 | - | } |
|
| 225 | - | ||
| 226 | - | pub fn update_link_favicon(db: &Db, id: i64, favicon_url: Option<&str>) -> Result<(), DbError> { |
|
| 227 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 228 | - | conn.execute( |
|
| 229 | - | "UPDATE links SET favicon_url = ?1 WHERE id = ?2", |
|
| 230 | - | params![favicon_url, id], |
|
| 231 | - | )?; |
|
| 232 | - | Ok(()) |
|
| 233 | - | } |
|
| 234 | - | ||
| 235 | - | pub fn delete_link_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 236 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 237 | - | let n = conn.execute("DELETE FROM links WHERE short_id = ?1", params![short_id])?; |
|
| 238 | - | Ok(n > 0) |
|
| 239 | - | } |
| 1 | - | use scraper::{Html, Selector}; |
|
| 2 | - | use std::time::Duration; |
|
| 3 | - | use url::Url; |
|
| 4 | - | ||
| 5 | - | fn build_client() -> reqwest::Client { |
|
| 6 | - | reqwest::Client::builder() |
|
| 7 | - | .timeout(Duration::from_secs(15)) |
|
| 8 | - | .user_agent("andromeda-bookmarks/0.1 (+https://github.com/stevedylandev/andromeda)") |
|
| 9 | - | .build() |
|
| 10 | - | .expect("Failed to build HTTP client") |
|
| 11 | - | } |
|
| 12 | - | ||
| 13 | - | /// Best-effort favicon URL for a page. Parses `<link rel="icon">` from the |
|
| 14 | - | /// HTML, falls back to `/favicon.ico` at the site root. Returns None if |
|
| 15 | - | /// the URL is invalid. |
|
| 16 | - | pub async fn discover_favicon(page_url: &str) -> Option<String> { |
|
| 17 | - | let parsed = Url::parse(page_url).ok()?; |
|
| 18 | - | let client = build_client(); |
|
| 19 | - | ||
| 20 | - | if let Ok(resp) = client.get(page_url).send().await { |
|
| 21 | - | if let Ok(body) = resp.text().await { |
|
| 22 | - | let document = Html::parse_document(&body); |
|
| 23 | - | let selector = Selector::parse( |
|
| 24 | - | r#"link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]"#, |
|
| 25 | - | ) |
|
| 26 | - | .ok()?; |
|
| 27 | - | if let Some(href) = document |
|
| 28 | - | .select(&selector) |
|
| 29 | - | .find_map(|el| el.attr("href")) |
|
| 30 | - | { |
|
| 31 | - | if let Ok(resolved) = parsed.join(href) { |
|
| 32 | - | return Some(resolved.to_string()); |
|
| 33 | - | } |
|
| 34 | - | } |
|
| 35 | - | } |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | parsed.join("/favicon.ico").ok().map(|u| u.to_string()) |
|
| 39 | - | } |
| 1 | - | mod auth; |
|
| 2 | - | mod db; |
|
| 3 | - | mod favicon; |
|
| 4 | - | ||
| 5 | - | use std::sync::{Arc, Mutex}; |
|
| 6 | - | ||
| 7 | - | use andromeda_db::{ |
|
| 8 | - | Db, |
|
| 9 | - | session::{SESSION_SCHEMA, prune_expired_sessions}, |
|
| 10 | - | }; |
|
| 11 | - | use askama::Template; |
|
| 12 | - | use axum::{ |
|
| 13 | - | Form, Json, Router, |
|
| 14 | - | extract::{Path, Query, Request, State}, |
|
| 15 | - | http::{HeaderMap, StatusCode, header}, |
|
| 16 | - | middleware::{self, Next}, |
|
| 17 | - | response::{Html, IntoResponse, Redirect, Response}, |
|
| 18 | - | routing::{get, post}, |
|
| 19 | - | }; |
|
| 20 | - | use rusqlite::Connection; |
|
| 21 | - | use rust_embed::Embed; |
|
| 22 | - | use serde::Deserialize; |
|
| 23 | - | ||
| 24 | - | #[derive(Embed)] |
|
| 25 | - | #[folder = "static/"] |
|
| 26 | - | struct Static; |
|
| 27 | - | ||
| 28 | - | async fn static_handler(Path(path): Path<String>) -> Response { |
|
| 29 | - | match Static::get(&path) { |
|
| 30 | - | Some(file) => { |
|
| 31 | - | let mime = mime_guess::from_path(&path).first_or_octet_stream(); |
|
| 32 | - | ([(header::CONTENT_TYPE, mime.as_ref())], file.data.to_vec()).into_response() |
|
| 33 | - | } |
|
| 34 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 35 | - | } |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | use crate::db::{Category, Link}; |
|
| 39 | - | ||
| 40 | - | pub struct AppState { |
|
| 41 | - | pub db: Db, |
|
| 42 | - | pub admin_password: Option<String>, |
|
| 43 | - | pub api_key: Option<String>, |
|
| 44 | - | pub cookie_secure: bool, |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | // ── Templates ──────────────────────────────────────────────────────────── |
|
| 48 | - | ||
| 49 | - | struct CategoryGroup { |
|
| 50 | - | name: String, |
|
| 51 | - | links: Vec<Link>, |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | #[derive(Template)] |
|
| 55 | - | #[template(path = "index.html")] |
|
| 56 | - | struct IndexTemplate { |
|
| 57 | - | groups: Vec<CategoryGroup>, |
|
| 58 | - | } |
|
| 59 | - | ||
| 60 | - | #[derive(Template)] |
|
| 61 | - | #[template(path = "login.html")] |
|
| 62 | - | struct LoginTemplate { |
|
| 63 | - | error: Option<String>, |
|
| 64 | - | } |
|
| 65 | - | ||
| 66 | - | #[derive(Template)] |
|
| 67 | - | #[template(path = "admin.html")] |
|
| 68 | - | struct AdminTemplate { |
|
| 69 | - | success: Option<String>, |
|
| 70 | - | error: Option<String>, |
|
| 71 | - | categories: Vec<Category>, |
|
| 72 | - | links: Vec<AdminLinkRow>, |
|
| 73 | - | } |
|
| 74 | - | ||
| 75 | - | struct AdminLinkRow { |
|
| 76 | - | short_id: String, |
|
| 77 | - | title: String, |
|
| 78 | - | url: String, |
|
| 79 | - | favicon_url: Option<String>, |
|
| 80 | - | category: String, |
|
| 81 | - | } |
|
| 82 | - | ||
| 83 | - | #[derive(Deserialize, Default)] |
|
| 84 | - | struct FlashQuery { |
|
| 85 | - | error: Option<String>, |
|
| 86 | - | success: Option<String>, |
|
| 87 | - | } |
|
| 88 | - | ||
| 89 | - | fn render<T: Template>(tpl: T) -> Response { |
|
| 90 | - | match tpl.render() { |
|
| 91 | - | Ok(html) => Html(html).into_response(), |
|
| 92 | - | Err(e) => { |
|
| 93 | - | tracing::error!("template render: {e}"); |
|
| 94 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() |
|
| 95 | - | } |
|
| 96 | - | } |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | // ── Public web ─────────────────────────────────────────────────────────── |
|
| 100 | - | ||
| 101 | - | async fn index_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 102 | - | let categories = db::list_categories(&state.db).unwrap_or_default(); |
|
| 103 | - | let all_links = db::list_links(&state.db).unwrap_or_default(); |
|
| 104 | - | let groups = categories |
|
| 105 | - | .into_iter() |
|
| 106 | - | .map(|c| { |
|
| 107 | - | let links = all_links |
|
| 108 | - | .iter() |
|
| 109 | - | .filter(|l| l.category_id == c.id) |
|
| 110 | - | .cloned() |
|
| 111 | - | .collect(); |
|
| 112 | - | CategoryGroup { name: c.name, links } |
|
| 113 | - | }) |
|
| 114 | - | .collect(); |
|
| 115 | - | render(IndexTemplate { groups }) |
|
| 116 | - | } |
|
| 117 | - | ||
| 118 | - | // ── Login / logout ─────────────────────────────────────────────────────── |
|
| 119 | - | ||
| 120 | - | #[derive(Deserialize)] |
|
| 121 | - | struct LoginForm { |
|
| 122 | - | password: String, |
|
| 123 | - | } |
|
| 124 | - | ||
| 125 | - | async fn login_get(Query(q): Query<FlashQuery>) -> Response { |
|
| 126 | - | render(LoginTemplate { error: q.error }) |
|
| 127 | - | } |
|
| 128 | - | ||
| 129 | - | async fn login_post(State(state): State<Arc<AppState>>, Form(form): Form<LoginForm>) -> Response { |
|
| 130 | - | let pw = match &state.admin_password { |
|
| 131 | - | Some(p) => p, |
|
| 132 | - | None => return Redirect::to("/login?error=No+password+configured").into_response(), |
|
| 133 | - | }; |
|
| 134 | - | if !auth::verify_password(&form.password, pw) { |
|
| 135 | - | return Redirect::to("/login?error=Invalid+password").into_response(); |
|
| 136 | - | } |
|
| 137 | - | let token = auth::generate_session_token(); |
|
| 138 | - | if let Err(e) = auth::create_session(&state.db, &token) { |
|
| 139 | - | tracing::error!("create session: {e}"); |
|
| 140 | - | return Redirect::to("/login?error=Session+error").into_response(); |
|
| 141 | - | } |
|
| 142 | - | let _ = prune_expired_sessions(&state.db); |
|
| 143 | - | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 144 | - | let mut resp = Redirect::to("/admin").into_response(); |
|
| 145 | - | resp.headers_mut() |
|
| 146 | - | .insert(header::SET_COOKIE, cookie.parse().unwrap()); |
|
| 147 | - | resp |
|
| 148 | - | } |
|
| 149 | - | ||
| 150 | - | async fn logout_handler(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response { |
|
| 151 | - | if let Some(token) = auth::extract_session_cookie(&headers) { |
|
| 152 | - | auth::delete_session(&state.db, &token); |
|
| 153 | - | } |
|
| 154 | - | let mut resp = Redirect::to("/login").into_response(); |
|
| 155 | - | resp.headers_mut() |
|
| 156 | - | .insert(header::SET_COOKIE, auth::clear_session_cookie().parse().unwrap()); |
|
| 157 | - | resp |
|
| 158 | - | } |
|
| 159 | - | ||
| 160 | - | // ── Admin ──────────────────────────────────────────────────────────────── |
|
| 161 | - | ||
| 162 | - | async fn admin_handler( |
|
| 163 | - | _session: auth::AuthSession, |
|
| 164 | - | State(state): State<Arc<AppState>>, |
|
| 165 | - | Query(q): Query<FlashQuery>, |
|
| 166 | - | ) -> Response { |
|
| 167 | - | let categories = db::list_categories(&state.db).unwrap_or_default(); |
|
| 168 | - | let raw_links = db::list_links(&state.db).unwrap_or_default(); |
|
| 169 | - | let links = raw_links |
|
| 170 | - | .into_iter() |
|
| 171 | - | .map(|l| { |
|
| 172 | - | let cat = categories |
|
| 173 | - | .iter() |
|
| 174 | - | .find(|c| c.id == l.category_id) |
|
| 175 | - | .map(|c| c.name.clone()) |
|
| 176 | - | .unwrap_or_default(); |
|
| 177 | - | AdminLinkRow { |
|
| 178 | - | short_id: l.short_id, |
|
| 179 | - | title: l.title, |
|
| 180 | - | url: l.url, |
|
| 181 | - | favicon_url: l.favicon_url, |
|
| 182 | - | category: cat, |
|
| 183 | - | } |
|
| 184 | - | }) |
|
| 185 | - | .collect(); |
|
| 186 | - | render(AdminTemplate { |
|
| 187 | - | success: q.success, |
|
| 188 | - | error: q.error, |
|
| 189 | - | categories, |
|
| 190 | - | links, |
|
| 191 | - | }) |
|
| 192 | - | } |
|
| 193 | - | ||
| 194 | - | #[derive(Deserialize)] |
|
| 195 | - | struct AddCategoryForm { |
|
| 196 | - | name: String, |
|
| 197 | - | } |
|
| 198 | - | ||
| 199 | - | async fn admin_add_category( |
|
| 200 | - | _session: auth::AuthSession, |
|
| 201 | - | State(state): State<Arc<AppState>>, |
|
| 202 | - | Form(form): Form<AddCategoryForm>, |
|
| 203 | - | ) -> Response { |
|
| 204 | - | let name = form.name.trim(); |
|
| 205 | - | if name.is_empty() { |
|
| 206 | - | return Redirect::to("/admin?error=Name+required").into_response(); |
|
| 207 | - | } |
|
| 208 | - | match db::create_category(&state.db, name) { |
|
| 209 | - | Ok(_) => Redirect::to("/admin?success=Category+added").into_response(), |
|
| 210 | - | Err(e) => { |
|
| 211 | - | tracing::error!("create category: {e}"); |
|
| 212 | - | Redirect::to("/admin?error=Failed+to+add+category").into_response() |
|
| 213 | - | } |
|
| 214 | - | } |
|
| 215 | - | } |
|
| 216 | - | ||
| 217 | - | async fn admin_delete_category( |
|
| 218 | - | _session: auth::AuthSession, |
|
| 219 | - | State(state): State<Arc<AppState>>, |
|
| 220 | - | Path(short_id): Path<String>, |
|
| 221 | - | ) -> Response { |
|
| 222 | - | let _ = db::delete_category_by_short_id(&state.db, &short_id); |
|
| 223 | - | Redirect::to("/admin?success=Category+removed").into_response() |
|
| 224 | - | } |
|
| 225 | - | ||
| 226 | - | async fn admin_move_category( |
|
| 227 | - | _session: auth::AuthSession, |
|
| 228 | - | State(state): State<Arc<AppState>>, |
|
| 229 | - | Path((short_id, dir)): Path<(String, String)>, |
|
| 230 | - | ) -> Response { |
|
| 231 | - | let direction: i64 = match dir.as_str() { |
|
| 232 | - | "up" => -1, |
|
| 233 | - | "down" => 1, |
|
| 234 | - | _ => return Redirect::to("/admin?error=Invalid+direction").into_response(), |
|
| 235 | - | }; |
|
| 236 | - | match db::move_category(&state.db, &short_id, direction) { |
|
| 237 | - | Ok(_) => Redirect::to("/admin?success=Category+reordered").into_response(), |
|
| 238 | - | Err(e) => { |
|
| 239 | - | tracing::error!("move category: {e}"); |
|
| 240 | - | Redirect::to("/admin?error=Failed+to+reorder").into_response() |
|
| 241 | - | } |
|
| 242 | - | } |
|
| 243 | - | } |
|
| 244 | - | ||
| 245 | - | #[derive(Deserialize)] |
|
| 246 | - | struct AddLinkForm { |
|
| 247 | - | title: String, |
|
| 248 | - | url: String, |
|
| 249 | - | category: String, |
|
| 250 | - | } |
|
| 251 | - | ||
| 252 | - | async fn admin_add_link( |
|
| 253 | - | _session: auth::AuthSession, |
|
| 254 | - | State(state): State<Arc<AppState>>, |
|
| 255 | - | Form(form): Form<AddLinkForm>, |
|
| 256 | - | ) -> Response { |
|
| 257 | - | let title = form.title.trim(); |
|
| 258 | - | let url = form.url.trim(); |
|
| 259 | - | if title.is_empty() || url.is_empty() { |
|
| 260 | - | return Redirect::to("/admin?error=Title+and+URL+required").into_response(); |
|
| 261 | - | } |
|
| 262 | - | let cat = match db::get_category_by_name(&state.db, form.category.trim()) { |
|
| 263 | - | Ok(Some(c)) => c, |
|
| 264 | - | Ok(None) => return Redirect::to("/admin?error=Unknown+category").into_response(), |
|
| 265 | - | Err(e) => { |
|
| 266 | - | tracing::error!("get category: {e}"); |
|
| 267 | - | return Redirect::to("/admin?error=Server+error").into_response(); |
|
| 268 | - | } |
|
| 269 | - | }; |
|
| 270 | - | let link = match db::create_link(&state.db, title, url, None, cat.id) { |
|
| 271 | - | Ok(l) => l, |
|
| 272 | - | Err(e) => { |
|
| 273 | - | tracing::error!("create link: {e}"); |
|
| 274 | - | return Redirect::to("/admin?error=Failed+to+add+link").into_response(); |
|
| 275 | - | } |
|
| 276 | - | }; |
|
| 277 | - | let db = state.db.clone(); |
|
| 278 | - | let url_owned = url.to_string(); |
|
| 279 | - | tokio::spawn(async move { |
|
| 280 | - | if let Some(fav) = favicon::discover_favicon(&url_owned).await { |
|
| 281 | - | let _ = db::update_link_favicon(&db, link.id, Some(&fav)); |
|
| 282 | - | } |
|
| 283 | - | }); |
|
| 284 | - | Redirect::to("/admin?success=Link+added").into_response() |
|
| 285 | - | } |
|
| 286 | - | ||
| 287 | - | async fn admin_delete_link( |
|
| 288 | - | _session: auth::AuthSession, |
|
| 289 | - | State(state): State<Arc<AppState>>, |
|
| 290 | - | Path(short_id): Path<String>, |
|
| 291 | - | ) -> Response { |
|
| 292 | - | let _ = db::delete_link_by_short_id(&state.db, &short_id); |
|
| 293 | - | Redirect::to("/admin?success=Link+removed").into_response() |
|
| 294 | - | } |
|
| 295 | - | ||
| 296 | - | // ── JSON API ───────────────────────────────────────────────────────────── |
|
| 297 | - | ||
| 298 | - | async fn api_list_categories(State(state): State<Arc<AppState>>) -> Response { |
|
| 299 | - | match db::list_categories(&state.db) { |
|
| 300 | - | Ok(cats) => Json(cats).into_response(), |
|
| 301 | - | Err(e) => { |
|
| 302 | - | tracing::error!("list categories: {e}"); |
|
| 303 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 304 | - | } |
|
| 305 | - | } |
|
| 306 | - | } |
|
| 307 | - | ||
| 308 | - | #[derive(Deserialize)] |
|
| 309 | - | struct ListLinksQuery { |
|
| 310 | - | category: Option<String>, |
|
| 311 | - | } |
|
| 312 | - | ||
| 313 | - | async fn api_list_links( |
|
| 314 | - | State(state): State<Arc<AppState>>, |
|
| 315 | - | Query(q): Query<ListLinksQuery>, |
|
| 316 | - | ) -> Response { |
|
| 317 | - | let categories = match db::list_categories(&state.db) { |
|
| 318 | - | Ok(c) => c, |
|
| 319 | - | Err(e) => { |
|
| 320 | - | tracing::error!("list categories: {e}"); |
|
| 321 | - | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 322 | - | } |
|
| 323 | - | }; |
|
| 324 | - | let links = match db::list_links(&state.db) { |
|
| 325 | - | Ok(l) => l, |
|
| 326 | - | Err(e) => { |
|
| 327 | - | tracing::error!("list links: {e}"); |
|
| 328 | - | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 329 | - | } |
|
| 330 | - | }; |
|
| 331 | - | if let Some(name) = q.category.as_deref().map(str::trim).filter(|s| !s.is_empty()) { |
|
| 332 | - | let Some(cat) = categories.iter().find(|c| c.name.eq_ignore_ascii_case(name)) else { |
|
| 333 | - | return ( |
|
| 334 | - | StatusCode::NOT_FOUND, |
|
| 335 | - | Json(serde_json::json!({ "error": "unknown category" })), |
|
| 336 | - | ) |
|
| 337 | - | .into_response(); |
|
| 338 | - | }; |
|
| 339 | - | let filtered: Vec<&Link> = links.iter().filter(|l| l.category_id == cat.id).collect(); |
|
| 340 | - | return Json(filtered).into_response(); |
|
| 341 | - | } |
|
| 342 | - | let mut grouped = serde_json::Map::new(); |
|
| 343 | - | for cat in &categories { |
|
| 344 | - | let items: Vec<&Link> = links.iter().filter(|l| l.category_id == cat.id).collect(); |
|
| 345 | - | grouped.insert(cat.name.clone(), serde_json::to_value(items).unwrap()); |
|
| 346 | - | } |
|
| 347 | - | Json(serde_json::Value::Object(grouped)).into_response() |
|
| 348 | - | } |
|
| 349 | - | ||
| 350 | - | #[derive(Deserialize)] |
|
| 351 | - | struct ApiCreateLink { |
|
| 352 | - | category: String, |
|
| 353 | - | title: String, |
|
| 354 | - | url: String, |
|
| 355 | - | } |
|
| 356 | - | ||
| 357 | - | async fn api_create_link( |
|
| 358 | - | State(state): State<Arc<AppState>>, |
|
| 359 | - | Json(body): Json<ApiCreateLink>, |
|
| 360 | - | ) -> Response { |
|
| 361 | - | let title = body.title.trim(); |
|
| 362 | - | let url = body.url.trim(); |
|
| 363 | - | if title.is_empty() || url.is_empty() { |
|
| 364 | - | return ( |
|
| 365 | - | StatusCode::BAD_REQUEST, |
|
| 366 | - | Json(serde_json::json!({ "error": "title and url required" })), |
|
| 367 | - | ) |
|
| 368 | - | .into_response(); |
|
| 369 | - | } |
|
| 370 | - | let cat = match db::get_category_by_name(&state.db, body.category.trim()) { |
|
| 371 | - | Ok(Some(c)) => c, |
|
| 372 | - | Ok(None) => { |
|
| 373 | - | return ( |
|
| 374 | - | StatusCode::NOT_FOUND, |
|
| 375 | - | Json(serde_json::json!({ "error": "unknown category" })), |
|
| 376 | - | ) |
|
| 377 | - | .into_response(); |
|
| 378 | - | } |
|
| 379 | - | Err(e) => { |
|
| 380 | - | tracing::error!("get category: {e}"); |
|
| 381 | - | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 382 | - | } |
|
| 383 | - | }; |
|
| 384 | - | let mut link = match db::create_link(&state.db, title, url, None, cat.id) { |
|
| 385 | - | Ok(l) => l, |
|
| 386 | - | Err(e) => { |
|
| 387 | - | tracing::error!("create link: {e}"); |
|
| 388 | - | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 389 | - | } |
|
| 390 | - | }; |
|
| 391 | - | if let Some(fav) = favicon::discover_favicon(url).await { |
|
| 392 | - | let _ = db::update_link_favicon(&state.db, link.id, Some(&fav)); |
|
| 393 | - | link.favicon_url = Some(fav); |
|
| 394 | - | } |
|
| 395 | - | (StatusCode::CREATED, Json(link)).into_response() |
|
| 396 | - | } |
|
| 397 | - | ||
| 398 | - | async fn require_api_key( |
|
| 399 | - | State(state): State<Arc<AppState>>, |
|
| 400 | - | headers: HeaderMap, |
|
| 401 | - | request: Request, |
|
| 402 | - | next: Next, |
|
| 403 | - | ) -> Result<Response, (StatusCode, Json<serde_json::Value>)> { |
|
| 404 | - | let server_key = state.api_key.as_deref().ok_or(( |
|
| 405 | - | StatusCode::FORBIDDEN, |
|
| 406 | - | Json(serde_json::json!({ "error": "API key not configured" })), |
|
| 407 | - | ))?; |
|
| 408 | - | let provided = headers.get("x-api-key").and_then(|v| v.to_str().ok()); |
|
| 409 | - | if let Some(k) = provided { |
|
| 410 | - | if auth::verify_api_key(k, server_key) { |
|
| 411 | - | return Ok(next.run(request).await); |
|
| 412 | - | } |
|
| 413 | - | } |
|
| 414 | - | Err(( |
|
| 415 | - | StatusCode::UNAUTHORIZED, |
|
| 416 | - | Json(serde_json::json!({ "error": "Invalid or missing API key" })), |
|
| 417 | - | )) |
|
| 418 | - | } |
|
| 419 | - | ||
| 420 | - | // ── main ───────────────────────────────────────────────────────────────── |
|
| 421 | - | ||
| 422 | - | #[tokio::main] |
|
| 423 | - | async fn main() { |
|
| 424 | - | dotenvy::dotenv().ok(); |
|
| 425 | - | tracing_subscriber::fmt() |
|
| 426 | - | .with_env_filter( |
|
| 427 | - | tracing_subscriber::EnvFilter::try_from_default_env() |
|
| 428 | - | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,bookmarks=info")), |
|
| 429 | - | ) |
|
| 430 | - | .init(); |
|
| 431 | - | ||
| 432 | - | let db_path = |
|
| 433 | - | std::env::var("BOOKMARKS_DB_PATH").unwrap_or_else(|_| "bookmarks.sqlite".to_string()); |
|
| 434 | - | let conn = Connection::open(&db_path).expect("open sqlite"); |
|
| 435 | - | conn.execute_batch("PRAGMA foreign_keys = ON;") |
|
| 436 | - | .expect("enable foreign keys"); |
|
| 437 | - | conn.execute_batch(SESSION_SCHEMA).expect("session schema"); |
|
| 438 | - | conn.execute_batch(db::SCHEMA).expect("bookmarks schema"); |
|
| 439 | - | let db: Db = Arc::new(Mutex::new(conn)); |
|
| 440 | - | db::migrate(&db).expect("bookmarks migrate"); |
|
| 441 | - | ||
| 442 | - | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 443 | - | .map(|v| v.eq_ignore_ascii_case("true")) |
|
| 444 | - | .unwrap_or(false); |
|
| 445 | - | ||
| 446 | - | let state = Arc::new(AppState { |
|
| 447 | - | db, |
|
| 448 | - | admin_password: std::env::var("BOOKMARKS_PASSWORD").ok().filter(|s| !s.is_empty()), |
|
| 449 | - | api_key: std::env::var("BOOKMARKS_API_KEY").ok().filter(|s| !s.is_empty()), |
|
| 450 | - | cookie_secure, |
|
| 451 | - | }); |
|
| 452 | - | ||
| 453 | - | { |
|
| 454 | - | let db = state.db.clone(); |
|
| 455 | - | tokio::spawn(async move { |
|
| 456 | - | let pending = match db::list_links_missing_favicon(&db) { |
|
| 457 | - | Ok(rows) => rows, |
|
| 458 | - | Err(e) => { |
|
| 459 | - | tracing::error!("favicon backfill query: {e}"); |
|
| 460 | - | return; |
|
| 461 | - | } |
|
| 462 | - | }; |
|
| 463 | - | if pending.is_empty() { |
|
| 464 | - | return; |
|
| 465 | - | } |
|
| 466 | - | tracing::info!("favicon backfill: {} link(s)", pending.len()); |
|
| 467 | - | for (id, url) in pending { |
|
| 468 | - | if let Some(fav) = favicon::discover_favicon(&url).await { |
|
| 469 | - | if let Err(e) = db::update_link_favicon(&db, id, Some(&fav)) { |
|
| 470 | - | tracing::error!("favicon backfill update {id}: {e}"); |
|
| 471 | - | } |
|
| 472 | - | } |
|
| 473 | - | tokio::time::sleep(std::time::Duration::from_millis(250)).await; |
|
| 474 | - | } |
|
| 475 | - | tracing::info!("favicon backfill: done"); |
|
| 476 | - | }); |
|
| 477 | - | } |
|
| 478 | - | ||
| 479 | - | let api_authed = Router::new() |
|
| 480 | - | .route("/api/links", post(api_create_link)) |
|
| 481 | - | .route_layer(middleware::from_fn_with_state(state.clone(), require_api_key)); |
|
| 482 | - | ||
| 483 | - | let api_open = Router::new() |
|
| 484 | - | .route("/api/categories", get(api_list_categories)) |
|
| 485 | - | .route("/api/links", get(api_list_links)); |
|
| 486 | - | ||
| 487 | - | let app = Router::new() |
|
| 488 | - | .route("/", get(index_handler)) |
|
| 489 | - | .route("/login", get(login_get).post(login_post)) |
|
| 490 | - | .route("/logout", get(logout_handler)) |
|
| 491 | - | .route("/admin", get(admin_handler)) |
|
| 492 | - | .route("/admin/categories", post(admin_add_category)) |
|
| 493 | - | .route("/admin/categories/{short_id}/delete", post(admin_delete_category)) |
|
| 494 | - | .route("/admin/categories/{short_id}/move/{dir}", post(admin_move_category)) |
|
| 495 | - | .route("/admin/links", post(admin_add_link)) |
|
| 496 | - | .route("/admin/links/{short_id}/delete", post(admin_delete_link)) |
|
| 497 | - | .route("/static/{*path}", get(static_handler)) |
|
| 498 | - | .merge(api_authed) |
|
| 499 | - | .merge(api_open) |
|
| 500 | - | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 501 | - | .with_state(state); |
|
| 502 | - | ||
| 503 | - | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
|
| 504 | - | let port: u16 = std::env::var("PORT") |
|
| 505 | - | .ok() |
|
| 506 | - | .and_then(|v| v.parse().ok()) |
|
| 507 | - | .unwrap_or(3000); |
|
| 508 | - | let addr = format!("{host}:{port}"); |
|
| 509 | - | let listener = tokio::net::TcpListener::bind(&addr) |
|
| 510 | - | .await |
|
| 511 | - | .unwrap_or_else(|_| panic!("Failed to bind to {addr}")); |
|
| 512 | - | ||
| 513 | - | tracing::info!("Bookmarks server running on http://{host}:{port}"); |
|
| 514 | - | axum::serve(listener, app).await.unwrap(); |
|
| 515 | - | } |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" /> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" /> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" /> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest" /> |
|
| 13 | - | <title>Bookmarks | Admin</title> |
|
| 14 | - | <style> |
|
| 15 | - | .section-label { |
|
| 16 | - | font-size: 14px; |
|
| 17 | - | font-weight: 400; |
|
| 18 | - | opacity: 0.5; |
|
| 19 | - | margin: 0 0 0.5rem; |
|
| 20 | - | } |
|
| 21 | - | section { width: 100%; margin-top: 1.5rem; } |
|
| 22 | - | </style> |
|
| 23 | - | </head> |
|
| 24 | - | <body> |
|
| 25 | - | <div class="header"> |
|
| 26 | - | <a href="/" class="logo">BOOKMARKS</a> |
|
| 27 | - | <nav class="links"> |
|
| 28 | - | <a href="/logout">logout</a> |
|
| 29 | - | </nav> |
|
| 30 | - | </div> |
|
| 31 | - | ||
| 32 | - | {% if let Some(msg) = success %} |
|
| 33 | - | <p class="success">{{ msg }}</p> |
|
| 34 | - | {% endif %} |
|
| 35 | - | {% if let Some(err) = error %} |
|
| 36 | - | <p class="error">{{ err }}</p> |
|
| 37 | - | {% endif %} |
|
| 38 | - | ||
| 39 | - | <section> |
|
| 40 | - | <h3 class="section-label">Categories</h3> |
|
| 41 | - | <form class="form" method="POST" action="/admin/categories"> |
|
| 42 | - | <div class="form-row"> |
|
| 43 | - | <div class="form-field"> |
|
| 44 | - | <input type="text" name="name" placeholder="new category" required /> |
|
| 45 | - | </div> |
|
| 46 | - | <button type="submit">Add</button> |
|
| 47 | - | </div> |
|
| 48 | - | </form> |
|
| 49 | - | {% if categories.is_empty() %} |
|
| 50 | - | <p class="empty">No categories yet.</p> |
|
| 51 | - | {% else %} |
|
| 52 | - | <ul class="admin-list"> |
|
| 53 | - | {% for cat in categories %} |
|
| 54 | - | <li class="admin-list-item"> |
|
| 55 | - | <div class="admin-list-info"> |
|
| 56 | - | <span class="admin-list-title">{{ cat.name }}</span> |
|
| 57 | - | </div> |
|
| 58 | - | <div class="admin-list-actions"> |
|
| 59 | - | <form method="POST" action="/admin/categories/{{ cat.short_id }}/move/up" class="inline-form"> |
|
| 60 | - | <button type="submit" class="link-button">↑</button> |
|
| 61 | - | </form> |
|
| 62 | - | <form method="POST" action="/admin/categories/{{ cat.short_id }}/move/down" class="inline-form"> |
|
| 63 | - | <button type="submit" class="link-button">↓</button> |
|
| 64 | - | </form> |
|
| 65 | - | <form method="POST" action="/admin/categories/{{ cat.short_id }}/delete" class="inline-form"> |
|
| 66 | - | <button type="submit" class="link-button danger">delete</button> |
|
| 67 | - | </form> |
|
| 68 | - | </div> |
|
| 69 | - | </li> |
|
| 70 | - | {% endfor %} |
|
| 71 | - | </ul> |
|
| 72 | - | {% endif %} |
|
| 73 | - | </section> |
|
| 74 | - | ||
| 75 | - | <section> |
|
| 76 | - | <h3 class="section-label">Add Link</h3> |
|
| 77 | - | {% if categories.is_empty() %} |
|
| 78 | - | <p class="empty">Add a category first.</p> |
|
| 79 | - | {% else %} |
|
| 80 | - | <form class="form" method="POST" action="/admin/links"> |
|
| 81 | - | <div class="form-field"> |
|
| 82 | - | <label for="title">Title</label> |
|
| 83 | - | <input type="text" id="title" name="title" required /> |
|
| 84 | - | </div> |
|
| 85 | - | <div class="form-field"> |
|
| 86 | - | <label for="url">URL</label> |
|
| 87 | - | <input type="url" id="url" name="url" required /> |
|
| 88 | - | </div> |
|
| 89 | - | <div class="form-field"> |
|
| 90 | - | <label for="category">Category</label> |
|
| 91 | - | <select id="category" name="category" required> |
|
| 92 | - | {% for cat in categories %} |
|
| 93 | - | <option value="{{ cat.name }}">{{ cat.name }}</option> |
|
| 94 | - | {% endfor %} |
|
| 95 | - | </select> |
|
| 96 | - | </div> |
|
| 97 | - | <div class="form-actions"> |
|
| 98 | - | <button type="submit">Add link</button> |
|
| 99 | - | </div> |
|
| 100 | - | </form> |
|
| 101 | - | {% endif %} |
|
| 102 | - | </section> |
|
| 103 | - | ||
| 104 | - | <section> |
|
| 105 | - | <h3 class="section-label">Links</h3> |
|
| 106 | - | {% if links.is_empty() %} |
|
| 107 | - | <p class="empty">No links yet.</p> |
|
| 108 | - | {% else %} |
|
| 109 | - | <ul class="admin-list"> |
|
| 110 | - | {% for link in links %} |
|
| 111 | - | <li class="admin-list-item"> |
|
| 112 | - | <div class="admin-list-info"> |
|
| 113 | - | <a class="admin-list-title" href="{{ link.url }}" target="_blank" rel="noopener noreferrer"> |
|
| 114 | - | {% if let Some(fav) = link.favicon_url %} |
|
| 115 | - | <img class="favicon" src="{{ fav }}" alt="" width="16" height="16" loading="lazy" /> |
|
| 116 | - | {% endif %} |
|
| 117 | - | {{ link.title }} |
|
| 118 | - | </a> |
|
| 119 | - | <div class="admin-list-meta"> |
|
| 120 | - | <span class="tag">{{ link.category }}</span> |
|
| 121 | - | </div> |
|
| 122 | - | </div> |
|
| 123 | - | <div class="admin-list-actions"> |
|
| 124 | - | <form method="POST" action="/admin/links/{{ link.short_id }}/delete" class="inline-form"> |
|
| 125 | - | <button type="submit" class="link-button danger">delete</button> |
|
| 126 | - | </form> |
|
| 127 | - | </div> |
|
| 128 | - | </li> |
|
| 129 | - | {% endfor %} |
|
| 130 | - | </ul> |
|
| 131 | - | {% endif %} |
|
| 132 | - | </section> |
|
| 133 | - | </body> |
|
| 134 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" /> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" /> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" /> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest" /> |
|
| 13 | - | <title>Bookmarks</title> |
|
| 14 | - | <style> |
|
| 15 | - | .category-heading { |
|
| 16 | - | font-size: 14px; |
|
| 17 | - | font-weight: 400; |
|
| 18 | - | opacity: 0.5; |
|
| 19 | - | text-transform: uppercase; |
|
| 20 | - | letter-spacing: 0.05em; |
|
| 21 | - | } |
|
| 22 | - | </style> |
|
| 23 | - | </head> |
|
| 24 | - | <body> |
|
| 25 | - | <div class="header"> |
|
| 26 | - | <a href="/" class="logo">BOOKMARKS</a> |
|
| 27 | - | <nav class="links"> |
|
| 28 | - | <a href="/admin">add</a> |
|
| 29 | - | </nav> |
|
| 30 | - | </div> |
|
| 31 | - | ||
| 32 | - | {% if groups.is_empty() %} |
|
| 33 | - | <p class="empty">No categories yet.</p> |
|
| 34 | - | {% else %} |
|
| 35 | - | {% for group in groups %} |
|
| 36 | - | <section> |
|
| 37 | - | <h2 class="category-heading">{{ group.name }}</h2> |
|
| 38 | - | {% if group.links.is_empty() %} |
|
| 39 | - | <p class="empty">No links.</p> |
|
| 40 | - | {% else %} |
|
| 41 | - | <ul class="item-list"> |
|
| 42 | - | {% for link in group.links %} |
|
| 43 | - | <li class="item"> |
|
| 44 | - | <a class="item-title" href="{{ link.url }}" target="_blank" rel="noopener noreferrer"> |
|
| 45 | - | {% if let Some(fav) = link.favicon_url %} |
|
| 46 | - | <img class="favicon" src="{{ fav }}" alt="" width="16" height="16" loading="lazy" /> |
|
| 47 | - | {% endif %} |
|
| 48 | - | {{ link.title }} |
|
| 49 | - | </a> |
|
| 50 | - | <div class="item-meta">{{ link.url }}</div> |
|
| 51 | - | </li> |
|
| 52 | - | {% endfor %} |
|
| 53 | - | </ul> |
|
| 54 | - | {% endif %} |
|
| 55 | - | </section> |
|
| 56 | - | {% endfor %} |
|
| 57 | - | {% endif %} |
|
| 58 | - | </body> |
|
| 59 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" /> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" /> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" /> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest" /> |
|
| 13 | - | <title>Bookmarks | Login</title> |
|
| 14 | - | </head> |
|
| 15 | - | <body> |
|
| 16 | - | <div class="header"> |
|
| 17 | - | <a href="/" class="logo">BOOKMARKS</a> |
|
| 18 | - | </div> |
|
| 19 | - | ||
| 20 | - | {% if let Some(err) = error %} |
|
| 21 | - | <p class="error">{{ err }}</p> |
|
| 22 | - | {% endif %} |
|
| 23 | - | ||
| 24 | - | <form class="form" method="POST" action="/login"> |
|
| 25 | - | <div class="form-field"> |
|
| 26 | - | <label for="password">Password</label> |
|
| 27 | - | <input type="password" id="password" name="password" required autofocus /> |
|
| 28 | - | </div> |
|
| 29 | - | <div class="form-actions"> |
|
| 30 | - | <button type="submit">Login</button> |
|
| 31 | - | </div> |
|
| 32 | - | </form> |
|
| 33 | - | </body> |
|
| 34 | - | </html> |
| 1 | - | CELLAR_PASSWORD=changeme |
|
| 2 | - | CELLAR_DB_PATH=cellar.sqlite |
|
| 3 | - | ANTHROPIC_API_KEY= |
|
| 4 | - | COOKIE_SECURE=false |
|
| 5 | - | HOST=127.0.0.1 |
|
| 6 | - | PORT=3000 |
|
| 7 | - | SITE_URL=http://localhost:3000 |
|
| 8 | - | SITE_TITLE=Cellar |
|
| 9 | - | SITE_DESCRIPTION=Personal wine tasting log |
| 1 | - | # Build from repo root: docker build -t cellar-go -f apps/cellar-go/Dockerfile . |
|
| 2 | - | FROM golang:1.24-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/cellar-go/go.mod apps/cellar-go/go.sum ./apps/cellar-go/ |
|
| 6 | - | WORKDIR /app/apps/cellar-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/cellar-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /cellar-go . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 13 | - | COPY --from=builder /cellar-go /usr/local/bin/cellar-go |
|
| 14 | - | WORKDIR /data |
|
| 15 | - | ENV HOST=0.0.0.0 |
|
| 16 | - | ENV PORT=3000 |
|
| 17 | - | EXPOSE 3000 |
|
| 18 | - | CMD ["cellar-go"] |
| 1 | - | # cellar-go |
|
| 2 | - | ||
| 3 | - | Go rewrite of [cellar](../cellar). Wine tasting log with optional Anthropic |
|
| 4 | - | vision (label analysis) and per-wine RSS feed. |
|
| 5 | - | ||
| 6 | - | ## Notes vs Rust version |
|
| 7 | - | ||
| 8 | - | - Anthropic `/v1/messages` called via stdlib `net/http` (no SDK). |
|
| 9 | - | - Image processing uses stdlib `image` decode + JPEG re-encode at quality 75. |
|
| 10 | - | EXIF orientation is not respected; rotate before upload if needed. |
|
| 11 | - | - Multipart upload limit kept at 10 MB. |
|
| 12 | - | ||
| 13 | - | See `.env.example` for config. |
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/cellar-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | - | environment: |
|
| 9 | - | - HOST=0.0.0.0 |
|
| 10 | - | - PORT=${PORT:-3000} |
|
| 11 | - | - CELLAR_DB_PATH=/data/cellar-go.sqlite |
|
| 12 | - | - CELLAR_PASSWORD=${CELLAR_PASSWORD:-changeme} |
|
| 13 | - | - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} |
|
| 14 | - | - COOKIE_SECURE=${COOKIE_SECURE:-false} |
|
| 15 | - | - SITE_URL=${SITE_URL:-http://localhost:3000} |
|
| 16 | - | - SITE_TITLE=${SITE_TITLE:-Cellar} |
|
| 17 | - | - SITE_DESCRIPTION=${SITE_DESCRIPTION:-Personal wine tasting log} |
|
| 18 | - | volumes: |
|
| 19 | - | - cellar-go-data:/data |
|
| 20 | - | restart: unless-stopped |
|
| 21 | - | ||
| 22 | - | volumes: |
|
| 23 | - | cellar-go-data: |
| 1 | - | module github.com/stevedylandev/andromeda/apps/cellar-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/library |
|
| 2 | 2 | ||
| 3 | 3 | go 1.24.4 |
|
| 4 | 4 |
| 53 | 53 | } |
|
| 54 | 54 | ||
| 55 | 55 | addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000") |
|
| 56 | - | logger.Info("cellar-go server running", "addr", addr) |
|
| 56 | + | logger.Info("cellar server running", "addr", addr) |
|
| 57 | 57 | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 58 | 58 | log.Fatal(err) |
|
| 59 | 59 | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* cellar — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | textarea { |
|
| 6 | - | min-height: 120px; |
|
| 7 | - | } |
|
| 8 | - | ||
| 9 | - | input[type="file"] { |
|
| 10 | - | border: none; |
|
| 11 | - | padding: 0; |
|
| 12 | - | font-size: 12px; |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | button:disabled { |
|
| 16 | - | opacity: 0.3; |
|
| 17 | - | cursor: not-allowed; |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | - | /* Wine list (public) */ |
|
| 21 | - | ||
| 22 | - | .wine-list { |
|
| 23 | - | display: flex; |
|
| 24 | - | flex-direction: column; |
|
| 25 | - | width: 100%; |
|
| 26 | - | } |
|
| 27 | - | ||
| 28 | - | .wine-card { |
|
| 29 | - | display: flex; |
|
| 30 | - | align-items: center; |
|
| 31 | - | gap: 1rem; |
|
| 32 | - | padding: 12px 0; |
|
| 33 | - | border-bottom: 1px solid #333; |
|
| 34 | - | text-decoration: none; |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | .wine-card:hover { |
|
| 38 | - | opacity: 0.7; |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | .wine-pentagon { |
|
| 42 | - | flex-shrink: 0; |
|
| 43 | - | width: 80px; |
|
| 44 | - | height: 80px; |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | .wine-info { |
|
| 48 | - | display: flex; |
|
| 49 | - | flex-direction: column; |
|
| 50 | - | gap: 2px; |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | .wine-name { |
|
| 54 | - | font-size: 16px; |
|
| 55 | - | } |
|
| 56 | - | ||
| 57 | - | .wine-meta { |
|
| 58 | - | font-size: 12px; |
|
| 59 | - | opacity: 0.5; |
|
| 60 | - | } |
|
| 61 | - | ||
| 62 | - | /* Wine detail (public) */ |
|
| 63 | - | ||
| 64 | - | .wine-detail { |
|
| 65 | - | display: flex; |
|
| 66 | - | flex-direction: column; |
|
| 67 | - | gap: 1.5rem; |
|
| 68 | - | width: 100%; |
|
| 69 | - | padding-bottom: 4rem; |
|
| 70 | - | } |
|
| 71 | - | ||
| 72 | - | .wine-detail-top { |
|
| 73 | - | display: grid; |
|
| 74 | - | grid-template-columns: 1fr 1fr; |
|
| 75 | - | gap: 1.5rem; |
|
| 76 | - | align-items: center; |
|
| 77 | - | } |
|
| 78 | - | ||
| 79 | - | @media (max-width: 480px) { |
|
| 80 | - | .wine-detail-top { |
|
| 81 | - | grid-template-columns: 1fr; |
|
| 82 | - | } |
|
| 83 | - | .wine-image { |
|
| 84 | - | max-height: none; |
|
| 85 | - | width: 100%; |
|
| 86 | - | } |
|
| 87 | - | } |
|
| 88 | - | ||
| 89 | - | .wine-image-wrap { |
|
| 90 | - | width: 100%; |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | .wine-image { |
|
| 94 | - | width: 100%; |
|
| 95 | - | object-fit: cover; |
|
| 96 | - | border-radius: 4px; |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | .wine-detail-name { |
|
| 100 | - | font-size: 24px; |
|
| 101 | - | font-weight: 700; |
|
| 102 | - | letter-spacing: -0.5px; |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | .wine-detail-meta { |
|
| 106 | - | display: flex; |
|
| 107 | - | flex-direction: column; |
|
| 108 | - | gap: 0.25rem; |
|
| 109 | - | } |
|
| 110 | - | ||
| 111 | - | .meta-row { |
|
| 112 | - | display: flex; |
|
| 113 | - | gap: 0.75rem; |
|
| 114 | - | font-size: 14px; |
|
| 115 | - | } |
|
| 116 | - | ||
| 117 | - | .meta-label { |
|
| 118 | - | font-size: 12px; |
|
| 119 | - | opacity: 0.5; |
|
| 120 | - | } |
|
| 121 | - | ||
| 122 | - | .wine-detail-chart { |
|
| 123 | - | display: flex; |
|
| 124 | - | flex-direction: column; |
|
| 125 | - | align-items: center; |
|
| 126 | - | gap: 1rem; |
|
| 127 | - | padding: 0.75rem; |
|
| 128 | - | } |
|
| 129 | - | ||
| 130 | - | .wine-detail-notes { |
|
| 131 | - | display: flex; |
|
| 132 | - | flex-direction: column; |
|
| 133 | - | gap: 0.25rem; |
|
| 134 | - | } |
|
| 135 | - | ||
| 136 | - | .wine-detail-notes p { |
|
| 137 | - | white-space: pre-wrap; |
|
| 138 | - | } |
|
| 139 | - | ||
| 140 | - | /* Admin list */ |
|
| 141 | - | ||
| 142 | - | .admin-list { |
|
| 143 | - | display: flex; |
|
| 144 | - | flex-direction: column; |
|
| 145 | - | width: 100%; |
|
| 146 | - | } |
|
| 147 | - | ||
| 148 | - | .admin-item { |
|
| 149 | - | display: flex; |
|
| 150 | - | justify-content: space-between; |
|
| 151 | - | align-items: center; |
|
| 152 | - | padding: 8px 0; |
|
| 153 | - | border-bottom: 1px solid #333; |
|
| 154 | - | } |
|
| 155 | - | ||
| 156 | - | .admin-item-info { |
|
| 157 | - | display: flex; |
|
| 158 | - | flex-direction: column; |
|
| 159 | - | gap: 2px; |
|
| 160 | - | } |
|
| 161 | - | ||
| 162 | - | .admin-item-name { |
|
| 163 | - | font-size: 16px; |
|
| 164 | - | } |
|
| 165 | - | ||
| 166 | - | .admin-item-meta { |
|
| 167 | - | font-size: 12px; |
|
| 168 | - | opacity: 0.5; |
|
| 169 | - | } |
|
| 170 | - | ||
| 171 | - | .admin-actions { |
|
| 172 | - | display: flex; |
|
| 173 | - | gap: 1rem; |
|
| 174 | - | font-size: 12px; |
|
| 175 | - | } |
|
| 176 | - | ||
| 177 | - | /* Score inputs */ |
|
| 178 | - | ||
| 179 | - | .image-upload-row { |
|
| 180 | - | display: flex; |
|
| 181 | - | align-items: center; |
|
| 182 | - | gap: 0.75rem; |
|
| 183 | - | } |
|
| 184 | - | ||
| 185 | - | .score-group { |
|
| 186 | - | display: flex; |
|
| 187 | - | flex-direction: column; |
|
| 188 | - | gap: 0.5rem; |
|
| 189 | - | margin-top: 0.5rem; |
|
| 190 | - | } |
|
| 191 | - | ||
| 192 | - | .score-section-label { |
|
| 193 | - | font-size: 11px; |
|
| 194 | - | opacity: 0.4; |
|
| 195 | - | text-transform: uppercase; |
|
| 196 | - | letter-spacing: 1px; |
|
| 197 | - | margin-top: 0.75rem; |
|
| 198 | - | } |
|
| 199 | - | ||
| 200 | - | .score-section-label:first-child { |
|
| 201 | - | margin-top: 0; |
|
| 202 | - | } |
|
| 203 | - | ||
| 204 | - | .score-row { |
|
| 205 | - | display: flex; |
|
| 206 | - | align-items: center; |
|
| 207 | - | gap: 0.75rem; |
|
| 208 | - | } |
|
| 209 | - | ||
| 210 | - | .score-row label { |
|
| 211 | - | width: 80px; |
|
| 212 | - | flex-shrink: 0; |
|
| 213 | - | } |
|
| 214 | - | ||
| 215 | - | .score-row input[type="range"] { |
|
| 216 | - | flex: 1; |
|
| 217 | - | -webkit-appearance: none; |
|
| 218 | - | appearance: none; |
|
| 219 | - | height: 2px; |
|
| 220 | - | background: #555; |
|
| 221 | - | border: none; |
|
| 222 | - | padding: 0; |
|
| 223 | - | } |
|
| 224 | - | ||
| 225 | - | .score-row input[type="range"]::-webkit-slider-thumb { |
|
| 226 | - | -webkit-appearance: none; |
|
| 227 | - | appearance: none; |
|
| 228 | - | width: 14px; |
|
| 229 | - | height: 14px; |
|
| 230 | - | background: #ffffff; |
|
| 231 | - | border: none; |
|
| 232 | - | border-radius: 0; |
|
| 233 | - | cursor: pointer; |
|
| 234 | - | } |
|
| 235 | - | ||
| 236 | - | .score-row input[type="range"]::-moz-range-thumb { |
|
| 237 | - | width: 14px; |
|
| 238 | - | height: 14px; |
|
| 239 | - | background: #ffffff; |
|
| 240 | - | border: none; |
|
| 241 | - | border-radius: 0; |
|
| 242 | - | cursor: pointer; |
|
| 243 | - | } |
|
| 244 | - | ||
| 245 | - | .score-value { |
|
| 246 | - | width: 16px; |
|
| 247 | - | text-align: center; |
|
| 248 | - | font-size: 12px; |
|
| 249 | - | opacity: 0.7; |
|
| 250 | - | } |
| 1 | - | {{define "admin.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Admin - Cellar{{end}} |
|
| 3 | - | {{define "nav"}} |
|
| 4 | - | <nav class="links"> |
|
| 5 | - | <a href="/admin/new">new</a> |
|
| 6 | - | <a href="/wishlist">wishlist</a> |
|
| 7 | - | </nav> |
|
| 8 | - | {{end}} |
|
| 9 | - | {{define "content"}} |
|
| 10 | - | {{if not .Wines}}<p class="empty">no wines yet</p>{{end}} |
|
| 11 | - | <div class="admin-list"> |
|
| 12 | - | {{range .Wines}} |
|
| 13 | - | <div class="admin-item"> |
|
| 14 | - | <div class="admin-item-info"> |
|
| 15 | - | <a href="/wines/{{.ShortID}}" class="admin-item-name">{{.Name}}</a> |
|
| 16 | - | <span class="admin-item-meta">{{.Origin}}{{if .Grape}} · {{.Grape}}{{end}}</span> |
|
| 17 | - | </div> |
|
| 18 | - | <div class="admin-actions"> |
|
| 19 | - | <a href="/admin/edit/{{.ShortID}}">edit</a> |
|
| 20 | - | <form method="POST" action="/admin/delete/{{.ShortID}}" class="inline-form" onsubmit="return confirm('delete this wine?')"> |
|
| 21 | - | <button type="submit" class="link-button">delete</button> |
|
| 22 | - | </form> |
|
| 23 | - | </div> |
|
| 24 | - | </div> |
|
| 25 | - | {{end}} |
|
| 26 | - | </div> |
|
| 27 | - | {{end}} |
| 1 | - | {{define "base.html"}}<!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>{{block "title" .}}Cellar{{end}}</title> |
|
| 7 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | - | <meta property="og:title" content="Cellar"> |
|
| 13 | - | <meta property="og:image" content="/static/og.png"> |
|
| 14 | - | <meta property="og:type" content="website"> |
|
| 15 | - | <meta name="theme-color" content="#121113" /> |
|
| 16 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 17 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | - | <link rel="alternate" type="application/rss+xml" title="Cellar RSS" href="/feed.xml"> |
|
| 19 | - | </head> |
|
| 20 | - | <body> |
|
| 21 | - | <header class="header"> |
|
| 22 | - | <a href="/" class="logo">cellar</a> |
|
| 23 | - | {{block "nav" .}}{{end}} |
|
| 24 | - | </header> |
|
| 25 | - | <main> |
|
| 26 | - | {{block "content" .}}{{end}} |
|
| 27 | - | </main> |
|
| 28 | - | </body> |
|
| 29 | - | </html>{{end}} |
| 1 | - | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Cellar{{end}} |
|
| 3 | - | {{define "nav"}} |
|
| 4 | - | <nav class="links"> |
|
| 5 | - | <a href="/admin/new">new</a> |
|
| 6 | - | <a href="/wishlist">wishlist</a> |
|
| 7 | - | </nav> |
|
| 8 | - | {{end}} |
|
| 9 | - | {{define "content"}} |
|
| 10 | - | {{if not .Wines}} |
|
| 11 | - | <p class="empty">no wines yet</p> |
|
| 12 | - | {{end}} |
|
| 13 | - | <div class="wine-list"> |
|
| 14 | - | {{range .Wines}} |
|
| 15 | - | <a href="/wines/{{.Wine.ShortID}}" class="wine-card"> |
|
| 16 | - | <div class="wine-pentagon"> |
|
| 17 | - | {{.PentagonSVG}} |
|
| 18 | - | </div> |
|
| 19 | - | <div class="wine-info"> |
|
| 20 | - | <span class="wine-name">{{.Wine.Name}}</span> |
|
| 21 | - | <span class="wine-meta">{{.Wine.Origin}}{{if .Wine.Grape}} · {{.Wine.Grape}}{{end}}</span> |
|
| 22 | - | </div> |
|
| 23 | - | </a> |
|
| 24 | - | {{end}} |
|
| 25 | - | </div> |
|
| 26 | - | {{end}} |
| 1 | - | {{define "login.html"}}<!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>Cellar</title> |
|
| 7 | - | <meta name="theme-color" content="#121113" /> |
|
| 8 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 9 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 10 | - | </head> |
|
| 11 | - | <body> |
|
| 12 | - | <header class="header"> |
|
| 13 | - | <span class="logo">CELLAR</span> |
|
| 14 | - | </header> |
|
| 15 | - | <main> |
|
| 16 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 17 | - | <form method="POST" action="/admin/login{{if .Next}}?next={{.Next}}{{end}}" class="form"> |
|
| 18 | - | <label for="password">password</label> |
|
| 19 | - | <input type="password" id="password" name="password" autofocus required> |
|
| 20 | - | <button type="submit">login</button> |
|
| 21 | - | </form> |
|
| 22 | - | </main> |
|
| 23 | - | </body> |
|
| 24 | - | </html>{{end}} |
| 1 | - | {{define "wine.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}{{.Wine.Name}} - Cellar{{end}} |
|
| 3 | - | {{define "nav"}} |
|
| 4 | - | <nav class="links"> |
|
| 5 | - | <a href="/admin/edit/{{.Wine.ShortID}}">edit</a> |
|
| 6 | - | </nav> |
|
| 7 | - | {{end}} |
|
| 8 | - | {{define "content"}} |
|
| 9 | - | <div class="wine-detail"> |
|
| 10 | - | <h1 class="wine-detail-name">{{.Wine.Name}}</h1> |
|
| 11 | - | <div class="wine-detail-top"> |
|
| 12 | - | {{if .Wine.HasImage}} |
|
| 13 | - | <div class="wine-image-wrap"> |
|
| 14 | - | <img src="/wines/{{.Wine.ShortID}}/image" alt="{{.Wine.Name}}" class="wine-image"> |
|
| 15 | - | </div> |
|
| 16 | - | {{end}} |
|
| 17 | - | {{if not .Wine.Wishlist}} |
|
| 18 | - | <div class="wine-detail-chart"> |
|
| 19 | - | {{.PentagonSVG}} |
|
| 20 | - | {{.BarsSVG}} |
|
| 21 | - | </div> |
|
| 22 | - | {{end}} |
|
| 23 | - | </div> |
|
| 24 | - | <div class="wine-detail-meta"> |
|
| 25 | - | {{if .Wine.Origin}}<div class="meta-row"><span class="meta-label">origin</span><span>{{.Wine.Origin}}</span></div>{{end}} |
|
| 26 | - | {{if .Wine.Grape}}<div class="meta-row"><span class="meta-label">grape</span><span>{{.Wine.Grape}}</span></div>{{end}} |
|
| 27 | - | </div> |
|
| 28 | - | {{if .Wine.Notes}}<div class="wine-detail-notes"><span class="meta-label">notes</span><p>{{.Wine.Notes}}</p></div>{{end}} |
|
| 29 | - | {{if .Wine.Background}}<div class="wine-detail-notes"><span class="meta-label">background</span><p>{{.Wine.Background}}</p></div>{{end}} |
|
| 30 | - | </div> |
|
| 31 | - | {{end}} |
| 1 | - | {{define "wine_form.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}{{if .Wine}}Edit{{else}}New{{end}} Wine - Cellar{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 5 | - | {{$w := .Wine}} |
|
| 6 | - | <form method="POST" enctype="multipart/form-data" |
|
| 7 | - | action="{{if $w}}/admin/edit/{{$w.ShortID}}{{else}}/admin/new{{end}}" |
|
| 8 | - | class="form"> |
|
| 9 | - | ||
| 10 | - | <label for="image">image</label> |
|
| 11 | - | <div class="image-upload-row"> |
|
| 12 | - | <input type="file" id="image" name="image" accept="image/*"> |
|
| 13 | - | {{if .HasAnthropicKey}}<button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button>{{end}} |
|
| 14 | - | </div> |
|
| 15 | - | ||
| 16 | - | <label for="name">name</label> |
|
| 17 | - | <input type="text" id="name" name="name" required value="{{if $w}}{{$w.Name}}{{end}}"> |
|
| 18 | - | ||
| 19 | - | <label for="origin">origin</label> |
|
| 20 | - | <input type="text" id="origin" name="origin" value="{{if $w}}{{$w.Origin}}{{end}}"> |
|
| 21 | - | ||
| 22 | - | <label for="grape">grape</label> |
|
| 23 | - | <input type="text" id="grape" name="grape" value="{{if $w}}{{$w.Grape}}{{end}}"> |
|
| 24 | - | ||
| 25 | - | <label for="notes">notes</label> |
|
| 26 | - | <textarea id="notes" name="notes" rows="5">{{if $w}}{{$w.Notes}}{{end}}</textarea> |
|
| 27 | - | ||
| 28 | - | <label for="background">background</label> |
|
| 29 | - | <textarea id="background" name="background" rows="5">{{if $w}}{{$w.Background}}{{end}}</textarea> |
|
| 30 | - | ||
| 31 | - | <div class="score-group"> |
|
| 32 | - | <div class="score-section-label">appearance</div> |
|
| 33 | - | <div class="score-row"> |
|
| 34 | - | <label for="clarity">clarity</label> |
|
| 35 | - | <input type="range" id="clarity" name="clarity" min="1" max="5" value="{{if $w}}{{$w.Clarity}}{{else}}3{{end}}"> |
|
| 36 | - | <span class="score-value" data-for="clarity">{{if $w}}{{$w.Clarity}}{{else}}3{{end}}</span> |
|
| 37 | - | </div> |
|
| 38 | - | <div class="score-row"> |
|
| 39 | - | <label for="color_intensity">intensity</label> |
|
| 40 | - | <input type="range" id="color_intensity" name="color_intensity" min="1" max="5" value="{{if $w}}{{$w.ColorIntensity}}{{else}}3{{end}}"> |
|
| 41 | - | <span class="score-value" data-for="color_intensity">{{if $w}}{{$w.ColorIntensity}}{{else}}3{{end}}</span> |
|
| 42 | - | </div> |
|
| 43 | - | ||
| 44 | - | <div class="score-section-label">nose</div> |
|
| 45 | - | <div class="score-row"> |
|
| 46 | - | <label for="aroma_intensity">aroma</label> |
|
| 47 | - | <input type="range" id="aroma_intensity" name="aroma_intensity" min="1" max="5" value="{{if $w}}{{$w.AromaIntensity}}{{else}}3{{end}}"> |
|
| 48 | - | <span class="score-value" data-for="aroma_intensity">{{if $w}}{{$w.AromaIntensity}}{{else}}3{{end}}</span> |
|
| 49 | - | </div> |
|
| 50 | - | <div class="score-row"> |
|
| 51 | - | <label for="nose_complexity">complexity</label> |
|
| 52 | - | <input type="range" id="nose_complexity" name="nose_complexity" min="1" max="5" value="{{if $w}}{{$w.NoseComplexity}}{{else}}3{{end}}"> |
|
| 53 | - | <span class="score-value" data-for="nose_complexity">{{if $w}}{{$w.NoseComplexity}}{{else}}3{{end}}</span> |
|
| 54 | - | </div> |
|
| 55 | - | ||
| 56 | - | <div class="score-section-label">palate</div> |
|
| 57 | - | <div class="score-row"> |
|
| 58 | - | <label for="sweetness">sweetness</label> |
|
| 59 | - | <input type="range" id="sweetness" name="sweetness" min="1" max="5" value="{{if $w}}{{$w.Sweetness}}{{else}}3{{end}}"> |
|
| 60 | - | <span class="score-value" data-for="sweetness">{{if $w}}{{$w.Sweetness}}{{else}}3{{end}}</span> |
|
| 61 | - | </div> |
|
| 62 | - | <div class="score-row"> |
|
| 63 | - | <label for="acidity">acidity</label> |
|
| 64 | - | <input type="range" id="acidity" name="acidity" min="1" max="5" value="{{if $w}}{{$w.Acidity}}{{else}}3{{end}}"> |
|
| 65 | - | <span class="score-value" data-for="acidity">{{if $w}}{{$w.Acidity}}{{else}}3{{end}}</span> |
|
| 66 | - | </div> |
|
| 67 | - | <div class="score-row"> |
|
| 68 | - | <label for="tannin">tannin</label> |
|
| 69 | - | <input type="range" id="tannin" name="tannin" min="1" max="5" value="{{if $w}}{{$w.Tannin}}{{else}}3{{end}}"> |
|
| 70 | - | <span class="score-value" data-for="tannin">{{if $w}}{{$w.Tannin}}{{else}}3{{end}}</span> |
|
| 71 | - | </div> |
|
| 72 | - | <div class="score-row"> |
|
| 73 | - | <label for="alcohol">alcohol</label> |
|
| 74 | - | <input type="range" id="alcohol" name="alcohol" min="1" max="5" value="{{if $w}}{{$w.Alcohol}}{{else}}3{{end}}"> |
|
| 75 | - | <span class="score-value" data-for="alcohol">{{if $w}}{{$w.Alcohol}}{{else}}3{{end}}</span> |
|
| 76 | - | </div> |
|
| 77 | - | <div class="score-row"> |
|
| 78 | - | <label for="body">body</label> |
|
| 79 | - | <input type="range" id="body" name="body" min="1" max="5" value="{{if $w}}{{$w.Body}}{{else}}3{{end}}"> |
|
| 80 | - | <span class="score-value" data-for="body">{{if $w}}{{$w.Body}}{{else}}3{{end}}</span> |
|
| 81 | - | </div> |
|
| 82 | - | </div> |
|
| 83 | - | ||
| 84 | - | <button type="submit">{{if $w}}update{{else}}create{{end}}</button> |
|
| 85 | - | </form> |
|
| 86 | - | ||
| 87 | - | <script> |
|
| 88 | - | document.querySelectorAll('input[type="range"]').forEach(function(input) { |
|
| 89 | - | input.addEventListener('input', function() { |
|
| 90 | - | var span = document.querySelector('.score-value[data-for="' + this.id + '"]'); |
|
| 91 | - | if (span) span.textContent = this.value; |
|
| 92 | - | }); |
|
| 93 | - | }); |
|
| 94 | - | ||
| 95 | - | {{if .HasAnthropicKey}} |
|
| 96 | - | async function analyzeImage() { |
|
| 97 | - | var fileInput = document.getElementById('image'); |
|
| 98 | - | if (!fileInput.files.length) return; |
|
| 99 | - | var formData = new FormData(); |
|
| 100 | - | formData.append('image', fileInput.files[0]); |
|
| 101 | - | var btn = document.getElementById('analyze-btn'); |
|
| 102 | - | btn.textContent = 'analyzing...'; |
|
| 103 | - | btn.disabled = true; |
|
| 104 | - | try { |
|
| 105 | - | var res = await fetch('/admin/analyze-image', { method: 'POST', body: formData }); |
|
| 106 | - | if (res.ok) { |
|
| 107 | - | var data = await res.json(); |
|
| 108 | - | if (data.name) document.getElementById('name').value = data.name; |
|
| 109 | - | if (data.origin) document.getElementById('origin').value = data.origin; |
|
| 110 | - | if (data.grape) document.getElementById('grape').value = data.grape; |
|
| 111 | - | if (data.background) document.getElementById('background').value = data.background; |
|
| 112 | - | } |
|
| 113 | - | } catch (e) { console.error('Analysis failed:', e); } |
|
| 114 | - | finally { btn.textContent = 'analyze'; btn.disabled = false; } |
|
| 115 | - | } |
|
| 116 | - | {{end}} |
|
| 117 | - | </script> |
|
| 118 | - | {{end}} |
| 1 | - | {{define "wishlist.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Wishlist - Cellar{{end}} |
|
| 3 | - | {{define "nav"}} |
|
| 4 | - | <nav class="links"> |
|
| 5 | - | <a href="/admin/wishlist/new">new</a> |
|
| 6 | - | <a href="/">cellar</a> |
|
| 7 | - | </nav> |
|
| 8 | - | {{end}} |
|
| 9 | - | {{define "content"}} |
|
| 10 | - | {{if not .Wines}}<p class="empty">wishlist empty</p>{{end}} |
|
| 11 | - | <div class="admin-list"> |
|
| 12 | - | {{$isAdmin := .IsAdmin}} |
|
| 13 | - | {{range .Wines}} |
|
| 14 | - | <div class="admin-item"> |
|
| 15 | - | <div class="admin-item-info"> |
|
| 16 | - | <a href="/wines/{{.ShortID}}" class="admin-item-name">{{.Name}}</a> |
|
| 17 | - | <span class="admin-item-meta">{{.Origin}}{{if .Grape}} · {{.Grape}}{{end}}</span> |
|
| 18 | - | </div> |
|
| 19 | - | {{if $isAdmin}} |
|
| 20 | - | <div class="admin-actions"> |
|
| 21 | - | <a href="/admin/wishlist/edit/{{.ShortID}}">edit</a> |
|
| 22 | - | <form method="POST" action="/admin/wishlist/promote/{{.ShortID}}" class="inline-form"> |
|
| 23 | - | <button type="submit" class="link-button">promote</button> |
|
| 24 | - | </form> |
|
| 25 | - | <form method="POST" action="/admin/wishlist/delete/{{.ShortID}}" class="inline-form" onsubmit="return confirm('delete this wine?')"> |
|
| 26 | - | <button type="submit" class="link-button">delete</button> |
|
| 27 | - | </form> |
|
| 28 | - | </div> |
|
| 29 | - | {{end}} |
|
| 30 | - | </div> |
|
| 31 | - | {{end}} |
|
| 32 | - | </div> |
|
| 33 | - | {{end}} |
| 1 | - | {{define "wishlist_form.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}{{if .Wine}}Edit{{else}}New{{end}} Wishlist Wine - Cellar{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 5 | - | {{$w := .Wine}} |
|
| 6 | - | <form method="POST" enctype="multipart/form-data" |
|
| 7 | - | action="{{if $w}}/admin/wishlist/edit/{{$w.ShortID}}{{else}}/admin/wishlist/new{{end}}" |
|
| 8 | - | class="form"> |
|
| 9 | - | ||
| 10 | - | <label for="image">image</label> |
|
| 11 | - | <div class="image-upload-row"> |
|
| 12 | - | <input type="file" id="image" name="image" accept="image/*"> |
|
| 13 | - | {{if .HasAnthropicKey}}<button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button>{{end}} |
|
| 14 | - | </div> |
|
| 15 | - | ||
| 16 | - | <label for="name">name</label> |
|
| 17 | - | <input type="text" id="name" name="name" required value="{{if $w}}{{$w.Name}}{{end}}"> |
|
| 18 | - | ||
| 19 | - | <label for="origin">origin</label> |
|
| 20 | - | <input type="text" id="origin" name="origin" value="{{if $w}}{{$w.Origin}}{{end}}"> |
|
| 21 | - | ||
| 22 | - | <label for="grape">grape</label> |
|
| 23 | - | <input type="text" id="grape" name="grape" value="{{if $w}}{{$w.Grape}}{{end}}"> |
|
| 24 | - | ||
| 25 | - | <label for="notes">notes</label> |
|
| 26 | - | <textarea id="notes" name="notes" rows="5">{{if $w}}{{$w.Notes}}{{end}}</textarea> |
|
| 27 | - | ||
| 28 | - | <label for="background">background</label> |
|
| 29 | - | <textarea id="background" name="background" rows="5">{{if $w}}{{$w.Background}}{{end}}</textarea> |
|
| 30 | - | ||
| 31 | - | <button type="submit">{{if $w}}update{{else}}create{{end}}</button> |
|
| 32 | - | </form> |
|
| 33 | - | ||
| 34 | - | <script> |
|
| 35 | - | {{if .HasAnthropicKey}} |
|
| 36 | - | async function analyzeImage() { |
|
| 37 | - | var fileInput = document.getElementById('image'); |
|
| 38 | - | if (!fileInput.files.length) return; |
|
| 39 | - | var formData = new FormData(); |
|
| 40 | - | formData.append('image', fileInput.files[0]); |
|
| 41 | - | var btn = document.getElementById('analyze-btn'); |
|
| 42 | - | btn.textContent = 'analyzing...'; |
|
| 43 | - | btn.disabled = true; |
|
| 44 | - | try { |
|
| 45 | - | var res = await fetch('/admin/analyze-image', { method: 'POST', body: formData }); |
|
| 46 | - | if (res.ok) { |
|
| 47 | - | var data = await res.json(); |
|
| 48 | - | if (data.name) document.getElementById('name').value = data.name; |
|
| 49 | - | if (data.origin) document.getElementById('origin').value = data.origin; |
|
| 50 | - | if (data.grape) document.getElementById('grape').value = data.grape; |
|
| 51 | - | if (data.background) document.getElementById('background').value = data.background; |
|
| 52 | - | } |
|
| 53 | - | } catch (e) { console.error('Analysis failed:', e); } |
|
| 54 | - | finally { btn.textContent = 'analyze'; btn.disabled = false; } |
|
| 55 | - | } |
|
| 56 | - | {{end}} |
|
| 57 | - | </script> |
|
| 58 | - | {{end}} |
| 1 | - | [package] |
|
| 2 | - | name = "cellar" |
|
| 3 | - | version = "0.2.2" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Personal wine tasting log" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | axum = { workspace = true, features = ["multipart"] } |
|
| 12 | - | tokio = { workspace = true } |
|
| 13 | - | serde = { workspace = true } |
|
| 14 | - | serde_json = { workspace = true } |
|
| 15 | - | rusqlite = { workspace = true } |
|
| 16 | - | nanoid = { workspace = true } |
|
| 17 | - | rust-embed = { workspace = true } |
|
| 18 | - | dotenvy = { workspace = true } |
|
| 19 | - | subtle = { workspace = true } |
|
| 20 | - | rand = { workspace = true } |
|
| 21 | - | tracing = { workspace = true } |
|
| 22 | - | tracing-subscriber = { workspace = true } |
|
| 23 | - | andromeda-auth = { workspace = true } |
|
| 24 | - | andromeda-db = { workspace = true, features = ["session"] } |
|
| 25 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 26 | - | askama = "0.15" |
|
| 27 | - | askama_web = { version = "0.15", features = ["axum-0.8"] } |
|
| 28 | - | reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } |
|
| 29 | - | base64 = "0.22" |
|
| 30 | - | image = "0.25" |
|
| 31 | - | serde_rusqlite = "0.41" |
|
| 32 | - | chrono = "0.4" |
|
| 33 | - | tower-http = { workspace = true, features = ["cors"] } |
| 1 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 1 | + | # Build from repo root: docker build -t cellar -f apps/cellar/Dockerfile . |
|
| 2 | + | FROM golang:1.24-bookworm AS builder |
|
| 2 | 3 | WORKDIR /app |
|
| 3 | - | ||
| 4 | - | FROM chef AS planner |
|
| 5 | - | COPY . . |
|
| 6 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 7 | - | ||
| 8 | - | FROM chef AS builder |
|
| 9 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 10 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 11 | - | RUN cargo chef cook --release --recipe-path recipe.json -p cellar |
|
| 12 | - | COPY . . |
|
| 13 | - | RUN cargo build --release -p cellar |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/cellar/go.mod apps/cellar/go.sum ./apps/cellar/ |
|
| 6 | + | WORKDIR /app/apps/cellar |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/cellar/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /cellar . |
|
| 14 | 10 | ||
| 15 | 11 | FROM debian:bookworm-slim |
|
| 16 | 12 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 17 | - | COPY --from=builder /app/target/release/cellar /usr/local/bin/cellar |
|
| 13 | + | COPY --from=builder /cellar /usr/local/bin/cellar |
|
| 18 | 14 | WORKDIR /data |
|
| 19 | - | EXPOSE 3000 |
|
| 20 | 15 | ENV HOST=0.0.0.0 |
|
| 21 | 16 | ENV PORT=3000 |
|
| 17 | + | EXPOSE 3000 |
|
| 22 | 18 | CMD ["cellar"] |
| 1 | - | # Cellar |
|
| 2 | - | ||
| 3 | - |  |
|
| 4 | - | ||
| 5 | - | A minimal wine collection tracker |
|
| 6 | - | ||
| 7 | - | ## Quickstart |
|
| 8 | - | ||
| 9 | - | ```bash |
|
| 10 | - | git clone https://github.com/stevedylandev/cellar.git |
|
| 11 | - | cd cellar |
|
| 12 | - | cp .env.example .env |
|
| 13 | - | # Edit .env with your password and Anthropic API key |
|
| 14 | - | cargo build --release |
|
| 15 | - | ./target/release/cellar |
|
| 16 | - | ``` |
|
| 17 | - | ||
| 18 | - | ### Environment Variables |
|
| 19 | - | ||
| 20 | - | | Variable | Description | Default | |
|
| 21 | - | |---|---|---| |
|
| 22 | - | | `CELLAR_PASSWORD` | Password for login authentication | `changeme` | |
|
| 23 | - | | `CELLAR_DB_PATH` | SQLite database file path | `cellar.sqlite` | |
|
| 24 | - | | `ANTHROPIC_API_KEY` | Anthropic API key for AI features | | |
|
| 25 | - | | `HOST` | Server bind address | `127.0.0.1` | |
|
| 26 | - | | `PORT` | Server port | `3000` | |
|
| 27 | - | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 1 | + | # cellar-go |
|
| 28 | 2 | ||
| 29 | - | ## Overview |
|
| 3 | + | Go rewrite of [cellar](../cellar). Wine tasting log with optional Anthropic |
|
| 4 | + | vision (label analysis) and per-wine RSS feed. |
|
| 30 | 5 | ||
| 31 | - | A simple, self-hosted wine collection app built with Rust. Here's a few highlights: |
|
| 32 | - | - Single Rust binary with embedded assets |
|
| 33 | - | - Password authentication with session cookies |
|
| 34 | - | - Add, edit, and delete wines from your collection |
|
| 35 | - | - AI-powered tasting notes via Claude |
|
| 36 | - | - Pentagon visualizations for wine profiles |
|
| 37 | - | - Dark themed UI with Commit Mono font |
|
| 38 | - | - SQLite for persistent storage |
|
| 6 | + | ## Notes vs Rust version |
|
| 39 | 7 | ||
| 40 | - | ## Structure |
|
| 8 | + | - Anthropic `/v1/messages` called via stdlib `net/http` (no SDK). |
|
| 9 | + | - Image processing uses stdlib `image` decode + JPEG re-encode at quality 75. |
|
| 10 | + | EXIF orientation is not respected; rotate before upload if needed. |
|
| 11 | + | - Multipart upload limit kept at 10 MB. |
|
| 41 | 12 | ||
| 42 | - | ``` |
|
| 43 | - | cellar/ |
|
| 44 | - | ├── src/ |
|
| 45 | - | │ ├── main.rs # App entrypoint, env vars, starts server |
|
| 46 | - | │ ├── server.rs # Axum router, HTTP handlers, and templates |
|
| 47 | - | │ ├── auth.rs # Password verification and session management |
|
| 48 | - | │ ├── claude.rs # Anthropic API integration for tasting notes |
|
| 49 | - | │ └── db.rs # SQLite database layer (wines, sessions) |
|
| 50 | - | ├── templates/ # Askama HTML templates |
|
| 51 | - | │ ├── base.html # Base layout with header and nav |
|
| 52 | - | │ ├── login.html # Login page |
|
| 53 | - | │ ├── index.html # Wine collection list |
|
| 54 | - | │ ├── wine.html # Single wine display |
|
| 55 | - | │ ├── wine_form.html # Add/edit wine form |
|
| 56 | - | │ └── admin.html # Admin page |
|
| 57 | - | ├── static/ # Favicons, og:image, styles, and webmanifest |
|
| 58 | - | ├── Dockerfile # Multi-stage build (Rust + Debian slim) |
|
| 59 | - | └── docker-compose.yml |
|
| 60 | - | ``` |
|
| 61 | - | ||
| 62 | - | ## Deployment |
|
| 63 | - | ||
| 64 | - | ### Railway |
|
| 65 | - | ||
| 66 | - | [](https://railway.com/deploy/MNprVh?referralCode=JGcIp6) |
|
| 67 | - | ||
| 68 | - | ### Docker (recommended) |
|
| 69 | - | ||
| 70 | - | ```bash |
|
| 71 | - | git clone https://github.com/stevedylandev/cellar.git |
|
| 72 | - | cd cellar |
|
| 73 | - | cp .env.example .env |
|
| 74 | - | # Edit .env with your password and Anthropic API key |
|
| 75 | - | docker compose up -d |
|
| 76 | - | ``` |
|
| 77 | - | ||
| 78 | - | This will start Cellar on port `3000` with a persistent volume for the SQLite database. |
|
| 79 | - | ||
| 80 | - | ### Binary |
|
| 81 | - | ||
| 82 | - | ```bash |
|
| 83 | - | cargo build --release |
|
| 84 | - | ``` |
|
| 85 | - | ||
| 86 | - | The resulting binary at `./target/release/cellar` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 87 | - | ||
| 88 | - | ## License |
|
| 89 | - | ||
| 90 | - | [MIT](LICENSE) |
|
| 13 | + | See `.env.example` for config. |
| 2 | 2 | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/cellar/Dockerfile |
|
| 5 | + | dockerfile: apps/cellar-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | 7 | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | 8 | environment: |
|
| 9 | + | - HOST=0.0.0.0 |
|
| 10 | + | - PORT=${PORT:-3000} |
|
| 11 | + | - CELLAR_DB_PATH=/data/cellar-go.sqlite |
|
| 9 | 12 | - CELLAR_PASSWORD=${CELLAR_PASSWORD:-changeme} |
|
| 10 | - | - CELLAR_DB_PATH=/data/cellar.sqlite |
|
| 11 | 13 | - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} |
|
| 12 | - | - COOKIE_SECURE=false |
|
| 13 | - | - HOST=0.0.0.0 |
|
| 14 | - | - PORT=${PORT:-3000} |
|
| 14 | + | - COOKIE_SECURE=${COOKIE_SECURE:-false} |
|
| 15 | + | - SITE_URL=${SITE_URL:-http://localhost:3000} |
|
| 16 | + | - SITE_TITLE=${SITE_TITLE:-Cellar} |
|
| 17 | + | - SITE_DESCRIPTION=${SITE_DESCRIPTION:-Personal wine tasting log} |
|
| 15 | 18 | volumes: |
|
| 16 | - | - cellar-data:/data |
|
| 19 | + | - cellar-go-data:/data |
|
| 17 | 20 | restart: unless-stopped |
|
| 18 | 21 | ||
| 19 | 22 | volumes: |
|
| 20 | - | cellar-data: |
|
| 23 | + | cellar-go-data: |
| 1 | - | use axum::{ |
|
| 2 | - | extract::FromRequestParts, |
|
| 3 | - | http::request::Parts, |
|
| 4 | - | response::{IntoResponse, Redirect, Response}, |
|
| 5 | - | }; |
|
| 6 | - | use std::sync::Arc; |
|
| 7 | - | ||
| 8 | - | use crate::db; |
|
| 9 | - | use crate::server::AppState; |
|
| 10 | - | ||
| 11 | - | pub use andromeda_auth::{ |
|
| 12 | - | build_session_cookie, clear_session_cookie, generate_session_token, verify_password, |
|
| 13 | - | }; |
|
| 14 | - | ||
| 15 | - | pub struct AuthSession; |
|
| 16 | - | ||
| 17 | - | impl FromRequestParts<Arc<AppState>> for AuthSession { |
|
| 18 | - | type Rejection = Response; |
|
| 19 | - | ||
| 20 | - | async fn from_request_parts( |
|
| 21 | - | parts: &mut Parts, |
|
| 22 | - | state: &Arc<AppState>, |
|
| 23 | - | ) -> Result<Self, Self::Rejection> { |
|
| 24 | - | let token = andromeda_auth::extract_session_cookie(&parts.headers); |
|
| 25 | - | if let Some(token) = token { |
|
| 26 | - | if is_valid_session(state, &token) { |
|
| 27 | - | return Ok(AuthSession); |
|
| 28 | - | } |
|
| 29 | - | } |
|
| 30 | - | let path = parts |
|
| 31 | - | .uri |
|
| 32 | - | .path_and_query() |
|
| 33 | - | .map(|pq| pq.as_str()) |
|
| 34 | - | .unwrap_or(parts.uri.path()); |
|
| 35 | - | let login_url = format!("/admin/login?next={}", urlencoding(path)); |
|
| 36 | - | Err(Redirect::to(&login_url).into_response()) |
|
| 37 | - | } |
|
| 38 | - | } |
|
| 39 | - | ||
| 40 | - | pub fn is_authenticated(state: &AppState, headers: &axum::http::HeaderMap) -> bool { |
|
| 41 | - | if let Some(token) = andromeda_auth::extract_session_cookie(headers) { |
|
| 42 | - | return is_valid_session(state, &token); |
|
| 43 | - | } |
|
| 44 | - | false |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | fn is_valid_session(state: &AppState, token: &str) -> bool { |
|
| 48 | - | match db::get_session_expiry(&state.db, token) { |
|
| 49 | - | Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(), |
|
| 50 | - | _ => false, |
|
| 51 | - | } |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | fn urlencoding(s: &str) -> String { |
|
| 55 | - | let mut out = String::with_capacity(s.len()); |
|
| 56 | - | for b in s.bytes() { |
|
| 57 | - | match b { |
|
| 58 | - | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { |
|
| 59 | - | out.push(b as char); |
|
| 60 | - | } |
|
| 61 | - | _ => { |
|
| 62 | - | out.push_str(&format!("%{:02X}", b)); |
|
| 63 | - | } |
|
| 64 | - | } |
|
| 65 | - | } |
|
| 66 | - | out |
|
| 67 | - | } |
| 1 | - | use base64::Engine; |
|
| 2 | - | use base64::engine::general_purpose::STANDARD; |
|
| 3 | - | use serde::{Deserialize, Serialize}; |
|
| 4 | - | ||
| 5 | - | #[derive(Serialize)] |
|
| 6 | - | struct ClaudeRequest { |
|
| 7 | - | model: String, |
|
| 8 | - | max_tokens: u32, |
|
| 9 | - | messages: Vec<ClaudeMessage>, |
|
| 10 | - | } |
|
| 11 | - | ||
| 12 | - | #[derive(Serialize)] |
|
| 13 | - | struct ClaudeMessage { |
|
| 14 | - | role: String, |
|
| 15 | - | content: Vec<ClaudeContent>, |
|
| 16 | - | } |
|
| 17 | - | ||
| 18 | - | #[derive(Serialize)] |
|
| 19 | - | #[serde(tag = "type")] |
|
| 20 | - | enum ClaudeContent { |
|
| 21 | - | #[serde(rename = "image")] |
|
| 22 | - | Image { source: ImageSource }, |
|
| 23 | - | #[serde(rename = "text")] |
|
| 24 | - | Text { text: String }, |
|
| 25 | - | } |
|
| 26 | - | ||
| 27 | - | #[derive(Serialize)] |
|
| 28 | - | struct ImageSource { |
|
| 29 | - | #[serde(rename = "type")] |
|
| 30 | - | source_type: String, |
|
| 31 | - | media_type: String, |
|
| 32 | - | data: String, |
|
| 33 | - | } |
|
| 34 | - | ||
| 35 | - | #[derive(Deserialize, Serialize)] |
|
| 36 | - | pub struct AnalyzeResult { |
|
| 37 | - | pub name: String, |
|
| 38 | - | pub origin: String, |
|
| 39 | - | pub grape: String, |
|
| 40 | - | pub background: String, |
|
| 41 | - | } |
|
| 42 | - | ||
| 43 | - | #[derive(Deserialize)] |
|
| 44 | - | struct ClaudeResponse { |
|
| 45 | - | content: Vec<ContentBlock>, |
|
| 46 | - | } |
|
| 47 | - | ||
| 48 | - | #[derive(Deserialize)] |
|
| 49 | - | struct ContentBlock { |
|
| 50 | - | text: Option<String>, |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | pub async fn analyze_wine_image( |
|
| 54 | - | api_key: &str, |
|
| 55 | - | image_bytes: &[u8], |
|
| 56 | - | media_type: &str, |
|
| 57 | - | ) -> Result<AnalyzeResult, String> { |
|
| 58 | - | let encoded = STANDARD.encode(image_bytes); |
|
| 59 | - | ||
| 60 | - | let request = ClaudeRequest { |
|
| 61 | - | model: "claude-sonnet-4-20250514".to_string(), |
|
| 62 | - | max_tokens: 1024, |
|
| 63 | - | messages: vec![ClaudeMessage { |
|
| 64 | - | role: "user".to_string(), |
|
| 65 | - | content: vec![ |
|
| 66 | - | ClaudeContent::Image { |
|
| 67 | - | source: ImageSource { |
|
| 68 | - | source_type: "base64".to_string(), |
|
| 69 | - | media_type: media_type.to_string(), |
|
| 70 | - | data: encoded, |
|
| 71 | - | }, |
|
| 72 | - | }, |
|
| 73 | - | ClaudeContent::Text { |
|
| 74 | - | text: "Look at this wine bottle label. Return a JSON object with exactly these fields: {\"name\": \"the full wine name\", \"origin\": \"region and/or country\", \"grape\": \"grape variety or blend\", \"background\": \"brief background about the wine and the winery, including any notable history or interesting facts\"}. If you cannot determine a field, use an empty string. Respond with ONLY the JSON, no other text.".to_string(), |
|
| 75 | - | }, |
|
| 76 | - | ], |
|
| 77 | - | }], |
|
| 78 | - | }; |
|
| 79 | - | ||
| 80 | - | let client = reqwest::Client::new(); |
|
| 81 | - | let response = client |
|
| 82 | - | .post("https://api.anthropic.com/v1/messages") |
|
| 83 | - | .header("x-api-key", api_key) |
|
| 84 | - | .header("anthropic-version", "2023-06-01") |
|
| 85 | - | .header("content-type", "application/json") |
|
| 86 | - | .json(&request) |
|
| 87 | - | .send() |
|
| 88 | - | .await |
|
| 89 | - | .map_err(|e| format!("Request failed: {}", e))?; |
|
| 90 | - | ||
| 91 | - | if !response.status().is_success() { |
|
| 92 | - | let status = response.status(); |
|
| 93 | - | let body = response.text().await.unwrap_or_default(); |
|
| 94 | - | return Err(format!("API error {}: {}", status, body)); |
|
| 95 | - | } |
|
| 96 | - | ||
| 97 | - | let claude_response: ClaudeResponse = response |
|
| 98 | - | .json() |
|
| 99 | - | .await |
|
| 100 | - | .map_err(|e| format!("Failed to parse response: {}", e))?; |
|
| 101 | - | ||
| 102 | - | let text = claude_response |
|
| 103 | - | .content |
|
| 104 | - | .iter() |
|
| 105 | - | .find_map(|block| block.text.as_ref()) |
|
| 106 | - | .ok_or_else(|| "No text in response".to_string())?; |
|
| 107 | - | ||
| 108 | - | let text = text.trim(); |
|
| 109 | - | let json_str = if let Some(start) = text.find('{') { |
|
| 110 | - | if let Some(end) = text.rfind('}') { |
|
| 111 | - | &text[start..=end] |
|
| 112 | - | } else { |
|
| 113 | - | text |
|
| 114 | - | } |
|
| 115 | - | } else { |
|
| 116 | - | text |
|
| 117 | - | }; |
|
| 118 | - | ||
| 119 | - | serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {} (raw: {})", e, text)) |
|
| 120 | - | } |
| 1 | - | use nanoid::nanoid; |
|
| 2 | - | use rusqlite::{Connection, params}; |
|
| 3 | - | use serde::{Deserialize, Serialize}; |
|
| 4 | - | use std::sync::{Arc, Mutex}; |
|
| 5 | - | ||
| 6 | - | pub use andromeda_db::{Db, DbError}; |
|
| 7 | - | pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions}; |
|
| 8 | - | ||
| 9 | - | #[derive(Debug, Serialize, Deserialize, Clone)] |
|
| 10 | - | pub struct Wine { |
|
| 11 | - | pub id: i64, |
|
| 12 | - | pub short_id: String, |
|
| 13 | - | pub name: String, |
|
| 14 | - | pub origin: String, |
|
| 15 | - | pub grape: String, |
|
| 16 | - | pub notes: String, |
|
| 17 | - | pub has_image: bool, |
|
| 18 | - | pub image_mime: Option<String>, |
|
| 19 | - | pub sweetness: i32, |
|
| 20 | - | pub acidity: i32, |
|
| 21 | - | pub tannin: i32, |
|
| 22 | - | pub alcohol: i32, |
|
| 23 | - | pub body: i32, |
|
| 24 | - | pub clarity: i32, |
|
| 25 | - | pub color_intensity: i32, |
|
| 26 | - | pub aroma_intensity: i32, |
|
| 27 | - | pub nose_complexity: i32, |
|
| 28 | - | pub background: String, |
|
| 29 | - | pub created_at: String, |
|
| 30 | - | pub wishlist: bool, |
|
| 31 | - | } |
|
| 32 | - | ||
| 33 | - | const SCHEMA: &str = " |
|
| 34 | - | CREATE TABLE IF NOT EXISTS wines ( |
|
| 35 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 36 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 37 | - | name TEXT NOT NULL, |
|
| 38 | - | origin TEXT NOT NULL, |
|
| 39 | - | grape TEXT NOT NULL, |
|
| 40 | - | notes TEXT NOT NULL, |
|
| 41 | - | image BLOB, |
|
| 42 | - | image_mime TEXT, |
|
| 43 | - | sweetness INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5), |
|
| 44 | - | acidity INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5), |
|
| 45 | - | tannin INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5), |
|
| 46 | - | alcohol INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5), |
|
| 47 | - | body INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5), |
|
| 48 | - | clarity INTEGER NOT NULL DEFAULT 3, |
|
| 49 | - | color_intensity INTEGER NOT NULL DEFAULT 3, |
|
| 50 | - | aroma_intensity INTEGER NOT NULL DEFAULT 3, |
|
| 51 | - | nose_complexity INTEGER NOT NULL DEFAULT 3, |
|
| 52 | - | background TEXT NOT NULL DEFAULT '', |
|
| 53 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 54 | - | wishlist INTEGER NOT NULL DEFAULT 0 |
|
| 55 | - | ); |
|
| 56 | - | ||
| 57 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 58 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 59 | - | token TEXT NOT NULL UNIQUE, |
|
| 60 | - | expires_at TEXT NOT NULL |
|
| 61 | - | ); |
|
| 62 | - | "; |
|
| 63 | - | ||
| 64 | - | pub fn init_db() -> Db { |
|
| 65 | - | let path = std::env::var("CELLAR_DB_PATH").unwrap_or_else(|_| "cellar.sqlite".to_string()); |
|
| 66 | - | let conn = Connection::open(&path).expect("Failed to open database"); |
|
| 67 | - | ||
| 68 | - | conn.execute_batch(SCHEMA).expect("Failed to create tables"); |
|
| 69 | - | ||
| 70 | - | // Migrations for existing databases (idempotent — ALTER TABLE fails silently if column exists) |
|
| 71 | - | let _ = conn.execute("ALTER TABLE wines ADD COLUMN background TEXT NOT NULL DEFAULT ''", []); |
|
| 72 | - | let _ = conn.execute("ALTER TABLE wines ADD COLUMN clarity INTEGER NOT NULL DEFAULT 3", []); |
|
| 73 | - | let _ = conn.execute("ALTER TABLE wines ADD COLUMN color_intensity INTEGER NOT NULL DEFAULT 3", []); |
|
| 74 | - | let _ = conn.execute("ALTER TABLE wines ADD COLUMN aroma_intensity INTEGER NOT NULL DEFAULT 3", []); |
|
| 75 | - | let _ = conn.execute("ALTER TABLE wines ADD COLUMN nose_complexity INTEGER NOT NULL DEFAULT 3", []); |
|
| 76 | - | let _ = conn.execute("ALTER TABLE wines ADD COLUMN wishlist INTEGER NOT NULL DEFAULT 0", []); |
|
| 77 | - | ||
| 78 | - | Arc::new(Mutex::new(conn)) |
|
| 79 | - | } |
|
| 80 | - | ||
| 81 | - | fn wine_from_row(row: &rusqlite::Row) -> rusqlite::Result<Wine> { |
|
| 82 | - | serde_rusqlite::from_row::<Wine>(row).map_err(|e| { |
|
| 83 | - | rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Null, Box::new(e)) |
|
| 84 | - | }) |
|
| 85 | - | } |
|
| 86 | - | ||
| 87 | - | const WINE_COLUMNS: &str = |
|
| 88 | - | "id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, created_at, wishlist"; |
|
| 89 | - | ||
| 90 | - | #[derive(Serialize)] |
|
| 91 | - | pub struct WineInput<'a> { |
|
| 92 | - | pub name: &'a str, |
|
| 93 | - | pub origin: &'a str, |
|
| 94 | - | pub grape: &'a str, |
|
| 95 | - | pub notes: &'a str, |
|
| 96 | - | pub sweetness: i32, |
|
| 97 | - | pub acidity: i32, |
|
| 98 | - | pub tannin: i32, |
|
| 99 | - | pub alcohol: i32, |
|
| 100 | - | pub body: i32, |
|
| 101 | - | pub clarity: i32, |
|
| 102 | - | pub color_intensity: i32, |
|
| 103 | - | pub aroma_intensity: i32, |
|
| 104 | - | pub nose_complexity: i32, |
|
| 105 | - | pub background: &'a str, |
|
| 106 | - | } |
|
| 107 | - | ||
| 108 | - | pub fn create_wine(db: &Db, input: &WineInput, wishlist: bool) -> Result<Wine, DbError> { |
|
| 109 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 110 | - | let short_id = nanoid!(10); |
|
| 111 | - | let named = serde_rusqlite::to_params_named(input) |
|
| 112 | - | .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; |
|
| 113 | - | let mut bindings = named.to_slice(); |
|
| 114 | - | bindings.push((":short_id", &short_id)); |
|
| 115 | - | bindings.push((":wishlist", &wishlist)); |
|
| 116 | - | conn.execute( |
|
| 117 | - | "INSERT INTO wines (short_id, name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist) |
|
| 118 | - | VALUES (:short_id, :name, :origin, :grape, :notes, :sweetness, :acidity, :tannin, :alcohol, :body, :clarity, :color_intensity, :aroma_intensity, :nose_complexity, :background, :wishlist)", |
|
| 119 | - | bindings.as_slice(), |
|
| 120 | - | )?; |
|
| 121 | - | let id = conn.last_insert_rowid(); |
|
| 122 | - | let wine = conn.query_row( |
|
| 123 | - | &format!("SELECT {} FROM wines WHERE id = ?1", WINE_COLUMNS), |
|
| 124 | - | params![id], |
|
| 125 | - | wine_from_row, |
|
| 126 | - | )?; |
|
| 127 | - | Ok(wine) |
|
| 128 | - | } |
|
| 129 | - | ||
| 130 | - | pub fn get_cellar_wines(db: &Db) -> Result<Vec<Wine>, DbError> { |
|
| 131 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 132 | - | let mut stmt = conn.prepare(&format!( |
|
| 133 | - | "SELECT {} FROM wines WHERE wishlist = 0 ORDER BY id DESC", |
|
| 134 | - | WINE_COLUMNS |
|
| 135 | - | ))?; |
|
| 136 | - | let wines = stmt |
|
| 137 | - | .query_map([], wine_from_row)? |
|
| 138 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 139 | - | Ok(wines) |
|
| 140 | - | } |
|
| 141 | - | ||
| 142 | - | pub fn get_wishlist_wines(db: &Db) -> Result<Vec<Wine>, DbError> { |
|
| 143 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 144 | - | let mut stmt = conn.prepare(&format!( |
|
| 145 | - | "SELECT {} FROM wines WHERE wishlist = 1 ORDER BY id DESC", |
|
| 146 | - | WINE_COLUMNS |
|
| 147 | - | ))?; |
|
| 148 | - | let wines = stmt |
|
| 149 | - | .query_map([], wine_from_row)? |
|
| 150 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 151 | - | Ok(wines) |
|
| 152 | - | } |
|
| 153 | - | ||
| 154 | - | pub fn promote_wine(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 155 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 156 | - | let rows = conn.execute( |
|
| 157 | - | "UPDATE wines SET wishlist = 0 WHERE short_id = ?1 AND wishlist = 1", |
|
| 158 | - | params![short_id], |
|
| 159 | - | )?; |
|
| 160 | - | Ok(rows > 0) |
|
| 161 | - | } |
|
| 162 | - | ||
| 163 | - | pub fn update_wishlist_wine( |
|
| 164 | - | db: &Db, |
|
| 165 | - | short_id: &str, |
|
| 166 | - | name: &str, |
|
| 167 | - | origin: &str, |
|
| 168 | - | grape: &str, |
|
| 169 | - | notes: &str, |
|
| 170 | - | background: &str, |
|
| 171 | - | ) -> Result<Option<Wine>, DbError> { |
|
| 172 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 173 | - | let rows = conn.execute( |
|
| 174 | - | "UPDATE wines SET name = ?1, origin = ?2, grape = ?3, notes = ?4, background = ?5 WHERE short_id = ?6 AND wishlist = 1", |
|
| 175 | - | params![name, origin, grape, notes, background, short_id], |
|
| 176 | - | )?; |
|
| 177 | - | if rows == 0 { |
|
| 178 | - | return Ok(None); |
|
| 179 | - | } |
|
| 180 | - | match conn.query_row( |
|
| 181 | - | &format!( |
|
| 182 | - | "SELECT {} FROM wines WHERE short_id = ?1", |
|
| 183 | - | WINE_COLUMNS |
|
| 184 | - | ), |
|
| 185 | - | params![short_id], |
|
| 186 | - | wine_from_row, |
|
| 187 | - | ) { |
|
| 188 | - | Ok(wine) => Ok(Some(wine)), |
|
| 189 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 190 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 191 | - | } |
|
| 192 | - | } |
|
| 193 | - | ||
| 194 | - | pub fn get_wine_by_short_id(db: &Db, short_id: &str) -> Result<Option<Wine>, DbError> { |
|
| 195 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 196 | - | match conn.query_row( |
|
| 197 | - | &format!( |
|
| 198 | - | "SELECT {} FROM wines WHERE short_id = ?1", |
|
| 199 | - | WINE_COLUMNS |
|
| 200 | - | ), |
|
| 201 | - | params![short_id], |
|
| 202 | - | wine_from_row, |
|
| 203 | - | ) { |
|
| 204 | - | Ok(wine) => Ok(Some(wine)), |
|
| 205 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 206 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 207 | - | } |
|
| 208 | - | } |
|
| 209 | - | ||
| 210 | - | pub fn get_wine_image(db: &Db, short_id: &str) -> Result<Option<(Vec<u8>, String)>, DbError> { |
|
| 211 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 212 | - | match conn.query_row( |
|
| 213 | - | "SELECT image, image_mime FROM wines WHERE short_id = ?1 AND image IS NOT NULL", |
|
| 214 | - | params![short_id], |
|
| 215 | - | |row| { |
|
| 216 | - | let image: Vec<u8> = row.get(0)?; |
|
| 217 | - | let mime: String = row.get(1)?; |
|
| 218 | - | Ok((image, mime)) |
|
| 219 | - | }, |
|
| 220 | - | ) { |
|
| 221 | - | Ok(result) => Ok(Some(result)), |
|
| 222 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 223 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 224 | - | } |
|
| 225 | - | } |
|
| 226 | - | ||
| 227 | - | pub fn update_wine( |
|
| 228 | - | db: &Db, |
|
| 229 | - | short_id: &str, |
|
| 230 | - | input: &WineInput, |
|
| 231 | - | ) -> Result<Option<Wine>, DbError> { |
|
| 232 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 233 | - | let named = serde_rusqlite::to_params_named(input) |
|
| 234 | - | .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; |
|
| 235 | - | let mut bindings = named.to_slice(); |
|
| 236 | - | bindings.push((":short_id", &short_id)); |
|
| 237 | - | let rows = conn.execute( |
|
| 238 | - | "UPDATE wines SET name = :name, origin = :origin, grape = :grape, notes = :notes, sweetness = :sweetness, acidity = :acidity, tannin = :tannin, alcohol = :alcohol, body = :body, clarity = :clarity, color_intensity = :color_intensity, aroma_intensity = :aroma_intensity, nose_complexity = :nose_complexity, background = :background WHERE short_id = :short_id", |
|
| 239 | - | bindings.as_slice(), |
|
| 240 | - | )?; |
|
| 241 | - | if rows == 0 { |
|
| 242 | - | return Ok(None); |
|
| 243 | - | } |
|
| 244 | - | match conn.query_row( |
|
| 245 | - | &format!( |
|
| 246 | - | "SELECT {} FROM wines WHERE short_id = ?1", |
|
| 247 | - | WINE_COLUMNS |
|
| 248 | - | ), |
|
| 249 | - | params![short_id], |
|
| 250 | - | wine_from_row, |
|
| 251 | - | ) { |
|
| 252 | - | Ok(wine) => Ok(Some(wine)), |
|
| 253 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 254 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 255 | - | } |
|
| 256 | - | } |
|
| 257 | - | ||
| 258 | - | pub fn update_wine_image( |
|
| 259 | - | db: &Db, |
|
| 260 | - | short_id: &str, |
|
| 261 | - | image: &[u8], |
|
| 262 | - | mime: &str, |
|
| 263 | - | ) -> Result<bool, DbError> { |
|
| 264 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 265 | - | let rows = conn.execute( |
|
| 266 | - | "UPDATE wines SET image = ?1, image_mime = ?2 WHERE short_id = ?3", |
|
| 267 | - | params![image, mime, short_id], |
|
| 268 | - | )?; |
|
| 269 | - | Ok(rows > 0) |
|
| 270 | - | } |
|
| 271 | - | ||
| 272 | - | pub fn delete_wine(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 273 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 274 | - | let rows = conn.execute( |
|
| 275 | - | "DELETE FROM wines WHERE short_id = ?1", |
|
| 276 | - | params![short_id], |
|
| 277 | - | )?; |
|
| 278 | - | Ok(rows > 0) |
|
| 279 | - | } |
|
| 280 | - | ||
| 281 | - | #[cfg(test)] |
|
| 282 | - | mod tests { |
|
| 283 | - | use super::*; |
|
| 284 | - | ||
| 285 | - | fn test_db() -> Db { |
|
| 286 | - | let conn = Connection::open_in_memory().unwrap(); |
|
| 287 | - | conn.execute_batch(SCHEMA).unwrap(); |
|
| 288 | - | Arc::new(Mutex::new(conn)) |
|
| 289 | - | } |
|
| 290 | - | ||
| 291 | - | fn sample_input<'a>(name: &'a str, sweetness: i32) -> WineInput<'a> { |
|
| 292 | - | WineInput { |
|
| 293 | - | name, |
|
| 294 | - | origin: "France", |
|
| 295 | - | grape: "Merlot", |
|
| 296 | - | notes: "Smooth", |
|
| 297 | - | sweetness, |
|
| 298 | - | acidity: 3, |
|
| 299 | - | tannin: 3, |
|
| 300 | - | alcohol: 3, |
|
| 301 | - | body: 3, |
|
| 302 | - | clarity: 3, |
|
| 303 | - | color_intensity: 3, |
|
| 304 | - | aroma_intensity: 3, |
|
| 305 | - | nose_complexity: 3, |
|
| 306 | - | background: "", |
|
| 307 | - | } |
|
| 308 | - | } |
|
| 309 | - | ||
| 310 | - | fn create_test_wine(db: &Db, name: &str, wishlist: bool) -> Wine { |
|
| 311 | - | create_wine(db, &sample_input(name, 3), wishlist).unwrap() |
|
| 312 | - | } |
|
| 313 | - | ||
| 314 | - | // ── Wine CRUD ────────────────────────────────────────────────────── |
|
| 315 | - | ||
| 316 | - | #[test] |
|
| 317 | - | fn create_and_get_wine() { |
|
| 318 | - | let db = test_db(); |
|
| 319 | - | let wine = create_test_wine(&db, "Chateau Test", false); |
|
| 320 | - | assert_eq!(wine.name, "Chateau Test"); |
|
| 321 | - | assert_eq!(wine.origin, "France"); |
|
| 322 | - | assert!(!wine.wishlist); |
|
| 323 | - | ||
| 324 | - | let fetched = get_wine_by_short_id(&db, &wine.short_id).unwrap().unwrap(); |
|
| 325 | - | assert_eq!(fetched.name, "Chateau Test"); |
|
| 326 | - | } |
|
| 327 | - | ||
| 328 | - | #[test] |
|
| 329 | - | fn create_wine_invalid_sweetness_fails() { |
|
| 330 | - | let db = test_db(); |
|
| 331 | - | let result = create_wine(&db, &sample_input("Bad", 6), false); |
|
| 332 | - | assert!(result.is_err()); |
|
| 333 | - | } |
|
| 334 | - | ||
| 335 | - | #[test] |
|
| 336 | - | fn create_wine_zero_rating_fails() { |
|
| 337 | - | let db = test_db(); |
|
| 338 | - | let result = create_wine(&db, &sample_input("Bad", 0), false); |
|
| 339 | - | assert!(result.is_err()); |
|
| 340 | - | } |
|
| 341 | - | ||
| 342 | - | #[test] |
|
| 343 | - | fn get_cellar_wines_excludes_wishlist() { |
|
| 344 | - | let db = test_db(); |
|
| 345 | - | create_test_wine(&db, "Cellar Wine", false); |
|
| 346 | - | create_test_wine(&db, "Wishlist Wine", true); |
|
| 347 | - | ||
| 348 | - | let cellar = get_cellar_wines(&db).unwrap(); |
|
| 349 | - | assert_eq!(cellar.len(), 1); |
|
| 350 | - | assert_eq!(cellar[0].name, "Cellar Wine"); |
|
| 351 | - | } |
|
| 352 | - | ||
| 353 | - | #[test] |
|
| 354 | - | fn get_wishlist_wines_only_wishlist() { |
|
| 355 | - | let db = test_db(); |
|
| 356 | - | create_test_wine(&db, "Cellar Wine", false); |
|
| 357 | - | create_test_wine(&db, "Wishlist Wine", true); |
|
| 358 | - | ||
| 359 | - | let wishlist = get_wishlist_wines(&db).unwrap(); |
|
| 360 | - | assert_eq!(wishlist.len(), 1); |
|
| 361 | - | assert_eq!(wishlist[0].name, "Wishlist Wine"); |
|
| 362 | - | } |
|
| 363 | - | ||
| 364 | - | #[test] |
|
| 365 | - | fn promote_wine_moves_to_cellar() { |
|
| 366 | - | let db = test_db(); |
|
| 367 | - | let wine = create_test_wine(&db, "To Promote", true); |
|
| 368 | - | ||
| 369 | - | assert!(promote_wine(&db, &wine.short_id).unwrap()); |
|
| 370 | - | ||
| 371 | - | let promoted = get_wine_by_short_id(&db, &wine.short_id).unwrap().unwrap(); |
|
| 372 | - | assert!(!promoted.wishlist); |
|
| 373 | - | ||
| 374 | - | assert_eq!(get_wishlist_wines(&db).unwrap().len(), 0); |
|
| 375 | - | assert_eq!(get_cellar_wines(&db).unwrap().len(), 1); |
|
| 376 | - | } |
|
| 377 | - | ||
| 378 | - | #[test] |
|
| 379 | - | fn promote_cellar_wine_returns_false() { |
|
| 380 | - | let db = test_db(); |
|
| 381 | - | let wine = create_test_wine(&db, "Already Cellar", false); |
|
| 382 | - | assert!(!promote_wine(&db, &wine.short_id).unwrap()); |
|
| 383 | - | } |
|
| 384 | - | ||
| 385 | - | #[test] |
|
| 386 | - | fn update_wine_works() { |
|
| 387 | - | let db = test_db(); |
|
| 388 | - | let wine = create_test_wine(&db, "Old Name", false); |
|
| 389 | - | ||
| 390 | - | let input = WineInput { |
|
| 391 | - | name: "New Name", |
|
| 392 | - | origin: "Italy", |
|
| 393 | - | grape: "Sangiovese", |
|
| 394 | - | notes: "Bold", |
|
| 395 | - | sweetness: 4, |
|
| 396 | - | acidity: 4, |
|
| 397 | - | tannin: 4, |
|
| 398 | - | alcohol: 4, |
|
| 399 | - | body: 4, |
|
| 400 | - | clarity: 4, |
|
| 401 | - | color_intensity: 4, |
|
| 402 | - | aroma_intensity: 4, |
|
| 403 | - | nose_complexity: 4, |
|
| 404 | - | background: "deep red", |
|
| 405 | - | }; |
|
| 406 | - | let updated = update_wine(&db, &wine.short_id, &input).unwrap().unwrap(); |
|
| 407 | - | ||
| 408 | - | assert_eq!(updated.name, "New Name"); |
|
| 409 | - | assert_eq!(updated.origin, "Italy"); |
|
| 410 | - | assert_eq!(updated.sweetness, 4); |
|
| 411 | - | assert_eq!(updated.background, "deep red"); |
|
| 412 | - | } |
|
| 413 | - | ||
| 414 | - | #[test] |
|
| 415 | - | fn update_wishlist_wine_works() { |
|
| 416 | - | let db = test_db(); |
|
| 417 | - | let wine = create_test_wine(&db, "Wish", true); |
|
| 418 | - | ||
| 419 | - | let updated = update_wishlist_wine( |
|
| 420 | - | &db, &wine.short_id, "Updated Wish", "Spain", "Tempranillo", "Try soon", "amber", |
|
| 421 | - | ) |
|
| 422 | - | .unwrap() |
|
| 423 | - | .unwrap(); |
|
| 424 | - | assert_eq!(updated.name, "Updated Wish"); |
|
| 425 | - | assert!(updated.wishlist); |
|
| 426 | - | } |
|
| 427 | - | ||
| 428 | - | #[test] |
|
| 429 | - | fn update_wishlist_wine_on_cellar_wine_returns_none() { |
|
| 430 | - | let db = test_db(); |
|
| 431 | - | let wine = create_test_wine(&db, "Cellar", false); |
|
| 432 | - | let result = update_wishlist_wine(&db, &wine.short_id, "X", "X", "X", "X", "").unwrap(); |
|
| 433 | - | assert!(result.is_none()); |
|
| 434 | - | } |
|
| 435 | - | ||
| 436 | - | #[test] |
|
| 437 | - | fn update_wine_image_and_get() { |
|
| 438 | - | let db = test_db(); |
|
| 439 | - | let wine = create_test_wine(&db, "Photo Wine", false); |
|
| 440 | - | assert!(!wine.has_image); |
|
| 441 | - | ||
| 442 | - | let img_data = vec![0xFF, 0xD8, 0xFF]; // fake JPEG header |
|
| 443 | - | assert!(update_wine_image(&db, &wine.short_id, &img_data, "image/jpeg").unwrap()); |
|
| 444 | - | ||
| 445 | - | let (data, mime) = get_wine_image(&db, &wine.short_id).unwrap().unwrap(); |
|
| 446 | - | assert_eq!(data, img_data); |
|
| 447 | - | assert_eq!(mime, "image/jpeg"); |
|
| 448 | - | } |
|
| 449 | - | ||
| 450 | - | #[test] |
|
| 451 | - | fn get_wine_image_no_image() { |
|
| 452 | - | let db = test_db(); |
|
| 453 | - | let wine = create_test_wine(&db, "No Photo", false); |
|
| 454 | - | assert!(get_wine_image(&db, &wine.short_id).unwrap().is_none()); |
|
| 455 | - | } |
|
| 456 | - | ||
| 457 | - | #[test] |
|
| 458 | - | fn delete_wine_works() { |
|
| 459 | - | let db = test_db(); |
|
| 460 | - | let wine = create_test_wine(&db, "Delete Me", false); |
|
| 461 | - | assert!(delete_wine(&db, &wine.short_id).unwrap()); |
|
| 462 | - | assert!(get_wine_by_short_id(&db, &wine.short_id).unwrap().is_none()); |
|
| 463 | - | } |
|
| 464 | - | ||
| 465 | - | #[test] |
|
| 466 | - | fn delete_nonexistent_wine() { |
|
| 467 | - | let db = test_db(); |
|
| 468 | - | assert!(!delete_wine(&db, "nope").unwrap()); |
|
| 469 | - | } |
|
| 470 | - | ||
| 471 | - | // ── Sessions ─────────────────────────────────────────────────────── |
|
| 472 | - | ||
| 473 | - | #[test] |
|
| 474 | - | fn session_lifecycle() { |
|
| 475 | - | let db = test_db(); |
|
| 476 | - | insert_session(&db, "tok", "2099-01-01 00:00:00").unwrap(); |
|
| 477 | - | assert!(get_session_expiry(&db, "tok").unwrap().is_some()); |
|
| 478 | - | delete_session(&db, "tok").unwrap(); |
|
| 479 | - | assert!(get_session_expiry(&db, "tok").unwrap().is_none()); |
|
| 480 | - | } |
|
| 481 | - | ||
| 482 | - | #[test] |
|
| 483 | - | fn prune_expired_sessions_works() { |
|
| 484 | - | let db = test_db(); |
|
| 485 | - | insert_session(&db, "old", "2000-01-01 00:00:00").unwrap(); |
|
| 486 | - | insert_session(&db, "new", "2099-01-01 00:00:00").unwrap(); |
|
| 487 | - | prune_expired_sessions(&db).unwrap(); |
|
| 488 | - | assert!(get_session_expiry(&db, "old").unwrap().is_none()); |
|
| 489 | - | assert!(get_session_expiry(&db, "new").unwrap().is_some()); |
|
| 490 | - | } |
|
| 491 | - | } |
| 1 | - | mod auth; |
|
| 2 | - | mod claude; |
|
| 3 | - | mod db; |
|
| 4 | - | mod server; |
|
| 5 | - | ||
| 6 | - | #[tokio::main] |
|
| 7 | - | async fn main() { |
|
| 8 | - | tracing_subscriber::fmt::init(); |
|
| 9 | - | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
|
| 10 | - | let port: u16 = std::env::var("PORT") |
|
| 11 | - | .ok() |
|
| 12 | - | .and_then(|v| v.parse().ok()) |
|
| 13 | - | .unwrap_or(3000); |
|
| 14 | - | server::run(host, port).await; |
|
| 15 | - | } |
| 1 | - | use askama_web::WebTemplate; |
|
| 2 | - | use axum::{ |
|
| 3 | - | extract::{Multipart, Path, Query, State}, |
|
| 4 | - | http::{HeaderValue, StatusCode}, |
|
| 5 | - | response::{Html, IntoResponse, Json, Redirect, Response}, |
|
| 6 | - | }; |
|
| 7 | - | use std::sync::Arc; |
|
| 8 | - | ||
| 9 | - | use super::super::*; |
|
| 10 | - | use crate::{auth, claude, db}; |
|
| 11 | - | ||
| 12 | - | // --- Auth handlers --- |
|
| 13 | - | ||
| 14 | - | pub async fn get_login(Query(q): Query<FlashQuery>) -> Response { |
|
| 15 | - | WebTemplate(LoginTemplate { error: q.error, next: q.next }).into_response() |
|
| 16 | - | } |
|
| 17 | - | ||
| 18 | - | pub async fn post_login( |
|
| 19 | - | Query(q): Query<FlashQuery>, |
|
| 20 | - | State(state): State<Arc<AppState>>, |
|
| 21 | - | axum::extract::Form(form): axum::extract::Form<LoginForm>, |
|
| 22 | - | ) -> Response { |
|
| 23 | - | let next = q.next.as_deref().unwrap_or("/admin"); |
|
| 24 | - | if !auth::verify_password(&form.password, &state.app_password) { |
|
| 25 | - | return Redirect::to(&format!( |
|
| 26 | - | "/admin/login?error=Invalid+password&next={}", |
|
| 27 | - | urlencoded(next) |
|
| 28 | - | )) |
|
| 29 | - | .into_response(); |
|
| 30 | - | } |
|
| 31 | - | ||
| 32 | - | let token = auth::generate_session_token(); |
|
| 33 | - | ||
| 34 | - | let expires_at = andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600); |
|
| 35 | - | ||
| 36 | - | if let Err(e) = db::insert_session(&state.db, &token, &expires_at) { |
|
| 37 | - | tracing::error!("Failed to create session: {}", e); |
|
| 38 | - | return Redirect::to("/admin/login?error=Server+error").into_response(); |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | let _ = db::prune_expired_sessions(&state.db); |
|
| 42 | - | ||
| 43 | - | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 44 | - | let redirect_to = if next.starts_with('/') { next } else { "/admin" }; |
|
| 45 | - | let mut resp = Redirect::to(redirect_to).into_response(); |
|
| 46 | - | resp.headers_mut().insert( |
|
| 47 | - | axum::http::header::SET_COOKIE, |
|
| 48 | - | HeaderValue::from_str(&cookie).unwrap(), |
|
| 49 | - | ); |
|
| 50 | - | resp |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | pub async fn get_logout( |
|
| 54 | - | State(state): State<Arc<AppState>>, |
|
| 55 | - | headers: axum::http::HeaderMap, |
|
| 56 | - | ) -> Response { |
|
| 57 | - | if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) { |
|
| 58 | - | for part in cookie_header.split(';') { |
|
| 59 | - | let part = part.trim(); |
|
| 60 | - | if let Some(val) = part.strip_prefix("session=") { |
|
| 61 | - | let val = val.trim(); |
|
| 62 | - | if !val.is_empty() { |
|
| 63 | - | let _ = db::delete_session(&state.db, val); |
|
| 64 | - | } |
|
| 65 | - | } |
|
| 66 | - | } |
|
| 67 | - | } |
|
| 68 | - | ||
| 69 | - | let cookie = auth::clear_session_cookie(); |
|
| 70 | - | let mut resp = Redirect::to("/admin/login").into_response(); |
|
| 71 | - | resp.headers_mut().insert( |
|
| 72 | - | axum::http::header::SET_COOKIE, |
|
| 73 | - | HeaderValue::from_str(&cookie).unwrap(), |
|
| 74 | - | ); |
|
| 75 | - | resp |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | // --- Admin wine handlers --- |
|
| 79 | - | ||
| 80 | - | pub async fn get_admin( |
|
| 81 | - | _session: auth::AuthSession, |
|
| 82 | - | State(state): State<Arc<AppState>>, |
|
| 83 | - | ) -> Response { |
|
| 84 | - | match db::get_cellar_wines(&state.db) { |
|
| 85 | - | Ok(wines) => WebTemplate(AdminTemplate { wines }).into_response(), |
|
| 86 | - | Err(e) => { |
|
| 87 | - | tracing::error!("Failed to list wines: {}", e); |
|
| 88 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 89 | - | } |
|
| 90 | - | } |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | pub async fn get_new_wine( |
|
| 94 | - | _session: auth::AuthSession, |
|
| 95 | - | State(state): State<Arc<AppState>>, |
|
| 96 | - | Query(q): Query<FlashQuery>, |
|
| 97 | - | ) -> Response { |
|
| 98 | - | WebTemplate(WineFormTemplate { |
|
| 99 | - | wine: None, |
|
| 100 | - | error: q.error, |
|
| 101 | - | has_anthropic_key: state.anthropic_api_key.is_some(), |
|
| 102 | - | }) |
|
| 103 | - | .into_response() |
|
| 104 | - | } |
|
| 105 | - | ||
| 106 | - | pub async fn get_edit_wine( |
|
| 107 | - | _session: auth::AuthSession, |
|
| 108 | - | State(state): State<Arc<AppState>>, |
|
| 109 | - | Path(short_id): Path<String>, |
|
| 110 | - | Query(q): Query<FlashQuery>, |
|
| 111 | - | ) -> Response { |
|
| 112 | - | match db::get_wine_by_short_id(&state.db, &short_id) { |
|
| 113 | - | Ok(Some(wine)) => WebTemplate(WineFormTemplate { |
|
| 114 | - | wine: Some(wine), |
|
| 115 | - | error: q.error, |
|
| 116 | - | has_anthropic_key: state.anthropic_api_key.is_some(), |
|
| 117 | - | }) |
|
| 118 | - | .into_response(), |
|
| 119 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(), |
|
| 120 | - | Err(e) => { |
|
| 121 | - | tracing::error!("Failed to get wine: {}", e); |
|
| 122 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 123 | - | } |
|
| 124 | - | } |
|
| 125 | - | } |
|
| 126 | - | ||
| 127 | - | pub async fn post_new_wine( |
|
| 128 | - | _session: auth::AuthSession, |
|
| 129 | - | State(state): State<Arc<AppState>>, |
|
| 130 | - | multipart: Multipart, |
|
| 131 | - | ) -> Response { |
|
| 132 | - | let data = match parse_wine_multipart(multipart).await { |
|
| 133 | - | Ok(data) => data, |
|
| 134 | - | Err(e) => { |
|
| 135 | - | return Redirect::to(&format!("/admin/new?error={}", urlencoded(&e))).into_response(); |
|
| 136 | - | } |
|
| 137 | - | }; |
|
| 138 | - | ||
| 139 | - | let input = db::WineInput::from(&data); |
|
| 140 | - | match db::create_wine(&state.db, &input, false) { |
|
| 141 | - | Ok(wine) => { |
|
| 142 | - | if let (Some(image), Some(mime)) = (&data.base.image, &data.base.image_mime) { |
|
| 143 | - | if let Err(e) = db::update_wine_image(&state.db, &wine.short_id, image, mime) { |
|
| 144 | - | tracing::error!("Failed to set wine image: {}", e); |
|
| 145 | - | } |
|
| 146 | - | } |
|
| 147 | - | Redirect::to(&format!("/wines/{}", wine.short_id)).into_response() |
|
| 148 | - | } |
|
| 149 | - | Err(e) => { |
|
| 150 | - | tracing::error!("Failed to create wine: {}", e); |
|
| 151 | - | Redirect::to("/admin/new?error=Failed+to+create+wine").into_response() |
|
| 152 | - | } |
|
| 153 | - | } |
|
| 154 | - | } |
|
| 155 | - | ||
| 156 | - | pub async fn post_edit_wine( |
|
| 157 | - | _session: auth::AuthSession, |
|
| 158 | - | State(state): State<Arc<AppState>>, |
|
| 159 | - | Path(short_id): Path<String>, |
|
| 160 | - | multipart: Multipart, |
|
| 161 | - | ) -> Response { |
|
| 162 | - | let data = match parse_wine_multipart(multipart).await { |
|
| 163 | - | Ok(data) => data, |
|
| 164 | - | Err(e) => { |
|
| 165 | - | return Redirect::to(&format!( |
|
| 166 | - | "/admin/edit/{}?error={}", |
|
| 167 | - | short_id, |
|
| 168 | - | urlencoded(&e) |
|
| 169 | - | )) |
|
| 170 | - | .into_response(); |
|
| 171 | - | } |
|
| 172 | - | }; |
|
| 173 | - | ||
| 174 | - | let input = db::WineInput::from(&data); |
|
| 175 | - | match db::update_wine(&state.db, &short_id, &input) { |
|
| 176 | - | Ok(Some(_)) => { |
|
| 177 | - | if let Some(image) = &data.base.image { |
|
| 178 | - | if let Some(mime) = &data.base.image_mime { |
|
| 179 | - | if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) { |
|
| 180 | - | tracing::error!("Failed to update wine image: {}", e); |
|
| 181 | - | } |
|
| 182 | - | } |
|
| 183 | - | } |
|
| 184 | - | Redirect::to(&format!("/wines/{}", short_id)).into_response() |
|
| 185 | - | } |
|
| 186 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(), |
|
| 187 | - | Err(e) => { |
|
| 188 | - | tracing::error!("Failed to update wine: {}", e); |
|
| 189 | - | Redirect::to(&format!( |
|
| 190 | - | "/admin/edit/{}?error=Failed+to+update+wine", |
|
| 191 | - | short_id |
|
| 192 | - | )) |
|
| 193 | - | .into_response() |
|
| 194 | - | } |
|
| 195 | - | } |
|
| 196 | - | } |
|
| 197 | - | ||
| 198 | - | pub async fn post_delete_wine( |
|
| 199 | - | _session: auth::AuthSession, |
|
| 200 | - | State(state): State<Arc<AppState>>, |
|
| 201 | - | Path(short_id): Path<String>, |
|
| 202 | - | ) -> Response { |
|
| 203 | - | match db::delete_wine(&state.db, &short_id) { |
|
| 204 | - | Ok(_) => Redirect::to("/admin").into_response(), |
|
| 205 | - | Err(e) => { |
|
| 206 | - | tracing::error!("Failed to delete wine: {}", e); |
|
| 207 | - | Redirect::to("/admin").into_response() |
|
| 208 | - | } |
|
| 209 | - | } |
|
| 210 | - | } |
|
| 211 | - | ||
| 212 | - | // --- Wishlist handlers --- |
|
| 213 | - | ||
| 214 | - | pub async fn get_new_wishlist_wine( |
|
| 215 | - | _session: auth::AuthSession, |
|
| 216 | - | State(state): State<Arc<AppState>>, |
|
| 217 | - | Query(q): Query<FlashQuery>, |
|
| 218 | - | ) -> Response { |
|
| 219 | - | WebTemplate(WishlistFormTemplate { |
|
| 220 | - | wine: None, |
|
| 221 | - | error: q.error, |
|
| 222 | - | has_anthropic_key: state.anthropic_api_key.is_some(), |
|
| 223 | - | }) |
|
| 224 | - | .into_response() |
|
| 225 | - | } |
|
| 226 | - | ||
| 227 | - | pub async fn get_edit_wishlist_wine( |
|
| 228 | - | _session: auth::AuthSession, |
|
| 229 | - | State(state): State<Arc<AppState>>, |
|
| 230 | - | Path(short_id): Path<String>, |
|
| 231 | - | Query(q): Query<FlashQuery>, |
|
| 232 | - | ) -> Response { |
|
| 233 | - | match db::get_wine_by_short_id(&state.db, &short_id) { |
|
| 234 | - | Ok(Some(wine)) => WebTemplate(WishlistFormTemplate { |
|
| 235 | - | wine: Some(wine), |
|
| 236 | - | error: q.error, |
|
| 237 | - | has_anthropic_key: state.anthropic_api_key.is_some(), |
|
| 238 | - | }) |
|
| 239 | - | .into_response(), |
|
| 240 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(), |
|
| 241 | - | Err(e) => { |
|
| 242 | - | tracing::error!("Failed to get wine: {}", e); |
|
| 243 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 244 | - | } |
|
| 245 | - | } |
|
| 246 | - | } |
|
| 247 | - | ||
| 248 | - | pub async fn post_new_wishlist_wine( |
|
| 249 | - | _session: auth::AuthSession, |
|
| 250 | - | State(state): State<Arc<AppState>>, |
|
| 251 | - | multipart: Multipart, |
|
| 252 | - | ) -> Response { |
|
| 253 | - | let data = match parse_wishlist_multipart(multipart).await { |
|
| 254 | - | Ok(data) => data, |
|
| 255 | - | Err(e) => { |
|
| 256 | - | return Redirect::to(&format!("/admin/wishlist/new?error={}", urlencoded(&e))) |
|
| 257 | - | .into_response(); |
|
| 258 | - | } |
|
| 259 | - | }; |
|
| 260 | - | ||
| 261 | - | let input = db::WineInput { |
|
| 262 | - | name: &data.name, |
|
| 263 | - | origin: &data.origin, |
|
| 264 | - | grape: &data.grape, |
|
| 265 | - | notes: &data.notes, |
|
| 266 | - | sweetness: 3, |
|
| 267 | - | acidity: 3, |
|
| 268 | - | tannin: 3, |
|
| 269 | - | alcohol: 3, |
|
| 270 | - | body: 3, |
|
| 271 | - | clarity: 3, |
|
| 272 | - | color_intensity: 3, |
|
| 273 | - | aroma_intensity: 3, |
|
| 274 | - | nose_complexity: 3, |
|
| 275 | - | background: &data.background, |
|
| 276 | - | }; |
|
| 277 | - | match db::create_wine(&state.db, &input, true) { |
|
| 278 | - | Ok(wine) => { |
|
| 279 | - | if let (Some(image), Some(mime)) = (&data.image, &data.image_mime) { |
|
| 280 | - | if let Err(e) = db::update_wine_image(&state.db, &wine.short_id, image, mime) { |
|
| 281 | - | tracing::error!("Failed to set wine image: {}", e); |
|
| 282 | - | } |
|
| 283 | - | } |
|
| 284 | - | Redirect::to("/wishlist").into_response() |
|
| 285 | - | } |
|
| 286 | - | Err(e) => { |
|
| 287 | - | tracing::error!("Failed to create wishlist wine: {}", e); |
|
| 288 | - | Redirect::to("/admin/wishlist/new?error=Failed+to+create+wine").into_response() |
|
| 289 | - | } |
|
| 290 | - | } |
|
| 291 | - | } |
|
| 292 | - | ||
| 293 | - | pub async fn post_edit_wishlist_wine( |
|
| 294 | - | _session: auth::AuthSession, |
|
| 295 | - | State(state): State<Arc<AppState>>, |
|
| 296 | - | Path(short_id): Path<String>, |
|
| 297 | - | multipart: Multipart, |
|
| 298 | - | ) -> Response { |
|
| 299 | - | let data = match parse_wishlist_multipart(multipart).await { |
|
| 300 | - | Ok(data) => data, |
|
| 301 | - | Err(e) => { |
|
| 302 | - | return Redirect::to(&format!( |
|
| 303 | - | "/admin/wishlist/edit/{}?error={}", |
|
| 304 | - | short_id, |
|
| 305 | - | urlencoded(&e) |
|
| 306 | - | )) |
|
| 307 | - | .into_response(); |
|
| 308 | - | } |
|
| 309 | - | }; |
|
| 310 | - | ||
| 311 | - | match db::update_wishlist_wine( |
|
| 312 | - | &state.db, |
|
| 313 | - | &short_id, |
|
| 314 | - | &data.name, |
|
| 315 | - | &data.origin, |
|
| 316 | - | &data.grape, |
|
| 317 | - | &data.notes, |
|
| 318 | - | &data.background, |
|
| 319 | - | ) { |
|
| 320 | - | Ok(Some(_)) => { |
|
| 321 | - | if let Some(image) = &data.image { |
|
| 322 | - | if let Some(mime) = &data.image_mime { |
|
| 323 | - | if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) { |
|
| 324 | - | tracing::error!("Failed to update wine image: {}", e); |
|
| 325 | - | } |
|
| 326 | - | } |
|
| 327 | - | } |
|
| 328 | - | Redirect::to("/wishlist").into_response() |
|
| 329 | - | } |
|
| 330 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(), |
|
| 331 | - | Err(e) => { |
|
| 332 | - | tracing::error!("Failed to update wishlist wine: {}", e); |
|
| 333 | - | Redirect::to(&format!( |
|
| 334 | - | "/admin/wishlist/edit/{}?error=Failed+to+update+wine", |
|
| 335 | - | short_id |
|
| 336 | - | )) |
|
| 337 | - | .into_response() |
|
| 338 | - | } |
|
| 339 | - | } |
|
| 340 | - | } |
|
| 341 | - | ||
| 342 | - | pub async fn post_delete_wishlist_wine( |
|
| 343 | - | _session: auth::AuthSession, |
|
| 344 | - | State(state): State<Arc<AppState>>, |
|
| 345 | - | Path(short_id): Path<String>, |
|
| 346 | - | ) -> Response { |
|
| 347 | - | match db::delete_wine(&state.db, &short_id) { |
|
| 348 | - | Ok(_) => Redirect::to("/wishlist").into_response(), |
|
| 349 | - | Err(e) => { |
|
| 350 | - | tracing::error!("Failed to delete wine: {}", e); |
|
| 351 | - | Redirect::to("/wishlist").into_response() |
|
| 352 | - | } |
|
| 353 | - | } |
|
| 354 | - | } |
|
| 355 | - | ||
| 356 | - | pub async fn post_promote_wine( |
|
| 357 | - | _session: auth::AuthSession, |
|
| 358 | - | State(state): State<Arc<AppState>>, |
|
| 359 | - | Path(short_id): Path<String>, |
|
| 360 | - | ) -> Response { |
|
| 361 | - | match db::promote_wine(&state.db, &short_id) { |
|
| 362 | - | Ok(true) => Redirect::to(&format!("/admin/edit/{}", short_id)).into_response(), |
|
| 363 | - | Ok(false) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(), |
|
| 364 | - | Err(e) => { |
|
| 365 | - | tracing::error!("Failed to promote wine: {}", e); |
|
| 366 | - | Redirect::to("/wishlist").into_response() |
|
| 367 | - | } |
|
| 368 | - | } |
|
| 369 | - | } |
|
| 370 | - | ||
| 371 | - | // --- Claude vision handler --- |
|
| 372 | - | ||
| 373 | - | pub async fn post_analyze_image( |
|
| 374 | - | _session: auth::AuthSession, |
|
| 375 | - | State(state): State<Arc<AppState>>, |
|
| 376 | - | mut multipart: Multipart, |
|
| 377 | - | ) -> Response { |
|
| 378 | - | let api_key = match &state.anthropic_api_key { |
|
| 379 | - | Some(key) => key.clone(), |
|
| 380 | - | None => { |
|
| 381 | - | return ( |
|
| 382 | - | StatusCode::BAD_REQUEST, |
|
| 383 | - | Json(serde_json::json!({"error": "No API key configured"})), |
|
| 384 | - | ) |
|
| 385 | - | .into_response(); |
|
| 386 | - | } |
|
| 387 | - | }; |
|
| 388 | - | ||
| 389 | - | let mut image_bytes: Option<Vec<u8>> = None; |
|
| 390 | - | let mut media_type = String::from("image/jpeg"); |
|
| 391 | - | ||
| 392 | - | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 393 | - | if field.name() == Some("image") { |
|
| 394 | - | media_type = field.content_type().unwrap_or("image/jpeg").to_string(); |
|
| 395 | - | if let Ok(bytes) = field.bytes().await { |
|
| 396 | - | if !bytes.is_empty() { |
|
| 397 | - | image_bytes = Some(bytes.to_vec()); |
|
| 398 | - | } |
|
| 399 | - | } |
|
| 400 | - | } |
|
| 401 | - | } |
|
| 402 | - | ||
| 403 | - | let image_bytes = match image_bytes { |
|
| 404 | - | Some(bytes) => bytes, |
|
| 405 | - | None => { |
|
| 406 | - | return ( |
|
| 407 | - | StatusCode::BAD_REQUEST, |
|
| 408 | - | Json(serde_json::json!({"error": "No image provided"})), |
|
| 409 | - | ) |
|
| 410 | - | .into_response(); |
|
| 411 | - | } |
|
| 412 | - | }; |
|
| 413 | - | ||
| 414 | - | match claude::analyze_wine_image(&api_key, &image_bytes, &media_type).await { |
|
| 415 | - | Ok(result) => (StatusCode::OK, Json(result)).into_response(), |
|
| 416 | - | Err(e) => { |
|
| 417 | - | tracing::error!("Claude analysis failed: {}", e); |
|
| 418 | - | ( |
|
| 419 | - | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 420 | - | Json(serde_json::json!({"error": e})), |
|
| 421 | - | ) |
|
| 422 | - | .into_response() |
|
| 423 | - | } |
|
| 424 | - | } |
|
| 425 | - | } |
| 1 | - | pub mod admin; |
|
| 2 | - | pub mod public; |
| 1 | - | use askama_web::WebTemplate; |
|
| 2 | - | use axum::{ |
|
| 3 | - | Json, |
|
| 4 | - | extract::{Path, State}, |
|
| 5 | - | http::{HeaderValue, StatusCode}, |
|
| 6 | - | response::{Html, IntoResponse, Response}, |
|
| 7 | - | }; |
|
| 8 | - | use std::sync::Arc; |
|
| 9 | - | ||
| 10 | - | use super::super::*; |
|
| 11 | - | use crate::{auth, db}; |
|
| 12 | - | ||
| 13 | - | pub async fn serve_static(Path(path): Path<String>) -> Response { |
|
| 14 | - | match Static::get(&path) { |
|
| 15 | - | Some(file) => { |
|
| 16 | - | let mime = mime_from_path(&path); |
|
| 17 | - | ( |
|
| 18 | - | StatusCode::OK, |
|
| 19 | - | [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))], |
|
| 20 | - | file.data.to_vec(), |
|
| 21 | - | ) |
|
| 22 | - | .into_response() |
|
| 23 | - | } |
|
| 24 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 25 | - | } |
|
| 26 | - | } |
|
| 27 | - | ||
| 28 | - | pub async fn get_index(State(state): State<Arc<AppState>>) -> Response { |
|
| 29 | - | match db::get_cellar_wines(&state.db) { |
|
| 30 | - | Ok(wines) => { |
|
| 31 | - | let wines: Vec<WineWithSvg> = wines |
|
| 32 | - | .into_iter() |
|
| 33 | - | .map(|wine| { |
|
| 34 | - | let pentagon_svg = build_pentagon_svg( |
|
| 35 | - | wine.sweetness, |
|
| 36 | - | wine.acidity, |
|
| 37 | - | wine.tannin, |
|
| 38 | - | wine.alcohol, |
|
| 39 | - | wine.body, |
|
| 40 | - | 80.0, |
|
| 41 | - | false, |
|
| 42 | - | ); |
|
| 43 | - | WineWithSvg { wine, pentagon_svg } |
|
| 44 | - | }) |
|
| 45 | - | .collect(); |
|
| 46 | - | WebTemplate(IndexTemplate { wines }).into_response() |
|
| 47 | - | } |
|
| 48 | - | Err(e) => { |
|
| 49 | - | tracing::error!("Failed to list wines: {}", e); |
|
| 50 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 51 | - | } |
|
| 52 | - | } |
|
| 53 | - | } |
|
| 54 | - | ||
| 55 | - | pub async fn get_wine_detail( |
|
| 56 | - | State(state): State<Arc<AppState>>, |
|
| 57 | - | Path(short_id): Path<String>, |
|
| 58 | - | ) -> Response { |
|
| 59 | - | match db::get_wine_by_short_id(&state.db, &short_id) { |
|
| 60 | - | Ok(Some(wine)) => { |
|
| 61 | - | let pentagon_svg = build_pentagon_svg( |
|
| 62 | - | wine.sweetness, |
|
| 63 | - | wine.acidity, |
|
| 64 | - | wine.tannin, |
|
| 65 | - | wine.alcohol, |
|
| 66 | - | wine.body, |
|
| 67 | - | 250.0, |
|
| 68 | - | true, |
|
| 69 | - | ); |
|
| 70 | - | let bars_svg = build_bars_svg( |
|
| 71 | - | wine.clarity, |
|
| 72 | - | wine.color_intensity, |
|
| 73 | - | wine.aroma_intensity, |
|
| 74 | - | wine.nose_complexity, |
|
| 75 | - | 250.0, |
|
| 76 | - | ); |
|
| 77 | - | WebTemplate(WineDetailTemplate { wine, pentagon_svg, bars_svg }).into_response() |
|
| 78 | - | } |
|
| 79 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(), |
|
| 80 | - | Err(e) => { |
|
| 81 | - | tracing::error!("Failed to get wine: {}", e); |
|
| 82 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 83 | - | } |
|
| 84 | - | } |
|
| 85 | - | } |
|
| 86 | - | ||
| 87 | - | pub async fn api_list_wines(State(state): State<Arc<AppState>>) -> Response { |
|
| 88 | - | match db::get_cellar_wines(&state.db) { |
|
| 89 | - | Ok(wines) => Json(wines).into_response(), |
|
| 90 | - | Err(e) => { |
|
| 91 | - | tracing::error!("api_list_wines: {}", e); |
|
| 92 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 93 | - | } |
|
| 94 | - | } |
|
| 95 | - | } |
|
| 96 | - | ||
| 97 | - | pub async fn api_get_wine( |
|
| 98 | - | State(state): State<Arc<AppState>>, |
|
| 99 | - | Path(short_id): Path<String>, |
|
| 100 | - | ) -> Response { |
|
| 101 | - | match db::get_wine_by_short_id(&state.db, &short_id) { |
|
| 102 | - | Ok(Some(wine)) => Json(wine).into_response(), |
|
| 103 | - | Ok(None) => StatusCode::NOT_FOUND.into_response(), |
|
| 104 | - | Err(e) => { |
|
| 105 | - | tracing::error!("api_get_wine: {}", e); |
|
| 106 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 107 | - | } |
|
| 108 | - | } |
|
| 109 | - | } |
|
| 110 | - | ||
| 111 | - | pub async fn api_get_pentagon_svg( |
|
| 112 | - | State(state): State<Arc<AppState>>, |
|
| 113 | - | Path(short_id): Path<String>, |
|
| 114 | - | ) -> Response { |
|
| 115 | - | match db::get_wine_by_short_id(&state.db, &short_id) { |
|
| 116 | - | Ok(Some(wine)) => { |
|
| 117 | - | let svg = build_pentagon_svg( |
|
| 118 | - | wine.sweetness, |
|
| 119 | - | wine.acidity, |
|
| 120 | - | wine.tannin, |
|
| 121 | - | wine.alcohol, |
|
| 122 | - | wine.body, |
|
| 123 | - | 250.0, |
|
| 124 | - | true, |
|
| 125 | - | ); |
|
| 126 | - | ( |
|
| 127 | - | StatusCode::OK, |
|
| 128 | - | [( |
|
| 129 | - | axum::http::header::CONTENT_TYPE, |
|
| 130 | - | HeaderValue::from_static("image/svg+xml"), |
|
| 131 | - | )], |
|
| 132 | - | svg, |
|
| 133 | - | ) |
|
| 134 | - | .into_response() |
|
| 135 | - | } |
|
| 136 | - | Ok(None) => StatusCode::NOT_FOUND.into_response(), |
|
| 137 | - | Err(e) => { |
|
| 138 | - | tracing::error!("api_get_pentagon_svg: {}", e); |
|
| 139 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 140 | - | } |
|
| 141 | - | } |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | pub async fn api_get_bars_svg( |
|
| 145 | - | State(state): State<Arc<AppState>>, |
|
| 146 | - | Path(short_id): Path<String>, |
|
| 147 | - | ) -> Response { |
|
| 148 | - | match db::get_wine_by_short_id(&state.db, &short_id) { |
|
| 149 | - | Ok(Some(wine)) => { |
|
| 150 | - | let svg = build_bars_svg( |
|
| 151 | - | wine.clarity, |
|
| 152 | - | wine.color_intensity, |
|
| 153 | - | wine.aroma_intensity, |
|
| 154 | - | wine.nose_complexity, |
|
| 155 | - | 250.0, |
|
| 156 | - | ); |
|
| 157 | - | ( |
|
| 158 | - | StatusCode::OK, |
|
| 159 | - | [( |
|
| 160 | - | axum::http::header::CONTENT_TYPE, |
|
| 161 | - | HeaderValue::from_static("image/svg+xml"), |
|
| 162 | - | )], |
|
| 163 | - | svg, |
|
| 164 | - | ) |
|
| 165 | - | .into_response() |
|
| 166 | - | } |
|
| 167 | - | Ok(None) => StatusCode::NOT_FOUND.into_response(), |
|
| 168 | - | Err(e) => { |
|
| 169 | - | tracing::error!("api_get_bars_svg: {}", e); |
|
| 170 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 171 | - | } |
|
| 172 | - | } |
|
| 173 | - | } |
|
| 174 | - | ||
| 175 | - | pub async fn get_wine_image( |
|
| 176 | - | State(state): State<Arc<AppState>>, |
|
| 177 | - | Path(short_id): Path<String>, |
|
| 178 | - | ) -> Response { |
|
| 179 | - | match db::get_wine_image(&state.db, &short_id) { |
|
| 180 | - | Ok(Some((bytes, mime))) => { |
|
| 181 | - | let content_type = HeaderValue::from_str(&mime) |
|
| 182 | - | .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); |
|
| 183 | - | ( |
|
| 184 | - | StatusCode::OK, |
|
| 185 | - | [(axum::http::header::CONTENT_TYPE, content_type)], |
|
| 186 | - | bytes, |
|
| 187 | - | ) |
|
| 188 | - | .into_response() |
|
| 189 | - | } |
|
| 190 | - | Ok(None) => StatusCode::NOT_FOUND.into_response(), |
|
| 191 | - | Err(e) => { |
|
| 192 | - | tracing::error!("Failed to get wine image: {}", e); |
|
| 193 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 194 | - | } |
|
| 195 | - | } |
|
| 196 | - | } |
|
| 197 | - | ||
| 198 | - | fn xml_escape(s: &str) -> String { |
|
| 199 | - | s.replace('&', "&") |
|
| 200 | - | .replace('<', "<") |
|
| 201 | - | .replace('>', ">") |
|
| 202 | - | .replace('"', """) |
|
| 203 | - | .replace('\'', "'") |
|
| 204 | - | } |
|
| 205 | - | ||
| 206 | - | fn to_rfc2822(sqlite_ts: &str) -> String { |
|
| 207 | - | chrono::NaiveDateTime::parse_from_str(sqlite_ts, "%Y-%m-%d %H:%M:%S") |
|
| 208 | - | .map(|naive| naive.and_utc().to_rfc2822()) |
|
| 209 | - | .unwrap_or_else(|_| sqlite_ts.to_string()) |
|
| 210 | - | } |
|
| 211 | - | ||
| 212 | - | pub async fn rss_feed(State(state): State<Arc<AppState>>) -> Response { |
|
| 213 | - | let site_url = &state.site_url; |
|
| 214 | - | ||
| 215 | - | let wines = match db::get_cellar_wines(&state.db) { |
|
| 216 | - | Ok(wines) => wines, |
|
| 217 | - | Err(e) => { |
|
| 218 | - | tracing::error!("Failed to get wines for RSS: {}", e); |
|
| 219 | - | return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response(); |
|
| 220 | - | } |
|
| 221 | - | }; |
|
| 222 | - | ||
| 223 | - | let mut items = String::new(); |
|
| 224 | - | for wine in &wines { |
|
| 225 | - | let link = format!("{}/wines/{}", site_url, xml_escape(&wine.short_id)); |
|
| 226 | - | let title = xml_escape(&wine.name); |
|
| 227 | - | let mut desc_parts: Vec<String> = Vec::new(); |
|
| 228 | - | if !wine.origin.is_empty() { |
|
| 229 | - | desc_parts.push(format!("Origin: {}", wine.origin)); |
|
| 230 | - | } |
|
| 231 | - | if !wine.grape.is_empty() { |
|
| 232 | - | desc_parts.push(format!("Grape: {}", wine.grape)); |
|
| 233 | - | } |
|
| 234 | - | if !wine.notes.is_empty() { |
|
| 235 | - | desc_parts.push(wine.notes.clone()); |
|
| 236 | - | } |
|
| 237 | - | let description = xml_escape(&desc_parts.join(" — ")); |
|
| 238 | - | let pub_date = to_rfc2822(&wine.created_at); |
|
| 239 | - | let guid = format!("{}/wines/{}", site_url, xml_escape(&wine.short_id)); |
|
| 240 | - | ||
| 241 | - | items.push_str(&format!( |
|
| 242 | - | " <item>\n <title>{title}</title>\n <link>{link}</link>\n <guid>{guid}</guid>\n <description>{description}</description>\n <pubDate>{pub_date}</pubDate>\n </item>\n" |
|
| 243 | - | )); |
|
| 244 | - | } |
|
| 245 | - | ||
| 246 | - | let last_build = wines |
|
| 247 | - | .first() |
|
| 248 | - | .map(|w| to_rfc2822(&w.created_at)) |
|
| 249 | - | .unwrap_or_default(); |
|
| 250 | - | ||
| 251 | - | let xml = format!( |
|
| 252 | - | r#"<?xml version="1.0" encoding="UTF-8"?> |
|
| 253 | - | <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> |
|
| 254 | - | <channel> |
|
| 255 | - | <title>{title}</title> |
|
| 256 | - | <link>{site_url}</link> |
|
| 257 | - | <description>{desc}</description> |
|
| 258 | - | <lastBuildDate>{last_build}</lastBuildDate> |
|
| 259 | - | <atom:link href="{site_url}/feed.xml" rel="self" type="application/rss+xml"/> |
|
| 260 | - | {items} </channel> |
|
| 261 | - | </rss>"#, |
|
| 262 | - | title = xml_escape(&state.site_title), |
|
| 263 | - | desc = xml_escape(&state.site_description), |
|
| 264 | - | site_url = site_url, |
|
| 265 | - | last_build = last_build, |
|
| 266 | - | items = items, |
|
| 267 | - | ); |
|
| 268 | - | ||
| 269 | - | ( |
|
| 270 | - | StatusCode::OK, |
|
| 271 | - | [( |
|
| 272 | - | axum::http::header::CONTENT_TYPE, |
|
| 273 | - | HeaderValue::from_static("application/rss+xml; charset=utf-8"), |
|
| 274 | - | )], |
|
| 275 | - | xml, |
|
| 276 | - | ) |
|
| 277 | - | .into_response() |
|
| 278 | - | } |
|
| 279 | - | ||
| 280 | - | pub async fn get_wishlist( |
|
| 281 | - | State(state): State<Arc<AppState>>, |
|
| 282 | - | headers: axum::http::HeaderMap, |
|
| 283 | - | ) -> Response { |
|
| 284 | - | let is_admin = auth::is_authenticated(&state, &headers); |
|
| 285 | - | match db::get_wishlist_wines(&state.db) { |
|
| 286 | - | Ok(wines) => WebTemplate(WishlistTemplate { wines, is_admin }).into_response(), |
|
| 287 | - | Err(e) => { |
|
| 288 | - | tracing::error!("Failed to list wishlist: {}", e); |
|
| 289 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 290 | - | } |
|
| 291 | - | } |
|
| 292 | - | } |
| 1 | - | use askama::Template; |
|
| 2 | - | use axum::{ |
|
| 3 | - | extract::{multipart::Field, DefaultBodyLimit, Multipart}, |
|
| 4 | - | routing::{get, post}, |
|
| 5 | - | Router, |
|
| 6 | - | }; |
|
| 7 | - | use image::ImageDecoder; |
|
| 8 | - | use rust_embed::Embed; |
|
| 9 | - | use std::sync::Arc; |
|
| 10 | - | use tower_http::cors::{Any, CorsLayer}; |
|
| 11 | - | ||
| 12 | - | use crate::db::{self, Db, Wine}; |
|
| 13 | - | ||
| 14 | - | mod handlers; |
|
| 15 | - | ||
| 16 | - | #[derive(Clone)] |
|
| 17 | - | pub struct AppState { |
|
| 18 | - | pub db: Db, |
|
| 19 | - | pub app_password: String, |
|
| 20 | - | pub cookie_secure: bool, |
|
| 21 | - | pub anthropic_api_key: Option<String>, |
|
| 22 | - | pub site_url: String, |
|
| 23 | - | pub site_title: String, |
|
| 24 | - | pub site_description: String, |
|
| 25 | - | } |
|
| 26 | - | ||
| 27 | - | #[derive(Embed)] |
|
| 28 | - | #[folder = "static/"] |
|
| 29 | - | struct Static; |
|
| 30 | - | ||
| 31 | - | // --- Templates --- |
|
| 32 | - | ||
| 33 | - | #[derive(Template)] |
|
| 34 | - | #[template(path = "base.html")] |
|
| 35 | - | struct BaseTemplate; |
|
| 36 | - | ||
| 37 | - | #[derive(Template)] |
|
| 38 | - | #[template(path = "login.html")] |
|
| 39 | - | struct LoginTemplate { |
|
| 40 | - | error: Option<String>, |
|
| 41 | - | next: Option<String>, |
|
| 42 | - | } |
|
| 43 | - | ||
| 44 | - | struct WineWithSvg { |
|
| 45 | - | wine: Wine, |
|
| 46 | - | pentagon_svg: String, |
|
| 47 | - | } |
|
| 48 | - | ||
| 49 | - | #[derive(Template)] |
|
| 50 | - | #[template(path = "index.html")] |
|
| 51 | - | struct IndexTemplate { |
|
| 52 | - | wines: Vec<WineWithSvg>, |
|
| 53 | - | } |
|
| 54 | - | ||
| 55 | - | #[derive(Template)] |
|
| 56 | - | #[template(path = "wine.html")] |
|
| 57 | - | struct WineDetailTemplate { |
|
| 58 | - | wine: Wine, |
|
| 59 | - | pentagon_svg: String, |
|
| 60 | - | bars_svg: String, |
|
| 61 | - | } |
|
| 62 | - | ||
| 63 | - | #[derive(Template)] |
|
| 64 | - | #[template(path = "admin.html")] |
|
| 65 | - | struct AdminTemplate { |
|
| 66 | - | wines: Vec<Wine>, |
|
| 67 | - | } |
|
| 68 | - | ||
| 69 | - | #[derive(Template)] |
|
| 70 | - | #[template(path = "wine_form.html")] |
|
| 71 | - | struct WineFormTemplate { |
|
| 72 | - | wine: Option<Wine>, |
|
| 73 | - | error: Option<String>, |
|
| 74 | - | has_anthropic_key: bool, |
|
| 75 | - | } |
|
| 76 | - | ||
| 77 | - | #[derive(Template)] |
|
| 78 | - | #[template(path = "wishlist.html")] |
|
| 79 | - | struct WishlistTemplate { |
|
| 80 | - | wines: Vec<Wine>, |
|
| 81 | - | is_admin: bool, |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | #[derive(Template)] |
|
| 85 | - | #[template(path = "wishlist_form.html")] |
|
| 86 | - | struct WishlistFormTemplate { |
|
| 87 | - | wine: Option<Wine>, |
|
| 88 | - | error: Option<String>, |
|
| 89 | - | has_anthropic_key: bool, |
|
| 90 | - | } |
|
| 91 | - | ||
| 92 | - | // --- Query/Form structs --- |
|
| 93 | - | ||
| 94 | - | #[derive(serde::Deserialize, Default)] |
|
| 95 | - | pub struct FlashQuery { |
|
| 96 | - | pub error: Option<String>, |
|
| 97 | - | pub next: Option<String>, |
|
| 98 | - | } |
|
| 99 | - | ||
| 100 | - | #[derive(serde::Deserialize)] |
|
| 101 | - | struct LoginForm { |
|
| 102 | - | password: String, |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | // --- Helpers --- |
|
| 106 | - | ||
| 107 | - | fn mime_from_path(path: &str) -> &'static str { |
|
| 108 | - | match path.rsplit('.').next().unwrap_or("") { |
|
| 109 | - | "css" => "text/css", |
|
| 110 | - | "js" => "application/javascript", |
|
| 111 | - | "html" => "text/html", |
|
| 112 | - | "png" => "image/png", |
|
| 113 | - | "jpg" | "jpeg" => "image/jpeg", |
|
| 114 | - | "ico" => "image/x-icon", |
|
| 115 | - | "svg" => "image/svg+xml", |
|
| 116 | - | "woff" | "woff2" => "font/woff2", |
|
| 117 | - | "ttf" => "font/ttf", |
|
| 118 | - | "otf" => "font/otf", |
|
| 119 | - | "json" | "webmanifest" => "application/json", |
|
| 120 | - | _ => "application/octet-stream", |
|
| 121 | - | } |
|
| 122 | - | } |
|
| 123 | - | ||
| 124 | - | fn urlencoded(s: &str) -> String { |
|
| 125 | - | s.replace(' ', "+") |
|
| 126 | - | .replace('&', "%26") |
|
| 127 | - | .replace('=', "%3D") |
|
| 128 | - | } |
|
| 129 | - | ||
| 130 | - | // --- Pentagon SVG --- |
|
| 131 | - | ||
| 132 | - | fn build_pentagon_svg( |
|
| 133 | - | sweetness: i32, |
|
| 134 | - | acidity: i32, |
|
| 135 | - | tannin: i32, |
|
| 136 | - | alcohol: i32, |
|
| 137 | - | body: i32, |
|
| 138 | - | size: f64, |
|
| 139 | - | show_labels: bool, |
|
| 140 | - | ) -> String { |
|
| 141 | - | let cx = size / 2.0; |
|
| 142 | - | let cy = size / 2.0; |
|
| 143 | - | let margin = if show_labels { 30.0 } else { 5.0 }; |
|
| 144 | - | let r = size / 2.0 - margin; |
|
| 145 | - | ||
| 146 | - | let scores = [sweetness, acidity, tannin, alcohol, body]; |
|
| 147 | - | let labels = ["Sweetness", "Acidity", "Tannin", "Alcohol", "Body"]; |
|
| 148 | - | ||
| 149 | - | let angles: Vec<f64> = (0..5) |
|
| 150 | - | .map(|i| (-90.0_f64 + 72.0 * i as f64).to_radians()) |
|
| 151 | - | .collect(); |
|
| 152 | - | ||
| 153 | - | let mut svg = format!( |
|
| 154 | - | r#"<svg viewBox="0 0 {s} {s}" width="100%" xmlns="http://www.w3.org/2000/svg">"#, |
|
| 155 | - | s = size |
|
| 156 | - | ); |
|
| 157 | - | ||
| 158 | - | for pct in &[0.2, 0.4, 0.6, 0.8] { |
|
| 159 | - | let points: String = angles |
|
| 160 | - | .iter() |
|
| 161 | - | .map(|a| format!("{:.1},{:.1}", cx + r * pct * a.cos(), cy + r * pct * a.sin())) |
|
| 162 | - | .collect::<Vec<_>>() |
|
| 163 | - | .join(" "); |
|
| 164 | - | svg.push_str(&format!( |
|
| 165 | - | r#"<polygon points="{}" fill="none" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>"#, |
|
| 166 | - | points |
|
| 167 | - | )); |
|
| 168 | - | } |
|
| 169 | - | ||
| 170 | - | let outline: String = angles |
|
| 171 | - | .iter() |
|
| 172 | - | .map(|a| format!("{:.1},{:.1}", cx + r * a.cos(), cy + r * a.sin())) |
|
| 173 | - | .collect::<Vec<_>>() |
|
| 174 | - | .join(" "); |
|
| 175 | - | svg.push_str(&format!( |
|
| 176 | - | r#"<polygon points="{}" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="1"/>"#, |
|
| 177 | - | outline |
|
| 178 | - | )); |
|
| 179 | - | ||
| 180 | - | for a in &angles { |
|
| 181 | - | svg.push_str(&format!( |
|
| 182 | - | r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>"#, |
|
| 183 | - | cx, cy, cx + r * a.cos(), cy + r * a.sin() |
|
| 184 | - | )); |
|
| 185 | - | } |
|
| 186 | - | ||
| 187 | - | let data_points: Vec<(f64, f64)> = scores |
|
| 188 | - | .iter() |
|
| 189 | - | .zip(&angles) |
|
| 190 | - | .map(|(s, a)| { |
|
| 191 | - | let d = (*s as f64 / 5.0) * r; |
|
| 192 | - | (cx + d * a.cos(), cy + d * a.sin()) |
|
| 193 | - | }) |
|
| 194 | - | .collect(); |
|
| 195 | - | ||
| 196 | - | let data_str: String = data_points |
|
| 197 | - | .iter() |
|
| 198 | - | .map(|(x, y)| format!("{:.1},{:.1}", x, y)) |
|
| 199 | - | .collect::<Vec<_>>() |
|
| 200 | - | .join(" "); |
|
| 201 | - | svg.push_str(&format!( |
|
| 202 | - | r#"<polygon points="{}" fill="white" fill-opacity="0.08" stroke="white" stroke-width="1.5"/>"#, |
|
| 203 | - | data_str |
|
| 204 | - | )); |
|
| 205 | - | ||
| 206 | - | for (x, y) in &data_points { |
|
| 207 | - | svg.push_str(&format!( |
|
| 208 | - | r#"<circle cx="{:.1}" cy="{:.1}" r="2.5" fill="white"/>"#, |
|
| 209 | - | x, y |
|
| 210 | - | )); |
|
| 211 | - | } |
|
| 212 | - | ||
| 213 | - | if show_labels { |
|
| 214 | - | for (i, label) in labels.iter().enumerate() { |
|
| 215 | - | let a = angles[i]; |
|
| 216 | - | let label_dist = r + 18.0; |
|
| 217 | - | let lx = cx + label_dist * a.cos(); |
|
| 218 | - | let ly = cy + label_dist * a.sin() + 3.5; |
|
| 219 | - | svg.push_str(&format!( |
|
| 220 | - | r#"<text x="{:.1}" y="{:.1}" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace" text-anchor="middle">{}</text>"#, |
|
| 221 | - | lx, ly, label |
|
| 222 | - | )); |
|
| 223 | - | } |
|
| 224 | - | } |
|
| 225 | - | ||
| 226 | - | svg.push_str("</svg>"); |
|
| 227 | - | svg |
|
| 228 | - | } |
|
| 229 | - | ||
| 230 | - | fn build_bars_svg( |
|
| 231 | - | clarity: i32, |
|
| 232 | - | color_intensity: i32, |
|
| 233 | - | aroma_intensity: i32, |
|
| 234 | - | nose_complexity: i32, |
|
| 235 | - | width: f64, |
|
| 236 | - | ) -> String { |
|
| 237 | - | let bar_height = 4.0; |
|
| 238 | - | let row_height = 22.0; |
|
| 239 | - | let section_gap = 14.0; |
|
| 240 | - | let label_width = 100.0; |
|
| 241 | - | let track_left = label_width + 4.0; |
|
| 242 | - | let track_width = width - track_left - 10.0; |
|
| 243 | - | let header_size = 9.0; |
|
| 244 | - | ||
| 245 | - | let sections: &[(&str, &[(&str, i32)])] = &[ |
|
| 246 | - | ("Appearance", &[("Clarity", clarity), ("Intensity", color_intensity)]), |
|
| 247 | - | ("Nose", &[("Aroma", aroma_intensity), ("Complexity", nose_complexity)]), |
|
| 248 | - | ]; |
|
| 249 | - | ||
| 250 | - | let total_rows: usize = sections.iter().map(|(_, attrs)| attrs.len()).sum(); |
|
| 251 | - | let total_height = (sections.len() as f64) * (header_size + 8.0) |
|
| 252 | - | + (total_rows as f64) * row_height |
|
| 253 | - | + section_gap; |
|
| 254 | - | ||
| 255 | - | let mut svg = format!( |
|
| 256 | - | r#"<svg viewBox="0 0 {w} {h}" width="100%" xmlns="http://www.w3.org/2000/svg">"#, |
|
| 257 | - | w = width, |
|
| 258 | - | h = total_height |
|
| 259 | - | ); |
|
| 260 | - | ||
| 261 | - | let mut y = 4.0; |
|
| 262 | - | ||
| 263 | - | for (si, (section_name, attrs)) in sections.iter().enumerate() { |
|
| 264 | - | if si > 0 { |
|
| 265 | - | y += section_gap; |
|
| 266 | - | } |
|
| 267 | - | ||
| 268 | - | svg.push_str(&format!( |
|
| 269 | - | r#"<text x="0" y="{:.1}" fill="white" fill-opacity="0.4" font-size="{}" font-family="Commit Mono, monospace" text-transform="uppercase" letter-spacing="1">{}</text>"#, |
|
| 270 | - | y + header_size, header_size, section_name |
|
| 271 | - | )); |
|
| 272 | - | y += header_size + 8.0; |
|
| 273 | - | ||
| 274 | - | for (label, score) in *attrs { |
|
| 275 | - | let bar_y = y + (row_height - bar_height) / 2.0; |
|
| 276 | - | let fill_width = (*score as f64 / 5.0) * track_width; |
|
| 277 | - | ||
| 278 | - | svg.push_str(&format!( |
|
| 279 | - | r#"<text x="0" y="{:.1}" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace">{}</text>"#, |
|
| 280 | - | y + row_height / 2.0 + 3.0, label |
|
| 281 | - | )); |
|
| 282 | - | ||
| 283 | - | svg.push_str(&format!( |
|
| 284 | - | r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2" fill="white" fill-opacity="0.08"/>"#, |
|
| 285 | - | track_left, bar_y, track_width, bar_height |
|
| 286 | - | )); |
|
| 287 | - | ||
| 288 | - | if fill_width > 0.0 { |
|
| 289 | - | svg.push_str(&format!( |
|
| 290 | - | r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2" fill="white" fill-opacity="0.6"/>"#, |
|
| 291 | - | track_left, bar_y, fill_width, bar_height |
|
| 292 | - | )); |
|
| 293 | - | } |
|
| 294 | - | ||
| 295 | - | y += row_height; |
|
| 296 | - | } |
|
| 297 | - | } |
|
| 298 | - | ||
| 299 | - | svg.push_str("</svg>"); |
|
| 300 | - | svg |
|
| 301 | - | } |
|
| 302 | - | ||
| 303 | - | // --- Image processing --- |
|
| 304 | - | ||
| 305 | - | fn process_image(data: &[u8]) -> Result<Vec<u8>, String> { |
|
| 306 | - | let reader = image::ImageReader::new(std::io::Cursor::new(data)) |
|
| 307 | - | .with_guessed_format() |
|
| 308 | - | .map_err(|e| format!("Failed to read image: {}", e))?; |
|
| 309 | - | let mut decoder = reader |
|
| 310 | - | .into_decoder() |
|
| 311 | - | .map_err(|e| format!("Failed to create decoder: {}", e))?; |
|
| 312 | - | let orientation = decoder |
|
| 313 | - | .orientation() |
|
| 314 | - | .unwrap_or(image::metadata::Orientation::NoTransforms); |
|
| 315 | - | let mut img = image::DynamicImage::from_decoder(decoder) |
|
| 316 | - | .map_err(|e| format!("Failed to decode image: {}", e))?; |
|
| 317 | - | img.apply_orientation(orientation); |
|
| 318 | - | let mut output = Vec::new(); |
|
| 319 | - | let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 75); |
|
| 320 | - | img.write_with_encoder(encoder) |
|
| 321 | - | .map_err(|e| format!("JPEG encoding failed: {}", e))?; |
|
| 322 | - | Ok(output) |
|
| 323 | - | } |
|
| 324 | - | ||
| 325 | - | // --- Multipart parsing --- |
|
| 326 | - | ||
| 327 | - | #[derive(Default)] |
|
| 328 | - | struct WineBase { |
|
| 329 | - | name: String, |
|
| 330 | - | origin: String, |
|
| 331 | - | grape: String, |
|
| 332 | - | notes: String, |
|
| 333 | - | background: String, |
|
| 334 | - | image: Option<Vec<u8>>, |
|
| 335 | - | image_mime: Option<String>, |
|
| 336 | - | } |
|
| 337 | - | ||
| 338 | - | impl WineBase { |
|
| 339 | - | fn owns(field_name: &str) -> bool { |
|
| 340 | - | matches!( |
|
| 341 | - | field_name, |
|
| 342 | - | "image" | "name" | "origin" | "grape" | "notes" | "background" |
|
| 343 | - | ) |
|
| 344 | - | } |
|
| 345 | - | ||
| 346 | - | async fn apply_field(&mut self, field_name: &str, field: Field<'_>) -> Result<(), String> { |
|
| 347 | - | match field_name { |
|
| 348 | - | "image" => { |
|
| 349 | - | let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?; |
|
| 350 | - | if !bytes.is_empty() { |
|
| 351 | - | self.image = Some(process_image(&bytes)?); |
|
| 352 | - | self.image_mime = Some("image/jpeg".to_string()); |
|
| 353 | - | } |
|
| 354 | - | } |
|
| 355 | - | "name" => self.name = field.text().await.unwrap_or_default(), |
|
| 356 | - | "origin" => self.origin = field.text().await.unwrap_or_default(), |
|
| 357 | - | "grape" => self.grape = field.text().await.unwrap_or_default(), |
|
| 358 | - | "notes" => self.notes = field.text().await.unwrap_or_default(), |
|
| 359 | - | "background" => self.background = field.text().await.unwrap_or_default(), |
|
| 360 | - | _ => {} |
|
| 361 | - | } |
|
| 362 | - | Ok(()) |
|
| 363 | - | } |
|
| 364 | - | ||
| 365 | - | fn finalize(&mut self) -> Result<(), String> { |
|
| 366 | - | if self.name.trim().is_empty() { |
|
| 367 | - | return Err("Name is required".to_string()); |
|
| 368 | - | } |
|
| 369 | - | self.name = self.name.trim().to_string(); |
|
| 370 | - | self.origin = self.origin.trim().to_string(); |
|
| 371 | - | self.grape = self.grape.trim().to_string(); |
|
| 372 | - | self.notes = self.notes.trim().to_string(); |
|
| 373 | - | self.background = self.background.trim().to_string(); |
|
| 374 | - | Ok(()) |
|
| 375 | - | } |
|
| 376 | - | } |
|
| 377 | - | ||
| 378 | - | type WishlistFormData = WineBase; |
|
| 379 | - | ||
| 380 | - | struct WineScores { |
|
| 381 | - | sweetness: i32, |
|
| 382 | - | acidity: i32, |
|
| 383 | - | tannin: i32, |
|
| 384 | - | alcohol: i32, |
|
| 385 | - | body: i32, |
|
| 386 | - | clarity: i32, |
|
| 387 | - | color_intensity: i32, |
|
| 388 | - | aroma_intensity: i32, |
|
| 389 | - | nose_complexity: i32, |
|
| 390 | - | } |
|
| 391 | - | ||
| 392 | - | impl Default for WineScores { |
|
| 393 | - | fn default() -> Self { |
|
| 394 | - | Self { |
|
| 395 | - | sweetness: 3, |
|
| 396 | - | acidity: 3, |
|
| 397 | - | tannin: 3, |
|
| 398 | - | alcohol: 3, |
|
| 399 | - | body: 3, |
|
| 400 | - | clarity: 3, |
|
| 401 | - | color_intensity: 3, |
|
| 402 | - | aroma_intensity: 3, |
|
| 403 | - | nose_complexity: 3, |
|
| 404 | - | } |
|
| 405 | - | } |
|
| 406 | - | } |
|
| 407 | - | ||
| 408 | - | impl WineScores { |
|
| 409 | - | fn slot(&mut self, field_name: &str) -> Option<&mut i32> { |
|
| 410 | - | Some(match field_name { |
|
| 411 | - | "sweetness" => &mut self.sweetness, |
|
| 412 | - | "acidity" => &mut self.acidity, |
|
| 413 | - | "tannin" => &mut self.tannin, |
|
| 414 | - | "alcohol" => &mut self.alcohol, |
|
| 415 | - | "body" => &mut self.body, |
|
| 416 | - | "clarity" => &mut self.clarity, |
|
| 417 | - | "color_intensity" => &mut self.color_intensity, |
|
| 418 | - | "aroma_intensity" => &mut self.aroma_intensity, |
|
| 419 | - | "nose_complexity" => &mut self.nose_complexity, |
|
| 420 | - | _ => return None, |
|
| 421 | - | }) |
|
| 422 | - | } |
|
| 423 | - | ||
| 424 | - | fn clamp_all(&mut self) { |
|
| 425 | - | for v in [ |
|
| 426 | - | &mut self.sweetness, |
|
| 427 | - | &mut self.acidity, |
|
| 428 | - | &mut self.tannin, |
|
| 429 | - | &mut self.alcohol, |
|
| 430 | - | &mut self.body, |
|
| 431 | - | &mut self.clarity, |
|
| 432 | - | &mut self.color_intensity, |
|
| 433 | - | &mut self.aroma_intensity, |
|
| 434 | - | &mut self.nose_complexity, |
|
| 435 | - | ] { |
|
| 436 | - | *v = (*v).clamp(1, 5); |
|
| 437 | - | } |
|
| 438 | - | } |
|
| 439 | - | } |
|
| 440 | - | ||
| 441 | - | struct WineFormData { |
|
| 442 | - | base: WineBase, |
|
| 443 | - | scores: WineScores, |
|
| 444 | - | } |
|
| 445 | - | ||
| 446 | - | impl<'a> From<&'a WineFormData> for crate::db::WineInput<'a> { |
|
| 447 | - | fn from(data: &'a WineFormData) -> Self { |
|
| 448 | - | Self { |
|
| 449 | - | name: &data.base.name, |
|
| 450 | - | origin: &data.base.origin, |
|
| 451 | - | grape: &data.base.grape, |
|
| 452 | - | notes: &data.base.notes, |
|
| 453 | - | sweetness: data.scores.sweetness, |
|
| 454 | - | acidity: data.scores.acidity, |
|
| 455 | - | tannin: data.scores.tannin, |
|
| 456 | - | alcohol: data.scores.alcohol, |
|
| 457 | - | body: data.scores.body, |
|
| 458 | - | clarity: data.scores.clarity, |
|
| 459 | - | color_intensity: data.scores.color_intensity, |
|
| 460 | - | aroma_intensity: data.scores.aroma_intensity, |
|
| 461 | - | nose_complexity: data.scores.nose_complexity, |
|
| 462 | - | background: &data.base.background, |
|
| 463 | - | } |
|
| 464 | - | } |
|
| 465 | - | } |
|
| 466 | - | ||
| 467 | - | async fn parse_wine_multipart(mut multipart: Multipart) -> Result<WineFormData, String> { |
|
| 468 | - | let mut base = WineBase::default(); |
|
| 469 | - | let mut scores = WineScores::default(); |
|
| 470 | - | ||
| 471 | - | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 472 | - | let field_name = field.name().unwrap_or("").to_string(); |
|
| 473 | - | if WineBase::owns(&field_name) { |
|
| 474 | - | base.apply_field(&field_name, field).await?; |
|
| 475 | - | } else if let Some(slot) = scores.slot(&field_name) { |
|
| 476 | - | *slot = field.text().await.unwrap_or_default().parse().unwrap_or(3); |
|
| 477 | - | } |
|
| 478 | - | } |
|
| 479 | - | ||
| 480 | - | base.finalize()?; |
|
| 481 | - | scores.clamp_all(); |
|
| 482 | - | Ok(WineFormData { base, scores }) |
|
| 483 | - | } |
|
| 484 | - | ||
| 485 | - | async fn parse_wishlist_multipart(mut multipart: Multipart) -> Result<WishlistFormData, String> { |
|
| 486 | - | let mut base = WineBase::default(); |
|
| 487 | - | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 488 | - | let field_name = field.name().unwrap_or("").to_string(); |
|
| 489 | - | base.apply_field(&field_name, field).await?; |
|
| 490 | - | } |
|
| 491 | - | base.finalize()?; |
|
| 492 | - | Ok(base) |
|
| 493 | - | } |
|
| 494 | - | ||
| 495 | - | // --- Router --- |
|
| 496 | - | ||
| 497 | - | pub async fn run(host: String, port: u16) { |
|
| 498 | - | use handlers::{admin, public}; |
|
| 499 | - | ||
| 500 | - | dotenvy::dotenv().ok(); |
|
| 501 | - | ||
| 502 | - | let db = db::init_db(); |
|
| 503 | - | ||
| 504 | - | if let Err(e) = db::prune_expired_sessions(&db) { |
|
| 505 | - | tracing::warn!("Failed to prune sessions: {}", e); |
|
| 506 | - | } |
|
| 507 | - | ||
| 508 | - | let app_password = std::env::var("CELLAR_PASSWORD").unwrap_or_else(|_| { |
|
| 509 | - | tracing::warn!("CELLAR_PASSWORD not set, using default 'changeme'"); |
|
| 510 | - | "changeme".to_string() |
|
| 511 | - | }); |
|
| 512 | - | ||
| 513 | - | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 514 | - | .map(|v| v == "true") |
|
| 515 | - | .unwrap_or(false); |
|
| 516 | - | ||
| 517 | - | let anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok().filter(|k| !k.is_empty()); |
|
| 518 | - | ||
| 519 | - | let site_url = std::env::var("SITE_URL") |
|
| 520 | - | .unwrap_or_else(|_| "http://localhost:3000".to_string()) |
|
| 521 | - | .trim_end_matches('/') |
|
| 522 | - | .to_string(); |
|
| 523 | - | ||
| 524 | - | let site_title = std::env::var("SITE_TITLE").unwrap_or_else(|_| "Cellar".to_string()); |
|
| 525 | - | let site_description = std::env::var("SITE_DESCRIPTION") |
|
| 526 | - | .unwrap_or_else(|_| "Personal wine tasting log".to_string()); |
|
| 527 | - | ||
| 528 | - | let state = Arc::new(AppState { |
|
| 529 | - | db, |
|
| 530 | - | app_password, |
|
| 531 | - | cookie_secure, |
|
| 532 | - | anthropic_api_key, |
|
| 533 | - | site_url, |
|
| 534 | - | site_title, |
|
| 535 | - | site_description, |
|
| 536 | - | }); |
|
| 537 | - | ||
| 538 | - | let cors = CorsLayer::new() |
|
| 539 | - | .allow_origin(Any) |
|
| 540 | - | .allow_methods([axum::http::Method::GET]); |
|
| 541 | - | ||
| 542 | - | let api = Router::new() |
|
| 543 | - | .route("/api/wines", get(public::api_list_wines)) |
|
| 544 | - | .route("/api/wines/{short_id}", get(public::api_get_wine)) |
|
| 545 | - | .route("/api/wines/{short_id}/pentagon.svg", get(public::api_get_pentagon_svg)) |
|
| 546 | - | .route("/api/wines/{short_id}/bars.svg", get(public::api_get_bars_svg)) |
|
| 547 | - | .layer(cors); |
|
| 548 | - | ||
| 549 | - | let app = Router::new() |
|
| 550 | - | // Public routes |
|
| 551 | - | .route("/", get(public::get_index)) |
|
| 552 | - | .route("/feed.xml", get(public::rss_feed)) |
|
| 553 | - | .route("/wines/{short_id}", get(public::get_wine_detail)) |
|
| 554 | - | .route("/wines/{short_id}/image", get(public::get_wine_image)) |
|
| 555 | - | .merge(api) |
|
| 556 | - | // Admin auth routes |
|
| 557 | - | .route("/admin/login", get(admin::get_login).post(admin::post_login)) |
|
| 558 | - | .route("/admin/logout", get(admin::get_logout)) |
|
| 559 | - | // Admin protected routes |
|
| 560 | - | .route("/admin", get(admin::get_admin)) |
|
| 561 | - | .route("/admin/new", get(admin::get_new_wine).post(admin::post_new_wine)) |
|
| 562 | - | .route( |
|
| 563 | - | "/admin/edit/{short_id}", |
|
| 564 | - | get(admin::get_edit_wine).post(admin::post_edit_wine), |
|
| 565 | - | ) |
|
| 566 | - | .route("/admin/delete/{short_id}", post(admin::post_delete_wine)) |
|
| 567 | - | // Wishlist |
|
| 568 | - | .route("/wishlist", get(public::get_wishlist)) |
|
| 569 | - | .route( |
|
| 570 | - | "/admin/wishlist/new", |
|
| 571 | - | get(admin::get_new_wishlist_wine).post(admin::post_new_wishlist_wine), |
|
| 572 | - | ) |
|
| 573 | - | .route( |
|
| 574 | - | "/admin/wishlist/edit/{short_id}", |
|
| 575 | - | get(admin::get_edit_wishlist_wine).post(admin::post_edit_wishlist_wine), |
|
| 576 | - | ) |
|
| 577 | - | .route( |
|
| 578 | - | "/admin/wishlist/delete/{short_id}", |
|
| 579 | - | post(admin::post_delete_wishlist_wine), |
|
| 580 | - | ) |
|
| 581 | - | .route( |
|
| 582 | - | "/admin/wishlist/promote/{short_id}", |
|
| 583 | - | post(admin::post_promote_wine), |
|
| 584 | - | ) |
|
| 585 | - | // Claude vision |
|
| 586 | - | .route("/admin/analyze-image", post(admin::post_analyze_image)) |
|
| 587 | - | // Static assets |
|
| 588 | - | .route("/static/{*path}", get(public::serve_static)) |
|
| 589 | - | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 590 | - | .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) |
|
| 591 | - | .with_state(state); |
|
| 592 | - | ||
| 593 | - | let addr = format!("{}:{}", host, port); |
|
| 594 | - | tracing::info!("Listening on http://{}", addr); |
|
| 595 | - | ||
| 596 | - | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); |
|
| 597 | - | axum::serve(listener, app).await.unwrap(); |
|
| 598 | - | } |
Binary file — no preview.
Binary file — no preview.
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Admin - Cellar{% endblock %} |
|
| 3 | - | {% block nav %} |
|
| 4 | - | <nav class="links"> |
|
| 5 | - | <a href="/admin/new">new</a> |
|
| 6 | - | <a href="/wishlist">wishlist</a> |
|
| 7 | - | </nav> |
|
| 8 | - | {% endblock %} |
|
| 9 | - | {% block content %} |
|
| 10 | - | {% if wines.is_empty() %} |
|
| 11 | - | <p class="empty">no wines yet</p> |
|
| 12 | - | {% endif %} |
|
| 1 | + | {{define "admin.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Admin - Cellar{{end}} |
|
| 3 | + | {{define "nav"}} |
|
| 4 | + | <nav class="links"> |
|
| 5 | + | <a href="/admin/new">new</a> |
|
| 6 | + | <a href="/wishlist">wishlist</a> |
|
| 7 | + | </nav> |
|
| 8 | + | {{end}} |
|
| 9 | + | {{define "content"}} |
|
| 10 | + | {{if not .Wines}}<p class="empty">no wines yet</p>{{end}} |
|
| 13 | 11 | <div class="admin-list"> |
|
| 14 | - | {% for wine in wines %} |
|
| 12 | + | {{range .Wines}} |
|
| 15 | 13 | <div class="admin-item"> |
|
| 16 | 14 | <div class="admin-item-info"> |
|
| 17 | - | <a href="/wines/{{ wine.short_id }}" class="admin-item-name">{{ wine.name }}</a> |
|
| 18 | - | <span class="admin-item-meta">{{ wine.origin }}{% if !wine.grape.is_empty() %} · {{ wine.grape }}{% endif %}</span> |
|
| 15 | + | <a href="/wines/{{.ShortID}}" class="admin-item-name">{{.Name}}</a> |
|
| 16 | + | <span class="admin-item-meta">{{.Origin}}{{if .Grape}} · {{.Grape}}{{end}}</span> |
|
| 19 | 17 | </div> |
|
| 20 | 18 | <div class="admin-actions"> |
|
| 21 | - | <a href="/admin/edit/{{ wine.short_id }}">edit</a> |
|
| 22 | - | <form method="POST" action="/admin/delete/{{ wine.short_id }}" class="inline-form" onsubmit="return confirm('delete this wine?')"> |
|
| 19 | + | <a href="/admin/edit/{{.ShortID}}">edit</a> |
|
| 20 | + | <form method="POST" action="/admin/delete/{{.ShortID}}" class="inline-form" onsubmit="return confirm('delete this wine?')"> |
|
| 23 | 21 | <button type="submit" class="link-button">delete</button> |
|
| 24 | 22 | </form> |
|
| 25 | 23 | </div> |
|
| 26 | 24 | </div> |
|
| 27 | - | {% endfor %} |
|
| 25 | + | {{end}} |
|
| 28 | 26 | </div> |
|
| 29 | - | {% endblock %} |
|
| 27 | + | {{end}} |
| 1 | - | <!DOCTYPE html> |
|
| 1 | + | {{define "base.html"}}<!DOCTYPE html> |
|
| 2 | 2 | <html lang="en"> |
|
| 3 | 3 | <head> |
|
| 4 | 4 | <meta charset="UTF-8"> |
|
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>{% block title %}Cellar{% endblock %}</title> |
|
| 6 | + | <title>{{block "title" .}}Cellar{{end}}</title> |
|
| 7 | 7 | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | 8 | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | 9 | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 20 | 20 | <body> |
|
| 21 | 21 | <header class="header"> |
|
| 22 | 22 | <a href="/" class="logo">cellar</a> |
|
| 23 | - | {% block nav %}{% endblock %} |
|
| 23 | + | {{block "nav" .}}{{end}} |
|
| 24 | 24 | </header> |
|
| 25 | 25 | <main> |
|
| 26 | - | {% block content %}{% endblock %} |
|
| 26 | + | {{block "content" .}}{{end}} |
|
| 27 | 27 | </main> |
|
| 28 | 28 | </body> |
|
| 29 | - | </html> |
|
| 29 | + | </html>{{end}} |
|
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Cellar{% endblock %} |
|
| 3 | - | {% block nav %} |
|
| 4 | - | <nav class="links"> |
|
| 5 | - | <a href="/admin/new">new</a> |
|
| 6 | - | <a href="/wishlist">wishlist</a> |
|
| 7 | - | </nav> |
|
| 8 | - | {% endblock %} |
|
| 9 | - | {% block content %} |
|
| 10 | - | {% if wines.is_empty() %} |
|
| 1 | + | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Cellar{{end}} |
|
| 3 | + | {{define "nav"}} |
|
| 4 | + | <nav class="links"> |
|
| 5 | + | <a href="/admin/new">new</a> |
|
| 6 | + | <a href="/wishlist">wishlist</a> |
|
| 7 | + | </nav> |
|
| 8 | + | {{end}} |
|
| 9 | + | {{define "content"}} |
|
| 10 | + | {{if not .Wines}} |
|
| 11 | 11 | <p class="empty">no wines yet</p> |
|
| 12 | - | {% endif %} |
|
| 12 | + | {{end}} |
|
| 13 | 13 | <div class="wine-list"> |
|
| 14 | - | {% for item in wines %} |
|
| 15 | - | <a href="/wines/{{ item.wine.short_id }}" class="wine-card"> |
|
| 14 | + | {{range .Wines}} |
|
| 15 | + | <a href="/wines/{{.Wine.ShortID}}" class="wine-card"> |
|
| 16 | 16 | <div class="wine-pentagon"> |
|
| 17 | - | {{ item.pentagon_svg|safe }} |
|
| 17 | + | {{.PentagonSVG}} |
|
| 18 | 18 | </div> |
|
| 19 | 19 | <div class="wine-info"> |
|
| 20 | - | <span class="wine-name">{{ item.wine.name }}</span> |
|
| 21 | - | <span class="wine-meta">{{ item.wine.origin }}{% if !item.wine.grape.is_empty() %} · {{ item.wine.grape }}{% endif %}</span> |
|
| 20 | + | <span class="wine-name">{{.Wine.Name}}</span> |
|
| 21 | + | <span class="wine-meta">{{.Wine.Origin}}{{if .Wine.Grape}} · {{.Wine.Grape}}{{end}}</span> |
|
| 22 | 22 | </div> |
|
| 23 | 23 | </a> |
|
| 24 | - | {% endfor %} |
|
| 24 | + | {{end}} |
|
| 25 | 25 | </div> |
|
| 26 | - | {% endblock %} |
|
| 26 | + | {{end}} |
| 1 | - | <!DOCTYPE html> |
|
| 1 | + | {{define "login.html"}}<!DOCTYPE html> |
|
| 2 | 2 | <html lang="en"> |
|
| 3 | 3 | <head> |
|
| 4 | 4 | <meta charset="UTF-8"> |
|
| 13 | 13 | <span class="logo">CELLAR</span> |
|
| 14 | 14 | </header> |
|
| 15 | 15 | <main> |
|
| 16 | - | {% if let Some(error) = error %} |
|
| 17 | - | <p class="error">{{ error }}</p> |
|
| 18 | - | {% endif %} |
|
| 19 | - | <form method="POST" action="/admin/login{% if let Some(next) = next %}?next={{ next }}{% endif %}" class="form"> |
|
| 16 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 17 | + | <form method="POST" action="/admin/login{{if .Next}}?next={{.Next}}{{end}}" class="form"> |
|
| 20 | 18 | <label for="password">password</label> |
|
| 21 | 19 | <input type="password" id="password" name="password" autofocus required> |
|
| 22 | 20 | <button type="submit">login</button> |
|
| 23 | 21 | </form> |
|
| 24 | 22 | </main> |
|
| 25 | 23 | </body> |
|
| 26 | - | </html> |
|
| 24 | + | </html>{{end}} |
|
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}{{ wine.name }} - Cellar{% endblock %} |
|
| 3 | - | {% block nav %} |
|
| 4 | - | <nav class="links"> |
|
| 5 | - | <a href="/admin/edit/{{ wine.short_id }}">edit</a> |
|
| 6 | - | </nav> |
|
| 7 | - | {% endblock %} |
|
| 8 | - | {% block content %} |
|
| 1 | + | {{define "wine.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{.Wine.Name}} - Cellar{{end}} |
|
| 3 | + | {{define "nav"}} |
|
| 4 | + | <nav class="links"> |
|
| 5 | + | <a href="/admin/edit/{{.Wine.ShortID}}">edit</a> |
|
| 6 | + | </nav> |
|
| 7 | + | {{end}} |
|
| 8 | + | {{define "content"}} |
|
| 9 | 9 | <div class="wine-detail"> |
|
| 10 | - | <h1 class="wine-detail-name">{{ wine.name }}</h1> |
|
| 10 | + | <h1 class="wine-detail-name">{{.Wine.Name}}</h1> |
|
| 11 | 11 | <div class="wine-detail-top"> |
|
| 12 | - | {% if wine.has_image %} |
|
| 12 | + | {{if .Wine.HasImage}} |
|
| 13 | 13 | <div class="wine-image-wrap"> |
|
| 14 | - | <img src="/wines/{{ wine.short_id }}/image" alt="{{ wine.name }}" class="wine-image"> |
|
| 14 | + | <img src="/wines/{{.Wine.ShortID}}/image" alt="{{.Wine.Name}}" class="wine-image"> |
|
| 15 | 15 | </div> |
|
| 16 | - | {% endif %} |
|
| 17 | - | {% if !wine.wishlist %} |
|
| 16 | + | {{end}} |
|
| 17 | + | {{if not .Wine.Wishlist}} |
|
| 18 | 18 | <div class="wine-detail-chart"> |
|
| 19 | - | {{ pentagon_svg|safe }} |
|
| 20 | - | {{ bars_svg|safe }} |
|
| 19 | + | {{.PentagonSVG}} |
|
| 20 | + | {{.BarsSVG}} |
|
| 21 | 21 | </div> |
|
| 22 | - | {% endif %} |
|
| 22 | + | {{end}} |
|
| 23 | 23 | </div> |
|
| 24 | 24 | <div class="wine-detail-meta"> |
|
| 25 | - | {% if !wine.origin.is_empty() %} |
|
| 26 | - | <div class="meta-row"> |
|
| 27 | - | <span class="meta-label">origin</span> |
|
| 28 | - | <span>{{ wine.origin }}</span> |
|
| 29 | - | </div> |
|
| 30 | - | {% endif %} |
|
| 31 | - | {% if !wine.grape.is_empty() %} |
|
| 32 | - | <div class="meta-row"> |
|
| 33 | - | <span class="meta-label">grape</span> |
|
| 34 | - | <span>{{ wine.grape }}</span> |
|
| 35 | - | </div> |
|
| 36 | - | {% endif %} |
|
| 25 | + | {{if .Wine.Origin}}<div class="meta-row"><span class="meta-label">origin</span><span>{{.Wine.Origin}}</span></div>{{end}} |
|
| 26 | + | {{if .Wine.Grape}}<div class="meta-row"><span class="meta-label">grape</span><span>{{.Wine.Grape}}</span></div>{{end}} |
|
| 37 | 27 | </div> |
|
| 38 | - | {% if !wine.notes.is_empty() %} |
|
| 39 | - | <div class="wine-detail-notes"> |
|
| 40 | - | <span class="meta-label">notes</span> |
|
| 41 | - | <p>{{ wine.notes }}</p> |
|
| 42 | - | </div> |
|
| 43 | - | {% endif %} |
|
| 44 | - | {% if !wine.background.is_empty() %} |
|
| 45 | - | <div class="wine-detail-notes"> |
|
| 46 | - | <span class="meta-label">background</span> |
|
| 47 | - | <p>{{ wine.background }}</p> |
|
| 48 | - | </div> |
|
| 49 | - | {% endif %} |
|
| 28 | + | {{if .Wine.Notes}}<div class="wine-detail-notes"><span class="meta-label">notes</span><p>{{.Wine.Notes}}</p></div>{{end}} |
|
| 29 | + | {{if .Wine.Background}}<div class="wine-detail-notes"><span class="meta-label">background</span><p>{{.Wine.Background}}</p></div>{{end}} |
|
| 50 | 30 | </div> |
|
| 51 | - | {% endblock %} |
|
| 31 | + | {{end}} |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}{% if wine.is_some() %}Edit{% else %}New{% endif %} Wine - Cellar{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | {% if let Some(error) = error %} |
|
| 5 | - | <p class="error">{{ error }}</p> |
|
| 6 | - | {% endif %} |
|
| 1 | + | {{define "wine_form.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{if .Wine}}Edit{{else}}New{{end}} Wine - Cellar{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 5 | + | {{$w := .Wine}} |
|
| 7 | 6 | <form method="POST" enctype="multipart/form-data" |
|
| 8 | - | action="{% if let Some(w) = wine %}/admin/edit/{{ w.short_id }}{% else %}/admin/new{% endif %}" |
|
| 7 | + | action="{{if $w}}/admin/edit/{{$w.ShortID}}{{else}}/admin/new{{end}}" |
|
| 9 | 8 | class="form"> |
|
| 10 | 9 | ||
| 11 | 10 | <label for="image">image</label> |
|
| 12 | 11 | <div class="image-upload-row"> |
|
| 13 | 12 | <input type="file" id="image" name="image" accept="image/*"> |
|
| 14 | - | {% if has_anthropic_key %} |
|
| 15 | - | <button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button> |
|
| 16 | - | {% endif %} |
|
| 13 | + | {{if .HasAnthropicKey}}<button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button>{{end}} |
|
| 17 | 14 | </div> |
|
| 18 | 15 | ||
| 19 | 16 | <label for="name">name</label> |
|
| 20 | - | <input type="text" id="name" name="name" required |
|
| 21 | - | value="{% if let Some(w) = wine %}{{ w.name }}{% endif %}"> |
|
| 17 | + | <input type="text" id="name" name="name" required value="{{if $w}}{{$w.Name}}{{end}}"> |
|
| 22 | 18 | ||
| 23 | 19 | <label for="origin">origin</label> |
|
| 24 | - | <input type="text" id="origin" name="origin" |
|
| 25 | - | value="{% if let Some(w) = wine %}{{ w.origin }}{% endif %}"> |
|
| 20 | + | <input type="text" id="origin" name="origin" value="{{if $w}}{{$w.Origin}}{{end}}"> |
|
| 26 | 21 | ||
| 27 | 22 | <label for="grape">grape</label> |
|
| 28 | - | <input type="text" id="grape" name="grape" |
|
| 29 | - | value="{% if let Some(w) = wine %}{{ w.grape }}{% endif %}"> |
|
| 23 | + | <input type="text" id="grape" name="grape" value="{{if $w}}{{$w.Grape}}{{end}}"> |
|
| 30 | 24 | ||
| 31 | 25 | <label for="notes">notes</label> |
|
| 32 | - | <textarea id="notes" name="notes" rows="5">{% if let Some(w) = wine %}{{ w.notes }}{% endif %}</textarea> |
|
| 26 | + | <textarea id="notes" name="notes" rows="5">{{if $w}}{{$w.Notes}}{{end}}</textarea> |
|
| 33 | 27 | ||
| 34 | 28 | <label for="background">background</label> |
|
| 35 | - | <textarea id="background" name="background" rows="5">{% if let Some(w) = wine %}{{ w.background }}{% endif %}</textarea> |
|
| 29 | + | <textarea id="background" name="background" rows="5">{{if $w}}{{$w.Background}}{{end}}</textarea> |
|
| 36 | 30 | ||
| 37 | 31 | <div class="score-group"> |
|
| 38 | 32 | <div class="score-section-label">appearance</div> |
|
| 39 | 33 | <div class="score-row"> |
|
| 40 | 34 | <label for="clarity">clarity</label> |
|
| 41 | - | <input type="range" id="clarity" name="clarity" min="1" max="5" |
|
| 42 | - | value="{% if let Some(w) = wine %}{{ w.clarity }}{% else %}3{% endif %}"> |
|
| 43 | - | <span class="score-value" data-for="clarity">{% if let Some(w) = wine %}{{ w.clarity }}{% else %}3{% endif %}</span> |
|
| 35 | + | <input type="range" id="clarity" name="clarity" min="1" max="5" value="{{if $w}}{{$w.Clarity}}{{else}}3{{end}}"> |
|
| 36 | + | <span class="score-value" data-for="clarity">{{if $w}}{{$w.Clarity}}{{else}}3{{end}}</span> |
|
| 44 | 37 | </div> |
|
| 45 | 38 | <div class="score-row"> |
|
| 46 | 39 | <label for="color_intensity">intensity</label> |
|
| 47 | - | <input type="range" id="color_intensity" name="color_intensity" min="1" max="5" |
|
| 48 | - | value="{% if let Some(w) = wine %}{{ w.color_intensity }}{% else %}3{% endif %}"> |
|
| 49 | - | <span class="score-value" data-for="color_intensity">{% if let Some(w) = wine %}{{ w.color_intensity }}{% else %}3{% endif %}</span> |
|
| 40 | + | <input type="range" id="color_intensity" name="color_intensity" min="1" max="5" value="{{if $w}}{{$w.ColorIntensity}}{{else}}3{{end}}"> |
|
| 41 | + | <span class="score-value" data-for="color_intensity">{{if $w}}{{$w.ColorIntensity}}{{else}}3{{end}}</span> |
|
| 50 | 42 | </div> |
|
| 51 | 43 | ||
| 52 | 44 | <div class="score-section-label">nose</div> |
|
| 53 | 45 | <div class="score-row"> |
|
| 54 | 46 | <label for="aroma_intensity">aroma</label> |
|
| 55 | - | <input type="range" id="aroma_intensity" name="aroma_intensity" min="1" max="5" |
|
| 56 | - | value="{% if let Some(w) = wine %}{{ w.aroma_intensity }}{% else %}3{% endif %}"> |
|
| 57 | - | <span class="score-value" data-for="aroma_intensity">{% if let Some(w) = wine %}{{ w.aroma_intensity }}{% else %}3{% endif %}</span> |
|
| 47 | + | <input type="range" id="aroma_intensity" name="aroma_intensity" min="1" max="5" value="{{if $w}}{{$w.AromaIntensity}}{{else}}3{{end}}"> |
|
| 48 | + | <span class="score-value" data-for="aroma_intensity">{{if $w}}{{$w.AromaIntensity}}{{else}}3{{end}}</span> |
|
| 58 | 49 | </div> |
|
| 59 | 50 | <div class="score-row"> |
|
| 60 | 51 | <label for="nose_complexity">complexity</label> |
|
| 61 | - | <input type="range" id="nose_complexity" name="nose_complexity" min="1" max="5" |
|
| 62 | - | value="{% if let Some(w) = wine %}{{ w.nose_complexity }}{% else %}3{% endif %}"> |
|
| 63 | - | <span class="score-value" data-for="nose_complexity">{% if let Some(w) = wine %}{{ w.nose_complexity }}{% else %}3{% endif %}</span> |
|
| 52 | + | <input type="range" id="nose_complexity" name="nose_complexity" min="1" max="5" value="{{if $w}}{{$w.NoseComplexity}}{{else}}3{{end}}"> |
|
| 53 | + | <span class="score-value" data-for="nose_complexity">{{if $w}}{{$w.NoseComplexity}}{{else}}3{{end}}</span> |
|
| 64 | 54 | </div> |
|
| 65 | 55 | ||
| 66 | 56 | <div class="score-section-label">palate</div> |
|
| 67 | 57 | <div class="score-row"> |
|
| 68 | 58 | <label for="sweetness">sweetness</label> |
|
| 69 | - | <input type="range" id="sweetness" name="sweetness" min="1" max="5" |
|
| 70 | - | value="{% if let Some(w) = wine %}{{ w.sweetness }}{% else %}3{% endif %}"> |
|
| 71 | - | <span class="score-value" data-for="sweetness">{% if let Some(w) = wine %}{{ w.sweetness }}{% else %}3{% endif %}</span> |
|
| 59 | + | <input type="range" id="sweetness" name="sweetness" min="1" max="5" value="{{if $w}}{{$w.Sweetness}}{{else}}3{{end}}"> |
|
| 60 | + | <span class="score-value" data-for="sweetness">{{if $w}}{{$w.Sweetness}}{{else}}3{{end}}</span> |
|
| 72 | 61 | </div> |
|
| 73 | 62 | <div class="score-row"> |
|
| 74 | 63 | <label for="acidity">acidity</label> |
|
| 75 | - | <input type="range" id="acidity" name="acidity" min="1" max="5" |
|
| 76 | - | value="{% if let Some(w) = wine %}{{ w.acidity }}{% else %}3{% endif %}"> |
|
| 77 | - | <span class="score-value" data-for="acidity">{% if let Some(w) = wine %}{{ w.acidity }}{% else %}3{% endif %}</span> |
|
| 64 | + | <input type="range" id="acidity" name="acidity" min="1" max="5" value="{{if $w}}{{$w.Acidity}}{{else}}3{{end}}"> |
|
| 65 | + | <span class="score-value" data-for="acidity">{{if $w}}{{$w.Acidity}}{{else}}3{{end}}</span> |
|
| 78 | 66 | </div> |
|
| 79 | 67 | <div class="score-row"> |
|
| 80 | 68 | <label for="tannin">tannin</label> |
|
| 81 | - | <input type="range" id="tannin" name="tannin" min="1" max="5" |
|
| 82 | - | value="{% if let Some(w) = wine %}{{ w.tannin }}{% else %}3{% endif %}"> |
|
| 83 | - | <span class="score-value" data-for="tannin">{% if let Some(w) = wine %}{{ w.tannin }}{% else %}3{% endif %}</span> |
|
| 69 | + | <input type="range" id="tannin" name="tannin" min="1" max="5" value="{{if $w}}{{$w.Tannin}}{{else}}3{{end}}"> |
|
| 70 | + | <span class="score-value" data-for="tannin">{{if $w}}{{$w.Tannin}}{{else}}3{{end}}</span> |
|
| 84 | 71 | </div> |
|
| 85 | 72 | <div class="score-row"> |
|
| 86 | 73 | <label for="alcohol">alcohol</label> |
|
| 87 | - | <input type="range" id="alcohol" name="alcohol" min="1" max="5" |
|
| 88 | - | value="{% if let Some(w) = wine %}{{ w.alcohol }}{% else %}3{% endif %}"> |
|
| 89 | - | <span class="score-value" data-for="alcohol">{% if let Some(w) = wine %}{{ w.alcohol }}{% else %}3{% endif %}</span> |
|
| 74 | + | <input type="range" id="alcohol" name="alcohol" min="1" max="5" value="{{if $w}}{{$w.Alcohol}}{{else}}3{{end}}"> |
|
| 75 | + | <span class="score-value" data-for="alcohol">{{if $w}}{{$w.Alcohol}}{{else}}3{{end}}</span> |
|
| 90 | 76 | </div> |
|
| 91 | 77 | <div class="score-row"> |
|
| 92 | 78 | <label for="body">body</label> |
|
| 93 | - | <input type="range" id="body" name="body" min="1" max="5" |
|
| 94 | - | value="{% if let Some(w) = wine %}{{ w.body }}{% else %}3{% endif %}"> |
|
| 95 | - | <span class="score-value" data-for="body">{% if let Some(w) = wine %}{{ w.body }}{% else %}3{% endif %}</span> |
|
| 79 | + | <input type="range" id="body" name="body" min="1" max="5" value="{{if $w}}{{$w.Body}}{{else}}3{{end}}"> |
|
| 80 | + | <span class="score-value" data-for="body">{{if $w}}{{$w.Body}}{{else}}3{{end}}</span> |
|
| 96 | 81 | </div> |
|
| 97 | 82 | </div> |
|
| 98 | 83 | ||
| 99 | - | <button type="submit">{% if wine.is_some() %}update{% else %}create{% endif %}</button> |
|
| 84 | + | <button type="submit">{{if $w}}update{{else}}create{{end}}</button> |
|
| 100 | 85 | </form> |
|
| 101 | 86 | ||
| 102 | 87 | <script> |
|
| 107 | 92 | }); |
|
| 108 | 93 | }); |
|
| 109 | 94 | ||
| 110 | - | {% if has_anthropic_key %} |
|
| 95 | + | {{if .HasAnthropicKey}} |
|
| 111 | 96 | async function analyzeImage() { |
|
| 112 | 97 | var fileInput = document.getElementById('image'); |
|
| 113 | 98 | if (!fileInput.files.length) return; |
|
| 125 | 110 | if (data.grape) document.getElementById('grape').value = data.grape; |
|
| 126 | 111 | if (data.background) document.getElementById('background').value = data.background; |
|
| 127 | 112 | } |
|
| 128 | - | } catch (e) { |
|
| 129 | - | console.error('Analysis failed:', e); |
|
| 130 | - | } finally { |
|
| 131 | - | btn.textContent = 'analyze'; |
|
| 132 | - | btn.disabled = false; |
|
| 133 | - | } |
|
| 113 | + | } catch (e) { console.error('Analysis failed:', e); } |
|
| 114 | + | finally { btn.textContent = 'analyze'; btn.disabled = false; } |
|
| 134 | 115 | } |
|
| 135 | - | {% endif %} |
|
| 116 | + | {{end}} |
|
| 136 | 117 | </script> |
|
| 137 | - | {% endblock %} |
|
| 118 | + | {{end}} |
|
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Wishlist - Cellar{% endblock %} |
|
| 3 | - | {% block nav %} |
|
| 4 | - | <nav class="links"> |
|
| 5 | - | <a href="/admin/wishlist/new">new</a> |
|
| 6 | - | <a href="/">cellar</a> |
|
| 7 | - | </nav> |
|
| 8 | - | {% endblock %} |
|
| 9 | - | {% block content %} |
|
| 10 | - | {% if wines.is_empty() %} |
|
| 11 | - | <p class="empty">wishlist empty</p> |
|
| 12 | - | {% endif %} |
|
| 1 | + | {{define "wishlist.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Wishlist - Cellar{{end}} |
|
| 3 | + | {{define "nav"}} |
|
| 4 | + | <nav class="links"> |
|
| 5 | + | <a href="/admin/wishlist/new">new</a> |
|
| 6 | + | <a href="/">cellar</a> |
|
| 7 | + | </nav> |
|
| 8 | + | {{end}} |
|
| 9 | + | {{define "content"}} |
|
| 10 | + | {{if not .Wines}}<p class="empty">wishlist empty</p>{{end}} |
|
| 13 | 11 | <div class="admin-list"> |
|
| 14 | - | {% for wine in wines %} |
|
| 12 | + | {{$isAdmin := .IsAdmin}} |
|
| 13 | + | {{range .Wines}} |
|
| 15 | 14 | <div class="admin-item"> |
|
| 16 | 15 | <div class="admin-item-info"> |
|
| 17 | - | <a href="/wines/{{ wine.short_id }}" class="admin-item-name">{{ wine.name }}</a> |
|
| 18 | - | <span class="admin-item-meta">{{ wine.origin }}{% if !wine.grape.is_empty() %} · {{ wine.grape }}{% endif %}</span> |
|
| 16 | + | <a href="/wines/{{.ShortID}}" class="admin-item-name">{{.Name}}</a> |
|
| 17 | + | <span class="admin-item-meta">{{.Origin}}{{if .Grape}} · {{.Grape}}{{end}}</span> |
|
| 19 | 18 | </div> |
|
| 20 | - | {% if is_admin %} |
|
| 19 | + | {{if $isAdmin}} |
|
| 21 | 20 | <div class="admin-actions"> |
|
| 22 | - | <a href="/admin/wishlist/edit/{{ wine.short_id }}">edit</a> |
|
| 23 | - | <form method="POST" action="/admin/wishlist/promote/{{ wine.short_id }}" class="inline-form"> |
|
| 21 | + | <a href="/admin/wishlist/edit/{{.ShortID}}">edit</a> |
|
| 22 | + | <form method="POST" action="/admin/wishlist/promote/{{.ShortID}}" class="inline-form"> |
|
| 24 | 23 | <button type="submit" class="link-button">promote</button> |
|
| 25 | 24 | </form> |
|
| 26 | - | <form method="POST" action="/admin/wishlist/delete/{{ wine.short_id }}" class="inline-form" onsubmit="return confirm('delete this wine?')"> |
|
| 25 | + | <form method="POST" action="/admin/wishlist/delete/{{.ShortID}}" class="inline-form" onsubmit="return confirm('delete this wine?')"> |
|
| 27 | 26 | <button type="submit" class="link-button">delete</button> |
|
| 28 | 27 | </form> |
|
| 29 | 28 | </div> |
|
| 30 | - | {% endif %} |
|
| 29 | + | {{end}} |
|
| 31 | 30 | </div> |
|
| 32 | - | {% endfor %} |
|
| 31 | + | {{end}} |
|
| 33 | 32 | </div> |
|
| 34 | - | {% endblock %} |
|
| 33 | + | {{end}} |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}{% if wine.is_some() %}Edit{% else %}New{% endif %} Wishlist Wine - Cellar{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | {% if let Some(error) = error %} |
|
| 5 | - | <p class="error">{{ error }}</p> |
|
| 6 | - | {% endif %} |
|
| 1 | + | {{define "wishlist_form.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{if .Wine}}Edit{{else}}New{{end}} Wishlist Wine - Cellar{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 5 | + | {{$w := .Wine}} |
|
| 7 | 6 | <form method="POST" enctype="multipart/form-data" |
|
| 8 | - | action="{% if let Some(w) = wine %}/admin/wishlist/edit/{{ w.short_id }}{% else %}/admin/wishlist/new{% endif %}" |
|
| 7 | + | action="{{if $w}}/admin/wishlist/edit/{{$w.ShortID}}{{else}}/admin/wishlist/new{{end}}" |
|
| 9 | 8 | class="form"> |
|
| 10 | 9 | ||
| 11 | 10 | <label for="image">image</label> |
|
| 12 | 11 | <div class="image-upload-row"> |
|
| 13 | 12 | <input type="file" id="image" name="image" accept="image/*"> |
|
| 14 | - | {% if has_anthropic_key %} |
|
| 15 | - | <button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button> |
|
| 16 | - | {% endif %} |
|
| 13 | + | {{if .HasAnthropicKey}}<button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button>{{end}} |
|
| 17 | 14 | </div> |
|
| 18 | 15 | ||
| 19 | 16 | <label for="name">name</label> |
|
| 20 | - | <input type="text" id="name" name="name" required |
|
| 21 | - | value="{% if let Some(w) = wine %}{{ w.name }}{% endif %}"> |
|
| 17 | + | <input type="text" id="name" name="name" required value="{{if $w}}{{$w.Name}}{{end}}"> |
|
| 22 | 18 | ||
| 23 | 19 | <label for="origin">origin</label> |
|
| 24 | - | <input type="text" id="origin" name="origin" |
|
| 25 | - | value="{% if let Some(w) = wine %}{{ w.origin }}{% endif %}"> |
|
| 20 | + | <input type="text" id="origin" name="origin" value="{{if $w}}{{$w.Origin}}{{end}}"> |
|
| 26 | 21 | ||
| 27 | 22 | <label for="grape">grape</label> |
|
| 28 | - | <input type="text" id="grape" name="grape" |
|
| 29 | - | value="{% if let Some(w) = wine %}{{ w.grape }}{% endif %}"> |
|
| 23 | + | <input type="text" id="grape" name="grape" value="{{if $w}}{{$w.Grape}}{{end}}"> |
|
| 30 | 24 | ||
| 31 | 25 | <label for="notes">notes</label> |
|
| 32 | - | <textarea id="notes" name="notes" rows="5">{% if let Some(w) = wine %}{{ w.notes }}{% endif %}</textarea> |
|
| 26 | + | <textarea id="notes" name="notes" rows="5">{{if $w}}{{$w.Notes}}{{end}}</textarea> |
|
| 33 | 27 | ||
| 34 | 28 | <label for="background">background</label> |
|
| 35 | - | <textarea id="background" name="background" rows="5">{% if let Some(w) = wine %}{{ w.background }}{% endif %}</textarea> |
|
| 29 | + | <textarea id="background" name="background" rows="5">{{if $w}}{{$w.Background}}{{end}}</textarea> |
|
| 36 | 30 | ||
| 37 | - | <button type="submit">{% if wine.is_some() %}update{% else %}create{% endif %}</button> |
|
| 31 | + | <button type="submit">{{if $w}}update{{else}}create{{end}}</button> |
|
| 38 | 32 | </form> |
|
| 39 | 33 | ||
| 40 | 34 | <script> |
|
| 41 | - | {% if has_anthropic_key %} |
|
| 35 | + | {{if .HasAnthropicKey}} |
|
| 42 | 36 | async function analyzeImage() { |
|
| 43 | 37 | var fileInput = document.getElementById('image'); |
|
| 44 | 38 | if (!fileInput.files.length) return; |
|
| 56 | 50 | if (data.grape) document.getElementById('grape').value = data.grape; |
|
| 57 | 51 | if (data.background) document.getElementById('background').value = data.background; |
|
| 58 | 52 | } |
|
| 59 | - | } catch (e) { |
|
| 60 | - | console.error('Analysis failed:', e); |
|
| 61 | - | } finally { |
|
| 62 | - | btn.textContent = 'analyze'; |
|
| 63 | - | btn.disabled = false; |
|
| 64 | - | } |
|
| 53 | + | } catch (e) { console.error('Analysis failed:', e); } |
|
| 54 | + | finally { btn.textContent = 'analyze'; btn.disabled = false; } |
|
| 65 | 55 | } |
|
| 66 | - | {% endif %} |
|
| 56 | + | {{end}} |
|
| 67 | 57 | </script> |
|
| 68 | - | {% endblock %} |
|
| 58 | + | {{end}} |
|
| 1 | - | HOST=127.0.0.1 |
|
| 2 | - | PORT=4242 |
|
| 3 | - | EASEL_DB_PATH=easel.sqlite |
|
| 4 | - | EASEL_TIMEZONE=UTC |
|
| 5 | - | EASEL_CLASSIFICATIONS=painting |
|
| 6 | - | EASEL_EXCLUDE_TERMS=erotic,erotica,shunga |
|
| 7 | - | EASEL_BACKFILL_DAYS=0 |
|
| 8 | - | EASEL_MAX_DEDUP_RETRIES=10 |
|
| 9 | - | EASEL_BASE_URL=http://localhost:4242 |
| 1 | - | # Build from repo root: docker build -t easel-go -f apps/easel-go/Dockerfile . |
|
| 2 | - | FROM golang:1.24-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/easel-go/go.mod apps/easel-go/go.sum ./apps/easel-go/ |
|
| 6 | - | WORKDIR /app/apps/easel-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/easel-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /easel-go . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | RUN apt-get update && apt-get install -y ca-certificates tzdata && rm -rf /var/lib/apt/lists/* |
|
| 13 | - | COPY --from=builder /easel-go /usr/local/bin/easel-go |
|
| 14 | - | WORKDIR /data |
|
| 15 | - | ENV HOST=0.0.0.0 |
|
| 16 | - | ENV PORT=4242 |
|
| 17 | - | EXPOSE 4242 |
|
| 18 | - | CMD ["easel-go"] |
| 1 | - | # easel-go |
|
| 2 | - | ||
| 3 | - | Go rewrite of [easel](../easel). A daily painting from the Art Institute of |
|
| 4 | - | Chicago, persisted to SQLite. Past days browsable; future days unavailable. |
|
| 5 | - | ||
| 6 | - | ## Routes |
|
| 7 | - | ||
| 8 | - | - `GET /` — today's artwork |
|
| 9 | - | - `GET /day/{YYYY-MM-DD}` — specific past day |
|
| 10 | - | - `GET /archive` — full archive |
|
| 11 | - | - `GET /api/today` / `GET /api/day/{date}` / `GET /api/archive` — JSON |
|
| 12 | - | - `GET /feed.xml` — Atom feed |
|
| 13 | - | ||
| 14 | - | ## Env |
|
| 15 | - | ||
| 16 | - | See `.env.example`. Notes: timezone uses Go's `time.LoadLocation`, which needs |
|
| 17 | - | the system tzdata (Debian slim base in the Dockerfile pulls `tzdata`). |
| 99 | 99 | if err != nil { |
|
| 100 | 100 | return 0, err |
|
| 101 | 101 | } |
|
| 102 | - | req.Header.Set("User-Agent", "andromeda-easel-go/0.1 (+https://github.com/stevedylandev/andromeda)") |
|
| 102 | + | req.Header.Set("User-Agent", "andromeda-easel/0.1 (+https://github.com/stevedylandev/andromeda)") |
|
| 103 | 103 | resp, err := client.Do(req) |
|
| 104 | 104 | if err != nil { |
|
| 105 | 105 | return 0, fmt.Errorf("count fetch failed: %w", err) |
|
| 123 | 123 | if err != nil { |
|
| 124 | 124 | return nil, err |
|
| 125 | 125 | } |
|
| 126 | - | req.Header.Set("User-Agent", "andromeda-easel-go/0.1") |
|
| 126 | + | req.Header.Set("User-Agent", "andromeda-easel/0.1") |
|
| 127 | 127 | resp, err := client.Do(req) |
|
| 128 | 128 | if err != nil { |
|
| 129 | 129 | return nil, fmt.Errorf("artwork fetch failed: %w", err) |
|
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/easel-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-4242}:${PORT:-4242}" |
|
| 8 | - | environment: |
|
| 9 | - | - HOST=0.0.0.0 |
|
| 10 | - | - PORT=${PORT:-4242} |
|
| 11 | - | - EASEL_DB_PATH=/data/easel-go.sqlite |
|
| 12 | - | - EASEL_TIMEZONE=${EASEL_TIMEZONE:-UTC} |
|
| 13 | - | - EASEL_CLASSIFICATIONS=${EASEL_CLASSIFICATIONS:-painting} |
|
| 14 | - | - EASEL_EXCLUDE_TERMS=${EASEL_EXCLUDE_TERMS:-erotic,erotica,shunga} |
|
| 15 | - | - EASEL_BACKFILL_DAYS=${EASEL_BACKFILL_DAYS:-0} |
|
| 16 | - | - EASEL_MAX_DEDUP_RETRIES=${EASEL_MAX_DEDUP_RETRIES:-10} |
|
| 17 | - | - EASEL_BASE_URL=${EASEL_BASE_URL:-http://localhost:4242} |
|
| 18 | - | volumes: |
|
| 19 | - | - easel-go-data:/data |
|
| 20 | - | restart: unless-stopped |
|
| 21 | - | ||
| 22 | - | volumes: |
|
| 23 | - | easel-go-data: |
| 1 | - | module github.com/stevedylandev/andromeda/apps/easel-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/easel |
|
| 2 | 2 | ||
| 3 | 3 | go 1.24.4 |
|
| 4 | 4 |
| 65 | 65 | MaxDedupRetries: config.GetenvInt("EASEL_MAX_DEDUP_RETRIES", 10), |
|
| 66 | 66 | BaseURL: strings.TrimRight(config.Getenv("EASEL_BASE_URL", "http://localhost:4242"), "/"), |
|
| 67 | 67 | } |
|
| 68 | - | logger.Info("easel-go starting", "tz", tzName, "classifications", classifications, "exclude_terms", excludeTerms, "backfill_days", app.BackfillDays, "retries", app.MaxDedupRetries) |
|
| 68 | + | logger.Info("easel starting", "tz", tzName, "classifications", classifications, "exclude_terms", excludeTerms, "backfill_days", app.BackfillDays, "retries", app.MaxDedupRetries) |
|
| 69 | 69 | ||
| 70 | 70 | go app.runScheduler(context.Background()) |
|
| 71 | 71 | ||
| 72 | 72 | addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "4242") |
|
| 73 | - | logger.Info("easel-go server running", "addr", addr) |
|
| 73 | + | logger.Info("easel server running", "addr", addr) |
|
| 74 | 74 | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 75 | 75 | log.Fatal(err) |
|
| 76 | 76 | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | .artwork-figure { |
|
| 2 | - | margin: 0 0 1rem; |
|
| 3 | - | border: 1px solid #333; |
|
| 4 | - | } |
|
| 5 | - | ||
| 6 | - | .artwork-figure img { |
|
| 7 | - | display: block; |
|
| 8 | - | width: 100%; |
|
| 9 | - | height: auto; |
|
| 10 | - | } |
|
| 11 | - | ||
| 12 | - | .artwork-meta { |
|
| 13 | - | margin-bottom: 0.5rem; |
|
| 14 | - | } |
|
| 15 | - | ||
| 16 | - | .artwork-date { |
|
| 17 | - | opacity: 0.5; |
|
| 18 | - | font-size: 12px; |
|
| 19 | - | } |
|
| 20 | - | ||
| 21 | - | .artwork-title { |
|
| 22 | - | font-size: 16px; |
|
| 23 | - | font-weight: 700; |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | .artwork-artist { |
|
| 27 | - | opacity: 0.7; |
|
| 28 | - | } |
|
| 29 | - | ||
| 30 | - | .artwork-details { |
|
| 31 | - | display: grid; |
|
| 32 | - | grid-template-columns: max-content 1fr; |
|
| 33 | - | gap: 0.25rem 1rem; |
|
| 34 | - | margin: 1rem 0; |
|
| 35 | - | font-size: 13px; |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | .artwork-details dt { |
|
| 39 | - | opacity: 0.5; |
|
| 40 | - | } |
|
| 41 | - | ||
| 42 | - | .artwork-description { |
|
| 43 | - | opacity: 0.85; |
|
| 44 | - | font-size: 13px; |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | .artwork-description p + p { |
|
| 48 | - | margin-top: 0.75rem; |
|
| 49 | - | } |
|
| 50 | - | ||
| 51 | - | .archive-list { |
|
| 52 | - | margin-top: 2rem; |
|
| 53 | - | border-top: 1px solid #333; |
|
| 54 | - | padding-top: 1rem; |
|
| 55 | - | width: 100%; |
|
| 56 | - | } |
|
| 57 | - | ||
| 58 | - | .archive-list h3 { |
|
| 59 | - | font-size: 12px; |
|
| 60 | - | opacity: 0.5; |
|
| 61 | - | text-transform: uppercase; |
|
| 62 | - | letter-spacing: 0.05em; |
|
| 63 | - | margin-bottom: 0.5rem; |
|
| 64 | - | } |
|
| 65 | - | ||
| 66 | - | .item a { |
|
| 67 | - | display: grid; |
|
| 68 | - | grid-template-columns: 90px 1fr auto; |
|
| 69 | - | gap: 0.75rem; |
|
| 70 | - | text-decoration: none; |
|
| 71 | - | color: inherit; |
|
| 72 | - | } |
|
| 73 | - | ||
| 74 | - | .item-title { |
|
| 75 | - | overflow: hidden; |
|
| 76 | - | text-overflow: ellipsis; |
|
| 77 | - | white-space: nowrap; |
|
| 78 | - | } |
| 1 | - | {{define "archive.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Easel — Archive{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <h2>Archive</h2> |
|
| 5 | - | {{if not .Archive}} |
|
| 6 | - | <p class="empty">No artworks stored yet.</p> |
|
| 7 | - | {{else}} |
|
| 8 | - | <ul class="item-list"> |
|
| 9 | - | {{range .Archive}} |
|
| 10 | - | <li class="item"> |
|
| 11 | - | <a href="/day/{{.Date}}"> |
|
| 12 | - | <span class="item-meta">{{.Date}}</span> |
|
| 13 | - | <span class="item-title"><em>{{.Title}}</em></span> |
|
| 14 | - | {{if .Artist}}<span class="item-meta">{{.Artist}}</span>{{end}} |
|
| 15 | - | </a> |
|
| 16 | - | </li> |
|
| 17 | - | {{end}} |
|
| 18 | - | </ul> |
|
| 19 | - | {{end}} |
|
| 20 | - | {{end}} |
| 1 | - | {{define "base.html"}}<!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <title>{{block "title" .}}Easel{{end}}</title> |
|
| 7 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | - | <meta property="og:title" content="Easel"> |
|
| 13 | - | <meta property="og:description" content="A daily painting from the Art Institute of Chicago"> |
|
| 14 | - | <meta property="og:image" content="/static/og.png"> |
|
| 15 | - | <meta property="og:type" content="website"> |
|
| 16 | - | <meta name="theme-color" content="#121113" /> |
|
| 17 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 18 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 19 | - | <meta name="description" content="A daily painting from the Art Institute of Chicago" /> |
|
| 20 | - | <link rel="alternate" type="application/atom+xml" title="Easel — Daily Artwork" href="/feed.xml" /> |
|
| 21 | - | </head> |
|
| 22 | - | <body> |
|
| 23 | - | <header class="header"> |
|
| 24 | - | <a href="/" class="logo">EASEL</a> |
|
| 25 | - | <nav class="links"> |
|
| 26 | - | <a href="/">today</a> |
|
| 27 | - | <a href="/archive">archive</a> |
|
| 28 | - | <a href="/feed.xml">rss</a> |
|
| 29 | - | </nav> |
|
| 30 | - | </header> |
|
| 31 | - | <main> |
|
| 32 | - | {{block "content" .}}{{end}} |
|
| 33 | - | </main> |
|
| 34 | - | </body> |
|
| 35 | - | </html>{{end}} |
|
| 36 | - | ||
| 37 | - | {{define "artwork"}} |
|
| 38 | - | <article class="artwork"> |
|
| 39 | - | <figure class="artwork-figure"> |
|
| 40 | - | <img src="{{.ImageURL}}" alt="{{.Title}}" loading="lazy" /> |
|
| 41 | - | </figure> |
|
| 42 | - | <header class="artwork-meta"> |
|
| 43 | - | <p class="artwork-date">{{.Date}}</p> |
|
| 44 | - | <h2 class="artwork-title"> |
|
| 45 | - | <a href="{{.SourceURL}}" target="_blank" rel="noopener noreferrer"><em>{{.Title}}</em></a> |
|
| 46 | - | </h2> |
|
| 47 | - | {{if .ArtistDisplay}}<p class="artwork-artist">{{.ArtistDisplay}}</p>{{end}} |
|
| 48 | - | </header> |
|
| 49 | - | <dl class="artwork-details"> |
|
| 50 | - | {{if .DateDisplay}}<dt>Date</dt><dd>{{.DateDisplay}}</dd>{{end}} |
|
| 51 | - | {{if .PlaceOfOrigin}}<dt>Origin</dt><dd>{{.PlaceOfOrigin}}</dd>{{end}} |
|
| 52 | - | {{if .MediumDisplay}}<dt>Medium</dt><dd>{{.MediumDisplay}}</dd>{{end}} |
|
| 53 | - | {{if .Dimensions}}<dt>Dimensions</dt><dd>{{.Dimensions}}</dd>{{end}} |
|
| 54 | - | {{if .CreditLine}}<dt>Credit</dt><dd>{{.CreditLine}}</dd>{{end}} |
|
| 55 | - | </dl> |
|
| 56 | - | {{if .Description}}<div class="artwork-description">{{.DescriptionHTML}}</div> |
|
| 57 | - | {{else if .ShortDescription}}<p class="artwork-description">{{.ShortDescription}}</p> |
|
| 58 | - | {{end}} |
|
| 59 | - | </article> |
|
| 60 | - | {{end}} |
| 1 | - | {{define "day.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Easel — {{.Date}}{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | {{template "artwork" .Artwork}} |
|
| 5 | - | {{end}} |
| 1 | - | {{define "error.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Easel — {{.Title}}{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <div class="error-page"> |
|
| 5 | - | <h2>{{.Title}}</h2> |
|
| 6 | - | <p>{{.Message}}</p> |
|
| 7 | - | <p><a href="/">← back to today</a></p> |
|
| 8 | - | </div> |
|
| 9 | - | {{end}} |
| 1 | - | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Easel — {{.TodayDate}}{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | {{if .Artwork}} |
|
| 5 | - | {{template "artwork" .Artwork}} |
|
| 6 | - | {{else}} |
|
| 7 | - | <div class="empty"> |
|
| 8 | - | <p>Today's artwork ({{.TodayDate}}) is not yet available. Check back shortly.</p> |
|
| 9 | - | </div> |
|
| 10 | - | {{end}} |
|
| 11 | - | {{end}} |
| 1 | - | # Bind / port |
|
| 2 | 1 | HOST=127.0.0.1 |
|
| 3 | 2 | PORT=4242 |
|
| 4 | - | ||
| 5 | - | # SQLite file path |
|
| 6 | 3 | EASEL_DB_PATH=easel.sqlite |
|
| 7 | - | ||
| 8 | - | # IANA timezone for day boundary (e.g. America/Chicago, Europe/London) |
|
| 9 | 4 | EASEL_TIMEZONE=UTC |
|
| 10 | - | ||
| 11 | - | # Comma-separated AIC classification_title filters (lowercased, e.g. painting,drawing,print) |
|
| 12 | 5 | EASEL_CLASSIFICATIONS=painting |
|
| 13 | - | ||
| 14 | - | # Phrases excluded via must_not match across title/description/term/subject/category/classification. |
|
| 15 | - | # Comma-separated. Set empty to disable filtering. |
|
| 16 | 6 | EASEL_EXCLUDE_TERMS=erotic,erotica,shunga |
|
| 17 | - | ||
| 18 | - | # On startup, fill any missing day in the last N days. 0 disables backfill. |
|
| 19 | 7 | EASEL_BACKFILL_DAYS=0 |
|
| 20 | - | ||
| 21 | - | # Max retries when picking a non-duplicate artwork |
|
| 22 | 8 | EASEL_MAX_DEDUP_RETRIES=10 |
|
| 23 | - | ||
| 24 | - | # Public base URL (used for absolute links in /feed.xml) |
|
| 25 | 9 | EASEL_BASE_URL=http://localhost:4242 |
| 1 | - | [package] |
|
| 2 | - | name = "easel" |
|
| 3 | - | version = "0.1.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "A daily painting from the Art Institute of Chicago" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | axum = { workspace = true } |
|
| 12 | - | tokio = { workspace = true } |
|
| 13 | - | serde = { workspace = true } |
|
| 14 | - | serde_json = { workspace = true } |
|
| 15 | - | dotenvy = { workspace = true } |
|
| 16 | - | rust-embed = { workspace = true } |
|
| 17 | - | rusqlite = { workspace = true } |
|
| 18 | - | rand = { workspace = true } |
|
| 19 | - | tracing = { workspace = true } |
|
| 20 | - | tracing-subscriber = { workspace = true, features = ["env-filter"] } |
|
| 21 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 22 | - | askama = "0.13" |
|
| 23 | - | reqwest = { version = "0.12", features = ["json"] } |
|
| 24 | - | chrono = "0.4" |
|
| 25 | - | chrono-tz = "0.10" |
|
| 26 | - | urlencoding = "2" |
|
| 27 | - | mime_guess = "2" |
|
| 28 | - | tower-http = { workspace = true, features = ["cors"] } |
| 1 | 1 | # Build from repo root: docker build -t easel -f apps/easel/Dockerfile . |
|
| 2 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 2 | + | FROM golang:1.24-bookworm AS builder |
|
| 3 | 3 | WORKDIR /app |
|
| 4 | - | ||
| 5 | - | FROM chef AS planner |
|
| 6 | - | COPY . . |
|
| 7 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 8 | - | ||
| 9 | - | FROM chef AS builder |
|
| 10 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 11 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 12 | - | RUN cargo chef cook --release --recipe-path recipe.json -p easel |
|
| 13 | - | COPY . . |
|
| 14 | - | RUN cargo build --release -p easel |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/easel/go.mod apps/easel/go.sum ./apps/easel/ |
|
| 6 | + | WORKDIR /app/apps/easel |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/easel/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /easel . |
|
| 15 | 10 | ||
| 16 | 11 | FROM debian:bookworm-slim |
|
| 17 | - | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 18 | - | COPY --from=builder /app/target/release/easel /usr/local/bin/easel |
|
| 12 | + | RUN apt-get update && apt-get install -y ca-certificates tzdata && rm -rf /var/lib/apt/lists/* |
|
| 13 | + | COPY --from=builder /easel /usr/local/bin/easel |
|
| 19 | 14 | WORKDIR /data |
|
| 20 | - | EXPOSE 3000 |
|
| 21 | 15 | ENV HOST=0.0.0.0 |
|
| 22 | - | ENV PORT=3000 |
|
| 23 | - | ENV EASEL_DB_PATH=/data/easel.sqlite |
|
| 16 | + | ENV PORT=4242 |
|
| 17 | + | EXPOSE 4242 |
|
| 24 | 18 | CMD ["easel"] |
| 1 | - | # Easel |
|
| 2 | - | ||
| 3 | - | A daily painting from the [Art Institute of Chicago](https://api.artic.edu/docs/). One public-domain artwork per calendar day, persisted to SQLite. Past days browsable; future days unavailable until populated. |
|
| 4 | - | ||
| 5 | - | ## Run locally |
|
| 6 | - | ||
| 7 | - | ```bash |
|
| 8 | - | cargo run -p easel |
|
| 9 | - | ``` |
|
| 10 | - | ||
| 11 | - | Visit `http://localhost:4242`. |
|
| 12 | - | ||
| 13 | - | ## Configuration |
|
| 1 | + | # easel-go |
|
| 14 | 2 | ||
| 15 | - | | Var | Default | Purpose | |
|
| 16 | - | |---|---|---| |
|
| 17 | - | | `HOST` | `127.0.0.1` | Bind address | |
|
| 18 | - | | `PORT` | `4242` | Listen port | |
|
| 19 | - | | `EASEL_DB_PATH` | `easel.sqlite` | SQLite file | |
|
| 20 | - | | `EASEL_TIMEZONE` | `UTC` | IANA TZ for day boundary | |
|
| 21 | - | | `EASEL_CLASSIFICATIONS` | `painting` | Comma-separated `classification_title` filter | |
|
| 22 | - | | `EASEL_BACKFILL_DAYS` | `0` | On boot, fill missing past N days | |
|
| 23 | - | | `EASEL_MAX_DEDUP_RETRIES` | `10` | Retries when picking a non-duplicate page | |
|
| 3 | + | Go rewrite of [easel](../easel). A daily painting from the Art Institute of |
|
| 4 | + | Chicago, persisted to SQLite. Past days browsable; future days unavailable. |
|
| 24 | 5 | ||
| 25 | 6 | ## Routes |
|
| 26 | 7 | ||
| 27 | 8 | - `GET /` — today's artwork |
|
| 28 | 9 | - `GET /day/{YYYY-MM-DD}` — specific past day |
|
| 29 | 10 | - `GET /archive` — full archive |
|
| 30 | - | - `GET /api/today` — JSON of today |
|
| 31 | - | - `GET /api/day/{YYYY-MM-DD}` — JSON of specific day |
|
| 32 | - | - `GET /api/archive` — JSON list |
|
| 11 | + | - `GET /api/today` / `GET /api/day/{date}` / `GET /api/archive` — JSON |
|
| 12 | + | - `GET /feed.xml` — Atom feed |
|
| 33 | 13 | ||
| 34 | - | ## Image source |
|
| 14 | + | ## Env |
|
| 35 | 15 | ||
| 36 | - | Images served from AIC's IIIF endpoint: |
|
| 37 | - | `https://www.artic.edu/iiif/2/{image_id}/full/843,/0/default.jpg` |
|
| 16 | + | See `.env.example`. Notes: timezone uses Go's `time.LoadLocation`, which needs |
|
| 17 | + | the system tzdata (Debian slim base in the Dockerfile pulls `tzdata`). |
| 2 | 2 | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/easel/Dockerfile |
|
| 5 | + | dockerfile: apps/easel-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | - | - "${PORT:-4242}:3000" |
|
| 7 | + | - "${PORT:-4242}:${PORT:-4242}" |
|
| 8 | 8 | environment: |
|
| 9 | - | - EASEL_DB_PATH=/data/easel.sqlite |
|
| 9 | + | - HOST=0.0.0.0 |
|
| 10 | + | - PORT=${PORT:-4242} |
|
| 11 | + | - EASEL_DB_PATH=/data/easel-go.sqlite |
|
| 10 | 12 | - EASEL_TIMEZONE=${EASEL_TIMEZONE:-UTC} |
|
| 11 | 13 | - EASEL_CLASSIFICATIONS=${EASEL_CLASSIFICATIONS:-painting} |
|
| 14 | + | - EASEL_EXCLUDE_TERMS=${EASEL_EXCLUDE_TERMS:-erotic,erotica,shunga} |
|
| 12 | 15 | - EASEL_BACKFILL_DAYS=${EASEL_BACKFILL_DAYS:-0} |
|
| 13 | 16 | - EASEL_MAX_DEDUP_RETRIES=${EASEL_MAX_DEDUP_RETRIES:-10} |
|
| 14 | - | - HOST=0.0.0.0 |
|
| 15 | - | - PORT=3000 |
|
| 17 | + | - EASEL_BASE_URL=${EASEL_BASE_URL:-http://localhost:4242} |
|
| 16 | 18 | volumes: |
|
| 17 | - | - easel-data:/data |
|
| 19 | + | - easel-go-data:/data |
|
| 18 | 20 | restart: unless-stopped |
|
| 19 | 21 | ||
| 20 | 22 | volumes: |
|
| 21 | - | easel-data: |
|
| 23 | + | easel-go-data: |
| 1 | - | use rand::Rng; |
|
| 2 | - | use serde::Deserialize; |
|
| 3 | - | use std::time::Duration; |
|
| 4 | - | ||
| 5 | - | use crate::db::{self, DailyArtwork, Db}; |
|
| 6 | - | ||
| 7 | - | const SEARCH_URL: &str = "https://api.artic.edu/api/v1/artworks/search"; |
|
| 8 | - | const FIELDS: &str = "id,title,artist_display,artist_title,date_display,medium_display,dimensions,place_of_origin,credit_line,description,short_description,image_id"; |
|
| 9 | - | ||
| 10 | - | pub fn build_client() -> reqwest::Client { |
|
| 11 | - | reqwest::Client::builder() |
|
| 12 | - | .timeout(Duration::from_secs(20)) |
|
| 13 | - | .user_agent("andromeda-easel/0.1 (+https://github.com/stevedylandev/andromeda)") |
|
| 14 | - | .build() |
|
| 15 | - | .expect("Failed to build HTTP client") |
|
| 16 | - | } |
|
| 17 | - | ||
| 18 | - | #[derive(Debug, Deserialize)] |
|
| 19 | - | pub struct RawArtwork { |
|
| 20 | - | pub id: i64, |
|
| 21 | - | pub title: Option<String>, |
|
| 22 | - | pub artist_display: Option<String>, |
|
| 23 | - | pub artist_title: Option<String>, |
|
| 24 | - | pub date_display: Option<String>, |
|
| 25 | - | pub medium_display: Option<String>, |
|
| 26 | - | pub dimensions: Option<String>, |
|
| 27 | - | pub place_of_origin: Option<String>, |
|
| 28 | - | pub credit_line: Option<String>, |
|
| 29 | - | pub description: Option<String>, |
|
| 30 | - | pub short_description: Option<String>, |
|
| 31 | - | pub image_id: Option<String>, |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | #[derive(Debug, Deserialize)] |
|
| 35 | - | struct Pagination { |
|
| 36 | - | total: u64, |
|
| 37 | - | } |
|
| 38 | - | ||
| 39 | - | #[derive(Debug, Deserialize)] |
|
| 40 | - | struct SearchResponse<T> { |
|
| 41 | - | pagination: Pagination, |
|
| 42 | - | data: Vec<T>, |
|
| 43 | - | } |
|
| 44 | - | ||
| 45 | - | #[derive(Debug, Deserialize)] |
|
| 46 | - | struct IdOnly { |
|
| 47 | - | #[allow(dead_code)] |
|
| 48 | - | id: i64, |
|
| 49 | - | } |
|
| 50 | - | ||
| 51 | - | const EXCLUDE_FIELDS: &[&str] = &[ |
|
| 52 | - | "title", |
|
| 53 | - | "description", |
|
| 54 | - | "short_description", |
|
| 55 | - | "term_titles", |
|
| 56 | - | "subject_titles", |
|
| 57 | - | "category_titles", |
|
| 58 | - | "classification_titles", |
|
| 59 | - | ]; |
|
| 60 | - | ||
| 61 | - | fn build_params(classifications: &[String], exclude_terms: &[String]) -> String { |
|
| 62 | - | let terms: Vec<serde_json::Value> = classifications |
|
| 63 | - | .iter() |
|
| 64 | - | .map(|c| serde_json::Value::String(c.to_lowercase())) |
|
| 65 | - | .collect(); |
|
| 66 | - | let must_not: Vec<serde_json::Value> = exclude_terms |
|
| 67 | - | .iter() |
|
| 68 | - | .map(|t| { |
|
| 69 | - | serde_json::json!({ |
|
| 70 | - | "multi_match": { |
|
| 71 | - | "query": t, |
|
| 72 | - | "fields": EXCLUDE_FIELDS, |
|
| 73 | - | "type": "phrase" |
|
| 74 | - | } |
|
| 75 | - | }) |
|
| 76 | - | }) |
|
| 77 | - | .collect(); |
|
| 78 | - | let body = serde_json::json!({ |
|
| 79 | - | "query": { |
|
| 80 | - | "bool": { |
|
| 81 | - | "must": [ |
|
| 82 | - | { "term": { "is_public_domain": true } }, |
|
| 83 | - | { "terms": { "classification_title.keyword": terms } }, |
|
| 84 | - | { "exists": { "field": "image_id" } } |
|
| 85 | - | ], |
|
| 86 | - | "must_not": must_not |
|
| 87 | - | } |
|
| 88 | - | } |
|
| 89 | - | }); |
|
| 90 | - | body.to_string() |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | pub async fn total_matching( |
|
| 94 | - | client: &reqwest::Client, |
|
| 95 | - | classifications: &[String], |
|
| 96 | - | exclude_terms: &[String], |
|
| 97 | - | ) -> Result<u64, String> { |
|
| 98 | - | let params = build_params(classifications, exclude_terms); |
|
| 99 | - | let url = format!( |
|
| 100 | - | "{SEARCH_URL}?params={}&limit=1&fields=id", |
|
| 101 | - | urlencoding::encode(¶ms) |
|
| 102 | - | ); |
|
| 103 | - | let resp = client |
|
| 104 | - | .get(&url) |
|
| 105 | - | .send() |
|
| 106 | - | .await |
|
| 107 | - | .map_err(|e| format!("count fetch failed: {e}"))?; |
|
| 108 | - | if !resp.status().is_success() { |
|
| 109 | - | return Err(format!("count returned status {}", resp.status())); |
|
| 110 | - | } |
|
| 111 | - | let body: SearchResponse<IdOnly> = resp |
|
| 112 | - | .json() |
|
| 113 | - | .await |
|
| 114 | - | .map_err(|e| format!("count parse failed: {e}"))?; |
|
| 115 | - | Ok(body.pagination.total) |
|
| 116 | - | } |
|
| 117 | - | ||
| 118 | - | pub async fn fetch_artwork_at( |
|
| 119 | - | client: &reqwest::Client, |
|
| 120 | - | classifications: &[String], |
|
| 121 | - | exclude_terms: &[String], |
|
| 122 | - | page: u64, |
|
| 123 | - | ) -> Result<Option<RawArtwork>, String> { |
|
| 124 | - | let params = build_params(classifications, exclude_terms); |
|
| 125 | - | let url = format!( |
|
| 126 | - | "{SEARCH_URL}?params={}&limit=1&page={page}&fields={FIELDS}", |
|
| 127 | - | urlencoding::encode(¶ms) |
|
| 128 | - | ); |
|
| 129 | - | let resp = client |
|
| 130 | - | .get(&url) |
|
| 131 | - | .send() |
|
| 132 | - | .await |
|
| 133 | - | .map_err(|e| format!("artwork fetch failed: {e}"))?; |
|
| 134 | - | if !resp.status().is_success() { |
|
| 135 | - | return Err(format!("artwork returned status {}", resp.status())); |
|
| 136 | - | } |
|
| 137 | - | let mut body: SearchResponse<RawArtwork> = resp |
|
| 138 | - | .json() |
|
| 139 | - | .await |
|
| 140 | - | .map_err(|e| format!("artwork parse failed: {e}"))?; |
|
| 141 | - | Ok(body.data.pop()) |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | pub async fn pick_unique( |
|
| 145 | - | client: &reqwest::Client, |
|
| 146 | - | db: &Db, |
|
| 147 | - | classifications: &[String], |
|
| 148 | - | exclude_terms: &[String], |
|
| 149 | - | max_retries: u32, |
|
| 150 | - | ) -> Result<RawArtwork, String> { |
|
| 151 | - | let total = total_matching(client, classifications, exclude_terms).await?; |
|
| 152 | - | if total == 0 { |
|
| 153 | - | return Err("AIC search returned zero matches for given classifications".to_string()); |
|
| 154 | - | } |
|
| 155 | - | ||
| 156 | - | for attempt in 0..=max_retries { |
|
| 157 | - | let page = { |
|
| 158 | - | let mut rng = rand::thread_rng(); |
|
| 159 | - | rng.gen_range(1..=total) |
|
| 160 | - | }; |
|
| 161 | - | let art = match fetch_artwork_at(client, classifications, exclude_terms, page).await? { |
|
| 162 | - | Some(a) => a, |
|
| 163 | - | None => continue, |
|
| 164 | - | }; |
|
| 165 | - | if art.image_id.is_none() || art.image_id.as_deref() == Some("") { |
|
| 166 | - | tracing::warn!("artwork {} has no image_id, retrying", art.id); |
|
| 167 | - | continue; |
|
| 168 | - | } |
|
| 169 | - | match db::artwork_id_exists(db, art.id) { |
|
| 170 | - | Ok(true) => { |
|
| 171 | - | tracing::info!( |
|
| 172 | - | "duplicate artwork {} on attempt {}, retrying", |
|
| 173 | - | art.id, |
|
| 174 | - | attempt + 1 |
|
| 175 | - | ); |
|
| 176 | - | continue; |
|
| 177 | - | } |
|
| 178 | - | Ok(false) => return Ok(art), |
|
| 179 | - | Err(e) => return Err(format!("dedup check failed: {e}")), |
|
| 180 | - | } |
|
| 181 | - | } |
|
| 182 | - | Err(format!( |
|
| 183 | - | "failed to pick a non-duplicate artwork after {} retries", |
|
| 184 | - | max_retries + 1 |
|
| 185 | - | )) |
|
| 186 | - | } |
|
| 187 | - | ||
| 188 | - | pub fn raw_to_daily(raw: RawArtwork, date: String, fetched_at: String) -> Option<DailyArtwork> { |
|
| 189 | - | let image_id = raw.image_id?; |
|
| 190 | - | if image_id.is_empty() { |
|
| 191 | - | return None; |
|
| 192 | - | } |
|
| 193 | - | Some(DailyArtwork { |
|
| 194 | - | date, |
|
| 195 | - | artwork_id: raw.id, |
|
| 196 | - | title: raw.title.unwrap_or_else(|| "Untitled".to_string()), |
|
| 197 | - | artist_display: raw.artist_display, |
|
| 198 | - | artist_title: raw.artist_title, |
|
| 199 | - | date_display: raw.date_display, |
|
| 200 | - | medium_display: raw.medium_display, |
|
| 201 | - | dimensions: raw.dimensions, |
|
| 202 | - | place_of_origin: raw.place_of_origin, |
|
| 203 | - | credit_line: raw.credit_line, |
|
| 204 | - | description: raw.description, |
|
| 205 | - | short_description: raw.short_description, |
|
| 206 | - | image_id, |
|
| 207 | - | fetched_at, |
|
| 208 | - | }) |
|
| 209 | - | } |
|
| 210 | - | ||
| 211 | - | #[cfg(test)] |
|
| 212 | - | mod tests { |
|
| 213 | - | use super::*; |
|
| 214 | - | ||
| 215 | - | #[test] |
|
| 216 | - | fn build_params_lowercases_classifications() { |
|
| 217 | - | let p = build_params(&["Painting".to_string(), "DRAWING".to_string()], &[]); |
|
| 218 | - | assert!(p.contains("\"painting\"")); |
|
| 219 | - | assert!(p.contains("\"drawing\"")); |
|
| 220 | - | assert!(p.contains("is_public_domain")); |
|
| 221 | - | assert!(p.contains("image_id")); |
|
| 222 | - | } |
|
| 223 | - | } |
| 1 | - | use rusqlite::{params, Connection, OptionalExtension}; |
|
| 2 | - | use serde::{Deserialize, Serialize}; |
|
| 3 | - | use std::fmt; |
|
| 4 | - | use std::sync::{Arc, Mutex}; |
|
| 5 | - | ||
| 6 | - | pub type Db = Arc<Mutex<Connection>>; |
|
| 7 | - | ||
| 8 | - | #[derive(Debug)] |
|
| 9 | - | pub enum DbError { |
|
| 10 | - | Sqlite(rusqlite::Error), |
|
| 11 | - | LockPoisoned, |
|
| 12 | - | } |
|
| 13 | - | ||
| 14 | - | impl fmt::Display for DbError { |
|
| 15 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 16 | - | match self { |
|
| 17 | - | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 18 | - | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 19 | - | } |
|
| 20 | - | } |
|
| 21 | - | } |
|
| 22 | - | ||
| 23 | - | impl std::error::Error for DbError {} |
|
| 24 | - | ||
| 25 | - | impl From<rusqlite::Error> for DbError { |
|
| 26 | - | fn from(e: rusqlite::Error) -> Self { |
|
| 27 | - | DbError::Sqlite(e) |
|
| 28 | - | } |
|
| 29 | - | } |
|
| 30 | - | ||
| 31 | - | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 32 | - | pub struct DailyArtwork { |
|
| 33 | - | pub date: String, |
|
| 34 | - | pub artwork_id: i64, |
|
| 35 | - | pub title: String, |
|
| 36 | - | pub artist_display: Option<String>, |
|
| 37 | - | pub artist_title: Option<String>, |
|
| 38 | - | pub date_display: Option<String>, |
|
| 39 | - | pub medium_display: Option<String>, |
|
| 40 | - | pub dimensions: Option<String>, |
|
| 41 | - | pub place_of_origin: Option<String>, |
|
| 42 | - | pub credit_line: Option<String>, |
|
| 43 | - | pub description: Option<String>, |
|
| 44 | - | pub short_description: Option<String>, |
|
| 45 | - | pub image_id: String, |
|
| 46 | - | pub fetched_at: String, |
|
| 47 | - | } |
|
| 48 | - | ||
| 49 | - | const SCHEMA: &str = " |
|
| 50 | - | CREATE TABLE IF NOT EXISTS daily_artworks ( |
|
| 51 | - | date TEXT PRIMARY KEY, |
|
| 52 | - | artwork_id INTEGER NOT NULL, |
|
| 53 | - | title TEXT NOT NULL, |
|
| 54 | - | artist_display TEXT, |
|
| 55 | - | artist_title TEXT, |
|
| 56 | - | date_display TEXT, |
|
| 57 | - | medium_display TEXT, |
|
| 58 | - | dimensions TEXT, |
|
| 59 | - | place_of_origin TEXT, |
|
| 60 | - | credit_line TEXT, |
|
| 61 | - | description TEXT, |
|
| 62 | - | short_description TEXT, |
|
| 63 | - | image_id TEXT NOT NULL, |
|
| 64 | - | fetched_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 65 | - | ); |
|
| 66 | - | CREATE INDEX IF NOT EXISTS idx_daily_artworks_artwork_id ON daily_artworks(artwork_id); |
|
| 67 | - | "; |
|
| 68 | - | ||
| 69 | - | pub fn init_db(path: &str) -> Db { |
|
| 70 | - | let conn = Connection::open(path).expect("Failed to open easel database"); |
|
| 71 | - | conn.execute_batch(SCHEMA).expect("Failed to apply schema"); |
|
| 72 | - | Arc::new(Mutex::new(conn)) |
|
| 73 | - | } |
|
| 74 | - | ||
| 75 | - | const COLS: &str = "date, artwork_id, title, artist_display, artist_title, date_display, medium_display, dimensions, place_of_origin, credit_line, description, short_description, image_id, fetched_at"; |
|
| 76 | - | ||
| 77 | - | fn from_row(row: &rusqlite::Row) -> rusqlite::Result<DailyArtwork> { |
|
| 78 | - | Ok(DailyArtwork { |
|
| 79 | - | date: row.get(0)?, |
|
| 80 | - | artwork_id: row.get(1)?, |
|
| 81 | - | title: row.get(2)?, |
|
| 82 | - | artist_display: row.get(3)?, |
|
| 83 | - | artist_title: row.get(4)?, |
|
| 84 | - | date_display: row.get(5)?, |
|
| 85 | - | medium_display: row.get(6)?, |
|
| 86 | - | dimensions: row.get(7)?, |
|
| 87 | - | place_of_origin: row.get(8)?, |
|
| 88 | - | credit_line: row.get(9)?, |
|
| 89 | - | description: row.get(10)?, |
|
| 90 | - | short_description: row.get(11)?, |
|
| 91 | - | image_id: row.get(12)?, |
|
| 92 | - | fetched_at: row.get(13)?, |
|
| 93 | - | }) |
|
| 94 | - | } |
|
| 95 | - | ||
| 96 | - | pub fn insert_daily(db: &Db, art: &DailyArtwork) -> Result<bool, DbError> { |
|
| 97 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 98 | - | let rows = conn.execute( |
|
| 99 | - | "INSERT OR IGNORE INTO daily_artworks |
|
| 100 | - | (date, artwork_id, title, artist_display, artist_title, date_display, medium_display, |
|
| 101 | - | dimensions, place_of_origin, credit_line, description, short_description, image_id, fetched_at) |
|
| 102 | - | VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", |
|
| 103 | - | params![ |
|
| 104 | - | art.date, |
|
| 105 | - | art.artwork_id, |
|
| 106 | - | art.title, |
|
| 107 | - | art.artist_display, |
|
| 108 | - | art.artist_title, |
|
| 109 | - | art.date_display, |
|
| 110 | - | art.medium_display, |
|
| 111 | - | art.dimensions, |
|
| 112 | - | art.place_of_origin, |
|
| 113 | - | art.credit_line, |
|
| 114 | - | art.description, |
|
| 115 | - | art.short_description, |
|
| 116 | - | art.image_id, |
|
| 117 | - | art.fetched_at, |
|
| 118 | - | ], |
|
| 119 | - | )?; |
|
| 120 | - | Ok(rows > 0) |
|
| 121 | - | } |
|
| 122 | - | ||
| 123 | - | pub fn get_daily(db: &Db, date: &str) -> Result<Option<DailyArtwork>, DbError> { |
|
| 124 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 125 | - | let row = conn |
|
| 126 | - | .query_row( |
|
| 127 | - | &format!("SELECT {COLS} FROM daily_artworks WHERE date = ?1"), |
|
| 128 | - | params![date], |
|
| 129 | - | from_row, |
|
| 130 | - | ) |
|
| 131 | - | .optional()?; |
|
| 132 | - | Ok(row) |
|
| 133 | - | } |
|
| 134 | - | ||
| 135 | - | pub fn list_daily(db: &Db, limit: i64) -> Result<Vec<DailyArtwork>, DbError> { |
|
| 136 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 137 | - | let mut stmt = conn.prepare(&format!( |
|
| 138 | - | "SELECT {COLS} FROM daily_artworks ORDER BY date DESC LIMIT ?1" |
|
| 139 | - | ))?; |
|
| 140 | - | let rows = stmt |
|
| 141 | - | .query_map(params![limit], from_row)? |
|
| 142 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 143 | - | Ok(rows) |
|
| 144 | - | } |
|
| 145 | - | ||
| 146 | - | pub fn artwork_id_exists(db: &Db, artwork_id: i64) -> Result<bool, DbError> { |
|
| 147 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 148 | - | let count: i64 = conn.query_row( |
|
| 149 | - | "SELECT COUNT(*) FROM daily_artworks WHERE artwork_id = ?1", |
|
| 150 | - | params![artwork_id], |
|
| 151 | - | |row| row.get(0), |
|
| 152 | - | )?; |
|
| 153 | - | Ok(count > 0) |
|
| 154 | - | } |
|
| 155 | - | ||
| 156 | - | pub fn missing_dates(db: &Db, dates: &[String]) -> Result<Vec<String>, DbError> { |
|
| 157 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 158 | - | let mut missing = Vec::new(); |
|
| 159 | - | for d in dates { |
|
| 160 | - | let exists: i64 = conn.query_row( |
|
| 161 | - | "SELECT COUNT(*) FROM daily_artworks WHERE date = ?1", |
|
| 162 | - | params![d], |
|
| 163 | - | |row| row.get(0), |
|
| 164 | - | )?; |
|
| 165 | - | if exists == 0 { |
|
| 166 | - | missing.push(d.clone()); |
|
| 167 | - | } |
|
| 168 | - | } |
|
| 169 | - | Ok(missing) |
|
| 170 | - | } |
|
| 171 | - | ||
| 172 | - | #[cfg(test)] |
|
| 173 | - | mod tests { |
|
| 174 | - | use super::*; |
|
| 175 | - | ||
| 176 | - | fn test_db() -> Db { |
|
| 177 | - | let conn = Connection::open_in_memory().unwrap(); |
|
| 178 | - | conn.execute_batch(SCHEMA).unwrap(); |
|
| 179 | - | Arc::new(Mutex::new(conn)) |
|
| 180 | - | } |
|
| 181 | - | ||
| 182 | - | fn sample(date: &str, artwork_id: i64) -> DailyArtwork { |
|
| 183 | - | DailyArtwork { |
|
| 184 | - | date: date.to_string(), |
|
| 185 | - | artwork_id, |
|
| 186 | - | title: "Test".to_string(), |
|
| 187 | - | artist_display: Some("An Artist".to_string()), |
|
| 188 | - | artist_title: None, |
|
| 189 | - | date_display: None, |
|
| 190 | - | medium_display: None, |
|
| 191 | - | dimensions: None, |
|
| 192 | - | place_of_origin: None, |
|
| 193 | - | credit_line: None, |
|
| 194 | - | description: None, |
|
| 195 | - | short_description: None, |
|
| 196 | - | image_id: "abc-123".to_string(), |
|
| 197 | - | fetched_at: "2024-01-01T00:00:00Z".to_string(), |
|
| 198 | - | } |
|
| 199 | - | } |
|
| 200 | - | ||
| 201 | - | #[test] |
|
| 202 | - | fn insert_and_get() { |
|
| 203 | - | let db = test_db(); |
|
| 204 | - | assert!(insert_daily(&db, &sample("2024-01-01", 1)).unwrap()); |
|
| 205 | - | let got = get_daily(&db, "2024-01-01").unwrap().unwrap(); |
|
| 206 | - | assert_eq!(got.artwork_id, 1); |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | #[test] |
|
| 210 | - | fn duplicate_date_ignored() { |
|
| 211 | - | let db = test_db(); |
|
| 212 | - | assert!(insert_daily(&db, &sample("2024-01-01", 1)).unwrap()); |
|
| 213 | - | assert!(!insert_daily(&db, &sample("2024-01-01", 2)).unwrap()); |
|
| 214 | - | assert_eq!(get_daily(&db, "2024-01-01").unwrap().unwrap().artwork_id, 1); |
|
| 215 | - | } |
|
| 216 | - | ||
| 217 | - | #[test] |
|
| 218 | - | fn artwork_id_exists_works() { |
|
| 219 | - | let db = test_db(); |
|
| 220 | - | insert_daily(&db, &sample("2024-01-01", 42)).unwrap(); |
|
| 221 | - | assert!(artwork_id_exists(&db, 42).unwrap()); |
|
| 222 | - | assert!(!artwork_id_exists(&db, 99).unwrap()); |
|
| 223 | - | } |
|
| 224 | - | ||
| 225 | - | #[test] |
|
| 226 | - | fn missing_dates_filter() { |
|
| 227 | - | let db = test_db(); |
|
| 228 | - | insert_daily(&db, &sample("2024-01-01", 1)).unwrap(); |
|
| 229 | - | let dates = vec![ |
|
| 230 | - | "2024-01-01".to_string(), |
|
| 231 | - | "2024-01-02".to_string(), |
|
| 232 | - | "2024-01-03".to_string(), |
|
| 233 | - | ]; |
|
| 234 | - | let missing = missing_dates(&db, &dates).unwrap(); |
|
| 235 | - | assert_eq!(missing, vec!["2024-01-02", "2024-01-03"]); |
|
| 236 | - | } |
|
| 237 | - | ||
| 238 | - | #[test] |
|
| 239 | - | fn list_daily_desc() { |
|
| 240 | - | let db = test_db(); |
|
| 241 | - | insert_daily(&db, &sample("2024-01-01", 1)).unwrap(); |
|
| 242 | - | insert_daily(&db, &sample("2024-01-03", 3)).unwrap(); |
|
| 243 | - | insert_daily(&db, &sample("2024-01-02", 2)).unwrap(); |
|
| 244 | - | let list = list_daily(&db, 10).unwrap(); |
|
| 245 | - | assert_eq!(list.len(), 3); |
|
| 246 | - | assert_eq!(list[0].date, "2024-01-03"); |
|
| 247 | - | assert_eq!(list[2].date, "2024-01-01"); |
|
| 248 | - | } |
|
| 249 | - | } |
| 1 | - | mod aic; |
|
| 2 | - | mod db; |
|
| 3 | - | mod scheduler; |
|
| 4 | - | mod server; |
|
| 5 | - | ||
| 6 | - | #[tokio::main] |
|
| 7 | - | async fn main() { |
|
| 8 | - | dotenvy::dotenv().ok(); |
|
| 9 | - | tracing_subscriber::fmt() |
|
| 10 | - | .with_env_filter( |
|
| 11 | - | tracing_subscriber::EnvFilter::try_from_default_env() |
|
| 12 | - | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,easel=info")), |
|
| 13 | - | ) |
|
| 14 | - | .init(); |
|
| 15 | - | server::run().await; |
|
| 16 | - | } |
| 1 | - | use std::sync::Arc; |
|
| 2 | - | use std::time::Duration; |
|
| 3 | - | ||
| 4 | - | use chrono::{Duration as ChronoDuration, NaiveDate, TimeZone, Utc}; |
|
| 5 | - | use chrono_tz::Tz; |
|
| 6 | - | ||
| 7 | - | use crate::aic; |
|
| 8 | - | use crate::db::{self, DailyArtwork}; |
|
| 9 | - | use crate::server::AppState; |
|
| 10 | - | ||
| 11 | - | pub async fn run(state: Arc<AppState>) { |
|
| 12 | - | if let Err(e) = ensure_day(&state, &today_in_tz(&state.tz)).await { |
|
| 13 | - | tracing::warn!("startup ensure_day failed: {e}"); |
|
| 14 | - | } |
|
| 15 | - | ||
| 16 | - | if state.backfill_days > 0 { |
|
| 17 | - | let dates = past_n_dates(&state.tz, state.backfill_days); |
|
| 18 | - | match db::missing_dates(&state.db, &dates) { |
|
| 19 | - | Ok(missing) => { |
|
| 20 | - | tracing::info!( |
|
| 21 | - | "backfill: {} of last {} days missing", |
|
| 22 | - | missing.len(), |
|
| 23 | - | state.backfill_days |
|
| 24 | - | ); |
|
| 25 | - | for d in missing { |
|
| 26 | - | if let Err(e) = ensure_day(&state, &d).await { |
|
| 27 | - | tracing::warn!("backfill {d} failed: {e}"); |
|
| 28 | - | } |
|
| 29 | - | } |
|
| 30 | - | } |
|
| 31 | - | Err(e) => tracing::error!("backfill missing_dates failed: {e}"), |
|
| 32 | - | } |
|
| 33 | - | } |
|
| 34 | - | ||
| 35 | - | loop { |
|
| 36 | - | let dur = duration_until_next_midnight(&state.tz); |
|
| 37 | - | tracing::info!( |
|
| 38 | - | "scheduler sleeping for {}s until next midnight in {}", |
|
| 39 | - | dur.as_secs(), |
|
| 40 | - | state.tz.name() |
|
| 41 | - | ); |
|
| 42 | - | tokio::time::sleep(dur).await; |
|
| 43 | - | let date = today_in_tz(&state.tz); |
|
| 44 | - | if let Err(e) = ensure_day(&state, &date).await { |
|
| 45 | - | tracing::warn!("scheduled ensure_day {date} failed: {e}"); |
|
| 46 | - | } |
|
| 47 | - | } |
|
| 48 | - | } |
|
| 49 | - | ||
| 50 | - | pub async fn ensure_day(state: &AppState, date: &str) -> Result<(), String> { |
|
| 51 | - | if db::get_daily(&state.db, date) |
|
| 52 | - | .map_err(|e| e.to_string())? |
|
| 53 | - | .is_some() |
|
| 54 | - | { |
|
| 55 | - | return Ok(()); |
|
| 56 | - | } |
|
| 57 | - | let raw = aic::pick_unique( |
|
| 58 | - | &state.http, |
|
| 59 | - | &state.db, |
|
| 60 | - | &state.classifications, |
|
| 61 | - | &state.exclude_terms, |
|
| 62 | - | state.max_dedup_retries, |
|
| 63 | - | ) |
|
| 64 | - | .await?; |
|
| 65 | - | let now = Utc::now().to_rfc3339(); |
|
| 66 | - | let daily: DailyArtwork = aic::raw_to_daily(raw, date.to_string(), now) |
|
| 67 | - | .ok_or_else(|| "missing image_id on selected artwork".to_string())?; |
|
| 68 | - | db::insert_daily(&state.db, &daily).map_err(|e| e.to_string())?; |
|
| 69 | - | tracing::info!( |
|
| 70 | - | "stored artwork {} for {} (image_id={})", |
|
| 71 | - | daily.artwork_id, |
|
| 72 | - | date, |
|
| 73 | - | daily.image_id |
|
| 74 | - | ); |
|
| 75 | - | Ok(()) |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | pub fn today_in_tz(tz: &Tz) -> String { |
|
| 79 | - | Utc::now() |
|
| 80 | - | .with_timezone(tz) |
|
| 81 | - | .date_naive() |
|
| 82 | - | .format("%Y-%m-%d") |
|
| 83 | - | .to_string() |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | pub fn past_n_dates(tz: &Tz, n: u32) -> Vec<String> { |
|
| 87 | - | let today = Utc::now().with_timezone(tz).date_naive(); |
|
| 88 | - | (1..=n as i64) |
|
| 89 | - | .filter_map(|i| today.checked_sub_signed(ChronoDuration::days(i))) |
|
| 90 | - | .map(|d| d.format("%Y-%m-%d").to_string()) |
|
| 91 | - | .collect() |
|
| 92 | - | } |
|
| 93 | - | ||
| 94 | - | pub fn parse_date(s: &str) -> Option<NaiveDate> { |
|
| 95 | - | NaiveDate::parse_from_str(s, "%Y-%m-%d").ok() |
|
| 96 | - | } |
|
| 97 | - | ||
| 98 | - | fn duration_until_next_midnight(tz: &Tz) -> Duration { |
|
| 99 | - | let now = Utc::now().with_timezone(tz); |
|
| 100 | - | let next_day = now.date_naive() + ChronoDuration::days(1); |
|
| 101 | - | let next_midnight = tz |
|
| 102 | - | .from_local_datetime(&next_day.and_hms_opt(0, 0, 1).expect("valid time")) |
|
| 103 | - | .single() |
|
| 104 | - | .or_else(|| { |
|
| 105 | - | tz.from_local_datetime(&next_day.and_hms_opt(0, 0, 1).expect("valid time")) |
|
| 106 | - | .earliest() |
|
| 107 | - | }) |
|
| 108 | - | .unwrap_or_else(|| now + ChronoDuration::days(1)); |
|
| 109 | - | let delta = next_midnight.signed_duration_since(now); |
|
| 110 | - | delta |
|
| 111 | - | .to_std() |
|
| 112 | - | .unwrap_or_else(|_| Duration::from_secs(60 * 60)) |
|
| 113 | - | } |
|
| 114 | - | ||
| 115 | - | #[cfg(test)] |
|
| 116 | - | mod tests { |
|
| 117 | - | use super::*; |
|
| 118 | - | ||
| 119 | - | #[test] |
|
| 120 | - | fn past_n_dates_count() { |
|
| 121 | - | let tz: Tz = "UTC".parse().unwrap(); |
|
| 122 | - | let dates = past_n_dates(&tz, 5); |
|
| 123 | - | assert_eq!(dates.len(), 5); |
|
| 124 | - | } |
|
| 125 | - | ||
| 126 | - | #[test] |
|
| 127 | - | fn past_n_dates_excludes_today() { |
|
| 128 | - | let tz: Tz = "UTC".parse().unwrap(); |
|
| 129 | - | let today = today_in_tz(&tz); |
|
| 130 | - | let dates = past_n_dates(&tz, 3); |
|
| 131 | - | assert!(!dates.contains(&today)); |
|
| 132 | - | } |
|
| 133 | - | ||
| 134 | - | #[test] |
|
| 135 | - | fn parse_date_valid_invalid() { |
|
| 136 | - | assert!(parse_date("2024-05-01").is_some()); |
|
| 137 | - | assert!(parse_date("2024-13-01").is_none()); |
|
| 138 | - | assert!(parse_date("notadate").is_none()); |
|
| 139 | - | } |
|
| 140 | - | } |
| 1 | - | use std::sync::Arc; |
|
| 2 | - | ||
| 3 | - | use askama::Template; |
|
| 4 | - | use axum::{ |
|
| 5 | - | extract::{Path, State}, |
|
| 6 | - | http::{header, Method, StatusCode}, |
|
| 7 | - | response::{Html, IntoResponse, Json, Response}, |
|
| 8 | - | routing::get, |
|
| 9 | - | Router, |
|
| 10 | - | }; |
|
| 11 | - | use chrono::Utc; |
|
| 12 | - | use rust_embed::Embed; |
|
| 13 | - | use serde::Serialize; |
|
| 14 | - | use tower_http::cors::{Any, CorsLayer}; |
|
| 15 | - | ||
| 16 | - | use crate::db::{self, DailyArtwork, Db}; |
|
| 17 | - | use crate::scheduler; |
|
| 18 | - | ||
| 19 | - | #[derive(Embed)] |
|
| 20 | - | #[folder = "static/"] |
|
| 21 | - | struct Static; |
|
| 22 | - | ||
| 23 | - | pub struct AppState { |
|
| 24 | - | pub db: Db, |
|
| 25 | - | pub http: reqwest::Client, |
|
| 26 | - | pub tz: chrono_tz::Tz, |
|
| 27 | - | pub classifications: Vec<String>, |
|
| 28 | - | pub exclude_terms: Vec<String>, |
|
| 29 | - | pub backfill_days: u32, |
|
| 30 | - | pub max_dedup_retries: u32, |
|
| 31 | - | pub base_url: String, |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | #[derive(Template)] |
|
| 35 | - | #[template(path = "index.html")] |
|
| 36 | - | struct IndexTemplate { |
|
| 37 | - | today_date: String, |
|
| 38 | - | artwork: Option<ArtworkView>, |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | #[derive(Template)] |
|
| 42 | - | #[template(path = "day.html")] |
|
| 43 | - | struct DayTemplate { |
|
| 44 | - | date: String, |
|
| 45 | - | artwork: ArtworkView, |
|
| 46 | - | } |
|
| 47 | - | ||
| 48 | - | #[derive(Template)] |
|
| 49 | - | #[template(path = "archive.html")] |
|
| 50 | - | struct ArchiveTemplate { |
|
| 51 | - | archive: Vec<ArchiveRow>, |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | #[derive(Template)] |
|
| 55 | - | #[template(path = "error.html")] |
|
| 56 | - | struct ErrorTemplate { |
|
| 57 | - | title: String, |
|
| 58 | - | message: String, |
|
| 59 | - | } |
|
| 60 | - | ||
| 61 | - | struct ArtworkView { |
|
| 62 | - | date: String, |
|
| 63 | - | title: String, |
|
| 64 | - | artist_display: String, |
|
| 65 | - | date_display: String, |
|
| 66 | - | medium_display: String, |
|
| 67 | - | dimensions: String, |
|
| 68 | - | place_of_origin: String, |
|
| 69 | - | credit_line: String, |
|
| 70 | - | description: String, |
|
| 71 | - | short_description: String, |
|
| 72 | - | image_url: String, |
|
| 73 | - | source_url: String, |
|
| 74 | - | } |
|
| 75 | - | ||
| 76 | - | struct ArchiveRow { |
|
| 77 | - | date: String, |
|
| 78 | - | title: String, |
|
| 79 | - | artist: String, |
|
| 80 | - | } |
|
| 81 | - | ||
| 82 | - | fn iiif_url(image_id: &str) -> String { |
|
| 83 | - | format!("https://www.artic.edu/iiif/2/{image_id}/full/843,/0/default.jpg") |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | fn source_url(artwork_id: i64) -> String { |
|
| 87 | - | format!("https://www.artic.edu/artworks/{artwork_id}") |
|
| 88 | - | } |
|
| 89 | - | ||
| 90 | - | fn to_view(a: DailyArtwork) -> ArtworkView { |
|
| 91 | - | ArtworkView { |
|
| 92 | - | date: a.date, |
|
| 93 | - | title: a.title, |
|
| 94 | - | artist_display: a.artist_display.unwrap_or_default(), |
|
| 95 | - | date_display: a.date_display.unwrap_or_default(), |
|
| 96 | - | medium_display: a.medium_display.unwrap_or_default(), |
|
| 97 | - | dimensions: a.dimensions.unwrap_or_default(), |
|
| 98 | - | place_of_origin: a.place_of_origin.unwrap_or_default(), |
|
| 99 | - | credit_line: a.credit_line.unwrap_or_default(), |
|
| 100 | - | description: a.description.unwrap_or_default(), |
|
| 101 | - | short_description: a.short_description.unwrap_or_default(), |
|
| 102 | - | image_url: iiif_url(&a.image_id), |
|
| 103 | - | source_url: source_url(a.artwork_id), |
|
| 104 | - | } |
|
| 105 | - | } |
|
| 106 | - | ||
| 107 | - | fn to_archive_row(a: &DailyArtwork) -> ArchiveRow { |
|
| 108 | - | ArchiveRow { |
|
| 109 | - | date: a.date.clone(), |
|
| 110 | - | title: a.title.clone(), |
|
| 111 | - | artist: a |
|
| 112 | - | .artist_title |
|
| 113 | - | .clone() |
|
| 114 | - | .or_else(|| a.artist_display.clone()) |
|
| 115 | - | .unwrap_or_default(), |
|
| 116 | - | } |
|
| 117 | - | } |
|
| 118 | - | ||
| 119 | - | fn render<T: Template>(t: T) -> Response { |
|
| 120 | - | match t.render() { |
|
| 121 | - | Ok(body) => Html(body).into_response(), |
|
| 122 | - | Err(e) => { |
|
| 123 | - | tracing::error!("render failed: {e}"); |
|
| 124 | - | (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response() |
|
| 125 | - | } |
|
| 126 | - | } |
|
| 127 | - | } |
|
| 128 | - | ||
| 129 | - | async fn index_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 130 | - | let today = scheduler::today_in_tz(&state.tz); |
|
| 131 | - | let artwork = match db::get_daily(&state.db, &today) { |
|
| 132 | - | Ok(Some(a)) => Some(to_view(a)), |
|
| 133 | - | Ok(None) => None, |
|
| 134 | - | Err(e) => { |
|
| 135 | - | tracing::error!("index db error: {e}"); |
|
| 136 | - | return render(ErrorTemplate { |
|
| 137 | - | title: "Error".to_string(), |
|
| 138 | - | message: "Could not load today's artwork.".to_string(), |
|
| 139 | - | }); |
|
| 140 | - | } |
|
| 141 | - | }; |
|
| 142 | - | render(IndexTemplate { |
|
| 143 | - | today_date: today, |
|
| 144 | - | artwork, |
|
| 145 | - | }) |
|
| 146 | - | } |
|
| 147 | - | ||
| 148 | - | async fn day_handler( |
|
| 149 | - | State(state): State<Arc<AppState>>, |
|
| 150 | - | Path(date): Path<String>, |
|
| 151 | - | ) -> Response { |
|
| 152 | - | let parsed = match scheduler::parse_date(&date) { |
|
| 153 | - | Some(d) => d, |
|
| 154 | - | None => { |
|
| 155 | - | return ( |
|
| 156 | - | StatusCode::BAD_REQUEST, |
|
| 157 | - | render(ErrorTemplate { |
|
| 158 | - | title: "Invalid date".to_string(), |
|
| 159 | - | message: format!("'{date}' is not a valid YYYY-MM-DD date."), |
|
| 160 | - | }), |
|
| 161 | - | ) |
|
| 162 | - | .into_response(); |
|
| 163 | - | } |
|
| 164 | - | }; |
|
| 165 | - | let today = scheduler::today_in_tz(&state.tz); |
|
| 166 | - | if date.as_str() > today.as_str() { |
|
| 167 | - | return ( |
|
| 168 | - | StatusCode::NOT_FOUND, |
|
| 169 | - | render(ErrorTemplate { |
|
| 170 | - | title: "Not yet".to_string(), |
|
| 171 | - | message: format!( |
|
| 172 | - | "{} is in the future. The next day's artwork is not available until midnight {}.", |
|
| 173 | - | parsed, state.tz.name() |
|
| 174 | - | ), |
|
| 175 | - | }), |
|
| 176 | - | ) |
|
| 177 | - | .into_response(); |
|
| 178 | - | } |
|
| 179 | - | let artwork = match db::get_daily(&state.db, &date) { |
|
| 180 | - | Ok(Some(a)) => to_view(a), |
|
| 181 | - | Ok(None) => { |
|
| 182 | - | return ( |
|
| 183 | - | StatusCode::NOT_FOUND, |
|
| 184 | - | render(ErrorTemplate { |
|
| 185 | - | title: "Not found".to_string(), |
|
| 186 | - | message: format!("No artwork stored for {date}."), |
|
| 187 | - | }), |
|
| 188 | - | ) |
|
| 189 | - | .into_response(); |
|
| 190 | - | } |
|
| 191 | - | Err(e) => { |
|
| 192 | - | tracing::error!("day db error: {e}"); |
|
| 193 | - | return ( |
|
| 194 | - | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 195 | - | render(ErrorTemplate { |
|
| 196 | - | title: "Error".to_string(), |
|
| 197 | - | message: "Database error.".to_string(), |
|
| 198 | - | }), |
|
| 199 | - | ) |
|
| 200 | - | .into_response(); |
|
| 201 | - | } |
|
| 202 | - | }; |
|
| 203 | - | render(DayTemplate { |
|
| 204 | - | date, |
|
| 205 | - | artwork, |
|
| 206 | - | }) |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | async fn archive_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 210 | - | let archive = db::list_daily(&state.db, 1000) |
|
| 211 | - | .unwrap_or_default() |
|
| 212 | - | .iter() |
|
| 213 | - | .map(to_archive_row) |
|
| 214 | - | .collect(); |
|
| 215 | - | render(ArchiveTemplate { archive }) |
|
| 216 | - | } |
|
| 217 | - | ||
| 218 | - | #[derive(Serialize)] |
|
| 219 | - | struct ApiArtwork<'a> { |
|
| 220 | - | date: &'a str, |
|
| 221 | - | artwork_id: i64, |
|
| 222 | - | title: &'a str, |
|
| 223 | - | artist_display: Option<&'a str>, |
|
| 224 | - | date_display: Option<&'a str>, |
|
| 225 | - | medium_display: Option<&'a str>, |
|
| 226 | - | dimensions: Option<&'a str>, |
|
| 227 | - | place_of_origin: Option<&'a str>, |
|
| 228 | - | credit_line: Option<&'a str>, |
|
| 229 | - | short_description: Option<&'a str>, |
|
| 230 | - | image_id: &'a str, |
|
| 231 | - | image_url: String, |
|
| 232 | - | source_url: String, |
|
| 233 | - | } |
|
| 234 | - | ||
| 235 | - | fn to_api<'a>(a: &'a DailyArtwork) -> ApiArtwork<'a> { |
|
| 236 | - | ApiArtwork { |
|
| 237 | - | date: &a.date, |
|
| 238 | - | artwork_id: a.artwork_id, |
|
| 239 | - | title: &a.title, |
|
| 240 | - | artist_display: a.artist_display.as_deref(), |
|
| 241 | - | date_display: a.date_display.as_deref(), |
|
| 242 | - | medium_display: a.medium_display.as_deref(), |
|
| 243 | - | dimensions: a.dimensions.as_deref(), |
|
| 244 | - | place_of_origin: a.place_of_origin.as_deref(), |
|
| 245 | - | credit_line: a.credit_line.as_deref(), |
|
| 246 | - | short_description: a.short_description.as_deref(), |
|
| 247 | - | image_id: &a.image_id, |
|
| 248 | - | image_url: iiif_url(&a.image_id), |
|
| 249 | - | source_url: source_url(a.artwork_id), |
|
| 250 | - | } |
|
| 251 | - | } |
|
| 252 | - | ||
| 253 | - | async fn api_today(State(state): State<Arc<AppState>>) -> Response { |
|
| 254 | - | let today = scheduler::today_in_tz(&state.tz); |
|
| 255 | - | match db::get_daily(&state.db, &today) { |
|
| 256 | - | Ok(Some(a)) => Json(to_api(&a)).into_response(), |
|
| 257 | - | Ok(None) => ( |
|
| 258 | - | StatusCode::NOT_FOUND, |
|
| 259 | - | Json(serde_json::json!({"error": "today not yet populated"})), |
|
| 260 | - | ) |
|
| 261 | - | .into_response(), |
|
| 262 | - | Err(e) => { |
|
| 263 | - | tracing::error!("api_today db error: {e}"); |
|
| 264 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 265 | - | } |
|
| 266 | - | } |
|
| 267 | - | } |
|
| 268 | - | ||
| 269 | - | async fn api_day( |
|
| 270 | - | State(state): State<Arc<AppState>>, |
|
| 271 | - | Path(date): Path<String>, |
|
| 272 | - | ) -> Response { |
|
| 273 | - | if scheduler::parse_date(&date).is_none() { |
|
| 274 | - | return ( |
|
| 275 | - | StatusCode::BAD_REQUEST, |
|
| 276 | - | Json(serde_json::json!({"error": "invalid date format"})), |
|
| 277 | - | ) |
|
| 278 | - | .into_response(); |
|
| 279 | - | } |
|
| 280 | - | let today = scheduler::today_in_tz(&state.tz); |
|
| 281 | - | if date.as_str() > today.as_str() { |
|
| 282 | - | return ( |
|
| 283 | - | StatusCode::NOT_FOUND, |
|
| 284 | - | Json(serde_json::json!({"error": "future date"})), |
|
| 285 | - | ) |
|
| 286 | - | .into_response(); |
|
| 287 | - | } |
|
| 288 | - | match db::get_daily(&state.db, &date) { |
|
| 289 | - | Ok(Some(a)) => Json(to_api(&a)).into_response(), |
|
| 290 | - | Ok(None) => ( |
|
| 291 | - | StatusCode::NOT_FOUND, |
|
| 292 | - | Json(serde_json::json!({"error": "no record for date"})), |
|
| 293 | - | ) |
|
| 294 | - | .into_response(), |
|
| 295 | - | Err(e) => { |
|
| 296 | - | tracing::error!("api_day db error: {e}"); |
|
| 297 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 298 | - | } |
|
| 299 | - | } |
|
| 300 | - | } |
|
| 301 | - | ||
| 302 | - | async fn api_archive(State(state): State<Arc<AppState>>) -> Response { |
|
| 303 | - | match db::list_daily(&state.db, 1000) { |
|
| 304 | - | Ok(items) => { |
|
| 305 | - | let out: Vec<ApiArtwork> = items.iter().map(to_api).collect(); |
|
| 306 | - | Json(out).into_response() |
|
| 307 | - | } |
|
| 308 | - | Err(e) => { |
|
| 309 | - | tracing::error!("api_archive db error: {e}"); |
|
| 310 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 311 | - | } |
|
| 312 | - | } |
|
| 313 | - | } |
|
| 314 | - | ||
| 315 | - | fn escape_xml(s: &str) -> String { |
|
| 316 | - | s.replace('&', "&") |
|
| 317 | - | .replace('<', "<") |
|
| 318 | - | .replace('>', ">") |
|
| 319 | - | .replace('"', """) |
|
| 320 | - | .replace('\'', "'") |
|
| 321 | - | } |
|
| 322 | - | ||
| 323 | - | fn entry_published(date: &str) -> String { |
|
| 324 | - | chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") |
|
| 325 | - | .ok() |
|
| 326 | - | .and_then(|d| d.and_hms_opt(12, 0, 0)) |
|
| 327 | - | .map(|dt| dt.and_utc().to_rfc3339()) |
|
| 328 | - | .unwrap_or_else(|| Utc::now().to_rfc3339()) |
|
| 329 | - | } |
|
| 330 | - | ||
| 331 | - | async fn atom_feed_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 332 | - | let items = match db::list_daily(&state.db, 100) { |
|
| 333 | - | Ok(items) => items, |
|
| 334 | - | Err(e) => { |
|
| 335 | - | tracing::error!("atom feed query failed: {e}"); |
|
| 336 | - | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 337 | - | } |
|
| 338 | - | }; |
|
| 339 | - | ||
| 340 | - | let updated = items |
|
| 341 | - | .first() |
|
| 342 | - | .map(|i| entry_published(&i.date)) |
|
| 343 | - | .unwrap_or_else(|| Utc::now().to_rfc3339()); |
|
| 344 | - | ||
| 345 | - | let base = state.base_url.trim_end_matches('/'); |
|
| 346 | - | let self_url = format!("{base}/feed.xml"); |
|
| 347 | - | ||
| 348 | - | let mut xml = String::with_capacity(8192); |
|
| 349 | - | xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); |
|
| 350 | - | xml.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n"); |
|
| 351 | - | xml.push_str(" <title>Easel — Daily Artwork</title>\n"); |
|
| 352 | - | xml.push_str(" <subtitle>A daily painting from the Art Institute of Chicago</subtitle>\n"); |
|
| 353 | - | xml.push_str(&format!( |
|
| 354 | - | " <link href=\"{}\" rel=\"self\" type=\"application/atom+xml\" />\n", |
|
| 355 | - | escape_xml(&self_url) |
|
| 356 | - | )); |
|
| 357 | - | xml.push_str(&format!(" <link href=\"{}\" />\n", escape_xml(base))); |
|
| 358 | - | xml.push_str(&format!(" <id>{}</id>\n", escape_xml(&self_url))); |
|
| 359 | - | xml.push_str(&format!(" <updated>{updated}</updated>\n")); |
|
| 360 | - | ||
| 361 | - | for item in &items { |
|
| 362 | - | let published = entry_published(&item.date); |
|
| 363 | - | let entry_url = format!("{base}/day/{}", item.date); |
|
| 364 | - | let author_name = item |
|
| 365 | - | .artist_title |
|
| 366 | - | .as_deref() |
|
| 367 | - | .or(item.artist_display.as_deref()) |
|
| 368 | - | .filter(|s| !s.is_empty()) |
|
| 369 | - | .unwrap_or("Unknown"); |
|
| 370 | - | let summary = item |
|
| 371 | - | .short_description |
|
| 372 | - | .as_deref() |
|
| 373 | - | .or(item.description.as_deref()) |
|
| 374 | - | .unwrap_or(""); |
|
| 375 | - | let image = iiif_url(&item.image_id); |
|
| 376 | - | let content = format!( |
|
| 377 | - | "<p><img src=\"{}\" alt=\"{}\" /></p><p>{}</p>", |
|
| 378 | - | escape_xml(&image), |
|
| 379 | - | escape_xml(&item.title), |
|
| 380 | - | escape_xml(summary) |
|
| 381 | - | ); |
|
| 382 | - | ||
| 383 | - | xml.push_str(" <entry>\n"); |
|
| 384 | - | xml.push_str(&format!( |
|
| 385 | - | " <title>{} — {}</title>\n", |
|
| 386 | - | escape_xml(&item.date), |
|
| 387 | - | escape_xml(&item.title) |
|
| 388 | - | )); |
|
| 389 | - | xml.push_str(&format!( |
|
| 390 | - | " <link href=\"{}\" />\n", |
|
| 391 | - | escape_xml(&entry_url) |
|
| 392 | - | )); |
|
| 393 | - | xml.push_str(&format!(" <id>{}</id>\n", escape_xml(&entry_url))); |
|
| 394 | - | xml.push_str(&format!(" <updated>{published}</updated>\n")); |
|
| 395 | - | xml.push_str(&format!(" <published>{published}</published>\n")); |
|
| 396 | - | xml.push_str(" <author>\n"); |
|
| 397 | - | xml.push_str(&format!(" <name>{}</name>\n", escape_xml(author_name))); |
|
| 398 | - | xml.push_str(" </author>\n"); |
|
| 399 | - | if !summary.is_empty() { |
|
| 400 | - | xml.push_str(&format!( |
|
| 401 | - | " <summary>{}</summary>\n", |
|
| 402 | - | escape_xml(summary) |
|
| 403 | - | )); |
|
| 404 | - | } |
|
| 405 | - | xml.push_str(&format!( |
|
| 406 | - | " <content type=\"html\">{}</content>\n", |
|
| 407 | - | escape_xml(&content) |
|
| 408 | - | )); |
|
| 409 | - | xml.push_str(" </entry>\n"); |
|
| 410 | - | } |
|
| 411 | - | ||
| 412 | - | xml.push_str("</feed>\n"); |
|
| 413 | - | ||
| 414 | - | ( |
|
| 415 | - | [(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8")], |
|
| 416 | - | xml, |
|
| 417 | - | ) |
|
| 418 | - | .into_response() |
|
| 419 | - | } |
|
| 420 | - | ||
| 421 | - | async fn static_handler(Path(path): Path<String>) -> Response { |
|
| 422 | - | match Static::get(&path) { |
|
| 423 | - | Some(file) => { |
|
| 424 | - | let mime = mime_guess::from_path(&path).first_or_octet_stream(); |
|
| 425 | - | ( |
|
| 426 | - | [(header::CONTENT_TYPE, mime.as_ref())], |
|
| 427 | - | file.data.to_vec(), |
|
| 428 | - | ) |
|
| 429 | - | .into_response() |
|
| 430 | - | } |
|
| 431 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 432 | - | } |
|
| 433 | - | } |
|
| 434 | - | ||
| 435 | - | pub async fn run() { |
|
| 436 | - | let db_path = std::env::var("EASEL_DB_PATH").unwrap_or_else(|_| "easel.sqlite".to_string()); |
|
| 437 | - | let tz_name = std::env::var("EASEL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string()); |
|
| 438 | - | let tz: chrono_tz::Tz = tz_name.parse().unwrap_or_else(|_| { |
|
| 439 | - | tracing::warn!("invalid EASEL_TIMEZONE={tz_name}, falling back to UTC"); |
|
| 440 | - | chrono_tz::UTC |
|
| 441 | - | }); |
|
| 442 | - | let classifications: Vec<String> = std::env::var("EASEL_CLASSIFICATIONS") |
|
| 443 | - | .unwrap_or_else(|_| "painting".to_string()) |
|
| 444 | - | .split(',') |
|
| 445 | - | .map(|s| s.trim().to_string()) |
|
| 446 | - | .filter(|s| !s.is_empty()) |
|
| 447 | - | .collect(); |
|
| 448 | - | if classifications.is_empty() { |
|
| 449 | - | panic!("EASEL_CLASSIFICATIONS resolved to empty list"); |
|
| 450 | - | } |
|
| 451 | - | let exclude_terms: Vec<String> = std::env::var("EASEL_EXCLUDE_TERMS") |
|
| 452 | - | .unwrap_or_else(|_| "erotic,erotica,shunga".to_string()) |
|
| 453 | - | .split(',') |
|
| 454 | - | .map(|s| s.trim().to_string()) |
|
| 455 | - | .filter(|s| !s.is_empty()) |
|
| 456 | - | .collect(); |
|
| 457 | - | let backfill_days: u32 = std::env::var("EASEL_BACKFILL_DAYS") |
|
| 458 | - | .ok() |
|
| 459 | - | .and_then(|v| v.parse().ok()) |
|
| 460 | - | .unwrap_or(0); |
|
| 461 | - | let max_dedup_retries: u32 = std::env::var("EASEL_MAX_DEDUP_RETRIES") |
|
| 462 | - | .ok() |
|
| 463 | - | .and_then(|v| v.parse().ok()) |
|
| 464 | - | .unwrap_or(10); |
|
| 465 | - | let base_url = std::env::var("EASEL_BASE_URL") |
|
| 466 | - | .unwrap_or_else(|_| "http://localhost:4242".to_string()) |
|
| 467 | - | .trim_end_matches('/') |
|
| 468 | - | .to_string(); |
|
| 469 | - | ||
| 470 | - | let db = db::init_db(&db_path); |
|
| 471 | - | let http = crate::aic::build_client(); |
|
| 472 | - | ||
| 473 | - | let state = Arc::new(AppState { |
|
| 474 | - | db, |
|
| 475 | - | http, |
|
| 476 | - | tz, |
|
| 477 | - | classifications: classifications.clone(), |
|
| 478 | - | exclude_terms: exclude_terms.clone(), |
|
| 479 | - | backfill_days, |
|
| 480 | - | max_dedup_retries, |
|
| 481 | - | base_url, |
|
| 482 | - | }); |
|
| 483 | - | ||
| 484 | - | tracing::info!( |
|
| 485 | - | "easel starting: tz={} classifications={:?} exclude_terms={:?} backfill_days={} retries={}", |
|
| 486 | - | state.tz.name(), |
|
| 487 | - | classifications, |
|
| 488 | - | exclude_terms, |
|
| 489 | - | backfill_days, |
|
| 490 | - | max_dedup_retries |
|
| 491 | - | ); |
|
| 492 | - | tracing::info!("startup time: {}", Utc::now().to_rfc3339()); |
|
| 493 | - | ||
| 494 | - | tokio::spawn(scheduler::run(state.clone())); |
|
| 495 | - | ||
| 496 | - | let public_cors = CorsLayer::new() |
|
| 497 | - | .allow_origin(Any) |
|
| 498 | - | .allow_methods([Method::GET]) |
|
| 499 | - | .allow_headers(Any); |
|
| 500 | - | ||
| 501 | - | let api_router = Router::new() |
|
| 502 | - | .route("/api/today", get(api_today)) |
|
| 503 | - | .route("/api/day/{date}", get(api_day)) |
|
| 504 | - | .route("/api/archive", get(api_archive)) |
|
| 505 | - | .route("/feed.xml", get(atom_feed_handler)) |
|
| 506 | - | .layer(public_cors); |
|
| 507 | - | ||
| 508 | - | let app = Router::new() |
|
| 509 | - | .route("/", get(index_handler)) |
|
| 510 | - | .route("/day/{date}", get(day_handler)) |
|
| 511 | - | .route("/archive", get(archive_handler)) |
|
| 512 | - | .route("/static/{*path}", get(static_handler)) |
|
| 513 | - | .merge(api_router) |
|
| 514 | - | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 515 | - | .with_state(state); |
|
| 516 | - | ||
| 517 | - | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
|
| 518 | - | let port: u16 = std::env::var("PORT") |
|
| 519 | - | .ok() |
|
| 520 | - | .and_then(|v| v.parse().ok()) |
|
| 521 | - | .unwrap_or(4242); |
|
| 522 | - | let addr = format!("{host}:{port}"); |
|
| 523 | - | let listener = tokio::net::TcpListener::bind(&addr) |
|
| 524 | - | .await |
|
| 525 | - | .unwrap_or_else(|_| panic!("failed to bind {addr}")); |
|
| 526 | - | tracing::info!("easel listening on http://{addr}"); |
|
| 527 | - | axum::serve(listener, app).await.expect("axum serve"); |
|
| 528 | - | } |
| 1 | - | <article class="artwork"> |
|
| 2 | - | <figure class="artwork-figure"> |
|
| 3 | - | <img src="{{ artwork.image_url }}" alt="{{ artwork.title }}" loading="lazy" /> |
|
| 4 | - | </figure> |
|
| 5 | - | <header class="artwork-meta"> |
|
| 6 | - | <p class="artwork-date">{{ artwork.date }}</p> |
|
| 7 | - | <h2 class="artwork-title"> |
|
| 8 | - | <a href="{{ artwork.source_url }}" target="_blank" rel="noopener noreferrer"><em>{{ artwork.title }}</em></a> |
|
| 9 | - | </h2> |
|
| 10 | - | {% if !artwork.artist_display.is_empty() %} |
|
| 11 | - | <p class="artwork-artist">{{ artwork.artist_display }}</p> |
|
| 12 | - | {% endif %} |
|
| 13 | - | </header> |
|
| 14 | - | <dl class="artwork-details"> |
|
| 15 | - | {% if !artwork.date_display.is_empty() %} |
|
| 16 | - | <dt>Date</dt><dd>{{ artwork.date_display }}</dd> |
|
| 17 | - | {% endif %} |
|
| 18 | - | {% if !artwork.place_of_origin.is_empty() %} |
|
| 19 | - | <dt>Origin</dt><dd>{{ artwork.place_of_origin }}</dd> |
|
| 20 | - | {% endif %} |
|
| 21 | - | {% if !artwork.medium_display.is_empty() %} |
|
| 22 | - | <dt>Medium</dt><dd>{{ artwork.medium_display }}</dd> |
|
| 23 | - | {% endif %} |
|
| 24 | - | {% if !artwork.dimensions.is_empty() %} |
|
| 25 | - | <dt>Dimensions</dt><dd>{{ artwork.dimensions }}</dd> |
|
| 26 | - | {% endif %} |
|
| 27 | - | {% if !artwork.credit_line.is_empty() %} |
|
| 28 | - | <dt>Credit</dt><dd>{{ artwork.credit_line }}</dd> |
|
| 29 | - | {% endif %} |
|
| 30 | - | </dl> |
|
| 31 | - | {% if !artwork.description.is_empty() %} |
|
| 32 | - | <div class="artwork-description">{{ artwork.description|safe }}</div> |
|
| 33 | - | {% else if !artwork.short_description.is_empty() %} |
|
| 34 | - | <p class="artwork-description">{{ artwork.short_description }}</p> |
|
| 35 | - | {% endif %} |
|
| 36 | - | </article> |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Easel — Archive{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 1 | + | {{define "archive.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Easel — Archive{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | 4 | <h2>Archive</h2> |
|
| 5 | - | {% if archive.is_empty() %} |
|
| 5 | + | {{if not .Archive}} |
|
| 6 | 6 | <p class="empty">No artworks stored yet.</p> |
|
| 7 | - | {% else %} |
|
| 7 | + | {{else}} |
|
| 8 | 8 | <ul class="item-list"> |
|
| 9 | - | {% for row in archive %} |
|
| 9 | + | {{range .Archive}} |
|
| 10 | 10 | <li class="item"> |
|
| 11 | - | <a href="/day/{{ row.date }}"> |
|
| 12 | - | <span class="item-meta">{{ row.date }}</span> |
|
| 13 | - | <span class="item-title"><em>{{ row.title }}</em></span> |
|
| 14 | - | {% if !row.artist.is_empty() %}<span class="item-meta">{{ row.artist }}</span>{% endif %} |
|
| 11 | + | <a href="/day/{{.Date}}"> |
|
| 12 | + | <span class="item-meta">{{.Date}}</span> |
|
| 13 | + | <span class="item-title"><em>{{.Title}}</em></span> |
|
| 14 | + | {{if .Artist}}<span class="item-meta">{{.Artist}}</span>{{end}} |
|
| 15 | 15 | </a> |
|
| 16 | 16 | </li> |
|
| 17 | - | {% endfor %} |
|
| 17 | + | {{end}} |
|
| 18 | 18 | </ul> |
|
| 19 | - | {% endif %} |
|
| 20 | - | {% endblock %} |
|
| 19 | + | {{end}} |
|
| 20 | + | {{end}} |
| 1 | - | <!doctype html> |
|
| 1 | + | {{define "base.html"}}<!doctype html> |
|
| 2 | 2 | <html lang="en"> |
|
| 3 | 3 | <head> |
|
| 4 | 4 | <meta charset="UTF-8" /> |
|
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <title>{% block title %}Easel{% endblock %}</title> |
|
| 6 | + | <title>{{block "title" .}}Easel{{end}}</title> |
|
| 7 | 7 | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | 8 | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | 9 | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 29 | 29 | </nav> |
|
| 30 | 30 | </header> |
|
| 31 | 31 | <main> |
|
| 32 | - | {% block content %}{% endblock %} |
|
| 32 | + | {{block "content" .}}{{end}} |
|
| 33 | 33 | </main> |
|
| 34 | 34 | </body> |
|
| 35 | - | </html> |
|
| 35 | + | </html>{{end}} |
|
| 36 | + | ||
| 37 | + | {{define "artwork"}} |
|
| 38 | + | <article class="artwork"> |
|
| 39 | + | <figure class="artwork-figure"> |
|
| 40 | + | <img src="{{.ImageURL}}" alt="{{.Title}}" loading="lazy" /> |
|
| 41 | + | </figure> |
|
| 42 | + | <header class="artwork-meta"> |
|
| 43 | + | <p class="artwork-date">{{.Date}}</p> |
|
| 44 | + | <h2 class="artwork-title"> |
|
| 45 | + | <a href="{{.SourceURL}}" target="_blank" rel="noopener noreferrer"><em>{{.Title}}</em></a> |
|
| 46 | + | </h2> |
|
| 47 | + | {{if .ArtistDisplay}}<p class="artwork-artist">{{.ArtistDisplay}}</p>{{end}} |
|
| 48 | + | </header> |
|
| 49 | + | <dl class="artwork-details"> |
|
| 50 | + | {{if .DateDisplay}}<dt>Date</dt><dd>{{.DateDisplay}}</dd>{{end}} |
|
| 51 | + | {{if .PlaceOfOrigin}}<dt>Origin</dt><dd>{{.PlaceOfOrigin}}</dd>{{end}} |
|
| 52 | + | {{if .MediumDisplay}}<dt>Medium</dt><dd>{{.MediumDisplay}}</dd>{{end}} |
|
| 53 | + | {{if .Dimensions}}<dt>Dimensions</dt><dd>{{.Dimensions}}</dd>{{end}} |
|
| 54 | + | {{if .CreditLine}}<dt>Credit</dt><dd>{{.CreditLine}}</dd>{{end}} |
|
| 55 | + | </dl> |
|
| 56 | + | {{if .Description}}<div class="artwork-description">{{.DescriptionHTML}}</div> |
|
| 57 | + | {{else if .ShortDescription}}<p class="artwork-description">{{.ShortDescription}}</p> |
|
| 58 | + | {{end}} |
|
| 59 | + | </article> |
|
| 60 | + | {{end}} |
|
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Easel — {{ date }}{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | {% include "_artwork.html" %} |
|
| 5 | - | {% endblock %} |
|
| 1 | + | {{define "day.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Easel — {{.Date}}{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | + | {{template "artwork" .Artwork}} |
|
| 5 | + | {{end}} |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Easel — {{ title }}{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 1 | + | {{define "error.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Easel — {{.Title}}{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | 4 | <div class="error-page"> |
|
| 5 | - | <h2>{{ title }}</h2> |
|
| 6 | - | <p>{{ message }}</p> |
|
| 5 | + | <h2>{{.Title}}</h2> |
|
| 6 | + | <p>{{.Message}}</p> |
|
| 7 | 7 | <p><a href="/">← back to today</a></p> |
|
| 8 | 8 | </div> |
|
| 9 | - | {% endblock %} |
|
| 9 | + | {{end}} |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Easel — {{ today_date }}{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | {% if let Some(artwork) = artwork %} |
|
| 5 | - | {% include "_artwork.html" %} |
|
| 6 | - | {% else %} |
|
| 1 | + | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Easel — {{.TodayDate}}{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | + | {{if .Artwork}} |
|
| 5 | + | {{template "artwork" .Artwork}} |
|
| 6 | + | {{else}} |
|
| 7 | 7 | <div class="empty"> |
|
| 8 | - | <p>Today's artwork ({{ today_date }}) is not yet available. Check back shortly.</p> |
|
| 8 | + | <p>Today's artwork ({{.TodayDate}}) is not yet available. Check back shortly.</p> |
|
| 9 | 9 | </div> |
|
| 10 | - | {% endif %} |
|
| 11 | - | {% endblock %} |
|
| 10 | + | {{end}} |
|
| 11 | + | {{end}} |
| 1 | - | ADMIN_PASSWORD=changeme |
|
| 2 | - | COOKIE_SECURE=false |
|
| 3 | - | BASE_URL=http://localhost:3000 |
|
| 4 | - | HOST=127.0.0.1 |
|
| 5 | - | PORT=3000 |
|
| 6 | - | FEEDS_DB_PATH=/data/feeds-go.sqlite |
|
| 7 | - | API_KEY= |
|
| 8 | - | DEFAULT_POLL_MINUTES=30 |
|
| 9 | - | ITEM_CAP_PER_FEED=200 |
| 1 | - | # Build from repo root: docker build -t feeds-go -f apps/feeds-go/Dockerfile . |
|
| 2 | - | FROM golang:1.25-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/feeds-go/go.mod apps/feeds-go/go.sum ./apps/feeds-go/ |
|
| 6 | - | WORKDIR /app/apps/feeds-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/feeds-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /feeds-go . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ |
|
| 13 | - | && rm -rf /var/lib/apt/lists/* |
|
| 14 | - | COPY --from=builder /feeds-go /usr/local/bin/feeds-go |
|
| 15 | - | WORKDIR /data |
|
| 16 | - | ENV HOST=0.0.0.0 |
|
| 17 | - | ENV PORT=3000 |
|
| 18 | - | ENV GODEBUG=x509usefallbackroots=1 |
|
| 19 | - | EXPOSE 3000 |
|
| 20 | - | CMD ["feeds-go"] |
| 1 | - | # Feeds Go |
|
| 2 | - | ||
| 3 | - | A Go rewrite of `apps/feeds` using mostly the Go standard library plus a SQLite driver and a feed parser. |
|
| 4 | - | ||
| 5 | - | ## Stack |
|
| 6 | - | ||
| 7 | - | - `net/http` |
|
| 8 | - | - `html/template` |
|
| 9 | - | - `database/sql` |
|
| 10 | - | - `embed` |
|
| 11 | - | - `modernc.org/sqlite` |
|
| 12 | - | - `github.com/mmcdole/gofeed` |
|
| 13 | - | ||
| 14 | - | ## Run |
|
| 15 | - | ||
| 16 | - | ```bash |
|
| 17 | - | cd apps/feeds-go |
|
| 18 | - | go run . |
|
| 19 | - | ``` |
|
| 20 | - | ||
| 21 | - | Copy `.env.example` to `.env` if you want local config. |
|
| 22 | - | ||
| 23 | - | ## What it includes |
|
| 24 | - | ||
| 25 | - | - public feed list |
|
| 26 | - | - preview mode via `?url=` / `?urls=` |
|
| 27 | - | - admin login with cookie sessions |
|
| 28 | - | - add/remove subscriptions and categories |
|
| 29 | - | - OPML import |
|
| 30 | - | - JSON API |
|
| 31 | - | - background polling with ETag / Last-Modified |
|
| 32 | - | - embedded templates and static assets |
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/feeds-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | - | environment: |
|
| 9 | - | - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme} |
|
| 10 | - | - FEEDS_DB_PATH=/data/feeds-go.sqlite |
|
| 11 | - | - COOKIE_SECURE=false |
|
| 12 | - | - HOST=0.0.0.0 |
|
| 13 | - | - PORT=${PORT:-3000} |
|
| 14 | - | - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}} |
|
| 15 | - | - API_KEY=${API_KEY:-} |
|
| 16 | - | - DEFAULT_POLL_MINUTES=${DEFAULT_POLL_MINUTES:-30} |
|
| 17 | - | - ITEM_CAP_PER_FEED=${ITEM_CAP_PER_FEED:-200} |
|
| 18 | - | volumes: |
|
| 19 | - | - feeds-go-data:/data |
|
| 20 | - | restart: unless-stopped |
|
| 21 | - | ||
| 22 | - | volumes: |
|
| 23 | - | feeds-go-data: |
| 42 | 42 | Published int64 |
|
| 43 | 43 | } |
|
| 44 | 44 | ||
| 45 | - | const appUserAgent = "andromeda-feeds-go/0.1 (+https://github.com/stevedylandev/andromeda)" |
|
| 45 | + | const appUserAgent = "andromeda-feeds/0.1 (+https://github.com/stevedylandev/andromeda)" |
|
| 46 | 46 | ||
| 47 | 47 | func buildHTTPClient() *http.Client { |
|
| 48 | 48 | return &http.Client{Timeout: 15 * time.Second} |
| 1 | - | module github.com/stevedylandev/andromeda/apps/feeds-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/feeds |
|
| 2 | 2 | ||
| 3 | 3 | go 1.25.0 |
|
| 4 | 4 |
| 57 | 57 | go app.poller(context.Background()) |
|
| 58 | 58 | ||
| 59 | 59 | addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000") |
|
| 60 | - | logger.Info("feeds-go server running", "addr", addr) |
|
| 60 | + | logger.Info("feeds server running", "addr", addr) |
|
| 61 | 61 | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 62 | 62 | log.Fatal(err) |
|
| 63 | 63 | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* feeds — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | /* The logo wraps an h1 in feeds markup. */ |
|
| 6 | - | ||
| 7 | - | .logo h1 { |
|
| 8 | - | font-size: 28px; |
|
| 9 | - | font-weight: 700; |
|
| 10 | - | text-transform: uppercase; |
|
| 11 | - | } |
|
| 12 | - | ||
| 13 | - | .about { |
|
| 14 | - | display: flex; |
|
| 15 | - | flex-direction: column; |
|
| 16 | - | gap: 0.5rem; |
|
| 17 | - | font-size: 14px; |
|
| 18 | - | line-height: 1.25rem; |
|
| 19 | - | } |
|
| 20 | - | ||
| 21 | - | /* Feeds list */ |
|
| 22 | - | ||
| 23 | - | #feeds-container { |
|
| 24 | - | width: 100%; |
|
| 25 | - | } |
|
| 26 | - | ||
| 27 | - | .feeds-list { |
|
| 28 | - | width: 100%; |
|
| 29 | - | display: flex; |
|
| 30 | - | flex-direction: column; |
|
| 31 | - | gap: 1.5rem; |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | .feed-item { |
|
| 35 | - | width: 100%; |
|
| 36 | - | display: flex; |
|
| 37 | - | flex-direction: column; |
|
| 38 | - | gap: 0.5rem; |
|
| 39 | - | padding: 1rem 0; |
|
| 40 | - | border-bottom: 1px solid #333; |
|
| 41 | - | } |
|
| 42 | - | ||
| 43 | - | .feed-item:last-child { |
|
| 44 | - | border-bottom: none; |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | .feed-meta { |
|
| 48 | - | display: flex; |
|
| 49 | - | justify-content: space-between; |
|
| 50 | - | align-items: center; |
|
| 51 | - | font-size: 12px; |
|
| 52 | - | opacity: 0.5; |
|
| 53 | - | } |
|
| 54 | - | ||
| 55 | - | .feed-source { |
|
| 56 | - | font-weight: 700; |
|
| 57 | - | } |
|
| 58 | - | ||
| 59 | - | .feed-title { |
|
| 60 | - | font-size: 16px; |
|
| 61 | - | font-weight: 400; |
|
| 62 | - | line-height: 1.4; |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | - | .feed-title a { |
|
| 66 | - | text-decoration: none; |
|
| 67 | - | } |
|
| 68 | - | ||
| 69 | - | .feed-author { |
|
| 70 | - | font-size: 12px; |
|
| 71 | - | opacity: 0.5; |
|
| 72 | - | font-style: italic; |
|
| 73 | - | } |
|
| 74 | - | ||
| 75 | - | #feed-urls { |
|
| 76 | - | font-size: 12px; |
|
| 77 | - | opacity: 0.5; |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | - | .no-feeds, |
|
| 81 | - | #loading { |
|
| 82 | - | text-align: center; |
|
| 83 | - | opacity: 0.5; |
|
| 84 | - | padding: 2rem; |
|
| 85 | - | } |
|
| 86 | - | ||
| 87 | - | #error { |
|
| 88 | - | text-align: center; |
|
| 89 | - | padding: 2rem; |
|
| 90 | - | } |
|
| 91 | - | ||
| 92 | - | /* Admin forms */ |
|
| 93 | - | ||
| 94 | - | .admin-form { |
|
| 95 | - | display: flex; |
|
| 96 | - | flex-direction: column; |
|
| 97 | - | gap: 0.75rem; |
|
| 98 | - | width: 100%; |
|
| 99 | - | } |
|
| 100 | - | ||
| 101 | - | .admin-form h3 { |
|
| 102 | - | font-size: 14px; |
|
| 103 | - | font-weight: 400; |
|
| 104 | - | opacity: 0.5; |
|
| 105 | - | } |
|
| 106 | - | ||
| 107 | - | .admin-notice, |
|
| 108 | - | .hint { |
|
| 109 | - | font-size: 12px; |
|
| 110 | - | opacity: 0.5; |
|
| 111 | - | line-height: 1.4; |
|
| 112 | - | } |
|
| 113 | - | ||
| 114 | - | /* Discover panel */ |
|
| 115 | - | ||
| 116 | - | .discover-row { |
|
| 117 | - | display: flex; |
|
| 118 | - | gap: 0.5rem; |
|
| 119 | - | width: 100%; |
|
| 120 | - | } |
|
| 121 | - | ||
| 122 | - | .discover-row input { |
|
| 123 | - | flex: 1; |
|
| 124 | - | } |
|
| 125 | - | ||
| 126 | - | .discover-status { |
|
| 127 | - | font-size: 12px; |
|
| 128 | - | } |
|
| 129 | - | ||
| 130 | - | .discover-results { |
|
| 131 | - | display: flex; |
|
| 132 | - | flex-direction: column; |
|
| 133 | - | gap: 0.25rem; |
|
| 134 | - | width: 100%; |
|
| 135 | - | } |
|
| 136 | - | ||
| 137 | - | .discover-result-item { |
|
| 138 | - | background: #121113; |
|
| 139 | - | color: #ffffff; |
|
| 140 | - | border: 1px solid #333; |
|
| 141 | - | padding: 8px 10px; |
|
| 142 | - | font-size: 12px; |
|
| 143 | - | text-align: left; |
|
| 144 | - | cursor: pointer; |
|
| 145 | - | width: 100%; |
|
| 146 | - | white-space: nowrap; |
|
| 147 | - | overflow: hidden; |
|
| 148 | - | text-overflow: ellipsis; |
|
| 149 | - | opacity: 0.7; |
|
| 150 | - | border-radius: 0; |
|
| 151 | - | -webkit-appearance: none; |
|
| 152 | - | appearance: none; |
|
| 153 | - | } |
|
| 154 | - | ||
| 155 | - | .discover-result-item:hover { |
|
| 156 | - | border-color: #555; |
|
| 157 | - | opacity: 1; |
|
| 158 | - | } |
|
| 159 | - | ||
| 160 | - | .discover-result-item.active { |
|
| 161 | - | border-color: #ffffff; |
|
| 162 | - | opacity: 1; |
|
| 163 | - | } |
|
| 164 | - | ||
| 165 | - | /* Admin subs */ |
|
| 166 | - | ||
| 167 | - | .admin-subs { |
|
| 168 | - | width: 100%; |
|
| 169 | - | display: flex; |
|
| 170 | - | flex-direction: column; |
|
| 171 | - | gap: 1rem; |
|
| 172 | - | } |
|
| 173 | - | ||
| 174 | - | .admin-subs h3 { |
|
| 175 | - | font-size: 14px; |
|
| 176 | - | opacity: 0.5; |
|
| 177 | - | font-weight: 400; |
|
| 178 | - | } |
|
| 179 | - | ||
| 180 | - | .feed-item form.inline { |
|
| 181 | - | display: flex; |
|
| 182 | - | gap: 0.5rem; |
|
| 183 | - | align-items: center; |
|
| 184 | - | } |
|
| 185 | - | ||
| 186 | - | .feed-item form.inline input { |
|
| 187 | - | flex: 1; |
|
| 188 | - | } |
|
| 189 | - | ||
| 190 | - | /* Generic .danger on buttons (used in admin) */ |
|
| 191 | - | ||
| 192 | - | button.danger, |
|
| 193 | - | .btn.danger { |
|
| 194 | - | opacity: 0.5; |
|
| 195 | - | } |
|
| 196 | - | ||
| 197 | - | button.danger:hover, |
|
| 198 | - | .btn.danger:hover { |
|
| 199 | - | opacity: 0.3; |
|
| 200 | - | } |
|
| 201 | - | ||
| 202 | - | /* Category list (admin) */ |
|
| 203 | - | ||
| 204 | - | .category-list { |
|
| 205 | - | list-style: none; |
|
| 206 | - | margin-left: 0; |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | .category-list li { |
|
| 210 | - | display: flex; |
|
| 211 | - | justify-content: space-between; |
|
| 212 | - | align-items: center; |
|
| 213 | - | padding: 0.25rem 0; |
|
| 214 | - | } |
|
| 215 | - | ||
| 216 | - | @media (max-width: 480px) { |
|
| 217 | - | .feed-meta { |
|
| 218 | - | flex-direction: column; |
|
| 219 | - | align-items: flex-start; |
|
| 220 | - | gap: 0.25rem; |
|
| 221 | - | } |
|
| 222 | - | ||
| 223 | - | .feed-title { |
|
| 224 | - | font-size: 14px; |
|
| 225 | - | } |
|
| 226 | - | } |
| 3 | 3 | BASE_URL=http://localhost:3000 |
|
| 4 | 4 | HOST=127.0.0.1 |
|
| 5 | 5 | PORT=3000 |
|
| 6 | - | FEEDS_DB_PATH=/data/feeds.sqlite |
|
| 6 | + | FEEDS_DB_PATH=/data/feeds-go.sqlite |
|
| 7 | 7 | API_KEY= |
|
| 8 | 8 | DEFAULT_POLL_MINUTES=30 |
|
| 9 | 9 | ITEM_CAP_PER_FEED=200 |
| 1 | - | [package] |
|
| 2 | - | name = "feeds" |
|
| 3 | - | version = "0.3.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Minimal RSS feed reader" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | axum = { workspace = true, features = ["multipart"] } |
|
| 12 | - | tokio = { workspace = true } |
|
| 13 | - | serde = { workspace = true } |
|
| 14 | - | serde_json = { workspace = true } |
|
| 15 | - | dotenvy = { workspace = true } |
|
| 16 | - | rust-embed = { workspace = true } |
|
| 17 | - | subtle = { workspace = true } |
|
| 18 | - | rand = { workspace = true } |
|
| 19 | - | rusqlite = { workspace = true } |
|
| 20 | - | tracing = { workspace = true } |
|
| 21 | - | tracing-subscriber = { workspace = true, features = ["env-filter"] } |
|
| 22 | - | andromeda-auth = { workspace = true } |
|
| 23 | - | andromeda-db = { workspace = true, features = ["axum", "session", "feeds"] } |
|
| 24 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 25 | - | askama = "0.13" |
|
| 26 | - | reqwest = { version = "0.12", features = ["json"] } |
|
| 27 | - | feed-rs = "2" |
|
| 28 | - | chrono = "0.4" |
|
| 29 | - | quick-xml = "0.37" |
|
| 30 | - | scraper = "0.22" |
|
| 31 | - | url = "2" |
|
| 32 | - | mime_guess = "2" |
|
| 33 | - | urlencoding = "2" |
|
| 34 | - | tower-http = { version = "0.6.8", features = ["cors"] } |
| 1 | 1 | # Build from repo root: docker build -t feeds -f apps/feeds/Dockerfile . |
|
| 2 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 2 | + | FROM golang:1.25-bookworm AS builder |
|
| 3 | 3 | WORKDIR /app |
|
| 4 | - | ||
| 5 | - | FROM chef AS planner |
|
| 6 | - | COPY . . |
|
| 7 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 8 | - | ||
| 9 | - | FROM chef AS builder |
|
| 10 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 11 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 12 | - | RUN cargo chef cook --release --recipe-path recipe.json -p feeds |
|
| 13 | - | COPY . . |
|
| 14 | - | RUN cargo build --release -p feeds |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/feeds/go.mod apps/feeds/go.sum ./apps/feeds/ |
|
| 6 | + | WORKDIR /app/apps/feeds |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/feeds/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /feeds . |
|
| 15 | 10 | ||
| 16 | 11 | FROM debian:bookworm-slim |
|
| 17 | - | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 18 | - | COPY --from=builder /app/target/release/feeds /usr/local/bin/feeds |
|
| 12 | + | RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ |
|
| 13 | + | && rm -rf /var/lib/apt/lists/* |
|
| 14 | + | COPY --from=builder /feeds /usr/local/bin/feeds |
|
| 19 | 15 | WORKDIR /data |
|
| 20 | - | EXPOSE 3000 |
|
| 21 | 16 | ENV HOST=0.0.0.0 |
|
| 22 | 17 | ENV PORT=3000 |
|
| 18 | + | ENV GODEBUG=x509usefallbackroots=1 |
|
| 19 | + | EXPOSE 3000 |
|
| 23 | 20 | CMD ["feeds"] |
| 1 | - | MIT License |
|
| 2 | - | ||
| 3 | - | Copyright (c) 2026 Steve Simkins |
|
| 4 | - | ||
| 5 | - | Permission is hereby granted, free of charge, to any person obtaining a copy |
|
| 6 | - | of this software and associated documentation files (the "Software"), to deal |
|
| 7 | - | in the Software without restriction, including without limitation the rights |
|
| 8 | - | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
| 9 | - | copies of the Software, and to permit persons to whom the Software is |
|
| 10 | - | furnished to do so, subject to the following conditions: |
|
| 11 | - | ||
| 12 | - | The above copyright notice and this permission notice shall be included in all |
|
| 13 | - | copies or substantial portions of the Software. |
|
| 14 | - | ||
| 15 | - | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
| 16 | - | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
| 17 | - | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
| 18 | - | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
| 19 | - | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
| 20 | - | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
| 21 | - | SOFTWARE. |
|
| 22 | - |
| 1 | - | # Feeds |
|
| 1 | + | # Feeds Go |
|
| 2 | 2 | ||
| 3 | - |  |
|
| 3 | + | A Go rewrite of `apps/feeds` using mostly the Go standard library plus a SQLite driver and a feed parser. |
|
| 4 | 4 | ||
| 5 | - | Minimal RSS Feeds |
|
| 5 | + | ## Stack |
|
| 6 | 6 | ||
| 7 | - | ## Quickstart |
|
| 8 | - | ||
| 9 | - | 1. Make sure [Rust](https://www.rust-lang.org/tools/install) is installed |
|
| 10 | - | ||
| 11 | - | ```bash |
|
| 12 | - | rustc --version |
|
| 13 | - | ``` |
|
| 7 | + | - `net/http` |
|
| 8 | + | - `html/template` |
|
| 9 | + | - `database/sql` |
|
| 10 | + | - `embed` |
|
| 11 | + | - `modernc.org/sqlite` |
|
| 12 | + | - `github.com/mmcdole/gofeed` |
|
| 14 | 13 | ||
| 15 | - | 2. Clone and build |
|
| 14 | + | ## Run |
|
| 16 | 15 | ||
| 17 | 16 | ```bash |
|
| 18 | - | git clone https://github.com/stevedylandev/andromeda |
|
| 19 | - | cd andromeda |
|
| 20 | - | cargo build -p feeds |
|
| 17 | + | cd apps/feeds-go |
|
| 18 | + | go run . |
|
| 21 | 19 | ``` |
|
| 22 | 20 | ||
| 23 | - | 3. Run the dev server |
|
| 24 | - | ||
| 25 | - | ```bash |
|
| 26 | - | cargo run -p feeds |
|
| 27 | - | # Server running on http://localhost:3000 |
|
| 28 | - | ``` |
|
| 21 | + | Copy `.env.example` to `.env` if you want local config. |
|
| 29 | 22 | ||
| 30 | - | ### Environment Variables |
|
| 23 | + | ## What it includes |
|
| 31 | 24 | ||
| 32 | - | | Variable | Description | Default | |
|
| 33 | - | |---|---|---| |
|
| 34 | - | | `ADMIN_PASSWORD` | Password for the admin panel | — | |
|
| 35 | - | | `API_KEY` | Bearer token for the JSON API at `/api/*` | — | |
|
| 36 | - | | `BASE_URL` | Public base URL of the app | `http://localhost:3000` | |
|
| 37 | - | | `HOST` | Bind address | `0.0.0.0` | |
|
| 38 | - | | `PORT` | Bind port | `3000` | |
|
| 39 | - | | `FEEDS_DB_PATH` | SQLite database path | `/data/feeds.sqlite` | |
|
| 40 | - | | `DEFAULT_POLL_MINUTES` | Background poll interval in minutes (overridable from the admin panel) | `30` | |
|
| 41 | - | | `ITEM_CAP_PER_FEED` | Maximum stored items per subscription; older items pruned | `200` | |
|
| 42 | - | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 43 | - | ||
| 44 | - | ## Overview |
|
| 45 | - | ||
| 46 | - | Feeds is a minimal RSS reader that mimics the original experience of RSS. It's just a list of posts. No categories, no marking a post read or unread, and there is no in-app reading. With this approach you have to read the post on the author's personal website and experience it in its original context. A few highlights: |
|
| 47 | - | ||
| 48 | - | - Single Rust binary with embedded assets |
|
| 49 | - | - Local SQLite storage with a background poller (ETag / `If-Modified-Since` aware) |
|
| 50 | - | - Password-protected admin panel for managing subscriptions and categories |
|
| 51 | - | - OPML import and JSON/OPML export |
|
| 52 | - | - Feed discovery from any site URL |
|
| 53 | - | - JSON REST API guarded by a Bearer token |
|
| 54 | - | - Ad-hoc preview by passing feed URLs as query params |
|
| 55 | - | - Dark themed UI with Commit Mono font |
|
| 56 | - | ||
| 57 | - | ## Usage |
|
| 58 | - | ||
| 59 | - | ### Admin Panel |
|
| 60 | - | ||
| 61 | - | Set `ADMIN_PASSWORD` and visit `/admin/login`. From the admin panel you can: |
|
| 62 | - | ||
| 63 | - | - Add feeds by URL (title and site URL are auto-detected on first fetch) |
|
| 64 | - | - Discover feeds from any site URL |
|
| 65 | - | - Import an OPML file |
|
| 66 | - | - Organize subscriptions into categories |
|
| 67 | - | - Adjust the poll interval |
|
| 68 | - | ||
| 69 | - | The background poller starts automatically on launch and re-polls every `DEFAULT_POLL_MINUTES` (or the value saved in the admin panel). Items are deduplicated by GUID and each subscription is capped at `ITEM_CAP_PER_FEED`. |
|
| 70 | - | ||
| 71 | - | ### URL Query Param (preview mode) |
|
| 72 | - | ||
| 73 | - | You can preview any feed without subscribing by passing it via query string: |
|
| 74 | - | ||
| 75 | - | ``` |
|
| 76 | - | ?url=https://bearblog.dev/discover/feed/ |
|
| 77 | - | ?urls=https://bearblog.dev/discover/feed/,https://bearblog.stevedylan.dev/feed/ |
|
| 78 | - | ``` |
|
| 79 | - | ||
| 80 | - | Preview mode bypasses the database and renders whatever the feed returns live. |
|
| 81 | - | ||
| 82 | - | ### Feeds Export |
|
| 83 | - | ||
| 84 | - | The `/feeds` endpoint exports your subscriptions: |
|
| 85 | - | ||
| 86 | - | ``` |
|
| 87 | - | /feeds?format=json |
|
| 88 | - | /feeds?format=opml |
|
| 89 | - | ``` |
|
| 90 | - | ||
| 91 | - | ### JSON API |
|
| 92 | - | ||
| 93 | - | Set `API_KEY` to enable programmatic access. All `/api/*` routes accept `Authorization: Bearer <API_KEY>` or a valid admin session cookie. |
|
| 94 | - | ||
| 95 | - | | Method | Path | Purpose | |
|
| 96 | - | |---|---|---| |
|
| 97 | - | | `GET` | `/api/items` | List items. Query: `limit`, `unread`, `category_id`, `subscription_id` | |
|
| 98 | - | | `POST` | `/api/items/{id}/read` | Mark item read | |
|
| 99 | - | | `POST` | `/api/items/{id}/unread` | Mark item unread | |
|
| 100 | - | | `GET` | `/api/subscriptions` | List subscriptions | |
|
| 101 | - | | `POST` | `/api/subscriptions` | Add subscription. Body: `{feed_url, title?, category_id?, category_name?}` | |
|
| 102 | - | | `PATCH` | `/api/subscriptions/{id}` | Update subscription. Body: `{category_id?, category_name?, clear_category?}` | |
|
| 103 | - | | `DELETE` | `/api/subscriptions/{id}` | Remove subscription | |
|
| 104 | - | | `GET` | `/api/categories` | List categories | |
|
| 105 | - | | `POST` | `/api/categories` | Create category. Body: `{name}` | |
|
| 106 | - | | `DELETE` | `/api/categories/{id}` | Remove category | |
|
| 107 | - | | `POST` | `/api/import/opml` | Import OPML (multipart `file` field) | |
|
| 108 | - | | `GET` | `/api/settings` | Get poll interval and item cap | |
|
| 109 | - | | `PUT` | `/api/settings` | Update `poll_interval_minutes` (1-1440) | |
|
| 110 | - | | `POST` | `/api/discover` | Discover feeds for a site. Body: `{base_url}` | |
|
| 111 | - | ||
| 112 | - | ## Structure |
|
| 113 | - | ||
| 114 | - | ``` |
|
| 115 | - | feeds/ |
|
| 116 | - | ├── src/ |
|
| 117 | - | │ ├── main.rs # Axum server, admin routes, templates, static serving |
|
| 118 | - | │ ├── api.rs # JSON REST API handlers |
|
| 119 | - | │ ├── poller.rs # Background feed poller |
|
| 120 | - | │ ├── feeds.rs # Feed fetching, OPML parsing, feed discovery |
|
| 121 | - | │ ├── auth.rs # Session + API-key guards |
|
| 122 | - | │ └── models.rs # Data structures |
|
| 123 | - | ├── templates/ # Askama HTML templates |
|
| 124 | - | ├── static/ # Static assets embedded at compile time via rust-embed |
|
| 125 | - | ├── Dockerfile |
|
| 126 | - | └── docker-compose.yml |
|
| 127 | - | ``` |
|
| 128 | - | ||
| 129 | - | Subscription and item storage lives in `crates/db/src/feeds.rs` (shared `andromeda-db` crate). |
|
| 130 | - | ||
| 131 | - | ## Deployment |
|
| 132 | - | ||
| 133 | - | Since Feeds compiles to a single binary, deployment is straightforward on any platform. |
|
| 134 | - | ||
| 135 | - | ### Railway |
|
| 136 | - | ||
| 137 | - | [](https://railway.com/deploy/Ezvmhx?referralCode=JGcIp6) |
|
| 138 | - | ||
| 139 | - | ### Docker (recommended) |
|
| 140 | - | ||
| 141 | - | ```bash |
|
| 142 | - | git clone https://github.com/stevedylandev/andromeda |
|
| 143 | - | cd andromeda/apps/feeds |
|
| 144 | - | cp .env.example .env |
|
| 145 | - | # Edit .env with your credentials |
|
| 146 | - | docker compose up -d |
|
| 147 | - | ``` |
|
| 148 | - | ||
| 149 | - | Mount a volume at `FEEDS_DB_PATH` to persist the SQLite database. |
|
| 150 | - | ||
| 151 | - | ### Binary |
|
| 152 | - | ||
| 153 | - | ```bash |
|
| 154 | - | cargo build --release -p feeds |
|
| 155 | - | ``` |
|
| 156 | - | ||
| 157 | - | The resulting binary at `./target/release/feeds` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 158 | - | ||
| 159 | - | ## License |
|
| 160 | - | ||
| 161 | - | [MIT](LICENSE) |
|
| 25 | + | - public feed list |
|
| 26 | + | - preview mode via `?url=` / `?urls=` |
|
| 27 | + | - admin login with cookie sessions |
|
| 28 | + | - add/remove subscriptions and categories |
|
| 29 | + | - OPML import |
|
| 30 | + | - JSON API |
|
| 31 | + | - background polling with ETag / Last-Modified |
|
| 32 | + | - embedded templates and static assets |
| 1 | - | [general] |
|
| 2 | - | dirs = ["src/templates"] |
| 2 | 2 | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/feeds/Dockerfile |
|
| 5 | + | dockerfile: apps/feeds-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | 7 | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | 8 | environment: |
|
| 9 | 9 | - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme} |
|
| 10 | - | - API_KEY=${API_KEY:-} |
|
| 11 | - | - FEEDS_DB_PATH=/data/feeds.sqlite |
|
| 12 | - | - BASE_URL=${BASE_URL:-http://localhost:3000} |
|
| 10 | + | - FEEDS_DB_PATH=/data/feeds-go.sqlite |
|
| 13 | 11 | - COOKIE_SECURE=false |
|
| 14 | 12 | - HOST=0.0.0.0 |
|
| 15 | 13 | - PORT=${PORT:-3000} |
|
| 14 | + | - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}} |
|
| 15 | + | - API_KEY=${API_KEY:-} |
|
| 16 | + | - DEFAULT_POLL_MINUTES=${DEFAULT_POLL_MINUTES:-30} |
|
| 17 | + | - ITEM_CAP_PER_FEED=${ITEM_CAP_PER_FEED:-200} |
|
| 16 | 18 | volumes: |
|
| 17 | - | - feeds-data:/data |
|
| 19 | + | - feeds-go-data:/data |
|
| 18 | 20 | restart: unless-stopped |
|
| 19 | 21 | ||
| 20 | 22 | volumes: |
|
| 21 | - | feeds-data: |
|
| 23 | + | feeds-go-data: |
| 1 | - | use std::sync::Arc; |
|
| 2 | - | ||
| 3 | - | use andromeda_db::feeds as fdb; |
|
| 4 | - | use axum::{ |
|
| 5 | - | extract::{Multipart, Path, Query, State}, |
|
| 6 | - | http::StatusCode, |
|
| 7 | - | response::{IntoResponse, Response}, |
|
| 8 | - | Json, |
|
| 9 | - | }; |
|
| 10 | - | use serde::Deserialize; |
|
| 11 | - | ||
| 12 | - | use andromeda_db::Db; |
|
| 13 | - | ||
| 14 | - | use crate::auth::ApiAuth; |
|
| 15 | - | use crate::feeds::{discover_favicon, discover_feeds, fetch_feed, parse_opml, ParsedEntry}; |
|
| 16 | - | use crate::poller::POLL_INTERVAL_KEY; |
|
| 17 | - | use crate::AppState; |
|
| 18 | - | ||
| 19 | - | fn err_json(status: StatusCode, msg: impl Into<String>) -> Response { |
|
| 20 | - | ( |
|
| 21 | - | status, |
|
| 22 | - | Json(serde_json::json!({ "error": msg.into() })), |
|
| 23 | - | ) |
|
| 24 | - | .into_response() |
|
| 25 | - | } |
|
| 26 | - | ||
| 27 | - | // ── Items ───────────────────────────────────────────────────────────── |
|
| 28 | - | ||
| 29 | - | #[derive(Deserialize)] |
|
| 30 | - | pub struct ListItemsQuery { |
|
| 31 | - | limit: Option<i64>, |
|
| 32 | - | #[serde(default)] |
|
| 33 | - | unread: bool, |
|
| 34 | - | category_id: Option<i64>, |
|
| 35 | - | subscription_id: Option<i64>, |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | pub async fn list_items( |
|
| 39 | - | State(state): State<Arc<AppState>>, |
|
| 40 | - | Query(q): Query<ListItemsQuery>, |
|
| 41 | - | ) -> Response { |
|
| 42 | - | let filter = fdb::ListItemsFilter { |
|
| 43 | - | limit: q.limit, |
|
| 44 | - | unread_only: q.unread, |
|
| 45 | - | category_id: q.category_id, |
|
| 46 | - | subscription_id: q.subscription_id, |
|
| 47 | - | }; |
|
| 48 | - | match fdb::list_items(&state.db, &filter) { |
|
| 49 | - | Ok(items) => Json(serde_json::json!({ "items": items })).into_response(), |
|
| 50 | - | Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 51 | - | } |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | pub async fn mark_item_read( |
|
| 55 | - | _auth: ApiAuth, |
|
| 56 | - | State(state): State<Arc<AppState>>, |
|
| 57 | - | Path(id): Path<i64>, |
|
| 58 | - | ) -> Response { |
|
| 59 | - | match fdb::mark_read(&state.db, id) { |
|
| 60 | - | Ok(true) => Json(serde_json::json!({ "ok": true, "is_read": true })).into_response(), |
|
| 61 | - | Ok(false) => err_json(StatusCode::NOT_FOUND, "item not found"), |
|
| 62 | - | Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 63 | - | } |
|
| 64 | - | } |
|
| 65 | - | ||
| 66 | - | pub async fn mark_item_unread( |
|
| 67 | - | _auth: ApiAuth, |
|
| 68 | - | State(state): State<Arc<AppState>>, |
|
| 69 | - | Path(id): Path<i64>, |
|
| 70 | - | ) -> Response { |
|
| 71 | - | match fdb::mark_unread(&state.db, id) { |
|
| 72 | - | Ok(true) => Json(serde_json::json!({ "ok": true, "is_read": false })).into_response(), |
|
| 73 | - | Ok(false) => err_json(StatusCode::NOT_FOUND, "item not found"), |
|
| 74 | - | Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 75 | - | } |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | // ── Subscriptions ───────────────────────────────────────────────────── |
|
| 79 | - | ||
| 80 | - | pub async fn list_subscriptions( |
|
| 81 | - | State(state): State<Arc<AppState>>, |
|
| 82 | - | ) -> Response { |
|
| 83 | - | match fdb::list_subscriptions(&state.db) { |
|
| 84 | - | Ok(subs) => Json(serde_json::json!({ "subscriptions": subs })).into_response(), |
|
| 85 | - | Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 86 | - | } |
|
| 87 | - | } |
|
| 88 | - | ||
| 89 | - | #[derive(Deserialize)] |
|
| 90 | - | pub struct CreateSubscriptionBody { |
|
| 91 | - | pub feed_url: String, |
|
| 92 | - | pub title: Option<String>, |
|
| 93 | - | pub category_id: Option<i64>, |
|
| 94 | - | pub category_name: Option<String>, |
|
| 95 | - | } |
|
| 96 | - | ||
| 97 | - | pub async fn create_subscription( |
|
| 98 | - | _auth: ApiAuth, |
|
| 99 | - | State(state): State<Arc<AppState>>, |
|
| 100 | - | Json(body): Json<CreateSubscriptionBody>, |
|
| 101 | - | ) -> Response { |
|
| 102 | - | add_subscription(&state, &body).await |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | pub async fn add_subscription( |
|
| 106 | - | state: &AppState, |
|
| 107 | - | body: &CreateSubscriptionBody, |
|
| 108 | - | ) -> Response { |
|
| 109 | - | let feed_url = body.feed_url.trim(); |
|
| 110 | - | if feed_url.is_empty() { |
|
| 111 | - | return err_json(StatusCode::BAD_REQUEST, "feed_url required"); |
|
| 112 | - | } |
|
| 113 | - | ||
| 114 | - | if let Ok(Some(existing)) = fdb::get_subscription_by_url(&state.db, feed_url) { |
|
| 115 | - | return ( |
|
| 116 | - | StatusCode::CONFLICT, |
|
| 117 | - | Json(serde_json::json!({ |
|
| 118 | - | "error": "already subscribed", |
|
| 119 | - | "subscription": existing |
|
| 120 | - | })), |
|
| 121 | - | ) |
|
| 122 | - | .into_response(); |
|
| 123 | - | } |
|
| 124 | - | ||
| 125 | - | // Probe once to resolve title + site_url + initial entries. |
|
| 126 | - | let probed = fetch_feed(feed_url, None, None).await; |
|
| 127 | - | let (title, site_url, etag, last_modified, entries) = match probed { |
|
| 128 | - | Ok(r) => ( |
|
| 129 | - | body.title |
|
| 130 | - | .clone() |
|
| 131 | - | .or(r.title) |
|
| 132 | - | .unwrap_or_else(|| feed_url.to_string()), |
|
| 133 | - | r.site_url, |
|
| 134 | - | r.etag, |
|
| 135 | - | r.last_modified, |
|
| 136 | - | r.entries, |
|
| 137 | - | ), |
|
| 138 | - | Err(e) => { |
|
| 139 | - | return err_json( |
|
| 140 | - | StatusCode::BAD_REQUEST, |
|
| 141 | - | format!("feed not reachable: {e}"), |
|
| 142 | - | ); |
|
| 143 | - | } |
|
| 144 | - | }; |
|
| 145 | - | ||
| 146 | - | let category_id = match resolve_category(state, body.category_id, body.category_name.as_deref()) |
|
| 147 | - | { |
|
| 148 | - | Ok(id) => id, |
|
| 149 | - | Err(resp) => return resp, |
|
| 150 | - | }; |
|
| 151 | - | ||
| 152 | - | let mut sub = match fdb::insert_subscription( |
|
| 153 | - | &state.db, |
|
| 154 | - | feed_url, |
|
| 155 | - | &title, |
|
| 156 | - | site_url.as_deref(), |
|
| 157 | - | category_id, |
|
| 158 | - | ) { |
|
| 159 | - | Ok(s) => s, |
|
| 160 | - | Err(e) => return err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 161 | - | }; |
|
| 162 | - | ||
| 163 | - | if let Some(site) = site_url.as_deref() { |
|
| 164 | - | if let Some(fav) = discover_favicon(site).await { |
|
| 165 | - | let _ = fdb::update_subscription_favicon(&state.db, sub.id, Some(&fav)); |
|
| 166 | - | sub.favicon_url = Some(fav); |
|
| 167 | - | } |
|
| 168 | - | } |
|
| 169 | - | ||
| 170 | - | seed_subscription( |
|
| 171 | - | &state.db, |
|
| 172 | - | sub.id, |
|
| 173 | - | &entries, |
|
| 174 | - | etag.as_deref(), |
|
| 175 | - | last_modified.as_deref(), |
|
| 176 | - | state.item_cap, |
|
| 177 | - | ); |
|
| 178 | - | ||
| 179 | - | (StatusCode::CREATED, Json(serde_json::json!({ "subscription": sub }))).into_response() |
|
| 180 | - | } |
|
| 181 | - | ||
| 182 | - | /// Same as `add_subscription` but inserts the row immediately and spawns a |
|
| 183 | - | /// background task to fetch the feed, discover the favicon, and seed items. |
|
| 184 | - | /// Returns instantly so the caller is not blocked on the HTTP round-trip. |
|
| 185 | - | pub async fn add_subscription_background( |
|
| 186 | - | state: Arc<AppState>, |
|
| 187 | - | body: CreateSubscriptionBody, |
|
| 188 | - | ) -> Response { |
|
| 189 | - | let feed_url = body.feed_url.trim().to_string(); |
|
| 190 | - | if feed_url.is_empty() { |
|
| 191 | - | return err_json(StatusCode::BAD_REQUEST, "feed_url required"); |
|
| 192 | - | } |
|
| 193 | - | ||
| 194 | - | if let Ok(Some(existing)) = fdb::get_subscription_by_url(&state.db, &feed_url) { |
|
| 195 | - | return ( |
|
| 196 | - | StatusCode::CONFLICT, |
|
| 197 | - | Json(serde_json::json!({ |
|
| 198 | - | "error": "already subscribed", |
|
| 199 | - | "subscription": existing |
|
| 200 | - | })), |
|
| 201 | - | ) |
|
| 202 | - | .into_response(); |
|
| 203 | - | } |
|
| 204 | - | ||
| 205 | - | let category_id = |
|
| 206 | - | match resolve_category(&state, body.category_id, body.category_name.as_deref()) { |
|
| 207 | - | Ok(id) => id, |
|
| 208 | - | Err(resp) => return resp, |
|
| 209 | - | }; |
|
| 210 | - | ||
| 211 | - | let title = body.title.clone().unwrap_or_else(|| feed_url.clone()); |
|
| 212 | - | ||
| 213 | - | let sub = match fdb::insert_subscription(&state.db, &feed_url, &title, None, category_id) { |
|
| 214 | - | Ok(s) => s, |
|
| 215 | - | Err(e) => return err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 216 | - | }; |
|
| 217 | - | ||
| 218 | - | let state = Arc::clone(&state); |
|
| 219 | - | let user_title = body.title; |
|
| 220 | - | let sub_id = sub.id; |
|
| 221 | - | let sub_title = sub.title.clone(); |
|
| 222 | - | ||
| 223 | - | tokio::spawn(async move { |
|
| 224 | - | match fetch_feed(&feed_url, None, None).await { |
|
| 225 | - | Ok(result) => { |
|
| 226 | - | let resolved_title = user_title.unwrap_or_else(|| { |
|
| 227 | - | result.title.unwrap_or_else(|| feed_url.clone()) |
|
| 228 | - | }); |
|
| 229 | - | if resolved_title != sub_title { |
|
| 230 | - | let _ = fdb::update_subscription_title(&state.db, sub_id, &resolved_title); |
|
| 231 | - | } |
|
| 232 | - | ||
| 233 | - | if let Some(site) = result.site_url.as_deref() { |
|
| 234 | - | let _ = fdb::update_subscription_site_url(&state.db, sub_id, Some(site)); |
|
| 235 | - | if let Some(fav) = discover_favicon(site).await { |
|
| 236 | - | let _ = fdb::update_subscription_favicon(&state.db, sub_id, Some(&fav)); |
|
| 237 | - | } |
|
| 238 | - | } |
|
| 239 | - | ||
| 240 | - | seed_subscription( |
|
| 241 | - | &state.db, |
|
| 242 | - | sub_id, |
|
| 243 | - | &result.entries, |
|
| 244 | - | result.etag.as_deref(), |
|
| 245 | - | result.last_modified.as_deref(), |
|
| 246 | - | state.item_cap, |
|
| 247 | - | ); |
|
| 248 | - | } |
|
| 249 | - | Err(e) => { |
|
| 250 | - | let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); |
|
| 251 | - | let _ = fdb::update_subscription_meta( |
|
| 252 | - | &state.db, |
|
| 253 | - | sub_id, |
|
| 254 | - | None, |
|
| 255 | - | None, |
|
| 256 | - | &now, |
|
| 257 | - | Some(&e), |
|
| 258 | - | ); |
|
| 259 | - | } |
|
| 260 | - | } |
|
| 261 | - | }); |
|
| 262 | - | ||
| 263 | - | (StatusCode::CREATED, Json(serde_json::json!({ "subscription": sub }))).into_response() |
|
| 264 | - | } |
|
| 265 | - | ||
| 266 | - | /// Insert probe entries into the new subscription, prune to the item cap, then |
|
| 267 | - | /// persist etag/last_modified. The order matters: persisting the conditional-fetch |
|
| 268 | - | /// metadata before seeding would let the next poller pass receive a 304 against an |
|
| 269 | - | /// empty subscription, leaving it permanently dry until upstream changes. |
|
| 270 | - | pub(crate) fn seed_subscription( |
|
| 271 | - | db: &Db, |
|
| 272 | - | sub_id: i64, |
|
| 273 | - | entries: &[ParsedEntry], |
|
| 274 | - | etag: Option<&str>, |
|
| 275 | - | last_modified: Option<&str>, |
|
| 276 | - | item_cap: usize, |
|
| 277 | - | ) -> usize { |
|
| 278 | - | let mut inserted = 0usize; |
|
| 279 | - | for entry in entries { |
|
| 280 | - | if entry.link.is_empty() { |
|
| 281 | - | continue; |
|
| 282 | - | } |
|
| 283 | - | match fdb::insert_item_ignore_dup( |
|
| 284 | - | db, |
|
| 285 | - | &fdb::NewItem { |
|
| 286 | - | subscription_id: sub_id, |
|
| 287 | - | guid: &entry.guid, |
|
| 288 | - | title: &entry.title, |
|
| 289 | - | link: &entry.link, |
|
| 290 | - | author: entry.author.as_deref(), |
|
| 291 | - | published_at: entry.published_at, |
|
| 292 | - | }, |
|
| 293 | - | ) { |
|
| 294 | - | Ok(true) => inserted += 1, |
|
| 295 | - | Ok(false) => {} |
|
| 296 | - | Err(e) => tracing::warn!("seed insert failed for sub {sub_id}: {e}"), |
|
| 297 | - | } |
|
| 298 | - | } |
|
| 299 | - | let _ = fdb::prune_subscription(db, sub_id, item_cap as i64); |
|
| 300 | - | let _ = fdb::update_subscription_meta( |
|
| 301 | - | db, |
|
| 302 | - | sub_id, |
|
| 303 | - | etag, |
|
| 304 | - | last_modified, |
|
| 305 | - | &chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), |
|
| 306 | - | None, |
|
| 307 | - | ); |
|
| 308 | - | inserted |
|
| 309 | - | } |
|
| 310 | - | ||
| 311 | - | fn resolve_category( |
|
| 312 | - | state: &AppState, |
|
| 313 | - | id: Option<i64>, |
|
| 314 | - | name: Option<&str>, |
|
| 315 | - | ) -> Result<Option<i64>, Response> { |
|
| 316 | - | if let Some(id) = id { |
|
| 317 | - | return Ok(Some(id)); |
|
| 318 | - | } |
|
| 319 | - | if let Some(raw) = name { |
|
| 320 | - | let trimmed = raw.trim(); |
|
| 321 | - | if !trimmed.is_empty() { |
|
| 322 | - | let cat = fdb::get_or_create_category(&state.db, trimmed) |
|
| 323 | - | .map_err(|e| err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
|
| 324 | - | return Ok(Some(cat.id)); |
|
| 325 | - | } |
|
| 326 | - | } |
|
| 327 | - | Ok(None) |
|
| 328 | - | } |
|
| 329 | - | ||
| 330 | - | #[derive(Deserialize)] |
|
| 331 | - | pub struct UpdateSubscriptionBody { |
|
| 332 | - | category_id: Option<i64>, |
|
| 333 | - | category_name: Option<String>, |
|
| 334 | - | clear_category: Option<bool>, |
|
| 335 | - | } |
|
| 336 | - | ||
| 337 | - | pub async fn update_subscription( |
|
| 338 | - | _auth: ApiAuth, |
|
| 339 | - | State(state): State<Arc<AppState>>, |
|
| 340 | - | Path(id): Path<i64>, |
|
| 341 | - | Json(body): Json<UpdateSubscriptionBody>, |
|
| 342 | - | ) -> Response { |
|
| 343 | - | let category_id = if body.clear_category.unwrap_or(false) { |
|
| 344 | - | None |
|
| 345 | - | } else { |
|
| 346 | - | match resolve_category(&state, body.category_id, body.category_name.as_deref()) { |
|
| 347 | - | Ok(v) => v, |
|
| 348 | - | Err(resp) => return resp, |
|
| 349 | - | } |
|
| 350 | - | }; |
|
| 351 | - | ||
| 352 | - | match fdb::update_subscription_category(&state.db, id, category_id) { |
|
| 353 | - | Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), |
|
| 354 | - | Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 355 | - | } |
|
| 356 | - | } |
|
| 357 | - | ||
| 358 | - | pub async fn delete_subscription( |
|
| 359 | - | _auth: ApiAuth, |
|
| 360 | - | State(state): State<Arc<AppState>>, |
|
| 361 | - | Path(id): Path<i64>, |
|
| 362 | - | ) -> Response { |
|
| 363 | - | match fdb::delete_subscription(&state.db, id) { |
|
| 364 | - | Ok(true) => Json(serde_json::json!({ "ok": true })).into_response(), |
|
| 365 | - | Ok(false) => err_json(StatusCode::NOT_FOUND, "subscription not found"), |
|
| 366 | - | Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 367 | - | } |
|
| 368 | - | } |
|
| 369 | - | ||
| 370 | - | // ── Categories ──────────────────────────────────────────────────────── |
|
| 371 | - | ||
| 372 | - | pub async fn list_categories( |
|
| 373 | - | State(state): State<Arc<AppState>>, |
|
| 374 | - | ) -> Response { |
|
| 375 | - | match fdb::list_categories(&state.db) { |
|
| 376 | - | Ok(cats) => Json(serde_json::json!({ "categories": cats })).into_response(), |
|
| 377 | - | Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 378 | - | } |
|
| 379 | - | } |
|
| 380 | - | ||
| 381 | - | #[derive(Deserialize)] |
|
| 382 | - | pub struct CreateCategoryBody { |
|
| 383 | - | name: String, |
|
| 384 | - | } |
|
| 385 | - | ||
| 386 | - | pub async fn create_category( |
|
| 387 | - | _auth: ApiAuth, |
|
| 388 | - | State(state): State<Arc<AppState>>, |
|
| 389 | - | Json(body): Json<CreateCategoryBody>, |
|
| 390 | - | ) -> Response { |
|
| 391 | - | let name = body.name.trim(); |
|
| 392 | - | if name.is_empty() { |
|
| 393 | - | return err_json(StatusCode::BAD_REQUEST, "name required"); |
|
| 394 | - | } |
|
| 395 | - | match fdb::get_or_create_category(&state.db, name) { |
|
| 396 | - | Ok(cat) => (StatusCode::CREATED, Json(serde_json::json!({ "category": cat }))).into_response(), |
|
| 397 | - | Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 398 | - | } |
|
| 399 | - | } |
|
| 400 | - | ||
| 401 | - | pub async fn delete_category( |
|
| 402 | - | _auth: ApiAuth, |
|
| 403 | - | State(state): State<Arc<AppState>>, |
|
| 404 | - | Path(id): Path<i64>, |
|
| 405 | - | ) -> Response { |
|
| 406 | - | match fdb::delete_category(&state.db, id) { |
|
| 407 | - | Ok(true) => Json(serde_json::json!({ "ok": true })).into_response(), |
|
| 408 | - | Ok(false) => err_json(StatusCode::NOT_FOUND, "category not found"), |
|
| 409 | - | Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 410 | - | } |
|
| 411 | - | } |
|
| 412 | - | ||
| 413 | - | // ── OPML import ─────────────────────────────────────────────────────── |
|
| 414 | - | ||
| 415 | - | pub async fn import_opml( |
|
| 416 | - | _auth: ApiAuth, |
|
| 417 | - | State(state): State<Arc<AppState>>, |
|
| 418 | - | mut multipart: Multipart, |
|
| 419 | - | ) -> Response { |
|
| 420 | - | let mut content: Option<String> = None; |
|
| 421 | - | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 422 | - | if field.name() == Some("file") { |
|
| 423 | - | match field.text().await { |
|
| 424 | - | Ok(s) => content = Some(s), |
|
| 425 | - | Err(e) => { |
|
| 426 | - | return err_json(StatusCode::BAD_REQUEST, format!("read file failed: {e}")); |
|
| 427 | - | } |
|
| 428 | - | } |
|
| 429 | - | } |
|
| 430 | - | } |
|
| 431 | - | ||
| 432 | - | let content = match content { |
|
| 433 | - | Some(c) => c, |
|
| 434 | - | None => return err_json(StatusCode::BAD_REQUEST, "missing `file` field"), |
|
| 435 | - | }; |
|
| 436 | - | ||
| 437 | - | let result = import_opml_str(state, &content).await; |
|
| 438 | - | Json(serde_json::json!(result)).into_response() |
|
| 439 | - | } |
|
| 440 | - | ||
| 441 | - | #[derive(serde::Serialize)] |
|
| 442 | - | pub struct ImportSummary { |
|
| 443 | - | pub imported: usize, |
|
| 444 | - | pub skipped: usize, |
|
| 445 | - | pub failed: Vec<String>, |
|
| 446 | - | } |
|
| 447 | - | ||
| 448 | - | const SEED_CONCURRENCY: usize = 8; |
|
| 449 | - | ||
| 450 | - | pub async fn import_opml_str(state: Arc<AppState>, content: &str) -> ImportSummary { |
|
| 451 | - | let entries = parse_opml(content); |
|
| 452 | - | let mut imported = 0usize; |
|
| 453 | - | let mut skipped = 0usize; |
|
| 454 | - | let mut failed: Vec<String> = Vec::new(); |
|
| 455 | - | let sem = Arc::new(tokio::sync::Semaphore::new(SEED_CONCURRENCY)); |
|
| 456 | - | let mut seed_handles: Vec<tokio::task::JoinHandle<Option<String>>> = Vec::new(); |
|
| 457 | - | ||
| 458 | - | for entry in entries { |
|
| 459 | - | if let Ok(Some(_)) = fdb::get_subscription_by_url(&state.db, &entry.xml_url) { |
|
| 460 | - | skipped += 1; |
|
| 461 | - | continue; |
|
| 462 | - | } |
|
| 463 | - | ||
| 464 | - | let category_id = entry |
|
| 465 | - | .category |
|
| 466 | - | .as_deref() |
|
| 467 | - | .and_then(|name| fdb::get_or_create_category(&state.db, name).ok()) |
|
| 468 | - | .map(|c| c.id); |
|
| 469 | - | ||
| 470 | - | let title = entry |
|
| 471 | - | .title |
|
| 472 | - | .clone() |
|
| 473 | - | .unwrap_or_else(|| entry.xml_url.clone()); |
|
| 474 | - | let site_url = entry.html_url.clone(); |
|
| 475 | - | ||
| 476 | - | match fdb::insert_subscription( |
|
| 477 | - | &state.db, |
|
| 478 | - | &entry.xml_url, |
|
| 479 | - | &title, |
|
| 480 | - | site_url.as_deref(), |
|
| 481 | - | category_id, |
|
| 482 | - | ) { |
|
| 483 | - | Ok(sub) => { |
|
| 484 | - | imported += 1; |
|
| 485 | - | let state_cloned = Arc::clone(&state); |
|
| 486 | - | let sem_cloned = Arc::clone(&sem); |
|
| 487 | - | let site = site_url.clone(); |
|
| 488 | - | seed_handles.push(tokio::spawn(async move { |
|
| 489 | - | let _permit = match sem_cloned.acquire().await { |
|
| 490 | - | Ok(p) => p, |
|
| 491 | - | Err(_) => return None, |
|
| 492 | - | }; |
|
| 493 | - | if let Some(site) = site.as_deref() { |
|
| 494 | - | if let Some(fav) = discover_favicon(site).await { |
|
| 495 | - | let _ = fdb::update_subscription_favicon( |
|
| 496 | - | &state_cloned.db, |
|
| 497 | - | sub.id, |
|
| 498 | - | Some(&fav), |
|
| 499 | - | ); |
|
| 500 | - | } |
|
| 501 | - | } |
|
| 502 | - | crate::poller::poll_one(&state_cloned, &sub) |
|
| 503 | - | .await |
|
| 504 | - | .err() |
|
| 505 | - | .map(|e| format!("{}: seed failed: {}", sub.feed_url, e)) |
|
| 506 | - | })); |
|
| 507 | - | } |
|
| 508 | - | Err(e) => failed.push(format!("{}: {}", entry.xml_url, e)), |
|
| 509 | - | } |
|
| 510 | - | } |
|
| 511 | - | ||
| 512 | - | for h in seed_handles { |
|
| 513 | - | if let Ok(Some(msg)) = h.await { |
|
| 514 | - | failed.push(msg); |
|
| 515 | - | } |
|
| 516 | - | } |
|
| 517 | - | ||
| 518 | - | ImportSummary { |
|
| 519 | - | imported, |
|
| 520 | - | skipped, |
|
| 521 | - | failed, |
|
| 522 | - | } |
|
| 523 | - | } |
|
| 524 | - | ||
| 525 | - | // ── Settings ────────────────────────────────────────────────────────── |
|
| 526 | - | ||
| 527 | - | #[derive(serde::Serialize)] |
|
| 528 | - | struct SettingsView { |
|
| 529 | - | poll_interval_minutes: u64, |
|
| 530 | - | default_poll_minutes: u64, |
|
| 531 | - | item_cap_per_feed: usize, |
|
| 532 | - | api_key_configured: bool, |
|
| 533 | - | } |
|
| 534 | - | ||
| 535 | - | pub async fn get_settings( |
|
| 536 | - | State(state): State<Arc<AppState>>, |
|
| 537 | - | ) -> Response { |
|
| 538 | - | let poll = fdb::get_setting(&state.db, POLL_INTERVAL_KEY) |
|
| 539 | - | .ok() |
|
| 540 | - | .flatten() |
|
| 541 | - | .and_then(|v| v.parse::<u64>().ok()) |
|
| 542 | - | .unwrap_or(state.default_poll_minutes); |
|
| 543 | - | let view = SettingsView { |
|
| 544 | - | poll_interval_minutes: poll, |
|
| 545 | - | default_poll_minutes: state.default_poll_minutes, |
|
| 546 | - | item_cap_per_feed: state.item_cap, |
|
| 547 | - | api_key_configured: state.api_key.is_some(), |
|
| 548 | - | }; |
|
| 549 | - | Json(serde_json::json!(view)).into_response() |
|
| 550 | - | } |
|
| 551 | - | ||
| 552 | - | #[derive(Deserialize)] |
|
| 553 | - | pub struct UpdateSettingsBody { |
|
| 554 | - | poll_interval_minutes: Option<u64>, |
|
| 555 | - | } |
|
| 556 | - | ||
| 557 | - | pub async fn update_settings( |
|
| 558 | - | _auth: ApiAuth, |
|
| 559 | - | State(state): State<Arc<AppState>>, |
|
| 560 | - | Json(body): Json<UpdateSettingsBody>, |
|
| 561 | - | ) -> Response { |
|
| 562 | - | if let Some(mins) = body.poll_interval_minutes { |
|
| 563 | - | if !(1..=1440).contains(&mins) { |
|
| 564 | - | return err_json( |
|
| 565 | - | StatusCode::BAD_REQUEST, |
|
| 566 | - | "poll_interval_minutes must be between 1 and 1440", |
|
| 567 | - | ); |
|
| 568 | - | } |
|
| 569 | - | if let Err(e) = fdb::set_setting(&state.db, POLL_INTERVAL_KEY, &mins.to_string()) { |
|
| 570 | - | return err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()); |
|
| 571 | - | } |
|
| 572 | - | } |
|
| 573 | - | Json(serde_json::json!({ "ok": true })).into_response() |
|
| 574 | - | } |
|
| 575 | - | ||
| 576 | - | // ── Discover ────────────────────────────────────────────────────────── |
|
| 577 | - | ||
| 578 | - | #[derive(Deserialize)] |
|
| 579 | - | pub struct DiscoverBody { |
|
| 580 | - | base_url: String, |
|
| 581 | - | } |
|
| 582 | - | ||
| 583 | - | pub async fn discover( |
|
| 584 | - | _auth: ApiAuth, |
|
| 585 | - | Json(body): Json<DiscoverBody>, |
|
| 586 | - | ) -> Response { |
|
| 587 | - | match discover_feeds(&body.base_url).await { |
|
| 588 | - | Ok(feeds) => Json(serde_json::json!({ "feeds": feeds })).into_response(), |
|
| 589 | - | Err(e) => err_json(StatusCode::BAD_REQUEST, e), |
|
| 590 | - | } |
|
| 591 | - | } |
|
| 592 | - | ||
| 593 | - | #[cfg(test)] |
|
| 594 | - | mod tests { |
|
| 595 | - | use super::*; |
|
| 596 | - | use andromeda_db::feeds::{ |
|
| 597 | - | get_subscription, insert_subscription, list_items, ListItemsFilter, FEEDS_SCHEMA, |
|
| 598 | - | }; |
|
| 599 | - | use rusqlite::Connection; |
|
| 600 | - | use std::sync::Mutex; |
|
| 601 | - | ||
| 602 | - | fn test_db() -> Db { |
|
| 603 | - | let conn = Connection::open_in_memory().unwrap(); |
|
| 604 | - | conn.execute_batch(FEEDS_SCHEMA).unwrap(); |
|
| 605 | - | Arc::new(Mutex::new(conn)) |
|
| 606 | - | } |
|
| 607 | - | ||
| 608 | - | fn entry(guid: &str, link: &str, ts: i64) -> ParsedEntry { |
|
| 609 | - | ParsedEntry { |
|
| 610 | - | guid: guid.into(), |
|
| 611 | - | title: format!("post {guid}"), |
|
| 612 | - | link: link.into(), |
|
| 613 | - | author: None, |
|
| 614 | - | published_at: ts, |
|
| 615 | - | } |
|
| 616 | - | } |
|
| 617 | - | ||
| 618 | - | #[test] |
|
| 619 | - | fn seed_subscription_inserts_entries_and_persists_meta() { |
|
| 620 | - | let db = test_db(); |
|
| 621 | - | let sub = insert_subscription(&db, "https://x.com/feed", "X", None, None).unwrap(); |
|
| 622 | - | let entries = vec![ |
|
| 623 | - | entry("g1", "https://x.com/1", 100), |
|
| 624 | - | entry("g2", "https://x.com/2", 200), |
|
| 625 | - | ]; |
|
| 626 | - | ||
| 627 | - | let inserted = |
|
| 628 | - | seed_subscription(&db, sub.id, &entries, Some("etag-1"), Some("Sun, 01 Jan"), 50); |
|
| 629 | - | ||
| 630 | - | assert_eq!(inserted, 2); |
|
| 631 | - | let items = list_items(&db, &ListItemsFilter::default()).unwrap(); |
|
| 632 | - | assert_eq!(items.len(), 2); |
|
| 633 | - | let after = get_subscription(&db, sub.id).unwrap().unwrap(); |
|
| 634 | - | assert_eq!(after.etag.as_deref(), Some("etag-1")); |
|
| 635 | - | assert_eq!(after.last_modified.as_deref(), Some("Sun, 01 Jan")); |
|
| 636 | - | assert!(after.last_fetched_at.is_some()); |
|
| 637 | - | assert!(after.last_error.is_none()); |
|
| 638 | - | } |
|
| 639 | - | ||
| 640 | - | #[test] |
|
| 641 | - | fn seed_subscription_skips_empty_links() { |
|
| 642 | - | let db = test_db(); |
|
| 643 | - | let sub = insert_subscription(&db, "https://x.com/feed", "X", None, None).unwrap(); |
|
| 644 | - | let entries = vec![entry("g1", "", 100), entry("g2", "https://x.com/2", 200)]; |
|
| 645 | - | ||
| 646 | - | let inserted = seed_subscription(&db, sub.id, &entries, None, None, 50); |
|
| 647 | - | ||
| 648 | - | assert_eq!(inserted, 1); |
|
| 649 | - | let items = list_items(&db, &ListItemsFilter::default()).unwrap(); |
|
| 650 | - | assert_eq!(items.len(), 1); |
|
| 651 | - | assert_eq!(items[0].guid, "g2"); |
|
| 652 | - | } |
|
| 653 | - | ||
| 654 | - | #[test] |
|
| 655 | - | fn seed_subscription_dedups_on_repeat() { |
|
| 656 | - | let db = test_db(); |
|
| 657 | - | let sub = insert_subscription(&db, "https://x.com/feed", "X", None, None).unwrap(); |
|
| 658 | - | let entries = vec![entry("g1", "https://x.com/1", 100)]; |
|
| 659 | - | ||
| 660 | - | assert_eq!(seed_subscription(&db, sub.id, &entries, None, None, 50), 1); |
|
| 661 | - | assert_eq!(seed_subscription(&db, sub.id, &entries, None, None, 50), 0); |
|
| 662 | - | assert_eq!(list_items(&db, &ListItemsFilter::default()).unwrap().len(), 1); |
|
| 663 | - | } |
|
| 664 | - | ||
| 665 | - | #[test] |
|
| 666 | - | fn seed_subscription_prunes_to_item_cap() { |
|
| 667 | - | let db = test_db(); |
|
| 668 | - | let sub = insert_subscription(&db, "https://x.com/feed", "X", None, None).unwrap(); |
|
| 669 | - | let entries: Vec<_> = (0..10) |
|
| 670 | - | .map(|i| entry(&format!("g{i}"), &format!("https://x.com/{i}"), i as i64)) |
|
| 671 | - | .collect(); |
|
| 672 | - | ||
| 673 | - | seed_subscription(&db, sub.id, &entries, None, None, 3); |
|
| 674 | - | ||
| 675 | - | let items = list_items(&db, &ListItemsFilter::default()).unwrap(); |
|
| 676 | - | assert_eq!(items.len(), 3); |
|
| 677 | - | // newest survive |
|
| 678 | - | assert_eq!(items[0].published_at, 9); |
|
| 679 | - | assert_eq!(items[2].published_at, 7); |
|
| 680 | - | } |
|
| 681 | - | ||
| 682 | - | #[test] |
|
| 683 | - | fn seed_subscription_with_no_entries_still_persists_meta() { |
|
| 684 | - | let db = test_db(); |
|
| 685 | - | let sub = insert_subscription(&db, "https://x.com/feed", "X", None, None).unwrap(); |
|
| 686 | - | ||
| 687 | - | let inserted = seed_subscription(&db, sub.id, &[], Some("etag-empty"), None, 50); |
|
| 688 | - | ||
| 689 | - | assert_eq!(inserted, 0); |
|
| 690 | - | let after = get_subscription(&db, sub.id).unwrap().unwrap(); |
|
| 691 | - | assert_eq!(after.etag.as_deref(), Some("etag-empty")); |
|
| 692 | - | } |
|
| 693 | - | } |
| 1 | - | use axum::{ |
|
| 2 | - | extract::{FromRef, FromRequestParts}, |
|
| 3 | - | http::{request::Parts, StatusCode}, |
|
| 4 | - | response::{IntoResponse, Redirect, Response}, |
|
| 5 | - | }; |
|
| 6 | - | use chrono::{Duration, Utc}; |
|
| 7 | - | use std::sync::Arc; |
|
| 8 | - | ||
| 9 | - | use crate::AppState; |
|
| 10 | - | use andromeda_db::session; |
|
| 11 | - | ||
| 12 | - | pub use andromeda_auth::{ |
|
| 13 | - | build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token, |
|
| 14 | - | verify_api_key, verify_password, |
|
| 15 | - | }; |
|
| 16 | - | ||
| 17 | - | const SESSION_DAYS: i64 = 7; |
|
| 18 | - | ||
| 19 | - | /// Create a session row with 7-day expiry. |
|
| 20 | - | pub fn create_session(db: &andromeda_db::Db, token: &str) -> Result<(), andromeda_db::DbError> { |
|
| 21 | - | let expires = (Utc::now() + Duration::days(SESSION_DAYS)) |
|
| 22 | - | .format("%Y-%m-%d %H:%M:%S") |
|
| 23 | - | .to_string(); |
|
| 24 | - | session::insert_session(db, token, &expires) |
|
| 25 | - | } |
|
| 26 | - | ||
| 27 | - | pub fn is_valid_session(db: &andromeda_db::Db, token: &str) -> bool { |
|
| 28 | - | match session::get_session_expiry(db, token) { |
|
| 29 | - | Ok(Some(expires_at)) => { |
|
| 30 | - | chrono::NaiveDateTime::parse_from_str(&expires_at, "%Y-%m-%d %H:%M:%S") |
|
| 31 | - | .map(|exp| exp > Utc::now().naive_utc()) |
|
| 32 | - | .unwrap_or(false) |
|
| 33 | - | } |
|
| 34 | - | _ => false, |
|
| 35 | - | } |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | pub fn delete_session(db: &andromeda_db::Db, token: &str) { |
|
| 39 | - | let _ = session::delete_session(db, token); |
|
| 40 | - | } |
|
| 41 | - | ||
| 42 | - | /// Guards browser admin routes. Redirects to login on failure. |
|
| 43 | - | pub struct AuthSession; |
|
| 44 | - | ||
| 45 | - | impl<S> FromRequestParts<S> for AuthSession |
|
| 46 | - | where |
|
| 47 | - | S: Send + Sync, |
|
| 48 | - | Arc<AppState>: FromRef<S>, |
|
| 49 | - | { |
|
| 50 | - | type Rejection = Response; |
|
| 51 | - | ||
| 52 | - | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
|
| 53 | - | let state = Arc::<AppState>::from_ref(state); |
|
| 54 | - | if let Some(token) = extract_session_cookie(&parts.headers) { |
|
| 55 | - | if is_valid_session(&state.db, &token) { |
|
| 56 | - | return Ok(AuthSession); |
|
| 57 | - | } |
|
| 58 | - | } |
|
| 59 | - | Err(Redirect::to("/admin/login").into_response()) |
|
| 60 | - | } |
|
| 61 | - | } |
|
| 62 | - | ||
| 63 | - | /// Guards JSON API routes. Accepts `Authorization: Bearer <API_KEY>` OR a valid session cookie. |
|
| 64 | - | /// Returns 401 JSON on failure (doesn't redirect). |
|
| 65 | - | pub struct ApiAuth; |
|
| 66 | - | ||
| 67 | - | impl<S> FromRequestParts<S> for ApiAuth |
|
| 68 | - | where |
|
| 69 | - | S: Send + Sync, |
|
| 70 | - | Arc<AppState>: FromRef<S>, |
|
| 71 | - | { |
|
| 72 | - | type Rejection = Response; |
|
| 73 | - | ||
| 74 | - | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
|
| 75 | - | let state = Arc::<AppState>::from_ref(state); |
|
| 76 | - | ||
| 77 | - | if let Some(expected_key) = state.api_key.as_deref() { |
|
| 78 | - | if let Some(header) = parts.headers.get(axum::http::header::AUTHORIZATION) { |
|
| 79 | - | if let Ok(s) = header.to_str() { |
|
| 80 | - | if let Some(token) = s.strip_prefix("Bearer ").or_else(|| s.strip_prefix("bearer ")) { |
|
| 81 | - | if verify_api_key(token.trim(), expected_key) { |
|
| 82 | - | return Ok(ApiAuth); |
|
| 83 | - | } |
|
| 84 | - | } |
|
| 85 | - | } |
|
| 86 | - | } |
|
| 87 | - | } |
|
| 88 | - | ||
| 89 | - | if let Some(token) = extract_session_cookie(&parts.headers) { |
|
| 90 | - | if is_valid_session(&state.db, &token) { |
|
| 91 | - | return Ok(ApiAuth); |
|
| 92 | - | } |
|
| 93 | - | } |
|
| 94 | - | ||
| 95 | - | Err(( |
|
| 96 | - | StatusCode::UNAUTHORIZED, |
|
| 97 | - | axum::Json(serde_json::json!({ "error": "unauthorized" })), |
|
| 98 | - | ) |
|
| 99 | - | .into_response()) |
|
| 100 | - | } |
|
| 101 | - | } |
| 1 | - | use crate::models::FeedItem; |
|
| 2 | - | use quick_xml::events::Event; |
|
| 3 | - | use scraper::{Html, Selector}; |
|
| 4 | - | use std::time::Duration; |
|
| 5 | - | use url::Url; |
|
| 6 | - | ||
| 7 | - | /// One outline entry from an OPML document (subscription plus optional category name). |
|
| 8 | - | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| 9 | - | pub struct OpmlEntry { |
|
| 10 | - | pub xml_url: String, |
|
| 11 | - | pub title: Option<String>, |
|
| 12 | - | pub html_url: Option<String>, |
|
| 13 | - | pub category: Option<String>, |
|
| 14 | - | } |
|
| 15 | - | ||
| 16 | - | /// Result of a conditional fetch against an RSS/Atom feed. |
|
| 17 | - | #[derive(Debug)] |
|
| 18 | - | pub struct FetchResult { |
|
| 19 | - | /// HTTP status code. 304 means nothing changed; items will be empty. |
|
| 20 | - | pub status: u16, |
|
| 21 | - | pub etag: Option<String>, |
|
| 22 | - | pub last_modified: Option<String>, |
|
| 23 | - | pub title: Option<String>, |
|
| 24 | - | pub site_url: Option<String>, |
|
| 25 | - | pub entries: Vec<ParsedEntry>, |
|
| 26 | - | } |
|
| 27 | - | ||
| 28 | - | #[derive(Debug, Clone)] |
|
| 29 | - | pub struct ParsedEntry { |
|
| 30 | - | pub guid: String, |
|
| 31 | - | pub title: String, |
|
| 32 | - | pub link: String, |
|
| 33 | - | pub author: Option<String>, |
|
| 34 | - | pub published_at: i64, |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | const DERIVED_TITLE_MAX_CHARS: usize = 80; |
|
| 38 | - | ||
| 39 | - | /// Build a synthetic title from an entry's HTML description when the feed |
|
| 40 | - | /// publishes empty `<title>` tags (common for Micro.blog-style microposts). |
|
| 41 | - | /// Strips tags, collapses whitespace, and truncates to a readable preview. |
|
| 42 | - | fn derive_title_from_html(html: &str) -> String { |
|
| 43 | - | let fragment = Html::parse_fragment(html); |
|
| 44 | - | let text: String = fragment.root_element().text().collect(); |
|
| 45 | - | let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" "); |
|
| 46 | - | let mut chars = collapsed.chars(); |
|
| 47 | - | let truncated: String = chars.by_ref().take(DERIVED_TITLE_MAX_CHARS).collect(); |
|
| 48 | - | if chars.next().is_some() { |
|
| 49 | - | format!("{}…", truncated.trim_end()) |
|
| 50 | - | } else { |
|
| 51 | - | truncated |
|
| 52 | - | } |
|
| 53 | - | } |
|
| 54 | - | ||
| 55 | - | fn build_client() -> reqwest::Client { |
|
| 56 | - | reqwest::Client::builder() |
|
| 57 | - | .timeout(Duration::from_secs(15)) |
|
| 58 | - | .user_agent("andromeda-feeds/0.1 (+https://github.com/stevedylandev/andromeda)") |
|
| 59 | - | .build() |
|
| 60 | - | .expect("Failed to build HTTP client") |
|
| 61 | - | } |
|
| 62 | - | ||
| 63 | - | pub async fn fetch_feed( |
|
| 64 | - | url: &str, |
|
| 65 | - | etag: Option<&str>, |
|
| 66 | - | last_modified: Option<&str>, |
|
| 67 | - | ) -> Result<FetchResult, String> { |
|
| 68 | - | let client = build_client(); |
|
| 69 | - | let mut req = client.get(url); |
|
| 70 | - | if let Some(tag) = etag { |
|
| 71 | - | req = req.header("If-None-Match", tag); |
|
| 72 | - | } |
|
| 73 | - | if let Some(lm) = last_modified { |
|
| 74 | - | req = req.header("If-Modified-Since", lm); |
|
| 75 | - | } |
|
| 76 | - | ||
| 77 | - | let resp = req.send().await.map_err(|e| format!("fetch failed: {e}"))?; |
|
| 78 | - | let status = resp.status().as_u16(); |
|
| 79 | - | let headers = resp.headers().clone(); |
|
| 80 | - | let new_etag = headers |
|
| 81 | - | .get(reqwest::header::ETAG) |
|
| 82 | - | .and_then(|v| v.to_str().ok()) |
|
| 83 | - | .map(|s| s.to_string()); |
|
| 84 | - | let new_last_modified = headers |
|
| 85 | - | .get(reqwest::header::LAST_MODIFIED) |
|
| 86 | - | .and_then(|v| v.to_str().ok()) |
|
| 87 | - | .map(|s| s.to_string()); |
|
| 88 | - | ||
| 89 | - | if status == 304 { |
|
| 90 | - | return Ok(FetchResult { |
|
| 91 | - | status, |
|
| 92 | - | etag: new_etag.or_else(|| etag.map(|s| s.to_string())), |
|
| 93 | - | last_modified: new_last_modified.or_else(|| last_modified.map(|s| s.to_string())), |
|
| 94 | - | title: None, |
|
| 95 | - | site_url: None, |
|
| 96 | - | entries: Vec::new(), |
|
| 97 | - | }); |
|
| 98 | - | } |
|
| 99 | - | ||
| 100 | - | if !resp.status().is_success() { |
|
| 101 | - | return Err(format!("upstream returned {status}")); |
|
| 102 | - | } |
|
| 103 | - | ||
| 104 | - | let body = resp |
|
| 105 | - | .bytes() |
|
| 106 | - | .await |
|
| 107 | - | .map_err(|e| format!("read body failed: {e}"))?; |
|
| 108 | - | let feed = |
|
| 109 | - | feed_rs::parser::parse(&body[..]).map_err(|e| format!("feed parse failed: {e}"))?; |
|
| 110 | - | ||
| 111 | - | let title = feed.title.as_ref().map(|t| t.content.clone()); |
|
| 112 | - | let site_url = feed |
|
| 113 | - | .links |
|
| 114 | - | .iter() |
|
| 115 | - | .find(|l| l.rel.as_deref() != Some("self")) |
|
| 116 | - | .map(|l| l.href.clone()) |
|
| 117 | - | .or_else(|| feed.links.first().map(|l| l.href.clone())); |
|
| 118 | - | ||
| 119 | - | let entries = feed |
|
| 120 | - | .entries |
|
| 121 | - | .into_iter() |
|
| 122 | - | .map(|entry| { |
|
| 123 | - | let published_at = entry |
|
| 124 | - | .published |
|
| 125 | - | .or(entry.updated) |
|
| 126 | - | .map(|dt| dt.timestamp()) |
|
| 127 | - | .unwrap_or(0); |
|
| 128 | - | let link = entry |
|
| 129 | - | .links |
|
| 130 | - | .first() |
|
| 131 | - | .map(|l| l.href.clone()) |
|
| 132 | - | .unwrap_or_default(); |
|
| 133 | - | let title = entry |
|
| 134 | - | .title |
|
| 135 | - | .as_ref() |
|
| 136 | - | .map(|t| t.content.clone()) |
|
| 137 | - | .filter(|t| !t.trim().is_empty()) |
|
| 138 | - | .or_else(|| { |
|
| 139 | - | let html = entry |
|
| 140 | - | .summary |
|
| 141 | - | .as_ref() |
|
| 142 | - | .map(|s| s.content.as_str()) |
|
| 143 | - | .or_else(|| entry.content.as_ref().and_then(|c| c.body.as_deref()))?; |
|
| 144 | - | let derived = derive_title_from_html(html); |
|
| 145 | - | if derived.is_empty() { |
|
| 146 | - | None |
|
| 147 | - | } else { |
|
| 148 | - | Some(derived) |
|
| 149 | - | } |
|
| 150 | - | }) |
|
| 151 | - | .unwrap_or_default(); |
|
| 152 | - | let author = entry.authors.first().map(|a| a.name.clone()); |
|
| 153 | - | let guid = if !entry.id.is_empty() { |
|
| 154 | - | entry.id |
|
| 155 | - | } else { |
|
| 156 | - | link.clone() |
|
| 157 | - | }; |
|
| 158 | - | ParsedEntry { |
|
| 159 | - | guid, |
|
| 160 | - | title, |
|
| 161 | - | link, |
|
| 162 | - | author, |
|
| 163 | - | published_at, |
|
| 164 | - | } |
|
| 165 | - | }) |
|
| 166 | - | .collect(); |
|
| 167 | - | ||
| 168 | - | Ok(FetchResult { |
|
| 169 | - | status, |
|
| 170 | - | etag: new_etag, |
|
| 171 | - | last_modified: new_last_modified, |
|
| 172 | - | title, |
|
| 173 | - | site_url, |
|
| 174 | - | entries, |
|
| 175 | - | }) |
|
| 176 | - | } |
|
| 177 | - | ||
| 178 | - | /// Ad-hoc preview: parse one or more feed URLs and return flattened items. |
|
| 179 | - | /// Kept for the `?url=` bypass mode on the index page. |
|
| 180 | - | pub async fn preview_urls(urls: &[String]) -> Vec<FeedItem> { |
|
| 181 | - | let mut handles = Vec::new(); |
|
| 182 | - | for url in urls { |
|
| 183 | - | let url = url.clone(); |
|
| 184 | - | handles.push(tokio::spawn(async move { |
|
| 185 | - | let result = fetch_feed(&url, None, None).await; |
|
| 186 | - | match result { |
|
| 187 | - | Ok(r) => { |
|
| 188 | - | let feed_title = r.title.clone().unwrap_or_default(); |
|
| 189 | - | r.entries |
|
| 190 | - | .into_iter() |
|
| 191 | - | .map(|e| FeedItem { |
|
| 192 | - | title: e.title, |
|
| 193 | - | link: e.link, |
|
| 194 | - | published: e.published_at, |
|
| 195 | - | author: match e.author { |
|
| 196 | - | Some(a) if !a.is_empty() && !feed_title.is_empty() => { |
|
| 197 | - | format!("{feed_title} - {a}") |
|
| 198 | - | } |
|
| 199 | - | Some(a) if !a.is_empty() => a, |
|
| 200 | - | _ => feed_title.clone(), |
|
| 201 | - | }, |
|
| 202 | - | }) |
|
| 203 | - | .collect::<Vec<_>>() |
|
| 204 | - | } |
|
| 205 | - | Err(e) => { |
|
| 206 | - | tracing::warn!("preview fetch failed for {url}: {e}"); |
|
| 207 | - | Vec::new() |
|
| 208 | - | } |
|
| 209 | - | } |
|
| 210 | - | })); |
|
| 211 | - | } |
|
| 212 | - | ||
| 213 | - | let mut all = Vec::new(); |
|
| 214 | - | for h in handles { |
|
| 215 | - | if let Ok(items) = h.await { |
|
| 216 | - | all.extend(items); |
|
| 217 | - | } |
|
| 218 | - | } |
|
| 219 | - | all.sort_by(|a, b| b.published.cmp(&a.published)); |
|
| 220 | - | all |
|
| 221 | - | } |
|
| 222 | - | ||
| 223 | - | /// Parse an OPML document into outline entries, carrying the parent `<outline>` title |
|
| 224 | - | /// as a category when the parent has no `xmlUrl`. |
|
| 225 | - | pub fn parse_opml(content: &str) -> Vec<OpmlEntry> { |
|
| 226 | - | let mut entries = Vec::new(); |
|
| 227 | - | let mut reader = quick_xml::Reader::from_str(content); |
|
| 228 | - | let mut category_stack: Vec<String> = Vec::new(); |
|
| 229 | - | ||
| 230 | - | loop { |
|
| 231 | - | match reader.read_event() { |
|
| 232 | - | Ok(Event::Start(ref e)) if e.name().as_ref() == b"outline" => { |
|
| 233 | - | let mut xml_url: Option<String> = None; |
|
| 234 | - | let mut title: Option<String> = None; |
|
| 235 | - | let mut html_url: Option<String> = None; |
|
| 236 | - | for attr in e.attributes().flatten() { |
|
| 237 | - | let val = attr |
|
| 238 | - | .decode_and_unescape_value(reader.decoder()) |
|
| 239 | - | .ok() |
|
| 240 | - | .map(|v| v.to_string()); |
|
| 241 | - | match attr.key.as_ref() { |
|
| 242 | - | b"xmlUrl" => xml_url = val.filter(|v| !v.is_empty()), |
|
| 243 | - | b"title" => title = val, |
|
| 244 | - | b"text" if title.is_none() => title = val, |
|
| 245 | - | b"htmlUrl" => html_url = val, |
|
| 246 | - | _ => {} |
|
| 247 | - | } |
|
| 248 | - | } |
|
| 249 | - | ||
| 250 | - | if let Some(xml) = xml_url { |
|
| 251 | - | entries.push(OpmlEntry { |
|
| 252 | - | xml_url: xml, |
|
| 253 | - | title, |
|
| 254 | - | html_url, |
|
| 255 | - | category: category_stack.last().cloned(), |
|
| 256 | - | }); |
|
| 257 | - | category_stack.push(String::new()); // balance Close event |
|
| 258 | - | } else { |
|
| 259 | - | category_stack.push(title.unwrap_or_default()); |
|
| 260 | - | } |
|
| 261 | - | } |
|
| 262 | - | Ok(Event::Empty(ref e)) if e.name().as_ref() == b"outline" => { |
|
| 263 | - | let mut xml_url: Option<String> = None; |
|
| 264 | - | let mut title: Option<String> = None; |
|
| 265 | - | let mut html_url: Option<String> = None; |
|
| 266 | - | for attr in e.attributes().flatten() { |
|
| 267 | - | let val = attr |
|
| 268 | - | .decode_and_unescape_value(reader.decoder()) |
|
| 269 | - | .ok() |
|
| 270 | - | .map(|v| v.to_string()); |
|
| 271 | - | match attr.key.as_ref() { |
|
| 272 | - | b"xmlUrl" => xml_url = val.filter(|v| !v.is_empty()), |
|
| 273 | - | b"title" => title = val, |
|
| 274 | - | b"text" if title.is_none() => title = val, |
|
| 275 | - | b"htmlUrl" => html_url = val, |
|
| 276 | - | _ => {} |
|
| 277 | - | } |
|
| 278 | - | } |
|
| 279 | - | if let Some(xml) = xml_url { |
|
| 280 | - | entries.push(OpmlEntry { |
|
| 281 | - | xml_url: xml, |
|
| 282 | - | title, |
|
| 283 | - | html_url, |
|
| 284 | - | category: category_stack.last().cloned().filter(|c| !c.is_empty()), |
|
| 285 | - | }); |
|
| 286 | - | } |
|
| 287 | - | } |
|
| 288 | - | Ok(Event::End(ref e)) if e.name().as_ref() == b"outline" => { |
|
| 289 | - | category_stack.pop(); |
|
| 290 | - | } |
|
| 291 | - | Ok(Event::Eof) => break, |
|
| 292 | - | Err(e) => { |
|
| 293 | - | tracing::warn!("OPML parse error: {e}"); |
|
| 294 | - | break; |
|
| 295 | - | } |
|
| 296 | - | _ => {} |
|
| 297 | - | } |
|
| 298 | - | } |
|
| 299 | - | ||
| 300 | - | entries |
|
| 301 | - | } |
|
| 302 | - | ||
| 303 | - | /// Best-effort favicon URL for a site. Parses `<link rel="icon">` from the |
|
| 304 | - | /// page HTML, falls back to `/favicon.ico` at the site root. Returns None if |
|
| 305 | - | /// the URL is invalid. |
|
| 306 | - | pub async fn discover_favicon(site_url: &str) -> Option<String> { |
|
| 307 | - | let parsed = Url::parse(site_url).ok()?; |
|
| 308 | - | let client = build_client(); |
|
| 309 | - | ||
| 310 | - | if let Ok(resp) = client.get(site_url).send().await { |
|
| 311 | - | if let Ok(body) = resp.text().await { |
|
| 312 | - | let document = Html::parse_document(&body); |
|
| 313 | - | let selector = Selector::parse( |
|
| 314 | - | r#"link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]"#, |
|
| 315 | - | ) |
|
| 316 | - | .ok()?; |
|
| 317 | - | if let Some(href) = document |
|
| 318 | - | .select(&selector) |
|
| 319 | - | .find_map(|el| el.attr("href")) |
|
| 320 | - | { |
|
| 321 | - | if let Ok(resolved) = parsed.join(href) { |
|
| 322 | - | return Some(resolved.to_string()); |
|
| 323 | - | } |
|
| 324 | - | } |
|
| 325 | - | } |
|
| 326 | - | } |
|
| 327 | - | ||
| 328 | - | parsed.join("/favicon.ico").ok().map(|u| u.to_string()) |
|
| 329 | - | } |
|
| 330 | - | ||
| 331 | - | pub async fn discover_feeds(base_url: &str) -> Result<Vec<String>, String> { |
|
| 332 | - | let parsed = Url::parse(base_url).map_err(|e| format!("Invalid URL: {e}"))?; |
|
| 333 | - | let client = build_client(); |
|
| 334 | - | let mut feeds = Vec::new(); |
|
| 335 | - | ||
| 336 | - | if let Ok(response) = client.get(base_url).send().await { |
|
| 337 | - | if let Ok(body) = response.text().await { |
|
| 338 | - | let document = Html::parse_document(&body); |
|
| 339 | - | let selector = Selector::parse(r#"link[rel="alternate"]"#).unwrap(); |
|
| 340 | - | for element in document.select(&selector) { |
|
| 341 | - | let type_attr = element.attr("type").unwrap_or_default(); |
|
| 342 | - | if type_attr.contains("rss") |
|
| 343 | - | || type_attr.contains("atom") |
|
| 344 | - | || type_attr.contains("xml") |
|
| 345 | - | { |
|
| 346 | - | if let Some(href) = element.attr("href") { |
|
| 347 | - | let resolved = parsed |
|
| 348 | - | .join(href) |
|
| 349 | - | .map(|u| u.to_string()) |
|
| 350 | - | .unwrap_or_else(|_| href.to_string()); |
|
| 351 | - | if !feeds.contains(&resolved) { |
|
| 352 | - | feeds.push(resolved); |
|
| 353 | - | } |
|
| 354 | - | } |
|
| 355 | - | } |
|
| 356 | - | } |
|
| 357 | - | } |
|
| 358 | - | } |
|
| 359 | - | ||
| 360 | - | if feeds.is_empty() { |
|
| 361 | - | let common_paths = [ |
|
| 362 | - | "/feed", |
|
| 363 | - | "/feed.xml", |
|
| 364 | - | "/rss", |
|
| 365 | - | "/rss.xml", |
|
| 366 | - | "/atom.xml", |
|
| 367 | - | "/index.xml", |
|
| 368 | - | "/feed/rss", |
|
| 369 | - | "/blog/feed", |
|
| 370 | - | "/blog/rss", |
|
| 371 | - | ]; |
|
| 372 | - | let mut handles = Vec::new(); |
|
| 373 | - | for path in common_paths { |
|
| 374 | - | let probe_url = match parsed.join(path) { |
|
| 375 | - | Ok(u) => u.to_string(), |
|
| 376 | - | Err(_) => continue, |
|
| 377 | - | }; |
|
| 378 | - | let client = client.clone(); |
|
| 379 | - | handles.push(tokio::spawn(async move { |
|
| 380 | - | if let Ok(resp) = client.head(&probe_url).send().await { |
|
| 381 | - | if resp.status().is_success() { |
|
| 382 | - | if let Some(ct) = resp.headers().get("content-type") { |
|
| 383 | - | let ct = ct.to_str().unwrap_or_default(); |
|
| 384 | - | if ct.contains("xml") || ct.contains("rss") || ct.contains("atom") { |
|
| 385 | - | return Some(probe_url); |
|
| 386 | - | } |
|
| 387 | - | } |
|
| 388 | - | } |
|
| 389 | - | } |
|
| 390 | - | None |
|
| 391 | - | })); |
|
| 392 | - | } |
|
| 393 | - | for h in handles { |
|
| 394 | - | if let Ok(Some(url)) = h.await { |
|
| 395 | - | if !feeds.contains(&url) { |
|
| 396 | - | feeds.push(url); |
|
| 397 | - | } |
|
| 398 | - | } |
|
| 399 | - | } |
|
| 400 | - | } |
|
| 401 | - | ||
| 402 | - | if feeds.is_empty() { |
|
| 403 | - | Err("No feeds found at this URL".to_string()) |
|
| 404 | - | } else { |
|
| 405 | - | Ok(feeds) |
|
| 406 | - | } |
|
| 407 | - | } |
|
| 408 | - | ||
| 409 | - | #[cfg(test)] |
|
| 410 | - | mod tests { |
|
| 411 | - | use super::*; |
|
| 412 | - | ||
| 413 | - | #[test] |
|
| 414 | - | fn derive_title_strips_html_and_collapses_whitespace() { |
|
| 415 | - | let html = "<p>If they launched full-time\n\ngoblin mode, I’d use it</p>"; |
|
| 416 | - | assert_eq!( |
|
| 417 | - | derive_title_from_html(html), |
|
| 418 | - | "If they launched full-time goblin mode, I\u{2019}d use it" |
|
| 419 | - | ); |
|
| 420 | - | } |
|
| 421 | - | ||
| 422 | - | #[test] |
|
| 423 | - | fn derive_title_truncates_long_text() { |
|
| 424 | - | let html = format!("<p>{}</p>", "a ".repeat(100)); |
|
| 425 | - | let out = derive_title_from_html(&html); |
|
| 426 | - | assert!(out.ends_with('…')); |
|
| 427 | - | assert!(out.chars().count() <= DERIVED_TITLE_MAX_CHARS + 1); |
|
| 428 | - | } |
|
| 429 | - | ||
| 430 | - | #[test] |
|
| 431 | - | fn derive_title_empty_html_yields_empty() { |
|
| 432 | - | assert_eq!(derive_title_from_html(""), ""); |
|
| 433 | - | assert_eq!(derive_title_from_html("<p> </p>"), ""); |
|
| 434 | - | } |
|
| 435 | - | ||
| 436 | - | #[test] |
|
| 437 | - | fn parse_opml_flat_outlines() { |
|
| 438 | - | let opml = r#"<?xml version="1.0" encoding="UTF-8"?> |
|
| 439 | - | <opml version="2.0"><body> |
|
| 440 | - | <outline type="rss" text="Blog A" xmlUrl="https://a.com/feed" /> |
|
| 441 | - | <outline type="rss" text="Blog B" xmlUrl="https://b.com/rss" /> |
|
| 442 | - | </body></opml>"#; |
|
| 443 | - | let entries = parse_opml(opml); |
|
| 444 | - | assert_eq!(entries.len(), 2); |
|
| 445 | - | assert_eq!(entries[0].xml_url, "https://a.com/feed"); |
|
| 446 | - | assert_eq!(entries[0].title.as_deref(), Some("Blog A")); |
|
| 447 | - | assert!(entries[0].category.is_none()); |
|
| 448 | - | } |
|
| 449 | - | ||
| 450 | - | #[test] |
|
| 451 | - | fn parse_opml_empty() { |
|
| 452 | - | let opml = r#"<?xml version="1.0"?><opml><body></body></opml>"#; |
|
| 453 | - | assert!(parse_opml(opml).is_empty()); |
|
| 454 | - | } |
|
| 455 | - | ||
| 456 | - | #[test] |
|
| 457 | - | fn parse_opml_no_xml_url_skipped() { |
|
| 458 | - | let opml = r#"<?xml version="1.0"?> |
|
| 459 | - | <opml><body><outline type="rss" text="No URL" htmlUrl="https://example.com" /></body></opml>"#; |
|
| 460 | - | assert!(parse_opml(opml).is_empty()); |
|
| 461 | - | } |
|
| 462 | - | ||
| 463 | - | #[test] |
|
| 464 | - | fn parse_opml_nested_carries_category() { |
|
| 465 | - | let opml = r#"<?xml version="1.0"?> |
|
| 466 | - | <opml><body> |
|
| 467 | - | <outline text="Tech"> |
|
| 468 | - | <outline type="rss" text="Inner" xmlUrl="https://inner.com/feed" /> |
|
| 469 | - | </outline> |
|
| 470 | - | </body></opml>"#; |
|
| 471 | - | let entries = parse_opml(opml); |
|
| 472 | - | assert_eq!(entries.len(), 1); |
|
| 473 | - | assert_eq!(entries[0].category.as_deref(), Some("Tech")); |
|
| 474 | - | } |
|
| 475 | - | ||
| 476 | - | #[test] |
|
| 477 | - | fn parse_opml_deeply_nested() { |
|
| 478 | - | let opml = r#"<?xml version="1.0"?> |
|
| 479 | - | <opml><body> |
|
| 480 | - | <outline text="Root"> |
|
| 481 | - | <outline text="Tech"> |
|
| 482 | - | <outline type="rss" text="A" xmlUrl="https://a.com/feed" /> |
|
| 483 | - | </outline> |
|
| 484 | - | <outline type="rss" text="B" xmlUrl="https://b.com/feed" /> |
|
| 485 | - | </outline> |
|
| 486 | - | </body></opml>"#; |
|
| 487 | - | let entries = parse_opml(opml); |
|
| 488 | - | assert_eq!(entries.len(), 2); |
|
| 489 | - | assert_eq!(entries[0].xml_url, "https://a.com/feed"); |
|
| 490 | - | assert_eq!(entries[0].category.as_deref(), Some("Tech")); |
|
| 491 | - | assert_eq!(entries[1].xml_url, "https://b.com/feed"); |
|
| 492 | - | assert_eq!(entries[1].category.as_deref(), Some("Root")); |
|
| 493 | - | } |
|
| 494 | - | ||
| 495 | - | #[test] |
|
| 496 | - | fn parse_opml_skips_empty_url() { |
|
| 497 | - | let opml = r#"<?xml version="1.0"?> |
|
| 498 | - | <opml><body> |
|
| 499 | - | <outline type="rss" text="Empty" xmlUrl="" /> |
|
| 500 | - | <outline type="rss" text="Valid" xmlUrl="https://valid.com/feed" /> |
|
| 501 | - | </body></opml>"#; |
|
| 502 | - | let entries = parse_opml(opml); |
|
| 503 | - | assert_eq!(entries.len(), 1); |
|
| 504 | - | assert_eq!(entries[0].xml_url, "https://valid.com/feed"); |
|
| 505 | - | } |
|
| 506 | - | } |
| 1 | - | mod api; |
|
| 2 | - | mod auth; |
|
| 3 | - | mod feeds; |
|
| 4 | - | mod models; |
|
| 5 | - | mod poller; |
|
| 6 | - | ||
| 7 | - | use std::collections::HashMap; |
|
| 8 | - | use std::sync::{Arc, Mutex}; |
|
| 9 | - | ||
| 10 | - | use andromeda_db::{ |
|
| 11 | - | feeds as fdb, |
|
| 12 | - | session::{SESSION_SCHEMA, prune_expired_sessions}, |
|
| 13 | - | Db, |
|
| 14 | - | }; |
|
| 15 | - | use askama::Template; |
|
| 16 | - | use axum::{ |
|
| 17 | - | extract::{Multipart, Path, Query, State}, |
|
| 18 | - | http::{header, HeaderMap, Method, StatusCode}, |
|
| 19 | - | response::{Html, IntoResponse, Json, Redirect, Response}, |
|
| 20 | - | routing::{delete, get, post}, |
|
| 21 | - | Form, Router, |
|
| 22 | - | }; |
|
| 23 | - | use chrono::DateTime; |
|
| 24 | - | use rust_embed::Embed; |
|
| 25 | - | use rusqlite::Connection; |
|
| 26 | - | use serde::Deserialize; |
|
| 27 | - | use tower_http::cors::{Any, CorsLayer}; |
|
| 28 | - | ||
| 29 | - | use crate::poller::POLL_INTERVAL_KEY; |
|
| 30 | - | ||
| 31 | - | #[derive(Embed)] |
|
| 32 | - | #[folder = "static/"] |
|
| 33 | - | struct Static; |
|
| 34 | - | ||
| 35 | - | pub struct AppState { |
|
| 36 | - | pub db: Db, |
|
| 37 | - | pub admin_password: Option<String>, |
|
| 38 | - | pub api_key: Option<String>, |
|
| 39 | - | pub cookie_secure: bool, |
|
| 40 | - | pub base_url: String, |
|
| 41 | - | pub default_poll_minutes: u64, |
|
| 42 | - | pub item_cap: usize, |
|
| 43 | - | } |
|
| 44 | - | ||
| 45 | - | struct TemplateFeedItem { |
|
| 46 | - | title: String, |
|
| 47 | - | link: String, |
|
| 48 | - | author: String, |
|
| 49 | - | formatted_date: String, |
|
| 50 | - | } |
|
| 51 | - | ||
| 52 | - | #[derive(Template)] |
|
| 53 | - | #[template(path = "index.html")] |
|
| 54 | - | struct IndexTemplate { |
|
| 55 | - | base_url: String, |
|
| 56 | - | items: Vec<TemplateFeedItem>, |
|
| 57 | - | feed_urls: Option<Vec<String>>, |
|
| 58 | - | error: Option<String>, |
|
| 59 | - | } |
|
| 60 | - | ||
| 61 | - | #[derive(Template)] |
|
| 62 | - | #[template(path = "login.html")] |
|
| 63 | - | struct LoginTemplate { |
|
| 64 | - | error: Option<String>, |
|
| 65 | - | } |
|
| 66 | - | ||
| 67 | - | #[derive(Template)] |
|
| 68 | - | #[template(path = "admin.html")] |
|
| 69 | - | struct AdminTemplate { |
|
| 70 | - | success: Option<String>, |
|
| 71 | - | error: Option<String>, |
|
| 72 | - | subscriptions: Vec<AdminSubRow>, |
|
| 73 | - | categories: Vec<fdb::Category>, |
|
| 74 | - | poll_interval_minutes: u64, |
|
| 75 | - | item_cap: usize, |
|
| 76 | - | api_key_configured: bool, |
|
| 77 | - | } |
|
| 78 | - | ||
| 79 | - | struct AdminSubRow { |
|
| 80 | - | id: i64, |
|
| 81 | - | title: String, |
|
| 82 | - | feed_url: String, |
|
| 83 | - | site_url: Option<String>, |
|
| 84 | - | category_name: Option<String>, |
|
| 85 | - | last_fetched_at: Option<String>, |
|
| 86 | - | last_error: Option<String>, |
|
| 87 | - | } |
|
| 88 | - | ||
| 89 | - | fn format_date(timestamp: i64) -> String { |
|
| 90 | - | DateTime::from_timestamp(timestamp, 0) |
|
| 91 | - | .map(|dt| dt.format("%b %-d, %Y").to_string()) |
|
| 92 | - | .unwrap_or_default() |
|
| 93 | - | } |
|
| 94 | - | ||
| 95 | - | // ── Public pages ────────────────────────────────────────────────────── |
|
| 96 | - | ||
| 97 | - | async fn index_handler( |
|
| 98 | - | State(state): State<Arc<AppState>>, |
|
| 99 | - | Query(params): Query<HashMap<String, String>>, |
|
| 100 | - | ) -> Response { |
|
| 101 | - | let url_query = params.get("url").or_else(|| params.get("urls")); |
|
| 102 | - | ||
| 103 | - | let (items, feed_urls, error) = if let Some(query) = url_query { |
|
| 104 | - | let urls: Vec<String> = query |
|
| 105 | - | .split(',') |
|
| 106 | - | .map(|u| u.trim().to_string()) |
|
| 107 | - | .filter(|u| !u.is_empty()) |
|
| 108 | - | .collect(); |
|
| 109 | - | if urls.is_empty() { |
|
| 110 | - | (Vec::new(), None, Some("No URLs provided".to_string())) |
|
| 111 | - | } else { |
|
| 112 | - | let items = feeds::preview_urls(&urls) |
|
| 113 | - | .await |
|
| 114 | - | .into_iter() |
|
| 115 | - | .map(|item| TemplateFeedItem { |
|
| 116 | - | title: item.title, |
|
| 117 | - | link: item.link, |
|
| 118 | - | author: item.author, |
|
| 119 | - | formatted_date: format_date(item.published), |
|
| 120 | - | }) |
|
| 121 | - | .collect(); |
|
| 122 | - | (items, Some(urls), None) |
|
| 123 | - | } |
|
| 124 | - | } else { |
|
| 125 | - | match fdb::list_items( |
|
| 126 | - | &state.db, |
|
| 127 | - | &fdb::ListItemsFilter { |
|
| 128 | - | limit: Some(100), |
|
| 129 | - | ..Default::default() |
|
| 130 | - | }, |
|
| 131 | - | ) { |
|
| 132 | - | Ok(items) => { |
|
| 133 | - | let rows = items |
|
| 134 | - | .into_iter() |
|
| 135 | - | .map(|i| TemplateFeedItem { |
|
| 136 | - | title: i.title, |
|
| 137 | - | link: i.link, |
|
| 138 | - | author: match i.author { |
|
| 139 | - | Some(a) if !a.is_empty() => format!("{} - {}", i.feed_title, a), |
|
| 140 | - | _ => i.feed_title, |
|
| 141 | - | }, |
|
| 142 | - | formatted_date: format_date(i.published_at), |
|
| 143 | - | }) |
|
| 144 | - | .collect(); |
|
| 145 | - | (rows, None, None) |
|
| 146 | - | } |
|
| 147 | - | Err(e) => { |
|
| 148 | - | tracing::error!("index query failed: {e}"); |
|
| 149 | - | ( |
|
| 150 | - | Vec::new(), |
|
| 151 | - | None, |
|
| 152 | - | Some("Error loading feeds. Please try again later.".to_string()), |
|
| 153 | - | ) |
|
| 154 | - | } |
|
| 155 | - | } |
|
| 156 | - | }; |
|
| 157 | - | ||
| 158 | - | Html( |
|
| 159 | - | IndexTemplate { |
|
| 160 | - | base_url: state.base_url.clone(), |
|
| 161 | - | items, |
|
| 162 | - | feed_urls, |
|
| 163 | - | error, |
|
| 164 | - | } |
|
| 165 | - | .render() |
|
| 166 | - | .unwrap(), |
|
| 167 | - | ) |
|
| 168 | - | .into_response() |
|
| 169 | - | } |
|
| 170 | - | ||
| 171 | - | /// Export current subscriptions. `?format=json` (default) or `?format=opml`. |
|
| 172 | - | async fn feeds_handler( |
|
| 173 | - | State(state): State<Arc<AppState>>, |
|
| 174 | - | Query(params): Query<HashMap<String, String>>, |
|
| 175 | - | ) -> Response { |
|
| 176 | - | let format = params |
|
| 177 | - | .get("format") |
|
| 178 | - | .map(|s| s.as_str()) |
|
| 179 | - | .unwrap_or("json"); |
|
| 180 | - | ||
| 181 | - | let subs = match fdb::list_subscriptions(&state.db) { |
|
| 182 | - | Ok(s) => s, |
|
| 183 | - | Err(e) => { |
|
| 184 | - | tracing::error!("feeds export failed: {e}"); |
|
| 185 | - | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 186 | - | } |
|
| 187 | - | }; |
|
| 188 | - | ||
| 189 | - | match format { |
|
| 190 | - | "json" => { |
|
| 191 | - | let subscriptions: Vec<_> = subs |
|
| 192 | - | .iter() |
|
| 193 | - | .map(|s| { |
|
| 194 | - | serde_json::json!({ |
|
| 195 | - | "id": format!("feed/{}", s.id), |
|
| 196 | - | "title": s.title, |
|
| 197 | - | "url": s.feed_url, |
|
| 198 | - | "htmlUrl": s.site_url.clone().unwrap_or_default(), |
|
| 199 | - | }) |
|
| 200 | - | }) |
|
| 201 | - | .collect(); |
|
| 202 | - | Json(serde_json::json!({ "subscriptions": subscriptions })).into_response() |
|
| 203 | - | } |
|
| 204 | - | "opml" => { |
|
| 205 | - | let cats: HashMap<i64, String> = fdb::list_categories(&state.db) |
|
| 206 | - | .unwrap_or_default() |
|
| 207 | - | .into_iter() |
|
| 208 | - | .map(|c| (c.id, c.name)) |
|
| 209 | - | .collect(); |
|
| 210 | - | ||
| 211 | - | let now = chrono::Utc::now().to_rfc2822(); |
|
| 212 | - | let mut by_cat: HashMap<String, Vec<&fdb::Subscription>> = HashMap::new(); |
|
| 213 | - | for sub in &subs { |
|
| 214 | - | let key = sub |
|
| 215 | - | .category_id |
|
| 216 | - | .and_then(|id| cats.get(&id).cloned()) |
|
| 217 | - | .unwrap_or_default(); |
|
| 218 | - | by_cat.entry(key).or_default().push(sub); |
|
| 219 | - | } |
|
| 220 | - | ||
| 221 | - | let mut opml = format!( |
|
| 222 | - | "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<opml version=\"2.0\">\n <head>\n <title>Feeds</title>\n <dateCreated>{now}</dateCreated>\n </head>\n <body>\n" |
|
| 223 | - | ); |
|
| 224 | - | ||
| 225 | - | let mut keys: Vec<&String> = by_cat.keys().collect(); |
|
| 226 | - | keys.sort(); |
|
| 227 | - | for key in keys { |
|
| 228 | - | let subs = &by_cat[key]; |
|
| 229 | - | let indent = if key.is_empty() { " " } else { " " }; |
|
| 230 | - | if !key.is_empty() { |
|
| 231 | - | opml.push_str(&format!( |
|
| 232 | - | " <outline text=\"{}\" title=\"{}\">\n", |
|
| 233 | - | escape_xml(key), |
|
| 234 | - | escape_xml(key) |
|
| 235 | - | )); |
|
| 236 | - | } |
|
| 237 | - | for sub in subs { |
|
| 238 | - | opml.push_str(&format!( |
|
| 239 | - | "{indent}<outline type=\"rss\" text=\"{}\" title=\"{}\" xmlUrl=\"{}\" htmlUrl=\"{}\" />\n", |
|
| 240 | - | escape_xml(&sub.title), |
|
| 241 | - | escape_xml(&sub.title), |
|
| 242 | - | escape_xml(&sub.feed_url), |
|
| 243 | - | escape_xml(sub.site_url.as_deref().unwrap_or("")), |
|
| 244 | - | )); |
|
| 245 | - | } |
|
| 246 | - | if !key.is_empty() { |
|
| 247 | - | opml.push_str(" </outline>\n"); |
|
| 248 | - | } |
|
| 249 | - | } |
|
| 250 | - | ||
| 251 | - | opml.push_str(" </body>\n</opml>"); |
|
| 252 | - | ||
| 253 | - | ( |
|
| 254 | - | [ |
|
| 255 | - | (header::CONTENT_TYPE, "application/xml"), |
|
| 256 | - | ( |
|
| 257 | - | header::CONTENT_DISPOSITION, |
|
| 258 | - | "attachment; filename=\"feeds.opml\"", |
|
| 259 | - | ), |
|
| 260 | - | ], |
|
| 261 | - | opml, |
|
| 262 | - | ) |
|
| 263 | - | .into_response() |
|
| 264 | - | } |
|
| 265 | - | _ => ( |
|
| 266 | - | StatusCode::BAD_REQUEST, |
|
| 267 | - | Json(serde_json::json!({ |
|
| 268 | - | "error": "Invalid format. Use ?format=json or ?format=opml" |
|
| 269 | - | })), |
|
| 270 | - | ) |
|
| 271 | - | .into_response(), |
|
| 272 | - | } |
|
| 273 | - | } |
|
| 274 | - | ||
| 275 | - | fn escape_xml(s: &str) -> String { |
|
| 276 | - | s.replace('&', "&") |
|
| 277 | - | .replace('<', "<") |
|
| 278 | - | .replace('>', ">") |
|
| 279 | - | .replace('"', """) |
|
| 280 | - | .replace('\'', "'") |
|
| 281 | - | } |
|
| 282 | - | ||
| 283 | - | async fn atom_feed_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 284 | - | let items = match fdb::list_items( |
|
| 285 | - | &state.db, |
|
| 286 | - | &fdb::ListItemsFilter { |
|
| 287 | - | limit: Some(100), |
|
| 288 | - | ..Default::default() |
|
| 289 | - | }, |
|
| 290 | - | ) { |
|
| 291 | - | Ok(items) => items, |
|
| 292 | - | Err(e) => { |
|
| 293 | - | tracing::error!("atom feed query failed: {e}"); |
|
| 294 | - | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 295 | - | } |
|
| 296 | - | }; |
|
| 297 | - | ||
| 298 | - | let updated = items |
|
| 299 | - | .iter() |
|
| 300 | - | .map(|i| i.published_at) |
|
| 301 | - | .max() |
|
| 302 | - | .and_then(|ts| DateTime::from_timestamp(ts, 0)) |
|
| 303 | - | .unwrap_or_else(chrono::Utc::now) |
|
| 304 | - | .to_rfc3339(); |
|
| 305 | - | ||
| 306 | - | let base = state.base_url.trim_end_matches('/'); |
|
| 307 | - | let self_url = format!("{base}/feed.xml"); |
|
| 308 | - | ||
| 309 | - | let mut xml = String::with_capacity(4096); |
|
| 310 | - | xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); |
|
| 311 | - | xml.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n"); |
|
| 312 | - | xml.push_str(" <title>Feeds</title>\n"); |
|
| 313 | - | xml.push_str(&format!( |
|
| 314 | - | " <link href=\"{}\" rel=\"self\" type=\"application/atom+xml\" />\n", |
|
| 315 | - | escape_xml(&self_url) |
|
| 316 | - | )); |
|
| 317 | - | xml.push_str(&format!(" <link href=\"{}\" />\n", escape_xml(base))); |
|
| 318 | - | xml.push_str(&format!(" <id>{}</id>\n", escape_xml(&self_url))); |
|
| 319 | - | xml.push_str(&format!(" <updated>{updated}</updated>\n")); |
|
| 320 | - | ||
| 321 | - | for item in &items { |
|
| 322 | - | let published = DateTime::from_timestamp(item.published_at, 0) |
|
| 323 | - | .unwrap_or_else(chrono::Utc::now) |
|
| 324 | - | .to_rfc3339(); |
|
| 325 | - | let author_name = match item.author.as_deref() { |
|
| 326 | - | Some(a) if !a.is_empty() => a, |
|
| 327 | - | _ => item.feed_title.as_str(), |
|
| 328 | - | }; |
|
| 329 | - | let entry_id = if item.guid.is_empty() { |
|
| 330 | - | &item.link |
|
| 331 | - | } else { |
|
| 332 | - | &item.guid |
|
| 333 | - | }; |
|
| 334 | - | ||
| 335 | - | xml.push_str(" <entry>\n"); |
|
| 336 | - | xml.push_str(&format!(" <title>{}</title>\n", escape_xml(&item.title))); |
|
| 337 | - | xml.push_str(&format!( |
|
| 338 | - | " <link href=\"{}\" />\n", |
|
| 339 | - | escape_xml(&item.link) |
|
| 340 | - | )); |
|
| 341 | - | xml.push_str(&format!(" <id>{}</id>\n", escape_xml(entry_id))); |
|
| 342 | - | xml.push_str(&format!(" <updated>{published}</updated>\n")); |
|
| 343 | - | xml.push_str(&format!(" <published>{published}</published>\n")); |
|
| 344 | - | xml.push_str(" <author>\n"); |
|
| 345 | - | xml.push_str(&format!( |
|
| 346 | - | " <name>{}</name>\n", |
|
| 347 | - | escape_xml(author_name) |
|
| 348 | - | )); |
|
| 349 | - | xml.push_str(" </author>\n"); |
|
| 350 | - | xml.push_str(&format!( |
|
| 351 | - | " <source><title>{}</title></source>\n", |
|
| 352 | - | escape_xml(&item.feed_title) |
|
| 353 | - | )); |
|
| 354 | - | xml.push_str(" </entry>\n"); |
|
| 355 | - | } |
|
| 356 | - | ||
| 357 | - | xml.push_str("</feed>\n"); |
|
| 358 | - | ||
| 359 | - | ( |
|
| 360 | - | [(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8")], |
|
| 361 | - | xml, |
|
| 362 | - | ) |
|
| 363 | - | .into_response() |
|
| 364 | - | } |
|
| 365 | - | ||
| 366 | - | async fn static_handler(axum::extract::Path(path): axum::extract::Path<String>) -> Response { |
|
| 367 | - | match Static::get(&path) { |
|
| 368 | - | Some(file) => { |
|
| 369 | - | let mime = mime_guess::from_path(&path).first_or_octet_stream(); |
|
| 370 | - | ([(header::CONTENT_TYPE, mime.as_ref())], file.data.to_vec()).into_response() |
|
| 371 | - | } |
|
| 372 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 373 | - | } |
|
| 374 | - | } |
|
| 375 | - | ||
| 376 | - | // ── Admin UI ────────────────────────────────────────────────────────── |
|
| 377 | - | ||
| 378 | - | #[derive(Deserialize, Default)] |
|
| 379 | - | struct FlashQuery { |
|
| 380 | - | error: Option<String>, |
|
| 381 | - | success: Option<String>, |
|
| 382 | - | } |
|
| 383 | - | ||
| 384 | - | #[derive(Deserialize)] |
|
| 385 | - | struct LoginForm { |
|
| 386 | - | password: String, |
|
| 387 | - | } |
|
| 388 | - | ||
| 389 | - | #[derive(Deserialize)] |
|
| 390 | - | struct AddFeedForm { |
|
| 391 | - | feed_url: String, |
|
| 392 | - | category_name: Option<String>, |
|
| 393 | - | } |
|
| 394 | - | ||
| 395 | - | #[derive(Deserialize)] |
|
| 396 | - | struct DiscoverFeedsForm { |
|
| 397 | - | base_url: String, |
|
| 398 | - | } |
|
| 399 | - | ||
| 400 | - | #[derive(Deserialize)] |
|
| 401 | - | struct AddCategoryForm { |
|
| 402 | - | name: String, |
|
| 403 | - | } |
|
| 404 | - | ||
| 405 | - | #[derive(Deserialize)] |
|
| 406 | - | struct UpdateSubCategoryForm { |
|
| 407 | - | category_name: Option<String>, |
|
| 408 | - | } |
|
| 409 | - | ||
| 410 | - | #[derive(Deserialize)] |
|
| 411 | - | struct UpdateSettingsForm { |
|
| 412 | - | poll_interval_minutes: u64, |
|
| 413 | - | } |
|
| 414 | - | ||
| 415 | - | async fn login_get_handler(Query(q): Query<FlashQuery>) -> Response { |
|
| 416 | - | Html(LoginTemplate { error: q.error }.render().unwrap()).into_response() |
|
| 417 | - | } |
|
| 418 | - | ||
| 419 | - | async fn login_post_handler( |
|
| 420 | - | State(state): State<Arc<AppState>>, |
|
| 421 | - | Form(form): Form<LoginForm>, |
|
| 422 | - | ) -> Response { |
|
| 423 | - | let admin_password = match &state.admin_password { |
|
| 424 | - | Some(p) => p, |
|
| 425 | - | None => { |
|
| 426 | - | return Redirect::to("/admin/login?error=No+admin+password+configured").into_response(); |
|
| 427 | - | } |
|
| 428 | - | }; |
|
| 429 | - | if !auth::verify_password(&form.password, admin_password) { |
|
| 430 | - | return Redirect::to("/admin/login?error=Invalid+password").into_response(); |
|
| 431 | - | } |
|
| 432 | - | ||
| 433 | - | let token = auth::generate_session_token(); |
|
| 434 | - | if let Err(e) = auth::create_session(&state.db, &token) { |
|
| 435 | - | tracing::error!("failed to create session: {e}"); |
|
| 436 | - | return Redirect::to("/admin/login?error=Session+error").into_response(); |
|
| 437 | - | } |
|
| 438 | - | let _ = prune_expired_sessions(&state.db); |
|
| 439 | - | ||
| 440 | - | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 441 | - | let mut resp = Redirect::to("/admin").into_response(); |
|
| 442 | - | resp.headers_mut() |
|
| 443 | - | .insert(header::SET_COOKIE, cookie.parse().unwrap()); |
|
| 444 | - | resp |
|
| 445 | - | } |
|
| 446 | - | ||
| 447 | - | async fn logout_handler(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response { |
|
| 448 | - | if let Some(token) = auth::extract_session_cookie(&headers) { |
|
| 449 | - | auth::delete_session(&state.db, &token); |
|
| 450 | - | } |
|
| 451 | - | let mut resp = Redirect::to("/admin/login").into_response(); |
|
| 452 | - | resp.headers_mut().insert( |
|
| 453 | - | header::SET_COOKIE, |
|
| 454 | - | auth::clear_session_cookie().parse().unwrap(), |
|
| 455 | - | ); |
|
| 456 | - | resp |
|
| 457 | - | } |
|
| 458 | - | ||
| 459 | - | async fn admin_handler( |
|
| 460 | - | _session: auth::AuthSession, |
|
| 461 | - | State(state): State<Arc<AppState>>, |
|
| 462 | - | Query(q): Query<FlashQuery>, |
|
| 463 | - | ) -> Response { |
|
| 464 | - | let subs = fdb::list_subscriptions(&state.db).unwrap_or_default(); |
|
| 465 | - | let cats = fdb::list_categories(&state.db).unwrap_or_default(); |
|
| 466 | - | let cat_map: HashMap<i64, String> = |
|
| 467 | - | cats.iter().map(|c| (c.id, c.name.clone())).collect(); |
|
| 468 | - | ||
| 469 | - | let subscriptions = subs |
|
| 470 | - | .into_iter() |
|
| 471 | - | .map(|s| AdminSubRow { |
|
| 472 | - | id: s.id, |
|
| 473 | - | title: s.title, |
|
| 474 | - | feed_url: s.feed_url, |
|
| 475 | - | site_url: s.site_url, |
|
| 476 | - | category_name: s.category_id.and_then(|id| cat_map.get(&id).cloned()), |
|
| 477 | - | last_fetched_at: s.last_fetched_at, |
|
| 478 | - | last_error: s.last_error, |
|
| 479 | - | }) |
|
| 480 | - | .collect(); |
|
| 481 | - | ||
| 482 | - | let poll_interval_minutes = fdb::get_setting(&state.db, POLL_INTERVAL_KEY) |
|
| 483 | - | .ok() |
|
| 484 | - | .flatten() |
|
| 485 | - | .and_then(|v| v.parse::<u64>().ok()) |
|
| 486 | - | .unwrap_or(state.default_poll_minutes); |
|
| 487 | - | ||
| 488 | - | Html( |
|
| 489 | - | AdminTemplate { |
|
| 490 | - | success: q.success, |
|
| 491 | - | error: q.error, |
|
| 492 | - | subscriptions, |
|
| 493 | - | categories: cats, |
|
| 494 | - | poll_interval_minutes, |
|
| 495 | - | item_cap: state.item_cap, |
|
| 496 | - | api_key_configured: state.api_key.is_some(), |
|
| 497 | - | } |
|
| 498 | - | .render() |
|
| 499 | - | .unwrap(), |
|
| 500 | - | ) |
|
| 501 | - | .into_response() |
|
| 502 | - | } |
|
| 503 | - | ||
| 504 | - | async fn discover_feeds_handler( |
|
| 505 | - | _session: auth::AuthSession, |
|
| 506 | - | Form(form): Form<DiscoverFeedsForm>, |
|
| 507 | - | ) -> Response { |
|
| 508 | - | match feeds::discover_feeds(&form.base_url).await { |
|
| 509 | - | Ok(urls) => Json(serde_json::json!(urls)).into_response(), |
|
| 510 | - | Err(e) => ( |
|
| 511 | - | StatusCode::BAD_REQUEST, |
|
| 512 | - | Json(serde_json::json!({ "error": e })), |
|
| 513 | - | ) |
|
| 514 | - | .into_response(), |
|
| 515 | - | } |
|
| 516 | - | } |
|
| 517 | - | ||
| 518 | - | async fn add_feed_handler( |
|
| 519 | - | _session: auth::AuthSession, |
|
| 520 | - | State(state): State<Arc<AppState>>, |
|
| 521 | - | Form(form): Form<AddFeedForm>, |
|
| 522 | - | ) -> Response { |
|
| 523 | - | let body = api::CreateSubscriptionBody { |
|
| 524 | - | feed_url: form.feed_url, |
|
| 525 | - | title: None, |
|
| 526 | - | category_id: None, |
|
| 527 | - | category_name: form.category_name.filter(|s| !s.trim().is_empty()), |
|
| 528 | - | }; |
|
| 529 | - | let resp = api::add_subscription_background(state, body).await; |
|
| 530 | - | let status = resp.status(); |
|
| 531 | - | if status.is_success() { |
|
| 532 | - | Redirect::to("/admin?success=Feed+added+and+will+be+fetched+in+the+background") |
|
| 533 | - | .into_response() |
|
| 534 | - | } else if status == StatusCode::CONFLICT { |
|
| 535 | - | Redirect::to("/admin?error=Already+subscribed").into_response() |
|
| 536 | - | } else { |
|
| 537 | - | Redirect::to("/admin?error=Failed+to+add+feed").into_response() |
|
| 538 | - | } |
|
| 539 | - | } |
|
| 540 | - | ||
| 541 | - | async fn delete_feed_handler( |
|
| 542 | - | _session: auth::AuthSession, |
|
| 543 | - | State(state): State<Arc<AppState>>, |
|
| 544 | - | Path(id): Path<i64>, |
|
| 545 | - | ) -> Response { |
|
| 546 | - | match fdb::delete_subscription(&state.db, id) { |
|
| 547 | - | Ok(true) => Redirect::to("/admin?success=Feed+removed").into_response(), |
|
| 548 | - | _ => Redirect::to("/admin?error=Failed+to+remove").into_response(), |
|
| 549 | - | } |
|
| 550 | - | } |
|
| 551 | - | ||
| 552 | - | async fn update_sub_category_handler( |
|
| 553 | - | _session: auth::AuthSession, |
|
| 554 | - | State(state): State<Arc<AppState>>, |
|
| 555 | - | Path(id): Path<i64>, |
|
| 556 | - | Form(form): Form<UpdateSubCategoryForm>, |
|
| 557 | - | ) -> Response { |
|
| 558 | - | let name = form.category_name.as_deref().map(str::trim).unwrap_or(""); |
|
| 559 | - | let category_id = if name.is_empty() { |
|
| 560 | - | None |
|
| 561 | - | } else { |
|
| 562 | - | fdb::get_or_create_category(&state.db, name) |
|
| 563 | - | .ok() |
|
| 564 | - | .map(|c| c.id) |
|
| 565 | - | }; |
|
| 566 | - | let _ = fdb::update_subscription_category(&state.db, id, category_id); |
|
| 567 | - | Redirect::to("/admin?success=Category+updated").into_response() |
|
| 568 | - | } |
|
| 569 | - | ||
| 570 | - | async fn add_category_handler( |
|
| 571 | - | _session: auth::AuthSession, |
|
| 572 | - | State(state): State<Arc<AppState>>, |
|
| 573 | - | Form(form): Form<AddCategoryForm>, |
|
| 574 | - | ) -> Response { |
|
| 575 | - | let name = form.name.trim(); |
|
| 576 | - | if name.is_empty() { |
|
| 577 | - | return Redirect::to("/admin?error=Name+required").into_response(); |
|
| 578 | - | } |
|
| 579 | - | match fdb::get_or_create_category(&state.db, name) { |
|
| 580 | - | Ok(_) => Redirect::to("/admin?success=Category+added").into_response(), |
|
| 581 | - | Err(_) => Redirect::to("/admin?error=Failed+to+add+category").into_response(), |
|
| 582 | - | } |
|
| 583 | - | } |
|
| 584 | - | ||
| 585 | - | async fn delete_category_handler( |
|
| 586 | - | _session: auth::AuthSession, |
|
| 587 | - | State(state): State<Arc<AppState>>, |
|
| 588 | - | Path(id): Path<i64>, |
|
| 589 | - | ) -> Response { |
|
| 590 | - | let _ = fdb::delete_category(&state.db, id); |
|
| 591 | - | Redirect::to("/admin?success=Category+removed").into_response() |
|
| 592 | - | } |
|
| 593 | - | ||
| 594 | - | async fn import_opml_handler( |
|
| 595 | - | _session: auth::AuthSession, |
|
| 596 | - | State(state): State<Arc<AppState>>, |
|
| 597 | - | mut multipart: Multipart, |
|
| 598 | - | ) -> Response { |
|
| 599 | - | let mut content: Option<String> = None; |
|
| 600 | - | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 601 | - | if field.name() == Some("file") { |
|
| 602 | - | if let Ok(s) = field.text().await { |
|
| 603 | - | content = Some(s); |
|
| 604 | - | } |
|
| 605 | - | } |
|
| 606 | - | } |
|
| 607 | - | let Some(content) = content else { |
|
| 608 | - | return Redirect::to("/admin?error=No+file+uploaded").into_response(); |
|
| 609 | - | }; |
|
| 610 | - | let summary = api::import_opml_str(state, &content).await; |
|
| 611 | - | let msg = format!( |
|
| 612 | - | "Imported+{}%2C+skipped+{}", |
|
| 613 | - | summary.imported, summary.skipped |
|
| 614 | - | ); |
|
| 615 | - | Redirect::to(&format!("/admin?success={msg}")).into_response() |
|
| 616 | - | } |
|
| 617 | - | ||
| 618 | - | async fn update_settings_handler( |
|
| 619 | - | _session: auth::AuthSession, |
|
| 620 | - | State(state): State<Arc<AppState>>, |
|
| 621 | - | Form(form): Form<UpdateSettingsForm>, |
|
| 622 | - | ) -> Response { |
|
| 623 | - | if !(1..=1440).contains(&form.poll_interval_minutes) { |
|
| 624 | - | return Redirect::to("/admin?error=Interval+must+be+1-1440").into_response(); |
|
| 625 | - | } |
|
| 626 | - | let _ = fdb::set_setting( |
|
| 627 | - | &state.db, |
|
| 628 | - | POLL_INTERVAL_KEY, |
|
| 629 | - | &form.poll_interval_minutes.to_string(), |
|
| 630 | - | ); |
|
| 631 | - | Redirect::to("/admin?success=Settings+saved").into_response() |
|
| 632 | - | } |
|
| 633 | - | ||
| 634 | - | // ── main ────────────────────────────────────────────────────────────── |
|
| 635 | - | ||
| 636 | - | #[tokio::main] |
|
| 637 | - | async fn main() { |
|
| 638 | - | dotenvy::dotenv().ok(); |
|
| 639 | - | tracing_subscriber::fmt() |
|
| 640 | - | .with_env_filter( |
|
| 641 | - | tracing_subscriber::EnvFilter::try_from_default_env() |
|
| 642 | - | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,feeds=info")), |
|
| 643 | - | ) |
|
| 644 | - | .init(); |
|
| 645 | - | ||
| 646 | - | let db_path = |
|
| 647 | - | std::env::var("FEEDS_DB_PATH").unwrap_or_else(|_| "feeds.sqlite".to_string()); |
|
| 648 | - | let conn = Connection::open(&db_path).expect("open sqlite"); |
|
| 649 | - | conn.execute_batch(SESSION_SCHEMA).expect("session schema"); |
|
| 650 | - | conn.execute_batch(fdb::FEEDS_SCHEMA).expect("feeds schema"); |
|
| 651 | - | let db: Db = Arc::new(Mutex::new(conn)); |
|
| 652 | - | fdb::migrate_feeds(&db).expect("feeds migration"); |
|
| 653 | - | ||
| 654 | - | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 655 | - | .map(|v| v.eq_ignore_ascii_case("true")) |
|
| 656 | - | .unwrap_or(false); |
|
| 657 | - | let base_url = |
|
| 658 | - | std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); |
|
| 659 | - | let default_poll_minutes: u64 = std::env::var("DEFAULT_POLL_MINUTES") |
|
| 660 | - | .ok() |
|
| 661 | - | .and_then(|v| v.parse().ok()) |
|
| 662 | - | .unwrap_or(30); |
|
| 663 | - | let item_cap: usize = std::env::var("ITEM_CAP_PER_FEED") |
|
| 664 | - | .ok() |
|
| 665 | - | .and_then(|v| v.parse().ok()) |
|
| 666 | - | .unwrap_or(200); |
|
| 667 | - | ||
| 668 | - | // Seed poll-interval setting if missing so the admin UI shows a value. |
|
| 669 | - | if fdb::get_setting(&db, POLL_INTERVAL_KEY).ok().flatten().is_none() { |
|
| 670 | - | let _ = fdb::set_setting(&db, POLL_INTERVAL_KEY, &default_poll_minutes.to_string()); |
|
| 671 | - | } |
|
| 672 | - | ||
| 673 | - | let api_key = std::env::var("API_KEY").ok().filter(|s| !s.is_empty()); |
|
| 674 | - | if api_key.is_none() { |
|
| 675 | - | tracing::warn!("API_KEY is not set; /api is accessible via session cookie only"); |
|
| 676 | - | } |
|
| 677 | - | ||
| 678 | - | let state = Arc::new(AppState { |
|
| 679 | - | db, |
|
| 680 | - | admin_password: std::env::var("ADMIN_PASSWORD").ok(), |
|
| 681 | - | api_key, |
|
| 682 | - | cookie_secure, |
|
| 683 | - | base_url, |
|
| 684 | - | default_poll_minutes, |
|
| 685 | - | item_cap, |
|
| 686 | - | }); |
|
| 687 | - | ||
| 688 | - | tokio::spawn(poller::run(state.clone())); |
|
| 689 | - | ||
| 690 | - | let admin_router = Router::new() |
|
| 691 | - | .route("/admin", get(admin_handler)) |
|
| 692 | - | .route( |
|
| 693 | - | "/admin/login", |
|
| 694 | - | get(login_get_handler).post(login_post_handler), |
|
| 695 | - | ) |
|
| 696 | - | .route("/admin/logout", get(logout_handler)) |
|
| 697 | - | .route("/admin/add-feed", post(add_feed_handler)) |
|
| 698 | - | .route("/admin/feeds/{id}/delete", post(delete_feed_handler)) |
|
| 699 | - | .route("/admin/feeds/{id}/category", post(update_sub_category_handler)) |
|
| 700 | - | .route("/admin/categories", post(add_category_handler)) |
|
| 701 | - | .route("/admin/categories/{id}/delete", post(delete_category_handler)) |
|
| 702 | - | .route("/admin/import-opml", post(import_opml_handler)) |
|
| 703 | - | .route("/admin/settings", post(update_settings_handler)) |
|
| 704 | - | .route("/admin/discover-feeds", post(discover_feeds_handler)); |
|
| 705 | - | ||
| 706 | - | let api_router = Router::new() |
|
| 707 | - | .route("/api/items", get(api::list_items)) |
|
| 708 | - | .route("/api/items/{id}/read", post(api::mark_item_read)) |
|
| 709 | - | .route("/api/items/{id}/unread", post(api::mark_item_unread)) |
|
| 710 | - | .route( |
|
| 711 | - | "/api/subscriptions", |
|
| 712 | - | get(api::list_subscriptions).post(api::create_subscription), |
|
| 713 | - | ) |
|
| 714 | - | .route( |
|
| 715 | - | "/api/subscriptions/{id}", |
|
| 716 | - | delete(api::delete_subscription).patch(api::update_subscription), |
|
| 717 | - | ) |
|
| 718 | - | .route( |
|
| 719 | - | "/api/categories", |
|
| 720 | - | get(api::list_categories).post(api::create_category), |
|
| 721 | - | ) |
|
| 722 | - | .route("/api/categories/{id}", delete(api::delete_category)) |
|
| 723 | - | .route("/api/import/opml", post(api::import_opml)) |
|
| 724 | - | .route( |
|
| 725 | - | "/api/settings", |
|
| 726 | - | get(api::get_settings).put(api::update_settings), |
|
| 727 | - | ) |
|
| 728 | - | .route("/api/discover", post(api::discover)) |
|
| 729 | - | .layer( |
|
| 730 | - | CorsLayer::new() |
|
| 731 | - | .allow_origin(Any) |
|
| 732 | - | .allow_methods([Method::GET]) |
|
| 733 | - | .allow_headers(Any), |
|
| 734 | - | ); |
|
| 735 | - | ||
| 736 | - | let app = Router::new() |
|
| 737 | - | .route("/", get(index_handler)) |
|
| 738 | - | .route("/feeds", get(feeds_handler)) |
|
| 739 | - | .route("/feed.xml", get(atom_feed_handler)) |
|
| 740 | - | .route("/static/{*path}", get(static_handler)) |
|
| 741 | - | .merge(admin_router) |
|
| 742 | - | .merge(api_router) |
|
| 743 | - | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 744 | - | .with_state(state); |
|
| 745 | - | ||
| 746 | - | let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); |
|
| 747 | - | let port: u16 = std::env::var("PORT") |
|
| 748 | - | .ok() |
|
| 749 | - | .and_then(|v| v.parse().ok()) |
|
| 750 | - | .unwrap_or(3000); |
|
| 751 | - | let addr = format!("{host}:{port}"); |
|
| 752 | - | let listener = tokio::net::TcpListener::bind(&addr) |
|
| 753 | - | .await |
|
| 754 | - | .unwrap_or_else(|_| panic!("Failed to bind to {addr}")); |
|
| 755 | - | ||
| 756 | - | tracing::info!("Feeds server running on http://{host}:{port}"); |
|
| 757 | - | axum::serve(listener, app).await.unwrap(); |
|
| 758 | - | } |
|
| 759 | - | ||
| 760 | - | #[cfg(test)] |
|
| 761 | - | mod tests { |
|
| 762 | - | use super::*; |
|
| 763 | - | ||
| 764 | - | #[test] |
|
| 765 | - | fn escape_xml_all_special() { |
|
| 766 | - | assert_eq!( |
|
| 767 | - | escape_xml(r#"<a href="x">&'test'</a>"#), |
|
| 768 | - | "<a href="x">&'test'</a>" |
|
| 769 | - | ); |
|
| 770 | - | } |
|
| 771 | - | ||
| 772 | - | #[test] |
|
| 773 | - | fn format_date_valid_timestamp() { |
|
| 774 | - | assert_eq!(format_date(1705276800), "Jan 15, 2024"); |
|
| 775 | - | } |
|
| 776 | - | } |
| 1 | - | use serde::{Deserialize, Serialize}; |
|
| 2 | - | ||
| 3 | - | /// Normalized feed entry used by the index template and ad-hoc URL previews. |
|
| 4 | - | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 5 | - | pub struct FeedItem { |
|
| 6 | - | pub title: String, |
|
| 7 | - | pub link: String, |
|
| 8 | - | pub author: String, |
|
| 9 | - | pub published: i64, |
|
| 10 | - | } |
| 1 | - | use std::sync::Arc; |
|
| 2 | - | use std::time::Duration; |
|
| 3 | - | ||
| 4 | - | use andromeda_db::feeds as fdb; |
|
| 5 | - | use chrono::Utc; |
|
| 6 | - | ||
| 7 | - | use crate::feeds::{fetch_feed, FetchResult}; |
|
| 8 | - | use crate::AppState; |
|
| 9 | - | ||
| 10 | - | pub const POLL_INTERVAL_KEY: &str = "poll_interval_minutes"; |
|
| 11 | - | ||
| 12 | - | pub async fn run(state: Arc<AppState>) { |
|
| 13 | - | // Stagger the first pass so startup is fast. |
|
| 14 | - | tokio::time::sleep(Duration::from_secs(3)).await; |
|
| 15 | - | loop { |
|
| 16 | - | let minutes = poll_interval_minutes(&state); |
|
| 17 | - | tracing::info!("poller pass starting (interval {minutes}m)"); |
|
| 18 | - | if let Err(e) = sweep(&state).await { |
|
| 19 | - | tracing::error!("poller sweep failed: {e}"); |
|
| 20 | - | } |
|
| 21 | - | tokio::time::sleep(Duration::from_secs(minutes * 60)).await; |
|
| 22 | - | } |
|
| 23 | - | } |
|
| 24 | - | ||
| 25 | - | fn poll_interval_minutes(state: &AppState) -> u64 { |
|
| 26 | - | fdb::get_setting(&state.db, POLL_INTERVAL_KEY) |
|
| 27 | - | .ok() |
|
| 28 | - | .flatten() |
|
| 29 | - | .and_then(|v| v.parse::<u64>().ok()) |
|
| 30 | - | .filter(|v| *v >= 1) |
|
| 31 | - | .unwrap_or(state.default_poll_minutes) |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | async fn sweep(state: &AppState) -> Result<(), String> { |
|
| 35 | - | let subs = fdb::list_subscriptions(&state.db).map_err(|e| e.to_string())?; |
|
| 36 | - | for sub in subs { |
|
| 37 | - | if let Err(e) = poll_one(state, &sub).await { |
|
| 38 | - | tracing::warn!("feed {} failed: {}", sub.feed_url, e); |
|
| 39 | - | let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); |
|
| 40 | - | let _ = fdb::update_subscription_meta( |
|
| 41 | - | &state.db, |
|
| 42 | - | sub.id, |
|
| 43 | - | sub.etag.as_deref(), |
|
| 44 | - | sub.last_modified.as_deref(), |
|
| 45 | - | &now, |
|
| 46 | - | Some(&e), |
|
| 47 | - | ); |
|
| 48 | - | } |
|
| 49 | - | } |
|
| 50 | - | Ok(()) |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | pub async fn poll_one(state: &AppState, sub: &fdb::Subscription) -> Result<usize, String> { |
|
| 54 | - | let result: FetchResult = |
|
| 55 | - | fetch_feed(&sub.feed_url, sub.etag.as_deref(), sub.last_modified.as_deref()).await?; |
|
| 56 | - | let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); |
|
| 57 | - | ||
| 58 | - | let mut inserted = 0usize; |
|
| 59 | - | if result.status != 304 { |
|
| 60 | - | for entry in &result.entries { |
|
| 61 | - | if entry.link.is_empty() { |
|
| 62 | - | continue; |
|
| 63 | - | } |
|
| 64 | - | let item = fdb::NewItem { |
|
| 65 | - | subscription_id: sub.id, |
|
| 66 | - | guid: &entry.guid, |
|
| 67 | - | title: &entry.title, |
|
| 68 | - | link: &entry.link, |
|
| 69 | - | author: entry.author.as_deref(), |
|
| 70 | - | published_at: entry.published_at, |
|
| 71 | - | }; |
|
| 72 | - | match fdb::insert_item_ignore_dup(&state.db, &item) { |
|
| 73 | - | Ok(true) => inserted += 1, |
|
| 74 | - | Ok(false) => {} |
|
| 75 | - | Err(e) => tracing::warn!("insert item failed for {}: {}", sub.feed_url, e), |
|
| 76 | - | } |
|
| 77 | - | } |
|
| 78 | - | ||
| 79 | - | // Refresh title if feed advertises a new one and current title looks placeholder. |
|
| 80 | - | if let Some(new_title) = result.title.as_deref() { |
|
| 81 | - | if !new_title.is_empty() && sub.title != new_title && sub.title == sub.feed_url { |
|
| 82 | - | let _ = fdb::update_subscription_title(&state.db, sub.id, new_title); |
|
| 83 | - | } |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | let _ = fdb::prune_subscription(&state.db, sub.id, state.item_cap as i64); |
|
| 87 | - | } |
|
| 88 | - | ||
| 89 | - | fdb::update_subscription_meta( |
|
| 90 | - | &state.db, |
|
| 91 | - | sub.id, |
|
| 92 | - | result.etag.as_deref(), |
|
| 93 | - | result.last_modified.as_deref(), |
|
| 94 | - | &now, |
|
| 95 | - | None, |
|
| 96 | - | ) |
|
| 97 | - | .map_err(|e| e.to_string())?; |
|
| 98 | - | ||
| 99 | - | tracing::info!( |
|
| 100 | - | "{} status={} new={} total_entries={}", |
|
| 101 | - | sub.feed_url, |
|
| 102 | - | result.status, |
|
| 103 | - | inserted, |
|
| 104 | - | result.entries.len() |
|
| 105 | - | ); |
|
| 106 | - | Ok(inserted) |
|
| 107 | - | } |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | - | <title>Feeds | Admin</title> |
|
| 14 | - | </head> |
|
| 15 | - | <body> |
|
| 16 | - | <div class="header"> |
|
| 17 | - | <a href="/" class="logo"><h1>FEEDS</h1></a> |
|
| 18 | - | <nav class="links"> |
|
| 19 | - | <a href="/feeds?format=opml">opml</a> |
|
| 20 | - | <a href="/admin/logout">logout</a> |
|
| 21 | - | </nav> |
|
| 22 | - | </div> |
|
| 23 | - | ||
| 24 | - | {% if let Some(msg) = success %} |
|
| 25 | - | <p class="success">{{ msg }}</p> |
|
| 26 | - | {% endif %} |
|
| 27 | - | {% if let Some(err) = error %} |
|
| 28 | - | <p class="error">{{ err }}</p> |
|
| 29 | - | {% endif %} |
|
| 30 | - | ||
| 31 | - | <section class="admin-form"> |
|
| 32 | - | <h3>Discover</h3> |
|
| 33 | - | <div class="discover-row"> |
|
| 34 | - | <input type="url" id="base_url" placeholder="https://example.com" /> |
|
| 35 | - | <button type="button" id="discover-btn" onclick="discoverFeeds()">Discover</button> |
|
| 36 | - | </div> |
|
| 37 | - | <div id="discover-status" class="discover-status" style="display:none;"></div> |
|
| 38 | - | <div id="discover-results" class="discover-results" style="display:none;"></div> |
|
| 39 | - | </section> |
|
| 40 | - | ||
| 41 | - | <form class="admin-form" id="add-feed-form" method="POST" action="/admin/add-feed"> |
|
| 42 | - | <h3>Add Feed</h3> |
|
| 43 | - | <label for="feed_url">Feed URL</label> |
|
| 44 | - | <input type="url" id="feed_url" name="feed_url" placeholder="https://example.com/feed.xml" required /> |
|
| 45 | - | <label for="category_name">Category (optional)</label> |
|
| 46 | - | <input type="text" id="category_name" name="category_name" placeholder="Tech" list="categories-list" /> |
|
| 47 | - | <datalist id="categories-list"> |
|
| 48 | - | {% for c in categories %} |
|
| 49 | - | <option value="{{ c.name }}"></option> |
|
| 50 | - | {% endfor %} |
|
| 51 | - | </datalist> |
|
| 52 | - | <button type="submit" id="add-feed-submit"><span id="add-feed-label">Add Feed</span></button> |
|
| 53 | - | </form> |
|
| 54 | - | ||
| 55 | - | <form class="admin-form" id="opml-form" method="POST" action="/admin/import-opml" enctype="multipart/form-data"> |
|
| 56 | - | <h3>Import OPML</h3> |
|
| 57 | - | <input type="file" name="file" accept=".opml,.xml,application/xml,text/xml" required /> |
|
| 58 | - | <button type="submit" id="opml-submit"><span id="opml-submit-label">Import</span></button> |
|
| 59 | - | </form> |
|
| 60 | - | ||
| 61 | - | <form class="admin-form" method="POST" action="/admin/settings"> |
|
| 62 | - | <h3>Settings</h3> |
|
| 63 | - | <label for="poll_interval_minutes">Poll interval (minutes)</label> |
|
| 64 | - | <input type="number" id="poll_interval_minutes" name="poll_interval_minutes" |
|
| 65 | - | min="1" max="1440" value="{{ poll_interval_minutes }}" required /> |
|
| 66 | - | <p class="hint">Item cap per feed: {{ item_cap }} (set via ITEM_CAP_PER_FEED)</p> |
|
| 67 | - | <p class="hint">API key: {% if api_key_configured %}configured{% else %}not set{% endif %}</p> |
|
| 68 | - | <button type="submit">Save</button> |
|
| 69 | - | </form> |
|
| 70 | - | ||
| 71 | - | <section class="admin-subs"> |
|
| 72 | - | <h3>Categories ({{ categories.len() }})</h3> |
|
| 73 | - | <form class="admin-form inline" method="POST" action="/admin/categories"> |
|
| 74 | - | <input type="text" name="name" placeholder="New category" required /> |
|
| 75 | - | <button type="submit">Add</button> |
|
| 76 | - | </form> |
|
| 77 | - | <ul class="category-list"> |
|
| 78 | - | {% for c in categories %} |
|
| 79 | - | <li> |
|
| 80 | - | <span>{{ c.name }}</span> |
|
| 81 | - | <form method="POST" action="/admin/categories/{{ c.id }}/delete" class="inline"> |
|
| 82 | - | <button type="submit" class="danger">Delete</button> |
|
| 83 | - | </form> |
|
| 84 | - | </li> |
|
| 85 | - | {% endfor %} |
|
| 86 | - | </ul> |
|
| 87 | - | </section> |
|
| 88 | - | ||
| 89 | - | <section class="admin-subs"> |
|
| 90 | - | <h3>Subscriptions ({{ subscriptions.len() }})</h3> |
|
| 91 | - | <div class="feeds-list"> |
|
| 92 | - | {% for sub in subscriptions %} |
|
| 93 | - | <div class="feed-item"> |
|
| 94 | - | <h3 class="feed-title"> |
|
| 95 | - | <a href="{% if let Some(url) = sub.site_url %}{{ url }}{% else %}{{ sub.feed_url }}{% endif %}" target="_blank" rel="noopener noreferrer">{{ sub.title }}</a> |
|
| 96 | - | </h3> |
|
| 97 | - | {% if let Some(last) = sub.last_fetched_at %} |
|
| 98 | - | <p class="feed-meta"><span class="feed-date">last: {{ last }}</span>{% if let Some(err) = sub.last_error %} <span class="error">· {{ err }}</span>{% endif %}</p> |
|
| 99 | - | {% endif %} |
|
| 100 | - | <form method="POST" action="/admin/feeds/{{ sub.id }}/category" class="inline"> |
|
| 101 | - | <input type="text" name="category_name" placeholder="category" list="categories-list" |
|
| 102 | - | value="{% if let Some(n) = sub.category_name %}{{ n }}{% endif %}" /> |
|
| 103 | - | <button type="submit">Save</button> |
|
| 104 | - | </form> |
|
| 105 | - | <form method="POST" action="/admin/feeds/{{ sub.id }}/delete" class="inline"> |
|
| 106 | - | <button type="submit" class="danger">Delete</button> |
|
| 107 | - | </form> |
|
| 108 | - | </div> |
|
| 109 | - | {% endfor %} |
|
| 110 | - | </div> |
|
| 111 | - | </section> |
|
| 112 | - | ||
| 113 | - | <script> |
|
| 114 | - | (function() { |
|
| 115 | - | const form = document.getElementById('add-feed-form'); |
|
| 116 | - | if (!form) return; |
|
| 117 | - | form.addEventListener('submit', function() { |
|
| 118 | - | const btn = document.getElementById('add-feed-submit'); |
|
| 119 | - | const label = document.getElementById('add-feed-label'); |
|
| 120 | - | btn.disabled = true; |
|
| 121 | - | btn.classList.add('loading'); |
|
| 122 | - | label.innerHTML = 'Adding <span class="spinner"></span>'; |
|
| 123 | - | }); |
|
| 124 | - | })(); |
|
| 125 | - | ||
| 126 | - | (function() { |
|
| 127 | - | const form = document.getElementById('opml-form'); |
|
| 128 | - | if (!form) return; |
|
| 129 | - | form.addEventListener('submit', function() { |
|
| 130 | - | const btn = document.getElementById('opml-submit'); |
|
| 131 | - | const label = document.getElementById('opml-submit-label'); |
|
| 132 | - | btn.disabled = true; |
|
| 133 | - | btn.classList.add('loading'); |
|
| 134 | - | label.innerHTML = 'Importing <span class="spinner"></span>'; |
|
| 135 | - | }); |
|
| 136 | - | })(); |
|
| 137 | - | ||
| 138 | - | async function discoverFeeds() { |
|
| 139 | - | const baseUrl = document.getElementById('base_url').value.trim(); |
|
| 140 | - | if (!baseUrl) return; |
|
| 141 | - | const btn = document.getElementById('discover-btn'); |
|
| 142 | - | const status = document.getElementById('discover-status'); |
|
| 143 | - | const results = document.getElementById('discover-results'); |
|
| 144 | - | const feedInput = document.getElementById('feed_url'); |
|
| 145 | - | btn.disabled = true; |
|
| 146 | - | btn.textContent = 'Searching...'; |
|
| 147 | - | status.style.display = 'none'; |
|
| 148 | - | results.style.display = 'none'; |
|
| 149 | - | results.innerHTML = ''; |
|
| 150 | - | try { |
|
| 151 | - | const body = new URLSearchParams({ base_url: baseUrl }); |
|
| 152 | - | const resp = await fetch('/admin/discover-feeds', { method: 'POST', body }); |
|
| 153 | - | const data = await resp.json(); |
|
| 154 | - | if (!resp.ok) { |
|
| 155 | - | status.textContent = data.error || 'No feeds found'; |
|
| 156 | - | status.className = 'discover-status error'; |
|
| 157 | - | status.style.display = 'block'; |
|
| 158 | - | return; |
|
| 159 | - | } |
|
| 160 | - | feedInput.value = data[0]; |
|
| 161 | - | status.textContent = data.length + ' feed(s) found'; |
|
| 162 | - | status.className = 'discover-status success'; |
|
| 163 | - | status.style.display = 'block'; |
|
| 164 | - | if (data.length > 1) { |
|
| 165 | - | results.style.display = 'flex'; |
|
| 166 | - | data.forEach(function(url) { |
|
| 167 | - | const item = document.createElement('button'); |
|
| 168 | - | item.type = 'button'; |
|
| 169 | - | item.className = 'discover-result-item' + (url === data[0] ? ' active' : ''); |
|
| 170 | - | item.textContent = url; |
|
| 171 | - | item.onclick = function() { |
|
| 172 | - | feedInput.value = url; |
|
| 173 | - | results.querySelectorAll('.discover-result-item').forEach(function(el) { |
|
| 174 | - | el.classList.remove('active'); |
|
| 175 | - | }); |
|
| 176 | - | item.classList.add('active'); |
|
| 177 | - | }; |
|
| 178 | - | results.appendChild(item); |
|
| 179 | - | }); |
|
| 180 | - | } |
|
| 181 | - | } catch (e) { |
|
| 182 | - | status.textContent = 'Request failed'; |
|
| 183 | - | status.className = 'discover-status error'; |
|
| 184 | - | status.style.display = 'block'; |
|
| 185 | - | } finally { |
|
| 186 | - | btn.disabled = false; |
|
| 187 | - | btn.textContent = 'Discover'; |
|
| 188 | - | } |
|
| 189 | - | } |
|
| 190 | - | </script> |
|
| 191 | - | </body> |
|
| 192 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | - | ||
| 14 | - | <title>Feeds</title> |
|
| 15 | - | <meta name="description" content="Minimal RSS Reading"> |
|
| 16 | - | ||
| 17 | - | <meta property="og:url" content="{{ base_url }}"> |
|
| 18 | - | <meta property="og:type" content="website"> |
|
| 19 | - | <meta property="og:title" content="Feeds"> |
|
| 20 | - | <meta property="og:description" content="Minimal RSS Reading"> |
|
| 21 | - | <meta property="og:image" content="{{ base_url }}/static/og.png"> |
|
| 22 | - | ||
| 23 | - | <meta name="twitter:card" content="summary_large_image"> |
|
| 24 | - | <meta property="twitter:url" content="{{ base_url }}"> |
|
| 25 | - | <meta name="twitter:title" content="Feeds"> |
|
| 26 | - | <meta name="twitter:description" content="Minimal RSS Reading"> |
|
| 27 | - | <meta name="twitter:image" content="{{ base_url }}/static/og.png"> |
|
| 28 | - | </head> |
|
| 29 | - | <body> |
|
| 30 | - | <div class="header"> |
|
| 31 | - | <a href="/" class="logo"><h1>FEEDS</h1></a> |
|
| 32 | - | <nav class="links"> |
|
| 33 | - | <a href="/admin">add</a> |
|
| 34 | - | </nav> |
|
| 35 | - | </div> |
|
| 36 | - | ||
| 37 | - | {% if let Some(urls) = feed_urls %} |
|
| 38 | - | <div id="feed-urls"> |
|
| 39 | - | {% for url in urls %} |
|
| 40 | - | {{ url }}<br> |
|
| 41 | - | {% endfor %} |
|
| 42 | - | </div> |
|
| 43 | - | {% endif %} |
|
| 44 | - | ||
| 45 | - | {% if let Some(err) = error %} |
|
| 46 | - | <div id="error" class="error"> |
|
| 47 | - | <p>{{ err }}</p> |
|
| 48 | - | </div> |
|
| 49 | - | {% elif items.is_empty() %} |
|
| 50 | - | <p class="no-feeds">No feeds available</p> |
|
| 51 | - | {% else %} |
|
| 52 | - | <div id="feeds-container"> |
|
| 53 | - | <div class="feeds-list"> |
|
| 54 | - | {% for item in items %} |
|
| 55 | - | <article class="feed-item"> |
|
| 56 | - | <div class="feed-meta"> |
|
| 57 | - | <span class="feed-date">{{ item.formatted_date }}</span> |
|
| 58 | - | </div> |
|
| 59 | - | <h3 class="feed-title"> |
|
| 60 | - | <a href="{{ item.link }}" target="_blank" rel="noopener noreferrer"> |
|
| 61 | - | {{ item.title }} |
|
| 62 | - | </a> |
|
| 63 | - | </h3> |
|
| 64 | - | {% if !item.author.is_empty() %} |
|
| 65 | - | <p class="feed-author">{{ item.author }}</p> |
|
| 66 | - | {% endif %} |
|
| 67 | - | </article> |
|
| 68 | - | {% endfor %} |
|
| 69 | - | </div> |
|
| 70 | - | </div> |
|
| 71 | - | {% endif %} |
|
| 72 | - | </body> |
|
| 73 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | - | <title>Feeds | Login</title> |
|
| 14 | - | </head> |
|
| 15 | - | <body> |
|
| 16 | - | <a href="/" class="header"> |
|
| 17 | - | <h1>FEEDS</h1> |
|
| 18 | - | </a> |
|
| 19 | - | {% if let Some(err) = error %} |
|
| 20 | - | <p class="error">{{ err }}</p> |
|
| 21 | - | {% endif %} |
|
| 22 | - | ||
| 23 | - | <form class="admin-form" method="POST" action="/admin/login"> |
|
| 24 | - | <label for="password">Password</label> |
|
| 25 | - | <input type="password" id="password" name="password" required autofocus /> |
|
| 26 | - | <button type="submit">Login</button> |
|
| 27 | - | </form> |
|
| 28 | - | </body> |
|
| 29 | - | </html> |
| 20 | 20 | ||
| 21 | 21 | /* Feeds list */ |
|
| 22 | 22 | ||
| 23 | + | #feeds-container { |
|
| 24 | + | width: 100%; |
|
| 25 | + | } |
|
| 26 | + | ||
| 23 | 27 | .feeds-list { |
|
| 24 | 28 | width: 100%; |
|
| 25 | 29 | display: flex; |
|
| 28 | 32 | } |
|
| 29 | 33 | ||
| 30 | 34 | .feed-item { |
|
| 35 | + | width: 100%; |
|
| 31 | 36 | display: flex; |
|
| 32 | 37 | flex-direction: column; |
|
| 33 | 38 | gap: 0.5rem; |
|
| 1 | - | JOTTS_PASSWORD=changeme |
|
| 2 | - | JOTTS_DB_PATH=jotts.sqlite |
|
| 3 | - | COOKIE_SECURE=false |
|
| 4 | - | HOST=127.0.0.1 |
|
| 5 | - | PORT=3000 |
|
| 6 | - | # Optional. When set, enables the JSON API at /api/notes gated by x-api-key header. |
|
| 7 | - | # Leave unset to disable the API (returns 403). |
|
| 8 | - | JOTTS_API_KEY= |
| 1 | - | # Build from repo root: docker build -t jotts-go -f apps/jotts-go/Dockerfile . |
|
| 2 | - | FROM golang:1.25-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/jotts-go/go.mod apps/jotts-go/go.sum ./apps/jotts-go/ |
|
| 6 | - | WORKDIR /app/apps/jotts-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/jotts-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /jotts-go . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | COPY --from=builder /jotts-go /usr/local/bin/jotts-go |
|
| 13 | - | WORKDIR /data |
|
| 14 | - | ENV HOST=0.0.0.0 |
|
| 15 | - | ENV PORT=3000 |
|
| 16 | - | EXPOSE 3000 |
|
| 17 | - | CMD ["jotts-go", "server"] |
| 1 | - | # jotts-go |
|
| 2 | - | ||
| 3 | - | Go port of [jotts](../jotts): minimal markdown notes app. |
|
| 4 | - | ||
| 5 | - | ## Stack |
|
| 6 | - | ||
| 7 | - | - Go stdlib `net/http` + `html/template` |
|
| 8 | - | - `modernc.org/sqlite` (pure-Go SQLite, no CGO) |
|
| 9 | - | - `github.com/yuin/goldmark` (markdown rendering w/ strikethrough, tables, tasklists) |
|
| 10 | - | - Bubble Tea/Lip Gloss/Glamour for the TUI editor |
|
| 11 | - | - `github.com/pkg/browser` and `github.com/atotto/clipboard` for TUI browser/copy actions |
|
| 12 | - | ||
| 13 | - | ## Quickstart |
|
| 14 | - | ||
| 15 | - | ```bash |
|
| 16 | - | cp .env.example .env |
|
| 17 | - | # edit .env with your password |
|
| 18 | - | go run . |
|
| 19 | - | ``` |
|
| 20 | - | ||
| 21 | - | ## Environment variables |
|
| 22 | - | ||
| 23 | - | | Variable | Description | Default | |
|
| 24 | - | |---|---|---| |
|
| 25 | - | | `JOTTS_PASSWORD` | Login password | `changeme` | |
|
| 26 | - | | `JOTTS_DB_PATH` | SQLite file path | `jotts.sqlite` | |
|
| 27 | - | | `HOST` | Bind address | `127.0.0.1` | |
|
| 28 | - | | `PORT` | Server port | `3000` | |
|
| 29 | - | | `COOKIE_SECURE` | HTTPS-only cookies | `false` | |
|
| 30 | - | | `JOTTS_API_KEY` | API key for `/api/notes` (unset = API disabled) | _(unset)_ | |
|
| 31 | - | ||
| 32 | - | ## Structure |
|
| 33 | - | ||
| 34 | - | ``` |
|
| 35 | - | jotts-go/ |
|
| 36 | - | ├── main.go # entrypoint |
|
| 37 | - | ├── app.go # App struct + page data types |
|
| 38 | - | ├── db.go # SQLite schema + queries (notes, sessions) |
|
| 39 | - | ├── routes.go # http.ServeMux routes |
|
| 40 | - | ├── middleware.go # session + API key middleware, cookies |
|
| 41 | - | ├── handlers_web.go # HTML form handlers |
|
| 42 | - | ├── handlers_api.go # JSON API handlers |
|
| 43 | - | ├── markdown.go # goldmark rendering |
|
| 44 | - | ├── web.go # template render, JSON, embedded static |
|
| 45 | - | ├── util.go # env, dotenv, short IDs, session tokens |
|
| 46 | - | ├── templates/ # html/template pages |
|
| 47 | - | ├── static/ # favicons, styles, og image |
|
| 48 | - | ├── assets/ # darkmatter.css + Commit Mono fonts |
|
| 49 | - | ├── Dockerfile |
|
| 50 | - | └── docker-compose.yml |
|
| 51 | - | ``` |
|
| 52 | - | ||
| 53 | - | ## API |
|
| 54 | - | ||
| 55 | - | All endpoints require `x-api-key: $JOTTS_API_KEY` header. |
|
| 56 | - | ||
| 57 | - | - `GET /api/notes` — list notes |
|
| 58 | - | - `POST /api/notes` — create `{title, content}` |
|
| 59 | - | - `GET /api/notes/{short_id}` |
|
| 60 | - | - `PUT /api/notes/{short_id}` — update `{title, content}` |
|
| 61 | - | - `DELETE /api/notes/{short_id}` |
|
| 62 | - | ||
| 63 | - | ## Build |
|
| 64 | - | ||
| 65 | - | ```bash |
|
| 66 | - | CGO_ENABLED=0 go build -o jotts-go . |
|
| 67 | - | ``` |
|
| 68 | - | ||
| 69 | - | Single ~10MB self-contained binary with all assets embedded. |
|
| 70 | - | ||
| 71 | - | ## Docker |
|
| 72 | - | ||
| 73 | - | ```bash |
|
| 74 | - | docker compose up -d |
|
| 75 | - | ``` |
| 6 | 6 | "html/template" |
|
| 7 | 7 | "log/slog" |
|
| 8 | 8 | ||
| 9 | - | "github.com/stevedylandev/andromeda/apps/jotts-go/internal/store" |
|
| 9 | + | "github.com/stevedylandev/andromeda/apps/jotts/internal/store" |
|
| 10 | 10 | "github.com/stevedylandev/andromeda/crates-go/auth" |
|
| 11 | 11 | ) |
|
| 12 | 12 |
| 7 | 7 | "strings" |
|
| 8 | 8 | "syscall" |
|
| 9 | 9 | ||
| 10 | - | "github.com/stevedylandev/andromeda/apps/jotts-go/tui" |
|
| 10 | + | "github.com/stevedylandev/andromeda/apps/jotts/tui" |
|
| 11 | 11 | "golang.org/x/term" |
|
| 12 | 12 | ) |
|
| 13 | 13 |
| 51 | 51 | } |
|
| 52 | 52 | ||
| 53 | 53 | addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000") |
|
| 54 | - | logger.Info("jotts-go server running", "addr", addr) |
|
| 54 | + | logger.Info("jotts server running", "addr", addr) |
|
| 55 | 55 | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 56 | 56 | log.Fatal(err) |
|
| 57 | 57 | } |
| 7 | 7 | "strings" |
|
| 8 | 8 | ||
| 9 | 9 | "github.com/atotto/clipboard" |
|
| 10 | - | "github.com/stevedylandev/andromeda/apps/jotts-go/tui" |
|
| 10 | + | "github.com/stevedylandev/andromeda/apps/jotts/tui" |
|
| 11 | 11 | ) |
|
| 12 | 12 | ||
| 13 | 13 | func runUpload(args []string) { |
| 3 | 3 | import ( |
|
| 4 | 4 | "database/sql" |
|
| 5 | 5 | ||
| 6 | - | "github.com/stevedylandev/andromeda/apps/jotts-go/internal/store" |
|
| 6 | + | "github.com/stevedylandev/andromeda/apps/jotts/internal/store" |
|
| 7 | 7 | ) |
|
| 8 | 8 | ||
| 9 | 9 | func openDB(path string) (*sql.DB, error) { return store.Open(path) } |
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/jotts-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | - | environment: |
|
| 9 | - | - JOTTS_PASSWORD=${JOTTS_PASSWORD:-changeme} |
|
| 10 | - | - JOTTS_DB_PATH=/data/jotts.sqlite |
|
| 11 | - | - COOKIE_SECURE=false |
|
| 12 | - | - HOST=0.0.0.0 |
|
| 13 | - | - PORT=${PORT:-3000} |
|
| 14 | - | - JOTTS_API_KEY=${JOTTS_API_KEY:-} |
|
| 15 | - | volumes: |
|
| 16 | - | - jotts-go-data:/data |
|
| 17 | - | restart: unless-stopped |
|
| 18 | - | ||
| 19 | - | volumes: |
|
| 20 | - | jotts-go-data: |
| 1 | - | module github.com/stevedylandev/andromeda/apps/jotts-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/jotts |
|
| 2 | 2 | ||
| 3 | 3 | go 1.25.8 |
|
| 4 | 4 |
| 1 | - | package main |
|
| 2 | - | ||
| 3 | - | import ( |
|
| 4 | - | "fmt" |
|
| 5 | - | "os" |
|
| 6 | - | ||
| 7 | - | "github.com/stevedylandev/andromeda/apps/jotts-go/tui" |
|
| 8 | - | ) |
|
| 9 | - | ||
| 10 | - | func main() { |
|
| 11 | - | args := os.Args[1:] |
|
| 12 | - | if len(args) == 0 { |
|
| 13 | - | runTUI(nil) |
|
| 14 | - | return |
|
| 15 | - | } |
|
| 16 | - | ||
| 17 | - | switch args[0] { |
|
| 18 | - | case "server": |
|
| 19 | - | runServer(args[1:]) |
|
| 20 | - | case "tui": |
|
| 21 | - | runTUI(args[1:]) |
|
| 22 | - | case "auth": |
|
| 23 | - | runAuth(args[1:]) |
|
| 24 | - | case "-h", "--help", "help": |
|
| 25 | - | printUsage() |
|
| 26 | - | default: |
|
| 27 | - | if _, err := os.Stat(args[0]); err == nil { |
|
| 28 | - | runUpload(args) |
|
| 29 | - | return |
|
| 30 | - | } |
|
| 31 | - | runTUI(args) |
|
| 32 | - | } |
|
| 33 | - | } |
|
| 34 | - | ||
| 35 | - | func runTUI(args []string) { |
|
| 36 | - | if err := tui.Run(tui.ParseArgs(args)); err != nil { |
|
| 37 | - | fmt.Fprintln(os.Stderr, "tui error:", err) |
|
| 38 | - | os.Exit(1) |
|
| 39 | - | } |
|
| 40 | - | } |
|
| 41 | - | ||
| 42 | - | func printUsage() { |
|
| 43 | - | fmt.Println(`jotts-go — minimal markdown notes |
|
| 44 | - | ||
| 45 | - | usage: |
|
| 46 | - | jotts-go launch TUI (default) |
|
| 47 | - | jotts-go tui [--remote URL --api-key KEY] |
|
| 48 | - | jotts-go server run HTTP server |
|
| 49 | - | jotts-go auth configure remote URL + API key |
|
| 50 | - | jotts-go <file.md> upload file as a new note`) |
|
| 51 | - | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* jotts — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | .note-list { |
|
| 6 | - | display: flex; |
|
| 7 | - | flex-direction: column; |
|
| 8 | - | width: 100%; |
|
| 9 | - | } |
|
| 10 | - | ||
| 11 | - | .note-item { |
|
| 12 | - | display: flex; |
|
| 13 | - | justify-content: space-between; |
|
| 14 | - | align-items: center; |
|
| 15 | - | padding: 8px 0; |
|
| 16 | - | border-bottom: 1px solid #333; |
|
| 17 | - | text-decoration: none; |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | - | .note-item:hover { |
|
| 21 | - | opacity: 0.7; |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | .note-title { |
|
| 25 | - | font-size: 16px; |
|
| 26 | - | } |
|
| 27 | - | ||
| 28 | - | .note-date { |
|
| 29 | - | font-size: 12px; |
|
| 30 | - | opacity: 0.5; |
|
| 31 | - | } |
|
| 32 | - | ||
| 33 | - | /* Note view */ |
|
| 34 | - | ||
| 35 | - | .note-header { |
|
| 36 | - | display: flex; |
|
| 37 | - | flex-direction: column; |
|
| 38 | - | gap: 0.25rem; |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | .note-header h1 { |
|
| 42 | - | font-size: 24px; |
|
| 43 | - | font-weight: 700; |
|
| 44 | - | letter-spacing: -0.5px; |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | .note-actions { |
|
| 48 | - | display: flex; |
|
| 49 | - | gap: 1.5rem; |
|
| 50 | - | font-size: 12px; |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | /* Markdown rendered content */ |
|
| 54 | - | ||
| 55 | - | .markdown-body { |
|
| 56 | - | width: 100%; |
|
| 57 | - | line-height: 1.6; |
|
| 58 | - | } |
|
| 59 | - | ||
| 60 | - | .markdown-body h1, |
|
| 61 | - | .markdown-body h2, |
|
| 62 | - | .markdown-body h3, |
|
| 63 | - | .markdown-body h4, |
|
| 64 | - | .markdown-body h5, |
|
| 65 | - | .markdown-body h6 { |
|
| 66 | - | margin-top: 1.5rem; |
|
| 67 | - | margin-bottom: 0.5rem; |
|
| 68 | - | font-weight: 700; |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | .markdown-body h1 { font-size: 18px; } |
|
| 72 | - | .markdown-body h2 { font-size: 16px; } |
|
| 73 | - | .markdown-body h3 { font-size: 15px; } |
|
| 74 | - | .markdown-body h4, |
|
| 75 | - | .markdown-body h5, |
|
| 76 | - | .markdown-body h6 { font-size: 14px; } |
|
| 77 | - | ||
| 78 | - | .markdown-body p { |
|
| 79 | - | margin-bottom: 0.75rem; |
|
| 80 | - | } |
|
| 81 | - | ||
| 82 | - | .markdown-body ul, |
|
| 83 | - | .markdown-body ol { |
|
| 84 | - | margin-left: 1.5rem; |
|
| 85 | - | margin-bottom: 0.75rem; |
|
| 86 | - | } |
|
| 87 | - | ||
| 88 | - | .markdown-body li { |
|
| 89 | - | margin-bottom: 0.25rem; |
|
| 90 | - | } |
|
| 91 | - | ||
| 92 | - | .markdown-body pre { |
|
| 93 | - | margin-bottom: 0.75rem; |
|
| 94 | - | } |
|
| 95 | - | ||
| 96 | - | .markdown-body blockquote { |
|
| 97 | - | border-left: 2px solid #555; |
|
| 98 | - | padding-left: 12px; |
|
| 99 | - | opacity: 0.7; |
|
| 100 | - | margin-bottom: 0.75rem; |
|
| 101 | - | } |
|
| 102 | - | ||
| 103 | - | .markdown-body table { |
|
| 104 | - | margin-bottom: 0.75rem; |
|
| 105 | - | } |
|
| 106 | - | ||
| 107 | - | .markdown-body th, |
|
| 108 | - | .markdown-body td { |
|
| 109 | - | border: 1px solid #333; |
|
| 110 | - | padding: 6px; |
|
| 111 | - | text-align: left; |
|
| 112 | - | } |
|
| 113 | - | ||
| 114 | - | .markdown-body th { |
|
| 115 | - | font-weight: 700; |
|
| 116 | - | } |
|
| 117 | - | ||
| 118 | - | .markdown-body hr { |
|
| 119 | - | border: none; |
|
| 120 | - | border-top: 1px solid #333; |
|
| 121 | - | margin: 1rem 0; |
|
| 122 | - | } |
|
| 123 | - | ||
| 124 | - | .markdown-body a { |
|
| 125 | - | text-decoration: underline; |
|
| 126 | - | } |
|
| 127 | - | ||
| 128 | - | .markdown-body img { |
|
| 129 | - | max-width: 100%; |
|
| 130 | - | } |
|
| 131 | - | ||
| 132 | - | .markdown-body li:has(> input[type="checkbox"]) { |
|
| 133 | - | list-style: none; |
|
| 134 | - | margin-left: -1.5rem; |
|
| 135 | - | } |
|
| 136 | - | ||
| 137 | - | .markdown-body input[type="checkbox"] { |
|
| 138 | - | width: 14px; |
|
| 139 | - | height: 14px; |
|
| 140 | - | margin-right: 6px; |
|
| 141 | - | vertical-align: middle; |
|
| 142 | - | position: relative; |
|
| 143 | - | top: -1px; |
|
| 144 | - | } |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <title>Jotts — {{.Note.Title}}</title> |
|
| 7 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | - | <meta name="theme-color" content="#121113" /> |
|
| 13 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 14 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 15 | - | </head> |
|
| 16 | - | <body> |
|
| 17 | - | <header class="header"> |
|
| 18 | - | <a href="/" class="logo">jotts</a> |
|
| 19 | - | <nav class="links"><a href="/notes/new">new</a></nav> |
|
| 20 | - | </header> |
|
| 21 | - | <main> |
|
| 22 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 23 | - | <form method="POST" action="/notes/{{.Note.ShortID}}" class="form"> |
|
| 24 | - | <label for="title">title</label> |
|
| 25 | - | <input type="text" id="title" name="title" value="{{.Note.Title}}" required> |
|
| 26 | - | <label for="content">content</label> |
|
| 27 | - | <textarea id="content" name="content">{{.Note.Content}}</textarea> |
|
| 28 | - | <button type="submit">save</button> |
|
| 29 | - | </form> |
|
| 30 | - | </main> |
|
| 31 | - | </body> |
|
| 32 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <title>Jotts</title> |
|
| 7 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | - | <meta property="og:title" content="Jotts"> |
|
| 13 | - | <meta property="og:image" content="/static/og.png"> |
|
| 14 | - | <meta property="og:type" content="website"> |
|
| 15 | - | <meta name="theme-color" content="#121113" /> |
|
| 16 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 17 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | - | </head> |
|
| 19 | - | <body> |
|
| 20 | - | <header class="header"> |
|
| 21 | - | <a href="/" class="logo">jotts</a> |
|
| 22 | - | <nav class="links"> |
|
| 23 | - | <a href="/notes/new">new</a> |
|
| 24 | - | </nav> |
|
| 25 | - | </header> |
|
| 26 | - | <main> |
|
| 27 | - | {{if not .Notes}}<p class="empty">no notes yet</p>{{end}} |
|
| 28 | - | <div class="note-list"> |
|
| 29 | - | {{range .Notes}} |
|
| 30 | - | <a href="/notes/{{.ShortID}}" class="note-item"> |
|
| 31 | - | <span class="note-title">{{.Title}}</span> |
|
| 32 | - | <time class="note-date" datetime="{{.UpdatedAt}}Z">{{.UpdatedAt}}</time> |
|
| 33 | - | </a> |
|
| 34 | - | {{end}} |
|
| 35 | - | </div> |
|
| 36 | - | </main> |
|
| 37 | - | <script> |
|
| 38 | - | document.querySelectorAll("time.note-date").forEach(el => { |
|
| 39 | - | const d = new Date(el.getAttribute("datetime")); |
|
| 40 | - | if (!isNaN(d)) { el.textContent = d.toLocaleString(); } |
|
| 41 | - | }); |
|
| 42 | - | </script> |
|
| 43 | - | </body> |
|
| 44 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <title>Jotts</title> |
|
| 7 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | - | <meta property="og:title" content="Jotts"> |
|
| 13 | - | <meta property="og:image" content="/static/og.png"> |
|
| 14 | - | <meta property="og:type" content="website"> |
|
| 15 | - | <meta name="theme-color" content="#121113" /> |
|
| 16 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 17 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | - | </head> |
|
| 19 | - | <body> |
|
| 20 | - | <header class="header"> |
|
| 21 | - | <span class="logo">JOTTS</span> |
|
| 22 | - | </header> |
|
| 23 | - | <main> |
|
| 24 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 25 | - | <form method="POST" action="/login" class="form"> |
|
| 26 | - | <label for="password">password</label> |
|
| 27 | - | <input type="password" id="password" name="password" autofocus required> |
|
| 28 | - | <button type="submit">login</button> |
|
| 29 | - | </form> |
|
| 30 | - | </main> |
|
| 31 | - | </body> |
|
| 32 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <title>Jotts — new</title> |
|
| 7 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | - | <meta name="theme-color" content="#121113" /> |
|
| 13 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 14 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 15 | - | </head> |
|
| 16 | - | <body> |
|
| 17 | - | <header class="header"> |
|
| 18 | - | <a href="/" class="logo">jotts</a> |
|
| 19 | - | <nav class="links"><a href="/notes/new">new</a></nav> |
|
| 20 | - | </header> |
|
| 21 | - | <main> |
|
| 22 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 23 | - | <form method="POST" action="/notes" class="form"> |
|
| 24 | - | <label for="title">title</label> |
|
| 25 | - | <input type="text" id="title" name="title" autofocus required> |
|
| 26 | - | <label for="content">content</label> |
|
| 27 | - | <textarea id="content" name="content" placeholder="write markdown here..."></textarea> |
|
| 28 | - | <button type="submit">save</button> |
|
| 29 | - | </form> |
|
| 30 | - | </main> |
|
| 31 | - | </body> |
|
| 32 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <title>Jotts — {{.Note.Title}}</title> |
|
| 7 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | - | <meta property="og:title" content="Jotts"> |
|
| 13 | - | <meta property="og:image" content="/static/og.png"> |
|
| 14 | - | <meta property="og:type" content="website"> |
|
| 15 | - | <meta name="theme-color" content="#121113" /> |
|
| 16 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 17 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | - | </head> |
|
| 19 | - | <body> |
|
| 20 | - | <header class="header"> |
|
| 21 | - | <a href="/" class="logo">jotts</a> |
|
| 22 | - | <nav class="links"><a href="/notes/new">new</a></nav> |
|
| 23 | - | </header> |
|
| 24 | - | <main> |
|
| 25 | - | <div class="note-header"> |
|
| 26 | - | <h1>{{.Note.Title}}</h1> |
|
| 27 | - | <time class="note-date" datetime="{{.Note.UpdatedAt}}Z">{{.Note.UpdatedAt}}</time> |
|
| 28 | - | </div> |
|
| 29 | - | <div class="note-actions"> |
|
| 30 | - | <a href="/notes/{{.Note.ShortID}}/edit">edit</a> |
|
| 31 | - | <button type="button" class="link-button" id="copy-md-btn" onclick="copyMarkdown()">copy</button> |
|
| 32 | - | <form method="POST" action="/notes/{{.Note.ShortID}}/delete" class="inline-form"> |
|
| 33 | - | <button type="submit" class="link-button" onclick="return confirm('delete this note?')">delete</button> |
|
| 34 | - | </form> |
|
| 35 | - | </div> |
|
| 36 | - | <template id="raw-md">{{.Note.Content}}</template> |
|
| 37 | - | <article class="markdown-body">{{.Rendered}}</article> |
|
| 38 | - | </main> |
|
| 39 | - | <script> |
|
| 40 | - | function copyMarkdown() { |
|
| 41 | - | const md = document.getElementById("raw-md").content.textContent; |
|
| 42 | - | const btn = document.getElementById("copy-md-btn"); |
|
| 43 | - | navigator.clipboard.writeText(md).then(() => { |
|
| 44 | - | btn.textContent = "copied!"; |
|
| 45 | - | setTimeout(() => { btn.textContent = "copy"; }, 1500); |
|
| 46 | - | }); |
|
| 47 | - | } |
|
| 48 | - | document.querySelectorAll("time.note-date").forEach(el => { |
|
| 49 | - | const d = new Date(el.getAttribute("datetime")); |
|
| 50 | - | if (!isNaN(d)) { el.textContent = d.toLocaleString(); } |
|
| 51 | - | }); |
|
| 52 | - | </script> |
|
| 53 | - | </body> |
|
| 54 | - | </html> |
| 11 | 11 | "strings" |
|
| 12 | 12 | "time" |
|
| 13 | 13 | ||
| 14 | - | "github.com/stevedylandev/andromeda/apps/jotts-go/internal/store" |
|
| 14 | + | "github.com/stevedylandev/andromeda/apps/jotts/internal/store" |
|
| 15 | 15 | "github.com/stevedylandev/andromeda/crates-go/config" |
|
| 16 | 16 | ) |
|
| 17 | 17 |
| 1 | - | [package] |
|
| 2 | - | name = "jotts" |
|
| 3 | - | version = "0.2.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Minimal markdown note app" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | axum = { workspace = true } |
|
| 12 | - | tokio = { workspace = true } |
|
| 13 | - | serde = { workspace = true } |
|
| 14 | - | serde_json = { workspace = true } |
|
| 15 | - | rusqlite = { workspace = true } |
|
| 16 | - | nanoid = { workspace = true } |
|
| 17 | - | rust-embed = { workspace = true } |
|
| 18 | - | dotenvy = { workspace = true } |
|
| 19 | - | subtle = { workspace = true } |
|
| 20 | - | rand = { workspace = true } |
|
| 21 | - | tracing = { workspace = true } |
|
| 22 | - | tracing-subscriber = { workspace = true } |
|
| 23 | - | andromeda-auth = { workspace = true } |
|
| 24 | - | andromeda-db = { workspace = true, features = ["session", "axum"] } |
|
| 25 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 26 | - | askama = "0.15" |
|
| 27 | - | askama_web = { version = "0.15", features = ["axum-0.8"] } |
|
| 28 | - | pulldown-cmark = "0.12" |
|
| 29 | - | ratatui = "0.30" |
|
| 30 | - | crossterm = "0.29" |
|
| 31 | - | arboard = "3" |
|
| 32 | - | syntect = "5" |
|
| 33 | - | reqwest = { version = "0.13", features = ["json", "blocking"] } |
|
| 34 | - | clap = { version = "4", features = ["derive", "env"] } |
|
| 35 | - | toml = "1.0" |
|
| 36 | - | rpassword = "7" |
|
| 37 | - | open = "5.3.3" |
|
| 38 | - | ||
| 39 | - | [[bin]] |
|
| 40 | - | name = "jotts" |
|
| 41 | - | path = "src/main.rs" |
| 1 | 1 | # Build from repo root: docker build -t jotts -f apps/jotts/Dockerfile . |
|
| 2 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 2 | + | FROM golang:1.25-bookworm AS builder |
|
| 3 | 3 | WORKDIR /app |
|
| 4 | - | ||
| 5 | - | FROM chef AS planner |
|
| 6 | - | COPY . . |
|
| 7 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 8 | - | ||
| 9 | - | FROM chef AS builder |
|
| 10 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 11 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 12 | - | RUN cargo chef cook --release --recipe-path recipe.json -p jotts |
|
| 13 | - | COPY . . |
|
| 14 | - | RUN cargo build --release -p jotts |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/jotts/go.mod apps/jotts/go.sum ./apps/jotts/ |
|
| 6 | + | WORKDIR /app/apps/jotts |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/jotts/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /jotts . |
|
| 15 | 10 | ||
| 16 | 11 | FROM debian:bookworm-slim |
|
| 17 | - | COPY --from=builder /app/target/release/jotts /usr/local/bin/jotts |
|
| 12 | + | COPY --from=builder /jotts /usr/local/bin/jotts |
|
| 18 | 13 | WORKDIR /data |
|
| 14 | + | ENV HOST=0.0.0.0 |
|
| 15 | + | ENV PORT=3000 |
|
| 19 | 16 | EXPOSE 3000 |
|
| 20 | - | CMD ["jotts", "server", "--port", "3000", "--host", "0.0.0.0"] |
|
| 17 | + | CMD ["jotts", "server"] |
| 1 | - | MIT License |
|
| 2 | - | ||
| 3 | - | Copyright (c) 2026 Steve Simkins |
|
| 4 | - | ||
| 5 | - | Permission is hereby granted, free of charge, to any person obtaining a copy |
|
| 6 | - | of this software and associated documentation files (the "Software"), to deal |
|
| 7 | - | in the Software without restriction, including without limitation the rights |
|
| 8 | - | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
| 9 | - | copies of the Software, and to permit persons to whom the Software is |
|
| 10 | - | furnished to do so, subject to the following conditions: |
|
| 11 | - | ||
| 12 | - | The above copyright notice and this permission notice shall be included in all |
|
| 13 | - | copies or substantial portions of the Software. |
|
| 14 | - | ||
| 15 | - | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
| 16 | - | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
| 17 | - | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
| 18 | - | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
| 19 | - | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
| 20 | - | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
| 21 | - | SOFTWARE. |
|
| 22 | - |
| 1 | - | # Jotts |
|
| 1 | + | # jotts-go |
|
| 2 | 2 | ||
| 3 | - |  |
|
| 3 | + | Go port of [jotts](../jotts): minimal markdown notes app. |
|
| 4 | 4 | ||
| 5 | - | A minimal notes app |
|
| 5 | + | ## Stack |
|
| 6 | + | ||
| 7 | + | - Go stdlib `net/http` + `html/template` |
|
| 8 | + | - `modernc.org/sqlite` (pure-Go SQLite, no CGO) |
|
| 9 | + | - `github.com/yuin/goldmark` (markdown rendering w/ strikethrough, tables, tasklists) |
|
| 10 | + | - Bubble Tea/Lip Gloss/Glamour for the TUI editor |
|
| 11 | + | - `github.com/pkg/browser` and `github.com/atotto/clipboard` for TUI browser/copy actions |
|
| 6 | 12 | ||
| 7 | 13 | ## Quickstart |
|
| 8 | 14 | ||
| 9 | 15 | ```bash |
|
| 10 | - | git clone https://github.com/stevedylandev/jotts.git |
|
| 11 | - | cd jotts |
|
| 12 | 16 | cp .env.example .env |
|
| 13 | - | # Edit .env with your password |
|
| 14 | - | cargo build --release |
|
| 15 | - | ./target/release/jotts |
|
| 17 | + | # edit .env with your password |
|
| 18 | + | go run . |
|
| 16 | 19 | ``` |
|
| 17 | 20 | ||
| 18 | - | ### Environment Variables |
|
| 21 | + | ## Environment variables |
|
| 19 | 22 | ||
| 20 | 23 | | Variable | Description | Default | |
|
| 21 | 24 | |---|---|---| |
|
| 22 | - | | `JOTTS_PASSWORD` | Password for login authentication | `changeme` | |
|
| 23 | - | | `JOTTS_DB_PATH` | SQLite database file path | `jotts.sqlite` | |
|
| 24 | - | | `HOST` | Server bind address | `127.0.0.1` | |
|
| 25 | + | | `JOTTS_PASSWORD` | Login password | `changeme` | |
|
| 26 | + | | `JOTTS_DB_PATH` | SQLite file path | `jotts.sqlite` | |
|
| 27 | + | | `HOST` | Bind address | `127.0.0.1` | |
|
| 25 | 28 | | `PORT` | Server port | `3000` | |
|
| 26 | - | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 27 | - | | `JOTTS_API_KEY` | API key for `/api/notes` JSON endpoints (unset = API disabled) | _(unset)_ | |
|
| 28 | - | ||
| 29 | - | ## Overview |
|
| 30 | - | ||
| 31 | - | A simple, self-hosted markdown note app built with Rust. Here's a few highlights: |
|
| 32 | - | - Single ~7MB Rust binary with embedded assets |
|
| 33 | - | - Password authentication with session cookies |
|
| 34 | - | - Create, edit, and delete markdown notes |
|
| 35 | - | - Markdown rendering with strikethrough, tables, and task lists |
|
| 36 | - | - Dark themed UI with Commit Mono font |
|
| 37 | - | - SQLite for persistent storage |
|
| 29 | + | | `COOKIE_SECURE` | HTTPS-only cookies | `false` | |
|
| 30 | + | | `JOTTS_API_KEY` | API key for `/api/notes` (unset = API disabled) | _(unset)_ | |
|
| 38 | 31 | ||
| 39 | 32 | ## Structure |
|
| 40 | 33 | ||
| 41 | 34 | ``` |
|
| 42 | - | jotts/ |
|
| 43 | - | ├── src/ |
|
| 44 | - | │ ├── main.rs # App entrypoint, env vars, starts server |
|
| 45 | - | │ ├── server.rs # Axum router, HTTP handlers, and templates |
|
| 46 | - | │ ├── auth.rs # Password verification and session management |
|
| 47 | - | │ └── db.rs # SQLite database layer (notes, sessions) |
|
| 48 | - | ├── templates/ # Askama HTML templates |
|
| 49 | - | │ ├── base.html # Base layout with header and nav |
|
| 50 | - | │ ├── login.html # Login page |
|
| 51 | - | │ ├── index.html # Note list |
|
| 52 | - | │ ├── view.html # Single note display |
|
| 53 | - | │ ├── new.html # Create note form |
|
| 54 | - | │ └── edit.html # Edit note form |
|
| 55 | - | ├── static/ # Favicons, og:image, styles, and webmanifest |
|
| 56 | - | ├── assets/ # Commit Mono font files |
|
| 57 | - | ├── Dockerfile # Multi-stage build (Rust + Debian slim) |
|
| 35 | + | jotts-go/ |
|
| 36 | + | ├── main.go # entrypoint |
|
| 37 | + | ├── app.go # App struct + page data types |
|
| 38 | + | ├── db.go # SQLite schema + queries (notes, sessions) |
|
| 39 | + | ├── routes.go # http.ServeMux routes |
|
| 40 | + | ├── middleware.go # session + API key middleware, cookies |
|
| 41 | + | ├── handlers_web.go # HTML form handlers |
|
| 42 | + | ├── handlers_api.go # JSON API handlers |
|
| 43 | + | ├── markdown.go # goldmark rendering |
|
| 44 | + | ├── web.go # template render, JSON, embedded static |
|
| 45 | + | ├── util.go # env, dotenv, short IDs, session tokens |
|
| 46 | + | ├── templates/ # html/template pages |
|
| 47 | + | ├── static/ # favicons, styles, og image |
|
| 48 | + | ├── assets/ # darkmatter.css + Commit Mono fonts |
|
| 49 | + | ├── Dockerfile |
|
| 58 | 50 | └── docker-compose.yml |
|
| 59 | 51 | ``` |
|
| 60 | 52 | ||
| 61 | - | ## CLI / TUI |
|
| 62 | - | ||
| 63 | - | The `jotts` binary also ships an interactive terminal UI and a minimal CLI. |
|
| 64 | - | ||
| 65 | - | ```bash |
|
| 66 | - | jotts # Launch TUI (local SQLite by default, remote if configured) |
|
| 67 | - | jotts server # Run the web server |
|
| 68 | - | jotts tui # Launch TUI explicitly |
|
| 69 | - | jotts auth # Prompt for remote URL + API key, save to ~/.config/jotts/config.toml |
|
| 70 | - | ``` |
|
| 71 | - | ||
| 72 | - | Flags: `--remote <URL>` (env `JOTTS_REMOTE_URL`), `--api-key <KEY>` (env `JOTTS_API_KEY`). |
|
| 73 | - | ||
| 74 | - | Against a remote server, set `JOTTS_API_KEY` on the server and pass the same key from the CLI. Requests go to `/api/notes` with the `x-api-key` header. The TUI renders note content with syntect Markdown syntax highlighting. |
|
| 75 | - | ||
| 76 | - | ## Deployment |
|
| 53 | + | ## API |
|
| 77 | 54 | ||
| 78 | - | ### Railway |
|
| 55 | + | All endpoints require `x-api-key: $JOTTS_API_KEY` header. |
|
| 79 | 56 | ||
| 80 | - | [](https://railway.com/deploy/DLhUhH?referralCode=JGcIp6) |
|
| 57 | + | - `GET /api/notes` — list notes |
|
| 58 | + | - `POST /api/notes` — create `{title, content}` |
|
| 59 | + | - `GET /api/notes/{short_id}` |
|
| 60 | + | - `PUT /api/notes/{short_id}` — update `{title, content}` |
|
| 61 | + | - `DELETE /api/notes/{short_id}` |
|
| 81 | 62 | ||
| 82 | - | ### Docker (recommended) |
|
| 63 | + | ## Build |
|
| 83 | 64 | ||
| 84 | 65 | ```bash |
|
| 85 | - | git clone https://github.com/stevedylandev/jotts.git |
|
| 86 | - | cd jotts |
|
| 87 | - | cp .env.example .env |
|
| 88 | - | # Edit .env with your password |
|
| 89 | - | docker compose up -d |
|
| 66 | + | CGO_ENABLED=0 go build -o jotts-go . |
|
| 90 | 67 | ``` |
|
| 91 | 68 | ||
| 92 | - | This will start Jotts on port `3000` with a persistent volume for the SQLite database. |
|
| 69 | + | Single ~10MB self-contained binary with all assets embedded. |
|
| 93 | 70 | ||
| 94 | - | ### Binary |
|
| 71 | + | ## Docker |
|
| 95 | 72 | ||
| 96 | 73 | ```bash |
|
| 97 | - | cargo build --release |
|
| 74 | + | docker compose up -d |
|
| 98 | 75 | ``` |
|
| 99 | - | ||
| 100 | - | The resulting binary at `./target/release/jotts` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 101 | - | ||
| 102 | - | ## License |
|
| 103 | - | ||
| 104 | - | [MIT](LICENSE) |
| 2 | 2 | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/jotts/Dockerfile |
|
| 5 | + | dockerfile: apps/jotts-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | - | - "3000:3000" |
|
| 7 | + | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | 8 | environment: |
|
| 9 | 9 | - JOTTS_PASSWORD=${JOTTS_PASSWORD:-changeme} |
|
| 10 | 10 | - JOTTS_DB_PATH=/data/jotts.sqlite |
|
| 11 | 11 | - COOKIE_SECURE=false |
|
| 12 | + | - HOST=0.0.0.0 |
|
| 13 | + | - PORT=${PORT:-3000} |
|
| 14 | + | - JOTTS_API_KEY=${JOTTS_API_KEY:-} |
|
| 12 | 15 | volumes: |
|
| 13 | - | - jotts-data:/data |
|
| 16 | + | - jotts-go-data:/data |
|
| 14 | 17 | restart: unless-stopped |
|
| 15 | 18 | ||
| 16 | 19 | volumes: |
|
| 17 | - | jotts-data: |
|
| 20 | + | jotts-go-data: |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "fmt" |
|
| 5 | + | "os" |
|
| 6 | + | ||
| 7 | + | "github.com/stevedylandev/andromeda/apps/jotts/tui" |
|
| 8 | + | ) |
|
| 9 | + | ||
| 10 | + | func main() { |
|
| 11 | + | args := os.Args[1:] |
|
| 12 | + | if len(args) == 0 { |
|
| 13 | + | runTUI(nil) |
|
| 14 | + | return |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | switch args[0] { |
|
| 18 | + | case "server": |
|
| 19 | + | runServer(args[1:]) |
|
| 20 | + | case "tui": |
|
| 21 | + | runTUI(args[1:]) |
|
| 22 | + | case "auth": |
|
| 23 | + | runAuth(args[1:]) |
|
| 24 | + | case "-h", "--help", "help": |
|
| 25 | + | printUsage() |
|
| 26 | + | default: |
|
| 27 | + | if _, err := os.Stat(args[0]); err == nil { |
|
| 28 | + | runUpload(args) |
|
| 29 | + | return |
|
| 30 | + | } |
|
| 31 | + | runTUI(args) |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | func runTUI(args []string) { |
|
| 36 | + | if err := tui.Run(tui.ParseArgs(args)); err != nil { |
|
| 37 | + | fmt.Fprintln(os.Stderr, "tui error:", err) |
|
| 38 | + | os.Exit(1) |
|
| 39 | + | } |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | func printUsage() { |
|
| 43 | + | fmt.Println(`jotts — minimal markdown notes |
|
| 44 | + | ||
| 45 | + | usage: |
|
| 46 | + | jotts launch TUI (default) |
|
| 47 | + | jotts tui [--remote URL --api-key KEY] |
|
| 48 | + | jotts server run HTTP server |
|
| 49 | + | jotts auth configure remote URL + API key |
|
| 50 | + | jotts <file.md> upload file as a new note`) |
|
| 51 | + | } |
| 1 | - | <?xml version="1.0" encoding="UTF-8"?> |
|
| 2 | - | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
| 3 | - | <plist version="1.0"> |
|
| 4 | - | <dict> |
|
| 5 | - | <!-- |
|
| 6 | - | The colors in this theme are encoded as #RRGGBBAA where: |
|
| 7 | - | * If AA is 00, then RR is an ANSI palette number from 00 to 07. |
|
| 8 | - | * If AA is 01, the terminal's default fg/bg color is used. |
|
| 9 | - | --> |
|
| 10 | - | <key>author</key> |
|
| 11 | - | <string>Template: Chris Kempson, Scheme: Mitchell Kember</string> |
|
| 12 | - | <key>name</key> |
|
| 13 | - | <string>ANSI</string> |
|
| 14 | - | <key>colorSpaceName</key> |
|
| 15 | - | <string>sRGB</string> |
|
| 16 | - | <key>settings</key> |
|
| 17 | - | <array> |
|
| 18 | - | <dict> |
|
| 19 | - | <key>settings</key> |
|
| 20 | - | <dict> |
|
| 21 | - | <key>background</key> |
|
| 22 | - | <string>#00000001</string> |
|
| 23 | - | <key>foreground</key> |
|
| 24 | - | <string>#00000001</string> |
|
| 25 | - | <!-- |
|
| 26 | - | Explicitly set the gutter color since bat falls back to a |
|
| 27 | - | hardcoded DEFAULT_GUTTER_COLOR otherwise. |
|
| 28 | - | --> |
|
| 29 | - | <key>gutter</key> |
|
| 30 | - | <string>#00000001</string> |
|
| 31 | - | <key>gutterForeground</key> |
|
| 32 | - | <string>#00000001</string> |
|
| 33 | - | </dict> |
|
| 34 | - | </dict> |
|
| 35 | - | <dict> |
|
| 36 | - | <key>name</key> |
|
| 37 | - | <string>Comments</string> |
|
| 38 | - | <key>scope</key> |
|
| 39 | - | <string>comment, punctuation.definition.comment</string> |
|
| 40 | - | <key>settings</key> |
|
| 41 | - | <dict> |
|
| 42 | - | <key>foreground</key> |
|
| 43 | - | <string>#02000000</string> |
|
| 44 | - | </dict> |
|
| 45 | - | </dict> |
|
| 46 | - | <dict> |
|
| 47 | - | <key>name</key> |
|
| 48 | - | <string>Keywords</string> |
|
| 49 | - | <key>scope</key> |
|
| 50 | - | <string>keyword</string> |
|
| 51 | - | <key>settings</key> |
|
| 52 | - | <dict> |
|
| 53 | - | <key>foreground</key> |
|
| 54 | - | <string>#05000000</string> |
|
| 55 | - | </dict> |
|
| 56 | - | </dict> |
|
| 57 | - | <dict> |
|
| 58 | - | <key>name</key> |
|
| 59 | - | <string>Functions</string> |
|
| 60 | - | <key>scope</key> |
|
| 61 | - | <string>entity.name.function, meta.require, support.function.any-method</string> |
|
| 62 | - | <key>settings</key> |
|
| 63 | - | <dict> |
|
| 64 | - | <key>foreground</key> |
|
| 65 | - | <string>#04000000</string> |
|
| 66 | - | </dict> |
|
| 67 | - | </dict> |
|
| 68 | - | <dict> |
|
| 69 | - | <key>name</key> |
|
| 70 | - | <string>Labels</string> |
|
| 71 | - | <key>scope</key> |
|
| 72 | - | <string>entity.name.label, variable.parameter</string> |
|
| 73 | - | <key>settings</key> |
|
| 74 | - | <dict> |
|
| 75 | - | <key>foreground</key> |
|
| 76 | - | <string>#06000000</string> |
|
| 77 | - | </dict> |
|
| 78 | - | </dict> |
|
| 79 | - | <dict> |
|
| 80 | - | <key>name</key> |
|
| 81 | - | <string>Classes</string> |
|
| 82 | - | <key>scope</key> |
|
| 83 | - | <string>support.class, entity.name.class, entity.name.type.class, entity.name</string> |
|
| 84 | - | <key>settings</key> |
|
| 85 | - | <dict> |
|
| 86 | - | <key>foreground</key> |
|
| 87 | - | <string>#03000000</string> |
|
| 88 | - | </dict> |
|
| 89 | - | </dict> |
|
| 90 | - | <dict> |
|
| 91 | - | <key>name</key> |
|
| 92 | - | <string>Methods</string> |
|
| 93 | - | <key>scope</key> |
|
| 94 | - | <string>keyword.other.special-method</string> |
|
| 95 | - | <key>settings</key> |
|
| 96 | - | <dict> |
|
| 97 | - | <key>foreground</key> |
|
| 98 | - | <string>#04000000</string> |
|
| 99 | - | </dict> |
|
| 100 | - | </dict> |
|
| 101 | - | <dict> |
|
| 102 | - | <key>name</key> |
|
| 103 | - | <string>Storage</string> |
|
| 104 | - | <key>scope</key> |
|
| 105 | - | <string>storage</string> |
|
| 106 | - | <key>settings</key> |
|
| 107 | - | <dict> |
|
| 108 | - | <key>foreground</key> |
|
| 109 | - | <string>#05000000</string> |
|
| 110 | - | </dict> |
|
| 111 | - | </dict> |
|
| 112 | - | <dict> |
|
| 113 | - | <key>name</key> |
|
| 114 | - | <string>Support</string> |
|
| 115 | - | <key>scope</key> |
|
| 116 | - | <string>support.function</string> |
|
| 117 | - | <key>settings</key> |
|
| 118 | - | <dict> |
|
| 119 | - | <key>foreground</key> |
|
| 120 | - | <string>#06000000</string> |
|
| 121 | - | </dict> |
|
| 122 | - | </dict> |
|
| 123 | - | <dict> |
|
| 124 | - | <key>name</key> |
|
| 125 | - | <string>Strings, Inherited Class</string> |
|
| 126 | - | <key>scope</key> |
|
| 127 | - | <string>string, constant.other.symbol, entity.other.inherited-class</string> |
|
| 128 | - | <key>settings</key> |
|
| 129 | - | <dict> |
|
| 130 | - | <key>foreground</key> |
|
| 131 | - | <string>#02000000</string> |
|
| 132 | - | </dict> |
|
| 133 | - | </dict> |
|
| 134 | - | <dict> |
|
| 135 | - | <key>name</key> |
|
| 136 | - | <string>Integers</string> |
|
| 137 | - | <key>scope</key> |
|
| 138 | - | <string>constant.numeric</string> |
|
| 139 | - | <key>settings</key> |
|
| 140 | - | <dict> |
|
| 141 | - | <key>foreground</key> |
|
| 142 | - | <string>#03000000</string> |
|
| 143 | - | </dict> |
|
| 144 | - | </dict> |
|
| 145 | - | <dict> |
|
| 146 | - | <key>name</key> |
|
| 147 | - | <string>Floats</string> |
|
| 148 | - | <key>scope</key> |
|
| 149 | - | <string>none</string> |
|
| 150 | - | <key>settings</key> |
|
| 151 | - | <dict> |
|
| 152 | - | <key>foreground</key> |
|
| 153 | - | <string>#03000000</string> |
|
| 154 | - | </dict> |
|
| 155 | - | </dict> |
|
| 156 | - | <dict> |
|
| 157 | - | <key>name</key> |
|
| 158 | - | <string>Boolean</string> |
|
| 159 | - | <key>scope</key> |
|
| 160 | - | <string>none</string> |
|
| 161 | - | <key>settings</key> |
|
| 162 | - | <dict> |
|
| 163 | - | <key>foreground</key> |
|
| 164 | - | <string>#03000000</string> |
|
| 165 | - | </dict> |
|
| 166 | - | </dict> |
|
| 167 | - | <dict> |
|
| 168 | - | <key>name</key> |
|
| 169 | - | <string>Constants</string> |
|
| 170 | - | <key>scope</key> |
|
| 171 | - | <string>constant</string> |
|
| 172 | - | <key>settings</key> |
|
| 173 | - | <dict> |
|
| 174 | - | <key>foreground</key> |
|
| 175 | - | <string>#03000000</string> |
|
| 176 | - | </dict> |
|
| 177 | - | </dict> |
|
| 178 | - | <dict> |
|
| 179 | - | <key>name</key> |
|
| 180 | - | <string>Tags</string> |
|
| 181 | - | <key>scope</key> |
|
| 182 | - | <string>entity.name.tag</string> |
|
| 183 | - | <key>settings</key> |
|
| 184 | - | <dict> |
|
| 185 | - | <key>foreground</key> |
|
| 186 | - | <string>#01000000</string> |
|
| 187 | - | </dict> |
|
| 188 | - | </dict> |
|
| 189 | - | <dict> |
|
| 190 | - | <key>name</key> |
|
| 191 | - | <string>Attributes</string> |
|
| 192 | - | <key>scope</key> |
|
| 193 | - | <string>entity.other.attribute-name</string> |
|
| 194 | - | <key>settings</key> |
|
| 195 | - | <dict> |
|
| 196 | - | <key>foreground</key> |
|
| 197 | - | <string>#03000000</string> |
|
| 198 | - | </dict> |
|
| 199 | - | </dict> |
|
| 200 | - | <dict> |
|
| 201 | - | <key>name</key> |
|
| 202 | - | <string>Attribute IDs</string> |
|
| 203 | - | <key>scope</key> |
|
| 204 | - | <string>entity.other.attribute-name.id, punctuation.definition.entity</string> |
|
| 205 | - | <key>settings</key> |
|
| 206 | - | <dict> |
|
| 207 | - | <key>foreground</key> |
|
| 208 | - | <string>#04000000</string> |
|
| 209 | - | </dict> |
|
| 210 | - | </dict> |
|
| 211 | - | <dict> |
|
| 212 | - | <key>name</key> |
|
| 213 | - | <string>Selector</string> |
|
| 214 | - | <key>scope</key> |
|
| 215 | - | <string>meta.selector</string> |
|
| 216 | - | <key>settings</key> |
|
| 217 | - | <dict> |
|
| 218 | - | <key>foreground</key> |
|
| 219 | - | <string>#05000000</string> |
|
| 220 | - | </dict> |
|
| 221 | - | </dict> |
|
| 222 | - | <dict> |
|
| 223 | - | <key>name</key> |
|
| 224 | - | <string>Values</string> |
|
| 225 | - | <key>scope</key> |
|
| 226 | - | <string>none</string> |
|
| 227 | - | <key>settings</key> |
|
| 228 | - | <dict> |
|
| 229 | - | <key>foreground</key> |
|
| 230 | - | <string>#03000000</string> |
|
| 231 | - | </dict> |
|
| 232 | - | </dict> |
|
| 233 | - | <dict> |
|
| 234 | - | <key>name</key> |
|
| 235 | - | <string>Headings</string> |
|
| 236 | - | <key>scope</key> |
|
| 237 | - | <string>markup.heading punctuation.definition.heading, entity.name.section, markup.heading - text.html.markdown, meta.mapping.key string.quoted.double</string> |
|
| 238 | - | <key>settings</key> |
|
| 239 | - | <dict> |
|
| 240 | - | <key>fontStyle</key> |
|
| 241 | - | <string></string> |
|
| 242 | - | <key>foreground</key> |
|
| 243 | - | <string>#04000000</string> |
|
| 244 | - | </dict> |
|
| 245 | - | </dict> |
|
| 246 | - | <dict> |
|
| 247 | - | <key>name</key> |
|
| 248 | - | <string>Units</string> |
|
| 249 | - | <key>scope</key> |
|
| 250 | - | <string>keyword.other.unit</string> |
|
| 251 | - | <key>settings</key> |
|
| 252 | - | <dict> |
|
| 253 | - | <key>foreground</key> |
|
| 254 | - | <string>#03000000</string> |
|
| 255 | - | </dict> |
|
| 256 | - | </dict> |
|
| 257 | - | <dict> |
|
| 258 | - | <key>name</key> |
|
| 259 | - | <string>Bold</string> |
|
| 260 | - | <key>scope</key> |
|
| 261 | - | <string>markup.bold, punctuation.definition.bold</string> |
|
| 262 | - | <key>settings</key> |
|
| 263 | - | <dict> |
|
| 264 | - | <key>fontStyle</key> |
|
| 265 | - | <string>bold</string> |
|
| 266 | - | <key>foreground</key> |
|
| 267 | - | <string>#03000000</string> |
|
| 268 | - | </dict> |
|
| 269 | - | </dict> |
|
| 270 | - | <dict> |
|
| 271 | - | <key>name</key> |
|
| 272 | - | <string>Italic</string> |
|
| 273 | - | <key>scope</key> |
|
| 274 | - | <string>markup.italic, punctuation.definition.italic</string> |
|
| 275 | - | <key>settings</key> |
|
| 276 | - | <dict> |
|
| 277 | - | <key>fontStyle</key> |
|
| 278 | - | <string>italic</string> |
|
| 279 | - | <key>foreground</key> |
|
| 280 | - | <string>#05000000</string> |
|
| 281 | - | </dict> |
|
| 282 | - | </dict> |
|
| 283 | - | <dict> |
|
| 284 | - | <key>name</key> |
|
| 285 | - | <string>Code</string> |
|
| 286 | - | <key>scope</key> |
|
| 287 | - | <string>markup.raw.inline</string> |
|
| 288 | - | <key>settings</key> |
|
| 289 | - | <dict> |
|
| 290 | - | <key>foreground</key> |
|
| 291 | - | <string>#02000000</string> |
|
| 292 | - | </dict> |
|
| 293 | - | </dict> |
|
| 294 | - | <dict> |
|
| 295 | - | <key>name</key> |
|
| 296 | - | <string>Link Text</string> |
|
| 297 | - | <key>scope</key> |
|
| 298 | - | <string>string.other.link, punctuation.definition.string.end.markdown, punctuation.definition.string.begin.markdown</string> |
|
| 299 | - | <key>settings</key> |
|
| 300 | - | <dict> |
|
| 301 | - | <key>foreground</key> |
|
| 302 | - | <string>#01000000</string> |
|
| 303 | - | </dict> |
|
| 304 | - | </dict> |
|
| 305 | - | <dict> |
|
| 306 | - | <key>name</key> |
|
| 307 | - | <string>Link Url</string> |
|
| 308 | - | <key>scope</key> |
|
| 309 | - | <string>meta.link</string> |
|
| 310 | - | <key>settings</key> |
|
| 311 | - | <dict> |
|
| 312 | - | <key>foreground</key> |
|
| 313 | - | <string>#03000000</string> |
|
| 314 | - | </dict> |
|
| 315 | - | </dict> |
|
| 316 | - | <dict> |
|
| 317 | - | <key>name</key> |
|
| 318 | - | <string>Quotes</string> |
|
| 319 | - | <key>scope</key> |
|
| 320 | - | <string>markup.quote</string> |
|
| 321 | - | <key>settings</key> |
|
| 322 | - | <dict> |
|
| 323 | - | <key>foreground</key> |
|
| 324 | - | <string>#03000000</string> |
|
| 325 | - | </dict> |
|
| 326 | - | </dict> |
|
| 327 | - | <dict> |
|
| 328 | - | <key>name</key> |
|
| 329 | - | <string>Inserted</string> |
|
| 330 | - | <key>scope</key> |
|
| 331 | - | <string>markup.inserted</string> |
|
| 332 | - | <key>settings</key> |
|
| 333 | - | <dict> |
|
| 334 | - | <key>foreground</key> |
|
| 335 | - | <string>#02000000</string> |
|
| 336 | - | </dict> |
|
| 337 | - | </dict> |
|
| 338 | - | <dict> |
|
| 339 | - | <key>name</key> |
|
| 340 | - | <string>Deleted</string> |
|
| 341 | - | <key>scope</key> |
|
| 342 | - | <string>markup.deleted</string> |
|
| 343 | - | <key>settings</key> |
|
| 344 | - | <dict> |
|
| 345 | - | <key>foreground</key> |
|
| 346 | - | <string>#01000000</string> |
|
| 347 | - | </dict> |
|
| 348 | - | </dict> |
|
| 349 | - | <dict> |
|
| 350 | - | <key>name</key> |
|
| 351 | - | <string>Changed</string> |
|
| 352 | - | <key>scope</key> |
|
| 353 | - | <string>markup.changed</string> |
|
| 354 | - | <key>settings</key> |
|
| 355 | - | <dict> |
|
| 356 | - | <key>foreground</key> |
|
| 357 | - | <string>#05000000</string> |
|
| 358 | - | </dict> |
|
| 359 | - | </dict> |
|
| 360 | - | <dict> |
|
| 361 | - | <key>name</key> |
|
| 362 | - | <string>Colors</string> |
|
| 363 | - | <key>scope</key> |
|
| 364 | - | <string>constant.other.color</string> |
|
| 365 | - | <key>settings</key> |
|
| 366 | - | <dict> |
|
| 367 | - | <key>foreground</key> |
|
| 368 | - | <string>#06000000</string> |
|
| 369 | - | </dict> |
|
| 370 | - | </dict> |
|
| 371 | - | <dict> |
|
| 372 | - | <key>name</key> |
|
| 373 | - | <string>Regular Expressions</string> |
|
| 374 | - | <key>scope</key> |
|
| 375 | - | <string>string.regexp</string> |
|
| 376 | - | <key>settings</key> |
|
| 377 | - | <dict> |
|
| 378 | - | <key>foreground</key> |
|
| 379 | - | <string>#06000000</string> |
|
| 380 | - | </dict> |
|
| 381 | - | </dict> |
|
| 382 | - | <dict> |
|
| 383 | - | <key>name</key> |
|
| 384 | - | <string>Escape Characters</string> |
|
| 385 | - | <key>scope</key> |
|
| 386 | - | <string>constant.character.escape</string> |
|
| 387 | - | <key>settings</key> |
|
| 388 | - | <dict> |
|
| 389 | - | <key>foreground</key> |
|
| 390 | - | <string>#06000000</string> |
|
| 391 | - | </dict> |
|
| 392 | - | </dict> |
|
| 393 | - | <dict> |
|
| 394 | - | <key>name</key> |
|
| 395 | - | <string>Embedded</string> |
|
| 396 | - | <key>scope</key> |
|
| 397 | - | <string>punctuation.section.embedded, variable.interpolation</string> |
|
| 398 | - | <key>settings</key> |
|
| 399 | - | <dict> |
|
| 400 | - | <key>foreground</key> |
|
| 401 | - | <string>#05000000</string> |
|
| 402 | - | </dict> |
|
| 403 | - | </dict> |
|
| 404 | - | <dict> |
|
| 405 | - | <key>name</key> |
|
| 406 | - | <string>Illegal</string> |
|
| 407 | - | <key>scope</key> |
|
| 408 | - | <string>invalid.illegal</string> |
|
| 409 | - | <key>settings</key> |
|
| 410 | - | <dict> |
|
| 411 | - | <key>background</key> |
|
| 412 | - | <string>#01000000</string> |
|
| 413 | - | </dict> |
|
| 414 | - | </dict> |
|
| 415 | - | <dict> |
|
| 416 | - | <key>name</key> |
|
| 417 | - | <string>Broken</string> |
|
| 418 | - | <key>scope</key> |
|
| 419 | - | <string>invalid.broken</string> |
|
| 420 | - | <key>settings</key> |
|
| 421 | - | <dict> |
|
| 422 | - | <key>background</key> |
|
| 423 | - | <string>#03000000</string> |
|
| 424 | - | </dict> |
|
| 425 | - | </dict> |
|
| 426 | - | </array> |
|
| 427 | - | <key>uuid</key> |
|
| 428 | - | <string>uuid</string> |
|
| 429 | - | </dict> |
|
| 430 | - | </plist> |
| 1 | - | use axum::{ |
|
| 2 | - | extract::FromRequestParts, |
|
| 3 | - | http::request::Parts, |
|
| 4 | - | response::{IntoResponse, Redirect, Response}, |
|
| 5 | - | }; |
|
| 6 | - | use std::sync::Arc; |
|
| 7 | - | ||
| 8 | - | use crate::db; |
|
| 9 | - | use crate::server::AppState; |
|
| 10 | - | ||
| 11 | - | pub use andromeda_auth::{ |
|
| 12 | - | build_session_cookie, clear_session_cookie, generate_session_token, verify_password, |
|
| 13 | - | }; |
|
| 14 | - | ||
| 15 | - | pub struct AuthSession; |
|
| 16 | - | ||
| 17 | - | impl FromRequestParts<Arc<AppState>> for AuthSession { |
|
| 18 | - | type Rejection = Response; |
|
| 19 | - | ||
| 20 | - | async fn from_request_parts( |
|
| 21 | - | parts: &mut Parts, |
|
| 22 | - | state: &Arc<AppState>, |
|
| 23 | - | ) -> Result<Self, Self::Rejection> { |
|
| 24 | - | let token = andromeda_auth::extract_session_cookie(&parts.headers); |
|
| 25 | - | if let Some(token) = token { |
|
| 26 | - | if is_valid_session(state, &token) { |
|
| 27 | - | return Ok(AuthSession); |
|
| 28 | - | } |
|
| 29 | - | } |
|
| 30 | - | Err(Redirect::to("/login").into_response()) |
|
| 31 | - | } |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | fn is_valid_session(state: &AppState, token: &str) -> bool { |
|
| 35 | - | match db::get_session_expiry(&state.db, token) { |
|
| 36 | - | Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(), |
|
| 37 | - | _ => false, |
|
| 38 | - | } |
|
| 39 | - | } |
| 1 | - | use crate::db::{self, Db, Note, NoteInput}; |
|
| 2 | - | use reqwest::StatusCode; |
|
| 3 | - | use reqwest::blocking::{Client, RequestBuilder, Response}; |
|
| 4 | - | use std::fmt; |
|
| 5 | - | ||
| 6 | - | #[derive(Debug)] |
|
| 7 | - | pub enum BackendError { |
|
| 8 | - | #[allow(dead_code)] |
|
| 9 | - | NotFound, |
|
| 10 | - | Unauthorized(String), |
|
| 11 | - | Network(String), |
|
| 12 | - | Database(String), |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | impl fmt::Display for BackendError { |
|
| 16 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 17 | - | match self { |
|
| 18 | - | BackendError::NotFound => write!(f, "Not found"), |
|
| 19 | - | BackendError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), |
|
| 20 | - | BackendError::Network(msg) => write!(f, "Network error: {}", msg), |
|
| 21 | - | BackendError::Database(msg) => write!(f, "Database error: {}", msg), |
|
| 22 | - | } |
|
| 23 | - | } |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | impl std::error::Error for BackendError {} |
|
| 27 | - | ||
| 28 | - | impl From<db::DbError> for BackendError { |
|
| 29 | - | fn from(e: db::DbError) -> Self { |
|
| 30 | - | BackendError::Database(e.to_string()) |
|
| 31 | - | } |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | fn net<E: fmt::Display>(e: E) -> BackendError { |
|
| 35 | - | BackendError::Network(e.to_string()) |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | fn with_key(req: RequestBuilder, key: &Option<String>) -> RequestBuilder { |
|
| 39 | - | match key { |
|
| 40 | - | Some(k) => req.header("x-api-key", k), |
|
| 41 | - | None => req, |
|
| 42 | - | } |
|
| 43 | - | } |
|
| 44 | - | ||
| 45 | - | fn send_request(req: RequestBuilder) -> Result<Response, BackendError> { |
|
| 46 | - | let resp = req.send().map_err(net)?; |
|
| 47 | - | match resp.status().as_u16() { |
|
| 48 | - | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 49 | - | 403 => Err(BackendError::Unauthorized( |
|
| 50 | - | "No API key configured on server".into(), |
|
| 51 | - | )), |
|
| 52 | - | _ => Ok(resp), |
|
| 53 | - | } |
|
| 54 | - | } |
|
| 55 | - | ||
| 56 | - | fn unexpected(status: StatusCode) -> BackendError { |
|
| 57 | - | BackendError::Network(format!("HTTP {}", status)) |
|
| 58 | - | } |
|
| 59 | - | ||
| 60 | - | pub enum Backend { |
|
| 61 | - | Local { |
|
| 62 | - | db: Db, |
|
| 63 | - | }, |
|
| 64 | - | Remote { |
|
| 65 | - | base_url: String, |
|
| 66 | - | api_key: Option<String>, |
|
| 67 | - | client: Client, |
|
| 68 | - | }, |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | impl Backend { |
|
| 72 | - | pub fn local() -> Self { |
|
| 73 | - | Backend::Local { db: db::init_db() } |
|
| 74 | - | } |
|
| 75 | - | ||
| 76 | - | pub fn remote(base_url: String, api_key: Option<String>) -> Self { |
|
| 77 | - | Backend::Remote { |
|
| 78 | - | base_url, |
|
| 79 | - | api_key, |
|
| 80 | - | client: Client::new(), |
|
| 81 | - | } |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | pub fn list_notes(&self) -> Result<Vec<Note>, BackendError> { |
|
| 85 | - | match self { |
|
| 86 | - | Backend::Local { db } => Ok(db::get_all_notes(db)?), |
|
| 87 | - | Backend::Remote { |
|
| 88 | - | base_url, |
|
| 89 | - | api_key, |
|
| 90 | - | client, |
|
| 91 | - | } => { |
|
| 92 | - | let req = with_key(client.get(format!("{base_url}/api/notes")), api_key); |
|
| 93 | - | let resp = send_request(req)?; |
|
| 94 | - | match resp.status().as_u16() { |
|
| 95 | - | 200 => resp.json::<Vec<Note>>().map_err(net), |
|
| 96 | - | _ => Err(unexpected(resp.status())), |
|
| 97 | - | } |
|
| 98 | - | } |
|
| 99 | - | } |
|
| 100 | - | } |
|
| 101 | - | ||
| 102 | - | pub fn create_note(&self, title: &str, content: &str) -> Result<Note, BackendError> { |
|
| 103 | - | match self { |
|
| 104 | - | Backend::Local { db } => Ok(db::create_note(db, title, content)?), |
|
| 105 | - | Backend::Remote { |
|
| 106 | - | base_url, |
|
| 107 | - | api_key, |
|
| 108 | - | client, |
|
| 109 | - | } => { |
|
| 110 | - | let body = NoteInput { |
|
| 111 | - | title: title.to_string(), |
|
| 112 | - | content: content.to_string(), |
|
| 113 | - | }; |
|
| 114 | - | let req = with_key( |
|
| 115 | - | client.post(format!("{base_url}/api/notes")).json(&body), |
|
| 116 | - | api_key, |
|
| 117 | - | ); |
|
| 118 | - | let resp = send_request(req)?; |
|
| 119 | - | match resp.status().as_u16() { |
|
| 120 | - | 201 => resp.json::<Note>().map_err(net), |
|
| 121 | - | _ => Err(unexpected(resp.status())), |
|
| 122 | - | } |
|
| 123 | - | } |
|
| 124 | - | } |
|
| 125 | - | } |
|
| 126 | - | ||
| 127 | - | pub fn update_note( |
|
| 128 | - | &self, |
|
| 129 | - | short_id: &str, |
|
| 130 | - | title: &str, |
|
| 131 | - | content: &str, |
|
| 132 | - | ) -> Result<Option<Note>, BackendError> { |
|
| 133 | - | match self { |
|
| 134 | - | Backend::Local { db } => Ok(db::update_note_by_short_id(db, short_id, title, content)?), |
|
| 135 | - | Backend::Remote { |
|
| 136 | - | base_url, |
|
| 137 | - | api_key, |
|
| 138 | - | client, |
|
| 139 | - | } => { |
|
| 140 | - | let body = NoteInput { |
|
| 141 | - | title: title.to_string(), |
|
| 142 | - | content: content.to_string(), |
|
| 143 | - | }; |
|
| 144 | - | let req = with_key( |
|
| 145 | - | client |
|
| 146 | - | .put(format!("{base_url}/api/notes/{short_id}")) |
|
| 147 | - | .json(&body), |
|
| 148 | - | api_key, |
|
| 149 | - | ); |
|
| 150 | - | let resp = send_request(req)?; |
|
| 151 | - | match resp.status().as_u16() { |
|
| 152 | - | 200 => resp.json::<Note>().map(Some).map_err(net), |
|
| 153 | - | 404 => Ok(None), |
|
| 154 | - | _ => Err(unexpected(resp.status())), |
|
| 155 | - | } |
|
| 156 | - | } |
|
| 157 | - | } |
|
| 158 | - | } |
|
| 159 | - | ||
| 160 | - | pub fn delete_note(&self, short_id: &str) -> Result<bool, BackendError> { |
|
| 161 | - | match self { |
|
| 162 | - | Backend::Local { db } => Ok(db::delete_note_by_short_id(db, short_id)?), |
|
| 163 | - | Backend::Remote { |
|
| 164 | - | base_url, |
|
| 165 | - | api_key, |
|
| 166 | - | client, |
|
| 167 | - | } => { |
|
| 168 | - | let req = with_key( |
|
| 169 | - | client.delete(format!("{base_url}/api/notes/{short_id}")), |
|
| 170 | - | api_key, |
|
| 171 | - | ); |
|
| 172 | - | let resp = send_request(req)?; |
|
| 173 | - | match resp.status().as_u16() { |
|
| 174 | - | 200 | 204 => Ok(true), |
|
| 175 | - | 404 => Ok(false), |
|
| 176 | - | _ => Err(unexpected(resp.status())), |
|
| 177 | - | } |
|
| 178 | - | } |
|
| 179 | - | } |
|
| 180 | - | } |
|
| 181 | - | } |
| 1 | - | use serde::{Deserialize, Serialize}; |
|
| 2 | - | use std::path::PathBuf; |
|
| 3 | - | ||
| 4 | - | #[derive(Debug, Default, Serialize, Deserialize)] |
|
| 5 | - | pub struct Config { |
|
| 6 | - | pub remote_url: Option<String>, |
|
| 7 | - | pub api_key: Option<String>, |
|
| 8 | - | } |
|
| 9 | - | ||
| 10 | - | pub fn config_path() -> PathBuf { |
|
| 11 | - | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); |
|
| 12 | - | PathBuf::from(home).join(".config/jotts/config.toml") |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | pub fn load_config() -> Config { |
|
| 16 | - | let path = config_path(); |
|
| 17 | - | match std::fs::read_to_string(&path) { |
|
| 18 | - | Ok(contents) => toml::from_str(&contents).unwrap_or_default(), |
|
| 19 | - | Err(_) => Config::default(), |
|
| 20 | - | } |
|
| 21 | - | } |
|
| 22 | - | ||
| 23 | - | pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> { |
|
| 24 | - | let path = config_path(); |
|
| 25 | - | if let Some(parent) = path.parent() { |
|
| 26 | - | std::fs::create_dir_all(parent)?; |
|
| 27 | - | } |
|
| 28 | - | let contents = toml::to_string_pretty(config)?; |
|
| 29 | - | std::fs::write(&path, contents)?; |
|
| 30 | - | Ok(()) |
|
| 31 | - | } |
| 1 | - | use nanoid::nanoid; |
|
| 2 | - | use rusqlite::{Connection, OptionalExtension, Row, params}; |
|
| 3 | - | use serde::{Deserialize, Serialize}; |
|
| 4 | - | use std::sync::{Arc, Mutex}; |
|
| 5 | - | ||
| 6 | - | pub use andromeda_db::{Db, DbError}; |
|
| 7 | - | pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions}; |
|
| 8 | - | ||
| 9 | - | const NOTE_COLUMNS: &str = "id, short_id, title, content, created_at, updated_at"; |
|
| 10 | - | ||
| 11 | - | const SCHEMA: &str = " |
|
| 12 | - | CREATE TABLE IF NOT EXISTS notes ( |
|
| 13 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 14 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 15 | - | title TEXT NOT NULL, |
|
| 16 | - | content TEXT NOT NULL, |
|
| 17 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 18 | - | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 19 | - | ); |
|
| 20 | - | ||
| 21 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 22 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 23 | - | token TEXT NOT NULL UNIQUE, |
|
| 24 | - | expires_at TEXT NOT NULL |
|
| 25 | - | ); |
|
| 26 | - | "; |
|
| 27 | - | ||
| 28 | - | #[derive(Debug, Serialize, Deserialize)] |
|
| 29 | - | pub struct Note { |
|
| 30 | - | pub id: i64, |
|
| 31 | - | pub short_id: String, |
|
| 32 | - | pub title: String, |
|
| 33 | - | pub content: String, |
|
| 34 | - | pub created_at: String, |
|
| 35 | - | pub updated_at: String, |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | impl Note { |
|
| 39 | - | fn from_row(row: &Row) -> rusqlite::Result<Self> { |
|
| 40 | - | Ok(Note { |
|
| 41 | - | id: row.get(0)?, |
|
| 42 | - | short_id: row.get(1)?, |
|
| 43 | - | title: row.get(2)?, |
|
| 44 | - | content: row.get(3)?, |
|
| 45 | - | created_at: row.get(4)?, |
|
| 46 | - | updated_at: row.get(5)?, |
|
| 47 | - | }) |
|
| 48 | - | } |
|
| 49 | - | } |
|
| 50 | - | ||
| 51 | - | /// Incoming note payload from JSON/form requests. Shared by server and backend. |
|
| 52 | - | #[derive(Debug, Serialize, Deserialize)] |
|
| 53 | - | pub struct NoteInput { |
|
| 54 | - | pub title: String, |
|
| 55 | - | pub content: String, |
|
| 56 | - | } |
|
| 57 | - | ||
| 58 | - | pub fn init_db() -> Db { |
|
| 59 | - | let path = std::env::var("JOTTS_DB_PATH").unwrap_or_else(|_| "jotts.sqlite".to_string()); |
|
| 60 | - | let conn = Connection::open(&path).expect("Failed to open database"); |
|
| 61 | - | conn.execute_batch(SCHEMA).expect("Failed to create tables"); |
|
| 62 | - | Arc::new(Mutex::new(conn)) |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | - | pub fn create_note(db: &Db, title: &str, content: &str) -> Result<Note, DbError> { |
|
| 66 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 67 | - | let short_id = nanoid!(10); |
|
| 68 | - | conn.execute( |
|
| 69 | - | "INSERT INTO notes (short_id, title, content) VALUES (?1, ?2, ?3)", |
|
| 70 | - | params![short_id, title, content], |
|
| 71 | - | )?; |
|
| 72 | - | let id = conn.last_insert_rowid(); |
|
| 73 | - | let note = conn.query_row( |
|
| 74 | - | &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"), |
|
| 75 | - | params![id], |
|
| 76 | - | Note::from_row, |
|
| 77 | - | )?; |
|
| 78 | - | Ok(note) |
|
| 79 | - | } |
|
| 80 | - | ||
| 81 | - | pub fn get_note_by_short_id(db: &Db, short_id: &str) -> Result<Option<Note>, DbError> { |
|
| 82 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 83 | - | let note = conn |
|
| 84 | - | .query_row( |
|
| 85 | - | &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE short_id = ?1"), |
|
| 86 | - | params![short_id], |
|
| 87 | - | Note::from_row, |
|
| 88 | - | ) |
|
| 89 | - | .optional()?; |
|
| 90 | - | Ok(note) |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | pub fn get_all_notes(db: &Db) -> Result<Vec<Note>, DbError> { |
|
| 94 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 95 | - | let mut stmt = |
|
| 96 | - | conn.prepare(&format!("SELECT {NOTE_COLUMNS} FROM notes ORDER BY id DESC"))?; |
|
| 97 | - | let notes = stmt |
|
| 98 | - | .query_map([], Note::from_row)? |
|
| 99 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 100 | - | Ok(notes) |
|
| 101 | - | } |
|
| 102 | - | ||
| 103 | - | pub fn update_note_by_short_id( |
|
| 104 | - | db: &Db, |
|
| 105 | - | short_id: &str, |
|
| 106 | - | title: &str, |
|
| 107 | - | content: &str, |
|
| 108 | - | ) -> Result<Option<Note>, DbError> { |
|
| 109 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 110 | - | let rows = conn.execute( |
|
| 111 | - | "UPDATE notes SET title = ?1, content = ?2, updated_at = datetime('now') WHERE short_id = ?3", |
|
| 112 | - | params![title, content, short_id], |
|
| 113 | - | )?; |
|
| 114 | - | if rows == 0 { |
|
| 115 | - | return Ok(None); |
|
| 116 | - | } |
|
| 117 | - | let note = conn |
|
| 118 | - | .query_row( |
|
| 119 | - | &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE short_id = ?1"), |
|
| 120 | - | params![short_id], |
|
| 121 | - | Note::from_row, |
|
| 122 | - | ) |
|
| 123 | - | .optional()?; |
|
| 124 | - | Ok(note) |
|
| 125 | - | } |
|
| 126 | - | ||
| 127 | - | pub fn delete_note_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 128 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 129 | - | let rows = conn.execute( |
|
| 130 | - | "DELETE FROM notes WHERE short_id = ?1", |
|
| 131 | - | params![short_id], |
|
| 132 | - | )?; |
|
| 133 | - | Ok(rows > 0) |
|
| 134 | - | } |
|
| 135 | - | ||
| 136 | - | ||
| 137 | - | #[cfg(test)] |
|
| 138 | - | mod tests { |
|
| 139 | - | use super::*; |
|
| 140 | - | ||
| 141 | - | fn test_db() -> Db { |
|
| 142 | - | let conn = Connection::open_in_memory().unwrap(); |
|
| 143 | - | conn.execute_batch(SCHEMA).unwrap(); |
|
| 144 | - | Arc::new(Mutex::new(conn)) |
|
| 145 | - | } |
|
| 146 | - | ||
| 147 | - | // ── Note CRUD ────────────────────────────────────────────────────── |
|
| 148 | - | ||
| 149 | - | #[test] |
|
| 150 | - | fn create_and_get_note() { |
|
| 151 | - | let db = test_db(); |
|
| 152 | - | let note = create_note(&db, "My Note", "Some content").unwrap(); |
|
| 153 | - | assert_eq!(note.title, "My Note"); |
|
| 154 | - | assert_eq!(note.content, "Some content"); |
|
| 155 | - | ||
| 156 | - | let fetched = get_note_by_short_id(&db, ¬e.short_id).unwrap().unwrap(); |
|
| 157 | - | assert_eq!(fetched.title, "My Note"); |
|
| 158 | - | } |
|
| 159 | - | ||
| 160 | - | #[test] |
|
| 161 | - | fn get_note_not_found() { |
|
| 162 | - | let db = test_db(); |
|
| 163 | - | assert!(get_note_by_short_id(&db, "nope").unwrap().is_none()); |
|
| 164 | - | } |
|
| 165 | - | ||
| 166 | - | #[test] |
|
| 167 | - | fn get_all_notes_ordered_desc() { |
|
| 168 | - | let db = test_db(); |
|
| 169 | - | create_note(&db, "First", "a").unwrap(); |
|
| 170 | - | create_note(&db, "Second", "b").unwrap(); |
|
| 171 | - | ||
| 172 | - | let all = get_all_notes(&db).unwrap(); |
|
| 173 | - | assert_eq!(all.len(), 2); |
|
| 174 | - | assert_eq!(all[0].title, "Second"); |
|
| 175 | - | assert_eq!(all[1].title, "First"); |
|
| 176 | - | } |
|
| 177 | - | ||
| 178 | - | #[test] |
|
| 179 | - | fn update_note() { |
|
| 180 | - | let db = test_db(); |
|
| 181 | - | let note = create_note(&db, "Old", "old").unwrap(); |
|
| 182 | - | let updated = update_note_by_short_id(&db, ¬e.short_id, "New", "new") |
|
| 183 | - | .unwrap() |
|
| 184 | - | .unwrap(); |
|
| 185 | - | assert_eq!(updated.title, "New"); |
|
| 186 | - | assert_eq!(updated.content, "new"); |
|
| 187 | - | } |
|
| 188 | - | ||
| 189 | - | #[test] |
|
| 190 | - | fn update_nonexistent_note() { |
|
| 191 | - | let db = test_db(); |
|
| 192 | - | assert!(update_note_by_short_id(&db, "nope", "x", "x").unwrap().is_none()); |
|
| 193 | - | } |
|
| 194 | - | ||
| 195 | - | #[test] |
|
| 196 | - | fn delete_note() { |
|
| 197 | - | let db = test_db(); |
|
| 198 | - | let note = create_note(&db, "Del", "x").unwrap(); |
|
| 199 | - | assert!(delete_note_by_short_id(&db, ¬e.short_id).unwrap()); |
|
| 200 | - | assert!(get_note_by_short_id(&db, ¬e.short_id).unwrap().is_none()); |
|
| 201 | - | } |
|
| 202 | - | ||
| 203 | - | #[test] |
|
| 204 | - | fn delete_nonexistent_note() { |
|
| 205 | - | let db = test_db(); |
|
| 206 | - | assert!(!delete_note_by_short_id(&db, "nope").unwrap()); |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | // ── Sessions ─────────────────────────────────────────────────────── |
|
| 210 | - | ||
| 211 | - | #[test] |
|
| 212 | - | fn session_lifecycle() { |
|
| 213 | - | let db = test_db(); |
|
| 214 | - | insert_session(&db, "tok", "2099-01-01 00:00:00").unwrap(); |
|
| 215 | - | assert_eq!( |
|
| 216 | - | get_session_expiry(&db, "tok").unwrap(), |
|
| 217 | - | Some("2099-01-01 00:00:00".to_string()) |
|
| 218 | - | ); |
|
| 219 | - | delete_session(&db, "tok").unwrap(); |
|
| 220 | - | assert!(get_session_expiry(&db, "tok").unwrap().is_none()); |
|
| 221 | - | } |
|
| 222 | - | ||
| 223 | - | #[test] |
|
| 224 | - | fn prune_expired_sessions_works() { |
|
| 225 | - | let db = test_db(); |
|
| 226 | - | insert_session(&db, "old", "2000-01-01 00:00:00").unwrap(); |
|
| 227 | - | insert_session(&db, "new", "2099-01-01 00:00:00").unwrap(); |
|
| 228 | - | prune_expired_sessions(&db).unwrap(); |
|
| 229 | - | assert!(get_session_expiry(&db, "old").unwrap().is_none()); |
|
| 230 | - | assert!(get_session_expiry(&db, "new").unwrap().is_some()); |
|
| 231 | - | } |
|
| 232 | - | } |
| 1 | - | use ratatui::style::{Color, Style}; |
|
| 2 | - | use ratatui::text::{Line, Span, Text}; |
|
| 3 | - | use std::io::Cursor; |
|
| 4 | - | use syntect::easy::HighlightLines; |
|
| 5 | - | use syntect::highlighting::Theme; |
|
| 6 | - | use syntect::parsing::SyntaxSet; |
|
| 7 | - | use syntect::util::LinesWithEndings; |
|
| 8 | - | ||
| 9 | - | pub struct Highlighter { |
|
| 10 | - | syntax_set: SyntaxSet, |
|
| 11 | - | theme: Theme, |
|
| 12 | - | } |
|
| 13 | - | ||
| 14 | - | impl Highlighter { |
|
| 15 | - | pub fn new() -> Self { |
|
| 16 | - | let theme_data = include_bytes!("ansi.tmTheme"); |
|
| 17 | - | let theme = |
|
| 18 | - | syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..])) |
|
| 19 | - | .expect("failed to load ansi theme"); |
|
| 20 | - | Self { |
|
| 21 | - | syntax_set: SyntaxSet::load_defaults_newlines(), |
|
| 22 | - | theme, |
|
| 23 | - | } |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | pub fn highlight_markdown(&self, content: &str) -> Text<'static> { |
|
| 27 | - | let syntax = self |
|
| 28 | - | .syntax_set |
|
| 29 | - | .find_syntax_by_extension("md") |
|
| 30 | - | .or_else(|| self.syntax_set.find_syntax_by_name("Markdown")) |
|
| 31 | - | .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); |
|
| 32 | - | let mut h = HighlightLines::new(syntax, &self.theme); |
|
| 33 | - | ||
| 34 | - | let lines: Vec<Line<'static>> = LinesWithEndings::from(content) |
|
| 35 | - | .map(|line| { |
|
| 36 | - | let ranges = h |
|
| 37 | - | .highlight_line(line, &self.syntax_set) |
|
| 38 | - | .unwrap_or_default(); |
|
| 39 | - | let spans: Vec<Span<'static>> = ranges |
|
| 40 | - | .into_iter() |
|
| 41 | - | .map(|(style, text)| { |
|
| 42 | - | let color = to_ratatui_color(style.foreground); |
|
| 43 | - | Span::styled(text.to_owned(), Style::default().fg(color)) |
|
| 44 | - | }) |
|
| 45 | - | .collect(); |
|
| 46 | - | Line::from(spans) |
|
| 47 | - | }) |
|
| 48 | - | .collect(); |
|
| 49 | - | ||
| 50 | - | Text::from(lines) |
|
| 51 | - | } |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | fn to_ratatui_color(color: syntect::highlighting::Color) -> Color { |
|
| 55 | - | if color.a == 0 { |
|
| 56 | - | Color::Indexed(color.r) |
|
| 57 | - | } else { |
|
| 58 | - | Color::Reset |
|
| 59 | - | } |
|
| 60 | - | } |
| 1 | - | pub mod auth; |
|
| 2 | - | pub mod backend; |
|
| 3 | - | pub mod config; |
|
| 4 | - | pub mod db; |
|
| 5 | - | pub mod highlight; |
|
| 6 | - | pub mod server; |
|
| 7 | - | pub mod tui; |
| 1 | - | use clap::{Parser, Subcommand}; |
|
| 2 | - | use std::path::PathBuf; |
|
| 3 | - | ||
| 4 | - | #[derive(Parser)] |
|
| 5 | - | #[command(name = "jotts", about = "Markdown notes — TUI, server, and CLI")] |
|
| 6 | - | struct Cli { |
|
| 7 | - | /// Remote server URL (e.g. http://localhost:3000) |
|
| 8 | - | #[arg(short, long, env = "JOTTS_REMOTE_URL")] |
|
| 9 | - | remote: Option<String>, |
|
| 10 | - | ||
| 11 | - | /// API key for authenticated operations |
|
| 12 | - | #[arg(short = 'k', long, env = "JOTTS_API_KEY")] |
|
| 13 | - | api_key: Option<String>, |
|
| 14 | - | ||
| 15 | - | /// File path to create a note from |
|
| 16 | - | #[arg(value_name = "FILE")] |
|
| 17 | - | file: Option<PathBuf>, |
|
| 18 | - | ||
| 19 | - | #[command(subcommand)] |
|
| 20 | - | command: Option<Commands>, |
|
| 21 | - | } |
|
| 22 | - | ||
| 23 | - | #[derive(Subcommand)] |
|
| 24 | - | enum Commands { |
|
| 25 | - | /// Start the web server |
|
| 26 | - | Server { |
|
| 27 | - | /// Port to listen on |
|
| 28 | - | #[arg(short, long, default_value_t = 3000)] |
|
| 29 | - | port: u16, |
|
| 30 | - | ||
| 31 | - | /// Host to bind to |
|
| 32 | - | #[arg(long, default_value = "127.0.0.1")] |
|
| 33 | - | host: String, |
|
| 34 | - | }, |
|
| 35 | - | /// Launch the interactive TUI |
|
| 36 | - | Tui { |
|
| 37 | - | #[arg(short, long, env = "JOTTS_REMOTE_URL")] |
|
| 38 | - | remote: Option<String>, |
|
| 39 | - | ||
| 40 | - | #[arg(short = 'k', long, env = "JOTTS_API_KEY")] |
|
| 41 | - | api_key: Option<String>, |
|
| 42 | - | }, |
|
| 43 | - | /// Save remote URL and API key to config file |
|
| 44 | - | Auth, |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | fn main() -> Result<(), Box<dyn std::error::Error>> { |
|
| 48 | - | dotenvy::dotenv().ok(); |
|
| 49 | - | tracing_subscriber::fmt::init(); |
|
| 50 | - | ||
| 51 | - | let cli = Cli::parse(); |
|
| 52 | - | ||
| 53 | - | match cli.command { |
|
| 54 | - | Some(Commands::Server { port, host }) => { |
|
| 55 | - | let rt = tokio::runtime::Runtime::new()?; |
|
| 56 | - | rt.block_on(jotts::server::run(host, port)); |
|
| 57 | - | } |
|
| 58 | - | Some(Commands::Tui { remote, api_key }) => { |
|
| 59 | - | jotts::tui::run_interactive(remote, api_key)?; |
|
| 60 | - | } |
|
| 61 | - | Some(Commands::Auth) => { |
|
| 62 | - | jotts::tui::run_auth()?; |
|
| 63 | - | } |
|
| 64 | - | None => { |
|
| 65 | - | if let Some(file) = cli.file { |
|
| 66 | - | jotts::tui::run_file_upload(cli.remote, cli.api_key, file)?; |
|
| 67 | - | } else { |
|
| 68 | - | jotts::tui::run_interactive(cli.remote, cli.api_key)?; |
|
| 69 | - | } |
|
| 70 | - | } |
|
| 71 | - | } |
|
| 72 | - | ||
| 73 | - | Ok(()) |
|
| 74 | - | } |
| 1 | - | use askama::Template; |
|
| 2 | - | use askama_web::WebTemplate; |
|
| 3 | - | use axum::{ |
|
| 4 | - | extract::{Form, Path, Query, Request, State}, |
|
| 5 | - | http::{HeaderValue, StatusCode}, |
|
| 6 | - | middleware::{self, Next}, |
|
| 7 | - | response::{Html, IntoResponse, Redirect, Response}, |
|
| 8 | - | routing::{get, post}, |
|
| 9 | - | Json, Router, |
|
| 10 | - | }; |
|
| 11 | - | use pulldown_cmark::{Options, Parser, html}; use rust_embed::Embed; |
|
| 12 | - | use std::sync::Arc; |
|
| 13 | - | ||
| 14 | - | use crate::auth; |
|
| 15 | - | use crate::db::{self, Db, DbError, Note, NoteInput}; |
|
| 16 | - | ||
| 17 | - | fn redirect_with_cookie(target: &str, cookie: String) -> Response { |
|
| 18 | - | let mut resp = Redirect::to(target).into_response(); |
|
| 19 | - | resp.headers_mut().insert( |
|
| 20 | - | axum::http::header::SET_COOKIE, |
|
| 21 | - | HeaderValue::from_str(&cookie).unwrap(), |
|
| 22 | - | ); |
|
| 23 | - | resp |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | #[derive(Clone)] |
|
| 27 | - | pub struct AppState { |
|
| 28 | - | pub db: Db, |
|
| 29 | - | pub app_password: String, |
|
| 30 | - | pub api_key: Option<String>, |
|
| 31 | - | pub cookie_secure: bool, |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | #[derive(Embed)] |
|
| 35 | - | #[folder = "static/"] |
|
| 36 | - | struct Static; |
|
| 37 | - | ||
| 38 | - | // --- Templates --- |
|
| 39 | - | ||
| 40 | - | #[derive(Template)] |
|
| 41 | - | #[template(path = "base.html")] |
|
| 42 | - | struct BaseTemplate; |
|
| 43 | - | ||
| 44 | - | #[derive(Template)] |
|
| 45 | - | #[template(path = "login.html")] |
|
| 46 | - | struct LoginTemplate { |
|
| 47 | - | error: Option<String>, |
|
| 48 | - | } |
|
| 49 | - | ||
| 50 | - | #[derive(Template)] |
|
| 51 | - | #[template(path = "index.html")] |
|
| 52 | - | struct IndexTemplate { |
|
| 53 | - | notes: Vec<Note>, |
|
| 54 | - | } |
|
| 55 | - | ||
| 56 | - | #[derive(Template)] |
|
| 57 | - | #[template(path = "view.html")] |
|
| 58 | - | struct ViewTemplate { |
|
| 59 | - | note: Note, |
|
| 60 | - | rendered_content: String, |
|
| 61 | - | } |
|
| 62 | - | ||
| 63 | - | #[derive(Template)] |
|
| 64 | - | #[template(path = "new.html")] |
|
| 65 | - | struct NewTemplate { |
|
| 66 | - | error: Option<String>, |
|
| 67 | - | } |
|
| 68 | - | ||
| 69 | - | #[derive(Template)] |
|
| 70 | - | #[template(path = "edit.html")] |
|
| 71 | - | struct EditTemplate { |
|
| 72 | - | note: Note, |
|
| 73 | - | error: Option<String>, |
|
| 74 | - | } |
|
| 75 | - | ||
| 76 | - | // --- Query/Form structs --- |
|
| 77 | - | ||
| 78 | - | #[derive(serde::Deserialize, Default)] |
|
| 79 | - | pub struct FlashQuery { |
|
| 80 | - | pub error: Option<String>, |
|
| 81 | - | } |
|
| 82 | - | ||
| 83 | - | #[derive(serde::Deserialize)] |
|
| 84 | - | struct LoginForm { |
|
| 85 | - | password: String, |
|
| 86 | - | } |
|
| 87 | - | ||
| 88 | - | // --- API key middleware --- |
|
| 89 | - | ||
| 90 | - | async fn api_key_guard( |
|
| 91 | - | State(state): State<Arc<AppState>>, |
|
| 92 | - | req: Request, |
|
| 93 | - | next: Next, |
|
| 94 | - | ) -> Response { |
|
| 95 | - | let expected = match &state.api_key { |
|
| 96 | - | Some(k) if !k.is_empty() => k.clone(), |
|
| 97 | - | _ => { |
|
| 98 | - | return (StatusCode::FORBIDDEN, "API key not configured on server").into_response(); |
|
| 99 | - | } |
|
| 100 | - | }; |
|
| 101 | - | ||
| 102 | - | let provided = req |
|
| 103 | - | .headers() |
|
| 104 | - | .get("x-api-key") |
|
| 105 | - | .and_then(|v| v.to_str().ok()) |
|
| 106 | - | .unwrap_or(""); |
|
| 107 | - | ||
| 108 | - | if !andromeda_auth::verify_api_key(provided, &expected) { |
|
| 109 | - | return (StatusCode::UNAUTHORIZED, "Invalid API key").into_response(); |
|
| 110 | - | } |
|
| 111 | - | ||
| 112 | - | next.run(req).await |
|
| 113 | - | } |
|
| 114 | - | ||
| 115 | - | // --- JSON API handlers --- |
|
| 116 | - | ||
| 117 | - | async fn api_list_notes(State(state): State<Arc<AppState>>) -> Result<Response, DbError> { |
|
| 118 | - | Ok(Json(db::get_all_notes(&state.db)?).into_response()) |
|
| 119 | - | } |
|
| 120 | - | ||
| 121 | - | async fn api_get_note( |
|
| 122 | - | State(state): State<Arc<AppState>>, |
|
| 123 | - | Path(short_id): Path<String>, |
|
| 124 | - | ) -> Result<Response, DbError> { |
|
| 125 | - | Ok(match db::get_note_by_short_id(&state.db, &short_id)? { |
|
| 126 | - | Some(note) => Json(note).into_response(), |
|
| 127 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 128 | - | }) |
|
| 129 | - | } |
|
| 130 | - | ||
| 131 | - | async fn api_create_note( |
|
| 132 | - | State(state): State<Arc<AppState>>, |
|
| 133 | - | Json(body): Json<NoteInput>, |
|
| 134 | - | ) -> Result<Response, DbError> { |
|
| 135 | - | let title = body.title.trim(); |
|
| 136 | - | if title.is_empty() { |
|
| 137 | - | return Ok((StatusCode::BAD_REQUEST, "title required").into_response()); |
|
| 138 | - | } |
|
| 139 | - | let note = db::create_note(&state.db, title, &body.content)?; |
|
| 140 | - | Ok((StatusCode::CREATED, Json(note)).into_response()) |
|
| 141 | - | } |
|
| 142 | - | ||
| 143 | - | async fn api_update_note( |
|
| 144 | - | State(state): State<Arc<AppState>>, |
|
| 145 | - | Path(short_id): Path<String>, |
|
| 146 | - | Json(body): Json<NoteInput>, |
|
| 147 | - | ) -> Result<Response, DbError> { |
|
| 148 | - | let title = body.title.trim(); |
|
| 149 | - | if title.is_empty() { |
|
| 150 | - | return Ok((StatusCode::BAD_REQUEST, "title required").into_response()); |
|
| 151 | - | } |
|
| 152 | - | Ok( |
|
| 153 | - | match db::update_note_by_short_id(&state.db, &short_id, title, &body.content)? { |
|
| 154 | - | Some(note) => Json(note).into_response(), |
|
| 155 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 156 | - | }, |
|
| 157 | - | ) |
|
| 158 | - | } |
|
| 159 | - | ||
| 160 | - | async fn api_delete_note( |
|
| 161 | - | State(state): State<Arc<AppState>>, |
|
| 162 | - | Path(short_id): Path<String>, |
|
| 163 | - | ) -> Result<Response, DbError> { |
|
| 164 | - | Ok(match db::delete_note_by_short_id(&state.db, &short_id)? { |
|
| 165 | - | true => StatusCode::NO_CONTENT.into_response(), |
|
| 166 | - | false => StatusCode::NOT_FOUND.into_response(), |
|
| 167 | - | }) |
|
| 168 | - | } |
|
| 169 | - | ||
| 170 | - | // --- Static file handlers --- |
|
| 171 | - | ||
| 172 | - | fn mime_from_path(path: &str) -> &'static str { |
|
| 173 | - | match path.rsplit('.').next().unwrap_or("") { |
|
| 174 | - | "css" => "text/css", |
|
| 175 | - | "js" => "application/javascript", |
|
| 176 | - | "html" => "text/html", |
|
| 177 | - | "png" => "image/png", |
|
| 178 | - | "ico" => "image/x-icon", |
|
| 179 | - | "svg" => "image/svg+xml", |
|
| 180 | - | "woff" | "woff2" => "font/woff2", |
|
| 181 | - | "ttf" => "font/ttf", |
|
| 182 | - | "otf" => "font/otf", |
|
| 183 | - | "json" | "webmanifest" => "application/json", |
|
| 184 | - | _ => "application/octet-stream", |
|
| 185 | - | } |
|
| 186 | - | } |
|
| 187 | - | ||
| 188 | - | async fn serve_static(Path(path): Path<String>) -> Response { |
|
| 189 | - | match Static::get(&path) { |
|
| 190 | - | Some(file) => { |
|
| 191 | - | let mime = mime_from_path(&path); |
|
| 192 | - | ( |
|
| 193 | - | StatusCode::OK, |
|
| 194 | - | [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))], |
|
| 195 | - | file.data.to_vec(), |
|
| 196 | - | ) |
|
| 197 | - | .into_response() |
|
| 198 | - | } |
|
| 199 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 200 | - | } |
|
| 201 | - | } |
|
| 202 | - | ||
| 203 | - | // --- Auth handlers --- |
|
| 204 | - | ||
| 205 | - | async fn get_login(Query(q): Query<FlashQuery>) -> Response { |
|
| 206 | - | WebTemplate(LoginTemplate { error: q.error }).into_response() |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | async fn post_login( |
|
| 210 | - | State(state): State<Arc<AppState>>, |
|
| 211 | - | Form(form): Form<LoginForm>, |
|
| 212 | - | ) -> Response { |
|
| 213 | - | if !auth::verify_password(&form.password, &state.app_password) { |
|
| 214 | - | return Redirect::to("/login?error=Invalid+password").into_response(); |
|
| 215 | - | } |
|
| 216 | - | ||
| 217 | - | let token = auth::generate_session_token(); |
|
| 218 | - | ||
| 219 | - | // Session expires in 7 days |
|
| 220 | - | // We need to compute a datetime 7 days from now |
|
| 221 | - | let expires_at = andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600); |
|
| 222 | - | ||
| 223 | - | if let Err(e) = db::insert_session(&state.db, &token, &expires_at) { |
|
| 224 | - | tracing::error!("Failed to create session: {}", e); |
|
| 225 | - | return Redirect::to("/login?error=Server+error").into_response(); |
|
| 226 | - | } |
|
| 227 | - | ||
| 228 | - | redirect_with_cookie("/", auth::build_session_cookie(&token, state.cookie_secure)) |
|
| 229 | - | } |
|
| 230 | - | ||
| 231 | - | async fn get_logout(State(state): State<Arc<AppState>>, headers: axum::http::HeaderMap) -> Response { |
|
| 232 | - | if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) { |
|
| 233 | - | for part in cookie_header.split(';') { |
|
| 234 | - | let part = part.trim(); |
|
| 235 | - | if let Some(val) = part.strip_prefix("session=") { |
|
| 236 | - | let val = val.trim(); |
|
| 237 | - | if !val.is_empty() { |
|
| 238 | - | let _ = db::delete_session(&state.db, val); |
|
| 239 | - | } |
|
| 240 | - | } |
|
| 241 | - | } |
|
| 242 | - | } |
|
| 243 | - | ||
| 244 | - | redirect_with_cookie("/login", auth::clear_session_cookie()) |
|
| 245 | - | } |
|
| 246 | - | ||
| 247 | - | // --- Note handlers --- |
|
| 248 | - | ||
| 249 | - | async fn get_index( |
|
| 250 | - | _session: auth::AuthSession, |
|
| 251 | - | State(state): State<Arc<AppState>>, |
|
| 252 | - | ) -> Result<Response, DbError> { |
|
| 253 | - | let notes = db::get_all_notes(&state.db)?; |
|
| 254 | - | Ok(WebTemplate(IndexTemplate { notes }).into_response()) |
|
| 255 | - | } |
|
| 256 | - | ||
| 257 | - | async fn get_new_note( |
|
| 258 | - | _session: auth::AuthSession, |
|
| 259 | - | Query(q): Query<FlashQuery>, |
|
| 260 | - | ) -> Response { |
|
| 261 | - | WebTemplate(NewTemplate { error: q.error }).into_response() |
|
| 262 | - | } |
|
| 263 | - | ||
| 264 | - | async fn post_create_note( |
|
| 265 | - | _session: auth::AuthSession, |
|
| 266 | - | State(state): State<Arc<AppState>>, |
|
| 267 | - | Form(form): Form<NoteInput>, |
|
| 268 | - | ) -> Response { |
|
| 269 | - | let title = form.title.trim(); |
|
| 270 | - | if title.is_empty() { |
|
| 271 | - | return Redirect::to("/notes/new?error=Title+is+required").into_response(); |
|
| 272 | - | } |
|
| 273 | - | ||
| 274 | - | match db::create_note(&state.db, title, &form.content) { |
|
| 275 | - | Ok(note) => Redirect::to(&format!("/notes/{}", note.short_id)).into_response(), |
|
| 276 | - | Err(e) => { |
|
| 277 | - | tracing::error!("Failed to create note: {}", e); |
|
| 278 | - | Redirect::to("/notes/new?error=Failed+to+create+note").into_response() |
|
| 279 | - | } |
|
| 280 | - | } |
|
| 281 | - | } |
|
| 282 | - | ||
| 283 | - | fn render_markdown(content: &str) -> String { |
|
| 284 | - | let mut options = Options::empty(); |
|
| 285 | - | options.insert(Options::ENABLE_STRIKETHROUGH); |
|
| 286 | - | options.insert(Options::ENABLE_TABLES); |
|
| 287 | - | options.insert(Options::ENABLE_TASKLISTS); |
|
| 288 | - | let parser = Parser::new_ext(content, options); |
|
| 289 | - | let mut html_output = String::new(); |
|
| 290 | - | html::push_html(&mut html_output, parser); |
|
| 291 | - | html_output |
|
| 292 | - | } |
|
| 293 | - | ||
| 294 | - | async fn get_view_note( |
|
| 295 | - | _session: auth::AuthSession, |
|
| 296 | - | State(state): State<Arc<AppState>>, |
|
| 297 | - | Path(short_id): Path<String>, |
|
| 298 | - | ) -> Result<Response, DbError> { |
|
| 299 | - | Ok(match db::get_note_by_short_id(&state.db, &short_id)? { |
|
| 300 | - | Some(note) => { |
|
| 301 | - | let rendered_content = render_markdown(¬e.content); |
|
| 302 | - | WebTemplate(ViewTemplate { |
|
| 303 | - | note, |
|
| 304 | - | rendered_content, |
|
| 305 | - | }) |
|
| 306 | - | .into_response() |
|
| 307 | - | } |
|
| 308 | - | None => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(), |
|
| 309 | - | }) |
|
| 310 | - | } |
|
| 311 | - | ||
| 312 | - | async fn get_edit_note( |
|
| 313 | - | _session: auth::AuthSession, |
|
| 314 | - | State(state): State<Arc<AppState>>, |
|
| 315 | - | Path(short_id): Path<String>, |
|
| 316 | - | Query(q): Query<FlashQuery>, |
|
| 317 | - | ) -> Result<Response, DbError> { |
|
| 318 | - | Ok(match db::get_note_by_short_id(&state.db, &short_id)? { |
|
| 319 | - | Some(note) => WebTemplate(EditTemplate { |
|
| 320 | - | note, |
|
| 321 | - | error: q.error, |
|
| 322 | - | }) |
|
| 323 | - | .into_response(), |
|
| 324 | - | None => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(), |
|
| 325 | - | }) |
|
| 326 | - | } |
|
| 327 | - | ||
| 328 | - | async fn post_update_note( |
|
| 329 | - | _session: auth::AuthSession, |
|
| 330 | - | State(state): State<Arc<AppState>>, |
|
| 331 | - | Path(short_id): Path<String>, |
|
| 332 | - | Form(form): Form<NoteInput>, |
|
| 333 | - | ) -> Response { |
|
| 334 | - | let title = form.title.trim(); |
|
| 335 | - | if title.is_empty() { |
|
| 336 | - | return Redirect::to(&format!("/notes/{}/edit?error=Title+is+required", short_id)) |
|
| 337 | - | .into_response(); |
|
| 338 | - | } |
|
| 339 | - | ||
| 340 | - | match db::update_note_by_short_id(&state.db, &short_id, title, &form.content) { |
|
| 341 | - | Ok(Some(_)) => Redirect::to(&format!("/notes/{}", short_id)).into_response(), |
|
| 342 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(), |
|
| 343 | - | Err(e) => { |
|
| 344 | - | tracing::error!("Failed to update note: {}", e); |
|
| 345 | - | Redirect::to(&format!( |
|
| 346 | - | "/notes/{}/edit?error=Failed+to+update+note", |
|
| 347 | - | short_id |
|
| 348 | - | )) |
|
| 349 | - | .into_response() |
|
| 350 | - | } |
|
| 351 | - | } |
|
| 352 | - | } |
|
| 353 | - | ||
| 354 | - | async fn post_delete_note( |
|
| 355 | - | _session: auth::AuthSession, |
|
| 356 | - | State(state): State<Arc<AppState>>, |
|
| 357 | - | Path(short_id): Path<String>, |
|
| 358 | - | ) -> Response { |
|
| 359 | - | match db::delete_note_by_short_id(&state.db, &short_id) { |
|
| 360 | - | Ok(_) => Redirect::to("/").into_response(), |
|
| 361 | - | Err(e) => { |
|
| 362 | - | tracing::error!("Failed to delete note: {}", e); |
|
| 363 | - | Redirect::to("/").into_response() |
|
| 364 | - | } |
|
| 365 | - | } |
|
| 366 | - | } |
|
| 367 | - | ||
| 368 | - | // --- Router --- |
|
| 369 | - | ||
| 370 | - | pub async fn run(host: String, port: u16) { |
|
| 371 | - | dotenvy::dotenv().ok(); |
|
| 372 | - | ||
| 373 | - | let db = db::init_db(); |
|
| 374 | - | ||
| 375 | - | // Prune expired sessions on startup |
|
| 376 | - | if let Err(e) = db::prune_expired_sessions(&db) { |
|
| 377 | - | tracing::warn!("Failed to prune sessions: {}", e); |
|
| 378 | - | } |
|
| 379 | - | ||
| 380 | - | let app_password = std::env::var("JOTTS_PASSWORD").unwrap_or_else(|_| { |
|
| 381 | - | tracing::warn!("JOTTS_PASSWORD not set, using default 'changeme'"); |
|
| 382 | - | "changeme".to_string() |
|
| 383 | - | }); |
|
| 384 | - | ||
| 385 | - | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 386 | - | .map(|v| v == "true") |
|
| 387 | - | .unwrap_or(false); |
|
| 388 | - | ||
| 389 | - | let api_key = std::env::var("JOTTS_API_KEY") |
|
| 390 | - | .ok() |
|
| 391 | - | .filter(|k| !k.is_empty()); |
|
| 392 | - | if api_key.is_none() { |
|
| 393 | - | tracing::info!("JOTTS_API_KEY not set, /api/* will return 403"); |
|
| 394 | - | } |
|
| 395 | - | ||
| 396 | - | let state = Arc::new(AppState { |
|
| 397 | - | db, |
|
| 398 | - | app_password, |
|
| 399 | - | api_key, |
|
| 400 | - | cookie_secure, |
|
| 401 | - | }); |
|
| 402 | - | ||
| 403 | - | let api_router = Router::new() |
|
| 404 | - | .route("/api/notes", get(api_list_notes).post(api_create_note)) |
|
| 405 | - | .route( |
|
| 406 | - | "/api/notes/{short_id}", |
|
| 407 | - | get(api_get_note) |
|
| 408 | - | .put(api_update_note) |
|
| 409 | - | .delete(api_delete_note), |
|
| 410 | - | ) |
|
| 411 | - | .route_layer(middleware::from_fn_with_state( |
|
| 412 | - | state.clone(), |
|
| 413 | - | api_key_guard, |
|
| 414 | - | )); |
|
| 415 | - | ||
| 416 | - | let app = Router::new() |
|
| 417 | - | // Public routes |
|
| 418 | - | .route("/login", get(get_login).post(post_login)) |
|
| 419 | - | .route("/logout", get(get_logout)) |
|
| 420 | - | // Protected routes |
|
| 421 | - | .route("/", get(get_index)) |
|
| 422 | - | .route("/notes/new", get(get_new_note)) |
|
| 423 | - | .route("/notes", post(post_create_note)) |
|
| 424 | - | .route("/notes/{short_id}", get(get_view_note)) |
|
| 425 | - | .route("/notes/{short_id}/edit", get(get_edit_note)) |
|
| 426 | - | .route("/notes/{short_id}", post(post_update_note)) |
|
| 427 | - | .route("/notes/{short_id}/delete", post(post_delete_note)) |
|
| 428 | - | // Static assets |
|
| 429 | - | .route("/static/{*path}", get(serve_static)) |
|
| 430 | - | .merge(api_router) |
|
| 431 | - | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 432 | - | .with_state(state); |
|
| 433 | - | ||
| 434 | - | let addr = format!("{}:{}", host, port); |
|
| 435 | - | tracing::info!("Listening on http://{}", addr); |
|
| 436 | - | ||
| 437 | - | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); |
|
| 438 | - | axum::serve(listener, app).await.unwrap(); |
|
| 439 | - | } |
| 1 | - | mod app; |
|
| 2 | - | mod editor; |
|
| 3 | - | mod events; |
|
| 4 | - | mod render; |
|
| 5 | - | ||
| 6 | - | use crate::backend::Backend; |
|
| 7 | - | use crate::config; |
|
| 8 | - | use app::App; |
|
| 9 | - | use arboard::Clipboard; |
|
| 10 | - | use crossterm::event::{self, Event}; |
|
| 11 | - | use ratatui::DefaultTerminal; |
|
| 12 | - | use std::path::PathBuf; |
|
| 13 | - | use std::time::Duration; |
|
| 14 | - | ||
| 15 | - | fn db_path() -> String { |
|
| 16 | - | std::env::var("JOTTS_DB_PATH").unwrap_or_else(|_| "jotts.sqlite".to_string()) |
|
| 17 | - | } |
|
| 18 | - | ||
| 19 | - | fn resolve_backend( |
|
| 20 | - | remote: Option<String>, |
|
| 21 | - | api_key: Option<String>, |
|
| 22 | - | ) -> Result<(Backend, bool, Option<String>), Box<dyn std::error::Error>> { |
|
| 23 | - | if let Some(url) = remote { |
|
| 24 | - | return Ok((Backend::remote(url.clone(), api_key), true, Some(url))); |
|
| 25 | - | } |
|
| 26 | - | ||
| 27 | - | if !std::path::Path::new(&db_path()).exists() { |
|
| 28 | - | let cfg = config::load_config(); |
|
| 29 | - | let url = cfg |
|
| 30 | - | .remote_url |
|
| 31 | - | .unwrap_or_else(|| "http://localhost:3000".to_string()); |
|
| 32 | - | let api_key = api_key.or(cfg.api_key); |
|
| 33 | - | return Ok((Backend::remote(url.clone(), api_key), true, Some(url))); |
|
| 34 | - | } |
|
| 35 | - | ||
| 36 | - | Ok(( |
|
| 37 | - | Backend::local(), |
|
| 38 | - | false, |
|
| 39 | - | Some("http://localhost:3000".to_string()), |
|
| 40 | - | )) |
|
| 41 | - | } |
|
| 42 | - | ||
| 43 | - | pub fn run_file_upload( |
|
| 44 | - | remote: Option<String>, |
|
| 45 | - | api_key: Option<String>, |
|
| 46 | - | file: PathBuf, |
|
| 47 | - | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 48 | - | let (backend, _, remote_url) = resolve_backend(remote, api_key)?; |
|
| 49 | - | ||
| 50 | - | let title = file |
|
| 51 | - | .file_stem() |
|
| 52 | - | .ok_or("Invalid file path")? |
|
| 53 | - | .to_string_lossy() |
|
| 54 | - | .to_string(); |
|
| 55 | - | let content = std::fs::read_to_string(&file) |
|
| 56 | - | .map_err(|e| format!("Failed to read file: {}", e))?; |
|
| 57 | - | let note = backend |
|
| 58 | - | .create_note(&title, &content) |
|
| 59 | - | .map_err(|e| format!("{}", e))?; |
|
| 60 | - | let link = match &remote_url { |
|
| 61 | - | Some(url) => format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id), |
|
| 62 | - | None => note.short_id.clone(), |
|
| 63 | - | }; |
|
| 64 | - | println!("{}", link); |
|
| 65 | - | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 66 | - | let _ = clipboard.set_text(&link); |
|
| 67 | - | println!("\u{2714} Copied to clipboard!"); |
|
| 68 | - | } |
|
| 69 | - | Ok(()) |
|
| 70 | - | } |
|
| 71 | - | ||
| 72 | - | pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> { |
|
| 73 | - | use std::io::{self, Write}; |
|
| 74 | - | ||
| 75 | - | print!("Remote URL: "); |
|
| 76 | - | io::stdout().flush()?; |
|
| 77 | - | let mut remote_url = String::new(); |
|
| 78 | - | io::stdin().read_line(&mut remote_url)?; |
|
| 79 | - | let remote_url = remote_url.trim().to_string(); |
|
| 80 | - | ||
| 81 | - | print!("API Key: "); |
|
| 82 | - | io::stdout().flush()?; |
|
| 83 | - | let api_key = rpassword::read_password()?; |
|
| 84 | - | let api_key = api_key.trim().to_string(); |
|
| 85 | - | ||
| 86 | - | let cfg = config::Config { |
|
| 87 | - | remote_url: if remote_url.is_empty() { |
|
| 88 | - | None |
|
| 89 | - | } else { |
|
| 90 | - | Some(remote_url) |
|
| 91 | - | }, |
|
| 92 | - | api_key: if api_key.is_empty() { |
|
| 93 | - | None |
|
| 94 | - | } else { |
|
| 95 | - | Some(api_key) |
|
| 96 | - | }, |
|
| 97 | - | }; |
|
| 98 | - | ||
| 99 | - | config::save_config(&cfg)?; |
|
| 100 | - | println!("Config saved to {}", config::config_path().display()); |
|
| 101 | - | Ok(()) |
|
| 102 | - | } |
|
| 103 | - | ||
| 104 | - | pub fn run_interactive( |
|
| 105 | - | remote: Option<String>, |
|
| 106 | - | api_key: Option<String>, |
|
| 107 | - | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 108 | - | let (backend, is_remote, remote_url) = resolve_backend(remote, api_key)?; |
|
| 109 | - | ||
| 110 | - | let notes = match backend.list_notes() { |
|
| 111 | - | Ok(n) => n, |
|
| 112 | - | Err(e) => { |
|
| 113 | - | eprintln!("Failed to load notes: {}", e); |
|
| 114 | - | Vec::new() |
|
| 115 | - | } |
|
| 116 | - | }; |
|
| 117 | - | ||
| 118 | - | ratatui::run(|terminal| run_app(terminal, App::new(notes, is_remote, remote_url), &backend)) |
|
| 119 | - | } |
|
| 120 | - | ||
| 121 | - | fn run_app( |
|
| 122 | - | terminal: &mut DefaultTerminal, |
|
| 123 | - | mut app: App, |
|
| 124 | - | backend: &Backend, |
|
| 125 | - | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 126 | - | while !app.should_quit { |
|
| 127 | - | app.clear_expired_status(); |
|
| 128 | - | ||
| 129 | - | let content_line_count = app |
|
| 130 | - | .selected_note() |
|
| 131 | - | .map(|n| n.content.lines().count() as u16) |
|
| 132 | - | .unwrap_or(0); |
|
| 133 | - | ||
| 134 | - | terminal.draw(|frame| render::draw(frame, &mut app))?; |
|
| 135 | - | ||
| 136 | - | if event::poll(Duration::from_millis(100))? |
|
| 137 | - | && let Event::Key(key) = event::read()? |
|
| 138 | - | { |
|
| 139 | - | events::handle_key(terminal, &mut app, backend, key, content_line_count)?; |
|
| 140 | - | } |
|
| 141 | - | } |
|
| 142 | - | ||
| 143 | - | Ok(()) |
|
| 144 | - | } |
| 1 | - | use crate::backend::Backend; |
|
| 2 | - | use crate::db::Note; |
|
| 3 | - | use crate::highlight::Highlighter; |
|
| 4 | - | use arboard::Clipboard; |
|
| 5 | - | use ratatui::widgets::ListState; |
|
| 6 | - | use std::time::{Duration, Instant}; |
|
| 7 | - | ||
| 8 | - | pub(super) enum Focus { |
|
| 9 | - | List, |
|
| 10 | - | Content, |
|
| 11 | - | CreateTitle, |
|
| 12 | - | CreateContent, |
|
| 13 | - | EditTitle, |
|
| 14 | - | EditContent, |
|
| 15 | - | Search, |
|
| 16 | - | } |
|
| 17 | - | ||
| 18 | - | pub(super) struct App { |
|
| 19 | - | pub(super) notes: Vec<Note>, |
|
| 20 | - | pub(super) list_state: ListState, |
|
| 21 | - | pub(super) should_quit: bool, |
|
| 22 | - | pub(super) status_message: Option<(String, Instant)>, |
|
| 23 | - | pub(super) focus: Focus, |
|
| 24 | - | pub(super) content_scroll: u16, |
|
| 25 | - | pub(super) show_help: bool, |
|
| 26 | - | pub(super) confirm_delete: bool, |
|
| 27 | - | pub(super) highlighter: Highlighter, |
|
| 28 | - | pub(super) edit_title: String, |
|
| 29 | - | pub(super) edit_content: String, |
|
| 30 | - | pub(super) edit_short_id: Option<String>, |
|
| 31 | - | pub(super) search_query: String, |
|
| 32 | - | pub(super) filtered_indices: Option<Vec<usize>>, |
|
| 33 | - | pub(super) is_remote: bool, |
|
| 34 | - | pub(super) remote_url: Option<String>, |
|
| 35 | - | pub(super) wrap_content: bool, |
|
| 36 | - | pub(super) edit_scroll: u16, |
|
| 37 | - | } |
|
| 38 | - | ||
| 39 | - | impl App { |
|
| 40 | - | pub(super) fn new(notes: Vec<Note>, is_remote: bool, remote_url: Option<String>) -> Self { |
|
| 41 | - | let mut list_state = ListState::default(); |
|
| 42 | - | if !notes.is_empty() { |
|
| 43 | - | list_state.select(Some(0)); |
|
| 44 | - | } |
|
| 45 | - | Self { |
|
| 46 | - | notes, |
|
| 47 | - | list_state, |
|
| 48 | - | should_quit: false, |
|
| 49 | - | status_message: None, |
|
| 50 | - | focus: Focus::List, |
|
| 51 | - | content_scroll: 0, |
|
| 52 | - | show_help: false, |
|
| 53 | - | confirm_delete: false, |
|
| 54 | - | highlighter: Highlighter::new(), |
|
| 55 | - | edit_title: String::new(), |
|
| 56 | - | edit_content: String::new(), |
|
| 57 | - | edit_short_id: None, |
|
| 58 | - | search_query: String::new(), |
|
| 59 | - | filtered_indices: None, |
|
| 60 | - | is_remote, |
|
| 61 | - | remote_url, |
|
| 62 | - | wrap_content: true, |
|
| 63 | - | edit_scroll: 0, |
|
| 64 | - | } |
|
| 65 | - | } |
|
| 66 | - | ||
| 67 | - | pub(super) fn selected_note(&self) -> Option<&Note> { |
|
| 68 | - | self.list_state.selected().and_then(|i| { |
|
| 69 | - | if let Some(indices) = &self.filtered_indices { |
|
| 70 | - | indices.get(i).and_then(|&real| self.notes.get(real)) |
|
| 71 | - | } else { |
|
| 72 | - | self.notes.get(i) |
|
| 73 | - | } |
|
| 74 | - | }) |
|
| 75 | - | } |
|
| 76 | - | ||
| 77 | - | pub(super) fn visible_count(&self) -> usize { |
|
| 78 | - | match &self.filtered_indices { |
|
| 79 | - | Some(indices) => indices.len(), |
|
| 80 | - | None => self.notes.len(), |
|
| 81 | - | } |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | pub(super) fn move_up(&mut self) { |
|
| 85 | - | let count = self.visible_count(); |
|
| 86 | - | if count == 0 { |
|
| 87 | - | return; |
|
| 88 | - | } |
|
| 89 | - | let i = match self.list_state.selected() { |
|
| 90 | - | Some(i) if i > 0 => i - 1, |
|
| 91 | - | Some(_) => count - 1, |
|
| 92 | - | None => 0, |
|
| 93 | - | }; |
|
| 94 | - | self.list_state.select(Some(i)); |
|
| 95 | - | self.content_scroll = 0; |
|
| 96 | - | } |
|
| 97 | - | ||
| 98 | - | pub(super) fn move_down(&mut self) { |
|
| 99 | - | let count = self.visible_count(); |
|
| 100 | - | if count == 0 { |
|
| 101 | - | return; |
|
| 102 | - | } |
|
| 103 | - | let i = match self.list_state.selected() { |
|
| 104 | - | Some(i) if i < count - 1 => i + 1, |
|
| 105 | - | Some(_) => 0, |
|
| 106 | - | None => 0, |
|
| 107 | - | }; |
|
| 108 | - | self.list_state.select(Some(i)); |
|
| 109 | - | self.content_scroll = 0; |
|
| 110 | - | } |
|
| 111 | - | ||
| 112 | - | pub(super) fn scroll_up(&mut self) { |
|
| 113 | - | self.content_scroll = self.content_scroll.saturating_sub(1); |
|
| 114 | - | } |
|
| 115 | - | ||
| 116 | - | pub(super) fn scroll_down(&mut self, max_lines: u16) { |
|
| 117 | - | if self.content_scroll < max_lines { |
|
| 118 | - | self.content_scroll += 1; |
|
| 119 | - | } |
|
| 120 | - | } |
|
| 121 | - | ||
| 122 | - | pub(super) fn copy_selected(&mut self) { |
|
| 123 | - | if let Some(note) = self.selected_note() { |
|
| 124 | - | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 125 | - | let _ = clipboard.set_text(¬e.content); |
|
| 126 | - | self.status_message = Some(("Copied!".to_string(), Instant::now())); |
|
| 127 | - | } |
|
| 128 | - | } |
|
| 129 | - | } |
|
| 130 | - | ||
| 131 | - | pub(super) fn copy_link(&mut self) { |
|
| 132 | - | match &self.remote_url { |
|
| 133 | - | Some(url) => { |
|
| 134 | - | if let Some(note) = self.selected_note() { |
|
| 135 | - | let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id); |
|
| 136 | - | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 137 | - | let _ = clipboard.set_text(&link); |
|
| 138 | - | self.status_message = |
|
| 139 | - | Some(("Link copied!".to_string(), Instant::now())); |
|
| 140 | - | } |
|
| 141 | - | } |
|
| 142 | - | } |
|
| 143 | - | None => { |
|
| 144 | - | self.status_message = |
|
| 145 | - | Some(("No remote URL configured".to_string(), Instant::now())); |
|
| 146 | - | } |
|
| 147 | - | } |
|
| 148 | - | } |
|
| 149 | - | ||
| 150 | - | pub(super) fn open_in_browser(&mut self) { |
|
| 151 | - | match &self.remote_url { |
|
| 152 | - | Some(url) => { |
|
| 153 | - | if let Some(note) = self.selected_note() { |
|
| 154 | - | let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id); |
|
| 155 | - | if let Err(e) = open::that(&link) { |
|
| 156 | - | self.status_message = |
|
| 157 | - | Some((format!("Failed to open browser: {}", e), Instant::now())); |
|
| 158 | - | } else { |
|
| 159 | - | self.status_message = |
|
| 160 | - | Some(("Opened in browser!".to_string(), Instant::now())); |
|
| 161 | - | } |
|
| 162 | - | } |
|
| 163 | - | } |
|
| 164 | - | None => { |
|
| 165 | - | self.status_message = |
|
| 166 | - | Some(("No remote URL configured".to_string(), Instant::now())); |
|
| 167 | - | } |
|
| 168 | - | } |
|
| 169 | - | } |
|
| 170 | - | ||
| 171 | - | pub(super) fn delete_selected(&mut self, backend: &Backend) { |
|
| 172 | - | if let Some(selected_index) = self.list_state.selected() { |
|
| 173 | - | let real_index = if let Some(indices) = &self.filtered_indices { |
|
| 174 | - | match indices.get(selected_index) { |
|
| 175 | - | Some(&ri) => ri, |
|
| 176 | - | None => return, |
|
| 177 | - | } |
|
| 178 | - | } else { |
|
| 179 | - | selected_index |
|
| 180 | - | }; |
|
| 181 | - | if let Some(note) = self.notes.get(real_index) { |
|
| 182 | - | let short_id = note.short_id.clone(); |
|
| 183 | - | match backend.delete_note(&short_id) { |
|
| 184 | - | Ok(true) => { |
|
| 185 | - | self.notes.remove(real_index); |
|
| 186 | - | if self.filtered_indices.is_some() { |
|
| 187 | - | self.update_search_filter(); |
|
| 188 | - | } |
|
| 189 | - | let count = self.visible_count(); |
|
| 190 | - | if count == 0 { |
|
| 191 | - | self.list_state.select(None); |
|
| 192 | - | } else if selected_index >= count { |
|
| 193 | - | self.list_state.select(Some(count - 1)); |
|
| 194 | - | } else { |
|
| 195 | - | self.list_state.select(Some(selected_index)); |
|
| 196 | - | } |
|
| 197 | - | self.status_message = Some(("Deleted!".to_string(), Instant::now())); |
|
| 198 | - | } |
|
| 199 | - | Ok(false) => { |
|
| 200 | - | self.status_message = |
|
| 201 | - | Some(("Note not found".to_string(), Instant::now())); |
|
| 202 | - | } |
|
| 203 | - | Err(e) => { |
|
| 204 | - | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 205 | - | } |
|
| 206 | - | } |
|
| 207 | - | } |
|
| 208 | - | } |
|
| 209 | - | } |
|
| 210 | - | ||
| 211 | - | pub(super) fn refresh(&mut self, backend: &Backend) { |
|
| 212 | - | match backend.list_notes() { |
|
| 213 | - | Ok(notes) => { |
|
| 214 | - | self.notes = notes; |
|
| 215 | - | self.filtered_indices = None; |
|
| 216 | - | self.search_query.clear(); |
|
| 217 | - | if self.notes.is_empty() { |
|
| 218 | - | self.list_state.select(None); |
|
| 219 | - | } else { |
|
| 220 | - | let idx = self.list_state.selected().unwrap_or(0); |
|
| 221 | - | if idx >= self.notes.len() { |
|
| 222 | - | self.list_state.select(Some(self.notes.len() - 1)); |
|
| 223 | - | } |
|
| 224 | - | } |
|
| 225 | - | self.status_message = Some(("Refreshed!".to_string(), Instant::now())); |
|
| 226 | - | } |
|
| 227 | - | Err(e) => { |
|
| 228 | - | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 229 | - | } |
|
| 230 | - | } |
|
| 231 | - | } |
|
| 232 | - | ||
| 233 | - | pub(super) fn cursor_position_wrapped(&self, width: u16) -> (u16, u16) { |
|
| 234 | - | let w = width as usize; |
|
| 235 | - | if w == 0 { |
|
| 236 | - | return (0, 0); |
|
| 237 | - | } |
|
| 238 | - | let text = &self.edit_content; |
|
| 239 | - | let mut visual_row: usize = 0; |
|
| 240 | - | let lines: Vec<&str> = if text.is_empty() { |
|
| 241 | - | vec![""] |
|
| 242 | - | } else { |
|
| 243 | - | text.split('\n').collect() |
|
| 244 | - | }; |
|
| 245 | - | let last_idx = lines.len() - 1; |
|
| 246 | - | for (i, line) in lines.iter().enumerate() { |
|
| 247 | - | let line_len = line.len(); |
|
| 248 | - | let wrapped_lines = if line_len == 0 { |
|
| 249 | - | 1 |
|
| 250 | - | } else { |
|
| 251 | - | (line_len + w - 1) / w |
|
| 252 | - | }; |
|
| 253 | - | if i < last_idx { |
|
| 254 | - | visual_row += wrapped_lines; |
|
| 255 | - | } else { |
|
| 256 | - | let cursor_col = if text.ends_with('\n') { 0 } else { line_len }; |
|
| 257 | - | let extra_rows = cursor_col / w; |
|
| 258 | - | let col = cursor_col % w; |
|
| 259 | - | visual_row += extra_rows; |
|
| 260 | - | return (col as u16, visual_row as u16); |
|
| 261 | - | } |
|
| 262 | - | } |
|
| 263 | - | (0, visual_row as u16) |
|
| 264 | - | } |
|
| 265 | - | ||
| 266 | - | pub(super) fn auto_scroll_edit(&mut self, cursor_visual_row: u16, visible_height: u16) { |
|
| 267 | - | if visible_height == 0 { |
|
| 268 | - | return; |
|
| 269 | - | } |
|
| 270 | - | if cursor_visual_row < self.edit_scroll { |
|
| 271 | - | self.edit_scroll = cursor_visual_row; |
|
| 272 | - | } else if cursor_visual_row >= self.edit_scroll + visible_height { |
|
| 273 | - | self.edit_scroll = cursor_visual_row - visible_height + 1; |
|
| 274 | - | } |
|
| 275 | - | } |
|
| 276 | - | ||
| 277 | - | pub(super) fn start_create(&mut self) { |
|
| 278 | - | self.edit_title.clear(); |
|
| 279 | - | self.edit_content.clear(); |
|
| 280 | - | self.edit_scroll = 0; |
|
| 281 | - | self.focus = Focus::CreateTitle; |
|
| 282 | - | } |
|
| 283 | - | ||
| 284 | - | pub(super) fn save_create(&mut self, backend: &Backend) { |
|
| 285 | - | if self.edit_title.trim().is_empty() { |
|
| 286 | - | self.status_message = Some(("Title cannot be empty".to_string(), Instant::now())); |
|
| 287 | - | return; |
|
| 288 | - | } |
|
| 289 | - | match backend.create_note(&self.edit_title, &self.edit_content) { |
|
| 290 | - | Ok(note) => { |
|
| 291 | - | self.notes.insert(0, note); |
|
| 292 | - | self.list_state.select(Some(0)); |
|
| 293 | - | self.filtered_indices = None; |
|
| 294 | - | self.search_query.clear(); |
|
| 295 | - | self.status_message = Some(("Created!".to_string(), Instant::now())); |
|
| 296 | - | self.focus = Focus::List; |
|
| 297 | - | self.edit_title.clear(); |
|
| 298 | - | self.edit_content.clear(); |
|
| 299 | - | } |
|
| 300 | - | Err(e) => { |
|
| 301 | - | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 302 | - | } |
|
| 303 | - | } |
|
| 304 | - | } |
|
| 305 | - | ||
| 306 | - | pub(super) fn cancel_create(&mut self) { |
|
| 307 | - | self.edit_title.clear(); |
|
| 308 | - | self.edit_content.clear(); |
|
| 309 | - | self.focus = Focus::List; |
|
| 310 | - | } |
|
| 311 | - | ||
| 312 | - | pub(super) fn start_edit(&mut self) { |
|
| 313 | - | let data = self |
|
| 314 | - | .selected_note() |
|
| 315 | - | .map(|n| (n.title.clone(), n.content.clone(), n.short_id.clone())); |
|
| 316 | - | if let Some((title, content, short_id)) = data { |
|
| 317 | - | self.edit_title = title; |
|
| 318 | - | self.edit_content = content; |
|
| 319 | - | self.edit_short_id = Some(short_id); |
|
| 320 | - | self.edit_scroll = 0; |
|
| 321 | - | self.focus = Focus::EditTitle; |
|
| 322 | - | } |
|
| 323 | - | } |
|
| 324 | - | ||
| 325 | - | pub(super) fn save_edit(&mut self, backend: &Backend) { |
|
| 326 | - | if self.edit_title.trim().is_empty() { |
|
| 327 | - | self.status_message = Some(("Title cannot be empty".to_string(), Instant::now())); |
|
| 328 | - | return; |
|
| 329 | - | } |
|
| 330 | - | let short_id = match &self.edit_short_id { |
|
| 331 | - | Some(id) => id.clone(), |
|
| 332 | - | None => return, |
|
| 333 | - | }; |
|
| 334 | - | match backend.update_note(&short_id, &self.edit_title, &self.edit_content) { |
|
| 335 | - | Ok(Some(updated)) => { |
|
| 336 | - | if let Some(pos) = self.notes.iter().position(|n| n.short_id == short_id) { |
|
| 337 | - | self.notes[pos] = updated; |
|
| 338 | - | } |
|
| 339 | - | self.status_message = Some(("Updated!".to_string(), Instant::now())); |
|
| 340 | - | self.focus = Focus::List; |
|
| 341 | - | self.edit_title.clear(); |
|
| 342 | - | self.edit_content.clear(); |
|
| 343 | - | self.edit_short_id = None; |
|
| 344 | - | } |
|
| 345 | - | Ok(None) => { |
|
| 346 | - | self.status_message = Some(("Note not found".to_string(), Instant::now())); |
|
| 347 | - | } |
|
| 348 | - | Err(e) => { |
|
| 349 | - | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 350 | - | } |
|
| 351 | - | } |
|
| 352 | - | } |
|
| 353 | - | ||
| 354 | - | pub(super) fn cancel_edit(&mut self) { |
|
| 355 | - | self.edit_title.clear(); |
|
| 356 | - | self.edit_content.clear(); |
|
| 357 | - | self.edit_short_id = None; |
|
| 358 | - | self.focus = Focus::List; |
|
| 359 | - | } |
|
| 360 | - | ||
| 361 | - | pub(super) fn start_search(&mut self) { |
|
| 362 | - | self.search_query.clear(); |
|
| 363 | - | self.filtered_indices = Some((0..self.notes.len()).collect()); |
|
| 364 | - | self.focus = Focus::Search; |
|
| 365 | - | self.list_state |
|
| 366 | - | .select(if self.notes.is_empty() { None } else { Some(0) }); |
|
| 367 | - | } |
|
| 368 | - | ||
| 369 | - | pub(super) fn update_search_filter(&mut self) { |
|
| 370 | - | let query = self.search_query.to_lowercase(); |
|
| 371 | - | let indices: Vec<usize> = self |
|
| 372 | - | .notes |
|
| 373 | - | .iter() |
|
| 374 | - | .enumerate() |
|
| 375 | - | .filter(|(_, n)| n.title.to_lowercase().contains(&query)) |
|
| 376 | - | .map(|(i, _)| i) |
|
| 377 | - | .collect(); |
|
| 378 | - | self.filtered_indices = Some(indices); |
|
| 379 | - | if self.visible_count() == 0 { |
|
| 380 | - | self.list_state.select(None); |
|
| 381 | - | } else { |
|
| 382 | - | self.list_state.select(Some(0)); |
|
| 383 | - | } |
|
| 384 | - | } |
|
| 385 | - | ||
| 386 | - | pub(super) fn cancel_search(&mut self) { |
|
| 387 | - | self.filtered_indices = None; |
|
| 388 | - | self.search_query.clear(); |
|
| 389 | - | self.focus = Focus::List; |
|
| 390 | - | } |
|
| 391 | - | ||
| 392 | - | pub(super) fn confirm_search(&mut self) { |
|
| 393 | - | let real_index = self.list_state.selected().and_then(|i| { |
|
| 394 | - | self.filtered_indices |
|
| 395 | - | .as_ref() |
|
| 396 | - | .and_then(|indices| indices.get(i).copied()) |
|
| 397 | - | }); |
|
| 398 | - | self.filtered_indices = None; |
|
| 399 | - | self.search_query.clear(); |
|
| 400 | - | self.focus = Focus::List; |
|
| 401 | - | if let Some(ri) = real_index { |
|
| 402 | - | self.list_state.select(Some(ri)); |
|
| 403 | - | } |
|
| 404 | - | } |
|
| 405 | - | ||
| 406 | - | pub(super) fn clear_expired_status(&mut self) { |
|
| 407 | - | if let Some((_, time)) = &self.status_message { |
|
| 408 | - | if time.elapsed() > Duration::from_secs(2) { |
|
| 409 | - | self.status_message = None; |
|
| 410 | - | } |
|
| 411 | - | } |
|
| 412 | - | } |
|
| 413 | - | } |
| 1 | - | use super::app::App; |
|
| 2 | - | use crate::backend::Backend; |
|
| 3 | - | use ratatui::DefaultTerminal; |
|
| 4 | - | use std::time::Instant; |
|
| 5 | - | ||
| 6 | - | pub(super) fn edit_in_external_editor( |
|
| 7 | - | terminal: &mut DefaultTerminal, |
|
| 8 | - | app: &mut App, |
|
| 9 | - | backend: &Backend, |
|
| 10 | - | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 11 | - | let (short_id, title, content) = match app.selected_note() { |
|
| 12 | - | Some(n) => (n.short_id.clone(), n.title.clone(), n.content.clone()), |
|
| 13 | - | None => return Ok(()), |
|
| 14 | - | }; |
|
| 15 | - | ||
| 16 | - | let editor = match std::env::var("EDITOR") { |
|
| 17 | - | Ok(e) if !e.trim().is_empty() => e, |
|
| 18 | - | _ => { |
|
| 19 | - | app.status_message = Some(("EDITOR env not set".to_string(), Instant::now())); |
|
| 20 | - | return Ok(()); |
|
| 21 | - | } |
|
| 22 | - | }; |
|
| 23 | - | ||
| 24 | - | let mut path = std::env::temp_dir(); |
|
| 25 | - | path.push(format!("jotts-{}.md", short_id)); |
|
| 26 | - | std::fs::write(&path, &content)?; |
|
| 27 | - | ||
| 28 | - | ratatui::restore(); |
|
| 29 | - | ||
| 30 | - | let status = std::process::Command::new(&editor).arg(&path).status(); |
|
| 31 | - | ||
| 32 | - | *terminal = ratatui::init(); |
|
| 33 | - | terminal.clear()?; |
|
| 34 | - | ||
| 35 | - | match status { |
|
| 36 | - | Ok(s) if s.success() => { |
|
| 37 | - | let new_content = std::fs::read_to_string(&path)?; |
|
| 38 | - | let _ = std::fs::remove_file(&path); |
|
| 39 | - | if new_content == content { |
|
| 40 | - | app.status_message = Some(("No changes".to_string(), Instant::now())); |
|
| 41 | - | return Ok(()); |
|
| 42 | - | } |
|
| 43 | - | match backend.update_note(&short_id, &title, &new_content) { |
|
| 44 | - | Ok(Some(updated)) => { |
|
| 45 | - | if let Some(pos) = app.notes.iter().position(|n| n.short_id == short_id) { |
|
| 46 | - | app.notes[pos] = updated; |
|
| 47 | - | } |
|
| 48 | - | app.status_message = Some(("Updated!".to_string(), Instant::now())); |
|
| 49 | - | } |
|
| 50 | - | Ok(None) => { |
|
| 51 | - | app.status_message = Some(("Note not found".to_string(), Instant::now())); |
|
| 52 | - | } |
|
| 53 | - | Err(e) => { |
|
| 54 | - | app.status_message = Some((e.to_string(), Instant::now())); |
|
| 55 | - | } |
|
| 56 | - | } |
|
| 57 | - | } |
|
| 58 | - | Ok(_) => { |
|
| 59 | - | let _ = std::fs::remove_file(&path); |
|
| 60 | - | app.status_message = Some(("Editor exited non-zero".to_string(), Instant::now())); |
|
| 61 | - | } |
|
| 62 | - | Err(e) => { |
|
| 63 | - | let _ = std::fs::remove_file(&path); |
|
| 64 | - | app.status_message = |
|
| 65 | - | Some((format!("Failed to launch editor: {}", e), Instant::now())); |
|
| 66 | - | } |
|
| 67 | - | } |
|
| 68 | - | Ok(()) |
|
| 69 | - | } |
| 1 | - | use super::app::{App, Focus}; |
|
| 2 | - | use super::editor::edit_in_external_editor; |
|
| 3 | - | use crate::backend::Backend; |
|
| 4 | - | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
|
| 5 | - | use ratatui::DefaultTerminal; |
|
| 6 | - | ||
| 7 | - | pub(super) fn handle_key( |
|
| 8 | - | terminal: &mut DefaultTerminal, |
|
| 9 | - | app: &mut App, |
|
| 10 | - | backend: &Backend, |
|
| 11 | - | key: KeyEvent, |
|
| 12 | - | content_line_count: u16, |
|
| 13 | - | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 14 | - | if app.show_help { |
|
| 15 | - | app.show_help = false; |
|
| 16 | - | return Ok(()); |
|
| 17 | - | } |
|
| 18 | - | if app.status_message.is_some() { |
|
| 19 | - | app.status_message = None; |
|
| 20 | - | return Ok(()); |
|
| 21 | - | } |
|
| 22 | - | if app.confirm_delete { |
|
| 23 | - | if key.code == KeyCode::Char('y') { |
|
| 24 | - | app.delete_selected(backend); |
|
| 25 | - | } |
|
| 26 | - | app.confirm_delete = false; |
|
| 27 | - | return Ok(()); |
|
| 28 | - | } |
|
| 29 | - | ||
| 30 | - | match app.focus { |
|
| 31 | - | Focus::List => match key.code { |
|
| 32 | - | KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, |
|
| 33 | - | KeyCode::Char('j') | KeyCode::Down => app.move_down(), |
|
| 34 | - | KeyCode::Char('k') | KeyCode::Up => app.move_up(), |
|
| 35 | - | KeyCode::Char('y') => app.copy_selected(), |
|
| 36 | - | KeyCode::Char('Y') => app.copy_link(), |
|
| 37 | - | KeyCode::Char('d') => app.confirm_delete = true, |
|
| 38 | - | KeyCode::Char('c') => app.start_create(), |
|
| 39 | - | KeyCode::Char('e') => app.start_edit(), |
|
| 40 | - | KeyCode::Char('E') => edit_in_external_editor(terminal, app, backend)?, |
|
| 41 | - | KeyCode::Char('/') => app.start_search(), |
|
| 42 | - | KeyCode::Char('o') => app.open_in_browser(), |
|
| 43 | - | KeyCode::Char('r') if app.is_remote => app.refresh(backend), |
|
| 44 | - | KeyCode::Char('?') => app.show_help = true, |
|
| 45 | - | KeyCode::Enter | KeyCode::Char('l') => { |
|
| 46 | - | if app.selected_note().is_some() { |
|
| 47 | - | app.focus = Focus::Content; |
|
| 48 | - | } |
|
| 49 | - | } |
|
| 50 | - | _ => {} |
|
| 51 | - | }, |
|
| 52 | - | Focus::Content => match key.code { |
|
| 53 | - | KeyCode::Char(' ') |
|
| 54 | - | | KeyCode::Esc |
|
| 55 | - | | KeyCode::Char('q') |
|
| 56 | - | | KeyCode::Char('h') => { |
|
| 57 | - | app.focus = Focus::List; |
|
| 58 | - | } |
|
| 59 | - | KeyCode::Char('j') | KeyCode::Down => app.scroll_down(content_line_count), |
|
| 60 | - | KeyCode::Char('k') | KeyCode::Up => app.scroll_up(), |
|
| 61 | - | KeyCode::Char('y') => app.copy_selected(), |
|
| 62 | - | KeyCode::Char('Y') => app.copy_link(), |
|
| 63 | - | KeyCode::Char('e') => app.start_edit(), |
|
| 64 | - | KeyCode::Char('E') => edit_in_external_editor(terminal, app, backend)?, |
|
| 65 | - | KeyCode::Char('o') => app.open_in_browser(), |
|
| 66 | - | KeyCode::Char('?') => app.show_help = true, |
|
| 67 | - | _ => {} |
|
| 68 | - | }, |
|
| 69 | - | Focus::CreateTitle => { |
|
| 70 | - | if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { |
|
| 71 | - | app.save_create(backend); |
|
| 72 | - | } else { |
|
| 73 | - | match key.code { |
|
| 74 | - | KeyCode::Esc => app.cancel_create(), |
|
| 75 | - | KeyCode::Enter | KeyCode::Tab => app.focus = Focus::CreateContent, |
|
| 76 | - | KeyCode::Backspace => { |
|
| 77 | - | app.edit_title.pop(); |
|
| 78 | - | } |
|
| 79 | - | KeyCode::Char(c) => app.edit_title.push(c), |
|
| 80 | - | _ => {} |
|
| 81 | - | } |
|
| 82 | - | } |
|
| 83 | - | } |
|
| 84 | - | Focus::CreateContent => { |
|
| 85 | - | if key.modifiers.contains(KeyModifiers::CONTROL) { |
|
| 86 | - | match key.code { |
|
| 87 | - | KeyCode::Char('s') => app.save_create(backend), |
|
| 88 | - | KeyCode::Char('w') => { |
|
| 89 | - | app.wrap_content = !app.wrap_content; |
|
| 90 | - | app.edit_scroll = 0; |
|
| 91 | - | } |
|
| 92 | - | _ => {} |
|
| 93 | - | } |
|
| 94 | - | } else { |
|
| 95 | - | match key.code { |
|
| 96 | - | KeyCode::Esc => app.cancel_create(), |
|
| 97 | - | KeyCode::Tab => app.focus = Focus::CreateTitle, |
|
| 98 | - | KeyCode::Enter => app.edit_content.push('\n'), |
|
| 99 | - | KeyCode::Backspace => { |
|
| 100 | - | app.edit_content.pop(); |
|
| 101 | - | } |
|
| 102 | - | KeyCode::Char(c) => app.edit_content.push(c), |
|
| 103 | - | _ => {} |
|
| 104 | - | } |
|
| 105 | - | } |
|
| 106 | - | } |
|
| 107 | - | Focus::EditTitle => { |
|
| 108 | - | if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { |
|
| 109 | - | app.save_edit(backend); |
|
| 110 | - | } else { |
|
| 111 | - | match key.code { |
|
| 112 | - | KeyCode::Esc => app.cancel_edit(), |
|
| 113 | - | KeyCode::Enter | KeyCode::Tab => app.focus = Focus::EditContent, |
|
| 114 | - | KeyCode::Backspace => { |
|
| 115 | - | app.edit_title.pop(); |
|
| 116 | - | } |
|
| 117 | - | KeyCode::Char(c) => app.edit_title.push(c), |
|
| 118 | - | _ => {} |
|
| 119 | - | } |
|
| 120 | - | } |
|
| 121 | - | } |
|
| 122 | - | Focus::EditContent => { |
|
| 123 | - | if key.modifiers.contains(KeyModifiers::CONTROL) { |
|
| 124 | - | match key.code { |
|
| 125 | - | KeyCode::Char('s') => app.save_edit(backend), |
|
| 126 | - | KeyCode::Char('w') => { |
|
| 127 | - | app.wrap_content = !app.wrap_content; |
|
| 128 | - | app.edit_scroll = 0; |
|
| 129 | - | } |
|
| 130 | - | _ => {} |
|
| 131 | - | } |
|
| 132 | - | } else { |
|
| 133 | - | match key.code { |
|
| 134 | - | KeyCode::Esc => app.cancel_edit(), |
|
| 135 | - | KeyCode::Tab => app.focus = Focus::EditTitle, |
|
| 136 | - | KeyCode::Enter => app.edit_content.push('\n'), |
|
| 137 | - | KeyCode::Backspace => { |
|
| 138 | - | app.edit_content.pop(); |
|
| 139 | - | } |
|
| 140 | - | KeyCode::Char(c) => app.edit_content.push(c), |
|
| 141 | - | _ => {} |
|
| 142 | - | } |
|
| 143 | - | } |
|
| 144 | - | } |
|
| 145 | - | Focus::Search => match key.code { |
|
| 146 | - | KeyCode::Esc => app.cancel_search(), |
|
| 147 | - | KeyCode::Enter => app.confirm_search(), |
|
| 148 | - | KeyCode::Backspace => { |
|
| 149 | - | app.search_query.pop(); |
|
| 150 | - | app.update_search_filter(); |
|
| 151 | - | } |
|
| 152 | - | KeyCode::Char(c) => { |
|
| 153 | - | app.search_query.push(c); |
|
| 154 | - | app.update_search_filter(); |
|
| 155 | - | } |
|
| 156 | - | _ => {} |
|
| 157 | - | }, |
|
| 158 | - | } |
|
| 159 | - | Ok(()) |
|
| 160 | - | } |
| 1 | - | use super::app::{App, Focus}; |
|
| 2 | - | use ratatui::{ |
|
| 3 | - | Frame, |
|
| 4 | - | layout::{Alignment, Constraint, Layout}, |
|
| 5 | - | style::{Color, Modifier, Style}, |
|
| 6 | - | text::{Line, Span, Text}, |
|
| 7 | - | widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Widget, Wrap}, |
|
| 8 | - | }; |
|
| 9 | - | ||
| 10 | - | pub(super) fn draw(frame: &mut Frame, app: &mut App) { |
|
| 11 | - | let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(frame.area()); |
|
| 12 | - | ||
| 13 | - | let chunks = |
|
| 14 | - | Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]).split(outer[0]); |
|
| 15 | - | ||
| 16 | - | let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices { |
|
| 17 | - | indices |
|
| 18 | - | .iter() |
|
| 19 | - | .filter_map(|&i| app.notes.get(i)) |
|
| 20 | - | .map(|n| ListItem::new(n.title.as_str())) |
|
| 21 | - | .collect() |
|
| 22 | - | } else { |
|
| 23 | - | app.notes |
|
| 24 | - | .iter() |
|
| 25 | - | .map(|n| ListItem::new(n.title.as_str())) |
|
| 26 | - | .collect() |
|
| 27 | - | }; |
|
| 28 | - | ||
| 29 | - | let list_border_style = match app.focus { |
|
| 30 | - | Focus::List | Focus::Search => Style::default().fg(Color::Yellow), |
|
| 31 | - | _ => Style::default().fg(Color::DarkGray), |
|
| 32 | - | }; |
|
| 33 | - | let content_border_style = match app.focus { |
|
| 34 | - | Focus::Content => Style::default().fg(Color::Yellow), |
|
| 35 | - | _ => Style::default().fg(Color::DarkGray), |
|
| 36 | - | }; |
|
| 37 | - | ||
| 38 | - | let list = List::new(items) |
|
| 39 | - | .block( |
|
| 40 | - | Block::default() |
|
| 41 | - | .title(" Notes ") |
|
| 42 | - | .borders(Borders::ALL) |
|
| 43 | - | .border_style(list_border_style), |
|
| 44 | - | ) |
|
| 45 | - | .highlight_style( |
|
| 46 | - | Style::default() |
|
| 47 | - | .fg(Color::Yellow) |
|
| 48 | - | .add_modifier(Modifier::BOLD), |
|
| 49 | - | ) |
|
| 50 | - | .highlight_symbol("▶ "); |
|
| 51 | - | ||
| 52 | - | if matches!(app.focus, Focus::Search) { |
|
| 53 | - | let search_split = |
|
| 54 | - | Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(chunks[0]); |
|
| 55 | - | ||
| 56 | - | let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices { |
|
| 57 | - | indices |
|
| 58 | - | .iter() |
|
| 59 | - | .filter_map(|&i| app.notes.get(i)) |
|
| 60 | - | .map(|n| ListItem::new(n.title.as_str())) |
|
| 61 | - | .collect() |
|
| 62 | - | } else { |
|
| 63 | - | app.notes |
|
| 64 | - | .iter() |
|
| 65 | - | .map(|n| ListItem::new(n.title.as_str())) |
|
| 66 | - | .collect() |
|
| 67 | - | }; |
|
| 68 | - | let search_list = List::new(search_items) |
|
| 69 | - | .block( |
|
| 70 | - | Block::default() |
|
| 71 | - | .title(" Notes ") |
|
| 72 | - | .borders(Borders::ALL) |
|
| 73 | - | .border_style(list_border_style), |
|
| 74 | - | ) |
|
| 75 | - | .highlight_style( |
|
| 76 | - | Style::default() |
|
| 77 | - | .fg(Color::Yellow) |
|
| 78 | - | .add_modifier(Modifier::BOLD), |
|
| 79 | - | ) |
|
| 80 | - | .highlight_symbol("▶ "); |
|
| 81 | - | frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state); |
|
| 82 | - | ||
| 83 | - | let search_input = Paragraph::new(app.search_query.as_str()).block( |
|
| 84 | - | Block::default() |
|
| 85 | - | .title(" Search ") |
|
| 86 | - | .borders(Borders::ALL) |
|
| 87 | - | .border_style(Style::default().fg(Color::Yellow)), |
|
| 88 | - | ); |
|
| 89 | - | frame.render_widget(search_input, search_split[1]); |
|
| 90 | - | ||
| 91 | - | let x = search_split[1].x + 1 + app.search_query.len() as u16; |
|
| 92 | - | let y = search_split[1].y + 1; |
|
| 93 | - | frame.set_cursor_position((x, y)); |
|
| 94 | - | } else { |
|
| 95 | - | frame.render_stateful_widget(list, chunks[0], &mut app.list_state); |
|
| 96 | - | } |
|
| 97 | - | ||
| 98 | - | match app.focus { |
|
| 99 | - | Focus::CreateTitle | Focus::CreateContent | Focus::EditTitle | Focus::EditContent => { |
|
| 100 | - | let form_title = match app.focus { |
|
| 101 | - | Focus::EditTitle | Focus::EditContent => " Edit Note ", |
|
| 102 | - | _ => " New Note ", |
|
| 103 | - | }; |
|
| 104 | - | let create_block = Block::default() |
|
| 105 | - | .title(form_title) |
|
| 106 | - | .borders(Borders::ALL) |
|
| 107 | - | .border_style(Style::default().fg(Color::Yellow)); |
|
| 108 | - | ||
| 109 | - | let inner = create_block.inner(chunks[1]); |
|
| 110 | - | frame.render_widget(create_block, chunks[1]); |
|
| 111 | - | ||
| 112 | - | let form_layout = |
|
| 113 | - | Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(inner); |
|
| 114 | - | ||
| 115 | - | let title_style = match app.focus { |
|
| 116 | - | Focus::CreateTitle | Focus::EditTitle => Style::default().fg(Color::Yellow), |
|
| 117 | - | _ => Style::default().fg(Color::DarkGray), |
|
| 118 | - | }; |
|
| 119 | - | let title_input = Paragraph::new(app.edit_title.as_str()).block( |
|
| 120 | - | Block::default() |
|
| 121 | - | .title(" Title ") |
|
| 122 | - | .borders(Borders::ALL) |
|
| 123 | - | .border_style(title_style), |
|
| 124 | - | ); |
|
| 125 | - | frame.render_widget(title_input, form_layout[0]); |
|
| 126 | - | ||
| 127 | - | let content_style = match app.focus { |
|
| 128 | - | Focus::CreateContent | Focus::EditContent => Style::default().fg(Color::Yellow), |
|
| 129 | - | _ => Style::default().fg(Color::DarkGray), |
|
| 130 | - | }; |
|
| 131 | - | let mut content_input = Paragraph::new(app.edit_content.as_str()).block( |
|
| 132 | - | Block::default() |
|
| 133 | - | .title(" Content ") |
|
| 134 | - | .borders(Borders::ALL) |
|
| 135 | - | .border_style(content_style), |
|
| 136 | - | ); |
|
| 137 | - | if app.wrap_content { |
|
| 138 | - | content_input = content_input.wrap(Wrap { trim: false }); |
|
| 139 | - | } |
|
| 140 | - | content_input = content_input.scroll((app.edit_scroll, 0)); |
|
| 141 | - | frame.render_widget(content_input, form_layout[1]); |
|
| 142 | - | ||
| 143 | - | let content_inner = Block::default().borders(Borders::ALL).inner(form_layout[1]); |
|
| 144 | - | let inner_width = content_inner.width; |
|
| 145 | - | let inner_height = content_inner.height; |
|
| 146 | - | ||
| 147 | - | match app.focus { |
|
| 148 | - | Focus::CreateTitle | Focus::EditTitle => { |
|
| 149 | - | let x = form_layout[0].x + 1 + app.edit_title.len() as u16; |
|
| 150 | - | let y = form_layout[0].y + 1; |
|
| 151 | - | frame.set_cursor_position((x, y)); |
|
| 152 | - | } |
|
| 153 | - | Focus::CreateContent | Focus::EditContent => { |
|
| 154 | - | let (cx, cy) = if app.wrap_content { |
|
| 155 | - | app.cursor_position_wrapped(inner_width) |
|
| 156 | - | } else { |
|
| 157 | - | let last_line = app.edit_content.lines().last().unwrap_or(""); |
|
| 158 | - | let line_count = app.edit_content.lines().count() |
|
| 159 | - | + if app.edit_content.ends_with('\n') { 1 } else { 0 }; |
|
| 160 | - | let y_offset = if line_count == 0 { 0 } else { line_count - 1 }; |
|
| 161 | - | let col = if app.edit_content.ends_with('\n') { |
|
| 162 | - | 0 |
|
| 163 | - | } else { |
|
| 164 | - | last_line.len() as u16 |
|
| 165 | - | }; |
|
| 166 | - | (col, y_offset as u16) |
|
| 167 | - | }; |
|
| 168 | - | app.auto_scroll_edit(cy, inner_height); |
|
| 169 | - | let screen_y = cy.saturating_sub(app.edit_scroll); |
|
| 170 | - | let x = content_inner.x + cx; |
|
| 171 | - | let y = content_inner.y + screen_y; |
|
| 172 | - | frame.set_cursor_position((x, y)); |
|
| 173 | - | } |
|
| 174 | - | _ => {} |
|
| 175 | - | } |
|
| 176 | - | } |
|
| 177 | - | _ => { |
|
| 178 | - | let highlighted = match app.selected_note() { |
|
| 179 | - | Some(n) => app.highlighter.highlight_markdown(&n.content), |
|
| 180 | - | None => Text::raw(""), |
|
| 181 | - | }; |
|
| 182 | - | ||
| 183 | - | let paragraph = Paragraph::new(highlighted) |
|
| 184 | - | .block( |
|
| 185 | - | Block::default() |
|
| 186 | - | .title(" Content ") |
|
| 187 | - | .borders(Borders::ALL) |
|
| 188 | - | .border_style(content_border_style), |
|
| 189 | - | ) |
|
| 190 | - | .scroll((app.content_scroll, 0)); |
|
| 191 | - | ||
| 192 | - | frame.render_widget(paragraph, chunks[1]); |
|
| 193 | - | } |
|
| 194 | - | } |
|
| 195 | - | ||
| 196 | - | let hints = match app.focus { |
|
| 197 | - | Focus::List => Line::from(vec![ |
|
| 198 | - | Span::styled("j/k", Style::default().fg(Color::Yellow)), |
|
| 199 | - | Span::raw(": Navigate "), |
|
| 200 | - | Span::styled("Enter", Style::default().fg(Color::Yellow)), |
|
| 201 | - | Span::raw(": View "), |
|
| 202 | - | Span::styled("y", Style::default().fg(Color::Yellow)), |
|
| 203 | - | Span::raw(": Copy "), |
|
| 204 | - | Span::styled("e", Style::default().fg(Color::Yellow)), |
|
| 205 | - | Span::raw(": Edit "), |
|
| 206 | - | Span::styled("d", Style::default().fg(Color::Yellow)), |
|
| 207 | - | Span::raw(": Delete "), |
|
| 208 | - | Span::styled("c", Style::default().fg(Color::Yellow)), |
|
| 209 | - | Span::raw(": Create "), |
|
| 210 | - | Span::styled("/", Style::default().fg(Color::Yellow)), |
|
| 211 | - | Span::raw(": Search "), |
|
| 212 | - | Span::styled("?", Style::default().fg(Color::Yellow)), |
|
| 213 | - | Span::raw(": Help "), |
|
| 214 | - | Span::styled("q", Style::default().fg(Color::Yellow)), |
|
| 215 | - | Span::raw(": Quit"), |
|
| 216 | - | ]), |
|
| 217 | - | Focus::Content => Line::from(vec![ |
|
| 218 | - | Span::styled("j/k", Style::default().fg(Color::Yellow)), |
|
| 219 | - | Span::raw(": Scroll "), |
|
| 220 | - | Span::styled("y", Style::default().fg(Color::Yellow)), |
|
| 221 | - | Span::raw(": Copy "), |
|
| 222 | - | Span::styled("e", Style::default().fg(Color::Yellow)), |
|
| 223 | - | Span::raw(": Edit "), |
|
| 224 | - | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 225 | - | Span::raw(": Back "), |
|
| 226 | - | Span::styled("?", Style::default().fg(Color::Yellow)), |
|
| 227 | - | Span::raw(": Help"), |
|
| 228 | - | ]), |
|
| 229 | - | Focus::CreateTitle | Focus::CreateContent | Focus::EditTitle | Focus::EditContent => { |
|
| 230 | - | Line::from(vec![ |
|
| 231 | - | Span::styled("Tab", Style::default().fg(Color::Yellow)), |
|
| 232 | - | Span::raw(": Switch field "), |
|
| 233 | - | Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)), |
|
| 234 | - | Span::raw(": Save "), |
|
| 235 | - | Span::styled("Ctrl+W", Style::default().fg(Color::Yellow)), |
|
| 236 | - | Span::raw(": Wrap "), |
|
| 237 | - | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 238 | - | Span::raw(": Cancel"), |
|
| 239 | - | ]) |
|
| 240 | - | } |
|
| 241 | - | Focus::Search => Line::from(vec![ |
|
| 242 | - | Span::styled("Type", Style::default().fg(Color::Yellow)), |
|
| 243 | - | Span::raw(": Filter "), |
|
| 244 | - | Span::styled("Enter", Style::default().fg(Color::Yellow)), |
|
| 245 | - | Span::raw(": Select "), |
|
| 246 | - | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 247 | - | Span::raw(": Cancel"), |
|
| 248 | - | ]), |
|
| 249 | - | }; |
|
| 250 | - | frame.render_widget(Paragraph::new(hints), outer[1]); |
|
| 251 | - | ||
| 252 | - | if let Some((msg, _)) = &app.status_message { |
|
| 253 | - | let area = frame.area(); |
|
| 254 | - | let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4)); |
|
| 255 | - | let popup_area = ratatui::layout::Rect { |
|
| 256 | - | x: (area.width.saturating_sub(msg_width)) / 2, |
|
| 257 | - | y: (area.height.saturating_sub(3)) / 2, |
|
| 258 | - | width: msg_width, |
|
| 259 | - | height: 3, |
|
| 260 | - | }; |
|
| 261 | - | Clear.render(popup_area, frame.buffer_mut()); |
|
| 262 | - | let status_popup = Paragraph::new(Line::from(msg.as_str())) |
|
| 263 | - | .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) |
|
| 264 | - | .alignment(Alignment::Center) |
|
| 265 | - | .block( |
|
| 266 | - | Block::default() |
|
| 267 | - | .borders(Borders::ALL) |
|
| 268 | - | .border_style(Style::default().fg(Color::Green)), |
|
| 269 | - | ); |
|
| 270 | - | frame.render_widget(status_popup, popup_area); |
|
| 271 | - | } |
|
| 272 | - | ||
| 273 | - | if app.confirm_delete { |
|
| 274 | - | let delete_msg = match app.selected_note() { |
|
| 275 | - | Some(n) => format!("Delete {}? (y/n)", n.title), |
|
| 276 | - | None => "Delete note? (y/n)".to_string(), |
|
| 277 | - | }; |
|
| 278 | - | let area = frame.area(); |
|
| 279 | - | let msg_width = (delete_msg.len() as u16 + 4).max(24).min(area.width.saturating_sub(4)); |
|
| 280 | - | let popup_area = ratatui::layout::Rect { |
|
| 281 | - | x: (area.width.saturating_sub(msg_width)) / 2, |
|
| 282 | - | y: (area.height.saturating_sub(3)) / 2, |
|
| 283 | - | width: msg_width, |
|
| 284 | - | height: 3, |
|
| 285 | - | }; |
|
| 286 | - | Clear.render(popup_area, frame.buffer_mut()); |
|
| 287 | - | let confirm_popup = Paragraph::new(Line::from(delete_msg)) |
|
| 288 | - | .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) |
|
| 289 | - | .alignment(Alignment::Center) |
|
| 290 | - | .block( |
|
| 291 | - | Block::default() |
|
| 292 | - | .borders(Borders::ALL) |
|
| 293 | - | .border_style(Style::default().fg(Color::Red)), |
|
| 294 | - | ); |
|
| 295 | - | frame.render_widget(confirm_popup, popup_area); |
|
| 296 | - | } |
|
| 297 | - | ||
| 298 | - | if app.show_help { |
|
| 299 | - | let area = frame.area(); |
|
| 300 | - | let popup_width = 34u16.min(area.width.saturating_sub(4)); |
|
| 301 | - | let popup_height = 21u16.min(area.height.saturating_sub(4)); |
|
| 302 | - | let popup_area = ratatui::layout::Rect { |
|
| 303 | - | x: (area.width.saturating_sub(popup_width)) / 2, |
|
| 304 | - | y: (area.height.saturating_sub(popup_height)) / 2, |
|
| 305 | - | width: popup_width, |
|
| 306 | - | height: popup_height, |
|
| 307 | - | }; |
|
| 308 | - | ||
| 309 | - | let mut help_lines = vec![ |
|
| 310 | - | Line::from(""), |
|
| 311 | - | Line::from(vec![ |
|
| 312 | - | Span::styled(" j/↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 313 | - | Span::raw("Move down / Scroll down"), |
|
| 314 | - | ]), |
|
| 315 | - | Line::from(vec![ |
|
| 316 | - | Span::styled(" k/↑ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 317 | - | Span::raw("Move up / Scroll up"), |
|
| 318 | - | ]), |
|
| 319 | - | Line::from(vec![ |
|
| 320 | - | Span::styled(" Enter", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 321 | - | Span::raw(" Focus content pane"), |
|
| 322 | - | ]), |
|
| 323 | - | Line::from(vec![ |
|
| 324 | - | Span::styled(" Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 325 | - | Span::raw("Back / Quit"), |
|
| 326 | - | ]), |
|
| 327 | - | Line::from(vec![ |
|
| 328 | - | Span::styled(" y ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 329 | - | Span::raw("Copy note"), |
|
| 330 | - | ]), |
|
| 331 | - | Line::from(vec![ |
|
| 332 | - | Span::styled(" Y ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 333 | - | Span::raw("Copy link"), |
|
| 334 | - | ]), |
|
| 335 | - | Line::from(vec![ |
|
| 336 | - | Span::styled(" o ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 337 | - | Span::raw("Open in browser"), |
|
| 338 | - | ]), |
|
| 339 | - | Line::from(vec![ |
|
| 340 | - | Span::styled(" d ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 341 | - | Span::raw("Delete note"), |
|
| 342 | - | ]), |
|
| 343 | - | Line::from(vec![ |
|
| 344 | - | Span::styled(" c ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 345 | - | Span::raw("Create note"), |
|
| 346 | - | ]), |
|
| 347 | - | Line::from(vec![ |
|
| 348 | - | Span::styled(" e ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 349 | - | Span::raw("Edit note"), |
|
| 350 | - | ]), |
|
| 351 | - | Line::from(vec![ |
|
| 352 | - | Span::styled(" E ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 353 | - | Span::raw("Edit in $EDITOR"), |
|
| 354 | - | ]), |
|
| 355 | - | Line::from(vec![ |
|
| 356 | - | Span::styled(" / ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 357 | - | Span::raw("Search notes"), |
|
| 358 | - | ]), |
|
| 359 | - | Line::from(vec![ |
|
| 360 | - | Span::styled(" ^W ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 361 | - | Span::raw("Toggle word wrap (edit)"), |
|
| 362 | - | ]), |
|
| 363 | - | ]; |
|
| 364 | - | ||
| 365 | - | if app.is_remote { |
|
| 366 | - | help_lines.push(Line::from(vec![ |
|
| 367 | - | Span::styled(" r ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 368 | - | Span::raw("Refresh notes"), |
|
| 369 | - | ])); |
|
| 370 | - | } |
|
| 371 | - | ||
| 372 | - | help_lines.extend([ |
|
| 373 | - | Line::from(vec![ |
|
| 374 | - | Span::styled(" q ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 375 | - | Span::raw("Quit"), |
|
| 376 | - | ]), |
|
| 377 | - | Line::from(vec![ |
|
| 378 | - | Span::styled(" ? ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 379 | - | Span::raw("Toggle this help"), |
|
| 380 | - | ]), |
|
| 381 | - | Line::from(""), |
|
| 382 | - | Line::from(Span::styled( |
|
| 383 | - | " Press any key to close", |
|
| 384 | - | Style::default().fg(Color::DarkGray), |
|
| 385 | - | )), |
|
| 386 | - | ]); |
|
| 387 | - | ||
| 388 | - | let help_text = Text::from(help_lines); |
|
| 389 | - | ||
| 390 | - | Clear.render(popup_area, frame.buffer_mut()); |
|
| 391 | - | let help = Paragraph::new(help_text).block( |
|
| 392 | - | Block::default() |
|
| 393 | - | .title(" Keybindings ") |
|
| 394 | - | .borders(Borders::ALL) |
|
| 395 | - | .border_style(Style::default().fg(Color::Yellow)), |
|
| 396 | - | ); |
|
| 397 | - | frame.render_widget(help, popup_area); |
|
| 398 | - | } |
|
| 399 | - | } |
Binary file — no preview.
Binary file — no preview.
| 1 | - | <!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>{% block title %}Jotts{% endblock %}</title> |
|
| 7 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | - | <meta property="og:title" content="Jotts"> |
|
| 13 | - | <meta property="og:image" content="/static/og.png"> |
|
| 14 | - | <meta property="og:type" content="website"> |
|
| 15 | - | <meta name="theme-color" content="#121113" /> |
|
| 16 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 17 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | - | </head> |
|
| 19 | - | <body> |
|
| 20 | - | <header class="header"> |
|
| 21 | - | <a href="/" class="logo">jotts</a> |
|
| 22 | - | <nav class="links"> |
|
| 23 | - | <a href="/notes/new">new</a> |
|
| 24 | - | </nav> |
|
| 25 | - | </header> |
|
| 26 | - | <main> |
|
| 27 | - | {% block content %}{% endblock %} |
|
| 28 | - | </main> |
|
| 29 | - | <script> |
|
| 30 | - | document.querySelectorAll("time.note-date").forEach(el => { |
|
| 31 | - | const d = new Date(el.getAttribute("datetime")); |
|
| 32 | - | if (!isNaN(d)) { |
|
| 33 | - | el.textContent = d.toLocaleString(); |
|
| 34 | - | } |
|
| 35 | - | }); |
|
| 36 | - | </script> |
|
| 37 | - | </body> |
|
| 38 | - | </html> |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Jotts — {{ note.title }}{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | {% if let Some(error) = error %} |
|
| 5 | - | <p class="error">{{ error }}</p> |
|
| 6 | - | {% endif %} |
|
| 7 | - | <form method="POST" action="/notes/{{ note.short_id }}" class="form"> |
|
| 8 | - | <label for="title">title</label> |
|
| 9 | - | <input type="text" id="title" name="title" value="{{ note.title }}" required> |
|
| 10 | - | <label for="content">content</label> |
|
| 11 | - | <textarea id="content" name="content">{{ note.content }}</textarea> |
|
| 12 | - | <button type="submit">save</button> |
|
| 13 | - | </form> |
|
| 14 | - | {% endblock %} |
|
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <title>Jotts — {{.Note.Title}}</title> |
|
| 7 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | + | <meta name="theme-color" content="#121113" /> |
|
| 13 | + | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 14 | + | <link rel="stylesheet" href="/static/styles.css"> |
|
| 15 | + | </head> |
|
| 16 | + | <body> |
|
| 17 | + | <header class="header"> |
|
| 18 | + | <a href="/" class="logo">jotts</a> |
|
| 19 | + | <nav class="links"><a href="/notes/new">new</a></nav> |
|
| 20 | + | </header> |
|
| 21 | + | <main> |
|
| 22 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 23 | + | <form method="POST" action="/notes/{{.Note.ShortID}}" class="form"> |
|
| 24 | + | <label for="title">title</label> |
|
| 25 | + | <input type="text" id="title" name="title" value="{{.Note.Title}}" required> |
|
| 26 | + | <label for="content">content</label> |
|
| 27 | + | <textarea id="content" name="content">{{.Note.Content}}</textarea> |
|
| 28 | + | <button type="submit">save</button> |
|
| 29 | + | </form> |
|
| 30 | + | </main> |
|
| 31 | + | </body> |
|
| 32 | + | </html> |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Jotts{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | {% if notes.is_empty() %} |
|
| 5 | - | <p class="empty">no notes yet</p> |
|
| 6 | - | {% endif %} |
|
| 7 | - | <div class="note-list"> |
|
| 8 | - | {% for note in notes %} |
|
| 9 | - | <a href="/notes/{{ note.short_id }}" class="note-item"> |
|
| 10 | - | <span class="note-title">{{ note.title }}</span> |
|
| 11 | - | <time class="note-date" datetime="{{ note.updated_at }}Z">{{ note.updated_at }}</time> |
|
| 12 | - | </a> |
|
| 13 | - | {% endfor %} |
|
| 14 | - | </div> |
|
| 15 | - | {% endblock %} |
|
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <title>Jotts</title> |
|
| 7 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | + | <meta property="og:title" content="Jotts"> |
|
| 13 | + | <meta property="og:image" content="/static/og.png"> |
|
| 14 | + | <meta property="og:type" content="website"> |
|
| 15 | + | <meta name="theme-color" content="#121113" /> |
|
| 16 | + | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 17 | + | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | + | </head> |
|
| 19 | + | <body> |
|
| 20 | + | <header class="header"> |
|
| 21 | + | <a href="/" class="logo">jotts</a> |
|
| 22 | + | <nav class="links"> |
|
| 23 | + | <a href="/notes/new">new</a> |
|
| 24 | + | </nav> |
|
| 25 | + | </header> |
|
| 26 | + | <main> |
|
| 27 | + | {{if not .Notes}}<p class="empty">no notes yet</p>{{end}} |
|
| 28 | + | <div class="note-list"> |
|
| 29 | + | {{range .Notes}} |
|
| 30 | + | <a href="/notes/{{.ShortID}}" class="note-item"> |
|
| 31 | + | <span class="note-title">{{.Title}}</span> |
|
| 32 | + | <time class="note-date" datetime="{{.UpdatedAt}}Z">{{.UpdatedAt}}</time> |
|
| 33 | + | </a> |
|
| 34 | + | {{end}} |
|
| 35 | + | </div> |
|
| 36 | + | </main> |
|
| 37 | + | <script> |
|
| 38 | + | document.querySelectorAll("time.note-date").forEach(el => { |
|
| 39 | + | const d = new Date(el.getAttribute("datetime")); |
|
| 40 | + | if (!isNaN(d)) { el.textContent = d.toLocaleString(); } |
|
| 41 | + | }); |
|
| 42 | + | </script> |
|
| 43 | + | </body> |
|
| 44 | + | </html> |
| 1 | - | <!DOCTYPE html> |
|
| 1 | + | <!doctype html> |
|
| 2 | 2 | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>Jotts</title> |
|
| 7 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | - | <meta property="og:title" content="Jotts"> |
|
| 13 | - | <meta property="og:image" content="/static/og.png"> |
|
| 14 | - | <meta property="og:type" content="website"> |
|
| 15 | - | <meta name="theme-color" content="#121113" /> |
|
| 16 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 17 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | - | </head> |
|
| 19 | - | <body> |
|
| 20 | - | <header class="header"> |
|
| 21 | - | <span class="logo">JOTTS</span> |
|
| 22 | - | </header> |
|
| 23 | - | <main> |
|
| 24 | - | {% if let Some(error) = error %} |
|
| 25 | - | <p class="error">{{ error }}</p> |
|
| 26 | - | {% endif %} |
|
| 27 | - | <form method="POST" action="/login" class="form"> |
|
| 28 | - | <label for="password">password</label> |
|
| 29 | - | <input type="password" id="password" name="password" autofocus required> |
|
| 30 | - | <button type="submit">login</button> |
|
| 31 | - | </form> |
|
| 32 | - | </main> |
|
| 33 | - | </body> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <title>Jotts</title> |
|
| 7 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | + | <meta property="og:title" content="Jotts"> |
|
| 13 | + | <meta property="og:image" content="/static/og.png"> |
|
| 14 | + | <meta property="og:type" content="website"> |
|
| 15 | + | <meta name="theme-color" content="#121113" /> |
|
| 16 | + | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 17 | + | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | + | </head> |
|
| 19 | + | <body> |
|
| 20 | + | <header class="header"> |
|
| 21 | + | <span class="logo">JOTTS</span> |
|
| 22 | + | </header> |
|
| 23 | + | <main> |
|
| 24 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 25 | + | <form method="POST" action="/login" class="form"> |
|
| 26 | + | <label for="password">password</label> |
|
| 27 | + | <input type="password" id="password" name="password" autofocus required> |
|
| 28 | + | <button type="submit">login</button> |
|
| 29 | + | </form> |
|
| 30 | + | </main> |
|
| 31 | + | </body> |
|
| 34 | 32 | </html> |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Jotts{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | {% if let Some(error) = error %} |
|
| 5 | - | <p class="error">{{ error }}</p> |
|
| 6 | - | {% endif %} |
|
| 7 | - | <form method="POST" action="/notes" class="form"> |
|
| 8 | - | <label for="title">title</label> |
|
| 9 | - | <input type="text" id="title" name="title" autofocus required> |
|
| 10 | - | <label for="content">content</label> |
|
| 11 | - | <textarea id="content" name="content" placeholder="write markdown here..."></textarea> |
|
| 12 | - | <button type="submit">save</button> |
|
| 13 | - | </form> |
|
| 14 | - | {% endblock %} |
|
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <title>Jotts — new</title> |
|
| 7 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | + | <meta name="theme-color" content="#121113" /> |
|
| 13 | + | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 14 | + | <link rel="stylesheet" href="/static/styles.css"> |
|
| 15 | + | </head> |
|
| 16 | + | <body> |
|
| 17 | + | <header class="header"> |
|
| 18 | + | <a href="/" class="logo">jotts</a> |
|
| 19 | + | <nav class="links"><a href="/notes/new">new</a></nav> |
|
| 20 | + | </header> |
|
| 21 | + | <main> |
|
| 22 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 23 | + | <form method="POST" action="/notes" class="form"> |
|
| 24 | + | <label for="title">title</label> |
|
| 25 | + | <input type="text" id="title" name="title" autofocus required> |
|
| 26 | + | <label for="content">content</label> |
|
| 27 | + | <textarea id="content" name="content" placeholder="write markdown here..."></textarea> |
|
| 28 | + | <button type="submit">save</button> |
|
| 29 | + | </form> |
|
| 30 | + | </main> |
|
| 31 | + | </body> |
|
| 32 | + | </html> |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Jotts — {{ note.title }}{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | <div class="note-header"> |
|
| 5 | - | <h1>{{ note.title }}</h1> |
|
| 6 | - | <time class="note-date" datetime="{{ note.updated_at }}Z">{{ note.updated_at }}</time> |
|
| 7 | - | </div> |
|
| 8 | - | <div class="note-actions"> |
|
| 9 | - | <a href="/notes/{{ note.short_id }}/edit">edit</a> |
|
| 10 | - | <button type="button" class="link-button" id="copy-md-btn" onclick="copyMarkdown()">copy</button> |
|
| 11 | - | <form method="POST" action="/notes/{{ note.short_id }}/delete" class="inline-form"> |
|
| 12 | - | <button type="submit" class="link-button" onclick="return confirm('delete this note?')">delete</button> |
|
| 13 | - | </form> |
|
| 14 | - | </div> |
|
| 15 | - | <template id="raw-md">{{ note.content }}</template> |
|
| 16 | - | <article class="markdown-body"> |
|
| 17 | - | {{ rendered_content|safe }} |
|
| 18 | - | </article> |
|
| 19 | - | <script> |
|
| 20 | - | function copyMarkdown() { |
|
| 21 | - | const md = document.getElementById("raw-md").content.textContent; |
|
| 22 | - | const btn = document.getElementById("copy-md-btn"); |
|
| 23 | - | navigator.clipboard.writeText(md).then(() => { |
|
| 24 | - | btn.textContent = "copied!"; |
|
| 25 | - | setTimeout(() => { btn.textContent = "copy"; }, 1500); |
|
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <title>Jotts — {{.Note.Title}}</title> |
|
| 7 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | + | <meta property="og:title" content="Jotts"> |
|
| 13 | + | <meta property="og:image" content="/static/og.png"> |
|
| 14 | + | <meta property="og:type" content="website"> |
|
| 15 | + | <meta name="theme-color" content="#121113" /> |
|
| 16 | + | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 17 | + | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | + | </head> |
|
| 19 | + | <body> |
|
| 20 | + | <header class="header"> |
|
| 21 | + | <a href="/" class="logo">jotts</a> |
|
| 22 | + | <nav class="links"><a href="/notes/new">new</a></nav> |
|
| 23 | + | </header> |
|
| 24 | + | <main> |
|
| 25 | + | <div class="note-header"> |
|
| 26 | + | <h1>{{.Note.Title}}</h1> |
|
| 27 | + | <time class="note-date" datetime="{{.Note.UpdatedAt}}Z">{{.Note.UpdatedAt}}</time> |
|
| 28 | + | </div> |
|
| 29 | + | <div class="note-actions"> |
|
| 30 | + | <a href="/notes/{{.Note.ShortID}}/edit">edit</a> |
|
| 31 | + | <button type="button" class="link-button" id="copy-md-btn" onclick="copyMarkdown()">copy</button> |
|
| 32 | + | <form method="POST" action="/notes/{{.Note.ShortID}}/delete" class="inline-form"> |
|
| 33 | + | <button type="submit" class="link-button" onclick="return confirm('delete this note?')">delete</button> |
|
| 34 | + | </form> |
|
| 35 | + | </div> |
|
| 36 | + | <template id="raw-md">{{.Note.Content}}</template> |
|
| 37 | + | <article class="markdown-body">{{.Rendered}}</article> |
|
| 38 | + | </main> |
|
| 39 | + | <script> |
|
| 40 | + | function copyMarkdown() { |
|
| 41 | + | const md = document.getElementById("raw-md").content.textContent; |
|
| 42 | + | const btn = document.getElementById("copy-md-btn"); |
|
| 43 | + | navigator.clipboard.writeText(md).then(() => { |
|
| 44 | + | btn.textContent = "copied!"; |
|
| 45 | + | setTimeout(() => { btn.textContent = "copy"; }, 1500); |
|
| 46 | + | }); |
|
| 47 | + | } |
|
| 48 | + | document.querySelectorAll("time.note-date").forEach(el => { |
|
| 49 | + | const d = new Date(el.getAttribute("datetime")); |
|
| 50 | + | if (!isNaN(d)) { el.textContent = d.toLocaleString(); } |
|
| 26 | 51 | }); |
|
| 27 | - | } |
|
| 28 | - | </script> |
|
| 29 | - | {% endblock %} |
|
| 52 | + | </script> |
|
| 53 | + | </body> |
|
| 54 | + | </html> |
| 1 | - | ADMIN_PASSWORD=changeme |
|
| 2 | - | COOKIE_SECURE=false |
|
| 3 | - | BASE_URL=http://localhost:3000 |
|
| 4 | - | HOST=127.0.0.1 |
|
| 5 | - | PORT=3000 |
|
| 6 | - | LIBRARY_DB_PATH=library.sqlite |
|
| 7 | - | GOOGLE_BOOKS_API_KEY= |
|
| 8 | - | LIBRARY_DISPLAY_MODE=inline |
| 1 | - | # Build from repo root: docker build -t library-go -f apps/library-go/Dockerfile . |
|
| 2 | - | FROM golang:1.24-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/library-go/go.mod apps/library-go/go.sum ./apps/library-go/ |
|
| 6 | - | WORKDIR /app/apps/library-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/library-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /library-go . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 13 | - | COPY --from=builder /library-go /usr/local/bin/library-go |
|
| 14 | - | WORKDIR /data |
|
| 15 | - | ENV HOST=0.0.0.0 |
|
| 16 | - | ENV PORT=3000 |
|
| 17 | - | EXPOSE 3000 |
|
| 18 | - | CMD ["library-go"] |
| 1 | - | # library-go |
|
| 2 | - | ||
| 3 | - | Go rewrite of [library](../library). Personal book tracker with Google Books |
|
| 4 | - | search. |
|
| 5 | - | ||
| 6 | - | ## Quickstart |
|
| 7 | - | ||
| 8 | - | ```bash |
|
| 9 | - | cp .env.example .env |
|
| 10 | - | go run . |
|
| 11 | - | ``` |
|
| 12 | - | ||
| 13 | - | ### Environment Variables |
|
| 14 | - | ||
| 15 | - | | Variable | Default | Description | |
|
| 16 | - | |---|---|---| |
|
| 17 | - | | `ADMIN_PASSWORD` | `changeme` | Admin login password | |
|
| 18 | - | | `LIBRARY_DB_PATH` | `library.sqlite` | SQLite path | |
|
| 19 | - | | `GOOGLE_BOOKS_API_KEY` | — | Optional Google Books API key | |
|
| 20 | - | | `BASE_URL` | `http://localhost:3000` | Public base URL (OG tags) | |
|
| 21 | - | | `HOST` | `0.0.0.0` | Bind host | |
|
| 22 | - | | `PORT` | `3000` | Server port | |
|
| 23 | - | | `COOKIE_SECURE` | `false` | Mark session cookie Secure | |
|
| 24 | - | | `LIBRARY_DISPLAY_MODE` | `inline` | `inline` or `nav` | |
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/library-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | - | environment: |
|
| 9 | - | - HOST=0.0.0.0 |
|
| 10 | - | - PORT=${PORT:-3000} |
|
| 11 | - | - LIBRARY_DB_PATH=/data/library-go.sqlite |
|
| 12 | - | - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme} |
|
| 13 | - | - GOOGLE_BOOKS_API_KEY=${GOOGLE_BOOKS_API_KEY:-} |
|
| 14 | - | - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}} |
|
| 15 | - | - LIBRARY_DISPLAY_MODE=${LIBRARY_DISPLAY_MODE:-inline} |
|
| 16 | - | - COOKIE_SECURE=${COOKIE_SECURE:-false} |
|
| 17 | - | volumes: |
|
| 18 | - | - library-go-data:/data |
|
| 19 | - | restart: unless-stopped |
|
| 20 | - | ||
| 21 | - | volumes: |
|
| 22 | - | library-go-data: |
| 1 | - | module github.com/stevedylandev/andromeda/apps/library-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/cellar |
|
| 2 | 2 | ||
| 3 | 3 | go 1.24.4 |
|
| 4 | 4 |
| 70 | 70 | if err != nil { |
|
| 71 | 71 | return nil, err |
|
| 72 | 72 | } |
|
| 73 | - | req.Header.Set("User-Agent", "andromeda-library-go/0.1") |
|
| 73 | + | req.Header.Set("User-Agent", "andromeda-library/0.1") |
|
| 74 | 74 | resp, err := client.Do(req) |
|
| 75 | 75 | if err != nil { |
|
| 76 | 76 | return nil, fmt.Errorf("request: %w", err) |
| 43 | 43 | } |
|
| 44 | 44 | ||
| 45 | 45 | addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000") |
|
| 46 | - | logger.Info("library-go server running", "addr", addr) |
|
| 46 | + | logger.Info("library server running", "addr", addr) |
|
| 47 | 47 | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 48 | 48 | log.Fatal(err) |
|
| 49 | 49 | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* library — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | .logo h1 { |
|
| 6 | - | font-size: 28px; |
|
| 7 | - | font-weight: 700; |
|
| 8 | - | text-transform: uppercase; |
|
| 9 | - | } |
|
| 10 | - | ||
| 11 | - | /* Inline sections */ |
|
| 12 | - | ||
| 13 | - | .section { |
|
| 14 | - | width: 100%; |
|
| 15 | - | display: flex; |
|
| 16 | - | flex-direction: column; |
|
| 17 | - | gap: 0; |
|
| 18 | - | margin-bottom: 2rem; |
|
| 19 | - | } |
|
| 20 | - | ||
| 21 | - | .section-label { |
|
| 22 | - | font-size: 11px; |
|
| 23 | - | font-weight: 600; |
|
| 24 | - | text-transform: uppercase; |
|
| 25 | - | letter-spacing: 0.12em; |
|
| 26 | - | opacity: 0.4; |
|
| 27 | - | margin-bottom: 0.5rem; |
|
| 28 | - | } |
|
| 29 | - | ||
| 30 | - | /* Books list */ |
|
| 31 | - | ||
| 32 | - | .books-list { |
|
| 33 | - | width: 100%; |
|
| 34 | - | display: flex; |
|
| 35 | - | flex-direction: column; |
|
| 36 | - | gap: 1.25rem; |
|
| 37 | - | } |
|
| 38 | - | ||
| 39 | - | .book-card { |
|
| 40 | - | display: flex; |
|
| 41 | - | gap: 1rem; |
|
| 42 | - | padding: 1rem 0; |
|
| 43 | - | border-bottom: 1px solid #333; |
|
| 44 | - | } |
|
| 45 | - | ||
| 46 | - | .book-card:last-child { |
|
| 47 | - | border-bottom: none; |
|
| 48 | - | } |
|
| 49 | - | ||
| 50 | - | .book-cover { |
|
| 51 | - | width: 72px; |
|
| 52 | - | height: 108px; |
|
| 53 | - | object-fit: cover; |
|
| 54 | - | background: #1e1c1f; |
|
| 55 | - | flex-shrink: 0; |
|
| 56 | - | display: block; |
|
| 57 | - | } |
|
| 58 | - | ||
| 59 | - | .book-cover.placeholder { |
|
| 60 | - | background: #1e1c1f; |
|
| 61 | - | } |
|
| 62 | - | ||
| 63 | - | .book-info { |
|
| 64 | - | display: flex; |
|
| 65 | - | flex-direction: column; |
|
| 66 | - | gap: 0.4rem; |
|
| 67 | - | flex: 1; |
|
| 68 | - | min-width: 0; |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | .book-title { |
|
| 72 | - | font-size: 16px; |
|
| 73 | - | font-weight: 400; |
|
| 74 | - | line-height: 1.4; |
|
| 75 | - | } |
|
| 76 | - | ||
| 77 | - | .book-authors { |
|
| 78 | - | font-size: 14px; |
|
| 79 | - | opacity: 0.7; |
|
| 80 | - | } |
|
| 81 | - | ||
| 82 | - | .book-meta { |
|
| 83 | - | font-size: 12px; |
|
| 84 | - | opacity: 0.5; |
|
| 85 | - | } |
|
| 86 | - | ||
| 87 | - | .book-notes { |
|
| 88 | - | font-size: 13px; |
|
| 89 | - | opacity: 0.7; |
|
| 90 | - | line-height: 1.5; |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | .no-books { |
|
| 94 | - | text-align: center; |
|
| 95 | - | opacity: 0.5; |
|
| 96 | - | padding: 2rem; |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | /* Admin */ |
|
| 100 | - | ||
| 101 | - | .admin-form { |
|
| 102 | - | display: flex; |
|
| 103 | - | flex-direction: column; |
|
| 104 | - | gap: 0.75rem; |
|
| 105 | - | width: 100%; |
|
| 106 | - | margin-bottom: 1.5rem; |
|
| 107 | - | } |
|
| 108 | - | ||
| 109 | - | .admin-form h3 { |
|
| 110 | - | font-size: 14px; |
|
| 111 | - | font-weight: 400; |
|
| 112 | - | opacity: 0.5; |
|
| 113 | - | } |
|
| 114 | - | ||
| 115 | - | .hint { |
|
| 116 | - | font-size: 12px; |
|
| 117 | - | opacity: 0.5; |
|
| 118 | - | line-height: 1.4; |
|
| 119 | - | } |
|
| 120 | - | ||
| 121 | - | .search-row { |
|
| 122 | - | display: flex; |
|
| 123 | - | gap: 0.5rem; |
|
| 124 | - | width: 100%; |
|
| 125 | - | } |
|
| 126 | - | ||
| 127 | - | .search-row input { |
|
| 128 | - | flex: 1; |
|
| 129 | - | } |
|
| 130 | - | ||
| 131 | - | .search-status { |
|
| 132 | - | font-size: 12px; |
|
| 133 | - | } |
|
| 134 | - | ||
| 135 | - | .search-results { |
|
| 136 | - | display: flex; |
|
| 137 | - | flex-direction: column; |
|
| 138 | - | gap: 0.5rem; |
|
| 139 | - | width: 100%; |
|
| 140 | - | } |
|
| 141 | - | ||
| 142 | - | .book-card.hit, |
|
| 143 | - | .book-card.admin { |
|
| 144 | - | border: 1px solid #333; |
|
| 145 | - | padding: 0.75rem; |
|
| 146 | - | border-bottom: 1px solid #333; |
|
| 147 | - | } |
|
| 148 | - | ||
| 149 | - | .book-card.admin .inline { |
|
| 150 | - | display: flex; |
|
| 151 | - | gap: 0.5rem; |
|
| 152 | - | align-items: center; |
|
| 153 | - | margin-top: 0.4rem; |
|
| 154 | - | } |
|
| 155 | - | ||
| 156 | - | .book-card.admin .notes-form { |
|
| 157 | - | flex-direction: column; |
|
| 158 | - | align-items: stretch; |
|
| 159 | - | } |
|
| 160 | - | ||
| 161 | - | .book-card.admin textarea { |
|
| 162 | - | width: 100%; |
|
| 163 | - | min-height: 1.6rem; |
|
| 164 | - | font-family: inherit; |
|
| 165 | - | font-size: 13px; |
|
| 166 | - | line-height: 1.4; |
|
| 167 | - | background: #121113; |
|
| 168 | - | color: #ffffff; |
|
| 169 | - | border: 1px solid #333; |
|
| 170 | - | padding: 0.3rem 0.4rem; |
|
| 171 | - | resize: vertical; |
|
| 172 | - | } |
|
| 173 | - | ||
| 174 | - | .admin-subs { |
|
| 175 | - | width: 100%; |
|
| 176 | - | display: flex; |
|
| 177 | - | flex-direction: column; |
|
| 178 | - | gap: 0.75rem; |
|
| 179 | - | } |
|
| 180 | - | ||
| 181 | - | .admin-subs h3 { |
|
| 182 | - | font-size: 14px; |
|
| 183 | - | opacity: 0.5; |
|
| 184 | - | font-weight: 400; |
|
| 185 | - | } |
|
| 186 | - | ||
| 187 | - | button.danger, |
|
| 188 | - | .btn.danger { |
|
| 189 | - | opacity: 0.5; |
|
| 190 | - | } |
|
| 191 | - | ||
| 192 | - | button.danger:hover, |
|
| 193 | - | .btn.danger:hover { |
|
| 194 | - | opacity: 0.3; |
|
| 195 | - | } |
|
| 196 | - | ||
| 197 | - | @media (max-width: 480px) { |
|
| 198 | - | .book-card { |
|
| 199 | - | gap: 0.75rem; |
|
| 200 | - | } |
|
| 201 | - | .book-cover { |
|
| 202 | - | width: 56px; |
|
| 203 | - | height: 84px; |
|
| 204 | - | } |
|
| 205 | - | .book-title { |
|
| 206 | - | font-size: 14px; |
|
| 207 | - | } |
|
| 208 | - | } |
|
| 209 | - | ||
| 210 | - | .scan-modal { |
|
| 211 | - | position: fixed; |
|
| 212 | - | inset: 0; |
|
| 213 | - | background: rgba(0, 0, 0, 0.85); |
|
| 214 | - | display: flex; |
|
| 215 | - | align-items: center; |
|
| 216 | - | justify-content: center; |
|
| 217 | - | z-index: 1000; |
|
| 218 | - | } |
|
| 219 | - | ||
| 220 | - | .scan-modal[hidden] { |
|
| 221 | - | display: none; |
|
| 222 | - | } |
|
| 223 | - | ||
| 224 | - | .scan-inner { |
|
| 225 | - | display: flex; |
|
| 226 | - | flex-direction: column; |
|
| 227 | - | align-items: center; |
|
| 228 | - | gap: 12px; |
|
| 229 | - | width: min(90vw, 480px); |
|
| 230 | - | } |
|
| 231 | - | ||
| 232 | - | .scan-inner video { |
|
| 233 | - | width: 100%; |
|
| 234 | - | max-height: 70vh; |
|
| 235 | - | background: #000; |
|
| 236 | - | border-radius: 8px; |
|
| 237 | - | } |
|
| 238 | - | ||
| 239 | - | .scan-status { |
|
| 240 | - | color: #eee; |
|
| 241 | - | font-size: 14px; |
|
| 242 | - | margin: 0; |
|
| 243 | - | } |
| 1 | - | [package] |
|
| 2 | - | name = "library" |
|
| 3 | - | version = "0.1.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Personal book tracking" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | axum = { workspace = true } |
|
| 12 | - | tokio = { workspace = true } |
|
| 13 | - | serde = { workspace = true } |
|
| 14 | - | serde_json = { workspace = true } |
|
| 15 | - | dotenvy = { workspace = true } |
|
| 16 | - | rust-embed = { workspace = true } |
|
| 17 | - | subtle = { workspace = true } |
|
| 18 | - | rand = { workspace = true } |
|
| 19 | - | rusqlite = { workspace = true } |
|
| 20 | - | tracing = { workspace = true } |
|
| 21 | - | tracing-subscriber = { workspace = true, features = ["env-filter"] } |
|
| 22 | - | andromeda-auth = { workspace = true } |
|
| 23 | - | andromeda-db = { workspace = true, features = ["axum", "session"] } |
|
| 24 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 25 | - | askama = "0.13" |
|
| 26 | - | reqwest = { version = "0.12", features = ["json"] } |
|
| 27 | - | chrono = "0.4" |
|
| 28 | - | mime_guess = "2" |
|
| 29 | - | urlencoding = "2" |
| 1 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 1 | + | # Build from repo root: docker build -t library -f apps/library/Dockerfile . |
|
| 2 | + | FROM golang:1.24-bookworm AS builder |
|
| 2 | 3 | WORKDIR /app |
|
| 3 | - | ||
| 4 | - | FROM chef AS planner |
|
| 5 | - | COPY . . |
|
| 6 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 7 | - | ||
| 8 | - | FROM chef AS builder |
|
| 9 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 10 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 11 | - | RUN cargo chef cook --release --recipe-path recipe.json -p library |
|
| 12 | - | COPY . . |
|
| 13 | - | RUN cargo build --release -p library |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/library/go.mod apps/library/go.sum ./apps/library/ |
|
| 6 | + | WORKDIR /app/apps/library |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/library/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /library . |
|
| 14 | 10 | ||
| 15 | 11 | FROM debian:bookworm-slim |
|
| 16 | 12 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 17 | - | COPY --from=builder /app/target/release/library /usr/local/bin/library |
|
| 13 | + | COPY --from=builder /library /usr/local/bin/library |
|
| 18 | 14 | WORKDIR /data |
|
| 19 | - | EXPOSE 3000 |
|
| 20 | 15 | ENV HOST=0.0.0.0 |
|
| 21 | 16 | ENV PORT=3000 |
|
| 17 | + | EXPOSE 3000 |
|
| 22 | 18 | CMD ["library"] |
| 1 | - | # Library |
|
| 1 | + | # library-go |
|
| 2 | 2 | ||
| 3 | - | A minimal personal book tracker |
|
| 3 | + | Go rewrite of [library](../library). Personal book tracker with Google Books |
|
| 4 | + | search. |
|
| 4 | 5 | ||
| 5 | 6 | ## Quickstart |
|
| 6 | 7 | ||
| 7 | 8 | ```bash |
|
| 8 | - | git clone https://github.com/stevedylandev/andromeda.git |
|
| 9 | - | cd andromeda |
|
| 10 | - | cp apps/library/.env.example apps/library/.env |
|
| 11 | - | # Edit .env with your admin password |
|
| 12 | - | cargo run -p library |
|
| 9 | + | cp .env.example .env |
|
| 10 | + | go run . |
|
| 13 | 11 | ``` |
|
| 14 | 12 | ||
| 15 | 13 | ### Environment Variables |
|
| 16 | 14 | ||
| 17 | - | | Variable | Description | Default | |
|
| 15 | + | | Variable | Default | Description | |
|
| 18 | 16 | |---|---|---| |
|
| 19 | - | | `ADMIN_PASSWORD` | Password for admin login | `changeme` | |
|
| 20 | - | | `LIBRARY_DB_PATH` | SQLite database file path | `library.sqlite` | |
|
| 21 | - | | `GOOGLE_BOOKS_API_KEY` | Google Books API key for search | | |
|
| 22 | - | | `BASE_URL` | Public base URL | `http://localhost:3000` | |
|
| 23 | - | | `HOST` | Server bind address | `127.0.0.1` | |
|
| 24 | - | | `PORT` | Server port | `3000` | |
|
| 25 | - | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 26 | - | | `LIBRARY_DISPLAY_MODE` | Public index layout: `inline` (stacked sections) or `nav` (filter buttons in header) | `inline` | |
|
| 27 | - | ||
| 28 | - | ## Overview |
|
| 29 | - | ||
| 30 | - | A simple, self-hosted book tracker built with Rust. Highlights: |
|
| 31 | - | - Single Rust binary with embedded assets |
|
| 32 | - | - Password authentication with session cookies |
|
| 33 | - | - Track books across Read, Reading, and Want to Read (labels customizable from admin) |
|
| 34 | - | - Google Books search to add titles with cover art and ISBN |
|
| 35 | - | - Library search from the admin page (title / author / ISBN) |
|
| 36 | - | - Toggle between inline category sections and a filter-nav layout via `LIBRARY_DISPLAY_MODE` |
|
| 37 | - | - Per-book notes |
|
| 38 | - | - JSON API for listing and fetching books |
|
| 39 | - | - SQLite for persistent storage |
|
| 40 | - | ||
| 41 | - | ## Structure |
|
| 42 | - | ||
| 43 | - | ``` |
|
| 44 | - | library/ |
|
| 45 | - | ├── src/ |
|
| 46 | - | │ ├── main.rs # App entrypoint, env vars, router |
|
| 47 | - | │ ├── auth.rs # Password verification and sessions |
|
| 48 | - | │ ├── db.rs # SQLite layer (books) |
|
| 49 | - | │ └── google_books.rs # Google Books API client |
|
| 50 | - | ├── templates/ # Askama HTML templates |
|
| 51 | - | ├── static/ # Favicons, og:image, styles |
|
| 52 | - | ├── Dockerfile # Multi-stage build (Rust + Debian slim) |
|
| 53 | - | └── Cargo.toml |
|
| 54 | - | ``` |
|
| 55 | - | ||
| 56 | - | ## Deployment |
|
| 57 | - | ||
| 58 | - | ### Docker (recommended) |
|
| 59 | - | ||
| 60 | - | From the repo root: |
|
| 61 | - | ||
| 62 | - | ```bash |
|
| 63 | - | cp apps/library/.env.example apps/library/.env |
|
| 64 | - | # Edit .env |
|
| 65 | - | docker compose up -d library |
|
| 66 | - | ``` |
|
| 67 | - | ||
| 68 | - | This will start Library on port `4646` with a persistent volume for the SQLite database. |
|
| 69 | - | ||
| 70 | - | ### Binary |
|
| 71 | - | ||
| 72 | - | ```bash |
|
| 73 | - | cargo build --release -p library |
|
| 74 | - | ``` |
|
| 75 | - | ||
| 76 | - | The resulting binary at `./target/release/library` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 17 | + | | `ADMIN_PASSWORD` | `changeme` | Admin login password | |
|
| 18 | + | | `LIBRARY_DB_PATH` | `library.sqlite` | SQLite path | |
|
| 19 | + | | `GOOGLE_BOOKS_API_KEY` | — | Optional Google Books API key | |
|
| 20 | + | | `BASE_URL` | `http://localhost:3000` | Public base URL (OG tags) | |
|
| 21 | + | | `HOST` | `0.0.0.0` | Bind host | |
|
| 22 | + | | `PORT` | `3000` | Server port | |
|
| 23 | + | | `COOKIE_SECURE` | `false` | Mark session cookie Secure | |
|
| 24 | + | | `LIBRARY_DISPLAY_MODE` | `inline` | `inline` or `nav` | |
| 1 | - | [general] |
|
| 2 | - | dirs = ["src/templates"] |
| 2 | 2 | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/library/Dockerfile |
|
| 5 | + | dockerfile: apps/library-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | 7 | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | 8 | environment: |
|
| 9 | + | - HOST=0.0.0.0 |
|
| 10 | + | - PORT=${PORT:-3000} |
|
| 11 | + | - LIBRARY_DB_PATH=/data/library-go.sqlite |
|
| 9 | 12 | - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme} |
|
| 10 | - | - LIBRARY_DB_PATH=/data/library.sqlite |
|
| 11 | 13 | - GOOGLE_BOOKS_API_KEY=${GOOGLE_BOOKS_API_KEY:-} |
|
| 12 | - | - BASE_URL=${BASE_URL:-} |
|
| 13 | - | - COOKIE_SECURE=false |
|
| 14 | - | - HOST=0.0.0.0 |
|
| 15 | - | - PORT=${PORT:-3000} |
|
| 14 | + | - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}} |
|
| 15 | + | - LIBRARY_DISPLAY_MODE=${LIBRARY_DISPLAY_MODE:-inline} |
|
| 16 | + | - COOKIE_SECURE=${COOKIE_SECURE:-false} |
|
| 16 | 17 | volumes: |
|
| 17 | - | - library-data:/data |
|
| 18 | + | - library-go-data:/data |
|
| 18 | 19 | restart: unless-stopped |
|
| 19 | 20 | ||
| 20 | 21 | volumes: |
|
| 21 | - | library-data: |
|
| 22 | + | library-go-data: |
| 1 | - | use axum::{ |
|
| 2 | - | extract::{FromRef, FromRequestParts}, |
|
| 3 | - | http::request::Parts, |
|
| 4 | - | response::{IntoResponse, Redirect, Response}, |
|
| 5 | - | }; |
|
| 6 | - | use chrono::{Duration, Utc}; |
|
| 7 | - | use std::sync::Arc; |
|
| 8 | - | ||
| 9 | - | use crate::AppState; |
|
| 10 | - | use andromeda_db::session; |
|
| 11 | - | ||
| 12 | - | pub use andromeda_auth::{ |
|
| 13 | - | build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token, |
|
| 14 | - | verify_password, |
|
| 15 | - | }; |
|
| 16 | - | ||
| 17 | - | const SESSION_DAYS: i64 = 7; |
|
| 18 | - | ||
| 19 | - | pub fn create_session(db: &andromeda_db::Db, token: &str) -> Result<(), andromeda_db::DbError> { |
|
| 20 | - | let expires = (Utc::now() + Duration::days(SESSION_DAYS)) |
|
| 21 | - | .format("%Y-%m-%d %H:%M:%S") |
|
| 22 | - | .to_string(); |
|
| 23 | - | session::insert_session(db, token, &expires) |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | pub fn is_valid_session(db: &andromeda_db::Db, token: &str) -> bool { |
|
| 27 | - | match session::get_session_expiry(db, token) { |
|
| 28 | - | Ok(Some(expires_at)) => { |
|
| 29 | - | chrono::NaiveDateTime::parse_from_str(&expires_at, "%Y-%m-%d %H:%M:%S") |
|
| 30 | - | .map(|exp| exp > Utc::now().naive_utc()) |
|
| 31 | - | .unwrap_or(false) |
|
| 32 | - | } |
|
| 33 | - | _ => false, |
|
| 34 | - | } |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | pub fn delete_session(db: &andromeda_db::Db, token: &str) { |
|
| 38 | - | let _ = session::delete_session(db, token); |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | pub struct AuthSession; |
|
| 42 | - | ||
| 43 | - | impl<S> FromRequestParts<S> for AuthSession |
|
| 44 | - | where |
|
| 45 | - | S: Send + Sync, |
|
| 46 | - | Arc<AppState>: FromRef<S>, |
|
| 47 | - | { |
|
| 48 | - | type Rejection = Response; |
|
| 49 | - | ||
| 50 | - | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
|
| 51 | - | let state = Arc::<AppState>::from_ref(state); |
|
| 52 | - | if let Some(token) = extract_session_cookie(&parts.headers) { |
|
| 53 | - | if is_valid_session(&state.db, &token) { |
|
| 54 | - | return Ok(AuthSession); |
|
| 55 | - | } |
|
| 56 | - | } |
|
| 57 | - | Err(Redirect::to("/admin/login").into_response()) |
|
| 58 | - | } |
|
| 59 | - | } |
|
| 60 | - |
| 1 | - | use andromeda_db::{Db, DbError}; |
|
| 2 | - | use rusqlite::{params, OptionalExtension}; |
|
| 3 | - | use serde::{Deserialize, Serialize}; |
|
| 4 | - | ||
| 5 | - | pub const BOOKS_SCHEMA: &str = r#" |
|
| 6 | - | CREATE TABLE IF NOT EXISTS books ( |
|
| 7 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 8 | - | google_id TEXT UNIQUE, |
|
| 9 | - | title TEXT NOT NULL, |
|
| 10 | - | authors TEXT NOT NULL, |
|
| 11 | - | isbn TEXT, |
|
| 12 | - | cover_url TEXT, |
|
| 13 | - | notes TEXT, |
|
| 14 | - | status TEXT NOT NULL CHECK (status IN ('read','reading','want')), |
|
| 15 | - | added_at INTEGER NOT NULL, |
|
| 16 | - | updated_at INTEGER NOT NULL |
|
| 17 | - | ); |
|
| 18 | - | CREATE INDEX IF NOT EXISTS idx_books_status_added ON books(status, added_at DESC); |
|
| 19 | - | ||
| 20 | - | CREATE TABLE IF NOT EXISTS settings ( |
|
| 21 | - | key TEXT PRIMARY KEY, |
|
| 22 | - | value TEXT NOT NULL |
|
| 23 | - | ); |
|
| 24 | - | "#; |
|
| 25 | - | ||
| 26 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
|
| 27 | - | #[serde(rename_all = "lowercase")] |
|
| 28 | - | pub enum BookStatus { |
|
| 29 | - | Read, |
|
| 30 | - | Reading, |
|
| 31 | - | Want, |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | impl BookStatus { |
|
| 35 | - | pub fn as_str(&self) -> &'static str { |
|
| 36 | - | match self { |
|
| 37 | - | BookStatus::Read => "read", |
|
| 38 | - | BookStatus::Reading => "reading", |
|
| 39 | - | BookStatus::Want => "want", |
|
| 40 | - | } |
|
| 41 | - | } |
|
| 42 | - | ||
| 43 | - | pub fn parse(s: &str) -> Option<Self> { |
|
| 44 | - | match s { |
|
| 45 | - | "read" => Some(BookStatus::Read), |
|
| 46 | - | "reading" => Some(BookStatus::Reading), |
|
| 47 | - | "want" => Some(BookStatus::Want), |
|
| 48 | - | _ => None, |
|
| 49 | - | } |
|
| 50 | - | } |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | #[derive(Debug, Clone, Serialize)] |
|
| 54 | - | pub struct Book { |
|
| 55 | - | pub id: i64, |
|
| 56 | - | pub google_id: Option<String>, |
|
| 57 | - | pub title: String, |
|
| 58 | - | pub authors: String, |
|
| 59 | - | pub isbn: Option<String>, |
|
| 60 | - | pub cover_url: Option<String>, |
|
| 61 | - | pub notes: Option<String>, |
|
| 62 | - | pub status: String, |
|
| 63 | - | pub added_at: i64, |
|
| 64 | - | pub updated_at: i64, |
|
| 65 | - | } |
|
| 66 | - | ||
| 67 | - | #[derive(Debug, Clone)] |
|
| 68 | - | pub struct NewBook { |
|
| 69 | - | pub google_id: Option<String>, |
|
| 70 | - | pub title: String, |
|
| 71 | - | pub authors: String, |
|
| 72 | - | pub isbn: Option<String>, |
|
| 73 | - | pub cover_url: Option<String>, |
|
| 74 | - | pub notes: Option<String>, |
|
| 75 | - | pub status: BookStatus, |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | fn map_book(row: &rusqlite::Row) -> rusqlite::Result<Book> { |
|
| 79 | - | Ok(Book { |
|
| 80 | - | id: row.get(0)?, |
|
| 81 | - | google_id: row.get(1)?, |
|
| 82 | - | title: row.get(2)?, |
|
| 83 | - | authors: row.get(3)?, |
|
| 84 | - | isbn: row.get(4)?, |
|
| 85 | - | cover_url: row.get(5)?, |
|
| 86 | - | notes: row.get(6)?, |
|
| 87 | - | status: row.get(7)?, |
|
| 88 | - | added_at: row.get(8)?, |
|
| 89 | - | updated_at: row.get(9)?, |
|
| 90 | - | }) |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | const SELECT_COLS: &str = |
|
| 94 | - | "id, google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at"; |
|
| 95 | - | ||
| 96 | - | pub fn list_books(db: &Db, status: Option<BookStatus>) -> Result<Vec<Book>, DbError> { |
|
| 97 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 98 | - | let books = match status { |
|
| 99 | - | Some(s) => { |
|
| 100 | - | let sql = format!( |
|
| 101 | - | "SELECT {SELECT_COLS} FROM books WHERE status = ?1 ORDER BY added_at DESC" |
|
| 102 | - | ); |
|
| 103 | - | let mut stmt = conn.prepare(&sql)?; |
|
| 104 | - | let rows = stmt.query_map([s.as_str()], map_book)?; |
|
| 105 | - | rows.collect::<Result<Vec<_>, _>>()? |
|
| 106 | - | } |
|
| 107 | - | None => { |
|
| 108 | - | let sql = format!("SELECT {SELECT_COLS} FROM books ORDER BY added_at DESC"); |
|
| 109 | - | let mut stmt = conn.prepare(&sql)?; |
|
| 110 | - | let rows = stmt.query_map([], map_book)?; |
|
| 111 | - | rows.collect::<Result<Vec<_>, _>>()? |
|
| 112 | - | } |
|
| 113 | - | }; |
|
| 114 | - | Ok(books) |
|
| 115 | - | } |
|
| 116 | - | ||
| 117 | - | pub fn get_book(db: &Db, id: i64) -> Result<Option<Book>, DbError> { |
|
| 118 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 119 | - | let sql = format!("SELECT {SELECT_COLS} FROM books WHERE id = ?1"); |
|
| 120 | - | let book = conn |
|
| 121 | - | .query_row(&sql, [id], map_book) |
|
| 122 | - | .optional()?; |
|
| 123 | - | Ok(book) |
|
| 124 | - | } |
|
| 125 | - | ||
| 126 | - | pub fn insert_book(db: &Db, b: &NewBook) -> Result<i64, DbError> { |
|
| 127 | - | let now = chrono::Utc::now().timestamp(); |
|
| 128 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 129 | - | conn.execute( |
|
| 130 | - | "INSERT INTO books (google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at) |
|
| 131 | - | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) |
|
| 132 | - | ON CONFLICT(google_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at", |
|
| 133 | - | params![ |
|
| 134 | - | b.google_id, |
|
| 135 | - | b.title, |
|
| 136 | - | b.authors, |
|
| 137 | - | b.isbn, |
|
| 138 | - | b.cover_url, |
|
| 139 | - | b.notes, |
|
| 140 | - | b.status.as_str(), |
|
| 141 | - | now, |
|
| 142 | - | ], |
|
| 143 | - | )?; |
|
| 144 | - | Ok(conn.last_insert_rowid()) |
|
| 145 | - | } |
|
| 146 | - | ||
| 147 | - | pub fn update_book_status(db: &Db, id: i64, status: BookStatus) -> Result<bool, DbError> { |
|
| 148 | - | let now = chrono::Utc::now().timestamp(); |
|
| 149 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 150 | - | let n = conn.execute( |
|
| 151 | - | "UPDATE books SET status = ?1, updated_at = ?2 WHERE id = ?3", |
|
| 152 | - | params![status.as_str(), now, id], |
|
| 153 | - | )?; |
|
| 154 | - | Ok(n > 0) |
|
| 155 | - | } |
|
| 156 | - | ||
| 157 | - | pub fn update_book_notes(db: &Db, id: i64, notes: Option<&str>) -> Result<bool, DbError> { |
|
| 158 | - | let now = chrono::Utc::now().timestamp(); |
|
| 159 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 160 | - | let n = conn.execute( |
|
| 161 | - | "UPDATE books SET notes = ?1, updated_at = ?2 WHERE id = ?3", |
|
| 162 | - | params![notes, now, id], |
|
| 163 | - | )?; |
|
| 164 | - | Ok(n > 0) |
|
| 165 | - | } |
|
| 166 | - | ||
| 167 | - | pub fn delete_book(db: &Db, id: i64) -> Result<bool, DbError> { |
|
| 168 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 169 | - | let n = conn.execute("DELETE FROM books WHERE id = ?1", [id])?; |
|
| 170 | - | Ok(n > 0) |
|
| 171 | - | } |
|
| 172 | - | ||
| 173 | - | pub fn search_books(db: &Db, q: &str) -> Result<Vec<Book>, DbError> { |
|
| 174 | - | let term = q.trim(); |
|
| 175 | - | if term.is_empty() { |
|
| 176 | - | return Ok(Vec::new()); |
|
| 177 | - | } |
|
| 178 | - | let pattern = format!("%{}%", term.to_lowercase()); |
|
| 179 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 180 | - | let sql = format!( |
|
| 181 | - | "SELECT {SELECT_COLS} FROM books \ |
|
| 182 | - | WHERE LOWER(title) LIKE ?1 OR LOWER(authors) LIKE ?1 OR LOWER(IFNULL(isbn, '')) LIKE ?1 \ |
|
| 183 | - | ORDER BY added_at DESC LIMIT 50" |
|
| 184 | - | ); |
|
| 185 | - | let mut stmt = conn.prepare(&sql)?; |
|
| 186 | - | let rows = stmt.query_map([&pattern], map_book)?; |
|
| 187 | - | Ok(rows.collect::<Result<Vec<_>, _>>()?) |
|
| 188 | - | } |
|
| 189 | - | ||
| 190 | - | #[derive(Debug, Clone, Serialize)] |
|
| 191 | - | pub struct CategoryLabels { |
|
| 192 | - | pub reading: String, |
|
| 193 | - | pub read: String, |
|
| 194 | - | pub want: String, |
|
| 195 | - | } |
|
| 196 | - | ||
| 197 | - | impl Default for CategoryLabels { |
|
| 198 | - | fn default() -> Self { |
|
| 199 | - | Self { |
|
| 200 | - | reading: "Reading".to_string(), |
|
| 201 | - | read: "Read".to_string(), |
|
| 202 | - | want: "Want to Read".to_string(), |
|
| 203 | - | } |
|
| 204 | - | } |
|
| 205 | - | } |
|
| 206 | - | ||
| 207 | - | impl CategoryLabels { |
|
| 208 | - | pub fn label_for(&self, status: BookStatus) -> &str { |
|
| 209 | - | match status { |
|
| 210 | - | BookStatus::Reading => &self.reading, |
|
| 211 | - | BookStatus::Read => &self.read, |
|
| 212 | - | BookStatus::Want => &self.want, |
|
| 213 | - | } |
|
| 214 | - | } |
|
| 215 | - | } |
|
| 216 | - | ||
| 217 | - | pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>, DbError> { |
|
| 218 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 219 | - | let val = conn |
|
| 220 | - | .query_row( |
|
| 221 | - | "SELECT value FROM settings WHERE key = ?1", |
|
| 222 | - | [key], |
|
| 223 | - | |r| r.get::<_, String>(0), |
|
| 224 | - | ) |
|
| 225 | - | .optional()?; |
|
| 226 | - | Ok(val) |
|
| 227 | - | } |
|
| 228 | - | ||
| 229 | - | pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<(), DbError> { |
|
| 230 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 231 | - | conn.execute( |
|
| 232 | - | "INSERT INTO settings (key, value) VALUES (?1, ?2) \ |
|
| 233 | - | ON CONFLICT(key) DO UPDATE SET value = excluded.value", |
|
| 234 | - | params![key, value], |
|
| 235 | - | )?; |
|
| 236 | - | Ok(()) |
|
| 237 | - | } |
|
| 238 | - | ||
| 239 | - | pub fn get_category_labels(db: &Db) -> Result<CategoryLabels, DbError> { |
|
| 240 | - | let mut labels = CategoryLabels::default(); |
|
| 241 | - | if let Some(v) = get_setting(db, "category_label.reading")? { |
|
| 242 | - | labels.reading = v; |
|
| 243 | - | } |
|
| 244 | - | if let Some(v) = get_setting(db, "category_label.read")? { |
|
| 245 | - | labels.read = v; |
|
| 246 | - | } |
|
| 247 | - | if let Some(v) = get_setting(db, "category_label.want")? { |
|
| 248 | - | labels.want = v; |
|
| 249 | - | } |
|
| 250 | - | Ok(labels) |
|
| 251 | - | } |
|
| 252 | - |
| 1 | - | use serde::{Deserialize, Serialize}; |
|
| 2 | - | use std::time::Duration; |
|
| 3 | - | ||
| 4 | - | #[derive(Debug, Clone, Serialize)] |
|
| 5 | - | pub struct SearchHit { |
|
| 6 | - | pub google_id: String, |
|
| 7 | - | pub title: String, |
|
| 8 | - | pub authors: String, |
|
| 9 | - | pub isbn: Option<String>, |
|
| 10 | - | pub cover_url: Option<String>, |
|
| 11 | - | } |
|
| 12 | - | ||
| 13 | - | #[derive(Deserialize)] |
|
| 14 | - | struct VolumesResponse { |
|
| 15 | - | #[serde(default)] |
|
| 16 | - | items: Vec<Volume>, |
|
| 17 | - | } |
|
| 18 | - | ||
| 19 | - | #[derive(Deserialize)] |
|
| 20 | - | struct Volume { |
|
| 21 | - | id: String, |
|
| 22 | - | #[serde(rename = "volumeInfo")] |
|
| 23 | - | volume_info: VolumeInfo, |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | #[derive(Deserialize)] |
|
| 27 | - | struct VolumeInfo { |
|
| 28 | - | title: Option<String>, |
|
| 29 | - | #[serde(default)] |
|
| 30 | - | authors: Vec<String>, |
|
| 31 | - | #[serde(default, rename = "industryIdentifiers")] |
|
| 32 | - | identifiers: Vec<Identifier>, |
|
| 33 | - | #[serde(rename = "imageLinks")] |
|
| 34 | - | image_links: Option<ImageLinks>, |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | #[derive(Deserialize)] |
|
| 38 | - | struct Identifier { |
|
| 39 | - | #[serde(rename = "type")] |
|
| 40 | - | kind: String, |
|
| 41 | - | identifier: String, |
|
| 42 | - | } |
|
| 43 | - | ||
| 44 | - | #[derive(Deserialize)] |
|
| 45 | - | struct ImageLinks { |
|
| 46 | - | thumbnail: Option<String>, |
|
| 47 | - | #[serde(rename = "smallThumbnail")] |
|
| 48 | - | small_thumbnail: Option<String>, |
|
| 49 | - | } |
|
| 50 | - | ||
| 51 | - | pub async fn search(query: &str, api_key: Option<&str>) -> Result<Vec<SearchHit>, String> { |
|
| 52 | - | let trimmed = query.trim(); |
|
| 53 | - | if trimmed.is_empty() { |
|
| 54 | - | return Ok(Vec::new()); |
|
| 55 | - | } |
|
| 56 | - | let normalized: String = trimmed |
|
| 57 | - | .chars() |
|
| 58 | - | .filter(|c| !c.is_whitespace() && *c != '-') |
|
| 59 | - | .collect(); |
|
| 60 | - | let is_isbn = matches!(normalized.len(), 10 | 13) |
|
| 61 | - | && normalized |
|
| 62 | - | .chars() |
|
| 63 | - | .all(|c| c.is_ascii_digit() || c == 'X' || c == 'x'); |
|
| 64 | - | let query_str = if is_isbn { |
|
| 65 | - | format!("isbn:{}", normalized.to_uppercase()) |
|
| 66 | - | } else { |
|
| 67 | - | trimmed.to_string() |
|
| 68 | - | }; |
|
| 69 | - | let q = urlencoding::encode(&query_str); |
|
| 70 | - | let mut url = format!( |
|
| 71 | - | "https://www.googleapis.com/books/v1/volumes?q={q}&maxResults=10&printType=books" |
|
| 72 | - | ); |
|
| 73 | - | if let Some(key) = api_key { |
|
| 74 | - | url.push_str(&format!("&key={}", urlencoding::encode(key))); |
|
| 75 | - | } |
|
| 76 | - | ||
| 77 | - | let client = reqwest::Client::builder() |
|
| 78 | - | .timeout(Duration::from_secs(8)) |
|
| 79 | - | .user_agent("andromeda-library/0.1") |
|
| 80 | - | .build() |
|
| 81 | - | .map_err(|e| format!("client build: {e}"))?; |
|
| 82 | - | ||
| 83 | - | let resp = client |
|
| 84 | - | .get(&url) |
|
| 85 | - | .send() |
|
| 86 | - | .await |
|
| 87 | - | .map_err(|e| format!("request: {e}"))?; |
|
| 88 | - | ||
| 89 | - | if !resp.status().is_success() { |
|
| 90 | - | return Err(format!("google books status {}", resp.status())); |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | let data: VolumesResponse = resp |
|
| 94 | - | .json() |
|
| 95 | - | .await |
|
| 96 | - | .map_err(|e| format!("parse: {e}"))?; |
|
| 97 | - | ||
| 98 | - | Ok(data |
|
| 99 | - | .items |
|
| 100 | - | .into_iter() |
|
| 101 | - | .map(|v| { |
|
| 102 | - | let info = v.volume_info; |
|
| 103 | - | let isbn = pick_isbn(&info.identifiers); |
|
| 104 | - | let cover_url = info |
|
| 105 | - | .image_links |
|
| 106 | - | .as_ref() |
|
| 107 | - | .and_then(|l| l.thumbnail.clone().or_else(|| l.small_thumbnail.clone())) |
|
| 108 | - | .map(|u| u.replacen("http://", "https://", 1)); |
|
| 109 | - | SearchHit { |
|
| 110 | - | google_id: v.id, |
|
| 111 | - | title: info.title.unwrap_or_else(|| "Untitled".to_string()), |
|
| 112 | - | authors: info.authors.join(", "), |
|
| 113 | - | isbn, |
|
| 114 | - | cover_url, |
|
| 115 | - | } |
|
| 116 | - | }) |
|
| 117 | - | .collect()) |
|
| 118 | - | } |
|
| 119 | - | ||
| 120 | - | fn pick_isbn(ids: &[Identifier]) -> Option<String> { |
|
| 121 | - | ids.iter() |
|
| 122 | - | .find(|i| i.kind == "ISBN_13") |
|
| 123 | - | .or_else(|| ids.iter().find(|i| i.kind == "ISBN_10")) |
|
| 124 | - | .map(|i| i.identifier.clone()) |
|
| 125 | - | } |
| 1 | - | mod auth; |
|
| 2 | - | mod db; |
|
| 3 | - | mod google_books; |
|
| 4 | - | ||
| 5 | - | use std::sync::{Arc, Mutex}; |
|
| 6 | - | ||
| 7 | - | use andromeda_db::{ |
|
| 8 | - | session::{prune_expired_sessions, SESSION_SCHEMA}, |
|
| 9 | - | Db, |
|
| 10 | - | }; |
|
| 11 | - | use askama::Template; |
|
| 12 | - | use axum::{ |
|
| 13 | - | extract::{Path, Query, State}, |
|
| 14 | - | http::{header, HeaderMap, StatusCode}, |
|
| 15 | - | response::{Html, IntoResponse, Json, Redirect, Response}, |
|
| 16 | - | routing::{get, post}, |
|
| 17 | - | Form, Router, |
|
| 18 | - | }; |
|
| 19 | - | use rusqlite::Connection; |
|
| 20 | - | use rust_embed::Embed; |
|
| 21 | - | use serde::Deserialize; |
|
| 22 | - | ||
| 23 | - | use crate::db::{Book, BookStatus, CategoryLabels, NewBook}; |
|
| 24 | - | ||
| 25 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
|
| 26 | - | pub enum DisplayMode { |
|
| 27 | - | Inline, |
|
| 28 | - | Nav, |
|
| 29 | - | } |
|
| 30 | - | ||
| 31 | - | impl DisplayMode { |
|
| 32 | - | fn parse(s: &str) -> Self { |
|
| 33 | - | match s.to_ascii_lowercase().as_str() { |
|
| 34 | - | "nav" => DisplayMode::Nav, |
|
| 35 | - | _ => DisplayMode::Inline, |
|
| 36 | - | } |
|
| 37 | - | } |
|
| 38 | - | } |
|
| 39 | - | ||
| 40 | - | #[derive(Embed)] |
|
| 41 | - | #[folder = "static/"] |
|
| 42 | - | struct Static; |
|
| 43 | - | ||
| 44 | - | pub struct AppState { |
|
| 45 | - | pub db: Db, |
|
| 46 | - | pub admin_password: Option<String>, |
|
| 47 | - | pub google_books_api_key: Option<String>, |
|
| 48 | - | pub cookie_secure: bool, |
|
| 49 | - | pub base_url: String, |
|
| 50 | - | pub display_mode: DisplayMode, |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | // ── Templates ──────────────────────────────────────────────────────────── |
|
| 54 | - | ||
| 55 | - | struct BookView { |
|
| 56 | - | title: String, |
|
| 57 | - | authors: String, |
|
| 58 | - | cover_url: Option<String>, |
|
| 59 | - | notes: Option<String>, |
|
| 60 | - | } |
|
| 61 | - | ||
| 62 | - | struct SectionView { |
|
| 63 | - | label: String, |
|
| 64 | - | books: Vec<BookView>, |
|
| 65 | - | } |
|
| 66 | - | ||
| 67 | - | struct NavCategory { |
|
| 68 | - | slug: &'static str, |
|
| 69 | - | label: String, |
|
| 70 | - | active: bool, |
|
| 71 | - | } |
|
| 72 | - | ||
| 73 | - | #[derive(Template)] |
|
| 74 | - | #[template(path = "index.html")] |
|
| 75 | - | struct IndexTemplate { |
|
| 76 | - | base_url: String, |
|
| 77 | - | sections: Vec<SectionView>, |
|
| 78 | - | nav_mode: bool, |
|
| 79 | - | nav_categories: Vec<NavCategory>, |
|
| 80 | - | } |
|
| 81 | - | ||
| 82 | - | #[derive(Template)] |
|
| 83 | - | #[template(path = "login.html")] |
|
| 84 | - | struct LoginTemplate { |
|
| 85 | - | error: Option<String>, |
|
| 86 | - | } |
|
| 87 | - | ||
| 88 | - | struct AdminBookRow { |
|
| 89 | - | id: i64, |
|
| 90 | - | title: String, |
|
| 91 | - | authors: String, |
|
| 92 | - | isbn: Option<String>, |
|
| 93 | - | cover_url: Option<String>, |
|
| 94 | - | notes: Option<String>, |
|
| 95 | - | status: String, |
|
| 96 | - | } |
|
| 97 | - | ||
| 98 | - | #[derive(Template)] |
|
| 99 | - | #[template(path = "admin.html")] |
|
| 100 | - | struct AdminTemplate { |
|
| 101 | - | success: Option<String>, |
|
| 102 | - | error: Option<String>, |
|
| 103 | - | books: Vec<AdminBookRow>, |
|
| 104 | - | labels: CategoryLabels, |
|
| 105 | - | library_query: String, |
|
| 106 | - | library_results: Vec<AdminBookRow>, |
|
| 107 | - | library_searched: bool, |
|
| 108 | - | } |
|
| 109 | - | ||
| 110 | - | fn make_book_view(b: Book) -> BookView { |
|
| 111 | - | BookView { |
|
| 112 | - | title: b.title, |
|
| 113 | - | authors: b.authors, |
|
| 114 | - | cover_url: b.cover_url, |
|
| 115 | - | notes: b.notes, |
|
| 116 | - | } |
|
| 117 | - | } |
|
| 118 | - | ||
| 119 | - | #[derive(Deserialize, Default)] |
|
| 120 | - | struct IndexQuery { |
|
| 121 | - | category: Option<String>, |
|
| 122 | - | } |
|
| 123 | - | ||
| 124 | - | async fn index_handler( |
|
| 125 | - | State(state): State<Arc<AppState>>, |
|
| 126 | - | Query(q): Query<IndexQuery>, |
|
| 127 | - | ) -> Response { |
|
| 128 | - | let all_books = db::list_books(&state.db, None).unwrap_or_default(); |
|
| 129 | - | let labels = db::get_category_labels(&state.db).unwrap_or_default(); |
|
| 130 | - | ||
| 131 | - | let section_defs: &[(&'static str, BookStatus)] = &[ |
|
| 132 | - | ("reading", BookStatus::Reading), |
|
| 133 | - | ("read", BookStatus::Read), |
|
| 134 | - | ("want", BookStatus::Want), |
|
| 135 | - | ]; |
|
| 136 | - | ||
| 137 | - | let make_section = |status: BookStatus| -> SectionView { |
|
| 138 | - | let books = all_books |
|
| 139 | - | .iter() |
|
| 140 | - | .filter(|b| b.status == status.as_str()) |
|
| 141 | - | .map(|b| make_book_view(b.clone())) |
|
| 142 | - | .collect(); |
|
| 143 | - | SectionView { |
|
| 144 | - | label: labels.label_for(status).to_string(), |
|
| 145 | - | books, |
|
| 146 | - | } |
|
| 147 | - | }; |
|
| 148 | - | ||
| 149 | - | let nav_mode = matches!(state.display_mode, DisplayMode::Nav); |
|
| 150 | - | ||
| 151 | - | let (sections, nav_categories) = if nav_mode { |
|
| 152 | - | let selected_slug = q |
|
| 153 | - | .category |
|
| 154 | - | .as_deref() |
|
| 155 | - | .and_then(|s| { |
|
| 156 | - | section_defs |
|
| 157 | - | .iter() |
|
| 158 | - | .find(|(slug, _)| *slug == s) |
|
| 159 | - | .map(|(slug, _)| *slug) |
|
| 160 | - | }) |
|
| 161 | - | .unwrap_or(section_defs[0].0); |
|
| 162 | - | ||
| 163 | - | let nav = section_defs |
|
| 164 | - | .iter() |
|
| 165 | - | .map(|(slug, status)| NavCategory { |
|
| 166 | - | slug, |
|
| 167 | - | label: labels.label_for(*status).to_string(), |
|
| 168 | - | active: *slug == selected_slug, |
|
| 169 | - | }) |
|
| 170 | - | .collect(); |
|
| 171 | - | ||
| 172 | - | let (_, status) = section_defs |
|
| 173 | - | .iter() |
|
| 174 | - | .find(|(slug, _)| *slug == selected_slug) |
|
| 175 | - | .copied() |
|
| 176 | - | .unwrap(); |
|
| 177 | - | (vec![make_section(status)], nav) |
|
| 178 | - | } else { |
|
| 179 | - | let secs = section_defs |
|
| 180 | - | .iter() |
|
| 181 | - | .filter_map(|(_, status)| { |
|
| 182 | - | let s = make_section(*status); |
|
| 183 | - | if s.books.is_empty() { None } else { Some(s) } |
|
| 184 | - | }) |
|
| 185 | - | .collect(); |
|
| 186 | - | (secs, Vec::new()) |
|
| 187 | - | }; |
|
| 188 | - | ||
| 189 | - | Html( |
|
| 190 | - | IndexTemplate { |
|
| 191 | - | base_url: state.base_url.clone(), |
|
| 192 | - | sections, |
|
| 193 | - | nav_mode, |
|
| 194 | - | nav_categories, |
|
| 195 | - | } |
|
| 196 | - | .render() |
|
| 197 | - | .unwrap(), |
|
| 198 | - | ) |
|
| 199 | - | .into_response() |
|
| 200 | - | } |
|
| 201 | - | ||
| 202 | - | async fn static_handler(Path(path): Path<String>) -> Response { |
|
| 203 | - | match Static::get(&path) { |
|
| 204 | - | Some(file) => { |
|
| 205 | - | let mime = mime_guess::from_path(&path).first_or_octet_stream(); |
|
| 206 | - | ([(header::CONTENT_TYPE, mime.as_ref())], file.data.to_vec()).into_response() |
|
| 207 | - | } |
|
| 208 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 209 | - | } |
|
| 210 | - | } |
|
| 211 | - | ||
| 212 | - | // ── Admin ──────────────────────────────────────────────────────────────── |
|
| 213 | - | ||
| 214 | - | #[derive(Deserialize, Default)] |
|
| 215 | - | struct FlashQuery { |
|
| 216 | - | error: Option<String>, |
|
| 217 | - | } |
|
| 218 | - | ||
| 219 | - | #[derive(Deserialize)] |
|
| 220 | - | struct LoginForm { |
|
| 221 | - | password: String, |
|
| 222 | - | } |
|
| 223 | - | ||
| 224 | - | async fn login_get_handler(Query(q): Query<FlashQuery>) -> Response { |
|
| 225 | - | Html(LoginTemplate { error: q.error }.render().unwrap()).into_response() |
|
| 226 | - | } |
|
| 227 | - | ||
| 228 | - | async fn login_post_handler( |
|
| 229 | - | State(state): State<Arc<AppState>>, |
|
| 230 | - | Form(form): Form<LoginForm>, |
|
| 231 | - | ) -> Response { |
|
| 232 | - | let admin_password = match &state.admin_password { |
|
| 233 | - | Some(p) => p, |
|
| 234 | - | None => { |
|
| 235 | - | return Redirect::to("/admin/login?error=No+admin+password+configured").into_response(); |
|
| 236 | - | } |
|
| 237 | - | }; |
|
| 238 | - | if !auth::verify_password(&form.password, admin_password) { |
|
| 239 | - | return Redirect::to("/admin/login?error=Invalid+password").into_response(); |
|
| 240 | - | } |
|
| 241 | - | ||
| 242 | - | let token = auth::generate_session_token(); |
|
| 243 | - | if let Err(e) = auth::create_session(&state.db, &token) { |
|
| 244 | - | tracing::error!("failed to create session: {e}"); |
|
| 245 | - | return Redirect::to("/admin/login?error=Session+error").into_response(); |
|
| 246 | - | } |
|
| 247 | - | let _ = prune_expired_sessions(&state.db); |
|
| 248 | - | ||
| 249 | - | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 250 | - | let mut resp = Redirect::to("/admin").into_response(); |
|
| 251 | - | resp.headers_mut() |
|
| 252 | - | .insert(header::SET_COOKIE, cookie.parse().unwrap()); |
|
| 253 | - | resp |
|
| 254 | - | } |
|
| 255 | - | ||
| 256 | - | async fn logout_handler(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response { |
|
| 257 | - | if let Some(token) = auth::extract_session_cookie(&headers) { |
|
| 258 | - | auth::delete_session(&state.db, &token); |
|
| 259 | - | } |
|
| 260 | - | let mut resp = Redirect::to("/admin/login").into_response(); |
|
| 261 | - | resp.headers_mut().insert( |
|
| 262 | - | header::SET_COOKIE, |
|
| 263 | - | auth::clear_session_cookie().parse().unwrap(), |
|
| 264 | - | ); |
|
| 265 | - | resp |
|
| 266 | - | } |
|
| 267 | - | ||
| 268 | - | fn book_to_row(b: Book) -> AdminBookRow { |
|
| 269 | - | AdminBookRow { |
|
| 270 | - | id: b.id, |
|
| 271 | - | title: b.title, |
|
| 272 | - | authors: b.authors, |
|
| 273 | - | isbn: b.isbn, |
|
| 274 | - | cover_url: b.cover_url, |
|
| 275 | - | notes: b.notes, |
|
| 276 | - | status: b.status, |
|
| 277 | - | } |
|
| 278 | - | } |
|
| 279 | - | ||
| 280 | - | #[derive(Deserialize, Default)] |
|
| 281 | - | struct AdminQuery { |
|
| 282 | - | error: Option<String>, |
|
| 283 | - | success: Option<String>, |
|
| 284 | - | q: Option<String>, |
|
| 285 | - | } |
|
| 286 | - | ||
| 287 | - | async fn admin_handler( |
|
| 288 | - | _session: auth::AuthSession, |
|
| 289 | - | State(state): State<Arc<AppState>>, |
|
| 290 | - | Query(q): Query<AdminQuery>, |
|
| 291 | - | ) -> Response { |
|
| 292 | - | let books = db::list_books(&state.db, None) |
|
| 293 | - | .unwrap_or_default() |
|
| 294 | - | .into_iter() |
|
| 295 | - | .map(book_to_row) |
|
| 296 | - | .collect(); |
|
| 297 | - | let labels = db::get_category_labels(&state.db).unwrap_or_default(); |
|
| 298 | - | ||
| 299 | - | let library_query = q.q.unwrap_or_default(); |
|
| 300 | - | let library_searched = !library_query.trim().is_empty(); |
|
| 301 | - | let library_results = if library_searched { |
|
| 302 | - | db::search_books(&state.db, &library_query) |
|
| 303 | - | .unwrap_or_default() |
|
| 304 | - | .into_iter() |
|
| 305 | - | .map(book_to_row) |
|
| 306 | - | .collect() |
|
| 307 | - | } else { |
|
| 308 | - | Vec::new() |
|
| 309 | - | }; |
|
| 310 | - | ||
| 311 | - | Html( |
|
| 312 | - | AdminTemplate { |
|
| 313 | - | success: q.success, |
|
| 314 | - | error: q.error, |
|
| 315 | - | books, |
|
| 316 | - | labels, |
|
| 317 | - | library_query, |
|
| 318 | - | library_results, |
|
| 319 | - | library_searched, |
|
| 320 | - | } |
|
| 321 | - | .render() |
|
| 322 | - | .unwrap(), |
|
| 323 | - | ) |
|
| 324 | - | .into_response() |
|
| 325 | - | } |
|
| 326 | - | ||
| 327 | - | #[derive(Deserialize)] |
|
| 328 | - | struct CategoryLabelsForm { |
|
| 329 | - | reading: String, |
|
| 330 | - | read: String, |
|
| 331 | - | want: String, |
|
| 332 | - | } |
|
| 333 | - | ||
| 334 | - | async fn admin_update_labels( |
|
| 335 | - | _session: auth::AuthSession, |
|
| 336 | - | State(state): State<Arc<AppState>>, |
|
| 337 | - | Form(form): Form<CategoryLabelsForm>, |
|
| 338 | - | ) -> Response { |
|
| 339 | - | let reading = form.reading.trim(); |
|
| 340 | - | let read = form.read.trim(); |
|
| 341 | - | let want = form.want.trim(); |
|
| 342 | - | if reading.is_empty() || read.is_empty() || want.is_empty() { |
|
| 343 | - | return Redirect::to("/admin?error=Labels+cannot+be+empty").into_response(); |
|
| 344 | - | } |
|
| 345 | - | if let Err(e) = db::set_setting(&state.db, "category_label.reading", reading) |
|
| 346 | - | .and_then(|_| db::set_setting(&state.db, "category_label.read", read)) |
|
| 347 | - | .and_then(|_| db::set_setting(&state.db, "category_label.want", want)) |
|
| 348 | - | { |
|
| 349 | - | tracing::error!("save labels: {e}"); |
|
| 350 | - | return Redirect::to("/admin?error=Failed+to+save+labels").into_response(); |
|
| 351 | - | } |
|
| 352 | - | Redirect::to("/admin?success=Labels+updated").into_response() |
|
| 353 | - | } |
|
| 354 | - | ||
| 355 | - | #[derive(Deserialize)] |
|
| 356 | - | struct SearchQuery { |
|
| 357 | - | q: String, |
|
| 358 | - | } |
|
| 359 | - | ||
| 360 | - | async fn admin_search_handler( |
|
| 361 | - | _session: auth::AuthSession, |
|
| 362 | - | State(state): State<Arc<AppState>>, |
|
| 363 | - | Query(q): Query<SearchQuery>, |
|
| 364 | - | ) -> Response { |
|
| 365 | - | match google_books::search(&q.q, state.google_books_api_key.as_deref()).await { |
|
| 366 | - | Ok(hits) => Json(hits).into_response(), |
|
| 367 | - | Err(e) => { |
|
| 368 | - | tracing::warn!("google books search failed: {e}"); |
|
| 369 | - | (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": e }))) |
|
| 370 | - | .into_response() |
|
| 371 | - | } |
|
| 372 | - | } |
|
| 373 | - | } |
|
| 374 | - | ||
| 375 | - | #[derive(Deserialize)] |
|
| 376 | - | struct AddBookForm { |
|
| 377 | - | google_id: Option<String>, |
|
| 378 | - | title: String, |
|
| 379 | - | authors: String, |
|
| 380 | - | isbn: Option<String>, |
|
| 381 | - | cover_url: Option<String>, |
|
| 382 | - | status: String, |
|
| 383 | - | } |
|
| 384 | - | ||
| 385 | - | async fn admin_add_book( |
|
| 386 | - | _session: auth::AuthSession, |
|
| 387 | - | State(state): State<Arc<AppState>>, |
|
| 388 | - | Form(form): Form<AddBookForm>, |
|
| 389 | - | ) -> Response { |
|
| 390 | - | let Some(status) = BookStatus::parse(&form.status) else { |
|
| 391 | - | return Redirect::to("/admin?error=Invalid+status").into_response(); |
|
| 392 | - | }; |
|
| 393 | - | let new_book = NewBook { |
|
| 394 | - | google_id: form.google_id.filter(|s| !s.is_empty()), |
|
| 395 | - | title: form.title, |
|
| 396 | - | authors: form.authors, |
|
| 397 | - | isbn: form.isbn.filter(|s| !s.is_empty()), |
|
| 398 | - | cover_url: form.cover_url.filter(|s| !s.is_empty()), |
|
| 399 | - | notes: None, |
|
| 400 | - | status, |
|
| 401 | - | }; |
|
| 402 | - | match db::insert_book(&state.db, &new_book) { |
|
| 403 | - | Ok(_) => Redirect::to("/admin?success=Book+added").into_response(), |
|
| 404 | - | Err(e) => { |
|
| 405 | - | tracing::error!("insert book: {e}"); |
|
| 406 | - | Redirect::to("/admin?error=Failed+to+add+book").into_response() |
|
| 407 | - | } |
|
| 408 | - | } |
|
| 409 | - | } |
|
| 410 | - | ||
| 411 | - | #[derive(Deserialize)] |
|
| 412 | - | struct UpdateStatusForm { |
|
| 413 | - | status: String, |
|
| 414 | - | } |
|
| 415 | - | ||
| 416 | - | async fn admin_update_status( |
|
| 417 | - | _session: auth::AuthSession, |
|
| 418 | - | State(state): State<Arc<AppState>>, |
|
| 419 | - | Path(id): Path<i64>, |
|
| 420 | - | Form(form): Form<UpdateStatusForm>, |
|
| 421 | - | ) -> Response { |
|
| 422 | - | let Some(status) = BookStatus::parse(&form.status) else { |
|
| 423 | - | return Redirect::to("/admin?error=Invalid+status").into_response(); |
|
| 424 | - | }; |
|
| 425 | - | let _ = db::update_book_status(&state.db, id, status); |
|
| 426 | - | Redirect::to("/admin?success=Status+updated").into_response() |
|
| 427 | - | } |
|
| 428 | - | ||
| 429 | - | #[derive(Deserialize)] |
|
| 430 | - | struct UpdateNotesForm { |
|
| 431 | - | notes: String, |
|
| 432 | - | } |
|
| 433 | - | ||
| 434 | - | async fn admin_update_notes( |
|
| 435 | - | _session: auth::AuthSession, |
|
| 436 | - | State(state): State<Arc<AppState>>, |
|
| 437 | - | Path(id): Path<i64>, |
|
| 438 | - | Form(form): Form<UpdateNotesForm>, |
|
| 439 | - | ) -> Response { |
|
| 440 | - | let trimmed = form.notes.trim(); |
|
| 441 | - | let notes = if trimmed.is_empty() { None } else { Some(trimmed) }; |
|
| 442 | - | let _ = db::update_book_notes(&state.db, id, notes); |
|
| 443 | - | Redirect::to("/admin?success=Notes+saved").into_response() |
|
| 444 | - | } |
|
| 445 | - | ||
| 446 | - | async fn admin_delete_book( |
|
| 447 | - | _session: auth::AuthSession, |
|
| 448 | - | State(state): State<Arc<AppState>>, |
|
| 449 | - | Path(id): Path<i64>, |
|
| 450 | - | ) -> Response { |
|
| 451 | - | let _ = db::delete_book(&state.db, id); |
|
| 452 | - | Redirect::to("/admin?success=Book+removed").into_response() |
|
| 453 | - | } |
|
| 454 | - | ||
| 455 | - | // ── JSON API ───────────────────────────────────────────────────────────── |
|
| 456 | - | ||
| 457 | - | #[derive(Deserialize)] |
|
| 458 | - | struct ListBooksQuery { |
|
| 459 | - | status: Option<String>, |
|
| 460 | - | } |
|
| 461 | - | ||
| 462 | - | async fn api_list_books( |
|
| 463 | - | State(state): State<Arc<AppState>>, |
|
| 464 | - | Query(q): Query<ListBooksQuery>, |
|
| 465 | - | ) -> Response { |
|
| 466 | - | let status = match q.status.as_deref() { |
|
| 467 | - | None | Some("") | Some("all") => None, |
|
| 468 | - | Some(s) => match BookStatus::parse(s) { |
|
| 469 | - | Some(st) => Some(st), |
|
| 470 | - | None => { |
|
| 471 | - | return ( |
|
| 472 | - | StatusCode::BAD_REQUEST, |
|
| 473 | - | Json(serde_json::json!({ "error": "invalid status" })), |
|
| 474 | - | ) |
|
| 475 | - | .into_response(); |
|
| 476 | - | } |
|
| 477 | - | }, |
|
| 478 | - | }; |
|
| 479 | - | match db::list_books(&state.db, status) { |
|
| 480 | - | Ok(books) => Json(books).into_response(), |
|
| 481 | - | Err(e) => { |
|
| 482 | - | tracing::error!("list books: {e}"); |
|
| 483 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 484 | - | } |
|
| 485 | - | } |
|
| 486 | - | } |
|
| 487 | - | ||
| 488 | - | async fn api_get_book( |
|
| 489 | - | State(state): State<Arc<AppState>>, |
|
| 490 | - | Path(id): Path<i64>, |
|
| 491 | - | ) -> Response { |
|
| 492 | - | match db::get_book(&state.db, id) { |
|
| 493 | - | Ok(Some(book)) => Json(book).into_response(), |
|
| 494 | - | Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "not found" }))) |
|
| 495 | - | .into_response(), |
|
| 496 | - | Err(e) => { |
|
| 497 | - | tracing::error!("get book: {e}"); |
|
| 498 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 499 | - | } |
|
| 500 | - | } |
|
| 501 | - | } |
|
| 502 | - | ||
| 503 | - | // ── main ───────────────────────────────────────────────────────────────── |
|
| 504 | - | ||
| 505 | - | #[tokio::main] |
|
| 506 | - | async fn main() { |
|
| 507 | - | dotenvy::dotenv().ok(); |
|
| 508 | - | tracing_subscriber::fmt() |
|
| 509 | - | .with_env_filter( |
|
| 510 | - | tracing_subscriber::EnvFilter::try_from_default_env() |
|
| 511 | - | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,library=info")), |
|
| 512 | - | ) |
|
| 513 | - | .init(); |
|
| 514 | - | ||
| 515 | - | let db_path = |
|
| 516 | - | std::env::var("LIBRARY_DB_PATH").unwrap_or_else(|_| "library.sqlite".to_string()); |
|
| 517 | - | let conn = Connection::open(&db_path).expect("open sqlite"); |
|
| 518 | - | conn.execute_batch(SESSION_SCHEMA).expect("session schema"); |
|
| 519 | - | conn.execute_batch(db::BOOKS_SCHEMA).expect("books schema"); |
|
| 520 | - | let db: Db = Arc::new(Mutex::new(conn)); |
|
| 521 | - | ||
| 522 | - | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 523 | - | .map(|v| v.eq_ignore_ascii_case("true")) |
|
| 524 | - | .unwrap_or(false); |
|
| 525 | - | let base_url = |
|
| 526 | - | std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); |
|
| 527 | - | ||
| 528 | - | let google_books_api_key = std::env::var("GOOGLE_BOOKS_API_KEY") |
|
| 529 | - | .ok() |
|
| 530 | - | .filter(|s| !s.is_empty()); |
|
| 531 | - | ||
| 532 | - | let display_mode = std::env::var("LIBRARY_DISPLAY_MODE") |
|
| 533 | - | .map(|v| DisplayMode::parse(&v)) |
|
| 534 | - | .unwrap_or(DisplayMode::Inline); |
|
| 535 | - | ||
| 536 | - | let state = Arc::new(AppState { |
|
| 537 | - | db, |
|
| 538 | - | admin_password: std::env::var("ADMIN_PASSWORD").ok(), |
|
| 539 | - | google_books_api_key, |
|
| 540 | - | cookie_secure, |
|
| 541 | - | base_url, |
|
| 542 | - | display_mode, |
|
| 543 | - | }); |
|
| 544 | - | ||
| 545 | - | let admin_router = Router::new() |
|
| 546 | - | .route("/admin", get(admin_handler)) |
|
| 547 | - | .route( |
|
| 548 | - | "/admin/login", |
|
| 549 | - | get(login_get_handler).post(login_post_handler), |
|
| 550 | - | ) |
|
| 551 | - | .route("/admin/logout", get(logout_handler)) |
|
| 552 | - | .route("/admin/search", get(admin_search_handler)) |
|
| 553 | - | .route("/admin/categories/labels", post(admin_update_labels)) |
|
| 554 | - | .route("/admin/add", post(admin_add_book)) |
|
| 555 | - | .route("/admin/books/{id}/status", post(admin_update_status)) |
|
| 556 | - | .route("/admin/books/{id}/notes", post(admin_update_notes)) |
|
| 557 | - | .route("/admin/books/{id}/delete", post(admin_delete_book)); |
|
| 558 | - | ||
| 559 | - | let api_router = Router::new() |
|
| 560 | - | .route("/api/books", get(api_list_books)) |
|
| 561 | - | .route("/api/books/{id}", get(api_get_book)); |
|
| 562 | - | ||
| 563 | - | let app = Router::new() |
|
| 564 | - | .route("/", get(index_handler)) |
|
| 565 | - | .route("/static/{*path}", get(static_handler)) |
|
| 566 | - | .merge(admin_router) |
|
| 567 | - | .merge(api_router) |
|
| 568 | - | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 569 | - | .with_state(state); |
|
| 570 | - | ||
| 571 | - | let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); |
|
| 572 | - | let port: u16 = std::env::var("PORT") |
|
| 573 | - | .ok() |
|
| 574 | - | .and_then(|v| v.parse().ok()) |
|
| 575 | - | .unwrap_or(3000); |
|
| 576 | - | let addr = format!("{host}:{port}"); |
|
| 577 | - | let listener = tokio::net::TcpListener::bind(&addr) |
|
| 578 | - | .await |
|
| 579 | - | .unwrap_or_else(|_| panic!("Failed to bind to {addr}")); |
|
| 580 | - | ||
| 581 | - | tracing::info!("Library server running on http://{host}:{port}"); |
|
| 582 | - | axum::serve(listener, app).await.unwrap(); |
|
| 583 | - | } |
|
| 584 | - |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" /> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" /> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" /> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest" /> |
|
| 13 | - | <title>Library | Admin</title> |
|
| 14 | - | </head> |
|
| 15 | - | <body> |
|
| 16 | - | <div class="header"> |
|
| 17 | - | <a href="/" class="logo"><h1>LIBRARY</h1></a> |
|
| 18 | - | <nav class="links"> |
|
| 19 | - | <a href="/admin/logout">logout</a> |
|
| 20 | - | </nav> |
|
| 21 | - | </div> |
|
| 22 | - | ||
| 23 | - | {% if let Some(msg) = success %} |
|
| 24 | - | <p class="success">{{ msg }}</p> |
|
| 25 | - | {% endif %} |
|
| 26 | - | {% if let Some(err) = error %} |
|
| 27 | - | <p class="error">{{ err }}</p> |
|
| 28 | - | {% endif %} |
|
| 29 | - | ||
| 30 | - | <section class="admin-form"> |
|
| 31 | - | <h3>Category Labels</h3> |
|
| 32 | - | <form method="POST" action="/admin/categories/labels" class="labels-form"> |
|
| 33 | - | <label> |
|
| 34 | - | <span>Reading</span> |
|
| 35 | - | <input type="text" name="reading" value="{{ labels.reading }}" required /> |
|
| 36 | - | </label> |
|
| 37 | - | <label> |
|
| 38 | - | <span>Read</span> |
|
| 39 | - | <input type="text" name="read" value="{{ labels.read }}" required /> |
|
| 40 | - | </label> |
|
| 41 | - | <label> |
|
| 42 | - | <span>Want to Read</span> |
|
| 43 | - | <input type="text" name="want" value="{{ labels.want }}" required /> |
|
| 44 | - | </label> |
|
| 45 | - | <button type="submit">Save labels</button> |
|
| 46 | - | </form> |
|
| 47 | - | </section> |
|
| 48 | - | ||
| 49 | - | <section class="admin-form"> |
|
| 50 | - | <h3>Search Books (Google)</h3> |
|
| 51 | - | <div class="search-row"> |
|
| 52 | - | <input type="text" id="book-query" placeholder="title, author, isbn" /> |
|
| 53 | - | <button type="button" id="search-btn" onclick="searchBooks()">Search</button> |
|
| 54 | - | <button type="button" id="scan-btn" onclick="openScanner()" hidden>Scan</button> |
|
| 55 | - | </div> |
|
| 56 | - | <div id="search-status" class="search-status" style="display:none;"></div> |
|
| 57 | - | <div id="search-results" class="search-results"></div> |
|
| 58 | - | </section> |
|
| 59 | - | ||
| 60 | - | <section class="admin-form"> |
|
| 61 | - | <h3>Search Library</h3> |
|
| 62 | - | <form method="GET" action="/admin" class="search-row"> |
|
| 63 | - | <input type="text" name="q" placeholder="title, author, isbn" value="{{ library_query }}" /> |
|
| 64 | - | <button type="submit">Search</button> |
|
| 65 | - | {% if library_searched %} |
|
| 66 | - | <a href="/admin" class="hint">clear</a> |
|
| 67 | - | {% endif %} |
|
| 68 | - | </form> |
|
| 69 | - | {% if library_searched %} |
|
| 70 | - | {% if library_results.is_empty() %} |
|
| 71 | - | <p class="hint">No matches.</p> |
|
| 72 | - | {% else %} |
|
| 73 | - | <div class="books-list"> |
|
| 74 | - | {% for b in library_results %} |
|
| 75 | - | <div class="book-card admin"> |
|
| 76 | - | {% if let Some(url) = b.cover_url %} |
|
| 77 | - | <img class="book-cover" src="{{ url }}" alt="" loading="lazy" /> |
|
| 78 | - | {% else %} |
|
| 79 | - | <div class="book-cover placeholder"></div> |
|
| 80 | - | {% endif %} |
|
| 81 | - | <div class="book-info"> |
|
| 82 | - | <h3 class="book-title">{{ b.title }}</h3> |
|
| 83 | - | <p class="book-authors">{{ b.authors }}</p> |
|
| 84 | - | {% if let Some(isbn) = b.isbn %} |
|
| 85 | - | <p class="book-meta">ISBN: {{ isbn }}</p> |
|
| 86 | - | {% endif %} |
|
| 87 | - | <form method="POST" action="/admin/books/{{ b.id }}/status" class="inline"> |
|
| 88 | - | <select name="status" onchange="this.form.submit()"> |
|
| 89 | - | <option value="read"{% if b.status == "read" %} selected{% endif %}>{{ labels.read }}</option> |
|
| 90 | - | <option value="reading"{% if b.status == "reading" %} selected{% endif %}>{{ labels.reading }}</option> |
|
| 91 | - | <option value="want"{% if b.status == "want" %} selected{% endif %}>{{ labels.want }}</option> |
|
| 92 | - | </select> |
|
| 93 | - | <noscript><button type="submit">Save</button></noscript> |
|
| 94 | - | </form> |
|
| 95 | - | <form method="POST" action="/admin/books/{{ b.id }}/notes" class="inline notes-form"> |
|
| 96 | - | <textarea name="notes" rows="5" placeholder="notes">{% if let Some(n) = b.notes %}{{ n }}{% endif %}</textarea> |
|
| 97 | - | <button type="submit">Save notes</button> |
|
| 98 | - | </form> |
|
| 99 | - | <form method="POST" action="/admin/books/{{ b.id }}/delete" class="inline"> |
|
| 100 | - | <button type="submit" class="danger">Delete</button> |
|
| 101 | - | </form> |
|
| 102 | - | </div> |
|
| 103 | - | </div> |
|
| 104 | - | {% endfor %} |
|
| 105 | - | </div> |
|
| 106 | - | {% endif %} |
|
| 107 | - | {% endif %} |
|
| 108 | - | </section> |
|
| 109 | - | ||
| 110 | - | <div id="scan-modal" class="scan-modal" hidden> |
|
| 111 | - | <div class="scan-inner"> |
|
| 112 | - | <video id="scan-video" playsinline muted></video> |
|
| 113 | - | <p id="scan-status" class="scan-status">Point camera at barcode</p> |
|
| 114 | - | <button type="button" onclick="closeScanner()">Cancel</button> |
|
| 115 | - | </div> |
|
| 116 | - | </div> |
|
| 117 | - | ||
| 118 | - | <section class="admin-subs"> |
|
| 119 | - | <h3>Library ({{ books.len() }})</h3> |
|
| 120 | - | {% if books.is_empty() %} |
|
| 121 | - | <p class="hint">No books yet. Search above to add one.</p> |
|
| 122 | - | {% else %} |
|
| 123 | - | <div class="books-list"> |
|
| 124 | - | {% for b in books %} |
|
| 125 | - | <div class="book-card admin"> |
|
| 126 | - | {% if let Some(url) = b.cover_url %} |
|
| 127 | - | <img class="book-cover" src="{{ url }}" alt="" loading="lazy" /> |
|
| 128 | - | {% else %} |
|
| 129 | - | <div class="book-cover placeholder"></div> |
|
| 130 | - | {% endif %} |
|
| 131 | - | <div class="book-info"> |
|
| 132 | - | <h3 class="book-title">{{ b.title }}</h3> |
|
| 133 | - | <p class="book-authors">{{ b.authors }}</p> |
|
| 134 | - | {% if let Some(isbn) = b.isbn %} |
|
| 135 | - | <p class="book-meta">ISBN: {{ isbn }}</p> |
|
| 136 | - | {% endif %} |
|
| 137 | - | <form method="POST" action="/admin/books/{{ b.id }}/status" class="inline"> |
|
| 138 | - | <select name="status" onchange="this.form.submit()"> |
|
| 139 | - | <option value="read"{% if b.status == "read" %} selected{% endif %}>{{ labels.read }}</option> |
|
| 140 | - | <option value="reading"{% if b.status == "reading" %} selected{% endif %}>{{ labels.reading }}</option> |
|
| 141 | - | <option value="want"{% if b.status == "want" %} selected{% endif %}>{{ labels.want }}</option> |
|
| 142 | - | </select> |
|
| 143 | - | <noscript><button type="submit">Save</button></noscript> |
|
| 144 | - | </form> |
|
| 145 | - | <form method="POST" action="/admin/books/{{ b.id }}/notes" class="inline notes-form"> |
|
| 146 | - | <textarea name="notes" rows="5" placeholder="notes">{% if let Some(n) = b.notes %}{{ n }}{% endif %}</textarea> |
|
| 147 | - | <button type="submit">Save notes</button> |
|
| 148 | - | </form> |
|
| 149 | - | <form method="POST" action="/admin/books/{{ b.id }}/delete" class="inline"> |
|
| 150 | - | <button type="submit" class="danger">Delete</button> |
|
| 151 | - | </form> |
|
| 152 | - | </div> |
|
| 153 | - | </div> |
|
| 154 | - | {% endfor %} |
|
| 155 | - | </div> |
|
| 156 | - | {% endif %} |
|
| 157 | - | </section> |
|
| 158 | - | ||
| 159 | - | <div id="category-labels-data" |
|
| 160 | - | data-want="{{ labels.want }}" |
|
| 161 | - | data-reading="{{ labels.reading }}" |
|
| 162 | - | data-read="{{ labels.read }}" |
|
| 163 | - | hidden></div> |
|
| 164 | - | ||
| 165 | - | <script src="https://unpkg.com/@zxing/browser@0.1.5/umd/zxing-browser.min.js"></script> |
|
| 166 | - | <script> |
|
| 167 | - | (function() { |
|
| 168 | - | const el = document.getElementById('category-labels-data'); |
|
| 169 | - | window.__categoryLabels = { |
|
| 170 | - | want: el.dataset.want, |
|
| 171 | - | reading: el.dataset.reading, |
|
| 172 | - | read: el.dataset.read, |
|
| 173 | - | }; |
|
| 174 | - | })(); |
|
| 175 | - | ||
| 176 | - | async function searchBooks() { |
|
| 177 | - | const q = document.getElementById('book-query').value.trim(); |
|
| 178 | - | if (!q) return; |
|
| 179 | - | const btn = document.getElementById('search-btn'); |
|
| 180 | - | const status = document.getElementById('search-status'); |
|
| 181 | - | const results = document.getElementById('search-results'); |
|
| 182 | - | btn.disabled = true; |
|
| 183 | - | btn.textContent = 'Searching...'; |
|
| 184 | - | status.style.display = 'none'; |
|
| 185 | - | results.innerHTML = ''; |
|
| 186 | - | try { |
|
| 187 | - | const resp = await fetch('/admin/search?q=' + encodeURIComponent(q)); |
|
| 188 | - | const data = await resp.json(); |
|
| 189 | - | if (!resp.ok) { |
|
| 190 | - | status.textContent = data.error || 'Search failed'; |
|
| 191 | - | status.className = 'search-status error'; |
|
| 192 | - | status.style.display = 'block'; |
|
| 193 | - | return; |
|
| 194 | - | } |
|
| 195 | - | if (!data.length) { |
|
| 196 | - | status.textContent = 'No results'; |
|
| 197 | - | status.className = 'search-status'; |
|
| 198 | - | status.style.display = 'block'; |
|
| 199 | - | return; |
|
| 200 | - | } |
|
| 201 | - | data.forEach(function(hit) { |
|
| 202 | - | results.appendChild(renderHit(hit)); |
|
| 203 | - | }); |
|
| 204 | - | } catch (e) { |
|
| 205 | - | status.textContent = 'Request failed'; |
|
| 206 | - | status.className = 'search-status error'; |
|
| 207 | - | status.style.display = 'block'; |
|
| 208 | - | } finally { |
|
| 209 | - | btn.disabled = false; |
|
| 210 | - | btn.textContent = 'Search'; |
|
| 211 | - | } |
|
| 212 | - | } |
|
| 213 | - | ||
| 214 | - | function renderHit(hit) { |
|
| 215 | - | const card = document.createElement('form'); |
|
| 216 | - | card.method = 'POST'; |
|
| 217 | - | card.action = '/admin/add'; |
|
| 218 | - | card.className = 'book-card hit'; |
|
| 219 | - | ||
| 220 | - | const cover = document.createElement('div'); |
|
| 221 | - | if (hit.cover_url) { |
|
| 222 | - | const img = document.createElement('img'); |
|
| 223 | - | img.src = hit.cover_url; |
|
| 224 | - | img.className = 'book-cover'; |
|
| 225 | - | img.loading = 'lazy'; |
|
| 226 | - | card.appendChild(img); |
|
| 227 | - | } else { |
|
| 228 | - | cover.className = 'book-cover placeholder'; |
|
| 229 | - | card.appendChild(cover); |
|
| 230 | - | } |
|
| 231 | - | ||
| 232 | - | const info = document.createElement('div'); |
|
| 233 | - | info.className = 'book-info'; |
|
| 234 | - | info.innerHTML = |
|
| 235 | - | '<h3 class="book-title"></h3>' + |
|
| 236 | - | '<p class="book-authors"></p>' + |
|
| 237 | - | (hit.isbn ? '<p class="book-meta">ISBN: ' + escapeHtml(hit.isbn) + '</p>' : ''); |
|
| 238 | - | info.querySelector('.book-title').textContent = hit.title; |
|
| 239 | - | info.querySelector('.book-authors').textContent = hit.authors; |
|
| 240 | - | ||
| 241 | - | const hidden = function(name, value) { |
|
| 242 | - | const el = document.createElement('input'); |
|
| 243 | - | el.type = 'hidden'; |
|
| 244 | - | el.name = name; |
|
| 245 | - | el.value = value || ''; |
|
| 246 | - | return el; |
|
| 247 | - | }; |
|
| 248 | - | info.appendChild(hidden('google_id', hit.google_id)); |
|
| 249 | - | info.appendChild(hidden('title', hit.title)); |
|
| 250 | - | info.appendChild(hidden('authors', hit.authors)); |
|
| 251 | - | info.appendChild(hidden('isbn', hit.isbn)); |
|
| 252 | - | info.appendChild(hidden('cover_url', hit.cover_url)); |
|
| 253 | - | ||
| 254 | - | const select = document.createElement('select'); |
|
| 255 | - | select.name = 'status'; |
|
| 256 | - | const labels = window.__categoryLabels || { want: 'Want to Read', reading: 'Reading', read: 'Read' }; |
|
| 257 | - | ['want', 'reading', 'read'].forEach(function(s) { |
|
| 258 | - | const o = document.createElement('option'); |
|
| 259 | - | o.value = s; |
|
| 260 | - | o.textContent = labels[s]; |
|
| 261 | - | select.appendChild(o); |
|
| 262 | - | }); |
|
| 263 | - | info.appendChild(select); |
|
| 264 | - | ||
| 265 | - | const btn = document.createElement('button'); |
|
| 266 | - | btn.type = 'submit'; |
|
| 267 | - | btn.textContent = 'Add'; |
|
| 268 | - | info.appendChild(btn); |
|
| 269 | - | ||
| 270 | - | card.appendChild(info); |
|
| 271 | - | return card; |
|
| 272 | - | } |
|
| 273 | - | ||
| 274 | - | function escapeHtml(s) { |
|
| 275 | - | return String(s || '').replace(/[&<>"']/g, function(c) { |
|
| 276 | - | return ({'&':'&','<':'<','>':'>','"':'"',"'":"'"})[c]; |
|
| 277 | - | }); |
|
| 278 | - | } |
|
| 279 | - | ||
| 280 | - | let scanStream = null; |
|
| 281 | - | let scanRaf = null; |
|
| 282 | - | let zxingControls = null; |
|
| 283 | - | ||
| 284 | - | const hasNativeBarcode = 'BarcodeDetector' in window; |
|
| 285 | - | const hasZxing = typeof ZXingBrowser !== 'undefined'; |
|
| 286 | - | ||
| 287 | - | (function initScan() { |
|
| 288 | - | if ((hasNativeBarcode || hasZxing) && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { |
|
| 289 | - | document.getElementById('scan-btn').hidden = false; |
|
| 290 | - | } |
|
| 291 | - | })(); |
|
| 292 | - | ||
| 293 | - | async function openScanner() { |
|
| 294 | - | const modal = document.getElementById('scan-modal'); |
|
| 295 | - | const video = document.getElementById('scan-video'); |
|
| 296 | - | const status = document.getElementById('scan-status'); |
|
| 297 | - | status.textContent = 'Point camera at barcode'; |
|
| 298 | - | modal.hidden = false; |
|
| 299 | - | ||
| 300 | - | const onHit = (isbn) => { |
|
| 301 | - | closeScanner(); |
|
| 302 | - | document.getElementById('book-query').value = isbn; |
|
| 303 | - | searchBooks(); |
|
| 304 | - | }; |
|
| 305 | - | ||
| 306 | - | try { |
|
| 307 | - | let detector = null; |
|
| 308 | - | if (hasNativeBarcode) { |
|
| 309 | - | try { |
|
| 310 | - | detector = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a'] }); |
|
| 311 | - | } catch (_) { |
|
| 312 | - | detector = null; |
|
| 313 | - | } |
|
| 314 | - | } |
|
| 315 | - | ||
| 316 | - | if (detector) { |
|
| 317 | - | scanStream = await navigator.mediaDevices.getUserMedia({ |
|
| 318 | - | video: { facingMode: 'environment' } |
|
| 319 | - | }); |
|
| 320 | - | video.srcObject = scanStream; |
|
| 321 | - | await video.play(); |
|
| 322 | - | const tick = async () => { |
|
| 323 | - | if (!scanStream) return; |
|
| 324 | - | try { |
|
| 325 | - | const codes = await detector.detect(video); |
|
| 326 | - | if (codes.length) return onHit(codes[0].rawValue); |
|
| 327 | - | } catch (_) {} |
|
| 328 | - | scanRaf = requestAnimationFrame(tick); |
|
| 329 | - | }; |
|
| 330 | - | tick(); |
|
| 331 | - | return; |
|
| 332 | - | } |
|
| 333 | - | ||
| 334 | - | if (hasZxing) { |
|
| 335 | - | const reader = new ZXingBrowser.BrowserMultiFormatReader(); |
|
| 336 | - | zxingControls = await reader.decodeFromVideoDevice(undefined, video, (result, err, controls) => { |
|
| 337 | - | if (result) { |
|
| 338 | - | controls.stop(); |
|
| 339 | - | zxingControls = null; |
|
| 340 | - | onHit(result.getText()); |
|
| 341 | - | } |
|
| 342 | - | }); |
|
| 343 | - | return; |
|
| 344 | - | } |
|
| 345 | - | ||
| 346 | - | status.textContent = 'Scanner not supported'; |
|
| 347 | - | } catch (e) { |
|
| 348 | - | status.textContent = 'Camera unavailable'; |
|
| 349 | - | } |
|
| 350 | - | } |
|
| 351 | - | ||
| 352 | - | function closeScanner() { |
|
| 353 | - | if (scanRaf) cancelAnimationFrame(scanRaf); |
|
| 354 | - | scanRaf = null; |
|
| 355 | - | if (zxingControls) { |
|
| 356 | - | try { zxingControls.stop(); } catch (_) {} |
|
| 357 | - | zxingControls = null; |
|
| 358 | - | } |
|
| 359 | - | if (scanStream) { |
|
| 360 | - | scanStream.getTracks().forEach(function(t) { t.stop(); }); |
|
| 361 | - | scanStream = null; |
|
| 362 | - | } |
|
| 363 | - | document.getElementById('scan-modal').hidden = true; |
|
| 364 | - | } |
|
| 365 | - | </script> |
|
| 366 | - | </body> |
|
| 367 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" /> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" /> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" /> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest" /> |
|
| 13 | - | <title>Library</title> |
|
| 14 | - | <meta name="description" content="Personal book tracker" /> |
|
| 15 | - | ||
| 16 | - | <meta property="og:url" content="{{ base_url }}" /> |
|
| 17 | - | <meta property="og:type" content="website" /> |
|
| 18 | - | <meta property="og:title" content="Library" /> |
|
| 19 | - | <meta property="og:description" content="Personal book tracker" /> |
|
| 20 | - | <meta property="og:image" content="{{ base_url }}/static/og.png" /> |
|
| 21 | - | ||
| 22 | - | <meta name="twitter:card" content="summary_large_image" /> |
|
| 23 | - | <meta property="twitter:url" content="{{ base_url }}" /> |
|
| 24 | - | <meta name="twitter:title" content="Library" /> |
|
| 25 | - | <meta name="twitter:description" content="Personal book tracker" /> |
|
| 26 | - | <meta name="twitter:image" content="{{ base_url }}/static/og.png" /> |
|
| 27 | - | </head> |
|
| 28 | - | <body> |
|
| 29 | - | <div class="header"> |
|
| 30 | - | <a href="/" class="logo"><h1>LIBRARY</h1></a> |
|
| 31 | - | <nav class="links"> |
|
| 32 | - | {% if nav_mode %} |
|
| 33 | - | {% for cat in nav_categories %} |
|
| 34 | - | <a href="/?category={{ cat.slug }}"{% if cat.active %} class="active"{% endif %}>{{ cat.label }}</a> |
|
| 35 | - | {% endfor %} |
|
| 36 | - | {% endif %} |
|
| 37 | - | <a href="/admin">add</a> |
|
| 38 | - | </nav> |
|
| 39 | - | </div> |
|
| 40 | - | ||
| 41 | - | {% if sections.is_empty() %} |
|
| 42 | - | <p class="no-books">No books yet.</p> |
|
| 43 | - | {% else %} |
|
| 44 | - | {% for section in sections %} |
|
| 45 | - | <div class="section"> |
|
| 46 | - | {% if !nav_mode %} |
|
| 47 | - | <h2 class="section-label">{{ section.label }}</h2> |
|
| 48 | - | {% endif %} |
|
| 49 | - | {% if section.books.is_empty() %} |
|
| 50 | - | <p class="no-books">No books in {{ section.label }}.</p> |
|
| 51 | - | {% else %} |
|
| 52 | - | <div class="books-list"> |
|
| 53 | - | {% for book in section.books %} |
|
| 54 | - | <article class="book-card"> |
|
| 55 | - | {% if let Some(url) = book.cover_url %} |
|
| 56 | - | <img class="book-cover" src="{{ url }}" alt="" loading="lazy" /> |
|
| 57 | - | {% else %} |
|
| 58 | - | <div class="book-cover placeholder"></div> |
|
| 59 | - | {% endif %} |
|
| 60 | - | <div class="book-info"> |
|
| 61 | - | <h3 class="book-title">{{ book.title }}</h3> |
|
| 62 | - | <p class="book-authors">{{ book.authors }}</p> |
|
| 63 | - | {% if let Some(n) = book.notes %} |
|
| 64 | - | <p class="book-notes">{{ n }}</p> |
|
| 65 | - | {% endif %} |
|
| 66 | - | </div> |
|
| 67 | - | </article> |
|
| 68 | - | {% endfor %} |
|
| 69 | - | </div> |
|
| 70 | - | {% endif %} |
|
| 71 | - | </div> |
|
| 72 | - | {% endfor %} |
|
| 73 | - | {% endif %} |
|
| 74 | - | </body> |
|
| 75 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" /> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" /> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" /> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest" /> |
|
| 13 | - | <title>Library | Login</title> |
|
| 14 | - | </head> |
|
| 15 | - | <body> |
|
| 16 | - | <a href="/" class="header"> |
|
| 17 | - | <h1>LIBRARY</h1> |
|
| 18 | - | </a> |
|
| 19 | - | {% if let Some(err) = error %} |
|
| 20 | - | <p class="error">{{ err }}</p> |
|
| 21 | - | {% endif %} |
|
| 22 | - | ||
| 23 | - | <form class="admin-form" method="POST" action="/admin/login"> |
|
| 24 | - | <label for="password">Password</label> |
|
| 25 | - | <input type="password" id="password" name="password" required autofocus /> |
|
| 26 | - | <button type="submit">Login</button> |
|
| 27 | - | </form> |
|
| 28 | - | </body> |
|
| 29 | - | </html> |
| 1 | - | PORT=3000 |
| 1 | - | # Build from repo root: docker build -t og-go -f apps/og-go/Dockerfile . |
|
| 2 | - | FROM golang:1.24-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/og-go/go.mod apps/og-go/go.sum ./apps/og-go/ |
|
| 6 | - | WORKDIR /app/apps/og-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/og-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /og-go . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 13 | - | COPY --from=builder /og-go /usr/local/bin/og-go |
|
| 14 | - | ENV HOST=0.0.0.0 |
|
| 15 | - | ENV PORT=3000 |
|
| 16 | - | EXPOSE 3000 |
|
| 17 | - | CMD ["og-go"] |
| 1 | - | # og-go |
|
| 2 | - | ||
| 3 | - | Go rewrite of [og](../og). Open Graph tag inspector for any URL. |
|
| 4 | - | ||
| 5 | - | ## Quickstart |
|
| 6 | - | ||
| 7 | - | ```bash |
|
| 8 | - | cp .env.example .env |
|
| 9 | - | go run . |
|
| 10 | - | ``` |
|
| 11 | - | ||
| 12 | - | Then open `http://localhost:3000`. |
|
| 13 | - | ||
| 14 | - | ### Environment Variables |
|
| 15 | - | ||
| 16 | - | | Variable | Default | Description | |
|
| 17 | - | |---|---|---| |
|
| 18 | - | | `HOST` | `0.0.0.0` | Bind host | |
|
| 19 | - | | `PORT` | `3000` | Server port | |
|
| 20 | - | ||
| 21 | - | ## Routes |
|
| 22 | - | ||
| 23 | - | - `GET /` — search form |
|
| 24 | - | - `POST /check` — inspect a URL (form field: `url`) |
|
| 25 | - | - `GET /static/*` — embedded favicon, styles, etc. |
|
| 26 | - | - `GET /assets/darkmatter.css` + `/assets/fonts/*` — served by `crates-go/darkmatter` |
|
| 27 | - | ||
| 28 | - | ## Build |
|
| 29 | - | ||
| 30 | - | ```bash |
|
| 31 | - | go build . |
|
| 32 | - | ``` |
|
| 33 | - | ||
| 34 | - | The binary embeds all templates and static assets. |
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/og-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | - | environment: |
|
| 9 | - | - HOST=0.0.0.0 |
|
| 10 | - | - PORT=${PORT:-3000} |
|
| 11 | - | restart: unless-stopped |
| 1 | - | module github.com/stevedylandev/andromeda/apps/og-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/og |
|
| 2 | 2 | ||
| 3 | 3 | go 1.24.4 |
|
| 4 | 4 |
| 19 | 19 | app := &App{Log: logger, Templates: tmpl} |
|
| 20 | 20 | ||
| 21 | 21 | addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000") |
|
| 22 | - | logger.Info("og-go server running", "addr", addr) |
|
| 22 | + | logger.Info("og server running", "addr", addr) |
|
| 23 | 23 | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 24 | 24 | log.Fatal(err) |
|
| 25 | 25 | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* og — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | .index-container { |
|
| 6 | - | width: 100%; |
|
| 7 | - | } |
|
| 8 | - | ||
| 9 | - | .description { |
|
| 10 | - | opacity: 0.7; |
|
| 11 | - | } |
|
| 12 | - | ||
| 13 | - | .check-form { |
|
| 14 | - | display: flex; |
|
| 15 | - | flex-wrap: nowrap; |
|
| 16 | - | gap: 0.5rem; |
|
| 17 | - | width: 100%; |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | - | .check-form input { |
|
| 21 | - | flex: 1; |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | .check-form input::placeholder { |
|
| 25 | - | opacity: 0.3; |
|
| 26 | - | } |
|
| 27 | - | ||
| 28 | - | .check-form button { |
|
| 29 | - | flex-shrink: 0; |
|
| 30 | - | white-space: nowrap; |
|
| 31 | - | font-weight: 700; |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | /* Results page */ |
|
| 35 | - | ||
| 36 | - | .results-container { |
|
| 37 | - | display: flex; |
|
| 38 | - | flex-direction: column; |
|
| 39 | - | gap: 1.5rem; |
|
| 40 | - | width: 100%; |
|
| 41 | - | } |
|
| 42 | - | ||
| 43 | - | .results-header { |
|
| 44 | - | display: flex; |
|
| 45 | - | flex-direction: column; |
|
| 46 | - | gap: 0.75rem; |
|
| 47 | - | padding-bottom: 1rem; |
|
| 48 | - | border-bottom: 1px solid #333; |
|
| 49 | - | } |
|
| 50 | - | ||
| 51 | - | .results-url a { |
|
| 52 | - | word-break: break-all; |
|
| 53 | - | } |
|
| 54 | - | ||
| 55 | - | .label { |
|
| 56 | - | display: block; |
|
| 57 | - | font-size: 12px; |
|
| 58 | - | opacity: 0.7; |
|
| 59 | - | text-transform: uppercase; |
|
| 60 | - | margin-bottom: 0.25rem; |
|
| 61 | - | } |
|
| 62 | - | ||
| 63 | - | .results-meta span:last-child { |
|
| 64 | - | opacity: 0.5; |
|
| 65 | - | font-size: 12px; |
|
| 66 | - | } |
|
| 67 | - | ||
| 68 | - | .error h2 { |
|
| 69 | - | font-size: 12px; |
|
| 70 | - | text-transform: uppercase; |
|
| 71 | - | margin-bottom: 0.25rem; |
|
| 72 | - | } |
|
| 73 | - | ||
| 74 | - | .preview-section { |
|
| 75 | - | display: flex; |
|
| 76 | - | flex-direction: column; |
|
| 77 | - | gap: 0.5rem; |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | - | .preview-section h2 { |
|
| 81 | - | font-size: 12px; |
|
| 82 | - | text-transform: uppercase; |
|
| 83 | - | opacity: 0.5; |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | .image-preview { |
|
| 87 | - | border: 1px solid #333; |
|
| 88 | - | overflow: hidden; |
|
| 89 | - | } |
|
| 90 | - | ||
| 91 | - | .image-preview img { |
|
| 92 | - | display: block; |
|
| 93 | - | width: 100%; |
|
| 94 | - | height: auto; |
|
| 95 | - | } |
|
| 96 | - | ||
| 97 | - | .tag-section { |
|
| 98 | - | display: flex; |
|
| 99 | - | flex-direction: column; |
|
| 100 | - | width: 100%; |
|
| 101 | - | } |
|
| 102 | - | ||
| 103 | - | .tag-section h2 { |
|
| 104 | - | font-size: 12px; |
|
| 105 | - | text-transform: uppercase; |
|
| 106 | - | opacity: 0.5; |
|
| 107 | - | margin-bottom: 0.5rem; |
|
| 108 | - | } |
|
| 109 | - | ||
| 110 | - | .tag-item { |
|
| 111 | - | display: flex; |
|
| 112 | - | gap: 1rem; |
|
| 113 | - | padding: 0.75rem 0; |
|
| 114 | - | border-bottom: 1px solid #333; |
|
| 115 | - | font-size: 14px; |
|
| 116 | - | } |
|
| 117 | - | ||
| 118 | - | .tag-item.found { |
|
| 119 | - | border-left: 2px solid #ffffff; |
|
| 120 | - | padding-left: 0.75rem; |
|
| 121 | - | } |
|
| 122 | - | ||
| 123 | - | .tag-item.missing { |
|
| 124 | - | border-left: 2px solid #555; |
|
| 125 | - | padding-left: 0.75rem; |
|
| 126 | - | opacity: 0.3; |
|
| 127 | - | } |
|
| 128 | - | ||
| 129 | - | .tag-key { |
|
| 130 | - | font-weight: 700; |
|
| 131 | - | min-width: 140px; |
|
| 132 | - | max-width: 200px; |
|
| 133 | - | opacity: 0.7; |
|
| 134 | - | word-break: break-all; |
|
| 135 | - | } |
|
| 136 | - | ||
| 137 | - | .tag-value { |
|
| 138 | - | word-break: break-all; |
|
| 139 | - | min-width: 0; |
|
| 140 | - | flex: 1; |
|
| 141 | - | } |
|
| 142 | - | ||
| 143 | - | .tag-item.missing .tag-value { |
|
| 144 | - | font-style: italic; |
|
| 145 | - | } |
|
| 146 | - | ||
| 147 | - | .tag-extra { |
|
| 148 | - | display: block; |
|
| 149 | - | font-size: 12px; |
|
| 150 | - | opacity: 0.5; |
|
| 151 | - | margin-top: 0.15rem; |
|
| 152 | - | } |
|
| 153 | - | ||
| 154 | - | .empty-state { |
|
| 155 | - | opacity: 0.5; |
|
| 156 | - | font-size: 14px; |
|
| 157 | - | } |
|
| 158 | - | ||
| 159 | - | .back-link { |
|
| 160 | - | display: inline-block; |
|
| 161 | - | font-size: 12px; |
|
| 162 | - | opacity: 0.5; |
|
| 163 | - | padding-top: 1rem; |
|
| 164 | - | border-top: 1px solid #333; |
|
| 165 | - | width: 100%; |
|
| 166 | - | } |
|
| 167 | - | ||
| 168 | - | .back-link:hover { |
|
| 169 | - | opacity: 0.7; |
|
| 170 | - | } |
|
| 171 | - | ||
| 172 | - | @media (max-width: 480px) { |
|
| 173 | - | .tag-item { |
|
| 174 | - | flex-direction: column; |
|
| 175 | - | gap: 0.25rem; |
|
| 176 | - | } |
|
| 177 | - | ||
| 178 | - | .tag-key { |
|
| 179 | - | min-width: unset; |
|
| 180 | - | } |
|
| 181 | - | } |
| 1 | - | {{define "base.html"}}<!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <meta name="theme-color" content="#121113"> |
|
| 7 | - | <title>{{block "title" .}}OG{{end}}</title> |
|
| 8 | - | <meta name="description" content="Check and preview OpenGraph tags for any URL"> |
|
| 9 | - | <meta property="og:title" content="OG Preview"> |
|
| 10 | - | <meta property="og:description" content="Check and preview OpenGraph tags for any URL"> |
|
| 11 | - | <meta property="og:image" content="/static/og.png"> |
|
| 12 | - | <meta property="og:type" content="website"> |
|
| 13 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 14 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 15 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 16 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 17 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 18 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 19 | - | </head> |
|
| 20 | - | <body> |
|
| 21 | - | <header class="header"> |
|
| 22 | - | <a href="/" class="logo">OG</a> |
|
| 23 | - | </header> |
|
| 24 | - | {{block "content" .}}{{end}} |
|
| 25 | - | </body> |
|
| 26 | - | </html>{{end}} |
| 1 | - | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}OG{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <div class="index-container"> |
|
| 5 | - | <form action="/check" method="POST" class="check-form"> |
|
| 6 | - | <input |
|
| 7 | - | type="text" |
|
| 8 | - | name="url" |
|
| 9 | - | placeholder="example.com" |
|
| 10 | - | required |
|
| 11 | - | autocomplete="url" |
|
| 12 | - | autofocus |
|
| 13 | - | > |
|
| 14 | - | <button type="submit">Check</button> |
|
| 15 | - | </form> |
|
| 16 | - | </div> |
|
| 17 | - | {{end}} |
| 1 | - | {{define "results.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Results — {{.URL}}{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <div class="results-container"> |
|
| 5 | - | <div class="results-header"> |
|
| 6 | - | <div class="results-url"> |
|
| 7 | - | <span class="label">URL</span> |
|
| 8 | - | <a href="{{.URL}}" target="_blank" rel="noopener">{{.URL}}</a> |
|
| 9 | - | </div> |
|
| 10 | - | </div> |
|
| 11 | - | ||
| 12 | - | {{if .Error}} |
|
| 13 | - | <div class="error"> |
|
| 14 | - | <h2>Error</h2> |
|
| 15 | - | <p>{{.Error}}</p> |
|
| 16 | - | </div> |
|
| 17 | - | {{else}} |
|
| 18 | - | ||
| 19 | - | {{if .OGImage}} |
|
| 20 | - | <div class="preview-section"> |
|
| 21 | - | <h2>Image Preview</h2> |
|
| 22 | - | <div class="image-preview"> |
|
| 23 | - | <img src="{{.OGImage}}" alt="OG Image preview" loading="lazy"> |
|
| 24 | - | </div> |
|
| 25 | - | </div> |
|
| 26 | - | {{end}} |
|
| 27 | - | ||
| 28 | - | {{if .Favicon}} |
|
| 29 | - | <div class="preview-section"> |
|
| 30 | - | <h2>Favicon</h2> |
|
| 31 | - | <img src="{{.Favicon}}" alt="Favicon" width="32" height="32"> |
|
| 32 | - | </div> |
|
| 33 | - | {{end}} |
|
| 34 | - | ||
| 35 | - | <div class="tag-section"> |
|
| 36 | - | <h2>Found Tags</h2> |
|
| 37 | - | {{if not .FoundTags}} |
|
| 38 | - | <p class="empty-state">No OpenGraph tags found.</p> |
|
| 39 | - | {{else}} |
|
| 40 | - | {{range .FoundTags}} |
|
| 41 | - | <div class="tag-item found"> |
|
| 42 | - | <span class="tag-key">{{.Key}}</span> |
|
| 43 | - | <span class="tag-value">{{.Value}}</span> |
|
| 44 | - | </div> |
|
| 45 | - | {{end}} |
|
| 46 | - | {{end}} |
|
| 47 | - | </div> |
|
| 48 | - | ||
| 49 | - | <div class="tag-section"> |
|
| 50 | - | <h2>Missing Tags</h2> |
|
| 51 | - | {{if not .MissingTags}} |
|
| 52 | - | <p class="empty-state">All common tags present.</p> |
|
| 53 | - | {{else}} |
|
| 54 | - | {{range .MissingTags}} |
|
| 55 | - | <div class="tag-item missing"> |
|
| 56 | - | <span class="tag-key">{{.}}</span> |
|
| 57 | - | <span class="tag-value">not found</span> |
|
| 58 | - | </div> |
|
| 59 | - | {{end}} |
|
| 60 | - | {{end}} |
|
| 61 | - | </div> |
|
| 62 | - | ||
| 63 | - | {{if .LinkTags}} |
|
| 64 | - | <div class="tag-section"> |
|
| 65 | - | <h2>Link Tags</h2> |
|
| 66 | - | {{range .LinkTags}} |
|
| 67 | - | <div class="tag-item found"> |
|
| 68 | - | <span class="tag-key">{{if .Rel}}{{.Rel}}{{else}}link{{end}}</span> |
|
| 69 | - | <span class="tag-value"> |
|
| 70 | - | {{if .Href}} |
|
| 71 | - | <a href="{{.Href}}" target="_blank" rel="noopener">{{.Href}}</a> |
|
| 72 | - | {{else}} |
|
| 73 | - | <span class="tag-extra">no href</span> |
|
| 74 | - | {{end}} |
|
| 75 | - | {{if .Extra}} |
|
| 76 | - | <span class="tag-extra">{{.Extra}}</span> |
|
| 77 | - | {{end}} |
|
| 78 | - | </span> |
|
| 79 | - | </div> |
|
| 80 | - | {{end}} |
|
| 81 | - | </div> |
|
| 82 | - | {{end}} |
|
| 83 | - | ||
| 84 | - | {{end}} |
|
| 85 | - | </div> |
|
| 86 | - | {{end}} |
| 1 | - | [package] |
|
| 2 | - | name = "og" |
|
| 3 | - | version = "0.1.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Minimal opengraph previewer" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | axum = { workspace = true } |
|
| 12 | - | tokio = { workspace = true } |
|
| 13 | - | serde = { workspace = true } |
|
| 14 | - | serde_json = { workspace = true } |
|
| 15 | - | tower-http = { workspace = true, features = ["cors"] } |
|
| 16 | - | rust-embed = { workspace = true, features = ["mime-guess"] } |
|
| 17 | - | dotenvy = { workspace = true } |
|
| 18 | - | tracing = { workspace = true } |
|
| 19 | - | tracing-subscriber = { workspace = true } |
|
| 20 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 21 | - | askama = "0.13" |
|
| 22 | - | reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } |
|
| 23 | - | scraper = "0.22" |
|
| 24 | - | url = "2" |
| 1 | 1 | # Build from repo root: docker build -t og -f apps/og/Dockerfile . |
|
| 2 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 2 | + | FROM golang:1.24-bookworm AS builder |
|
| 3 | 3 | WORKDIR /app |
|
| 4 | - | ||
| 5 | - | FROM chef AS planner |
|
| 6 | - | COPY . . |
|
| 7 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 8 | - | ||
| 9 | - | FROM chef AS builder |
|
| 10 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 11 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 12 | - | RUN cargo chef cook --release --recipe-path recipe.json -p og |
|
| 13 | - | COPY . . |
|
| 14 | - | RUN cargo build --release -p og |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/og/go.mod apps/og/go.sum ./apps/og/ |
|
| 6 | + | WORKDIR /app/apps/og |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/og/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /og . |
|
| 15 | 10 | ||
| 16 | 11 | FROM debian:bookworm-slim |
|
| 17 | 12 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 18 | - | WORKDIR /app |
|
| 19 | - | COPY --from=builder /app/target/release/og . |
|
| 13 | + | COPY --from=builder /og /usr/local/bin/og |
|
| 14 | + | ENV HOST=0.0.0.0 |
|
| 15 | + | ENV PORT=3000 |
|
| 20 | 16 | EXPOSE 3000 |
|
| 21 | - | CMD ["./og"] |
|
| 17 | + | CMD ["og"] |
| 1 | - | MIT License |
|
| 2 | - | ||
| 3 | - | Copyright (c) 2026 Steve Simkins |
|
| 4 | - | ||
| 5 | - | Permission is hereby granted, free of charge, to any person obtaining a copy |
|
| 6 | - | of this software and associated documentation files (the "Software"), to deal |
|
| 7 | - | in the Software without restriction, including without limitation the rights |
|
| 8 | - | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
| 9 | - | copies of the Software, and to permit persons to whom the Software is |
|
| 10 | - | furnished to do so, subject to the following conditions: |
|
| 11 | - | ||
| 12 | - | The above copyright notice and this permission notice shall be included in all |
|
| 13 | - | copies or substantial portions of the Software. |
|
| 14 | - | ||
| 15 | - | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
| 16 | - | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
| 17 | - | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
| 18 | - | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
| 19 | - | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
| 20 | - | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
| 21 | - | SOFTWARE. |
|
| 22 | - |
| 1 | - | # OG |
|
| 1 | + | # og-go |
|
| 2 | 2 | ||
| 3 | - |  |
|
| 4 | - | ||
| 5 | - | A simple web tool for inspecting Open Graph tags on any URL. |
|
| 3 | + | Go rewrite of [og](../og). Open Graph tag inspector for any URL. |
|
| 6 | 4 | ||
| 7 | 5 | ## Quickstart |
|
| 8 | 6 | ||
| 9 | 7 | ```bash |
|
| 10 | - | git clone https://github.com/stevedylandev/og.git |
|
| 11 | - | cd og |
|
| 12 | - | cargo build --release |
|
| 13 | - | ./target/release/og |
|
| 8 | + | cp .env.example .env |
|
| 9 | + | go run . |
|
| 14 | 10 | ``` |
|
| 11 | + | ||
| 12 | + | Then open `http://localhost:3000`. |
|
| 15 | 13 | ||
| 16 | 14 | ### Environment Variables |
|
| 17 | 15 | ||
| 18 | - | | Variable | Description | Default | |
|
| 16 | + | | Variable | Default | Description | |
|
| 19 | 17 | |---|---|---| |
|
| 20 | - | | `PORT` | Server port | `3000` | |
|
| 21 | - | ||
| 22 | - | ## Overview |
|
| 23 | - | ||
| 24 | - | A self-hosted Open Graph tag inspector built with Rust. Enter any URL and instantly see its OG metadata. A few highlights: |
|
| 25 | - | ||
| 26 | - | - Single Rust binary with embedded assets |
|
| 27 | - | - Inspects title, description, image, and other OG tags |
|
| 28 | - | - Dark themed UI with Commit Mono font |
|
| 29 | - | - No database needed — fully stateless |
|
| 30 | - | ||
| 31 | - | ## Structure |
|
| 32 | - | ||
| 33 | - | ``` |
|
| 34 | - | og/ |
|
| 35 | - | ├── src/ |
|
| 36 | - | │ ├── main.rs # Entry point and server startup |
|
| 37 | - | │ ├── server.rs # Axum routes and request handling |
|
| 38 | - | │ └── og.rs # Open Graph tag fetching and parsing |
|
| 39 | - | ├── templates/ # Askama HTML templates |
|
| 40 | - | │ ├── base.html # Base layout |
|
| 41 | - | │ ├── index.html # Search form |
|
| 42 | - | │ └── results.html # OG tag results display |
|
| 43 | - | ├── static/ # Fonts, favicons, and styles |
|
| 44 | - | ├── Dockerfile |
|
| 45 | - | └── docker-compose.yml |
|
| 46 | - | ``` |
|
| 47 | - | ||
| 48 | - | ## Deployment |
|
| 18 | + | | `HOST` | `0.0.0.0` | Bind host | |
|
| 19 | + | | `PORT` | `3000` | Server port | |
|
| 49 | 20 | ||
| 50 | - | ### Railway |
|
| 21 | + | ## Routes |
|
| 51 | 22 | ||
| 52 | - | [](https://railway.com/deploy/OdXBt_?referralCode=JGcIp6) |
|
| 53 | - | ||
| 54 | - | ### Docker (recommended) |
|
| 55 | - | ||
| 56 | - | ```bash |
|
| 57 | - | git clone https://github.com/stevedylandev/og.git |
|
| 58 | - | cd og |
|
| 59 | - | docker compose up -d |
|
| 60 | - | ``` |
|
| 23 | + | - `GET /` — search form |
|
| 24 | + | - `POST /check` — inspect a URL (form field: `url`) |
|
| 25 | + | - `GET /static/*` — embedded favicon, styles, etc. |
|
| 26 | + | - `GET /assets/darkmatter.css` + `/assets/fonts/*` — served by `crates-go/darkmatter` |
|
| 61 | 27 | ||
| 62 | - | ### Binary |
|
| 28 | + | ## Build |
|
| 63 | 29 | ||
| 64 | 30 | ```bash |
|
| 65 | - | cargo build --release |
|
| 31 | + | go build . |
|
| 66 | 32 | ``` |
|
| 67 | 33 | ||
| 68 | - | The resulting binary at `./target/release/og` is self-contained with all assets embedded. Copy it to your server and run it directly. |
|
| 69 | - | ||
| 70 | - | ## License |
|
| 71 | - | ||
| 72 | - | [MIT](LICENSE) |
|
| 34 | + | The binary embeds all templates and static assets. |
| 1 | 1 | services: |
|
| 2 | - | og: |
|
| 2 | + | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/og/Dockerfile |
|
| 5 | + | dockerfile: apps/og-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | - | - "3000:3000" |
|
| 8 | - | volumes: |
|
| 9 | - | - db_data:/app/data |
|
| 7 | + | - "${PORT:-3000}:${PORT:-3000}" |
|
| 10 | 8 | environment: |
|
| 11 | - | - PORT=3000 |
|
| 12 | - | ||
| 13 | - | volumes: |
|
| 14 | - | db_data: |
|
| 9 | + | - HOST=0.0.0.0 |
|
| 10 | + | - PORT=${PORT:-3000} |
|
| 11 | + | restart: unless-stopped |
| 1 | - | mod og; |
|
| 2 | - | mod server; |
|
| 3 | - | ||
| 4 | - | #[tokio::main] |
|
| 5 | - | async fn main() { |
|
| 6 | - | dotenvy::dotenv().ok(); |
|
| 7 | - | tracing_subscriber::fmt::init(); |
|
| 8 | - | server::run().await; |
|
| 9 | - | } |
| 1 | - | use scraper::{Html, Selector}; |
|
| 2 | - | use std::collections::HashMap; |
|
| 3 | - | use url::Url; |
|
| 4 | - | ||
| 5 | - | pub struct LinkTag { |
|
| 6 | - | pub rel: String, |
|
| 7 | - | pub href: String, |
|
| 8 | - | pub extra: String, |
|
| 9 | - | } |
|
| 10 | - | ||
| 11 | - | pub struct OgResult { |
|
| 12 | - | pub og_tags: HashMap<String, String>, |
|
| 13 | - | pub favicon: Option<String>, |
|
| 14 | - | pub link_tags: Vec<LinkTag>, |
|
| 15 | - | } |
|
| 16 | - | ||
| 17 | - | pub async fn fetch_og_data(target_url: &str) -> Result<OgResult, String> { |
|
| 18 | - | let parsed_url = Url::parse(target_url).map_err(|e| format!("Invalid URL: {e}"))?; |
|
| 19 | - | ||
| 20 | - | let client = reqwest::Client::builder() |
|
| 21 | - | .timeout(std::time::Duration::from_secs(10)) |
|
| 22 | - | .user_agent("Mozilla/5.0 (compatible; OGPreview/1.0)") |
|
| 23 | - | .build() |
|
| 24 | - | .map_err(|e| format!("Failed to create HTTP client: {e}"))?; |
|
| 25 | - | ||
| 26 | - | let resp = client |
|
| 27 | - | .get(target_url) |
|
| 28 | - | .send() |
|
| 29 | - | .await |
|
| 30 | - | .map_err(|e| format!("Failed to fetch URL: {e}"))?; |
|
| 31 | - | ||
| 32 | - | if !resp.status().is_success() { |
|
| 33 | - | return Err(format!("HTTP error: {}", resp.status())); |
|
| 34 | - | } |
|
| 35 | - | ||
| 36 | - | let content_type = resp |
|
| 37 | - | .headers() |
|
| 38 | - | .get("content-type") |
|
| 39 | - | .and_then(|v| v.to_str().ok()) |
|
| 40 | - | .unwrap_or(""); |
|
| 41 | - | if !content_type.contains("text/html") && !content_type.contains("application/xhtml") { |
|
| 42 | - | return Err(format!("Not an HTML page (Content-Type: {content_type})")); |
|
| 43 | - | } |
|
| 44 | - | ||
| 45 | - | let body = resp |
|
| 46 | - | .text() |
|
| 47 | - | .await |
|
| 48 | - | .map_err(|e| format!("Failed to read response body: {e}"))?; |
|
| 49 | - | ||
| 50 | - | let document = Html::parse_document(&body); |
|
| 51 | - | ||
| 52 | - | // Extract OG tags |
|
| 53 | - | let mut og_tags = HashMap::new(); |
|
| 54 | - | let og_property = Selector::parse(r#"meta[property^="og:"]"#).unwrap(); |
|
| 55 | - | let og_name = Selector::parse(r#"meta[name^="og:"]"#).unwrap(); |
|
| 56 | - | ||
| 57 | - | for el in document.select(&og_property).chain(document.select(&og_name)) { |
|
| 58 | - | let key = el |
|
| 59 | - | .value() |
|
| 60 | - | .attr("property") |
|
| 61 | - | .or_else(|| el.value().attr("name")); |
|
| 62 | - | let value = el.value().attr("content"); |
|
| 63 | - | if let (Some(k), Some(v)) = (key, value) { |
|
| 64 | - | og_tags.entry(k.to_string()).or_insert_with(|| v.to_string()); |
|
| 65 | - | } |
|
| 66 | - | } |
|
| 67 | - | ||
| 68 | - | // Resolve relative og:image URL |
|
| 69 | - | if let Some(image) = og_tags.get("og:image") { |
|
| 70 | - | if let Ok(resolved) = parsed_url.join(image) { |
|
| 71 | - | og_tags.insert("og:image".to_string(), resolved.to_string()); |
|
| 72 | - | } |
|
| 73 | - | } |
|
| 74 | - | ||
| 75 | - | // Extract favicon |
|
| 76 | - | let favicon = extract_favicon(&document, &parsed_url); |
|
| 77 | - | ||
| 78 | - | // Extract head <link> tags |
|
| 79 | - | let link_tags = extract_link_tags(&document, &parsed_url); |
|
| 80 | - | ||
| 81 | - | Ok(OgResult { og_tags, favicon, link_tags }) |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | fn extract_favicon(document: &Html, base_url: &Url) -> Option<String> { |
|
| 85 | - | let selectors = [ |
|
| 86 | - | r#"link[rel="icon"]"#, |
|
| 87 | - | r#"link[rel="shortcut icon"]"#, |
|
| 88 | - | r#"link[rel="apple-touch-icon"]"#, |
|
| 89 | - | ]; |
|
| 90 | - | for sel_str in &selectors { |
|
| 91 | - | if let Ok(sel) = Selector::parse(sel_str) { |
|
| 92 | - | if let Some(el) = document.select(&sel).next() { |
|
| 93 | - | if let Some(href) = el.value().attr("href") { |
|
| 94 | - | if let Ok(resolved) = base_url.join(href) { |
|
| 95 | - | return Some(resolved.to_string()); |
|
| 96 | - | } |
|
| 97 | - | } |
|
| 98 | - | } |
|
| 99 | - | } |
|
| 100 | - | } |
|
| 101 | - | // Fallback to /favicon.ico |
|
| 102 | - | base_url.join("/favicon.ico").ok().map(|u| u.to_string()) |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | fn extract_link_tags(document: &Html, base_url: &Url) -> Vec<LinkTag> { |
|
| 106 | - | let mut link_tags = Vec::new(); |
|
| 107 | - | if let Ok(sel) = Selector::parse("head link") { |
|
| 108 | - | for el in document.select(&sel) { |
|
| 109 | - | let rel = el.value().attr("rel").unwrap_or("").to_string(); |
|
| 110 | - | let href = el |
|
| 111 | - | .value() |
|
| 112 | - | .attr("href") |
|
| 113 | - | .map(|h| base_url.join(h).map(|u| u.to_string()).unwrap_or_else(|_| h.to_string())) |
|
| 114 | - | .unwrap_or_default(); |
|
| 115 | - | let mut extras = Vec::new(); |
|
| 116 | - | for (name, val) in el.value().attrs() { |
|
| 117 | - | if name != "rel" && name != "href" { |
|
| 118 | - | extras.push(format!("{name}=\"{val}\"")); |
|
| 119 | - | } |
|
| 120 | - | } |
|
| 121 | - | link_tags.push(LinkTag { |
|
| 122 | - | rel, |
|
| 123 | - | href, |
|
| 124 | - | extra: extras.join(" "), |
|
| 125 | - | }); |
|
| 126 | - | } |
|
| 127 | - | } |
|
| 128 | - | link_tags |
|
| 129 | - | } |
|
| 130 | - | ||
| 131 | - | #[cfg(test)] |
|
| 132 | - | mod tests { |
|
| 133 | - | use super::*; |
|
| 134 | - | ||
| 135 | - | fn base() -> Url { |
|
| 136 | - | Url::parse("https://example.com/page").unwrap() |
|
| 137 | - | } |
|
| 138 | - | ||
| 139 | - | // ── extract_favicon ──────────────────────────────────────────────── |
|
| 140 | - | ||
| 141 | - | #[test] |
|
| 142 | - | fn favicon_from_rel_icon() { |
|
| 143 | - | let doc = Html::parse_document( |
|
| 144 | - | r#"<html><head><link rel="icon" href="/favicon.png"></head></html>"#, |
|
| 145 | - | ); |
|
| 146 | - | let result = extract_favicon(&doc, &base()); |
|
| 147 | - | assert_eq!(result, Some("https://example.com/favicon.png".to_string())); |
|
| 148 | - | } |
|
| 149 | - | ||
| 150 | - | #[test] |
|
| 151 | - | fn favicon_from_shortcut_icon() { |
|
| 152 | - | let doc = Html::parse_document( |
|
| 153 | - | r#"<html><head><link rel="shortcut icon" href="/icon.ico"></head></html>"#, |
|
| 154 | - | ); |
|
| 155 | - | let result = extract_favicon(&doc, &base()); |
|
| 156 | - | assert_eq!(result, Some("https://example.com/icon.ico".to_string())); |
|
| 157 | - | } |
|
| 158 | - | ||
| 159 | - | #[test] |
|
| 160 | - | fn favicon_from_apple_touch_icon() { |
|
| 161 | - | let doc = Html::parse_document( |
|
| 162 | - | r#"<html><head><link rel="apple-touch-icon" href="/apple.png"></head></html>"#, |
|
| 163 | - | ); |
|
| 164 | - | let result = extract_favicon(&doc, &base()); |
|
| 165 | - | assert_eq!(result, Some("https://example.com/apple.png".to_string())); |
|
| 166 | - | } |
|
| 167 | - | ||
| 168 | - | #[test] |
|
| 169 | - | fn favicon_priority_icon_over_shortcut() { |
|
| 170 | - | let doc = Html::parse_document( |
|
| 171 | - | r#"<html><head> |
|
| 172 | - | <link rel="icon" href="/first.png"> |
|
| 173 | - | <link rel="shortcut icon" href="/second.ico"> |
|
| 174 | - | </head></html>"#, |
|
| 175 | - | ); |
|
| 176 | - | let result = extract_favicon(&doc, &base()); |
|
| 177 | - | assert_eq!(result, Some("https://example.com/first.png".to_string())); |
|
| 178 | - | } |
|
| 179 | - | ||
| 180 | - | #[test] |
|
| 181 | - | fn favicon_fallback_to_favicon_ico() { |
|
| 182 | - | let doc = Html::parse_document("<html><head></head></html>"); |
|
| 183 | - | let result = extract_favicon(&doc, &base()); |
|
| 184 | - | assert_eq!(result, Some("https://example.com/favicon.ico".to_string())); |
|
| 185 | - | } |
|
| 186 | - | ||
| 187 | - | #[test] |
|
| 188 | - | fn favicon_resolves_relative_url() { |
|
| 189 | - | let doc = Html::parse_document( |
|
| 190 | - | r#"<html><head><link rel="icon" href="assets/icon.png"></head></html>"#, |
|
| 191 | - | ); |
|
| 192 | - | let result = extract_favicon(&doc, &base()); |
|
| 193 | - | assert_eq!( |
|
| 194 | - | result, |
|
| 195 | - | Some("https://example.com/assets/icon.png".to_string()) |
|
| 196 | - | ); |
|
| 197 | - | } |
|
| 198 | - | ||
| 199 | - | // ── extract_link_tags ────────────────────────────────────────────── |
|
| 200 | - | ||
| 201 | - | #[test] |
|
| 202 | - | fn link_tags_extracts_multiple() { |
|
| 203 | - | let doc = Html::parse_document( |
|
| 204 | - | r#"<html><head> |
|
| 205 | - | <link rel="stylesheet" href="/style.css"> |
|
| 206 | - | <link rel="canonical" href="https://example.com/"> |
|
| 207 | - | </head></html>"#, |
|
| 208 | - | ); |
|
| 209 | - | let tags = extract_link_tags(&doc, &base()); |
|
| 210 | - | assert_eq!(tags.len(), 2); |
|
| 211 | - | assert_eq!(tags[0].rel, "stylesheet"); |
|
| 212 | - | assert_eq!(tags[1].rel, "canonical"); |
|
| 213 | - | } |
|
| 214 | - | ||
| 215 | - | #[test] |
|
| 216 | - | fn link_tags_resolves_relative_href() { |
|
| 217 | - | let doc = Html::parse_document( |
|
| 218 | - | r#"<html><head><link rel="stylesheet" href="css/main.css"></head></html>"#, |
|
| 219 | - | ); |
|
| 220 | - | let tags = extract_link_tags(&doc, &base()); |
|
| 221 | - | assert_eq!(tags[0].href, "https://example.com/css/main.css"); |
|
| 222 | - | } |
|
| 223 | - | ||
| 224 | - | #[test] |
|
| 225 | - | fn link_tags_preserves_extra_attrs() { |
|
| 226 | - | let doc = Html::parse_document( |
|
| 227 | - | r#"<html><head><link rel="stylesheet" href="/s.css" type="text/css"></head></html>"#, |
|
| 228 | - | ); |
|
| 229 | - | let tags = extract_link_tags(&doc, &base()); |
|
| 230 | - | assert!(tags[0].extra.contains("type=\"text/css\"")); |
|
| 231 | - | } |
|
| 232 | - | ||
| 233 | - | #[test] |
|
| 234 | - | fn link_tags_empty_head() { |
|
| 235 | - | let doc = Html::parse_document("<html><head></head></html>"); |
|
| 236 | - | let tags = extract_link_tags(&doc, &base()); |
|
| 237 | - | assert!(tags.is_empty()); |
|
| 238 | - | } |
|
| 239 | - | } |
| 1 | - | use axum::{ |
|
| 2 | - | Router, |
|
| 3 | - | extract::{Form, Path}, |
|
| 4 | - | http::{StatusCode, header}, |
|
| 5 | - | response::{Html, IntoResponse, Response}, |
|
| 6 | - | routing::{get, post}, |
|
| 7 | - | }; |
|
| 8 | - | use askama::Template; |
|
| 9 | - | use rust_embed::Embed; |
|
| 10 | - | use serde::Deserialize; |
|
| 11 | - | ||
| 12 | - | use crate::og; |
|
| 13 | - | ||
| 14 | - | const COMMON_TAGS: &[&str] = &[ |
|
| 15 | - | "og:title", |
|
| 16 | - | "og:description", |
|
| 17 | - | "og:image", |
|
| 18 | - | "og:url", |
|
| 19 | - | "og:type", |
|
| 20 | - | ]; |
|
| 21 | - | ||
| 22 | - | #[derive(Embed)] |
|
| 23 | - | #[folder = "static/"] |
|
| 24 | - | struct StaticAssets; |
|
| 25 | - | ||
| 26 | - | #[derive(Template)] |
|
| 27 | - | #[template(path = "index.html")] |
|
| 28 | - | struct IndexTemplate; |
|
| 29 | - | ||
| 30 | - | #[derive(Template)] |
|
| 31 | - | #[template(path = "results.html")] |
|
| 32 | - | struct ResultsTemplate { |
|
| 33 | - | url: String, |
|
| 34 | - | error: Option<String>, |
|
| 35 | - | og_image: Option<String>, |
|
| 36 | - | favicon: Option<String>, |
|
| 37 | - | found_tags: Vec<(String, String)>, |
|
| 38 | - | missing_tags: Vec<String>, |
|
| 39 | - | link_tags: Vec<(String, String, String)>, |
|
| 40 | - | } |
|
| 41 | - | ||
| 42 | - | async fn get_index() -> impl IntoResponse { |
|
| 43 | - | Html(IndexTemplate.render().unwrap()) |
|
| 44 | - | } |
|
| 45 | - | ||
| 46 | - | #[derive(Deserialize)] |
|
| 47 | - | struct CheckForm { |
|
| 48 | - | url: String, |
|
| 49 | - | } |
|
| 50 | - | ||
| 51 | - | async fn post_check(Form(form): Form<CheckForm>) -> Response { |
|
| 52 | - | let mut url = form.url.trim().to_string(); |
|
| 53 | - | ||
| 54 | - | if url.is_empty() { |
|
| 55 | - | let tmpl = ResultsTemplate { |
|
| 56 | - | url, |
|
| 57 | - | error: Some("Please enter a URL".to_string()), |
|
| 58 | - | og_image: None, |
|
| 59 | - | favicon: None, |
|
| 60 | - | found_tags: Vec::new(), |
|
| 61 | - | missing_tags: Vec::new(), |
|
| 62 | - | link_tags: Vec::new(), |
|
| 63 | - | }; |
|
| 64 | - | return Html(tmpl.render().unwrap()).into_response(); |
|
| 65 | - | } |
|
| 66 | - | ||
| 67 | - | if !url.starts_with("http://") && !url.starts_with("https://") { |
|
| 68 | - | url = format!("https://{url}"); |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | match og::fetch_og_data(&url).await { |
|
| 72 | - | Ok(result) => { |
|
| 73 | - | let og_image = result.og_tags.get("og:image").cloned(); |
|
| 74 | - | ||
| 75 | - | let mut found_tags = Vec::new(); |
|
| 76 | - | let mut missing_tags = Vec::new(); |
|
| 77 | - | ||
| 78 | - | for &tag in COMMON_TAGS { |
|
| 79 | - | if let Some(value) = result.og_tags.get(tag) { |
|
| 80 | - | found_tags.push((tag.to_string(), value.clone())); |
|
| 81 | - | } else { |
|
| 82 | - | missing_tags.push(tag.to_string()); |
|
| 83 | - | } |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | for (key, value) in &result.og_tags { |
|
| 87 | - | if !COMMON_TAGS.contains(&key.as_str()) { |
|
| 88 | - | found_tags.push((key.clone(), value.clone())); |
|
| 89 | - | } |
|
| 90 | - | } |
|
| 91 | - | ||
| 92 | - | let link_tags: Vec<(String, String, String)> = result |
|
| 93 | - | .link_tags |
|
| 94 | - | .into_iter() |
|
| 95 | - | .map(|lt| (lt.rel, lt.href, lt.extra)) |
|
| 96 | - | .collect(); |
|
| 97 | - | ||
| 98 | - | let tmpl = ResultsTemplate { |
|
| 99 | - | url, |
|
| 100 | - | error: None, |
|
| 101 | - | og_image, |
|
| 102 | - | favicon: result.favicon, |
|
| 103 | - | found_tags, |
|
| 104 | - | missing_tags, |
|
| 105 | - | link_tags, |
|
| 106 | - | }; |
|
| 107 | - | Html(tmpl.render().unwrap()).into_response() |
|
| 108 | - | } |
|
| 109 | - | Err(err) => { |
|
| 110 | - | let tmpl = ResultsTemplate { |
|
| 111 | - | url, |
|
| 112 | - | error: Some(err), |
|
| 113 | - | og_image: None, |
|
| 114 | - | favicon: None, |
|
| 115 | - | found_tags: Vec::new(), |
|
| 116 | - | missing_tags: Vec::new(), |
|
| 117 | - | link_tags: Vec::new(), |
|
| 118 | - | }; |
|
| 119 | - | Html(tmpl.render().unwrap()).into_response() |
|
| 120 | - | } |
|
| 121 | - | } |
|
| 122 | - | } |
|
| 123 | - | ||
| 124 | - | async fn static_handler(Path(path): Path<String>) -> Response { |
|
| 125 | - | match StaticAssets::get(&path) { |
|
| 126 | - | Some(file) => { |
|
| 127 | - | let mime = file.metadata.mimetype(); |
|
| 128 | - | ( |
|
| 129 | - | StatusCode::OK, |
|
| 130 | - | [(header::CONTENT_TYPE, mime)], |
|
| 131 | - | file.data.to_vec(), |
|
| 132 | - | ) |
|
| 133 | - | .into_response() |
|
| 134 | - | } |
|
| 135 | - | None => (StatusCode::NOT_FOUND, "Not found").into_response(), |
|
| 136 | - | } |
|
| 137 | - | } |
|
| 138 | - | ||
| 139 | - | pub async fn run() { |
|
| 140 | - | let app = Router::new() |
|
| 141 | - | .route("/", get(get_index)) |
|
| 142 | - | .route("/check", post(post_check)) |
|
| 143 | - | .route("/static/{*path}", get(static_handler)) |
|
| 144 | - | .merge(andromeda_darkmatter_css::router::<()>()); |
|
| 145 | - | ||
| 146 | - | let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); |
|
| 147 | - | let addr = format!("0.0.0.0:{port}"); |
|
| 148 | - | tracing::info!("Listening on http://localhost:{port}"); |
|
| 149 | - | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); |
|
| 150 | - | axum::serve(listener, app).await.unwrap(); |
|
| 151 | - | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | <!DOCTYPE html> |
|
| 1 | + | {{define "base.html"}}<!DOCTYPE html> |
|
| 2 | 2 | <html lang="en"> |
|
| 3 | 3 | <head> |
|
| 4 | 4 | <meta charset="UTF-8"> |
|
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | 6 | <meta name="theme-color" content="#121113"> |
|
| 7 | - | <title>{% block title %}OG{% endblock %}</title> |
|
| 7 | + | <title>{{block "title" .}}OG{{end}}</title> |
|
| 8 | 8 | <meta name="description" content="Check and preview OpenGraph tags for any URL"> |
|
| 9 | 9 | <meta property="og:title" content="OG Preview"> |
|
| 10 | 10 | <meta property="og:description" content="Check and preview OpenGraph tags for any URL"> |
|
| 21 | 21 | <header class="header"> |
|
| 22 | 22 | <a href="/" class="logo">OG</a> |
|
| 23 | 23 | </header> |
|
| 24 | - | {% block content %}{% endblock %} |
|
| 24 | + | {{block "content" .}}{{end}} |
|
| 25 | 25 | </body> |
|
| 26 | - | </html> |
|
| 26 | + | </html>{{end}} |
|
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | ||
| 3 | - | {% block title %}OG{% endblock %} |
|
| 4 | - | ||
| 5 | - | {% block content %} |
|
| 1 | + | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}OG{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 6 | 4 | <div class="index-container"> |
|
| 7 | 5 | <form action="/check" method="POST" class="check-form"> |
|
| 8 | 6 | <input |
|
| 16 | 14 | <button type="submit">Check</button> |
|
| 17 | 15 | </form> |
|
| 18 | 16 | </div> |
|
| 19 | - | {% endblock %} |
|
| 17 | + | {{end}} |
|
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | ||
| 3 | - | {% block title %}Results — {{ url }}{% endblock %} |
|
| 4 | - | ||
| 5 | - | {% block content %} |
|
| 1 | + | {{define "results.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Results — {{.URL}}{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 6 | 4 | <div class="results-container"> |
|
| 7 | 5 | <div class="results-header"> |
|
| 8 | 6 | <div class="results-url"> |
|
| 9 | 7 | <span class="label">URL</span> |
|
| 10 | - | <a href="{{ url }}" target="_blank" rel="noopener">{{ url }}</a> |
|
| 8 | + | <a href="{{.URL}}" target="_blank" rel="noopener">{{.URL}}</a> |
|
| 11 | 9 | </div> |
|
| 12 | 10 | </div> |
|
| 13 | 11 | ||
| 14 | - | {% if let Some(err) = error %} |
|
| 12 | + | {{if .Error}} |
|
| 15 | 13 | <div class="error"> |
|
| 16 | 14 | <h2>Error</h2> |
|
| 17 | - | <p>{{ err }}</p> |
|
| 15 | + | <p>{{.Error}}</p> |
|
| 18 | 16 | </div> |
|
| 19 | - | {% else %} |
|
| 17 | + | {{else}} |
|
| 20 | 18 | ||
| 21 | - | {% if let Some(image) = og_image %} |
|
| 19 | + | {{if .OGImage}} |
|
| 22 | 20 | <div class="preview-section"> |
|
| 23 | 21 | <h2>Image Preview</h2> |
|
| 24 | 22 | <div class="image-preview"> |
|
| 25 | - | <img src="{{ image }}" alt="OG Image preview" loading="lazy"> |
|
| 23 | + | <img src="{{.OGImage}}" alt="OG Image preview" loading="lazy"> |
|
| 26 | 24 | </div> |
|
| 27 | 25 | </div> |
|
| 28 | - | {% endif %} |
|
| 26 | + | {{end}} |
|
| 29 | 27 | ||
| 30 | - | {% if let Some(fav) = favicon %} |
|
| 28 | + | {{if .Favicon}} |
|
| 31 | 29 | <div class="preview-section"> |
|
| 32 | 30 | <h2>Favicon</h2> |
|
| 33 | - | <img src="{{ fav }}" alt="Favicon" width="32" height="32"> |
|
| 31 | + | <img src="{{.Favicon}}" alt="Favicon" width="32" height="32"> |
|
| 34 | 32 | </div> |
|
| 35 | - | {% endif %} |
|
| 33 | + | {{end}} |
|
| 36 | 34 | ||
| 37 | 35 | <div class="tag-section"> |
|
| 38 | 36 | <h2>Found Tags</h2> |
|
| 39 | - | {% if found_tags.is_empty() %} |
|
| 37 | + | {{if not .FoundTags}} |
|
| 40 | 38 | <p class="empty-state">No OpenGraph tags found.</p> |
|
| 41 | - | {% else %} |
|
| 42 | - | {% for (key, value) in &found_tags %} |
|
| 39 | + | {{else}} |
|
| 40 | + | {{range .FoundTags}} |
|
| 43 | 41 | <div class="tag-item found"> |
|
| 44 | - | <span class="tag-key">{{ key }}</span> |
|
| 45 | - | <span class="tag-value">{{ value }}</span> |
|
| 42 | + | <span class="tag-key">{{.Key}}</span> |
|
| 43 | + | <span class="tag-value">{{.Value}}</span> |
|
| 46 | 44 | </div> |
|
| 47 | - | {% endfor %} |
|
| 48 | - | {% endif %} |
|
| 45 | + | {{end}} |
|
| 46 | + | {{end}} |
|
| 49 | 47 | </div> |
|
| 50 | 48 | ||
| 51 | 49 | <div class="tag-section"> |
|
| 52 | 50 | <h2>Missing Tags</h2> |
|
| 53 | - | {% if missing_tags.is_empty() %} |
|
| 51 | + | {{if not .MissingTags}} |
|
| 54 | 52 | <p class="empty-state">All common tags present.</p> |
|
| 55 | - | {% else %} |
|
| 56 | - | {% for tag in &missing_tags %} |
|
| 53 | + | {{else}} |
|
| 54 | + | {{range .MissingTags}} |
|
| 57 | 55 | <div class="tag-item missing"> |
|
| 58 | - | <span class="tag-key">{{ tag }}</span> |
|
| 56 | + | <span class="tag-key">{{.}}</span> |
|
| 59 | 57 | <span class="tag-value">not found</span> |
|
| 60 | 58 | </div> |
|
| 61 | - | {% endfor %} |
|
| 62 | - | {% endif %} |
|
| 59 | + | {{end}} |
|
| 60 | + | {{end}} |
|
| 63 | 61 | </div> |
|
| 64 | 62 | ||
| 65 | - | {% if !link_tags.is_empty() %} |
|
| 63 | + | {{if .LinkTags}} |
|
| 66 | 64 | <div class="tag-section"> |
|
| 67 | 65 | <h2>Link Tags</h2> |
|
| 68 | - | {% for (rel, href, extra) in &link_tags %} |
|
| 66 | + | {{range .LinkTags}} |
|
| 69 | 67 | <div class="tag-item found"> |
|
| 70 | - | <span class="tag-key">{% if rel.is_empty() %}link{% else %}{{ rel }}{% endif %}</span> |
|
| 68 | + | <span class="tag-key">{{if .Rel}}{{.Rel}}{{else}}link{{end}}</span> |
|
| 71 | 69 | <span class="tag-value"> |
|
| 72 | - | {% if !href.is_empty() %} |
|
| 73 | - | <a href="{{ href }}" target="_blank" rel="noopener">{{ href }}</a> |
|
| 74 | - | {% else %} |
|
| 70 | + | {{if .Href}} |
|
| 71 | + | <a href="{{.Href}}" target="_blank" rel="noopener">{{.Href}}</a> |
|
| 72 | + | {{else}} |
|
| 75 | 73 | <span class="tag-extra">no href</span> |
|
| 76 | - | {% endif %} |
|
| 77 | - | {% if !extra.is_empty() %} |
|
| 78 | - | <span class="tag-extra">{{ extra }}</span> |
|
| 79 | - | {% endif %} |
|
| 74 | + | {{end}} |
|
| 75 | + | {{if .Extra}} |
|
| 76 | + | <span class="tag-extra">{{.Extra}}</span> |
|
| 77 | + | {{end}} |
|
| 80 | 78 | </span> |
|
| 81 | 79 | </div> |
|
| 82 | - | {% endfor %} |
|
| 80 | + | {{end}} |
|
| 83 | 81 | </div> |
|
| 84 | - | {% endif %} |
|
| 82 | + | {{end}} |
|
| 85 | 83 | ||
| 86 | - | {% endif %} |
|
| 84 | + | {{end}} |
|
| 87 | 85 | </div> |
|
| 88 | - | {% endblock %} |
|
| 86 | + | {{end}} |
| 1 | - | APP_PASSWORD=changeme |
|
| 2 | - | USPS_CLIENT_ID=your_client_id_here |
|
| 3 | - | USPS_CLIENT_SECRET=your_client_secret_here |
|
| 4 | - | PORT=3000 |
|
| 5 | - | COOKIE_SECURE=false |
| 1 | - | [package] |
|
| 2 | - | name = "parcels" |
|
| 3 | - | version = "0.1.3" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Minimal package tracking" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [[bin]] |
|
| 11 | - | name = "parcels" |
|
| 12 | - | path = "src/main.rs" |
|
| 13 | - | ||
| 14 | - | [dependencies] |
|
| 15 | - | axum = { workspace = true } |
|
| 16 | - | tokio = { workspace = true } |
|
| 17 | - | serde = { workspace = true } |
|
| 18 | - | serde_json = { workspace = true } |
|
| 19 | - | tower-http = { workspace = true, features = ["fs"] } |
|
| 20 | - | rand = { workspace = true } |
|
| 21 | - | subtle = { workspace = true } |
|
| 22 | - | tracing = { workspace = true } |
|
| 23 | - | tracing-subscriber = { workspace = true, features = ["env-filter"] } |
|
| 24 | - | andromeda-auth = { workspace = true } |
|
| 25 | - | andromeda-db = { workspace = true, features = ["session"] } |
|
| 26 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 27 | - | rusqlite = { workspace = true } |
|
| 28 | - | reqwest = { version = "0.12", features = ["json"] } |
|
| 29 | - | askama = { version = "0.12", features = ["with-axum"] } |
|
| 30 | - | askama_axum = "0.4" |
|
| 31 | - | anyhow = "1" |
| 1 | - | # Build from repo root: docker build -t parcels -f apps/parcels/Dockerfile . |
|
| 2 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | ||
| 5 | - | FROM chef AS planner |
|
| 6 | - | COPY . . |
|
| 7 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 8 | - | ||
| 9 | - | FROM chef AS builder |
|
| 10 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 11 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 12 | - | RUN cargo chef cook --release --recipe-path recipe.json -p parcels |
|
| 13 | - | COPY . . |
|
| 14 | - | RUN cargo build --release -p parcels |
|
| 15 | - | ||
| 16 | - | FROM debian:bookworm-slim |
|
| 17 | - | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 18 | - | COPY --from=builder /app/target/release/parcels /usr/local/bin/parcels |
|
| 19 | - | WORKDIR /data |
|
| 20 | - | COPY --from=builder /app/apps/parcels/static ./static |
|
| 21 | - | EXPOSE 3000 |
|
| 22 | - | CMD ["parcels"] |
| 1 | - | MIT License |
|
| 2 | - | ||
| 3 | - | Copyright (c) 2026 Steve Simkins |
|
| 4 | - | ||
| 5 | - | Permission is hereby granted, free of charge, to any person obtaining a copy |
|
| 6 | - | of this software and associated documentation files (the "Software"), to deal |
|
| 7 | - | in the Software without restriction, including without limitation the rights |
|
| 8 | - | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
| 9 | - | copies of the Software, and to permit persons to whom the Software is |
|
| 10 | - | furnished to do so, subject to the following conditions: |
|
| 11 | - | ||
| 12 | - | The above copyright notice and this permission notice shall be included in all |
|
| 13 | - | copies or substantial portions of the Software. |
|
| 14 | - | ||
| 15 | - | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
| 16 | - | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
| 17 | - | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
| 18 | - | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
| 19 | - | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
| 20 | - | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
| 21 | - | SOFTWARE. |
|
| 22 | - |
| 1 | - | # Parcels |
|
| 2 | - | ||
| 3 | - |  |
|
| 4 | - | ||
| 5 | - | A minimal package tracking app |
|
| 6 | - | ||
| 7 | - | >[!WARNING] |
|
| 8 | - | >This app originally used USPS, but starting April 1st 2026, it has become much harder to obtain/maintain API keys. Please see the [opened issue](#4) on the matter. |
|
| 9 | - | ||
| 10 | - | ## Quickstart |
|
| 11 | - | ||
| 12 | - | ```bash |
|
| 13 | - | git clone https://github.com/stevedylandev/parcels.git |
|
| 14 | - | cd parcels |
|
| 15 | - | cp .env.example .env |
|
| 16 | - | # Edit .env with your USPS API credentials and app password |
|
| 17 | - | cargo build --release |
|
| 18 | - | ./target/release/parcels |
|
| 19 | - | ``` |
|
| 20 | - | ||
| 21 | - | You'll need a [USPS Web Tools API](https://developer.usps.com) account to get your `USPS_CLIENT_ID` and `USPS_CLIENT_SECRET`. |
|
| 22 | - | ||
| 23 | - | ### Environment Variables |
|
| 24 | - | ||
| 25 | - | | Variable | Description | Default | |
|
| 26 | - | |---|---|---| |
|
| 27 | - | | `APP_PASSWORD` | Password for login authentication | *required* | |
|
| 28 | - | | `PARCELS_DB_PATH` | SQLite database file path | `parcels.db` | |
|
| 29 | - | | `USPS_CLIENT_ID` | USPS OAuth2 client ID | *required* | |
|
| 30 | - | | `USPS_CLIENT_SECRET` | USPS OAuth2 client secret | *required* | |
|
| 31 | - | | `PORT` | Server port | `3000` | |
|
| 32 | - | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 33 | - | ||
| 34 | - | ## Overview |
|
| 35 | - | ||
| 36 | - | I got tired of logging into USPS, so I built this to track my own personal packages. Over time I might add more providers, but it currently gets the job done. Here's a few highlights: |
|
| 37 | - | - Single ~7MB Rust binary |
|
| 38 | - | - Averages around ~10MB of Ram usage |
|
| 39 | - | - Password authentication |
|
| 40 | - | - Track USPS packages with custom labels |
|
| 41 | - | - Delete packages you no longer want to track |
|
| 42 | - | ||
| 43 | - | ## Structure |
|
| 44 | - | ||
| 45 | - | ``` |
|
| 46 | - | parcels/ |
|
| 47 | - | ├── src/ |
|
| 48 | - | │ ├── main.rs # Axum web server, routes, and app state |
|
| 49 | - | │ ├── auth.rs # Password verification and session management |
|
| 50 | - | │ ├── db.rs # SQLite database layer (packages, events, sessions) |
|
| 51 | - | │ └── usps.rs # USPS API integration with OAuth2 token caching |
|
| 52 | - | ├── templates/ # Askama HTML templates |
|
| 53 | - | │ ├── base.html # Base layout |
|
| 54 | - | │ ├── index.html # Package list |
|
| 55 | - | │ ├── detail.html # Package detail with tracking events |
|
| 56 | - | │ ├── add.html # Add package form |
|
| 57 | - | │ └── login.html # Login page |
|
| 58 | - | ├── static/ # Fonts, favicons, and images |
|
| 59 | - | ├── Dockerfile # Multi-stage build (Rust 1.87 + Debian slim) |
|
| 60 | - | └── docker-compose.yml |
|
| 61 | - | ``` |
|
| 62 | - | ||
| 63 | - | ## Deployment |
|
| 64 | - | ||
| 65 | - | ### Railway |
|
| 66 | - | ||
| 67 | - | [](https://railway.com/deploy/HNQUs4?referralCode=JGcIp6) |
|
| 68 | - | ||
| 69 | - | ### Docker (recommended) |
|
| 70 | - | ||
| 71 | - | ```bash |
|
| 72 | - | git clone https://github.com/stevedylandev/parcels.git |
|
| 73 | - | cd parcels |
|
| 74 | - | cp .env.example .env |
|
| 75 | - | # Edit .env with your credentials |
|
| 76 | - | docker compose up -d |
|
| 77 | - | ``` |
|
| 78 | - | ||
| 79 | - | This will start Parcels on port `3000` with a persistent volume for the SQLite database. |
|
| 80 | - | ||
| 81 | - | ### Binary |
|
| 82 | - | ||
| 83 | - | ```bash |
|
| 84 | - | cargo build --release |
|
| 85 | - | ``` |
|
| 86 | - | ||
| 87 | - | The resulting binary at `./target/release/parcels` is self-contained (~7MB). Copy it to your server along with the `static/` directory and a configured `.env` file, then run it directly. |
|
| 88 | - | ||
| 89 | - | ## License |
|
| 90 | - | ||
| 91 | - | [MIT](LICENSE) |
| 1 | - | services: |
|
| 2 | - | parcels: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/parcels/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "3000:3000" |
|
| 8 | - | volumes: |
|
| 9 | - | - parcels_data:/data |
|
| 10 | - | env_file: |
|
| 11 | - | - .env |
|
| 12 | - | restart: unless-stopped |
|
| 13 | - | ||
| 14 | - | volumes: |
|
| 15 | - | parcels_data: |
| 1 | - | use axum::{ |
|
| 2 | - | extract::{FromRef, FromRequestParts}, |
|
| 3 | - | http::request::Parts, |
|
| 4 | - | response::{IntoResponse, Redirect, Response}, |
|
| 5 | - | }; |
|
| 6 | - | use std::sync::Arc; |
|
| 7 | - | ||
| 8 | - | use crate::AppState; |
|
| 9 | - | ||
| 10 | - | pub use andromeda_auth::{ |
|
| 11 | - | build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token, |
|
| 12 | - | verify_password, |
|
| 13 | - | }; |
|
| 14 | - | ||
| 15 | - | /// Return an ISO datetime string 7 days from now. |
|
| 16 | - | pub fn session_expiry_at() -> String { |
|
| 17 | - | andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600) |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | - | pub fn extract_session_token(headers: &axum::http::HeaderMap) -> Option<String> { |
|
| 21 | - | extract_session_cookie(headers) |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | /// Authenticated session guard. Extract from request; redirects to /login if not valid. |
|
| 25 | - | pub struct AuthSession; |
|
| 26 | - | ||
| 27 | - | impl<S> FromRequestParts<S> for AuthSession |
|
| 28 | - | where |
|
| 29 | - | S: Send + Sync, |
|
| 30 | - | Arc<AppState>: FromRef<S>, |
|
| 31 | - | { |
|
| 32 | - | type Rejection = Response; |
|
| 33 | - | ||
| 34 | - | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
|
| 35 | - | let state = Arc::<AppState>::from_ref(state); |
|
| 36 | - | let token = extract_session_cookie(&parts.headers); |
|
| 37 | - | ||
| 38 | - | if let Some(token) = token { |
|
| 39 | - | if is_valid_session(&state, &token).await { |
|
| 40 | - | return Ok(AuthSession); |
|
| 41 | - | } |
|
| 42 | - | } |
|
| 43 | - | ||
| 44 | - | Err(Redirect::to("/login").into_response()) |
|
| 45 | - | } |
|
| 46 | - | } |
|
| 47 | - | ||
| 48 | - | async fn is_valid_session(state: &AppState, token: &str) -> bool { |
|
| 49 | - | match crate::db::get_session_expiry(&state.db, token) { |
|
| 50 | - | Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(), |
|
| 51 | - | _ => false, |
|
| 52 | - | } |
|
| 53 | - | } |
| 1 | - | use rusqlite::{Connection, OptionalExtension, params}; |
|
| 2 | - | use std::sync::{Arc, Mutex}; |
|
| 3 | - | ||
| 4 | - | pub use andromeda_db::{Db, DbError}; |
|
| 5 | - | pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions}; |
|
| 6 | - | ||
| 7 | - | // ── Types ─────────────────────────────────────────────────────────────────── |
|
| 8 | - | ||
| 9 | - | #[derive(Debug, Clone)] |
|
| 10 | - | pub struct Package { |
|
| 11 | - | pub id: i64, |
|
| 12 | - | pub tracking_number: String, |
|
| 13 | - | pub label: Option<String>, |
|
| 14 | - | pub status: Option<String>, |
|
| 15 | - | pub status_category: Option<String>, |
|
| 16 | - | pub status_summary: Option<String>, |
|
| 17 | - | pub mail_class: Option<String>, |
|
| 18 | - | pub expected_delivery: Option<String>, |
|
| 19 | - | pub last_refreshed_at: Option<String>, |
|
| 20 | - | pub created_at: String, |
|
| 21 | - | } |
|
| 22 | - | ||
| 23 | - | #[derive(Debug, Clone)] |
|
| 24 | - | pub struct TrackingEvent { |
|
| 25 | - | pub id: i64, |
|
| 26 | - | pub package_id: i64, |
|
| 27 | - | pub event_timestamp: Option<String>, |
|
| 28 | - | pub event_type: Option<String>, |
|
| 29 | - | pub event_city: Option<String>, |
|
| 30 | - | pub event_state: Option<String>, |
|
| 31 | - | pub event_zip: Option<String>, |
|
| 32 | - | pub event_code: Option<String>, |
|
| 33 | - | } |
|
| 34 | - | ||
| 35 | - | // ── Pool Setup ────────────────────────────────────────────────────────────── |
|
| 36 | - | ||
| 37 | - | const SCHEMA: &str = " |
|
| 38 | - | CREATE TABLE IF NOT EXISTS packages ( |
|
| 39 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 40 | - | tracking_number TEXT NOT NULL UNIQUE, |
|
| 41 | - | label TEXT, |
|
| 42 | - | status TEXT, |
|
| 43 | - | status_category TEXT, |
|
| 44 | - | status_summary TEXT, |
|
| 45 | - | mail_class TEXT, |
|
| 46 | - | expected_delivery TEXT, |
|
| 47 | - | last_refreshed_at TEXT, |
|
| 48 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 49 | - | ); |
|
| 50 | - | CREATE TABLE IF NOT EXISTS tracking_events ( |
|
| 51 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 52 | - | package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE, |
|
| 53 | - | event_timestamp TEXT, |
|
| 54 | - | event_type TEXT, |
|
| 55 | - | event_city TEXT, |
|
| 56 | - | event_state TEXT, |
|
| 57 | - | event_zip TEXT, |
|
| 58 | - | event_code TEXT |
|
| 59 | - | ); |
|
| 60 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 61 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 62 | - | token TEXT NOT NULL UNIQUE, |
|
| 63 | - | expires_at TEXT NOT NULL |
|
| 64 | - | ); |
|
| 65 | - | "; |
|
| 66 | - | ||
| 67 | - | pub fn init_db() -> Db { |
|
| 68 | - | let path = "parcels.db"; |
|
| 69 | - | let conn = Connection::open(path).expect("Failed to open database"); |
|
| 70 | - | conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") |
|
| 71 | - | .expect("Failed to set PRAGMAs"); |
|
| 72 | - | conn.execute_batch(SCHEMA).expect("Failed to create tables"); |
|
| 73 | - | Arc::new(Mutex::new(conn)) |
|
| 74 | - | } |
|
| 75 | - | ||
| 76 | - | // ── Row Helpers ──────────────────────────────────────────────────────────── |
|
| 77 | - | ||
| 78 | - | const PACKAGE_COLS: &str = "id, tracking_number, label, status, status_category, status_summary, mail_class, expected_delivery, last_refreshed_at, created_at"; |
|
| 79 | - | ||
| 80 | - | fn package_from_row(row: &rusqlite::Row) -> rusqlite::Result<Package> { |
|
| 81 | - | Ok(Package { |
|
| 82 | - | id: row.get(0)?, |
|
| 83 | - | tracking_number: row.get(1)?, |
|
| 84 | - | label: row.get(2)?, |
|
| 85 | - | status: row.get(3)?, |
|
| 86 | - | status_category: row.get(4)?, |
|
| 87 | - | status_summary: row.get(5)?, |
|
| 88 | - | mail_class: row.get(6)?, |
|
| 89 | - | expected_delivery: row.get(7)?, |
|
| 90 | - | last_refreshed_at: row.get(8)?, |
|
| 91 | - | created_at: row.get(9)?, |
|
| 92 | - | }) |
|
| 93 | - | } |
|
| 94 | - | ||
| 95 | - | const EVENT_COLS: &str = "id, package_id, event_timestamp, event_type, event_city, event_state, event_zip, event_code"; |
|
| 96 | - | ||
| 97 | - | fn event_from_row(row: &rusqlite::Row) -> rusqlite::Result<TrackingEvent> { |
|
| 98 | - | Ok(TrackingEvent { |
|
| 99 | - | id: row.get(0)?, |
|
| 100 | - | package_id: row.get(1)?, |
|
| 101 | - | event_timestamp: row.get(2)?, |
|
| 102 | - | event_type: row.get(3)?, |
|
| 103 | - | event_city: row.get(4)?, |
|
| 104 | - | event_state: row.get(5)?, |
|
| 105 | - | event_zip: row.get(6)?, |
|
| 106 | - | event_code: row.get(7)?, |
|
| 107 | - | }) |
|
| 108 | - | } |
|
| 109 | - | ||
| 110 | - | // ── Package Queries ───────────────────────────────────────────────────────── |
|
| 111 | - | ||
| 112 | - | pub fn list_packages(db: &Db) -> Result<Vec<Package>, DbError> { |
|
| 113 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 114 | - | let mut stmt = conn.prepare( |
|
| 115 | - | &format!("SELECT {} FROM packages ORDER BY created_at DESC", PACKAGE_COLS), |
|
| 116 | - | )?; |
|
| 117 | - | let packages = stmt |
|
| 118 | - | .query_map([], package_from_row)? |
|
| 119 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 120 | - | Ok(packages) |
|
| 121 | - | } |
|
| 122 | - | ||
| 123 | - | pub fn get_package(db: &Db, id: i64) -> Result<Option<Package>, DbError> { |
|
| 124 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 125 | - | let pkg = conn |
|
| 126 | - | .query_row( |
|
| 127 | - | &format!("SELECT {} FROM packages WHERE id = ?1", PACKAGE_COLS), |
|
| 128 | - | params![id], |
|
| 129 | - | package_from_row, |
|
| 130 | - | ) |
|
| 131 | - | .optional()?; |
|
| 132 | - | Ok(pkg) |
|
| 133 | - | } |
|
| 134 | - | ||
| 135 | - | pub fn insert_package(db: &Db, tracking_number: &str, label: Option<&str>) -> Result<i64, DbError> { |
|
| 136 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 137 | - | conn.execute( |
|
| 138 | - | "INSERT INTO packages (tracking_number, label) VALUES (?1, ?2)", |
|
| 139 | - | params![tracking_number, label], |
|
| 140 | - | )?; |
|
| 141 | - | Ok(conn.last_insert_rowid()) |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | pub fn update_package_status( |
|
| 145 | - | db: &Db, |
|
| 146 | - | id: i64, |
|
| 147 | - | status: &str, |
|
| 148 | - | status_category: Option<&str>, |
|
| 149 | - | status_summary: Option<&str>, |
|
| 150 | - | mail_class: Option<&str>, |
|
| 151 | - | expected_delivery: Option<&str>, |
|
| 152 | - | last_refreshed_at: &str, |
|
| 153 | - | ) -> Result<(), DbError> { |
|
| 154 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 155 | - | conn.execute( |
|
| 156 | - | "UPDATE packages SET status = ?1, status_category = ?2, status_summary = ?3, |
|
| 157 | - | mail_class = ?4, expected_delivery = ?5, last_refreshed_at = ?6 |
|
| 158 | - | WHERE id = ?7", |
|
| 159 | - | params![status, status_category, status_summary, mail_class, expected_delivery, last_refreshed_at, id], |
|
| 160 | - | )?; |
|
| 161 | - | Ok(()) |
|
| 162 | - | } |
|
| 163 | - | ||
| 164 | - | pub fn delete_package(db: &Db, id: i64) -> Result<(), DbError> { |
|
| 165 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 166 | - | conn.execute("DELETE FROM packages WHERE id = ?1", params![id])?; |
|
| 167 | - | Ok(()) |
|
| 168 | - | } |
|
| 169 | - | ||
| 170 | - | // ── Tracking Event Queries ─────────────────────────────────────────────────── |
|
| 171 | - | ||
| 172 | - | pub fn delete_events_for_package(db: &Db, package_id: i64) -> Result<(), DbError> { |
|
| 173 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 174 | - | conn.execute("DELETE FROM tracking_events WHERE package_id = ?1", params![package_id])?; |
|
| 175 | - | Ok(()) |
|
| 176 | - | } |
|
| 177 | - | ||
| 178 | - | pub fn insert_event( |
|
| 179 | - | db: &Db, |
|
| 180 | - | package_id: i64, |
|
| 181 | - | event_timestamp: Option<&str>, |
|
| 182 | - | event_type: Option<&str>, |
|
| 183 | - | event_city: Option<&str>, |
|
| 184 | - | event_state: Option<&str>, |
|
| 185 | - | event_zip: Option<&str>, |
|
| 186 | - | event_code: Option<&str>, |
|
| 187 | - | ) -> Result<(), DbError> { |
|
| 188 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 189 | - | conn.execute( |
|
| 190 | - | "INSERT INTO tracking_events |
|
| 191 | - | (package_id, event_timestamp, event_type, event_city, event_state, event_zip, event_code) |
|
| 192 | - | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", |
|
| 193 | - | params![package_id, event_timestamp, event_type, event_city, event_state, event_zip, event_code], |
|
| 194 | - | )?; |
|
| 195 | - | Ok(()) |
|
| 196 | - | } |
|
| 197 | - | ||
| 198 | - | pub fn get_events_for_package(db: &Db, package_id: i64) -> Result<Vec<TrackingEvent>, DbError> { |
|
| 199 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 200 | - | let mut stmt = conn.prepare( |
|
| 201 | - | &format!("SELECT {} FROM tracking_events WHERE package_id = ?1 ORDER BY event_timestamp DESC", EVENT_COLS), |
|
| 202 | - | )?; |
|
| 203 | - | let events = stmt |
|
| 204 | - | .query_map(params![package_id], event_from_row)? |
|
| 205 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 206 | - | Ok(events) |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | #[cfg(test)] |
|
| 210 | - | mod tests { |
|
| 211 | - | use super::*; |
|
| 212 | - | ||
| 213 | - | fn test_db() -> Db { |
|
| 214 | - | let conn = Connection::open_in_memory().unwrap(); |
|
| 215 | - | conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); |
|
| 216 | - | conn.execute_batch(SCHEMA).unwrap(); |
|
| 217 | - | Arc::new(Mutex::new(conn)) |
|
| 218 | - | } |
|
| 219 | - | ||
| 220 | - | // ── Package CRUD ─────────────────────────────────────────────────── |
|
| 221 | - | ||
| 222 | - | #[test] |
|
| 223 | - | fn insert_and_list_packages() { |
|
| 224 | - | let db = test_db(); |
|
| 225 | - | let id = insert_package(&db, "TRACK001", Some("My Package")).unwrap(); |
|
| 226 | - | assert!(id > 0); |
|
| 227 | - | ||
| 228 | - | let packages = list_packages(&db).unwrap(); |
|
| 229 | - | assert_eq!(packages.len(), 1); |
|
| 230 | - | assert_eq!(packages[0].tracking_number, "TRACK001"); |
|
| 231 | - | assert_eq!(packages[0].label.as_deref(), Some("My Package")); |
|
| 232 | - | } |
|
| 233 | - | ||
| 234 | - | #[test] |
|
| 235 | - | fn insert_duplicate_tracking_number_fails() { |
|
| 236 | - | let db = test_db(); |
|
| 237 | - | insert_package(&db, "TRACK001", None).unwrap(); |
|
| 238 | - | let result = insert_package(&db, "TRACK001", None); |
|
| 239 | - | assert!(result.is_err()); |
|
| 240 | - | } |
|
| 241 | - | ||
| 242 | - | #[test] |
|
| 243 | - | fn get_package_found() { |
|
| 244 | - | let db = test_db(); |
|
| 245 | - | let id = insert_package(&db, "TRACK001", None).unwrap(); |
|
| 246 | - | let pkg = get_package(&db, id).unwrap(); |
|
| 247 | - | assert!(pkg.is_some()); |
|
| 248 | - | assert_eq!(pkg.unwrap().tracking_number, "TRACK001"); |
|
| 249 | - | } |
|
| 250 | - | ||
| 251 | - | #[test] |
|
| 252 | - | fn get_package_not_found() { |
|
| 253 | - | let db = test_db(); |
|
| 254 | - | let pkg = get_package(&db, 999).unwrap(); |
|
| 255 | - | assert!(pkg.is_none()); |
|
| 256 | - | } |
|
| 257 | - | ||
| 258 | - | #[test] |
|
| 259 | - | fn update_package_status_works() { |
|
| 260 | - | let db = test_db(); |
|
| 261 | - | let id = insert_package(&db, "TRACK001", None).unwrap(); |
|
| 262 | - | update_package_status( |
|
| 263 | - | &db, |
|
| 264 | - | id, |
|
| 265 | - | "Delivered", |
|
| 266 | - | Some("Delivered"), |
|
| 267 | - | Some("Package delivered"), |
|
| 268 | - | Some("Priority"), |
|
| 269 | - | Some("2024-01-20"), |
|
| 270 | - | "2024-01-18 12:00:00", |
|
| 271 | - | ) |
|
| 272 | - | .unwrap(); |
|
| 273 | - | ||
| 274 | - | let pkg = get_package(&db, id).unwrap().unwrap(); |
|
| 275 | - | assert_eq!(pkg.status.as_deref(), Some("Delivered")); |
|
| 276 | - | assert_eq!(pkg.mail_class.as_deref(), Some("Priority")); |
|
| 277 | - | } |
|
| 278 | - | ||
| 279 | - | #[test] |
|
| 280 | - | fn delete_package_removes_it() { |
|
| 281 | - | let db = test_db(); |
|
| 282 | - | let id = insert_package(&db, "TRACK001", None).unwrap(); |
|
| 283 | - | delete_package(&db, id).unwrap(); |
|
| 284 | - | assert!(get_package(&db, id).unwrap().is_none()); |
|
| 285 | - | } |
|
| 286 | - | ||
| 287 | - | // ── Tracking Events ──────────────────────────────────────────────── |
|
| 288 | - | ||
| 289 | - | #[test] |
|
| 290 | - | fn insert_and_get_events() { |
|
| 291 | - | let db = test_db(); |
|
| 292 | - | let pkg_id = insert_package(&db, "TRACK001", None).unwrap(); |
|
| 293 | - | ||
| 294 | - | insert_event( |
|
| 295 | - | &db, |
|
| 296 | - | pkg_id, |
|
| 297 | - | Some("2024-01-15 10:00:00"), |
|
| 298 | - | Some("Delivered"), |
|
| 299 | - | Some("New York"), |
|
| 300 | - | Some("NY"), |
|
| 301 | - | Some("10001"), |
|
| 302 | - | Some("01"), |
|
| 303 | - | ) |
|
| 304 | - | .unwrap(); |
|
| 305 | - | ||
| 306 | - | insert_event( |
|
| 307 | - | &db, |
|
| 308 | - | pkg_id, |
|
| 309 | - | Some("2024-01-14 08:00:00"), |
|
| 310 | - | Some("In Transit"), |
|
| 311 | - | Some("Chicago"), |
|
| 312 | - | Some("IL"), |
|
| 313 | - | None, |
|
| 314 | - | None, |
|
| 315 | - | ) |
|
| 316 | - | .unwrap(); |
|
| 317 | - | ||
| 318 | - | let events = get_events_for_package(&db, pkg_id).unwrap(); |
|
| 319 | - | assert_eq!(events.len(), 2); |
|
| 320 | - | // Ordered by timestamp DESC |
|
| 321 | - | assert_eq!(events[0].event_city.as_deref(), Some("New York")); |
|
| 322 | - | assert_eq!(events[1].event_city.as_deref(), Some("Chicago")); |
|
| 323 | - | } |
|
| 324 | - | ||
| 325 | - | #[test] |
|
| 326 | - | fn delete_events_for_package_clears_them() { |
|
| 327 | - | let db = test_db(); |
|
| 328 | - | let pkg_id = insert_package(&db, "TRACK001", None).unwrap(); |
|
| 329 | - | insert_event(&db, pkg_id, None, Some("Shipped"), None, None, None, None).unwrap(); |
|
| 330 | - | ||
| 331 | - | delete_events_for_package(&db, pkg_id).unwrap(); |
|
| 332 | - | let events = get_events_for_package(&db, pkg_id).unwrap(); |
|
| 333 | - | assert!(events.is_empty()); |
|
| 334 | - | } |
|
| 335 | - | ||
| 336 | - | #[test] |
|
| 337 | - | fn delete_package_cascades_to_events() { |
|
| 338 | - | let db = test_db(); |
|
| 339 | - | let pkg_id = insert_package(&db, "TRACK001", None).unwrap(); |
|
| 340 | - | insert_event(&db, pkg_id, None, Some("Shipped"), None, None, None, None).unwrap(); |
|
| 341 | - | ||
| 342 | - | delete_package(&db, pkg_id).unwrap(); |
|
| 343 | - | let events = get_events_for_package(&db, pkg_id).unwrap(); |
|
| 344 | - | assert!(events.is_empty()); |
|
| 345 | - | } |
|
| 346 | - | ||
| 347 | - | // ── Sessions ─────────────────────────────────────────────────────── |
|
| 348 | - | ||
| 349 | - | #[test] |
|
| 350 | - | fn session_crud_lifecycle() { |
|
| 351 | - | let db = test_db(); |
|
| 352 | - | let token = "abc123"; |
|
| 353 | - | ||
| 354 | - | insert_session(&db, token, "2099-01-01 00:00:00").unwrap(); |
|
| 355 | - | ||
| 356 | - | let expiry = get_session_expiry(&db, token).unwrap(); |
|
| 357 | - | assert_eq!(expiry, Some("2099-01-01 00:00:00".to_string())); |
|
| 358 | - | ||
| 359 | - | delete_session(&db, token).unwrap(); |
|
| 360 | - | let expiry = get_session_expiry(&db, token).unwrap(); |
|
| 361 | - | assert!(expiry.is_none()); |
|
| 362 | - | } |
|
| 363 | - | ||
| 364 | - | #[test] |
|
| 365 | - | fn get_session_expiry_missing_token() { |
|
| 366 | - | let db = test_db(); |
|
| 367 | - | let expiry = get_session_expiry(&db, "nonexistent").unwrap(); |
|
| 368 | - | assert!(expiry.is_none()); |
|
| 369 | - | } |
|
| 370 | - | ||
| 371 | - | #[test] |
|
| 372 | - | fn prune_expired_sessions_removes_old() { |
|
| 373 | - | let db = test_db(); |
|
| 374 | - | insert_session(&db, "expired", "2000-01-01 00:00:00").unwrap(); |
|
| 375 | - | insert_session(&db, "valid", "2099-01-01 00:00:00").unwrap(); |
|
| 376 | - | ||
| 377 | - | prune_expired_sessions(&db).unwrap(); |
|
| 378 | - | ||
| 379 | - | assert!(get_session_expiry(&db, "expired").unwrap().is_none()); |
|
| 380 | - | assert!(get_session_expiry(&db, "valid").unwrap().is_some()); |
|
| 381 | - | } |
|
| 382 | - | } |
| 1 | - | mod db; |
|
| 2 | - | mod auth; |
|
| 3 | - | mod usps; |
|
| 4 | - | ||
| 5 | - | use askama::Template; |
|
| 6 | - | use axum::{ |
|
| 7 | - | Form, |
|
| 8 | - | Router, |
|
| 9 | - | extract::{Path, Query, State}, |
|
| 10 | - | http::{HeaderMap, StatusCode}, |
|
| 11 | - | response::{Html, IntoResponse, Redirect, Response}, |
|
| 12 | - | routing::{get, post}, |
|
| 13 | - | }; |
|
| 14 | - | use db::Db; |
|
| 15 | - | use reqwest::Client; |
|
| 16 | - | use serde::Deserialize; |
|
| 17 | - | use std::sync::{Arc, Mutex}; |
|
| 18 | - | use tower_http::services::ServeDir; |
|
| 19 | - | ||
| 20 | - | // ── App State ────────────────────────────────────────────────────────────── |
|
| 21 | - | ||
| 22 | - | pub struct AppState { |
|
| 23 | - | pub db: Db, |
|
| 24 | - | pub app_password: String, |
|
| 25 | - | pub cookie_secure: bool, |
|
| 26 | - | pub usps_token: Arc<Mutex<Option<usps::CachedToken>>>, |
|
| 27 | - | pub usps_client_id: String, |
|
| 28 | - | pub usps_client_secret: String, |
|
| 29 | - | pub http_client: Client, |
|
| 30 | - | } |
|
| 31 | - | ||
| 32 | - | // ── Template rendering helper ────────────────────────────────────────────── |
|
| 33 | - | ||
| 34 | - | fn render<T: Template>(t: T) -> Response { |
|
| 35 | - | match t.render() { |
|
| 36 | - | Ok(html) => Html(html).into_response(), |
|
| 37 | - | Err(e) => { |
|
| 38 | - | tracing::error!("Template render error: {}", e); |
|
| 39 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response() |
|
| 40 | - | } |
|
| 41 | - | } |
|
| 42 | - | } |
|
| 43 | - | ||
| 44 | - | // ── Query params ─────────────────────────────────────────────────────────── |
|
| 45 | - | ||
| 46 | - | #[derive(Deserialize, Default)] |
|
| 47 | - | pub struct ErrorQuery { |
|
| 48 | - | pub error: Option<String>, |
|
| 49 | - | } |
|
| 50 | - | ||
| 51 | - | // ── Templates ────────────────────────────────────────────────────────────── |
|
| 52 | - | ||
| 53 | - | #[derive(Template)] |
|
| 54 | - | #[template(path = "login.html")] |
|
| 55 | - | struct LoginTemplate { |
|
| 56 | - | error: Option<String>, |
|
| 57 | - | } |
|
| 58 | - | ||
| 59 | - | #[derive(Template)] |
|
| 60 | - | #[template(path = "index.html")] |
|
| 61 | - | struct IndexTemplate { |
|
| 62 | - | packages: Vec<db::Package>, |
|
| 63 | - | error: Option<String>, |
|
| 64 | - | } |
|
| 65 | - | ||
| 66 | - | #[derive(Template)] |
|
| 67 | - | #[template(path = "add.html")] |
|
| 68 | - | struct AddTemplate { |
|
| 69 | - | error: Option<String>, |
|
| 70 | - | } |
|
| 71 | - | ||
| 72 | - | #[derive(Template)] |
|
| 73 | - | #[template(path = "detail.html")] |
|
| 74 | - | struct DetailTemplate { |
|
| 75 | - | package: db::Package, |
|
| 76 | - | events: Vec<db::TrackingEvent>, |
|
| 77 | - | error: Option<String>, |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | - | // ── Login ────────────────────────────────────────────────────────────────── |
|
| 81 | - | ||
| 82 | - | async fn get_login(Query(q): Query<ErrorQuery>) -> Response { |
|
| 83 | - | render(LoginTemplate { error: q.error }) |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | #[derive(Deserialize)] |
|
| 87 | - | struct LoginForm { |
|
| 88 | - | password: String, |
|
| 89 | - | } |
|
| 90 | - | ||
| 91 | - | async fn post_login( |
|
| 92 | - | State(state): State<Arc<AppState>>, |
|
| 93 | - | Form(form): Form<LoginForm>, |
|
| 94 | - | ) -> Response { |
|
| 95 | - | if !auth::verify_password(&form.password, &state.app_password) { |
|
| 96 | - | return render(LoginTemplate { error: Some("Invalid password.".to_string()) }); |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | let _ = db::prune_expired_sessions(&state.db); |
|
| 100 | - | ||
| 101 | - | let token = auth::generate_session_token(); |
|
| 102 | - | let expires_at = auth::session_expiry_at(); |
|
| 103 | - | ||
| 104 | - | if let Err(e) = db::insert_session(&state.db, &token, &expires_at) { |
|
| 105 | - | tracing::error!("Failed to insert session: {}", e); |
|
| 106 | - | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 107 | - | } |
|
| 108 | - | ||
| 109 | - | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 110 | - | let mut resp = Redirect::to("/").into_response(); |
|
| 111 | - | resp.headers_mut().insert("set-cookie", cookie.parse().unwrap()); |
|
| 112 | - | resp |
|
| 113 | - | } |
|
| 114 | - | ||
| 115 | - | // ── Logout ───────────────────────────────────────────────────────────────── |
|
| 116 | - | ||
| 117 | - | async fn get_logout( |
|
| 118 | - | State(state): State<Arc<AppState>>, |
|
| 119 | - | headers: HeaderMap, |
|
| 120 | - | ) -> Response { |
|
| 121 | - | if let Some(token) = auth::extract_session_token(&headers) { |
|
| 122 | - | let _ = db::delete_session(&state.db, &token); |
|
| 123 | - | } |
|
| 124 | - | let mut resp = Redirect::to("/login").into_response(); |
|
| 125 | - | resp.headers_mut().insert("set-cookie", auth::clear_session_cookie().parse().unwrap()); |
|
| 126 | - | resp |
|
| 127 | - | } |
|
| 128 | - | ||
| 129 | - | // ── Refresh Helper ───────────────────────────────────────────────────────── |
|
| 130 | - | ||
| 131 | - | async fn refresh_one(state: &AppState, package: &db::Package) -> Result<(), anyhow::Error> { |
|
| 132 | - | let token = usps::get_token( |
|
| 133 | - | &state.usps_token, |
|
| 134 | - | &state.http_client, |
|
| 135 | - | &state.usps_client_id, |
|
| 136 | - | &state.usps_client_secret, |
|
| 137 | - | ) |
|
| 138 | - | .await?; |
|
| 139 | - | ||
| 140 | - | let detail = usps::fetch_tracking(&state.http_client, &token, &package.tracking_number).await?; |
|
| 141 | - | ||
| 142 | - | let refreshed_at = andromeda_auth::datetime::now_datetime_string(); |
|
| 143 | - | ||
| 144 | - | let expected_delivery = detail |
|
| 145 | - | .delivery_date_expectation |
|
| 146 | - | .as_ref() |
|
| 147 | - | .and_then(|d| d.expected_delivery_date.as_deref()); |
|
| 148 | - | ||
| 149 | - | db::update_package_status( |
|
| 150 | - | &state.db, |
|
| 151 | - | package.id, |
|
| 152 | - | detail.status.as_deref().unwrap_or(""), |
|
| 153 | - | detail.status_category.as_deref(), |
|
| 154 | - | detail.status_summary.as_deref(), |
|
| 155 | - | detail.mail_class.as_deref(), |
|
| 156 | - | expected_delivery, |
|
| 157 | - | &refreshed_at, |
|
| 158 | - | )?; |
|
| 159 | - | ||
| 160 | - | db::delete_events_for_package(&state.db, package.id)?; |
|
| 161 | - | ||
| 162 | - | for event in &detail.tracking_events { |
|
| 163 | - | if let Err(e) = db::insert_event( |
|
| 164 | - | &state.db, |
|
| 165 | - | package.id, |
|
| 166 | - | event.event_timestamp.as_deref(), |
|
| 167 | - | event.event_type.as_deref(), |
|
| 168 | - | event.event_city.as_deref(), |
|
| 169 | - | event.event_state.as_deref(), |
|
| 170 | - | event.event_zip_code.as_deref(), |
|
| 171 | - | event.event_code.as_deref(), |
|
| 172 | - | ) { |
|
| 173 | - | tracing::warn!("DB error inserting event for package {}: {}", package.id, e); |
|
| 174 | - | } |
|
| 175 | - | } |
|
| 176 | - | ||
| 177 | - | Ok(()) |
|
| 178 | - | } |
|
| 179 | - | ||
| 180 | - | // ── Index ────────────────────────────────────────────────────────────────── |
|
| 181 | - | ||
| 182 | - | async fn get_index( |
|
| 183 | - | _session: auth::AuthSession, |
|
| 184 | - | State(state): State<Arc<AppState>>, |
|
| 185 | - | Query(q): Query<ErrorQuery>, |
|
| 186 | - | ) -> Response { |
|
| 187 | - | let packages = match db::list_packages(&state.db) { |
|
| 188 | - | Ok(p) => p, |
|
| 189 | - | Err(e) => { |
|
| 190 | - | tracing::error!("DB error listing packages: {}", e); |
|
| 191 | - | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 192 | - | } |
|
| 193 | - | }; |
|
| 194 | - | ||
| 195 | - | for package in &packages { |
|
| 196 | - | if let Err(e) = refresh_one(&state, package).await { |
|
| 197 | - | tracing::warn!("Failed to refresh package {}: {}", package.id, e); |
|
| 198 | - | } |
|
| 199 | - | } |
|
| 200 | - | ||
| 201 | - | match db::list_packages(&state.db) { |
|
| 202 | - | Ok(packages) => render(IndexTemplate { packages, error: q.error }), |
|
| 203 | - | Err(e) => { |
|
| 204 | - | tracing::error!("DB error listing packages after refresh: {}", e); |
|
| 205 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response() |
|
| 206 | - | } |
|
| 207 | - | } |
|
| 208 | - | } |
|
| 209 | - | ||
| 210 | - | // ── Add Package ──────────────────────────────────────────────────────────── |
|
| 211 | - | ||
| 212 | - | async fn get_add( |
|
| 213 | - | _session: auth::AuthSession, |
|
| 214 | - | Query(q): Query<ErrorQuery>, |
|
| 215 | - | ) -> Response { |
|
| 216 | - | render(AddTemplate { error: q.error }) |
|
| 217 | - | } |
|
| 218 | - | ||
| 219 | - | #[derive(Deserialize)] |
|
| 220 | - | struct AddPackageForm { |
|
| 221 | - | tracking_number: String, |
|
| 222 | - | label: Option<String>, |
|
| 223 | - | } |
|
| 224 | - | ||
| 225 | - | async fn post_packages( |
|
| 226 | - | _session: auth::AuthSession, |
|
| 227 | - | State(state): State<Arc<AppState>>, |
|
| 228 | - | Form(form): Form<AddPackageForm>, |
|
| 229 | - | ) -> Response { |
|
| 230 | - | let tracking_number = form.tracking_number.trim().to_uppercase(); |
|
| 231 | - | if tracking_number.is_empty() { |
|
| 232 | - | return Redirect::to("/packages/add?error=Tracking+number+is+required.").into_response(); |
|
| 233 | - | } |
|
| 234 | - | let label = form.label.as_deref().map(str::trim).filter(|s| !s.is_empty()); |
|
| 235 | - | ||
| 236 | - | match db::insert_package(&state.db, &tracking_number, label) { |
|
| 237 | - | Ok(_) => Redirect::to("/").into_response(), |
|
| 238 | - | Err(e) if e.to_string().contains("UNIQUE") => { |
|
| 239 | - | Redirect::to("/packages/add?error=Tracking+number+already+exists.").into_response() |
|
| 240 | - | } |
|
| 241 | - | Err(e) => { |
|
| 242 | - | tracing::error!("DB error inserting package: {}", e); |
|
| 243 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response() |
|
| 244 | - | } |
|
| 245 | - | } |
|
| 246 | - | } |
|
| 247 | - | ||
| 248 | - | // ── Delete Package ───────────────────────────────────────────────────────── |
|
| 249 | - | ||
| 250 | - | async fn post_delete_package( |
|
| 251 | - | _session: auth::AuthSession, |
|
| 252 | - | State(state): State<Arc<AppState>>, |
|
| 253 | - | Path(id): Path<i64>, |
|
| 254 | - | ) -> Response { |
|
| 255 | - | if let Err(e) = db::delete_package(&state.db, id) { |
|
| 256 | - | tracing::error!("DB error deleting package {}: {}", id, e); |
|
| 257 | - | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 258 | - | } |
|
| 259 | - | Redirect::to("/").into_response() |
|
| 260 | - | } |
|
| 261 | - | ||
| 262 | - | // ── Refresh Package ──────────────────────────────────────────────────────── |
|
| 263 | - | ||
| 264 | - | async fn post_refresh_package( |
|
| 265 | - | _session: auth::AuthSession, |
|
| 266 | - | State(state): State<Arc<AppState>>, |
|
| 267 | - | Path(id): Path<i64>, |
|
| 268 | - | ) -> Response { |
|
| 269 | - | let package = match db::get_package(&state.db, id) { |
|
| 270 | - | Ok(Some(p)) => p, |
|
| 271 | - | Ok(None) => return Redirect::to("/?error=Package+not+found.").into_response(), |
|
| 272 | - | Err(e) => { |
|
| 273 | - | tracing::error!("DB error fetching package {}: {}", id, e); |
|
| 274 | - | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 275 | - | } |
|
| 276 | - | }; |
|
| 277 | - | ||
| 278 | - | if let Err(e) = refresh_one(&state, &package).await { |
|
| 279 | - | let msg = urlencoding_encode(&e.to_string()); |
|
| 280 | - | return Redirect::to(&format!("/packages/{}?error={}", id, msg)).into_response(); |
|
| 281 | - | } |
|
| 282 | - | ||
| 283 | - | Redirect::to(&format!("/packages/{}", id)).into_response() |
|
| 284 | - | } |
|
| 285 | - | ||
| 286 | - | // ── Package Detail ───────────────────────────────────────────────────────── |
|
| 287 | - | ||
| 288 | - | async fn get_package_detail( |
|
| 289 | - | _session: auth::AuthSession, |
|
| 290 | - | State(state): State<Arc<AppState>>, |
|
| 291 | - | Path(id): Path<i64>, |
|
| 292 | - | Query(q): Query<ErrorQuery>, |
|
| 293 | - | ) -> Response { |
|
| 294 | - | let package = match db::get_package(&state.db, id) { |
|
| 295 | - | Ok(Some(p)) => p, |
|
| 296 | - | Ok(None) => return Redirect::to("/?error=Package+not+found.").into_response(), |
|
| 297 | - | Err(e) => { |
|
| 298 | - | tracing::error!("DB error fetching package {}: {}", id, e); |
|
| 299 | - | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 300 | - | } |
|
| 301 | - | }; |
|
| 302 | - | ||
| 303 | - | let events = match db::get_events_for_package(&state.db, id) { |
|
| 304 | - | Ok(e) => e, |
|
| 305 | - | Err(e) => { |
|
| 306 | - | tracing::error!("DB error fetching events for package {}: {}", id, e); |
|
| 307 | - | vec![] |
|
| 308 | - | } |
|
| 309 | - | }; |
|
| 310 | - | ||
| 311 | - | render(DetailTemplate { package, events, error: q.error }) |
|
| 312 | - | } |
|
| 313 | - | ||
| 314 | - | // ── URL encoding helper ──────────────────────────────────────────────────── |
|
| 315 | - | ||
| 316 | - | fn urlencoding_encode(s: &str) -> String { |
|
| 317 | - | s.chars() |
|
| 318 | - | .flat_map(|c| match c { |
|
| 319 | - | ' ' => vec!['+'], |
|
| 320 | - | c if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' => vec![c], |
|
| 321 | - | c => { |
|
| 322 | - | let mut buf = [0u8; 4]; |
|
| 323 | - | let bytes = c.encode_utf8(&mut buf); |
|
| 324 | - | bytes.bytes().flat_map(|b| { |
|
| 325 | - | vec!['%', char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase(), |
|
| 326 | - | char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase()] |
|
| 327 | - | }).collect() |
|
| 328 | - | } |
|
| 329 | - | }) |
|
| 330 | - | .collect() |
|
| 331 | - | } |
|
| 332 | - | ||
| 333 | - | // ── Main ─────────────────────────────────────────────────────────────────── |
|
| 334 | - | ||
| 335 | - | #[tokio::main] |
|
| 336 | - | async fn main() { |
|
| 337 | - | tracing_subscriber::fmt::init(); |
|
| 338 | - | use std::env; |
|
| 339 | - | ||
| 340 | - | let app_password = env::var("APP_PASSWORD").expect("APP_PASSWORD must be set"); |
|
| 341 | - | let usps_client_id = env::var("USPS_CLIENT_ID").expect("USPS_CLIENT_ID must be set"); |
|
| 342 | - | let usps_client_secret = env::var("USPS_CLIENT_SECRET").expect("USPS_CLIENT_SECRET must be set"); |
|
| 343 | - | let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); |
|
| 344 | - | let port: u16 = env::var("PORT") |
|
| 345 | - | .ok() |
|
| 346 | - | .and_then(|v| v.parse().ok()) |
|
| 347 | - | .unwrap_or(3000); |
|
| 348 | - | let bind_addr = format!("{}:{}", host, port); |
|
| 349 | - | let cookie_secure = env::var("COOKIE_SECURE") |
|
| 350 | - | .map(|v| v.eq_ignore_ascii_case("true")) |
|
| 351 | - | .unwrap_or(false); |
|
| 352 | - | ||
| 353 | - | let db = db::init_db(); |
|
| 354 | - | ||
| 355 | - | let state = Arc::new(AppState { |
|
| 356 | - | db, |
|
| 357 | - | app_password, |
|
| 358 | - | cookie_secure, |
|
| 359 | - | usps_token: Arc::new(Mutex::new(None)), |
|
| 360 | - | usps_client_id, |
|
| 361 | - | usps_client_secret, |
|
| 362 | - | http_client: Client::new(), |
|
| 363 | - | }); |
|
| 364 | - | ||
| 365 | - | let app = Router::new() |
|
| 366 | - | .route("/login", get(get_login).post(post_login)) |
|
| 367 | - | .route("/logout", get(get_logout)) |
|
| 368 | - | .route("/", get(get_index)) |
|
| 369 | - | .route("/packages/add", get(get_add)) |
|
| 370 | - | .route("/packages", post(post_packages)) |
|
| 371 | - | .route("/packages/{id}", get(get_package_detail)) |
|
| 372 | - | .route("/packages/{id}/refresh", post(post_refresh_package)) |
|
| 373 | - | .route("/packages/{id}/delete", post(post_delete_package)) |
|
| 374 | - | .nest_service("/static", ServeDir::new("static")) |
|
| 375 | - | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 376 | - | .with_state(state); |
|
| 377 | - | ||
| 378 | - | let listener = tokio::net::TcpListener::bind(&bind_addr) |
|
| 379 | - | .await |
|
| 380 | - | .expect("Failed to bind"); |
|
| 381 | - | eprintln!("Listening on {}", bind_addr); |
|
| 382 | - | axum::serve(listener, app).await.expect("Server failed"); |
|
| 383 | - | } |
| 1 | - | use reqwest::Client; |
|
| 2 | - | use serde::{Deserialize, Serialize}; |
|
| 3 | - | use std::time::{Duration, Instant}; |
|
| 4 | - | ||
| 5 | - | // ── Token Cache ──────────────────────────────────────────────────────────── |
|
| 6 | - | ||
| 7 | - | pub struct CachedToken { |
|
| 8 | - | pub token: String, |
|
| 9 | - | pub expires_at: Instant, |
|
| 10 | - | } |
|
| 11 | - | ||
| 12 | - | // ── USPS OAuth2 Response ─────────────────────────────────────────────────── |
|
| 13 | - | ||
| 14 | - | #[derive(Deserialize)] |
|
| 15 | - | struct TokenResponse { |
|
| 16 | - | access_token: String, |
|
| 17 | - | expires_in: u64, |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | - | // ── Tracking Request/Response Types ──────────────────────────────────────── |
|
| 21 | - | ||
| 22 | - | #[derive(Serialize)] |
|
| 23 | - | struct TrackingRequestBody { |
|
| 24 | - | #[serde(rename = "trackingNumber")] |
|
| 25 | - | tracking_number: String, |
|
| 26 | - | } |
|
| 27 | - | ||
| 28 | - | #[derive(Debug, Deserialize)] |
|
| 29 | - | #[serde(rename_all = "camelCase")] |
|
| 30 | - | pub struct TrackingDetail { |
|
| 31 | - | pub tracking_number: Option<String>, |
|
| 32 | - | pub status: Option<String>, |
|
| 33 | - | pub status_category: Option<String>, |
|
| 34 | - | pub status_summary: Option<String>, |
|
| 35 | - | pub mail_class: Option<String>, |
|
| 36 | - | pub delivery_date_expectation: Option<DeliveryDateExpectation>, |
|
| 37 | - | #[serde(default)] |
|
| 38 | - | pub tracking_events: Vec<TrackingEvent>, |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | #[derive(Debug, Deserialize)] |
|
| 42 | - | #[serde(rename_all = "camelCase")] |
|
| 43 | - | pub struct DeliveryDateExpectation { |
|
| 44 | - | pub expected_delivery_date: Option<String>, |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | #[derive(Debug, Deserialize)] |
|
| 48 | - | #[serde(rename_all = "camelCase")] |
|
| 49 | - | pub struct TrackingEvent { |
|
| 50 | - | pub event_timestamp: Option<String>, |
|
| 51 | - | pub event_type: Option<String>, |
|
| 52 | - | pub event_city: Option<String>, |
|
| 53 | - | pub event_state: Option<String>, |
|
| 54 | - | #[serde(rename = "eventZIPCode")] |
|
| 55 | - | pub event_zip_code: Option<String>, |
|
| 56 | - | pub event_code: Option<String>, |
|
| 57 | - | } |
|
| 58 | - | ||
| 59 | - | // ── Multi-status (207) response ──────────────────────────────────────────── |
|
| 60 | - | ||
| 61 | - | #[derive(Debug, Deserialize)] |
|
| 62 | - | #[serde(rename_all = "camelCase")] |
|
| 63 | - | pub struct FailureResponse { |
|
| 64 | - | #[serde(default)] |
|
| 65 | - | pub status_code: String, |
|
| 66 | - | pub error: Option<ErrorObject>, |
|
| 67 | - | } |
|
| 68 | - | ||
| 69 | - | #[derive(Debug, Deserialize)] |
|
| 70 | - | pub struct ErrorObject { |
|
| 71 | - | pub message: Option<String>, |
|
| 72 | - | } |
|
| 73 | - | ||
| 74 | - | // ── Errors ───────────────────────────────────────────────────────────────── |
|
| 75 | - | ||
| 76 | - | #[derive(Debug)] |
|
| 77 | - | pub enum UspsError { |
|
| 78 | - | NotFoundOrInvalid(String), |
|
| 79 | - | BadRequest, |
|
| 80 | - | Unauthorized, |
|
| 81 | - | RateLimit, |
|
| 82 | - | ServiceUnavailable, |
|
| 83 | - | Timeout, |
|
| 84 | - | Other(String), |
|
| 85 | - | } |
|
| 86 | - | ||
| 87 | - | impl std::fmt::Display for UspsError { |
|
| 88 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 89 | - | match self { |
|
| 90 | - | UspsError::NotFoundOrInvalid(msg) => write!(f, "{}", msg), |
|
| 91 | - | UspsError::BadRequest => write!(f, "Invalid request sent to USPS API. Check the tracking number format."), |
|
| 92 | - | UspsError::Unauthorized => write!(f, "USPS credentials are invalid. Check USPS_CLIENT_ID and USPS_CLIENT_SECRET."), |
|
| 93 | - | UspsError::RateLimit => write!(f, "USPS rate limit hit. Try again shortly."), |
|
| 94 | - | UspsError::ServiceUnavailable => write!(f, "USPS service unavailable. Try again later."), |
|
| 95 | - | UspsError::Timeout => write!(f, "USPS request timed out."), |
|
| 96 | - | UspsError::Other(msg) => write!(f, "{}", msg), |
|
| 97 | - | } |
|
| 98 | - | } |
|
| 99 | - | } |
|
| 100 | - | ||
| 101 | - | impl std::error::Error for UspsError {} |
|
| 102 | - | ||
| 103 | - | // ── Token Fetch ──────────────────────────────────────────────────────────── |
|
| 104 | - | ||
| 105 | - | pub async fn fetch_token( |
|
| 106 | - | client: &Client, |
|
| 107 | - | client_id: &str, |
|
| 108 | - | client_secret: &str, |
|
| 109 | - | ) -> Result<CachedToken, UspsError> { |
|
| 110 | - | let params = [ |
|
| 111 | - | ("grant_type", "client_credentials"), |
|
| 112 | - | ("client_id", client_id), |
|
| 113 | - | ("client_secret", client_secret), |
|
| 114 | - | ("scope", "tracking"), |
|
| 115 | - | ]; |
|
| 116 | - | ||
| 117 | - | let resp = client |
|
| 118 | - | .post("https://apis.usps.com/oauth2/v3/token") |
|
| 119 | - | .form(¶ms) |
|
| 120 | - | .timeout(Duration::from_secs(10)) |
|
| 121 | - | .send() |
|
| 122 | - | .await |
|
| 123 | - | .map_err(|e| { |
|
| 124 | - | if e.is_timeout() { |
|
| 125 | - | UspsError::Timeout |
|
| 126 | - | } else { |
|
| 127 | - | UspsError::Other(e.to_string()) |
|
| 128 | - | } |
|
| 129 | - | })?; |
|
| 130 | - | ||
| 131 | - | match resp.status().as_u16() { |
|
| 132 | - | 200 => { |
|
| 133 | - | let body: TokenResponse = resp.json().await.map_err(|e| UspsError::Other(e.to_string()))?; |
|
| 134 | - | let expires_at = Instant::now() + Duration::from_secs(body.expires_in.saturating_sub(30)); |
|
| 135 | - | Ok(CachedToken { token: body.access_token, expires_at }) |
|
| 136 | - | } |
|
| 137 | - | 401 => Err(UspsError::Unauthorized), |
|
| 138 | - | 429 => Err(UspsError::RateLimit), |
|
| 139 | - | 503 => Err(UspsError::ServiceUnavailable), |
|
| 140 | - | _ => Err(UspsError::Other(format!("OAuth token request failed: {}", resp.status()))), |
|
| 141 | - | } |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | // ── Token Cache Helper ───────────────────────────────────────────────────── |
|
| 145 | - | ||
| 146 | - | /// Get or refresh the cached USPS token. |
|
| 147 | - | pub async fn get_token( |
|
| 148 | - | cache: &std::sync::Arc<std::sync::Mutex<Option<CachedToken>>>, |
|
| 149 | - | client: &Client, |
|
| 150 | - | client_id: &str, |
|
| 151 | - | client_secret: &str, |
|
| 152 | - | ) -> Result<String, UspsError> { |
|
| 153 | - | { |
|
| 154 | - | let guard = cache.lock().unwrap(); |
|
| 155 | - | if let Some(ref cached) = *guard { |
|
| 156 | - | if Instant::now() < cached.expires_at { |
|
| 157 | - | return Ok(cached.token.clone()); |
|
| 158 | - | } |
|
| 159 | - | } |
|
| 160 | - | } |
|
| 161 | - | // Need to fetch or refresh |
|
| 162 | - | let new_token = fetch_token(client, client_id, client_secret).await?; |
|
| 163 | - | let token_str = new_token.token.clone(); |
|
| 164 | - | let mut guard = cache.lock().unwrap(); |
|
| 165 | - | *guard = Some(new_token); |
|
| 166 | - | Ok(token_str) |
|
| 167 | - | } |
|
| 168 | - | ||
| 169 | - | // ── Tracking Request ─────────────────────────────────────────────────────── |
|
| 170 | - | ||
| 171 | - | pub async fn fetch_tracking( |
|
| 172 | - | client: &Client, |
|
| 173 | - | token: &str, |
|
| 174 | - | tracking_number: &str, |
|
| 175 | - | ) -> Result<TrackingDetail, UspsError> { |
|
| 176 | - | let body = vec![TrackingRequestBody { |
|
| 177 | - | tracking_number: tracking_number.to_string(), |
|
| 178 | - | }]; |
|
| 179 | - | ||
| 180 | - | let resp = client |
|
| 181 | - | .post("https://apis.usps.com/tracking/v3r2/tracking") |
|
| 182 | - | .bearer_auth(token) |
|
| 183 | - | .json(&body) |
|
| 184 | - | .timeout(Duration::from_secs(10)) |
|
| 185 | - | .send() |
|
| 186 | - | .await |
|
| 187 | - | .map_err(|e| { |
|
| 188 | - | if e.is_timeout() { |
|
| 189 | - | UspsError::Timeout |
|
| 190 | - | } else { |
|
| 191 | - | UspsError::Other(e.to_string()) |
|
| 192 | - | } |
|
| 193 | - | })?; |
|
| 194 | - | ||
| 195 | - | match resp.status().as_u16() { |
|
| 196 | - | 200 => { |
|
| 197 | - | let mut details: Vec<TrackingDetail> = resp |
|
| 198 | - | .json() |
|
| 199 | - | .await |
|
| 200 | - | .map_err(|e| UspsError::Other(e.to_string()))?; |
|
| 201 | - | details |
|
| 202 | - | .pop() |
|
| 203 | - | .ok_or_else(|| UspsError::Other("Empty tracking response".into())) |
|
| 204 | - | } |
|
| 205 | - | 207 => { |
|
| 206 | - | // Try to extract error message from FailureResponse |
|
| 207 | - | let failures: Vec<FailureResponse> = resp |
|
| 208 | - | .json() |
|
| 209 | - | .await |
|
| 210 | - | .unwrap_or_default(); |
|
| 211 | - | let msg = failures |
|
| 212 | - | .into_iter() |
|
| 213 | - | .next() |
|
| 214 | - | .and_then(|f| f.error) |
|
| 215 | - | .and_then(|e| e.message) |
|
| 216 | - | .unwrap_or_else(|| "Tracking number not found or invalid.".to_string()); |
|
| 217 | - | Err(UspsError::NotFoundOrInvalid(msg)) |
|
| 218 | - | } |
|
| 219 | - | 400 => Err(UspsError::BadRequest), |
|
| 220 | - | 401 => Err(UspsError::Unauthorized), |
|
| 221 | - | 429 => Err(UspsError::RateLimit), |
|
| 222 | - | 503 => Err(UspsError::ServiceUnavailable), |
|
| 223 | - | _ => Err(UspsError::Other(format!("USPS tracking returned: {}", resp.status()))), |
|
| 224 | - | } |
|
| 225 | - | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* parcels — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | .null { |
|
| 6 | - | opacity: 0.3; |
|
| 7 | - | } |
|
| 8 | - | ||
| 9 | - | .parcel-list { |
|
| 10 | - | display: flex; |
|
| 11 | - | flex-direction: column; |
|
| 12 | - | width: 100%; |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | .parcel-list a { |
|
| 16 | - | text-decoration: none; |
|
| 17 | - | } |
|
| 18 | - | ||
| 19 | - | .parcel-item { |
|
| 20 | - | display: flex; |
|
| 21 | - | flex-direction: column; |
|
| 22 | - | gap: 0.25rem; |
|
| 23 | - | padding: 0.75rem 0; |
|
| 24 | - | border-bottom: 1px solid #333; |
|
| 25 | - | } |
|
| 26 | - | ||
| 27 | - | .parcel-item:last-child { |
|
| 28 | - | border-bottom: none; |
|
| 29 | - | } |
|
| 30 | - | ||
| 31 | - | .parcel-label { |
|
| 32 | - | font-size: 16px; |
|
| 33 | - | } |
|
| 34 | - | ||
| 35 | - | .parcel-tracking { |
|
| 36 | - | font-size: 12px; |
|
| 37 | - | opacity: 0.5; |
|
| 38 | - | } |
|
| 39 | - | ||
| 40 | - | .parcel-meta { |
|
| 41 | - | font-size: 12px; |
|
| 42 | - | opacity: 0.5; |
|
| 43 | - | font-style: italic; |
|
| 44 | - | } |
|
| 45 | - | ||
| 46 | - | .detail-header { |
|
| 47 | - | display: flex; |
|
| 48 | - | justify-content: space-between; |
|
| 49 | - | align-items: baseline; |
|
| 50 | - | margin-bottom: 0.25rem; |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | .detail-title { |
|
| 54 | - | font-size: 16px; |
|
| 55 | - | font-weight: 700; |
|
| 56 | - | } |
|
| 57 | - | ||
| 58 | - | .detail-tracking { |
|
| 59 | - | opacity: 0.6; |
|
| 60 | - | margin-bottom: 1rem; |
|
| 61 | - | } |
|
| 62 | - | ||
| 63 | - | .detail-table { |
|
| 64 | - | width: auto; |
|
| 65 | - | margin-bottom: 1.5rem; |
|
| 66 | - | } |
|
| 67 | - | ||
| 68 | - | .detail-table th { |
|
| 69 | - | padding-right: 2rem; |
|
| 70 | - | } |
|
| 71 | - | ||
| 72 | - | .event-section-header { |
|
| 73 | - | opacity: 0.5; |
|
| 74 | - | font-size: 12px; |
|
| 75 | - | text-transform: uppercase; |
|
| 76 | - | margin-bottom: 1rem; |
|
| 77 | - | border-bottom: 1px solid #333; |
|
| 78 | - | padding-bottom: 0.5rem; |
|
| 79 | - | } |
|
| 80 | - | ||
| 81 | - | .event-item { |
|
| 82 | - | padding: 0.75rem 0; |
|
| 83 | - | border-bottom: 1px solid #1e1c1f; |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | .event-timestamp { |
|
| 87 | - | opacity: 0.5; |
|
| 88 | - | font-size: 12px; |
|
| 89 | - | margin-bottom: 0.2rem; |
|
| 90 | - | } |
|
| 91 | - | ||
| 92 | - | .event-type { |
|
| 93 | - | font-weight: 700; |
|
| 94 | - | } |
|
| 95 | - | ||
| 96 | - | .event-location { |
|
| 97 | - | opacity: 0.6; |
|
| 98 | - | font-size: 13px; |
|
| 99 | - | } |
|
| 100 | - | ||
| 101 | - | .empty-note { |
|
| 102 | - | opacity: 0.4; |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | .login-wrap { |
|
| 106 | - | max-width: 320px; |
|
| 107 | - | margin: 4rem auto; |
|
| 108 | - | width: 100%; |
|
| 109 | - | } |
|
| 110 | - | ||
| 111 | - | .login-wrap h1 { |
|
| 112 | - | font-size: 24px; |
|
| 113 | - | margin-bottom: 2rem; |
|
| 114 | - | font-weight: 700; |
|
| 115 | - | } |
|
| 116 | - | ||
| 117 | - | .form-group { |
|
| 118 | - | margin-bottom: 1rem; |
|
| 119 | - | } |
|
| 120 | - | ||
| 121 | - | .form-group label { |
|
| 122 | - | display: block; |
|
| 123 | - | margin-bottom: 0.25rem; |
|
| 124 | - | opacity: 0.7; |
|
| 125 | - | font-size: 12px; |
|
| 126 | - | } |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}PARCELS — add package{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | <div class="header"> |
|
| 5 | - | <a href="/" class="logo">PARCELS</a> |
|
| 6 | - | <nav class="links"> |
|
| 7 | - | <a href="/">← back</a> |
|
| 8 | - | </nav> |
|
| 9 | - | </div> |
|
| 10 | - | {% if let Some(err) = error %} |
|
| 11 | - | <p class="error">{{ err }}</p> |
|
| 12 | - | {% endif %} |
|
| 13 | - | <form class="form" method="POST" action="/packages"> |
|
| 14 | - | <div class="form-field"> |
|
| 15 | - | <label for="tracking_number">tracking number</label> |
|
| 16 | - | <input type="text" id="tracking_number" name="tracking_number" required autofocus |
|
| 17 | - | placeholder="e.g. 9400111899223397992148"> |
|
| 18 | - | </div> |
|
| 19 | - | <div class="form-field"> |
|
| 20 | - | <label for="label">label</label> |
|
| 21 | - | <input type="text" id="label" name="label" required placeholder="e.g. Amazon order"> |
|
| 22 | - | </div> |
|
| 23 | - | <div class="form-actions"> |
|
| 24 | - | <button type="submit">add package</button> |
|
| 25 | - | </div> |
|
| 26 | - | </form> |
|
| 27 | - | {% endblock %} |
| 1 | - | <!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <title>{% block title %}Parcels{% endblock %}</title> |
|
| 8 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 9 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 10 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 12 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 13 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 14 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 15 | - | <meta property="og:title" content="PARCELS"> |
|
| 16 | - | <meta property="og:image" content="/static/og.png"> |
|
| 17 | - | <meta property="og:type" content="website"> |
|
| 18 | - | </head> |
|
| 19 | - | <body> |
|
| 20 | - | {% block content %}{% endblock %} |
|
| 21 | - | </body> |
|
| 22 | - | </html> |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}PARCELS — {% if let Some(label) = package.label %}{{ label }}{% else %}{{ package.tracking_number }}{% endif %}{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | <div class="header"> |
|
| 5 | - | <a href="/" class="logo">PARCELS</a> |
|
| 6 | - | <nav class="links"> |
|
| 7 | - | <a href="/">← back</a> |
|
| 8 | - | </nav> |
|
| 9 | - | </div> |
|
| 10 | - | {% if let Some(err) = error %} |
|
| 11 | - | <p class="error">{{ err }}</p> |
|
| 12 | - | {% endif %} |
|
| 13 | - | ||
| 14 | - | <div> |
|
| 15 | - | <div class="detail-header"> |
|
| 16 | - | {% if let Some(label) = package.label %} |
|
| 17 | - | <div class="detail-title">{{ label }}</div> |
|
| 18 | - | {% endif %} |
|
| 19 | - | <form class="inline-form" method="POST" action="/packages/{{ package.id }}/delete" |
|
| 20 | - | onsubmit="return confirm('Delete this package?')"> |
|
| 21 | - | <button type="submit" class="link-button danger">delete</button> |
|
| 22 | - | </form> |
|
| 23 | - | </div> |
|
| 24 | - | <div class="detail-tracking">{{ package.tracking_number }}</div> |
|
| 25 | - | ||
| 26 | - | <table class="detail-table"> |
|
| 27 | - | <tr> |
|
| 28 | - | <th>status</th> |
|
| 29 | - | <td>{% if let Some(s) = package.status %}{{ s }}{% else %}<span class="null">—</span>{% endif %}</td> |
|
| 30 | - | </tr> |
|
| 31 | - | <tr> |
|
| 32 | - | <th>summary</th> |
|
| 33 | - | <td>{% if let Some(s) = package.status_summary %}{{ s }}{% else %}<span class="null">—</span>{% endif %}</td> |
|
| 34 | - | </tr> |
|
| 35 | - | <tr> |
|
| 36 | - | <th>mail class</th> |
|
| 37 | - | <td>{% if let Some(m) = package.mail_class %}{{ m }}{% else %}<span class="null">—</span>{% endif %}</td> |
|
| 38 | - | </tr> |
|
| 39 | - | <tr> |
|
| 40 | - | <th>expected delivery</th> |
|
| 41 | - | <td>{% if let Some(d) = package.expected_delivery %}{{ d }}{% else %}<span class="null">—</span>{% endif %}</td> |
|
| 42 | - | </tr> |
|
| 43 | - | </table> |
|
| 44 | - | </div> |
|
| 45 | - | ||
| 46 | - | {% if !events.is_empty() %} |
|
| 47 | - | <div> |
|
| 48 | - | <div class="event-section-header">event history</div> |
|
| 49 | - | {% for event in events %} |
|
| 50 | - | <div class="event-item"> |
|
| 51 | - | <div class="event-timestamp"> |
|
| 52 | - | {% if let Some(ts) = event.event_timestamp %}{{ ts }}{% else %}<span class="null">—</span>{% endif %} |
|
| 53 | - | </div> |
|
| 54 | - | <div class="event-type"> |
|
| 55 | - | {% if let Some(et) = event.event_type %}{{ et }}{% else %}<span class="null">—</span>{% endif %} |
|
| 56 | - | </div> |
|
| 57 | - | {% if event.event_city.is_some() || event.event_state.is_some() %} |
|
| 58 | - | <div class="event-location"> |
|
| 59 | - | {% if let Some(city) = event.event_city %}{{ city }}{% endif %}{% if event.event_city.is_some() && event.event_state.is_some() %}, {% endif %}{% if let Some(state) = event.event_state %}{{ state }}{% endif %} |
|
| 60 | - | </div> |
|
| 61 | - | {% endif %} |
|
| 62 | - | </div> |
|
| 63 | - | {% endfor %} |
|
| 64 | - | </div> |
|
| 65 | - | {% else %} |
|
| 66 | - | <p class="empty">no events yet. click refresh to load tracking history.</p> |
|
| 67 | - | {% endif %} |
|
| 68 | - | {% endblock %} |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}PARCELS{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | <div class="header"> |
|
| 5 | - | <a href="/" class="logo">PARCELS</a> |
|
| 6 | - | <nav class="links"> |
|
| 7 | - | <a href="/packages/add">add package</a> |
|
| 8 | - | <a href="/logout">logout</a> |
|
| 9 | - | </nav> |
|
| 10 | - | </div> |
|
| 11 | - | {% if let Some(err) = error %} |
|
| 12 | - | <p class="error">{{ err }}</p> |
|
| 13 | - | {% endif %} |
|
| 14 | - | {% if packages.is_empty() %} |
|
| 15 | - | <p class="empty">no packages. <a href="/packages/add">add one</a></p> |
|
| 16 | - | {% else %} |
|
| 17 | - | <div class="parcel-list"> |
|
| 18 | - | {% for pkg in packages %} |
|
| 19 | - | <a href="/packages/{{ pkg.id }}"> |
|
| 20 | - | <div class="parcel-item"> |
|
| 21 | - | {% if let Some(label) = pkg.label %} |
|
| 22 | - | <span class="parcel-label">{{ label }}</span> |
|
| 23 | - | <span class="parcel-tracking">{{ pkg.tracking_number }}</span> |
|
| 24 | - | {% else %} |
|
| 25 | - | <span class="parcel-label">{{ pkg.tracking_number }}</span> |
|
| 26 | - | {% endif %} |
|
| 27 | - | {% if let Some(summary) = pkg.status_summary %} |
|
| 28 | - | <span class="parcel-meta">{{ summary }}</span> |
|
| 29 | - | {% else if let Some(status) = pkg.status %} |
|
| 30 | - | <span class="parcel-meta">{{ status }}</span> |
|
| 31 | - | {% endif %} |
|
| 32 | - | </div> |
|
| 33 | - | </a> |
|
| 34 | - | {% endfor %} |
|
| 35 | - | </div> |
|
| 36 | - | {% endif %} |
|
| 37 | - | {% endblock %} |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}PARCELS — login{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | <div class="login-wrap"> |
|
| 5 | - | <h1>PARCELS</h1> |
|
| 6 | - | {% if let Some(err) = error %} |
|
| 7 | - | <p class="error">{{ err }}</p> |
|
| 8 | - | {% endif %} |
|
| 9 | - | <form class="form" method="POST" action="/login"> |
|
| 10 | - | <div class="form-field"> |
|
| 11 | - | <label for="password">password</label> |
|
| 12 | - | <input type="password" id="password" name="password" autofocus required> |
|
| 13 | - | </div> |
|
| 14 | - | <button type="submit">sign in</button> |
|
| 15 | - | </form> |
|
| 16 | - | </div> |
|
| 17 | - | {% endblock %} |
| 1 | - | POSTS_PASSWORD=changeme |
|
| 2 | - | POSTS_DB_PATH=posts.sqlite |
|
| 3 | - | UPLOADS_DIR=uploads |
|
| 4 | - | COOKIE_SECURE=false |
|
| 5 | - | HOST=127.0.0.1 |
|
| 6 | - | PORT=3000 |
|
| 7 | - | SITE_URL=http://localhost:3000 |
|
| 8 | - | ||
| 9 | - | # Optional Cloudflare R2 storage for uploads. Leave R2_BUCKET empty for local files. |
|
| 10 | - | R2_ACCOUNT_ID= |
|
| 11 | - | R2_ACCESS_KEY_ID= |
|
| 12 | - | R2_SECRET_ACCESS_KEY= |
|
| 13 | - | R2_BUCKET= |
|
| 14 | - | R2_PUBLIC_URL= |
| 1 | - | # Build from repo root: docker build -t posts-go -f apps/posts-go/Dockerfile . |
|
| 2 | - | FROM golang:1.24-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/posts-go/go.mod apps/posts-go/go.sum ./apps/posts-go/ |
|
| 6 | - | WORKDIR /app/apps/posts-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/posts-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /posts-go . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 13 | - | COPY --from=builder /posts-go /usr/local/bin/posts-go |
|
| 14 | - | WORKDIR /data |
|
| 15 | - | ENV HOST=0.0.0.0 |
|
| 16 | - | ENV PORT=3000 |
|
| 17 | - | ENV UPLOADS_DIR=/data/uploads |
|
| 18 | - | EXPOSE 3000 |
|
| 19 | - | CMD ["posts-go"] |
| 1 | - | # posts-go |
|
| 2 | - | ||
| 3 | - | Go rewrite of [posts](../posts). CMS blog with admin, pages, file uploads, |
|
| 4 | - | markdown rendering, RSS, zip import/export. |
|
| 5 | - | ||
| 6 | - | ## Notes vs Rust version |
|
| 7 | - | ||
| 8 | - | - Upload storage supports local filesystem (`UPLOADS_DIR`, default `uploads`) |
|
| 9 | - | or Cloudflare R2 when `R2_BUCKET` and credentials are set. |
|
| 10 | - | - Markdown: `github.com/yuin/goldmark` with GFM + Footnotes (replaces |
|
| 11 | - | pulldown-cmark). |
|
| 12 | - | - Zip via stdlib `archive/zip`. Upload limit 10 MB; import zip limit 50 MB. |
|
| 13 | - | - API: `GET /api/posts` and `GET /api/posts/{slug}` (permissive CORS). |
|
| 14 | - | ||
| 15 | - | See `.env.example`. |
| 7 | 7 | "log/slog" |
|
| 8 | 8 | "strings" |
|
| 9 | 9 | ||
| 10 | - | poststorage "github.com/stevedylandev/andromeda/apps/posts-go/storage" |
|
| 10 | + | poststorage "github.com/stevedylandev/andromeda/apps/posts/storage" |
|
| 11 | 11 | "github.com/stevedylandev/andromeda/crates-go/auth" |
|
| 12 | 12 | ) |
|
| 13 | 13 |
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/posts-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | - | environment: |
|
| 9 | - | - HOST=0.0.0.0 |
|
| 10 | - | - PORT=${PORT:-3000} |
|
| 11 | - | - POSTS_DB_PATH=/data/posts-go.sqlite |
|
| 12 | - | - POSTS_PASSWORD=${POSTS_PASSWORD:-changeme} |
|
| 13 | - | - UPLOADS_DIR=/data/uploads |
|
| 14 | - | - COOKIE_SECURE=${COOKIE_SECURE:-false} |
|
| 15 | - | - SITE_URL=${SITE_URL:-http://localhost:3000} |
|
| 16 | - | volumes: |
|
| 17 | - | - posts-go-data:/data |
|
| 18 | - | restart: unless-stopped |
|
| 19 | - | ||
| 20 | - | volumes: |
|
| 21 | - | posts-go-data: |
| 1 | - | module github.com/stevedylandev/andromeda/apps/posts-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/posts |
|
| 2 | 2 | ||
| 3 | 3 | go 1.24.4 |
|
| 4 | 4 |
| 7 | 7 | "os" |
|
| 8 | 8 | "strings" |
|
| 9 | 9 | ||
| 10 | - | poststorage "github.com/stevedylandev/andromeda/apps/posts-go/storage" |
|
| 10 | + | poststorage "github.com/stevedylandev/andromeda/apps/posts/storage" |
|
| 11 | 11 | "github.com/stevedylandev/andromeda/crates-go/auth" |
|
| 12 | 12 | "github.com/stevedylandev/andromeda/crates-go/config" |
|
| 13 | 13 | "github.com/stevedylandev/andromeda/crates-go/sqlite" |
|
| 76 | 76 | } |
|
| 77 | 77 | ||
| 78 | 78 | addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000") |
|
| 79 | - | logger.Info("posts-go server running", "addr", addr) |
|
| 79 | + | logger.Info("posts server running", "addr", addr) |
|
| 80 | 80 | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 81 | 81 | log.Fatal(err) |
|
| 82 | 82 | } |
|
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* posts — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | /* Textarea variants */ |
|
| 6 | - | ||
| 7 | - | textarea.post-content { |
|
| 8 | - | min-height: 500px; |
|
| 9 | - | } |
|
| 10 | - | ||
| 11 | - | textarea.attributes-textarea { |
|
| 12 | - | min-height: 80px; |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | .nav-links-input { |
|
| 16 | - | min-height: 40px; |
|
| 17 | - | height: 40px; |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | - | .available-fields { |
|
| 21 | - | margin-top: 0.5rem; |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | .available-fields > summary { |
|
| 25 | - | cursor: pointer; |
|
| 26 | - | user-select: none; |
|
| 27 | - | font-size: 0.85rem; |
|
| 28 | - | opacity: 0.6; |
|
| 29 | - | } |
|
| 30 | - | ||
| 31 | - | .fields-list { |
|
| 32 | - | display: flex; |
|
| 33 | - | flex-direction: column; |
|
| 34 | - | gap: 0.15rem; |
|
| 35 | - | margin-top: 0.25rem; |
|
| 36 | - | font-size: 0.85rem; |
|
| 37 | - | opacity: 0.6; |
|
| 38 | - | } |
|
| 39 | - | ||
| 40 | - | /* Post list (public) */ |
|
| 41 | - | ||
| 42 | - | .post-list { |
|
| 43 | - | display: flex; |
|
| 44 | - | flex-direction: column; |
|
| 45 | - | width: 100%; |
|
| 46 | - | gap: 0.5rem; |
|
| 47 | - | } |
|
| 48 | - | ||
| 49 | - | .post-item { |
|
| 50 | - | display: flex; |
|
| 51 | - | justify-content: space-between; |
|
| 52 | - | align-items: center; |
|
| 53 | - | padding: 8px 0; |
|
| 54 | - | text-decoration: none; |
|
| 55 | - | } |
|
| 56 | - | ||
| 57 | - | .post-item:hover { |
|
| 58 | - | opacity: 0.7; |
|
| 59 | - | } |
|
| 60 | - | ||
| 61 | - | .post-item-info { |
|
| 62 | - | display: flex; |
|
| 63 | - | flex-direction: column; |
|
| 64 | - | gap: 0.25rem; |
|
| 65 | - | } |
|
| 66 | - | ||
| 67 | - | .post-item-enhanced .post-item-info { |
|
| 68 | - | gap: 0.25rem; |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | .post-title { |
|
| 72 | - | font-size: 16px; |
|
| 73 | - | } |
|
| 74 | - | ||
| 75 | - | .post-description { |
|
| 76 | - | font-style: italic; |
|
| 77 | - | opacity: 0.7; |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | - | .post-date { |
|
| 81 | - | font-size: 12px; |
|
| 82 | - | opacity: 0.5; |
|
| 83 | - | } |
|
| 84 | - | ||
| 85 | - | .post-excerpt { |
|
| 86 | - | font-size: 12px; |
|
| 87 | - | opacity: 0.6; |
|
| 88 | - | line-height: 1.4; |
|
| 89 | - | } |
|
| 90 | - | ||
| 91 | - | .post-tags { |
|
| 92 | - | display: flex; |
|
| 93 | - | gap: 0.4rem; |
|
| 94 | - | flex-wrap: wrap; |
|
| 95 | - | } |
|
| 96 | - | ||
| 97 | - | /* Post header (public single) */ |
|
| 98 | - | ||
| 99 | - | .post-header { |
|
| 100 | - | display: flex; |
|
| 101 | - | flex-direction: column; |
|
| 102 | - | gap: 0.25rem; |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | .post-header h1 { |
|
| 106 | - | font-size: 24px; |
|
| 107 | - | font-weight: 700; |
|
| 108 | - | letter-spacing: -0.5px; |
|
| 109 | - | } |
|
| 110 | - | ||
| 111 | - | .page-header { |
|
| 112 | - | display: flex; |
|
| 113 | - | flex-direction: column; |
|
| 114 | - | gap: 0.25rem; |
|
| 115 | - | } |
|
| 116 | - | ||
| 117 | - | .page-header h1 { |
|
| 118 | - | font-size: 24px; |
|
| 119 | - | font-weight: 700; |
|
| 120 | - | letter-spacing: -0.5px; |
|
| 121 | - | } |
|
| 122 | - | ||
| 123 | - | .intro { |
|
| 124 | - | padding-bottom: 1rem; |
|
| 125 | - | border-bottom: 1px solid #333; |
|
| 126 | - | } |
|
| 127 | - | ||
| 128 | - | /* Markdown rendered content */ |
|
| 129 | - | ||
| 130 | - | .markdown-body { |
|
| 131 | - | width: 100%; |
|
| 132 | - | line-height: 1.6; |
|
| 133 | - | } |
|
| 134 | - | ||
| 135 | - | .markdown-body h1, |
|
| 136 | - | .markdown-body h2, |
|
| 137 | - | .markdown-body h3, |
|
| 138 | - | .markdown-body h4, |
|
| 139 | - | .markdown-body h5, |
|
| 140 | - | .markdown-body h6 { |
|
| 141 | - | margin-top: 1.5rem; |
|
| 142 | - | margin-bottom: 0.5rem; |
|
| 143 | - | font-weight: 700; |
|
| 144 | - | } |
|
| 145 | - | ||
| 146 | - | .markdown-body h1 { font-size: 18px; } |
|
| 147 | - | .markdown-body h2 { font-size: 16px; } |
|
| 148 | - | .markdown-body h3 { font-size: 15px; } |
|
| 149 | - | .markdown-body h4, |
|
| 150 | - | .markdown-body h5, |
|
| 151 | - | .markdown-body h6 { font-size: 14px; } |
|
| 152 | - | ||
| 153 | - | .markdown-body p { |
|
| 154 | - | margin-bottom: 0.75rem; |
|
| 155 | - | } |
|
| 156 | - | ||
| 157 | - | .markdown-body ul, |
|
| 158 | - | .markdown-body ol { |
|
| 159 | - | margin-left: 1.5rem; |
|
| 160 | - | margin-bottom: 0.75rem; |
|
| 161 | - | } |
|
| 162 | - | ||
| 163 | - | .markdown-body li { |
|
| 164 | - | margin-bottom: 0.25rem; |
|
| 165 | - | } |
|
| 166 | - | ||
| 167 | - | .markdown-body pre { |
|
| 168 | - | margin-bottom: 0.75rem; |
|
| 169 | - | } |
|
| 170 | - | ||
| 171 | - | .markdown-body blockquote { |
|
| 172 | - | border-left: 2px solid #555; |
|
| 173 | - | padding-left: 12px; |
|
| 174 | - | opacity: 0.7; |
|
| 175 | - | margin-bottom: 0.75rem; |
|
| 176 | - | } |
|
| 177 | - | ||
| 178 | - | .markdown-body table { |
|
| 179 | - | margin-bottom: 0.75rem; |
|
| 180 | - | } |
|
| 181 | - | ||
| 182 | - | .markdown-body th, |
|
| 183 | - | .markdown-body td { |
|
| 184 | - | border: 1px solid #333; |
|
| 185 | - | } |
|
| 186 | - | ||
| 187 | - | .markdown-body hr { |
|
| 188 | - | border: none; |
|
| 189 | - | border-top: 1px solid #333; |
|
| 190 | - | margin: 1rem 0; |
|
| 191 | - | } |
|
| 192 | - | ||
| 193 | - | .markdown-body a { |
|
| 194 | - | text-decoration: underline; |
|
| 195 | - | } |
|
| 196 | - | ||
| 197 | - | .markdown-body img { |
|
| 198 | - | max-width: 100%; |
|
| 199 | - | } |
|
| 200 | - | ||
| 201 | - | .markdown-body li:has(> input[type="checkbox"]) { |
|
| 202 | - | list-style: none; |
|
| 203 | - | margin-left: -1.5rem; |
|
| 204 | - | } |
|
| 205 | - | ||
| 206 | - | .markdown-body input[type="checkbox"] { |
|
| 207 | - | width: 14px; |
|
| 208 | - | height: 14px; |
|
| 209 | - | margin-right: 6px; |
|
| 210 | - | vertical-align: middle; |
|
| 211 | - | position: relative; |
|
| 212 | - | top: -1px; |
|
| 213 | - | } |
|
| 214 | - | ||
| 215 | - | .markdown-body input[type="checkbox"]:checked::after { |
|
| 216 | - | font-size: 12px; |
|
| 217 | - | } |
|
| 218 | - | ||
| 219 | - | /* Footnotes */ |
|
| 220 | - | ||
| 221 | - | .markdown-body .footnote-definition { |
|
| 222 | - | font-size: 12px; |
|
| 223 | - | opacity: 0.7; |
|
| 224 | - | margin-bottom: 0.5rem; |
|
| 225 | - | display: flex; |
|
| 226 | - | gap: 0.5rem; |
|
| 227 | - | } |
|
| 228 | - | ||
| 229 | - | .markdown-body .footnote-definition-label { |
|
| 230 | - | font-size: 11px; |
|
| 231 | - | opacity: 0.5; |
|
| 232 | - | flex-shrink: 0; |
|
| 233 | - | } |
|
| 234 | - | ||
| 235 | - | .markdown-body .footnote-definition p { |
|
| 236 | - | margin-bottom: 0; |
|
| 237 | - | } |
|
| 238 | - | ||
| 239 | - | .markdown-body sup.footnote-reference a { |
|
| 240 | - | font-size: 11px; |
|
| 241 | - | text-decoration: none; |
|
| 242 | - | opacity: 0.6; |
|
| 243 | - | } |
|
| 244 | - | ||
| 245 | - | .markdown-body sup.footnote-reference a:hover { |
|
| 246 | - | opacity: 1; |
|
| 247 | - | } |
|
| 248 | - | ||
| 249 | - | /* File thumbnails */ |
|
| 250 | - | ||
| 251 | - | .file-thumbnail { |
|
| 252 | - | max-width: 60px; |
|
| 253 | - | max-height: 60px; |
|
| 254 | - | object-fit: cover; |
|
| 255 | - | border: 1px solid #333; |
|
| 256 | - | flex-shrink: 0; |
|
| 257 | - | } |
|
| 258 | - | ||
| 259 | - | /* RSS link in footer */ |
|
| 260 | - | ||
| 261 | - | .rss-link { |
|
| 262 | - | display: flex; |
|
| 263 | - | align-items: center; |
|
| 264 | - | gap: 0.4rem; |
|
| 265 | - | font-size: 12px; |
|
| 266 | - | opacity: 0.5; |
|
| 267 | - | } |
|
| 268 | - | ||
| 269 | - | .rss-link:hover { |
|
| 270 | - | opacity: 0.8; |
|
| 271 | - | } |
| 1 | - | {{define "admin_base.html"}}<!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>{{block "title" .}}Admin{{end}}</title> |
|
| 7 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 8 | - | <meta name="theme-color" content="#121113" /> |
|
| 9 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 10 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 11 | - | </head> |
|
| 12 | - | <body> |
|
| 13 | - | <header class="header"> |
|
| 14 | - | <a href="/admin" class="logo">POSTS</a> |
|
| 15 | - | <nav class="links"> |
|
| 16 | - | <a href="/admin">posts</a> |
|
| 17 | - | <a href="/admin/pages">pages</a> |
|
| 18 | - | <a href="/admin/files">files</a> |
|
| 19 | - | <a href="/admin/import">import</a> |
|
| 20 | - | <a href="/admin/settings">settings</a> |
|
| 21 | - | <a href="/" target="_blank">view site</a> |
|
| 22 | - | <a href="/admin/logout">logout</a> |
|
| 23 | - | </nav> |
|
| 24 | - | </header> |
|
| 25 | - | <main> |
|
| 26 | - | {{block "content" .}}{{end}} |
|
| 27 | - | </main> |
|
| 28 | - | </body> |
|
| 29 | - | </html>{{end}} |
| 1 | - | {{define "admin_files.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Admin — Files{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <div class="admin-toolbar"> |
|
| 5 | - | <h2>Files</h2> |
|
| 6 | - | </div> |
|
| 7 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 8 | - | {{if .Success}}<p class="success">File uploaded.</p>{{end}} |
|
| 9 | - | <form method="POST" action="/admin/files/upload" enctype="multipart/form-data" class="form"> |
|
| 10 | - | <label for="file">upload file (max 10MB)</label> |
|
| 11 | - | <input type="file" id="file" name="file" required> |
|
| 12 | - | <button type="submit">upload</button> |
|
| 13 | - | </form> |
|
| 14 | - | {{if not .Files}} |
|
| 15 | - | <p class="empty">no files yet</p> |
|
| 16 | - | {{else}} |
|
| 17 | - | {{$site := .SiteURL}} |
|
| 18 | - | <div class="admin-list"> |
|
| 19 | - | {{range .Files}} |
|
| 20 | - | <div class="admin-list-item"> |
|
| 21 | - | {{if .IsImage}} |
|
| 22 | - | <img src="/files/{{.Filename}}" class="file-thumbnail" alt="{{.OriginalName}}"> |
|
| 23 | - | {{end}} |
|
| 24 | - | <div class="admin-list-info"> |
|
| 25 | - | <span class="admin-list-title">{{.OriginalName}}</span> |
|
| 26 | - | <div class="admin-list-meta"> |
|
| 27 | - | <span class="admin-list-date">{{.ContentType}}</span> |
|
| 28 | - | <span class="admin-list-date">{{.SizeHuman}}</span> |
|
| 29 | - | <span class="admin-list-date">{{.CreatedAt}}</span> |
|
| 30 | - | </div> |
|
| 31 | - | </div> |
|
| 32 | - | <div class="admin-list-actions"> |
|
| 33 | - | <button type="button" class="link-button" |
|
| 34 | - | onclick="navigator.clipboard.writeText('{{$site}}/files/{{.Filename}}');this.textContent='copied!'"> |
|
| 35 | - | copy url |
|
| 36 | - | </button> |
|
| 37 | - | <button type="button" class="link-button" |
|
| 38 | - | onclick="navigator.clipboard.writeText('');this.textContent='copied!'"> |
|
| 39 | - | copy md |
|
| 40 | - | </button> |
|
| 41 | - | <form method="POST" action="/admin/files/{{.ShortID}}/delete" class="inline-form"> |
|
| 42 | - | <button type="submit" class="link-button danger" onclick="return confirm('Delete this file?')">delete</button> |
|
| 43 | - | </form> |
|
| 44 | - | </div> |
|
| 45 | - | </div> |
|
| 46 | - | {{end}} |
|
| 47 | - | </div> |
|
| 48 | - | {{end}} |
|
| 49 | - | {{end}} |
| 1 | - | {{define "admin_import.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Admin — Import{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <div class="admin-toolbar"> |
|
| 5 | - | <h2>Import posts</h2> |
|
| 6 | - | </div> |
|
| 7 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 8 | - | {{if .Imported}} |
|
| 9 | - | <p class="success">Imported {{.Imported}} posts, skipped {{if .Skipped}}{{.Skipped}}{{else}}0{{end}}.</p> |
|
| 10 | - | {{end}} |
|
| 11 | - | <form method="POST" action="/admin/import" enctype="multipart/form-data" class="form"> |
|
| 12 | - | <label for="zip">upload zip of markdown files (max 50MB)</label> |
|
| 13 | - | <input type="file" id="zip" name="zip" accept=".zip" required> |
|
| 14 | - | <button type="submit">import</button> |
|
| 15 | - | </form> |
|
| 16 | - | <section> |
|
| 17 | - | <h3>Format</h3> |
|
| 18 | - | <p>The zip can contain any number of <code>.md</code> or <code>.markdown</code> files. Each file may begin with YAML-style frontmatter:</p> |
|
| 19 | - | <pre><code>--- |
|
| 20 | - | title: My Post |
|
| 21 | - | slug: my-post |
|
| 22 | - | status: published |
|
| 23 | - | published_date: 2025-01-15 10:00:00 |
|
| 24 | - | tags: rust, sqlite |
|
| 25 | - | description: A short summary |
|
| 26 | - | lang: en |
|
| 27 | - | --- |
|
| 28 | - | ||
| 29 | - | # Hello |
|
| 30 | - | ||
| 31 | - | Post body in markdown. |
|
| 32 | - | </code></pre> |
|
| 33 | - | <p>Supported keys: <code>title</code>, <code>slug</code>, <code>status</code> (<code>draft</code> or <code>published</code>), <code>published_date</code>, <code>tags</code>, <code>description</code>, <code>meta_image</code>, <code>alias</code>, <code>lang</code>. Files without frontmatter are imported with the title derived from the filename. Posts whose slug already exists are skipped.</p> |
|
| 34 | - | </section> |
|
| 35 | - | {{end}} |
| 1 | - | {{define "admin_index.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Admin — Posts{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <div class="admin-toolbar"> |
|
| 5 | - | <h2>Posts</h2> |
|
| 6 | - | <a href="/admin/posts/new" class="btn">new post</a> |
|
| 7 | - | </div> |
|
| 8 | - | {{if not .Posts}} |
|
| 9 | - | <p class="empty">no posts yet</p> |
|
| 10 | - | {{else}} |
|
| 11 | - | <div class="admin-list"> |
|
| 12 | - | {{range .Posts}} |
|
| 13 | - | <div class="admin-list-item"> |
|
| 14 | - | <div class="admin-list-info"> |
|
| 15 | - | <a href="/admin/posts/{{.ShortID}}/edit" class="admin-list-title">{{.DisplayTitle}}</a> |
|
| 16 | - | <div class="admin-list-meta"> |
|
| 17 | - | <span class="status-badge {{if eq .Status "published"}}status-published{{else}}status-draft{{end}}">{{.Status}}</span> |
|
| 18 | - | <span class="admin-list-date">{{.UpdatedAt}}</span> |
|
| 19 | - | </div> |
|
| 20 | - | </div> |
|
| 21 | - | <div class="admin-list-actions"> |
|
| 22 | - | <a href="/admin/posts/{{.ShortID}}/edit">edit</a> |
|
| 23 | - | <form method="POST" action="/admin/posts/{{.ShortID}}/publish" class="inline-form"> |
|
| 24 | - | <button type="submit" class="link-button"> |
|
| 25 | - | {{if eq .Status "published"}}unpublish{{else}}publish{{end}} |
|
| 26 | - | </button> |
|
| 27 | - | </form> |
|
| 28 | - | <form method="POST" action="/admin/posts/{{.ShortID}}/delete" class="inline-form"> |
|
| 29 | - | <button type="submit" class="link-button danger" onclick="return confirm('Delete this post?')">delete</button> |
|
| 30 | - | </form> |
|
| 31 | - | </div> |
|
| 32 | - | </div> |
|
| 33 | - | {{end}} |
|
| 34 | - | </div> |
|
| 35 | - | {{end}} |
|
| 36 | - | {{end}} |
| 1 | - | {{define "admin_page_form.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Admin — {{if .Page}}Edit Page{{else}}New Page{{end}}{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <h2>{{if .Page}}Edit Page{{else}}New Page{{end}}</h2> |
|
| 5 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 6 | - | {{$p := .Page}} |
|
| 7 | - | {{if $p}} |
|
| 8 | - | <form method="POST" action="/admin/pages/{{$p.ShortID}}" class="form post-form"> |
|
| 9 | - | <textarea name="attributes" class="attributes-textarea">title: {{$p.Title}} |
|
| 10 | - | slug: {{$p.Slug}} |
|
| 11 | - | published: {{$p.IsPublished}}</textarea> |
|
| 12 | - | <details class="available-fields"> |
|
| 13 | - | <summary>available fields</summary> |
|
| 14 | - | <div class="fields-list"> |
|
| 15 | - | <span>title: My Page Title</span> |
|
| 16 | - | <span>slug: my-page-slug</span> |
|
| 17 | - | <span>published: true</span> |
|
| 18 | - | </div> |
|
| 19 | - | </details> |
|
| 20 | - | <label for="content">content</label> |
|
| 21 | - | <textarea id="content" name="content" class="post-content">{{$p.Content}}</textarea> |
|
| 22 | - | <button type="submit">save</button> |
|
| 23 | - | </form> |
|
| 24 | - | {{else}} |
|
| 25 | - | <form method="POST" action="/admin/pages/create" class="form post-form"> |
|
| 26 | - | <textarea name="attributes" class="attributes-textarea">title: |
|
| 27 | - | slug: |
|
| 28 | - | published: false</textarea> |
|
| 29 | - | <details class="available-fields"> |
|
| 30 | - | <summary>available fields</summary> |
|
| 31 | - | <div class="fields-list"> |
|
| 32 | - | <span>title: My Page Title</span> |
|
| 33 | - | <span>slug: my-page-slug</span> |
|
| 34 | - | <span>published: true</span> |
|
| 35 | - | </div> |
|
| 36 | - | </details> |
|
| 37 | - | <label for="content">content</label> |
|
| 38 | - | <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea> |
|
| 39 | - | <button type="submit">save</button> |
|
| 40 | - | </form> |
|
| 41 | - | {{end}} |
|
| 42 | - | {{end}} |
| 1 | - | {{define "admin_pages.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Admin — Pages{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <div class="admin-toolbar"> |
|
| 5 | - | <h2>Pages</h2> |
|
| 6 | - | <a href="/admin/pages/new" class="btn">new page</a> |
|
| 7 | - | </div> |
|
| 8 | - | {{if not .Pages}} |
|
| 9 | - | <p class="empty">no pages yet</p> |
|
| 10 | - | {{else}} |
|
| 11 | - | <div class="admin-list"> |
|
| 12 | - | {{range .Pages}} |
|
| 13 | - | <div class="admin-list-item"> |
|
| 14 | - | <div class="admin-list-info"> |
|
| 15 | - | <a href="/admin/pages/{{.ShortID}}/edit" class="admin-list-title">{{.Title}}</a> |
|
| 16 | - | <div class="admin-list-meta"> |
|
| 17 | - | <span class="status-badge {{if .IsPublished}}status-published{{else}}status-draft{{end}}"> |
|
| 18 | - | {{if .IsPublished}}published{{else}}draft{{end}} |
|
| 19 | - | </span> |
|
| 20 | - | <span class="admin-list-date">/{{.Slug}}</span> |
|
| 21 | - | <span class="admin-list-date">order: {{.NavOrder}}</span> |
|
| 22 | - | </div> |
|
| 23 | - | </div> |
|
| 24 | - | <div class="admin-list-actions"> |
|
| 25 | - | <a href="/admin/pages/{{.ShortID}}/edit">edit</a> |
|
| 26 | - | <form method="POST" action="/admin/pages/{{.ShortID}}/delete" class="inline-form"> |
|
| 27 | - | <button type="submit" class="link-button danger" onclick="return confirm('Delete this page?')">delete</button> |
|
| 28 | - | </form> |
|
| 29 | - | </div> |
|
| 30 | - | </div> |
|
| 31 | - | {{end}} |
|
| 32 | - | </div> |
|
| 33 | - | {{end}} |
|
| 34 | - | {{end}} |
| 1 | - | {{define "admin_post_form.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Admin — {{if .Post}}Edit Post{{else}}New Post{{end}}{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <h2>{{if .Post}}Edit Post{{else}}New Post{{end}}</h2> |
|
| 5 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 6 | - | {{$p := .Post}} |
|
| 7 | - | {{if $p}} |
|
| 8 | - | <form method="POST" action="/admin/posts/{{$p.ShortID}}" class="form post-form"> |
|
| 9 | - | <textarea name="attributes" class="attributes-textarea">title: {{$p.TitleStr}} |
|
| 10 | - | slug: {{$p.Slug}} |
|
| 11 | - | {{if $p.PublishedDate}}published_date: {{$p.PublishedDateStr}} |
|
| 12 | - | {{end}}{{if ne $p.Lang "en"}}lang: {{$p.Lang}} |
|
| 13 | - | {{end}}{{if $p.Tags}}tags: {{$p.TagsStr}} |
|
| 14 | - | {{end}}{{if $p.Alias}}alias: {{$p.AliasStr}} |
|
| 15 | - | {{end}}{{if $p.MetaImage}}meta_image: {{$p.MetaImageStr}} |
|
| 16 | - | {{end}}{{if $p.MetaDescription}}description: {{$p.MetaDescriptionStr}}{{end}}</textarea> |
|
| 17 | - | <details class="available-fields"> |
|
| 18 | - | <summary>available fields</summary> |
|
| 19 | - | <div class="fields-list"> |
|
| 20 | - | <span>title: My Post Title</span> |
|
| 21 | - | <span>slug: my-post-title</span> |
|
| 22 | - | <span>published_date: 2025-01-15 14:30:00</span> |
|
| 23 | - | <span>lang: en</span> |
|
| 24 | - | <span>tags: rust, web, tutorial</span> |
|
| 25 | - | <span>alias: /old/path</span> |
|
| 26 | - | <span>meta_image: https://example.com/image.jpg</span> |
|
| 27 | - | <span>description: A short summary of the post</span> |
|
| 28 | - | </div> |
|
| 29 | - | </details> |
|
| 30 | - | <label for="content">content</label> |
|
| 31 | - | <textarea id="content" name="content" class="post-content">{{$p.Content}}</textarea> |
|
| 32 | - | <div class="form-actions"> |
|
| 33 | - | {{if eq $p.Status "published"}} |
|
| 34 | - | <button type="submit" name="action" value="publish">update</button> |
|
| 35 | - | <button type="submit" name="action" value="draft">unpublish</button> |
|
| 36 | - | {{else}} |
|
| 37 | - | <button type="submit" name="action" value="draft">save draft</button> |
|
| 38 | - | <button type="submit" name="action" value="publish">publish</button> |
|
| 39 | - | {{end}} |
|
| 40 | - | </div> |
|
| 41 | - | </form> |
|
| 42 | - | {{else}} |
|
| 43 | - | <form method="POST" action="/admin/posts" class="form post-form"> |
|
| 44 | - | <textarea name="attributes" class="attributes-textarea">title: </textarea> |
|
| 45 | - | <details class="available-fields"> |
|
| 46 | - | <summary>available fields</summary> |
|
| 47 | - | <div class="fields-list"> |
|
| 48 | - | <span>title: My Post Title</span> |
|
| 49 | - | <span>slug: my-post-title</span> |
|
| 50 | - | <span>published_date: 2025-01-15 14:30:00</span> |
|
| 51 | - | <span>lang: en</span> |
|
| 52 | - | <span>tags: rust, web, tutorial</span> |
|
| 53 | - | <span>alias: /old/path</span> |
|
| 54 | - | <span>meta_image: https://example.com/image.jpg</span> |
|
| 55 | - | <span>description: A short summary of the post</span> |
|
| 56 | - | </div> |
|
| 57 | - | </details> |
|
| 58 | - | <label for="content">content</label> |
|
| 59 | - | <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea> |
|
| 60 | - | <div class="form-actions"> |
|
| 61 | - | <button type="submit" name="action" value="draft">save draft</button> |
|
| 62 | - | <button type="submit" name="action" value="publish">publish</button> |
|
| 63 | - | </div> |
|
| 64 | - | </form> |
|
| 65 | - | {{end}} |
|
| 66 | - | {{end}} |
| 1 | - | {{define "admin_settings.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Admin — Settings{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | <h2>Settings</h2> |
|
| 5 | - | {{if .Success}}<p class="success">Settings saved.</p>{{end}} |
|
| 6 | - | <form method="POST" action="/admin/settings" class="form"> |
|
| 7 | - | <label for="blog_title">blog title</label> |
|
| 8 | - | <input type="text" id="blog_title" name="blog_title" value="{{.BlogTitle}}" required> |
|
| 9 | - | <label for="blog_description">blog description</label> |
|
| 10 | - | <input type="text" id="blog_description" name="blog_description" value="{{.BlogDescription}}"> |
|
| 11 | - | <label for="nav_links">navigation links (format: [label](url) [label2](url2))</label> |
|
| 12 | - | <textarea id="nav_links" name="nav_links" class="nav-links-input">{{.NavLinksRaw}}</textarea> |
|
| 13 | - | <label for="favicon_url">favicon URL (leave empty for default)</label> |
|
| 14 | - | <input type="text" id="favicon_url" name="favicon_url" value="{{.FaviconURL}}" placeholder="https://example.com/favicon.png"> |
|
| 15 | - | <label for="og_image_url">default OG image URL (used when posts don't have their own)</label> |
|
| 16 | - | <input type="text" id="og_image_url" name="og_image_url" value="{{.OGImageURL}}" placeholder="https://example.com/og.png"> |
|
| 17 | - | <label for="intro_content">intro content (markdown, shown on homepage — use {{latest_posts}} to embed recent posts)</label> |
|
| 18 | - | <textarea id="intro_content" name="intro_content" class="post-content">{{.IntroContent}}</textarea> |
|
| 19 | - | <div class="switch-row"> |
|
| 20 | - | <label class="switch"> |
|
| 21 | - | <input type="checkbox" id="custom_css_toggle" {{if .CustomCSS}}checked{{end}}> |
|
| 22 | - | <span class="switch-slider"></span> |
|
| 23 | - | </label> |
|
| 24 | - | <span class="switch-label">custom stylesheet</span> |
|
| 25 | - | </div> |
|
| 26 | - | <div id="custom_css_section" {{if not .CustomCSS}}class="hidden"{{end}}> |
|
| 27 | - | <label for="custom_css">custom CSS (overrides default styles)</label> |
|
| 28 | - | <textarea id="custom_css" name="custom_css" class="post-content">{{if .CustomCSS}}{{.CustomCSS}}{{else}}{{.DefaultCSS}}{{end}}</textarea> |
|
| 29 | - | </div> |
|
| 30 | - | <label for="custom_header">custom header (markdown or HTML, shown above nav on all pages)</label> |
|
| 31 | - | <textarea id="custom_header" name="custom_header" class="nav-links-input">{{.CustomHeader}}</textarea> |
|
| 32 | - | <label for="custom_footer">custom footer (markdown or HTML, shown at bottom of all pages)</label> |
|
| 33 | - | <textarea id="custom_footer" name="custom_footer" class="post-content">{{.CustomFooter}}</textarea> |
|
| 34 | - | <button type="submit">save</button> |
|
| 35 | - | </form> |
|
| 36 | - | <h3>Data Export</h3> |
|
| 37 | - | <div class="form-actions"> |
|
| 38 | - | <a href="/admin/downloads/posts" class="btn">download posts</a> |
|
| 39 | - | <a href="/admin/downloads/uploads" class="btn">download uploads</a> |
|
| 40 | - | </div> |
|
| 41 | - | <script> |
|
| 42 | - | var toggle = document.getElementById('custom_css_toggle'); |
|
| 43 | - | var section = document.getElementById('custom_css_section'); |
|
| 44 | - | var cssTextarea = document.getElementById('custom_css'); |
|
| 45 | - | toggle.addEventListener('change', function() { |
|
| 46 | - | section.classList.toggle('hidden', !this.checked); |
|
| 47 | - | }); |
|
| 48 | - | document.querySelector('form').addEventListener('submit', function() { |
|
| 49 | - | if (!toggle.checked) { cssTextarea.value = ''; } |
|
| 50 | - | }); |
|
| 51 | - | </script> |
|
| 52 | - | {{end}} |
| 1 | - | {{define "base.html"}}<!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>{{block "title" .}}{{.BlogTitle}}{{end}}</title> |
|
| 7 | - | {{if .FaviconURL}} |
|
| 8 | - | <link rel="apple-touch-icon" sizes="180x180" href="{{.FaviconURL}}"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="32x32" href="{{.FaviconURL}}"> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="16x16" href="{{.FaviconURL}}"> |
|
| 11 | - | {{else}} |
|
| 12 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 13 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 14 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 15 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 16 | - | {{end}} |
|
| 17 | - | <meta name="theme-color" content="#121113" /> |
|
| 18 | - | {{block "meta" .}}{{end}} |
|
| 19 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 20 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 21 | - | <link rel="stylesheet" href="/custom-styles.css"> |
|
| 22 | - | </head> |
|
| 23 | - | <body> |
|
| 24 | - | {{if .HeaderHTML}} |
|
| 25 | - | <div class="custom-header"> |
|
| 26 | - | {{.HeaderHTML}} |
|
| 27 | - | </div> |
|
| 28 | - | {{end}} |
|
| 29 | - | <header class="header"> |
|
| 30 | - | <a href="/" class="logo">{{.BlogTitle}}</a> |
|
| 31 | - | <nav class="links"> |
|
| 32 | - | {{range .NavLinks}} |
|
| 33 | - | <a href="{{.URL}}">{{.Label}}</a> |
|
| 34 | - | {{end}} |
|
| 35 | - | </nav> |
|
| 36 | - | </header> |
|
| 37 | - | <main> |
|
| 38 | - | {{block "content" .}}{{end}} |
|
| 39 | - | </main> |
|
| 40 | - | <footer class="footer"> |
|
| 41 | - | {{.FooterHTML}} |
|
| 42 | - | </footer> |
|
| 43 | - | <script> |
|
| 44 | - | document.querySelectorAll('.post-date').forEach(el => { |
|
| 45 | - | const d = new Date(el.textContent.trim()); |
|
| 46 | - | if (!isNaN(d)) { |
|
| 47 | - | const day = String(d.getDate()).padStart(2, '0'); |
|
| 48 | - | const mon = d.toLocaleString('en-US', { month: 'short' }); |
|
| 49 | - | el.textContent = `${day} ${mon}, ${d.getFullYear()}`; |
|
| 50 | - | } |
|
| 51 | - | }); |
|
| 52 | - | </script> |
|
| 53 | - | </body> |
|
| 54 | - | </html>{{end}} |
| 1 | - | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}{{.BlogTitle}}{{end}} |
|
| 3 | - | {{define "meta"}} |
|
| 4 | - | <meta name="description" content="{{.BlogDescription}}"> |
|
| 5 | - | <meta property="og:title" content="{{.BlogTitle}}"> |
|
| 6 | - | <meta property="og:description" content="{{.BlogDescription}}"> |
|
| 7 | - | <meta property="og:type" content="website"> |
|
| 8 | - | <meta property="og:url" content="{{.SiteURL}}"> |
|
| 9 | - | {{if .OGImageURL}} |
|
| 10 | - | <meta property="og:image" content="{{.OGImageURL}}"> |
|
| 11 | - | {{else}} |
|
| 12 | - | <meta property="og:image" content="{{.SiteURL}}/static/og.png"> |
|
| 13 | - | {{end}} |
|
| 14 | - | {{end}} |
|
| 15 | - | {{define "content"}} |
|
| 16 | - | {{if .IntroHTML}} |
|
| 17 | - | <article class="intro markdown-body"> |
|
| 18 | - | {{.IntroHTML}} |
|
| 19 | - | </article> |
|
| 20 | - | {{end}} |
|
| 21 | - | {{if not .Posts}} |
|
| 22 | - | <p class="empty">no posts yet</p> |
|
| 23 | - | {{end}} |
|
| 24 | - | <div class="post-list"> |
|
| 25 | - | {{range .Posts}} |
|
| 26 | - | <a href="/posts/{{.Slug}}" class="post-item"> |
|
| 27 | - | <div class="post-item-info"> |
|
| 28 | - | <span class="post-title">{{.DisplayTitle}}</span> |
|
| 29 | - | </div> |
|
| 30 | - | {{if .PublishedDate}} |
|
| 31 | - | <time class="post-date">{{.PublishedDateStr}}</time> |
|
| 32 | - | {{end}} |
|
| 33 | - | </a> |
|
| 34 | - | {{end}} |
|
| 35 | - | </div> |
|
| 36 | - | {{end}} |
| 1 | - | {{define "login.html"}}<!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>Login</title> |
|
| 7 | - | <link rel="icon" href="/static/favicon.ico"> |
|
| 8 | - | <meta name="theme-color" content="#121113" /> |
|
| 9 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 10 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 11 | - | </head> |
|
| 12 | - | <body> |
|
| 13 | - | <header class="header"> |
|
| 14 | - | <span class="logo">POSTS</span> |
|
| 15 | - | </header> |
|
| 16 | - | <main> |
|
| 17 | - | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 18 | - | <form method="POST" action="/admin/login" class="form"> |
|
| 19 | - | <label for="password">password</label> |
|
| 20 | - | <input type="password" id="password" name="password" autofocus required> |
|
| 21 | - | <button type="submit">login</button> |
|
| 22 | - | </form> |
|
| 23 | - | </main> |
|
| 24 | - | </body> |
|
| 25 | - | </html>{{end}} |
| 1 | - | {{define "page.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}{{.Page.Title}} — {{.BlogTitle}}{{end}} |
|
| 3 | - | {{define "meta"}} |
|
| 4 | - | {{if .OGImageURL}} |
|
| 5 | - | <meta property="og:image" content="{{.OGImageURL}}"> |
|
| 6 | - | {{else}} |
|
| 7 | - | <meta property="og:image" content="{{.SiteURL}}/static/og.png"> |
|
| 8 | - | {{end}} |
|
| 9 | - | <meta property="og:url" content="{{.SiteURL}}/{{.Page.Slug}}"> |
|
| 10 | - | {{end}} |
|
| 11 | - | {{define "content"}} |
|
| 12 | - | <div class="page-header"> |
|
| 13 | - | <h1>{{.Page.Title}}</h1> |
|
| 14 | - | </div> |
|
| 15 | - | <article class="markdown-body"> |
|
| 16 | - | {{.RenderedContent}} |
|
| 17 | - | </article> |
|
| 18 | - | {{end}} |
| 1 | - | {{define "post.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}{{.Post.DisplayTitle}} — {{.BlogTitle}}{{end}} |
|
| 3 | - | {{define "meta"}} |
|
| 4 | - | {{if .Post.MetaDescription}} |
|
| 5 | - | <meta name="description" content="{{.Post.MetaDescriptionStr}}"> |
|
| 6 | - | <meta property="og:description" content="{{.Post.MetaDescriptionStr}}"> |
|
| 7 | - | {{end}} |
|
| 8 | - | {{if and .Post.MetaImage .Post.MetaImageStr}} |
|
| 9 | - | <meta property="og:image" content="{{.Post.MetaImageStr}}"> |
|
| 10 | - | {{else if .OGImageURL}} |
|
| 11 | - | <meta property="og:image" content="{{.OGImageURL}}"> |
|
| 12 | - | {{else}} |
|
| 13 | - | <meta property="og:image" content="{{.SiteURL}}/static/og.png"> |
|
| 14 | - | {{end}} |
|
| 15 | - | {{if .Post.CanonicalURL}} |
|
| 16 | - | <link rel="canonical" href="{{.Post.CanonicalURLStr}}"> |
|
| 17 | - | {{end}} |
|
| 18 | - | <meta property="og:title" content="{{.Post.DisplayTitle}}"> |
|
| 19 | - | <meta property="og:type" content="article"> |
|
| 20 | - | <meta property="og:url" content="{{.SiteURL}}/posts/{{.Post.Slug}}"> |
|
| 21 | - | <meta property="article:published_time" content="{{.Post.PublishedDateStr}}"> |
|
| 22 | - | {{end}} |
|
| 23 | - | {{define "content"}} |
|
| 24 | - | <div class="post-header"> |
|
| 25 | - | {{if .Post.HasTitle}} |
|
| 26 | - | <h1>{{.Post.TitleStr}}</h1> |
|
| 27 | - | {{end}} |
|
| 28 | - | {{if .Post.MetaDescription}} |
|
| 29 | - | <p class="post-description">{{.Post.MetaDescriptionStr}}</p> |
|
| 30 | - | {{end}} |
|
| 31 | - | {{if .Post.PublishedDate}} |
|
| 32 | - | <time class="post-date">{{.Post.PublishedDateStr}}</time> |
|
| 33 | - | {{end}} |
|
| 34 | - | {{if .Post.Tags}} |
|
| 35 | - | <div class="post-tags"> |
|
| 36 | - | {{range .Post.TagList}} |
|
| 37 | - | <span class="tag">{{.}}</span> |
|
| 38 | - | {{end}} |
|
| 39 | - | </div> |
|
| 40 | - | {{end}} |
|
| 41 | - | </div> |
|
| 42 | - | <article class="markdown-body"> |
|
| 43 | - | {{.RenderedContent}} |
|
| 44 | - | </article> |
|
| 45 | - | {{end}} |
| 1 | - | {{define "posts.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}Posts — {{.BlogTitle}}{{end}} |
|
| 3 | - | {{define "meta"}} |
|
| 4 | - | {{if .OGImageURL}} |
|
| 5 | - | <meta property="og:image" content="{{.OGImageURL}}"> |
|
| 6 | - | {{else}} |
|
| 7 | - | <meta property="og:image" content="{{.SiteURL}}/static/og.png"> |
|
| 8 | - | {{end}} |
|
| 9 | - | <meta property="og:url" content="{{.SiteURL}}/posts"> |
|
| 10 | - | {{end}} |
|
| 11 | - | {{define "content"}} |
|
| 12 | - | <h1>Posts</h1> |
|
| 13 | - | {{if not .Posts}} |
|
| 14 | - | <p class="empty">no posts yet</p> |
|
| 15 | - | {{end}} |
|
| 16 | - | <div class="post-list"> |
|
| 17 | - | {{range .Posts}} |
|
| 18 | - | <a href="/posts/{{.Slug}}" class="post-item post-item-enhanced"> |
|
| 19 | - | <div class="post-item-info"> |
|
| 20 | - | <span class="post-title">{{.DisplayTitle}}</span> |
|
| 21 | - | </div> |
|
| 22 | - | {{if .PublishedDate}} |
|
| 23 | - | <time class="post-date">{{.PublishedDateStr}}</time> |
|
| 24 | - | {{end}} |
|
| 25 | - | </a> |
|
| 26 | - | {{end}} |
|
| 27 | - | </div> |
|
| 28 | - | {{end}} |
| 6 | 6 | PORT=3000 |
|
| 7 | 7 | SITE_URL=http://localhost:3000 |
|
| 8 | 8 | ||
| 9 | - | # Optional: Cloudflare R2 (set ALL to enable; otherwise local filesystem is used) |
|
| 10 | - | # R2_ACCOUNT_ID= |
|
| 11 | - | # R2_ACCESS_KEY_ID= |
|
| 12 | - | # R2_SECRET_ACCESS_KEY= |
|
| 13 | - | # R2_BUCKET= |
|
| 14 | - | # R2_PUBLIC_URL=https://pub-xxxx.r2.dev |
|
| 9 | + | # Optional Cloudflare R2 storage for uploads. Leave R2_BUCKET empty for local files. |
|
| 10 | + | R2_ACCOUNT_ID= |
|
| 11 | + | R2_ACCESS_KEY_ID= |
|
| 12 | + | R2_SECRET_ACCESS_KEY= |
|
| 13 | + | R2_BUCKET= |
|
| 14 | + | R2_PUBLIC_URL= |
| 1 | - | [package] |
|
| 2 | - | name = "posts" |
|
| 3 | - | version = "0.2.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "CMS blog with admin interface" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | axum = { workspace = true, features = ["multipart"] } |
|
| 12 | - | tokio = { workspace = true } |
|
| 13 | - | serde = { workspace = true } |
|
| 14 | - | serde_json = { workspace = true } |
|
| 15 | - | rusqlite = { workspace = true } |
|
| 16 | - | nanoid = { workspace = true } |
|
| 17 | - | rust-embed = { workspace = true } |
|
| 18 | - | dotenvy = { workspace = true } |
|
| 19 | - | subtle = { workspace = true } |
|
| 20 | - | rand = { workspace = true } |
|
| 21 | - | tracing = { workspace = true } |
|
| 22 | - | tracing-subscriber = { workspace = true } |
|
| 23 | - | andromeda-auth = { workspace = true } |
|
| 24 | - | andromeda-db = { workspace = true, features = ["session"] } |
|
| 25 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 26 | - | serde_rusqlite = "0.41" |
|
| 27 | - | askama = "0.15" |
|
| 28 | - | askama_web = { version = "0.15", features = ["axum-0.8"] } |
|
| 29 | - | pulldown-cmark = "0.12" |
|
| 30 | - | chrono = "0.4" |
|
| 31 | - | zip = { workspace = true } |
|
| 32 | - | tower-http = { version = "0.6.8", features = ["cors"] } |
|
| 33 | - | rusty-s3 = "0.9" |
|
| 34 | - | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } |
|
| 35 | - | url = "2" |
| 1 | 1 | # Build from repo root: docker build -t posts -f apps/posts/Dockerfile . |
|
| 2 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 2 | + | FROM golang:1.24-bookworm AS builder |
|
| 3 | 3 | WORKDIR /app |
|
| 4 | - | ||
| 5 | - | FROM chef AS planner |
|
| 6 | - | COPY . . |
|
| 7 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 8 | - | ||
| 9 | - | FROM chef AS builder |
|
| 10 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 11 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 12 | - | RUN cargo chef cook --release --recipe-path recipe.json -p posts |
|
| 13 | - | COPY . . |
|
| 14 | - | RUN cargo build --release -p posts |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/posts/go.mod apps/posts/go.sum ./apps/posts/ |
|
| 6 | + | WORKDIR /app/apps/posts |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/posts/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /posts . |
|
| 15 | 10 | ||
| 16 | 11 | FROM debian:bookworm-slim |
|
| 17 | - | COPY --from=builder /app/target/release/posts /usr/local/bin/posts |
|
| 12 | + | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 13 | + | COPY --from=builder /posts /usr/local/bin/posts |
|
| 18 | 14 | WORKDIR /data |
|
| 19 | - | EXPOSE 3000 |
|
| 20 | 15 | ENV HOST=0.0.0.0 |
|
| 21 | 16 | ENV PORT=3000 |
|
| 17 | + | ENV UPLOADS_DIR=/data/uploads |
|
| 18 | + | EXPOSE 3000 |
|
| 22 | 19 | CMD ["posts"] |
| 1 | - | MIT License |
|
| 2 | - | ||
| 3 | - | Copyright (c) 2026 Steve Simkins |
|
| 4 | - | ||
| 5 | - | Permission is hereby granted, free of charge, to any person obtaining a copy |
|
| 6 | - | of this software and associated documentation files (the "Software"), to deal |
|
| 7 | - | in the Software without restriction, including without limitation the rights |
|
| 8 | - | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
| 9 | - | copies of the Software, and to permit persons to whom the Software is |
|
| 10 | - | furnished to do so, subject to the following conditions: |
|
| 11 | - | ||
| 12 | - | The above copyright notice and this permission notice shall be included in all |
|
| 13 | - | copies or substantial portions of the Software. |
|
| 14 | - | ||
| 15 | - | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
| 16 | - | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
| 17 | - | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
| 18 | - | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
| 19 | - | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
| 20 | - | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
| 21 | - | SOFTWARE. |
|
| 22 | - |
| 1 | - | # Posts |
|
| 2 | - | ||
| 3 | - |  |
|
| 4 | - | ||
| 5 | - | A minimal CMS blog with admin interface |
|
| 6 | - | ||
| 7 | - | ## Quickstart |
|
| 8 | - | ||
| 9 | - | ```bash |
|
| 10 | - | cd apps/posts |
|
| 11 | - | cp .env.example .env |
|
| 12 | - | # Edit .env with your password |
|
| 13 | - | cargo build --release |
|
| 14 | - | ./target/release/posts |
|
| 15 | - | ``` |
|
| 16 | - | ||
| 17 | - | ### Environment Variables |
|
| 18 | - | ||
| 19 | - | | Variable | Description | Default | |
|
| 20 | - | |---|---|---| |
|
| 21 | - | | `POSTS_PASSWORD` | Password for admin login | `changeme` | |
|
| 22 | - | | `POSTS_DB_PATH` | SQLite database file path | `posts.sqlite` | |
|
| 23 | - | | `UPLOADS_DIR` | Directory for uploaded files | `uploads` | |
|
| 24 | - | | `SITE_URL` | Public URL for RSS feed and links | `http://localhost:3000` | |
|
| 25 | - | | `HOST` | Server bind address | `127.0.0.1` | |
|
| 26 | - | | `PORT` | Server port | `3000` | |
|
| 27 | - | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 28 | - | ||
| 29 | - | ## Overview |
|
| 30 | - | ||
| 31 | - | A self-hosted blog CMS built with Rust. Here's a few highlights: |
|
| 32 | - | - Single Rust binary with embedded assets |
|
| 33 | - | - Password authentication with session cookies |
|
| 34 | - | - Create, edit, publish, and delete blog posts with markdown |
|
| 35 | - | - Static pages with custom navigation links |
|
| 36 | - | - File uploads with admin management |
|
| 37 | - | - Custom CSS support from the admin panel |
|
| 38 | - | - RSS feed at `/feed.xml` |
|
| 39 | - | - Dark themed UI with Commit Mono font |
|
| 40 | - | - SQLite for persistent storage |
|
| 41 | - | - Bulk import posts from a zip of markdown files at `/admin/import` |
|
| 42 | - | ||
| 43 | - | ## Structure |
|
| 44 | - | ||
| 45 | - | ``` |
|
| 46 | - | posts/ |
|
| 47 | - | ├── src/ |
|
| 48 | - | │ ├── main.rs # App entrypoint, env vars, starts server |
|
| 49 | - | │ ├── server.rs # Axum router, HTTP handlers, and templates |
|
| 50 | - | │ ├── auth.rs # Password verification and session management |
|
| 51 | - | │ └── db.rs # SQLite database layer (posts, pages, files, settings, sessions) |
|
| 52 | - | ├── templates/ # Askama HTML templates |
|
| 53 | - | │ ├── base.html # Public base layout |
|
| 54 | - | │ ├── index.html # Blog home page |
|
| 55 | - | │ ├── post.html # Single post view |
|
| 56 | - | │ ├── posts.html # Post listing |
|
| 57 | - | │ ├── page.html # Static page view |
|
| 58 | - | │ ├── login.html # Login page |
|
| 59 | - | │ ├── admin_base.html # Admin layout |
|
| 60 | - | │ ├── admin_index.html # Admin dashboard |
|
| 61 | - | │ ├── admin_post_form.html # Create/edit post form |
|
| 62 | - | │ ├── admin_pages.html # Admin page listing |
|
| 63 | - | │ ├── admin_page_form.html # Create/edit page form |
|
| 64 | - | │ ├── admin_files.html # File upload management |
|
| 65 | - | │ └── admin_settings.html # Blog settings |
|
| 66 | - | ├── static/ # Favicons, fonts, and styles |
|
| 67 | - | ├── uploads/ # Uploaded files directory |
|
| 68 | - | ├── Dockerfile |
|
| 69 | - | └── docker-compose.yml |
|
| 70 | - | ``` |
|
| 71 | - | ||
| 72 | - | ## Deployment |
|
| 73 | - | ||
| 74 | - | ### Railway |
|
| 75 | - | ||
| 76 | - | [](https://railway.com/deploy/tYtJYp?referralCode=JGcIp6) |
|
| 77 | - | ||
| 78 | - | ### Docker (recommended) |
|
| 79 | - | ||
| 80 | - | ```bash |
|
| 81 | - | cd apps/posts |
|
| 82 | - | cp .env.example .env |
|
| 83 | - | # Edit .env with your password |
|
| 84 | - | docker compose up -d |
|
| 85 | - | ``` |
|
| 86 | - | ||
| 87 | - | This will start Posts on port `3000` with a persistent volume for the SQLite database and uploads. |
|
| 88 | - | ||
| 89 | - | ### Binary |
|
| 90 | - | ||
| 91 | - | ```bash |
|
| 92 | - | cargo build --release |
|
| 93 | - | ``` |
|
| 94 | - | ||
| 95 | - | The resulting binary at `./target/release/posts` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 96 | - | ||
| 97 | - | ## Importing posts |
|
| 98 | - | ||
| 99 | - | Visit `/admin/import` and upload a zip containing `.md` or `.markdown` files. Each file may start with YAML-style frontmatter: |
|
| 1 | + | # posts-go |
|
| 100 | 2 | ||
| 101 | - | ```markdown |
|
| 102 | - | --- |
|
| 103 | - | title: My Post |
|
| 104 | - | slug: my-post |
|
| 105 | - | status: published |
|
| 106 | - | published_date: 2025-01-15 10:00:00 |
|
| 107 | - | tags: rust, sqlite |
|
| 108 | - | description: A short summary |
|
| 109 | - | lang: en |
|
| 110 | - | --- |
|
| 3 | + | Go rewrite of [posts](../posts). CMS blog with admin, pages, file uploads, |
|
| 4 | + | markdown rendering, RSS, zip import/export. |
|
| 111 | 5 | ||
| 112 | - | Post body in markdown. |
|
| 113 | - | ``` |
|
| 6 | + | ## Notes vs Rust version |
|
| 114 | 7 | ||
| 115 | - | Supported keys: `title`, `slug`, `status` (`draft` or `published`), `published_date`, `tags`, `description`, `meta_image`, `alias`, `lang`. Files without frontmatter are imported with the title derived from the filename and a slug auto-generated from that title. Posts whose slug already exists in the database are skipped, so re-uploading the same archive is safe. |
|
| 116 | - | ||
| 117 | - | The zip can be up to 50MB. Asset references inside posts (images, etc.) are left untouched — pre-host them or upload them separately at `/admin/files`. |
|
| 118 | - | ||
| 119 | - | ## Acknowledgements |
|
| 120 | - | ||
| 121 | - | Posts is heavily inspired by [Bear Blog](https://bearblog.dev). If you'd rather not self-host, Bear Blog is a great alternative with the same minimal, no-nonsense approach to blogging. |
|
| 122 | - | ||
| 123 | - | ## License |
|
| 8 | + | - Upload storage supports local filesystem (`UPLOADS_DIR`, default `uploads`) |
|
| 9 | + | or Cloudflare R2 when `R2_BUCKET` and credentials are set. |
|
| 10 | + | - Markdown: `github.com/yuin/goldmark` with GFM + Footnotes (replaces |
|
| 11 | + | pulldown-cmark). |
|
| 12 | + | - Zip via stdlib `archive/zip`. Upload limit 10 MB; import zip limit 50 MB. |
|
| 13 | + | - API: `GET /api/posts` and `GET /api/posts/{slug}` (permissive CORS). |
|
| 124 | 14 | ||
| 125 | - | [MIT](LICENSE) |
|
| 15 | + | See `.env.example`. |
| 2 | 2 | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/posts/Dockerfile |
|
| 5 | + | dockerfile: apps/posts-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | 7 | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | 8 | environment: |
|
| 9 | + | - HOST=0.0.0.0 |
|
| 10 | + | - PORT=${PORT:-3000} |
|
| 11 | + | - POSTS_DB_PATH=/data/posts-go.sqlite |
|
| 9 | 12 | - POSTS_PASSWORD=${POSTS_PASSWORD:-changeme} |
|
| 10 | - | - POSTS_DB_PATH=/data/posts.sqlite |
|
| 11 | 13 | - UPLOADS_DIR=/data/uploads |
|
| 14 | + | - COOKIE_SECURE=${COOKIE_SECURE:-false} |
|
| 12 | 15 | - SITE_URL=${SITE_URL:-http://localhost:3000} |
|
| 13 | - | - COOKIE_SECURE=false |
|
| 14 | - | - HOST=0.0.0.0 |
|
| 15 | - | - PORT=${PORT:-3000} |
|
| 16 | 16 | volumes: |
|
| 17 | - | - posts-data:/data |
|
| 17 | + | - posts-go-data:/data |
|
| 18 | 18 | restart: unless-stopped |
|
| 19 | 19 | ||
| 20 | 20 | volumes: |
|
| 21 | - | posts-data: |
|
| 21 | + | posts-go-data: |
| 1 | - | use axum::{ |
|
| 2 | - | extract::FromRequestParts, |
|
| 3 | - | http::request::Parts, |
|
| 4 | - | response::{IntoResponse, Redirect, Response}, |
|
| 5 | - | }; |
|
| 6 | - | use std::sync::Arc; |
|
| 7 | - | ||
| 8 | - | use crate::db; |
|
| 9 | - | use crate::server::AppState; |
|
| 10 | - | ||
| 11 | - | pub use andromeda_auth::{ |
|
| 12 | - | build_session_cookie, clear_session_cookie, generate_session_token, verify_password, |
|
| 13 | - | }; |
|
| 14 | - | ||
| 15 | - | pub struct AuthSession; |
|
| 16 | - | ||
| 17 | - | impl FromRequestParts<Arc<AppState>> for AuthSession { |
|
| 18 | - | type Rejection = Response; |
|
| 19 | - | ||
| 20 | - | async fn from_request_parts( |
|
| 21 | - | parts: &mut Parts, |
|
| 22 | - | state: &Arc<AppState>, |
|
| 23 | - | ) -> Result<Self, Self::Rejection> { |
|
| 24 | - | let token = andromeda_auth::extract_session_cookie(&parts.headers); |
|
| 25 | - | if let Some(token) = token { |
|
| 26 | - | if is_valid_session(state, &token) { |
|
| 27 | - | return Ok(AuthSession); |
|
| 28 | - | } |
|
| 29 | - | } |
|
| 30 | - | Err(Redirect::to("/admin/login").into_response()) |
|
| 31 | - | } |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | fn is_valid_session(state: &AppState, token: &str) -> bool { |
|
| 35 | - | match db::get_session_expiry(&state.db, token) { |
|
| 36 | - | Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(), |
|
| 37 | - | _ => false, |
|
| 38 | - | } |
|
| 39 | - | } |
| 1 | - | use nanoid::nanoid; |
|
| 2 | - | use rusqlite::{Connection, OptionalExtension, params}; |
|
| 3 | - | use serde::{Deserialize, Serialize}; |
|
| 4 | - | use std::sync::{Arc, Mutex}; |
|
| 5 | - | ||
| 6 | - | pub use andromeda_db::{Db, DbError}; |
|
| 7 | - | pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions}; |
|
| 8 | - | ||
| 9 | - | fn from_row<T: serde::de::DeserializeOwned>(row: &rusqlite::Row) -> rusqlite::Result<T> { |
|
| 10 | - | serde_rusqlite::from_row::<T>(row).map_err(|e| { |
|
| 11 | - | rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Null, Box::new(e)) |
|
| 12 | - | }) |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | #[derive(Debug, Serialize, Deserialize, Clone)] |
|
| 16 | - | pub struct Post { |
|
| 17 | - | pub id: i64, |
|
| 18 | - | pub short_id: String, |
|
| 19 | - | pub title: Option<String>, |
|
| 20 | - | pub slug: String, |
|
| 21 | - | pub alias: Option<String>, |
|
| 22 | - | pub canonical_url: Option<String>, |
|
| 23 | - | pub published_date: Option<String>, |
|
| 24 | - | pub meta_description: Option<String>, |
|
| 25 | - | pub meta_image: Option<String>, |
|
| 26 | - | pub lang: String, |
|
| 27 | - | pub tags: Option<String>, |
|
| 28 | - | pub content: String, |
|
| 29 | - | pub status: String, |
|
| 30 | - | pub created_at: String, |
|
| 31 | - | pub updated_at: String, |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | impl Post { |
|
| 35 | - | pub fn display_title(&self) -> String { |
|
| 36 | - | if let Some(t) = self.title.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { |
|
| 37 | - | return t.to_string(); |
|
| 38 | - | } |
|
| 39 | - | let snippet: String = self |
|
| 40 | - | .content |
|
| 41 | - | .chars() |
|
| 42 | - | .filter(|c| !matches!(c, '\n' | '\r')) |
|
| 43 | - | .take(25) |
|
| 44 | - | .collect(); |
|
| 45 | - | let snippet = snippet.trim(); |
|
| 46 | - | if snippet.is_empty() { |
|
| 47 | - | "Untitled".to_string() |
|
| 48 | - | } else if self.content.chars().count() > 60 { |
|
| 49 | - | format!("{}…", snippet) |
|
| 50 | - | } else { |
|
| 51 | - | snippet.to_string() |
|
| 52 | - | } |
|
| 53 | - | } |
|
| 54 | - | } |
|
| 55 | - | ||
| 56 | - | #[derive(Serialize)] |
|
| 57 | - | pub struct PostInput<'a> { |
|
| 58 | - | pub title: Option<&'a str>, |
|
| 59 | - | pub slug: &'a str, |
|
| 60 | - | pub content: &'a str, |
|
| 61 | - | pub status: &'a str, |
|
| 62 | - | pub alias: Option<&'a str>, |
|
| 63 | - | pub canonical_url: Option<&'a str>, |
|
| 64 | - | pub published_date: Option<&'a str>, |
|
| 65 | - | pub meta_description: Option<&'a str>, |
|
| 66 | - | pub meta_image: Option<&'a str>, |
|
| 67 | - | pub lang: &'a str, |
|
| 68 | - | pub tags: Option<&'a str>, |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | #[derive(Debug, Serialize, Deserialize, Clone)] |
|
| 72 | - | pub struct Page { |
|
| 73 | - | pub id: i64, |
|
| 74 | - | pub short_id: String, |
|
| 75 | - | pub title: String, |
|
| 76 | - | pub slug: String, |
|
| 77 | - | pub content: String, |
|
| 78 | - | pub is_published: bool, |
|
| 79 | - | pub nav_order: i64, |
|
| 80 | - | pub created_at: String, |
|
| 81 | - | pub updated_at: String, |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | #[derive(Debug, Serialize, Deserialize, Clone)] |
|
| 85 | - | pub struct UploadedFile { |
|
| 86 | - | pub id: i64, |
|
| 87 | - | pub short_id: String, |
|
| 88 | - | pub filename: String, |
|
| 89 | - | pub original_name: String, |
|
| 90 | - | pub content_type: String, |
|
| 91 | - | pub size: i64, |
|
| 92 | - | pub created_at: String, |
|
| 93 | - | pub storage_backend: String, |
|
| 94 | - | } |
|
| 95 | - | ||
| 96 | - | const SCHEMA: &str = " |
|
| 97 | - | CREATE TABLE IF NOT EXISTS posts ( |
|
| 98 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 99 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 100 | - | title TEXT, |
|
| 101 | - | slug TEXT NOT NULL UNIQUE, |
|
| 102 | - | alias TEXT, |
|
| 103 | - | canonical_url TEXT, |
|
| 104 | - | published_date TEXT, |
|
| 105 | - | meta_description TEXT, |
|
| 106 | - | meta_image TEXT, |
|
| 107 | - | lang TEXT NOT NULL DEFAULT 'en', |
|
| 108 | - | tags TEXT, |
|
| 109 | - | content TEXT NOT NULL, |
|
| 110 | - | status TEXT NOT NULL DEFAULT 'draft', |
|
| 111 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 112 | - | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 113 | - | ); |
|
| 114 | - | ||
| 115 | - | CREATE TABLE IF NOT EXISTS pages ( |
|
| 116 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 117 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 118 | - | title TEXT NOT NULL, |
|
| 119 | - | slug TEXT NOT NULL UNIQUE, |
|
| 120 | - | content TEXT NOT NULL, |
|
| 121 | - | is_published INTEGER NOT NULL DEFAULT 0, |
|
| 122 | - | nav_order INTEGER NOT NULL DEFAULT 0, |
|
| 123 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 124 | - | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 125 | - | ); |
|
| 126 | - | ||
| 127 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 128 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 129 | - | token TEXT NOT NULL UNIQUE, |
|
| 130 | - | expires_at TEXT NOT NULL |
|
| 131 | - | ); |
|
| 132 | - | ||
| 133 | - | CREATE TABLE IF NOT EXISTS settings ( |
|
| 134 | - | key TEXT PRIMARY KEY, |
|
| 135 | - | value TEXT NOT NULL |
|
| 136 | - | ); |
|
| 137 | - | ||
| 138 | - | CREATE TABLE IF NOT EXISTS files ( |
|
| 139 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 140 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 141 | - | filename TEXT NOT NULL UNIQUE, |
|
| 142 | - | original_name TEXT NOT NULL, |
|
| 143 | - | content_type TEXT NOT NULL DEFAULT 'application/octet-stream', |
|
| 144 | - | size INTEGER NOT NULL, |
|
| 145 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 146 | - | storage_backend TEXT NOT NULL DEFAULT 'local' |
|
| 147 | - | ); |
|
| 148 | - | "; |
|
| 149 | - | ||
| 150 | - | const DEFAULT_SETTINGS: &[(&str, &str)] = &[ |
|
| 151 | - | ("blog_title", "My Blog"), |
|
| 152 | - | ("blog_description", "A simple blog"), |
|
| 153 | - | ("intro_content", ""), |
|
| 154 | - | ("nav_links", "[blog](/) [posts](/posts)"), |
|
| 155 | - | ("custom_css", ""), |
|
| 156 | - | ("favicon_url", ""), |
|
| 157 | - | ("og_image_url", ""), |
|
| 158 | - | ("custom_header", ""), |
|
| 159 | - | ("custom_footer", "<div> |
|
| 160 | - | <a href=\"/feed.xml\" class=\"rss-link\" title=\"RSS Feed\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" fill=\"currentColor\" viewBox=\"0 0 256 256\"><path fill=\"currentColor\" d=\"M104.08 151.92A67.52 67.52 0 0 1 124 200a4 4 0 0 1-8 0a60 60 0 0 0-60-60a4 4 0 0 1 0-8a67.52 67.52 0 0 1 48.08 19.92M56 84a4 4 0 0 0 0 8a108 108 0 0 1 108 108a4 4 0 0 0 8 0A116 116 0 0 0 56 84m116 0A162.92 162.92 0 0 0 56 36a4 4 0 0 0 0 8a155 155 0 0 1 110.31 45.69A155 155 0 0 1 212 200a4 4 0 0 0 8 0a162.92 162.92 0 0 0-48-116M60 188a8 8 0 1 0 8 8a8 8 0 0 0-8-8\"/></svg></a> |
|
| 161 | - | </div>"), |
|
| 162 | - | ]; |
|
| 163 | - | ||
| 164 | - | pub fn init_db() -> Db { |
|
| 165 | - | let path = std::env::var("POSTS_DB_PATH").unwrap_or_else(|_| "posts.sqlite".to_string()); |
|
| 166 | - | let conn = Connection::open(&path).expect("Failed to open database"); |
|
| 167 | - | ||
| 168 | - | conn.execute_batch(SCHEMA).expect("Failed to create tables"); |
|
| 169 | - | migrate_post_title_nullable(&conn).expect("Failed to migrate posts.title"); |
|
| 170 | - | migrate_files_storage_backend(&conn).expect("Failed to migrate files.storage_backend"); |
|
| 171 | - | ||
| 172 | - | for (key, value) in DEFAULT_SETTINGS { |
|
| 173 | - | conn.execute( |
|
| 174 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)", |
|
| 175 | - | params![key, value], |
|
| 176 | - | ) |
|
| 177 | - | .ok(); |
|
| 178 | - | } |
|
| 179 | - | ||
| 180 | - | Arc::new(Mutex::new(conn)) |
|
| 181 | - | } |
|
| 182 | - | ||
| 183 | - | fn migrate_post_title_nullable(conn: &Connection) -> rusqlite::Result<()> { |
|
| 184 | - | let title_notnull: i64 = conn |
|
| 185 | - | .query_row( |
|
| 186 | - | "SELECT \"notnull\" FROM pragma_table_info('posts') WHERE name = 'title'", |
|
| 187 | - | [], |
|
| 188 | - | |row| row.get(0), |
|
| 189 | - | ) |
|
| 190 | - | .unwrap_or(0); |
|
| 191 | - | if title_notnull == 0 { |
|
| 192 | - | return Ok(()); |
|
| 193 | - | } |
|
| 194 | - | conn.execute_batch( |
|
| 195 | - | "BEGIN; |
|
| 196 | - | CREATE TABLE posts_new ( |
|
| 197 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 198 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 199 | - | title TEXT, |
|
| 200 | - | slug TEXT NOT NULL UNIQUE, |
|
| 201 | - | alias TEXT, |
|
| 202 | - | canonical_url TEXT, |
|
| 203 | - | published_date TEXT, |
|
| 204 | - | meta_description TEXT, |
|
| 205 | - | meta_image TEXT, |
|
| 206 | - | lang TEXT NOT NULL DEFAULT 'en', |
|
| 207 | - | tags TEXT, |
|
| 208 | - | content TEXT NOT NULL, |
|
| 209 | - | status TEXT NOT NULL DEFAULT 'draft', |
|
| 210 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 211 | - | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 212 | - | ); |
|
| 213 | - | INSERT INTO posts_new (id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at) |
|
| 214 | - | SELECT id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at FROM posts; |
|
| 215 | - | DROP TABLE posts; |
|
| 216 | - | ALTER TABLE posts_new RENAME TO posts; |
|
| 217 | - | COMMIT;", |
|
| 218 | - | ) |
|
| 219 | - | } |
|
| 220 | - | ||
| 221 | - | fn migrate_files_storage_backend(conn: &Connection) -> rusqlite::Result<()> { |
|
| 222 | - | let exists: i64 = conn |
|
| 223 | - | .query_row( |
|
| 224 | - | "SELECT COUNT(*) FROM pragma_table_info('files') WHERE name = 'storage_backend'", |
|
| 225 | - | [], |
|
| 226 | - | |row| row.get(0), |
|
| 227 | - | ) |
|
| 228 | - | .unwrap_or(0); |
|
| 229 | - | if exists > 0 { |
|
| 230 | - | return Ok(()); |
|
| 231 | - | } |
|
| 232 | - | conn.execute( |
|
| 233 | - | "ALTER TABLE files ADD COLUMN storage_backend TEXT NOT NULL DEFAULT 'local'", |
|
| 234 | - | [], |
|
| 235 | - | )?; |
|
| 236 | - | Ok(()) |
|
| 237 | - | } |
|
| 238 | - | ||
| 239 | - | // --- Post CRUD --- |
|
| 240 | - | ||
| 241 | - | const POST_COLS: &str = "id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at"; |
|
| 242 | - | ||
| 243 | - | pub fn create_post(db: &Db, input: &PostInput) -> Result<Post, DbError> { |
|
| 244 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 245 | - | let short_id = nanoid!(10); |
|
| 246 | - | let named = serde_rusqlite::to_params_named(input) |
|
| 247 | - | .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; |
|
| 248 | - | let mut bindings = named.to_slice(); |
|
| 249 | - | bindings.push((":short_id", &short_id)); |
|
| 250 | - | conn.execute( |
|
| 251 | - | "INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags) |
|
| 252 | - | VALUES (:short_id, :title, :slug, :content, :status, :alias, :canonical_url, :published_date, :meta_description, :meta_image, :lang, :tags)", |
|
| 253 | - | bindings.as_slice(), |
|
| 254 | - | )?; |
|
| 255 | - | let id = conn.last_insert_rowid(); |
|
| 256 | - | let post = conn.query_row( |
|
| 257 | - | &format!("SELECT {} FROM posts WHERE id = ?1", POST_COLS), |
|
| 258 | - | params![id], |
|
| 259 | - | from_row, |
|
| 260 | - | )?; |
|
| 261 | - | Ok(post) |
|
| 262 | - | } |
|
| 263 | - | ||
| 264 | - | pub fn get_post_by_short_id(db: &Db, short_id: &str) -> Result<Option<Post>, DbError> { |
|
| 265 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 266 | - | let post = conn |
|
| 267 | - | .query_row( |
|
| 268 | - | &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS), |
|
| 269 | - | params![short_id], |
|
| 270 | - | from_row, |
|
| 271 | - | ) |
|
| 272 | - | .optional()?; |
|
| 273 | - | Ok(post) |
|
| 274 | - | } |
|
| 275 | - | ||
| 276 | - | pub fn get_post_by_slug(db: &Db, slug: &str) -> Result<Option<Post>, DbError> { |
|
| 277 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 278 | - | let post = conn |
|
| 279 | - | .query_row( |
|
| 280 | - | &format!("SELECT {} FROM posts WHERE slug = ?1", POST_COLS), |
|
| 281 | - | params![slug], |
|
| 282 | - | from_row, |
|
| 283 | - | ) |
|
| 284 | - | .optional()?; |
|
| 285 | - | Ok(post) |
|
| 286 | - | } |
|
| 287 | - | ||
| 288 | - | pub fn get_all_posts(db: &Db) -> Result<Vec<Post>, DbError> { |
|
| 289 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 290 | - | let mut stmt = conn.prepare( |
|
| 291 | - | &format!("SELECT {} FROM posts ORDER BY id DESC", POST_COLS), |
|
| 292 | - | )?; |
|
| 293 | - | let posts = stmt |
|
| 294 | - | .query_map([], from_row)? |
|
| 295 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 296 | - | Ok(posts) |
|
| 297 | - | } |
|
| 298 | - | ||
| 299 | - | pub fn get_published_posts(db: &Db, limit: Option<i64>) -> Result<Vec<Post>, DbError> { |
|
| 300 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 301 | - | let limit_value = limit.unwrap_or(-1); |
|
| 302 | - | let mut stmt = conn.prepare( |
|
| 303 | - | &format!("SELECT {} FROM posts WHERE status = 'published' ORDER BY published_date DESC, id DESC LIMIT ?1", POST_COLS), |
|
| 304 | - | )?; |
|
| 305 | - | let posts = stmt |
|
| 306 | - | .query_map(params![limit_value], from_row)? |
|
| 307 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 308 | - | Ok(posts) |
|
| 309 | - | } |
|
| 310 | - | ||
| 311 | - | pub fn update_post(db: &Db, short_id: &str, input: &PostInput) -> Result<Option<Post>, DbError> { |
|
| 312 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 313 | - | let effective_published_date = if input.status == "published" { |
|
| 314 | - | Some(input.published_date.unwrap_or("")) |
|
| 315 | - | } else { |
|
| 316 | - | input.published_date |
|
| 317 | - | }; |
|
| 318 | - | let rows = conn.execute( |
|
| 319 | - | "UPDATE posts SET title = ?1, slug = ?2, content = ?3, status = ?4, alias = ?5, canonical_url = ?6, |
|
| 320 | - | published_date = CASE WHEN ?4 = 'published' THEN COALESCE(?7, published_date, datetime('now')) ELSE ?7 END, |
|
| 321 | - | meta_description = ?8, meta_image = ?9, lang = ?10, tags = ?11, |
|
| 322 | - | updated_at = datetime('now') WHERE short_id = ?12", |
|
| 323 | - | params![input.title, input.slug, input.content, input.status, input.alias, input.canonical_url, effective_published_date, input.meta_description, input.meta_image, input.lang, input.tags, short_id], |
|
| 324 | - | )?; |
|
| 325 | - | if rows == 0 { |
|
| 326 | - | return Ok(None); |
|
| 327 | - | } |
|
| 328 | - | let post = conn |
|
| 329 | - | .query_row( |
|
| 330 | - | &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS), |
|
| 331 | - | params![short_id], |
|
| 332 | - | from_row, |
|
| 333 | - | ) |
|
| 334 | - | .optional()?; |
|
| 335 | - | Ok(post) |
|
| 336 | - | } |
|
| 337 | - | ||
| 338 | - | pub fn delete_post(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 339 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 340 | - | let rows = conn.execute("DELETE FROM posts WHERE short_id = ?1", params![short_id])?; |
|
| 341 | - | Ok(rows > 0) |
|
| 342 | - | } |
|
| 343 | - | ||
| 344 | - | pub fn toggle_post_status(db: &Db, short_id: &str) -> Result<Option<String>, DbError> { |
|
| 345 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 346 | - | let current: Option<String> = conn |
|
| 347 | - | .query_row( |
|
| 348 | - | "SELECT status FROM posts WHERE short_id = ?1", |
|
| 349 | - | params![short_id], |
|
| 350 | - | |row| row.get(0), |
|
| 351 | - | ) |
|
| 352 | - | .optional()?; |
|
| 353 | - | let current = match current { |
|
| 354 | - | Some(s) => s, |
|
| 355 | - | None => return Ok(None), |
|
| 356 | - | }; |
|
| 357 | - | let new_status = if current == "published" { "draft" } else { "published" }; |
|
| 358 | - | if new_status == "published" { |
|
| 359 | - | conn.execute( |
|
| 360 | - | "UPDATE posts SET status = ?1, published_date = COALESCE(published_date, datetime('now')), updated_at = datetime('now') WHERE short_id = ?2", |
|
| 361 | - | params![new_status, short_id], |
|
| 362 | - | )?; |
|
| 363 | - | } else { |
|
| 364 | - | conn.execute( |
|
| 365 | - | "UPDATE posts SET status = ?1, updated_at = datetime('now') WHERE short_id = ?2", |
|
| 366 | - | params![new_status, short_id], |
|
| 367 | - | )?; |
|
| 368 | - | } |
|
| 369 | - | Ok(Some(new_status.to_string())) |
|
| 370 | - | } |
|
| 371 | - | ||
| 372 | - | pub fn find_alias_redirect(db: &Db, alias: &str) -> Result<Option<String>, DbError> { |
|
| 373 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 374 | - | let slug: Option<String> = conn |
|
| 375 | - | .query_row( |
|
| 376 | - | "SELECT slug FROM posts WHERE alias = ?1 AND status = 'published'", |
|
| 377 | - | params![alias], |
|
| 378 | - | |row| row.get(0), |
|
| 379 | - | ) |
|
| 380 | - | .optional()?; |
|
| 381 | - | Ok(slug.map(|s| format!("/posts/{}", s))) |
|
| 382 | - | } |
|
| 383 | - | ||
| 384 | - | // --- Page CRUD --- |
|
| 385 | - | ||
| 386 | - | const PAGE_COLS: &str = "id, short_id, title, slug, content, is_published, nav_order, created_at, updated_at"; |
|
| 387 | - | ||
| 388 | - | pub fn create_page( |
|
| 389 | - | db: &Db, |
|
| 390 | - | title: &str, |
|
| 391 | - | slug: &str, |
|
| 392 | - | content: &str, |
|
| 393 | - | is_published: bool, |
|
| 394 | - | nav_order: i64, |
|
| 395 | - | ) -> Result<Page, DbError> { |
|
| 396 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 397 | - | let short_id = nanoid!(10); |
|
| 398 | - | conn.execute( |
|
| 399 | - | "INSERT INTO pages (short_id, title, slug, content, is_published, nav_order) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", |
|
| 400 | - | params![short_id, title, slug, content, is_published as i64, nav_order], |
|
| 401 | - | )?; |
|
| 402 | - | let id = conn.last_insert_rowid(); |
|
| 403 | - | let page = conn.query_row( |
|
| 404 | - | &format!("SELECT {} FROM pages WHERE id = ?1", PAGE_COLS), |
|
| 405 | - | params![id], |
|
| 406 | - | from_row, |
|
| 407 | - | )?; |
|
| 408 | - | Ok(page) |
|
| 409 | - | } |
|
| 410 | - | ||
| 411 | - | pub fn get_page_by_short_id(db: &Db, short_id: &str) -> Result<Option<Page>, DbError> { |
|
| 412 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 413 | - | let page = conn |
|
| 414 | - | .query_row( |
|
| 415 | - | &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS), |
|
| 416 | - | params![short_id], |
|
| 417 | - | from_row, |
|
| 418 | - | ) |
|
| 419 | - | .optional()?; |
|
| 420 | - | Ok(page) |
|
| 421 | - | } |
|
| 422 | - | ||
| 423 | - | pub fn get_page_by_slug(db: &Db, slug: &str) -> Result<Option<Page>, DbError> { |
|
| 424 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 425 | - | let page = conn |
|
| 426 | - | .query_row( |
|
| 427 | - | &format!("SELECT {} FROM pages WHERE slug = ?1", PAGE_COLS), |
|
| 428 | - | params![slug], |
|
| 429 | - | from_row, |
|
| 430 | - | ) |
|
| 431 | - | .optional()?; |
|
| 432 | - | Ok(page) |
|
| 433 | - | } |
|
| 434 | - | ||
| 435 | - | pub fn get_all_pages(db: &Db) -> Result<Vec<Page>, DbError> { |
|
| 436 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 437 | - | let mut stmt = conn.prepare( |
|
| 438 | - | &format!("SELECT {} FROM pages ORDER BY nav_order ASC, id ASC", PAGE_COLS), |
|
| 439 | - | )?; |
|
| 440 | - | let pages = stmt |
|
| 441 | - | .query_map([], from_row)? |
|
| 442 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 443 | - | Ok(pages) |
|
| 444 | - | } |
|
| 445 | - | ||
| 446 | - | pub fn update_page( |
|
| 447 | - | db: &Db, |
|
| 448 | - | short_id: &str, |
|
| 449 | - | title: &str, |
|
| 450 | - | slug: &str, |
|
| 451 | - | content: &str, |
|
| 452 | - | is_published: bool, |
|
| 453 | - | nav_order: i64, |
|
| 454 | - | ) -> Result<Option<Page>, DbError> { |
|
| 455 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 456 | - | let rows = conn.execute( |
|
| 457 | - | "UPDATE pages SET title = ?1, slug = ?2, content = ?3, is_published = ?4, nav_order = ?5, updated_at = datetime('now') WHERE short_id = ?6", |
|
| 458 | - | params![title, slug, content, is_published as i64, nav_order, short_id], |
|
| 459 | - | )?; |
|
| 460 | - | if rows == 0 { |
|
| 461 | - | return Ok(None); |
|
| 462 | - | } |
|
| 463 | - | let page = conn |
|
| 464 | - | .query_row( |
|
| 465 | - | &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS), |
|
| 466 | - | params![short_id], |
|
| 467 | - | from_row, |
|
| 468 | - | ) |
|
| 469 | - | .optional()?; |
|
| 470 | - | Ok(page) |
|
| 471 | - | } |
|
| 472 | - | ||
| 473 | - | pub fn delete_page(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 474 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 475 | - | let rows = conn.execute("DELETE FROM pages WHERE short_id = ?1", params![short_id])?; |
|
| 476 | - | Ok(rows > 0) |
|
| 477 | - | } |
|
| 478 | - | ||
| 479 | - | // --- Settings --- |
|
| 480 | - | ||
| 481 | - | pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>, DbError> { |
|
| 482 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 483 | - | let val = conn |
|
| 484 | - | .query_row( |
|
| 485 | - | "SELECT value FROM settings WHERE key = ?1", |
|
| 486 | - | params![key], |
|
| 487 | - | |row| row.get(0), |
|
| 488 | - | ) |
|
| 489 | - | .optional()?; |
|
| 490 | - | Ok(val) |
|
| 491 | - | } |
|
| 492 | - | ||
| 493 | - | pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<(), DbError> { |
|
| 494 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 495 | - | conn.execute( |
|
| 496 | - | "INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = ?2", |
|
| 497 | - | params![key, value], |
|
| 498 | - | )?; |
|
| 499 | - | Ok(()) |
|
| 500 | - | } |
|
| 501 | - | ||
| 502 | - | // --- File CRUD --- |
|
| 503 | - | ||
| 504 | - | const FILE_COLS: &str = "id, short_id, filename, original_name, content_type, size, created_at, storage_backend"; |
|
| 505 | - | ||
| 506 | - | pub fn create_file( |
|
| 507 | - | db: &Db, |
|
| 508 | - | filename: &str, |
|
| 509 | - | original_name: &str, |
|
| 510 | - | content_type: &str, |
|
| 511 | - | size: i64, |
|
| 512 | - | storage_backend: &str, |
|
| 513 | - | ) -> Result<UploadedFile, DbError> { |
|
| 514 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 515 | - | let short_id = nanoid!(10); |
|
| 516 | - | conn.execute( |
|
| 517 | - | "INSERT INTO files (short_id, filename, original_name, content_type, size, storage_backend) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", |
|
| 518 | - | params![short_id, filename, original_name, content_type, size, storage_backend], |
|
| 519 | - | )?; |
|
| 520 | - | let id = conn.last_insert_rowid(); |
|
| 521 | - | let file = conn.query_row( |
|
| 522 | - | &format!("SELECT {} FROM files WHERE id = ?1", FILE_COLS), |
|
| 523 | - | params![id], |
|
| 524 | - | from_row, |
|
| 525 | - | )?; |
|
| 526 | - | Ok(file) |
|
| 527 | - | } |
|
| 528 | - | ||
| 529 | - | pub fn get_file_by_filename(db: &Db, filename: &str) -> Result<Option<UploadedFile>, DbError> { |
|
| 530 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 531 | - | let file = conn |
|
| 532 | - | .query_row( |
|
| 533 | - | &format!("SELECT {} FROM files WHERE filename = ?1", FILE_COLS), |
|
| 534 | - | params![filename], |
|
| 535 | - | from_row, |
|
| 536 | - | ) |
|
| 537 | - | .optional()?; |
|
| 538 | - | Ok(file) |
|
| 539 | - | } |
|
| 540 | - | ||
| 541 | - | pub fn get_all_files(db: &Db) -> Result<Vec<UploadedFile>, DbError> { |
|
| 542 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 543 | - | let mut stmt = conn.prepare( |
|
| 544 | - | &format!("SELECT {} FROM files ORDER BY id DESC", FILE_COLS), |
|
| 545 | - | )?; |
|
| 546 | - | let files = stmt |
|
| 547 | - | .query_map([], from_row)? |
|
| 548 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 549 | - | Ok(files) |
|
| 550 | - | } |
|
| 551 | - | ||
| 552 | - | pub fn delete_file(db: &Db, short_id: &str) -> Result<Option<UploadedFile>, DbError> { |
|
| 553 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 554 | - | let file: Option<UploadedFile> = conn |
|
| 555 | - | .query_row( |
|
| 556 | - | &format!("SELECT {} FROM files WHERE short_id = ?1", FILE_COLS), |
|
| 557 | - | params![short_id], |
|
| 558 | - | from_row, |
|
| 559 | - | ) |
|
| 560 | - | .optional()?; |
|
| 561 | - | match file { |
|
| 562 | - | Some(f) => { |
|
| 563 | - | conn.execute("DELETE FROM files WHERE short_id = ?1", params![short_id])?; |
|
| 564 | - | Ok(Some(f)) |
|
| 565 | - | } |
|
| 566 | - | None => Ok(None), |
|
| 567 | - | } |
|
| 568 | - | } |
|
| 569 | - | ||
| 570 | - | #[cfg(test)] |
|
| 571 | - | mod tests { |
|
| 572 | - | use super::*; |
|
| 573 | - | ||
| 574 | - | fn test_db() -> Db { |
|
| 575 | - | let conn = Connection::open_in_memory().unwrap(); |
|
| 576 | - | conn.execute_batch(SCHEMA).unwrap(); |
|
| 577 | - | Arc::new(Mutex::new(conn)) |
|
| 578 | - | } |
|
| 579 | - | ||
| 580 | - | fn test_post_input<'a>(title: &'a str, slug: &'a str, content: &'a str, status: &'a str) -> PostInput<'a> { |
|
| 581 | - | PostInput { |
|
| 582 | - | title: Some(title), |
|
| 583 | - | slug, |
|
| 584 | - | content, |
|
| 585 | - | status, |
|
| 586 | - | alias: None, |
|
| 587 | - | canonical_url: None, |
|
| 588 | - | published_date: None, |
|
| 589 | - | meta_description: None, |
|
| 590 | - | meta_image: None, |
|
| 591 | - | lang: "en", |
|
| 592 | - | tags: None, |
|
| 593 | - | } |
|
| 594 | - | } |
|
| 595 | - | ||
| 596 | - | // ── Post CRUD ────────────────────────────────────────────────────── |
|
| 597 | - | ||
| 598 | - | #[test] |
|
| 599 | - | fn create_and_get_post() { |
|
| 600 | - | let db = test_db(); |
|
| 601 | - | let post = create_post(&db, &test_post_input("Hello World", "hello-world", "# Hello", "draft")).unwrap(); |
|
| 602 | - | assert_eq!(post.title.as_deref(), Some("Hello World")); |
|
| 603 | - | assert_eq!(post.slug, "hello-world"); |
|
| 604 | - | assert_eq!(post.status, "draft"); |
|
| 605 | - | ||
| 606 | - | let fetched = get_post_by_short_id(&db, &post.short_id).unwrap().unwrap(); |
|
| 607 | - | assert_eq!(fetched.title.as_deref(), Some("Hello World")); |
|
| 608 | - | } |
|
| 609 | - | ||
| 610 | - | #[test] |
|
| 611 | - | fn create_post_without_title() { |
|
| 612 | - | let db = test_db(); |
|
| 613 | - | let input = PostInput { |
|
| 614 | - | title: None, |
|
| 615 | - | slug: "no-title", |
|
| 616 | - | content: "just a quick thought", |
|
| 617 | - | status: "draft", |
|
| 618 | - | alias: None, |
|
| 619 | - | canonical_url: None, |
|
| 620 | - | published_date: None, |
|
| 621 | - | meta_description: None, |
|
| 622 | - | meta_image: None, |
|
| 623 | - | lang: "en", |
|
| 624 | - | tags: None, |
|
| 625 | - | }; |
|
| 626 | - | let post = create_post(&db, &input).unwrap(); |
|
| 627 | - | assert!(post.title.is_none()); |
|
| 628 | - | assert_eq!(post.display_title(), "just a quick thought"); |
|
| 629 | - | } |
|
| 630 | - | ||
| 631 | - | #[test] |
|
| 632 | - | fn get_post_by_slug_works() { |
|
| 633 | - | let db = test_db(); |
|
| 634 | - | let mut input = test_post_input("Test", "test-slug", "content", "published"); |
|
| 635 | - | input.published_date = Some("2024-01-01"); |
|
| 636 | - | create_post(&db, &input).unwrap(); |
|
| 637 | - | ||
| 638 | - | let post = get_post_by_slug(&db, "test-slug").unwrap().unwrap(); |
|
| 639 | - | assert_eq!(post.title.as_deref(), Some("Test")); |
|
| 640 | - | } |
|
| 641 | - | ||
| 642 | - | #[test] |
|
| 643 | - | fn duplicate_slug_fails() { |
|
| 644 | - | let db = test_db(); |
|
| 645 | - | create_post(&db, &test_post_input("A", "same-slug", "a", "draft")).unwrap(); |
|
| 646 | - | let result = create_post(&db, &test_post_input("B", "same-slug", "b", "draft")); |
|
| 647 | - | assert!(result.is_err()); |
|
| 648 | - | } |
|
| 649 | - | ||
| 650 | - | #[test] |
|
| 651 | - | fn get_all_posts_ordered_desc() { |
|
| 652 | - | let db = test_db(); |
|
| 653 | - | create_post(&db, &test_post_input("First", "first", "a", "draft")).unwrap(); |
|
| 654 | - | create_post(&db, &test_post_input("Second", "second", "b", "draft")).unwrap(); |
|
| 655 | - | ||
| 656 | - | let all = get_all_posts(&db).unwrap(); |
|
| 657 | - | assert_eq!(all.len(), 2); |
|
| 658 | - | assert_eq!(all[0].title.as_deref(), Some("Second")); |
|
| 659 | - | assert_eq!(all[1].title.as_deref(), Some("First")); |
|
| 660 | - | } |
|
| 661 | - | ||
| 662 | - | #[test] |
|
| 663 | - | fn get_published_posts_filters() { |
|
| 664 | - | let db = test_db(); |
|
| 665 | - | create_post(&db, &test_post_input("Draft", "draft", "a", "draft")).unwrap(); |
|
| 666 | - | let mut input = test_post_input("Published", "pub", "b", "published"); |
|
| 667 | - | input.published_date = Some("2024-01-01"); |
|
| 668 | - | create_post(&db, &input).unwrap(); |
|
| 669 | - | ||
| 670 | - | let published = get_published_posts(&db, None).unwrap(); |
|
| 671 | - | assert_eq!(published.len(), 1); |
|
| 672 | - | assert_eq!(published[0].title.as_deref(), Some("Published")); |
|
| 673 | - | } |
|
| 674 | - | ||
| 675 | - | #[test] |
|
| 676 | - | fn delete_post_works() { |
|
| 677 | - | let db = test_db(); |
|
| 678 | - | let post = create_post(&db, &test_post_input("Del", "del", "x", "draft")).unwrap(); |
|
| 679 | - | assert!(delete_post(&db, &post.short_id).unwrap()); |
|
| 680 | - | assert!(get_post_by_short_id(&db, &post.short_id).unwrap().is_none()); |
|
| 681 | - | } |
|
| 682 | - | ||
| 683 | - | #[test] |
|
| 684 | - | fn toggle_post_status_draft_to_published() { |
|
| 685 | - | let db = test_db(); |
|
| 686 | - | let post = create_post(&db, &test_post_input("Toggle", "toggle", "x", "draft")).unwrap(); |
|
| 687 | - | let new_status = toggle_post_status(&db, &post.short_id).unwrap().unwrap(); |
|
| 688 | - | assert_eq!(new_status, "published"); |
|
| 689 | - | ||
| 690 | - | let updated = get_post_by_short_id(&db, &post.short_id).unwrap().unwrap(); |
|
| 691 | - | assert_eq!(updated.status, "published"); |
|
| 692 | - | assert!(updated.published_date.is_some()); |
|
| 693 | - | } |
|
| 694 | - | ||
| 695 | - | #[test] |
|
| 696 | - | fn toggle_post_status_published_to_draft() { |
|
| 697 | - | let db = test_db(); |
|
| 698 | - | let mut input = test_post_input("Toggle", "toggle", "x", "published"); |
|
| 699 | - | input.published_date = Some("2024-01-01"); |
|
| 700 | - | let post = create_post(&db, &input).unwrap(); |
|
| 701 | - | let new_status = toggle_post_status(&db, &post.short_id).unwrap().unwrap(); |
|
| 702 | - | assert_eq!(new_status, "draft"); |
|
| 703 | - | } |
|
| 704 | - | ||
| 705 | - | #[test] |
|
| 706 | - | fn find_alias_redirect_found() { |
|
| 707 | - | let db = test_db(); |
|
| 708 | - | let mut input = test_post_input("Aliased", "aliased-post", "x", "published"); |
|
| 709 | - | input.alias = Some("old-url"); |
|
| 710 | - | input.published_date = Some("2024-01-01"); |
|
| 711 | - | create_post(&db, &input).unwrap(); |
|
| 712 | - | let redirect = find_alias_redirect(&db, "old-url").unwrap(); |
|
| 713 | - | assert_eq!(redirect, Some("/posts/aliased-post".to_string())); |
|
| 714 | - | } |
|
| 715 | - | ||
| 716 | - | #[test] |
|
| 717 | - | fn find_alias_redirect_not_found() { |
|
| 718 | - | let db = test_db(); |
|
| 719 | - | assert!(find_alias_redirect(&db, "nonexistent").unwrap().is_none()); |
|
| 720 | - | } |
|
| 721 | - | ||
| 722 | - | #[test] |
|
| 723 | - | fn find_alias_redirect_only_published() { |
|
| 724 | - | let db = test_db(); |
|
| 725 | - | let mut input = test_post_input("Draft Alias", "draft-alias", "x", "draft"); |
|
| 726 | - | input.alias = Some("my-alias"); |
|
| 727 | - | create_post(&db, &input).unwrap(); |
|
| 728 | - | assert!(find_alias_redirect(&db, "my-alias").unwrap().is_none()); |
|
| 729 | - | } |
|
| 730 | - | ||
| 731 | - | // ── Page CRUD ────────────────────────────────────────────────────── |
|
| 732 | - | ||
| 733 | - | #[test] |
|
| 734 | - | fn create_and_get_page() { |
|
| 735 | - | let db = test_db(); |
|
| 736 | - | let page = create_page(&db, "About", "about", "About content", true, 1).unwrap(); |
|
| 737 | - | assert_eq!(page.title, "About"); |
|
| 738 | - | assert!(page.is_published); |
|
| 739 | - | ||
| 740 | - | let fetched = get_page_by_short_id(&db, &page.short_id).unwrap().unwrap(); |
|
| 741 | - | assert_eq!(fetched.slug, "about"); |
|
| 742 | - | } |
|
| 743 | - | ||
| 744 | - | #[test] |
|
| 745 | - | fn get_page_by_slug_works() { |
|
| 746 | - | let db = test_db(); |
|
| 747 | - | create_page(&db, "Contact", "contact", "Email us", false, 2).unwrap(); |
|
| 748 | - | let page = get_page_by_slug(&db, "contact").unwrap().unwrap(); |
|
| 749 | - | assert_eq!(page.title, "Contact"); |
|
| 750 | - | } |
|
| 751 | - | ||
| 752 | - | #[test] |
|
| 753 | - | fn update_page_works() { |
|
| 754 | - | let db = test_db(); |
|
| 755 | - | let page = create_page(&db, "Old", "old", "old content", false, 0).unwrap(); |
|
| 756 | - | let updated = update_page(&db, &page.short_id, "New", "new", "new content", true, 5) |
|
| 757 | - | .unwrap() |
|
| 758 | - | .unwrap(); |
|
| 759 | - | assert_eq!(updated.title, "New"); |
|
| 760 | - | assert!(updated.is_published); |
|
| 761 | - | assert_eq!(updated.nav_order, 5); |
|
| 762 | - | } |
|
| 763 | - | ||
| 764 | - | #[test] |
|
| 765 | - | fn delete_page_works() { |
|
| 766 | - | let db = test_db(); |
|
| 767 | - | let page = create_page(&db, "Del", "del", "x", false, 0).unwrap(); |
|
| 768 | - | assert!(delete_page(&db, &page.short_id).unwrap()); |
|
| 769 | - | assert!(get_page_by_short_id(&db, &page.short_id).unwrap().is_none()); |
|
| 770 | - | } |
|
| 771 | - | ||
| 772 | - | // ── Settings ─────────────────────────────────────────────────────── |
|
| 773 | - | ||
| 774 | - | #[test] |
|
| 775 | - | fn settings_get_set() { |
|
| 776 | - | let db = test_db(); |
|
| 777 | - | set_setting(&db, "blog_title", "My Blog").unwrap(); |
|
| 778 | - | let val = get_setting(&db, "blog_title").unwrap(); |
|
| 779 | - | assert_eq!(val, Some("My Blog".to_string())); |
|
| 780 | - | } |
|
| 781 | - | ||
| 782 | - | #[test] |
|
| 783 | - | fn settings_upsert() { |
|
| 784 | - | let db = test_db(); |
|
| 785 | - | set_setting(&db, "key", "first").unwrap(); |
|
| 786 | - | set_setting(&db, "key", "second").unwrap(); |
|
| 787 | - | assert_eq!(get_setting(&db, "key").unwrap(), Some("second".to_string())); |
|
| 788 | - | } |
|
| 789 | - | ||
| 790 | - | #[test] |
|
| 791 | - | fn settings_missing_key() { |
|
| 792 | - | let db = test_db(); |
|
| 793 | - | assert!(get_setting(&db, "nonexistent").unwrap().is_none()); |
|
| 794 | - | } |
|
| 795 | - | ||
| 796 | - | // ── File CRUD ────────────────────────────────────────────────────── |
|
| 797 | - | ||
| 798 | - | #[test] |
|
| 799 | - | fn create_and_get_files() { |
|
| 800 | - | let db = test_db(); |
|
| 801 | - | let file = create_file(&db, "abc123.jpg", "photo.jpg", "image/jpeg", 1024, "local").unwrap(); |
|
| 802 | - | assert_eq!(file.filename, "abc123.jpg"); |
|
| 803 | - | assert_eq!(file.original_name, "photo.jpg"); |
|
| 804 | - | assert_eq!(file.size, 1024); |
|
| 805 | - | ||
| 806 | - | let all = get_all_files(&db).unwrap(); |
|
| 807 | - | assert_eq!(all.len(), 1); |
|
| 808 | - | } |
|
| 809 | - | ||
| 810 | - | #[test] |
|
| 811 | - | fn delete_file_returns_deleted() { |
|
| 812 | - | let db = test_db(); |
|
| 813 | - | let file = create_file(&db, "f.txt", "f.txt", "text/plain", 10, "local").unwrap(); |
|
| 814 | - | let deleted = delete_file(&db, &file.short_id).unwrap(); |
|
| 815 | - | assert!(deleted.is_some()); |
|
| 816 | - | assert_eq!(deleted.unwrap().filename, "f.txt"); |
|
| 817 | - | ||
| 818 | - | assert!(get_all_files(&db).unwrap().is_empty()); |
|
| 819 | - | } |
|
| 820 | - | ||
| 821 | - | #[test] |
|
| 822 | - | fn delete_file_not_found() { |
|
| 823 | - | let db = test_db(); |
|
| 824 | - | assert!(delete_file(&db, "nonexistent").unwrap().is_none()); |
|
| 825 | - | } |
|
| 826 | - | ||
| 827 | - | // ── Sessions ─────────────────────────────────────────────────────── |
|
| 828 | - | ||
| 829 | - | #[test] |
|
| 830 | - | fn session_lifecycle() { |
|
| 831 | - | let db = test_db(); |
|
| 832 | - | insert_session(&db, "tok", "2099-12-31 23:59:59").unwrap(); |
|
| 833 | - | assert_eq!( |
|
| 834 | - | get_session_expiry(&db, "tok").unwrap(), |
|
| 835 | - | Some("2099-12-31 23:59:59".to_string()) |
|
| 836 | - | ); |
|
| 837 | - | delete_session(&db, "tok").unwrap(); |
|
| 838 | - | assert!(get_session_expiry(&db, "tok").unwrap().is_none()); |
|
| 839 | - | } |
|
| 840 | - | ||
| 841 | - | #[test] |
|
| 842 | - | fn prune_expired_sessions_works() { |
|
| 843 | - | let db = test_db(); |
|
| 844 | - | insert_session(&db, "old", "2000-01-01 00:00:00").unwrap(); |
|
| 845 | - | insert_session(&db, "new", "2099-01-01 00:00:00").unwrap(); |
|
| 846 | - | prune_expired_sessions(&db).unwrap(); |
|
| 847 | - | assert!(get_session_expiry(&db, "old").unwrap().is_none()); |
|
| 848 | - | assert!(get_session_expiry(&db, "new").unwrap().is_some()); |
|
| 849 | - | } |
|
| 850 | - | } |
| 1 | - | mod auth; |
|
| 2 | - | mod db; |
|
| 3 | - | mod server; |
|
| 4 | - | mod storage; |
|
| 5 | - | ||
| 6 | - | #[tokio::main] |
|
| 7 | - | async fn main() { |
|
| 8 | - | dotenvy::dotenv().ok(); |
|
| 9 | - | tracing_subscriber::fmt::init(); |
|
| 10 | - | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
|
| 11 | - | let port: u16 = std::env::var("PORT") |
|
| 12 | - | .ok() |
|
| 13 | - | .and_then(|v| v.parse().ok()) |
|
| 14 | - | .unwrap_or(3000); |
|
| 15 | - | server::run(host, port).await; |
|
| 16 | - | } |
| 1 | - | use askama_web::WebTemplate; |
|
| 2 | - | use axum::{ |
|
| 3 | - | extract::{Form, Multipart, Path, Query, State}, |
|
| 4 | - | http::{HeaderValue, StatusCode}, |
|
| 5 | - | response::{Html, IntoResponse, Redirect, Response}, |
|
| 6 | - | }; |
|
| 7 | - | use std::io::{Cursor, Read}; |
|
| 8 | - | use std::sync::Arc; |
|
| 9 | - | use zip::ZipArchive; |
|
| 10 | - | ||
| 11 | - | use super::super::*; |
|
| 12 | - | use crate::{auth, db}; |
|
| 13 | - | ||
| 14 | - | // --- Auth handlers --- |
|
| 15 | - | ||
| 16 | - | pub async fn get_login(Query(q): Query<FlashQuery>) -> Response { |
|
| 17 | - | WebTemplate(LoginTemplate { error: q.error }).into_response() |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | - | pub async fn post_login( |
|
| 21 | - | State(state): State<Arc<AppState>>, |
|
| 22 | - | Form(form): Form<LoginForm>, |
|
| 23 | - | ) -> Response { |
|
| 24 | - | if !auth::verify_password(&form.password, &state.app_password) { |
|
| 25 | - | return Redirect::to("/admin/login?error=Invalid+password").into_response(); |
|
| 26 | - | } |
|
| 27 | - | ||
| 28 | - | let token = auth::generate_session_token(); |
|
| 29 | - | ||
| 30 | - | let expires_at = andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600); |
|
| 31 | - | ||
| 32 | - | if let Err(e) = db::insert_session(&state.db, &token, &expires_at) { |
|
| 33 | - | tracing::error!("Failed to create session: {}", e); |
|
| 34 | - | return Redirect::to("/admin/login?error=Server+error").into_response(); |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 38 | - | let mut resp = Redirect::to("/admin").into_response(); |
|
| 39 | - | resp.headers_mut().insert( |
|
| 40 | - | axum::http::header::SET_COOKIE, |
|
| 41 | - | HeaderValue::from_str(&cookie).unwrap(), |
|
| 42 | - | ); |
|
| 43 | - | resp |
|
| 44 | - | } |
|
| 45 | - | ||
| 46 | - | pub async fn get_logout( |
|
| 47 | - | State(state): State<Arc<AppState>>, |
|
| 48 | - | headers: axum::http::HeaderMap, |
|
| 49 | - | ) -> Response { |
|
| 50 | - | if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) { |
|
| 51 | - | for part in cookie_header.split(';') { |
|
| 52 | - | let part = part.trim(); |
|
| 53 | - | if let Some(val) = part.strip_prefix("session=") { |
|
| 54 | - | let val = val.trim(); |
|
| 55 | - | if !val.is_empty() { |
|
| 56 | - | let _ = db::delete_session(&state.db, val); |
|
| 57 | - | } |
|
| 58 | - | } |
|
| 59 | - | } |
|
| 60 | - | } |
|
| 61 | - | ||
| 62 | - | let cookie = auth::clear_session_cookie(); |
|
| 63 | - | let mut resp = Redirect::to("/admin/login").into_response(); |
|
| 64 | - | resp.headers_mut().insert( |
|
| 65 | - | axum::http::header::SET_COOKIE, |
|
| 66 | - | HeaderValue::from_str(&cookie).unwrap(), |
|
| 67 | - | ); |
|
| 68 | - | resp |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | // --- Admin post handlers --- |
|
| 72 | - | ||
| 73 | - | pub async fn admin_index( |
|
| 74 | - | _session: auth::AuthSession, |
|
| 75 | - | State(state): State<Arc<AppState>>, |
|
| 76 | - | ) -> Response { |
|
| 77 | - | match db::get_all_posts(&state.db) { |
|
| 78 | - | Ok(posts) => WebTemplate(AdminIndexTemplate { posts }).into_response(), |
|
| 79 | - | Err(e) => { |
|
| 80 | - | tracing::error!("Failed to list posts: {}", e); |
|
| 81 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 82 | - | } |
|
| 83 | - | } |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | pub async fn admin_new_post( |
|
| 87 | - | _session: auth::AuthSession, |
|
| 88 | - | Query(q): Query<FlashQuery>, |
|
| 89 | - | ) -> Response { |
|
| 90 | - | WebTemplate(AdminPostFormTemplate { |
|
| 91 | - | post: None, |
|
| 92 | - | error: q.error, |
|
| 93 | - | }) |
|
| 94 | - | .into_response() |
|
| 95 | - | } |
|
| 96 | - | ||
| 97 | - | pub async fn admin_create_post( |
|
| 98 | - | _session: auth::AuthSession, |
|
| 99 | - | State(state): State<Arc<AppState>>, |
|
| 100 | - | Form(form): Form<PostForm>, |
|
| 101 | - | ) -> Response { |
|
| 102 | - | let attrs = parse_attributes(&form.attributes); |
|
| 103 | - | let title = attrs.title.trim(); |
|
| 104 | - | let slug = derive_slug(title, attrs.slug.trim()); |
|
| 105 | - | ||
| 106 | - | let status = if form.action == "publish" { "published" } else { "draft" }; |
|
| 107 | - | let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() }; |
|
| 108 | - | let published_date = if attrs.published_date.trim().is_empty() { |
|
| 109 | - | now_datetime() |
|
| 110 | - | } else { |
|
| 111 | - | attrs.published_date.trim().to_string() |
|
| 112 | - | }; |
|
| 113 | - | ||
| 114 | - | let input = db::PostInput { |
|
| 115 | - | title: opt_str(title), |
|
| 116 | - | slug: &slug, |
|
| 117 | - | content: &form.content, |
|
| 118 | - | status, |
|
| 119 | - | alias: opt_str(&attrs.alias), |
|
| 120 | - | canonical_url: None, |
|
| 121 | - | published_date: Some(&published_date), |
|
| 122 | - | meta_description: opt_str(&attrs.meta_description), |
|
| 123 | - | meta_image: opt_str(&attrs.meta_image), |
|
| 124 | - | lang, |
|
| 125 | - | tags: opt_str(&attrs.tags), |
|
| 126 | - | }; |
|
| 127 | - | match db::create_post(&state.db, &input) { |
|
| 128 | - | Ok(_) => Redirect::to("/admin").into_response(), |
|
| 129 | - | Err(e) => { |
|
| 130 | - | tracing::error!("Failed to create post: {}", e); |
|
| 131 | - | Redirect::to("/admin/posts/new?error=Failed+to+create+post").into_response() |
|
| 132 | - | } |
|
| 133 | - | } |
|
| 134 | - | } |
|
| 135 | - | ||
| 136 | - | fn derive_slug(title: &str, slug: &str) -> String { |
|
| 137 | - | if !slug.is_empty() { |
|
| 138 | - | return slug.to_string(); |
|
| 139 | - | } |
|
| 140 | - | let from_title = slugify(title); |
|
| 141 | - | if !from_title.is_empty() { |
|
| 142 | - | return from_title; |
|
| 143 | - | } |
|
| 144 | - | nanoid::nanoid!(10) |
|
| 145 | - | } |
|
| 146 | - | ||
| 147 | - | pub async fn admin_edit_post( |
|
| 148 | - | _session: auth::AuthSession, |
|
| 149 | - | State(state): State<Arc<AppState>>, |
|
| 150 | - | Path(short_id): Path<String>, |
|
| 151 | - | Query(q): Query<FlashQuery>, |
|
| 152 | - | ) -> Response { |
|
| 153 | - | match db::get_post_by_short_id(&state.db, &short_id) { |
|
| 154 | - | Ok(Some(post)) => WebTemplate(AdminPostFormTemplate { |
|
| 155 | - | post: Some(post), |
|
| 156 | - | error: q.error, |
|
| 157 | - | }) |
|
| 158 | - | .into_response(), |
|
| 159 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(), |
|
| 160 | - | Err(e) => { |
|
| 161 | - | tracing::error!("Failed to get post: {}", e); |
|
| 162 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 163 | - | } |
|
| 164 | - | } |
|
| 165 | - | } |
|
| 166 | - | ||
| 167 | - | pub async fn admin_update_post( |
|
| 168 | - | _session: auth::AuthSession, |
|
| 169 | - | State(state): State<Arc<AppState>>, |
|
| 170 | - | Path(short_id): Path<String>, |
|
| 171 | - | Form(form): Form<PostForm>, |
|
| 172 | - | ) -> Response { |
|
| 173 | - | let attrs = parse_attributes(&form.attributes); |
|
| 174 | - | let title = attrs.title.trim(); |
|
| 175 | - | let slug = derive_slug(title, attrs.slug.trim()); |
|
| 176 | - | ||
| 177 | - | let status = if form.action == "publish" { "published" } else { "draft" }; |
|
| 178 | - | let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() }; |
|
| 179 | - | let published_date = if attrs.published_date.trim().is_empty() { |
|
| 180 | - | None |
|
| 181 | - | } else { |
|
| 182 | - | Some(attrs.published_date.trim().to_string()) |
|
| 183 | - | }; |
|
| 184 | - | ||
| 185 | - | let input = db::PostInput { |
|
| 186 | - | title: opt_str(title), |
|
| 187 | - | slug: &slug, |
|
| 188 | - | content: &form.content, |
|
| 189 | - | status, |
|
| 190 | - | alias: opt_str(&attrs.alias), |
|
| 191 | - | canonical_url: None, |
|
| 192 | - | published_date: published_date.as_deref(), |
|
| 193 | - | meta_description: opt_str(&attrs.meta_description), |
|
| 194 | - | meta_image: opt_str(&attrs.meta_image), |
|
| 195 | - | lang, |
|
| 196 | - | tags: opt_str(&attrs.tags), |
|
| 197 | - | }; |
|
| 198 | - | match db::update_post(&state.db, &short_id, &input) { |
|
| 199 | - | Ok(Some(_)) => Redirect::to("/admin").into_response(), |
|
| 200 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(), |
|
| 201 | - | Err(e) => { |
|
| 202 | - | tracing::error!("Failed to update post: {}", e); |
|
| 203 | - | Redirect::to(&format!("/admin/posts/{}/edit?error=Failed+to+update", short_id)) |
|
| 204 | - | .into_response() |
|
| 205 | - | } |
|
| 206 | - | } |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | pub async fn admin_delete_post( |
|
| 210 | - | _session: auth::AuthSession, |
|
| 211 | - | State(state): State<Arc<AppState>>, |
|
| 212 | - | Path(short_id): Path<String>, |
|
| 213 | - | ) -> Response { |
|
| 214 | - | match db::delete_post(&state.db, &short_id) { |
|
| 215 | - | Ok(_) => Redirect::to("/admin").into_response(), |
|
| 216 | - | Err(e) => { |
|
| 217 | - | tracing::error!("Failed to delete post: {}", e); |
|
| 218 | - | Redirect::to("/admin").into_response() |
|
| 219 | - | } |
|
| 220 | - | } |
|
| 221 | - | } |
|
| 222 | - | ||
| 223 | - | pub async fn admin_toggle_publish( |
|
| 224 | - | _session: auth::AuthSession, |
|
| 225 | - | State(state): State<Arc<AppState>>, |
|
| 226 | - | Path(short_id): Path<String>, |
|
| 227 | - | ) -> Response { |
|
| 228 | - | match db::toggle_post_status(&state.db, &short_id) { |
|
| 229 | - | Ok(_) => Redirect::to("/admin").into_response(), |
|
| 230 | - | Err(e) => { |
|
| 231 | - | tracing::error!("Failed to toggle post status: {}", e); |
|
| 232 | - | Redirect::to("/admin").into_response() |
|
| 233 | - | } |
|
| 234 | - | } |
|
| 235 | - | } |
|
| 236 | - | ||
| 237 | - | // --- Admin page handlers --- |
|
| 238 | - | ||
| 239 | - | pub async fn admin_pages( |
|
| 240 | - | _session: auth::AuthSession, |
|
| 241 | - | State(state): State<Arc<AppState>>, |
|
| 242 | - | ) -> Response { |
|
| 243 | - | match db::get_all_pages(&state.db) { |
|
| 244 | - | Ok(pages) => WebTemplate(AdminPagesTemplate { pages }).into_response(), |
|
| 245 | - | Err(e) => { |
|
| 246 | - | tracing::error!("Failed to list pages: {}", e); |
|
| 247 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 248 | - | } |
|
| 249 | - | } |
|
| 250 | - | } |
|
| 251 | - | ||
| 252 | - | pub async fn admin_new_page( |
|
| 253 | - | _session: auth::AuthSession, |
|
| 254 | - | Query(q): Query<FlashQuery>, |
|
| 255 | - | ) -> Response { |
|
| 256 | - | WebTemplate(AdminPageFormTemplate { |
|
| 257 | - | page: None, |
|
| 258 | - | error: q.error, |
|
| 259 | - | }) |
|
| 260 | - | .into_response() |
|
| 261 | - | } |
|
| 262 | - | ||
| 263 | - | const RESERVED_PAGE_SLUGS: &[&str] = &[ |
|
| 264 | - | "posts", "admin", "feed.xml", "custom-styles.css", "static", "files", |
|
| 265 | - | ]; |
|
| 266 | - | ||
| 267 | - | fn is_reserved_page_slug(slug: &str) -> bool { |
|
| 268 | - | RESERVED_PAGE_SLUGS.contains(&slug) |
|
| 269 | - | } |
|
| 270 | - | ||
| 271 | - | pub async fn admin_create_page( |
|
| 272 | - | _session: auth::AuthSession, |
|
| 273 | - | State(state): State<Arc<AppState>>, |
|
| 274 | - | Form(form): Form<PageForm>, |
|
| 275 | - | ) -> Response { |
|
| 276 | - | let attrs = parse_page_attributes(&form.attributes); |
|
| 277 | - | let title = attrs.title.trim().to_string(); |
|
| 278 | - | let slug = attrs.slug.trim().to_string(); |
|
| 279 | - | if title.is_empty() || slug.is_empty() { |
|
| 280 | - | return Redirect::to("/admin/pages/new?error=Title+and+slug+are+required").into_response(); |
|
| 281 | - | } |
|
| 282 | - | if is_reserved_page_slug(&slug) { |
|
| 283 | - | return Redirect::to("/admin/pages/new?error=That+slug+is+reserved").into_response(); |
|
| 284 | - | } |
|
| 285 | - | ||
| 286 | - | match db::create_page(&state.db, &title, &slug, &form.content, attrs.is_published, 0) { |
|
| 287 | - | Ok(_) => Redirect::to("/admin/pages").into_response(), |
|
| 288 | - | Err(e) => { |
|
| 289 | - | tracing::error!("Failed to create page: {}", e); |
|
| 290 | - | Redirect::to("/admin/pages/new?error=Failed+to+create+page").into_response() |
|
| 291 | - | } |
|
| 292 | - | } |
|
| 293 | - | } |
|
| 294 | - | ||
| 295 | - | pub async fn admin_edit_page( |
|
| 296 | - | _session: auth::AuthSession, |
|
| 297 | - | State(state): State<Arc<AppState>>, |
|
| 298 | - | Path(short_id): Path<String>, |
|
| 299 | - | Query(q): Query<FlashQuery>, |
|
| 300 | - | ) -> Response { |
|
| 301 | - | match db::get_page_by_short_id(&state.db, &short_id) { |
|
| 302 | - | Ok(Some(page)) => WebTemplate(AdminPageFormTemplate { |
|
| 303 | - | page: Some(page), |
|
| 304 | - | error: q.error, |
|
| 305 | - | }) |
|
| 306 | - | .into_response(), |
|
| 307 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(), |
|
| 308 | - | Err(e) => { |
|
| 309 | - | tracing::error!("Failed to get page: {}", e); |
|
| 310 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 311 | - | } |
|
| 312 | - | } |
|
| 313 | - | } |
|
| 314 | - | ||
| 315 | - | pub async fn admin_update_page( |
|
| 316 | - | _session: auth::AuthSession, |
|
| 317 | - | State(state): State<Arc<AppState>>, |
|
| 318 | - | Path(short_id): Path<String>, |
|
| 319 | - | Form(form): Form<PageForm>, |
|
| 320 | - | ) -> Response { |
|
| 321 | - | let attrs = parse_page_attributes(&form.attributes); |
|
| 322 | - | let title = attrs.title.trim().to_string(); |
|
| 323 | - | let slug = attrs.slug.trim().to_string(); |
|
| 324 | - | if title.is_empty() || slug.is_empty() { |
|
| 325 | - | return Redirect::to(&format!( |
|
| 326 | - | "/admin/pages/{}/edit?error=Title+and+slug+are+required", |
|
| 327 | - | short_id |
|
| 328 | - | )) |
|
| 329 | - | .into_response(); |
|
| 330 | - | } |
|
| 331 | - | if is_reserved_page_slug(&slug) { |
|
| 332 | - | return Redirect::to(&format!( |
|
| 333 | - | "/admin/pages/{}/edit?error=That+slug+is+reserved", |
|
| 334 | - | short_id |
|
| 335 | - | )) |
|
| 336 | - | .into_response(); |
|
| 337 | - | } |
|
| 338 | - | ||
| 339 | - | match db::update_page(&state.db, &short_id, &title, &slug, &form.content, attrs.is_published, 0) { |
|
| 340 | - | Ok(Some(_)) => Redirect::to("/admin/pages").into_response(), |
|
| 341 | - | Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(), |
|
| 342 | - | Err(e) => { |
|
| 343 | - | tracing::error!("Failed to update page: {}", e); |
|
| 344 | - | Redirect::to(&format!("/admin/pages/{}/edit?error=Failed+to+update", short_id)) |
|
| 345 | - | .into_response() |
|
| 346 | - | } |
|
| 347 | - | } |
|
| 348 | - | } |
|
| 349 | - | ||
| 350 | - | pub async fn admin_delete_page( |
|
| 351 | - | _session: auth::AuthSession, |
|
| 352 | - | State(state): State<Arc<AppState>>, |
|
| 353 | - | Path(short_id): Path<String>, |
|
| 354 | - | ) -> Response { |
|
| 355 | - | match db::delete_page(&state.db, &short_id) { |
|
| 356 | - | Ok(_) => Redirect::to("/admin/pages").into_response(), |
|
| 357 | - | Err(e) => { |
|
| 358 | - | tracing::error!("Failed to delete page: {}", e); |
|
| 359 | - | Redirect::to("/admin/pages").into_response() |
|
| 360 | - | } |
|
| 361 | - | } |
|
| 362 | - | } |
|
| 363 | - | ||
| 364 | - | // --- Admin settings handlers --- |
|
| 365 | - | ||
| 366 | - | pub async fn admin_get_settings( |
|
| 367 | - | _session: auth::AuthSession, |
|
| 368 | - | State(state): State<Arc<AppState>>, |
|
| 369 | - | Query(q): Query<FlashQuery>, |
|
| 370 | - | ) -> Response { |
|
| 371 | - | let blog_title = get_setting_or_default(&state.db, "blog_title"); |
|
| 372 | - | let blog_description = get_setting_or_default(&state.db, "blog_description"); |
|
| 373 | - | let intro_content = get_setting_or_default(&state.db, "intro_content"); |
|
| 374 | - | let nav_links = get_setting_or_default(&state.db, "nav_links"); |
|
| 375 | - | let custom_css = get_setting_or_default(&state.db, "custom_css"); |
|
| 376 | - | let favicon_url = get_setting_or_default(&state.db, "favicon_url"); |
|
| 377 | - | let og_image_url = get_setting_or_default(&state.db, "og_image_url"); |
|
| 378 | - | let custom_header = get_setting_or_default(&state.db, "custom_header"); |
|
| 379 | - | let custom_footer = get_setting_or_default(&state.db, "custom_footer"); |
|
| 380 | - | let default_css = Static::get("styles.css") |
|
| 381 | - | .map(|f| String::from_utf8_lossy(&f.data).into_owned()) |
|
| 382 | - | .unwrap_or_default(); |
|
| 383 | - | ||
| 384 | - | WebTemplate(AdminSettingsTemplate { |
|
| 385 | - | blog_title, |
|
| 386 | - | blog_description, |
|
| 387 | - | intro_content, |
|
| 388 | - | nav_links, |
|
| 389 | - | custom_css, |
|
| 390 | - | default_css, |
|
| 391 | - | favicon_url, |
|
| 392 | - | og_image_url, |
|
| 393 | - | custom_header, |
|
| 394 | - | custom_footer, |
|
| 395 | - | success: q.success, |
|
| 396 | - | }) |
|
| 397 | - | .into_response() |
|
| 398 | - | } |
|
| 399 | - | ||
| 400 | - | pub async fn admin_post_settings( |
|
| 401 | - | _session: auth::AuthSession, |
|
| 402 | - | State(state): State<Arc<AppState>>, |
|
| 403 | - | Form(form): Form<SettingsForm>, |
|
| 404 | - | ) -> Response { |
|
| 405 | - | let _ = db::set_setting(&state.db, "blog_title", form.blog_title.trim()); |
|
| 406 | - | let _ = db::set_setting(&state.db, "blog_description", form.blog_description.trim()); |
|
| 407 | - | let _ = db::set_setting(&state.db, "intro_content", &form.intro_content); |
|
| 408 | - | let _ = db::set_setting(&state.db, "nav_links", &form.nav_links); |
|
| 409 | - | let _ = db::set_setting(&state.db, "custom_css", &form.custom_css); |
|
| 410 | - | let _ = db::set_setting(&state.db, "favicon_url", form.favicon_url.trim()); |
|
| 411 | - | let _ = db::set_setting(&state.db, "og_image_url", form.og_image_url.trim()); |
|
| 412 | - | let _ = db::set_setting(&state.db, "custom_header", &form.custom_header); |
|
| 413 | - | let _ = db::set_setting(&state.db, "custom_footer", &form.custom_footer); |
|
| 414 | - | Redirect::to("/admin/settings?success=true").into_response() |
|
| 415 | - | } |
|
| 416 | - | ||
| 417 | - | // --- Admin file handlers --- |
|
| 418 | - | ||
| 419 | - | pub async fn admin_files( |
|
| 420 | - | _session: auth::AuthSession, |
|
| 421 | - | State(state): State<Arc<AppState>>, |
|
| 422 | - | Query(q): Query<FlashQuery>, |
|
| 423 | - | ) -> Response { |
|
| 424 | - | match db::get_all_files(&state.db) { |
|
| 425 | - | Ok(files) => WebTemplate(AdminFilesTemplate { |
|
| 426 | - | files, |
|
| 427 | - | site_url: state.site_url.clone(), |
|
| 428 | - | error: q.error, |
|
| 429 | - | success: q.success, |
|
| 430 | - | }) |
|
| 431 | - | .into_response(), |
|
| 432 | - | Err(e) => { |
|
| 433 | - | tracing::error!("Failed to list files: {}", e); |
|
| 434 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 435 | - | } |
|
| 436 | - | } |
|
| 437 | - | } |
|
| 438 | - | ||
| 439 | - | pub async fn admin_upload_file( |
|
| 440 | - | _session: auth::AuthSession, |
|
| 441 | - | State(state): State<Arc<AppState>>, |
|
| 442 | - | mut multipart: Multipart, |
|
| 443 | - | ) -> Response { |
|
| 444 | - | let mut file_data: Option<(String, String, Vec<u8>)> = None; |
|
| 445 | - | ||
| 446 | - | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 447 | - | if field.name() == Some("file") { |
|
| 448 | - | let original_name = field |
|
| 449 | - | .file_name() |
|
| 450 | - | .unwrap_or("upload") |
|
| 451 | - | .to_string(); |
|
| 452 | - | let content_type = field |
|
| 453 | - | .content_type() |
|
| 454 | - | .unwrap_or("application/octet-stream") |
|
| 455 | - | .to_string(); |
|
| 456 | - | match field.bytes().await { |
|
| 457 | - | Ok(bytes) => { |
|
| 458 | - | file_data = Some((original_name, content_type, bytes.to_vec())); |
|
| 459 | - | } |
|
| 460 | - | Err(e) => { |
|
| 461 | - | tracing::error!("Failed to read upload: {}", e); |
|
| 462 | - | return Redirect::to("/admin/files?error=Failed+to+read+upload").into_response(); |
|
| 463 | - | } |
|
| 464 | - | } |
|
| 465 | - | } |
|
| 466 | - | } |
|
| 467 | - | ||
| 468 | - | let (original_name, content_type, bytes) = match file_data { |
|
| 469 | - | Some(d) => d, |
|
| 470 | - | None => return Redirect::to("/admin/files?error=No+file+provided").into_response(), |
|
| 471 | - | }; |
|
| 472 | - | ||
| 473 | - | let max_size: usize = 10 * 1024 * 1024; |
|
| 474 | - | if bytes.len() > max_size { |
|
| 475 | - | return Redirect::to("/admin/files?error=File+exceeds+10MB+limit").into_response(); |
|
| 476 | - | } |
|
| 477 | - | ||
| 478 | - | let ext = original_name |
|
| 479 | - | .rsplit('.') |
|
| 480 | - | .next() |
|
| 481 | - | .filter(|e| !e.is_empty() && *e != original_name) |
|
| 482 | - | .unwrap_or(""); |
|
| 483 | - | let id = nanoid::nanoid!(10); |
|
| 484 | - | let stored_name = if ext.is_empty() { |
|
| 485 | - | id |
|
| 486 | - | } else { |
|
| 487 | - | format!("{}.{}", id, ext) |
|
| 488 | - | }; |
|
| 489 | - | ||
| 490 | - | let backend = if let Some(r2) = &state.r2 { |
|
| 491 | - | if let Err(e) = r2.put_object(&stored_name, &content_type, bytes.clone()).await { |
|
| 492 | - | tracing::error!("Failed to upload to R2: {}", e); |
|
| 493 | - | return Redirect::to("/admin/files?error=Failed+to+save+file").into_response(); |
|
| 494 | - | } |
|
| 495 | - | "r2" |
|
| 496 | - | } else { |
|
| 497 | - | let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name); |
|
| 498 | - | if let Err(e) = tokio::fs::write(&path, &bytes).await { |
|
| 499 | - | tracing::error!("Failed to write file: {}", e); |
|
| 500 | - | return Redirect::to("/admin/files?error=Failed+to+save+file").into_response(); |
|
| 501 | - | } |
|
| 502 | - | "local" |
|
| 503 | - | }; |
|
| 504 | - | ||
| 505 | - | match db::create_file(&state.db, &stored_name, &original_name, &content_type, bytes.len() as i64, backend) { |
|
| 506 | - | Ok(_) => Redirect::to("/admin/files?success=true").into_response(), |
|
| 507 | - | Err(e) => { |
|
| 508 | - | tracing::error!("Failed to record file: {}", e); |
|
| 509 | - | if backend == "r2" { |
|
| 510 | - | if let Some(r2) = &state.r2 { |
|
| 511 | - | if let Err(e) = r2.delete_object(&stored_name).await { |
|
| 512 | - | tracing::warn!("Failed to roll back R2 upload: {}", e); |
|
| 513 | - | } |
|
| 514 | - | } |
|
| 515 | - | } else { |
|
| 516 | - | let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name); |
|
| 517 | - | let _ = tokio::fs::remove_file(&path).await; |
|
| 518 | - | } |
|
| 519 | - | Redirect::to("/admin/files?error=Failed+to+record+file").into_response() |
|
| 520 | - | } |
|
| 521 | - | } |
|
| 522 | - | } |
|
| 523 | - | ||
| 524 | - | pub async fn admin_delete_file( |
|
| 525 | - | _session: auth::AuthSession, |
|
| 526 | - | State(state): State<Arc<AppState>>, |
|
| 527 | - | Path(short_id): Path<String>, |
|
| 528 | - | ) -> Response { |
|
| 529 | - | match db::delete_file(&state.db, &short_id) { |
|
| 530 | - | Ok(Some(file)) => { |
|
| 531 | - | if file.storage_backend == "r2" { |
|
| 532 | - | if let Some(r2) = &state.r2 { |
|
| 533 | - | if let Err(e) = r2.delete_object(&file.filename).await { |
|
| 534 | - | tracing::warn!("Failed to delete file from R2: {}", e); |
|
| 535 | - | } |
|
| 536 | - | } else { |
|
| 537 | - | tracing::warn!( |
|
| 538 | - | "File {} stored in R2 but R2 not configured; skipping remote delete", |
|
| 539 | - | file.filename |
|
| 540 | - | ); |
|
| 541 | - | } |
|
| 542 | - | } else { |
|
| 543 | - | let path = std::path::PathBuf::from(&state.uploads_dir).join(&file.filename); |
|
| 544 | - | if let Err(e) = tokio::fs::remove_file(&path).await { |
|
| 545 | - | tracing::warn!("Failed to delete file from disk: {}", e); |
|
| 546 | - | } |
|
| 547 | - | } |
|
| 548 | - | Redirect::to("/admin/files").into_response() |
|
| 549 | - | } |
|
| 550 | - | Ok(None) => Redirect::to("/admin/files").into_response(), |
|
| 551 | - | Err(e) => { |
|
| 552 | - | tracing::error!("Failed to delete file: {}", e); |
|
| 553 | - | Redirect::to("/admin/files").into_response() |
|
| 554 | - | } |
|
| 555 | - | } |
|
| 556 | - | } |
|
| 557 | - | ||
| 558 | - | // --- Download/export handlers --- |
|
| 559 | - | ||
| 560 | - | pub async fn admin_download_posts( |
|
| 561 | - | _session: auth::AuthSession, |
|
| 562 | - | State(state): State<Arc<AppState>>, |
|
| 563 | - | ) -> Response { |
|
| 564 | - | let posts = match db::get_all_posts(&state.db) { |
|
| 565 | - | Ok(posts) => posts, |
|
| 566 | - | Err(e) => { |
|
| 567 | - | tracing::error!("Failed to get posts for export: {}", e); |
|
| 568 | - | return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response(); |
|
| 569 | - | } |
|
| 570 | - | }; |
|
| 571 | - | ||
| 572 | - | let result = tokio::task::spawn_blocking(move || { |
|
| 573 | - | let markdown_files: Vec<_> = posts |
|
| 574 | - | .iter() |
|
| 575 | - | .map(|p| (format!("{}.md", p.slug), post_to_markdown(p))) |
|
| 576 | - | .collect(); |
|
| 577 | - | let entries: Vec<_> = markdown_files |
|
| 578 | - | .iter() |
|
| 579 | - | .map(|(name, content)| (name.clone(), content.as_bytes())) |
|
| 580 | - | .collect(); |
|
| 581 | - | build_zip(&entries, zip::CompressionMethod::Deflated) |
|
| 582 | - | }) |
|
| 583 | - | .await; |
|
| 584 | - | ||
| 585 | - | match result { |
|
| 586 | - | Ok(bytes) => zip_response(bytes, "posts.zip"), |
|
| 587 | - | Err(e) => { |
|
| 588 | - | tracing::error!("Failed to create posts zip: {}", e); |
|
| 589 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response() |
|
| 590 | - | } |
|
| 591 | - | } |
|
| 592 | - | } |
|
| 593 | - | ||
| 594 | - | pub async fn admin_download_uploads( |
|
| 595 | - | _session: auth::AuthSession, |
|
| 596 | - | State(state): State<Arc<AppState>>, |
|
| 597 | - | ) -> Response { |
|
| 598 | - | let files = match db::get_all_files(&state.db) { |
|
| 599 | - | Ok(files) => files, |
|
| 600 | - | Err(e) => { |
|
| 601 | - | tracing::error!("Failed to get files for export: {}", e); |
|
| 602 | - | return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response(); |
|
| 603 | - | } |
|
| 604 | - | }; |
|
| 605 | - | ||
| 606 | - | let uploads_dir = state.uploads_dir.clone(); |
|
| 607 | - | let mut file_data: Vec<(String, Vec<u8>)> = Vec::new(); |
|
| 608 | - | let mut seen_names = std::collections::HashSet::new(); |
|
| 609 | - | for file in &files { |
|
| 610 | - | let path = std::path::PathBuf::from(&uploads_dir).join(&file.filename); |
|
| 611 | - | match tokio::fs::read(&path).await { |
|
| 612 | - | Ok(bytes) => { |
|
| 613 | - | let name = if seen_names.contains(&file.original_name) { |
|
| 614 | - | format!("{}_{}", file.short_id, file.original_name) |
|
| 615 | - | } else { |
|
| 616 | - | file.original_name.clone() |
|
| 617 | - | }; |
|
| 618 | - | seen_names.insert(file.original_name.clone()); |
|
| 619 | - | file_data.push((name, bytes)); |
|
| 620 | - | } |
|
| 621 | - | Err(e) => { |
|
| 622 | - | tracing::warn!("Skipping file {} ({}): {}", file.original_name, file.filename, e); |
|
| 623 | - | } |
|
| 624 | - | } |
|
| 625 | - | } |
|
| 626 | - | ||
| 627 | - | let result = tokio::task::spawn_blocking(move || { |
|
| 628 | - | let entries: Vec<_> = file_data |
|
| 629 | - | .iter() |
|
| 630 | - | .map(|(name, bytes)| (name.clone(), bytes.as_slice())) |
|
| 631 | - | .collect(); |
|
| 632 | - | build_zip(&entries, zip::CompressionMethod::Stored) |
|
| 633 | - | }) |
|
| 634 | - | .await; |
|
| 635 | - | ||
| 636 | - | match result { |
|
| 637 | - | Ok(bytes) => zip_response(bytes, "uploads.zip"), |
|
| 638 | - | Err(e) => { |
|
| 639 | - | tracing::error!("Failed to create uploads zip: {}", e); |
|
| 640 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response() |
|
| 641 | - | } |
|
| 642 | - | } |
|
| 643 | - | } |
|
| 644 | - | ||
| 645 | - | // --- Import handlers --- |
|
| 646 | - | ||
| 647 | - | const IMPORT_MAX_BYTES: usize = 50 * 1024 * 1024; |
|
| 648 | - | ||
| 649 | - | pub async fn admin_import_form( |
|
| 650 | - | _session: auth::AuthSession, |
|
| 651 | - | Query(q): Query<FlashQuery>, |
|
| 652 | - | ) -> Response { |
|
| 653 | - | WebTemplate(AdminImportTemplate { |
|
| 654 | - | error: q.error, |
|
| 655 | - | imported: q.imported, |
|
| 656 | - | skipped: q.skipped, |
|
| 657 | - | }) |
|
| 658 | - | .into_response() |
|
| 659 | - | } |
|
| 660 | - | ||
| 661 | - | pub async fn admin_import_posts( |
|
| 662 | - | _session: auth::AuthSession, |
|
| 663 | - | State(state): State<Arc<AppState>>, |
|
| 664 | - | mut multipart: Multipart, |
|
| 665 | - | ) -> Response { |
|
| 666 | - | let mut zip_data: Option<Vec<u8>> = None; |
|
| 667 | - | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 668 | - | if field.name() == Some("zip") { |
|
| 669 | - | match field.bytes().await { |
|
| 670 | - | Ok(bytes) => zip_data = Some(bytes.to_vec()), |
|
| 671 | - | Err(e) => { |
|
| 672 | - | tracing::error!("Failed to read import upload: {}", e); |
|
| 673 | - | return Redirect::to("/admin/import?error=Failed+to+read+upload").into_response(); |
|
| 674 | - | } |
|
| 675 | - | } |
|
| 676 | - | } |
|
| 677 | - | } |
|
| 678 | - | ||
| 679 | - | let bytes = match zip_data { |
|
| 680 | - | Some(b) => b, |
|
| 681 | - | None => return Redirect::to("/admin/import?error=No+zip+provided").into_response(), |
|
| 682 | - | }; |
|
| 683 | - | if bytes.len() > IMPORT_MAX_BYTES { |
|
| 684 | - | return Redirect::to("/admin/import?error=Zip+exceeds+50MB+limit").into_response(); |
|
| 685 | - | } |
|
| 686 | - | ||
| 687 | - | let db = state.db.clone(); |
|
| 688 | - | let result = tokio::task::spawn_blocking(move || process_import_zip(&db, &bytes)).await; |
|
| 689 | - | ||
| 690 | - | match result { |
|
| 691 | - | Ok(Ok(summary)) => Redirect::to(&format!( |
|
| 692 | - | "/admin/import?imported={}&skipped={}", |
|
| 693 | - | summary.imported, summary.skipped |
|
| 694 | - | )) |
|
| 695 | - | .into_response(), |
|
| 696 | - | Ok(Err(e)) => { |
|
| 697 | - | tracing::error!("Import failed: {}", e); |
|
| 698 | - | Redirect::to("/admin/import?error=Invalid+zip+archive").into_response() |
|
| 699 | - | } |
|
| 700 | - | Err(e) => { |
|
| 701 | - | tracing::error!("Import join error: {}", e); |
|
| 702 | - | Redirect::to("/admin/import?error=Server+error").into_response() |
|
| 703 | - | } |
|
| 704 | - | } |
|
| 705 | - | } |
|
| 706 | - | ||
| 707 | - | struct ImportSummary { |
|
| 708 | - | imported: u32, |
|
| 709 | - | skipped: u32, |
|
| 710 | - | } |
|
| 711 | - | ||
| 712 | - | fn process_import_zip(db: &db::Db, bytes: &[u8]) -> Result<ImportSummary, String> { |
|
| 713 | - | let mut archive = ZipArchive::new(Cursor::new(bytes)) |
|
| 714 | - | .map_err(|e| format!("Bad zip: {}", e))?; |
|
| 715 | - | ||
| 716 | - | let mut imported = 0u32; |
|
| 717 | - | let mut skipped = 0u32; |
|
| 718 | - | ||
| 719 | - | for i in 0..archive.len() { |
|
| 720 | - | let mut file = match archive.by_index(i) { |
|
| 721 | - | Ok(f) => f, |
|
| 722 | - | Err(e) => { |
|
| 723 | - | tracing::warn!("Skipping zip entry {}: {}", i, e); |
|
| 724 | - | continue; |
|
| 725 | - | } |
|
| 726 | - | }; |
|
| 727 | - | if file.is_dir() { |
|
| 728 | - | continue; |
|
| 729 | - | } |
|
| 730 | - | let name = match file.enclosed_name() { |
|
| 731 | - | Some(p) => p.to_string_lossy().into_owned(), |
|
| 732 | - | None => continue, |
|
| 733 | - | }; |
|
| 734 | - | if name.starts_with("__MACOSX/") { |
|
| 735 | - | continue; |
|
| 736 | - | } |
|
| 737 | - | let basename = std::path::Path::new(&name) |
|
| 738 | - | .file_name() |
|
| 739 | - | .and_then(|s| s.to_str()) |
|
| 740 | - | .unwrap_or(""); |
|
| 741 | - | if basename.is_empty() || basename.starts_with('.') { |
|
| 742 | - | continue; |
|
| 743 | - | } |
|
| 744 | - | let lower = basename.to_lowercase(); |
|
| 745 | - | if !(lower.ends_with(".md") || lower.ends_with(".markdown")) { |
|
| 746 | - | continue; |
|
| 747 | - | } |
|
| 748 | - | ||
| 749 | - | let mut raw = String::new(); |
|
| 750 | - | if let Err(e) = file.read_to_string(&mut raw) { |
|
| 751 | - | tracing::warn!("Skipping {}: read error {}", name, e); |
|
| 752 | - | continue; |
|
| 753 | - | } |
|
| 754 | - | ||
| 755 | - | if !import_one(db, basename, &raw, &mut imported, &mut skipped) { |
|
| 756 | - | skipped += 1; |
|
| 757 | - | } |
|
| 758 | - | } |
|
| 759 | - | ||
| 760 | - | Ok(ImportSummary { imported, skipped }) |
|
| 761 | - | } |
|
| 762 | - | ||
| 763 | - | fn import_one( |
|
| 764 | - | db: &db::Db, |
|
| 765 | - | basename: &str, |
|
| 766 | - | raw: &str, |
|
| 767 | - | imported: &mut u32, |
|
| 768 | - | skipped: &mut u32, |
|
| 769 | - | ) -> bool { |
|
| 770 | - | let (frontmatter, body) = split_frontmatter(raw); |
|
| 771 | - | let attrs = parse_attributes(frontmatter.unwrap_or("")); |
|
| 772 | - | ||
| 773 | - | let title = if attrs.title.trim().is_empty() { |
|
| 774 | - | title_from_filename(basename) |
|
| 775 | - | } else { |
|
| 776 | - | attrs.title.trim().to_string() |
|
| 777 | - | }; |
|
| 778 | - | ||
| 779 | - | let slug = derive_slug(&title, attrs.slug.trim()); |
|
| 780 | - | if slug.is_empty() { |
|
| 781 | - | return false; |
|
| 782 | - | } |
|
| 783 | - | ||
| 784 | - | match db::get_post_by_slug(db, &slug) { |
|
| 785 | - | Ok(Some(_)) => { |
|
| 786 | - | *skipped += 1; |
|
| 787 | - | return true; |
|
| 788 | - | } |
|
| 789 | - | Ok(None) => {} |
|
| 790 | - | Err(e) => { |
|
| 791 | - | tracing::warn!("DB error checking slug {}: {}", slug, e); |
|
| 792 | - | return false; |
|
| 793 | - | } |
|
| 794 | - | } |
|
| 795 | - | ||
| 796 | - | let status = if attrs.status.trim().eq_ignore_ascii_case("published") { |
|
| 797 | - | "published" |
|
| 798 | - | } else { |
|
| 799 | - | "draft" |
|
| 800 | - | }; |
|
| 801 | - | let lang = if attrs.lang.trim().is_empty() { |
|
| 802 | - | "en" |
|
| 803 | - | } else { |
|
| 804 | - | attrs.lang.trim() |
|
| 805 | - | }; |
|
| 806 | - | let published_date = if attrs.published_date.trim().is_empty() { |
|
| 807 | - | now_datetime() |
|
| 808 | - | } else { |
|
| 809 | - | attrs.published_date.trim().to_string() |
|
| 810 | - | }; |
|
| 811 | - | ||
| 812 | - | let input = db::PostInput { |
|
| 813 | - | title: opt_str(&title), |
|
| 814 | - | slug: &slug, |
|
| 815 | - | content: body, |
|
| 816 | - | status, |
|
| 817 | - | alias: opt_str(&attrs.alias), |
|
| 818 | - | canonical_url: None, |
|
| 819 | - | published_date: Some(&published_date), |
|
| 820 | - | meta_description: opt_str(&attrs.meta_description), |
|
| 821 | - | meta_image: opt_str(&attrs.meta_image), |
|
| 822 | - | lang, |
|
| 823 | - | tags: opt_str(&attrs.tags), |
|
| 824 | - | }; |
|
| 825 | - | match db::create_post(db, &input) { |
|
| 826 | - | Ok(_) => { |
|
| 827 | - | *imported += 1; |
|
| 828 | - | true |
|
| 829 | - | } |
|
| 830 | - | Err(e) => { |
|
| 831 | - | tracing::warn!("Failed to insert {}: {}", slug, e); |
|
| 832 | - | false |
|
| 833 | - | } |
|
| 834 | - | } |
|
| 835 | - | } |
|
| 836 | - | ||
| 837 | - | fn split_frontmatter(content: &str) -> (Option<&str>, &str) { |
|
| 838 | - | let trimmed = content.trim_start_matches('\u{feff}'); |
|
| 839 | - | let after_open = if let Some(rest) = trimmed.strip_prefix("---\n") { |
|
| 840 | - | rest |
|
| 841 | - | } else if let Some(rest) = trimmed.strip_prefix("---\r\n") { |
|
| 842 | - | rest |
|
| 843 | - | } else { |
|
| 844 | - | return (None, content); |
|
| 845 | - | }; |
|
| 846 | - | for sep in ["\r\n---\r\n", "\r\n---\n", "\n---\r\n", "\n---\n"] { |
|
| 847 | - | if let Some((fm, rest)) = after_open.split_once(sep) { |
|
| 848 | - | let body = rest.trim_start_matches(['\r', '\n']); |
|
| 849 | - | return (Some(fm), body); |
|
| 850 | - | } |
|
| 851 | - | } |
|
| 852 | - | if let Some(fm) = after_open.strip_suffix("\n---").or_else(|| after_open.strip_suffix("\r\n---")) { |
|
| 853 | - | return (Some(fm), ""); |
|
| 854 | - | } |
|
| 855 | - | (None, content) |
|
| 856 | - | } |
|
| 857 | - | ||
| 858 | - | fn title_from_filename(name: &str) -> String { |
|
| 859 | - | let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name); |
|
| 860 | - | let cleaned: String = stem |
|
| 861 | - | .chars() |
|
| 862 | - | .map(|c| if c == '-' || c == '_' { ' ' } else { c }) |
|
| 863 | - | .collect(); |
|
| 864 | - | let trimmed = cleaned.trim(); |
|
| 865 | - | let mut chars = trimmed.chars(); |
|
| 866 | - | match chars.next() { |
|
| 867 | - | Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), |
|
| 868 | - | None => String::new(), |
|
| 869 | - | } |
|
| 870 | - | } |
|
| 871 | - | ||
| 872 | - | #[cfg(test)] |
|
| 873 | - | mod tests { |
|
| 874 | - | use super::*; |
|
| 875 | - | ||
| 876 | - | #[test] |
|
| 877 | - | fn split_frontmatter_basic() { |
|
| 878 | - | let input = "---\ntitle: Hello\nslug: hello\n---\n# Body\n"; |
|
| 879 | - | let (fm, body) = split_frontmatter(input); |
|
| 880 | - | assert_eq!(fm, Some("title: Hello\nslug: hello")); |
|
| 881 | - | assert_eq!(body, "# Body\n"); |
|
| 882 | - | } |
|
| 883 | - | ||
| 884 | - | #[test] |
|
| 885 | - | fn split_frontmatter_crlf() { |
|
| 886 | - | let input = "---\r\ntitle: Hi\r\n---\r\nbody\r\n"; |
|
| 887 | - | let (fm, body) = split_frontmatter(input); |
|
| 888 | - | assert_eq!(fm, Some("title: Hi")); |
|
| 889 | - | assert_eq!(body, "body\r\n"); |
|
| 890 | - | } |
|
| 891 | - | ||
| 892 | - | #[test] |
|
| 893 | - | fn split_frontmatter_no_fence() { |
|
| 894 | - | let (fm, body) = split_frontmatter("# Just markdown\n\ncontent"); |
|
| 895 | - | assert!(fm.is_none()); |
|
| 896 | - | assert_eq!(body, "# Just markdown\n\ncontent"); |
|
| 897 | - | } |
|
| 898 | - | ||
| 899 | - | #[test] |
|
| 900 | - | fn split_frontmatter_strips_bom() { |
|
| 901 | - | let input = "\u{feff}---\ntitle: Hi\n---\nbody"; |
|
| 902 | - | let (fm, body) = split_frontmatter(input); |
|
| 903 | - | assert_eq!(fm, Some("title: Hi")); |
|
| 904 | - | assert_eq!(body, "body"); |
|
| 905 | - | } |
|
| 906 | - | ||
| 907 | - | #[test] |
|
| 908 | - | fn title_from_filename_replaces_separators() { |
|
| 909 | - | assert_eq!(title_from_filename("my-cool-post.md"), "My cool post"); |
|
| 910 | - | assert_eq!(title_from_filename("hello_world.markdown"), "Hello world"); |
|
| 911 | - | assert_eq!(title_from_filename("noext"), "Noext"); |
|
| 912 | - | } |
|
| 913 | - | ||
| 914 | - | #[test] |
|
| 915 | - | fn parse_attributes_picks_up_status() { |
|
| 916 | - | let attrs = parse_attributes("title: T\nstatus: published\n"); |
|
| 917 | - | assert_eq!(attrs.title, "T"); |
|
| 918 | - | assert_eq!(attrs.status, "published"); |
|
| 919 | - | } |
|
| 920 | - | } |
| 1 | - | use axum::{ |
|
| 2 | - | Json, |
|
| 3 | - | extract::{Path, Query, State}, |
|
| 4 | - | http::StatusCode, |
|
| 5 | - | response::{IntoResponse, Response}, |
|
| 6 | - | }; |
|
| 7 | - | use serde::{Deserialize, Serialize}; |
|
| 8 | - | use serde_json::json; |
|
| 9 | - | use std::sync::Arc; |
|
| 10 | - | ||
| 11 | - | const DEFAULT_LIST_LIMIT: i64 = 30; |
|
| 12 | - | ||
| 13 | - | #[derive(Deserialize)] |
|
| 14 | - | pub struct ListPostsQuery { |
|
| 15 | - | limit: Option<i64>, |
|
| 16 | - | } |
|
| 17 | - | ||
| 18 | - | use super::super::*; |
|
| 19 | - | use crate::db; |
|
| 20 | - | ||
| 21 | - | #[derive(Serialize)] |
|
| 22 | - | struct ApiPostSummary { |
|
| 23 | - | short_id: String, |
|
| 24 | - | title: Option<String>, |
|
| 25 | - | slug: String, |
|
| 26 | - | published_date: Option<String>, |
|
| 27 | - | meta_description: Option<String>, |
|
| 28 | - | meta_image: Option<String>, |
|
| 29 | - | canonical_url: Option<String>, |
|
| 30 | - | lang: String, |
|
| 31 | - | tags: Option<String>, |
|
| 32 | - | content: String, |
|
| 33 | - | created_at: String, |
|
| 34 | - | updated_at: String, |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | #[derive(Serialize)] |
|
| 38 | - | struct ApiPostDetail { |
|
| 39 | - | short_id: String, |
|
| 40 | - | title: Option<String>, |
|
| 41 | - | slug: String, |
|
| 42 | - | alias: Option<String>, |
|
| 43 | - | canonical_url: Option<String>, |
|
| 44 | - | published_date: Option<String>, |
|
| 45 | - | meta_description: Option<String>, |
|
| 46 | - | meta_image: Option<String>, |
|
| 47 | - | lang: String, |
|
| 48 | - | tags: Option<String>, |
|
| 49 | - | content: String, |
|
| 50 | - | created_at: String, |
|
| 51 | - | updated_at: String, |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | #[derive(Serialize)] |
|
| 55 | - | struct ApiPostsList { |
|
| 56 | - | posts: Vec<ApiPostSummary>, |
|
| 57 | - | } |
|
| 58 | - | ||
| 59 | - | impl From<Post> for ApiPostSummary { |
|
| 60 | - | fn from(p: Post) -> Self { |
|
| 61 | - | Self { |
|
| 62 | - | short_id: p.short_id, |
|
| 63 | - | title: p.title, |
|
| 64 | - | slug: p.slug, |
|
| 65 | - | published_date: p.published_date, |
|
| 66 | - | meta_description: p.meta_description, |
|
| 67 | - | meta_image: p.meta_image, |
|
| 68 | - | canonical_url: p.canonical_url, |
|
| 69 | - | lang: p.lang, |
|
| 70 | - | tags: p.tags, |
|
| 71 | - | content: p.content, |
|
| 72 | - | created_at: p.created_at, |
|
| 73 | - | updated_at: p.updated_at, |
|
| 74 | - | } |
|
| 75 | - | } |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | impl From<Post> for ApiPostDetail { |
|
| 79 | - | fn from(p: Post) -> Self { |
|
| 80 | - | Self { |
|
| 81 | - | short_id: p.short_id, |
|
| 82 | - | title: p.title, |
|
| 83 | - | slug: p.slug, |
|
| 84 | - | alias: p.alias, |
|
| 85 | - | canonical_url: p.canonical_url, |
|
| 86 | - | published_date: p.published_date, |
|
| 87 | - | meta_description: p.meta_description, |
|
| 88 | - | meta_image: p.meta_image, |
|
| 89 | - | lang: p.lang, |
|
| 90 | - | tags: p.tags, |
|
| 91 | - | content: p.content, |
|
| 92 | - | created_at: p.created_at, |
|
| 93 | - | updated_at: p.updated_at, |
|
| 94 | - | } |
|
| 95 | - | } |
|
| 96 | - | } |
|
| 97 | - | ||
| 98 | - | pub async fn list_posts( |
|
| 99 | - | State(state): State<Arc<AppState>>, |
|
| 100 | - | Query(params): Query<ListPostsQuery>, |
|
| 101 | - | ) -> Response { |
|
| 102 | - | let limit = params.limit.unwrap_or(DEFAULT_LIST_LIMIT).max(0); |
|
| 103 | - | match db::get_published_posts(&state.db, Some(limit)) { |
|
| 104 | - | Ok(posts) => { |
|
| 105 | - | let posts = posts.into_iter().map(ApiPostSummary::from).collect(); |
|
| 106 | - | Json(ApiPostsList { posts }).into_response() |
|
| 107 | - | } |
|
| 108 | - | Err(e) => { |
|
| 109 | - | tracing::error!("Failed to list posts for API: {}", e); |
|
| 110 | - | ( |
|
| 111 | - | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 112 | - | Json(json!({ "error": "internal server error" })), |
|
| 113 | - | ) |
|
| 114 | - | .into_response() |
|
| 115 | - | } |
|
| 116 | - | } |
|
| 117 | - | } |
|
| 118 | - | ||
| 119 | - | pub async fn get_post( |
|
| 120 | - | State(state): State<Arc<AppState>>, |
|
| 121 | - | Path(slug): Path<String>, |
|
| 122 | - | ) -> Response { |
|
| 123 | - | match db::get_post_by_slug(&state.db, &slug) { |
|
| 124 | - | Ok(Some(post)) if post.status == "published" => { |
|
| 125 | - | Json(ApiPostDetail::from(post)).into_response() |
|
| 126 | - | } |
|
| 127 | - | Ok(_) => ( |
|
| 128 | - | StatusCode::NOT_FOUND, |
|
| 129 | - | Json(json!({ "error": "not found" })), |
|
| 130 | - | ) |
|
| 131 | - | .into_response(), |
|
| 132 | - | Err(e) => { |
|
| 133 | - | tracing::error!("Failed to get post for API: {}", e); |
|
| 134 | - | ( |
|
| 135 | - | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 136 | - | Json(json!({ "error": "internal server error" })), |
|
| 137 | - | ) |
|
| 138 | - | .into_response() |
|
| 139 | - | } |
|
| 140 | - | } |
|
| 141 | - | } |
| 1 | - | pub mod admin; |
|
| 2 | - | pub mod api; |
|
| 3 | - | pub mod public; |
| 1 | - | use askama_web::WebTemplate; |
|
| 2 | - | use axum::{ |
|
| 3 | - | extract::{Path, State}, |
|
| 4 | - | http::{HeaderValue, StatusCode, Uri}, |
|
| 5 | - | response::{Html, IntoResponse, Redirect, Response}, |
|
| 6 | - | }; |
|
| 7 | - | use std::sync::Arc; |
|
| 8 | - | ||
| 9 | - | use super::super::*; |
|
| 10 | - | use crate::db; |
|
| 11 | - | ||
| 12 | - | pub async fn serve_static(Path(path): Path<String>) -> Response { |
|
| 13 | - | match Static::get(&path) { |
|
| 14 | - | Some(file) => { |
|
| 15 | - | let mime = mime_from_path(&path); |
|
| 16 | - | ( |
|
| 17 | - | StatusCode::OK, |
|
| 18 | - | [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))], |
|
| 19 | - | file.data.to_vec(), |
|
| 20 | - | ) |
|
| 21 | - | .into_response() |
|
| 22 | - | } |
|
| 23 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 24 | - | } |
|
| 25 | - | } |
|
| 26 | - | ||
| 27 | - | pub async fn public_index(State(state): State<Arc<AppState>>) -> Response { |
|
| 28 | - | let ctx = SiteContext::from_state(&state); |
|
| 29 | - | let blog_description = get_setting_or_default(&state.db, "blog_description"); |
|
| 30 | - | let intro_content = get_setting_or_default(&state.db, "intro_content"); |
|
| 31 | - | ||
| 32 | - | match db::get_published_posts(&state.db, None) { |
|
| 33 | - | Ok(posts) => { |
|
| 34 | - | let mut intro_html = render_markdown(&intro_content); |
|
| 35 | - | ||
| 36 | - | if intro_content.contains("{{latest_posts}}") { |
|
| 37 | - | let latest: Vec<&Post> = posts.iter().take(5).collect(); |
|
| 38 | - | let embed_html = render_latest_posts_embed(&latest); |
|
| 39 | - | intro_html = intro_html.replace("<p>{{latest_posts}}</p>", &embed_html); |
|
| 40 | - | intro_html = intro_html.replace("{{latest_posts}}", &embed_html); |
|
| 41 | - | } |
|
| 42 | - | ||
| 43 | - | WebTemplate(IndexTemplate { |
|
| 44 | - | blog_title: ctx.blog_title, |
|
| 45 | - | blog_description, |
|
| 46 | - | intro_html, |
|
| 47 | - | posts, |
|
| 48 | - | nav_links: ctx.nav_links, |
|
| 49 | - | favicon_url: ctx.favicon_url, |
|
| 50 | - | og_image_url: ctx.og_image_url, |
|
| 51 | - | site_url: ctx.site_url, |
|
| 52 | - | header_html: ctx.header_html, |
|
| 53 | - | footer_html: ctx.footer_html, |
|
| 54 | - | }) |
|
| 55 | - | .into_response() |
|
| 56 | - | } |
|
| 57 | - | Err(e) => { |
|
| 58 | - | tracing::error!("Failed to list posts: {}", e); |
|
| 59 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 60 | - | } |
|
| 61 | - | } |
|
| 62 | - | } |
|
| 63 | - | ||
| 64 | - | pub async fn public_post( |
|
| 65 | - | State(state): State<Arc<AppState>>, |
|
| 66 | - | Path(slug): Path<String>, |
|
| 67 | - | ) -> Response { |
|
| 68 | - | match db::get_post_by_slug(&state.db, &slug) { |
|
| 69 | - | Ok(Some(post)) if post.status == "published" => { |
|
| 70 | - | let ctx = SiteContext::from_state(&state); |
|
| 71 | - | let rendered_content = render_markdown(&post.content); |
|
| 72 | - | WebTemplate(PostTemplate { |
|
| 73 | - | blog_title: ctx.blog_title, |
|
| 74 | - | nav_links: ctx.nav_links, |
|
| 75 | - | post, |
|
| 76 | - | rendered_content, |
|
| 77 | - | favicon_url: ctx.favicon_url, |
|
| 78 | - | og_image_url: ctx.og_image_url, |
|
| 79 | - | site_url: ctx.site_url, |
|
| 80 | - | header_html: ctx.header_html, |
|
| 81 | - | footer_html: ctx.footer_html, |
|
| 82 | - | }) |
|
| 83 | - | .into_response() |
|
| 84 | - | } |
|
| 85 | - | Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(), |
|
| 86 | - | Err(e) => { |
|
| 87 | - | tracing::error!("Failed to get post: {}", e); |
|
| 88 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 89 | - | } |
|
| 90 | - | } |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | pub async fn public_page( |
|
| 94 | - | State(state): State<Arc<AppState>>, |
|
| 95 | - | Path(slug): Path<String>, |
|
| 96 | - | ) -> Response { |
|
| 97 | - | match db::get_page_by_slug(&state.db, &slug) { |
|
| 98 | - | Ok(Some(page)) if page.is_published => { |
|
| 99 | - | let ctx = SiteContext::from_state(&state); |
|
| 100 | - | let rendered_content = render_markdown(&page.content); |
|
| 101 | - | WebTemplate(PageTemplate { |
|
| 102 | - | blog_title: ctx.blog_title, |
|
| 103 | - | nav_links: ctx.nav_links, |
|
| 104 | - | page, |
|
| 105 | - | rendered_content, |
|
| 106 | - | favicon_url: ctx.favicon_url, |
|
| 107 | - | og_image_url: ctx.og_image_url, |
|
| 108 | - | site_url: ctx.site_url, |
|
| 109 | - | header_html: ctx.header_html, |
|
| 110 | - | footer_html: ctx.footer_html, |
|
| 111 | - | }) |
|
| 112 | - | .into_response() |
|
| 113 | - | } |
|
| 114 | - | Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(), |
|
| 115 | - | Err(e) => { |
|
| 116 | - | tracing::error!("Failed to get page: {}", e); |
|
| 117 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 118 | - | } |
|
| 119 | - | } |
|
| 120 | - | } |
|
| 121 | - | ||
| 122 | - | pub async fn public_posts_list(State(state): State<Arc<AppState>>) -> Response { |
|
| 123 | - | let ctx = SiteContext::from_state(&state); |
|
| 124 | - | ||
| 125 | - | match db::get_published_posts(&state.db, None) { |
|
| 126 | - | Ok(posts) => WebTemplate(PostsListTemplate { |
|
| 127 | - | blog_title: ctx.blog_title, |
|
| 128 | - | nav_links: ctx.nav_links, |
|
| 129 | - | posts, |
|
| 130 | - | favicon_url: ctx.favicon_url, |
|
| 131 | - | og_image_url: ctx.og_image_url, |
|
| 132 | - | site_url: ctx.site_url, |
|
| 133 | - | header_html: ctx.header_html, |
|
| 134 | - | footer_html: ctx.footer_html, |
|
| 135 | - | }) |
|
| 136 | - | .into_response(), |
|
| 137 | - | Err(e) => { |
|
| 138 | - | tracing::error!("Failed to list posts: {}", e); |
|
| 139 | - | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 140 | - | } |
|
| 141 | - | } |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | pub async fn serve_custom_css(State(state): State<Arc<AppState>>) -> Response { |
|
| 145 | - | let css = get_setting_or_default(&state.db, "custom_css"); |
|
| 146 | - | ( |
|
| 147 | - | StatusCode::OK, |
|
| 148 | - | [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("text/css"))], |
|
| 149 | - | css, |
|
| 150 | - | ) |
|
| 151 | - | .into_response() |
|
| 152 | - | } |
|
| 153 | - | ||
| 154 | - | pub async fn fallback_handler( |
|
| 155 | - | State(state): State<Arc<AppState>>, |
|
| 156 | - | uri: Uri, |
|
| 157 | - | ) -> Response { |
|
| 158 | - | let path = uri.path().trim_start_matches('/'); |
|
| 159 | - | if let Ok(Some(redirect_to)) = db::find_alias_redirect(&state.db, path) { |
|
| 160 | - | return Redirect::permanent(&redirect_to).into_response(); |
|
| 161 | - | } |
|
| 162 | - | (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response() |
|
| 163 | - | } |
|
| 164 | - | ||
| 165 | - | pub async fn serve_uploaded_file( |
|
| 166 | - | State(state): State<Arc<AppState>>, |
|
| 167 | - | Path(filename): Path<String>, |
|
| 168 | - | ) -> Response { |
|
| 169 | - | if filename.contains("..") || filename.contains('/') || filename.contains('\\') { |
|
| 170 | - | return StatusCode::NOT_FOUND.into_response(); |
|
| 171 | - | } |
|
| 172 | - | ||
| 173 | - | if let Ok(Some(file)) = db::get_file_by_filename(&state.db, &filename) { |
|
| 174 | - | if file.storage_backend == "r2" { |
|
| 175 | - | if let Some(r2) = &state.r2 { |
|
| 176 | - | return Redirect::temporary(&r2.public_url_for(&filename)).into_response(); |
|
| 177 | - | } |
|
| 178 | - | tracing::warn!( |
|
| 179 | - | "File {} stored in R2 but R2 not configured; cannot serve", |
|
| 180 | - | filename |
|
| 181 | - | ); |
|
| 182 | - | return StatusCode::NOT_FOUND.into_response(); |
|
| 183 | - | } |
|
| 184 | - | } |
|
| 185 | - | ||
| 186 | - | let path = std::path::PathBuf::from(&state.uploads_dir).join(&filename); |
|
| 187 | - | match tokio::fs::read(&path).await { |
|
| 188 | - | Ok(bytes) => { |
|
| 189 | - | let mime = mime_from_path(&filename); |
|
| 190 | - | ( |
|
| 191 | - | StatusCode::OK, |
|
| 192 | - | [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))], |
|
| 193 | - | bytes, |
|
| 194 | - | ) |
|
| 195 | - | .into_response() |
|
| 196 | - | } |
|
| 197 | - | Err(_) => StatusCode::NOT_FOUND.into_response(), |
|
| 198 | - | } |
|
| 199 | - | } |
|
| 200 | - | ||
| 201 | - | fn xml_escape(s: &str) -> String { |
|
| 202 | - | s.replace('&', "&") |
|
| 203 | - | .replace('<', "<") |
|
| 204 | - | .replace('>', ">") |
|
| 205 | - | .replace('"', """) |
|
| 206 | - | .replace('\'', "'") |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | fn to_rfc2822(sqlite_ts: &str) -> String { |
|
| 210 | - | chrono::NaiveDateTime::parse_from_str(sqlite_ts, "%Y-%m-%d %H:%M:%S") |
|
| 211 | - | .map(|naive| naive.and_utc().to_rfc2822()) |
|
| 212 | - | .unwrap_or_else(|_| sqlite_ts.to_string()) |
|
| 213 | - | } |
|
| 214 | - | ||
| 215 | - | pub async fn rss_feed(State(state): State<Arc<AppState>>) -> Response { |
|
| 216 | - | let blog_title = get_blog_title(&state.db); |
|
| 217 | - | let blog_description = get_setting_or_default(&state.db, "blog_description"); |
|
| 218 | - | let site_url = &state.site_url; |
|
| 219 | - | ||
| 220 | - | let posts = match db::get_published_posts(&state.db, None) { |
|
| 221 | - | Ok(posts) => posts, |
|
| 222 | - | Err(e) => { |
|
| 223 | - | tracing::error!("Failed to get posts for RSS: {}", e); |
|
| 224 | - | return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response(); |
|
| 225 | - | } |
|
| 226 | - | }; |
|
| 227 | - | ||
| 228 | - | let mut items = String::new(); |
|
| 229 | - | for post in &posts { |
|
| 230 | - | let link = format!("{}/posts/{}", site_url, xml_escape(&post.slug)); |
|
| 231 | - | let title = match post.title.as_deref().map(str::trim).filter(|s| !s.is_empty()) { |
|
| 232 | - | Some(t) => xml_escape(t), |
|
| 233 | - | None => String::new(), |
|
| 234 | - | }; |
|
| 235 | - | let description = match &post.meta_description { |
|
| 236 | - | Some(d) if !d.is_empty() => xml_escape(d), |
|
| 237 | - | _ => { |
|
| 238 | - | let plain: String = post.content.chars().take(200).collect(); |
|
| 239 | - | xml_escape(&plain) |
|
| 240 | - | } |
|
| 241 | - | }; |
|
| 242 | - | let raw_date = post.published_date.as_deref().unwrap_or(&post.created_at); |
|
| 243 | - | let pub_date = to_rfc2822(raw_date); |
|
| 244 | - | let guid = format!("{}/posts/{}", site_url, xml_escape(&post.slug)); |
|
| 245 | - | ||
| 246 | - | items.push_str(&format!( |
|
| 247 | - | " <item>\n <title>{title}</title>\n <link>{link}</link>\n <guid>{guid}</guid>\n <description>{description}</description>\n <pubDate>{pub_date}</pubDate>\n </item>\n" |
|
| 248 | - | )); |
|
| 249 | - | } |
|
| 250 | - | ||
| 251 | - | let last_build = posts |
|
| 252 | - | .first() |
|
| 253 | - | .and_then(|p| p.published_date.as_deref()) |
|
| 254 | - | .map(to_rfc2822) |
|
| 255 | - | .unwrap_or_default(); |
|
| 256 | - | ||
| 257 | - | let xml = format!( |
|
| 258 | - | r#"<?xml version="1.0" encoding="UTF-8"?> |
|
| 259 | - | <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> |
|
| 260 | - | <channel> |
|
| 261 | - | <title>{title}</title> |
|
| 262 | - | <link>{site_url}</link> |
|
| 263 | - | <description>{desc}</description> |
|
| 264 | - | <lastBuildDate>{last_build}</lastBuildDate> |
|
| 265 | - | <atom:link href="{site_url}/feed.xml" rel="self" type="application/rss+xml"/> |
|
| 266 | - | {items} </channel> |
|
| 267 | - | </rss>"#, |
|
| 268 | - | title = xml_escape(&blog_title), |
|
| 269 | - | site_url = site_url, |
|
| 270 | - | desc = xml_escape(&blog_description), |
|
| 271 | - | last_build = last_build, |
|
| 272 | - | items = items, |
|
| 273 | - | ); |
|
| 274 | - | ||
| 275 | - | ( |
|
| 276 | - | StatusCode::OK, |
|
| 277 | - | [( |
|
| 278 | - | axum::http::header::CONTENT_TYPE, |
|
| 279 | - | HeaderValue::from_static("application/rss+xml; charset=utf-8"), |
|
| 280 | - | )], |
|
| 281 | - | xml, |
|
| 282 | - | ) |
|
| 283 | - | .into_response() |
|
| 284 | - | } |
| 1 | - | use askama::Template; |
|
| 2 | - | use axum::{ |
|
| 3 | - | extract::DefaultBodyLimit, |
|
| 4 | - | http::Method, |
|
| 5 | - | routing::{get, post}, |
|
| 6 | - | Router, |
|
| 7 | - | }; |
|
| 8 | - | use pulldown_cmark::{Options, Parser, html}; |
|
| 9 | - | use rust_embed::Embed; |
|
| 10 | - | use std::sync::Arc; |
|
| 11 | - | use tower_http::cors::{Any, CorsLayer}; |
|
| 12 | - | ||
| 13 | - | use crate::db::{self, Db, Page, Post, UploadedFile}; |
|
| 14 | - | use crate::storage::R2Config; |
|
| 15 | - | ||
| 16 | - | mod handlers; |
|
| 17 | - | ||
| 18 | - | #[derive(Debug, Clone)] |
|
| 19 | - | pub struct NavLink { |
|
| 20 | - | pub label: String, |
|
| 21 | - | pub url: String, |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | #[derive(Clone)] |
|
| 25 | - | pub struct AppState { |
|
| 26 | - | pub db: Db, |
|
| 27 | - | pub app_password: String, |
|
| 28 | - | pub cookie_secure: bool, |
|
| 29 | - | pub uploads_dir: String, |
|
| 30 | - | pub site_url: String, |
|
| 31 | - | pub r2: Option<R2Config>, |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | #[derive(Embed)] |
|
| 35 | - | #[folder = "static/"] |
|
| 36 | - | struct Static; |
|
| 37 | - | ||
| 38 | - | // --- Templates --- |
|
| 39 | - | ||
| 40 | - | #[derive(Template)] |
|
| 41 | - | #[template(path = "base.html")] |
|
| 42 | - | struct BaseTemplate { |
|
| 43 | - | blog_title: String, |
|
| 44 | - | nav_links: Vec<NavLink>, |
|
| 45 | - | favicon_url: String, |
|
| 46 | - | header_html: String, |
|
| 47 | - | footer_html: String, |
|
| 48 | - | } |
|
| 49 | - | ||
| 50 | - | #[derive(Template)] |
|
| 51 | - | #[template(path = "admin_base.html")] |
|
| 52 | - | struct AdminBaseTemplate; |
|
| 53 | - | ||
| 54 | - | #[derive(Template)] |
|
| 55 | - | #[template(path = "login.html")] |
|
| 56 | - | struct LoginTemplate { |
|
| 57 | - | error: Option<String>, |
|
| 58 | - | } |
|
| 59 | - | ||
| 60 | - | #[derive(Template)] |
|
| 61 | - | #[template(path = "index.html")] |
|
| 62 | - | struct IndexTemplate { |
|
| 63 | - | blog_title: String, |
|
| 64 | - | blog_description: String, |
|
| 65 | - | intro_html: String, |
|
| 66 | - | posts: Vec<Post>, |
|
| 67 | - | nav_links: Vec<NavLink>, |
|
| 68 | - | favicon_url: String, |
|
| 69 | - | og_image_url: String, |
|
| 70 | - | site_url: String, |
|
| 71 | - | header_html: String, |
|
| 72 | - | footer_html: String, |
|
| 73 | - | } |
|
| 74 | - | ||
| 75 | - | #[derive(Template)] |
|
| 76 | - | #[template(path = "post.html")] |
|
| 77 | - | struct PostTemplate { |
|
| 78 | - | blog_title: String, |
|
| 79 | - | nav_links: Vec<NavLink>, |
|
| 80 | - | post: Post, |
|
| 81 | - | rendered_content: String, |
|
| 82 | - | favicon_url: String, |
|
| 83 | - | og_image_url: String, |
|
| 84 | - | site_url: String, |
|
| 85 | - | header_html: String, |
|
| 86 | - | footer_html: String, |
|
| 87 | - | } |
|
| 88 | - | ||
| 89 | - | #[derive(Template)] |
|
| 90 | - | #[template(path = "page.html")] |
|
| 91 | - | struct PageTemplate { |
|
| 92 | - | blog_title: String, |
|
| 93 | - | nav_links: Vec<NavLink>, |
|
| 94 | - | page: Page, |
|
| 95 | - | rendered_content: String, |
|
| 96 | - | favicon_url: String, |
|
| 97 | - | og_image_url: String, |
|
| 98 | - | site_url: String, |
|
| 99 | - | header_html: String, |
|
| 100 | - | footer_html: String, |
|
| 101 | - | } |
|
| 102 | - | ||
| 103 | - | #[derive(Template)] |
|
| 104 | - | #[template(path = "admin_index.html")] |
|
| 105 | - | struct AdminIndexTemplate { |
|
| 106 | - | posts: Vec<Post>, |
|
| 107 | - | } |
|
| 108 | - | ||
| 109 | - | #[derive(Template)] |
|
| 110 | - | #[template(path = "admin_post_form.html")] |
|
| 111 | - | struct AdminPostFormTemplate { |
|
| 112 | - | post: Option<Post>, |
|
| 113 | - | error: Option<String>, |
|
| 114 | - | } |
|
| 115 | - | ||
| 116 | - | #[derive(Template)] |
|
| 117 | - | #[template(path = "admin_pages.html")] |
|
| 118 | - | struct AdminPagesTemplate { |
|
| 119 | - | pages: Vec<Page>, |
|
| 120 | - | } |
|
| 121 | - | ||
| 122 | - | #[derive(Template)] |
|
| 123 | - | #[template(path = "admin_page_form.html")] |
|
| 124 | - | struct AdminPageFormTemplate { |
|
| 125 | - | page: Option<Page>, |
|
| 126 | - | error: Option<String>, |
|
| 127 | - | } |
|
| 128 | - | ||
| 129 | - | #[derive(Template)] |
|
| 130 | - | #[template(path = "admin_settings.html")] |
|
| 131 | - | struct AdminSettingsTemplate { |
|
| 132 | - | blog_title: String, |
|
| 133 | - | blog_description: String, |
|
| 134 | - | intro_content: String, |
|
| 135 | - | nav_links: String, |
|
| 136 | - | custom_css: String, |
|
| 137 | - | default_css: String, |
|
| 138 | - | favicon_url: String, |
|
| 139 | - | og_image_url: String, |
|
| 140 | - | custom_header: String, |
|
| 141 | - | custom_footer: String, |
|
| 142 | - | success: bool, |
|
| 143 | - | } |
|
| 144 | - | ||
| 145 | - | #[derive(Template)] |
|
| 146 | - | #[template(path = "posts.html")] |
|
| 147 | - | struct PostsListTemplate { |
|
| 148 | - | blog_title: String, |
|
| 149 | - | nav_links: Vec<NavLink>, |
|
| 150 | - | posts: Vec<Post>, |
|
| 151 | - | favicon_url: String, |
|
| 152 | - | og_image_url: String, |
|
| 153 | - | site_url: String, |
|
| 154 | - | header_html: String, |
|
| 155 | - | footer_html: String, |
|
| 156 | - | } |
|
| 157 | - | ||
| 158 | - | #[derive(Template)] |
|
| 159 | - | #[template(path = "admin_files.html")] |
|
| 160 | - | struct AdminFilesTemplate { |
|
| 161 | - | files: Vec<UploadedFile>, |
|
| 162 | - | site_url: String, |
|
| 163 | - | error: Option<String>, |
|
| 164 | - | success: bool, |
|
| 165 | - | } |
|
| 166 | - | ||
| 167 | - | #[derive(Template)] |
|
| 168 | - | #[template(path = "admin_import.html")] |
|
| 169 | - | struct AdminImportTemplate { |
|
| 170 | - | error: Option<String>, |
|
| 171 | - | imported: Option<u32>, |
|
| 172 | - | skipped: Option<u32>, |
|
| 173 | - | } |
|
| 174 | - | ||
| 175 | - | // --- Query/Form structs --- |
|
| 176 | - | ||
| 177 | - | #[derive(serde::Deserialize, Default)] |
|
| 178 | - | pub struct FlashQuery { |
|
| 179 | - | pub error: Option<String>, |
|
| 180 | - | #[serde(default)] |
|
| 181 | - | pub success: bool, |
|
| 182 | - | pub imported: Option<u32>, |
|
| 183 | - | pub skipped: Option<u32>, |
|
| 184 | - | } |
|
| 185 | - | ||
| 186 | - | #[derive(serde::Deserialize)] |
|
| 187 | - | struct LoginForm { |
|
| 188 | - | password: String, |
|
| 189 | - | } |
|
| 190 | - | ||
| 191 | - | #[derive(serde::Deserialize)] |
|
| 192 | - | struct PostForm { |
|
| 193 | - | attributes: String, |
|
| 194 | - | content: String, |
|
| 195 | - | #[serde(default)] |
|
| 196 | - | action: String, |
|
| 197 | - | } |
|
| 198 | - | ||
| 199 | - | struct ParsedAttributes { |
|
| 200 | - | title: String, |
|
| 201 | - | slug: String, |
|
| 202 | - | alias: String, |
|
| 203 | - | published_date: String, |
|
| 204 | - | meta_description: String, |
|
| 205 | - | meta_image: String, |
|
| 206 | - | lang: String, |
|
| 207 | - | tags: String, |
|
| 208 | - | status: String, |
|
| 209 | - | } |
|
| 210 | - | ||
| 211 | - | fn parse_attributes(text: &str) -> ParsedAttributes { |
|
| 212 | - | let mut attrs = ParsedAttributes { |
|
| 213 | - | title: String::new(), |
|
| 214 | - | slug: String::new(), |
|
| 215 | - | alias: String::new(), |
|
| 216 | - | published_date: String::new(), |
|
| 217 | - | meta_description: String::new(), |
|
| 218 | - | meta_image: String::new(), |
|
| 219 | - | lang: String::new(), |
|
| 220 | - | tags: String::new(), |
|
| 221 | - | status: String::new(), |
|
| 222 | - | }; |
|
| 223 | - | for line in text.lines() { |
|
| 224 | - | if let Some((key, value)) = line.split_once(':') { |
|
| 225 | - | let key = key.trim().to_lowercase(); |
|
| 226 | - | let value = value.trim().to_string(); |
|
| 227 | - | match key.as_str() { |
|
| 228 | - | "title" => attrs.title = value, |
|
| 229 | - | "slug" => attrs.slug = value, |
|
| 230 | - | "alias" => attrs.alias = value, |
|
| 231 | - | "published_date" => attrs.published_date = value, |
|
| 232 | - | "description" | "meta_description" => attrs.meta_description = value, |
|
| 233 | - | "meta_image" => attrs.meta_image = value, |
|
| 234 | - | "lang" => attrs.lang = value, |
|
| 235 | - | "tags" => attrs.tags = value, |
|
| 236 | - | "status" => attrs.status = value, |
|
| 237 | - | _ => {} |
|
| 238 | - | } |
|
| 239 | - | } |
|
| 240 | - | } |
|
| 241 | - | attrs |
|
| 242 | - | } |
|
| 243 | - | ||
| 244 | - | #[derive(serde::Deserialize)] |
|
| 245 | - | struct PageForm { |
|
| 246 | - | attributes: String, |
|
| 247 | - | content: String, |
|
| 248 | - | } |
|
| 249 | - | ||
| 250 | - | struct ParsedPageAttributes { |
|
| 251 | - | title: String, |
|
| 252 | - | slug: String, |
|
| 253 | - | is_published: bool, |
|
| 254 | - | } |
|
| 255 | - | ||
| 256 | - | fn parse_page_attributes(text: &str) -> ParsedPageAttributes { |
|
| 257 | - | let mut attrs = ParsedPageAttributes { |
|
| 258 | - | title: String::new(), |
|
| 259 | - | slug: String::new(), |
|
| 260 | - | is_published: false, |
|
| 261 | - | }; |
|
| 262 | - | for line in text.lines() { |
|
| 263 | - | if let Some((key, value)) = line.split_once(':') { |
|
| 264 | - | let key = key.trim().to_lowercase(); |
|
| 265 | - | let value = value.trim().to_string(); |
|
| 266 | - | match key.as_str() { |
|
| 267 | - | "title" => attrs.title = value, |
|
| 268 | - | "slug" => attrs.slug = value, |
|
| 269 | - | "published" => attrs.is_published = value == "true", |
|
| 270 | - | _ => {} |
|
| 271 | - | } |
|
| 272 | - | } |
|
| 273 | - | } |
|
| 274 | - | attrs |
|
| 275 | - | } |
|
| 276 | - | ||
| 277 | - | #[derive(serde::Deserialize)] |
|
| 278 | - | struct SettingsForm { |
|
| 279 | - | blog_title: String, |
|
| 280 | - | blog_description: String, |
|
| 281 | - | intro_content: String, |
|
| 282 | - | nav_links: String, |
|
| 283 | - | custom_css: String, |
|
| 284 | - | favicon_url: String, |
|
| 285 | - | og_image_url: String, |
|
| 286 | - | custom_header: String, |
|
| 287 | - | custom_footer: String, |
|
| 288 | - | } |
|
| 289 | - | ||
| 290 | - | // --- Helpers --- |
|
| 291 | - | ||
| 292 | - | fn mime_from_path(path: &str) -> &'static str { |
|
| 293 | - | match path.rsplit('.').next().unwrap_or("") { |
|
| 294 | - | "css" => "text/css", |
|
| 295 | - | "js" => "application/javascript", |
|
| 296 | - | "html" => "text/html", |
|
| 297 | - | "png" => "image/png", |
|
| 298 | - | "jpg" | "jpeg" => "image/jpeg", |
|
| 299 | - | "gif" => "image/gif", |
|
| 300 | - | "webp" => "image/webp", |
|
| 301 | - | "ico" => "image/x-icon", |
|
| 302 | - | "svg" => "image/svg+xml", |
|
| 303 | - | "woff" | "woff2" => "font/woff2", |
|
| 304 | - | "ttf" => "font/ttf", |
|
| 305 | - | "otf" => "font/otf", |
|
| 306 | - | "json" | "webmanifest" => "application/json", |
|
| 307 | - | "pdf" => "application/pdf", |
|
| 308 | - | "mp4" => "video/mp4", |
|
| 309 | - | "webm" => "video/webm", |
|
| 310 | - | _ => "application/octet-stream", |
|
| 311 | - | } |
|
| 312 | - | } |
|
| 313 | - | ||
| 314 | - | fn get_header_footer_html(db: &db::Db) -> (String, String) { |
|
| 315 | - | let custom_header = get_setting_or_default(db, "custom_header"); |
|
| 316 | - | let custom_footer = get_setting_or_default(db, "custom_footer"); |
|
| 317 | - | let header_html = render_markdown(&custom_header); |
|
| 318 | - | let footer_html = render_markdown(&custom_footer); |
|
| 319 | - | (header_html, footer_html) |
|
| 320 | - | } |
|
| 321 | - | ||
| 322 | - | fn render_markdown(content: &str) -> String { |
|
| 323 | - | let mut options = Options::empty(); |
|
| 324 | - | options.insert(Options::ENABLE_STRIKETHROUGH); |
|
| 325 | - | options.insert(Options::ENABLE_TABLES); |
|
| 326 | - | options.insert(Options::ENABLE_TASKLISTS); |
|
| 327 | - | options.insert(Options::ENABLE_FOOTNOTES); |
|
| 328 | - | let parser = Parser::new_ext(content, options); |
|
| 329 | - | let mut html_output = String::new(); |
|
| 330 | - | html::push_html(&mut html_output, parser); |
|
| 331 | - | html_output |
|
| 332 | - | } |
|
| 333 | - | ||
| 334 | - | fn now_datetime() -> String { |
|
| 335 | - | andromeda_auth::datetime::now_datetime_string() |
|
| 336 | - | } |
|
| 337 | - | ||
| 338 | - | fn slugify(s: &str) -> String { |
|
| 339 | - | s.to_lowercase() |
|
| 340 | - | .chars() |
|
| 341 | - | .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) |
|
| 342 | - | .collect::<String>() |
|
| 343 | - | .split('-') |
|
| 344 | - | .filter(|s| !s.is_empty()) |
|
| 345 | - | .collect::<Vec<_>>() |
|
| 346 | - | .join("-") |
|
| 347 | - | } |
|
| 348 | - | ||
| 349 | - | fn opt_str(s: &str) -> Option<&str> { |
|
| 350 | - | let trimmed = s.trim(); |
|
| 351 | - | if trimmed.is_empty() { None } else { Some(trimmed) } |
|
| 352 | - | } |
|
| 353 | - | ||
| 354 | - | fn get_setting_or_default(db: &Db, key: &str) -> String { |
|
| 355 | - | db::get_setting(db, key).ok().flatten().unwrap_or_default() |
|
| 356 | - | } |
|
| 357 | - | ||
| 358 | - | fn get_blog_title(db: &Db) -> String { |
|
| 359 | - | let title = get_setting_or_default(db, "blog_title"); |
|
| 360 | - | if title.is_empty() { "My Blog".to_string() } else { title } |
|
| 361 | - | } |
|
| 362 | - | ||
| 363 | - | fn parse_nav_links(input: &str) -> Vec<NavLink> { |
|
| 364 | - | let mut links = Vec::new(); |
|
| 365 | - | let mut chars = input.chars().peekable(); |
|
| 366 | - | while let Some(c) = chars.next() { |
|
| 367 | - | if c == '[' { |
|
| 368 | - | let label: String = chars.by_ref().take_while(|&ch| ch != ']').collect(); |
|
| 369 | - | if chars.peek() == Some(&'(') { |
|
| 370 | - | chars.next(); |
|
| 371 | - | let url: String = chars.by_ref().take_while(|&ch| ch != ')').collect(); |
|
| 372 | - | if !label.is_empty() && !url.is_empty() { |
|
| 373 | - | links.push(NavLink { label, url }); |
|
| 374 | - | } |
|
| 375 | - | } |
|
| 376 | - | } |
|
| 377 | - | } |
|
| 378 | - | links |
|
| 379 | - | } |
|
| 380 | - | ||
| 381 | - | fn get_nav_links(db: &Db) -> Vec<NavLink> { |
|
| 382 | - | let raw = db::get_setting(db, "nav_links") |
|
| 383 | - | .ok() |
|
| 384 | - | .flatten() |
|
| 385 | - | .unwrap_or_default(); |
|
| 386 | - | parse_nav_links(&raw) |
|
| 387 | - | } |
|
| 388 | - | ||
| 389 | - | fn get_favicon_url(db: &Db) -> String { |
|
| 390 | - | get_setting_or_default(db, "favicon_url") |
|
| 391 | - | } |
|
| 392 | - | ||
| 393 | - | fn get_og_image_url(db: &Db) -> String { |
|
| 394 | - | get_setting_or_default(db, "og_image_url") |
|
| 395 | - | } |
|
| 396 | - | ||
| 397 | - | struct SiteContext { |
|
| 398 | - | blog_title: String, |
|
| 399 | - | nav_links: Vec<NavLink>, |
|
| 400 | - | favicon_url: String, |
|
| 401 | - | og_image_url: String, |
|
| 402 | - | site_url: String, |
|
| 403 | - | header_html: String, |
|
| 404 | - | footer_html: String, |
|
| 405 | - | } |
|
| 406 | - | ||
| 407 | - | impl SiteContext { |
|
| 408 | - | fn from_state(state: &AppState) -> Self { |
|
| 409 | - | let (header_html, footer_html) = get_header_footer_html(&state.db); |
|
| 410 | - | Self { |
|
| 411 | - | blog_title: get_blog_title(&state.db), |
|
| 412 | - | nav_links: get_nav_links(&state.db), |
|
| 413 | - | favicon_url: get_favicon_url(&state.db), |
|
| 414 | - | og_image_url: get_og_image_url(&state.db), |
|
| 415 | - | site_url: state.site_url.clone(), |
|
| 416 | - | header_html, |
|
| 417 | - | footer_html, |
|
| 418 | - | } |
|
| 419 | - | } |
|
| 420 | - | } |
|
| 421 | - | ||
| 422 | - | fn render_latest_posts_embed(posts: &[&Post]) -> String { |
|
| 423 | - | let mut html = String::from("<div class=\"post-list\">"); |
|
| 424 | - | for post in posts { |
|
| 425 | - | html.push_str(&format!( |
|
| 426 | - | r#"<a href="/posts/{slug}" class="post-item"><div class="post-item-info"><span class="post-title">{title}</span>"#, |
|
| 427 | - | slug = post.slug, |
|
| 428 | - | title = post.display_title(), |
|
| 429 | - | )); |
|
| 430 | - | if let Some(ref tags) = post.tags { |
|
| 431 | - | if !tags.is_empty() { |
|
| 432 | - | html.push_str(r#"<span class="post-tags">"#); |
|
| 433 | - | for tag in tags.split(',') { |
|
| 434 | - | let tag = tag.trim(); |
|
| 435 | - | if !tag.is_empty() { |
|
| 436 | - | html.push_str(&format!(r#"<span class="tag">{}</span>"#, tag)); |
|
| 437 | - | } |
|
| 438 | - | } |
|
| 439 | - | html.push_str("</span>"); |
|
| 440 | - | } |
|
| 441 | - | } |
|
| 442 | - | html.push_str("</div>"); |
|
| 443 | - | if let Some(ref date) = post.published_date { |
|
| 444 | - | html.push_str(&format!(r#"<time class="post-date">{}</time>"#, date)); |
|
| 445 | - | } |
|
| 446 | - | html.push_str("</a>"); |
|
| 447 | - | } |
|
| 448 | - | html.push_str("</div>"); |
|
| 449 | - | html |
|
| 450 | - | } |
|
| 451 | - | ||
| 452 | - | fn post_to_markdown(post: &Post) -> String { |
|
| 453 | - | use std::fmt::Write; |
|
| 454 | - | let mut out = String::from("---"); |
|
| 455 | - | if let Some(t) = &post.title { |
|
| 456 | - | let _ = write!(out, "\ntitle: {}", t); |
|
| 457 | - | } |
|
| 458 | - | let _ = write!(out, "\nslug: {}\nstatus: {}", post.slug, post.status); |
|
| 459 | - | let optional_fields: &[(&str, &Option<String>)] = &[ |
|
| 460 | - | ("published_date", &post.published_date), |
|
| 461 | - | ("tags", &post.tags), |
|
| 462 | - | ]; |
|
| 463 | - | for (key, value) in optional_fields { |
|
| 464 | - | if let Some(v) = value { |
|
| 465 | - | let _ = write!(out, "\n{}: {}", key, v); |
|
| 466 | - | } |
|
| 467 | - | } |
|
| 468 | - | let _ = write!(out, "\nlang: {}", post.lang); |
|
| 469 | - | let optional_tail: &[(&str, &Option<String>)] = &[ |
|
| 470 | - | ("alias", &post.alias), |
|
| 471 | - | ("meta_image", &post.meta_image), |
|
| 472 | - | ("description", &post.meta_description), |
|
| 473 | - | ]; |
|
| 474 | - | for (key, value) in optional_tail { |
|
| 475 | - | if let Some(v) = value { |
|
| 476 | - | let _ = write!(out, "\n{}: {}", key, v); |
|
| 477 | - | } |
|
| 478 | - | } |
|
| 479 | - | out.push_str("\n---\n\n"); |
|
| 480 | - | out.push_str(&post.content); |
|
| 481 | - | out |
|
| 482 | - | } |
|
| 483 | - | ||
| 484 | - | fn build_zip( |
|
| 485 | - | entries: &[(String, &[u8])], |
|
| 486 | - | compression: zip::CompressionMethod, |
|
| 487 | - | ) -> Vec<u8> { |
|
| 488 | - | let mut buf = std::io::Cursor::new(Vec::new()); |
|
| 489 | - | { |
|
| 490 | - | let mut zip = zip::ZipWriter::new(&mut buf); |
|
| 491 | - | let options = zip::write::SimpleFileOptions::default().compression_method(compression); |
|
| 492 | - | for (name, data) in entries { |
|
| 493 | - | if let Err(e) = zip.start_file(name, options) { |
|
| 494 | - | tracing::warn!("Failed to add {} to zip: {}", name, e); |
|
| 495 | - | continue; |
|
| 496 | - | } |
|
| 497 | - | if let Err(e) = std::io::Write::write_all(&mut zip, data) { |
|
| 498 | - | tracing::warn!("Failed to write {} to zip: {}", name, e); |
|
| 499 | - | } |
|
| 500 | - | } |
|
| 501 | - | let _ = zip.finish(); |
|
| 502 | - | } |
|
| 503 | - | buf.into_inner() |
|
| 504 | - | } |
|
| 505 | - | ||
| 506 | - | fn zip_response(bytes: Vec<u8>, filename: &str) -> axum::response::Response { |
|
| 507 | - | use axum::response::IntoResponse; |
|
| 508 | - | ( |
|
| 509 | - | axum::http::StatusCode::OK, |
|
| 510 | - | [ |
|
| 511 | - | (axum::http::header::CONTENT_TYPE, "application/zip"), |
|
| 512 | - | ( |
|
| 513 | - | axum::http::header::CONTENT_DISPOSITION, |
|
| 514 | - | &format!("attachment; filename=\"{}\"", filename), |
|
| 515 | - | ), |
|
| 516 | - | ], |
|
| 517 | - | bytes, |
|
| 518 | - | ) |
|
| 519 | - | .into_response() |
|
| 520 | - | } |
|
| 521 | - | ||
| 522 | - | // --- Router --- |
|
| 523 | - | ||
| 524 | - | pub async fn run(host: String, port: u16) { |
|
| 525 | - | use handlers::{admin, api, public}; |
|
| 526 | - | ||
| 527 | - | let db = db::init_db(); |
|
| 528 | - | ||
| 529 | - | if let Err(e) = db::prune_expired_sessions(&db) { |
|
| 530 | - | tracing::warn!("Failed to prune sessions: {}", e); |
|
| 531 | - | } |
|
| 532 | - | ||
| 533 | - | let app_password = std::env::var("POSTS_PASSWORD").unwrap_or_else(|_| { |
|
| 534 | - | tracing::warn!("POSTS_PASSWORD not set, using default 'changeme'"); |
|
| 535 | - | "changeme".to_string() |
|
| 536 | - | }); |
|
| 537 | - | ||
| 538 | - | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 539 | - | .map(|v| v == "true") |
|
| 540 | - | .unwrap_or(false); |
|
| 541 | - | ||
| 542 | - | let uploads_dir = std::env::var("UPLOADS_DIR").unwrap_or_else(|_| "uploads".to_string()); |
|
| 543 | - | tokio::fs::create_dir_all(&uploads_dir) |
|
| 544 | - | .await |
|
| 545 | - | .expect("Failed to create uploads directory"); |
|
| 546 | - | ||
| 547 | - | let site_url = std::env::var("SITE_URL") |
|
| 548 | - | .unwrap_or_else(|_| "http://localhost:3000".to_string()) |
|
| 549 | - | .trim_end_matches('/') |
|
| 550 | - | .to_string(); |
|
| 551 | - | ||
| 552 | - | let r2 = R2Config::from_env(); |
|
| 553 | - | if r2.is_some() { |
|
| 554 | - | tracing::info!("Cloudflare R2 storage enabled for new uploads"); |
|
| 555 | - | } else { |
|
| 556 | - | tracing::info!("R2 not configured, using local filesystem for uploads"); |
|
| 557 | - | } |
|
| 558 | - | ||
| 559 | - | let state = Arc::new(AppState { |
|
| 560 | - | db, |
|
| 561 | - | app_password, |
|
| 562 | - | cookie_secure, |
|
| 563 | - | uploads_dir, |
|
| 564 | - | site_url, |
|
| 565 | - | r2, |
|
| 566 | - | }); |
|
| 567 | - | ||
| 568 | - | let api_router = Router::new() |
|
| 569 | - | .route("/api/posts", get(api::list_posts)) |
|
| 570 | - | .route("/api/posts/{slug}", get(api::get_post)) |
|
| 571 | - | .layer( |
|
| 572 | - | CorsLayer::new() |
|
| 573 | - | .allow_origin(Any) |
|
| 574 | - | .allow_methods([Method::GET]) |
|
| 575 | - | .allow_headers(Any), |
|
| 576 | - | ); |
|
| 577 | - | ||
| 578 | - | let app = Router::new() |
|
| 579 | - | // Public routes |
|
| 580 | - | .route("/", get(public::public_index)) |
|
| 581 | - | .route("/posts", get(public::public_posts_list)) |
|
| 582 | - | .route("/posts/{slug}", get(public::public_post)) |
|
| 583 | - | .route("/custom-styles.css", get(public::serve_custom_css)) |
|
| 584 | - | .route("/{slug}", get(public::public_page)) |
|
| 585 | - | .route("/feed.xml", get(public::rss_feed)) |
|
| 586 | - | // Public JSON API |
|
| 587 | - | .merge(api_router) |
|
| 588 | - | // Admin auth |
|
| 589 | - | .route("/admin/login", get(admin::get_login).post(admin::post_login)) |
|
| 590 | - | .route("/admin/logout", get(admin::get_logout)) |
|
| 591 | - | // Admin posts |
|
| 592 | - | .route("/admin", get(admin::admin_index)) |
|
| 593 | - | .route("/admin/posts/new", get(admin::admin_new_post)) |
|
| 594 | - | .route("/admin/posts", post(admin::admin_create_post)) |
|
| 595 | - | .route("/admin/posts/{id}/edit", get(admin::admin_edit_post)) |
|
| 596 | - | .route("/admin/posts/{id}", post(admin::admin_update_post)) |
|
| 597 | - | .route("/admin/posts/{id}/delete", post(admin::admin_delete_post)) |
|
| 598 | - | .route("/admin/posts/{id}/publish", post(admin::admin_toggle_publish)) |
|
| 599 | - | // Admin pages |
|
| 600 | - | .route("/admin/pages", get(admin::admin_pages)) |
|
| 601 | - | .route("/admin/pages/new", get(admin::admin_new_page)) |
|
| 602 | - | .route("/admin/pages/create", post(admin::admin_create_page)) |
|
| 603 | - | .route("/admin/pages/{id}/edit", get(admin::admin_edit_page)) |
|
| 604 | - | .route("/admin/pages/{id}", post(admin::admin_update_page)) |
|
| 605 | - | .route("/admin/pages/{id}/delete", post(admin::admin_delete_page)) |
|
| 606 | - | // Admin settings |
|
| 607 | - | .route( |
|
| 608 | - | "/admin/settings", |
|
| 609 | - | get(admin::admin_get_settings).post(admin::admin_post_settings), |
|
| 610 | - | ) |
|
| 611 | - | // Admin downloads |
|
| 612 | - | .route("/admin/downloads/posts", get(admin::admin_download_posts)) |
|
| 613 | - | .route("/admin/downloads/uploads", get(admin::admin_download_uploads)) |
|
| 614 | - | // Admin import |
|
| 615 | - | .route( |
|
| 616 | - | "/admin/import", |
|
| 617 | - | get(admin::admin_import_form).post(admin::admin_import_posts), |
|
| 618 | - | ) |
|
| 619 | - | // Admin files |
|
| 620 | - | .route("/admin/files", get(admin::admin_files)) |
|
| 621 | - | .route("/admin/files/upload", post(admin::admin_upload_file)) |
|
| 622 | - | .route("/admin/files/{id}/delete", post(admin::admin_delete_file)) |
|
| 623 | - | // Public files |
|
| 624 | - | .route("/files/{filename}", get(public::serve_uploaded_file)) |
|
| 625 | - | // Static assets |
|
| 626 | - | .route("/static/{*path}", get(public::serve_static)) |
|
| 627 | - | // Shared Darkmatter CSS assets + /darkmatter gallery |
|
| 628 | - | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 629 | - | // Fallback |
|
| 630 | - | .fallback(get(public::fallback_handler)) |
|
| 631 | - | .with_state(state) |
|
| 632 | - | .layer(DefaultBodyLimit::max(51 * 1024 * 1024)); |
|
| 633 | - | ||
| 634 | - | let addr = format!("{}:{}", host, port); |
|
| 635 | - | tracing::info!("Listening on http://{}", addr); |
|
| 636 | - | ||
| 637 | - | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); |
|
| 638 | - | axum::serve(listener, app).await.unwrap(); |
|
| 639 | - | } |
| 1 | - | use std::time::Duration; |
|
| 2 | - | ||
| 3 | - | use rusty_s3::actions::S3Action; |
|
| 4 | - | use rusty_s3::{Bucket, Credentials, UrlStyle, actions}; |
|
| 5 | - | ||
| 6 | - | #[derive(Clone)] |
|
| 7 | - | pub struct R2Config { |
|
| 8 | - | bucket: Bucket, |
|
| 9 | - | creds: Credentials, |
|
| 10 | - | public_url: String, |
|
| 11 | - | http: reqwest::Client, |
|
| 12 | - | } |
|
| 13 | - | ||
| 14 | - | #[derive(Debug)] |
|
| 15 | - | pub enum R2Error { |
|
| 16 | - | Http(reqwest::Error), |
|
| 17 | - | Status(u16, String), |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | - | impl std::fmt::Display for R2Error { |
|
| 21 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 22 | - | match self { |
|
| 23 | - | R2Error::Http(e) => write!(f, "http error: {}", e), |
|
| 24 | - | R2Error::Status(code, body) => write!(f, "R2 returned {}: {}", code, body), |
|
| 25 | - | } |
|
| 26 | - | } |
|
| 27 | - | } |
|
| 28 | - | ||
| 29 | - | impl std::error::Error for R2Error {} |
|
| 30 | - | ||
| 31 | - | impl From<reqwest::Error> for R2Error { |
|
| 32 | - | fn from(e: reqwest::Error) -> Self { |
|
| 33 | - | R2Error::Http(e) |
|
| 34 | - | } |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | const SIGN_TTL: Duration = Duration::from_secs(60); |
|
| 38 | - | ||
| 39 | - | impl R2Config { |
|
| 40 | - | pub fn from_env() -> Option<Self> { |
|
| 41 | - | let account_id = std::env::var("R2_ACCOUNT_ID").ok()?; |
|
| 42 | - | let access_key = std::env::var("R2_ACCESS_KEY_ID").ok()?; |
|
| 43 | - | let secret_key = std::env::var("R2_SECRET_ACCESS_KEY").ok()?; |
|
| 44 | - | let bucket_name = std::env::var("R2_BUCKET").ok()?; |
|
| 45 | - | let public_url = std::env::var("R2_PUBLIC_URL").ok()?; |
|
| 46 | - | ||
| 47 | - | let endpoint_str = format!("https://{}.r2.cloudflarestorage.com", account_id); |
|
| 48 | - | let endpoint = match endpoint_str.parse() { |
|
| 49 | - | Ok(u) => u, |
|
| 50 | - | Err(e) => { |
|
| 51 | - | tracing::error!("Invalid R2 endpoint URL: {}", e); |
|
| 52 | - | return None; |
|
| 53 | - | } |
|
| 54 | - | }; |
|
| 55 | - | let bucket = match Bucket::new(endpoint, UrlStyle::Path, bucket_name, "auto") { |
|
| 56 | - | Ok(b) => b, |
|
| 57 | - | Err(e) => { |
|
| 58 | - | tracing::error!("Failed to construct R2 bucket: {:?}", e); |
|
| 59 | - | return None; |
|
| 60 | - | } |
|
| 61 | - | }; |
|
| 62 | - | let creds = Credentials::new(access_key, secret_key); |
|
| 63 | - | let http = reqwest::Client::builder() |
|
| 64 | - | .timeout(Duration::from_secs(30)) |
|
| 65 | - | .build() |
|
| 66 | - | .ok()?; |
|
| 67 | - | ||
| 68 | - | Some(Self { |
|
| 69 | - | bucket, |
|
| 70 | - | creds, |
|
| 71 | - | public_url: public_url.trim_end_matches('/').to_string(), |
|
| 72 | - | http, |
|
| 73 | - | }) |
|
| 74 | - | } |
|
| 75 | - | ||
| 76 | - | pub async fn put_object( |
|
| 77 | - | &self, |
|
| 78 | - | key: &str, |
|
| 79 | - | content_type: &str, |
|
| 80 | - | bytes: Vec<u8>, |
|
| 81 | - | ) -> Result<(), R2Error> { |
|
| 82 | - | let mut action = actions::PutObject::new(&self.bucket, Some(&self.creds), key); |
|
| 83 | - | action.headers_mut().insert("content-type", content_type); |
|
| 84 | - | let url = action.sign(SIGN_TTL); |
|
| 85 | - | ||
| 86 | - | let resp = self |
|
| 87 | - | .http |
|
| 88 | - | .put(url) |
|
| 89 | - | .header("content-type", content_type) |
|
| 90 | - | .body(bytes) |
|
| 91 | - | .send() |
|
| 92 | - | .await?; |
|
| 93 | - | ||
| 94 | - | if !resp.status().is_success() { |
|
| 95 | - | let code = resp.status().as_u16(); |
|
| 96 | - | let body = resp.text().await.unwrap_or_default(); |
|
| 97 | - | return Err(R2Error::Status(code, body)); |
|
| 98 | - | } |
|
| 99 | - | Ok(()) |
|
| 100 | - | } |
|
| 101 | - | ||
| 102 | - | pub async fn delete_object(&self, key: &str) -> Result<(), R2Error> { |
|
| 103 | - | let action = actions::DeleteObject::new(&self.bucket, Some(&self.creds), key); |
|
| 104 | - | let url = action.sign(SIGN_TTL); |
|
| 105 | - | ||
| 106 | - | let resp = self.http.delete(url).send().await?; |
|
| 107 | - | let status = resp.status(); |
|
| 108 | - | if status.is_success() || status.as_u16() == 404 { |
|
| 109 | - | return Ok(()); |
|
| 110 | - | } |
|
| 111 | - | let body = resp.text().await.unwrap_or_default(); |
|
| 112 | - | Err(R2Error::Status(status.as_u16(), body)) |
|
| 113 | - | } |
|
| 114 | - | ||
| 115 | - | pub fn public_url_for(&self, key: &str) -> String { |
|
| 116 | - | format!("{}/{}", self.public_url, key) |
|
| 117 | - | } |
|
| 118 | - | } |
| 1 | - | <!DOCTYPE html> |
|
| 1 | + | {{define "admin_base.html"}}<!DOCTYPE html> |
|
| 2 | 2 | <html lang="en"> |
|
| 3 | 3 | <head> |
|
| 4 | 4 | <meta charset="UTF-8"> |
|
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>{% block title %}Admin{% endblock %}</title> |
|
| 7 | - | <link rel="icon" href="/static/favicons/favicon.ico"> |
|
| 6 | + | <title>{{block "title" .}}Admin{{end}}</title> |
|
| 7 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 8 | 8 | <meta name="theme-color" content="#121113" /> |
|
| 9 | 9 | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 10 | 10 | <link rel="stylesheet" href="/static/styles.css"> |
|
| 23 | 23 | </nav> |
|
| 24 | 24 | </header> |
|
| 25 | 25 | <main> |
|
| 26 | - | {% block content %}{% endblock %} |
|
| 26 | + | {{block "content" .}}{{end}} |
|
| 27 | 27 | </main> |
|
| 28 | 28 | </body> |
|
| 29 | - | </html> |
|
| 29 | + | </html>{{end}} |
|
| 1 | - | {% extends "admin_base.html" %} |
|
| 2 | - | {% block title %}Admin — Files{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 1 | + | {{define "admin_files.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Admin — Files{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | 4 | <div class="admin-toolbar"> |
|
| 5 | 5 | <h2>Files</h2> |
|
| 6 | 6 | </div> |
|
| 7 | - | {% if let Some(err) = error %} |
|
| 8 | - | <p class="error">{{ err }}</p> |
|
| 9 | - | {% endif %} |
|
| 10 | - | {% if success %} |
|
| 11 | - | <p class="success">File uploaded.</p> |
|
| 12 | - | {% endif %} |
|
| 7 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 8 | + | {{if .Success}}<p class="success">File uploaded.</p>{{end}} |
|
| 13 | 9 | <form method="POST" action="/admin/files/upload" enctype="multipart/form-data" class="form"> |
|
| 14 | 10 | <label for="file">upload file (max 10MB)</label> |
|
| 15 | 11 | <input type="file" id="file" name="file" required> |
|
| 16 | 12 | <button type="submit">upload</button> |
|
| 17 | 13 | </form> |
|
| 18 | - | {% if files.is_empty() %} |
|
| 14 | + | {{if not .Files}} |
|
| 19 | 15 | <p class="empty">no files yet</p> |
|
| 20 | - | {% else %} |
|
| 16 | + | {{else}} |
|
| 17 | + | {{$site := .SiteURL}} |
|
| 21 | 18 | <div class="admin-list"> |
|
| 22 | - | {% for file in files %} |
|
| 19 | + | {{range .Files}} |
|
| 23 | 20 | <div class="admin-list-item"> |
|
| 24 | - | {% if file.content_type.starts_with("image/") %} |
|
| 25 | - | <img src="/files/{{ file.filename }}" class="file-thumbnail" alt="{{ file.original_name }}"> |
|
| 26 | - | {% endif %} |
|
| 21 | + | {{if .IsImage}} |
|
| 22 | + | <img src="/files/{{.Filename}}" class="file-thumbnail" alt="{{.OriginalName}}"> |
|
| 23 | + | {{end}} |
|
| 27 | 24 | <div class="admin-list-info"> |
|
| 28 | - | <span class="admin-list-title">{{ file.original_name }}</span> |
|
| 25 | + | <span class="admin-list-title">{{.OriginalName}}</span> |
|
| 29 | 26 | <div class="admin-list-meta"> |
|
| 30 | - | <span class="admin-list-date">{{ file.content_type }}</span> |
|
| 31 | - | <span class="admin-list-date">{{ file.size|filesizeformat }}</span> |
|
| 32 | - | <span class="admin-list-date">{{ file.created_at }}</span> |
|
| 27 | + | <span class="admin-list-date">{{.ContentType}}</span> |
|
| 28 | + | <span class="admin-list-date">{{.SizeHuman}}</span> |
|
| 29 | + | <span class="admin-list-date">{{.CreatedAt}}</span> |
|
| 33 | 30 | </div> |
|
| 34 | 31 | </div> |
|
| 35 | 32 | <div class="admin-list-actions"> |
|
| 36 | 33 | <button type="button" class="link-button" |
|
| 37 | - | onclick="navigator.clipboard.writeText('{{ site_url }}/files/{{ file.filename }}');this.textContent='copied!'"> |
|
| 34 | + | onclick="navigator.clipboard.writeText('{{$site}}/files/{{.Filename}}');this.textContent='copied!'"> |
|
| 38 | 35 | copy url |
|
| 39 | 36 | </button> |
|
| 40 | 37 | <button type="button" class="link-button" |
|
| 41 | - | onclick="navigator.clipboard.writeText('');this.textContent='copied!'"> |
|
| 38 | + | onclick="navigator.clipboard.writeText('');this.textContent='copied!'"> |
|
| 42 | 39 | copy md |
|
| 43 | 40 | </button> |
|
| 44 | - | <form method="POST" action="/admin/files/{{ file.short_id }}/delete" class="inline-form"> |
|
| 41 | + | <form method="POST" action="/admin/files/{{.ShortID}}/delete" class="inline-form"> |
|
| 45 | 42 | <button type="submit" class="link-button danger" onclick="return confirm('Delete this file?')">delete</button> |
|
| 46 | 43 | </form> |
|
| 47 | 44 | </div> |
|
| 48 | 45 | </div> |
|
| 49 | - | {% endfor %} |
|
| 46 | + | {{end}} |
|
| 50 | 47 | </div> |
|
| 51 | - | {% endif %} |
|
| 52 | - | {% endblock %} |
|
| 48 | + | {{end}} |
|
| 49 | + | {{end}} |
| 1 | - | {% extends "admin_base.html" %} |
|
| 2 | - | {% block title %}Admin — Import{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 1 | + | {{define "admin_import.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Admin — Import{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | 4 | <div class="admin-toolbar"> |
|
| 5 | 5 | <h2>Import posts</h2> |
|
| 6 | 6 | </div> |
|
| 7 | - | {% if let Some(err) = error %} |
|
| 8 | - | <p class="error">{{ err }}</p> |
|
| 9 | - | {% endif %} |
|
| 10 | - | {% if let Some(n) = imported %} |
|
| 11 | - | <p class="success">Imported {{ n }} posts, skipped {{ skipped.unwrap_or(0) }}.</p> |
|
| 12 | - | {% endif %} |
|
| 7 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 8 | + | {{if .Imported}} |
|
| 9 | + | <p class="success">Imported {{.Imported}} posts, skipped {{if .Skipped}}{{.Skipped}}{{else}}0{{end}}.</p> |
|
| 10 | + | {{end}} |
|
| 13 | 11 | <form method="POST" action="/admin/import" enctype="multipart/form-data" class="form"> |
|
| 14 | 12 | <label for="zip">upload zip of markdown files (max 50MB)</label> |
|
| 15 | 13 | <input type="file" id="zip" name="zip" accept=".zip" required> |
|
| 34 | 32 | </code></pre> |
|
| 35 | 33 | <p>Supported keys: <code>title</code>, <code>slug</code>, <code>status</code> (<code>draft</code> or <code>published</code>), <code>published_date</code>, <code>tags</code>, <code>description</code>, <code>meta_image</code>, <code>alias</code>, <code>lang</code>. Files without frontmatter are imported with the title derived from the filename. Posts whose slug already exists are skipped.</p> |
|
| 36 | 34 | </section> |
|
| 37 | - | {% endblock %} |
|
| 35 | + | {{end}} |
|
| 1 | - | {% extends "admin_base.html" %} |
|
| 2 | - | {% block title %}Admin — Posts{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 1 | + | {{define "admin_index.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Admin — Posts{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | 4 | <div class="admin-toolbar"> |
|
| 5 | 5 | <h2>Posts</h2> |
|
| 6 | 6 | <a href="/admin/posts/new" class="btn">new post</a> |
|
| 7 | 7 | </div> |
|
| 8 | - | {% if posts.is_empty() %} |
|
| 8 | + | {{if not .Posts}} |
|
| 9 | 9 | <p class="empty">no posts yet</p> |
|
| 10 | - | {% else %} |
|
| 10 | + | {{else}} |
|
| 11 | 11 | <div class="admin-list"> |
|
| 12 | - | {% for post in posts %} |
|
| 12 | + | {{range .Posts}} |
|
| 13 | 13 | <div class="admin-list-item"> |
|
| 14 | 14 | <div class="admin-list-info"> |
|
| 15 | - | <a href="/admin/posts/{{ post.short_id }}/edit" class="admin-list-title">{{ post.display_title() }}</a> |
|
| 15 | + | <a href="/admin/posts/{{.ShortID}}/edit" class="admin-list-title">{{.DisplayTitle}}</a> |
|
| 16 | 16 | <div class="admin-list-meta"> |
|
| 17 | - | <span class="status-badge {% if post.status == "published" %}status-published{% else %}status-draft{% endif %}">{{ post.status }}</span> |
|
| 18 | - | <span class="admin-list-date">{{ post.updated_at }}</span> |
|
| 17 | + | <span class="status-badge {{if eq .Status "published"}}status-published{{else}}status-draft{{end}}">{{.Status}}</span> |
|
| 18 | + | <span class="admin-list-date">{{.UpdatedAt}}</span> |
|
| 19 | 19 | </div> |
|
| 20 | 20 | </div> |
|
| 21 | 21 | <div class="admin-list-actions"> |
|
| 22 | - | <a href="/admin/posts/{{ post.short_id }}/edit">edit</a> |
|
| 23 | - | <form method="POST" action="/admin/posts/{{ post.short_id }}/publish" class="inline-form"> |
|
| 22 | + | <a href="/admin/posts/{{.ShortID}}/edit">edit</a> |
|
| 23 | + | <form method="POST" action="/admin/posts/{{.ShortID}}/publish" class="inline-form"> |
|
| 24 | 24 | <button type="submit" class="link-button"> |
|
| 25 | - | {% if post.status == "published" %}unpublish{% else %}publish{% endif %} |
|
| 25 | + | {{if eq .Status "published"}}unpublish{{else}}publish{{end}} |
|
| 26 | 26 | </button> |
|
| 27 | 27 | </form> |
|
| 28 | - | <form method="POST" action="/admin/posts/{{ post.short_id }}/delete" class="inline-form"> |
|
| 28 | + | <form method="POST" action="/admin/posts/{{.ShortID}}/delete" class="inline-form"> |
|
| 29 | 29 | <button type="submit" class="link-button danger" onclick="return confirm('Delete this post?')">delete</button> |
|
| 30 | 30 | </form> |
|
| 31 | 31 | </div> |
|
| 32 | 32 | </div> |
|
| 33 | - | {% endfor %} |
|
| 33 | + | {{end}} |
|
| 34 | 34 | </div> |
|
| 35 | - | {% endif %} |
|
| 36 | - | {% endblock %} |
|
| 35 | + | {{end}} |
|
| 36 | + | {{end}} |
| 1 | - | {% extends "admin_base.html" %} |
|
| 2 | - | {% block title %}Admin — {% if page.is_some() %}Edit Page{% else %}New Page{% endif %}{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | <h2>{% if page.is_some() %}Edit Page{% else %}New Page{% endif %}</h2> |
|
| 5 | - | {% if let Some(error) = error %} |
|
| 6 | - | <p class="error">{{ error }}</p> |
|
| 7 | - | {% endif %} |
|
| 8 | - | {% match page %} |
|
| 9 | - | {% when Some with (p) %} |
|
| 10 | - | <form method="POST" action="/admin/pages/{{ p.short_id }}" class="form post-form"> |
|
| 11 | - | <textarea name="attributes" class="attributes-textarea">title: {{ p.title }} |
|
| 12 | - | slug: {{ p.slug }} |
|
| 13 | - | published: {{ p.is_published }}</textarea> |
|
| 14 | - | <details class="available-fields"> |
|
| 15 | - | <summary>available fields</summary> |
|
| 16 | - | <div class="fields-list"> |
|
| 17 | - | <span>title: My Page Title</span> |
|
| 18 | - | <span>slug: my-page-slug</span> |
|
| 19 | - | <span>published: true</span> |
|
| 20 | - | </div> |
|
| 21 | - | </details> |
|
| 22 | - | <label for="content">content</label> |
|
| 23 | - | <textarea id="content" name="content" class="post-content">{{ p.content }}</textarea> |
|
| 24 | - | <button type="submit">save</button> |
|
| 25 | - | </form> |
|
| 26 | - | {% when None %} |
|
| 27 | - | <form method="POST" action="/admin/pages/create" class="form post-form"> |
|
| 28 | - | <textarea name="attributes" class="attributes-textarea">title: |
|
| 1 | + | {{define "admin_page_form.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Admin — {{if .Page}}Edit Page{{else}}New Page{{end}}{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | + | <h2>{{if .Page}}Edit Page{{else}}New Page{{end}}</h2> |
|
| 5 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 6 | + | {{$p := .Page}} |
|
| 7 | + | {{if $p}} |
|
| 8 | + | <form method="POST" action="/admin/pages/{{$p.ShortID}}" class="form post-form"> |
|
| 9 | + | <textarea name="attributes" class="attributes-textarea">title: {{$p.Title}} |
|
| 10 | + | slug: {{$p.Slug}} |
|
| 11 | + | published: {{$p.IsPublished}}</textarea> |
|
| 12 | + | <details class="available-fields"> |
|
| 13 | + | <summary>available fields</summary> |
|
| 14 | + | <div class="fields-list"> |
|
| 15 | + | <span>title: My Page Title</span> |
|
| 16 | + | <span>slug: my-page-slug</span> |
|
| 17 | + | <span>published: true</span> |
|
| 18 | + | </div> |
|
| 19 | + | </details> |
|
| 20 | + | <label for="content">content</label> |
|
| 21 | + | <textarea id="content" name="content" class="post-content">{{$p.Content}}</textarea> |
|
| 22 | + | <button type="submit">save</button> |
|
| 23 | + | </form> |
|
| 24 | + | {{else}} |
|
| 25 | + | <form method="POST" action="/admin/pages/create" class="form post-form"> |
|
| 26 | + | <textarea name="attributes" class="attributes-textarea">title: |
|
| 29 | 27 | slug: |
|
| 30 | 28 | published: false</textarea> |
|
| 31 | - | <details class="available-fields"> |
|
| 32 | - | <summary>available fields</summary> |
|
| 33 | - | <div class="fields-list"> |
|
| 34 | - | <span>title: My Page Title</span> |
|
| 35 | - | <span>slug: my-page-slug</span> |
|
| 36 | - | <span>published: true</span> |
|
| 37 | - | </div> |
|
| 38 | - | </details> |
|
| 39 | - | <label for="content">content</label> |
|
| 40 | - | <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea> |
|
| 41 | - | <button type="submit">save</button> |
|
| 42 | - | </form> |
|
| 43 | - | {% endmatch %} |
|
| 44 | - | {% endblock %} |
|
| 29 | + | <details class="available-fields"> |
|
| 30 | + | <summary>available fields</summary> |
|
| 31 | + | <div class="fields-list"> |
|
| 32 | + | <span>title: My Page Title</span> |
|
| 33 | + | <span>slug: my-page-slug</span> |
|
| 34 | + | <span>published: true</span> |
|
| 35 | + | </div> |
|
| 36 | + | </details> |
|
| 37 | + | <label for="content">content</label> |
|
| 38 | + | <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea> |
|
| 39 | + | <button type="submit">save</button> |
|
| 40 | + | </form> |
|
| 41 | + | {{end}} |
|
| 42 | + | {{end}} |
| 1 | - | {% extends "admin_base.html" %} |
|
| 2 | - | {% block title %}Admin — Pages{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 1 | + | {{define "admin_pages.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Admin — Pages{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | 4 | <div class="admin-toolbar"> |
|
| 5 | 5 | <h2>Pages</h2> |
|
| 6 | 6 | <a href="/admin/pages/new" class="btn">new page</a> |
|
| 7 | 7 | </div> |
|
| 8 | - | {% if pages.is_empty() %} |
|
| 8 | + | {{if not .Pages}} |
|
| 9 | 9 | <p class="empty">no pages yet</p> |
|
| 10 | - | {% else %} |
|
| 10 | + | {{else}} |
|
| 11 | 11 | <div class="admin-list"> |
|
| 12 | - | {% for page in pages %} |
|
| 12 | + | {{range .Pages}} |
|
| 13 | 13 | <div class="admin-list-item"> |
|
| 14 | 14 | <div class="admin-list-info"> |
|
| 15 | - | <a href="/admin/pages/{{ page.short_id }}/edit" class="admin-list-title">{{ page.title }}</a> |
|
| 15 | + | <a href="/admin/pages/{{.ShortID}}/edit" class="admin-list-title">{{.Title}}</a> |
|
| 16 | 16 | <div class="admin-list-meta"> |
|
| 17 | - | <span class="status-badge {% if page.is_published %}status-published{% else %}status-draft{% endif %}"> |
|
| 18 | - | {% if page.is_published %}published{% else %}draft{% endif %} |
|
| 17 | + | <span class="status-badge {{if .IsPublished}}status-published{{else}}status-draft{{end}}"> |
|
| 18 | + | {{if .IsPublished}}published{{else}}draft{{end}} |
|
| 19 | 19 | </span> |
|
| 20 | - | <span class="admin-list-date">/{{ page.slug }}</span> |
|
| 21 | - | <span class="admin-list-date">order: {{ page.nav_order }}</span> |
|
| 20 | + | <span class="admin-list-date">/{{.Slug}}</span> |
|
| 21 | + | <span class="admin-list-date">order: {{.NavOrder}}</span> |
|
| 22 | 22 | </div> |
|
| 23 | 23 | </div> |
|
| 24 | 24 | <div class="admin-list-actions"> |
|
| 25 | - | <a href="/admin/pages/{{ page.short_id }}/edit">edit</a> |
|
| 26 | - | <form method="POST" action="/admin/pages/{{ page.short_id }}/delete" class="inline-form"> |
|
| 25 | + | <a href="/admin/pages/{{.ShortID}}/edit">edit</a> |
|
| 26 | + | <form method="POST" action="/admin/pages/{{.ShortID}}/delete" class="inline-form"> |
|
| 27 | 27 | <button type="submit" class="link-button danger" onclick="return confirm('Delete this page?')">delete</button> |
|
| 28 | 28 | </form> |
|
| 29 | 29 | </div> |
|
| 30 | 30 | </div> |
|
| 31 | - | {% endfor %} |
|
| 31 | + | {{end}} |
|
| 32 | 32 | </div> |
|
| 33 | - | {% endif %} |
|
| 34 | - | {% endblock %} |
|
| 33 | + | {{end}} |
|
| 34 | + | {{end}} |
| 1 | - | {% extends "admin_base.html" %} |
|
| 2 | - | {% block title %}Admin — {% if post.is_some() %}Edit Post{% else %}New Post{% endif %}{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 4 | - | <h2>{% if post.is_some() %}Edit Post{% else %}New Post{% endif %}</h2> |
|
| 5 | - | {% if let Some(error) = error %} |
|
| 6 | - | <p class="error">{{ error }}</p> |
|
| 7 | - | {% endif %} |
|
| 8 | - | {% match post %} |
|
| 9 | - | {% when Some with (p) %} |
|
| 10 | - | <form method="POST" action="/admin/posts/{{ p.short_id }}" class="form post-form"> |
|
| 11 | - | <textarea name="attributes" class="attributes-textarea">title: {{ p.title.as_deref().unwrap_or("") }} |
|
| 12 | - | slug: {{ p.slug }} |
|
| 13 | - | {%- if p.published_date.is_some() %} |
|
| 14 | - | published_date: {{ p.published_date.as_deref().unwrap_or_default() }} |
|
| 15 | - | {%- endif %} |
|
| 16 | - | {%- if p.lang != "en" %} |
|
| 17 | - | lang: {{ p.lang }} |
|
| 18 | - | {%- endif %} |
|
| 19 | - | {%- if p.tags.is_some() %} |
|
| 20 | - | tags: {{ p.tags.as_deref().unwrap_or_default() }} |
|
| 21 | - | {%- endif %} |
|
| 22 | - | {%- if p.alias.is_some() %} |
|
| 23 | - | alias: {{ p.alias.as_deref().unwrap_or_default() }} |
|
| 24 | - | {%- endif %} |
|
| 25 | - | {%- if p.meta_image.is_some() %} |
|
| 26 | - | meta_image: {{ p.meta_image.as_deref().unwrap_or_default() }} |
|
| 27 | - | {%- endif %} |
|
| 28 | - | {%- if p.meta_description.is_some() %} |
|
| 29 | - | description: {{ p.meta_description.as_deref().unwrap_or_default() }} |
|
| 30 | - | {%- endif %}</textarea> |
|
| 31 | - | <details class="available-fields"> |
|
| 32 | - | <summary>available fields</summary> |
|
| 33 | - | <div class="fields-list"> |
|
| 34 | - | <span>title: My Post Title</span> |
|
| 35 | - | <span>slug: my-post-title</span> |
|
| 36 | - | <span>published_date: 2025-01-15 14:30:00</span> |
|
| 37 | - | <span>lang: en</span> |
|
| 38 | - | <span>tags: rust, web, tutorial</span> |
|
| 39 | - | <span>alias: /old/path</span> |
|
| 40 | - | <span>meta_image: https://example.com/image.jpg</span> |
|
| 41 | - | <span>description: A short summary of the post</span> |
|
| 42 | - | </div> |
|
| 43 | - | </details> |
|
| 44 | - | <label for="content">content</label> |
|
| 45 | - | <textarea id="content" name="content" class="post-content">{{ p.content }}</textarea> |
|
| 46 | - | <div class="form-actions"> |
|
| 47 | - | {% if p.status == "published" %} |
|
| 48 | - | <button type="submit" name="action" value="publish">update</button> |
|
| 49 | - | <button type="submit" name="action" value="draft">unpublish</button> |
|
| 50 | - | {% else %} |
|
| 51 | - | <button type="submit" name="action" value="draft">save draft</button> |
|
| 52 | - | <button type="submit" name="action" value="publish">publish</button> |
|
| 53 | - | {% endif %} |
|
| 1 | + | {{define "admin_post_form.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Admin — {{if .Post}}Edit Post{{else}}New Post{{end}}{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | + | <h2>{{if .Post}}Edit Post{{else}}New Post{{end}}</h2> |
|
| 5 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 6 | + | {{$p := .Post}} |
|
| 7 | + | {{if $p}} |
|
| 8 | + | <form method="POST" action="/admin/posts/{{$p.ShortID}}" class="form post-form"> |
|
| 9 | + | <textarea name="attributes" class="attributes-textarea">title: {{$p.TitleStr}} |
|
| 10 | + | slug: {{$p.Slug}} |
|
| 11 | + | {{if $p.PublishedDate}}published_date: {{$p.PublishedDateStr}} |
|
| 12 | + | {{end}}{{if ne $p.Lang "en"}}lang: {{$p.Lang}} |
|
| 13 | + | {{end}}{{if $p.Tags}}tags: {{$p.TagsStr}} |
|
| 14 | + | {{end}}{{if $p.Alias}}alias: {{$p.AliasStr}} |
|
| 15 | + | {{end}}{{if $p.MetaImage}}meta_image: {{$p.MetaImageStr}} |
|
| 16 | + | {{end}}{{if $p.MetaDescription}}description: {{$p.MetaDescriptionStr}}{{end}}</textarea> |
|
| 17 | + | <details class="available-fields"> |
|
| 18 | + | <summary>available fields</summary> |
|
| 19 | + | <div class="fields-list"> |
|
| 20 | + | <span>title: My Post Title</span> |
|
| 21 | + | <span>slug: my-post-title</span> |
|
| 22 | + | <span>published_date: 2025-01-15 14:30:00</span> |
|
| 23 | + | <span>lang: en</span> |
|
| 24 | + | <span>tags: rust, web, tutorial</span> |
|
| 25 | + | <span>alias: /old/path</span> |
|
| 26 | + | <span>meta_image: https://example.com/image.jpg</span> |
|
| 27 | + | <span>description: A short summary of the post</span> |
|
| 54 | 28 | </div> |
|
| 55 | - | </form> |
|
| 56 | - | {% when None %} |
|
| 57 | - | <form method="POST" action="/admin/posts" class="form post-form"> |
|
| 58 | - | <textarea name="attributes" class="attributes-textarea">title: </textarea> |
|
| 59 | - | <details class="available-fields"> |
|
| 60 | - | <summary>available fields</summary> |
|
| 61 | - | <div class="fields-list"> |
|
| 62 | - | <span>title: My Post Title</span> |
|
| 63 | - | <span>slug: my-post-title</span> |
|
| 64 | - | <span>published_date: 2025-01-15 14:30:00</span> |
|
| 65 | - | <span>lang: en</span> |
|
| 66 | - | <span>tags: rust, web, tutorial</span> |
|
| 67 | - | <span>alias: /old/path</span> |
|
| 68 | - | <span>meta_image: https://example.com/image.jpg</span> |
|
| 69 | - | <span>description: A short summary of the post</span> |
|
| 70 | - | </div> |
|
| 71 | - | </details> |
|
| 72 | - | <label for="content">content</label> |
|
| 73 | - | <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea> |
|
| 74 | - | <div class="form-actions"> |
|
| 29 | + | </details> |
|
| 30 | + | <label for="content">content</label> |
|
| 31 | + | <textarea id="content" name="content" class="post-content">{{$p.Content}}</textarea> |
|
| 32 | + | <div class="form-actions"> |
|
| 33 | + | {{if eq $p.Status "published"}} |
|
| 34 | + | <button type="submit" name="action" value="publish">update</button> |
|
| 35 | + | <button type="submit" name="action" value="draft">unpublish</button> |
|
| 36 | + | {{else}} |
|
| 75 | 37 | <button type="submit" name="action" value="draft">save draft</button> |
|
| 76 | 38 | <button type="submit" name="action" value="publish">publish</button> |
|
| 39 | + | {{end}} |
|
| 40 | + | </div> |
|
| 41 | + | </form> |
|
| 42 | + | {{else}} |
|
| 43 | + | <form method="POST" action="/admin/posts" class="form post-form"> |
|
| 44 | + | <textarea name="attributes" class="attributes-textarea">title: </textarea> |
|
| 45 | + | <details class="available-fields"> |
|
| 46 | + | <summary>available fields</summary> |
|
| 47 | + | <div class="fields-list"> |
|
| 48 | + | <span>title: My Post Title</span> |
|
| 49 | + | <span>slug: my-post-title</span> |
|
| 50 | + | <span>published_date: 2025-01-15 14:30:00</span> |
|
| 51 | + | <span>lang: en</span> |
|
| 52 | + | <span>tags: rust, web, tutorial</span> |
|
| 53 | + | <span>alias: /old/path</span> |
|
| 54 | + | <span>meta_image: https://example.com/image.jpg</span> |
|
| 55 | + | <span>description: A short summary of the post</span> |
|
| 77 | 56 | </div> |
|
| 78 | - | </form> |
|
| 79 | - | {% endmatch %} |
|
| 80 | - | {% endblock %} |
|
| 57 | + | </details> |
|
| 58 | + | <label for="content">content</label> |
|
| 59 | + | <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea> |
|
| 60 | + | <div class="form-actions"> |
|
| 61 | + | <button type="submit" name="action" value="draft">save draft</button> |
|
| 62 | + | <button type="submit" name="action" value="publish">publish</button> |
|
| 63 | + | </div> |
|
| 64 | + | </form> |
|
| 65 | + | {{end}} |
|
| 66 | + | {{end}} |
| 1 | - | {% extends "admin_base.html" %} |
|
| 2 | - | {% block title %}Admin — Settings{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 1 | + | {{define "admin_settings.html"}}{{template "admin_base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Admin — Settings{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | 4 | <h2>Settings</h2> |
|
| 5 | - | {% if success %} |
|
| 6 | - | <p class="success">Settings saved.</p> |
|
| 7 | - | {% endif %} |
|
| 5 | + | {{if .Success}}<p class="success">Settings saved.</p>{{end}} |
|
| 8 | 6 | <form method="POST" action="/admin/settings" class="form"> |
|
| 9 | 7 | <label for="blog_title">blog title</label> |
|
| 10 | - | <input type="text" id="blog_title" name="blog_title" value="{{ blog_title }}" required> |
|
| 8 | + | <input type="text" id="blog_title" name="blog_title" value="{{.BlogTitle}}" required> |
|
| 11 | 9 | <label for="blog_description">blog description</label> |
|
| 12 | - | <input type="text" id="blog_description" name="blog_description" value="{{ blog_description }}"> |
|
| 10 | + | <input type="text" id="blog_description" name="blog_description" value="{{.BlogDescription}}"> |
|
| 13 | 11 | <label for="nav_links">navigation links (format: [label](url) [label2](url2))</label> |
|
| 14 | - | <textarea id="nav_links" name="nav_links" class="nav-links-input">{{ nav_links }}</textarea> |
|
| 12 | + | <textarea id="nav_links" name="nav_links" class="nav-links-input">{{.NavLinksRaw}}</textarea> |
|
| 15 | 13 | <label for="favicon_url">favicon URL (leave empty for default)</label> |
|
| 16 | - | <input type="text" id="favicon_url" name="favicon_url" value="{{ favicon_url }}" placeholder="https://example.com/favicon.png"> |
|
| 14 | + | <input type="text" id="favicon_url" name="favicon_url" value="{{.FaviconURL}}" placeholder="https://example.com/favicon.png"> |
|
| 17 | 15 | <label for="og_image_url">default OG image URL (used when posts don't have their own)</label> |
|
| 18 | - | <input type="text" id="og_image_url" name="og_image_url" value="{{ og_image_url }}" placeholder="https://example.com/og.png"> |
|
| 16 | + | <input type="text" id="og_image_url" name="og_image_url" value="{{.OGImageURL}}" placeholder="https://example.com/og.png"> |
|
| 19 | 17 | <label for="intro_content">intro content (markdown, shown on homepage — use {{latest_posts}} to embed recent posts)</label> |
|
| 20 | - | <textarea id="intro_content" name="intro_content" class="post-content">{{ intro_content }}</textarea> |
|
| 18 | + | <textarea id="intro_content" name="intro_content" class="post-content">{{.IntroContent}}</textarea> |
|
| 21 | 19 | <div class="switch-row"> |
|
| 22 | 20 | <label class="switch"> |
|
| 23 | - | <input type="checkbox" id="custom_css_toggle" {% if !custom_css.is_empty() %}checked{% endif %}> |
|
| 21 | + | <input type="checkbox" id="custom_css_toggle" {{if .CustomCSS}}checked{{end}}> |
|
| 24 | 22 | <span class="switch-slider"></span> |
|
| 25 | 23 | </label> |
|
| 26 | 24 | <span class="switch-label">custom stylesheet</span> |
|
| 27 | 25 | </div> |
|
| 28 | - | <div id="custom_css_section" {% if custom_css.is_empty() %}class="hidden"{% endif %}> |
|
| 26 | + | <div id="custom_css_section" {{if not .CustomCSS}}class="hidden"{{end}}> |
|
| 29 | 27 | <label for="custom_css">custom CSS (overrides default styles)</label> |
|
| 30 | - | <textarea id="custom_css" name="custom_css" class="post-content">{% if custom_css.is_empty() %}{{ default_css }}{% else %}{{ custom_css }}{% endif %}</textarea> |
|
| 28 | + | <textarea id="custom_css" name="custom_css" class="post-content">{{if .CustomCSS}}{{.CustomCSS}}{{else}}{{.DefaultCSS}}{{end}}</textarea> |
|
| 31 | 29 | </div> |
|
| 32 | 30 | <label for="custom_header">custom header (markdown or HTML, shown above nav on all pages)</label> |
|
| 33 | - | <textarea id="custom_header" name="custom_header" class="nav-links-input">{{ custom_header }}</textarea> |
|
| 31 | + | <textarea id="custom_header" name="custom_header" class="nav-links-input">{{.CustomHeader}}</textarea> |
|
| 34 | 32 | <label for="custom_footer">custom footer (markdown or HTML, shown at bottom of all pages)</label> |
|
| 35 | - | <textarea id="custom_footer" name="custom_footer" class="post-content">{{ custom_footer }}</textarea> |
|
| 33 | + | <textarea id="custom_footer" name="custom_footer" class="post-content">{{.CustomFooter}}</textarea> |
|
| 36 | 34 | <button type="submit">save</button> |
|
| 37 | 35 | </form> |
|
| 38 | 36 | <h3>Data Export</h3> |
|
| 48 | 46 | section.classList.toggle('hidden', !this.checked); |
|
| 49 | 47 | }); |
|
| 50 | 48 | document.querySelector('form').addEventListener('submit', function() { |
|
| 51 | - | if (!toggle.checked) { |
|
| 52 | - | cssTextarea.value = ''; |
|
| 53 | - | } |
|
| 49 | + | if (!toggle.checked) { cssTextarea.value = ''; } |
|
| 54 | 50 | }); |
|
| 55 | 51 | </script> |
|
| 56 | - | {% endblock %} |
|
| 52 | + | {{end}} |
|
| 1 | - | <!DOCTYPE html> |
|
| 1 | + | {{define "base.html"}}<!DOCTYPE html> |
|
| 2 | 2 | <html lang="en"> |
|
| 3 | 3 | <head> |
|
| 4 | 4 | <meta charset="UTF-8"> |
|
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <title>{% block title %}{{ blog_title }}{% endblock %}</title> |
|
| 7 | - | {% if !favicon_url.is_empty() %} |
|
| 8 | - | <link rel="apple-touch-icon" sizes="180x180" href="{{ favicon_url }}"> |
|
| 9 | - | <link rel="icon" type="image/png" sizes="32x32" href="{{ favicon_url }}"> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="16x16" href="{{ favicon_url }}"> |
|
| 11 | - | {% else %} |
|
| 6 | + | <title>{{block "title" .}}{{.BlogTitle}}{{end}}</title> |
|
| 7 | + | {{if .FaviconURL}} |
|
| 8 | + | <link rel="apple-touch-icon" sizes="180x180" href="{{.FaviconURL}}"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="32x32" href="{{.FaviconURL}}"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="16x16" href="{{.FaviconURL}}"> |
|
| 11 | + | {{else}} |
|
| 12 | 12 | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 13 | 13 | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 14 | 14 | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 15 | 15 | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 16 | - | {% endif %} |
|
| 16 | + | {{end}} |
|
| 17 | 17 | <meta name="theme-color" content="#121113" /> |
|
| 18 | - | {% block meta %}{% endblock %} |
|
| 18 | + | {{block "meta" .}}{{end}} |
|
| 19 | 19 | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 20 | 20 | <link rel="stylesheet" href="/static/styles.css"> |
|
| 21 | 21 | <link rel="stylesheet" href="/custom-styles.css"> |
|
| 22 | 22 | </head> |
|
| 23 | 23 | <body> |
|
| 24 | - | {% if !header_html.is_empty() %} |
|
| 24 | + | {{if .HeaderHTML}} |
|
| 25 | 25 | <div class="custom-header"> |
|
| 26 | - | {{ header_html|safe }} |
|
| 26 | + | {{.HeaderHTML}} |
|
| 27 | 27 | </div> |
|
| 28 | - | {% endif %} |
|
| 28 | + | {{end}} |
|
| 29 | 29 | <header class="header"> |
|
| 30 | - | <a href="/" class="logo">{{ blog_title }}</a> |
|
| 30 | + | <a href="/" class="logo">{{.BlogTitle}}</a> |
|
| 31 | 31 | <nav class="links"> |
|
| 32 | - | {% for link in nav_links %} |
|
| 33 | - | <a href="{{ link.url }}">{{ link.label }}</a> |
|
| 34 | - | {% endfor %} |
|
| 32 | + | {{range .NavLinks}} |
|
| 33 | + | <a href="{{.URL}}">{{.Label}}</a> |
|
| 34 | + | {{end}} |
|
| 35 | 35 | </nav> |
|
| 36 | 36 | </header> |
|
| 37 | 37 | <main> |
|
| 38 | - | {% block content %}{% endblock %} |
|
| 38 | + | {{block "content" .}}{{end}} |
|
| 39 | 39 | </main> |
|
| 40 | 40 | <footer class="footer"> |
|
| 41 | - | {{ footer_html|safe }} |
|
| 41 | + | {{.FooterHTML}} |
|
| 42 | 42 | </footer> |
|
| 43 | 43 | <script> |
|
| 44 | 44 | document.querySelectorAll('.post-date').forEach(el => { |
|
| 51 | 51 | }); |
|
| 52 | 52 | </script> |
|
| 53 | 53 | </body> |
|
| 54 | - | </html> |
|
| 54 | + | </html>{{end}} |
|
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}{{ blog_title }}{% endblock %} |
|
| 3 | - | {% block meta %} |
|
| 4 | - | <meta name="description" content="{{ blog_description }}"> |
|
| 5 | - | <meta property="og:title" content="{{ blog_title }}"> |
|
| 6 | - | <meta property="og:description" content="{{ blog_description }}"> |
|
| 1 | + | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{.BlogTitle}}{{end}} |
|
| 3 | + | {{define "meta"}} |
|
| 4 | + | <meta name="description" content="{{.BlogDescription}}"> |
|
| 5 | + | <meta property="og:title" content="{{.BlogTitle}}"> |
|
| 6 | + | <meta property="og:description" content="{{.BlogDescription}}"> |
|
| 7 | 7 | <meta property="og:type" content="website"> |
|
| 8 | - | <meta property="og:url" content="{{ site_url }}"> |
|
| 9 | - | {% if !og_image_url.is_empty() %} |
|
| 10 | - | <meta property="og:image" content="{{ og_image_url }}"> |
|
| 11 | - | {% else %} |
|
| 12 | - | <meta property="og:image" content="{{ site_url }}/static/og.png"> |
|
| 13 | - | {% endif %} |
|
| 14 | - | {% endblock %} |
|
| 15 | - | {% block content %} |
|
| 16 | - | {% if !intro_html.is_empty() %} |
|
| 8 | + | <meta property="og:url" content="{{.SiteURL}}"> |
|
| 9 | + | {{if .OGImageURL}} |
|
| 10 | + | <meta property="og:image" content="{{.OGImageURL}}"> |
|
| 11 | + | {{else}} |
|
| 12 | + | <meta property="og:image" content="{{.SiteURL}}/static/og.png"> |
|
| 13 | + | {{end}} |
|
| 14 | + | {{end}} |
|
| 15 | + | {{define "content"}} |
|
| 16 | + | {{if .IntroHTML}} |
|
| 17 | 17 | <article class="intro markdown-body"> |
|
| 18 | - | {{ intro_html|safe }} |
|
| 18 | + | {{.IntroHTML}} |
|
| 19 | 19 | </article> |
|
| 20 | - | {% endif %} |
|
| 21 | - | {% if posts.is_empty() %} |
|
| 20 | + | {{end}} |
|
| 21 | + | {{if not .Posts}} |
|
| 22 | 22 | <p class="empty">no posts yet</p> |
|
| 23 | - | {% endif %} |
|
| 23 | + | {{end}} |
|
| 24 | 24 | <div class="post-list"> |
|
| 25 | - | {% for post in posts %} |
|
| 26 | - | <a href="/posts/{{ post.slug }}" class="post-item"> |
|
| 25 | + | {{range .Posts}} |
|
| 26 | + | <a href="/posts/{{.Slug}}" class="post-item"> |
|
| 27 | 27 | <div class="post-item-info"> |
|
| 28 | - | <span class="post-title">{{ post.display_title() }}</span> |
|
| 28 | + | <span class="post-title">{{.DisplayTitle}}</span> |
|
| 29 | 29 | </div> |
|
| 30 | - | {% if post.published_date.is_some() %} |
|
| 31 | - | <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time> |
|
| 32 | - | {% endif %} |
|
| 30 | + | {{if .PublishedDate}} |
|
| 31 | + | <time class="post-date">{{.PublishedDateStr}}</time> |
|
| 32 | + | {{end}} |
|
| 33 | 33 | </a> |
|
| 34 | - | {% endfor %} |
|
| 34 | + | {{end}} |
|
| 35 | 35 | </div> |
|
| 36 | - | {% endblock %} |
|
| 36 | + | {{end}} |
| 1 | - | <!DOCTYPE html> |
|
| 1 | + | {{define "login.html"}}<!DOCTYPE html> |
|
| 2 | 2 | <html lang="en"> |
|
| 3 | 3 | <head> |
|
| 4 | 4 | <meta charset="UTF-8"> |
|
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | 6 | <title>Login</title> |
|
| 7 | - | <link rel="icon" href="/static/favicons/favicon.ico"> |
|
| 7 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 8 | 8 | <meta name="theme-color" content="#121113" /> |
|
| 9 | 9 | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 10 | 10 | <link rel="stylesheet" href="/static/styles.css"> |
|
| 14 | 14 | <span class="logo">POSTS</span> |
|
| 15 | 15 | </header> |
|
| 16 | 16 | <main> |
|
| 17 | - | {% if let Some(error) = error %} |
|
| 18 | - | <p class="error">{{ error }}</p> |
|
| 19 | - | {% endif %} |
|
| 17 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 20 | 18 | <form method="POST" action="/admin/login" class="form"> |
|
| 21 | 19 | <label for="password">password</label> |
|
| 22 | 20 | <input type="password" id="password" name="password" autofocus required> |
|
| 24 | 22 | </form> |
|
| 25 | 23 | </main> |
|
| 26 | 24 | </body> |
|
| 27 | - | </html> |
|
| 25 | + | </html>{{end}} |
|
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}{{ page.title }} — {{ blog_title }}{% endblock %} |
|
| 3 | - | {% block meta %} |
|
| 4 | - | {% if !og_image_url.is_empty() %} |
|
| 5 | - | <meta property="og:image" content="{{ og_image_url }}"> |
|
| 6 | - | {% else %} |
|
| 7 | - | <meta property="og:image" content="{{ site_url }}/static/og.png"> |
|
| 8 | - | {% endif %} |
|
| 9 | - | <meta property="og:url" content="{{ site_url }}/{{ page.slug }}"> |
|
| 10 | - | {% endblock %} |
|
| 11 | - | {% block content %} |
|
| 1 | + | {{define "page.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{.Page.Title}} — {{.BlogTitle}}{{end}} |
|
| 3 | + | {{define "meta"}} |
|
| 4 | + | {{if .OGImageURL}} |
|
| 5 | + | <meta property="og:image" content="{{.OGImageURL}}"> |
|
| 6 | + | {{else}} |
|
| 7 | + | <meta property="og:image" content="{{.SiteURL}}/static/og.png"> |
|
| 8 | + | {{end}} |
|
| 9 | + | <meta property="og:url" content="{{.SiteURL}}/{{.Page.Slug}}"> |
|
| 10 | + | {{end}} |
|
| 11 | + | {{define "content"}} |
|
| 12 | 12 | <div class="page-header"> |
|
| 13 | - | <h1>{{ page.title }}</h1> |
|
| 13 | + | <h1>{{.Page.Title}}</h1> |
|
| 14 | 14 | </div> |
|
| 15 | 15 | <article class="markdown-body"> |
|
| 16 | - | {{ rendered_content|safe }} |
|
| 16 | + | {{.RenderedContent}} |
|
| 17 | 17 | </article> |
|
| 18 | - | {% endblock %} |
|
| 18 | + | {{end}} |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}{{ post.display_title() }} — {{ blog_title }}{% endblock %} |
|
| 3 | - | {% block meta %} |
|
| 4 | - | {% if post.meta_description.is_some() %} |
|
| 5 | - | <meta name="description" content="{{ post.meta_description.as_deref().unwrap_or_default() }}"> |
|
| 6 | - | <meta property="og:description" content="{{ post.meta_description.as_deref().unwrap_or_default() }}"> |
|
| 7 | - | {% endif %} |
|
| 8 | - | {% if post.meta_image.is_some() && !post.meta_image.as_deref().unwrap_or_default().is_empty() %} |
|
| 9 | - | <meta property="og:image" content="{{ post.meta_image.as_deref().unwrap_or_default() }}"> |
|
| 10 | - | {% else if !og_image_url.is_empty() %} |
|
| 11 | - | <meta property="og:image" content="{{ og_image_url }}"> |
|
| 12 | - | {% else %} |
|
| 13 | - | <meta property="og:image" content="{{ site_url }}/static/og.png"> |
|
| 14 | - | {% endif %} |
|
| 15 | - | {% if post.canonical_url.is_some() %} |
|
| 16 | - | <link rel="canonical" href="{{ post.canonical_url.as_deref().unwrap_or_default() }}"> |
|
| 17 | - | {% endif %} |
|
| 18 | - | <meta property="og:title" content="{{ post.display_title() }}"> |
|
| 1 | + | {{define "post.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{.Post.DisplayTitle}} — {{.BlogTitle}}{{end}} |
|
| 3 | + | {{define "meta"}} |
|
| 4 | + | {{if .Post.MetaDescription}} |
|
| 5 | + | <meta name="description" content="{{.Post.MetaDescriptionStr}}"> |
|
| 6 | + | <meta property="og:description" content="{{.Post.MetaDescriptionStr}}"> |
|
| 7 | + | {{end}} |
|
| 8 | + | {{if and .Post.MetaImage .Post.MetaImageStr}} |
|
| 9 | + | <meta property="og:image" content="{{.Post.MetaImageStr}}"> |
|
| 10 | + | {{else if .OGImageURL}} |
|
| 11 | + | <meta property="og:image" content="{{.OGImageURL}}"> |
|
| 12 | + | {{else}} |
|
| 13 | + | <meta property="og:image" content="{{.SiteURL}}/static/og.png"> |
|
| 14 | + | {{end}} |
|
| 15 | + | {{if .Post.CanonicalURL}} |
|
| 16 | + | <link rel="canonical" href="{{.Post.CanonicalURLStr}}"> |
|
| 17 | + | {{end}} |
|
| 18 | + | <meta property="og:title" content="{{.Post.DisplayTitle}}"> |
|
| 19 | 19 | <meta property="og:type" content="article"> |
|
| 20 | - | <meta property="og:url" content="{{ site_url }}/posts/{{ post.slug }}"> |
|
| 21 | - | <meta property="article:published_time" content="{{ post.published_date.as_deref().unwrap_or_default() }}"> |
|
| 22 | - | {% endblock %} |
|
| 23 | - | {% block content %} |
|
| 20 | + | <meta property="og:url" content="{{.SiteURL}}/posts/{{.Post.Slug}}"> |
|
| 21 | + | <meta property="article:published_time" content="{{.Post.PublishedDateStr}}"> |
|
| 22 | + | {{end}} |
|
| 23 | + | {{define "content"}} |
|
| 24 | 24 | <div class="post-header"> |
|
| 25 | - | {% if let Some(title) = post.title.as_deref() %} |
|
| 26 | - | {% if !title.trim().is_empty() %} |
|
| 27 | - | <h1>{{ title }}</h1> |
|
| 28 | - | {% endif %} |
|
| 29 | - | {% endif %} |
|
| 30 | - | {% if post.meta_description.is_some() %} |
|
| 31 | - | <p class="post-description">{{ post.meta_description.as_deref().unwrap_or_default() }}</p> |
|
| 32 | - | {% endif %} |
|
| 33 | - | {% if post.published_date.is_some() %} |
|
| 34 | - | <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time> |
|
| 35 | - | {% endif %} |
|
| 36 | - | {% if post.tags.is_some() %} |
|
| 25 | + | {{if .Post.HasTitle}} |
|
| 26 | + | <h1>{{.Post.TitleStr}}</h1> |
|
| 27 | + | {{end}} |
|
| 28 | + | {{if .Post.MetaDescription}} |
|
| 29 | + | <p class="post-description">{{.Post.MetaDescriptionStr}}</p> |
|
| 30 | + | {{end}} |
|
| 31 | + | {{if .Post.PublishedDate}} |
|
| 32 | + | <time class="post-date">{{.Post.PublishedDateStr}}</time> |
|
| 33 | + | {{end}} |
|
| 34 | + | {{if .Post.Tags}} |
|
| 37 | 35 | <div class="post-tags"> |
|
| 38 | - | {% for tag in post.tags.as_deref().unwrap_or_default().split(',') %} |
|
| 39 | - | {% if !tag.trim().is_empty() %} |
|
| 40 | - | <span class="tag">{{ tag.trim() }}</span> |
|
| 41 | - | {% endif %} |
|
| 42 | - | {% endfor %} |
|
| 36 | + | {{range .Post.TagList}} |
|
| 37 | + | <span class="tag">{{.}}</span> |
|
| 38 | + | {{end}} |
|
| 43 | 39 | </div> |
|
| 44 | - | {% endif %} |
|
| 40 | + | {{end}} |
|
| 45 | 41 | </div> |
|
| 46 | 42 | <article class="markdown-body"> |
|
| 47 | - | {{ rendered_content|safe }} |
|
| 43 | + | {{.RenderedContent}} |
|
| 48 | 44 | </article> |
|
| 49 | - | {% endblock %} |
|
| 45 | + | {{end}} |
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}Posts — {{ blog_title }}{% endblock %} |
|
| 3 | - | {% block meta %} |
|
| 4 | - | {% if !og_image_url.is_empty() %} |
|
| 5 | - | <meta property="og:image" content="{{ og_image_url }}"> |
|
| 6 | - | {% else %} |
|
| 7 | - | <meta property="og:image" content="{{ site_url }}/static/og.png"> |
|
| 8 | - | {% endif %} |
|
| 9 | - | <meta property="og:url" content="{{ site_url }}/posts"> |
|
| 10 | - | {% endblock %} |
|
| 11 | - | {% block content %} |
|
| 1 | + | {{define "posts.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Posts — {{.BlogTitle}}{{end}} |
|
| 3 | + | {{define "meta"}} |
|
| 4 | + | {{if .OGImageURL}} |
|
| 5 | + | <meta property="og:image" content="{{.OGImageURL}}"> |
|
| 6 | + | {{else}} |
|
| 7 | + | <meta property="og:image" content="{{.SiteURL}}/static/og.png"> |
|
| 8 | + | {{end}} |
|
| 9 | + | <meta property="og:url" content="{{.SiteURL}}/posts"> |
|
| 10 | + | {{end}} |
|
| 11 | + | {{define "content"}} |
|
| 12 | 12 | <h1>Posts</h1> |
|
| 13 | - | {% if posts.is_empty() %} |
|
| 13 | + | {{if not .Posts}} |
|
| 14 | 14 | <p class="empty">no posts yet</p> |
|
| 15 | - | {% endif %} |
|
| 15 | + | {{end}} |
|
| 16 | 16 | <div class="post-list"> |
|
| 17 | - | {% for post in posts %} |
|
| 18 | - | <a href="/posts/{{ post.slug }}" class="post-item post-item-enhanced"> |
|
| 17 | + | {{range .Posts}} |
|
| 18 | + | <a href="/posts/{{.Slug}}" class="post-item post-item-enhanced"> |
|
| 19 | 19 | <div class="post-item-info"> |
|
| 20 | - | <span class="post-title">{{ post.display_title() }}</span> |
|
| 20 | + | <span class="post-title">{{.DisplayTitle}}</span> |
|
| 21 | 21 | </div> |
|
| 22 | - | {% if post.published_date.is_some() %} |
|
| 23 | - | <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time> |
|
| 24 | - | {% endif %} |
|
| 22 | + | {{if .PublishedDate}} |
|
| 23 | + | <time class="post-date">{{.PublishedDateStr}}</time> |
|
| 24 | + | {{end}} |
|
| 25 | 25 | </a> |
|
| 26 | - | {% endfor %} |
|
| 26 | + | {{end}} |
|
| 27 | 27 | </div> |
|
| 28 | - | {% endblock %} |
|
| 28 | + | {{end}} |
| 1 | - | # Build from repo root: docker build -t shrink-go -f apps/shrink-go/Dockerfile . |
|
| 2 | - | FROM golang:1.24-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/shrink-go/go.mod apps/shrink-go/go.sum ./apps/shrink-go/ |
|
| 6 | - | WORKDIR /app/apps/shrink-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/shrink-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /shrink-go . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | COPY --from=builder /shrink-go /usr/local/bin/shrink-go |
|
| 13 | - | ENV HOST=0.0.0.0 |
|
| 14 | - | ENV PORT=3000 |
|
| 15 | - | EXPOSE 3000 |
|
| 16 | - | CMD ["shrink-go"] |
| 1 | - | # shrink-go |
|
| 2 | - | ||
| 3 | - | Go rewrite of [shrink](../shrink). JPEG compression + resize via stdlib `image` |
|
| 4 | - | plus `golang.org/x/image/draw` for Catmull-Rom scaling. |
|
| 5 | - | ||
| 6 | - | ## Quickstart |
|
| 7 | - | ||
| 8 | - | ```bash |
|
| 9 | - | cp .env.example .env |
|
| 10 | - | go run . |
|
| 11 | - | ``` |
|
| 12 | - | ||
| 13 | - | ### Environment Variables |
|
| 14 | - | ||
| 15 | - | | Variable | Default | Description | |
|
| 16 | - | |---|---|---| |
|
| 17 | - | | `HOST` | `127.0.0.1` | Bind host | |
|
| 18 | - | | `PORT` | `3000` | Server port | |
|
| 19 | - | ||
| 20 | - | ## Routes |
|
| 21 | - | ||
| 22 | - | - `GET /` — upload UI |
|
| 23 | - | - `POST /compress` — multipart upload (`file`, `quality` 1-100, optional `width`) |
|
| 24 | - | - `GET /static/*` — embedded assets |
|
| 25 | - | - `/assets/*` — darkmatter css/fonts |
|
| 26 | - | ||
| 27 | - | ## Notes vs Rust version |
|
| 28 | - | ||
| 29 | - | JPEG EXIF metadata is preserved after recompression, with GPS data stripped to |
|
| 30 | - | match the Rust implementation. The 20 MB upload limit is preserved. |
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/shrink-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | - | environment: |
|
| 9 | - | - HOST=0.0.0.0 |
|
| 10 | - | - PORT=${PORT:-3000} |
|
| 11 | - | restart: unless-stopped |
| 1 | - | module github.com/stevedylandev/andromeda/apps/shrink-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/shrink |
|
| 2 | 2 | ||
| 3 | 3 | go 1.24.4 |
|
| 4 | 4 |
| 17 | 17 | app := &App{Log: logger, Templates: tmpl} |
|
| 18 | 18 | ||
| 19 | 19 | addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000") |
|
| 20 | - | logger.Info("shrink-go server running", "addr", addr) |
|
| 20 | + | logger.Info("shrink server running", "addr", addr) |
|
| 21 | 21 | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 22 | 22 | log.Fatal(err) |
|
| 23 | 23 | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* shrink — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | /* Drop Zone */ |
|
| 6 | - | #drop-zone { |
|
| 7 | - | border: 1px solid rgba(255, 255, 255, 0.3); |
|
| 8 | - | padding: 3rem 2rem; |
|
| 9 | - | text-align: center; |
|
| 10 | - | cursor: pointer; |
|
| 11 | - | display: flex; |
|
| 12 | - | align-items: center; |
|
| 13 | - | justify-content: center; |
|
| 14 | - | min-height: 150px; |
|
| 15 | - | transition: border-color 0.15s; |
|
| 16 | - | } |
|
| 17 | - | ||
| 18 | - | #drop-zone:hover, |
|
| 19 | - | #drop-zone.drag-over { |
|
| 20 | - | border-color: #ffffff; |
|
| 21 | - | } |
|
| 22 | - | ||
| 23 | - | #drop-zone p { |
|
| 24 | - | opacity: 0.5; |
|
| 25 | - | font-size: 14px; |
|
| 26 | - | text-transform: uppercase; |
|
| 27 | - | letter-spacing: 0.1em; |
|
| 28 | - | } |
|
| 29 | - | ||
| 30 | - | /* Preview */ |
|
| 31 | - | #preview-section { |
|
| 32 | - | display: none; |
|
| 33 | - | flex-direction: column; |
|
| 34 | - | gap: 0.5rem; |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | #preview-section.visible { |
|
| 38 | - | display: flex; |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | #preview-img { |
|
| 42 | - | max-width: 100%; |
|
| 43 | - | max-height: 300px; |
|
| 44 | - | object-fit: contain; |
|
| 45 | - | border: 1px solid #333; |
|
| 46 | - | } |
|
| 47 | - | ||
| 48 | - | #original-size { |
|
| 49 | - | opacity: 0.7; |
|
| 50 | - | font-size: 12px; |
|
| 51 | - | text-transform: uppercase; |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | /* Controls */ |
|
| 55 | - | #controls { |
|
| 56 | - | display: none; |
|
| 57 | - | flex-direction: column; |
|
| 58 | - | gap: 1rem; |
|
| 59 | - | } |
|
| 60 | - | ||
| 61 | - | #controls.visible { |
|
| 62 | - | display: flex; |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | - | .control-row { |
|
| 66 | - | display: flex; |
|
| 67 | - | align-items: center; |
|
| 68 | - | gap: 1rem; |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | .control-row label { |
|
| 72 | - | font-size: 12px; |
|
| 73 | - | opacity: 0.7; |
|
| 74 | - | text-transform: uppercase; |
|
| 75 | - | min-width: 80px; |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | #quality-value { |
|
| 79 | - | font-size: 14px; |
|
| 80 | - | min-width: 2.5em; |
|
| 81 | - | text-align: right; |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | /* Number Inputs */ |
|
| 85 | - | input[type="number"] { |
|
| 86 | - | width: 90px; |
|
| 87 | - | -moz-appearance: textfield; |
|
| 88 | - | } |
|
| 89 | - | ||
| 90 | - | input[type="number"]::-webkit-inner-spin-button, |
|
| 91 | - | input[type="number"]::-webkit-outer-spin-button { |
|
| 92 | - | -webkit-appearance: none; |
|
| 93 | - | } |
|
| 94 | - | ||
| 95 | - | .dimension-sep { |
|
| 96 | - | opacity: 0.5; |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | #height-display { |
|
| 100 | - | opacity: 0.7; |
|
| 101 | - | min-width: 4em; |
|
| 102 | - | } |
|
| 103 | - | ||
| 104 | - | /* Range Input */ |
|
| 105 | - | input[type="range"] { |
|
| 106 | - | -webkit-appearance: none; |
|
| 107 | - | appearance: none; |
|
| 108 | - | flex: 1; |
|
| 109 | - | height: 1px; |
|
| 110 | - | background: rgba(255, 255, 255, 0.3); |
|
| 111 | - | outline: none; |
|
| 112 | - | border-radius: 0; |
|
| 113 | - | } |
|
| 114 | - | ||
| 115 | - | input[type="range"]::-webkit-slider-thumb { |
|
| 116 | - | -webkit-appearance: none; |
|
| 117 | - | appearance: none; |
|
| 118 | - | width: 14px; |
|
| 119 | - | height: 14px; |
|
| 120 | - | background: #ffffff; |
|
| 121 | - | border: none; |
|
| 122 | - | border-radius: 0; |
|
| 123 | - | cursor: pointer; |
|
| 124 | - | } |
|
| 125 | - | ||
| 126 | - | input[type="range"]::-moz-range-thumb { |
|
| 127 | - | width: 14px; |
|
| 128 | - | height: 14px; |
|
| 129 | - | background: #ffffff; |
|
| 130 | - | border: none; |
|
| 131 | - | border-radius: 0; |
|
| 132 | - | cursor: pointer; |
|
| 133 | - | } |
|
| 134 | - | ||
| 135 | - | input[type="range"].disabled { |
|
| 136 | - | opacity: 0.3; |
|
| 137 | - | } |
|
| 138 | - | ||
| 139 | - | /* Results */ |
|
| 140 | - | #result-section { |
|
| 141 | - | display: none; |
|
| 142 | - | flex-direction: column; |
|
| 143 | - | gap: 1.5rem; |
|
| 144 | - | padding-top: 1.5rem; |
|
| 145 | - | border-top: 1px solid #333; |
|
| 146 | - | } |
|
| 147 | - | ||
| 148 | - | #result-section.visible { |
|
| 149 | - | display: flex; |
|
| 150 | - | } |
|
| 151 | - | ||
| 152 | - | .size-comparison { |
|
| 153 | - | display: flex; |
|
| 154 | - | gap: 2rem; |
|
| 155 | - | } |
|
| 156 | - | ||
| 157 | - | .size-comparison label { |
|
| 158 | - | font-size: 12px; |
|
| 159 | - | opacity: 0.5; |
|
| 160 | - | text-transform: uppercase; |
|
| 161 | - | display: block; |
|
| 162 | - | margin-bottom: 0.25rem; |
|
| 163 | - | } |
|
| 164 | - | ||
| 165 | - | .size-comparison p { |
|
| 166 | - | font-size: 16px; |
|
| 167 | - | } |
|
| 168 | - | ||
| 169 | - | #download-link { |
|
| 170 | - | text-decoration: none; |
|
| 171 | - | align-self: flex-start; |
|
| 172 | - | } |
|
| 173 | - | ||
| 174 | - | button { |
|
| 175 | - | text-transform: uppercase; |
|
| 176 | - | letter-spacing: 0.05em; |
|
| 177 | - | padding: 0.6rem 1.5rem; |
|
| 178 | - | } |
|
| 179 | - | ||
| 180 | - | button:disabled { |
|
| 181 | - | opacity: 0.3; |
|
| 182 | - | cursor: default; |
|
| 183 | - | } |
| 1 | - | {{define "base.html"}}<!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <meta name="theme-color" content="#121113"> |
|
| 7 | - | <title>{{block "title" .}}Shrink{{end}}</title> |
|
| 8 | - | <meta name="description" content="Compress and resize images"> |
|
| 9 | - | <meta property="og:title" content="SHRINK"> |
|
| 10 | - | <meta property="og:description" content="Compress and resize images"> |
|
| 11 | - | <meta property="og:image" content="/static/og.png"> |
|
| 12 | - | <meta property="og:type" content="website"> |
|
| 13 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 14 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 15 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 16 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 17 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 18 | - | <link rel="stylesheet" href="/static/styles.css"> |
|
| 19 | - | </head> |
|
| 20 | - | <body> |
|
| 21 | - | <div class="header"> |
|
| 22 | - | <a href="/" class="logo">SHRINK</a> |
|
| 23 | - | </div> |
|
| 24 | - | <main> |
|
| 25 | - | {{block "content" .}}{{end}} |
|
| 26 | - | </main> |
|
| 27 | - | </body> |
|
| 28 | - | </html>{{end}} |
| 1 | - | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | - | {{define "title"}}SHRINK{{end}} |
|
| 3 | - | {{define "content"}} |
|
| 4 | - | ||
| 5 | - | <div id="drop-zone"> |
|
| 6 | - | <p>DROP IMAGE HERE OR CLICK TO SELECT</p> |
|
| 7 | - | <input type="file" id="file-input" accept="image/*" hidden> |
|
| 8 | - | </div> |
|
| 9 | - | ||
| 10 | - | <div id="preview-section"> |
|
| 11 | - | <img id="preview-img" alt="Preview"> |
|
| 12 | - | <p id="original-size"></p> |
|
| 13 | - | </div> |
|
| 14 | - | ||
| 15 | - | <div id="controls"> |
|
| 16 | - | <div class="control-row"> |
|
| 17 | - | <label for="quality-slider">QUALITY</label> |
|
| 18 | - | <input type="range" id="quality-slider" min="1" max="100" value="80"> |
|
| 19 | - | <span id="quality-value">80</span> |
|
| 20 | - | </div> |
|
| 21 | - | <div class="control-row"> |
|
| 22 | - | <label>RESIZE</label> |
|
| 23 | - | <input type="number" id="width-input" placeholder="WIDTH" min="1"> |
|
| 24 | - | <span class="dimension-sep">x</span> |
|
| 25 | - | <span id="height-display">—</span> |
|
| 26 | - | </div> |
|
| 27 | - | <button id="compress-btn">COMPRESS</button> |
|
| 28 | - | </div> |
|
| 29 | - | ||
| 30 | - | <div id="result-section"> |
|
| 31 | - | <div class="size-comparison"> |
|
| 32 | - | <div> |
|
| 33 | - | <label>ORIGINAL</label> |
|
| 34 | - | <p id="result-original-size"></p> |
|
| 35 | - | </div> |
|
| 36 | - | <div> |
|
| 37 | - | <label>COMPRESSED</label> |
|
| 38 | - | <p id="result-compressed-size"></p> |
|
| 39 | - | </div> |
|
| 40 | - | <div> |
|
| 41 | - | <label>REDUCTION</label> |
|
| 42 | - | <p id="result-reduction"></p> |
|
| 43 | - | </div> |
|
| 44 | - | </div> |
|
| 45 | - | <a id="download-link"> |
|
| 46 | - | <button>DOWNLOAD</button> |
|
| 47 | - | </a> |
|
| 48 | - | </div> |
|
| 49 | - | ||
| 50 | - | <script> |
|
| 51 | - | const dropZone = document.getElementById('drop-zone'); |
|
| 52 | - | const fileInput = document.getElementById('file-input'); |
|
| 53 | - | const previewSection = document.getElementById('preview-section'); |
|
| 54 | - | const previewImg = document.getElementById('preview-img'); |
|
| 55 | - | const originalSize = document.getElementById('original-size'); |
|
| 56 | - | const controls = document.getElementById('controls'); |
|
| 57 | - | const qualitySlider = document.getElementById('quality-slider'); |
|
| 58 | - | const qualityValue = document.getElementById('quality-value'); |
|
| 59 | - | const widthInput = document.getElementById('width-input'); |
|
| 60 | - | const heightDisplay = document.getElementById('height-display'); |
|
| 61 | - | const compressBtn = document.getElementById('compress-btn'); |
|
| 62 | - | const resultSection = document.getElementById('result-section'); |
|
| 63 | - | const resultOriginalSize = document.getElementById('result-original-size'); |
|
| 64 | - | const resultCompressedSize = document.getElementById('result-compressed-size'); |
|
| 65 | - | const resultReduction = document.getElementById('result-reduction'); |
|
| 66 | - | const downloadLink = document.getElementById('download-link'); |
|
| 67 | - | ||
| 68 | - | let selectedFile = null; |
|
| 69 | - | let naturalWidth = 0; |
|
| 70 | - | let naturalHeight = 0; |
|
| 71 | - | ||
| 72 | - | function formatBytes(bytes) { |
|
| 73 | - | if (bytes < 1024) return bytes + ' B'; |
|
| 74 | - | if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; |
|
| 75 | - | return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | function handleFile(file) { |
|
| 79 | - | if (!file || !file.type.startsWith('image/')) return; |
|
| 80 | - | selectedFile = file; |
|
| 81 | - | const url = URL.createObjectURL(file); |
|
| 82 | - | previewImg.src = url; |
|
| 83 | - | originalSize.textContent = formatBytes(file.size); |
|
| 84 | - | previewSection.classList.add('visible'); |
|
| 85 | - | controls.classList.add('visible'); |
|
| 86 | - | resultSection.classList.remove('visible'); |
|
| 87 | - | ||
| 88 | - | const tmp = new Image(); |
|
| 89 | - | tmp.onload = () => { |
|
| 90 | - | naturalWidth = tmp.naturalWidth; |
|
| 91 | - | naturalHeight = tmp.naturalHeight; |
|
| 92 | - | widthInput.value = naturalWidth; |
|
| 93 | - | heightDisplay.textContent = naturalHeight; |
|
| 94 | - | }; |
|
| 95 | - | tmp.src = url; |
|
| 96 | - | } |
|
| 97 | - | ||
| 98 | - | dropZone.addEventListener('click', () => fileInput.click()); |
|
| 99 | - | fileInput.addEventListener('change', () => { |
|
| 100 | - | if (fileInput.files.length) handleFile(fileInput.files[0]); |
|
| 101 | - | }); |
|
| 102 | - | ||
| 103 | - | dropZone.addEventListener('dragover', (e) => { |
|
| 104 | - | e.preventDefault(); |
|
| 105 | - | dropZone.classList.add('drag-over'); |
|
| 106 | - | }); |
|
| 107 | - | dropZone.addEventListener('dragleave', () => { |
|
| 108 | - | dropZone.classList.remove('drag-over'); |
|
| 109 | - | }); |
|
| 110 | - | dropZone.addEventListener('drop', (e) => { |
|
| 111 | - | e.preventDefault(); |
|
| 112 | - | dropZone.classList.remove('drag-over'); |
|
| 113 | - | if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); |
|
| 114 | - | }); |
|
| 115 | - | ||
| 116 | - | qualitySlider.addEventListener('input', () => { |
|
| 117 | - | qualityValue.textContent = qualitySlider.value; |
|
| 118 | - | }); |
|
| 119 | - | ||
| 120 | - | widthInput.addEventListener('input', () => { |
|
| 121 | - | if (naturalWidth > 0) { |
|
| 122 | - | const w = parseInt(widthInput.value) || 0; |
|
| 123 | - | heightDisplay.textContent = w > 0 ? Math.round(w * naturalHeight / naturalWidth) : '—'; |
|
| 124 | - | } |
|
| 125 | - | }); |
|
| 126 | - | ||
| 127 | - | compressBtn.addEventListener('click', async () => { |
|
| 128 | - | if (!selectedFile) return; |
|
| 129 | - | compressBtn.disabled = true; |
|
| 130 | - | compressBtn.textContent = 'COMPRESSING...'; |
|
| 131 | - | ||
| 132 | - | const formData = new FormData(); |
|
| 133 | - | formData.append('file', selectedFile); |
|
| 134 | - | formData.append('quality', qualitySlider.value); |
|
| 135 | - | const w = parseInt(widthInput.value) || 0; |
|
| 136 | - | if (w > 0) { |
|
| 137 | - | formData.append('width', w.toString()); |
|
| 138 | - | } |
|
| 139 | - | ||
| 140 | - | try { |
|
| 141 | - | const res = await fetch('/compress', { method: 'POST', body: formData }); |
|
| 142 | - | if (!res.ok) { |
|
| 143 | - | const text = await res.text(); |
|
| 144 | - | alert('Compression failed: ' + text); |
|
| 145 | - | return; |
|
| 146 | - | } |
|
| 147 | - | const blob = await res.blob(); |
|
| 148 | - | const originalBytes = selectedFile.size; |
|
| 149 | - | const compressedBytes = blob.size; |
|
| 150 | - | const reduction = ((1 - compressedBytes / originalBytes) * 100).toFixed(1); |
|
| 151 | - | ||
| 152 | - | resultOriginalSize.textContent = formatBytes(originalBytes); |
|
| 153 | - | resultCompressedSize.textContent = formatBytes(compressedBytes); |
|
| 154 | - | resultReduction.textContent = reduction + '%'; |
|
| 155 | - | ||
| 156 | - | if (downloadLink.href && downloadLink.href.startsWith('blob:')) { |
|
| 157 | - | URL.revokeObjectURL(downloadLink.href); |
|
| 158 | - | } |
|
| 159 | - | downloadLink.href = URL.createObjectURL(blob); |
|
| 160 | - | downloadLink.download = selectedFile.name.replace(/\.[^.]+$/, '') + '_compressed.jpg'; |
|
| 161 | - | resultSection.classList.add('visible'); |
|
| 162 | - | } catch (err) { |
|
| 163 | - | alert('Error: ' + err.message); |
|
| 164 | - | } finally { |
|
| 165 | - | compressBtn.disabled = false; |
|
| 166 | - | compressBtn.textContent = 'COMPRESS'; |
|
| 167 | - | } |
|
| 168 | - | }); |
|
| 169 | - | </script> |
|
| 170 | - | ||
| 171 | - | {{end}} |
| 1 | - | [package] |
|
| 2 | - | name = "shrink" |
|
| 3 | - | version = "0.1.2" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Minimal image compression and resizing service" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | axum = { workspace = true, features = ["multipart"] } |
|
| 12 | - | tokio = { workspace = true } |
|
| 13 | - | serde = { workspace = true } |
|
| 14 | - | tower-http = { workspace = true, features = ["fs"] } |
|
| 15 | - | tracing = { workspace = true } |
|
| 16 | - | tracing-subscriber = { workspace = true } |
|
| 17 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 18 | - | askama = "0.15" |
|
| 19 | - | image = "0.25" |
|
| 20 | - | img-parts = "0.3" |
| 1 | 1 | # Build from repo root: docker build -t shrink -f apps/shrink/Dockerfile . |
|
| 2 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 2 | + | FROM golang:1.24-bookworm AS builder |
|
| 3 | 3 | WORKDIR /app |
|
| 4 | - | ||
| 5 | - | FROM chef AS planner |
|
| 6 | - | COPY . . |
|
| 7 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 8 | - | ||
| 9 | - | FROM chef AS builder |
|
| 10 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 11 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 12 | - | RUN cargo chef cook --release --recipe-path recipe.json -p shrink |
|
| 13 | - | COPY . . |
|
| 14 | - | RUN cargo build --release -p shrink |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/shrink/go.mod apps/shrink/go.sum ./apps/shrink/ |
|
| 6 | + | WORKDIR /app/apps/shrink |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/shrink/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /shrink . |
|
| 15 | 10 | ||
| 16 | 11 | FROM debian:bookworm-slim |
|
| 17 | - | WORKDIR /app |
|
| 18 | - | COPY --from=builder /app/target/release/shrink /usr/local/bin/shrink |
|
| 19 | - | COPY --from=builder /app/apps/shrink/static /app/static |
|
| 12 | + | COPY --from=builder /shrink /usr/local/bin/shrink |
|
| 13 | + | ENV HOST=0.0.0.0 |
|
| 14 | + | ENV PORT=3000 |
|
| 20 | 15 | EXPOSE 3000 |
|
| 21 | - | ENV HOST=0.0.0.0 |
|
| 22 | 16 | CMD ["shrink"] |
| 1 | - | MIT License |
|
| 2 | - | ||
| 3 | - | Copyright (c) 2026 Steve Simkins |
|
| 4 | - | ||
| 5 | - | Permission is hereby granted, free of charge, to any person obtaining a copy |
|
| 6 | - | of this software and associated documentation files (the "Software"), to deal |
|
| 7 | - | in the Software without restriction, including without limitation the rights |
|
| 8 | - | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
| 9 | - | copies of the Software, and to permit persons to whom the Software is |
|
| 10 | - | furnished to do so, subject to the following conditions: |
|
| 11 | - | ||
| 12 | - | The above copyright notice and this permission notice shall be included in all |
|
| 13 | - | copies or substantial portions of the Software. |
|
| 14 | - | ||
| 15 | - | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
| 16 | - | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
| 17 | - | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
| 18 | - | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
| 19 | - | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
| 20 | - | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
| 21 | - | SOFTWARE. |
|
| 22 | - |
| 1 | - | # Shrink |
|
| 1 | + | # shrink-go |
|
| 2 | 2 | ||
| 3 | - |  |
|
| 4 | - | ||
| 5 | - | A minimal image compression app |
|
| 3 | + | Go rewrite of [shrink](../shrink). JPEG compression + resize via stdlib `image` |
|
| 4 | + | plus `golang.org/x/image/draw` for Catmull-Rom scaling. |
|
| 6 | 5 | ||
| 7 | 6 | ## Quickstart |
|
| 8 | 7 | ||
| 9 | 8 | ```bash |
|
| 10 | - | git clone https://github.com/stevedylandev/shrink.git |
|
| 11 | - | cd shrink |
|
| 12 | - | cargo build --release |
|
| 13 | - | ./target/release/shrink |
|
| 9 | + | cp .env.example .env |
|
| 10 | + | go run . |
|
| 14 | 11 | ``` |
|
| 15 | 12 | ||
| 16 | 13 | ### Environment Variables |
|
| 17 | 14 | ||
| 18 | - | | Variable | Description | Default | |
|
| 15 | + | | Variable | Default | Description | |
|
| 19 | 16 | |---|---|---| |
|
| 20 | - | | `HOST` | Server bind host | `127.0.0.1` | |
|
| 21 | - | | `PORT` | Server bind port | `3000` | |
|
| 22 | - | ||
| 23 | - | ## Overview |
|
| 24 | - | ||
| 25 | - | A simple self-hosted tool for compressing and resizing images. Upload an image, set your desired quality and optional width, and download the compressed JPEG. A few highlights: |
|
| 26 | - | ||
| 27 | - | - Single Rust binary |
|
| 28 | - | - Compress images to JPEG with configurable quality (1-100) |
|
| 29 | - | - Optional resize by width (preserves aspect ratio) |
|
| 30 | - | - 20MB upload limit |
|
| 31 | - | ||
| 32 | - | ## Structure |
|
| 33 | - | ||
| 34 | - | ``` |
|
| 35 | - | shrink/ |
|
| 36 | - | ├── src/ |
|
| 37 | - | │ ├── main.rs # Entry point and server startup |
|
| 38 | - | │ └── server.rs # Axum routes and image compression logic |
|
| 39 | - | ├── templates/ |
|
| 40 | - | │ └── index.html # Upload UI |
|
| 41 | - | ├── static/ # Fonts and static assets |
|
| 42 | - | ├── Dockerfile |
|
| 43 | - | └── docker-compose.yml |
|
| 44 | - | ``` |
|
| 17 | + | | `HOST` | `127.0.0.1` | Bind host | |
|
| 18 | + | | `PORT` | `3000` | Server port | |
|
| 45 | 19 | ||
| 46 | - | ## Deployment |
|
| 20 | + | ## Routes |
|
| 47 | 21 | ||
| 48 | - | ### Railway |
|
| 22 | + | - `GET /` — upload UI |
|
| 23 | + | - `POST /compress` — multipart upload (`file`, `quality` 1-100, optional `width`) |
|
| 24 | + | - `GET /static/*` — embedded assets |
|
| 25 | + | - `/assets/*` — darkmatter css/fonts |
|
| 49 | 26 | ||
| 50 | - | [](https://railway.com/deploy/enYUFb?referralCode=JGcIp6) |
|
| 27 | + | ## Notes vs Rust version |
|
| 51 | 28 | ||
| 52 | - | ### Docker (recommended) |
|
| 53 | - | ||
| 54 | - | ```bash |
|
| 55 | - | git clone https://github.com/stevedylandev/shrink.git |
|
| 56 | - | cd shrink |
|
| 57 | - | docker compose up -d |
|
| 58 | - | ``` |
|
| 59 | - | ||
| 60 | - | This will start Shrink on port `3000`. |
|
| 61 | - | ||
| 62 | - | ### Binary |
|
| 63 | - | ||
| 64 | - | ```bash |
|
| 65 | - | cargo build --release |
|
| 66 | - | ``` |
|
| 67 | - | ||
| 68 | - | The resulting binary at `./target/release/shrink` is self-contained. Copy it to your server along with the `static/` and `templates/` directories, then run it directly. |
|
| 69 | - | ||
| 70 | - | ## License |
|
| 71 | - | ||
| 72 | - | [MIT](LICENSE) |
|
| 29 | + | JPEG EXIF metadata is preserved after recompression, with GPS data stripped to |
|
| 30 | + | match the Rust implementation. The 20 MB upload limit is preserved. |
| 2 | 2 | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/shrink/Dockerfile |
|
| 5 | + | dockerfile: apps/shrink-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | - | - "3000:3000" |
|
| 7 | + | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | 8 | environment: |
|
| 9 | 9 | - HOST=0.0.0.0 |
|
| 10 | - | - PORT=3000 |
|
| 10 | + | - PORT=${PORT:-3000} |
|
| 11 | 11 | restart: unless-stopped |
| 1 | - | mod server; |
|
| 2 | - | ||
| 3 | - | #[tokio::main] |
|
| 4 | - | async fn main() { |
|
| 5 | - | tracing_subscriber::fmt::init(); |
|
| 6 | - | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
|
| 7 | - | let port: u16 = std::env::var("PORT") |
|
| 8 | - | .ok() |
|
| 9 | - | .and_then(|v| v.parse().ok()) |
|
| 10 | - | .unwrap_or(3000); |
|
| 11 | - | server::run(host, port).await; |
|
| 12 | - | } |
| 1 | - | use askama::Template; |
|
| 2 | - | use axum::{ |
|
| 3 | - | Router, |
|
| 4 | - | extract::Multipart, |
|
| 5 | - | http::{StatusCode, header}, |
|
| 6 | - | response::{Html, IntoResponse, Response}, |
|
| 7 | - | routing::{get, post}, |
|
| 8 | - | }; |
|
| 9 | - | use axum::extract::DefaultBodyLimit; |
|
| 10 | - | use img_parts::ImageEXIF; |
|
| 11 | - | use img_parts::jpeg::Jpeg; |
|
| 12 | - | use tower_http::services::ServeDir; |
|
| 13 | - | ||
| 14 | - | #[derive(Template)] |
|
| 15 | - | #[template(path = "index.html")] |
|
| 16 | - | struct IndexTemplate; |
|
| 17 | - | ||
| 18 | - | pub async fn run(host: String, port: u16) { |
|
| 19 | - | let app = Router::new() |
|
| 20 | - | .route("/", get(get_index)) |
|
| 21 | - | .route("/compress", post(post_compress)) |
|
| 22 | - | .layer(DefaultBodyLimit::max(20 * 1024 * 1024)) |
|
| 23 | - | .nest_service("/static", ServeDir::new("static")) |
|
| 24 | - | .merge(andromeda_darkmatter_css::router::<()>()); |
|
| 25 | - | ||
| 26 | - | let addr = format!("{}:{}", host, port); |
|
| 27 | - | tracing::info!("Listening on {}", addr); |
|
| 28 | - | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); |
|
| 29 | - | axum::serve(listener, app).await.unwrap(); |
|
| 30 | - | } |
|
| 31 | - | ||
| 32 | - | async fn get_index() -> impl IntoResponse { |
|
| 33 | - | let html = IndexTemplate.render().unwrap(); |
|
| 34 | - | Html(html) |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | async fn post_compress(mut multipart: Multipart) -> Result<Response, (StatusCode, String)> { |
|
| 38 | - | let mut file_data: Option<Vec<u8>> = None; |
|
| 39 | - | let mut quality: u8 = 80; |
|
| 40 | - | let mut width: u32 = 0; |
|
| 41 | - | let mut original_filename: String = "image".to_string(); |
|
| 42 | - | ||
| 43 | - | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 44 | - | let name = field.name().unwrap_or("").to_string(); |
|
| 45 | - | match name.as_str() { |
|
| 46 | - | "file" => { |
|
| 47 | - | if let Some(fname) = field.file_name() { |
|
| 48 | - | original_filename = fname.to_string(); |
|
| 49 | - | } |
|
| 50 | - | let bytes = field |
|
| 51 | - | .bytes() |
|
| 52 | - | .await |
|
| 53 | - | .map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read file: {}", e)))?; |
|
| 54 | - | file_data = Some(bytes.to_vec()); |
|
| 55 | - | } |
|
| 56 | - | "quality" => { |
|
| 57 | - | let text = field |
|
| 58 | - | .text() |
|
| 59 | - | .await |
|
| 60 | - | .map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read quality: {}", e)))?; |
|
| 61 | - | quality = text.parse::<u8>().unwrap_or(80).clamp(1, 100); |
|
| 62 | - | } |
|
| 63 | - | "width" => { |
|
| 64 | - | let text = field |
|
| 65 | - | .text() |
|
| 66 | - | .await |
|
| 67 | - | .map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read width: {}", e)))?; |
|
| 68 | - | width = text.parse::<u32>().unwrap_or(0); |
|
| 69 | - | } |
|
| 70 | - | _ => {} |
|
| 71 | - | } |
|
| 72 | - | } |
|
| 73 | - | ||
| 74 | - | let file_data = file_data.ok_or((StatusCode::BAD_REQUEST, "No file provided".to_string()))?; |
|
| 75 | - | ||
| 76 | - | let result = |
|
| 77 | - | tokio::task::spawn_blocking(move || compress_image(&file_data, quality, width)) |
|
| 78 | - | .await |
|
| 79 | - | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Task failed: {}", e)))? |
|
| 80 | - | .map_err(|e| { |
|
| 81 | - | ( |
|
| 82 | - | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 83 | - | format!("Compression failed: {}", e), |
|
| 84 | - | ) |
|
| 85 | - | })?; |
|
| 86 | - | ||
| 87 | - | let download_name = build_download_filename(&original_filename, "jpg"); |
|
| 88 | - | ||
| 89 | - | Ok(( |
|
| 90 | - | StatusCode::OK, |
|
| 91 | - | [ |
|
| 92 | - | (header::CONTENT_TYPE, "image/jpeg".to_string()), |
|
| 93 | - | ( |
|
| 94 | - | header::CONTENT_DISPOSITION, |
|
| 95 | - | format!("attachment; filename=\"{}\"", download_name), |
|
| 96 | - | ), |
|
| 97 | - | ], |
|
| 98 | - | result, |
|
| 99 | - | ) |
|
| 100 | - | .into_response()) |
|
| 101 | - | } |
|
| 102 | - | ||
| 103 | - | fn compress_image(data: &[u8], quality: u8, width: u32) -> Result<Vec<u8>, String> { |
|
| 104 | - | // Extract EXIF from original before re-encoding destroys it |
|
| 105 | - | let original_exif = Jpeg::from_bytes(data.to_vec().into()) |
|
| 106 | - | .ok() |
|
| 107 | - | .and_then(|j| j.exif().map(|e| e.to_vec())); |
|
| 108 | - | ||
| 109 | - | let img = |
|
| 110 | - | image::load_from_memory(data).map_err(|e| format!("Failed to decode image: {}", e))?; |
|
| 111 | - | ||
| 112 | - | let img = if width > 0 && width != img.width() { |
|
| 113 | - | let aspect = img.height() as f64 / img.width() as f64; |
|
| 114 | - | let height = (width as f64 * aspect).round() as u32; |
|
| 115 | - | img.resize(width, height, image::imageops::FilterType::Lanczos3) |
|
| 116 | - | } else { |
|
| 117 | - | img |
|
| 118 | - | }; |
|
| 119 | - | ||
| 120 | - | let mut output = Vec::new(); |
|
| 121 | - | let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, quality); |
|
| 122 | - | img.write_with_encoder(encoder) |
|
| 123 | - | .map_err(|e| format!("JPEG encoding failed: {}", e))?; |
|
| 124 | - | ||
| 125 | - | // Re-inject EXIF into the compressed output (always strip GPS data) |
|
| 126 | - | if let Some(exif_bytes) = original_exif { |
|
| 127 | - | let exif = strip_gps_from_exif(&exif_bytes); |
|
| 128 | - | ||
| 129 | - | let mut out_jpeg = Jpeg::from_bytes(output.into()) |
|
| 130 | - | .map_err(|e| format!("Failed to parse compressed JPEG: {}", e))?; |
|
| 131 | - | out_jpeg.set_exif(Some(exif.into())); |
|
| 132 | - | let mut final_output = Vec::new(); |
|
| 133 | - | out_jpeg |
|
| 134 | - | .encoder() |
|
| 135 | - | .write_to(&mut final_output) |
|
| 136 | - | .map_err(|e| format!("Failed to write JPEG with EXIF: {}", e))?; |
|
| 137 | - | Ok(final_output) |
|
| 138 | - | } else { |
|
| 139 | - | Ok(output) |
|
| 140 | - | } |
|
| 141 | - | } |
|
| 142 | - | ||
| 143 | - | /// Strips GPS data from raw EXIF bytes by zeroing the GPS IFD entry count. |
|
| 144 | - | /// Preserves all other metadata (camera, lens, settings, etc.) and offsets. |
|
| 145 | - | fn strip_gps_from_exif(exif: &[u8]) -> Vec<u8> { |
|
| 146 | - | let mut data = exif.to_vec(); |
|
| 147 | - | ||
| 148 | - | // img-parts strips the "Exif\0\0" prefix, so bytes start with TIFF header (II/MM) |
|
| 149 | - | let tiff_start = if data.len() >= 14 && &data[0..4] == b"Exif" { |
|
| 150 | - | 6 |
|
| 151 | - | } else if data.len() >= 8 && (&data[0..2] == b"II" || &data[0..2] == b"MM") { |
|
| 152 | - | 0 |
|
| 153 | - | } else { |
|
| 154 | - | return data; |
|
| 155 | - | }; |
|
| 156 | - | let big_endian = &data[tiff_start..tiff_start + 2] == b"MM"; |
|
| 157 | - | ||
| 158 | - | let read_u16 = |d: &[u8], off: usize| -> u16 { |
|
| 159 | - | if big_endian { |
|
| 160 | - | u16::from_be_bytes([d[off], d[off + 1]]) |
|
| 161 | - | } else { |
|
| 162 | - | u16::from_le_bytes([d[off], d[off + 1]]) |
|
| 163 | - | } |
|
| 164 | - | }; |
|
| 165 | - | ||
| 166 | - | let read_u32 = |d: &[u8], off: usize| -> u32 { |
|
| 167 | - | if big_endian { |
|
| 168 | - | u32::from_be_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]]) |
|
| 169 | - | } else { |
|
| 170 | - | u32::from_le_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]]) |
|
| 171 | - | } |
|
| 172 | - | }; |
|
| 173 | - | ||
| 174 | - | // IFD0 offset (relative to TIFF start) |
|
| 175 | - | let ifd0_rel = read_u32(&data, tiff_start + 4) as usize; |
|
| 176 | - | let ifd0_off = tiff_start + ifd0_rel; |
|
| 177 | - | if ifd0_off + 2 > data.len() { |
|
| 178 | - | return data; |
|
| 179 | - | } |
|
| 180 | - | ||
| 181 | - | let entry_count = read_u16(&data, ifd0_off) as usize; |
|
| 182 | - | ||
| 183 | - | for i in 0..entry_count { |
|
| 184 | - | let entry_off = ifd0_off + 2 + i * 12; |
|
| 185 | - | if entry_off + 12 > data.len() { |
|
| 186 | - | break; |
|
| 187 | - | } |
|
| 188 | - | let tag = read_u16(&data, entry_off); |
|
| 189 | - | if tag == 0x8825 { |
|
| 190 | - | // GPS IFD pointer — read the offset, then zero out the GPS IFD entry count |
|
| 191 | - | let gps_ifd_rel = read_u32(&data, entry_off + 8) as usize; |
|
| 192 | - | let gps_ifd_off = tiff_start + gps_ifd_rel; |
|
| 193 | - | if gps_ifd_off + 2 <= data.len() { |
|
| 194 | - | let zero = if big_endian { |
|
| 195 | - | 0u16.to_be_bytes() |
|
| 196 | - | } else { |
|
| 197 | - | 0u16.to_le_bytes() |
|
| 198 | - | }; |
|
| 199 | - | data[gps_ifd_off] = zero[0]; |
|
| 200 | - | data[gps_ifd_off + 1] = zero[1]; |
|
| 201 | - | } |
|
| 202 | - | break; |
|
| 203 | - | } |
|
| 204 | - | } |
|
| 205 | - | ||
| 206 | - | data |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | fn build_download_filename(original: &str, new_ext: &str) -> String { |
|
| 210 | - | let stem = std::path::Path::new(original) |
|
| 211 | - | .file_stem() |
|
| 212 | - | .and_then(|s| s.to_str()) |
|
| 213 | - | .unwrap_or("compressed"); |
|
| 214 | - | format!("{}_compressed.{}", stem, new_ext) |
|
| 215 | - | } |
|
| 216 | - | ||
| 217 | - | #[cfg(test)] |
|
| 218 | - | mod tests { |
|
| 219 | - | use super::*; |
|
| 220 | - | ||
| 221 | - | // ── build_download_filename ──────────────────────────────────────── |
|
| 222 | - | ||
| 223 | - | #[test] |
|
| 224 | - | fn filename_with_extension() { |
|
| 225 | - | assert_eq!(build_download_filename("photo.png", "jpg"), "photo_compressed.jpg"); |
|
| 226 | - | } |
|
| 227 | - | ||
| 228 | - | #[test] |
|
| 229 | - | fn filename_without_extension() { |
|
| 230 | - | assert_eq!(build_download_filename("photo", "jpg"), "photo_compressed.jpg"); |
|
| 231 | - | } |
|
| 232 | - | ||
| 233 | - | #[test] |
|
| 234 | - | fn filename_empty_string() { |
|
| 235 | - | assert_eq!(build_download_filename("", "jpg"), "compressed_compressed.jpg"); |
|
| 236 | - | } |
|
| 237 | - | ||
| 238 | - | #[test] |
|
| 239 | - | fn filename_multiple_dots() { |
|
| 240 | - | assert_eq!( |
|
| 241 | - | build_download_filename("my.cool.photo.png", "jpg"), |
|
| 242 | - | "my.cool.photo_compressed.jpg" |
|
| 243 | - | ); |
|
| 244 | - | } |
|
| 245 | - | ||
| 246 | - | // ── strip_gps_from_exif ──────────────────────────────────────────── |
|
| 247 | - | ||
| 248 | - | #[test] |
|
| 249 | - | fn strip_gps_too_short_returns_unchanged() { |
|
| 250 | - | let data = vec![0u8; 4]; |
|
| 251 | - | assert_eq!(strip_gps_from_exif(&data), data); |
|
| 252 | - | } |
|
| 253 | - | ||
| 254 | - | #[test] |
|
| 255 | - | fn strip_gps_no_tiff_header_returns_unchanged() { |
|
| 256 | - | let data = vec![0u8; 32]; |
|
| 257 | - | assert_eq!(strip_gps_from_exif(&data), data); |
|
| 258 | - | } |
|
| 259 | - | ||
| 260 | - | #[test] |
|
| 261 | - | fn strip_gps_little_endian_zeroes_gps_ifd() { |
|
| 262 | - | // Build minimal TIFF (little-endian) with one IFD entry: GPS tag 0x8825 |
|
| 263 | - | let mut data = Vec::new(); |
|
| 264 | - | // TIFF header: "II" + magic 42 + offset to IFD0 (8) |
|
| 265 | - | data.extend_from_slice(b"II"); |
|
| 266 | - | data.extend_from_slice(&42u16.to_le_bytes()); |
|
| 267 | - | data.extend_from_slice(&8u32.to_le_bytes()); // IFD0 at offset 8 |
|
| 268 | - | ||
| 269 | - | // IFD0 at offset 8: 1 entry |
|
| 270 | - | data.extend_from_slice(&1u16.to_le_bytes()); |
|
| 271 | - | ||
| 272 | - | // IFD entry: tag=0x8825 (GPS), type=LONG(4), count=1, value=offset to GPS IFD |
|
| 273 | - | let gps_ifd_offset: u32 = 22; // right after this IFD entry + next IFD pointer |
|
| 274 | - | data.extend_from_slice(&0x8825u16.to_le_bytes()); |
|
| 275 | - | data.extend_from_slice(&4u16.to_le_bytes()); // type LONG |
|
| 276 | - | data.extend_from_slice(&1u32.to_le_bytes()); // count |
|
| 277 | - | data.extend_from_slice(&gps_ifd_offset.to_le_bytes()); // GPS IFD offset |
|
| 278 | - | ||
| 279 | - | // Next IFD pointer (none) |
|
| 280 | - | // We need padding to get to offset 22 |
|
| 281 | - | // Current size = 8 + 2 + 12 = 22, perfect |
|
| 282 | - | ||
| 283 | - | // GPS IFD at offset 22: entry count = 5 (nonzero, should be zeroed) |
|
| 284 | - | data.extend_from_slice(&5u16.to_le_bytes()); |
|
| 285 | - | // Some dummy GPS entries |
|
| 286 | - | data.extend_from_slice(&[0u8; 24]); |
|
| 287 | - | ||
| 288 | - | let result = strip_gps_from_exif(&data); |
|
| 289 | - | // GPS IFD entry count at offset 22 should now be 0 |
|
| 290 | - | let gps_count = u16::from_le_bytes([result[22], result[23]]); |
|
| 291 | - | assert_eq!(gps_count, 0); |
|
| 292 | - | } |
|
| 293 | - | ||
| 294 | - | #[test] |
|
| 295 | - | fn strip_gps_big_endian_zeroes_gps_ifd() { |
|
| 296 | - | let mut data = Vec::new(); |
|
| 297 | - | // TIFF header: "MM" + magic 42 + offset to IFD0 (8) |
|
| 298 | - | data.extend_from_slice(b"MM"); |
|
| 299 | - | data.extend_from_slice(&42u16.to_be_bytes()); |
|
| 300 | - | data.extend_from_slice(&8u32.to_be_bytes()); |
|
| 301 | - | ||
| 302 | - | // IFD0: 1 entry |
|
| 303 | - | data.extend_from_slice(&1u16.to_be_bytes()); |
|
| 304 | - | ||
| 305 | - | // GPS tag entry pointing to GPS IFD at offset 22 |
|
| 306 | - | data.extend_from_slice(&0x8825u16.to_be_bytes()); |
|
| 307 | - | data.extend_from_slice(&4u16.to_be_bytes()); |
|
| 308 | - | data.extend_from_slice(&1u32.to_be_bytes()); |
|
| 309 | - | data.extend_from_slice(&22u32.to_be_bytes()); |
|
| 310 | - | ||
| 311 | - | // GPS IFD at offset 22 |
|
| 312 | - | data.extend_from_slice(&3u16.to_be_bytes()); // 3 entries |
|
| 313 | - | data.extend_from_slice(&[0u8; 24]); |
|
| 314 | - | ||
| 315 | - | let result = strip_gps_from_exif(&data); |
|
| 316 | - | let gps_count = u16::from_be_bytes([result[22], result[23]]); |
|
| 317 | - | assert_eq!(gps_count, 0); |
|
| 318 | - | } |
|
| 319 | - | ||
| 320 | - | #[test] |
|
| 321 | - | fn strip_gps_no_gps_tag_unchanged() { |
|
| 322 | - | let mut data = Vec::new(); |
|
| 323 | - | data.extend_from_slice(b"II"); |
|
| 324 | - | data.extend_from_slice(&42u16.to_le_bytes()); |
|
| 325 | - | data.extend_from_slice(&8u32.to_le_bytes()); |
|
| 326 | - | ||
| 327 | - | // IFD0: 1 entry, but NOT a GPS tag (use 0x010F = Make) |
|
| 328 | - | data.extend_from_slice(&1u16.to_le_bytes()); |
|
| 329 | - | data.extend_from_slice(&0x010Fu16.to_le_bytes()); |
|
| 330 | - | data.extend_from_slice(&2u16.to_le_bytes()); |
|
| 331 | - | data.extend_from_slice(&1u32.to_le_bytes()); |
|
| 332 | - | data.extend_from_slice(&0u32.to_le_bytes()); |
|
| 333 | - | ||
| 334 | - | let original = data.clone(); |
|
| 335 | - | let result = strip_gps_from_exif(&data); |
|
| 336 | - | assert_eq!(result, original); |
|
| 337 | - | } |
|
| 338 | - | ||
| 339 | - | // ── compress_image ───────────────────────────────────────────────── |
|
| 340 | - | ||
| 341 | - | #[test] |
|
| 342 | - | fn compress_image_invalid_data_returns_error() { |
|
| 343 | - | let result = compress_image(&[0, 1, 2, 3], 80, 0); |
|
| 344 | - | assert!(result.is_err()); |
|
| 345 | - | } |
|
| 346 | - | ||
| 347 | - | #[test] |
|
| 348 | - | fn compress_image_valid_jpeg() { |
|
| 349 | - | // Create a minimal 2x2 RGB image and encode as JPEG |
|
| 350 | - | let img = image::RgbImage::from_fn(2, 2, |_, _| image::Rgb([255u8, 0, 0])); |
|
| 351 | - | let mut buf = Vec::new(); |
|
| 352 | - | let encoder = image::codecs::jpeg::JpegEncoder::new(&mut buf); |
|
| 353 | - | image::DynamicImage::ImageRgb8(img) |
|
| 354 | - | .write_with_encoder(encoder) |
|
| 355 | - | .unwrap(); |
|
| 356 | - | ||
| 357 | - | let result = compress_image(&buf, 80, 0); |
|
| 358 | - | assert!(result.is_ok()); |
|
| 359 | - | assert!(!result.unwrap().is_empty()); |
|
| 360 | - | } |
|
| 361 | - | ||
| 362 | - | #[test] |
|
| 363 | - | fn compress_image_with_resize() { |
|
| 364 | - | let img = image::RgbImage::from_fn(100, 50, |_, _| image::Rgb([0u8, 128, 255])); |
|
| 365 | - | let mut buf = Vec::new(); |
|
| 366 | - | let encoder = image::codecs::jpeg::JpegEncoder::new(&mut buf); |
|
| 367 | - | image::DynamicImage::ImageRgb8(img) |
|
| 368 | - | .write_with_encoder(encoder) |
|
| 369 | - | .unwrap(); |
|
| 370 | - | ||
| 371 | - | let result = compress_image(&buf, 80, 50).unwrap(); |
|
| 372 | - | // Verify output is valid JPEG (starts with FFD8) |
|
| 373 | - | assert!(result.len() >= 2); |
|
| 374 | - | assert_eq!(result[0], 0xFF); |
|
| 375 | - | assert_eq!(result[1], 0xD8); |
|
| 376 | - | } |
|
| 377 | - | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | <!DOCTYPE html> |
|
| 1 | + | {{define "base.html"}}<!DOCTYPE html> |
|
| 2 | 2 | <html lang="en"> |
|
| 3 | 3 | <head> |
|
| 4 | 4 | <meta charset="UTF-8"> |
|
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | 6 | <meta name="theme-color" content="#121113"> |
|
| 7 | - | <title>{% block title %}Shrink{% endblock %}</title> |
|
| 8 | - | <meta name="description" content="Shorten URLs with SHRINK"> |
|
| 7 | + | <title>{{block "title" .}}Shrink{{end}}</title> |
|
| 8 | + | <meta name="description" content="Compress and resize images"> |
|
| 9 | 9 | <meta property="og:title" content="SHRINK"> |
|
| 10 | - | <meta property="og:description" content="Shorten URLs with SHRINK"> |
|
| 10 | + | <meta property="og:description" content="Compress and resize images"> |
|
| 11 | 11 | <meta property="og:image" content="/static/og.png"> |
|
| 12 | 12 | <meta property="og:type" content="website"> |
|
| 13 | 13 | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 22 | 22 | <a href="/" class="logo">SHRINK</a> |
|
| 23 | 23 | </div> |
|
| 24 | 24 | <main> |
|
| 25 | - | {% block content %}{% endblock %} |
|
| 25 | + | {{block "content" .}}{{end}} |
|
| 26 | 26 | </main> |
|
| 27 | 27 | </body> |
|
| 28 | - | </html> |
|
| 28 | + | </html>{{end}} |
|
| 1 | - | {% extends "base.html" %} |
|
| 2 | - | {% block title %}SHRINK{% endblock %} |
|
| 3 | - | {% block content %} |
|
| 1 | + | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}SHRINK{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | 4 | ||
| 5 | 5 | <div id="drop-zone"> |
|
| 6 | 6 | <p>DROP IMAGE HERE OR CLICK TO SELECT</p> |
|
| 168 | 168 | }); |
|
| 169 | 169 | </script> |
|
| 170 | 170 | ||
| 171 | - | {% endblock %} |
|
| 171 | + | {{end}} |
|
| 1 | - | SIPP_API_KEY=your-secret-key |
|
| 2 | - | SIPP_AUTH_ENDPOINTS=api_delete,api_list,api_update |
|
| 3 | - | SIPP_DB_PATH=sipp.sqlite |
|
| 4 | - | SIPP_MAX_CONTENT_SIZE=512000 |
|
| 5 | - | SIPP_REMOTE_URL=http://your-server.com |
|
| 6 | - | BASE_URL=http://localhost:3000 |
|
| 7 | - | HOST=127.0.0.1 |
|
| 8 | - | PORT=3000 |
| 1 | - | # Build from repo root: docker build -t sipp-go -f apps/sipp-go/Dockerfile . |
|
| 2 | - | FROM golang:1.25-bookworm AS builder |
|
| 3 | - | WORKDIR /app |
|
| 4 | - | COPY crates-go/ ./crates-go/ |
|
| 5 | - | COPY apps/sipp-go/go.mod apps/sipp-go/go.sum ./apps/sipp-go/ |
|
| 6 | - | WORKDIR /app/apps/sipp-go |
|
| 7 | - | RUN go mod download |
|
| 8 | - | COPY apps/sipp-go/ ./ |
|
| 9 | - | RUN CGO_ENABLED=0 go build -o /sipp . |
|
| 10 | - | ||
| 11 | - | FROM debian:bookworm-slim |
|
| 12 | - | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 13 | - | COPY --from=builder /sipp /usr/local/bin/sipp |
|
| 14 | - | WORKDIR /data |
|
| 15 | - | ENV HOST=0.0.0.0 |
|
| 16 | - | ENV PORT=3000 |
|
| 17 | - | EXPOSE 3000 |
|
| 18 | - | CMD ["sipp", "server"] |
| 1 | - | # sipp-go |
|
| 2 | - | ||
| 3 | - | Go rewrite of [sipp](../sipp). Single binary with subcommands: |
|
| 4 | - | ||
| 5 | - | - `sipp server [--host H] [--port P]` — web server (HTTP + admin + API + |
|
| 6 | - | syntax highlight via `github.com/alecthomas/chroma/v2`). |
|
| 7 | - | - `sipp tui` — interactive TUI. |
|
| 8 | - | - `sipp auth` — save remote URL + API key to config. |
|
| 9 | - | - `sipp <file>` — upload a snippet to a remote instance via the JSON API. |
|
| 10 | - | ||
| 11 | - | ## Notes vs Rust version |
|
| 12 | - | ||
| 13 | - | - TUI uses Bubble Tea (Rust uses `ratatui` + `crossterm`). |
|
| 14 | - | - Syntax highlighting uses Chroma (replaces syntect). The darkmatter |
|
| 15 | - | `.tmTheme` is not reused; Chroma's `monokai` style ships by default. |
|
| 16 | - | - Snippet schema and routes match the Rust app; existing SQLite files are |
|
| 17 | - | compatible. |
|
| 18 | - | ||
| 19 | - | ## Quickstart |
|
| 20 | - | ||
| 21 | - | ```bash |
|
| 22 | - | cp .env.example .env |
|
| 23 | - | go run . server --port 3000 |
|
| 24 | - | ``` |
|
| 25 | - | ||
| 26 | - | Upload a file: |
|
| 27 | - | ||
| 28 | - | ```bash |
|
| 29 | - | SIPP_REMOTE_URL=http://localhost:3000 SIPP_API_KEY=$KEY \ |
|
| 30 | - | go run . ./path/to/file.go |
|
| 31 | - | ``` |
|
| 32 | - | ||
| 33 | - | See `.env.example` for env vars. |
| 7 | 7 | "strings" |
|
| 8 | 8 | "syscall" |
|
| 9 | 9 | ||
| 10 | - | "github.com/stevedylandev/andromeda/apps/sipp-go/tui" |
|
| 10 | + | "github.com/stevedylandev/andromeda/apps/sipp/tui" |
|
| 11 | 11 | "golang.org/x/term" |
|
| 12 | 12 | ) |
|
| 13 | 13 |
| 7 | 7 | "strings" |
|
| 8 | 8 | ||
| 9 | 9 | "github.com/atotto/clipboard" |
|
| 10 | - | "github.com/stevedylandev/andromeda/apps/sipp-go/tui" |
|
| 10 | + | "github.com/stevedylandev/andromeda/apps/sipp/tui" |
|
| 11 | 11 | ) |
|
| 12 | 12 | ||
| 13 | 13 | func runUpload(args []string) { |
| 1 | - | services: |
|
| 2 | - | app: |
|
| 3 | - | build: |
|
| 4 | - | context: ../.. |
|
| 5 | - | dockerfile: apps/sipp-go/Dockerfile |
|
| 6 | - | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | - | environment: |
|
| 9 | - | - HOST=0.0.0.0 |
|
| 10 | - | - PORT=${PORT:-3000} |
|
| 11 | - | - SIPP_DB_PATH=/data/sipp-go.sqlite |
|
| 12 | - | - SIPP_API_KEY=${SIPP_API_KEY:-} |
|
| 13 | - | - SIPP_AUTH_ENDPOINTS=${SIPP_AUTH_ENDPOINTS:-api_delete,api_list,api_update} |
|
| 14 | - | - SIPP_MAX_CONTENT_SIZE=${SIPP_MAX_CONTENT_SIZE:-512000} |
|
| 15 | - | - BASE_URL=${BASE_URL:-http://localhost:3000} |
|
| 16 | - | volumes: |
|
| 17 | - | - sipp-go-data:/data |
|
| 18 | - | restart: unless-stopped |
|
| 19 | - | ||
| 20 | - | volumes: |
|
| 21 | - | sipp-go-data: |
| 1 | - | module github.com/stevedylandev/andromeda/apps/sipp-go |
|
| 1 | + | module github.com/stevedylandev/andromeda/apps/sipp |
|
| 2 | 2 | ||
| 3 | 3 | go 1.25.0 |
|
| 4 | 4 |
| 13 | 13 | "os" |
|
| 14 | 14 | "strconv" |
|
| 15 | 15 | ||
| 16 | - | "github.com/stevedylandev/andromeda/apps/sipp-go/server" |
|
| 17 | - | "github.com/stevedylandev/andromeda/apps/sipp-go/tui" |
|
| 16 | + | "github.com/stevedylandev/andromeda/apps/sipp/server" |
|
| 17 | + | "github.com/stevedylandev/andromeda/apps/sipp/tui" |
|
| 18 | 18 | "github.com/stevedylandev/andromeda/crates-go/config" |
|
| 19 | 19 | ) |
|
| 20 | 20 |
| 20 | 20 | "github.com/alecthomas/chroma/v2/formatters/html" |
|
| 21 | 21 | "github.com/alecthomas/chroma/v2/lexers" |
|
| 22 | 22 | "github.com/alecthomas/chroma/v2/styles" |
|
| 23 | - | "github.com/stevedylandev/andromeda/apps/sipp-go/internal/store" |
|
| 23 | + | "github.com/stevedylandev/andromeda/apps/sipp/internal/store" |
|
| 24 | 24 | "github.com/stevedylandev/andromeda/crates-go/auth" |
|
| 25 | 25 | "github.com/stevedylandev/andromeda/crates-go/config" |
|
| 26 | 26 | "github.com/stevedylandev/andromeda/crates-go/darkmatter" |
|
| 397 | 397 | darkmatter.Mount(mux, "/assets") |
|
| 398 | 398 | ||
| 399 | 399 | addr := host + ":" + strconv.Itoa(port) |
|
| 400 | - | logger.Info("sipp-go server running", "addr", addr) |
|
| 400 | + | logger.Info("sipp server running", "addr", addr) |
|
| 401 | 401 | return http.ListenAndServe(addr, mux) |
|
| 402 | 402 | } |
|
| 403 | 403 | ||
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 11 | 11 | "strings" |
|
| 12 | 12 | "time" |
|
| 13 | 13 | ||
| 14 | - | "github.com/stevedylandev/andromeda/apps/sipp-go/internal/store" |
|
| 14 | + | "github.com/stevedylandev/andromeda/apps/sipp/internal/store" |
|
| 15 | 15 | "github.com/stevedylandev/andromeda/crates-go/config" |
|
| 16 | 16 | ) |
|
| 17 | 17 |
| 1 | 1 | SIPP_API_KEY=your-secret-key |
|
| 2 | - | SIPP_AUTH_ENDPOINTS=api_delete,api_list |
|
| 2 | + | SIPP_AUTH_ENDPOINTS=api_delete,api_list,api_update |
|
| 3 | 3 | SIPP_DB_PATH=sipp.sqlite |
|
| 4 | + | SIPP_MAX_CONTENT_SIZE=512000 |
|
| 4 | 5 | SIPP_REMOTE_URL=http://your-server.com |
|
| 6 | + | BASE_URL=http://localhost:3000 |
|
| 7 | + | HOST=127.0.0.1 |
|
| 8 | + | PORT=3000 |
| 1 | - | # Changelog |
|
| 2 | - | ||
| 3 | - | All notable changes to this project will be documented in this file. |
|
| 4 | - | ||
| 5 | - | ## [Unreleased] |
|
| 6 | - | ||
| 7 | - | ### Miscellaneous |
|
| 8 | - | ||
| 9 | - | - Add changelog workflow |
|
| 10 | - | - Update changelog |
|
| 11 | - | - Added wrapping when creating inside tui |
|
| 12 | - | - Update changelog |
|
| 13 | - | - Replaced rand with nanoid |
|
| 14 | - | ||
| 15 | - | ## [0.1.4] - 2026-02-21 |
|
| 16 | - | ||
| 17 | - | ### Miscellaneous |
|
| 18 | - | ||
| 19 | - | - Added db path env |
|
| 20 | - | - Update README |
|
| 21 | - | - Update readme |
|
| 22 | - | - Update readme |
|
| 23 | - | - Update readme |
|
| 24 | - | - Added max file size and plain text response for curl |
|
| 25 | - | - Updated workflows |
|
| 26 | - | - Version bump |
|
| 27 | - | ||
| 28 | - | ## [0.1.3] - 2026-02-21 |
|
| 29 | - | ||
| 30 | - | ### Miscellaneous |
|
| 31 | - | ||
| 32 | - | - Update readme |
|
| 33 | - | - Update readme |
|
| 34 | - | - Added update and search |
|
| 35 | - | - Version bump |
|
| 36 | - | ||
| 37 | - | ## [0.1.2] - 2026-02-20 |
|
| 38 | - | ||
| 39 | - | ### Bug Fixes |
|
| 40 | - | ||
| 41 | - | - Patch over ts tsx jsx issue |
|
| 42 | - | ||
| 43 | - | ### Miscellaneous |
|
| 44 | - | ||
| 45 | - | - Updated TUI interface |
|
| 46 | - | - Added proper error handling |
|
| 47 | - | - Added git cliff workflow |
|
| 48 | - | - Version bump |
|
| 49 | - | ||
| 50 | - | ## [0.1.1] - 2026-02-20 |
|
| 51 | - | ||
| 52 | - | ### Miscellaneous |
|
| 53 | - | ||
| 54 | - | - Updated workflow |
|
| 55 | - | - Updated workflow |
|
| 56 | - | - Updated docker info |
|
| 57 | - | - Removed about page |
|
| 58 | - | - Update readme |
|
| 59 | - | - Updated html |
|
| 60 | - | - Fixed svg |
|
| 61 | - | - Version bump |
|
| 62 | - | ||
| 63 | - | ## [0.1.0] - 2026-02-19 |
|
| 64 | - | ||
| 65 | - | ### Features |
|
| 66 | - | ||
| 67 | - | - Init |
|
| 68 | - | - Added tui |
|
| 69 | - | - Added syntax highlighting to snippets |
|
| 70 | - | - Added remote access via tui and api key |
|
| 71 | - | - Added config auth setup |
|
| 72 | - | ||
| 73 | - | ### Miscellaneous |
|
| 74 | - | ||
| 75 | - | - Added tui scroll for snipps |
|
| 76 | - | - Added syntax highlighting |
|
| 77 | - | - TUI help menu |
|
| 78 | - | - Added delete to snippet |
|
| 79 | - | - Tui enhancements |
|
| 80 | - | - Added link copy to tui |
|
| 81 | - | - Tui improvements |
|
| 82 | - | - Merged tui and server into single binary |
|
| 83 | - | - Server auth config |
|
| 84 | - | - Added slow equal for auth ccheck |
|
| 85 | - | - Bumped packages and renamed to sipp |
|
| 86 | - | - Renamed to sipp-so |
|
| 87 | - | - Covered edge cases with no config setup |
|
| 88 | - | - Update gitignore |
|
| 89 | - | - Update README |
|
| 90 | - | - Add LICENSE |
|
| 91 | - | - Update cargo.toml |
|
| 92 | - | - Add Rust CI workflow |
|
| 93 | - | - Added release workflow |
|
| 94 | - |
| 1 | - | [package] |
|
| 2 | - | name = "sipp-so" |
|
| 3 | - | version = "0.2.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Minimal code sharing - single binary for web server, CLI, and TUI" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://sipp.so" |
|
| 9 | - | documentation = "https://github.com/stevedylandev/sipp#readme" |
|
| 10 | - | readme = "README.md" |
|
| 11 | - | keywords = ["cli", "tui", "snippet", "code-sharing", "pastebin"] |
|
| 12 | - | categories = ["command-line-utilities", "web-programming"] |
|
| 13 | - | authors = ["Steve Simkins"] |
|
| 14 | - | exclude = [".github", "*.png", "*.gif"] |
|
| 15 | - | ||
| 16 | - | [[bin]] |
|
| 17 | - | name = "sipp" |
|
| 18 | - | path = "src/main.rs" |
|
| 19 | - | ||
| 20 | - | [dependencies] |
|
| 21 | - | axum = { workspace = true } |
|
| 22 | - | tokio = { workspace = true } |
|
| 23 | - | serde = { workspace = true } |
|
| 24 | - | serde_json = { workspace = true } |
|
| 25 | - | tower-http = { workspace = true, features = ["fs"] } |
|
| 26 | - | nanoid = { workspace = true } |
|
| 27 | - | rust-embed = { workspace = true } |
|
| 28 | - | dotenvy = { workspace = true } |
|
| 29 | - | subtle = { workspace = true } |
|
| 30 | - | rusqlite = { workspace = true } |
|
| 31 | - | andromeda-db = { workspace = true, features = ["session"] } |
|
| 32 | - | andromeda-auth = { workspace = true } |
|
| 33 | - | andromeda-darkmatter-css = { workspace = true } |
|
| 34 | - | askama = "0.15.4" |
|
| 35 | - | askama_web = { version = "0.15.1", features = ["axum-0.8"] } |
|
| 36 | - | ratatui = "0.30" |
|
| 37 | - | crossterm = "0.29" |
|
| 38 | - | arboard = "3" |
|
| 39 | - | syntect = "5" |
|
| 40 | - | reqwest = { version = "0.13", features = ["json", "blocking"] } |
|
| 41 | - | clap = { version = "4", features = ["derive", "env"] } |
|
| 42 | - | toml = "1.0" |
|
| 43 | - | rpassword = "7" |
|
| 44 | - | open = "5.3.3" |
|
| 45 | - | ||
| 46 | - | [dev-dependencies] |
|
| 47 | - | tempfile = { workspace = true } |
| 1 | 1 | # Build from repo root: docker build -t sipp -f apps/sipp/Dockerfile . |
|
| 2 | - | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 2 | + | FROM golang:1.25-bookworm AS builder |
|
| 3 | 3 | WORKDIR /app |
|
| 4 | - | ||
| 5 | - | FROM chef AS planner |
|
| 6 | - | COPY . . |
|
| 7 | - | RUN cargo chef prepare --recipe-path recipe.json |
|
| 8 | - | ||
| 9 | - | FROM chef AS builder |
|
| 10 | - | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 11 | - | COPY --from=planner /app/recipe.json recipe.json |
|
| 12 | - | RUN cargo chef cook --release --recipe-path recipe.json -p sipp-so |
|
| 13 | - | COPY . . |
|
| 14 | - | RUN cargo build --release -p sipp-so |
|
| 4 | + | COPY crates-go/ ./crates-go/ |
|
| 5 | + | COPY apps/sipp/go.mod apps/sipp/go.sum ./apps/sipp/ |
|
| 6 | + | WORKDIR /app/apps/sipp |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/sipp/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /sipp . |
|
| 15 | 10 | ||
| 16 | 11 | FROM debian:bookworm-slim |
|
| 17 | - | COPY --from=builder /app/target/release/sipp /usr/local/bin/sipp |
|
| 12 | + | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 13 | + | COPY --from=builder /sipp /usr/local/bin/sipp |
|
| 18 | 14 | WORKDIR /data |
|
| 15 | + | ENV HOST=0.0.0.0 |
|
| 16 | + | ENV PORT=3000 |
|
| 19 | 17 | EXPOSE 3000 |
|
| 20 | - | CMD ["sipp", "server", "--port", "3000", "--host", "0.0.0.0"] |
|
| 18 | + | CMD ["sipp", "server"] |
| 1 | - | MIT License |
|
| 2 | - | ||
| 3 | - | Copyright (c) 2026 Steve Simkins |
|
| 4 | - | ||
| 5 | - | Permission is hereby granted, free of charge, to any person obtaining a copy |
|
| 6 | - | of this software and associated documentation files (the "Software"), to deal |
|
| 7 | - | in the Software without restriction, including without limitation the rights |
|
| 8 | - | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
| 9 | - | copies of the Software, and to permit persons to whom the Software is |
|
| 10 | - | furnished to do so, subject to the following conditions: |
|
| 11 | - | ||
| 12 | - | The above copyright notice and this permission notice shall be included in all |
|
| 13 | - | copies or substantial portions of the Software. |
|
| 14 | - | ||
| 15 | - | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
| 16 | - | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
| 17 | - | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
| 18 | - | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
| 19 | - | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
| 20 | - | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
| 21 | - | SOFTWARE. |
| 1 | - | # Sipp |
|
| 2 | - | ||
| 3 | - | Minimal code sharing |
|
| 4 | - | ||
| 5 | - | https://github.com/user-attachments/assets/cadafb70-f796-456d-bfd9-e88704e7132c |
|
| 1 | + | # sipp-go |
|
| 6 | 2 | ||
| 7 | - | ## Quickstart |
|
| 8 | - | ||
| 9 | - | ```bash |
|
| 10 | - | cargo install sipp-so |
|
| 11 | - | sipp --help |
|
| 12 | - | ``` |
|
| 13 | - | ||
| 14 | - | Start a server and create a snippet: |
|
| 3 | + | Go rewrite of [sipp](../sipp). Single binary with subcommands: |
|
| 15 | 4 | ||
| 16 | - | ```bash |
|
| 17 | - | sipp server --port 3000 |
|
| 18 | - | ``` |
|
| 5 | + | - `sipp server [--host H] [--port P]` — web server (HTTP + admin + API + |
|
| 6 | + | syntax highlight via `github.com/alecthomas/chroma/v2`). |
|
| 7 | + | - `sipp tui` — interactive TUI. |
|
| 8 | + | - `sipp auth` — save remote URL + API key to config. |
|
| 9 | + | - `sipp <file>` — upload a snippet to a remote instance via the JSON API. |
|
| 19 | 10 | ||
| 20 | - | ```bash |
|
| 21 | - | # Path to file |
|
| 22 | - | sipp path/to/file.rs |
|
| 11 | + | ## Notes vs Rust version |
|
| 23 | 12 | ||
| 24 | - | # Or use the interactive TUI |
|
| 25 | - | sipp |
|
| 26 | - | ``` |
|
| 27 | - | ||
| 28 | - | ## Overview |
|
| 29 | - | ||
| 30 | - | A single binary for code sharing with a web server and interactive TUI. A few highlights: |
|
| 31 | - | ||
| 32 | - | - Create snippets and share on the web |
|
| 33 | - | - Raw output for CLI tools — `curl`, `wget`, and `httpie` get plain text automatically |
|
| 34 | - | - Interactive TUI with authenticated access for snippet management |
|
| 35 | - | - Minimal, fast, and low memory consumption |
|
| 36 | - | ||
| 37 | - | > [!WARNING] |
|
| 38 | - | > A small demo instance runs at [sipp.so](https://sipp.so). All snippets created there are public and might be deleted at any time; host your own instance with your own API key for personal use! |
|
| 39 | - | ||
| 40 | - | ## Usage |
|
| 41 | - | ||
| 42 | - | ### Install |
|
| 43 | - | ||
| 44 | - | Sipp can be installed several ways: |
|
| 45 | - | ||
| 46 | - | #### Releases |
|
| 47 | - | ||
| 48 | - | Visit the [releases](https://github.com/stevedylandev/sipp/releases) page and install through cURL script and other methods. |
|
| 49 | - | ||
| 50 | - | #### Homebrew |
|
| 51 | - | ||
| 52 | - | ``` |
|
| 53 | - | brew install stevedylandev/tap/sipp-so |
|
| 54 | - | ``` |
|
| 13 | + | - TUI uses Bubble Tea (Rust uses `ratatui` + `crossterm`). |
|
| 14 | + | - Syntax highlighting uses Chroma (replaces syntect). The darkmatter |
|
| 15 | + | `.tmTheme` is not reused; Chroma's `monokai` style ships by default. |
|
| 16 | + | - Snippet schema and routes match the Rust app; existing SQLite files are |
|
| 17 | + | compatible. |
|
| 55 | 18 | ||
| 56 | - | #### Cargo |
|
| 19 | + | ## Quickstart |
|
| 57 | 20 | ||
| 58 | 21 | ```bash |
|
| 59 | - | cargo install sipp-so |
|
| 60 | - | ``` |
|
| 61 | - | ||
| 62 | - | ### CLI |
|
| 63 | - | ||
| 64 | - | ``` |
|
| 65 | - | sipp [OPTIONS] [FILE] [COMMAND] |
|
| 22 | + | cp .env.example .env |
|
| 23 | + | go run . server --port 3000 |
|
| 66 | 24 | ``` |
|
| 67 | 25 | ||
| 68 | - | #### Commands |
|
| 69 | - | ||
| 70 | - | | Command | Description | |
|
| 71 | - | |---|---| |
|
| 72 | - | | `server` | Start the web server | |
|
| 73 | - | | `tui` | Launch the interactive TUI | |
|
| 74 | - | | `auth` | Save remote URL and API key to config file | |
|
| 75 | - | ||
| 76 | - | #### Arguments |
|
| 77 | - | ||
| 78 | - | | Argument | Description | |
|
| 79 | - | |---|---| |
|
| 80 | - | | `[FILE]` | File path to create a snippet from | |
|
| 81 | - | ||
| 82 | - | #### Options |
|
| 83 | - | ||
| 84 | - | | Option | Description | |
|
| 85 | - | |---|---| |
|
| 86 | - | | `-r, --remote <URL>` | Remote server URL (e.g. `http://localhost:3000`) (env: `SIPP_REMOTE_URL`) | |
|
| 87 | - | | `-k, --api-key <KEY>` | API key for authenticated operations (env: `SIPP_API_KEY`) | |
|
| 88 | - | ||
| 89 | - | ### Server |
|
| 90 | - | ||
| 91 | - | Sipp includes a built-in web server powered by Axum. Start it with: |
|
| 26 | + | Upload a file: |
|
| 92 | 27 | ||
| 93 | 28 | ```bash |
|
| 94 | - | sipp server --port 3000 --host localhost |
|
| 29 | + | SIPP_REMOTE_URL=http://localhost:3000 SIPP_API_KEY=$KEY \ |
|
| 30 | + | go run . ./path/to/file.go |
|
| 95 | 31 | ``` |
|
| 96 | 32 | ||
| 97 | - | #### Environment Variables |
|
| 98 | - | ||
| 99 | - | | Variable | Description | |
|
| 100 | - | |---|---| |
|
| 101 | - | | `SIPP_API_KEY` | API key for protecting endpoints | |
|
| 102 | - | | `SIPP_AUTH_ENDPOINTS` | Comma-separated list of endpoints requiring auth: `api_list`, `api_create`, `api_get`, `api_delete`, `all`, or `none` (defaults to `api_delete,api_list`) | |
|
| 103 | - | | `SIPP_MAX_CONTENT_SIZE` | Maximum snippet content size in bytes (defaults to `512000` / 500 KB) | |
|
| 104 | - | | `SIPP_DB_PATH` | Custom path for the SQLite database file (defaults to `sipp.sqlite` in the working directory) | |
|
| 105 | - | ||
| 106 | - | The server stores snippets in a local `sipp.sqlite` SQLite database. |
|
| 107 | - | ||
| 108 | - | #### API Endpoints |
|
| 109 | - | ||
| 110 | - | | Method | Endpoint | Description | |
|
| 111 | - | |---|---|---| |
|
| 112 | - | | `GET` | `/api/snippets` | List all snippets | |
|
| 113 | - | | `POST` | `/api/snippets` | Create a snippet (`{"name": "...", "content": "..."}`) | |
|
| 114 | - | | `GET` | `/api/snippets/{short_id}` | Get a snippet by ID | |
|
| 115 | - | | `PUT` | `/api/snippets/{short_id}` | Update a snippet (`{"name": "...", "content": "..."}`) | |
|
| 116 | - | | `DELETE` | `/api/snippets/{short_id}` | Delete a snippet by ID | |
|
| 117 | - | ||
| 118 | - | Authenticated endpoints require an `x-api-key` header. |
|
| 119 | - | ||
| 120 | - | #### Raw Output for CLI Tools |
|
| 121 | - | ||
| 122 | - | When you access a snippet URL (`/s/{short_id}`) with `curl`, `wget`, or `httpie`, the server returns the raw content as plain text instead of HTML: |
|
| 123 | - | ||
| 124 | - | ```bash |
|
| 125 | - | curl https://sipp.so/s/abc123 |
|
| 126 | - | ``` |
|
| 127 | - | ||
| 128 | - | ### TUI |
|
| 129 | - | ||
| 130 | - | The Sipp TUI makes it easy to create, copy, share, and manage your snippets either locally or remotely. Launch it with: |
|
| 131 | - | ||
| 132 | - | ```bash |
|
| 133 | - | # Launch TUI (default behavior when no file argument is given) |
|
| 134 | - | sipp |
|
| 135 | - | ||
| 136 | - | # Or explicitly |
|
| 137 | - | sipp tui |
|
| 138 | - | ||
| 139 | - | # With remote options |
|
| 140 | - | sipp -r https://sipp.so -k your-api-key |
|
| 141 | - | ``` |
|
| 142 | - | ||
| 143 | - | #### Local Access |
|
| 144 | - | ||
| 145 | - | If you are running `sipp` in the same directory as the `sipp.sqlite` file created by the server instance, the TUI will automatically access the database locally and you can edit it directly. |
|
| 146 | - | ||
| 147 | - | #### Remote Access |
|
| 148 | - | ||
| 149 | - | To access a remote instance of Sipp make sure to do the following: |
|
| 150 | - | - Set the `SIPP_API_KEY` variable in your server instance |
|
| 151 | - | - Run `sipp auth` to enter in your server instance URL and the API key, which will be stored under `$HOME/.config/sipp`. You can also set these with the ENV variables `SIPP_REMOTE_URL` and `SIPP_API_KEY` |
|
| 152 | - | ||
| 153 | - | >[!NOTE] |
|
| 154 | - | >You can try a limited remote instance without an API key with `sipp -r https://sipp.so` |
|
| 155 | - | ||
| 156 | - | #### Actions |
|
| 157 | - | ||
| 158 | - | While inside the TUI the following actions are available |
|
| 159 | - | ||
| 160 | - | | Key | Action | |
|
| 161 | - | |---|---| |
|
| 162 | - | | `j`/`↓` | Move down / Scroll down | |
|
| 163 | - | | `k`/`↑` | Move up / Scroll up | |
|
| 164 | - | | `Enter` | Focus content pane | |
|
| 165 | - | | `Esc` | Back / Quit | |
|
| 166 | - | | `y` | Copy snippet content | |
|
| 167 | - | | `Y` | Copy snippet link | |
|
| 168 | - | | `o` | Open in browser | |
|
| 169 | - | | `e` | Edit snippet | |
|
| 170 | - | | `d` | Delete snippet | |
|
| 171 | - | | `c` | Create snippet | |
|
| 172 | - | | `/` | Search snippets | |
|
| 173 | - | | `r` | Refresh snippets (remote only) | |
|
| 174 | - | | `q` | Quit | |
|
| 175 | - | | `?` | Toggle help | |
|
| 176 | - | ||
| 177 | - | ## Structure |
|
| 178 | - | ||
| 179 | - | ``` |
|
| 180 | - | sipp/ |
|
| 181 | - | ├── src/ |
|
| 182 | - | │ ├── main.rs # CLI argument parsing and entry point |
|
| 183 | - | │ ├── lib.rs # Library exports |
|
| 184 | - | │ ├── server.rs # Axum web server, routes, and templates |
|
| 185 | - | │ ├── tui.rs # Interactive terminal UI |
|
| 186 | - | │ ├── db.rs # SQLite database layer |
|
| 187 | - | │ ├── backend.rs # Local/remote backend abstraction |
|
| 188 | - | │ ├── config.rs # Config file management |
|
| 189 | - | │ └── highlight.rs # Syntax highlighting with custom themes |
|
| 190 | - | ├── templates/ # Askama HTML templates |
|
| 191 | - | │ ├── index.html # Snippet list |
|
| 192 | - | │ ├── snippet.html # Single snippet view |
|
| 193 | - | │ └── admin.html # Admin page |
|
| 194 | - | ├── static/ # Fonts, favicons, and styles |
|
| 195 | - | ├── Dockerfile |
|
| 196 | - | └── docker-compose.yml |
|
| 197 | - | ``` |
|
| 198 | - | ||
| 199 | - | ## Deployment |
|
| 200 | - | ||
| 201 | - | Since Sipp is a single binary it can be run in virtually any environment. |
|
| 202 | - | ||
| 203 | - | ### Railway |
|
| 204 | - | ||
| 205 | - | [](https://railway.com/deploy/Axcf_D?referralCode=JGcIp6) |
|
| 206 | - | ||
| 207 | - | ### Docker (recommended) |
|
| 208 | - | ||
| 209 | - | ```bash |
|
| 210 | - | # Using Docker Compose (recommended) |
|
| 211 | - | SIPP_API_KEY=your-secret-key docker compose up -d |
|
| 212 | - | ||
| 213 | - | # Or build and run manually |
|
| 214 | - | docker build -t sipp . |
|
| 215 | - | docker run -p 3000:3000 -e SIPP_API_KEY=your-secret-key -v sipp-data:/data sipp |
|
| 216 | - | ``` |
|
| 217 | - | ||
| 218 | - | ### Binary |
|
| 219 | - | ||
| 220 | - | ```bash |
|
| 221 | - | cargo build --release |
|
| 222 | - | ``` |
|
| 223 | - | ||
| 224 | - | The resulting binary at `./target/release/sipp` is self-contained with all assets embedded. Copy it to your server with your environment variables configured and run it directly. |
|
| 225 | - | ||
| 226 | - | ## License |
|
| 227 | - | ||
| 228 | - | [MIT](LICENSE) |
|
| 33 | + | See `.env.example` for env vars. |
| 1 | - | - [x] Allow creating snippet via file path |
|
| 2 | - | - [x] Use dotfile config to store creds, make an auth command |
|
| 3 | - | - [x] Combine tui and server into one binary? ie `sipp server --port 3000` |
|
| 4 | - | - [x] Server config to enable or disable certain endpoints as authenticated |
|
| 5 | - | - [x] Server config as env / .env? |
|
| 6 | - | - [x] README |
|
| 7 | - | - [x] Add help menu to bottom status bar |
|
| 8 | - | - [x] Make messages pop ups instead of status bar |
|
| 9 | - | - [x] Figure out ts and tsx issues for syntax highlighting |
|
| 10 | - | - [x] Look for SQL injection possibilities |
|
| 11 | - | - [x] Make sure DB can handle multiple connections |
|
| 12 | - | - [x] Find way to handle changelog and release notes in automation |
|
| 13 | - | - [x] Confirm delete in TUI pop up |
|
| 14 | - | - [x] Edit in TUI? |
|
| 15 | - | - [x] Curl URLs for just content |
|
| 16 | - | - [x] Limit on file size |
| 1 | - | [changelog] |
|
| 2 | - | header = """ |
|
| 3 | - | # Changelog\n |
|
| 4 | - | All notable changes to this project will be documented in this file.\n |
|
| 5 | - | """ |
|
| 6 | - | body = """ |
|
| 7 | - | {%- macro remote_url() -%} |
|
| 8 | - | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} |
|
| 9 | - | {%- endmacro -%} |
|
| 10 | - | ||
| 11 | - | {% if version -%} |
|
| 12 | - | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} |
|
| 13 | - | {% else -%} |
|
| 14 | - | ## [Unreleased] |
|
| 15 | - | {% endif -%} |
|
| 16 | - | ||
| 17 | - | {% for group, commits in commits | group_by(attribute="group") %} |
|
| 18 | - | ### {{ group | striptags | trim | upper_first }} |
|
| 19 | - | {% for commit in commits %} |
|
| 20 | - | - {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ |
|
| 21 | - | {% endfor %} |
|
| 22 | - | {% endfor %}\n |
|
| 23 | - | """ |
|
| 24 | - | trim = true |
|
| 25 | - | ||
| 26 | - | [git] |
|
| 27 | - | conventional_commits = true |
|
| 28 | - | filter_unconventional = true |
|
| 29 | - | split_commits = false |
|
| 30 | - | commit_parsers = [ |
|
| 31 | - | { message = "^feat", group = "Features" }, |
|
| 32 | - | { message = "^fix", group = "Bug Fixes" }, |
|
| 33 | - | { message = "^docs", group = "Documentation" }, |
|
| 34 | - | { message = "^perf", group = "Performance" }, |
|
| 35 | - | { message = "^refactor", group = "Refactoring" }, |
|
| 36 | - | { message = "^style", group = "Styling" }, |
|
| 37 | - | { message = "^test", group = "Testing" }, |
|
| 38 | - | { message = "^chore\\(release\\)", skip = true }, |
|
| 39 | - | { message = "^chore\\(deps\\)", skip = true }, |
|
| 40 | - | { message = "^chore|^ci", group = "Miscellaneous" }, |
|
| 41 | - | ] |
|
| 42 | - | protect_breaking_commits = false |
|
| 43 | - | filter_commits = false |
|
| 44 | - | topo_order_commits = false |
|
| 45 | - | sort_commits = "oldest" |
|
| 46 | - | ||
| 47 | - | [remote.github] |
|
| 48 | - | owner = "stevedylandev" |
|
| 49 | - | repo = "sipp" |
| 1 | 1 | services: |
|
| 2 | - | sipp: |
|
| 2 | + | app: |
|
| 3 | 3 | build: |
|
| 4 | 4 | context: ../.. |
|
| 5 | - | dockerfile: apps/sipp/Dockerfile |
|
| 5 | + | dockerfile: apps/sipp-go/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | - | - "3000:3000" |
|
| 7 | + | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | 8 | environment: |
|
| 9 | - | - SIPP_API_KEY=${SIPP_API_KEY:-changeme} |
|
| 10 | - | - SIPP_AUTH_ENDPOINTS=api_delete,api_list |
|
| 11 | - | - SIPP_DB_PATH=/data/sipp.sqlite |
|
| 9 | + | - HOST=0.0.0.0 |
|
| 10 | + | - PORT=${PORT:-3000} |
|
| 11 | + | - SIPP_DB_PATH=/data/sipp-go.sqlite |
|
| 12 | + | - SIPP_API_KEY=${SIPP_API_KEY:-} |
|
| 13 | + | - SIPP_AUTH_ENDPOINTS=${SIPP_AUTH_ENDPOINTS:-api_delete,api_list,api_update} |
|
| 14 | + | - SIPP_MAX_CONTENT_SIZE=${SIPP_MAX_CONTENT_SIZE:-512000} |
|
| 15 | + | - BASE_URL=${BASE_URL:-http://localhost:3000} |
|
| 12 | 16 | volumes: |
|
| 13 | - | - sipp-data:/data |
|
| 17 | + | - sipp-go-data:/data |
|
| 14 | 18 | restart: unless-stopped |
|
| 15 | 19 | ||
| 16 | 20 | volumes: |
|
| 17 | - | sipp-data: |
|
| 21 | + | sipp-go-data: |
| 1 | - | <?xml version="1.0" encoding="UTF-8"?> |
|
| 2 | - | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
| 3 | - | <plist version="1.0"> |
|
| 4 | - | <dict> |
|
| 5 | - | <!-- |
|
| 6 | - | The colors in this theme are encoded as #RRGGBBAA where: |
|
| 7 | - | * If AA is 00, then RR is an ANSI palette number from 00 to 07. |
|
| 8 | - | * If AA is 01, the terminal's default fg/bg color is used. |
|
| 9 | - | --> |
|
| 10 | - | <key>author</key> |
|
| 11 | - | <string>Template: Chris Kempson, Scheme: Mitchell Kember</string> |
|
| 12 | - | <key>name</key> |
|
| 13 | - | <string>ANSI</string> |
|
| 14 | - | <key>colorSpaceName</key> |
|
| 15 | - | <string>sRGB</string> |
|
| 16 | - | <key>settings</key> |
|
| 17 | - | <array> |
|
| 18 | - | <dict> |
|
| 19 | - | <key>settings</key> |
|
| 20 | - | <dict> |
|
| 21 | - | <key>background</key> |
|
| 22 | - | <string>#00000001</string> |
|
| 23 | - | <key>foreground</key> |
|
| 24 | - | <string>#00000001</string> |
|
| 25 | - | <!-- |
|
| 26 | - | Explicitly set the gutter color since bat falls back to a |
|
| 27 | - | hardcoded DEFAULT_GUTTER_COLOR otherwise. |
|
| 28 | - | --> |
|
| 29 | - | <key>gutter</key> |
|
| 30 | - | <string>#00000001</string> |
|
| 31 | - | <key>gutterForeground</key> |
|
| 32 | - | <string>#00000001</string> |
|
| 33 | - | </dict> |
|
| 34 | - | </dict> |
|
| 35 | - | <dict> |
|
| 36 | - | <key>name</key> |
|
| 37 | - | <string>Comments</string> |
|
| 38 | - | <key>scope</key> |
|
| 39 | - | <string>comment, punctuation.definition.comment</string> |
|
| 40 | - | <key>settings</key> |
|
| 41 | - | <dict> |
|
| 42 | - | <key>foreground</key> |
|
| 43 | - | <string>#02000000</string> |
|
| 44 | - | </dict> |
|
| 45 | - | </dict> |
|
| 46 | - | <dict> |
|
| 47 | - | <key>name</key> |
|
| 48 | - | <string>Keywords</string> |
|
| 49 | - | <key>scope</key> |
|
| 50 | - | <string>keyword</string> |
|
| 51 | - | <key>settings</key> |
|
| 52 | - | <dict> |
|
| 53 | - | <key>foreground</key> |
|
| 54 | - | <string>#05000000</string> |
|
| 55 | - | </dict> |
|
| 56 | - | </dict> |
|
| 57 | - | <dict> |
|
| 58 | - | <key>name</key> |
|
| 59 | - | <string>Functions</string> |
|
| 60 | - | <key>scope</key> |
|
| 61 | - | <string>entity.name.function, meta.require, support.function.any-method</string> |
|
| 62 | - | <key>settings</key> |
|
| 63 | - | <dict> |
|
| 64 | - | <key>foreground</key> |
|
| 65 | - | <string>#04000000</string> |
|
| 66 | - | </dict> |
|
| 67 | - | </dict> |
|
| 68 | - | <dict> |
|
| 69 | - | <key>name</key> |
|
| 70 | - | <string>Labels</string> |
|
| 71 | - | <key>scope</key> |
|
| 72 | - | <string>entity.name.label, variable.parameter</string> |
|
| 73 | - | <key>settings</key> |
|
| 74 | - | <dict> |
|
| 75 | - | <key>foreground</key> |
|
| 76 | - | <string>#06000000</string> |
|
| 77 | - | </dict> |
|
| 78 | - | </dict> |
|
| 79 | - | <dict> |
|
| 80 | - | <key>name</key> |
|
| 81 | - | <string>Classes</string> |
|
| 82 | - | <key>scope</key> |
|
| 83 | - | <string>support.class, entity.name.class, entity.name.type.class, entity.name</string> |
|
| 84 | - | <key>settings</key> |
|
| 85 | - | <dict> |
|
| 86 | - | <key>foreground</key> |
|
| 87 | - | <string>#03000000</string> |
|
| 88 | - | </dict> |
|
| 89 | - | </dict> |
|
| 90 | - | <dict> |
|
| 91 | - | <key>name</key> |
|
| 92 | - | <string>Methods</string> |
|
| 93 | - | <key>scope</key> |
|
| 94 | - | <string>keyword.other.special-method</string> |
|
| 95 | - | <key>settings</key> |
|
| 96 | - | <dict> |
|
| 97 | - | <key>foreground</key> |
|
| 98 | - | <string>#04000000</string> |
|
| 99 | - | </dict> |
|
| 100 | - | </dict> |
|
| 101 | - | <dict> |
|
| 102 | - | <key>name</key> |
|
| 103 | - | <string>Storage</string> |
|
| 104 | - | <key>scope</key> |
|
| 105 | - | <string>storage</string> |
|
| 106 | - | <key>settings</key> |
|
| 107 | - | <dict> |
|
| 108 | - | <key>foreground</key> |
|
| 109 | - | <string>#05000000</string> |
|
| 110 | - | </dict> |
|
| 111 | - | </dict> |
|
| 112 | - | <dict> |
|
| 113 | - | <key>name</key> |
|
| 114 | - | <string>Support</string> |
|
| 115 | - | <key>scope</key> |
|
| 116 | - | <string>support.function</string> |
|
| 117 | - | <key>settings</key> |
|
| 118 | - | <dict> |
|
| 119 | - | <key>foreground</key> |
|
| 120 | - | <string>#06000000</string> |
|
| 121 | - | </dict> |
|
| 122 | - | </dict> |
|
| 123 | - | <dict> |
|
| 124 | - | <key>name</key> |
|
| 125 | - | <string>Strings, Inherited Class</string> |
|
| 126 | - | <key>scope</key> |
|
| 127 | - | <string>string, constant.other.symbol, entity.other.inherited-class</string> |
|
| 128 | - | <key>settings</key> |
|
| 129 | - | <dict> |
|
| 130 | - | <key>foreground</key> |
|
| 131 | - | <string>#02000000</string> |
|
| 132 | - | </dict> |
|
| 133 | - | </dict> |
|
| 134 | - | <dict> |
|
| 135 | - | <key>name</key> |
|
| 136 | - | <string>Integers</string> |
|
| 137 | - | <key>scope</key> |
|
| 138 | - | <string>constant.numeric</string> |
|
| 139 | - | <key>settings</key> |
|
| 140 | - | <dict> |
|
| 141 | - | <key>foreground</key> |
|
| 142 | - | <string>#03000000</string> |
|
| 143 | - | </dict> |
|
| 144 | - | </dict> |
|
| 145 | - | <dict> |
|
| 146 | - | <key>name</key> |
|
| 147 | - | <string>Floats</string> |
|
| 148 | - | <key>scope</key> |
|
| 149 | - | <string>none</string> |
|
| 150 | - | <key>settings</key> |
|
| 151 | - | <dict> |
|
| 152 | - | <key>foreground</key> |
|
| 153 | - | <string>#03000000</string> |
|
| 154 | - | </dict> |
|
| 155 | - | </dict> |
|
| 156 | - | <dict> |
|
| 157 | - | <key>name</key> |
|
| 158 | - | <string>Boolean</string> |
|
| 159 | - | <key>scope</key> |
|
| 160 | - | <string>none</string> |
|
| 161 | - | <key>settings</key> |
|
| 162 | - | <dict> |
|
| 163 | - | <key>foreground</key> |
|
| 164 | - | <string>#03000000</string> |
|
| 165 | - | </dict> |
|
| 166 | - | </dict> |
|
| 167 | - | <dict> |
|
| 168 | - | <key>name</key> |
|
| 169 | - | <string>Constants</string> |
|
| 170 | - | <key>scope</key> |
|
| 171 | - | <string>constant</string> |
|
| 172 | - | <key>settings</key> |
|
| 173 | - | <dict> |
|
| 174 | - | <key>foreground</key> |
|
| 175 | - | <string>#03000000</string> |
|
| 176 | - | </dict> |
|
| 177 | - | </dict> |
|
| 178 | - | <dict> |
|
| 179 | - | <key>name</key> |
|
| 180 | - | <string>Tags</string> |
|
| 181 | - | <key>scope</key> |
|
| 182 | - | <string>entity.name.tag</string> |
|
| 183 | - | <key>settings</key> |
|
| 184 | - | <dict> |
|
| 185 | - | <key>foreground</key> |
|
| 186 | - | <string>#01000000</string> |
|
| 187 | - | </dict> |
|
| 188 | - | </dict> |
|
| 189 | - | <dict> |
|
| 190 | - | <key>name</key> |
|
| 191 | - | <string>Attributes</string> |
|
| 192 | - | <key>scope</key> |
|
| 193 | - | <string>entity.other.attribute-name</string> |
|
| 194 | - | <key>settings</key> |
|
| 195 | - | <dict> |
|
| 196 | - | <key>foreground</key> |
|
| 197 | - | <string>#03000000</string> |
|
| 198 | - | </dict> |
|
| 199 | - | </dict> |
|
| 200 | - | <dict> |
|
| 201 | - | <key>name</key> |
|
| 202 | - | <string>Attribute IDs</string> |
|
| 203 | - | <key>scope</key> |
|
| 204 | - | <string>entity.other.attribute-name.id, punctuation.definition.entity</string> |
|
| 205 | - | <key>settings</key> |
|
| 206 | - | <dict> |
|
| 207 | - | <key>foreground</key> |
|
| 208 | - | <string>#04000000</string> |
|
| 209 | - | </dict> |
|
| 210 | - | </dict> |
|
| 211 | - | <dict> |
|
| 212 | - | <key>name</key> |
|
| 213 | - | <string>Selector</string> |
|
| 214 | - | <key>scope</key> |
|
| 215 | - | <string>meta.selector</string> |
|
| 216 | - | <key>settings</key> |
|
| 217 | - | <dict> |
|
| 218 | - | <key>foreground</key> |
|
| 219 | - | <string>#05000000</string> |
|
| 220 | - | </dict> |
|
| 221 | - | </dict> |
|
| 222 | - | <dict> |
|
| 223 | - | <key>name</key> |
|
| 224 | - | <string>Values</string> |
|
| 225 | - | <key>scope</key> |
|
| 226 | - | <string>none</string> |
|
| 227 | - | <key>settings</key> |
|
| 228 | - | <dict> |
|
| 229 | - | <key>foreground</key> |
|
| 230 | - | <string>#03000000</string> |
|
| 231 | - | </dict> |
|
| 232 | - | </dict> |
|
| 233 | - | <dict> |
|
| 234 | - | <key>name</key> |
|
| 235 | - | <string>Headings</string> |
|
| 236 | - | <key>scope</key> |
|
| 237 | - | <string>markup.heading punctuation.definition.heading, entity.name.section, markup.heading - text.html.markdown, meta.mapping.key string.quoted.double</string> |
|
| 238 | - | <key>settings</key> |
|
| 239 | - | <dict> |
|
| 240 | - | <key>fontStyle</key> |
|
| 241 | - | <string></string> |
|
| 242 | - | <key>foreground</key> |
|
| 243 | - | <string>#04000000</string> |
|
| 244 | - | </dict> |
|
| 245 | - | </dict> |
|
| 246 | - | <dict> |
|
| 247 | - | <key>name</key> |
|
| 248 | - | <string>Units</string> |
|
| 249 | - | <key>scope</key> |
|
| 250 | - | <string>keyword.other.unit</string> |
|
| 251 | - | <key>settings</key> |
|
| 252 | - | <dict> |
|
| 253 | - | <key>foreground</key> |
|
| 254 | - | <string>#03000000</string> |
|
| 255 | - | </dict> |
|
| 256 | - | </dict> |
|
| 257 | - | <dict> |
|
| 258 | - | <key>name</key> |
|
| 259 | - | <string>Bold</string> |
|
| 260 | - | <key>scope</key> |
|
| 261 | - | <string>markup.bold, punctuation.definition.bold</string> |
|
| 262 | - | <key>settings</key> |
|
| 263 | - | <dict> |
|
| 264 | - | <key>fontStyle</key> |
|
| 265 | - | <string>bold</string> |
|
| 266 | - | <key>foreground</key> |
|
| 267 | - | <string>#03000000</string> |
|
| 268 | - | </dict> |
|
| 269 | - | </dict> |
|
| 270 | - | <dict> |
|
| 271 | - | <key>name</key> |
|
| 272 | - | <string>Italic</string> |
|
| 273 | - | <key>scope</key> |
|
| 274 | - | <string>markup.italic, punctuation.definition.italic</string> |
|
| 275 | - | <key>settings</key> |
|
| 276 | - | <dict> |
|
| 277 | - | <key>fontStyle</key> |
|
| 278 | - | <string>italic</string> |
|
| 279 | - | <key>foreground</key> |
|
| 280 | - | <string>#05000000</string> |
|
| 281 | - | </dict> |
|
| 282 | - | </dict> |
|
| 283 | - | <dict> |
|
| 284 | - | <key>name</key> |
|
| 285 | - | <string>Code</string> |
|
| 286 | - | <key>scope</key> |
|
| 287 | - | <string>markup.raw.inline</string> |
|
| 288 | - | <key>settings</key> |
|
| 289 | - | <dict> |
|
| 290 | - | <key>foreground</key> |
|
| 291 | - | <string>#02000000</string> |
|
| 292 | - | </dict> |
|
| 293 | - | </dict> |
|
| 294 | - | <dict> |
|
| 295 | - | <key>name</key> |
|
| 296 | - | <string>Link Text</string> |
|
| 297 | - | <key>scope</key> |
|
| 298 | - | <string>string.other.link, punctuation.definition.string.end.markdown, punctuation.definition.string.begin.markdown</string> |
|
| 299 | - | <key>settings</key> |
|
| 300 | - | <dict> |
|
| 301 | - | <key>foreground</key> |
|
| 302 | - | <string>#01000000</string> |
|
| 303 | - | </dict> |
|
| 304 | - | </dict> |
|
| 305 | - | <dict> |
|
| 306 | - | <key>name</key> |
|
| 307 | - | <string>Link Url</string> |
|
| 308 | - | <key>scope</key> |
|
| 309 | - | <string>meta.link</string> |
|
| 310 | - | <key>settings</key> |
|
| 311 | - | <dict> |
|
| 312 | - | <key>foreground</key> |
|
| 313 | - | <string>#03000000</string> |
|
| 314 | - | </dict> |
|
| 315 | - | </dict> |
|
| 316 | - | <dict> |
|
| 317 | - | <key>name</key> |
|
| 318 | - | <string>Quotes</string> |
|
| 319 | - | <key>scope</key> |
|
| 320 | - | <string>markup.quote</string> |
|
| 321 | - | <key>settings</key> |
|
| 322 | - | <dict> |
|
| 323 | - | <key>foreground</key> |
|
| 324 | - | <string>#03000000</string> |
|
| 325 | - | </dict> |
|
| 326 | - | </dict> |
|
| 327 | - | <dict> |
|
| 328 | - | <key>name</key> |
|
| 329 | - | <string>Inserted</string> |
|
| 330 | - | <key>scope</key> |
|
| 331 | - | <string>markup.inserted</string> |
|
| 332 | - | <key>settings</key> |
|
| 333 | - | <dict> |
|
| 334 | - | <key>foreground</key> |
|
| 335 | - | <string>#02000000</string> |
|
| 336 | - | </dict> |
|
| 337 | - | </dict> |
|
| 338 | - | <dict> |
|
| 339 | - | <key>name</key> |
|
| 340 | - | <string>Deleted</string> |
|
| 341 | - | <key>scope</key> |
|
| 342 | - | <string>markup.deleted</string> |
|
| 343 | - | <key>settings</key> |
|
| 344 | - | <dict> |
|
| 345 | - | <key>foreground</key> |
|
| 346 | - | <string>#01000000</string> |
|
| 347 | - | </dict> |
|
| 348 | - | </dict> |
|
| 349 | - | <dict> |
|
| 350 | - | <key>name</key> |
|
| 351 | - | <string>Changed</string> |
|
| 352 | - | <key>scope</key> |
|
| 353 | - | <string>markup.changed</string> |
|
| 354 | - | <key>settings</key> |
|
| 355 | - | <dict> |
|
| 356 | - | <key>foreground</key> |
|
| 357 | - | <string>#05000000</string> |
|
| 358 | - | </dict> |
|
| 359 | - | </dict> |
|
| 360 | - | <dict> |
|
| 361 | - | <key>name</key> |
|
| 362 | - | <string>Colors</string> |
|
| 363 | - | <key>scope</key> |
|
| 364 | - | <string>constant.other.color</string> |
|
| 365 | - | <key>settings</key> |
|
| 366 | - | <dict> |
|
| 367 | - | <key>foreground</key> |
|
| 368 | - | <string>#06000000</string> |
|
| 369 | - | </dict> |
|
| 370 | - | </dict> |
|
| 371 | - | <dict> |
|
| 372 | - | <key>name</key> |
|
| 373 | - | <string>Regular Expressions</string> |
|
| 374 | - | <key>scope</key> |
|
| 375 | - | <string>string.regexp</string> |
|
| 376 | - | <key>settings</key> |
|
| 377 | - | <dict> |
|
| 378 | - | <key>foreground</key> |
|
| 379 | - | <string>#06000000</string> |
|
| 380 | - | </dict> |
|
| 381 | - | </dict> |
|
| 382 | - | <dict> |
|
| 383 | - | <key>name</key> |
|
| 384 | - | <string>Escape Characters</string> |
|
| 385 | - | <key>scope</key> |
|
| 386 | - | <string>constant.character.escape</string> |
|
| 387 | - | <key>settings</key> |
|
| 388 | - | <dict> |
|
| 389 | - | <key>foreground</key> |
|
| 390 | - | <string>#06000000</string> |
|
| 391 | - | </dict> |
|
| 392 | - | </dict> |
|
| 393 | - | <dict> |
|
| 394 | - | <key>name</key> |
|
| 395 | - | <string>Embedded</string> |
|
| 396 | - | <key>scope</key> |
|
| 397 | - | <string>punctuation.section.embedded, variable.interpolation</string> |
|
| 398 | - | <key>settings</key> |
|
| 399 | - | <dict> |
|
| 400 | - | <key>foreground</key> |
|
| 401 | - | <string>#05000000</string> |
|
| 402 | - | </dict> |
|
| 403 | - | </dict> |
|
| 404 | - | <dict> |
|
| 405 | - | <key>name</key> |
|
| 406 | - | <string>Illegal</string> |
|
| 407 | - | <key>scope</key> |
|
| 408 | - | <string>invalid.illegal</string> |
|
| 409 | - | <key>settings</key> |
|
| 410 | - | <dict> |
|
| 411 | - | <key>background</key> |
|
| 412 | - | <string>#01000000</string> |
|
| 413 | - | </dict> |
|
| 414 | - | </dict> |
|
| 415 | - | <dict> |
|
| 416 | - | <key>name</key> |
|
| 417 | - | <string>Broken</string> |
|
| 418 | - | <key>scope</key> |
|
| 419 | - | <string>invalid.broken</string> |
|
| 420 | - | <key>settings</key> |
|
| 421 | - | <dict> |
|
| 422 | - | <key>background</key> |
|
| 423 | - | <string>#03000000</string> |
|
| 424 | - | </dict> |
|
| 425 | - | </dict> |
|
| 426 | - | </array> |
|
| 427 | - | <key>uuid</key> |
|
| 428 | - | <string>uuid</string> |
|
| 429 | - | </dict> |
|
| 430 | - | </plist> |
| 1 | - | use axum::{ |
|
| 2 | - | extract::FromRequestParts, |
|
| 3 | - | http::request::Parts, |
|
| 4 | - | response::{IntoResponse, Redirect, Response}, |
|
| 5 | - | }; |
|
| 6 | - | ||
| 7 | - | use crate::db; |
|
| 8 | - | use crate::server::AppState; |
|
| 9 | - | ||
| 10 | - | pub use andromeda_auth::{ |
|
| 11 | - | build_session_cookie, clear_session_cookie, generate_session_token, verify_api_key, |
|
| 12 | - | }; |
|
| 13 | - | ||
| 14 | - | pub struct AuthSession; |
|
| 15 | - | ||
| 16 | - | impl FromRequestParts<AppState> for AuthSession { |
|
| 17 | - | type Rejection = Response; |
|
| 18 | - | ||
| 19 | - | async fn from_request_parts( |
|
| 20 | - | parts: &mut Parts, |
|
| 21 | - | state: &AppState, |
|
| 22 | - | ) -> Result<Self, Self::Rejection> { |
|
| 23 | - | if let Some(token) = andromeda_auth::extract_session_cookie(&parts.headers) { |
|
| 24 | - | if is_valid_session(state, &token) { |
|
| 25 | - | return Ok(AuthSession); |
|
| 26 | - | } |
|
| 27 | - | } |
|
| 28 | - | let path = parts |
|
| 29 | - | .uri |
|
| 30 | - | .path_and_query() |
|
| 31 | - | .map(|pq| pq.as_str()) |
|
| 32 | - | .unwrap_or(parts.uri.path()); |
|
| 33 | - | let login_url = format!("/admin/login?next={}", urlencoding(path)); |
|
| 34 | - | Err(Redirect::to(&login_url).into_response()) |
|
| 35 | - | } |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | pub fn is_valid_session(state: &AppState, token: &str) -> bool { |
|
| 39 | - | match db::get_session_expiry(&state.db, token) { |
|
| 40 | - | Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(), |
|
| 41 | - | _ => false, |
|
| 42 | - | } |
|
| 43 | - | } |
|
| 44 | - | ||
| 45 | - | pub fn urlencoding(s: &str) -> String { |
|
| 46 | - | let mut out = String::with_capacity(s.len()); |
|
| 47 | - | for b in s.bytes() { |
|
| 48 | - | match b { |
|
| 49 | - | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { |
|
| 50 | - | out.push(b as char); |
|
| 51 | - | } |
|
| 52 | - | _ => { |
|
| 53 | - | out.push_str(&format!("%{:02X}", b)); |
|
| 54 | - | } |
|
| 55 | - | } |
|
| 56 | - | } |
|
| 57 | - | out |
|
| 58 | - | } |
| 1 | - | use crate::db::{self, Db, Snippet, SnippetInput}; |
|
| 2 | - | use reqwest::StatusCode; |
|
| 3 | - | use reqwest::blocking::{Client, RequestBuilder, Response}; |
|
| 4 | - | use std::fmt; |
|
| 5 | - | ||
| 6 | - | #[derive(Debug)] |
|
| 7 | - | pub enum BackendError { |
|
| 8 | - | NotFound, |
|
| 9 | - | Unauthorized(String), |
|
| 10 | - | Network(String), |
|
| 11 | - | Database(String), |
|
| 12 | - | } |
|
| 13 | - | ||
| 14 | - | impl fmt::Display for BackendError { |
|
| 15 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 16 | - | match self { |
|
| 17 | - | BackendError::NotFound => write!(f, "Not found"), |
|
| 18 | - | BackendError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), |
|
| 19 | - | BackendError::Network(msg) => write!(f, "Network error: {}", msg), |
|
| 20 | - | BackendError::Database(msg) => write!(f, "Database error: {}", msg), |
|
| 21 | - | } |
|
| 22 | - | } |
|
| 23 | - | } |
|
| 24 | - | ||
| 25 | - | impl std::error::Error for BackendError {} |
|
| 26 | - | ||
| 27 | - | impl From<db::DbError> for BackendError { |
|
| 28 | - | fn from(e: db::DbError) -> Self { |
|
| 29 | - | BackendError::Database(e.to_string()) |
|
| 30 | - | } |
|
| 31 | - | } |
|
| 32 | - | ||
| 33 | - | fn net<E: fmt::Display>(e: E) -> BackendError { |
|
| 34 | - | BackendError::Network(e.to_string()) |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | fn with_key(req: RequestBuilder, key: &Option<String>) -> RequestBuilder { |
|
| 38 | - | match key { |
|
| 39 | - | Some(k) => req.header("x-api-key", k), |
|
| 40 | - | None => req, |
|
| 41 | - | } |
|
| 42 | - | } |
|
| 43 | - | ||
| 44 | - | fn send_request(req: RequestBuilder) -> Result<Response, BackendError> { |
|
| 45 | - | let resp = req.send().map_err(net)?; |
|
| 46 | - | match resp.status().as_u16() { |
|
| 47 | - | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 48 | - | 403 => Err(BackendError::Unauthorized( |
|
| 49 | - | "No API key configured on server".into(), |
|
| 50 | - | )), |
|
| 51 | - | _ => Ok(resp), |
|
| 52 | - | } |
|
| 53 | - | } |
|
| 54 | - | ||
| 55 | - | fn unexpected(status: StatusCode) -> BackendError { |
|
| 56 | - | BackendError::Network(format!("HTTP {}", status)) |
|
| 57 | - | } |
|
| 58 | - | ||
| 59 | - | pub enum Backend { |
|
| 60 | - | Local { |
|
| 61 | - | db: Db, |
|
| 62 | - | }, |
|
| 63 | - | Remote { |
|
| 64 | - | base_url: String, |
|
| 65 | - | api_key: Option<String>, |
|
| 66 | - | client: Client, |
|
| 67 | - | }, |
|
| 68 | - | } |
|
| 69 | - | ||
| 70 | - | impl Backend { |
|
| 71 | - | pub fn local() -> Result<Self, BackendError> { |
|
| 72 | - | Ok(Backend::Local { db: db::init_db()? }) |
|
| 73 | - | } |
|
| 74 | - | ||
| 75 | - | pub fn remote(base_url: String, api_key: Option<String>) -> Self { |
|
| 76 | - | Backend::Remote { |
|
| 77 | - | base_url, |
|
| 78 | - | api_key, |
|
| 79 | - | client: Client::new(), |
|
| 80 | - | } |
|
| 81 | - | } |
|
| 82 | - | ||
| 83 | - | pub fn list_snippets(&self) -> Result<Vec<Snippet>, BackendError> { |
|
| 84 | - | match self { |
|
| 85 | - | Backend::Local { db } => Ok(db::get_all_snippets(db)?), |
|
| 86 | - | Backend::Remote { |
|
| 87 | - | base_url, |
|
| 88 | - | api_key, |
|
| 89 | - | client, |
|
| 90 | - | } => { |
|
| 91 | - | let req = with_key(client.get(format!("{base_url}/api/snippets")), api_key); |
|
| 92 | - | let resp = send_request(req)?; |
|
| 93 | - | match resp.status().as_u16() { |
|
| 94 | - | 200 => resp.json::<Vec<Snippet>>().map_err(net), |
|
| 95 | - | _ => Err(unexpected(resp.status())), |
|
| 96 | - | } |
|
| 97 | - | } |
|
| 98 | - | } |
|
| 99 | - | } |
|
| 100 | - | ||
| 101 | - | pub fn create_snippet(&self, name: &str, content: &str) -> Result<Snippet, BackendError> { |
|
| 102 | - | match self { |
|
| 103 | - | Backend::Local { db } => Ok(db::create_snippet(db, name, content)?), |
|
| 104 | - | Backend::Remote { |
|
| 105 | - | base_url, |
|
| 106 | - | api_key, |
|
| 107 | - | client, |
|
| 108 | - | } => { |
|
| 109 | - | let body = SnippetInput { |
|
| 110 | - | name: name.to_string(), |
|
| 111 | - | content: content.to_string(), |
|
| 112 | - | }; |
|
| 113 | - | let req = with_key( |
|
| 114 | - | client.post(format!("{base_url}/api/snippets")).json(&body), |
|
| 115 | - | api_key, |
|
| 116 | - | ); |
|
| 117 | - | let resp = send_request(req)?; |
|
| 118 | - | match resp.status().as_u16() { |
|
| 119 | - | 201 => resp.json::<Snippet>().map_err(net), |
|
| 120 | - | _ => Err(unexpected(resp.status())), |
|
| 121 | - | } |
|
| 122 | - | } |
|
| 123 | - | } |
|
| 124 | - | } |
|
| 125 | - | ||
| 126 | - | pub fn update_snippet( |
|
| 127 | - | &self, |
|
| 128 | - | short_id: &str, |
|
| 129 | - | name: &str, |
|
| 130 | - | content: &str, |
|
| 131 | - | ) -> Result<Option<Snippet>, BackendError> { |
|
| 132 | - | match self { |
|
| 133 | - | Backend::Local { db } => Ok(db::update_snippet_by_short_id(db, short_id, name, content)?), |
|
| 134 | - | Backend::Remote { |
|
| 135 | - | base_url, |
|
| 136 | - | api_key, |
|
| 137 | - | client, |
|
| 138 | - | } => { |
|
| 139 | - | let body = SnippetInput { |
|
| 140 | - | name: name.to_string(), |
|
| 141 | - | content: content.to_string(), |
|
| 142 | - | }; |
|
| 143 | - | let req = with_key( |
|
| 144 | - | client |
|
| 145 | - | .put(format!("{base_url}/api/snippets/{short_id}")) |
|
| 146 | - | .json(&body), |
|
| 147 | - | api_key, |
|
| 148 | - | ); |
|
| 149 | - | let resp = send_request(req)?; |
|
| 150 | - | match resp.status().as_u16() { |
|
| 151 | - | 200 => resp.json::<Snippet>().map(Some).map_err(net), |
|
| 152 | - | 404 => Ok(None), |
|
| 153 | - | _ => Err(unexpected(resp.status())), |
|
| 154 | - | } |
|
| 155 | - | } |
|
| 156 | - | } |
|
| 157 | - | } |
|
| 158 | - | ||
| 159 | - | pub fn delete_snippet(&self, short_id: &str) -> Result<bool, BackendError> { |
|
| 160 | - | match self { |
|
| 161 | - | Backend::Local { db } => Ok(db::delete_snippet_by_short_id(db, short_id)?), |
|
| 162 | - | Backend::Remote { |
|
| 163 | - | base_url, |
|
| 164 | - | api_key, |
|
| 165 | - | client, |
|
| 166 | - | } => { |
|
| 167 | - | let req = with_key( |
|
| 168 | - | client.delete(format!("{base_url}/api/snippets/{short_id}")), |
|
| 169 | - | api_key, |
|
| 170 | - | ); |
|
| 171 | - | let resp = send_request(req)?; |
|
| 172 | - | match resp.status().as_u16() { |
|
| 173 | - | 200 => Ok(true), |
|
| 174 | - | 404 => Ok(false), |
|
| 175 | - | _ => Err(unexpected(resp.status())), |
|
| 176 | - | } |
|
| 177 | - | } |
|
| 178 | - | } |
|
| 179 | - | } |
|
| 180 | - | } |
| 1 | - | use serde::{Deserialize, Serialize}; |
|
| 2 | - | use std::path::{Path, PathBuf}; |
|
| 3 | - | ||
| 4 | - | #[derive(Debug, Default, Serialize, Deserialize)] |
|
| 5 | - | pub struct Config { |
|
| 6 | - | pub remote_url: Option<String>, |
|
| 7 | - | pub api_key: Option<String>, |
|
| 8 | - | } |
|
| 9 | - | ||
| 10 | - | pub fn config_path() -> PathBuf { |
|
| 11 | - | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); |
|
| 12 | - | config_path_from(Path::new(&home)) |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | pub fn config_path_from(home: &Path) -> PathBuf { |
|
| 16 | - | home.join(".config/sipp/config.toml") |
|
| 17 | - | } |
|
| 18 | - | ||
| 19 | - | pub fn load_config() -> Config { |
|
| 20 | - | load_config_from(&config_path()) |
|
| 21 | - | } |
|
| 22 | - | ||
| 23 | - | pub fn load_config_from(path: &Path) -> Config { |
|
| 24 | - | match std::fs::read_to_string(path) { |
|
| 25 | - | Ok(contents) => toml::from_str(&contents).unwrap_or_default(), |
|
| 26 | - | Err(_) => Config::default(), |
|
| 27 | - | } |
|
| 28 | - | } |
|
| 29 | - | ||
| 30 | - | pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> { |
|
| 31 | - | save_config_to(&config_path(), config) |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | pub fn save_config_to(path: &Path, config: &Config) -> Result<(), Box<dyn std::error::Error>> { |
|
| 35 | - | if let Some(parent) = path.parent() { |
|
| 36 | - | std::fs::create_dir_all(parent)?; |
|
| 37 | - | } |
|
| 38 | - | let contents = toml::to_string_pretty(config)?; |
|
| 39 | - | std::fs::write(path, contents)?; |
|
| 40 | - | Ok(()) |
|
| 41 | - | } |
|
| 42 | - | ||
| 43 | - | #[cfg(test)] |
|
| 44 | - | mod tests { |
|
| 45 | - | use super::*; |
|
| 46 | - | ||
| 47 | - | #[test] |
|
| 48 | - | fn config_default_is_none_fields() { |
|
| 49 | - | let config = Config::default(); |
|
| 50 | - | assert!(config.remote_url.is_none()); |
|
| 51 | - | assert!(config.api_key.is_none()); |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | #[test] |
|
| 55 | - | fn config_toml_roundtrip() { |
|
| 56 | - | let config = Config { |
|
| 57 | - | remote_url: Some("http://localhost:3000".to_string()), |
|
| 58 | - | api_key: Some("secret-key-123".to_string()), |
|
| 59 | - | }; |
|
| 60 | - | let serialized = toml::to_string_pretty(&config).unwrap(); |
|
| 61 | - | let deserialized: Config = toml::from_str(&serialized).unwrap(); |
|
| 62 | - | assert_eq!(deserialized.remote_url, config.remote_url); |
|
| 63 | - | assert_eq!(deserialized.api_key, config.api_key); |
|
| 64 | - | } |
|
| 65 | - | ||
| 66 | - | #[test] |
|
| 67 | - | fn config_toml_roundtrip_with_nones() { |
|
| 68 | - | let config = Config { |
|
| 69 | - | remote_url: None, |
|
| 70 | - | api_key: None, |
|
| 71 | - | }; |
|
| 72 | - | let serialized = toml::to_string_pretty(&config).unwrap(); |
|
| 73 | - | let deserialized: Config = toml::from_str(&serialized).unwrap(); |
|
| 74 | - | assert!(deserialized.remote_url.is_none()); |
|
| 75 | - | assert!(deserialized.api_key.is_none()); |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | #[test] |
|
| 79 | - | fn load_config_missing_file_returns_default() { |
|
| 80 | - | let tmp = tempfile::tempdir().unwrap(); |
|
| 81 | - | let path = config_path_from(tmp.path()); |
|
| 82 | - | let config = load_config_from(&path); |
|
| 83 | - | assert!(config.remote_url.is_none()); |
|
| 84 | - | assert!(config.api_key.is_none()); |
|
| 85 | - | } |
|
| 86 | - | ||
| 87 | - | #[test] |
|
| 88 | - | fn save_and_load_config_roundtrip() { |
|
| 89 | - | let tmp = tempfile::tempdir().unwrap(); |
|
| 90 | - | let path = config_path_from(tmp.path()); |
|
| 91 | - | ||
| 92 | - | let config = Config { |
|
| 93 | - | remote_url: Some("https://sipp.example.com".to_string()), |
|
| 94 | - | api_key: Some("key123".to_string()), |
|
| 95 | - | }; |
|
| 96 | - | save_config_to(&path, &config).unwrap(); |
|
| 97 | - | ||
| 98 | - | let loaded = load_config_from(&path); |
|
| 99 | - | assert_eq!(loaded.remote_url, config.remote_url); |
|
| 100 | - | assert_eq!(loaded.api_key, config.api_key); |
|
| 101 | - | } |
|
| 102 | - | } |
| 1 | - | <?xml version="1.0" encoding="UTF-8"?> |
|
| 2 | - | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
| 3 | - | <plist version="1.0"> |
|
| 4 | - | <dict> |
|
| 5 | - | <key>author</key> |
|
| 6 | - | <string>Template: Chris Kempson, Scheme: metalelf0 (https://github.com/metalelf0)</string> |
|
| 7 | - | <key>name</key> |
|
| 8 | - | <string>Base16 Black Metal (Bathory)</string> |
|
| 9 | - | <key>semanticClass</key> |
|
| 10 | - | <string>theme.base16.black-metal-bathory</string> |
|
| 11 | - | <key>colorSpaceName</key> |
|
| 12 | - | <string>sRGB</string> |
|
| 13 | - | <key>gutterSettings</key> |
|
| 14 | - | <dict> |
|
| 15 | - | <key>background</key> |
|
| 16 | - | <string>#121212</string> |
|
| 17 | - | <key>divider</key> |
|
| 18 | - | <string>#121212</string> |
|
| 19 | - | <key>foreground</key> |
|
| 20 | - | <string>#333333</string> |
|
| 21 | - | <key>selectionBackground</key> |
|
| 22 | - | <string>#222222</string> |
|
| 23 | - | <key>selectionForeground</key> |
|
| 24 | - | <string>#999999</string> |
|
| 25 | - | </dict> |
|
| 26 | - | <key>settings</key> |
|
| 27 | - | <array> |
|
| 28 | - | <dict> |
|
| 29 | - | <key>settings</key> |
|
| 30 | - | <dict> |
|
| 31 | - | <key>background</key> |
|
| 32 | - | <string>#121113</string> |
|
| 33 | - | <key>caret</key> |
|
| 34 | - | <string>#c1c1c1</string> |
|
| 35 | - | <key>foreground</key> |
|
| 36 | - | <string>#c1c1c1</string> |
|
| 37 | - | <key>invisibles</key> |
|
| 38 | - | <string>#333333</string> |
|
| 39 | - | <key>lineHighlight</key> |
|
| 40 | - | <string>#33333355</string> |
|
| 41 | - | <key>selection</key> |
|
| 42 | - | <string>#222222</string> |
|
| 43 | - | </dict> |
|
| 44 | - | </dict> |
|
| 45 | - | <dict> |
|
| 46 | - | <key>name</key> |
|
| 47 | - | <string>Text</string> |
|
| 48 | - | <key>scope</key> |
|
| 49 | - | <string>variable.parameter.function</string> |
|
| 50 | - | <key>settings</key> |
|
| 51 | - | <dict> |
|
| 52 | - | <key>foreground</key> |
|
| 53 | - | <string>#c1c1c1</string> |
|
| 54 | - | </dict> |
|
| 55 | - | </dict> |
|
| 56 | - | <dict> |
|
| 57 | - | <key>name</key> |
|
| 58 | - | <string>Comments</string> |
|
| 59 | - | <key>scope</key> |
|
| 60 | - | <string>comment, punctuation.definition.comment</string> |
|
| 61 | - | <key>settings</key> |
|
| 62 | - | <dict> |
|
| 63 | - | <key>foreground</key> |
|
| 64 | - | <string>#333333</string> |
|
| 65 | - | </dict> |
|
| 66 | - | </dict> |
|
| 67 | - | <dict> |
|
| 68 | - | <key>name</key> |
|
| 69 | - | <string>Punctuation</string> |
|
| 70 | - | <key>scope</key> |
|
| 71 | - | <string>punctuation.definition.string, punctuation.definition.variable, punctuation.definition.string, punctuation.definition.parameters, punctuation.definition.string, punctuation.definition.array</string> |
|
| 72 | - | <key>settings</key> |
|
| 73 | - | <dict> |
|
| 74 | - | <key>foreground</key> |
|
| 75 | - | <string>#c1c1c1</string> |
|
| 76 | - | </dict> |
|
| 77 | - | </dict> |
|
| 78 | - | <dict> |
|
| 79 | - | <key>name</key> |
|
| 80 | - | <string>Delimiters</string> |
|
| 81 | - | <key>scope</key> |
|
| 82 | - | <string>none</string> |
|
| 83 | - | <key>settings</key> |
|
| 84 | - | <dict> |
|
| 85 | - | <key>foreground</key> |
|
| 86 | - | <string>#c1c1c1</string> |
|
| 87 | - | </dict> |
|
| 88 | - | </dict> |
|
| 89 | - | <dict> |
|
| 90 | - | <key>name</key> |
|
| 91 | - | <string>Operators</string> |
|
| 92 | - | <key>scope</key> |
|
| 93 | - | <string>keyword.operator</string> |
|
| 94 | - | <key>settings</key> |
|
| 95 | - | <dict> |
|
| 96 | - | <key>foreground</key> |
|
| 97 | - | <string>#c1c1c1</string> |
|
| 98 | - | </dict> |
|
| 99 | - | </dict> |
|
| 100 | - | <dict> |
|
| 101 | - | <key>name</key> |
|
| 102 | - | <string>Keywords</string> |
|
| 103 | - | <key>scope</key> |
|
| 104 | - | <string>keyword</string> |
|
| 105 | - | <key>settings</key> |
|
| 106 | - | <dict> |
|
| 107 | - | <key>foreground</key> |
|
| 108 | - | <string>#999999</string> |
|
| 109 | - | </dict> |
|
| 110 | - | </dict> |
|
| 111 | - | <dict> |
|
| 112 | - | <key>name</key> |
|
| 113 | - | <string>Variables</string> |
|
| 114 | - | <key>scope</key> |
|
| 115 | - | <string>variable</string> |
|
| 116 | - | <key>settings</key> |
|
| 117 | - | <dict> |
|
| 118 | - | <key>foreground</key> |
|
| 119 | - | <string>#5f8787</string> |
|
| 120 | - | </dict> |
|
| 121 | - | </dict> |
|
| 122 | - | <dict> |
|
| 123 | - | <key>name</key> |
|
| 124 | - | <string>Functions</string> |
|
| 125 | - | <key>scope</key> |
|
| 126 | - | <string>entity.name.function, meta.require, support.function.any-method, variable.function, variable.annotation, support.macro</string> |
|
| 127 | - | <key>settings</key> |
|
| 128 | - | <dict> |
|
| 129 | - | <key>foreground</key> |
|
| 130 | - | <string>#888888</string> |
|
| 131 | - | </dict> |
|
| 132 | - | </dict> |
|
| 133 | - | <dict> |
|
| 134 | - | <key>name</key> |
|
| 135 | - | <string>Labels</string> |
|
| 136 | - | <key>scope</key> |
|
| 137 | - | <string>entity.name.label</string> |
|
| 138 | - | <key>settings</key> |
|
| 139 | - | <dict> |
|
| 140 | - | <key>foreground</key> |
|
| 141 | - | <string>#444444</string> |
|
| 142 | - | </dict> |
|
| 143 | - | </dict> |
|
| 144 | - | <dict> |
|
| 145 | - | <key>name</key> |
|
| 146 | - | <string>Classes</string> |
|
| 147 | - | <key>scope</key> |
|
| 148 | - | <string>support.class, entity.name.class, entity.name.type.class</string> |
|
| 149 | - | <key>settings</key> |
|
| 150 | - | <dict> |
|
| 151 | - | <key>foreground</key> |
|
| 152 | - | <string>#e78a53</string> |
|
| 153 | - | </dict> |
|
| 154 | - | </dict> |
|
| 155 | - | <dict> |
|
| 156 | - | <key>name</key> |
|
| 157 | - | <string>Classes</string> |
|
| 158 | - | <key>scope</key> |
|
| 159 | - | <string>meta.class</string> |
|
| 160 | - | <key>settings</key> |
|
| 161 | - | <dict> |
|
| 162 | - | <key>foreground</key> |
|
| 163 | - | <string>#c1c1c1</string> |
|
| 164 | - | </dict> |
|
| 165 | - | </dict> |
|
| 166 | - | <dict> |
|
| 167 | - | <key>name</key> |
|
| 168 | - | <string>Methods</string> |
|
| 169 | - | <key>scope</key> |
|
| 170 | - | <string>keyword.other.special-method</string> |
|
| 171 | - | <key>settings</key> |
|
| 172 | - | <dict> |
|
| 173 | - | <key>foreground</key> |
|
| 174 | - | <string>#888888</string> |
|
| 175 | - | </dict> |
|
| 176 | - | </dict> |
|
| 177 | - | <dict> |
|
| 178 | - | <key>name</key> |
|
| 179 | - | <string>Storage</string> |
|
| 180 | - | <key>scope</key> |
|
| 181 | - | <string>storage</string> |
|
| 182 | - | <key>settings</key> |
|
| 183 | - | <dict> |
|
| 184 | - | <key>foreground</key> |
|
| 185 | - | <string>#999999</string> |
|
| 186 | - | </dict> |
|
| 187 | - | </dict> |
|
| 188 | - | <dict> |
|
| 189 | - | <key>name</key> |
|
| 190 | - | <string>Support</string> |
|
| 191 | - | <key>scope</key> |
|
| 192 | - | <string>support.function</string> |
|
| 193 | - | <key>settings</key> |
|
| 194 | - | <dict> |
|
| 195 | - | <key>foreground</key> |
|
| 196 | - | <string>#aaaaaa</string> |
|
| 197 | - | </dict> |
|
| 198 | - | </dict> |
|
| 199 | - | <dict> |
|
| 200 | - | <key>name</key> |
|
| 201 | - | <string>Strings, Inherited Class</string> |
|
| 202 | - | <key>scope</key> |
|
| 203 | - | <string>string, constant.other.symbol, entity.other.inherited-class</string> |
|
| 204 | - | <key>settings</key> |
|
| 205 | - | <dict> |
|
| 206 | - | <key>foreground</key> |
|
| 207 | - | <string>#fbcb97</string> |
|
| 208 | - | </dict> |
|
| 209 | - | </dict> |
|
| 210 | - | <dict> |
|
| 211 | - | <key>name</key> |
|
| 212 | - | <string>Integers</string> |
|
| 213 | - | <key>scope</key> |
|
| 214 | - | <string>constant.numeric</string> |
|
| 215 | - | <key>settings</key> |
|
| 216 | - | <dict> |
|
| 217 | - | <key>foreground</key> |
|
| 218 | - | <string>#aaaaaa</string> |
|
| 219 | - | </dict> |
|
| 220 | - | </dict> |
|
| 221 | - | <dict> |
|
| 222 | - | <key>name</key> |
|
| 223 | - | <string>Floats</string> |
|
| 224 | - | <key>scope</key> |
|
| 225 | - | <string>none</string> |
|
| 226 | - | <key>settings</key> |
|
| 227 | - | <dict> |
|
| 228 | - | <key>foreground</key> |
|
| 229 | - | <string>#aaaaaa</string> |
|
| 230 | - | </dict> |
|
| 231 | - | </dict> |
|
| 232 | - | <dict> |
|
| 233 | - | <key>name</key> |
|
| 234 | - | <string>Boolean</string> |
|
| 235 | - | <key>scope</key> |
|
| 236 | - | <string>none</string> |
|
| 237 | - | <key>settings</key> |
|
| 238 | - | <dict> |
|
| 239 | - | <key>foreground</key> |
|
| 240 | - | <string>#aaaaaa</string> |
|
| 241 | - | </dict> |
|
| 242 | - | </dict> |
|
| 243 | - | <dict> |
|
| 244 | - | <key>name</key> |
|
| 245 | - | <string>Constants</string> |
|
| 246 | - | <key>scope</key> |
|
| 247 | - | <string>constant</string> |
|
| 248 | - | <key>settings</key> |
|
| 249 | - | <dict> |
|
| 250 | - | <key>foreground</key> |
|
| 251 | - | <string>#aaaaaa</string> |
|
| 252 | - | </dict> |
|
| 253 | - | </dict> |
|
| 254 | - | <dict> |
|
| 255 | - | <key>name</key> |
|
| 256 | - | <string>Tags</string> |
|
| 257 | - | <key>scope</key> |
|
| 258 | - | <string>entity.name.tag</string> |
|
| 259 | - | <key>settings</key> |
|
| 260 | - | <dict> |
|
| 261 | - | <key>foreground</key> |
|
| 262 | - | <string>#5f8787</string> |
|
| 263 | - | </dict> |
|
| 264 | - | </dict> |
|
| 265 | - | <dict> |
|
| 266 | - | <key>name</key> |
|
| 267 | - | <string>Attributes</string> |
|
| 268 | - | <key>scope</key> |
|
| 269 | - | <string>entity.other.attribute-name</string> |
|
| 270 | - | <key>settings</key> |
|
| 271 | - | <dict> |
|
| 272 | - | <key>foreground</key> |
|
| 273 | - | <string>#aaaaaa</string> |
|
| 274 | - | </dict> |
|
| 275 | - | </dict> |
|
| 276 | - | <dict> |
|
| 277 | - | <key>name</key> |
|
| 278 | - | <string>Attribute IDs</string> |
|
| 279 | - | <key>scope</key> |
|
| 280 | - | <string>entity.other.attribute-name.id, punctuation.definition.entity</string> |
|
| 281 | - | <key>settings</key> |
|
| 282 | - | <dict> |
|
| 283 | - | <key>foreground</key> |
|
| 284 | - | <string>#888888</string> |
|
| 285 | - | </dict> |
|
| 286 | - | </dict> |
|
| 287 | - | <dict> |
|
| 288 | - | <key>name</key> |
|
| 289 | - | <string>Selector</string> |
|
| 290 | - | <key>scope</key> |
|
| 291 | - | <string>meta.selector</string> |
|
| 292 | - | <key>settings</key> |
|
| 293 | - | <dict> |
|
| 294 | - | <key>foreground</key> |
|
| 295 | - | <string>#999999</string> |
|
| 296 | - | </dict> |
|
| 297 | - | </dict> |
|
| 298 | - | <dict> |
|
| 299 | - | <key>name</key> |
|
| 300 | - | <string>Values</string> |
|
| 301 | - | <key>scope</key> |
|
| 302 | - | <string>none</string> |
|
| 303 | - | <key>settings</key> |
|
| 304 | - | <dict> |
|
| 305 | - | <key>foreground</key> |
|
| 306 | - | <string>#aaaaaa</string> |
|
| 307 | - | </dict> |
|
| 308 | - | </dict> |
|
| 309 | - | <dict> |
|
| 310 | - | <key>name</key> |
|
| 311 | - | <string>Headings</string> |
|
| 312 | - | <key>scope</key> |
|
| 313 | - | <string>markup.heading punctuation.definition.heading, entity.name.section</string> |
|
| 314 | - | <key>settings</key> |
|
| 315 | - | <dict> |
|
| 316 | - | <key>fontStyle</key> |
|
| 317 | - | <string></string> |
|
| 318 | - | <key>foreground</key> |
|
| 319 | - | <string>#888888</string> |
|
| 320 | - | </dict> |
|
| 321 | - | </dict> |
|
| 322 | - | <dict> |
|
| 323 | - | <key>name</key> |
|
| 324 | - | <string>Units</string> |
|
| 325 | - | <key>scope</key> |
|
| 326 | - | <string>keyword.other.unit</string> |
|
| 327 | - | <key>settings</key> |
|
| 328 | - | <dict> |
|
| 329 | - | <key>foreground</key> |
|
| 330 | - | <string>#aaaaaa</string> |
|
| 331 | - | </dict> |
|
| 332 | - | </dict> |
|
| 333 | - | <dict> |
|
| 334 | - | <key>name</key> |
|
| 335 | - | <string>Bold</string> |
|
| 336 | - | <key>scope</key> |
|
| 337 | - | <string>markup.bold, punctuation.definition.bold</string> |
|
| 338 | - | <key>settings</key> |
|
| 339 | - | <dict> |
|
| 340 | - | <key>fontStyle</key> |
|
| 341 | - | <string>bold</string> |
|
| 342 | - | <key>foreground</key> |
|
| 343 | - | <string>#e78a53</string> |
|
| 344 | - | </dict> |
|
| 345 | - | </dict> |
|
| 346 | - | <dict> |
|
| 347 | - | <key>name</key> |
|
| 348 | - | <string>Italic</string> |
|
| 349 | - | <key>scope</key> |
|
| 350 | - | <string>markup.italic, punctuation.definition.italic</string> |
|
| 351 | - | <key>settings</key> |
|
| 352 | - | <dict> |
|
| 353 | - | <key>fontStyle</key> |
|
| 354 | - | <string>italic</string> |
|
| 355 | - | <key>foreground</key> |
|
| 356 | - | <string>#999999</string> |
|
| 357 | - | </dict> |
|
| 358 | - | </dict> |
|
| 359 | - | <dict> |
|
| 360 | - | <key>name</key> |
|
| 361 | - | <string>Code</string> |
|
| 362 | - | <key>scope</key> |
|
| 363 | - | <string>markup.raw.inline</string> |
|
| 364 | - | <key>settings</key> |
|
| 365 | - | <dict> |
|
| 366 | - | <key>foreground</key> |
|
| 367 | - | <string>#fbcb97</string> |
|
| 368 | - | </dict> |
|
| 369 | - | </dict> |
|
| 370 | - | <dict> |
|
| 371 | - | <key>name</key> |
|
| 372 | - | <string>Link Text</string> |
|
| 373 | - | <key>scope</key> |
|
| 374 | - | <string>string.other.link, punctuation.definition.string.end.markdown, punctuation.definition.string.begin.markdown</string> |
|
| 375 | - | <key>settings</key> |
|
| 376 | - | <dict> |
|
| 377 | - | <key>foreground</key> |
|
| 378 | - | <string>#5f8787</string> |
|
| 379 | - | </dict> |
|
| 380 | - | </dict> |
|
| 381 | - | <dict> |
|
| 382 | - | <key>name</key> |
|
| 383 | - | <string>Link Url</string> |
|
| 384 | - | <key>scope</key> |
|
| 385 | - | <string>meta.link</string> |
|
| 386 | - | <key>settings</key> |
|
| 387 | - | <dict> |
|
| 388 | - | <key>foreground</key> |
|
| 389 | - | <string>#aaaaaa</string> |
|
| 390 | - | </dict> |
|
| 391 | - | </dict> |
|
| 392 | - | <dict> |
|
| 393 | - | <key>name</key> |
|
| 394 | - | <string>Lists</string> |
|
| 395 | - | <key>scope</key> |
|
| 396 | - | <string>markup.list</string> |
|
| 397 | - | <key>settings</key> |
|
| 398 | - | <dict> |
|
| 399 | - | <key>foreground</key> |
|
| 400 | - | <string>#5f8787</string> |
|
| 401 | - | </dict> |
|
| 402 | - | </dict> |
|
| 403 | - | <dict> |
|
| 404 | - | <key>name</key> |
|
| 405 | - | <string>Quotes</string> |
|
| 406 | - | <key>scope</key> |
|
| 407 | - | <string>markup.quote</string> |
|
| 408 | - | <key>settings</key> |
|
| 409 | - | <dict> |
|
| 410 | - | <key>foreground</key> |
|
| 411 | - | <string>#aaaaaa</string> |
|
| 412 | - | </dict> |
|
| 413 | - | </dict> |
|
| 414 | - | <dict> |
|
| 415 | - | <key>name</key> |
|
| 416 | - | <string>Separator</string> |
|
| 417 | - | <key>scope</key> |
|
| 418 | - | <string>meta.separator</string> |
|
| 419 | - | <key>settings</key> |
|
| 420 | - | <dict> |
|
| 421 | - | <key>background</key> |
|
| 422 | - | <string>#222222</string> |
|
| 423 | - | <key>foreground</key> |
|
| 424 | - | <string>#c1c1c1</string> |
|
| 425 | - | </dict> |
|
| 426 | - | </dict> |
|
| 427 | - | <dict> |
|
| 428 | - | <key>name</key> |
|
| 429 | - | <string>Inserted</string> |
|
| 430 | - | <key>scope</key> |
|
| 431 | - | <string>markup.inserted</string> |
|
| 432 | - | <key>settings</key> |
|
| 433 | - | <dict> |
|
| 434 | - | <key>foreground</key> |
|
| 435 | - | <string>#fbcb97</string> |
|
| 436 | - | </dict> |
|
| 437 | - | </dict> |
|
| 438 | - | <dict> |
|
| 439 | - | <key>name</key> |
|
| 440 | - | <string>Deleted</string> |
|
| 441 | - | <key>scope</key> |
|
| 442 | - | <string>markup.deleted</string> |
|
| 443 | - | <key>settings</key> |
|
| 444 | - | <dict> |
|
| 445 | - | <key>foreground</key> |
|
| 446 | - | <string>#5f8787</string> |
|
| 447 | - | </dict> |
|
| 448 | - | </dict> |
|
| 449 | - | <dict> |
|
| 450 | - | <key>name</key> |
|
| 451 | - | <string>Changed</string> |
|
| 452 | - | <key>scope</key> |
|
| 453 | - | <string>markup.changed</string> |
|
| 454 | - | <key>settings</key> |
|
| 455 | - | <dict> |
|
| 456 | - | <key>foreground</key> |
|
| 457 | - | <string>#999999</string> |
|
| 458 | - | </dict> |
|
| 459 | - | </dict> |
|
| 460 | - | <dict> |
|
| 461 | - | <key>name</key> |
|
| 462 | - | <string>Colors</string> |
|
| 463 | - | <key>scope</key> |
|
| 464 | - | <string>constant.other.color</string> |
|
| 465 | - | <key>settings</key> |
|
| 466 | - | <dict> |
|
| 467 | - | <key>foreground</key> |
|
| 468 | - | <string>#aaaaaa</string> |
|
| 469 | - | </dict> |
|
| 470 | - | </dict> |
|
| 471 | - | <dict> |
|
| 472 | - | <key>name</key> |
|
| 473 | - | <string>Regular Expressions</string> |
|
| 474 | - | <key>scope</key> |
|
| 475 | - | <string>string.regexp</string> |
|
| 476 | - | <key>settings</key> |
|
| 477 | - | <dict> |
|
| 478 | - | <key>foreground</key> |
|
| 479 | - | <string>#aaaaaa</string> |
|
| 480 | - | </dict> |
|
| 481 | - | </dict> |
|
| 482 | - | <dict> |
|
| 483 | - | <key>name</key> |
|
| 484 | - | <string>Escape Characters</string> |
|
| 485 | - | <key>scope</key> |
|
| 486 | - | <string>constant.character.escape</string> |
|
| 487 | - | <key>settings</key> |
|
| 488 | - | <dict> |
|
| 489 | - | <key>foreground</key> |
|
| 490 | - | <string>#aaaaaa</string> |
|
| 491 | - | </dict> |
|
| 492 | - | </dict> |
|
| 493 | - | <dict> |
|
| 494 | - | <key>name</key> |
|
| 495 | - | <string>Embedded</string> |
|
| 496 | - | <key>scope</key> |
|
| 497 | - | <string>punctuation.section.embedded, variable.interpolation</string> |
|
| 498 | - | <key>settings</key> |
|
| 499 | - | <dict> |
|
| 500 | - | <key>foreground</key> |
|
| 501 | - | <string>#999999</string> |
|
| 502 | - | </dict> |
|
| 503 | - | </dict> |
|
| 504 | - | <dict> |
|
| 505 | - | <key>name</key> |
|
| 506 | - | <string>Illegal</string> |
|
| 507 | - | <key>scope</key> |
|
| 508 | - | <string>invalid.illegal</string> |
|
| 509 | - | <key>settings</key> |
|
| 510 | - | <dict> |
|
| 511 | - | <key>background</key> |
|
| 512 | - | <string>#5f8787</string> |
|
| 513 | - | <key>foreground</key> |
|
| 514 | - | <string>#c1c1c1</string> |
|
| 515 | - | </dict> |
|
| 516 | - | </dict> |
|
| 517 | - | <dict> |
|
| 518 | - | <key>name</key> |
|
| 519 | - | <string>Broken</string> |
|
| 520 | - | <key>scope</key> |
|
| 521 | - | <string>invalid.broken</string> |
|
| 522 | - | <key>settings</key> |
|
| 523 | - | <dict> |
|
| 524 | - | <key>background</key> |
|
| 525 | - | <string>#aaaaaa</string> |
|
| 526 | - | <key>foreground</key> |
|
| 527 | - | <string>#121113</string> |
|
| 528 | - | </dict> |
|
| 529 | - | </dict> |
|
| 530 | - | <dict> |
|
| 531 | - | <key>name</key> |
|
| 532 | - | <string>Deprecated</string> |
|
| 533 | - | <key>scope</key> |
|
| 534 | - | <string>invalid.deprecated</string> |
|
| 535 | - | <key>settings</key> |
|
| 536 | - | <dict> |
|
| 537 | - | <key>background</key> |
|
| 538 | - | <string>#444444</string> |
|
| 539 | - | <key>foreground</key> |
|
| 540 | - | <string>#c1c1c1</string> |
|
| 541 | - | </dict> |
|
| 542 | - | </dict> |
|
| 543 | - | <dict> |
|
| 544 | - | <key>name</key> |
|
| 545 | - | <string>Unimplemented</string> |
|
| 546 | - | <key>scope</key> |
|
| 547 | - | <string>invalid.unimplemented</string> |
|
| 548 | - | <key>settings</key> |
|
| 549 | - | <dict> |
|
| 550 | - | <key>background</key> |
|
| 551 | - | <string>#333333</string> |
|
| 552 | - | <key>foreground</key> |
|
| 553 | - | <string>#c1c1c1</string> |
|
| 554 | - | </dict> |
|
| 555 | - | </dict> |
|
| 556 | - | </array> |
|
| 557 | - | <key>uuid</key> |
|
| 558 | - | <string>uuid</string> |
|
| 559 | - | </dict> |
|
| 560 | - | </plist> |
| 1 | - | use nanoid::nanoid; |
|
| 2 | - | use rusqlite::{Connection, OptionalExtension, params}; |
|
| 3 | - | use serde::{Deserialize, Serialize}; |
|
| 4 | - | use std::sync::{Arc, Mutex}; |
|
| 5 | - | ||
| 6 | - | pub use andromeda_db::session::{ |
|
| 7 | - | delete_session, get_session_expiry, insert_session, prune_expired_sessions, |
|
| 8 | - | }; |
|
| 9 | - | pub use andromeda_db::{Db, DbError}; |
|
| 10 | - | ||
| 11 | - | #[derive(Serialize, Deserialize)] |
|
| 12 | - | pub struct Snippet { |
|
| 13 | - | pub id: i64, |
|
| 14 | - | pub short_id: String, |
|
| 15 | - | pub content: String, |
|
| 16 | - | pub name: String, |
|
| 17 | - | } |
|
| 18 | - | ||
| 19 | - | #[derive(Debug, Serialize, Deserialize)] |
|
| 20 | - | pub struct SnippetInput { |
|
| 21 | - | pub name: String, |
|
| 22 | - | pub content: String, |
|
| 23 | - | } |
|
| 24 | - | ||
| 25 | - | const SNIPPET_COLS: &str = "id, short_id, content, name"; |
|
| 26 | - | ||
| 27 | - | fn snippet_from_row(row: &rusqlite::Row) -> rusqlite::Result<Snippet> { |
|
| 28 | - | Ok(Snippet { |
|
| 29 | - | id: row.get(0)?, |
|
| 30 | - | short_id: row.get(1)?, |
|
| 31 | - | content: row.get(2)?, |
|
| 32 | - | name: row.get(3)?, |
|
| 33 | - | }) |
|
| 34 | - | } |
|
| 35 | - | ||
| 36 | - | fn generate_short_id() -> String { |
|
| 37 | - | nanoid!(10) |
|
| 38 | - | } |
|
| 39 | - | ||
| 40 | - | pub fn db_path() -> String { |
|
| 41 | - | std::env::var("SIPP_DB_PATH").unwrap_or_else(|_| "sipp.sqlite".to_string()) |
|
| 42 | - | } |
|
| 43 | - | ||
| 44 | - | const SCHEMA: &str = " |
|
| 45 | - | CREATE TABLE IF NOT EXISTS snippets ( |
|
| 46 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 47 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 48 | - | content TEXT NOT NULL, |
|
| 49 | - | name TEXT NOT NULL |
|
| 50 | - | ); |
|
| 51 | - | ||
| 52 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 53 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 54 | - | token TEXT NOT NULL UNIQUE, |
|
| 55 | - | expires_at TEXT NOT NULL |
|
| 56 | - | ); |
|
| 57 | - | "; |
|
| 58 | - | ||
| 59 | - | pub fn init_db() -> Result<Db, DbError> { |
|
| 60 | - | let conn = Connection::open(db_path())?; |
|
| 61 | - | conn.execute_batch(SCHEMA)?; |
|
| 62 | - | Ok(Arc::new(Mutex::new(conn))) |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | - | pub fn create_snippet(db: &Db, name: &str, content: &str) -> Result<Snippet, DbError> { |
|
| 66 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 67 | - | let short_id = generate_short_id(); |
|
| 68 | - | conn.execute( |
|
| 69 | - | "INSERT INTO snippets (short_id, content, name) VALUES (?1, ?2, ?3)", |
|
| 70 | - | params![short_id, content, name], |
|
| 71 | - | )?; |
|
| 72 | - | let id = conn.last_insert_rowid(); |
|
| 73 | - | Ok(Snippet { |
|
| 74 | - | id, |
|
| 75 | - | short_id, |
|
| 76 | - | content: content.to_string(), |
|
| 77 | - | name: name.to_string(), |
|
| 78 | - | }) |
|
| 79 | - | } |
|
| 80 | - | ||
| 81 | - | pub fn get_snippet_by_short_id(db: &Db, short_id: &str) -> Result<Option<Snippet>, DbError> { |
|
| 82 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 83 | - | let snippet = conn |
|
| 84 | - | .query_row( |
|
| 85 | - | &format!("SELECT {} FROM snippets WHERE short_id = ?1", SNIPPET_COLS), |
|
| 86 | - | params![short_id], |
|
| 87 | - | snippet_from_row, |
|
| 88 | - | ) |
|
| 89 | - | .optional()?; |
|
| 90 | - | Ok(snippet) |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | pub fn get_all_snippets(db: &Db) -> Result<Vec<Snippet>, DbError> { |
|
| 94 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 95 | - | let mut stmt = conn.prepare( |
|
| 96 | - | &format!("SELECT {} FROM snippets ORDER BY id DESC", SNIPPET_COLS), |
|
| 97 | - | )?; |
|
| 98 | - | let snippets = stmt |
|
| 99 | - | .query_map([], snippet_from_row)? |
|
| 100 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 101 | - | Ok(snippets) |
|
| 102 | - | } |
|
| 103 | - | ||
| 104 | - | pub fn delete_snippet_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 105 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 106 | - | let rows_affected = conn.execute( |
|
| 107 | - | "DELETE FROM snippets WHERE short_id = ?1", |
|
| 108 | - | params![short_id], |
|
| 109 | - | )?; |
|
| 110 | - | Ok(rows_affected > 0) |
|
| 111 | - | } |
|
| 112 | - | ||
| 113 | - | pub fn update_snippet_by_short_id( |
|
| 114 | - | db: &Db, |
|
| 115 | - | short_id: &str, |
|
| 116 | - | name: &str, |
|
| 117 | - | content: &str, |
|
| 118 | - | ) -> Result<Option<Snippet>, DbError> { |
|
| 119 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 120 | - | let rows_affected = conn.execute( |
|
| 121 | - | "UPDATE snippets SET name = ?1, content = ?2 WHERE short_id = ?3", |
|
| 122 | - | params![name, content, short_id], |
|
| 123 | - | )?; |
|
| 124 | - | if rows_affected == 0 { |
|
| 125 | - | return Ok(None); |
|
| 126 | - | } |
|
| 127 | - | let snippet = conn |
|
| 128 | - | .query_row( |
|
| 129 | - | &format!("SELECT {} FROM snippets WHERE short_id = ?1", SNIPPET_COLS), |
|
| 130 | - | params![short_id], |
|
| 131 | - | snippet_from_row, |
|
| 132 | - | ) |
|
| 133 | - | .optional()?; |
|
| 134 | - | Ok(snippet) |
|
| 135 | - | } |
|
| 136 | - | ||
| 137 | - | #[cfg(test)] |
|
| 138 | - | mod tests { |
|
| 139 | - | use super::*; |
|
| 140 | - | ||
| 141 | - | fn test_db() -> Db { |
|
| 142 | - | let conn = Connection::open_in_memory().unwrap(); |
|
| 143 | - | conn.execute_batch(SCHEMA).unwrap(); |
|
| 144 | - | Arc::new(Mutex::new(conn)) |
|
| 145 | - | } |
|
| 146 | - | ||
| 147 | - | #[test] |
|
| 148 | - | fn create_and_get_snippet() { |
|
| 149 | - | let db = test_db(); |
|
| 150 | - | let snippet = create_snippet(&db, "hello.rs", "fn main() {}").unwrap(); |
|
| 151 | - | assert_eq!(snippet.name, "hello.rs"); |
|
| 152 | - | assert_eq!(snippet.content, "fn main() {}"); |
|
| 153 | - | assert!(!snippet.short_id.is_empty()); |
|
| 154 | - | ||
| 155 | - | let fetched = get_snippet_by_short_id(&db, &snippet.short_id) |
|
| 156 | - | .unwrap() |
|
| 157 | - | .unwrap(); |
|
| 158 | - | assert_eq!(fetched.name, "hello.rs"); |
|
| 159 | - | assert_eq!(fetched.content, "fn main() {}"); |
|
| 160 | - | } |
|
| 161 | - | ||
| 162 | - | #[test] |
|
| 163 | - | fn get_snippet_not_found() { |
|
| 164 | - | let db = test_db(); |
|
| 165 | - | let result = get_snippet_by_short_id(&db, "nonexistent").unwrap(); |
|
| 166 | - | assert!(result.is_none()); |
|
| 167 | - | } |
|
| 168 | - | ||
| 169 | - | #[test] |
|
| 170 | - | fn get_all_snippets_ordered_desc() { |
|
| 171 | - | let db = test_db(); |
|
| 172 | - | create_snippet(&db, "first", "aaa").unwrap(); |
|
| 173 | - | create_snippet(&db, "second", "bbb").unwrap(); |
|
| 174 | - | ||
| 175 | - | let all = get_all_snippets(&db).unwrap(); |
|
| 176 | - | assert_eq!(all.len(), 2); |
|
| 177 | - | assert_eq!(all[0].name, "second"); // DESC order |
|
| 178 | - | assert_eq!(all[1].name, "first"); |
|
| 179 | - | } |
|
| 180 | - | ||
| 181 | - | #[test] |
|
| 182 | - | fn update_snippet() { |
|
| 183 | - | let db = test_db(); |
|
| 184 | - | let snippet = create_snippet(&db, "old.rs", "old content").unwrap(); |
|
| 185 | - | ||
| 186 | - | let updated = update_snippet_by_short_id(&db, &snippet.short_id, "new.rs", "new content") |
|
| 187 | - | .unwrap() |
|
| 188 | - | .unwrap(); |
|
| 189 | - | assert_eq!(updated.name, "new.rs"); |
|
| 190 | - | assert_eq!(updated.content, "new content"); |
|
| 191 | - | } |
|
| 192 | - | ||
| 193 | - | #[test] |
|
| 194 | - | fn update_nonexistent_snippet() { |
|
| 195 | - | let db = test_db(); |
|
| 196 | - | let result = update_snippet_by_short_id(&db, "nope", "name", "content").unwrap(); |
|
| 197 | - | assert!(result.is_none()); |
|
| 198 | - | } |
|
| 199 | - | ||
| 200 | - | #[test] |
|
| 201 | - | fn delete_snippet() { |
|
| 202 | - | let db = test_db(); |
|
| 203 | - | let snippet = create_snippet(&db, "test", "content").unwrap(); |
|
| 204 | - | ||
| 205 | - | let deleted = delete_snippet_by_short_id(&db, &snippet.short_id).unwrap(); |
|
| 206 | - | assert!(deleted); |
|
| 207 | - | ||
| 208 | - | let fetched = get_snippet_by_short_id(&db, &snippet.short_id).unwrap(); |
|
| 209 | - | assert!(fetched.is_none()); |
|
| 210 | - | } |
|
| 211 | - | ||
| 212 | - | #[test] |
|
| 213 | - | fn delete_nonexistent_returns_false() { |
|
| 214 | - | let db = test_db(); |
|
| 215 | - | let deleted = delete_snippet_by_short_id(&db, "nonexistent").unwrap(); |
|
| 216 | - | assert!(!deleted); |
|
| 217 | - | } |
|
| 218 | - | } |
| 1 | - | use std::io::Cursor; |
|
| 2 | - | use syntect::highlighting::{Theme, ThemeSet}; |
|
| 3 | - | use syntect::html::highlighted_html_for_string; |
|
| 4 | - | use syntect::parsing::SyntaxSet; |
|
| 5 | - | ||
| 6 | - | pub struct Highlighter { |
|
| 7 | - | syntax_set: SyntaxSet, |
|
| 8 | - | theme: Theme, |
|
| 9 | - | } |
|
| 10 | - | ||
| 11 | - | impl Highlighter { |
|
| 12 | - | pub fn new() -> Self { |
|
| 13 | - | let theme_data = include_bytes!("darkmatter.tmTheme"); |
|
| 14 | - | let theme = ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..])) |
|
| 15 | - | .expect("failed to load darkmatter theme"); |
|
| 16 | - | Self { |
|
| 17 | - | syntax_set: SyntaxSet::load_defaults_newlines(), |
|
| 18 | - | theme, |
|
| 19 | - | } |
|
| 20 | - | } |
|
| 21 | - | ||
| 22 | - | pub fn highlight(&self, name: &str, content: &str) -> String { |
|
| 23 | - | let raw_ext = name.rsplit('.').next().unwrap_or(""); |
|
| 24 | - | let ext = match raw_ext { |
|
| 25 | - | "ts" | "tsx" | "jsx" => "js", |
|
| 26 | - | other => other, |
|
| 27 | - | }; |
|
| 28 | - | let syntax = self |
|
| 29 | - | .syntax_set |
|
| 30 | - | .find_syntax_by_extension(ext) |
|
| 31 | - | .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); |
|
| 32 | - | highlighted_html_for_string(content, &self.syntax_set, syntax, &self.theme) |
|
| 33 | - | .unwrap_or_else(|_| { |
|
| 34 | - | let escaped = content |
|
| 35 | - | .replace('&', "&") |
|
| 36 | - | .replace('<', "<") |
|
| 37 | - | .replace('>', ">"); |
|
| 38 | - | format!("<pre>{}</pre>", escaped) |
|
| 39 | - | }) |
|
| 40 | - | } |
|
| 41 | - | } |
| 1 | - | pub mod auth; |
|
| 2 | - | pub mod backend; |
|
| 3 | - | pub mod config; |
|
| 4 | - | pub mod db; |
|
| 5 | - | pub mod highlight; |
|
| 6 | - | pub mod server; |
|
| 7 | - | pub mod tui; |
| 1 | - | use clap::{Parser, Subcommand}; |
|
| 2 | - | use std::path::PathBuf; |
|
| 3 | - | ||
| 4 | - | #[derive(Parser)] |
|
| 5 | - | #[command(name = "sipp", about = "Snippet manager — TUI, server, and CLI")] |
|
| 6 | - | struct Cli { |
|
| 7 | - | /// Remote server URL (e.g. http://localhost:3000) |
|
| 8 | - | #[arg(short, long, env = "SIPP_REMOTE_URL")] |
|
| 9 | - | remote: Option<String>, |
|
| 10 | - | ||
| 11 | - | /// API key for authenticated operations |
|
| 12 | - | #[arg(short = 'k', long, env = "SIPP_API_KEY")] |
|
| 13 | - | api_key: Option<String>, |
|
| 14 | - | ||
| 15 | - | /// File path to create a snippet from |
|
| 16 | - | #[arg(value_name = "FILE")] |
|
| 17 | - | file: Option<PathBuf>, |
|
| 18 | - | ||
| 19 | - | #[command(subcommand)] |
|
| 20 | - | command: Option<Commands>, |
|
| 21 | - | } |
|
| 22 | - | ||
| 23 | - | #[derive(Subcommand)] |
|
| 24 | - | enum Commands { |
|
| 25 | - | /// Start the web server |
|
| 26 | - | Server { |
|
| 27 | - | /// Port to listen on |
|
| 28 | - | #[arg(short, long, default_value_t = 3000)] |
|
| 29 | - | port: u16, |
|
| 30 | - | ||
| 31 | - | /// Host to bind to |
|
| 32 | - | #[arg(long, default_value = "localhost")] |
|
| 33 | - | host: String, |
|
| 34 | - | }, |
|
| 35 | - | /// Launch the interactive TUI |
|
| 36 | - | Tui { |
|
| 37 | - | /// Remote server URL (e.g. http://localhost:3000) |
|
| 38 | - | #[arg(short, long, env = "SIPP_REMOTE_URL")] |
|
| 39 | - | remote: Option<String>, |
|
| 40 | - | ||
| 41 | - | /// API key for authenticated operations |
|
| 42 | - | #[arg(short = 'k', long, env = "SIPP_API_KEY")] |
|
| 43 | - | api_key: Option<String>, |
|
| 44 | - | }, |
|
| 45 | - | /// Save remote URL and API key to config file |
|
| 46 | - | Auth, |
|
| 47 | - | } |
|
| 48 | - | ||
| 49 | - | fn main() -> Result<(), Box<dyn std::error::Error>> { |
|
| 50 | - | let cli = Cli::parse(); |
|
| 51 | - | ||
| 52 | - | match cli.command { |
|
| 53 | - | Some(Commands::Server { port, host }) => { |
|
| 54 | - | let rt = tokio::runtime::Runtime::new()?; |
|
| 55 | - | rt.block_on(sipp_so::server::run(host, port)); |
|
| 56 | - | } |
|
| 57 | - | Some(Commands::Tui { remote, api_key }) => { |
|
| 58 | - | sipp_so::tui::run_interactive(remote, api_key)?; |
|
| 59 | - | } |
|
| 60 | - | Some(Commands::Auth) => { |
|
| 61 | - | sipp_so::tui::run_auth()?; |
|
| 62 | - | } |
|
| 63 | - | None => { |
|
| 64 | - | if let Some(file) = cli.file { |
|
| 65 | - | sipp_so::tui::run_file_upload(cli.remote, cli.api_key, file)?; |
|
| 66 | - | } else { |
|
| 67 | - | sipp_so::tui::run_interactive(cli.remote, cli.api_key)?; |
|
| 68 | - | } |
|
| 69 | - | } |
|
| 70 | - | } |
|
| 71 | - | ||
| 72 | - | Ok(()) |
|
| 73 | - | } |
| 1 | - | use askama::Template; |
|
| 2 | - | use askama_web::WebTemplate; |
|
| 3 | - | use axum::{ |
|
| 4 | - | Form, Json, Router, |
|
| 5 | - | extract::{Path, Query, Request, State}, |
|
| 6 | - | http::{HeaderMap, HeaderValue, StatusCode, header}, |
|
| 7 | - | middleware::{self, Next}, |
|
| 8 | - | response::{Html, IntoResponse, Redirect, Response}, |
|
| 9 | - | routing::{delete, get, post, put}, |
|
| 10 | - | }; |
|
| 11 | - | use rust_embed::Embed; |
|
| 12 | - | use serde::Deserialize; |
|
| 13 | - | use crate::auth; |
|
| 14 | - | use crate::db::{self, Db, Snippet}; |
|
| 15 | - | use crate::highlight::Highlighter; |
|
| 16 | - | use std::collections::HashSet; |
|
| 17 | - | use std::sync::Arc; |
|
| 18 | - | ||
| 19 | - | #[derive(Embed)] |
|
| 20 | - | #[folder = "static/"] |
|
| 21 | - | struct Static; |
|
| 22 | - | ||
| 23 | - | #[derive(Clone)] |
|
| 24 | - | pub struct ServerConfig { |
|
| 25 | - | api_key: Option<String>, |
|
| 26 | - | auth_endpoints: HashSet<String>, |
|
| 27 | - | max_content_size: usize, |
|
| 28 | - | } |
|
| 29 | - | ||
| 30 | - | impl ServerConfig { |
|
| 31 | - | fn from_env() -> Self { |
|
| 32 | - | let api_key = std::env::var("SIPP_API_KEY").ok(); |
|
| 33 | - | let auth_endpoints = match std::env::var("SIPP_AUTH_ENDPOINTS") { |
|
| 34 | - | Ok(val) if val.trim().eq_ignore_ascii_case("none") => HashSet::new(), |
|
| 35 | - | Ok(val) => val.split(',').map(|s| s.trim().to_lowercase()).collect(), |
|
| 36 | - | Err(_) => ["api_delete", "api_list", "api_update"].iter().map(|s| s.to_string()).collect(), |
|
| 37 | - | }; |
|
| 38 | - | let max_content_size = std::env::var("SIPP_MAX_CONTENT_SIZE") |
|
| 39 | - | .ok() |
|
| 40 | - | .and_then(|v| v.parse().ok()) |
|
| 41 | - | .unwrap_or(512_000); |
|
| 42 | - | ServerConfig { api_key, auth_endpoints, max_content_size } |
|
| 43 | - | } |
|
| 44 | - | ||
| 45 | - | fn requires_auth(&self, name: &str) -> bool { |
|
| 46 | - | self.auth_endpoints.contains("all") || self.auth_endpoints.contains(name) |
|
| 47 | - | } |
|
| 48 | - | } |
|
| 49 | - | ||
| 50 | - | #[derive(Clone)] |
|
| 51 | - | pub struct AppState { |
|
| 52 | - | pub db: Db, |
|
| 53 | - | pub highlighter: Arc<Highlighter>, |
|
| 54 | - | pub server_config: ServerConfig, |
|
| 55 | - | pub base_url: String, |
|
| 56 | - | pub cookie_secure: bool, |
|
| 57 | - | } |
|
| 58 | - | ||
| 59 | - | #[derive(Template)] |
|
| 60 | - | #[template(path = "index.html")] |
|
| 61 | - | struct IndexTemplate { |
|
| 62 | - | base_url: String, |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | - | #[derive(Template)] |
|
| 66 | - | #[template(path = "admin.html")] |
|
| 67 | - | struct AdminTemplate { |
|
| 68 | - | base_url: String, |
|
| 69 | - | snippets: Vec<Snippet>, |
|
| 70 | - | } |
|
| 71 | - | ||
| 72 | - | #[derive(Template)] |
|
| 73 | - | #[template(path = "login.html")] |
|
| 74 | - | struct LoginTemplate { |
|
| 75 | - | error: Option<String>, |
|
| 76 | - | next: Option<String>, |
|
| 77 | - | } |
|
| 78 | - | ||
| 79 | - | #[derive(Template)] |
|
| 80 | - | #[template(path = "snippet.html")] |
|
| 81 | - | struct SnippetTemplate { |
|
| 82 | - | base_url: String, |
|
| 83 | - | name: String, |
|
| 84 | - | content: String, |
|
| 85 | - | highlighted_content: String, |
|
| 86 | - | } |
|
| 87 | - | ||
| 88 | - | #[derive(Deserialize)] |
|
| 89 | - | struct CreateSnippetForm { |
|
| 90 | - | name: String, |
|
| 91 | - | content: String, |
|
| 92 | - | } |
|
| 93 | - | ||
| 94 | - | #[derive(Deserialize)] |
|
| 95 | - | struct LoginForm { |
|
| 96 | - | api_key: String, |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | #[derive(Deserialize, Default)] |
|
| 100 | - | struct FlashQuery { |
|
| 101 | - | error: Option<String>, |
|
| 102 | - | next: Option<String>, |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | async fn index(State(state): State<AppState>) -> WebTemplate<IndexTemplate> { |
|
| 106 | - | WebTemplate(IndexTemplate { base_url: state.base_url.clone() }) |
|
| 107 | - | } |
|
| 108 | - | ||
| 109 | - | async fn admin( |
|
| 110 | - | _session: auth::AuthSession, |
|
| 111 | - | State(state): State<AppState>, |
|
| 112 | - | ) -> Response { |
|
| 113 | - | match db::get_all_snippets(&state.db) { |
|
| 114 | - | Ok(snippets) => WebTemplate(AdminTemplate { |
|
| 115 | - | base_url: state.base_url.clone(), |
|
| 116 | - | snippets, |
|
| 117 | - | }) |
|
| 118 | - | .into_response(), |
|
| 119 | - | Err(_) => ( |
|
| 120 | - | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 121 | - | Html("<h1>Internal server error</h1>".to_string()), |
|
| 122 | - | ) |
|
| 123 | - | .into_response(), |
|
| 124 | - | } |
|
| 125 | - | } |
|
| 126 | - | ||
| 127 | - | async fn get_login(Query(q): Query<FlashQuery>) -> WebTemplate<LoginTemplate> { |
|
| 128 | - | WebTemplate(LoginTemplate { |
|
| 129 | - | error: q.error, |
|
| 130 | - | next: q.next, |
|
| 131 | - | }) |
|
| 132 | - | } |
|
| 133 | - | ||
| 134 | - | async fn post_login( |
|
| 135 | - | State(state): State<AppState>, |
|
| 136 | - | Query(q): Query<FlashQuery>, |
|
| 137 | - | Form(form): Form<LoginForm>, |
|
| 138 | - | ) -> Response { |
|
| 139 | - | let next = q.next.as_deref().unwrap_or("/admin"); |
|
| 140 | - | let server_key = match &state.server_config.api_key { |
|
| 141 | - | Some(k) => k, |
|
| 142 | - | None => { |
|
| 143 | - | return Redirect::to("/admin/login?error=No+API+key+configured").into_response(); |
|
| 144 | - | } |
|
| 145 | - | }; |
|
| 146 | - | ||
| 147 | - | if !auth::verify_api_key(&form.api_key, server_key) { |
|
| 148 | - | return Redirect::to(&format!( |
|
| 149 | - | "/admin/login?error=Invalid+API+key&next={}", |
|
| 150 | - | auth::urlencoding(next) |
|
| 151 | - | )) |
|
| 152 | - | .into_response(); |
|
| 153 | - | } |
|
| 154 | - | ||
| 155 | - | let token = auth::generate_session_token(); |
|
| 156 | - | let expires_at = andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600); |
|
| 157 | - | ||
| 158 | - | if db::insert_session(&state.db, &token, &expires_at).is_err() { |
|
| 159 | - | return Redirect::to("/admin/login?error=Server+error").into_response(); |
|
| 160 | - | } |
|
| 161 | - | let _ = db::prune_expired_sessions(&state.db); |
|
| 162 | - | ||
| 163 | - | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 164 | - | let redirect_to = if next.starts_with('/') { next } else { "/admin" }; |
|
| 165 | - | let mut resp = Redirect::to(redirect_to).into_response(); |
|
| 166 | - | resp.headers_mut().insert( |
|
| 167 | - | header::SET_COOKIE, |
|
| 168 | - | HeaderValue::from_str(&cookie).unwrap(), |
|
| 169 | - | ); |
|
| 170 | - | resp |
|
| 171 | - | } |
|
| 172 | - | ||
| 173 | - | async fn post_logout( |
|
| 174 | - | State(state): State<AppState>, |
|
| 175 | - | headers: HeaderMap, |
|
| 176 | - | ) -> Response { |
|
| 177 | - | if let Some(token) = andromeda_auth::extract_session_cookie(&headers) { |
|
| 178 | - | let _ = db::delete_session(&state.db, &token); |
|
| 179 | - | } |
|
| 180 | - | let cookie = auth::clear_session_cookie(); |
|
| 181 | - | let mut resp = Redirect::to("/admin/login").into_response(); |
|
| 182 | - | resp.headers_mut().insert( |
|
| 183 | - | header::SET_COOKIE, |
|
| 184 | - | HeaderValue::from_str(&cookie).unwrap(), |
|
| 185 | - | ); |
|
| 186 | - | resp |
|
| 187 | - | } |
|
| 188 | - | ||
| 189 | - | async fn admin_delete_snippet( |
|
| 190 | - | _session: auth::AuthSession, |
|
| 191 | - | State(state): State<AppState>, |
|
| 192 | - | Path(short_id): Path<String>, |
|
| 193 | - | ) -> Response { |
|
| 194 | - | let _ = db::delete_snippet_by_short_id(&state.db, &short_id); |
|
| 195 | - | Redirect::to("/admin").into_response() |
|
| 196 | - | } |
|
| 197 | - | ||
| 198 | - | fn is_cli_user_agent(headers: &HeaderMap) -> bool { |
|
| 199 | - | headers |
|
| 200 | - | .get(header::USER_AGENT) |
|
| 201 | - | .and_then(|v| v.to_str().ok()) |
|
| 202 | - | .map(|ua| { |
|
| 203 | - | let ua = ua.to_lowercase(); |
|
| 204 | - | ua.starts_with("curl/") || ua.starts_with("wget/") || ua.starts_with("httpie/") |
|
| 205 | - | }) |
|
| 206 | - | .unwrap_or(false) |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | async fn view_snippet( |
|
| 210 | - | State(state): State<AppState>, |
|
| 211 | - | Path(short_id): Path<String>, |
|
| 212 | - | headers: HeaderMap, |
|
| 213 | - | ) -> Result<Response, (StatusCode, Html<String>)> { |
|
| 214 | - | match db::get_snippet_by_short_id(&state.db, &short_id) { |
|
| 215 | - | Ok(Some(snippet)) => { |
|
| 216 | - | if is_cli_user_agent(&headers) { |
|
| 217 | - | Ok(( |
|
| 218 | - | [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], |
|
| 219 | - | snippet.content, |
|
| 220 | - | ) |
|
| 221 | - | .into_response()) |
|
| 222 | - | } else { |
|
| 223 | - | let highlighted_content = |
|
| 224 | - | state.highlighter.highlight(&snippet.name, &snippet.content); |
|
| 225 | - | Ok(WebTemplate(SnippetTemplate { |
|
| 226 | - | base_url: state.base_url.clone(), |
|
| 227 | - | name: snippet.name, |
|
| 228 | - | content: snippet.content, |
|
| 229 | - | highlighted_content, |
|
| 230 | - | }) |
|
| 231 | - | .into_response()) |
|
| 232 | - | } |
|
| 233 | - | } |
|
| 234 | - | Ok(None) => Err(( |
|
| 235 | - | StatusCode::NOT_FOUND, |
|
| 236 | - | Html("<h1>Snippet not found</h1>".to_string()), |
|
| 237 | - | )), |
|
| 238 | - | Err(_) => Err(( |
|
| 239 | - | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 240 | - | Html("<h1>Internal server error</h1>".to_string()), |
|
| 241 | - | )), |
|
| 242 | - | } |
|
| 243 | - | } |
|
| 244 | - | ||
| 245 | - | async fn create_snippet( |
|
| 246 | - | State(state): State<AppState>, |
|
| 247 | - | Form(form): Form<CreateSnippetForm>, |
|
| 248 | - | ) -> Result<Redirect, (StatusCode, Html<String>)> { |
|
| 249 | - | if form.content.len() > state.server_config.max_content_size { |
|
| 250 | - | return Err(( |
|
| 251 | - | StatusCode::PAYLOAD_TOO_LARGE, |
|
| 252 | - | Html(format!( |
|
| 253 | - | "<h1>Content too large</h1><p>Maximum size is {} bytes</p>", |
|
| 254 | - | state.server_config.max_content_size |
|
| 255 | - | )), |
|
| 256 | - | )); |
|
| 257 | - | } |
|
| 258 | - | match db::create_snippet(&state.db, &form.name, &form.content) { |
|
| 259 | - | Ok(snippet) => Ok(Redirect::to(&format!("/s/{}", snippet.short_id))), |
|
| 260 | - | Err(_) => Err(( |
|
| 261 | - | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 262 | - | Html("<h1>Internal server error</h1>".to_string()), |
|
| 263 | - | )), |
|
| 264 | - | } |
|
| 265 | - | } |
|
| 266 | - | ||
| 267 | - | async fn require_api_key( |
|
| 268 | - | State(state): State<AppState>, |
|
| 269 | - | headers: HeaderMap, |
|
| 270 | - | request: Request, |
|
| 271 | - | next: Next, |
|
| 272 | - | ) -> Result<Response, (StatusCode, Json<serde_json::Value>)> { |
|
| 273 | - | let server_key = match &state.server_config.api_key { |
|
| 274 | - | Some(k) => k, |
|
| 275 | - | None => return Err(( |
|
| 276 | - | StatusCode::FORBIDDEN, |
|
| 277 | - | Json(serde_json::json!({"error": "No API key configured on server"})), |
|
| 278 | - | )), |
|
| 279 | - | }; |
|
| 280 | - | let provided = headers |
|
| 281 | - | .get("x-api-key") |
|
| 282 | - | .and_then(|v| v.to_str().ok()); |
|
| 283 | - | if let Some(k) = provided { |
|
| 284 | - | if auth::verify_api_key(k, server_key) { |
|
| 285 | - | return Ok(next.run(request).await); |
|
| 286 | - | } |
|
| 287 | - | } |
|
| 288 | - | if let Some(token) = andromeda_auth::extract_session_cookie(&headers) { |
|
| 289 | - | if auth::is_valid_session(&state, &token) { |
|
| 290 | - | return Ok(next.run(request).await); |
|
| 291 | - | } |
|
| 292 | - | } |
|
| 293 | - | Err(( |
|
| 294 | - | StatusCode::UNAUTHORIZED, |
|
| 295 | - | Json(serde_json::json!({"error": "Invalid or missing API key"})), |
|
| 296 | - | )) |
|
| 297 | - | } |
|
| 298 | - | ||
| 299 | - | async fn api_list_snippets( |
|
| 300 | - | State(state): State<AppState>, |
|
| 301 | - | ) -> Result<Json<Vec<Snippet>>, (StatusCode, Json<serde_json::Value>)> { |
|
| 302 | - | match db::get_all_snippets(&state.db) { |
|
| 303 | - | Ok(snippets) => Ok(Json(snippets)), |
|
| 304 | - | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 305 | - | } |
|
| 306 | - | } |
|
| 307 | - | ||
| 308 | - | async fn api_get_snippet( |
|
| 309 | - | State(state): State<AppState>, |
|
| 310 | - | Path(short_id): Path<String>, |
|
| 311 | - | ) -> Result<Json<Snippet>, (StatusCode, Json<serde_json::Value>)> { |
|
| 312 | - | match db::get_snippet_by_short_id(&state.db, &short_id) { |
|
| 313 | - | Ok(Some(snippet)) => Ok(Json(snippet)), |
|
| 314 | - | Ok(None) => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"})))), |
|
| 315 | - | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 316 | - | } |
|
| 317 | - | } |
|
| 318 | - | ||
| 319 | - | #[derive(Deserialize)] |
|
| 320 | - | struct ApiCreateSnippet { |
|
| 321 | - | name: String, |
|
| 322 | - | content: String, |
|
| 323 | - | } |
|
| 324 | - | ||
| 325 | - | async fn api_create_snippet( |
|
| 326 | - | State(state): State<AppState>, |
|
| 327 | - | Json(body): Json<ApiCreateSnippet>, |
|
| 328 | - | ) -> Result<(StatusCode, Json<Snippet>), (StatusCode, Json<serde_json::Value>)> { |
|
| 329 | - | if body.content.len() > state.server_config.max_content_size { |
|
| 330 | - | return Err(( |
|
| 331 | - | StatusCode::PAYLOAD_TOO_LARGE, |
|
| 332 | - | Json(serde_json::json!({ |
|
| 333 | - | "error": format!("Content too large. Maximum size is {} bytes", state.server_config.max_content_size) |
|
| 334 | - | })), |
|
| 335 | - | )); |
|
| 336 | - | } |
|
| 337 | - | match db::create_snippet(&state.db, &body.name, &body.content) { |
|
| 338 | - | Ok(snippet) => Ok((StatusCode::CREATED, Json(snippet))), |
|
| 339 | - | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 340 | - | } |
|
| 341 | - | } |
|
| 342 | - | ||
| 343 | - | async fn api_delete_snippet( |
|
| 344 | - | State(state): State<AppState>, |
|
| 345 | - | Path(short_id): Path<String>, |
|
| 346 | - | ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> { |
|
| 347 | - | match db::delete_snippet_by_short_id(&state.db, &short_id) { |
|
| 348 | - | Ok(true) => Ok(Json(serde_json::json!({"deleted": true}))), |
|
| 349 | - | Ok(false) => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"})))), |
|
| 350 | - | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 351 | - | } |
|
| 352 | - | } |
|
| 353 | - | ||
| 354 | - | async fn api_update_snippet( |
|
| 355 | - | State(state): State<AppState>, |
|
| 356 | - | Path(short_id): Path<String>, |
|
| 357 | - | Json(body): Json<ApiCreateSnippet>, |
|
| 358 | - | ) -> Result<Json<Snippet>, (StatusCode, Json<serde_json::Value>)> { |
|
| 359 | - | if body.content.len() > state.server_config.max_content_size { |
|
| 360 | - | return Err(( |
|
| 361 | - | StatusCode::PAYLOAD_TOO_LARGE, |
|
| 362 | - | Json(serde_json::json!({ |
|
| 363 | - | "error": format!("Content too large. Maximum size is {} bytes", state.server_config.max_content_size) |
|
| 364 | - | })), |
|
| 365 | - | )); |
|
| 366 | - | } |
|
| 367 | - | match db::update_snippet_by_short_id(&state.db, &short_id, &body.name, &body.content) { |
|
| 368 | - | Ok(Some(snippet)) => Ok(Json(snippet)), |
|
| 369 | - | Ok(None) => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"})))), |
|
| 370 | - | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 371 | - | } |
|
| 372 | - | } |
|
| 373 | - | ||
| 374 | - | fn build_api_routes(state: &AppState) -> Router<AppState> { |
|
| 375 | - | let config = &state.server_config; |
|
| 376 | - | ||
| 377 | - | let auth_layer = middleware::from_fn_with_state(state.clone(), require_api_key); |
|
| 378 | - | ||
| 379 | - | // /api/snippets — GET (api_list) and POST (api_create) |
|
| 380 | - | let list_authed = config.requires_auth("api_list"); |
|
| 381 | - | let create_authed = config.requires_auth("api_create"); |
|
| 382 | - | ||
| 383 | - | // /api/snippets/{short_id} — GET (api_get), PUT (api_update), and DELETE (api_delete) |
|
| 384 | - | let get_authed = config.requires_auth("api_get"); |
|
| 385 | - | let update_authed = config.requires_auth("api_update"); |
|
| 386 | - | let delete_authed = config.requires_auth("api_delete"); |
|
| 387 | - | ||
| 388 | - | // Build authed router |
|
| 389 | - | let mut authed = Router::new(); |
|
| 390 | - | if list_authed { |
|
| 391 | - | authed = authed.route("/api/snippets", get(api_list_snippets)); |
|
| 392 | - | } |
|
| 393 | - | if create_authed { |
|
| 394 | - | authed = authed.route("/api/snippets", post(api_create_snippet)); |
|
| 395 | - | } |
|
| 396 | - | if get_authed { |
|
| 397 | - | authed = authed.route("/api/snippets/{short_id}", get(api_get_snippet)); |
|
| 398 | - | } |
|
| 399 | - | if update_authed { |
|
| 400 | - | authed = authed.route("/api/snippets/{short_id}", put(api_update_snippet)); |
|
| 401 | - | } |
|
| 402 | - | if delete_authed { |
|
| 403 | - | authed = authed.route("/api/snippets/{short_id}", delete(api_delete_snippet)); |
|
| 404 | - | } |
|
| 405 | - | let authed = authed.route_layer(auth_layer); |
|
| 406 | - | ||
| 407 | - | // Build open router |
|
| 408 | - | let mut open = Router::new(); |
|
| 409 | - | if !list_authed { |
|
| 410 | - | open = open.route("/api/snippets", get(api_list_snippets)); |
|
| 411 | - | } |
|
| 412 | - | if !create_authed { |
|
| 413 | - | open = open.route("/api/snippets", post(api_create_snippet)); |
|
| 414 | - | } |
|
| 415 | - | if !get_authed { |
|
| 416 | - | open = open.route("/api/snippets/{short_id}", get(api_get_snippet)); |
|
| 417 | - | } |
|
| 418 | - | if !update_authed { |
|
| 419 | - | open = open.route("/api/snippets/{short_id}", put(api_update_snippet)); |
|
| 420 | - | } |
|
| 421 | - | if !delete_authed { |
|
| 422 | - | open = open.route("/api/snippets/{short_id}", delete(api_delete_snippet)); |
|
| 423 | - | } |
|
| 424 | - | ||
| 425 | - | authed.merge(open) |
|
| 426 | - | } |
|
| 427 | - | ||
| 428 | - | fn mime_from_path(path: &str) -> &'static str { |
|
| 429 | - | match path.rsplit('.').next().unwrap_or("") { |
|
| 430 | - | "css" => "text/css", |
|
| 431 | - | "js" => "application/javascript", |
|
| 432 | - | "html" => "text/html", |
|
| 433 | - | "png" => "image/png", |
|
| 434 | - | "ico" => "image/x-icon", |
|
| 435 | - | "svg" => "image/svg+xml", |
|
| 436 | - | "woff" => "font/woff", |
|
| 437 | - | "woff2" => "font/woff2", |
|
| 438 | - | "ttf" => "font/ttf", |
|
| 439 | - | "otf" => "font/otf", |
|
| 440 | - | "json" | "webmanifest" => "application/json", |
|
| 441 | - | "jpg" | "jpeg" => "image/jpeg", |
|
| 442 | - | _ => "application/octet-stream", |
|
| 443 | - | } |
|
| 444 | - | } |
|
| 445 | - | ||
| 446 | - | async fn serve_static(Path(path): Path<String>) -> Response { |
|
| 447 | - | match Static::get(&path) { |
|
| 448 | - | Some(file) => { |
|
| 449 | - | let mime = mime_from_path(&path); |
|
| 450 | - | ([(header::CONTENT_TYPE, mime)], file.data).into_response() |
|
| 451 | - | } |
|
| 452 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 453 | - | } |
|
| 454 | - | } |
|
| 455 | - | ||
| 456 | - | pub async fn run(host: String, port: u16) { |
|
| 457 | - | dotenvy::dotenv().ok(); |
|
| 458 | - | ||
| 459 | - | let server_config = ServerConfig::from_env(); |
|
| 460 | - | ||
| 461 | - | // Validate endpoint names |
|
| 462 | - | let known = ["api_list", "api_create", "api_get", "api_update", "api_delete", "all", "none"]; |
|
| 463 | - | for name in &server_config.auth_endpoints { |
|
| 464 | - | if !known.contains(&name.as_str()) { |
|
| 465 | - | eprintln!("Warning: unknown auth endpoint name '{}' in SIPP_AUTH_ENDPOINTS", name); |
|
| 466 | - | } |
|
| 467 | - | } |
|
| 468 | - | ||
| 469 | - | if !server_config.auth_endpoints.is_empty() && server_config.api_key.is_none() { |
|
| 470 | - | eprintln!("Warning: SIPP_AUTH_ENDPOINTS is set but SIPP_API_KEY is not configured"); |
|
| 471 | - | } |
|
| 472 | - | ||
| 473 | - | if server_config.auth_endpoints.is_empty() { |
|
| 474 | - | println!("Auth: disabled (no endpoints require authentication)"); |
|
| 475 | - | } else { |
|
| 476 | - | let names: Vec<&str> = server_config.auth_endpoints.iter().map(|s| s.as_str()).collect(); |
|
| 477 | - | println!("Auth: enabled for endpoints: {}", names.join(", ")); |
|
| 478 | - | } |
|
| 479 | - | ||
| 480 | - | println!("Max content size: {} bytes", server_config.max_content_size); |
|
| 481 | - | ||
| 482 | - | let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); |
|
| 483 | - | ||
| 484 | - | let cookie_secure = std::env::var("SIPP_COOKIE_SECURE") |
|
| 485 | - | .map(|v| v == "true") |
|
| 486 | - | .unwrap_or(false); |
|
| 487 | - | ||
| 488 | - | let db = db::init_db().expect("Failed to initialize database"); |
|
| 489 | - | let _ = db::prune_expired_sessions(&db); |
|
| 490 | - | ||
| 491 | - | let state = AppState { |
|
| 492 | - | db, |
|
| 493 | - | highlighter: Arc::new(Highlighter::new()), |
|
| 494 | - | server_config, |
|
| 495 | - | base_url, |
|
| 496 | - | cookie_secure, |
|
| 497 | - | }; |
|
| 498 | - | ||
| 499 | - | let api_routes = build_api_routes(&state); |
|
| 500 | - | ||
| 501 | - | let app = Router::new() |
|
| 502 | - | .route("/", get(index)) |
|
| 503 | - | .route("/admin", get(admin)) |
|
| 504 | - | .route("/admin/login", get(get_login).post(post_login)) |
|
| 505 | - | .route("/admin/logout", post(post_logout)) |
|
| 506 | - | .route("/admin/snippets/{short_id}/delete", post(admin_delete_snippet)) |
|
| 507 | - | .route("/s/{short_id}", get(view_snippet)) |
|
| 508 | - | .route("/snippets", post(create_snippet)) |
|
| 509 | - | .merge(api_routes) |
|
| 510 | - | .route("/static/{*path}", get(serve_static)) |
|
| 511 | - | .merge(andromeda_darkmatter_css::router::<AppState>()) |
|
| 512 | - | .with_state(state); |
|
| 513 | - | ||
| 514 | - | let addr = format!("{}:{}", host, port); |
|
| 515 | - | let listener = tokio::net::TcpListener::bind(&addr) |
|
| 516 | - | .await |
|
| 517 | - | .unwrap_or_else(|_| panic!("Failed to bind to {}", addr)); |
|
| 518 | - | ||
| 519 | - | println!("Server running at http://{}:{}", host, port); |
|
| 520 | - | ||
| 521 | - | axum::serve(listener, app) |
|
| 522 | - | .await |
|
| 523 | - | .expect("Failed to start server"); |
|
| 524 | - | } |
| 1 | - | use crate::backend::Backend; |
|
| 2 | - | use crate::db::Snippet; |
|
| 3 | - | use arboard::Clipboard; |
|
| 4 | - | use ratatui::style::{Color, Style}; |
|
| 5 | - | use ratatui::text::{Line, Span, Text}; |
|
| 6 | - | use ratatui::widgets::ListState; |
|
| 7 | - | use std::io::Cursor; |
|
| 8 | - | use std::time::{Duration, Instant}; |
|
| 9 | - | use syntect::easy::HighlightLines; |
|
| 10 | - | use syntect::highlighting::Theme; |
|
| 11 | - | use syntect::parsing::SyntaxSet; |
|
| 12 | - | use syntect::util::LinesWithEndings; |
|
| 13 | - | ||
| 14 | - | pub(super) enum Focus { |
|
| 15 | - | List, |
|
| 16 | - | Content, |
|
| 17 | - | CreateName, |
|
| 18 | - | CreateContent, |
|
| 19 | - | EditName, |
|
| 20 | - | EditContent, |
|
| 21 | - | Search, |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | pub(super) struct App { |
|
| 25 | - | pub(super) snippets: Vec<Snippet>, |
|
| 26 | - | pub(super) list_state: ListState, |
|
| 27 | - | pub(super) should_quit: bool, |
|
| 28 | - | pub(super) status_message: Option<(String, Instant)>, |
|
| 29 | - | pub(super) focus: Focus, |
|
| 30 | - | pub(super) content_scroll: u16, |
|
| 31 | - | pub(super) show_help: bool, |
|
| 32 | - | pub(super) confirm_delete: bool, |
|
| 33 | - | syntax_set: SyntaxSet, |
|
| 34 | - | theme: Theme, |
|
| 35 | - | pub(super) create_name: String, |
|
| 36 | - | pub(super) create_content: String, |
|
| 37 | - | pub(super) edit_short_id: Option<String>, |
|
| 38 | - | pub(super) search_query: String, |
|
| 39 | - | pub(super) filtered_indices: Option<Vec<usize>>, |
|
| 40 | - | pub(super) is_remote: bool, |
|
| 41 | - | pub(super) remote_url: Option<String>, |
|
| 42 | - | pub(super) wrap_content: bool, |
|
| 43 | - | pub(super) edit_scroll: u16, |
|
| 44 | - | } |
|
| 45 | - | ||
| 46 | - | impl App { |
|
| 47 | - | pub(super) fn new( |
|
| 48 | - | snippets: Vec<Snippet>, |
|
| 49 | - | is_remote: bool, |
|
| 50 | - | remote_url: Option<String>, |
|
| 51 | - | ) -> Self { |
|
| 52 | - | let mut list_state = ListState::default(); |
|
| 53 | - | if !snippets.is_empty() { |
|
| 54 | - | list_state.select(Some(0)); |
|
| 55 | - | } |
|
| 56 | - | let syntax_set = SyntaxSet::load_defaults_newlines(); |
|
| 57 | - | let theme_data = include_bytes!("../ansi.tmTheme"); |
|
| 58 | - | let theme = |
|
| 59 | - | syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..])) |
|
| 60 | - | .expect("failed to load base16 theme"); |
|
| 61 | - | Self { |
|
| 62 | - | snippets, |
|
| 63 | - | list_state, |
|
| 64 | - | should_quit: false, |
|
| 65 | - | status_message: None, |
|
| 66 | - | focus: Focus::List, |
|
| 67 | - | content_scroll: 0, |
|
| 68 | - | show_help: false, |
|
| 69 | - | confirm_delete: false, |
|
| 70 | - | syntax_set, |
|
| 71 | - | theme, |
|
| 72 | - | create_name: String::new(), |
|
| 73 | - | create_content: String::new(), |
|
| 74 | - | edit_short_id: None, |
|
| 75 | - | search_query: String::new(), |
|
| 76 | - | filtered_indices: None, |
|
| 77 | - | is_remote, |
|
| 78 | - | remote_url, |
|
| 79 | - | wrap_content: true, |
|
| 80 | - | edit_scroll: 0, |
|
| 81 | - | } |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | pub(super) fn selected_snippet(&self) -> Option<&Snippet> { |
|
| 85 | - | self.list_state.selected().and_then(|i| { |
|
| 86 | - | if let Some(indices) = &self.filtered_indices { |
|
| 87 | - | indices.get(i).and_then(|&real| self.snippets.get(real)) |
|
| 88 | - | } else { |
|
| 89 | - | self.snippets.get(i) |
|
| 90 | - | } |
|
| 91 | - | }) |
|
| 92 | - | } |
|
| 93 | - | ||
| 94 | - | pub(super) fn visible_count(&self) -> usize { |
|
| 95 | - | match &self.filtered_indices { |
|
| 96 | - | Some(indices) => indices.len(), |
|
| 97 | - | None => self.snippets.len(), |
|
| 98 | - | } |
|
| 99 | - | } |
|
| 100 | - | ||
| 101 | - | pub(super) fn move_up(&mut self) { |
|
| 102 | - | let count = self.visible_count(); |
|
| 103 | - | if count == 0 { |
|
| 104 | - | return; |
|
| 105 | - | } |
|
| 106 | - | let i = match self.list_state.selected() { |
|
| 107 | - | Some(i) if i > 0 => i - 1, |
|
| 108 | - | Some(_) => count - 1, |
|
| 109 | - | None => 0, |
|
| 110 | - | }; |
|
| 111 | - | self.list_state.select(Some(i)); |
|
| 112 | - | self.content_scroll = 0; |
|
| 113 | - | } |
|
| 114 | - | ||
| 115 | - | pub(super) fn move_down(&mut self) { |
|
| 116 | - | let count = self.visible_count(); |
|
| 117 | - | if count == 0 { |
|
| 118 | - | return; |
|
| 119 | - | } |
|
| 120 | - | let i = match self.list_state.selected() { |
|
| 121 | - | Some(i) if i < count - 1 => i + 1, |
|
| 122 | - | Some(_) => 0, |
|
| 123 | - | None => 0, |
|
| 124 | - | }; |
|
| 125 | - | self.list_state.select(Some(i)); |
|
| 126 | - | self.content_scroll = 0; |
|
| 127 | - | } |
|
| 128 | - | ||
| 129 | - | pub(super) fn scroll_up(&mut self) { |
|
| 130 | - | self.content_scroll = self.content_scroll.saturating_sub(1); |
|
| 131 | - | } |
|
| 132 | - | ||
| 133 | - | pub(super) fn scroll_down(&mut self, max_lines: u16) { |
|
| 134 | - | if self.content_scroll < max_lines { |
|
| 135 | - | self.content_scroll += 1; |
|
| 136 | - | } |
|
| 137 | - | } |
|
| 138 | - | ||
| 139 | - | pub(super) fn copy_selected(&mut self) { |
|
| 140 | - | if let Some(snippet) = self.selected_snippet() { |
|
| 141 | - | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 142 | - | let _ = clipboard.set_text(&snippet.content); |
|
| 143 | - | self.status_message = Some(("Copied!".to_string(), Instant::now())); |
|
| 144 | - | } |
|
| 145 | - | } |
|
| 146 | - | } |
|
| 147 | - | ||
| 148 | - | pub(super) fn copy_link(&mut self) { |
|
| 149 | - | match &self.remote_url { |
|
| 150 | - | Some(url) => { |
|
| 151 | - | if let Some(snippet) = self.selected_snippet() { |
|
| 152 | - | let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id); |
|
| 153 | - | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 154 | - | let _ = clipboard.set_text(&link); |
|
| 155 | - | self.status_message = |
|
| 156 | - | Some(("Link copied!".to_string(), Instant::now())); |
|
| 157 | - | } |
|
| 158 | - | } |
|
| 159 | - | } |
|
| 160 | - | None => { |
|
| 161 | - | self.status_message = |
|
| 162 | - | Some(("No remote URL configured".to_string(), Instant::now())); |
|
| 163 | - | } |
|
| 164 | - | } |
|
| 165 | - | } |
|
| 166 | - | ||
| 167 | - | pub(super) fn open_in_browser(&mut self) { |
|
| 168 | - | match &self.remote_url { |
|
| 169 | - | Some(url) => { |
|
| 170 | - | if let Some(snippet) = self.selected_snippet() { |
|
| 171 | - | let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id); |
|
| 172 | - | if let Err(e) = open::that(&link) { |
|
| 173 | - | self.status_message = |
|
| 174 | - | Some((format!("Failed to open browser: {}", e), Instant::now())); |
|
| 175 | - | } else { |
|
| 176 | - | self.status_message = |
|
| 177 | - | Some(("Opened in browser!".to_string(), Instant::now())); |
|
| 178 | - | } |
|
| 179 | - | } |
|
| 180 | - | } |
|
| 181 | - | None => { |
|
| 182 | - | self.status_message = |
|
| 183 | - | Some(("No remote URL configured".to_string(), Instant::now())); |
|
| 184 | - | } |
|
| 185 | - | } |
|
| 186 | - | } |
|
| 187 | - | ||
| 188 | - | pub(super) fn delete_selected(&mut self, backend: &Backend) { |
|
| 189 | - | if let Some(selected_index) = self.list_state.selected() { |
|
| 190 | - | let real_index = if let Some(indices) = &self.filtered_indices { |
|
| 191 | - | match indices.get(selected_index) { |
|
| 192 | - | Some(&ri) => ri, |
|
| 193 | - | None => return, |
|
| 194 | - | } |
|
| 195 | - | } else { |
|
| 196 | - | selected_index |
|
| 197 | - | }; |
|
| 198 | - | if let Some(snippet) = self.snippets.get(real_index) { |
|
| 199 | - | let short_id = snippet.short_id.clone(); |
|
| 200 | - | match backend.delete_snippet(&short_id) { |
|
| 201 | - | Ok(true) => { |
|
| 202 | - | self.snippets.remove(real_index); |
|
| 203 | - | if self.filtered_indices.is_some() { |
|
| 204 | - | self.update_search_filter(); |
|
| 205 | - | } |
|
| 206 | - | let count = self.visible_count(); |
|
| 207 | - | if count == 0 { |
|
| 208 | - | self.list_state.select(None); |
|
| 209 | - | } else if selected_index >= count { |
|
| 210 | - | self.list_state.select(Some(count - 1)); |
|
| 211 | - | } else { |
|
| 212 | - | self.list_state.select(Some(selected_index)); |
|
| 213 | - | } |
|
| 214 | - | self.status_message = Some(("Deleted!".to_string(), Instant::now())); |
|
| 215 | - | } |
|
| 216 | - | Ok(false) => { |
|
| 217 | - | self.status_message = |
|
| 218 | - | Some(("Snippet not found".to_string(), Instant::now())); |
|
| 219 | - | } |
|
| 220 | - | Err(e) => { |
|
| 221 | - | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 222 | - | } |
|
| 223 | - | } |
|
| 224 | - | } |
|
| 225 | - | } |
|
| 226 | - | } |
|
| 227 | - | ||
| 228 | - | pub(super) fn refresh(&mut self, backend: &Backend) { |
|
| 229 | - | match backend.list_snippets() { |
|
| 230 | - | Ok(snippets) => { |
|
| 231 | - | self.snippets = snippets; |
|
| 232 | - | self.filtered_indices = None; |
|
| 233 | - | self.search_query.clear(); |
|
| 234 | - | if self.snippets.is_empty() { |
|
| 235 | - | self.list_state.select(None); |
|
| 236 | - | } else { |
|
| 237 | - | let idx = self.list_state.selected().unwrap_or(0); |
|
| 238 | - | if idx >= self.snippets.len() { |
|
| 239 | - | self.list_state.select(Some(self.snippets.len() - 1)); |
|
| 240 | - | } |
|
| 241 | - | } |
|
| 242 | - | self.status_message = Some(("Refreshed!".to_string(), Instant::now())); |
|
| 243 | - | } |
|
| 244 | - | Err(e) => { |
|
| 245 | - | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 246 | - | } |
|
| 247 | - | } |
|
| 248 | - | } |
|
| 249 | - | ||
| 250 | - | pub(super) fn cursor_position_wrapped(&self, width: u16) -> (u16, u16) { |
|
| 251 | - | let w = width as usize; |
|
| 252 | - | if w == 0 { |
|
| 253 | - | return (0, 0); |
|
| 254 | - | } |
|
| 255 | - | let text = &self.create_content; |
|
| 256 | - | let mut visual_row: usize = 0; |
|
| 257 | - | let lines: Vec<&str> = if text.is_empty() { |
|
| 258 | - | vec![""] |
|
| 259 | - | } else if text.ends_with('\n') { |
|
| 260 | - | text.split('\n').collect() |
|
| 261 | - | } else { |
|
| 262 | - | text.split('\n').collect() |
|
| 263 | - | }; |
|
| 264 | - | let last_idx = lines.len() - 1; |
|
| 265 | - | for (i, line) in lines.iter().enumerate() { |
|
| 266 | - | let line_len = line.len(); |
|
| 267 | - | let wrapped_lines = if line_len == 0 { |
|
| 268 | - | 1 |
|
| 269 | - | } else { |
|
| 270 | - | (line_len + w - 1) / w |
|
| 271 | - | }; |
|
| 272 | - | if i < last_idx { |
|
| 273 | - | visual_row += wrapped_lines; |
|
| 274 | - | } else { |
|
| 275 | - | let cursor_col = if text.ends_with('\n') { 0 } else { line_len }; |
|
| 276 | - | let extra_rows = cursor_col / w; |
|
| 277 | - | let col = cursor_col % w; |
|
| 278 | - | visual_row += extra_rows; |
|
| 279 | - | return (col as u16, visual_row as u16); |
|
| 280 | - | } |
|
| 281 | - | } |
|
| 282 | - | (0, visual_row as u16) |
|
| 283 | - | } |
|
| 284 | - | ||
| 285 | - | pub(super) fn auto_scroll_edit(&mut self, cursor_visual_row: u16, visible_height: u16) { |
|
| 286 | - | if visible_height == 0 { |
|
| 287 | - | return; |
|
| 288 | - | } |
|
| 289 | - | if cursor_visual_row < self.edit_scroll { |
|
| 290 | - | self.edit_scroll = cursor_visual_row; |
|
| 291 | - | } else if cursor_visual_row >= self.edit_scroll + visible_height { |
|
| 292 | - | self.edit_scroll = cursor_visual_row - visible_height + 1; |
|
| 293 | - | } |
|
| 294 | - | } |
|
| 295 | - | ||
| 296 | - | pub(super) fn start_create(&mut self) { |
|
| 297 | - | self.create_name.clear(); |
|
| 298 | - | self.create_content.clear(); |
|
| 299 | - | self.edit_scroll = 0; |
|
| 300 | - | self.focus = Focus::CreateName; |
|
| 301 | - | } |
|
| 302 | - | ||
| 303 | - | pub(super) fn save_create(&mut self, backend: &Backend) { |
|
| 304 | - | if self.create_name.trim().is_empty() { |
|
| 305 | - | self.status_message = Some(("Name cannot be empty".to_string(), Instant::now())); |
|
| 306 | - | return; |
|
| 307 | - | } |
|
| 308 | - | match backend.create_snippet(&self.create_name, &self.create_content) { |
|
| 309 | - | Ok(snippet) => { |
|
| 310 | - | self.snippets.insert(0, snippet); |
|
| 311 | - | self.list_state.select(Some(0)); |
|
| 312 | - | self.filtered_indices = None; |
|
| 313 | - | self.search_query.clear(); |
|
| 314 | - | self.status_message = Some(("Created!".to_string(), Instant::now())); |
|
| 315 | - | self.focus = Focus::List; |
|
| 316 | - | self.create_name.clear(); |
|
| 317 | - | self.create_content.clear(); |
|
| 318 | - | } |
|
| 319 | - | Err(e) => { |
|
| 320 | - | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 321 | - | } |
|
| 322 | - | } |
|
| 323 | - | } |
|
| 324 | - | ||
| 325 | - | pub(super) fn cancel_create(&mut self) { |
|
| 326 | - | self.create_name.clear(); |
|
| 327 | - | self.create_content.clear(); |
|
| 328 | - | self.focus = Focus::List; |
|
| 329 | - | } |
|
| 330 | - | ||
| 331 | - | pub(super) fn start_edit(&mut self) { |
|
| 332 | - | let data = self |
|
| 333 | - | .selected_snippet() |
|
| 334 | - | .map(|s| (s.name.clone(), s.content.clone(), s.short_id.clone())); |
|
| 335 | - | if let Some((name, content, short_id)) = data { |
|
| 336 | - | self.create_name = name; |
|
| 337 | - | self.create_content = content; |
|
| 338 | - | self.edit_short_id = Some(short_id); |
|
| 339 | - | self.edit_scroll = 0; |
|
| 340 | - | self.focus = Focus::EditName; |
|
| 341 | - | } |
|
| 342 | - | } |
|
| 343 | - | ||
| 344 | - | pub(super) fn save_edit(&mut self, backend: &Backend) { |
|
| 345 | - | if self.create_name.trim().is_empty() { |
|
| 346 | - | self.status_message = Some(("Name cannot be empty".to_string(), Instant::now())); |
|
| 347 | - | return; |
|
| 348 | - | } |
|
| 349 | - | let short_id = match &self.edit_short_id { |
|
| 350 | - | Some(id) => id.clone(), |
|
| 351 | - | None => return, |
|
| 352 | - | }; |
|
| 353 | - | match backend.update_snippet(&short_id, &self.create_name, &self.create_content) { |
|
| 354 | - | Ok(Some(updated)) => { |
|
| 355 | - | if let Some(pos) = self.snippets.iter().position(|s| s.short_id == short_id) { |
|
| 356 | - | self.snippets[pos] = updated; |
|
| 357 | - | } |
|
| 358 | - | self.status_message = Some(("Updated!".to_string(), Instant::now())); |
|
| 359 | - | self.focus = Focus::List; |
|
| 360 | - | self.create_name.clear(); |
|
| 361 | - | self.create_content.clear(); |
|
| 362 | - | self.edit_short_id = None; |
|
| 363 | - | } |
|
| 364 | - | Ok(None) => { |
|
| 365 | - | self.status_message = Some(("Snippet not found".to_string(), Instant::now())); |
|
| 366 | - | } |
|
| 367 | - | Err(e) => { |
|
| 368 | - | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 369 | - | } |
|
| 370 | - | } |
|
| 371 | - | } |
|
| 372 | - | ||
| 373 | - | pub(super) fn cancel_edit(&mut self) { |
|
| 374 | - | self.create_name.clear(); |
|
| 375 | - | self.create_content.clear(); |
|
| 376 | - | self.edit_short_id = None; |
|
| 377 | - | self.focus = Focus::List; |
|
| 378 | - | } |
|
| 379 | - | ||
| 380 | - | pub(super) fn start_search(&mut self) { |
|
| 381 | - | self.search_query.clear(); |
|
| 382 | - | self.filtered_indices = Some((0..self.snippets.len()).collect()); |
|
| 383 | - | self.focus = Focus::Search; |
|
| 384 | - | self.list_state |
|
| 385 | - | .select(if self.snippets.is_empty() { None } else { Some(0) }); |
|
| 386 | - | } |
|
| 387 | - | ||
| 388 | - | pub(super) fn update_search_filter(&mut self) { |
|
| 389 | - | let query = self.search_query.to_lowercase(); |
|
| 390 | - | let indices: Vec<usize> = self |
|
| 391 | - | .snippets |
|
| 392 | - | .iter() |
|
| 393 | - | .enumerate() |
|
| 394 | - | .filter(|(_, s)| s.name.to_lowercase().contains(&query)) |
|
| 395 | - | .map(|(i, _)| i) |
|
| 396 | - | .collect(); |
|
| 397 | - | self.filtered_indices = Some(indices); |
|
| 398 | - | if self.visible_count() == 0 { |
|
| 399 | - | self.list_state.select(None); |
|
| 400 | - | } else { |
|
| 401 | - | self.list_state.select(Some(0)); |
|
| 402 | - | } |
|
| 403 | - | } |
|
| 404 | - | ||
| 405 | - | pub(super) fn cancel_search(&mut self) { |
|
| 406 | - | self.filtered_indices = None; |
|
| 407 | - | self.search_query.clear(); |
|
| 408 | - | self.focus = Focus::List; |
|
| 409 | - | } |
|
| 410 | - | ||
| 411 | - | pub(super) fn confirm_search(&mut self) { |
|
| 412 | - | let real_index = self.list_state.selected().and_then(|i| { |
|
| 413 | - | self.filtered_indices |
|
| 414 | - | .as_ref() |
|
| 415 | - | .and_then(|indices| indices.get(i).copied()) |
|
| 416 | - | }); |
|
| 417 | - | self.filtered_indices = None; |
|
| 418 | - | self.search_query.clear(); |
|
| 419 | - | self.focus = Focus::List; |
|
| 420 | - | if let Some(ri) = real_index { |
|
| 421 | - | self.list_state.select(Some(ri)); |
|
| 422 | - | } |
|
| 423 | - | } |
|
| 424 | - | ||
| 425 | - | pub(super) fn clear_expired_status(&mut self) { |
|
| 426 | - | if let Some((_, time)) = &self.status_message { |
|
| 427 | - | if time.elapsed() > Duration::from_secs(2) { |
|
| 428 | - | self.status_message = None; |
|
| 429 | - | } |
|
| 430 | - | } |
|
| 431 | - | } |
|
| 432 | - | ||
| 433 | - | pub(super) fn highlight_content(&self, name: &str, content: &str) -> Text<'static> { |
|
| 434 | - | let raw_ext = name.rsplit('.').next().unwrap_or(""); |
|
| 435 | - | let ext = match raw_ext { |
|
| 436 | - | "ts" | "tsx" | "jsx" => "js", |
|
| 437 | - | other => other, |
|
| 438 | - | }; |
|
| 439 | - | let syntax = self |
|
| 440 | - | .syntax_set |
|
| 441 | - | .find_syntax_by_extension(ext) |
|
| 442 | - | .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); |
|
| 443 | - | let mut highlighter = HighlightLines::new(syntax, &self.theme); |
|
| 444 | - | ||
| 445 | - | let lines: Vec<Line<'static>> = LinesWithEndings::from(content) |
|
| 446 | - | .map(|line| { |
|
| 447 | - | let ranges = highlighter |
|
| 448 | - | .highlight_line(line, &self.syntax_set) |
|
| 449 | - | .unwrap_or_default(); |
|
| 450 | - | let spans: Vec<Span<'static>> = ranges |
|
| 451 | - | .into_iter() |
|
| 452 | - | .map(|(style, text)| { |
|
| 453 | - | let color = to_ratatui_color(style.foreground); |
|
| 454 | - | Span::styled(text.to_owned(), Style::default().fg(color)) |
|
| 455 | - | }) |
|
| 456 | - | .collect(); |
|
| 457 | - | Line::from(spans) |
|
| 458 | - | }) |
|
| 459 | - | .collect(); |
|
| 460 | - | ||
| 461 | - | Text::from(lines) |
|
| 462 | - | } |
|
| 463 | - | } |
|
| 464 | - | ||
| 465 | - | fn to_ratatui_color(color: syntect::highlighting::Color) -> Color { |
|
| 466 | - | if color.a == 0 { |
|
| 467 | - | Color::Indexed(color.r) |
|
| 468 | - | } else { |
|
| 469 | - | Color::Reset |
|
| 470 | - | } |
|
| 471 | - | } |
| 1 | - | use super::app::{App, Focus}; |
|
| 2 | - | use crate::backend::Backend; |
|
| 3 | - | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
|
| 4 | - | ||
| 5 | - | pub(super) fn handle_key( |
|
| 6 | - | app: &mut App, |
|
| 7 | - | backend: &Backend, |
|
| 8 | - | key: KeyEvent, |
|
| 9 | - | content_line_count: u16, |
|
| 10 | - | ) { |
|
| 11 | - | if app.show_help { |
|
| 12 | - | app.show_help = false; |
|
| 13 | - | } else if app.status_message.is_some() { |
|
| 14 | - | app.status_message = None; |
|
| 15 | - | } else if app.confirm_delete { |
|
| 16 | - | if key.code == KeyCode::Char('y') { |
|
| 17 | - | app.delete_selected(backend); |
|
| 18 | - | } |
|
| 19 | - | app.confirm_delete = false; |
|
| 20 | - | } else { |
|
| 21 | - | match app.focus { |
|
| 22 | - | Focus::List => match key.code { |
|
| 23 | - | KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, |
|
| 24 | - | KeyCode::Char('j') | KeyCode::Down => app.move_down(), |
|
| 25 | - | KeyCode::Char('k') | KeyCode::Up => app.move_up(), |
|
| 26 | - | KeyCode::Char('y') => app.copy_selected(), |
|
| 27 | - | KeyCode::Char('Y') => app.copy_link(), |
|
| 28 | - | KeyCode::Char('d') => app.confirm_delete = true, |
|
| 29 | - | KeyCode::Char('c') => app.start_create(), |
|
| 30 | - | KeyCode::Char('e') => app.start_edit(), |
|
| 31 | - | KeyCode::Char('/') => app.start_search(), |
|
| 32 | - | KeyCode::Char('o') => app.open_in_browser(), |
|
| 33 | - | KeyCode::Char('r') if app.is_remote => app.refresh(backend), |
|
| 34 | - | KeyCode::Char('?') => app.show_help = true, |
|
| 35 | - | KeyCode::Enter | KeyCode::Char('l') => { |
|
| 36 | - | if app.selected_snippet().is_some() { |
|
| 37 | - | app.focus = Focus::Content; |
|
| 38 | - | } |
|
| 39 | - | } |
|
| 40 | - | _ => {} |
|
| 41 | - | }, |
|
| 42 | - | Focus::Content => match key.code { |
|
| 43 | - | KeyCode::Char(' ') | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('h') => { |
|
| 44 | - | app.focus = Focus::List; |
|
| 45 | - | } |
|
| 46 | - | KeyCode::Char('j') | KeyCode::Down => { |
|
| 47 | - | app.scroll_down(content_line_count); |
|
| 48 | - | } |
|
| 49 | - | KeyCode::Char('k') | KeyCode::Up => app.scroll_up(), |
|
| 50 | - | KeyCode::Char('y') => app.copy_selected(), |
|
| 51 | - | KeyCode::Char('Y') => app.copy_link(), |
|
| 52 | - | KeyCode::Char('e') => app.start_edit(), |
|
| 53 | - | KeyCode::Char('o') => app.open_in_browser(), |
|
| 54 | - | KeyCode::Char('?') => app.show_help = true, |
|
| 55 | - | _ => {} |
|
| 56 | - | }, |
|
| 57 | - | Focus::CreateName => { |
|
| 58 | - | if key.modifiers.contains(KeyModifiers::CONTROL) |
|
| 59 | - | && key.code == KeyCode::Char('s') |
|
| 60 | - | { |
|
| 61 | - | app.save_create(backend); |
|
| 62 | - | } else { |
|
| 63 | - | match key.code { |
|
| 64 | - | KeyCode::Esc => app.cancel_create(), |
|
| 65 | - | KeyCode::Enter | KeyCode::Tab => app.focus = Focus::CreateContent, |
|
| 66 | - | KeyCode::Backspace => { |
|
| 67 | - | app.create_name.pop(); |
|
| 68 | - | } |
|
| 69 | - | KeyCode::Char(c) => app.create_name.push(c), |
|
| 70 | - | _ => {} |
|
| 71 | - | } |
|
| 72 | - | } |
|
| 73 | - | } |
|
| 74 | - | Focus::CreateContent => { |
|
| 75 | - | if key.modifiers.contains(KeyModifiers::CONTROL) { |
|
| 76 | - | match key.code { |
|
| 77 | - | KeyCode::Char('s') => app.save_create(backend), |
|
| 78 | - | KeyCode::Char('w') => { |
|
| 79 | - | app.wrap_content = !app.wrap_content; |
|
| 80 | - | app.edit_scroll = 0; |
|
| 81 | - | } |
|
| 82 | - | _ => {} |
|
| 83 | - | } |
|
| 84 | - | } else { |
|
| 85 | - | match key.code { |
|
| 86 | - | KeyCode::Esc => app.cancel_create(), |
|
| 87 | - | KeyCode::Tab => app.focus = Focus::CreateName, |
|
| 88 | - | KeyCode::Enter => app.create_content.push('\n'), |
|
| 89 | - | KeyCode::Backspace => { |
|
| 90 | - | app.create_content.pop(); |
|
| 91 | - | } |
|
| 92 | - | KeyCode::Char(c) => app.create_content.push(c), |
|
| 93 | - | _ => {} |
|
| 94 | - | } |
|
| 95 | - | } |
|
| 96 | - | } |
|
| 97 | - | Focus::EditName => { |
|
| 98 | - | if key.modifiers.contains(KeyModifiers::CONTROL) |
|
| 99 | - | && key.code == KeyCode::Char('s') |
|
| 100 | - | { |
|
| 101 | - | app.save_edit(backend); |
|
| 102 | - | } else { |
|
| 103 | - | match key.code { |
|
| 104 | - | KeyCode::Esc => app.cancel_edit(), |
|
| 105 | - | KeyCode::Enter | KeyCode::Tab => app.focus = Focus::EditContent, |
|
| 106 | - | KeyCode::Backspace => { |
|
| 107 | - | app.create_name.pop(); |
|
| 108 | - | } |
|
| 109 | - | KeyCode::Char(c) => app.create_name.push(c), |
|
| 110 | - | _ => {} |
|
| 111 | - | } |
|
| 112 | - | } |
|
| 113 | - | } |
|
| 114 | - | Focus::EditContent => { |
|
| 115 | - | if key.modifiers.contains(KeyModifiers::CONTROL) { |
|
| 116 | - | match key.code { |
|
| 117 | - | KeyCode::Char('s') => app.save_edit(backend), |
|
| 118 | - | KeyCode::Char('w') => { |
|
| 119 | - | app.wrap_content = !app.wrap_content; |
|
| 120 | - | app.edit_scroll = 0; |
|
| 121 | - | } |
|
| 122 | - | _ => {} |
|
| 123 | - | } |
|
| 124 | - | } else { |
|
| 125 | - | match key.code { |
|
| 126 | - | KeyCode::Esc => app.cancel_edit(), |
|
| 127 | - | KeyCode::Tab => app.focus = Focus::EditName, |
|
| 128 | - | KeyCode::Enter => app.create_content.push('\n'), |
|
| 129 | - | KeyCode::Backspace => { |
|
| 130 | - | app.create_content.pop(); |
|
| 131 | - | } |
|
| 132 | - | KeyCode::Char(c) => app.create_content.push(c), |
|
| 133 | - | _ => {} |
|
| 134 | - | } |
|
| 135 | - | } |
|
| 136 | - | } |
|
| 137 | - | Focus::Search => match key.code { |
|
| 138 | - | KeyCode::Esc => app.cancel_search(), |
|
| 139 | - | KeyCode::Enter => app.confirm_search(), |
|
| 140 | - | KeyCode::Backspace => { |
|
| 141 | - | app.search_query.pop(); |
|
| 142 | - | app.update_search_filter(); |
|
| 143 | - | } |
|
| 144 | - | KeyCode::Char(c) => { |
|
| 145 | - | app.search_query.push(c); |
|
| 146 | - | app.update_search_filter(); |
|
| 147 | - | } |
|
| 148 | - | _ => {} |
|
| 149 | - | }, |
|
| 150 | - | } |
|
| 151 | - | } |
|
| 152 | - | } |
| 1 | - | mod app; |
|
| 2 | - | mod events; |
|
| 3 | - | mod render; |
|
| 4 | - | ||
| 5 | - | use crate::backend::Backend; |
|
| 6 | - | use crate::config; |
|
| 7 | - | use app::App; |
|
| 8 | - | use arboard::Clipboard; |
|
| 9 | - | use crossterm::event::{self, Event}; |
|
| 10 | - | use ratatui::DefaultTerminal; |
|
| 11 | - | use std::path::PathBuf; |
|
| 12 | - | use std::time::Duration; |
|
| 13 | - | ||
| 14 | - | fn resolve_backend( |
|
| 15 | - | remote: Option<String>, |
|
| 16 | - | api_key: Option<String>, |
|
| 17 | - | ) -> Result<(Backend, bool, Option<String>), Box<dyn std::error::Error>> { |
|
| 18 | - | if let Some(url) = remote { |
|
| 19 | - | return Ok((Backend::remote(url.clone(), api_key), true, Some(url))); |
|
| 20 | - | } |
|
| 21 | - | ||
| 22 | - | if !std::path::Path::new(&crate::db::db_path()).exists() { |
|
| 23 | - | let cfg = config::load_config(); |
|
| 24 | - | let url = cfg |
|
| 25 | - | .remote_url |
|
| 26 | - | .unwrap_or_else(|| "http://localhost:3000".to_string()); |
|
| 27 | - | let api_key = api_key.or(cfg.api_key); |
|
| 28 | - | return Ok((Backend::remote(url.clone(), api_key), true, Some(url))); |
|
| 29 | - | } |
|
| 30 | - | ||
| 31 | - | Ok(( |
|
| 32 | - | Backend::local()?, |
|
| 33 | - | false, |
|
| 34 | - | Some("http://localhost:3000".to_string()), |
|
| 35 | - | )) |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> { |
|
| 39 | - | use std::io::{self, Write}; |
|
| 40 | - | ||
| 41 | - | print!("Remote URL: "); |
|
| 42 | - | io::stdout().flush()?; |
|
| 43 | - | let mut remote_url = String::new(); |
|
| 44 | - | io::stdin().read_line(&mut remote_url)?; |
|
| 45 | - | let remote_url = remote_url.trim().to_string(); |
|
| 46 | - | ||
| 47 | - | print!("API Key: "); |
|
| 48 | - | io::stdout().flush()?; |
|
| 49 | - | let api_key = rpassword::read_password()?; |
|
| 50 | - | let api_key = api_key.trim().to_string(); |
|
| 51 | - | ||
| 52 | - | let cfg = config::Config { |
|
| 53 | - | remote_url: if remote_url.is_empty() { |
|
| 54 | - | None |
|
| 55 | - | } else { |
|
| 56 | - | Some(remote_url) |
|
| 57 | - | }, |
|
| 58 | - | api_key: if api_key.is_empty() { |
|
| 59 | - | None |
|
| 60 | - | } else { |
|
| 61 | - | Some(api_key) |
|
| 62 | - | }, |
|
| 63 | - | }; |
|
| 64 | - | ||
| 65 | - | config::save_config(&cfg)?; |
|
| 66 | - | println!("Config saved to {}", config::config_path().display()); |
|
| 67 | - | Ok(()) |
|
| 68 | - | } |
|
| 69 | - | ||
| 70 | - | pub fn run_interactive( |
|
| 71 | - | remote: Option<String>, |
|
| 72 | - | api_key: Option<String>, |
|
| 73 | - | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 74 | - | let (backend, is_remote, remote_url) = resolve_backend(remote, api_key)?; |
|
| 75 | - | ||
| 76 | - | let snippets = match backend.list_snippets() { |
|
| 77 | - | Ok(s) => s, |
|
| 78 | - | Err(e) => { |
|
| 79 | - | eprintln!("Failed to load snippets: {}", e); |
|
| 80 | - | Vec::new() |
|
| 81 | - | } |
|
| 82 | - | }; |
|
| 83 | - | ||
| 84 | - | ratatui::run(|terminal| { |
|
| 85 | - | run_app( |
|
| 86 | - | terminal, |
|
| 87 | - | App::new(snippets, is_remote, remote_url), |
|
| 88 | - | &backend, |
|
| 89 | - | ) |
|
| 90 | - | }) |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | pub fn run_file_upload( |
|
| 94 | - | remote: Option<String>, |
|
| 95 | - | api_key: Option<String>, |
|
| 96 | - | file: PathBuf, |
|
| 97 | - | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 98 | - | let (backend, _, remote_url) = resolve_backend(remote, api_key)?; |
|
| 99 | - | ||
| 100 | - | let name = file |
|
| 101 | - | .file_name() |
|
| 102 | - | .ok_or("Invalid file path")? |
|
| 103 | - | .to_string_lossy() |
|
| 104 | - | .to_string(); |
|
| 105 | - | let content = |
|
| 106 | - | std::fs::read_to_string(&file).map_err(|e| format!("Failed to read file: {}", e))?; |
|
| 107 | - | let snippet = backend |
|
| 108 | - | .create_snippet(&name, &content) |
|
| 109 | - | .map_err(|e| format!("{}", e))?; |
|
| 110 | - | let link = match &remote_url { |
|
| 111 | - | Some(url) => format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id), |
|
| 112 | - | None => snippet.short_id.clone(), |
|
| 113 | - | }; |
|
| 114 | - | println!("{}", link); |
|
| 115 | - | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 116 | - | let _ = clipboard.set_text(&link); |
|
| 117 | - | println!("\u{2714} Copied to clipboard!"); |
|
| 118 | - | } |
|
| 119 | - | Ok(()) |
|
| 120 | - | } |
|
| 121 | - | ||
| 122 | - | fn run_app( |
|
| 123 | - | terminal: &mut DefaultTerminal, |
|
| 124 | - | mut app: App, |
|
| 125 | - | backend: &Backend, |
|
| 126 | - | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 127 | - | while !app.should_quit { |
|
| 128 | - | app.clear_expired_status(); |
|
| 129 | - | ||
| 130 | - | let content_line_count = app |
|
| 131 | - | .selected_snippet() |
|
| 132 | - | .map(|s| s.content.lines().count() as u16) |
|
| 133 | - | .unwrap_or(0); |
|
| 134 | - | ||
| 135 | - | terminal.draw(|frame| render::draw(frame, &mut app))?; |
|
| 136 | - | ||
| 137 | - | if event::poll(Duration::from_millis(100))? |
|
| 138 | - | && let Event::Key(key) = event::read()? |
|
| 139 | - | { |
|
| 140 | - | events::handle_key(&mut app, backend, key, content_line_count); |
|
| 141 | - | } |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | Ok(()) |
|
| 145 | - | } |
| 1 | - | use super::app::{App, Focus}; |
|
| 2 | - | use ratatui::{ |
|
| 3 | - | Frame, |
|
| 4 | - | layout::{Alignment, Constraint, Layout}, |
|
| 5 | - | style::{Color, Modifier, Style}, |
|
| 6 | - | text::{Line, Span, Text}, |
|
| 7 | - | widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Widget, Wrap}, |
|
| 8 | - | }; |
|
| 9 | - | ||
| 10 | - | pub(super) fn draw(frame: &mut Frame, app: &mut App) { |
|
| 11 | - | let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(frame.area()); |
|
| 12 | - | ||
| 13 | - | let chunks = Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]) |
|
| 14 | - | .split(outer[0]); |
|
| 15 | - | ||
| 16 | - | let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices { |
|
| 17 | - | indices |
|
| 18 | - | .iter() |
|
| 19 | - | .filter_map(|&i| app.snippets.get(i)) |
|
| 20 | - | .map(|s| ListItem::new(s.name.as_str())) |
|
| 21 | - | .collect() |
|
| 22 | - | } else { |
|
| 23 | - | app.snippets |
|
| 24 | - | .iter() |
|
| 25 | - | .map(|s| ListItem::new(s.name.as_str())) |
|
| 26 | - | .collect() |
|
| 27 | - | }; |
|
| 28 | - | ||
| 29 | - | let list_border_style = match app.focus { |
|
| 30 | - | Focus::List | Focus::Search => Style::default().fg(Color::Yellow), |
|
| 31 | - | _ => Style::default().fg(Color::DarkGray), |
|
| 32 | - | }; |
|
| 33 | - | let content_border_style = match app.focus { |
|
| 34 | - | Focus::Content => Style::default().fg(Color::Yellow), |
|
| 35 | - | _ => Style::default().fg(Color::DarkGray), |
|
| 36 | - | }; |
|
| 37 | - | ||
| 38 | - | let list = List::new(items) |
|
| 39 | - | .block( |
|
| 40 | - | Block::default() |
|
| 41 | - | .title(" Snippets ") |
|
| 42 | - | .borders(Borders::ALL) |
|
| 43 | - | .border_style(list_border_style), |
|
| 44 | - | ) |
|
| 45 | - | .highlight_style( |
|
| 46 | - | Style::default() |
|
| 47 | - | .fg(Color::Yellow) |
|
| 48 | - | .add_modifier(Modifier::BOLD), |
|
| 49 | - | ) |
|
| 50 | - | .highlight_symbol("▶ "); |
|
| 51 | - | ||
| 52 | - | if matches!(app.focus, Focus::Search) { |
|
| 53 | - | let search_split = |
|
| 54 | - | Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(chunks[0]); |
|
| 55 | - | ||
| 56 | - | let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices { |
|
| 57 | - | indices |
|
| 58 | - | .iter() |
|
| 59 | - | .filter_map(|&i| app.snippets.get(i)) |
|
| 60 | - | .map(|s| ListItem::new(s.name.as_str())) |
|
| 61 | - | .collect() |
|
| 62 | - | } else { |
|
| 63 | - | app.snippets |
|
| 64 | - | .iter() |
|
| 65 | - | .map(|s| ListItem::new(s.name.as_str())) |
|
| 66 | - | .collect() |
|
| 67 | - | }; |
|
| 68 | - | let search_list = List::new(search_items) |
|
| 69 | - | .block( |
|
| 70 | - | Block::default() |
|
| 71 | - | .title(" Snippets ") |
|
| 72 | - | .borders(Borders::ALL) |
|
| 73 | - | .border_style(list_border_style), |
|
| 74 | - | ) |
|
| 75 | - | .highlight_style( |
|
| 76 | - | Style::default() |
|
| 77 | - | .fg(Color::Yellow) |
|
| 78 | - | .add_modifier(Modifier::BOLD), |
|
| 79 | - | ) |
|
| 80 | - | .highlight_symbol("▶ "); |
|
| 81 | - | frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state); |
|
| 82 | - | ||
| 83 | - | let search_input = Paragraph::new(app.search_query.as_str()).block( |
|
| 84 | - | Block::default() |
|
| 85 | - | .title(" Search ") |
|
| 86 | - | .borders(Borders::ALL) |
|
| 87 | - | .border_style(Style::default().fg(Color::Yellow)), |
|
| 88 | - | ); |
|
| 89 | - | frame.render_widget(search_input, search_split[1]); |
|
| 90 | - | ||
| 91 | - | let x = search_split[1].x + 1 + app.search_query.len() as u16; |
|
| 92 | - | let y = search_split[1].y + 1; |
|
| 93 | - | frame.set_cursor_position((x, y)); |
|
| 94 | - | } else { |
|
| 95 | - | frame.render_stateful_widget(list, chunks[0], &mut app.list_state); |
|
| 96 | - | } |
|
| 97 | - | ||
| 98 | - | match app.focus { |
|
| 99 | - | Focus::CreateName | Focus::CreateContent | Focus::EditName | Focus::EditContent => { |
|
| 100 | - | let form_title = match app.focus { |
|
| 101 | - | Focus::EditName | Focus::EditContent => " Edit Snippet ", |
|
| 102 | - | _ => " New Snippet ", |
|
| 103 | - | }; |
|
| 104 | - | let create_block = Block::default() |
|
| 105 | - | .title(form_title) |
|
| 106 | - | .borders(Borders::ALL) |
|
| 107 | - | .border_style(Style::default().fg(Color::Yellow)); |
|
| 108 | - | ||
| 109 | - | let inner = create_block.inner(chunks[1]); |
|
| 110 | - | frame.render_widget(create_block, chunks[1]); |
|
| 111 | - | ||
| 112 | - | let form_layout = |
|
| 113 | - | Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(inner); |
|
| 114 | - | ||
| 115 | - | let name_style = match app.focus { |
|
| 116 | - | Focus::CreateName | Focus::EditName => Style::default().fg(Color::Yellow), |
|
| 117 | - | _ => Style::default().fg(Color::DarkGray), |
|
| 118 | - | }; |
|
| 119 | - | let name_input = Paragraph::new(app.create_name.as_str()).block( |
|
| 120 | - | Block::default() |
|
| 121 | - | .title(" Name ") |
|
| 122 | - | .borders(Borders::ALL) |
|
| 123 | - | .border_style(name_style), |
|
| 124 | - | ); |
|
| 125 | - | frame.render_widget(name_input, form_layout[0]); |
|
| 126 | - | ||
| 127 | - | let content_style = match app.focus { |
|
| 128 | - | Focus::CreateContent | Focus::EditContent => Style::default().fg(Color::Yellow), |
|
| 129 | - | _ => Style::default().fg(Color::DarkGray), |
|
| 130 | - | }; |
|
| 131 | - | let mut content_input = Paragraph::new(app.create_content.as_str()).block( |
|
| 132 | - | Block::default() |
|
| 133 | - | .title(" Content ") |
|
| 134 | - | .borders(Borders::ALL) |
|
| 135 | - | .border_style(content_style), |
|
| 136 | - | ); |
|
| 137 | - | if app.wrap_content { |
|
| 138 | - | content_input = content_input.wrap(Wrap { trim: false }); |
|
| 139 | - | } |
|
| 140 | - | content_input = content_input.scroll((app.edit_scroll, 0)); |
|
| 141 | - | frame.render_widget(content_input, form_layout[1]); |
|
| 142 | - | ||
| 143 | - | let content_inner = Block::default().borders(Borders::ALL).inner(form_layout[1]); |
|
| 144 | - | let inner_width = content_inner.width; |
|
| 145 | - | let inner_height = content_inner.height; |
|
| 146 | - | ||
| 147 | - | match app.focus { |
|
| 148 | - | Focus::CreateName | Focus::EditName => { |
|
| 149 | - | let x = form_layout[0].x + 1 + app.create_name.len() as u16; |
|
| 150 | - | let y = form_layout[0].y + 1; |
|
| 151 | - | frame.set_cursor_position((x, y)); |
|
| 152 | - | } |
|
| 153 | - | Focus::CreateContent | Focus::EditContent => { |
|
| 154 | - | let (cx, cy) = if app.wrap_content { |
|
| 155 | - | app.cursor_position_wrapped(inner_width) |
|
| 156 | - | } else { |
|
| 157 | - | let last_line = app.create_content.lines().last().unwrap_or(""); |
|
| 158 | - | let line_count = app.create_content.lines().count() |
|
| 159 | - | + if app.create_content.ends_with('\n') { |
|
| 160 | - | 1 |
|
| 161 | - | } else { |
|
| 162 | - | 0 |
|
| 163 | - | }; |
|
| 164 | - | let y_offset = if line_count == 0 { 0 } else { line_count - 1 }; |
|
| 165 | - | let col = if app.create_content.ends_with('\n') { |
|
| 166 | - | 0 |
|
| 167 | - | } else { |
|
| 168 | - | last_line.len() as u16 |
|
| 169 | - | }; |
|
| 170 | - | (col, y_offset as u16) |
|
| 171 | - | }; |
|
| 172 | - | app.auto_scroll_edit(cy, inner_height); |
|
| 173 | - | let screen_y = cy.saturating_sub(app.edit_scroll); |
|
| 174 | - | let x = content_inner.x + cx; |
|
| 175 | - | let y = content_inner.y + screen_y; |
|
| 176 | - | frame.set_cursor_position((x, y)); |
|
| 177 | - | } |
|
| 178 | - | _ => {} |
|
| 179 | - | } |
|
| 180 | - | } |
|
| 181 | - | _ => { |
|
| 182 | - | let highlighted = match app.selected_snippet() { |
|
| 183 | - | Some(s) => app.highlight_content(&s.name, &s.content), |
|
| 184 | - | None => Text::raw(""), |
|
| 185 | - | }; |
|
| 186 | - | ||
| 187 | - | let paragraph = Paragraph::new(highlighted) |
|
| 188 | - | .block( |
|
| 189 | - | Block::default() |
|
| 190 | - | .title(" Content ") |
|
| 191 | - | .borders(Borders::ALL) |
|
| 192 | - | .border_style(content_border_style), |
|
| 193 | - | ) |
|
| 194 | - | .scroll((app.content_scroll, 0)); |
|
| 195 | - | ||
| 196 | - | frame.render_widget(paragraph, chunks[1]); |
|
| 197 | - | } |
|
| 198 | - | } |
|
| 199 | - | ||
| 200 | - | let hints = match app.focus { |
|
| 201 | - | Focus::List => Line::from(vec![ |
|
| 202 | - | Span::styled("j/k", Style::default().fg(Color::Yellow)), |
|
| 203 | - | Span::raw(": Navigate "), |
|
| 204 | - | Span::styled("Enter", Style::default().fg(Color::Yellow)), |
|
| 205 | - | Span::raw(": View "), |
|
| 206 | - | Span::styled("y", Style::default().fg(Color::Yellow)), |
|
| 207 | - | Span::raw(": Copy "), |
|
| 208 | - | Span::styled("e", Style::default().fg(Color::Yellow)), |
|
| 209 | - | Span::raw(": Edit "), |
|
| 210 | - | Span::styled("d", Style::default().fg(Color::Yellow)), |
|
| 211 | - | Span::raw(": Delete "), |
|
| 212 | - | Span::styled("c", Style::default().fg(Color::Yellow)), |
|
| 213 | - | Span::raw(": Create "), |
|
| 214 | - | Span::styled("/", Style::default().fg(Color::Yellow)), |
|
| 215 | - | Span::raw(": Search "), |
|
| 216 | - | Span::styled("?", Style::default().fg(Color::Yellow)), |
|
| 217 | - | Span::raw(": Help "), |
|
| 218 | - | Span::styled("q", Style::default().fg(Color::Yellow)), |
|
| 219 | - | Span::raw(": Quit"), |
|
| 220 | - | ]), |
|
| 221 | - | Focus::Content => Line::from(vec![ |
|
| 222 | - | Span::styled("j/k", Style::default().fg(Color::Yellow)), |
|
| 223 | - | Span::raw(": Scroll "), |
|
| 224 | - | Span::styled("y", Style::default().fg(Color::Yellow)), |
|
| 225 | - | Span::raw(": Copy "), |
|
| 226 | - | Span::styled("e", Style::default().fg(Color::Yellow)), |
|
| 227 | - | Span::raw(": Edit "), |
|
| 228 | - | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 229 | - | Span::raw(": Back "), |
|
| 230 | - | Span::styled("?", Style::default().fg(Color::Yellow)), |
|
| 231 | - | Span::raw(": Help"), |
|
| 232 | - | ]), |
|
| 233 | - | Focus::CreateName | Focus::CreateContent | Focus::EditName | Focus::EditContent => { |
|
| 234 | - | Line::from(vec![ |
|
| 235 | - | Span::styled("Tab", Style::default().fg(Color::Yellow)), |
|
| 236 | - | Span::raw(": Switch field "), |
|
| 237 | - | Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)), |
|
| 238 | - | Span::raw(": Save "), |
|
| 239 | - | Span::styled("Ctrl+W", Style::default().fg(Color::Yellow)), |
|
| 240 | - | Span::raw(": Wrap "), |
|
| 241 | - | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 242 | - | Span::raw(": Cancel"), |
|
| 243 | - | ]) |
|
| 244 | - | } |
|
| 245 | - | Focus::Search => Line::from(vec![ |
|
| 246 | - | Span::styled("Type", Style::default().fg(Color::Yellow)), |
|
| 247 | - | Span::raw(": Filter "), |
|
| 248 | - | Span::styled("Enter", Style::default().fg(Color::Yellow)), |
|
| 249 | - | Span::raw(": Select "), |
|
| 250 | - | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 251 | - | Span::raw(": Cancel"), |
|
| 252 | - | ]), |
|
| 253 | - | }; |
|
| 254 | - | frame.render_widget(Paragraph::new(hints), outer[1]); |
|
| 255 | - | ||
| 256 | - | if let Some((msg, _)) = &app.status_message { |
|
| 257 | - | let area = frame.area(); |
|
| 258 | - | let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4)); |
|
| 259 | - | let popup_area = ratatui::layout::Rect { |
|
| 260 | - | x: (area.width.saturating_sub(msg_width)) / 2, |
|
| 261 | - | y: (area.height.saturating_sub(3)) / 2, |
|
| 262 | - | width: msg_width, |
|
| 263 | - | height: 3, |
|
| 264 | - | }; |
|
| 265 | - | Clear.render(popup_area, frame.buffer_mut()); |
|
| 266 | - | let status_popup = Paragraph::new(Line::from(msg.as_str())) |
|
| 267 | - | .style( |
|
| 268 | - | Style::default() |
|
| 269 | - | .fg(Color::Green) |
|
| 270 | - | .add_modifier(Modifier::BOLD), |
|
| 271 | - | ) |
|
| 272 | - | .alignment(Alignment::Center) |
|
| 273 | - | .block( |
|
| 274 | - | Block::default() |
|
| 275 | - | .borders(Borders::ALL) |
|
| 276 | - | .border_style(Style::default().fg(Color::Green)), |
|
| 277 | - | ); |
|
| 278 | - | frame.render_widget(status_popup, popup_area); |
|
| 279 | - | } |
|
| 280 | - | ||
| 281 | - | if app.confirm_delete { |
|
| 282 | - | let delete_msg = match app.selected_snippet() { |
|
| 283 | - | Some(s) => format!("Delete {}? (y/n)", s.name), |
|
| 284 | - | None => "Delete snippet? (y/n)".to_string(), |
|
| 285 | - | }; |
|
| 286 | - | let area = frame.area(); |
|
| 287 | - | let msg_width = (delete_msg.len() as u16 + 4) |
|
| 288 | - | .max(24) |
|
| 289 | - | .min(area.width.saturating_sub(4)); |
|
| 290 | - | let popup_area = ratatui::layout::Rect { |
|
| 291 | - | x: (area.width.saturating_sub(msg_width)) / 2, |
|
| 292 | - | y: (area.height.saturating_sub(3)) / 2, |
|
| 293 | - | width: msg_width, |
|
| 294 | - | height: 3, |
|
| 295 | - | }; |
|
| 296 | - | Clear.render(popup_area, frame.buffer_mut()); |
|
| 297 | - | let confirm_popup = Paragraph::new(Line::from(delete_msg)) |
|
| 298 | - | .style( |
|
| 299 | - | Style::default() |
|
| 300 | - | .fg(Color::Red) |
|
| 301 | - | .add_modifier(Modifier::BOLD), |
|
| 302 | - | ) |
|
| 303 | - | .alignment(Alignment::Center) |
|
| 304 | - | .block( |
|
| 305 | - | Block::default() |
|
| 306 | - | .borders(Borders::ALL) |
|
| 307 | - | .border_style(Style::default().fg(Color::Red)), |
|
| 308 | - | ); |
|
| 309 | - | frame.render_widget(confirm_popup, popup_area); |
|
| 310 | - | } |
|
| 311 | - | ||
| 312 | - | if app.show_help { |
|
| 313 | - | let area = frame.area(); |
|
| 314 | - | let popup_width = 34u16.min(area.width.saturating_sub(4)); |
|
| 315 | - | let popup_height = 21u16.min(area.height.saturating_sub(4)); |
|
| 316 | - | let popup_area = ratatui::layout::Rect { |
|
| 317 | - | x: (area.width.saturating_sub(popup_width)) / 2, |
|
| 318 | - | y: (area.height.saturating_sub(popup_height)) / 2, |
|
| 319 | - | width: popup_width, |
|
| 320 | - | height: popup_height, |
|
| 321 | - | }; |
|
| 322 | - | ||
| 323 | - | let mut help_lines = vec![ |
|
| 324 | - | Line::from(""), |
|
| 325 | - | Line::from(vec![ |
|
| 326 | - | Span::styled( |
|
| 327 | - | " j/↓ ", |
|
| 328 | - | Style::default() |
|
| 329 | - | .fg(Color::Yellow) |
|
| 330 | - | .add_modifier(Modifier::BOLD), |
|
| 331 | - | ), |
|
| 332 | - | Span::raw("Move down / Scroll down"), |
|
| 333 | - | ]), |
|
| 334 | - | Line::from(vec![ |
|
| 335 | - | Span::styled( |
|
| 336 | - | " k/↑ ", |
|
| 337 | - | Style::default() |
|
| 338 | - | .fg(Color::Yellow) |
|
| 339 | - | .add_modifier(Modifier::BOLD), |
|
| 340 | - | ), |
|
| 341 | - | Span::raw("Move up / Scroll up"), |
|
| 342 | - | ]), |
|
| 343 | - | Line::from(vec![ |
|
| 344 | - | Span::styled( |
|
| 345 | - | " Enter", |
|
| 346 | - | Style::default() |
|
| 347 | - | .fg(Color::Yellow) |
|
| 348 | - | .add_modifier(Modifier::BOLD), |
|
| 349 | - | ), |
|
| 350 | - | Span::raw(" Focus content pane"), |
|
| 351 | - | ]), |
|
| 352 | - | Line::from(vec![ |
|
| 353 | - | Span::styled( |
|
| 354 | - | " Esc ", |
|
| 355 | - | Style::default() |
|
| 356 | - | .fg(Color::Yellow) |
|
| 357 | - | .add_modifier(Modifier::BOLD), |
|
| 358 | - | ), |
|
| 359 | - | Span::raw("Back / Quit"), |
|
| 360 | - | ]), |
|
| 361 | - | Line::from(vec![ |
|
| 362 | - | Span::styled( |
|
| 363 | - | " y ", |
|
| 364 | - | Style::default() |
|
| 365 | - | .fg(Color::Yellow) |
|
| 366 | - | .add_modifier(Modifier::BOLD), |
|
| 367 | - | ), |
|
| 368 | - | Span::raw("Copy snippet"), |
|
| 369 | - | ]), |
|
| 370 | - | Line::from(vec![ |
|
| 371 | - | Span::styled( |
|
| 372 | - | " Y ", |
|
| 373 | - | Style::default() |
|
| 374 | - | .fg(Color::Yellow) |
|
| 375 | - | .add_modifier(Modifier::BOLD), |
|
| 376 | - | ), |
|
| 377 | - | Span::raw("Copy link"), |
|
| 378 | - | ]), |
|
| 379 | - | Line::from(vec![ |
|
| 380 | - | Span::styled( |
|
| 381 | - | " o ", |
|
| 382 | - | Style::default() |
|
| 383 | - | .fg(Color::Yellow) |
|
| 384 | - | .add_modifier(Modifier::BOLD), |
|
| 385 | - | ), |
|
| 386 | - | Span::raw("Open in browser"), |
|
| 387 | - | ]), |
|
| 388 | - | Line::from(vec![ |
|
| 389 | - | Span::styled( |
|
| 390 | - | " d ", |
|
| 391 | - | Style::default() |
|
| 392 | - | .fg(Color::Yellow) |
|
| 393 | - | .add_modifier(Modifier::BOLD), |
|
| 394 | - | ), |
|
| 395 | - | Span::raw("Delete snippet"), |
|
| 396 | - | ]), |
|
| 397 | - | Line::from(vec![ |
|
| 398 | - | Span::styled( |
|
| 399 | - | " c ", |
|
| 400 | - | Style::default() |
|
| 401 | - | .fg(Color::Yellow) |
|
| 402 | - | .add_modifier(Modifier::BOLD), |
|
| 403 | - | ), |
|
| 404 | - | Span::raw("Create snippet"), |
|
| 405 | - | ]), |
|
| 406 | - | Line::from(vec![ |
|
| 407 | - | Span::styled( |
|
| 408 | - | " e ", |
|
| 409 | - | Style::default() |
|
| 410 | - | .fg(Color::Yellow) |
|
| 411 | - | .add_modifier(Modifier::BOLD), |
|
| 412 | - | ), |
|
| 413 | - | Span::raw("Edit snippet"), |
|
| 414 | - | ]), |
|
| 415 | - | Line::from(vec![ |
|
| 416 | - | Span::styled( |
|
| 417 | - | " / ", |
|
| 418 | - | Style::default() |
|
| 419 | - | .fg(Color::Yellow) |
|
| 420 | - | .add_modifier(Modifier::BOLD), |
|
| 421 | - | ), |
|
| 422 | - | Span::raw("Search snippets"), |
|
| 423 | - | ]), |
|
| 424 | - | Line::from(vec![ |
|
| 425 | - | Span::styled( |
|
| 426 | - | " ^W ", |
|
| 427 | - | Style::default() |
|
| 428 | - | .fg(Color::Yellow) |
|
| 429 | - | .add_modifier(Modifier::BOLD), |
|
| 430 | - | ), |
|
| 431 | - | Span::raw("Toggle word wrap (edit)"), |
|
| 432 | - | ]), |
|
| 433 | - | ]; |
|
| 434 | - | ||
| 435 | - | if app.is_remote { |
|
| 436 | - | help_lines.push(Line::from(vec![ |
|
| 437 | - | Span::styled( |
|
| 438 | - | " r ", |
|
| 439 | - | Style::default() |
|
| 440 | - | .fg(Color::Yellow) |
|
| 441 | - | .add_modifier(Modifier::BOLD), |
|
| 442 | - | ), |
|
| 443 | - | Span::raw("Refresh snippets"), |
|
| 444 | - | ])); |
|
| 445 | - | } |
|
| 446 | - | ||
| 447 | - | help_lines.extend([ |
|
| 448 | - | Line::from(vec![ |
|
| 449 | - | Span::styled( |
|
| 450 | - | " q ", |
|
| 451 | - | Style::default() |
|
| 452 | - | .fg(Color::Yellow) |
|
| 453 | - | .add_modifier(Modifier::BOLD), |
|
| 454 | - | ), |
|
| 455 | - | Span::raw("Quit"), |
|
| 456 | - | ]), |
|
| 457 | - | Line::from(vec![ |
|
| 458 | - | Span::styled( |
|
| 459 | - | " ? ", |
|
| 460 | - | Style::default() |
|
| 461 | - | .fg(Color::Yellow) |
|
| 462 | - | .add_modifier(Modifier::BOLD), |
|
| 463 | - | ), |
|
| 464 | - | Span::raw("Toggle this help"), |
|
| 465 | - | ]), |
|
| 466 | - | Line::from(""), |
|
| 467 | - | Line::from(Span::styled( |
|
| 468 | - | " Press any key to close", |
|
| 469 | - | Style::default().fg(Color::DarkGray), |
|
| 470 | - | )), |
|
| 471 | - | ]); |
|
| 472 | - | ||
| 473 | - | let help_text = Text::from(help_lines); |
|
| 474 | - | ||
| 475 | - | Clear.render(popup_area, frame.buffer_mut()); |
|
| 476 | - | let help = Paragraph::new(help_text).block( |
|
| 477 | - | Block::default() |
|
| 478 | - | .title(" Keybindings ") |
|
| 479 | - | .borders(Borders::ALL) |
|
| 480 | - | .border_style(Style::default().fg(Color::Yellow)), |
|
| 481 | - | ); |
|
| 482 | - | frame.render_widget(help, popup_area); |
|
| 483 | - | } |
|
| 484 | - | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/assets/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/assets/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | /* sipp — app-specific styles. |
|
| 2 | - | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | - | */ |
|
| 4 | - | ||
| 5 | - | /* Logo wraps an h1 in sipp markup. */ |
|
| 6 | - | ||
| 7 | - | .logo h1 { |
|
| 8 | - | font-size: 28px; |
|
| 9 | - | font-weight: 700; |
|
| 10 | - | text-transform: uppercase; |
|
| 11 | - | } |
|
| 12 | - | ||
| 13 | - | /* Snippet icon links */ |
|
| 14 | - | ||
| 15 | - | .icon { |
|
| 16 | - | display: flex; |
|
| 17 | - | align-items: center; |
|
| 18 | - | justify-content: center; |
|
| 19 | - | color: #878787; |
|
| 20 | - | width: 24px; |
|
| 21 | - | height: 24px; |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | .icon svg { |
|
| 25 | - | width: 24px; |
|
| 26 | - | height: 24px; |
|
| 27 | - | } |
|
| 28 | - | ||
| 29 | - | .icon svg path { |
|
| 30 | - | fill: #878787; |
|
| 31 | - | } |
|
| 32 | - | ||
| 33 | - | .icon:hover svg path { |
|
| 34 | - | fill: #ffffff; |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | /* Create-snippet form */ |
|
| 38 | - | ||
| 39 | - | #snippetForm { |
|
| 40 | - | display: flex; |
|
| 41 | - | flex-direction: column; |
|
| 42 | - | gap: 1rem; |
|
| 43 | - | width: 100%; |
|
| 44 | - | } |
|
| 45 | - | ||
| 46 | - | #snippetName { |
|
| 47 | - | font-size: 14px; |
|
| 48 | - | opacity: 0.7; |
|
| 49 | - | } |
|
| 50 | - | ||
| 51 | - | /* Highlighted snippet viewer */ |
|
| 52 | - | ||
| 53 | - | .code-container { |
|
| 54 | - | border: 1px solid #ffffff; |
|
| 55 | - | height: 400px; |
|
| 56 | - | overflow: auto; |
|
| 57 | - | -webkit-overflow-scrolling: touch; |
|
| 58 | - | } |
|
| 59 | - | ||
| 60 | - | .code-container pre { |
|
| 61 | - | background-color: #121113 !important; |
|
| 62 | - | padding: 6px; |
|
| 63 | - | margin: 0; |
|
| 64 | - | min-height: 100%; |
|
| 65 | - | font-size: 13px; |
|
| 66 | - | line-height: 1.4; |
|
| 67 | - | border: none; |
|
| 68 | - | } |
|
| 69 | - | ||
| 70 | - | /* Viewer action row */ |
|
| 71 | - | ||
| 72 | - | .button-group { |
|
| 73 | - | display: flex; |
|
| 74 | - | gap: 0.5rem; |
|
| 75 | - | flex-wrap: wrap; |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | /* Snippet page header variant (plain <a class="header">) */ |
|
| 79 | - | ||
| 80 | - | .nav { |
|
| 81 | - | width: 100%; |
|
| 82 | - | margin-top: 2rem; |
|
| 83 | - | } |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | - | ||
| 14 | - | <title>Sipp - Admin</title> |
|
| 15 | - | <meta name="description" content="Minimal Code Sharing"> |
|
| 16 | - | ||
| 17 | - | <meta property="og:url" content="{{ base_url }}"> |
|
| 18 | - | <meta property="og:type" content="website"> |
|
| 19 | - | <meta property="og:title" content="Sipps"> |
|
| 20 | - | <meta property="og:description" content="Minimal Code Sharing"> |
|
| 21 | - | <meta property="og:image" content="{{ base_url }}/static/og.png"> |
|
| 22 | - | ||
| 23 | - | <meta name="twitter:card" content="summary_large_image"> |
|
| 24 | - | <meta property="twitter:url" content="{{ base_url }}"> |
|
| 25 | - | <meta name="twitter:title" content="Sipps"> |
|
| 26 | - | <meta name="twitter:description" content="Minimal Code Sharing"> |
|
| 27 | - | <meta name="twitter:image" content="{{ base_url }}/static/og.png"> |
|
| 28 | - | </head> |
|
| 29 | - | <body> |
|
| 30 | - | ||
| 31 | - | <div class="header"> |
|
| 32 | - | <a href="/" class="logo"><h1>SIPP</h1></a> |
|
| 33 | - | </div> |
|
| 34 | - | ||
| 35 | - | {% if snippets.is_empty() %} |
|
| 36 | - | <p class="empty">no snippets yet</p> |
|
| 37 | - | {% else %} |
|
| 38 | - | <div class="admin-list"> |
|
| 39 | - | {% for s in snippets %} |
|
| 40 | - | <div class="admin-list-item"> |
|
| 41 | - | <div class="admin-list-info"> |
|
| 42 | - | <a href="/s/{{ s.short_id }}" class="admin-list-title">{{ s.name }}</a> |
|
| 43 | - | <div class="admin-list-meta"> |
|
| 44 | - | <span class="admin-list-date">/s/{{ s.short_id }}</span> |
|
| 45 | - | </div> |
|
| 46 | - | </div> |
|
| 47 | - | <div class="admin-list-actions"> |
|
| 48 | - | <a href="/s/{{ s.short_id }}">view</a> |
|
| 49 | - | <form method="POST" action="/admin/snippets/{{ s.short_id }}/delete" class="inline-form" onsubmit="return confirm('delete this snippet?')"> |
|
| 50 | - | <button type="submit" class="link-button danger">delete</button> |
|
| 51 | - | </form> |
|
| 52 | - | </div> |
|
| 53 | - | </div> |
|
| 54 | - | {% endfor %} |
|
| 55 | - | </div> |
|
| 56 | - | {% endif %} |
|
| 57 | - | </body> |
|
| 58 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | - | ||
| 14 | - | <title>Sipp</title> |
|
| 15 | - | <meta name="description" content="Minimal Code Sharing"> |
|
| 16 | - | ||
| 17 | - | <meta property="og:url" content="{{ base_url }}"> |
|
| 18 | - | <meta property="og:type" content="website"> |
|
| 19 | - | <meta property="og:title" content="Sipps"> |
|
| 20 | - | <meta property="og:description" content="Minimal Code Sharing"> |
|
| 21 | - | <meta property="og:image" content="{{ base_url }}/static/og.png"> |
|
| 22 | - | ||
| 23 | - | <meta name="twitter:card" content="summary_large_image"> |
|
| 24 | - | <meta property="twitter:url" content="{{ base_url }}"> |
|
| 25 | - | <meta name="twitter:title" content="Sipps"> |
|
| 26 | - | <meta name="twitter:description" content="Minimal Code Sharing"> |
|
| 27 | - | <meta name="twitter:image" content="{{ base_url }}/static/og.png"> |
|
| 28 | - | </head> |
|
| 29 | - | <body> |
|
| 30 | - | ||
| 31 | - | <div class="header"> |
|
| 32 | - | <a href="/" class="logo"><h1>SIPP</h1></a> |
|
| 33 | - | <nav class="links"> |
|
| 34 | - | <a href="/admin">admin</a> |
|
| 35 | - | </nav> |
|
| 36 | - | </div> |
|
| 37 | - | ||
| 38 | - | ||
| 39 | - | <form id="snippetForm" method="POST" action="/snippets"> |
|
| 40 | - | <div> |
|
| 41 | - | <input placeholder="index.ts" type="text" id="name" name="name" required> |
|
| 42 | - | </div> |
|
| 43 | - | ||
| 44 | - | <div> |
|
| 45 | - | <textarea placeholder="// paste your code here" id="content" name="content" required></textarea> |
|
| 46 | - | </div> |
|
| 47 | - | ||
| 48 | - | <button type="submit">Create Snippet</button> |
|
| 49 | - | </form> |
|
| 50 | - | ||
| 51 | - | <script> |
|
| 52 | - | document.getElementById('content').addEventListener('keydown', (e) => { |
|
| 53 | - | if (e.metaKey && e.key === 'Enter' || e.ctrlKey && e.key === 'Enter') { |
|
| 54 | - | e.preventDefault(); |
|
| 55 | - | document.getElementById('snippetForm').requestSubmit(); |
|
| 56 | - | } |
|
| 57 | - | }); |
|
| 58 | - | </script> |
|
| 59 | - | </body> |
|
| 60 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <meta name="theme-color" content="#121113" /> |
|
| 7 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | - | ||
| 14 | - | <title>Sipp - Login</title> |
|
| 15 | - | <meta name="description" content="Minimal Code Sharing"> |
|
| 16 | - | </head> |
|
| 17 | - | <body> |
|
| 18 | - | ||
| 19 | - | <header class="header"> |
|
| 20 | - | <a href="/" class="logo"><h1>SIPP</h1></a> |
|
| 21 | - | </header> |
|
| 22 | - | <main> |
|
| 23 | - | {% if let Some(error) = error %} |
|
| 24 | - | <p class="error">{{ error }}</p> |
|
| 25 | - | {% endif %} |
|
| 26 | - | ||
| 27 | - | <form method="POST" action="/admin/login{% if let Some(next) = next %}?next={{ next }}{% endif %}" class="form"> |
|
| 28 | - | <label for="api_key">api key</label> |
|
| 29 | - | <input type="password" id="api_key" name="api_key" autofocus required> |
|
| 30 | - | <button type="submit">login</button> |
|
| 31 | - | </form> |
|
| 32 | - | </main> |
|
| 33 | - | </body> |
|
| 34 | - | </html> |
| 1 | - | <!doctype html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8" /> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | - | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 7 | - | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 8 | - | <meta name="theme-color" content="#121113" /> |
|
| 9 | - | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | - | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | - | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | - | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | - | ||
| 14 | - | <title>{{ name }} | Sipp</title> |
|
| 15 | - | <meta name="description" content="Minimal Code Sharing"> |
|
| 16 | - | ||
| 17 | - | <meta property="og:url" content="{{ base_url }}"> |
|
| 18 | - | <meta property="og:type" content="website"> |
|
| 19 | - | <meta property="og:title" content="Sipp | {{ name }}"> |
|
| 20 | - | <meta property="og:description" content="Minimal Code Sharing"> |
|
| 21 | - | <meta property="og:image" content="{{ base_url }}/static/og.png"> |
|
| 22 | - | ||
| 23 | - | <meta name="twitter:card" content="summary_large_image"> |
|
| 24 | - | <meta property="twitter:url" content="{{ base_url }}"> |
|
| 25 | - | <meta name="twitter:title" content="Sipp | {{ name }}"> |
|
| 26 | - | <meta name="twitter:description" content="Minimal Code Sharing"> |
|
| 27 | - | <meta name="twitter:image" content="{{ base_url }}/static/og.png"> |
|
| 28 | - | ||
| 29 | - | </head> |
|
| 30 | - | <body> |
|
| 31 | - | <div class="nav"> |
|
| 32 | - | <a href="/" class="header"> |
|
| 33 | - | <h1>SIPP</h1> |
|
| 34 | - | </a> |
|
| 35 | - | </div> |
|
| 36 | - | ||
| 37 | - | <div id="snippetForm"> |
|
| 38 | - | <label id="snippetName">{{ name }}</label> |
|
| 39 | - | <div class="code-container">{{ highlighted_content|safe }}</div> |
|
| 40 | - | <textarea id="content" style="display:none;">{{ content }}</textarea> |
|
| 41 | - | <div class="button-group"> |
|
| 42 | - | <button type="button" id="copyLinkBtn" data-original-text="Copy Link">Copy Link</button> |
|
| 43 | - | <button type="button" id="copyContentBtn" data-original-text="Copy Content">Copy Content</button> |
|
| 44 | - | <button type="button" id="createNewBtn">Create New Snippet</button> |
|
| 45 | - | </div> |
|
| 46 | - | </div> |
|
| 47 | - | ||
| 48 | - | <script> |
|
| 49 | - | async function copyToClipboard(text, button) { |
|
| 50 | - | try { |
|
| 51 | - | await navigator.clipboard.writeText(text); |
|
| 52 | - | showButtonFeedback(button, '\u2714 Copied', 'success'); |
|
| 53 | - | } catch (error) { |
|
| 54 | - | console.error('Copy failed:', error); |
|
| 55 | - | showButtonFeedback(button, '\u2718 Failed', 'error'); |
|
| 56 | - | ||
| 57 | - | try { |
|
| 58 | - | const textArea = document.createElement('textarea'); |
|
| 59 | - | textArea.value = text; |
|
| 60 | - | textArea.style.position = 'fixed'; |
|
| 61 | - | textArea.style.opacity = '0'; |
|
| 62 | - | document.body.appendChild(textArea); |
|
| 63 | - | textArea.select(); |
|
| 64 | - | document.execCommand('copy'); |
|
| 65 | - | document.body.removeChild(textArea); |
|
| 66 | - | showButtonFeedback(button, '\u2714 Copied', 'success'); |
|
| 67 | - | } catch (fallbackError) { |
|
| 68 | - | showButtonFeedback(button, '\u2718 Failed', 'error'); |
|
| 69 | - | } |
|
| 70 | - | } |
|
| 71 | - | } |
|
| 72 | - | ||
| 73 | - | function showButtonFeedback(button, message, type = 'success') { |
|
| 74 | - | const originalText = button.dataset.originalText || button.textContent; |
|
| 75 | - | const originalDisabled = button.disabled; |
|
| 76 | - | ||
| 77 | - | if (!button.dataset.originalText) { |
|
| 78 | - | button.dataset.originalText = originalText; |
|
| 79 | - | } |
|
| 80 | - | ||
| 81 | - | button.textContent = message; |
|
| 82 | - | button.disabled = true; |
|
| 83 | - | button.classList.add(`copy-${type}`); |
|
| 84 | - | ||
| 85 | - | setTimeout(() => { |
|
| 86 | - | button.textContent = originalText; |
|
| 87 | - | button.disabled = originalDisabled; |
|
| 88 | - | button.classList.remove(`copy-${type}`); |
|
| 89 | - | }, 1000); |
|
| 90 | - | } |
|
| 91 | - | ||
| 92 | - | document.getElementById('copyContentBtn').addEventListener('click', async () => { |
|
| 93 | - | const content = document.getElementById('content').value; |
|
| 94 | - | const button = document.getElementById('copyContentBtn'); |
|
| 95 | - | await copyToClipboard(content, button); |
|
| 96 | - | }); |
|
| 97 | - | ||
| 98 | - | document.getElementById('copyLinkBtn').addEventListener('click', async () => { |
|
| 99 | - | const currentUrl = window.location.href; |
|
| 100 | - | const button = document.getElementById('copyLinkBtn'); |
|
| 101 | - | await copyToClipboard(currentUrl, button); |
|
| 102 | - | }); |
|
| 103 | - | ||
| 104 | - | document.getElementById('createNewBtn').addEventListener('click', () => { |
|
| 105 | - | window.location.href = '/'; |
|
| 106 | - | }); |
|
| 107 | - | </script> |
|
| 108 | - | </body> |
|
| 109 | - | </html> |
| 1 | - | [package] |
|
| 2 | - | name = "andromeda-auth" |
|
| 3 | - | version = "0.1.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | ||
| 6 | - | [dependencies] |
|
| 7 | - | subtle = { workspace = true } |
|
| 8 | - | rand = { workspace = true } |
|
| 9 | - | axum = { workspace = true } |
| 1 | - | //! Zero-dep datetime helpers for formatting Unix timestamps as |
|
| 2 | - | //! `YYYY-MM-DD HH:MM:SS` strings suitable for SQLite TEXT columns |
|
| 3 | - | //! and string-sortable comparisons. |
|
| 4 | - | ||
| 5 | - | use std::time::{SystemTime, UNIX_EPOCH}; |
|
| 6 | - | ||
| 7 | - | /// Current time as `YYYY-MM-DD HH:MM:SS`. |
|
| 8 | - | pub fn now_datetime_string() -> String { |
|
| 9 | - | from_unix_secs(now_secs()) |
|
| 10 | - | } |
|
| 11 | - | ||
| 12 | - | /// Time `secs_from_now` seconds in the future as `YYYY-MM-DD HH:MM:SS`. |
|
| 13 | - | pub fn expiry_datetime_string(secs_from_now: u64) -> String { |
|
| 14 | - | from_unix_secs(now_secs() + secs_from_now) |
|
| 15 | - | } |
|
| 16 | - | ||
| 17 | - | /// Format an absolute Unix timestamp as `YYYY-MM-DD HH:MM:SS`. |
|
| 18 | - | pub fn from_unix_secs(secs: u64) -> String { |
|
| 19 | - | let days = secs / 86400; |
|
| 20 | - | let h = (secs / 3600) % 24; |
|
| 21 | - | let m = (secs / 60) % 60; |
|
| 22 | - | let s = secs % 60; |
|
| 23 | - | format_unix_to_datetime(days, h, m, s) |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | /// Format an already-split unix time into `YYYY-MM-DD HH:MM:SS`. |
|
| 27 | - | pub fn format_unix_to_datetime(days: u64, h: u64, m: u64, s: u64) -> String { |
|
| 28 | - | let (y, mo, d) = days_to_ymd(days as i64); |
|
| 29 | - | format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, mo, d, h, m, s) |
|
| 30 | - | } |
|
| 31 | - | ||
| 32 | - | /// Convert days-since-Unix-epoch into `(year, month, day)`. |
|
| 33 | - | /// https://howardhinnant.github.io/date_algorithms.html |
|
| 34 | - | pub fn days_to_ymd(mut days: i64) -> (i64, i64, i64) { |
|
| 35 | - | days += 719468; |
|
| 36 | - | let era = if days >= 0 { days } else { days - 146096 } / 146097; |
|
| 37 | - | let doe = (days - era * 146097) as u32; |
|
| 38 | - | let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; |
|
| 39 | - | let y = yoe as i64 + era * 400; |
|
| 40 | - | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
|
| 41 | - | let mp = (5 * doy + 2) / 153; |
|
| 42 | - | let d = doy - (153 * mp + 2) / 5 + 1; |
|
| 43 | - | let m = if mp < 10 { mp + 3 } else { mp - 9 }; |
|
| 44 | - | let y = if m <= 2 { y + 1 } else { y }; |
|
| 45 | - | (y, m as i64, d as i64) |
|
| 46 | - | } |
|
| 47 | - | ||
| 48 | - | fn now_secs() -> u64 { |
|
| 49 | - | SystemTime::now() |
|
| 50 | - | .duration_since(UNIX_EPOCH) |
|
| 51 | - | .unwrap() |
|
| 52 | - | .as_secs() |
|
| 53 | - | } |
|
| 54 | - | ||
| 55 | - | #[cfg(test)] |
|
| 56 | - | mod tests { |
|
| 57 | - | use super::*; |
|
| 58 | - | ||
| 59 | - | #[test] |
|
| 60 | - | fn format_unix_epoch() { |
|
| 61 | - | assert_eq!(format_unix_to_datetime(0, 0, 0, 0), "1970-01-01 00:00:00"); |
|
| 62 | - | } |
|
| 63 | - | ||
| 64 | - | #[test] |
|
| 65 | - | fn format_unix_known_date() { |
|
| 66 | - | assert_eq!( |
|
| 67 | - | format_unix_to_datetime(19737, 12, 30, 45), |
|
| 68 | - | "2024-01-15 12:30:45" |
|
| 69 | - | ); |
|
| 70 | - | } |
|
| 71 | - | ||
| 72 | - | #[test] |
|
| 73 | - | fn format_unix_y2k() { |
|
| 74 | - | assert_eq!( |
|
| 75 | - | format_unix_to_datetime(10957, 0, 0, 0), |
|
| 76 | - | "2000-01-01 00:00:00" |
|
| 77 | - | ); |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | - | #[test] |
|
| 81 | - | fn format_unix_leap_day() { |
|
| 82 | - | assert_eq!( |
|
| 83 | - | format_unix_to_datetime(19782, 23, 59, 59), |
|
| 84 | - | "2024-02-29 23:59:59" |
|
| 85 | - | ); |
|
| 86 | - | } |
|
| 87 | - | ||
| 88 | - | #[test] |
|
| 89 | - | fn format_unix_end_of_year() { |
|
| 90 | - | assert_eq!( |
|
| 91 | - | format_unix_to_datetime(19722, 23, 59, 59), |
|
| 92 | - | "2023-12-31 23:59:59" |
|
| 93 | - | ); |
|
| 94 | - | } |
|
| 95 | - | ||
| 96 | - | #[test] |
|
| 97 | - | fn now_string_valid_format() { |
|
| 98 | - | let s = now_datetime_string(); |
|
| 99 | - | assert_eq!(s.len(), 19); |
|
| 100 | - | assert_eq!(&s[4..5], "-"); |
|
| 101 | - | assert_eq!(&s[7..8], "-"); |
|
| 102 | - | assert_eq!(&s[10..11], " "); |
|
| 103 | - | assert_eq!(&s[13..14], ":"); |
|
| 104 | - | assert_eq!(&s[16..17], ":"); |
|
| 105 | - | } |
|
| 106 | - | ||
| 107 | - | #[test] |
|
| 108 | - | fn expiry_string_in_future() { |
|
| 109 | - | let now = now_datetime_string(); |
|
| 110 | - | let exp = expiry_datetime_string(7 * 24 * 3600); |
|
| 111 | - | assert!(exp > now); |
|
| 112 | - | } |
|
| 113 | - | ||
| 114 | - | #[test] |
|
| 115 | - | fn from_unix_secs_known() { |
|
| 116 | - | // 2024-01-15 12:30:45 UTC = 1705321845 |
|
| 117 | - | assert_eq!(from_unix_secs(1705321845), "2024-01-15 12:30:45"); |
|
| 118 | - | } |
|
| 119 | - | } |
| 1 | - | use rand::RngCore; |
|
| 2 | - | use subtle::ConstantTimeEq; |
|
| 3 | - | ||
| 4 | - | pub mod datetime; |
|
| 5 | - | ||
| 6 | - | /// Constant-time password comparison to prevent timing attacks. |
|
| 7 | - | /// Pads/truncates both sides to a fixed 256-byte buffer so length |
|
| 8 | - | /// differences don't leak via timing. |
|
| 9 | - | pub fn verify_password(input: &str, expected: &str) -> bool { |
|
| 10 | - | const LEN: usize = 256; |
|
| 11 | - | let mut a = [0u8; LEN]; |
|
| 12 | - | let mut b = [0u8; LEN]; |
|
| 13 | - | let ib = input.as_bytes(); |
|
| 14 | - | let eb = expected.as_bytes(); |
|
| 15 | - | a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]); |
|
| 16 | - | b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]); |
|
| 17 | - | let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8); |
|
| 18 | - | (lengths_match & a.ct_eq(&b)).into() |
|
| 19 | - | } |
|
| 20 | - | ||
| 21 | - | /// Generate a 32-byte cryptographically random hex token. |
|
| 22 | - | pub fn generate_session_token() -> String { |
|
| 23 | - | let mut bytes = [0u8; 32]; |
|
| 24 | - | rand::rngs::OsRng.fill_bytes(&mut bytes); |
|
| 25 | - | bytes.iter().map(|b| format!("{:02x}", b)).collect() |
|
| 26 | - | } |
|
| 27 | - | ||
| 28 | - | /// Constant-time API key comparison. Same shape as `verify_password`. |
|
| 29 | - | pub fn verify_api_key(input: &str, expected: &str) -> bool { |
|
| 30 | - | const LEN: usize = 256; |
|
| 31 | - | let mut a = [0u8; LEN]; |
|
| 32 | - | let mut b = [0u8; LEN]; |
|
| 33 | - | let ib = input.as_bytes(); |
|
| 34 | - | let eb = expected.as_bytes(); |
|
| 35 | - | a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]); |
|
| 36 | - | b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]); |
|
| 37 | - | let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8); |
|
| 38 | - | (lengths_match & a.ct_eq(&b)).into() |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | /// Generate a 32-byte cryptographically random hex API key. |
|
| 42 | - | pub fn generate_api_key() -> String { |
|
| 43 | - | let mut bytes = [0u8; 32]; |
|
| 44 | - | rand::rngs::OsRng.fill_bytes(&mut bytes); |
|
| 45 | - | bytes.iter().map(|b| format!("{:02x}", b)).collect() |
|
| 46 | - | } |
|
| 47 | - | ||
| 48 | - | /// Build a session cookie with HttpOnly, SameSite=Strict, 7-day Max-Age. |
|
| 49 | - | pub fn build_session_cookie(token: &str, secure: bool) -> String { |
|
| 50 | - | let mut cookie = format!( |
|
| 51 | - | "session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800", |
|
| 52 | - | token |
|
| 53 | - | ); |
|
| 54 | - | if secure { |
|
| 55 | - | cookie.push_str("; Secure"); |
|
| 56 | - | } |
|
| 57 | - | cookie |
|
| 58 | - | } |
|
| 59 | - | ||
| 60 | - | /// Build a cookie that clears the session. |
|
| 61 | - | pub fn clear_session_cookie() -> String { |
|
| 62 | - | "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string() |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | - | /// Extract the session token from the Cookie header. |
|
| 66 | - | pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> { |
|
| 67 | - | let cookie_header = headers.get("cookie")?.to_str().ok()?; |
|
| 68 | - | for part in cookie_header.split(';') { |
|
| 69 | - | let part = part.trim(); |
|
| 70 | - | if let Some(val) = part.strip_prefix("session=") { |
|
| 71 | - | let val = val.trim().to_string(); |
|
| 72 | - | if !val.is_empty() { |
|
| 73 | - | return Some(val); |
|
| 74 | - | } |
|
| 75 | - | } |
|
| 76 | - | } |
|
| 77 | - | None |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | - | #[cfg(test)] |
|
| 81 | - | mod tests { |
|
| 82 | - | use super::*; |
|
| 83 | - | use axum::http::HeaderMap; |
|
| 84 | - | ||
| 85 | - | // ── verify_password ──────────────────────────────────────────────── |
|
| 86 | - | ||
| 87 | - | #[test] |
|
| 88 | - | fn verify_password_correct() { |
|
| 89 | - | assert!(verify_password("hunter2", "hunter2")); |
|
| 90 | - | } |
|
| 91 | - | ||
| 92 | - | #[test] |
|
| 93 | - | fn verify_password_wrong() { |
|
| 94 | - | assert!(!verify_password("hunter2", "hunter3")); |
|
| 95 | - | } |
|
| 96 | - | ||
| 97 | - | #[test] |
|
| 98 | - | fn verify_password_empty_both() { |
|
| 99 | - | assert!(verify_password("", "")); |
|
| 100 | - | } |
|
| 101 | - | ||
| 102 | - | #[test] |
|
| 103 | - | fn verify_password_empty_vs_nonempty() { |
|
| 104 | - | assert!(!verify_password("", "something")); |
|
| 105 | - | assert!(!verify_password("something", "")); |
|
| 106 | - | } |
|
| 107 | - | ||
| 108 | - | #[test] |
|
| 109 | - | fn verify_password_length_mismatch() { |
|
| 110 | - | assert!(!verify_password("short", "longer_password")); |
|
| 111 | - | } |
|
| 112 | - | ||
| 113 | - | #[test] |
|
| 114 | - | fn verify_password_over_256_bytes_truncated_to_same() { |
|
| 115 | - | // Both 300 chars, identical first 256 → truncation makes them equal |
|
| 116 | - | let long_a = "a".repeat(300); |
|
| 117 | - | let mut long_b = "a".repeat(256); |
|
| 118 | - | long_b.push_str(&"b".repeat(44)); |
|
| 119 | - | // Same length, same first 256 bytes → passes |
|
| 120 | - | assert!(verify_password(&long_a, &long_b)); |
|
| 121 | - | } |
|
| 122 | - | ||
| 123 | - | #[test] |
|
| 124 | - | fn verify_password_over_256_bytes_different_prefix() { |
|
| 125 | - | let long_a = "a".repeat(300); |
|
| 126 | - | let mut long_b = "a".repeat(300); |
|
| 127 | - | // Differ within first 256 bytes |
|
| 128 | - | unsafe { long_b.as_bytes_mut()[0] = b'z'; } |
|
| 129 | - | assert!(!verify_password(&long_a, &long_b)); |
|
| 130 | - | } |
|
| 131 | - | ||
| 132 | - | #[test] |
|
| 133 | - | fn verify_password_exactly_256_bytes() { |
|
| 134 | - | let pw = "x".repeat(256); |
|
| 135 | - | assert!(verify_password(&pw, &pw)); |
|
| 136 | - | } |
|
| 137 | - | ||
| 138 | - | // ── generate_session_token ───────────────────────────────────────── |
|
| 139 | - | ||
| 140 | - | #[test] |
|
| 141 | - | fn session_token_is_64_hex_chars() { |
|
| 142 | - | let token = generate_session_token(); |
|
| 143 | - | assert_eq!(token.len(), 64); |
|
| 144 | - | assert!(token.chars().all(|c| c.is_ascii_hexdigit())); |
|
| 145 | - | } |
|
| 146 | - | ||
| 147 | - | #[test] |
|
| 148 | - | fn session_token_unique_across_calls() { |
|
| 149 | - | let a = generate_session_token(); |
|
| 150 | - | let b = generate_session_token(); |
|
| 151 | - | assert_ne!(a, b); |
|
| 152 | - | } |
|
| 153 | - | ||
| 154 | - | // ── verify_api_key ───────────────────────────────────────────────── |
|
| 155 | - | ||
| 156 | - | #[test] |
|
| 157 | - | fn verify_api_key_correct() { |
|
| 158 | - | assert!(verify_api_key("abc123", "abc123")); |
|
| 159 | - | } |
|
| 160 | - | ||
| 161 | - | #[test] |
|
| 162 | - | fn verify_api_key_wrong() { |
|
| 163 | - | assert!(!verify_api_key("abc123", "abc124")); |
|
| 164 | - | } |
|
| 165 | - | ||
| 166 | - | #[test] |
|
| 167 | - | fn verify_api_key_length_mismatch() { |
|
| 168 | - | assert!(!verify_api_key("short", "longer_key")); |
|
| 169 | - | } |
|
| 170 | - | ||
| 171 | - | // ── generate_api_key ─────────────────────────────────────────────── |
|
| 172 | - | ||
| 173 | - | #[test] |
|
| 174 | - | fn api_key_is_64_hex_chars() { |
|
| 175 | - | let key = generate_api_key(); |
|
| 176 | - | assert_eq!(key.len(), 64); |
|
| 177 | - | assert!(key.chars().all(|c| c.is_ascii_hexdigit())); |
|
| 178 | - | } |
|
| 179 | - | ||
| 180 | - | #[test] |
|
| 181 | - | fn api_key_unique_across_calls() { |
|
| 182 | - | assert_ne!(generate_api_key(), generate_api_key()); |
|
| 183 | - | } |
|
| 184 | - | ||
| 185 | - | // ── build_session_cookie ─────────────────────────────────────────── |
|
| 186 | - | ||
| 187 | - | #[test] |
|
| 188 | - | fn build_session_cookie_not_secure() { |
|
| 189 | - | let cookie = build_session_cookie("abc123", false); |
|
| 190 | - | assert!(cookie.contains("session=abc123")); |
|
| 191 | - | assert!(cookie.contains("HttpOnly")); |
|
| 192 | - | assert!(cookie.contains("SameSite=Strict")); |
|
| 193 | - | assert!(cookie.contains("Path=/")); |
|
| 194 | - | assert!(cookie.contains("Max-Age=604800")); |
|
| 195 | - | assert!(!cookie.contains("Secure")); |
|
| 196 | - | } |
|
| 197 | - | ||
| 198 | - | #[test] |
|
| 199 | - | fn build_session_cookie_secure() { |
|
| 200 | - | let cookie = build_session_cookie("abc123", true); |
|
| 201 | - | assert!(cookie.contains("Secure")); |
|
| 202 | - | } |
|
| 203 | - | ||
| 204 | - | // ── clear_session_cookie ─────────────────────────────────────────── |
|
| 205 | - | ||
| 206 | - | #[test] |
|
| 207 | - | fn clear_session_cookie_has_zero_max_age() { |
|
| 208 | - | let cookie = clear_session_cookie(); |
|
| 209 | - | assert!(cookie.contains("session=")); |
|
| 210 | - | assert!(cookie.contains("Max-Age=0")); |
|
| 211 | - | assert!(cookie.contains("HttpOnly")); |
|
| 212 | - | } |
|
| 213 | - | ||
| 214 | - | // ── extract_session_cookie ───────────────────────────────────────── |
|
| 215 | - | ||
| 216 | - | #[test] |
|
| 217 | - | fn extract_session_cookie_present() { |
|
| 218 | - | let mut headers = HeaderMap::new(); |
|
| 219 | - | headers.insert("cookie", "session=tok123".parse().unwrap()); |
|
| 220 | - | assert_eq!(extract_session_cookie(&headers), Some("tok123".to_string())); |
|
| 221 | - | } |
|
| 222 | - | ||
| 223 | - | #[test] |
|
| 224 | - | fn extract_session_cookie_absent() { |
|
| 225 | - | let headers = HeaderMap::new(); |
|
| 226 | - | assert_eq!(extract_session_cookie(&headers), None); |
|
| 227 | - | } |
|
| 228 | - | ||
| 229 | - | #[test] |
|
| 230 | - | fn extract_session_cookie_empty_value() { |
|
| 231 | - | let mut headers = HeaderMap::new(); |
|
| 232 | - | headers.insert("cookie", "session=".parse().unwrap()); |
|
| 233 | - | assert_eq!(extract_session_cookie(&headers), None); |
|
| 234 | - | } |
|
| 235 | - | ||
| 236 | - | #[test] |
|
| 237 | - | fn extract_session_cookie_among_multiple() { |
|
| 238 | - | let mut headers = HeaderMap::new(); |
|
| 239 | - | headers.insert( |
|
| 240 | - | "cookie", |
|
| 241 | - | "theme=dark; session=abc; lang=en".parse().unwrap(), |
|
| 242 | - | ); |
|
| 243 | - | assert_eq!(extract_session_cookie(&headers), Some("abc".to_string())); |
|
| 244 | - | } |
|
| 245 | - | ||
| 246 | - | #[test] |
|
| 247 | - | fn extract_session_cookie_no_session_key() { |
|
| 248 | - | let mut headers = HeaderMap::new(); |
|
| 249 | - | headers.insert("cookie", "theme=dark; lang=en".parse().unwrap()); |
|
| 250 | - | assert_eq!(extract_session_cookie(&headers), None); |
|
| 251 | - | } |
|
| 252 | - | } |
| 1 | - | [package] |
|
| 2 | - | name = "andromeda-darkmatter-css" |
|
| 3 | - | version = "0.1.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | ||
| 6 | - | [dependencies] |
|
| 7 | - | axum = { workspace = true } |
|
| 8 | - | rust-embed = { workspace = true } |
| 1 | - | /* Darkmatter — canonical CSS for Andromeda apps. |
|
| 2 | - | * Source of truth for reset, tokens, and shared components. |
|
| 3 | - | * Docs: /darkmatter |
|
| 4 | - | */ |
|
| 5 | - | ||
| 6 | - | @font-face { |
|
| 7 | - | font-family: "Commit Mono"; |
|
| 8 | - | src: url("/assets/fonts/CommitMono-400-Regular.otf") format("opentype"); |
|
| 9 | - | font-weight: 400; |
|
| 10 | - | font-style: normal; |
|
| 11 | - | font-display: swap; |
|
| 12 | - | } |
|
| 13 | - | ||
| 14 | - | @font-face { |
|
| 15 | - | font-family: "Commit Mono"; |
|
| 16 | - | src: url("/assets/fonts/CommitMono-700-Regular.otf") format("opentype"); |
|
| 17 | - | font-weight: 700; |
|
| 18 | - | font-style: normal; |
|
| 19 | - | font-display: swap; |
|
| 20 | - | } |
|
| 21 | - | ||
| 22 | - | /* ── Reset + webkit hardening ─────────────────────────────────────── */ |
|
| 23 | - | ||
| 24 | - | *, |
|
| 25 | - | *::before, |
|
| 26 | - | *::after { |
|
| 27 | - | padding: 0; |
|
| 28 | - | margin: 0; |
|
| 29 | - | box-sizing: border-box; |
|
| 30 | - | font-family: "Commit Mono", monospace, sans-serif; |
|
| 31 | - | -webkit-tap-highlight-color: transparent; |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | * { |
|
| 35 | - | scrollbar-width: none; |
|
| 36 | - | -ms-overflow-style: none; |
|
| 37 | - | } |
|
| 38 | - | ||
| 39 | - | html { |
|
| 40 | - | background: #121113; |
|
| 41 | - | color: #ffffff; |
|
| 42 | - | font-size: 14px; |
|
| 43 | - | line-height: 1.6; |
|
| 44 | - | -webkit-text-size-adjust: 100%; |
|
| 45 | - | text-size-adjust: 100%; |
|
| 46 | - | } |
|
| 47 | - | ||
| 48 | - | html::-webkit-scrollbar { |
|
| 49 | - | display: none; |
|
| 50 | - | } |
|
| 51 | - | ||
| 52 | - | body { |
|
| 53 | - | display: flex; |
|
| 54 | - | flex-direction: column; |
|
| 55 | - | justify-content: start; |
|
| 56 | - | align-items: start; |
|
| 57 | - | gap: 1.5rem; |
|
| 58 | - | min-height: 100vh; |
|
| 59 | - | max-width: 700px; |
|
| 60 | - | margin: auto; |
|
| 61 | - | padding: 0 1rem 4rem; |
|
| 62 | - | } |
|
| 63 | - | ||
| 64 | - | @media (max-width: 480px) { |
|
| 65 | - | body { |
|
| 66 | - | padding: 1rem; |
|
| 67 | - | gap: 1rem; |
|
| 68 | - | } |
|
| 69 | - | } |
|
| 70 | - | ||
| 71 | - | /* ── Links ────────────────────────────────────────────────────────── */ |
|
| 72 | - | ||
| 73 | - | a { |
|
| 74 | - | color: #ffffff; |
|
| 75 | - | text-decoration: none; |
|
| 76 | - | touch-action: manipulation; |
|
| 77 | - | } |
|
| 78 | - | ||
| 79 | - | a:hover { |
|
| 80 | - | opacity: 0.7; |
|
| 81 | - | } |
|
| 82 | - | ||
| 83 | - | /* ── Header / nav ─────────────────────────────────────────────────── */ |
|
| 84 | - | ||
| 85 | - | .header { |
|
| 86 | - | display: flex; |
|
| 87 | - | flex-direction: column; |
|
| 88 | - | gap: 0.5rem; |
|
| 89 | - | width: 100%; |
|
| 90 | - | margin-top: 2rem; |
|
| 91 | - | border-bottom: 1px solid #333; |
|
| 92 | - | padding-bottom: 1rem; |
|
| 93 | - | } |
|
| 94 | - | ||
| 95 | - | .logo { |
|
| 96 | - | font-size: 28px; |
|
| 97 | - | font-weight: 700; |
|
| 98 | - | text-decoration: none; |
|
| 99 | - | text-transform: uppercase; |
|
| 100 | - | } |
|
| 101 | - | ||
| 102 | - | .links { |
|
| 103 | - | display: flex; |
|
| 104 | - | align-items: center; |
|
| 105 | - | gap: 0.75rem; |
|
| 106 | - | font-size: 12px; |
|
| 107 | - | } |
|
| 108 | - | ||
| 109 | - | /* ── Main ─────────────────────────────────────────────────────────── */ |
|
| 110 | - | ||
| 111 | - | main { |
|
| 112 | - | width: 100%; |
|
| 113 | - | display: flex; |
|
| 114 | - | flex-direction: column; |
|
| 115 | - | gap: 1rem; |
|
| 116 | - | } |
|
| 117 | - | ||
| 118 | - | /* ── Forms ────────────────────────────────────────────────────────── */ |
|
| 119 | - | ||
| 120 | - | .form { |
|
| 121 | - | display: flex; |
|
| 122 | - | flex-direction: column; |
|
| 123 | - | gap: 0.5rem; |
|
| 124 | - | width: 100%; |
|
| 125 | - | } |
|
| 126 | - | ||
| 127 | - | .form-row { |
|
| 128 | - | display: flex; |
|
| 129 | - | gap: 0.5rem; |
|
| 130 | - | width: 100%; |
|
| 131 | - | } |
|
| 132 | - | ||
| 133 | - | .form-row .form-field { |
|
| 134 | - | flex: 1; |
|
| 135 | - | } |
|
| 136 | - | ||
| 137 | - | .form-field { |
|
| 138 | - | display: flex; |
|
| 139 | - | flex-direction: column; |
|
| 140 | - | gap: 0.25rem; |
|
| 141 | - | } |
|
| 142 | - | ||
| 143 | - | .form-actions { |
|
| 144 | - | display: flex; |
|
| 145 | - | gap: 0.5rem; |
|
| 146 | - | } |
|
| 147 | - | ||
| 148 | - | @media (max-width: 480px) { |
|
| 149 | - | .form-row { |
|
| 150 | - | flex-direction: column; |
|
| 151 | - | } |
|
| 152 | - | } |
|
| 153 | - | ||
| 154 | - | label { |
|
| 155 | - | font-size: 12px; |
|
| 156 | - | opacity: 0.7; |
|
| 157 | - | } |
|
| 158 | - | ||
| 159 | - | input, |
|
| 160 | - | textarea, |
|
| 161 | - | select { |
|
| 162 | - | background: #121113; |
|
| 163 | - | color: #ffffff; |
|
| 164 | - | border: 1px solid #ffffff; |
|
| 165 | - | padding: 0.4rem 0.75rem; |
|
| 166 | - | font-size: 16px; /* 16px prevents iOS focus zoom */ |
|
| 167 | - | width: 100%; |
|
| 168 | - | border-radius: 0; |
|
| 169 | - | -webkit-appearance: none; |
|
| 170 | - | appearance: none; |
|
| 171 | - | outline: none; |
|
| 172 | - | } |
|
| 173 | - | ||
| 174 | - | input:focus, |
|
| 175 | - | textarea:focus, |
|
| 176 | - | select:focus { |
|
| 177 | - | outline: none; |
|
| 178 | - | } |
|
| 179 | - | ||
| 180 | - | textarea { |
|
| 181 | - | min-height: 400px; |
|
| 182 | - | resize: vertical; |
|
| 183 | - | } |
|
| 184 | - | ||
| 185 | - | select { |
|
| 186 | - | background-image: none; |
|
| 187 | - | padding-right: 0.75rem; |
|
| 188 | - | } |
|
| 189 | - | ||
| 190 | - | input[type="file"] { |
|
| 191 | - | cursor: pointer; |
|
| 192 | - | font-size: 14px; |
|
| 193 | - | } |
|
| 194 | - | ||
| 195 | - | input[type="file"]::-webkit-file-upload-button, |
|
| 196 | - | input[type="file"]::file-selector-button { |
|
| 197 | - | background: #121113; |
|
| 198 | - | color: #ffffff; |
|
| 199 | - | border: 1px solid #555; |
|
| 200 | - | padding: 0.25rem 0.5rem; |
|
| 201 | - | cursor: pointer; |
|
| 202 | - | font-family: "Commit Mono", monospace; |
|
| 203 | - | font-size: 12px; |
|
| 204 | - | margin-right: 0.5rem; |
|
| 205 | - | border-radius: 0; |
|
| 206 | - | } |
|
| 207 | - | ||
| 208 | - | input[type="search"]::-webkit-search-decoration, |
|
| 209 | - | input[type="search"]::-webkit-search-cancel-button, |
|
| 210 | - | input[type="search"]::-webkit-search-results-button, |
|
| 211 | - | input[type="search"]::-webkit-search-results-decoration { |
|
| 212 | - | -webkit-appearance: none; |
|
| 213 | - | } |
|
| 214 | - | ||
| 215 | - | input[type="checkbox"], |
|
| 216 | - | input[type="radio"] { |
|
| 217 | - | -webkit-appearance: none; |
|
| 218 | - | appearance: none; |
|
| 219 | - | width: 16px; |
|
| 220 | - | height: 16px; |
|
| 221 | - | background: transparent; |
|
| 222 | - | border: 1px solid #ffffff; |
|
| 223 | - | border-radius: 0; |
|
| 224 | - | padding: 0; |
|
| 225 | - | cursor: pointer; |
|
| 226 | - | position: relative; |
|
| 227 | - | flex-shrink: 0; |
|
| 228 | - | touch-action: manipulation; |
|
| 229 | - | } |
|
| 230 | - | ||
| 231 | - | input[type="radio"] { |
|
| 232 | - | border-radius: 50%; |
|
| 233 | - | } |
|
| 234 | - | ||
| 235 | - | input[type="checkbox"]:checked::after { |
|
| 236 | - | content: '✔︎'; |
|
| 237 | - | position: absolute; |
|
| 238 | - | top: 50%; |
|
| 239 | - | left: 50%; |
|
| 240 | - | transform: translate(-50%, -50%); |
|
| 241 | - | font-size: 12px; |
|
| 242 | - | color: #ffffff; |
|
| 243 | - | line-height: 1; |
|
| 244 | - | } |
|
| 245 | - | ||
| 246 | - | input[type="radio"]:checked::after { |
|
| 247 | - | content: ''; |
|
| 248 | - | position: absolute; |
|
| 249 | - | top: 50%; |
|
| 250 | - | left: 50%; |
|
| 251 | - | width: 8px; |
|
| 252 | - | height: 8px; |
|
| 253 | - | border-radius: 50%; |
|
| 254 | - | background: #ffffff; |
|
| 255 | - | transform: translate(-50%, -50%); |
|
| 256 | - | } |
|
| 257 | - | ||
| 258 | - | .checkbox-field { |
|
| 259 | - | justify-content: flex-end; |
|
| 260 | - | } |
|
| 261 | - | ||
| 262 | - | .checkbox-field label { |
|
| 263 | - | display: flex; |
|
| 264 | - | align-items: center; |
|
| 265 | - | gap: 0.5rem; |
|
| 266 | - | font-size: 14px; |
|
| 267 | - | opacity: 1; |
|
| 268 | - | cursor: pointer; |
|
| 269 | - | } |
|
| 270 | - | ||
| 271 | - | /* Switch */ |
|
| 272 | - | ||
| 273 | - | .switch-row { |
|
| 274 | - | display: flex; |
|
| 275 | - | align-items: center; |
|
| 276 | - | gap: 0.5rem; |
|
| 277 | - | } |
|
| 278 | - | ||
| 279 | - | .switch-label { |
|
| 280 | - | font-size: 14px; |
|
| 281 | - | } |
|
| 282 | - | ||
| 283 | - | .switch { |
|
| 284 | - | position: relative; |
|
| 285 | - | display: inline-block; |
|
| 286 | - | width: 36px; |
|
| 287 | - | height: 20px; |
|
| 288 | - | flex-shrink: 0; |
|
| 289 | - | } |
|
| 290 | - | ||
| 291 | - | .switch input { |
|
| 292 | - | opacity: 0; |
|
| 293 | - | width: 0; |
|
| 294 | - | height: 0; |
|
| 295 | - | } |
|
| 296 | - | ||
| 297 | - | .switch-slider { |
|
| 298 | - | position: absolute; |
|
| 299 | - | cursor: pointer; |
|
| 300 | - | top: 0; |
|
| 301 | - | left: 0; |
|
| 302 | - | right: 0; |
|
| 303 | - | bottom: 0; |
|
| 304 | - | background: #333; |
|
| 305 | - | border-radius: 20px; |
|
| 306 | - | transition: background 0.2s; |
|
| 307 | - | } |
|
| 308 | - | ||
| 309 | - | .switch-slider::before { |
|
| 310 | - | content: ""; |
|
| 311 | - | position: absolute; |
|
| 312 | - | height: 14px; |
|
| 313 | - | width: 14px; |
|
| 314 | - | left: 3px; |
|
| 315 | - | bottom: 3px; |
|
| 316 | - | background: #888; |
|
| 317 | - | border-radius: 50%; |
|
| 318 | - | transition: transform 0.2s, background 0.2s; |
|
| 319 | - | } |
|
| 320 | - | ||
| 321 | - | .switch input:checked + .switch-slider { |
|
| 322 | - | background: #555; |
|
| 323 | - | } |
|
| 324 | - | ||
| 325 | - | .switch input:checked + .switch-slider::before { |
|
| 326 | - | transform: translateX(16px); |
|
| 327 | - | background: #ffffff; |
|
| 328 | - | } |
|
| 329 | - | ||
| 330 | - | /* ── Buttons ──────────────────────────────────────────────────────── */ |
|
| 331 | - | ||
| 332 | - | button, |
|
| 333 | - | .btn { |
|
| 334 | - | background: #121113; |
|
| 335 | - | color: #ffffff; |
|
| 336 | - | padding: 0.2rem 0.75rem; |
|
| 337 | - | border: 1px solid #ffffff; |
|
| 338 | - | cursor: pointer; |
|
| 339 | - | width: fit-content; |
|
| 340 | - | font-size: 14px; |
|
| 341 | - | line-height: 1.4; |
|
| 342 | - | border-radius: 0; |
|
| 343 | - | -webkit-appearance: none; |
|
| 344 | - | appearance: none; |
|
| 345 | - | text-decoration: none; |
|
| 346 | - | display: inline-block; |
|
| 347 | - | touch-action: manipulation; |
|
| 348 | - | } |
|
| 349 | - | ||
| 350 | - | button:hover, |
|
| 351 | - | .btn:hover { |
|
| 352 | - | opacity: 0.7; |
|
| 353 | - | } |
|
| 354 | - | ||
| 355 | - | button.loading { |
|
| 356 | - | cursor: wait; |
|
| 357 | - | } |
|
| 358 | - | ||
| 359 | - | .link-button { |
|
| 360 | - | background: none; |
|
| 361 | - | border: none; |
|
| 362 | - | color: #ffffff; |
|
| 363 | - | cursor: pointer; |
|
| 364 | - | font-size: 12px; |
|
| 365 | - | padding: 0; |
|
| 366 | - | font-family: inherit; |
|
| 367 | - | -webkit-appearance: none; |
|
| 368 | - | appearance: none; |
|
| 369 | - | } |
|
| 370 | - | ||
| 371 | - | .link-button:hover { |
|
| 372 | - | opacity: 0.7; |
|
| 373 | - | } |
|
| 374 | - | ||
| 375 | - | .link-button.danger { |
|
| 376 | - | opacity: 0.5; |
|
| 377 | - | } |
|
| 378 | - | ||
| 379 | - | .link-button.danger:hover { |
|
| 380 | - | opacity: 0.3; |
|
| 381 | - | } |
|
| 382 | - | ||
| 383 | - | .inline-form { |
|
| 384 | - | display: inline; |
|
| 385 | - | margin: 0; |
|
| 386 | - | padding: 0; |
|
| 387 | - | } |
|
| 388 | - | ||
| 389 | - | /* ── Feedback ─────────────────────────────────────────────────────── */ |
|
| 390 | - | ||
| 391 | - | .error { |
|
| 392 | - | color: #ffffff; |
|
| 393 | - | border-left: 2px solid #ffffff; |
|
| 394 | - | padding-left: 0.5rem; |
|
| 395 | - | font-size: 13px; |
|
| 396 | - | opacity: 0.8; |
|
| 397 | - | } |
|
| 398 | - | ||
| 399 | - | .success { |
|
| 400 | - | color: #ffffff; |
|
| 401 | - | border-left: 2px solid #555; |
|
| 402 | - | padding-left: 0.5rem; |
|
| 403 | - | font-size: 13px; |
|
| 404 | - | opacity: 0.7; |
|
| 405 | - | } |
|
| 406 | - | ||
| 407 | - | .empty { |
|
| 408 | - | opacity: 0.5; |
|
| 409 | - | font-size: 12px; |
|
| 410 | - | } |
|
| 411 | - | ||
| 412 | - | /* ── Item list (generic stacked list pattern) ────────────────────── */ |
|
| 413 | - | ||
| 414 | - | .item-list { |
|
| 415 | - | display: flex; |
|
| 416 | - | flex-direction: column; |
|
| 417 | - | width: 100%; |
|
| 418 | - | } |
|
| 419 | - | ||
| 420 | - | .item { |
|
| 421 | - | display: flex; |
|
| 422 | - | flex-direction: column; |
|
| 423 | - | gap: 0.25rem; |
|
| 424 | - | padding: 0.75rem 0; |
|
| 425 | - | border-bottom: 1px solid #333; |
|
| 426 | - | min-width: 0; |
|
| 427 | - | } |
|
| 428 | - | ||
| 429 | - | .item:hover { |
|
| 430 | - | opacity: 0.7; |
|
| 431 | - | } |
|
| 432 | - | ||
| 433 | - | .item-title { |
|
| 434 | - | display: grid; |
|
| 435 | - | grid-template-columns: auto minmax(0, 1fr); |
|
| 436 | - | align-items: center; |
|
| 437 | - | gap: 0.4rem; |
|
| 438 | - | min-width: 0; |
|
| 439 | - | max-width: 100%; |
|
| 440 | - | font-size: 16px; |
|
| 441 | - | overflow-wrap: anywhere; |
|
| 442 | - | } |
|
| 443 | - | ||
| 444 | - | .item-meta { |
|
| 445 | - | max-width: 100%; |
|
| 446 | - | font-size: 12px; |
|
| 447 | - | opacity: 0.5; |
|
| 448 | - | overflow-wrap: anywhere; |
|
| 449 | - | word-break: break-word; |
|
| 450 | - | } |
|
| 451 | - | ||
| 452 | - | .favicon { |
|
| 453 | - | flex-shrink: 0; |
|
| 454 | - | } |
|
| 455 | - | ||
| 456 | - | /* ── Admin list (horizontal row w/ actions) ──────────────────────── */ |
|
| 457 | - | ||
| 458 | - | .admin-list { |
|
| 459 | - | display: flex; |
|
| 460 | - | flex-direction: column; |
|
| 461 | - | width: 100%; |
|
| 462 | - | } |
|
| 463 | - | ||
| 464 | - | .admin-list-item { |
|
| 465 | - | display: flex; |
|
| 466 | - | justify-content: space-between; |
|
| 467 | - | align-items: center; |
|
| 468 | - | padding: 8px 0; |
|
| 469 | - | border-bottom: 1px solid #333; |
|
| 470 | - | gap: 1rem; |
|
| 471 | - | min-width: 0; |
|
| 472 | - | } |
|
| 473 | - | ||
| 474 | - | .admin-list-info { |
|
| 475 | - | display: flex; |
|
| 476 | - | flex: 1; |
|
| 477 | - | flex-direction: column; |
|
| 478 | - | gap: 0.2rem; |
|
| 479 | - | min-width: 0; |
|
| 480 | - | } |
|
| 481 | - | ||
| 482 | - | .admin-list-title { |
|
| 483 | - | display: grid; |
|
| 484 | - | grid-template-columns: auto minmax(0, 1fr); |
|
| 485 | - | align-items: center; |
|
| 486 | - | gap: 0.4rem; |
|
| 487 | - | min-width: 0; |
|
| 488 | - | max-width: 100%; |
|
| 489 | - | font-size: 15px; |
|
| 490 | - | white-space: normal; |
|
| 491 | - | overflow: visible; |
|
| 492 | - | text-overflow: clip; |
|
| 493 | - | overflow-wrap: anywhere; |
|
| 494 | - | } |
|
| 495 | - | ||
| 496 | - | .admin-list-meta { |
|
| 497 | - | display: flex; |
|
| 498 | - | gap: 0.75rem; |
|
| 499 | - | align-items: center; |
|
| 500 | - | } |
|
| 501 | - | ||
| 502 | - | .admin-list-date { |
|
| 503 | - | font-size: 11px; |
|
| 504 | - | opacity: 0.4; |
|
| 505 | - | } |
|
| 506 | - | ||
| 507 | - | .admin-list-actions { |
|
| 508 | - | display: flex; |
|
| 509 | - | gap: 1rem; |
|
| 510 | - | font-size: 12px; |
|
| 511 | - | flex-shrink: 0; |
|
| 512 | - | flex-wrap: wrap; |
|
| 513 | - | } |
|
| 514 | - | ||
| 515 | - | .admin-toolbar { |
|
| 516 | - | display: flex; |
|
| 517 | - | justify-content: space-between; |
|
| 518 | - | align-items: center; |
|
| 519 | - | width: 100%; |
|
| 520 | - | } |
|
| 521 | - | ||
| 522 | - | .admin-toolbar h2 { |
|
| 523 | - | font-size: 18px; |
|
| 524 | - | font-weight: 700; |
|
| 525 | - | } |
|
| 526 | - | ||
| 527 | - | @media (max-width: 480px) { |
|
| 528 | - | .admin-list-item { |
|
| 529 | - | flex-direction: column; |
|
| 530 | - | align-items: flex-start; |
|
| 531 | - | gap: 0.5rem; |
|
| 532 | - | } |
|
| 533 | - | } |
|
| 534 | - | ||
| 535 | - | /* ── Tags / badges ───────────────────────────────────────────────── */ |
|
| 536 | - | ||
| 537 | - | .tag { |
|
| 538 | - | font-size: 11px; |
|
| 539 | - | opacity: 0.5; |
|
| 540 | - | background: #1e1c1f; |
|
| 541 | - | padding: 1px 6px; |
|
| 542 | - | border: 1px solid #333; |
|
| 543 | - | } |
|
| 544 | - | ||
| 545 | - | .status-badge { |
|
| 546 | - | font-size: 11px; |
|
| 547 | - | padding: 1px 6px; |
|
| 548 | - | border: 1px solid #333; |
|
| 549 | - | } |
|
| 550 | - | ||
| 551 | - | .status-published { |
|
| 552 | - | opacity: 1; |
|
| 553 | - | border-color: #555; |
|
| 554 | - | } |
|
| 555 | - | ||
| 556 | - | .status-draft { |
|
| 557 | - | opacity: 0.4; |
|
| 558 | - | } |
|
| 559 | - | ||
| 560 | - | /* ── Tables ──────────────────────────────────────────────────────── */ |
|
| 561 | - | ||
| 562 | - | table { |
|
| 563 | - | width: 100%; |
|
| 564 | - | border-collapse: collapse; |
|
| 565 | - | } |
|
| 566 | - | ||
| 567 | - | th { |
|
| 568 | - | opacity: 0.5; |
|
| 569 | - | font-weight: 400; |
|
| 570 | - | font-size: 12px; |
|
| 571 | - | text-transform: uppercase; |
|
| 572 | - | text-align: left; |
|
| 573 | - | padding: 6px; |
|
| 574 | - | border-bottom: 1px solid #333; |
|
| 575 | - | } |
|
| 576 | - | ||
| 577 | - | td { |
|
| 578 | - | padding: 6px; |
|
| 579 | - | border-bottom: 1px solid #333; |
|
| 580 | - | } |
|
| 581 | - | ||
| 582 | - | /* ── Spinner (braille) ────────────────────────────────────────────── */ |
|
| 583 | - | ||
| 584 | - | .spinner { |
|
| 585 | - | margin-left: 0.6rem; |
|
| 586 | - | } |
|
| 587 | - | ||
| 588 | - | .spinner::after { |
|
| 589 | - | content: "⠋"; |
|
| 590 | - | display: inline-block; |
|
| 591 | - | animation: braille-spin 0.8s steps(10) infinite; |
|
| 592 | - | } |
|
| 593 | - | ||
| 594 | - | @keyframes braille-spin { |
|
| 595 | - | 0% { content: "⠋"; } |
|
| 596 | - | 10% { content: "⠙"; } |
|
| 597 | - | 20% { content: "⠹"; } |
|
| 598 | - | 30% { content: "⠸"; } |
|
| 599 | - | 40% { content: "⠼"; } |
|
| 600 | - | 50% { content: "⠴"; } |
|
| 601 | - | 60% { content: "⠦"; } |
|
| 602 | - | 70% { content: "⠧"; } |
|
| 603 | - | 80% { content: "⠇"; } |
|
| 604 | - | 90% { content: "⠏"; } |
|
| 605 | - | } |
|
| 606 | - | ||
| 607 | - | /* ── Inline code ─────────────────────────────────────────────────── */ |
|
| 608 | - | ||
| 609 | - | code { |
|
| 610 | - | background: #1e1c1f; |
|
| 611 | - | padding: 2px 4px; |
|
| 612 | - | font-size: 13px; |
|
| 613 | - | } |
|
| 614 | - | ||
| 615 | - | pre { |
|
| 616 | - | background: #1e1c1f; |
|
| 617 | - | padding: 12px; |
|
| 618 | - | overflow-x: auto; |
|
| 619 | - | border: 1px solid #333; |
|
| 620 | - | -webkit-overflow-scrolling: touch; |
|
| 621 | - | } |
|
| 622 | - | ||
| 623 | - | pre code { |
|
| 624 | - | background: none; |
|
| 625 | - | padding: 0; |
|
| 626 | - | } |
|
| 627 | - | ||
| 628 | - | /* ── Footer ──────────────────────────────────────────────────────── */ |
|
| 629 | - | ||
| 630 | - | .footer { |
|
| 631 | - | width: 100%; |
|
| 632 | - | border-top: 1px solid #333; |
|
| 633 | - | padding-top: 1rem; |
|
| 634 | - | margin-top: auto; |
|
| 635 | - | display: flex; |
|
| 636 | - | justify-content: center; |
|
| 637 | - | } |
|
| 638 | - | ||
| 639 | - | /* ── Utility ─────────────────────────────────────────────────────── */ |
|
| 640 | - | ||
| 641 | - | .hidden { |
|
| 642 | - | display: none; |
|
| 643 | - | } |
|
| 644 | - | ||
| 645 | - | .scroll-x { |
|
| 646 | - | overflow-x: auto; |
|
| 647 | - | -webkit-overflow-scrolling: touch; |
|
| 648 | - | } |
Binary file — no preview.
Binary file — no preview.
| 1 | - | <!DOCTYPE html> |
|
| 2 | - | <html lang="en"> |
|
| 3 | - | <head> |
|
| 4 | - | <meta charset="UTF-8"> |
|
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | - | <meta name="theme-color" content="#121113"> |
|
| 7 | - | <title>Darkmatter — Gallery</title> |
|
| 8 | - | <link rel="stylesheet" href="/assets/darkmatter.css"> |
|
| 9 | - | <style> |
|
| 10 | - | .section { width: 100%; border-bottom: 1px solid #333; padding-bottom: 2rem; } |
|
| 11 | - | .section h2 { font-size: 16px; margin-bottom: 0.5rem; } |
|
| 12 | - | .section p.desc { font-size: 12px; opacity: 0.5; margin-bottom: 0.75rem; } |
|
| 13 | - | .row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; margin-bottom: 1rem; } |
|
| 14 | - | .swatch { width: 40px; height: 24px; border: 1px solid #333; } |
|
| 15 | - | .opacity-scale { display: flex; flex-direction: column; gap: 0.25rem; } |
|
| 16 | - | </style> |
|
| 17 | - | </head> |
|
| 18 | - | <body> |
|
| 19 | - | <header class="header"> |
|
| 20 | - | <a href="/darkmatter" class="logo">DARKMATTER</a> |
|
| 21 | - | <nav class="links"> |
|
| 22 | - | <a href="#reset">reset</a> |
|
| 23 | - | <a href="#typography">typography</a> |
|
| 24 | - | <a href="#buttons">buttons</a> |
|
| 25 | - | <a href="#forms">forms</a> |
|
| 26 | - | <a href="#feedback">feedback</a> |
|
| 27 | - | <a href="#lists">lists</a> |
|
| 28 | - | <a href="#tags">tags</a> |
|
| 29 | - | <a href="#table">table</a> |
|
| 30 | - | <a href="#code">code</a> |
|
| 31 | - | </nav> |
|
| 32 | - | </header> |
|
| 33 | - | ||
| 34 | - | <main> |
|
| 35 | - | <section class="section" id="reset"> |
|
| 36 | - | <h2>Palette</h2> |
|
| 37 | - | <p class="desc">Background <code>#121113</code>, foreground <code>#ffffff</code>, grays <code>#1e1c1f</code> / <code>#333</code> / <code>#555</code>. No accent colors.</p> |
|
| 38 | - | <div class="row"> |
|
| 39 | - | <div class="swatch" style="background:#121113"></div> |
|
| 40 | - | <div class="swatch" style="background:#1e1c1f"></div> |
|
| 41 | - | <div class="swatch" style="background:#333"></div> |
|
| 42 | - | <div class="swatch" style="background:#555"></div> |
|
| 43 | - | <div class="swatch" style="background:#ffffff"></div> |
|
| 44 | - | </div> |
|
| 45 | - | </section> |
|
| 46 | - | ||
| 47 | - | <section class="section" id="typography"> |
|
| 48 | - | <h2>Typography</h2> |
|
| 49 | - | <p class="desc">Commit Mono. Hierarchy by opacity, not gray hex.</p> |
|
| 50 | - | <div class="opacity-scale"> |
|
| 51 | - | <div>Primary text — opacity 1.0</div> |
|
| 52 | - | <div style="opacity: 0.7">Secondary text — opacity 0.7 (labels, blockquotes)</div> |
|
| 53 | - | <div style="opacity: 0.5">Tertiary text — opacity 0.5 (metadata, dates, table headers)</div> |
|
| 54 | - | <div style="opacity: 0.3">Muted text — opacity 0.3 (null, placeholder)</div> |
|
| 55 | - | </div> |
|
| 56 | - | <div class="row" style="margin-top: 0.75rem"> |
|
| 57 | - | <span style="font-size: 28px; font-weight: 700; text-transform: uppercase">LOGO 28</span> |
|
| 58 | - | <span style="font-size: 18px">h1 18</span> |
|
| 59 | - | <span style="font-size: 16px">h2 / title 16</span> |
|
| 60 | - | <span style="font-size: 14px">body 14</span> |
|
| 61 | - | <span style="font-size: 12px">meta 12</span> |
|
| 62 | - | <span style="font-size: 11px">tag 11</span> |
|
| 63 | - | </div> |
|
| 64 | - | </section> |
|
| 65 | - | ||
| 66 | - | <section class="section" id="buttons"> |
|
| 67 | - | <h2>Buttons & links</h2> |
|
| 68 | - | <p class="desc"><code>button</code>, <code>.btn</code>, <code>.link-button</code>, <code>.link-button.danger</code>, <code>.spinner</code>.</p> |
|
| 69 | - | <div class="row"> |
|
| 70 | - | <button>Submit</button> |
|
| 71 | - | <a href="#" class="btn">Link as button</a> |
|
| 72 | - | <button class="loading">Saving<span class="spinner"></span></button> |
|
| 73 | - | <button class="link-button">edit</button> |
|
| 74 | - | <button class="link-button danger">delete</button> |
|
| 75 | - | <a href="#">plain link</a> |
|
| 76 | - | </div> |
|
| 77 | - | </section> |
|
| 78 | - | ||
| 79 | - | <section class="section" id="forms"> |
|
| 80 | - | <h2>Form controls</h2> |
|
| 81 | - | <p class="desc">Inputs use 16px font-size to prevent iOS focus zoom. All <code>-webkit-appearance: none</code> to kill default iOS chrome.</p> |
|
| 82 | - | <form class="form" onsubmit="event.preventDefault()"> |
|
| 83 | - | <div class="form-field"> |
|
| 84 | - | <label for="demo-text">Text input</label> |
|
| 85 | - | <input id="demo-text" type="text" placeholder="placeholder"> |
|
| 86 | - | </div> |
|
| 87 | - | <div class="form-field"> |
|
| 88 | - | <label for="demo-search">Search</label> |
|
| 89 | - | <input id="demo-search" type="search" placeholder="search..."> |
|
| 90 | - | </div> |
|
| 91 | - | <div class="form-row"> |
|
| 92 | - | <div class="form-field"> |
|
| 93 | - | <label for="demo-a">Split field A</label> |
|
| 94 | - | <input id="demo-a" type="text"> |
|
| 95 | - | </div> |
|
| 96 | - | <div class="form-field"> |
|
| 97 | - | <label for="demo-b">Split field B</label> |
|
| 98 | - | <input id="demo-b" type="text"> |
|
| 99 | - | </div> |
|
| 100 | - | </div> |
|
| 101 | - | <div class="form-field"> |
|
| 102 | - | <label for="demo-select">Select</label> |
|
| 103 | - | <select id="demo-select"> |
|
| 104 | - | <option>one</option> |
|
| 105 | - | <option>two</option> |
|
| 106 | - | </select> |
|
| 107 | - | </div> |
|
| 108 | - | <div class="form-field"> |
|
| 109 | - | <label for="demo-textarea">Textarea</label> |
|
| 110 | - | <textarea id="demo-textarea" style="min-height: 120px">multiline input</textarea> |
|
| 111 | - | </div> |
|
| 112 | - | <div class="form-field"> |
|
| 113 | - | <label for="demo-file">File upload</label> |
|
| 114 | - | <input id="demo-file" type="file"> |
|
| 115 | - | </div> |
|
| 116 | - | <div class="form-field checkbox-field"> |
|
| 117 | - | <label> |
|
| 118 | - | <input type="checkbox" checked> |
|
| 119 | - | Checkbox (checked) |
|
| 120 | - | </label> |
|
| 121 | - | <label> |
|
| 122 | - | <input type="checkbox"> |
|
| 123 | - | Checkbox (unchecked) |
|
| 124 | - | </label> |
|
| 125 | - | </div> |
|
| 126 | - | <div class="form-field"> |
|
| 127 | - | <label> |
|
| 128 | - | <input type="radio" name="demo-radio" checked> Option A |
|
| 129 | - | </label> |
|
| 130 | - | <label> |
|
| 131 | - | <input type="radio" name="demo-radio"> Option B |
|
| 132 | - | </label> |
|
| 133 | - | </div> |
|
| 134 | - | <div class="switch-row"> |
|
| 135 | - | <label class="switch"> |
|
| 136 | - | <input type="checkbox" checked> |
|
| 137 | - | <span class="switch-slider"></span> |
|
| 138 | - | </label> |
|
| 139 | - | <span class="switch-label">Switch toggle</span> |
|
| 140 | - | </div> |
|
| 141 | - | <div class="form-actions"> |
|
| 142 | - | <button type="submit">Save</button> |
|
| 143 | - | <button type="button" class="link-button danger">Cancel</button> |
|
| 144 | - | </div> |
|
| 145 | - | </form> |
|
| 146 | - | </section> |
|
| 147 | - | ||
| 148 | - | <section class="section" id="feedback"> |
|
| 149 | - | <h2>Feedback</h2> |
|
| 150 | - | <p class="desc"><code>.error</code>, <code>.success</code>, <code>.empty</code>. No red/green — borders + opacity only.</p> |
|
| 151 | - | <div class="error">Something went wrong.</div> |
|
| 152 | - | <div class="success" style="margin-top: 0.5rem">Saved successfully.</div> |
|
| 153 | - | <div class="empty" style="margin-top: 0.5rem">No items yet.</div> |
|
| 154 | - | </section> |
|
| 155 | - | ||
| 156 | - | <section class="section" id="lists"> |
|
| 157 | - | <h2>Item list</h2> |
|
| 158 | - | <p class="desc"><code>.item-list</code> / <code>.item</code> — stacked with <code>#333</code> bottom divider.</p> |
|
| 159 | - | <div class="item-list"> |
|
| 160 | - | <a href="#" class="item"> |
|
| 161 | - | <div class="item-title">First entry title</div> |
|
| 162 | - | <div class="item-meta">apr 18, 2026</div> |
|
| 163 | - | </a> |
|
| 164 | - | <a href="#" class="item"> |
|
| 165 | - | <div class="item-title">Second entry title</div> |
|
| 166 | - | <div class="item-meta">apr 17, 2026</div> |
|
| 167 | - | </a> |
|
| 168 | - | </div> |
|
| 169 | - | ||
| 170 | - | <h2 style="margin-top: 1rem">Admin list</h2> |
|
| 171 | - | <p class="desc"><code>.admin-list</code> — horizontal row with inline actions.</p> |
|
| 172 | - | <div class="admin-list"> |
|
| 173 | - | <div class="admin-list-item"> |
|
| 174 | - | <div class="admin-list-info"> |
|
| 175 | - | <div class="admin-list-title">example-item-one</div> |
|
| 176 | - | <div class="admin-list-meta"> |
|
| 177 | - | <span class="status-badge status-published">published</span> |
|
| 178 | - | <span class="admin-list-date">2026-04-18</span> |
|
| 179 | - | </div> |
|
| 180 | - | </div> |
|
| 181 | - | <div class="admin-list-actions"> |
|
| 182 | - | <a href="#">edit</a> |
|
| 183 | - | <button class="link-button danger">delete</button> |
|
| 184 | - | </div> |
|
| 185 | - | </div> |
|
| 186 | - | <div class="admin-list-item"> |
|
| 187 | - | <div class="admin-list-info"> |
|
| 188 | - | <div class="admin-list-title">example-item-two</div> |
|
| 189 | - | <div class="admin-list-meta"> |
|
| 190 | - | <span class="status-badge status-draft">draft</span> |
|
| 191 | - | <span class="admin-list-date">2026-04-17</span> |
|
| 192 | - | </div> |
|
| 193 | - | </div> |
|
| 194 | - | <div class="admin-list-actions"> |
|
| 195 | - | <a href="#">edit</a> |
|
| 196 | - | <button class="link-button danger">delete</button> |
|
| 197 | - | </div> |
|
| 198 | - | </div> |
|
| 199 | - | </div> |
|
| 200 | - | </section> |
|
| 201 | - | ||
| 202 | - | <section class="section" id="tags"> |
|
| 203 | - | <h2>Tags</h2> |
|
| 204 | - | <div class="row"> |
|
| 205 | - | <span class="tag">rust</span> |
|
| 206 | - | <span class="tag">web</span> |
|
| 207 | - | <span class="tag">css</span> |
|
| 208 | - | </div> |
|
| 209 | - | </section> |
|
| 210 | - | ||
| 211 | - | <section class="section" id="table"> |
|
| 212 | - | <h2>Table</h2> |
|
| 213 | - | <table> |
|
| 214 | - | <thead> |
|
| 215 | - | <tr> |
|
| 216 | - | <th>name</th> |
|
| 217 | - | <th>status</th> |
|
| 218 | - | <th>date</th> |
|
| 219 | - | </tr> |
|
| 220 | - | </thead> |
|
| 221 | - | <tbody> |
|
| 222 | - | <tr> |
|
| 223 | - | <td>first</td> |
|
| 224 | - | <td>ok</td> |
|
| 225 | - | <td style="opacity: 0.5">2026-04-18</td> |
|
| 226 | - | </tr> |
|
| 227 | - | <tr> |
|
| 228 | - | <td>second</td> |
|
| 229 | - | <td>ok</td> |
|
| 230 | - | <td style="opacity: 0.5">2026-04-17</td> |
|
| 231 | - | </tr> |
|
| 232 | - | </tbody> |
|
| 233 | - | </table> |
|
| 234 | - | </section> |
|
| 235 | - | ||
| 236 | - | <section class="section" id="code"> |
|
| 237 | - | <h2>Code</h2> |
|
| 238 | - | <p>Inline <code>let x = 42;</code> and block:</p> |
|
| 239 | - | <pre><code>fn main() { |
|
| 240 | - | println!("hello, darkmatter"); |
|
| 241 | - | }</code></pre> |
|
| 242 | - | </section> |
|
| 243 | - | </main> |
|
| 244 | - | ||
| 245 | - | <footer class="footer"> |
|
| 246 | - | <span style="opacity: 0.5; font-size: 12px">darkmatter-css · andromeda workspace</span> |
|
| 247 | - | </footer> |
|
| 248 | - | </body> |
|
| 249 | - | </html> |
| 1 | - | use axum::{ |
|
| 2 | - | extract::Path, |
|
| 3 | - | http::{HeaderValue, StatusCode, header}, |
|
| 4 | - | response::{IntoResponse, Response}, |
|
| 5 | - | routing::get, |
|
| 6 | - | Router, |
|
| 7 | - | }; |
|
| 8 | - | use rust_embed::Embed; |
|
| 9 | - | ||
| 10 | - | #[derive(Embed)] |
|
| 11 | - | #[folder = "assets/"] |
|
| 12 | - | pub struct Assets; |
|
| 13 | - | ||
| 14 | - | pub fn router<S>() -> Router<S> |
|
| 15 | - | where |
|
| 16 | - | S: Clone + Send + Sync + 'static, |
|
| 17 | - | { |
|
| 18 | - | Router::new() |
|
| 19 | - | .route("/assets/darkmatter.css", get(css)) |
|
| 20 | - | .route("/assets/fonts/{file}", get(font)) |
|
| 21 | - | .route("/darkmatter", get(gallery)) |
|
| 22 | - | .route("/darkmatter/", get(gallery)) |
|
| 23 | - | } |
|
| 24 | - | ||
| 25 | - | async fn css() -> Response { |
|
| 26 | - | serve("darkmatter.css", "text/css; charset=utf-8") |
|
| 27 | - | } |
|
| 28 | - | ||
| 29 | - | async fn gallery() -> Response { |
|
| 30 | - | serve("index.html", "text/html; charset=utf-8") |
|
| 31 | - | } |
|
| 32 | - | ||
| 33 | - | async fn font(Path(file): Path<String>) -> Response { |
|
| 34 | - | let mime = match file.rsplit('.').next().unwrap_or("") { |
|
| 35 | - | "otf" => "font/otf", |
|
| 36 | - | "ttf" => "font/ttf", |
|
| 37 | - | "woff" => "font/woff", |
|
| 38 | - | "woff2" => "font/woff2", |
|
| 39 | - | _ => "application/octet-stream", |
|
| 40 | - | }; |
|
| 41 | - | serve(&format!("fonts/{file}"), mime) |
|
| 42 | - | } |
|
| 43 | - | ||
| 44 | - | fn serve(path: &str, mime: &'static str) -> Response { |
|
| 45 | - | match Assets::get(path) { |
|
| 46 | - | Some(f) => ( |
|
| 47 | - | StatusCode::OK, |
|
| 48 | - | [(header::CONTENT_TYPE, HeaderValue::from_static(mime))], |
|
| 49 | - | f.data.to_vec(), |
|
| 50 | - | ) |
|
| 51 | - | .into_response(), |
|
| 52 | - | None => StatusCode::NOT_FOUND.into_response(), |
|
| 53 | - | } |
|
| 54 | - | } |
| 1 | - | [package] |
|
| 2 | - | name = "andromeda-db" |
|
| 3 | - | version = "0.1.0" |
|
| 4 | - | edition = "2024" |
|
| 5 | - | description = "Shared database types and session management for Andromeda apps" |
|
| 6 | - | license = "MIT" |
|
| 7 | - | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | - | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | - | ||
| 10 | - | [dependencies] |
|
| 11 | - | rusqlite = { workspace = true } |
|
| 12 | - | serde = { workspace = true, optional = true } |
|
| 13 | - | axum = { workspace = true, optional = true } |
|
| 14 | - | tracing = { workspace = true, optional = true } |
|
| 15 | - | ||
| 16 | - | [features] |
|
| 17 | - | default = [] |
|
| 18 | - | session = [] |
|
| 19 | - | feeds = ["dep:serde"] |
|
| 20 | - | axum = ["dep:axum", "dep:tracing"] |
| 1 | - | use rusqlite::{params, OptionalExtension, Row}; |
|
| 2 | - | use serde::{Deserialize, Serialize}; |
|
| 3 | - | ||
| 4 | - | use crate::{Db, DbError}; |
|
| 5 | - | ||
| 6 | - | pub const FEEDS_SCHEMA: &str = " |
|
| 7 | - | CREATE TABLE IF NOT EXISTS categories ( |
|
| 8 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 9 | - | name TEXT NOT NULL UNIQUE, |
|
| 10 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 11 | - | ); |
|
| 12 | - | ||
| 13 | - | CREATE TABLE IF NOT EXISTS subscriptions ( |
|
| 14 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 15 | - | feed_url TEXT NOT NULL UNIQUE, |
|
| 16 | - | title TEXT NOT NULL, |
|
| 17 | - | site_url TEXT, |
|
| 18 | - | favicon_url TEXT, |
|
| 19 | - | category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, |
|
| 20 | - | etag TEXT, |
|
| 21 | - | last_modified TEXT, |
|
| 22 | - | last_fetched_at TEXT, |
|
| 23 | - | last_error TEXT, |
|
| 24 | - | added_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 25 | - | ); |
|
| 26 | - | CREATE INDEX IF NOT EXISTS idx_subs_category ON subscriptions(category_id); |
|
| 27 | - | ||
| 28 | - | CREATE TABLE IF NOT EXISTS items ( |
|
| 29 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 30 | - | subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, |
|
| 31 | - | guid TEXT NOT NULL, |
|
| 32 | - | title TEXT NOT NULL, |
|
| 33 | - | link TEXT NOT NULL, |
|
| 34 | - | author TEXT, |
|
| 35 | - | published_at INTEGER NOT NULL, |
|
| 36 | - | is_read INTEGER NOT NULL DEFAULT 0, |
|
| 37 | - | fetched_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 38 | - | UNIQUE(subscription_id, guid) |
|
| 39 | - | ); |
|
| 40 | - | CREATE INDEX IF NOT EXISTS idx_items_sub_pub ON items(subscription_id, published_at DESC); |
|
| 41 | - | CREATE INDEX IF NOT EXISTS idx_items_pub ON items(published_at DESC); |
|
| 42 | - | CREATE INDEX IF NOT EXISTS idx_items_unread ON items(is_read, published_at DESC); |
|
| 43 | - | ||
| 44 | - | CREATE TABLE IF NOT EXISTS settings ( |
|
| 45 | - | key TEXT PRIMARY KEY, |
|
| 46 | - | value TEXT NOT NULL |
|
| 47 | - | ); |
|
| 48 | - | "; |
|
| 49 | - | ||
| 50 | - | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 51 | - | pub struct Category { |
|
| 52 | - | pub id: i64, |
|
| 53 | - | pub name: String, |
|
| 54 | - | pub created_at: String, |
|
| 55 | - | } |
|
| 56 | - | ||
| 57 | - | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 58 | - | pub struct Subscription { |
|
| 59 | - | pub id: i64, |
|
| 60 | - | pub feed_url: String, |
|
| 61 | - | pub title: String, |
|
| 62 | - | pub site_url: Option<String>, |
|
| 63 | - | pub favicon_url: Option<String>, |
|
| 64 | - | pub category_id: Option<i64>, |
|
| 65 | - | pub etag: Option<String>, |
|
| 66 | - | pub last_modified: Option<String>, |
|
| 67 | - | pub last_fetched_at: Option<String>, |
|
| 68 | - | pub last_error: Option<String>, |
|
| 69 | - | pub added_at: String, |
|
| 70 | - | } |
|
| 71 | - | ||
| 72 | - | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 73 | - | pub struct Item { |
|
| 74 | - | pub id: i64, |
|
| 75 | - | pub subscription_id: i64, |
|
| 76 | - | pub guid: String, |
|
| 77 | - | pub title: String, |
|
| 78 | - | pub link: String, |
|
| 79 | - | pub author: Option<String>, |
|
| 80 | - | pub published_at: i64, |
|
| 81 | - | pub is_read: bool, |
|
| 82 | - | pub fetched_at: String, |
|
| 83 | - | } |
|
| 84 | - | ||
| 85 | - | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 86 | - | pub struct ItemWithFeed { |
|
| 87 | - | pub id: i64, |
|
| 88 | - | pub subscription_id: i64, |
|
| 89 | - | pub guid: String, |
|
| 90 | - | pub title: String, |
|
| 91 | - | pub link: String, |
|
| 92 | - | pub author: Option<String>, |
|
| 93 | - | pub published_at: i64, |
|
| 94 | - | pub is_read: bool, |
|
| 95 | - | pub fetched_at: String, |
|
| 96 | - | pub feed_title: String, |
|
| 97 | - | pub feed_url: String, |
|
| 98 | - | pub category_id: Option<i64>, |
|
| 99 | - | pub category_name: Option<String>, |
|
| 100 | - | } |
|
| 101 | - | ||
| 102 | - | #[derive(Debug, Clone)] |
|
| 103 | - | pub struct NewItem<'a> { |
|
| 104 | - | pub subscription_id: i64, |
|
| 105 | - | pub guid: &'a str, |
|
| 106 | - | pub title: &'a str, |
|
| 107 | - | pub link: &'a str, |
|
| 108 | - | pub author: Option<&'a str>, |
|
| 109 | - | pub published_at: i64, |
|
| 110 | - | } |
|
| 111 | - | ||
| 112 | - | fn category_from_row(row: &Row) -> rusqlite::Result<Category> { |
|
| 113 | - | Ok(Category { |
|
| 114 | - | id: row.get(0)?, |
|
| 115 | - | name: row.get(1)?, |
|
| 116 | - | created_at: row.get(2)?, |
|
| 117 | - | }) |
|
| 118 | - | } |
|
| 119 | - | ||
| 120 | - | fn subscription_from_row(row: &Row) -> rusqlite::Result<Subscription> { |
|
| 121 | - | Ok(Subscription { |
|
| 122 | - | id: row.get(0)?, |
|
| 123 | - | feed_url: row.get(1)?, |
|
| 124 | - | title: row.get(2)?, |
|
| 125 | - | site_url: row.get(3)?, |
|
| 126 | - | favicon_url: row.get(4)?, |
|
| 127 | - | category_id: row.get(5)?, |
|
| 128 | - | etag: row.get(6)?, |
|
| 129 | - | last_modified: row.get(7)?, |
|
| 130 | - | last_fetched_at: row.get(8)?, |
|
| 131 | - | last_error: row.get(9)?, |
|
| 132 | - | added_at: row.get(10)?, |
|
| 133 | - | }) |
|
| 134 | - | } |
|
| 135 | - | ||
| 136 | - | const SUB_COLS: &str = "id, feed_url, title, site_url, favicon_url, category_id, etag, last_modified, last_fetched_at, last_error, added_at"; |
|
| 137 | - | ||
| 138 | - | /// Add columns introduced after the initial schema. Idempotent. |
|
| 139 | - | pub fn migrate_feeds(db: &Db) -> Result<(), DbError> { |
|
| 140 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 141 | - | let has_favicon: bool = conn |
|
| 142 | - | .query_row( |
|
| 143 | - | "SELECT 1 FROM pragma_table_info('subscriptions') WHERE name = 'favicon_url'", |
|
| 144 | - | [], |
|
| 145 | - | |_| Ok(true), |
|
| 146 | - | ) |
|
| 147 | - | .optional()? |
|
| 148 | - | .unwrap_or(false); |
|
| 149 | - | if !has_favicon { |
|
| 150 | - | conn.execute("ALTER TABLE subscriptions ADD COLUMN favicon_url TEXT", [])?; |
|
| 151 | - | } |
|
| 152 | - | Ok(()) |
|
| 153 | - | } |
|
| 154 | - | ||
| 155 | - | pub fn update_subscription_favicon( |
|
| 156 | - | db: &Db, |
|
| 157 | - | id: i64, |
|
| 158 | - | favicon_url: Option<&str>, |
|
| 159 | - | ) -> Result<(), DbError> { |
|
| 160 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 161 | - | conn.execute( |
|
| 162 | - | "UPDATE subscriptions SET favicon_url = ?1 WHERE id = ?2", |
|
| 163 | - | params![favicon_url, id], |
|
| 164 | - | )?; |
|
| 165 | - | Ok(()) |
|
| 166 | - | } |
|
| 167 | - | ||
| 168 | - | pub fn update_subscription_site_url( |
|
| 169 | - | db: &Db, |
|
| 170 | - | id: i64, |
|
| 171 | - | site_url: Option<&str>, |
|
| 172 | - | ) -> Result<(), DbError> { |
|
| 173 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 174 | - | conn.execute( |
|
| 175 | - | "UPDATE subscriptions SET site_url = ?1 WHERE id = ?2", |
|
| 176 | - | params![site_url, id], |
|
| 177 | - | )?; |
|
| 178 | - | Ok(()) |
|
| 179 | - | } |
|
| 180 | - | ||
| 181 | - | // ── Categories ──────────────────────────────────────────────────────── |
|
| 182 | - | ||
| 183 | - | pub fn list_categories(db: &Db) -> Result<Vec<Category>, DbError> { |
|
| 184 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 185 | - | let mut stmt = conn.prepare("SELECT id, name, created_at FROM categories ORDER BY name ASC")?; |
|
| 186 | - | let rows = stmt |
|
| 187 | - | .query_map([], category_from_row)? |
|
| 188 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 189 | - | Ok(rows) |
|
| 190 | - | } |
|
| 191 | - | ||
| 192 | - | pub fn insert_category(db: &Db, name: &str) -> Result<Category, DbError> { |
|
| 193 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 194 | - | conn.execute("INSERT INTO categories (name) VALUES (?1)", params![name])?; |
|
| 195 | - | let id = conn.last_insert_rowid(); |
|
| 196 | - | let cat = conn.query_row( |
|
| 197 | - | "SELECT id, name, created_at FROM categories WHERE id = ?1", |
|
| 198 | - | params![id], |
|
| 199 | - | category_from_row, |
|
| 200 | - | )?; |
|
| 201 | - | Ok(cat) |
|
| 202 | - | } |
|
| 203 | - | ||
| 204 | - | pub fn get_or_create_category(db: &Db, name: &str) -> Result<Category, DbError> { |
|
| 205 | - | { |
|
| 206 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 207 | - | if let Some(cat) = conn |
|
| 208 | - | .query_row( |
|
| 209 | - | "SELECT id, name, created_at FROM categories WHERE name = ?1", |
|
| 210 | - | params![name], |
|
| 211 | - | category_from_row, |
|
| 212 | - | ) |
|
| 213 | - | .optional()? |
|
| 214 | - | { |
|
| 215 | - | return Ok(cat); |
|
| 216 | - | } |
|
| 217 | - | } |
|
| 218 | - | insert_category(db, name) |
|
| 219 | - | } |
|
| 220 | - | ||
| 221 | - | pub fn delete_category(db: &Db, id: i64) -> Result<bool, DbError> { |
|
| 222 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 223 | - | let rows = conn.execute("DELETE FROM categories WHERE id = ?1", params![id])?; |
|
| 224 | - | Ok(rows > 0) |
|
| 225 | - | } |
|
| 226 | - | ||
| 227 | - | // ── Subscriptions ───────────────────────────────────────────────────── |
|
| 228 | - | ||
| 229 | - | pub fn list_subscriptions(db: &Db) -> Result<Vec<Subscription>, DbError> { |
|
| 230 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 231 | - | let mut stmt = conn.prepare(&format!( |
|
| 232 | - | "SELECT {SUB_COLS} FROM subscriptions ORDER BY title COLLATE NOCASE ASC" |
|
| 233 | - | ))?; |
|
| 234 | - | let rows = stmt |
|
| 235 | - | .query_map([], subscription_from_row)? |
|
| 236 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 237 | - | Ok(rows) |
|
| 238 | - | } |
|
| 239 | - | ||
| 240 | - | pub fn get_subscription(db: &Db, id: i64) -> Result<Option<Subscription>, DbError> { |
|
| 241 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 242 | - | let sub = conn |
|
| 243 | - | .query_row( |
|
| 244 | - | &format!("SELECT {SUB_COLS} FROM subscriptions WHERE id = ?1"), |
|
| 245 | - | params![id], |
|
| 246 | - | subscription_from_row, |
|
| 247 | - | ) |
|
| 248 | - | .optional()?; |
|
| 249 | - | Ok(sub) |
|
| 250 | - | } |
|
| 251 | - | ||
| 252 | - | pub fn get_subscription_by_url(db: &Db, feed_url: &str) -> Result<Option<Subscription>, DbError> { |
|
| 253 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 254 | - | let sub = conn |
|
| 255 | - | .query_row( |
|
| 256 | - | &format!("SELECT {SUB_COLS} FROM subscriptions WHERE feed_url = ?1"), |
|
| 257 | - | params![feed_url], |
|
| 258 | - | subscription_from_row, |
|
| 259 | - | ) |
|
| 260 | - | .optional()?; |
|
| 261 | - | Ok(sub) |
|
| 262 | - | } |
|
| 263 | - | ||
| 264 | - | pub fn insert_subscription( |
|
| 265 | - | db: &Db, |
|
| 266 | - | feed_url: &str, |
|
| 267 | - | title: &str, |
|
| 268 | - | site_url: Option<&str>, |
|
| 269 | - | category_id: Option<i64>, |
|
| 270 | - | ) -> Result<Subscription, DbError> { |
|
| 271 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 272 | - | conn.execute( |
|
| 273 | - | "INSERT INTO subscriptions (feed_url, title, site_url, category_id) VALUES (?1, ?2, ?3, ?4)", |
|
| 274 | - | params![feed_url, title, site_url, category_id], |
|
| 275 | - | )?; |
|
| 276 | - | let id = conn.last_insert_rowid(); |
|
| 277 | - | let sub = conn.query_row( |
|
| 278 | - | &format!("SELECT {SUB_COLS} FROM subscriptions WHERE id = ?1"), |
|
| 279 | - | params![id], |
|
| 280 | - | subscription_from_row, |
|
| 281 | - | )?; |
|
| 282 | - | Ok(sub) |
|
| 283 | - | } |
|
| 284 | - | ||
| 285 | - | pub fn update_subscription_meta( |
|
| 286 | - | db: &Db, |
|
| 287 | - | id: i64, |
|
| 288 | - | etag: Option<&str>, |
|
| 289 | - | last_modified: Option<&str>, |
|
| 290 | - | last_fetched_at: &str, |
|
| 291 | - | last_error: Option<&str>, |
|
| 292 | - | ) -> Result<(), DbError> { |
|
| 293 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 294 | - | conn.execute( |
|
| 295 | - | "UPDATE subscriptions SET etag = ?1, last_modified = ?2, last_fetched_at = ?3, last_error = ?4 WHERE id = ?5", |
|
| 296 | - | params![etag, last_modified, last_fetched_at, last_error, id], |
|
| 297 | - | )?; |
|
| 298 | - | Ok(()) |
|
| 299 | - | } |
|
| 300 | - | ||
| 301 | - | pub fn update_subscription_title(db: &Db, id: i64, title: &str) -> Result<(), DbError> { |
|
| 302 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 303 | - | conn.execute( |
|
| 304 | - | "UPDATE subscriptions SET title = ?1 WHERE id = ?2", |
|
| 305 | - | params![title, id], |
|
| 306 | - | )?; |
|
| 307 | - | Ok(()) |
|
| 308 | - | } |
|
| 309 | - | ||
| 310 | - | pub fn update_subscription_category( |
|
| 311 | - | db: &Db, |
|
| 312 | - | id: i64, |
|
| 313 | - | category_id: Option<i64>, |
|
| 314 | - | ) -> Result<(), DbError> { |
|
| 315 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 316 | - | conn.execute( |
|
| 317 | - | "UPDATE subscriptions SET category_id = ?1 WHERE id = ?2", |
|
| 318 | - | params![category_id, id], |
|
| 319 | - | )?; |
|
| 320 | - | Ok(()) |
|
| 321 | - | } |
|
| 322 | - | ||
| 323 | - | pub fn delete_subscription(db: &Db, id: i64) -> Result<bool, DbError> { |
|
| 324 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 325 | - | let rows = conn.execute("DELETE FROM subscriptions WHERE id = ?1", params![id])?; |
|
| 326 | - | Ok(rows > 0) |
|
| 327 | - | } |
|
| 328 | - | ||
| 329 | - | // ── Items ───────────────────────────────────────────────────────────── |
|
| 330 | - | ||
| 331 | - | /// Insert if new. Returns true if inserted, false if a duplicate (guid) existed. |
|
| 332 | - | pub fn insert_item_ignore_dup(db: &Db, item: &NewItem<'_>) -> Result<bool, DbError> { |
|
| 333 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 334 | - | let rows = conn.execute( |
|
| 335 | - | "INSERT OR IGNORE INTO items (subscription_id, guid, title, link, author, published_at) |
|
| 336 | - | VALUES (?1, ?2, ?3, ?4, ?5, ?6)", |
|
| 337 | - | params![ |
|
| 338 | - | item.subscription_id, |
|
| 339 | - | item.guid, |
|
| 340 | - | item.title, |
|
| 341 | - | item.link, |
|
| 342 | - | item.author, |
|
| 343 | - | item.published_at, |
|
| 344 | - | ], |
|
| 345 | - | )?; |
|
| 346 | - | Ok(rows > 0) |
|
| 347 | - | } |
|
| 348 | - | ||
| 349 | - | #[derive(Debug, Clone, Default)] |
|
| 350 | - | pub struct ListItemsFilter { |
|
| 351 | - | pub limit: Option<i64>, |
|
| 352 | - | pub unread_only: bool, |
|
| 353 | - | pub category_id: Option<i64>, |
|
| 354 | - | pub subscription_id: Option<i64>, |
|
| 355 | - | } |
|
| 356 | - | ||
| 357 | - | pub fn list_items(db: &Db, filter: &ListItemsFilter) -> Result<Vec<ItemWithFeed>, DbError> { |
|
| 358 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 359 | - | ||
| 360 | - | let mut sql = String::from( |
|
| 361 | - | "SELECT i.id, i.subscription_id, i.guid, i.title, i.link, i.author, i.published_at, |
|
| 362 | - | i.is_read, i.fetched_at, s.title, s.feed_url, s.category_id, c.name |
|
| 363 | - | FROM items i |
|
| 364 | - | JOIN subscriptions s ON s.id = i.subscription_id |
|
| 365 | - | LEFT JOIN categories c ON c.id = s.category_id |
|
| 366 | - | WHERE 1=1", |
|
| 367 | - | ); |
|
| 368 | - | let mut binds: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); |
|
| 369 | - | ||
| 370 | - | if filter.unread_only { |
|
| 371 | - | sql.push_str(" AND i.is_read = 0"); |
|
| 372 | - | } |
|
| 373 | - | if let Some(cid) = filter.category_id { |
|
| 374 | - | sql.push_str(&format!(" AND s.category_id = ?{}", binds.len() + 1)); |
|
| 375 | - | binds.push(Box::new(cid)); |
|
| 376 | - | } |
|
| 377 | - | if let Some(sid) = filter.subscription_id { |
|
| 378 | - | sql.push_str(&format!(" AND i.subscription_id = ?{}", binds.len() + 1)); |
|
| 379 | - | binds.push(Box::new(sid)); |
|
| 380 | - | } |
|
| 381 | - | ||
| 382 | - | sql.push_str(" ORDER BY i.published_at DESC, i.id DESC"); |
|
| 383 | - | ||
| 384 | - | let limit = filter.limit.unwrap_or(100).clamp(1, 1000); |
|
| 385 | - | sql.push_str(&format!(" LIMIT ?{}", binds.len() + 1)); |
|
| 386 | - | binds.push(Box::new(limit)); |
|
| 387 | - | ||
| 388 | - | let mut stmt = conn.prepare(&sql)?; |
|
| 389 | - | let params_slice: Vec<&dyn rusqlite::ToSql> = binds.iter().map(|b| b.as_ref()).collect(); |
|
| 390 | - | let rows = stmt |
|
| 391 | - | .query_map(params_slice.as_slice(), |row| { |
|
| 392 | - | Ok(ItemWithFeed { |
|
| 393 | - | id: row.get(0)?, |
|
| 394 | - | subscription_id: row.get(1)?, |
|
| 395 | - | guid: row.get(2)?, |
|
| 396 | - | title: row.get(3)?, |
|
| 397 | - | link: row.get(4)?, |
|
| 398 | - | author: row.get(5)?, |
|
| 399 | - | published_at: row.get(6)?, |
|
| 400 | - | is_read: row.get::<_, i64>(7)? != 0, |
|
| 401 | - | fetched_at: row.get(8)?, |
|
| 402 | - | feed_title: row.get(9)?, |
|
| 403 | - | feed_url: row.get(10)?, |
|
| 404 | - | category_id: row.get(11)?, |
|
| 405 | - | category_name: row.get(12)?, |
|
| 406 | - | }) |
|
| 407 | - | })? |
|
| 408 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 409 | - | Ok(rows) |
|
| 410 | - | } |
|
| 411 | - | ||
| 412 | - | pub fn mark_read(db: &Db, id: i64) -> Result<bool, DbError> { |
|
| 413 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 414 | - | let rows = conn.execute("UPDATE items SET is_read = 1 WHERE id = ?1", params![id])?; |
|
| 415 | - | Ok(rows > 0) |
|
| 416 | - | } |
|
| 417 | - | ||
| 418 | - | pub fn mark_unread(db: &Db, id: i64) -> Result<bool, DbError> { |
|
| 419 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 420 | - | let rows = conn.execute("UPDATE items SET is_read = 0 WHERE id = ?1", params![id])?; |
|
| 421 | - | Ok(rows > 0) |
|
| 422 | - | } |
|
| 423 | - | ||
| 424 | - | /// Keep newest `keep_n` items for a subscription, delete older. |
|
| 425 | - | pub fn prune_subscription(db: &Db, subscription_id: i64, keep_n: i64) -> Result<usize, DbError> { |
|
| 426 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 427 | - | let rows = conn.execute( |
|
| 428 | - | "DELETE FROM items |
|
| 429 | - | WHERE subscription_id = ?1 |
|
| 430 | - | AND id NOT IN ( |
|
| 431 | - | SELECT id FROM items |
|
| 432 | - | WHERE subscription_id = ?1 |
|
| 433 | - | ORDER BY published_at DESC, id DESC |
|
| 434 | - | LIMIT ?2 |
|
| 435 | - | )", |
|
| 436 | - | params![subscription_id, keep_n], |
|
| 437 | - | )?; |
|
| 438 | - | Ok(rows) |
|
| 439 | - | } |
|
| 440 | - | ||
| 441 | - | // ── Settings ────────────────────────────────────────────────────────── |
|
| 442 | - | ||
| 443 | - | pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>, DbError> { |
|
| 444 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 445 | - | let val = conn |
|
| 446 | - | .query_row( |
|
| 447 | - | "SELECT value FROM settings WHERE key = ?1", |
|
| 448 | - | params![key], |
|
| 449 | - | |row| row.get::<_, String>(0), |
|
| 450 | - | ) |
|
| 451 | - | .optional()?; |
|
| 452 | - | Ok(val) |
|
| 453 | - | } |
|
| 454 | - | ||
| 455 | - | pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<(), DbError> { |
|
| 456 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 457 | - | conn.execute( |
|
| 458 | - | "INSERT INTO settings (key, value) VALUES (?1, ?2) |
|
| 459 | - | ON CONFLICT(key) DO UPDATE SET value = excluded.value", |
|
| 460 | - | params![key, value], |
|
| 461 | - | )?; |
|
| 462 | - | Ok(()) |
|
| 463 | - | } |
|
| 464 | - | ||
| 465 | - | #[cfg(test)] |
|
| 466 | - | mod tests { |
|
| 467 | - | use super::*; |
|
| 468 | - | use rusqlite::Connection; |
|
| 469 | - | use std::sync::{Arc, Mutex}; |
|
| 470 | - | ||
| 471 | - | fn test_db() -> Db { |
|
| 472 | - | let conn = Connection::open_in_memory().unwrap(); |
|
| 473 | - | conn.execute_batch(FEEDS_SCHEMA).unwrap(); |
|
| 474 | - | Arc::new(Mutex::new(conn)) |
|
| 475 | - | } |
|
| 476 | - | ||
| 477 | - | #[test] |
|
| 478 | - | fn category_crud() { |
|
| 479 | - | let db = test_db(); |
|
| 480 | - | let cat = insert_category(&db, "Tech").unwrap(); |
|
| 481 | - | assert_eq!(cat.name, "Tech"); |
|
| 482 | - | ||
| 483 | - | let all = list_categories(&db).unwrap(); |
|
| 484 | - | assert_eq!(all.len(), 1); |
|
| 485 | - | ||
| 486 | - | let same = get_or_create_category(&db, "Tech").unwrap(); |
|
| 487 | - | assert_eq!(same.id, cat.id); |
|
| 488 | - | ||
| 489 | - | let other = get_or_create_category(&db, "News").unwrap(); |
|
| 490 | - | assert_ne!(other.id, cat.id); |
|
| 491 | - | ||
| 492 | - | assert!(delete_category(&db, cat.id).unwrap()); |
|
| 493 | - | assert_eq!(list_categories(&db).unwrap().len(), 1); |
|
| 494 | - | } |
|
| 495 | - | ||
| 496 | - | #[test] |
|
| 497 | - | fn subscription_crud() { |
|
| 498 | - | let db = test_db(); |
|
| 499 | - | let sub = insert_subscription( |
|
| 500 | - | &db, |
|
| 501 | - | "https://example.com/feed", |
|
| 502 | - | "Example", |
|
| 503 | - | Some("https://example.com"), |
|
| 504 | - | None, |
|
| 505 | - | ) |
|
| 506 | - | .unwrap(); |
|
| 507 | - | assert_eq!(sub.title, "Example"); |
|
| 508 | - | ||
| 509 | - | let fetched = get_subscription_by_url(&db, "https://example.com/feed") |
|
| 510 | - | .unwrap() |
|
| 511 | - | .unwrap(); |
|
| 512 | - | assert_eq!(fetched.id, sub.id); |
|
| 513 | - | ||
| 514 | - | update_subscription_meta( |
|
| 515 | - | &db, |
|
| 516 | - | sub.id, |
|
| 517 | - | Some("etag-1"), |
|
| 518 | - | Some("Sun, 01 Jan 2024 00:00:00 GMT"), |
|
| 519 | - | "2024-01-01 00:00:00", |
|
| 520 | - | None, |
|
| 521 | - | ) |
|
| 522 | - | .unwrap(); |
|
| 523 | - | let after = get_subscription(&db, sub.id).unwrap().unwrap(); |
|
| 524 | - | assert_eq!(after.etag.as_deref(), Some("etag-1")); |
|
| 525 | - | ||
| 526 | - | assert!(delete_subscription(&db, sub.id).unwrap()); |
|
| 527 | - | assert!(get_subscription(&db, sub.id).unwrap().is_none()); |
|
| 528 | - | } |
|
| 529 | - | ||
| 530 | - | #[test] |
|
| 531 | - | fn item_insert_dedup_and_list() { |
|
| 532 | - | let db = test_db(); |
|
| 533 | - | let sub = insert_subscription(&db, "https://a.com/feed", "A", None, None).unwrap(); |
|
| 534 | - | ||
| 535 | - | let inserted = insert_item_ignore_dup( |
|
| 536 | - | &db, |
|
| 537 | - | &NewItem { |
|
| 538 | - | subscription_id: sub.id, |
|
| 539 | - | guid: "g1", |
|
| 540 | - | title: "Post 1", |
|
| 541 | - | link: "https://a.com/1", |
|
| 542 | - | author: Some("Alice"), |
|
| 543 | - | published_at: 1_700_000_000, |
|
| 544 | - | }, |
|
| 545 | - | ) |
|
| 546 | - | .unwrap(); |
|
| 547 | - | assert!(inserted); |
|
| 548 | - | ||
| 549 | - | let dup = insert_item_ignore_dup( |
|
| 550 | - | &db, |
|
| 551 | - | &NewItem { |
|
| 552 | - | subscription_id: sub.id, |
|
| 553 | - | guid: "g1", |
|
| 554 | - | title: "Post 1 different title", |
|
| 555 | - | link: "https://a.com/1", |
|
| 556 | - | author: None, |
|
| 557 | - | published_at: 1_700_000_000, |
|
| 558 | - | }, |
|
| 559 | - | ) |
|
| 560 | - | .unwrap(); |
|
| 561 | - | assert!(!dup); |
|
| 562 | - | ||
| 563 | - | let items = list_items(&db, &ListItemsFilter::default()).unwrap(); |
|
| 564 | - | assert_eq!(items.len(), 1); |
|
| 565 | - | assert_eq!(items[0].title, "Post 1"); |
|
| 566 | - | assert_eq!(items[0].feed_title, "A"); |
|
| 567 | - | assert!(!items[0].is_read); |
|
| 568 | - | } |
|
| 569 | - | ||
| 570 | - | #[test] |
|
| 571 | - | fn mark_read_unread() { |
|
| 572 | - | let db = test_db(); |
|
| 573 | - | let sub = insert_subscription(&db, "https://a.com/feed", "A", None, None).unwrap(); |
|
| 574 | - | insert_item_ignore_dup( |
|
| 575 | - | &db, |
|
| 576 | - | &NewItem { |
|
| 577 | - | subscription_id: sub.id, |
|
| 578 | - | guid: "g", |
|
| 579 | - | title: "t", |
|
| 580 | - | link: "l", |
|
| 581 | - | author: None, |
|
| 582 | - | published_at: 1, |
|
| 583 | - | }, |
|
| 584 | - | ) |
|
| 585 | - | .unwrap(); |
|
| 586 | - | let items = list_items(&db, &ListItemsFilter::default()).unwrap(); |
|
| 587 | - | let id = items[0].id; |
|
| 588 | - | ||
| 589 | - | assert!(mark_read(&db, id).unwrap()); |
|
| 590 | - | let read = list_items( |
|
| 591 | - | &db, |
|
| 592 | - | &ListItemsFilter { |
|
| 593 | - | unread_only: true, |
|
| 594 | - | ..Default::default() |
|
| 595 | - | }, |
|
| 596 | - | ) |
|
| 597 | - | .unwrap(); |
|
| 598 | - | assert_eq!(read.len(), 0); |
|
| 599 | - | ||
| 600 | - | assert!(mark_unread(&db, id).unwrap()); |
|
| 601 | - | let unread = list_items( |
|
| 602 | - | &db, |
|
| 603 | - | &ListItemsFilter { |
|
| 604 | - | unread_only: true, |
|
| 605 | - | ..Default::default() |
|
| 606 | - | }, |
|
| 607 | - | ) |
|
| 608 | - | .unwrap(); |
|
| 609 | - | assert_eq!(unread.len(), 1); |
|
| 610 | - | } |
|
| 611 | - | ||
| 612 | - | #[test] |
|
| 613 | - | fn prune_keeps_newest() { |
|
| 614 | - | let db = test_db(); |
|
| 615 | - | let sub = insert_subscription(&db, "https://a.com/feed", "A", None, None).unwrap(); |
|
| 616 | - | for i in 0..10 { |
|
| 617 | - | insert_item_ignore_dup( |
|
| 618 | - | &db, |
|
| 619 | - | &NewItem { |
|
| 620 | - | subscription_id: sub.id, |
|
| 621 | - | guid: &format!("g{i}"), |
|
| 622 | - | title: "t", |
|
| 623 | - | link: "l", |
|
| 624 | - | author: None, |
|
| 625 | - | published_at: i as i64, |
|
| 626 | - | }, |
|
| 627 | - | ) |
|
| 628 | - | .unwrap(); |
|
| 629 | - | } |
|
| 630 | - | let removed = prune_subscription(&db, sub.id, 3).unwrap(); |
|
| 631 | - | assert_eq!(removed, 7); |
|
| 632 | - | ||
| 633 | - | let items = list_items(&db, &ListItemsFilter::default()).unwrap(); |
|
| 634 | - | assert_eq!(items.len(), 3); |
|
| 635 | - | assert_eq!(items[0].published_at, 9); |
|
| 636 | - | assert_eq!(items[2].published_at, 7); |
|
| 637 | - | } |
|
| 638 | - | ||
| 639 | - | #[test] |
|
| 640 | - | fn settings_upsert() { |
|
| 641 | - | let db = test_db(); |
|
| 642 | - | assert!(get_setting(&db, "poll").unwrap().is_none()); |
|
| 643 | - | set_setting(&db, "poll", "30").unwrap(); |
|
| 644 | - | assert_eq!(get_setting(&db, "poll").unwrap().as_deref(), Some("30")); |
|
| 645 | - | set_setting(&db, "poll", "60").unwrap(); |
|
| 646 | - | assert_eq!(get_setting(&db, "poll").unwrap().as_deref(), Some("60")); |
|
| 647 | - | } |
|
| 648 | - | ||
| 649 | - | #[test] |
|
| 650 | - | fn category_filter_on_items() { |
|
| 651 | - | let db = test_db(); |
|
| 652 | - | let tech = insert_category(&db, "Tech").unwrap(); |
|
| 653 | - | let sub_tech = |
|
| 654 | - | insert_subscription(&db, "https://a.com/feed", "A", None, Some(tech.id)).unwrap(); |
|
| 655 | - | let sub_other = insert_subscription(&db, "https://b.com/feed", "B", None, None).unwrap(); |
|
| 656 | - | ||
| 657 | - | insert_item_ignore_dup( |
|
| 658 | - | &db, |
|
| 659 | - | &NewItem { |
|
| 660 | - | subscription_id: sub_tech.id, |
|
| 661 | - | guid: "g1", |
|
| 662 | - | title: "tech post", |
|
| 663 | - | link: "", |
|
| 664 | - | author: None, |
|
| 665 | - | published_at: 1, |
|
| 666 | - | }, |
|
| 667 | - | ) |
|
| 668 | - | .unwrap(); |
|
| 669 | - | insert_item_ignore_dup( |
|
| 670 | - | &db, |
|
| 671 | - | &NewItem { |
|
| 672 | - | subscription_id: sub_other.id, |
|
| 673 | - | guid: "g2", |
|
| 674 | - | title: "other post", |
|
| 675 | - | link: "", |
|
| 676 | - | author: None, |
|
| 677 | - | published_at: 2, |
|
| 678 | - | }, |
|
| 679 | - | ) |
|
| 680 | - | .unwrap(); |
|
| 681 | - | ||
| 682 | - | let tech_items = list_items( |
|
| 683 | - | &db, |
|
| 684 | - | &ListItemsFilter { |
|
| 685 | - | category_id: Some(tech.id), |
|
| 686 | - | ..Default::default() |
|
| 687 | - | }, |
|
| 688 | - | ) |
|
| 689 | - | .unwrap(); |
|
| 690 | - | assert_eq!(tech_items.len(), 1); |
|
| 691 | - | assert_eq!(tech_items[0].title, "tech post"); |
|
| 692 | - | assert_eq!(tech_items[0].category_name.as_deref(), Some("Tech")); |
|
| 693 | - | } |
|
| 694 | - | } |
| 1 | - | use rusqlite::Connection; |
|
| 2 | - | use std::fmt; |
|
| 3 | - | use std::sync::{Arc, Mutex}; |
|
| 4 | - | ||
| 5 | - | pub type Db = Arc<Mutex<Connection>>; |
|
| 6 | - | ||
| 7 | - | pub trait HasDb { |
|
| 8 | - | fn db(&self) -> &Db; |
|
| 9 | - | } |
|
| 10 | - | ||
| 11 | - | #[derive(Debug)] |
|
| 12 | - | pub enum DbError { |
|
| 13 | - | Sqlite(rusqlite::Error), |
|
| 14 | - | LockPoisoned, |
|
| 15 | - | } |
|
| 16 | - | ||
| 17 | - | impl fmt::Display for DbError { |
|
| 18 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 19 | - | match self { |
|
| 20 | - | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 21 | - | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 22 | - | } |
|
| 23 | - | } |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | impl std::error::Error for DbError {} |
|
| 27 | - | ||
| 28 | - | impl From<rusqlite::Error> for DbError { |
|
| 29 | - | fn from(e: rusqlite::Error) -> Self { |
|
| 30 | - | DbError::Sqlite(e) |
|
| 31 | - | } |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | #[cfg(feature = "axum")] |
|
| 35 | - | impl axum::response::IntoResponse for DbError { |
|
| 36 | - | fn into_response(self) -> axum::response::Response { |
|
| 37 | - | tracing::error!("{}", self); |
|
| 38 | - | ( |
|
| 39 | - | axum::http::StatusCode::INTERNAL_SERVER_ERROR, |
|
| 40 | - | "Server error", |
|
| 41 | - | ) |
|
| 42 | - | .into_response() |
|
| 43 | - | } |
|
| 44 | - | } |
|
| 45 | - | ||
| 46 | - | #[cfg(feature = "session")] |
|
| 47 | - | pub mod session; |
|
| 48 | - | ||
| 49 | - | #[cfg(feature = "feeds")] |
|
| 50 | - | pub mod feeds; |
| 1 | - | use rusqlite::params; |
|
| 2 | - | ||
| 3 | - | use crate::{Db, DbError}; |
|
| 4 | - | ||
| 5 | - | pub const SESSION_SCHEMA: &str = " |
|
| 6 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 7 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 8 | - | token TEXT NOT NULL UNIQUE, |
|
| 9 | - | expires_at TEXT NOT NULL |
|
| 10 | - | ); |
|
| 11 | - | "; |
|
| 12 | - | ||
| 13 | - | pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
|
| 14 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 15 | - | conn.execute( |
|
| 16 | - | "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", |
|
| 17 | - | params![token, expires_at], |
|
| 18 | - | )?; |
|
| 19 | - | Ok(()) |
|
| 20 | - | } |
|
| 21 | - | ||
| 22 | - | pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
|
| 23 | - | use rusqlite::OptionalExtension; |
|
| 24 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 25 | - | let val = conn |
|
| 26 | - | .query_row( |
|
| 27 | - | "SELECT expires_at FROM sessions WHERE token = ?1", |
|
| 28 | - | params![token], |
|
| 29 | - | |row| row.get(0), |
|
| 30 | - | ) |
|
| 31 | - | .optional()?; |
|
| 32 | - | Ok(val) |
|
| 33 | - | } |
|
| 34 | - | ||
| 35 | - | pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
|
| 36 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 37 | - | conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
|
| 38 | - | Ok(()) |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
|
| 42 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 43 | - | conn.execute( |
|
| 44 | - | "DELETE FROM sessions WHERE expires_at < datetime('now')", |
|
| 45 | - | [], |
|
| 46 | - | )?; |
|
| 47 | - | Ok(()) |
|
| 48 | - | } |
|
| 49 | - | ||
| 50 | - | #[cfg(test)] |
|
| 51 | - | mod tests { |
|
| 52 | - | use super::*; |
|
| 53 | - | use rusqlite::Connection; |
|
| 54 | - | use std::sync::{Arc, Mutex}; |
|
| 55 | - | ||
| 56 | - | fn test_db() -> Db { |
|
| 57 | - | let conn = Connection::open_in_memory().unwrap(); |
|
| 58 | - | conn.execute_batch(SESSION_SCHEMA).unwrap(); |
|
| 59 | - | Arc::new(Mutex::new(conn)) |
|
| 60 | - | } |
|
| 61 | - | ||
| 62 | - | #[test] |
|
| 63 | - | fn session_lifecycle() { |
|
| 64 | - | let db = test_db(); |
|
| 65 | - | insert_session(&db, "tok", "2099-12-31 23:59:59").unwrap(); |
|
| 66 | - | assert_eq!( |
|
| 67 | - | get_session_expiry(&db, "tok").unwrap(), |
|
| 68 | - | Some("2099-12-31 23:59:59".to_string()) |
|
| 69 | - | ); |
|
| 70 | - | delete_session(&db, "tok").unwrap(); |
|
| 71 | - | assert!(get_session_expiry(&db, "tok").unwrap().is_none()); |
|
| 72 | - | } |
|
| 73 | - | ||
| 74 | - | #[test] |
|
| 75 | - | fn prune_expired_sessions_works() { |
|
| 76 | - | let db = test_db(); |
|
| 77 | - | insert_session(&db, "old", "2000-01-01 00:00:00").unwrap(); |
|
| 78 | - | insert_session(&db, "new", "2099-01-01 00:00:00").unwrap(); |
|
| 79 | - | prune_expired_sessions(&db).unwrap(); |
|
| 80 | - | assert!(get_session_expiry(&db, "old").unwrap().is_none()); |
|
| 81 | - | assert!(get_session_expiry(&db, "new").unwrap().is_some()); |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | #[test] |
|
| 85 | - | fn missing_token_returns_none() { |
|
| 86 | - | let db = test_db(); |
|
| 87 | - | assert!(get_session_expiry(&db, "nonexistent").unwrap().is_none()); |
|
| 88 | - | } |
|
| 89 | - | } |
| 1 | - | [workspace] |
|
| 2 | - | members = [ |
|
| 3 | - | "cargo:apps/sipp", |
|
| 4 | - | "cargo:apps/feeds", |
|
| 5 | - | "cargo:apps/parcels", |
|
| 6 | - | "cargo:apps/jotts", |
|
| 7 | - | "cargo:apps/og", |
|
| 8 | - | "cargo:apps/shrink", |
|
| 9 | - | "cargo:apps/cellar", |
|
| 10 | - | "cargo:apps/posts", |
|
| 11 | - | "cargo:apps/library", |
|
| 12 | - | "cargo:apps/bookmarks", |
|
| 13 | - | "cargo:apps/easel", |
|
| 14 | - | ] |
|
| 15 | - | ||
| 16 | - | # Config for 'dist' |
|
| 17 | - | [dist] |
|
| 18 | - | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) |
|
| 19 | - | cargo-dist-version = "0.31.0" |
|
| 20 | - | # CI backends to support |
|
| 21 | - | ci = "github" |
|
| 22 | - | # The installers to generate for each app |
|
| 23 | - | installers = ["shell", "homebrew"] |
|
| 24 | - | # A GitHub repo to push Homebrew formulas to |
|
| 25 | - | tap = "stevedylandev/homebrew-tap" |
|
| 26 | - | # Target platforms to build apps for (Rust target-triple syntax) |
|
| 27 | - | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] |
|
| 28 | - | # Path that installers should place binaries in |
|
| 29 | - | install-path = "CARGO_HOME" |
|
| 30 | - | # Publish jobs to run in CI |
|
| 31 | - | publish-jobs = ["homebrew"] |
|
| 32 | - | # Whether to install an updater program |
|
| 33 | - | install-updater = false |
|
| 34 | - | # Which actions to run on pull requests |
|
| 35 | - | pr-run-mode = "skip" |
| 11 | 11 | jotts: |
|
| 12 | 12 | image: ghcr.io/stevedylandev/andromeda/jotts:latest |
|
| 13 | 13 | restart: unless-stopped |
|
| 14 | + | command: ["jotts", "server"] |
|
| 14 | 15 | ports: |
|
| 15 | - | - "3030:3000" |
|
| 16 | + | - "3030:3030" |
|
| 16 | 17 | volumes: |
|
| 17 | 18 | - jotts_data:/data |
|
| 18 | 19 | env_file: apps/jotts/.env |
|
| 90 | 91 | image: ghcr.io/stevedylandev/andromeda/easel:latest |
|
| 91 | 92 | restart: unless-stopped |
|
| 92 | 93 | ports: |
|
| 93 | - | - "4242:3000" |
|
| 94 | + | - "4242:4242" |
|
| 94 | 95 | volumes: |
|
| 95 | 96 | - easel_data:/data |
|
| 96 | 97 | env_file: apps/easel/.env |
|
| 105 | 106 | - feeds_data:/data/feeds:ro |
|
| 106 | 107 | - library_data:/data/library:ro |
|
| 107 | 108 | - bookmarks_data:/data/bookmarks:ro |
|
| 108 | - | - parcels_data:/data/parcels:ro |
|
| 109 | 109 | - easel_data:/data/easel:ro |
|
| 110 | 110 | env_file: apps/backup/.env |
|
| 111 | 111 | restart: unless-stopped |
|
| 135 | 135 | bookmarks_data: |
|
| 136 | 136 | external: true |
|
| 137 | 137 | name: bookmarks_bookmarks-data |
|
| 138 | - | parcels_data: |
|
| 139 | - | external: true |
|
| 140 | - | name: parcels_parcels_data |
|
| 141 | 138 | easel_data: |
|
| 142 | 139 | external: true |
|
| 143 | 140 | name: easel_easel-data |
|
| 11 | 11 | Andromeda |
|
| 12 | 12 | </h1> |
|
| 13 | 13 | <h3 className="text-center text-lg font-normal font-['Commit_Mono',monospace] text-white"> |
|
| 14 | - | Minimal, self-hosted, personal software in Rust |
|
| 14 | + | Minimal, self-hosted, personal software in Go |
|
| 15 | 15 | </h3> |
|
| 16 | 16 | </div> |
|
| 17 | 17 | <div className="flex items-center gap-4"> |
| 4 | 4 | ||
| 5 | 5 | Bookmarks is a single-user link saver. Add links via the admin panel or JSON API, organize them into categories, and view them on a public index page grouped by category. |
|
| 6 | 6 | ||
| 7 | - | - Single Rust binary with embedded assets |
|
| 7 | + | - Single Go binary with embedded assets |
|
| 8 | 8 | - Local SQLite storage |
|
| 9 | 9 | - Password-protected admin panel for managing categories and links |
|
| 10 | 10 | - JSON read API (open) and write API (key-guarded) |
|
| 45 | 45 | ### Binary |
|
| 46 | 46 | ||
| 47 | 47 | ```bash |
|
| 48 | - | cargo build --release -p bookmarks |
|
| 48 | + | cd apps/bookmarks && go build . |
|
| 49 | 49 | ``` |
|
| 50 | 50 | ||
| 51 | 51 | The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 2 | 2 | ||
| 3 | 3 |  |
|
| 4 | 4 | ||
| 5 | - | A simple, self-hosted wine collection app built with Rust. |
|
| 5 | + | A simple, self-hosted wine collection app built in Go. |
|
| 6 | 6 | ||
| 7 | 7 | - Single binary with embedded assets |
|
| 8 | 8 | - Password authentication with session cookies |
|
| 64 | 64 | You can also build from source: |
|
| 65 | 65 | ||
| 66 | 66 | ```bash |
|
| 67 | - | cargo build --release -p cellar |
|
| 67 | + | cd apps/cellar && go build . |
|
| 68 | 68 | ``` |
|
| 69 | 69 | ||
| 70 | 70 | The resulting binary at `./target/release/cellar` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 2 | 2 | ||
| 3 | 3 |  |
|
| 4 | 4 | ||
| 5 | - | A self-hosted daily painting viewer built with Rust. One public-domain artwork from the [Art Institute of Chicago](https://api.artic.edu/docs/) per calendar day, persisted to SQLite. |
|
| 5 | + | A self-hosted daily painting viewer built in Go. One public-domain artwork from the [Art Institute of Chicago](https://api.artic.edu/docs/) per calendar day, persisted to SQLite. |
|
| 6 | 6 | ||
| 7 | - | - Single Rust binary with embedded assets |
|
| 7 | + | - Single Go binary with embedded assets |
|
| 8 | 8 | - One artwork per day, picked from AIC's public-domain collection |
|
| 9 | 9 | - Past days browsable via archive; future days unavailable until populated |
|
| 10 | 10 | - Atom feed at `/feed.xml` |
|
| 58 | 58 | You can also build from source: |
|
| 59 | 59 | ||
| 60 | 60 | ```bash |
|
| 61 | - | cargo build --release -p easel |
|
| 61 | + | cd apps/easel && go build . |
|
| 62 | 62 | ``` |
|
| 63 | 63 | ||
| 64 | 64 | The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 4 | 4 | ||
| 5 | 5 | Feeds is a minimal RSS reader that mimics the original experience of RSS. It's just a list of posts. No categories, no marking a post read or unread, and there is no in-app reading. With this approach you have to read the post on the author's personal website and experience it in its original context. |
|
| 6 | 6 | ||
| 7 | - | - Single Rust binary with embedded assets |
|
| 7 | + | - Single Go binary with embedded assets |
|
| 8 | 8 | - Local SQLite storage with a background poller (ETag / `If-Modified-Since` aware) |
|
| 9 | 9 | - Password-protected admin panel for managing subscriptions and categories |
|
| 10 | 10 | - OPML import and JSON/OPML export |
|
| 61 | 61 | You can also build from source: |
|
| 62 | 62 | ||
| 63 | 63 | ```bash |
|
| 64 | - | cargo build --release -p feeds |
|
| 64 | + | cd apps/feeds && go build . |
|
| 65 | 65 | ``` |
|
| 66 | 66 | ||
| 67 | 67 | The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 2 | 2 | ||
| 3 | 3 |  |
|
| 4 | 4 | ||
| 5 | - | A simple, self-hosted markdown note app built with Rust. |
|
| 5 | + | A simple, self-hosted markdown note app built in Go. |
|
| 6 | 6 | ||
| 7 | - | - Single ~7MB Rust binary with embedded assets |
|
| 7 | + | - Single Go binary with embedded assets |
|
| 8 | 8 | - Password authentication with session cookies |
|
| 9 | 9 | - Create, edit, and delete markdown notes |
|
| 10 | 10 | - Markdown rendering with strikethrough, tables, and task lists |
|
| 63 | 63 | ### Binary |
|
| 64 | 64 | ||
| 65 | 65 | ```bash |
|
| 66 | - | cargo build --release -p jotts |
|
| 66 | + | cd apps/jotts && go build . |
|
| 67 | 67 | ``` |
|
| 68 | 68 | ||
| 69 | 69 | The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 2 | 2 | ||
| 3 | 3 |  |
|
| 4 | 4 | ||
| 5 | - | A simple, self-hosted book tracker built with Rust. |
|
| 5 | + | A simple, self-hosted book tracker built in Go. |
|
| 6 | 6 | ||
| 7 | 7 | - Single binary with embedded assets |
|
| 8 | 8 | - Password authentication with session cookies |
|
| 58 | 58 | Build from source: |
|
| 59 | 59 | ||
| 60 | 60 | ```bash |
|
| 61 | - | cargo build --release -p library |
|
| 61 | + | cd apps/library && go build . |
|
| 62 | 62 | ``` |
|
| 63 | 63 | ||
| 64 | 64 | The resulting binary at `./target/release/library` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 2 | 2 | ||
| 3 | 3 |  |
|
| 4 | 4 | ||
| 5 | - | A self-hosted Open Graph tag inspector built with Rust. Enter any URL and instantly see its OG metadata. |
|
| 5 | + | A self-hosted Open Graph tag inspector built in Go. Enter any URL and instantly see its OG metadata. |
|
| 6 | 6 | ||
| 7 | - | - Single Rust binary with embedded assets |
|
| 7 | + | - Single Go binary with embedded assets |
|
| 8 | 8 | - Inspects title, description, image, and other OG tags |
|
| 9 | 9 | - Dark themed UI with Commit Mono font |
|
| 10 | 10 | - No database needed -- fully stateless |
|
| 45 | 45 | You can also build from source: |
|
| 46 | 46 | ||
| 47 | 47 | ```bash |
|
| 48 | - | cargo build --release -p og |
|
| 48 | + | cd apps/og && go build . |
|
| 49 | 49 | ``` |
|
| 50 | 50 | ||
| 51 | 51 | The resulting binary is self-contained with all assets embedded. Copy it to your server and run it directly. |
|
| 1 | - | # Parcels |
|
| 2 | - | ||
| 3 | - |  |
|
| 4 | - | ||
| 5 | - | ||
| 6 | - | :::warning |
|
| 7 | - | This app originally used USPS, but starting April 1st 2026, it has become much harder to obtain/maintain API keys. |
|
| 8 | - | ::: |
|
| 9 | - | ||
| 10 | - | A self-hosted package tracker for USPS. Track your packages without logging into USPS every time. |
|
| 11 | - | ||
| 12 | - | - Single ~7MB Rust binary |
|
| 13 | - | - Averages around ~10MB of RAM usage |
|
| 14 | - | - Password authentication |
|
| 15 | - | - Track USPS packages with custom labels |
|
| 16 | - | - Delete packages you no longer want to track |
|
| 17 | - | ||
| 18 | - | ## Configure |
|
| 19 | - | ||
| 20 | - | You'll need a [USPS Web Tools API](https://developer.usps.com) account to get your `USPS_CLIENT_ID` and `USPS_CLIENT_SECRET`. |
|
| 21 | - | ||
| 22 | - | ### Environment Variables |
|
| 23 | - | ||
| 24 | - | | Variable | Description | Default | |
|
| 25 | - | |---|---|---| |
|
| 26 | - | | `APP_PASSWORD` | Password for login authentication | *required* | |
|
| 27 | - | | `USPS_CLIENT_ID` | USPS OAuth2 client ID | *required* | |
|
| 28 | - | | `USPS_CLIENT_SECRET` | USPS OAuth2 client secret | *required* | |
|
| 29 | - | | `HOST` | Server bind address | `0.0.0.0` | |
|
| 30 | - | | `PORT` | Server port | `3000` | |
|
| 31 | - | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 32 | - | ||
| 33 | - | ## Deploy |
|
| 34 | - | ||
| 35 | - | ### Railway |
|
| 36 | - | ||
| 37 | - | The easiest way to deploy Parcels is with the one-click Railway template. See the [Deploying with Railway](/deploy-railway) guide for a walkthrough of the process. Parcels requires `APP_PASSWORD`, `USPS_CLIENT_ID`, and `USPS_CLIENT_SECRET` to be set during the configure step. |
|
| 38 | - | ||
| 39 | - | [](https://railway.com/deploy/HNQUs4?referralCode=JGcIp6) |
|
| 40 | - | ||
| 41 | - | ### Docker |
|
| 42 | - | ||
| 43 | - | ```bash |
|
| 44 | - | cd apps/parcels |
|
| 45 | - | cp .env.example .env |
|
| 46 | - | # Edit .env with your credentials |
|
| 47 | - | docker compose up -d |
|
| 48 | - | ``` |
|
| 49 | - | ||
| 50 | - | This will start Parcels on port `3000` with a persistent volume for the SQLite database. |
|
| 51 | - | ||
| 52 | - | ### Binary |
|
| 53 | - | ||
| 54 | - | Install with Homebrew: |
|
| 55 | - | ||
| 56 | - | ```bash |
|
| 57 | - | brew install stevedylandev/tap/parcels |
|
| 58 | - | ``` |
|
| 59 | - | ||
| 60 | - | Or grab a prebuilt binary from the [releases page](https://github.com/stevedylandev/andromeda/releases?q=parcels&expanded=true). |
|
| 61 | - | ||
| 62 | - | You can also build from source: |
|
| 63 | - | ||
| 64 | - | ```bash |
|
| 65 | - | cargo build --release -p parcels |
|
| 66 | - | ``` |
|
| 67 | - | ||
| 68 | - | The resulting binary is self-contained (~7MB). Copy it to your server with a configured `.env` file and run it directly. |
|
| 69 | - | ||
| 70 | - | ## Use |
|
| 71 | - | ||
| 72 | - | Log in with your configured password, then add packages by entering a USPS tracking number and an optional label. Your packages and their tracking events are displayed on the main page. Delete packages you no longer need to track. |
| 2 | 2 | ||
| 3 | 3 |  |
|
| 4 | 4 | ||
| 5 | - | A self-hosted blog CMS built with Rust. |
|
| 5 | + | A self-hosted blog CMS built in Go. |
|
| 6 | 6 | ||
| 7 | - | - Single Rust binary with embedded assets |
|
| 7 | + | - Single Go binary with embedded assets |
|
| 8 | 8 | - Password authentication with session cookies |
|
| 9 | 9 | - Create, edit, publish, and delete blog posts with markdown |
|
| 10 | 10 | - Static pages with custom navigation links |
|
| 60 | 60 | You can also build from source: |
|
| 61 | 61 | ||
| 62 | 62 | ```bash |
|
| 63 | - | cargo build --release -p posts |
|
| 63 | + | cd apps/posts && go build . |
|
| 64 | 64 | ``` |
|
| 65 | 65 | ||
| 66 | 66 | The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 4 | 4 | ||
| 5 | 5 | A simple self-hosted tool for compressing and resizing images. Upload an image, set your desired quality and optional width, and download the compressed JPEG. |
|
| 6 | 6 | ||
| 7 | - | - Single Rust binary |
|
| 7 | + | - Single Go binary |
|
| 8 | 8 | - Compress images to JPEG with configurable quality (1-100) |
|
| 9 | 9 | - Optional resize by width (preserves aspect ratio) |
|
| 10 | 10 | - 20MB upload limit |
|
| 48 | 48 | You can also build from source: |
|
| 49 | 49 | ||
| 50 | 50 | ```bash |
|
| 51 | - | cargo build --release -p shrink |
|
| 51 | + | cd apps/shrink && go build . |
|
| 52 | 52 | ``` |
|
| 53 | 53 | ||
| 54 | 54 | The resulting binary is self-contained. Copy it to your server and run it directly. |
|
| 26 | 26 | ### Cargo |
|
| 27 | 27 | ||
| 28 | 28 | ```bash |
|
| 29 | - | cargo install sipp-so |
|
| 29 | + | # Build sipp CLI from source: cd apps/sipp && go build . |
|
| 30 | 30 | ``` |
|
| 31 | 31 | ||
| 32 | 32 | ### Releases |
|
| 61 | 61 | ### Binary |
|
| 62 | 62 | ||
| 63 | 63 | ```bash |
|
| 64 | - | cargo build --release -p sipp |
|
| 64 | + | cd apps/sipp && go build . |
|
| 65 | 65 | ``` |
|
| 66 | 66 | ||
| 67 | 67 | The resulting binary is self-contained with all assets embedded. Copy it to your server with your environment variables configured and run it directly. |
|
| 1 | 1 | # Skills |
|
| 2 | 2 | ||
| 3 | - | Andromeda ships with two [Claude Code](https://claude.ai/code) skills that make it easy to build new apps in the same style. Both live in the repo at [`skills/`](https://github.com/stevedylandev/andromeda/tree/main/skills). |
|
| 4 | - | ||
| 5 | - | ## andromeda-stack |
|
| 6 | - | ||
| 7 | - | [`andromeda-stack`](https://github.com/stevedylandev/andromeda/blob/main/skills/andromeda-stack/SKILL.md) scaffolds a complete Rust CRUD web app matching the Andromeda stack: |
|
| 8 | - | ||
| 9 | - | - Axum web server with routing |
|
| 10 | - | - SQLite database with rusqlite |
|
| 11 | - | - Askama HTML templates |
|
| 12 | - | - `andromeda-auth` for session or API key auth |
|
| 13 | - | - Embedded static assets via rust-embed |
|
| 14 | - | - Dockerfile and docker-compose.yml for deployment |
|
| 15 | - | ||
| 16 | - | New apps land under `apps/` in the workspace and share dependencies via the root `Cargo.toml`. |
|
| 3 | + | Andromeda ships with a [Claude Code](https://claude.ai/code) skill for matching the shared visual style. It lives in the repo at [`skills/`](https://github.com/stevedylandev/andromeda/tree/main/skills). |
|
| 17 | 4 | ||
| 18 | 5 | ## darkmatter-styles |
|
| 19 | 6 | ||
| 20 | 7 | [`darkmatter-styles`](https://github.com/stevedylandev/andromeda/tree/main/skills/darkmatter-styles/SKILL.md) applies the shared Andromeda aesthetic: dark background, white borders, Commit Mono, minimal layout, no frameworks. Use it when building new pages or components that need to match the rest of the lineup. |
|
| 21 | 8 | ||
| 22 | - | The canonical CSS and fonts live in the [`andromeda-darkmatter-css`](https://github.com/stevedylandev/andromeda/tree/main/crates/darkmatter-css) crate. Mount its router and link `/assets/darkmatter.css` from your templates — the skill guides style decisions while the crate serves the actual bytes. |
|
| 9 | + | The canonical CSS and fonts live in the [`crates-go/darkmatter`](https://github.com/stevedylandev/andromeda/tree/main/crates-go/darkmatter) module. Mount its handler set and link `/assets/darkmatter.css` from your templates — the skill guides style decisions while the module serves the actual bytes. |
|
| 23 | 10 | ||
| 24 | 11 | ## Installing |
|
| 25 | 12 | ||
| 26 | - | Use [`npx skills add`](https://github.com/vercel-labs/skills) to pull either skill directly from the repo: |
|
| 13 | + | Use [`npx skills add`](https://github.com/vercel-labs/skills) to pull the skill directly from the repo: |
|
| 27 | 14 | ||
| 28 | 15 | ```bash |
|
| 29 | 16 | npx skills add stevedylandev/andromeda/skills |
|
| 30 | 17 | ``` |
|
| 31 | 18 | ||
| 32 | - | Once installed, invoke them in Claude Code by describing what you want to build — the skills trigger automatically based on their descriptions. |
|
| 19 | + | Once installed, invoke it in Claude Code by describing what you want to build — the skill triggers automatically based on its description. |
| 1 | 1 | # Stack |
|
| 2 | 2 | ||
| 3 | - | Every app in Andromeda is built on the same Rust stack. This page covers the core dependencies and how they fit together. |
|
| 3 | + | Every app in Andromeda is built on the same Go stack. This page covers the core dependencies and how they fit together. |
|
| 4 | 4 | ||
| 5 | 5 | ## Core Dependencies |
|
| 6 | 6 | ||
| 7 | - | ### Axum |
|
| 7 | + | ### net/http |
|
| 8 | 8 | ||
| 9 | - | [Axum](https://github.com/tokio-rs/axum) is the web framework powering every app. It provides routing, request extraction, middleware, and response handling. All apps follow a similar pattern: |
|
| 9 | + | The stdlib `net/http` package powers every app — no web framework. Routing is done with `http.ServeMux` (1.22+ method+pattern routes): |
|
| 10 | 10 | ||
| 11 | - | ```rust |
|
| 12 | - | let app = Router::new() |
|
| 13 | - | .route("/", get(index)) |
|
| 14 | - | .route("/api/items", post(create_item)) |
|
| 15 | - | .with_state(app_state); |
|
| 11 | + | ```go |
|
| 12 | + | mux := http.NewServeMux() |
|
| 13 | + | mux.HandleFunc("GET /", index) |
|
| 14 | + | mux.HandleFunc("POST /api/items", createItem) |
|
| 16 | 15 | ``` |
|
| 17 | 16 | ||
| 18 | - | ### SQLite (rusqlite) |
|
| 17 | + | ### SQLite (modernc.org/sqlite) |
|
| 19 | 18 | ||
| 20 | - | [rusqlite](https://github.com/rusqlite/rusqlite) provides the storage layer. Each app creates its own SQLite database file, keeping data local and portable. No external database server needed. |
|
| 19 | + | [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) provides the storage layer — a pure-Go SQLite driver, no cgo required. Each app creates its own SQLite database file, keeping data local and portable. No external database server needed. |
|
| 21 | 20 | ||
| 22 | - | ### Askama |
|
| 21 | + | ### html/template |
|
| 23 | 22 | ||
| 24 | - | [Askama](https://github.com/djc/askama) handles HTML templating with compile-time checked templates. Templates live in a `templates/` directory and are type-safe Rust structs: |
|
| 23 | + | The stdlib `html/template` package handles HTML templating with context-aware escaping. Templates live in a `templates/` directory and are parsed at startup: |
|
| 25 | 24 | ||
| 26 | - | ```rust |
|
| 27 | - | #[derive(Template)] |
|
| 28 | - | #[template(path = "index.html")] |
|
| 29 | - | struct IndexTemplate { |
|
| 30 | - | items: Vec<Item>, |
|
| 31 | - | } |
|
| 25 | + | ```go |
|
| 26 | + | tmpl := template.Must(template.ParseFS(templatesFS, "templates/*.html")) |
|
| 27 | + | tmpl.ExecuteTemplate(w, "index.html", IndexData{Items: items}) |
|
| 32 | 28 | ``` |
|
| 33 | 29 | ||
| 34 | - | ### rust-embed |
|
| 30 | + | ### embed.FS |
|
| 35 | 31 | ||
| 36 | - | [rust-embed](https://github.com/pyrossh/rust-embed) embeds static assets (CSS, fonts, images) directly into the binary at compile time. This is what makes each app a single, self-contained binary with no external file dependencies. |
|
| 32 | + | The stdlib `embed` package embeds static assets (CSS, fonts, images) and templates directly into the binary at compile time. This is what makes each app a single, self-contained binary with no external file dependencies. |
|
| 37 | 33 | ||
| 38 | - | ### Tokio |
|
| 34 | + | ```go |
|
| 35 | + | //go:embed static templates |
|
| 36 | + | var assets embed.FS |
|
| 37 | + | ``` |
|
| 39 | 38 | ||
| 40 | - | [Tokio](https://tokio.rs) provides the async runtime that Axum runs on. |
|
| 39 | + | ## Shared Packages |
|
| 41 | 40 | ||
| 42 | - | ## Shared Crates |
|
| 41 | + | Each is its own Go module under `crates-go/`, referenced via `replace` directives in each app's `go.mod`. |
|
| 43 | 42 | ||
| 44 | - | ### andromeda-auth |
|
| 43 | + | ### crates-go/auth |
|
| 45 | 44 | ||
| 46 | - | The `andromeda-auth` crate provides session-based password authentication used across apps that require login. It handles: |
|
| 45 | + | Provides session-based password authentication used across apps that require login. It handles: |
|
| 47 | 46 | ||
| 48 | - | - Constant-time password verification |
|
| 49 | - | - Session cookie management |
|
| 50 | - | - Secure cookie configuration via `COOKIE_SECURE` env var |
|
| 47 | + | - Constant-time password verification (bcrypt + plain) |
|
| 48 | + | - Session cookie management with an in-memory store |
|
| 49 | + | - Short-id and session-token generators |
|
| 50 | + | ||
| 51 | + | ### crates-go/web |
|
| 52 | + | ||
| 53 | + | HTTP helpers used across apps: |
|
| 51 | 54 | ||
| 52 | - | ### andromeda-db |
|
| 55 | + | - `Render` — execute template + write response |
|
| 56 | + | - `WriteJSON` / `DecodeJSON` — JSON I/O helpers |
|
| 57 | + | - `EmbeddedHandler` — serve assets out of an `embed.FS` |
|
| 58 | + | - redirect helpers |
|
| 53 | 59 | ||
| 54 | - | The `andromeda-db` crate provides shared database types and utilities used across apps. It handles: |
|
| 60 | + | ### crates-go/config |
|
| 55 | 61 | ||
| 56 | - | - `Db` type alias (`Arc<Mutex<Connection>>`) for thread-safe SQLite access |
|
| 57 | - | - `HasDb` trait for consistent database access across app states |
|
| 58 | - | - `DbError` type with automatic conversion from rusqlite errors |
|
| 59 | - | - Optional Axum integration (`axum` feature) for `IntoResponse` on errors |
|
| 60 | - | - Optional session management (`session` feature) |
|
| 62 | + | Loads env vars from process environment and `.env` files. |
|
| 61 | 63 | ||
| 62 | - | ### andromeda-darkmatter-css |
|
| 64 | + | ### crates-go/darkmatter |
|
| 63 | 65 | ||
| 64 | - | The `andromeda-darkmatter-css` crate ships the canonical Darkmatter stylesheet and Commit Mono fonts as an embedded Axum router. Mount it once and every app inherits the shared aesthetic: |
|
| 66 | + | Ships the canonical Darkmatter stylesheet and Commit Mono fonts as an embedded handler set. Call `Mount()` once on your mux and every app inherits the shared aesthetic: |
|
| 65 | 67 | ||
| 66 | 68 | - `/assets/darkmatter.css` — reset, tokens, and shared component styles |
|
| 67 | 69 | - `/assets/fonts/*` — Commit Mono 400/700 |
|
| 68 | 70 | - `/darkmatter` — live component gallery |
|
| 69 | 71 | ||
| 70 | - | ```rust |
|
| 71 | - | use andromeda_darkmatter_css; |
|
| 72 | + | ```go |
|
| 73 | + | import "github.com/stevedylandev/andromeda/crates-go/darkmatter" |
|
| 72 | 74 | ||
| 73 | - | let app = Router::new() |
|
| 74 | - | .route("/", get(index)) |
|
| 75 | - | .merge(andromeda_darkmatter_css::router()); |
|
| 75 | + | mux := http.NewServeMux() |
|
| 76 | + | darkmatter.Mount(mux) |
|
| 76 | 77 | ``` |
|
| 77 | 78 | ||
| 78 | 79 | Then reference `/assets/darkmatter.css` from your templates instead of duplicating the styles per app. |
|
| 83 | 84 | ||
| 84 | 85 | ``` |
|
| 85 | 86 | app/ |
|
| 86 | - | ├── src/ |
|
| 87 | - | │ ├── main.rs # Entry point, env vars, starts server |
|
| 88 | - | │ ├── server.rs # Axum routes and handlers |
|
| 89 | - | │ ├── db.rs # SQLite database layer |
|
| 90 | - | │ └── auth.rs # Authentication (if needed) |
|
| 91 | - | ├── templates/ # Askama HTML templates |
|
| 87 | + | ├── main.go # Entry point, env vars, starts server |
|
| 88 | + | ├── app.go # App state + dependency wiring |
|
| 89 | + | ├── routes.go # Route registration |
|
| 90 | + | ├── handlers_*.go # HTTP handlers |
|
| 91 | + | ├── db.go # SQLite database layer |
|
| 92 | + | ├── templates/ # html/template files |
|
| 92 | 93 | ├── static/ # Fonts, favicons, styles |
|
| 94 | + | ├── go.mod |
|
| 93 | 95 | ├── Dockerfile |
|
| 94 | 96 | └── docker-compose.yml |
|
| 95 | 97 | ``` |
|
| 4 | 4 | ||
| 5 | 5 | Andromeda is a collection of apps that focus on minimalism, portability, and efficiency. It all started with my own desire to have a place where I could write a quick note and access it on any browser. Sure there are plenty out there, but what if I want to own my data? What if I want it to look a certain way? This was the beginning of [Jotts](/apps/jotts), and shortly thereafter slew of other small apps that accent my day to day life. |
|
| 6 | 6 | ||
| 7 | - | Each one of these apps is written in Rust, and if you're not a programmer, it simply means that these run on very little memory and CPU power. It's not another massive app that will slow down your computer. More importantly, these are web apps designed to be hosted on a server. Instead of depending on a provider, their terms of service, or another subscription, these apps just need a place to run; a computer in the cloud. Even if you're not that technical, I've designed these to be simple to setup through a hosting service called [Railway](https://railway.com). All you have to do is click a button, then enter a few details like a password. Check out the [quickstart](/quickstart) if you haven't already to give it a try. |
|
| 7 | + | Each one of these apps is written in Go, and if you're not a programmer, it simply means that these run on very little memory and CPU power. It's not another massive app that will slow down your computer. More importantly, these are web apps designed to be hosted on a server. Instead of depending on a provider, their terms of service, or another subscription, these apps just need a place to run; a computer in the cloud. Even if you're not that technical, I've designed these to be simple to setup through a hosting service called [Railway](https://railway.com). All you have to do is click a button, then enter a few details like a password. Check out the [quickstart](/quickstart) if you haven't already to give it a try. |
|
| 8 | 8 | ||
| 9 | 9 | The goal of Andromeda is to provide free, minimal, and efficient open source apps, that empower people to run their own software. If you're a developer, check out the [stack for Andromeda](/diy/stack) and start building similar apps. Take control of your apps, and make them serve you; not the other way around. |
| 69 | 69 | link: '/apps/og', |
|
| 70 | 70 | }, |
|
| 71 | 71 | { |
|
| 72 | - | text: 'Parcels', |
|
| 73 | - | link: '/apps/parcels', |
|
| 74 | - | }, |
|
| 75 | - | { |
|
| 76 | 72 | text: 'Posts', |
|
| 77 | 73 | link: '/apps/posts', |
|
| 78 | 74 | }, |