Merge pull request #49 from stevedylandev/chore/complete-go-migration e354df64
Steve Simkins · 2026-05-20 19:23 611 file(s) · +1348 −36139
.github/workflows/ci.yml +3 −53
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 ./...)
.github/workflows/docker-test.yml +3 −3
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
.github/workflows/docker.yml +4 −13
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
.github/workflows/release.yml (deleted) +0 −342
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
Cargo.lock (deleted) +0 −6242
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 -
]
Cargo.toml (deleted) +0 −59
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" }
Makefile +6 −6
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:
PERF_COMPARISON.md (deleted) +0 −142
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.
README.md +18 −66
3 3
![cover](https://files.stevedylan.dev/andromeda-cover.png)
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 | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/Axcf_D?referralCode=JGcIp6) |
13 +
| [**Sipp**](apps/sipp) | Minimal code sharing with web UI | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/Axcf_D?referralCode=JGcIp6) |
15 14
| [**Feeds**](apps/feeds) | Minimal RSS reader with OPML import/export and a JSON API | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/Ezvmhx?referralCode=JGcIp6) |
16 -
| [**Parcels**](apps/parcels) | Minimal package tracking (USPS) | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/HNQUs4?referralCode=JGcIp6) |
17 15
| [**Jotts**](apps/jotts) | Minimal markdown notes app | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DLhUhH?referralCode=JGcIp6) |
18 16
| [**OG**](apps/og) | Open Graph tag inspector | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/OdXBt_?referralCode=JGcIp6) |
19 17
| [**Shrink**](apps/shrink) | Image compression and resizing | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/enYUFb?referralCode=JGcIp6) |
23 21
| [**Library**](apps/library) | Minimal book tracker with Google Books search | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/tepdeI?referralCode=JGcIp6) |
24 22
| [**Easel**](apps/easel) | Daily public-domain painting from the Art Institute of Chicago | [![Deploy on Railway](https://railway.com/button.svg)](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
apps/bookmarks-go/.env.example (deleted) +0 −14
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
apps/bookmarks-go/Dockerfile (deleted) +0 −18
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"]
apps/bookmarks-go/README.md (deleted) +0 −27
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`.
apps/bookmarks-go/app.go → apps/bookmarks/app.go +0 −0
apps/bookmarks-go/db.go → apps/bookmarks/db.go +0 −0
apps/bookmarks-go/db_test.go → apps/bookmarks/db_test.go +0 −0
apps/bookmarks-go/docker-compose.yml (deleted) +0 −20
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:
apps/bookmarks-go/favicon.go → apps/bookmarks/favicon.go +1 −1
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)
apps/bookmarks-go/go.mod → apps/bookmarks/go.mod +1 −1
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
apps/bookmarks-go/go.sum → apps/bookmarks/go.sum +0 −0
apps/bookmarks-go/handlers_api.go → apps/bookmarks/handlers_api.go +0 −0
apps/bookmarks-go/handlers_web.go → apps/bookmarks/handlers_web.go +0 −0
apps/bookmarks-go/main.go → apps/bookmarks/main.go +1 −1
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
	}
apps/bookmarks-go/routes.go → apps/bookmarks/routes.go +0 −0
apps/bookmarks-go/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/bookmarks-go/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/bookmarks-go/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/bookmarks-go/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/bookmarks-go/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/bookmarks-go/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/bookmarks-go/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/bookmarks-go/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/bookmarks-go/static/site.webmanifest (deleted) +0 −1
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"}
apps/bookmarks-go/static/styles.css (deleted) +0 −221
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 -
}
apps/bookmarks-go/templates/admin.html → apps/bookmarks/templates/admin.html +0 −0
apps/bookmarks-go/templates/index.html → apps/bookmarks/templates/index.html +0 −0
apps/bookmarks-go/templates/login.html → apps/bookmarks/templates/login.html +0 −0
apps/bookmarks/.env.example +2 −2
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)
apps/bookmarks/Cargo.toml (deleted) +0 −31
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"
apps/bookmarks/Dockerfile +10 −14
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"]
apps/bookmarks/README.md +15 −106
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`.
apps/bookmarks/askama.toml (deleted) +0 −2
1 -
[general]
2 -
dirs = ["src/templates"]
apps/bookmarks/docker-compose.yml +7 −7
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:
apps/bookmarks/src/auth.rs (deleted) +0 −57
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 -
}
apps/bookmarks/src/db.rs (deleted) +0 −239
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 -
}
apps/bookmarks/src/favicon.rs (deleted) +0 −39
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 -
}
apps/bookmarks/src/main.rs (deleted) +0 −515
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 -
}
apps/bookmarks/src/templates/admin.html (deleted) +0 −134
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>
apps/bookmarks/src/templates/index.html (deleted) +0 −59
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>
apps/bookmarks/src/templates/login.html (deleted) +0 −34
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>
apps/cellar-go/.env.example (deleted) +0 −9
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
apps/cellar-go/Dockerfile (deleted) +0 −18
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"]
apps/cellar-go/README.md (deleted) +0 −13
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.
apps/cellar-go/app.go → apps/cellar/app.go +0 −0
apps/cellar-go/claude.go → apps/cellar/claude.go +0 −0
apps/cellar-go/db.go → apps/cellar/db.go +0 −0
apps/cellar-go/db_test.go → apps/cellar/db_test.go +0 −0
apps/cellar-go/docker-compose.yml (deleted) +0 −23
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:
apps/cellar-go/forms.go → apps/cellar/forms.go +0 −0
apps/cellar-go/go.mod → apps/library/go.mod +1 −1
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
apps/cellar-go/go.sum → apps/cellar/go.sum +0 −0
apps/cellar-go/handlers_admin.go → apps/cellar/handlers_admin.go +0 −0
apps/cellar-go/handlers_api.go → apps/cellar/handlers_api.go +0 −0
apps/cellar-go/handlers_public.go → apps/cellar/handlers_public.go +0 −0
apps/cellar-go/image.go → apps/cellar/image.go +0 −0
apps/cellar-go/main.go → apps/cellar/main.go +1 −1
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
	}
apps/cellar-go/render.go → apps/cellar/render.go +0 −0
apps/cellar-go/routes.go → apps/cellar/routes.go +0 −0
apps/cellar-go/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/cellar-go/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/cellar-go/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/cellar-go/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/cellar-go/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/cellar-go/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/cellar-go/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/cellar-go/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/cellar-go/static/site.webmanifest (deleted) +0 −1
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"}
apps/cellar-go/static/styles.css (deleted) +0 −250
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 -
}
apps/cellar-go/svg.go → apps/cellar/svg.go +0 −0
apps/cellar-go/templates/admin.html (deleted) +0 −27
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}} &middot; {{.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}}
apps/cellar-go/templates/base.html (deleted) +0 −29
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}}
apps/cellar-go/templates/index.html (deleted) +0 −26
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}} &middot; {{.Wine.Grape}}{{end}}</span>
22 -
      </div>
23 -
    </a>
24 -
    {{end}}
25 -
  </div>
26 -
{{end}}
apps/cellar-go/templates/login.html (deleted) +0 −24
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}}
apps/cellar-go/templates/wine.html (deleted) +0 −31
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}}
apps/cellar-go/templates/wine_form.html (deleted) +0 −118
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}}
apps/cellar-go/templates/wishlist.html (deleted) +0 −33
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}} &middot; {{.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}}
apps/cellar-go/templates/wishlist_form.html (deleted) +0 −58
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}}
apps/cellar/Cargo.toml (deleted) +0 −33
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"] }
apps/cellar/Dockerfile +10 −14
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"]
apps/cellar/README.md +9 −86
1 -
# Cellar
2 -
3 -
![cover](https://files.stevedylan.dev/cellar-demo.png)
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 -
[![Deploy on Railway](https://railway.com/button.svg)](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.
apps/cellar/docker-compose.yml +10 −7
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:
apps/cellar/src/auth.rs (deleted) +0 −67
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 -
}
apps/cellar/src/claude.rs (deleted) +0 −120
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 -
}
apps/cellar/src/db.rs (deleted) +0 −491
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 -
}
apps/cellar/src/main.rs (deleted) +0 −15
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 -
}
apps/cellar/src/server/handlers/admin.rs (deleted) +0 −425
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 -
}
apps/cellar/src/server/handlers/mod.rs (deleted) +0 −2
1 -
pub mod admin;
2 -
pub mod public;
apps/cellar/src/server/handlers/public.rs (deleted) +0 −292
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('&', "&amp;")
200 -
        .replace('<', "&lt;")
201 -
        .replace('>', "&gt;")
202 -
        .replace('"', "&quot;")
203 -
        .replace('\'', "&apos;")
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 -
}
apps/cellar/src/server/mod.rs (deleted) +0 −598
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 -
}
apps/cellar/static/fonts/CommitMono-400-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/cellar/static/fonts/CommitMono-700-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/cellar/templates/admin.html +17 −19
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() %} &middot; {{ wine.grape }}{% endif %}</span>
15 +
        <a href="/wines/{{.ShortID}}" class="admin-item-name">{{.Name}}</a>
16 +
        <span class="admin-item-meta">{{.Origin}}{{if .Grape}} &middot; {{.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}}
apps/cellar/templates/base.html +5 −5
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}}
apps/cellar/templates/index.html +18 −18
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() %} &middot; {{ item.wine.grape }}{% endif %}</span>
20 +
        <span class="wine-name">{{.Wine.Name}}</span>
21 +
        <span class="wine-meta">{{.Wine.Origin}}{{if .Wine.Grape}} &middot; {{.Wine.Grape}}{{end}}</span>
22 22
      </div>
23 23
    </a>
24 -
    {% endfor %}
24 +
    {{end}}
25 25
  </div>
26 -
{% endblock %}
26 +
{{end}}
apps/cellar/templates/login.html +4 −6
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}}
apps/cellar/templates/wine.html +21 −41
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}}
apps/cellar/templates/wine_form.html +36 −55
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}}
apps/cellar/templates/wishlist.html +21 −22
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() %} &middot; {{ wine.grape }}{% endif %}</span>
16 +
        <a href="/wines/{{.ShortID}}" class="admin-item-name">{{.Name}}</a>
17 +
        <span class="admin-item-meta">{{.Origin}}{{if .Grape}} &middot; {{.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}}
apps/cellar/templates/wishlist_form.html +18 −28
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}}
apps/easel-go/.env.example (deleted) +0 −9
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
apps/easel-go/Dockerfile (deleted) +0 −18
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"]
apps/easel-go/README.md (deleted) +0 −17
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`).
apps/easel-go/aic.go → apps/easel/aic.go +2 −2
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)
apps/easel-go/app.go → apps/easel/app.go +0 −0
apps/easel-go/db.go → apps/easel/db.go +0 −0
apps/easel-go/db_test.go → apps/easel/db_test.go +0 −0
apps/easel-go/docker-compose.yml (deleted) +0 −23
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:
apps/easel-go/feed.go → apps/easel/feed.go +0 −0
apps/easel-go/go.mod → apps/easel/go.mod +1 −1
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
apps/easel-go/go.sum → apps/easel/go.sum +0 −0
apps/easel-go/handlers.go → apps/easel/handlers.go +0 −0
apps/easel-go/handlers_api.go → apps/easel/handlers_api.go +0 −0
apps/easel-go/main.go → apps/easel/main.go +2 −2
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
	}
apps/easel-go/render.go → apps/easel/render.go +0 −0
apps/easel-go/routes.go → apps/easel/routes.go +0 −0
apps/easel-go/scheduler.go → apps/easel/scheduler.go +0 −0
apps/easel-go/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/easel-go/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/easel-go/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/easel-go/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/easel-go/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/easel-go/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/easel-go/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/easel-go/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/easel-go/static/site.webmanifest (deleted) +0 −1
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"}
apps/easel-go/static/styles.css (deleted) +0 −78
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 -
}
apps/easel-go/templates/archive.html (deleted) +0 −20
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}}
apps/easel-go/templates/base.html (deleted) +0 −60
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}}
apps/easel-go/templates/day.html (deleted) +0 −5
1 -
{{define "day.html"}}{{template "base.html" .}}{{end}}
2 -
{{define "title"}}Easel — {{.Date}}{{end}}
3 -
{{define "content"}}
4 -
  {{template "artwork" .Artwork}}
5 -
{{end}}
apps/easel-go/templates/error.html (deleted) +0 −9
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}}
apps/easel-go/templates/index.html (deleted) +0 −11
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}}
apps/easel/.env.example +0 −16
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
apps/easel/Cargo.toml (deleted) +0 −28
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"] }
apps/easel/Dockerfile +11 −17
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"]
apps/easel/README.md +8 −28
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`).
apps/easel/docker-compose.yml +9 −7
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:
apps/easel/src/aic.rs (deleted) +0 −223
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(&params)
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(&params)
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 -
}
apps/easel/src/db.rs (deleted) +0 −249
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 -
}
apps/easel/src/main.rs (deleted) +0 −16
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 -
}
apps/easel/src/scheduler.rs (deleted) +0 −140
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 -
}
apps/easel/src/server.rs (deleted) +0 −528
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('&', "&amp;")
317 -
        .replace('<', "&lt;")
318 -
        .replace('>', "&gt;")
319 -
        .replace('"', "&quot;")
320 -
        .replace('\'', "&apos;")
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 -
}
apps/easel/templates/_artwork.html (deleted) +0 −36
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>
apps/easel/templates/archive.html +13 −13
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}}
apps/easel/templates/base.html +29 −4
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}}
apps/easel/templates/day.html +5 −5
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}}
apps/easel/templates/error.html +6 −6
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}}
apps/easel/templates/index.html +9 −9
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}}
apps/feeds-go/.env.example (deleted) +0 −9
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
apps/feeds-go/.gitignore → apps/feeds/.gitignore +0 −0
apps/feeds-go/Dockerfile (deleted) +0 −20
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"]
apps/feeds-go/README.md (deleted) +0 −32
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
apps/feeds-go/app.go → apps/feeds/app.go +0 −0
apps/feeds-go/db.go → apps/feeds/db.go +0 −0
apps/feeds-go/db_categories.go → apps/feeds/db_categories.go +0 −0
apps/feeds-go/db_items.go → apps/feeds/db_items.go +0 −0
apps/feeds-go/db_subscriptions.go → apps/feeds/db_subscriptions.go +0 −0
apps/feeds-go/docker-compose.yml (deleted) +0 −23
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:
apps/feeds-go/feeds.go → apps/feeds/feeds.go +1 −1
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}
apps/feeds-go/feeds_test.go → apps/feeds/feeds_test.go +0 −0
apps/feeds-go/go.mod → apps/feeds/go.mod +1 −1
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
apps/feeds-go/go.sum → apps/feeds/go.sum +0 −0
apps/feeds-go/handlers_admin.go → apps/feeds/handlers_admin.go +0 −0
apps/feeds-go/handlers_api.go → apps/feeds/handlers_api.go +0 −0
apps/feeds-go/handlers_public.go → apps/feeds/handlers_public.go +0 −0
apps/feeds-go/main.go → apps/feeds/main.go +1 −1
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
	}
apps/feeds-go/middleware.go → apps/feeds/middleware.go +0 −0
apps/feeds-go/opml.go → apps/feeds/opml.go +0 −0
apps/feeds-go/routes.go → apps/feeds/routes.go +0 −0
apps/feeds-go/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/feeds-go/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/feeds-go/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/feeds-go/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/feeds-go/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/feeds-go/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/feeds-go/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/feeds-go/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/feeds-go/static/site.webmanifest (deleted) +0 −1
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"}
apps/feeds-go/static/styles.css (deleted) +0 −226
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 -
}
apps/feeds-go/subscriptions.go → apps/feeds/subscriptions.go +0 −0
apps/feeds-go/templates/admin.html → apps/feeds/templates/admin.html +0 −0
apps/feeds-go/templates/index.html → apps/feeds/templates/index.html +0 −0
apps/feeds-go/templates/login.html → apps/feeds/templates/login.html +0 −0
apps/feeds-go/util.go → apps/feeds/util.go +0 −0
apps/feeds/.env.example +1 −1
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
apps/feeds/Cargo.toml (deleted) +0 −34
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"] }
apps/feeds/Dockerfile +12 −15
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"]
apps/feeds/LICENSE (deleted) +0 −22
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 -
apps/feeds/README.md +22 −151
1 -
# Feeds
1 +
# Feeds Go
2 2
3 -
![cover](https://feeds.stevedylan.dev/assets/og.png)
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 -
[![Deploy on Railway](https://railway.com/button.svg)](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
apps/feeds/askama.toml (deleted) +0 −2
1 -
[general]
2 -
dirs = ["src/templates"]
apps/feeds/docker-compose.yml +8 −6
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:
apps/feeds/src/api.rs (deleted) +0 −693
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 -
}
apps/feeds/src/auth.rs (deleted) +0 −101
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 -
}
apps/feeds/src/feeds.rs (deleted) +0 −506
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&rsquo;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 -
}
apps/feeds/src/main.rs (deleted) +0 −776
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('&', "&amp;")
277 -
        .replace('<', "&lt;")
278 -
        .replace('>', "&gt;")
279 -
        .replace('"', "&quot;")
280 -
        .replace('\'', "&apos;")
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 -
            "&lt;a href=&quot;x&quot;&gt;&amp;&apos;test&apos;&lt;/a&gt;"
769 -
        );
770 -
    }
771 -
772 -
    #[test]
773 -
    fn format_date_valid_timestamp() {
774 -
        assert_eq!(format_date(1705276800), "Jan 15, 2024");
775 -
    }
776 -
}
apps/feeds/src/models.rs (deleted) +0 −10
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 -
}
apps/feeds/src/poller.rs (deleted) +0 −107
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 -
}
apps/feeds/src/templates/admin.html (deleted) +0 −192
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>
apps/feeds/src/templates/index.html (deleted) +0 −73
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>
apps/feeds/src/templates/login.html (deleted) +0 −29
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>
apps/feeds/static/styles.css +5 −0
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;
apps/jotts-go/.env.example (deleted) +0 −8
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=
apps/jotts-go/Dockerfile (deleted) +0 −17
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"]
apps/jotts-go/README.md (deleted) +0 −75
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 -
```
apps/jotts-go/app.go → apps/jotts/app.go +1 −1
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
apps/jotts-go/cmd_auth.go → apps/jotts/cmd_auth.go +1 −1
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
apps/jotts-go/cmd_server.go → apps/jotts/cmd_server.go +1 −1
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
	}
apps/jotts-go/cmd_upload.go → apps/jotts/cmd_upload.go +1 −1
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) {
apps/jotts-go/db.go → apps/jotts/db.go +1 −1
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) }
apps/jotts-go/db_test.go → apps/jotts/db_test.go +0 −0
apps/jotts-go/docker-compose.yml (deleted) +0 −20
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:
apps/jotts-go/go.mod → apps/jotts/go.mod +1 −1
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
apps/jotts-go/go.sum → apps/jotts/go.sum +0 −0
apps/jotts-go/handlers_api.go → apps/jotts/handlers_api.go +0 −0
apps/jotts-go/handlers_web.go → apps/jotts/handlers_web.go +0 −0
apps/jotts-go/internal/store/store.go → apps/jotts/internal/store/store.go +0 −0
apps/jotts-go/main.go (deleted) +0 −51
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 -
}
apps/jotts-go/markdown.go → apps/jotts/markdown.go +0 −0
apps/jotts-go/routes.go → apps/jotts/routes.go +0 −0
apps/jotts-go/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/static/site.webmanifest (deleted) +0 −1
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"}
apps/jotts-go/static/styles.css (deleted) +0 −144
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 -
}
apps/jotts-go/templates/edit.html (deleted) +0 −32
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>
apps/jotts-go/templates/index.html (deleted) +0 −44
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>
apps/jotts-go/templates/login.html (deleted) +0 −32
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>
apps/jotts-go/templates/new.html (deleted) +0 −32
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>
apps/jotts-go/templates/view.html (deleted) +0 −54
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>
apps/jotts-go/tui/backend.go → apps/jotts/tui/backend.go +1 −1
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
apps/jotts-go/tui/backend_test.go → apps/jotts/tui/backend_test.go +0 −0
apps/jotts-go/tui/browser.go → apps/jotts/tui/browser.go +0 −0
apps/jotts-go/tui/commands.go → apps/jotts/tui/commands.go +0 −0
apps/jotts-go/tui/config.go → apps/jotts/tui/config.go +0 −0
apps/jotts-go/tui/content_model.go → apps/jotts/tui/content_model.go +0 −0
apps/jotts-go/tui/editor.go → apps/jotts/tui/editor.go +0 −0
apps/jotts-go/tui/form_model.go → apps/jotts/tui/form_model.go +0 −0
apps/jotts-go/tui/keys.go → apps/jotts/tui/keys.go +0 −0
apps/jotts-go/tui/list_model.go → apps/jotts/tui/list_model.go +0 −0
apps/jotts-go/tui/messages.go → apps/jotts/tui/messages.go +0 −0
apps/jotts-go/tui/model.go → apps/jotts/tui/model.go +0 −0
apps/jotts-go/tui/render_md.go → apps/jotts/tui/render_md.go +0 −0
apps/jotts-go/tui/tui.go → apps/jotts/tui/tui.go +0 −0
apps/jotts-go/tui/update.go → apps/jotts/tui/update.go +0 −0
apps/jotts-go/tui/view.go → apps/jotts/tui/view.go +0 −0
apps/jotts/Cargo.toml (deleted) +0 −41
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"
apps/jotts/Dockerfile +11 −14
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"]
apps/jotts/LICENSE (deleted) +0 −22
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 -
apps/jotts/README.md +44 −73
1 -
# Jotts
1 +
# jotts-go
2 2
3 -
![cover](https://files.stevedylan.dev/jotts-demo.png)
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 -
[![Deploy on Railway](https://railway.com/button.svg)](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)
apps/jotts/docker-compose.yml +7 −4
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:
apps/jotts/main.go (added) +51 −0
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 +
}
apps/jotts/src/ansi.tmTheme (deleted) +0 −430
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>
apps/jotts/src/auth.rs (deleted) +0 −39
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 -
}
apps/jotts/src/backend.rs (deleted) +0 −181
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 -
}
apps/jotts/src/config.rs (deleted) +0 −31
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 -
}
apps/jotts/src/db.rs (deleted) +0 −232
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, &note.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, &note.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, &note.short_id).unwrap());
200 -
        assert!(get_note_by_short_id(&db, &note.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 -
}
apps/jotts/src/highlight.rs (deleted) +0 −60
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 -
}
apps/jotts/src/lib.rs (deleted) +0 −7
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;
apps/jotts/src/main.rs (deleted) +0 −74
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 -
}
apps/jotts/src/server.rs (deleted) +0 −439
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(&note.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 -
}
apps/jotts/src/tui.rs (deleted) +0 −144
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 -
}
apps/jotts/src/tui/app.rs (deleted) +0 −413
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(&note.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 -
}
apps/jotts/src/tui/editor.rs (deleted) +0 −69
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 -
}
apps/jotts/src/tui/events.rs (deleted) +0 −160
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 -
}
apps/jotts/src/tui/render.rs (deleted) +0 −399
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 -
}
apps/jotts/static/fonts/CommitMono-400-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/jotts/static/fonts/CommitMono-700-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/jotts/templates/base.html (deleted) +0 −38
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>
apps/jotts/templates/edit.html +32 −14
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>
apps/jotts/templates/index.html +44 −15
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>
apps/jotts/templates/login.html +30 −32
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>
apps/jotts/templates/new.html +32 −14
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>
apps/jotts/templates/view.html +53 −28
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>
apps/library-go/.env.example (deleted) +0 −8
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
apps/library-go/Dockerfile (deleted) +0 −18
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"]
apps/library-go/README.md (deleted) +0 −24
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` |
apps/library-go/app.go → apps/library/app.go +0 −0
apps/library-go/db.go → apps/library/db.go +0 −0
apps/library-go/db_test.go → apps/library/db_test.go +0 −0
apps/library-go/docker-compose.yml (deleted) +0 −22
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:
apps/library-go/go.mod → apps/cellar/go.mod +1 −1
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
apps/library-go/go.sum → apps/library/go.sum +0 −0
apps/library-go/google_books.go → apps/library/google_books.go +1 −1
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)
apps/library-go/handlers_api.go → apps/library/handlers_api.go +0 −0
apps/library-go/handlers_web.go → apps/library/handlers_web.go +0 −0
apps/library-go/main.go → apps/library/main.go +1 −1
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
	}
apps/library-go/routes.go → apps/library/routes.go +0 −0
apps/library-go/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/library-go/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/library-go/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/library-go/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/library-go/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/library-go/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/library-go/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/library-go/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/library-go/static/site.webmanifest (deleted) +0 −1
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"}
apps/library-go/static/styles.css (deleted) +0 −243
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 -
}
apps/library-go/templates/admin.html → apps/library/templates/admin.html +0 −0
apps/library-go/templates/index.html → apps/library/templates/index.html +0 −0
apps/library-go/templates/login.html → apps/library/templates/login.html +0 −0
apps/library/Cargo.toml (deleted) +0 −29
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"
apps/library/Dockerfile +10 −14
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"]
apps/library/README.md +14 −66
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` |
apps/library/askama.toml (deleted) +0 −2
1 -
[general]
2 -
dirs = ["src/templates"]
apps/library/docker-compose.yml +9 −8
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:
apps/library/src/auth.rs (deleted) +0 −60
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 -
apps/library/src/db.rs (deleted) +0 −252
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 -
apps/library/src/google_books.rs (deleted) +0 −125
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 -
}
apps/library/src/main.rs (deleted) +0 −584
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 -
apps/library/src/templates/admin.html (deleted) +0 −367
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 ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"})[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>
apps/library/src/templates/index.html (deleted) +0 −75
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>
apps/library/src/templates/login.html (deleted) +0 −29
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>
apps/og-go/.env.example (deleted) +0 −1
1 -
PORT=3000
apps/og-go/Dockerfile (deleted) +0 −17
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"]
apps/og-go/README.md (deleted) +0 −34
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.
apps/og-go/app.go → apps/og/app.go +0 −0
apps/og-go/docker-compose.yml (deleted) +0 −11
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
apps/og-go/go.mod → apps/og/go.mod +1 −1
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
apps/og-go/go.sum → apps/og/go.sum +0 −0
apps/og-go/handlers.go → apps/og/handlers.go +0 −0
apps/og-go/main.go → apps/og/main.go +1 −1
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
	}
apps/og-go/og.go → apps/og/og.go +0 −0
apps/og-go/og_test.go → apps/og/og_test.go +0 −0
apps/og-go/render.go → apps/og/render.go +0 −0
apps/og-go/routes.go → apps/og/routes.go +0 −0
apps/og-go/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/og-go/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/og-go/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/og-go/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/og-go/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/og-go/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/og-go/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/og-go/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/og-go/static/site.webmanifest (deleted) +0 −1
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"}
apps/og-go/static/styles.css (deleted) +0 −181
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 -
}
apps/og-go/templates/base.html (deleted) +0 −26
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}}
apps/og-go/templates/index.html (deleted) +0 −17
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}}
apps/og-go/templates/results.html (deleted) +0 −86
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}}
apps/og/Cargo.toml (deleted) +0 −24
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"
apps/og/Dockerfile +11 −15
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"]
apps/og/LICENSE (deleted) +0 −22
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 -
apps/og/README.md +17 −55
1 -
# OG
1 +
# og-go
2 2
3 -
![cover](https://files.stevedylan.dev/og-demo-1.png)
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 -
[![Deploy on Railway](https://railway.com/button.svg)](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.
apps/og/docker-compose.yml +6 −9
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
apps/og/src/main.rs (deleted) +0 −9
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 -
}
apps/og/src/og.rs (deleted) +0 −239
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 -
}
apps/og/src/server.rs (deleted) +0 −151
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 -
}
apps/og/static/fonts/CommitMono-400-Italic.otf (deleted) +0 −0

Binary file — no preview.

apps/og/static/fonts/CommitMono-400-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/og/static/fonts/CommitMono-700-Italic.otf (deleted) +0 −0

Binary file — no preview.

apps/og/static/fonts/CommitMono-700-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/og/templates/base.html +4 −4
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}}
apps/og/templates/index.html +4 −6
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}}
apps/og/templates/results.html +40 −42
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}}
apps/parcels/.env.example (deleted) +0 −5
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
apps/parcels/Cargo.toml (deleted) +0 −31
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"
apps/parcels/Dockerfile (deleted) +0 −22
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"]
apps/parcels/LICENSE (deleted) +0 −22
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 -
apps/parcels/README.md (deleted) +0 −91
1 -
# Parcels
2 -
3 -
![cover](https://files.stevedylan.dev/parcels-demo.png)
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 -
[![Deploy on Railway](https://railway.com/button.svg)](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)
apps/parcels/docker-compose.yml (deleted) +0 −15
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:
apps/parcels/src/auth.rs (deleted) +0 −53
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 -
}
apps/parcels/src/db.rs (deleted) +0 −382
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 -
}
apps/parcels/src/main.rs (deleted) +0 −383
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 -
}
apps/parcels/src/usps.rs (deleted) +0 −225
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(&params)
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 -
}
apps/parcels/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/parcels/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/parcels/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/parcels/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/parcels/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/parcels/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/parcels/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/parcels/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/parcels/static/site.webmanifest (deleted) +0 −1
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"}
apps/parcels/static/styles.css (deleted) +0 −126
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 -
}
apps/parcels/templates/add.html (deleted) +0 −27
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 %}
apps/parcels/templates/base.html (deleted) +0 −22
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>
apps/parcels/templates/detail.html (deleted) +0 −68
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 %}
apps/parcels/templates/index.html (deleted) +0 −37
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 %}
apps/parcels/templates/login.html (deleted) +0 −17
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 %}
apps/posts-go/.env.example (deleted) +0 −14
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=
apps/posts-go/Dockerfile (deleted) +0 −19
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"]
apps/posts-go/README.md (deleted) +0 −15
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`.
apps/posts-go/app.go → apps/posts/app.go +1 −1
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
apps/posts-go/db.go → apps/posts/db.go +0 −0
apps/posts-go/db_test.go → apps/posts/db_test.go +0 −0
apps/posts-go/docker-compose.yml (deleted) +0 −21
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:
apps/posts-go/go.mod → apps/posts/go.mod +1 −1
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
apps/posts-go/go.sum → apps/posts/go.sum +0 −0
apps/posts-go/handlers_admin.go → apps/posts/handlers_admin.go +0 −0
apps/posts-go/handlers_api.go → apps/posts/handlers_api.go +0 −0
apps/posts-go/handlers_public.go → apps/posts/handlers_public.go +0 −0
apps/posts-go/main.go → apps/posts/main.go +2 −2
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
	}
apps/posts-go/markdown.go → apps/posts/markdown.go +0 −0
apps/posts-go/render.go → apps/posts/render.go +0 −0
apps/posts-go/routes.go → apps/posts/routes.go +0 −0
apps/posts-go/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/posts-go/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/posts-go/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/posts-go/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/posts-go/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/posts-go/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/posts-go/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/posts-go/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/posts-go/static/site.webmanifest (deleted) +0 −1
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"}
apps/posts-go/static/styles.css (deleted) +0 −271
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 -
}
apps/posts-go/storage.go → apps/posts/storage.go +0 −0
apps/posts-go/storage/interface.go → apps/posts/storage/interface.go +0 −0
apps/posts-go/storage/local.go → apps/posts/storage/local.go +0 −0
apps/posts-go/storage/r2.go → apps/posts/storage/r2.go +0 −0
apps/posts-go/templates/admin_base.html (deleted) +0 −29
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}}
apps/posts-go/templates/admin_files.html (deleted) +0 −49
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('![{{.OriginalName}}]({{$site}}/files/{{.Filename}})');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}}
apps/posts-go/templates/admin_import.html (deleted) +0 −35
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}}
apps/posts-go/templates/admin_index.html (deleted) +0 −36
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}}
apps/posts-go/templates/admin_page_form.html (deleted) +0 −42
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}}
apps/posts-go/templates/admin_pages.html (deleted) +0 −34
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}}
apps/posts-go/templates/admin_post_form.html (deleted) +0 −66
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}}
apps/posts-go/templates/admin_settings.html (deleted) +0 −52
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 &#123;&#123;latest_posts&#125;&#125; 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}}
apps/posts-go/templates/base.html (deleted) +0 −54
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}}
apps/posts-go/templates/index.html (deleted) +0 −36
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}}
apps/posts-go/templates/login.html (deleted) +0 −25
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}}
apps/posts-go/templates/page.html (deleted) +0 −18
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}}
apps/posts-go/templates/post.html (deleted) +0 −45
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}}
apps/posts-go/templates/posts.html (deleted) +0 −28
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}}
apps/posts-go/util.go → apps/posts/util.go +0 −0
apps/posts/.env.example +6 −6
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=
apps/posts/Cargo.toml (deleted) +0 −35
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"
apps/posts/Dockerfile +11 −14
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"]
apps/posts/LICENSE (deleted) +0 −22
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 -
apps/posts/README.md +11 −121
1 -
# Posts
2 -
3 -
![cover](https://assets.andromeda.build/posts-demo.png)
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 -
[![Deploy on Railway](https://railway.com/button.svg)](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`.
apps/posts/docker-compose.yml +7 −7
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:
apps/posts/src/auth.rs (deleted) +0 −39
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 -
}
apps/posts/src/db.rs (deleted) +0 −850
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 -
}
apps/posts/src/main.rs (deleted) +0 −16
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 -
}
apps/posts/src/server/handlers/admin.rs (deleted) +0 −920
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 -
}
apps/posts/src/server/handlers/api.rs (deleted) +0 −141
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 -
}
apps/posts/src/server/handlers/mod.rs (deleted) +0 −3
1 -
pub mod admin;
2 -
pub mod api;
3 -
pub mod public;
apps/posts/src/server/handlers/public.rs (deleted) +0 −284
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('&', "&amp;")
203 -
        .replace('<', "&lt;")
204 -
        .replace('>', "&gt;")
205 -
        .replace('"', "&quot;")
206 -
        .replace('\'', "&apos;")
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 -
}
apps/posts/src/server/mod.rs (deleted) +0 −639
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 -
}
apps/posts/src/storage.rs (deleted) +0 −118
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 -
}
apps/posts/templates/admin_base.html +5 −5
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}}
apps/posts/templates/admin_files.html +22 −25
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('![{{ file.original_name }}]({{ site_url }}/files/{{ file.filename }})');this.textContent='copied!'">
38 +
              onclick="navigator.clipboard.writeText('![{{.OriginalName}}]({{$site}}/files/{{.Filename}})');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}}
apps/posts/templates/admin_import.html +8 −10
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}}
apps/posts/templates/admin_index.html +16 −16
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}}
apps/posts/templates/admin_page_form.html +40 −42
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}}
apps/posts/templates/admin_pages.html +16 −16
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}}
apps/posts/templates/admin_post_form.html +62 −76
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}}
apps/posts/templates/admin_settings.html +17 −21
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 &#123;&#123;latest_posts&#125;&#125; 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}}
apps/posts/templates/base.html +19 −19
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}}
apps/posts/templates/index.html +27 −27
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}}
apps/posts/templates/login.html +4 −6
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}}
apps/posts/templates/page.html +14 −14
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}}
apps/posts/templates/post.html +38 −42
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}}
apps/posts/templates/posts.html +21 −21
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}}
apps/shrink-go/.env.example → apps/shrink/.env.example +0 −0
apps/shrink-go/Dockerfile (deleted) +0 −16
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"]
apps/shrink-go/README.md (deleted) +0 −30
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.
apps/shrink-go/app.go → apps/shrink/app.go +0 −0
apps/shrink-go/docker-compose.yml (deleted) +0 −11
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
apps/shrink-go/exif.go → apps/shrink/exif.go +0 −0
apps/shrink-go/go.mod → apps/shrink/go.mod +1 −1
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
apps/shrink-go/go.sum → apps/shrink/go.sum +0 −0
apps/shrink-go/handlers.go → apps/shrink/handlers.go +0 −0
apps/shrink-go/image.go → apps/shrink/image.go +0 −0
apps/shrink-go/image_test.go → apps/shrink/image_test.go +0 −0
apps/shrink-go/main.go → apps/shrink/main.go +1 −1
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
	}
apps/shrink-go/routes.go → apps/shrink/routes.go +0 −0
apps/shrink-go/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/shrink-go/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/shrink-go/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/shrink-go/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/shrink-go/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/shrink-go/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/shrink-go/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/shrink-go/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/shrink-go/static/site.webmanifest (deleted) +0 −1
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"}
apps/shrink-go/static/styles.css (deleted) +0 −183
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 -
}
apps/shrink-go/templates/base.html (deleted) +0 −28
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}}
apps/shrink-go/templates/index.html (deleted) +0 −171
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}}
apps/shrink/Cargo.toml (deleted) +0 −20
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"
apps/shrink/Dockerfile +10 −16
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"]
apps/shrink/LICENSE (deleted) +0 −22
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 -
apps/shrink/README.md +16 −58
1 -
# Shrink
1 +
# shrink-go
2 2
3 -
![cover](https://files.stevedylan.dev/shrink-demo.png)
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 -
[![Deploy on Railway](https://railway.com/button.svg)](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.
apps/shrink/docker-compose.yml +3 −3
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
apps/shrink/src/main.rs (deleted) +0 −12
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 -
}
apps/shrink/src/server.rs (deleted) +0 −377
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 -
}
apps/shrink/static/fonts/CommitMono-400-Italic.otf (deleted) +0 −0

Binary file — no preview.

apps/shrink/static/fonts/CommitMono-400-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/shrink/static/fonts/CommitMono-700-Italic.otf (deleted) +0 −0

Binary file — no preview.

apps/shrink/static/fonts/CommitMono-700-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/shrink/templates/base.html +6 −6
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}}
apps/shrink/templates/index.html +4 −4
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}}
apps/sipp-go/.env.example (deleted) +0 −8
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
apps/sipp-go/Dockerfile (deleted) +0 −18
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"]
apps/sipp-go/README.md (deleted) +0 −33
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.
apps/sipp-go/cmd_auth.go → apps/sipp/cmd_auth.go +1 −1
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
apps/sipp-go/cmd_upload.go → apps/sipp/cmd_upload.go +1 −1
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) {
apps/sipp-go/docker-compose.yml (deleted) +0 −21
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:
apps/sipp-go/go.mod → apps/sipp/go.mod +1 −1
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
apps/sipp-go/go.sum → apps/sipp/go.sum +0 −0
apps/sipp-go/internal/store/store.go → apps/sipp/internal/store/store.go +0 −0
apps/sipp-go/internal/store/store_test.go → apps/sipp/internal/store/store_test.go +0 −0
apps/sipp-go/main.go → apps/sipp/main.go +2 −2
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
apps/sipp-go/server/server.go → apps/sipp/server/server.go +2 −2
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
apps/sipp-go/server/static/android-chrome-192x192.png → apps/sipp/server/static/android-chrome-192x192.png +0 −0

Binary file — no preview.

apps/sipp-go/server/static/android-chrome-512x512.png → apps/sipp/server/static/android-chrome-512x512.png +0 −0

Binary file — no preview.

apps/sipp-go/server/static/apple-touch-icon.png → apps/sipp/server/static/apple-touch-icon.png +0 −0

Binary file — no preview.

apps/sipp-go/server/static/favicon-16x16.png → apps/sipp/server/static/favicon-16x16.png +0 −0

Binary file — no preview.

apps/sipp-go/server/static/favicon-32x32.png → apps/sipp/server/static/favicon-32x32.png +0 −0

Binary file — no preview.

apps/sipp-go/server/static/favicon.ico → apps/sipp/server/static/favicon.ico +0 −0

Binary file — no preview.

apps/sipp-go/server/static/icon.png → apps/sipp/server/static/icon.png +0 −0

Binary file — no preview.

apps/sipp-go/server/static/og.png → apps/sipp/server/static/og.png +0 −0

Binary file — no preview.

apps/sipp-go/server/static/site.webmanifest → apps/sipp/server/static/site.webmanifest +0 −0
apps/sipp-go/server/static/styles.css → apps/sipp/server/static/styles.css +0 −0
apps/sipp-go/server/templates/admin.html → apps/sipp/server/templates/admin.html +0 −0
apps/sipp-go/server/templates/index.html → apps/sipp/server/templates/index.html +0 −0
apps/sipp-go/server/templates/login.html → apps/sipp/server/templates/login.html +0 −0
apps/sipp-go/server/templates/snippet.html → apps/sipp/server/templates/snippet.html +0 −0
apps/sipp-go/tui/backend.go → apps/sipp/tui/backend.go +1 −1
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
apps/sipp-go/tui/commands.go → apps/sipp/tui/commands.go +0 −0
apps/sipp-go/tui/config.go → apps/sipp/tui/config.go +0 −0
apps/sipp-go/tui/content_model.go → apps/sipp/tui/content_model.go +0 −0
apps/sipp-go/tui/editor.go → apps/sipp/tui/editor.go +0 −0
apps/sipp-go/tui/form_model.go → apps/sipp/tui/form_model.go +0 −0
apps/sipp-go/tui/highlight.go → apps/sipp/tui/highlight.go +0 −0
apps/sipp-go/tui/keys.go → apps/sipp/tui/keys.go +0 −0
apps/sipp-go/tui/list_model.go → apps/sipp/tui/list_model.go +0 −0
apps/sipp-go/tui/messages.go → apps/sipp/tui/messages.go +0 −0
apps/sipp-go/tui/model.go → apps/sipp/tui/model.go +0 −0
apps/sipp-go/tui/tui.go → apps/sipp/tui/tui.go +0 −0
apps/sipp-go/tui/update.go → apps/sipp/tui/update.go +0 −0
apps/sipp-go/tui/view.go → apps/sipp/tui/view.go +0 −0
apps/sipp/.env.example +5 −1
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
apps/sipp/CHANGELOG.md (deleted) +0 −94
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 -
apps/sipp/Cargo.toml (deleted) +0 −47
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 }
apps/sipp/Dockerfile +12 −14
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"]
apps/sipp/LICENSE (deleted) +0 −21
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.
apps/sipp/README.md +20 −215
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 -
[![Deploy on Railway](https://railway.com/button.svg)](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.
apps/sipp/TODO.md (deleted) +0 −16
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
apps/sipp/cliff.toml (deleted) +0 −49
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"
apps/sipp/docker-compose.yml +12 −8
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:
apps/sipp/src/ansi.tmTheme (deleted) +0 −430
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>
apps/sipp/src/auth.rs (deleted) +0 −58
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 -
}
apps/sipp/src/backend.rs (deleted) +0 −180
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 -
}
apps/sipp/src/config.rs (deleted) +0 −102
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 -
}
apps/sipp/src/darkmatter.tmTheme (deleted) +0 −560
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>
apps/sipp/src/db.rs (deleted) +0 −218
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 -
}
apps/sipp/src/highlight.rs (deleted) +0 −41
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('&', "&amp;")
36 -
                    .replace('<', "&lt;")
37 -
                    .replace('>', "&gt;");
38 -
                format!("<pre>{}</pre>", escaped)
39 -
            })
40 -
    }
41 -
}
apps/sipp/src/lib.rs (deleted) +0 −7
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;
apps/sipp/src/main.rs (deleted) +0 −73
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 -
}
apps/sipp/src/server.rs (deleted) +0 −524
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 -
}
apps/sipp/src/tui/app.rs (deleted) +0 −471
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 -
}
apps/sipp/src/tui/events.rs (deleted) +0 −152
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 -
}
apps/sipp/src/tui/mod.rs (deleted) +0 −145
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 -
}
apps/sipp/src/tui/render.rs (deleted) +0 −484
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 -
}
apps/sipp/static/android-chrome-192x192.png (deleted) +0 −0

Binary file — no preview.

apps/sipp/static/android-chrome-512x512.png (deleted) +0 −0

Binary file — no preview.

apps/sipp/static/apple-touch-icon.png (deleted) +0 −0

Binary file — no preview.

apps/sipp/static/favicon-16x16.png (deleted) +0 −0

Binary file — no preview.

apps/sipp/static/favicon-32x32.png (deleted) +0 −0

Binary file — no preview.

apps/sipp/static/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/sipp/static/icon.png (deleted) +0 −0

Binary file — no preview.

apps/sipp/static/og.png (deleted) +0 −0

Binary file — no preview.

apps/sipp/static/site.webmanifest (deleted) +0 −1
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"}
apps/sipp/static/styles.css (deleted) +0 −83
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 -
}
apps/sipp/templates/admin.html (deleted) +0 −58
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>
apps/sipp/templates/index.html (deleted) +0 −60
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>
apps/sipp/templates/login.html (deleted) +0 −34
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>
apps/sipp/templates/snippet.html (deleted) +0 −109
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>
crates/auth/Cargo.toml (deleted) +0 −9
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 }
crates/auth/src/datetime.rs (deleted) +0 −119
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 -
}
crates/auth/src/lib.rs (deleted) +0 −252
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 -
}
crates/darkmatter-css/Cargo.toml (deleted) +0 −8
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 }
crates/darkmatter-css/assets/darkmatter.css (deleted) +0 −648
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 -
}
crates/darkmatter-css/assets/fonts/CommitMono-400-Regular.otf (deleted) +0 −0

Binary file — no preview.

crates/darkmatter-css/assets/fonts/CommitMono-700-Regular.otf (deleted) +0 −0

Binary file — no preview.

crates/darkmatter-css/assets/index.html (deleted) +0 −249
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 &amp; 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>
crates/darkmatter-css/src/lib.rs (deleted) +0 −54
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 -
}
crates/db/Cargo.toml (deleted) +0 −20
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"]
crates/db/src/feeds.rs (deleted) +0 −694
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 -
}
crates/db/src/lib.rs (deleted) +0 −50
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;
crates/db/src/session.rs (deleted) +0 −89
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 -
}
dist-workspace.toml (deleted) +0 −35
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"
docker-compose.yml +3 −6
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
docs/docs/components/Landing.tsx +1 −1
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">
docs/docs/pages/apps/bookmarks.mdx +2 −2
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.
docs/docs/pages/apps/cellar.mdx +2 −2
2 2
3 3
![demo of cellar](https://files.stevedylan.dev/cellar-demo.png)
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.
docs/docs/pages/apps/easel.mdx +3 −3
2 2
3 3
![demo of easel](https://assets.andromeda.build/easel-demo-2_compressed.jpg)
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.
docs/docs/pages/apps/feeds.mdx +2 −2
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.
docs/docs/pages/apps/jotts.mdx +3 −3
2 2
3 3
![demo of jotts](https://files.stevedylan.dev/jotts-demo.png)
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.
docs/docs/pages/apps/library.mdx +2 −2
2 2
3 3
![demo of library](https://assets.andromeda.build/library-demo.png)
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.
docs/docs/pages/apps/og.mdx +3 −3
2 2
3 3
![demo of og](https://files.stevedylan.dev/og-demo.png)
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.
docs/docs/pages/apps/parcels.mdx (deleted) +0 −72
1 -
# Parcels
2 -
3 -
![demo of parcels](https://files.stevedylan.dev/parcels-demo.png)
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 -
[![Deploy on Railway](https://railway.com/button.svg)](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.
docs/docs/pages/apps/posts.mdx +3 −3
2 2
3 3
![demo of posts](https://assets.andromeda.build/posts-demo.png)
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.
docs/docs/pages/apps/shrink.mdx +2 −2
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.
docs/docs/pages/apps/sipp.mdx +2 −2
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.
docs/docs/pages/diy/skills.mdx +4 −17
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.
docs/docs/pages/diy/stack.mdx +50 −48
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
```
docs/docs/pages/what-is-andromeda.mdx +1 −1
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.
docs/vocs.config.ts +0 −4
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
        },