├── .github └── workflows │ ├── nix-build.yml │ ├── release.yml │ └── static.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── autost.toml.example ├── build.rs ├── default.nix ├── flake.lock ├── flake.nix ├── how-to-release.md ├── nix ├── overlay.nix └── package.nix ├── rust-toolchain.toml ├── rustfmt.toml ├── shell.nix ├── sites └── docs │ ├── .gitignore │ ├── autost.toml │ └── posts │ ├── directory-structure.md │ ├── post-format.md │ └── settings.md ├── src ├── akkoma.rs ├── attachments.rs ├── cohost.rs ├── command │ ├── attach.rs │ ├── cohost2autost.rs │ ├── cohost2json.rs │ ├── cohost_archive.rs │ ├── import.rs │ ├── new.rs │ ├── render.rs │ └── server.rs ├── css.rs ├── dom.rs ├── http.rs ├── lib.rs ├── main.rs ├── meta.rs ├── migrations.rs ├── output.rs ├── path.rs ├── rocket_eyre.rs └── settings.rs ├── static ├── Atkinson-Hyperlegible-Bold-102.woff2 ├── Atkinson-Hyperlegible-BoldItalic-102.woff2 ├── Atkinson-Hyperlegible-Font-License-2020-1104.pdf ├── Atkinson-Hyperlegible-Italic-102.woff2 ├── Atkinson-Hyperlegible-Regular-102.woff2 ├── deploy.sh ├── script.js └── style.css ├── templates ├── akkoma-img.html ├── ask.html ├── cohost-audio.html ├── cohost-img.html ├── compose.html ├── feed.xml ├── post-meta.html ├── thread-or-post-author.html ├── thread-or-post-header.html ├── thread-or-post-meta.html ├── threads-content.html └── threads.html └── wix └── main.wxs /.github/workflows/nix-build.yml: -------------------------------------------------------------------------------- 1 | name: "Nix Build/Cache" 2 | 3 | on: 4 | push: 5 | # Runs on pushes targeting the default branch 6 | branches: ["main"] 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | os: 13 | - ubuntu-latest 14 | - macos-latest 15 | - macos-13 16 | runs-on: '${{ matrix.os }}' 17 | steps: 18 | - uses: actions/checkout@v4 19 | # Install Nix in the runner 20 | - uses: cachix/install-nix-action@v25 21 | with: 22 | nix_path: nixpkgs=channel:nixos-unstable 23 | # Setup Cachix to push build results to cache 24 | - uses: cachix/cachix-action@v14 25 | with: 26 | name: autost 27 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 28 | # Run build 29 | - run: nix build -L 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by cargo-dist: https://opensource.axo.dev/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 cargo-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 cargo-dist-able). 28 | # 29 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 30 | # (cargo-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 | pull_request: 43 | push: 44 | tags: 45 | - '**[0-9]+.[0-9]+.[0-9]+*' 46 | 47 | jobs: 48 | # Run 'cargo dist plan' (or host) to determine what tasks we need to do 49 | plan: 50 | runs-on: "ubuntu-20.04" 51 | outputs: 52 | val: ${{ steps.plan.outputs.manifest }} 53 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 54 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 55 | publishing: ${{ !github.event.pull_request }} 56 | env: 57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | submodules: recursive 62 | - name: Install cargo-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.22.1/cargo-dist-installer.sh | sh" 67 | - name: Cache cargo-dist 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: cargo-dist-cache 71 | path: ~/.cargo/bin/cargo-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 | cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 80 | echo "cargo 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@v4 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 cargo-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 cargo dist 103 | # - install-dist: expression to run to install cargo-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 | env: 111 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 112 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 113 | steps: 114 | - name: enable windows longpaths 115 | run: | 116 | git config --global core.longpaths true 117 | - uses: actions/checkout@v4 118 | with: 119 | submodules: recursive 120 | - uses: swatinem/rust-cache@v2 121 | with: 122 | key: ${{ join(matrix.targets, '-') }} 123 | cache-provider: ${{ matrix.cache_provider }} 124 | - name: Install cargo-dist 125 | run: ${{ matrix.install_dist }} 126 | # Get the dist-manifest 127 | - name: Fetch local artifacts 128 | uses: actions/download-artifact@v4 129 | with: 130 | pattern: artifacts-* 131 | path: target/distrib/ 132 | merge-multiple: true 133 | - name: Install dependencies 134 | run: | 135 | ${{ matrix.packages_install }} 136 | - name: Build artifacts 137 | run: | 138 | # Actually do builds and make zips and whatnot 139 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 140 | echo "cargo dist ran successfully" 141 | - id: cargo-dist 142 | name: Post-build 143 | # We force bash here just because github makes it really hard to get values up 144 | # to "real" actions without writing to env-vars, and writing to env-vars has 145 | # inconsistent syntax between shell and powershell. 146 | shell: bash 147 | run: | 148 | # Parse out what we just built and upload it to scratch storage 149 | echo "paths<> "$GITHUB_OUTPUT" 150 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 151 | echo "EOF" >> "$GITHUB_OUTPUT" 152 | 153 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 154 | - name: "Upload artifacts" 155 | uses: actions/upload-artifact@v4 156 | with: 157 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 158 | path: | 159 | ${{ steps.cargo-dist.outputs.paths }} 160 | ${{ env.BUILD_MANIFEST_NAME }} 161 | 162 | # Build and package all the platform-agnostic(ish) things 163 | build-global-artifacts: 164 | needs: 165 | - plan 166 | - build-local-artifacts 167 | runs-on: "ubuntu-20.04" 168 | env: 169 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 170 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 171 | steps: 172 | - uses: actions/checkout@v4 173 | with: 174 | submodules: recursive 175 | - name: Install cached cargo-dist 176 | uses: actions/download-artifact@v4 177 | with: 178 | name: cargo-dist-cache 179 | path: ~/.cargo/bin/ 180 | - run: chmod +x ~/.cargo/bin/cargo-dist 181 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 182 | - name: Fetch local artifacts 183 | uses: actions/download-artifact@v4 184 | with: 185 | pattern: artifacts-* 186 | path: target/distrib/ 187 | merge-multiple: true 188 | - id: cargo-dist 189 | shell: bash 190 | run: | 191 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 192 | echo "cargo dist ran successfully" 193 | 194 | # Parse out what we just built and upload it to scratch storage 195 | echo "paths<> "$GITHUB_OUTPUT" 196 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 197 | echo "EOF" >> "$GITHUB_OUTPUT" 198 | 199 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 200 | - name: "Upload artifacts" 201 | uses: actions/upload-artifact@v4 202 | with: 203 | name: artifacts-build-global 204 | path: | 205 | ${{ steps.cargo-dist.outputs.paths }} 206 | ${{ env.BUILD_MANIFEST_NAME }} 207 | # Determines if we should publish/announce 208 | host: 209 | needs: 210 | - plan 211 | - build-local-artifacts 212 | - build-global-artifacts 213 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 214 | if: ${{ always() && 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') }} 215 | env: 216 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 217 | runs-on: "ubuntu-20.04" 218 | outputs: 219 | val: ${{ steps.host.outputs.manifest }} 220 | steps: 221 | - uses: actions/checkout@v4 222 | with: 223 | submodules: recursive 224 | - name: Install cached cargo-dist 225 | uses: actions/download-artifact@v4 226 | with: 227 | name: cargo-dist-cache 228 | path: ~/.cargo/bin/ 229 | - run: chmod +x ~/.cargo/bin/cargo-dist 230 | # Fetch artifacts from scratch-storage 231 | - name: Fetch artifacts 232 | uses: actions/download-artifact@v4 233 | with: 234 | pattern: artifacts-* 235 | path: target/distrib/ 236 | merge-multiple: true 237 | - id: host 238 | shell: bash 239 | run: | 240 | cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 241 | echo "artifacts uploaded and released successfully" 242 | cat dist-manifest.json 243 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 244 | - name: "Upload dist-manifest.json" 245 | uses: actions/upload-artifact@v4 246 | with: 247 | # Overwrite the previous copy 248 | name: artifacts-dist-manifest 249 | path: dist-manifest.json 250 | # Create a GitHub Release while uploading all files to it 251 | - name: "Download GitHub Artifacts" 252 | uses: actions/download-artifact@v4 253 | with: 254 | pattern: artifacts-* 255 | path: artifacts 256 | merge-multiple: true 257 | - name: Cleanup 258 | run: | 259 | # Remove the granular manifests 260 | rm -f artifacts/*-dist-manifest.json 261 | - name: Create GitHub Release 262 | env: 263 | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 264 | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 265 | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 266 | RELEASE_COMMIT: "${{ github.sha }}" 267 | run: | 268 | # Write and read notes from a file to avoid quoting breaking things 269 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 270 | 271 | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 272 | 273 | announce: 274 | needs: 275 | - plan 276 | - host 277 | # use "always() && ..." to allow us to wait for all publish jobs while 278 | # still allowing individual publish jobs to skip themselves (for prereleases). 279 | # "host" however must run to completion, no skipping allowed! 280 | if: ${{ always() && needs.host.result == 'success' }} 281 | runs-on: "ubuntu-20.04" 282 | env: 283 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 284 | steps: 285 | - uses: actions/checkout@v4 286 | with: 287 | submodules: recursive 288 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | # - name: Setup Pages 35 | # uses: actions/configure-pages@v5 36 | - run: | 37 | cd sites/docs 38 | # cargo run render 39 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/delan/autost/releases/download/1.3.1/autost-installer.sh | sh 40 | autost render 41 | - name: Upload artifact 42 | uses: actions/upload-pages-artifact@v3 43 | with: 44 | # Upload entire repository 45 | path: 'sites/docs/site' 46 | - name: Deploy to GitHub Pages 47 | id: deployment 48 | uses: actions/deploy-pages@v4 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.direnv/ 2 | /.envrc 3 | 4 | /result 5 | 6 | /target/ 7 | /autost.toml 8 | /site/*.html 9 | /site/*.feed.xml 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.4.0](https://github.com/delan/autost/releases/tag/1.4.0) (2025-03-02) 2 | 3 | in the html output... 4 | - posts can now contain the **‘aria-label’ attribute** 5 | 6 | in `autost import`... 7 | - now supports **importing post pages from [akkoma](https://akkoma.social/) instances** 8 | - now gives you an alternative **link to compose a transparent share** (`?is_transparent_share`) 9 | 10 | in `autost render`... 11 | - **all <img> elements** are now **lazy loaded**, massively reducing the size of large threads pages 12 | 13 | in `autost server`... 14 | - **fixed a bug where serving index.html was broken on windows** ([@ar1a](https://github.com/ar1a), [#39](https://github.com/delan/autost/pull/39)) 15 | - **now has \[+\] buttons on each tag** for quickly composing a post in an existing tag 16 | - now responds with http 404 and logs the path when request is outside of `base_url` ([#37](https://github.com/delan/autost/issues/37)) 17 | - now correctly logs the details of errors that occur in the `GET /compose` route ([#38](https://github.com/delan/autost/issues/38)) 18 | 19 | # [1.3.2](https://github.com/delan/autost/releases/tag/1.3.2) (2024-12-31) 20 | 21 | in `autost cohost2json` and `autost cohost-archive`... 22 | - **fixed a bug causing `--liked` mode to skip the last page of liked chosts** 23 | - in other words, up to 20 of your oldest likes were missing from your archive 24 | - this only affects archives of your own cohost projects, not other people’s projects 25 | - fixing your archives should be relatively fast, because most of your attachments will not need to be redownloaded 26 | - if you used `autost cohost2json`, please rerun both `autost cohost2json` and `autost cohost2autost` to fix your archived chosts 27 | - if you used `autost cohost-archive`, please delete both `cohost2json.done` and `cohost2autost.done` on only the archived projects you need to fix, then rerun it 28 | - now writes lists of chosts to own_chosts.txt and liked_chosts.txt for futureproofing 29 | - now retries failed requests using exponential backoff ([#33](https://github.com/delan/autost/issues/33)) 30 | 31 | in `autost cohost2autost` and `autost cohost-archive`... 32 | - now ignores chost files not ending in .json unless explicitly given 33 | - now retries attachment redirect requests that fail with no response 34 | - now uses exponential backoff when retrying attachment redirect requests 35 | 36 | in `autost cohost-archive`... 37 | - now writes some details about the run to run_details.toml for futureproofing 38 | 39 | thanks to [@nex3](https://github.com/nex3) and [@docprofsky](https://github.com/docprofsky) for their feedback! 40 | 41 | # [1.3.1](https://github.com/delan/autost/releases/tag/1.3.1) (2024-12-30) 42 | 43 | in `autost cohost2json` and `autost cohost-archive`... 44 | - **fixed a bug causing incorrect output in `--liked` mode** ([#34](https://github.com/delan/autost/issues/34)) 45 | - this only affects archives of your own cohost projects, not other people’s projects 46 | - if you used `autost cohost2json`, please rerun both `autost cohost2json` and `autost cohost2autost` to fix your archived chosts 47 | - if you used `autost cohost-archive`, please delete both `cohost2json.done` and `cohost2autost.done` on only the archived projects you need to fix, then rerun it 48 | 49 | in `autost cohost2autost` and `autost cohost-archive`... 50 | - no longer crashes when attachment filenames contain `:` on windows ([@echowritescode](https://github.com/echowritescode), [#32](https://github.com/delan/autost/pull/32)) 51 | 52 | # [1.3.0](https://github.com/delan/autost/releases/tag/1.3.0) (2024-12-29) 53 | 54 | in `autost cohost2json` and `autost cohost-archive`... 55 | - **you can now include your own liked chosts** with `--liked` ([@Sorixelle](https://github.com/Sorixelle), [#31](https://github.com/delan/autost/pull/31)) 56 | - liked chosts can be found at liked.html, e.g. 57 | - if you used `autost cohost-archive`, remember to delete both `cohost2json.done` and `cohost2autost.done` on archived projects you want to update 58 | 59 | in `autost cohost2autost` and `autost cohost-archive`... 60 | - now handles some malformed but technically valid attachment urls on staging.cohostcdn.org 61 | - now handles attachment urls that 404 gracefully, by logging an error and continuing 62 | 63 | # [1.2.1](https://github.com/delan/autost/releases/tag/1.2.1) (2024-12-28) 64 | 65 | couple of small improvements to `autost cohost-archive`... 66 | - **now archives your own chosts too**, then the people you follow, if no specific projects are given 67 | - **now tells you which project is currently being archived**, in the log output 68 | 69 | # [1.2.0](https://github.com/delan/autost/releases/tag/1.2.0) (2024-12-28) 70 | 71 | - **be sure to rerun `autost cohost-archive` and/or `autost cohost2autost` before cohost shuts down!** why? 72 | - **we now archive cohost attachments in inline styles**, like `
` 73 | - **we now archive hotlinked cohost emotes, eggbug logos, etc**, like 74 | - **we now archive hotlinked cohost avatar and header images**, like in 75 | - attachments are fetched and rewritten when chosts (json) are converted to posts, so you will need to reconvert your chosts 76 | - to make `autost cohost-archive` actually reconvert chosts, delete the `cohost2autost.done` file in each archived project 77 | - **got nix?** 78 | - you can now run the latest *released* version of autost with `nix run github:delan/autost/latest` 79 | - you can now get prebuilt binaries [on cachix](https://autost.cachix.org); see the README for details ([@Sorixelle](https://github.com/Sorixelle), [#30](https://github.com/delan/autost/pull/30)) 80 | 81 | in `autost cohost-archive`... 82 | - archived chosts are now visible on the main page, without needing to navigate to 83 | - **you can rerun `autost cohost-archive` to update your existing archives!** it’s smart enough to not need to redownload and reconvert the chosts, but see above for why you should reconvert anyway 84 | 85 | in `autost server`... 86 | - **you can now override the listening port** with `-p` (`--port`) 87 | 88 | posts by the `[self_author] href` are always considered “interesting”, but the check for this has been improved: 89 | - fixed a bug that prevented archived chosts with that `href` from being considered “interesting” 90 | - fixed a bug that prevented you from changing your `name`, `display_name`, or `handle` 91 | 92 | as a result of these changes... 93 | - you can now publish all of your archived chosts at once by setting your `href` to the url of your cohost page (e.g. ) 94 | - you can now change your `name`, `display_name`, or `handle` without editing all of your old posts 95 | 96 | # [1.1.0](https://github.com/delan/autost/releases/tag/1.1.0) (2024-12-27) 97 | 98 | - **be sure to rerun `autost cohost2autost` before cohost shuts down!** why? 99 | - we’ve fixed a bug that broke audio attachments in a variety of places, including `autost render`, `autost server`, `autost import`, and `autost cohost2autost` 100 | - **got nix?** you can now run autost *without any extra setup* with `nix run github:delan/autost`, or build autost with `nix build`, `nix develop`, or `nix-shell` ([@Sorixelle](https://github.com/Sorixelle), [#25](https://github.com/delan/autost/pull/25)) 101 | - you can now easily **archive the chosts of everyone you follow** (`autost cohost-archive`) 102 | 103 | in the html output... 104 | 105 | - **posts can now contain <video> and <details name>** 106 | - **posts now have [link previews](https://ogp.me)**, with title, description, and the first image if any ([#21](https://github.com/delan/autost/issues/21)) 107 | - **<pre> elements** in the top level of a post **are now scrollable**, if their contents are too wide 108 | - post titles are now marked as the h-entry title (.p-name), making them more accurately rebloggable 109 | - posts without titles now have a placeholder title, like “untitled post by @staff” 110 | - authors without display names are handled better ([#23](https://github.com/delan/autost/issues/23)) 111 | 112 | in `autost cohost2json`... 113 | - no longer hangs after fetching ~120 pages of posts ([#15](https://github.com/delan/autost/issues/15)) 114 | - no longer crashes in debug builds ([#15](https://github.com/delan/autost/issues/15)) 115 | 116 | in `autost cohost2autost`... 117 | - **cohost emotes** like `:eggbug:` are now converted 118 | - **authors without display names** are handled better ([#23](https://github.com/delan/autost/issues/23)) 119 | - now handles posts with deeply nested dom trees without crashing ([#28](https://github.com/delan/autost/issues/28)) 120 | - now retries attachment redirect requests, since they occasionally fail ([#29](https://github.com/delan/autost/issues/29)) 121 | - now runs faster, by walking the dom tree only once per post 122 | 123 | in `autost import`... 124 | - h-entry author names (.p-author .p-name), and other p-properties, can now be extracted from <abbr>, <link>, <data>, <input>, <img>, and <area> ([#18](https://github.com/delan/autost/issues/18)) 125 | - fixed a bug where post titles (.p-name) were sometimes mixed up with author names (.p-author .p-name) ([#18](https://github.com/delan/autost/issues/18)) 126 | 127 | in `autost server`... 128 | - .mp4 files are now served with the correct mime type 129 | - the **reply** buttons now work correctly on tag pages 130 | 131 | thanks to [@LotteMakesStuff](https://github.com/LotteMakesStuff), [@the6p4c](https://github.com/the6p4c), [@VinDuv](https://github.com/VinDuv), and [@Sorixelle](https://github.com/Sorixelle) for their feedback! 132 | 133 | # [1.0.0](https://github.com/delan/autost/releases/tag/1.0.0) (2024-10-10) 134 | 135 | - check out the new [**autost book**](https://delan.github.io/autost/) for more detailed docs! 136 | - you can now **reply to (or share) posts from other blogs** (`autost import`, `autost reimport`) 137 | - for consistency, we suggest setting your `[self_author] display_handle` to a domain name 138 | - you can now **create attachments from local files** (`autost attach`) 139 | 140 | in the html output… 141 | - **your posts are now rebloggable** thanks to the microformats2 h-entry format (#16) 142 | - for more details, check out [*Reblogging posts with h-entry*](https://nex-3.com/blog/reblogging-posts-with-h-entry/) by [@nex3](https://github.com/nex3) 143 | - **fragment links** like `[](#section)` now work properly, since we no longer use <base href> (#17) 144 | - author display names are now wrapped in parentheses 145 | 146 | in the atom output… 147 | - threads with multiple posts are **no longer an unreadable mess** (#19) 148 | - entries now include **<author>** and **<category>** metadata 149 | - your subscribers will need to convince their clients to redownload your posts, or unsubscribe and resubscribe, to see these effects on existing posts 150 | 151 | in `autost server` and the composer… 152 | - added a setting to make `autost server` listen on another port (`server_port`) 153 | - request errors are now more readable, and disappear when requests succeed 154 | - atom feeds (and other .xml files) are now served with the correct mime type 155 | - the **reply** buttons are no longer broken when `base_url` is not `/posts/` 156 | - the **publish** button no longer creates the post file if there are errors 157 | 158 | when rendering your site with `autost render` or `autost server`… 159 | - your posts are now rendered on all CPU cores 160 | - we no longer crash if you have no `posts` directory 161 | 162 | # [0.3.0](https://github.com/delan/autost/releases/tag/0.3.0) (2024-10-01) 163 | 164 | - you can now **download and run autost without building it yourself** or needing the source code 165 | - this makes the `path_to_autost` setting optional, but you should use `path_to_static` instead 166 | - `path_to_static` is a new optional setting that lets you replace the stock css and js files 167 | - you can now **click reply on a thread** to share it without typing out the references by hand 168 | - added a command to **create a new autost site** (`autost new`) 169 | - `autost server` now renders your site on startup ([#10](https://github.com/delan/autost/issues/10)) 170 | - `autost server` now gives you more details and context when errors occur (thanks [@the6p4c](https://github.com/the6p4c)!) 171 | - `autost render` now generates your `interesting_output_filenames_list_path` in a stable order 172 | - `autost cohost2json` can now run without already having an autost site (autost.toml) 173 | - `autost cohost2autost` now downloads attachments to `attachments`, not `site/attachments` 174 | - `autost render` instantly copies them from `attachments` to `site/attachments` using hard links 175 | - `autost render` also updates existing autost sites to move attachments out of `site/attachments` 176 | - removed side margins around threads in narrow viewports 177 | - highlighted the compose button in the web ui 178 | 179 | once you’ve built your autost sites with the new `autost render`… 180 | - you can **delete `site`** to render your autost site from scratch! ([#12](https://github.com/delan/autost/issues/12)) 181 | - and once you do that, your `site` directory will no longer contain any orphaned attachments ([#11](https://github.com/delan/autost/issues/11)) 182 | 183 | # [0.2.0](https://github.com/delan/autost/releases/tag/0.2.0) (2024-09-29) 184 | 185 | - **breaking change:** `cohost2autost` no longer takes the `./posts` and `site/attachments` arguments 186 | - **breaking change:** `render` no longer takes the `site` argument 187 | - these paths were always `./posts`, `site/attachments`, and `site` ([#8](https://github.com/delan/autost/issues/8)) 188 | - in the `server`, you can now **compose posts** ([#7](https://github.com/delan/autost/issues/7)) 189 | - in the `server`, you now get a link to your site in the terminal 190 | - in the `server`, you no longer get a `NotFound` error if your `base_url` setting is not `/` 191 | - you no longer have to type `RUST_LOG=info` to see what’s happening ([#5](https://github.com/delan/autost/issues/5)) 192 | - attachment filenames containing `%` and `?` are now handled correctly ([#4](https://github.com/delan/autost/issues/4)) 193 | 194 | # [0.1.0](https://github.com/delan/autost/releases/tag/0.1.0) (2024-09-27) 195 | 196 | initial release (see [announcement](https://cohost.org/delan/post/7848210-autost-a-cohost-com)) 197 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "autost" 3 | version = "1.4.0" 4 | edition = "2021" 5 | default-run = "autost" 6 | repository = "https://github.com/delan/autost" 7 | authors = ["Delan Azabani "] 8 | description = "cohost-compatible blog engine and feed reader" 9 | homepage = "https://github.com/delan/autost" 10 | 11 | [package.metadata.wix] 12 | upgrade-guid = "6653B1BD-7AAD-48A7-AE48-025289EF12F9" 13 | path-guid = "AF2D9F97-09C2-4296-9950-B486F61F39A3" 14 | license = false 15 | eula = false 16 | 17 | [build-dependencies] 18 | vergen-gix = "1.0.3" 19 | 20 | [dependencies] 21 | ammonia = "4.0.0" 22 | askama = { version = "0.12.1", features = ["with-rocket"] } 23 | askama_rocket = "0.12.0" 24 | base64 = "0.22.1" 25 | bytes = "1.7.1" 26 | chrono = "0.4.38" 27 | clap = { version = "4.5.23", features = ["derive"] } 28 | comrak = "0.28.0" 29 | cssparser = "0.34.0" 30 | html5ever = "0.27.0" 31 | http = "0.2.12" 32 | indexmap = { version = "2.7.0", features = ["serde"] } 33 | jane-eyre = "0.3.0" 34 | markup5ever_rcdom = "0.3.0" 35 | rayon = "1.10.0" 36 | renamore = "0.3.2" 37 | rocket = { version = "0.5.1", features = ["json"] } 38 | scraper = "0.22.0" 39 | serde = { version = "1.0.210", features = ["derive"] } 40 | serde_json = { version = "1.0.128", features = ["unbounded_depth"] } 41 | sha2 = "0.10.8" 42 | tokio = { version = "1.40.0", features = ["full"] } 43 | toml = "0.8.19" 44 | tracing = "0.1.40" 45 | tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 46 | url = "2.5.2" 47 | urlencoding = "2.1.3" 48 | uuid = { version = "1.10.0", features = ["v4"] } 49 | xml5ever = "0.18.1" 50 | 51 | [dependencies.reqwest] 52 | version = "0.12.7" 53 | default-features = false 54 | # default features, minus default-tls, plus rustls-tls + blocking + json 55 | features = ["rustls-tls", "blocking", "json", "charset", "http2", "macos-system-configuration"] 56 | 57 | [profile.release] 58 | debug = "line-tables-only" 59 | 60 | # The profile that 'cargo dist' will build with 61 | [profile.dist] 62 | inherits = "release" 63 | lto = "thin" 64 | 65 | # Config for 'cargo dist' 66 | [workspace.metadata.dist] 67 | # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) 68 | cargo-dist-version = "0.22.1" 69 | # CI backends to support 70 | ci = "github" 71 | # The installers to generate for each app 72 | installers = ["shell", "powershell", "npm", "homebrew", "msi"] 73 | # Target platforms to build apps for (Rust target-triple syntax) 74 | targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] 75 | # The archive format to use for windows builds (defaults .zip) 76 | windows-archive = ".tar.gz" 77 | # The archive format to use for non-windows builds (defaults .tar.xz) 78 | unix-archive = ".tar.gz" 79 | # Path that installers should place binaries in 80 | install-path = "CARGO_HOME" 81 | # Whether to install an updater program 82 | install-updater = true 83 | # build and upload artifacts for pull requests 84 | pr-run-mode = "upload" 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024, Delan Azabani 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | autost 2 | ====== 3 | 4 | **questions and contributions welcome :3** 5 | 6 | want to **archive your chosts on your website** but have too many for the [cohost web component](https://cohost.org/astral/post/7796845-div-style-position)? want something like [cohost-dl](https://cohost.org/blep/post/7639936-cohost-dl) except **you can keep posting**? what if your blog engine had the same posting *and reading* experience as cohost? what if you could follow people with rss/atom feeds and see their posts on a chronological timeline? what if you could share their posts too? 7 | 8 | ## getting started 9 | 10 | autost is a single program you run in your terminal (`autost`). 11 | 12 | **go to [the releases page](https://github.com/delan/autost/releases) to download or install autost!** 13 | 14 | go to [CHANGELOG.md](CHANGELOG.md) to find out what changed in each new release. 15 | 16 | for more docs, check out [the autost book](https://delan.github.io/autost/), which you can also render locally: 17 | 18 | ``` 19 | $ cd sites/docs 20 | $ cargo run render 21 | - or - 22 | $ cargo run server 23 | ``` 24 | 25 | **got nix?** you can run autost *without any extra setup* using `nix run github:delan/autost/latest`! see [§ using autost with nix](#using-autost-with-nix) for more details. 26 | 27 | ## how to quickly archive chosts by you and everyone you follow 28 | 29 | `autost cohost-archive` takes care of the `autost new`, `autost cohost2json`, and `autost cohost2autost` thing for you. 30 | 31 | set COHOST_COOKIE to the value of your “connect.sid” cookie as follows, **and switch projects in the cohost web ui**! 32 | 33 | - for bash or zsh, paste in `read -r COHOST_COOKIE; export COHOST_COOKIE`, press enter, paste in your cookie value, press enter 34 | - for powershell, paste in `$env:COHOST_COOKIE='eggbug'`, replace `eggbug` with your cookie value, press enter 35 | - for cmd (windows), paste in `set COHOST_COOKIE="eggbug"`, replace `eggbug` with your cookie value, press enter 36 | 37 | ``` 38 | $ read -r COHOST_COOKIE; export COHOST_COOKIE 39 | ``` 40 | 41 | to archive chosts by you and everyone you follow, but not your liked chosts: 42 | 43 | ``` 44 | $ autost cohost-archive path/to/archived # example (can be anywhere) 45 | ``` 46 | 47 | to archive chosts by you and everyone you follow, including your liked chosts (liked.html): 48 | 49 | ``` 50 | $ autost cohost-archive path/to/archived --liked 51 | ``` 52 | 53 | to archive chosts by specific projects: 54 | 55 | ``` 56 | $ autost cohost-archive path/to/archived staff catball rats 57 | ``` 58 | 59 | then start the server for a project: 60 | 61 | ``` 62 | $ cd path/to/archived/staff 63 | $ autost server 64 | ``` 65 | 66 | ## how to make a new site 67 | 68 | ``` 69 | $ autost new sites/example.com # example (can be anywhere) 70 | $ cd sites/example.com 71 | ``` 72 | 73 | ## how to dump your own chosts 74 | 75 | cohost “projects” are the things with handles like `@staff` that you can have more than one of. to dump chosts for a specific project: 76 | 77 | ``` 78 | $ cd sites/example.com 79 | $ autost cohost2json projectName path/to/chosts 80 | ``` 81 | 82 | you may want to dump private or logged-in-only chosts, be they your own or those of people you’ve followed or reblogged. in this case, you will need to set COHOST_COOKIE to the value of your “connect.sid” cookie as follows, **and switch projects in the cohost web ui**, otherwise you won’t see everything! 83 | 84 | ``` 85 | $ read -r COHOST_COOKIE; export COHOST_COOKIE # optional 86 | ``` 87 | 88 | if you’re dumping chosts for the project you’re logged in as, you can include your liked chosts as follows (liked.html): 89 | 90 | ``` 91 | $ autost cohost2json projectName path/to/chosts --liked 92 | ``` 93 | 94 | ## how to convert chosts to posts 95 | 96 | ``` 97 | $ cd sites/example.com 98 | $ autost cohost2autost path/to/chosts 99 | ``` 100 | 101 | or to convert specific chosts only: 102 | 103 | ``` 104 | $ cd sites/example.com 105 | $ autost cohost2autost path/to/chosts 123456.json 234567.json 106 | ``` 107 | 108 | ## how to render your posts to pages 109 | 110 | ``` 111 | $ cd sites/example.com 112 | $ autost render 113 | ``` 114 | 115 | or to render specific posts only: 116 | 117 | ``` 118 | $ cd sites/example.com 119 | $ autost render posts/123456.html posts/10000000.md 120 | ``` 121 | 122 | ## how to include or exclude specific chosts 123 | 124 | 1. set the `interesting_archived_threads_list_path` or `excluded_archived_threads_list_path` to a text file 125 | 2. in the text file, add a line for each chost with the original cohost url 126 | 127 | ## how to add tags to converted chosts 128 | 129 | 1. set the `archived_thread_tags_path` to a text file 130 | 2. in the text file, add a line for each chost as follows: 131 | 132 | ``` 133 | https://cohost.org/project/post/123456-slug tag,another tag 134 | ``` 135 | 136 | ## how to start the server so you can post 137 | 138 | **warning: this server has no password and no sandboxing yet! do not expose it to the internet!** 139 | 140 | ``` 141 | $ cd sites/example.com 142 | $ autost server 143 | ``` 144 | 145 | ## how to reply to a post on another blog 146 | 147 | this works with any blog that uses microformats2 [h-entry](https://microformats.org/wiki/h-entry). see [@nex3](https://github.com/nex3)’s [Reblogging posts with h-entry](https://nex-3.com/blog/reblogging-posts-with-h-entry/) for more details on how this works. 148 | 149 | ``` 150 | $ cd sites/example.com 151 | $ autost import https://nex-3.com/blog/reblogging-posts-with-h-entry/ 152 | INFO autost::command::import: click here to reply: http://[::1]:8420/posts/compose?reply_to=imported/1.html 153 | ``` 154 | 155 | if you run `autost import` with the same url again, the existing imported post will be updated. you can also use `autost reimport` to update an existing imported post: 156 | 157 | ``` 158 | $ cd sites/example.com 159 | $ autost reimport posts/imported/1.html 160 | ``` 161 | 162 | ## how to create an attachment from a local file 163 | 164 | **warning: this command does not strip any exif data yet, including your gps location!** 165 | 166 | ``` 167 | $ cd sites/example.com 168 | $ autost attach path/to/diffie.jpg 169 | ``` 170 | 171 | ## how to deploy 172 | 173 | the best way to upload your site to a web host depends on if you have chosts you might not want people to see. if you upload everything, someone can count from 1.html to 9999999.html and find all of your chosts. 174 | 175 | if you want to upload everything, you can use rsync directly (note the trailing slash): 176 | 177 | ``` 178 | $ cd sites/example.com 179 | $ rsync -av site/ host:/var/www/example.com 180 | ``` 181 | 182 | if you want to only upload the chosts you have curated, you can use site/deploy.sh (where path/to/interesting.txt is your `interesting_output_filenames_list_path`): 183 | 184 | ``` 185 | $ cd sites/example.com 186 | $ site/deploy.sh host:/var/www/example.com path/to/interesting.txt -n # dry run 187 | $ site/deploy.sh host:/var/www/example.com path/to/interesting.txt # wet run 188 | ``` 189 | 190 | ## suggested workflow 191 | 192 | if you just want to back up your chosts, make an autost site for each cohost project, like `sites/@catball` and `sites/@rats`. 193 | 194 | if you want to do anything more involved, you should make a `staging` and `production` version of your autost site, like `sites/staging` and `sites/production`: 195 | 196 | - to render your site, `cd sites/staging; autost render` 197 | - to see what changed, `colordiff -ru sites/production sites/staging` 198 | - if you’re happy with the changes, `rsync -a sites/staging sites/production` 199 | - and finally to deploy, `cd sites/production` and see “how to deploy” 200 | 201 | that way, you can catch unintentional changes or autost bugs, and you have a backup of your site in case anything goes wrong. 202 | 203 | ## troubleshooting 204 | 205 | if something goes wrong, you can set RUST_LOG or RUST_BACKTRACE to get more details: 206 | 207 | ``` 208 | $ export RUST_LOG=autost=debug 209 | $ export RUST_LOG=autost=trace 210 | $ export RUST_BACKTRACE=1 211 | ``` 212 | 213 | ## building autost yourself 214 | 215 | if you want to tinker with autost, [install rust](https://rustup.rs), then download and build the source (see below). to run autost, replace `autost` in the commands above with `cargo run -r --`. 216 | 217 | ``` 218 | $ git clone https://github.com/delan/autost.git 219 | $ cd autost 220 | ``` 221 | 222 | if you've got nix installed, there's also a devshell you can jump into with `nix-shell` or `nix develop` that has rust included. you can also build the nix derivation for autost with `nix build`. 223 | 224 | ## using autost with nix 225 | 226 | `nix run github:delan/autost` (without `/latest`) will get you the bleeding-edge version of autost, including changes that haven’t been released yet. you can run a specific version of autost as follows: 227 | 228 | ``` 229 | $ nix run github:delan/autost/1.1.0 # version 1.1.0 230 | $ nix run github:delan/autost/latest # latest released version 231 | $ nix run github:delan/autost # bleeding-edge 232 | ``` 233 | 234 | if nix builds are too slow, there's a binary cache available through [cachix](https://cachix.org). you can set it up by running `nix run nixpkgs#cachix use autost`, or for nixos: 235 | ```nix 236 | { 237 | nix.settings = { 238 | substituters = [ 239 | "https://autost.cachix.org" 240 | ]; 241 | trusted-public-keys = [ 242 | "autost.cachix.org-1:zl/QINkEtBrk/TVeogtROIpQwQH6QjQWTPkbPNNsgpk=" 243 | ]; 244 | } 245 | } 246 | ``` 247 | 248 | ## roadmap 249 | 250 | 1. archive your chosts 251 | - [x] download chosts from the api (`cohost2json`) 252 | - [x] import chosts from the api (`cohost2autost`) 253 | - [ ] import chosts from [cohost-dl](https://cohost.org/blep/post/7639936-cohost-dl) 254 | - [ ] import chosts from your cohost data export 255 | - [x] extract and render chost content 256 | - [x] download and rewrite cohost cdn links 257 | - [x] extract cohost-rendered chost content 258 | - [x] render asks 259 | - [x] render image attachments 260 | - [x] render audio attachments 261 | - [x] render attachment rows (new post editor) 262 | - [x] generate the main page (`index.html`) 263 | - [x] generate chost pages (`.html`) 264 | - [x] generate tag pages (`tagged/.html`) 265 | 2. curate your chosts 266 | - [x] select tags to include on the main page (`interesting_tags`) 267 | - [x] select posts to include on the main page (`interesting_archived_threads_list_path`) 268 | - [x] select posts to exclude from the main page (`excluded_archived_threads_list_path`) 269 | - [x] deploy only included posts, to avoid enumeration (`interesting_output_filenames_list_path`) 270 | - [x] generate pages for all posts, posts not yet interesting/excluded, … 271 | - [x] add tags to chosts without editing the originals (`archived_thread_tags_path`) 272 | - [x] automatically rename tags whenever encountered (tag synonyms; `renamed_tags`) 273 | - [x] add tags whenever a tag is encountered (tag implications; `implied_tags`) 274 | 3. **compose new posts (we are here!)** 275 | - [x] compose simple posts 276 | - [x] compose replies 277 | - [ ] upload attachments 278 | 4. follow others 279 | - [x] generate atom feeds (`index.feed.xml`, `tagged/.feed.xml`) 280 | - [ ] subscribe to feeds 281 | - [ ] single reverse chronological timeline 282 | - [ ] share and reply to posts 283 | -------------------------------------------------------------------------------- /autost.toml.example: -------------------------------------------------------------------------------- 1 | base_url = "/" 2 | external_base_url = "https://example.com/" 3 | # server_port = 8420 4 | site_title = "ao!!" 5 | other_self_authors = ["https://cohost.org/staff"] 6 | interesting_tags = [["photography"], ["reading", "watching", "listening"]] 7 | # archived_thread_tags_path = "path/to/archived_thread_tags.txt" 8 | # interesting_output_filenames_list_path = "path/to/output_interesting.txt" 9 | # interesting_archived_threads_list_path = "path/to/interesting.txt" 10 | # excluded_archived_threads_list_path = "path/to/excluded.txt" 11 | 12 | # if you want to tinker with the css/js without rebuilding autost: 13 | # path_to_static = "/home/me/autost/static2" 14 | 15 | [self_author] 16 | href = "https://example.com" 17 | name = "eggbug" 18 | display_name = "eggbug" 19 | display_handle = "example.com" 20 | 21 | [renamed_tags] 22 | "Laptop stickers" = "laptop stickers" 23 | 24 | [implied_tags] 25 | "bird photography" = ["photography"] 26 | 27 | [[nav]] 28 | href = "." 29 | text = "posts" 30 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use vergen_gix::{Emitter, GixBuilder}; 4 | 5 | fn main() -> Result<(), Box> { 6 | let mut gix = GixBuilder::default(); 7 | gix.describe(false, true, None); 8 | 9 | Emitter::default().add_instructions(&gix.build()?)?.emit()?; 10 | 11 | Ok(()) 12 | } 13 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | let 3 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 4 | in 5 | fetchTarball { 6 | url = 7 | lock.nodes.flake-compat.locked.url 8 | or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 9 | sha256 = lock.nodes.flake-compat.locked.narHash; 10 | } 11 | ) { src = ./.; }).defaultNix 12 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "locked": { 5 | "lastModified": 1733328505, 6 | "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", 7 | "owner": "edolstra", 8 | "repo": "flake-compat", 9 | "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "edolstra", 14 | "repo": "flake-compat", 15 | "type": "github" 16 | } 17 | }, 18 | "flake-utils": { 19 | "inputs": { 20 | "systems": "systems" 21 | }, 22 | "locked": { 23 | "lastModified": 1731533236, 24 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 25 | "owner": "numtide", 26 | "repo": "flake-utils", 27 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "numtide", 32 | "repo": "flake-utils", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs": { 37 | "locked": { 38 | "lastModified": 1734126203, 39 | "narHash": "sha256-0XovF7BYP50rTD2v4r55tR5MuBLet7q4xIz6Rgh3BBU=", 40 | "owner": "nixos", 41 | "repo": "nixpkgs", 42 | "rev": "71a6392e367b08525ee710a93af2e80083b5b3e2", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "nixos", 47 | "ref": "nixpkgs-unstable", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "flake-compat": "flake-compat", 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs" 57 | } 58 | }, 59 | "systems": { 60 | "locked": { 61 | "lastModified": 1681028828, 62 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 63 | "owner": "nix-systems", 64 | "repo": "default", 65 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 66 | "type": "github" 67 | }, 68 | "original": { 69 | "owner": "nix-systems", 70 | "repo": "default", 71 | "type": "github" 72 | } 73 | } 74 | }, 75 | "root": "root", 76 | "version": 7 77 | } 78 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "cohost-compatible blog engine and feed reader"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | flake-compat.url = "github:edolstra/flake-compat"; 8 | }; 9 | 10 | outputs = 11 | { 12 | self, 13 | nixpkgs, 14 | flake-utils, 15 | ... 16 | }: 17 | flake-utils.lib.eachDefaultSystem ( 18 | system: 19 | let 20 | pkgs = nixpkgs.legacyPackages.${system}; 21 | 22 | appliedOverlay = self.overlays.default pkgs pkgs; 23 | in 24 | { 25 | packages = rec { 26 | inherit (appliedOverlay) autost; 27 | 28 | default = autost; 29 | }; 30 | 31 | devShell = import ./shell.nix { inherit pkgs; }; 32 | } 33 | ) 34 | // { 35 | overlays.default = import ./nix/overlay.nix; 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /how-to-release.md: -------------------------------------------------------------------------------- 1 | - update CHANGELOG.md 2 | - bump `[package] version` in Cargo.toml 3 | - update Cargo.lock: `cargo check` 4 | - bump `version` in package.nix 5 | - update `cargoHash`: `nix build` 6 | - `git commit -m 'version x.x.x'` 7 | - `git tag -am x.x.x x.x.x` 8 | - `git push` 9 | - `git push --tags` 10 | - `git push -f origin @:latest` 11 | -------------------------------------------------------------------------------- /nix/overlay.nix: -------------------------------------------------------------------------------- 1 | final: prev: 2 | 3 | { 4 | autost = final.callPackage ./package.nix { }; 5 | } 6 | -------------------------------------------------------------------------------- /nix/package.nix: -------------------------------------------------------------------------------- 1 | { lib, rustPlatform }: 2 | 3 | let 4 | fs = lib.fileset; 5 | 6 | autostSources = fs.intersection (fs.gitTracked ../.) ( 7 | fs.unions [ 8 | ../Cargo.lock 9 | ../Cargo.toml 10 | ../autost.toml.example 11 | ../src 12 | ../static 13 | ../templates 14 | ] 15 | ); 16 | in 17 | rustPlatform.buildRustPackage { 18 | pname = "autost"; 19 | version = "1.4.0"; 20 | 21 | src = fs.toSource { 22 | root = ../.; 23 | fileset = autostSources; 24 | }; 25 | 26 | # don't forget to update this hash when Cargo.lock or ${version} changes! 27 | cargoHash = "sha256-0r0HoXF0jrrUyVdssGZeZTy6801HtT0a88rGoup8n9o="; 28 | 29 | # tell rust that the version should be “x.y.z-nix” 30 | # FIXME: nix package does not have access to git 31 | # 32 | AUTOST_IS_NIX_BUILD = 1; 33 | 34 | meta = { 35 | description = "cohost-compatible blog engine and feed reader"; 36 | homepage = "https://github.com/delan/autost"; 37 | downloadPage = "https://github.com/delan/autost/releases"; 38 | changelog = "https://github.com/delan/autost/blob/main/CHANGELOG.md"; 39 | license = lib.licenses.isc; 40 | mainProgram = "autost"; 41 | platforms = lib.platforms.all; 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.82" 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delan/autost/01e96bfc5b129e743e0424c6e11bfe4e74ae99ef/rustfmt.toml -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs ? import { }, 3 | }: 4 | 5 | pkgs.mkShell { 6 | name = "autost-dev-shell"; 7 | 8 | packages = with pkgs; [ 9 | cargo 10 | rustc 11 | rustfmt 12 | rust-analyzer 13 | 14 | nixd 15 | nixfmt-rfc-style 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /sites/docs/.gitignore: -------------------------------------------------------------------------------- 1 | /site/ 2 | -------------------------------------------------------------------------------- /sites/docs/autost.toml: -------------------------------------------------------------------------------- 1 | base_url = "/autost/" 2 | external_base_url = "https://delan.github.io/autost/" 3 | site_title = "the autost book" 4 | other_self_authors = [] 5 | interesting_tags = [] 6 | 7 | [self_author] 8 | href = "#" 9 | name = "autost" 10 | display_name = "autost" 11 | display_handle = "autost.example" 12 | 13 | [renamed_tags] 14 | 15 | [implied_tags] 16 | 17 | [[nav]] 18 | href = "." 19 | text = "posts" 20 | -------------------------------------------------------------------------------- /sites/docs/posts/directory-structure.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | `autost.toml` is where your site settings go, and any directory that contains one is an autost site. create one with `autost new `. 8 | 9 | `/posts/` (`PostsPath` internally) is where your post sources are stored, as `.md` or `.html` files. only files at the top level of this directory are considered when rendering your site, but files in subdirectories can still be replied to (``). 10 | - `1.html` … `9999999.html` for chosts (`autost cohost2autost`) 11 | - `1/1.html` … `1/9999999.html` for chosts in the thread of chost id 1 12 | - `10000000.md` or `10000000.html` and beyond for your other posts 13 | - `imported/1.html` and beyond for other imported posts (`autost import`) 14 | 15 | `/attachments/` (`AttachmentsPath` internally), is where your attachments are stored, including attachments cached from chosts or other imported posts. 16 | - `/` for your own attachments and attachments in chosts 17 | - `thumbs//` for thumbnails of attachments in chosts 18 | - `imported--/file.` for attachments in other imported posts 19 | - `emoji//file.` for emoji in chosts 20 | 21 | `/site/` (`SitePath` internally), or the *site output path*, is where your site gets rendered to. you can delete this directory whenever you want a clean build. 22 | - `1.html` … `9999999.html` for each of your “interesting” chosts 23 | - `10000000.html` and beyond for your other posts (always “interesting”) 24 | - `index.html` and `index.feed.xml` for all of your “interesting” posts 25 | - `tagged/.html` and `tagged/.feed.xml` for each “interesting” tag 26 | - `attachments/` is a mirror of your `/attachments/` directory, using hard links 27 | - plus several static files copied from the program binary or `path_to_static` 28 | - `deploy.sh` uses rsync to upload your “interesting” posts to a web server 29 | -------------------------------------------------------------------------------- /sites/docs/posts/post-format.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | posts are markdown (`.md`) or html (`.html`) fragments with html “front matter” for metadata. the front matter includes… 8 | 9 |
10 |
<link rel="archived" href> 11 |
link to the original post, for imported posts. 12 |
<link rel="references" href> 13 |
one for each post being replied to, including the posts that those posts are replying to (these are not resolved recursively). 14 |
<meta name="title" content> 15 |
title or “headline” of the post. 16 |
<meta name="published" content> 17 |
date the post was published, as a rfc 3339 timestamp. 18 |
<link rel="author" href name> 19 |
author of the post. the name here is used in atom output, while the other author metadata is used in html output. 20 |
<meta name="author_display_name" content> 21 |
name of the author, used in html output. 22 |
<meta name="author_display_handle" content> 23 |
handle of the author, used in html output. this is @projectName for chosts (autost cohost2autost), or a domain name like example.com for other imported posts (autost import). we recommend setting this to a domain name like example.com, but it can be anything really. 24 |
<meta name="tags" content> 25 |
one for each tag associated with the post. 26 |
<meta name="is_transparent_share"> 27 |
if present, hide the post content area entirely. this is used by autost cohost2autost to make cohost’s “transparent shares” look nicer. 28 |
29 | 30 | see also `templates/post-meta.html` and `PostMeta` internally. 31 | -------------------------------------------------------------------------------- /sites/docs/posts/settings.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
base_url = "/" (required) 9 |
relative url your site will be served under in autost server, or any other web server you deploy it to. must end with a slash. 10 |
external_base_url = "https://example.com/" (required) 11 |
absolute url of the web server you are deploying to, for atom output. must end with a slash. 12 |
server_port = 8420 (optional) 13 |
port to listen on, for autost server. 14 |
site_title = "ao!!" (required) 15 |
title of your site as a whole, for both html and atom output. 16 |
other_self_authors = ["https://cohost.org/staff"] (required) 17 |
author urls whose posts are considered your own, in addition to [self_author]. 18 |
19 | 20 | the settings below control which posts are considered “interesting” and included in the html and atom output by default. this allows you to curate your imported chosts, and linkify meaningful tags. 21 | 22 |
23 |
interesting_tags = [["photography"], ["reading", "watching", "listening"]] (required) 24 |
posts with these tags are considered “interesting” and included by default, regardless of author. these tags also generate tag pages, which are linked to in all of the posts in those tags. 25 | 26 | this setting must be a list of lists of tags — the grouping controls how they are displayed in the navigation at the top of the html output. 27 |
archived_thread_tags_path = "path/to/archived_thread_tags.txt" (optional) 28 |
path (relative to autost.toml) to a list of additional tags to add to imported posts. you write this, and the format is: 29 |
# <original url> <tag>,<tag>,...
30 | https://cohost.org/project/post/123456-slug tag,another tag
31 |
interesting_output_filenames_list_path = "path/to/output_interesting.txt" (optional) 32 |
path (relative to autost.toml) to a list of paths relative to your site output directory, representing the “interesting” posts and tag pages. autost render writes this, and you need this to use sites/deploy.sh. 33 |
interesting_archived_threads_list_path = "path/to/interesting.txt" (optional) 34 |
path (relative to autost.toml) to a list of imported posts that should also be considered “interesting”, regardless of tags or author. you write this, and the format is: 35 |
# <original url>
36 | https://cohost.org/project/post/123456-slug
37 | https://nex-3.com/blog/reblogging-posts-with-h-entry/
38 |
excluded_archived_threads_list_path = "path/to/excluded.txt" (optional) 39 |
path (relative to autost.toml) to a list of imported posts that should not be considered “interesting”, even if your other settings would otherwise consider them interesting. you write this, and the format is: 40 |
# <original url>
41 | https://cohost.org/project/post/123456-slug
42 |
43 | 44 | use the settings below if you want to tinker with static files like `style.css` and `script.js` without rebuilding your copy of `autost`: 45 | 46 |
47 |
path_to_static = "../../static2" (optional) 48 |
path (relative to autost.toml) to a directory with your own versions of the files in autost’s static directory. note that if you set this to the actual static directory in your copy of the source code, autost will still get rebuilt whenever you change any files, which may not be what you want. 49 |
path_to_static = "../../static2" (optional) (deprecated) 50 |
path (relative to autost.toml) to a directory containing a static directory with your own version of the files in autost’s static directory. this doesn’t work as nicely as path_to_static, but it was needed in older versions of autost (< 0.3.0) where static files were not built into the autost binary. 51 |
52 | 53 | # `[self_author]` (optional) 54 | 55 | this section is for your details as an author. it has two effects: new posts are prefilled with this author, and posts by this `href` are always considered “interesting”. 56 | 57 |
58 |
href = "https://example.com" (required in section)<link rel="author" href> 59 |
url for <link> metadata and your name and handle links. uniquely identifies you for the purposes of checking if a post is your own. 60 |
name = "eggbug" (required in section)<link rel="author" name> 61 |
your name, for atom output. 62 |
display_name = "eggbug" (required in section)<meta name="author_display_name" content> 63 |
your name, for html output. 64 |
display_handle = "eggbug" (required in section)<meta name="author_display_handle" content> 65 |
your handle, for html output. since this is a domain name like example.com in other imported posts (autost import), we recommend setting this to a domain name like example.com, but it can be anything really. 66 |
67 | 68 | # `[renamed_tags]` (optional) 69 | 70 | this section is for automatically renaming tags in your posts without editing them. this takes effect *before* `[implied_tags]`. 71 | 72 |
73 |
"Laptop stickers" = "laptop stickers" 74 |
renames any occurrence of “Laptop stickers” to “laptop stickers”. 75 |
76 | 77 | # `[implied_tags]` (optional) 78 | 79 | this section is for automatically adding tags to your posts when they contain a specific tag. this takes effect *after* `[renamed_tags]`. 80 | 81 | you can use this to tag your posts with more general tags (e.g. “photography”) when they have a more specific tag (e.g. “bird photography”). the implied tags (to the right of “`=`”) are inserted *before* the specific tag, so the more general tags come first. 82 | 83 |
84 |
"bird photography" = ["birds", "photography"] 85 |
when a post is tagged “bird photography”, replace that tag with “birds”, “photography”, and “bird photography”. 86 |
87 | 88 | # `[[nav]]` (optional) 89 | 90 | you can have any number of these sections, or none at all. each of these sections adds a link to the navigation at the top of the html output. 91 | 92 |
93 |
href = "." (required in section) 94 |
url of the link. relative urls are relative to base_url, not to the current page. 95 |
text = "posts" (required in section) 96 |
text to display in the link. 97 |
98 | -------------------------------------------------------------------------------- /src/akkoma.rs: -------------------------------------------------------------------------------- 1 | use askama::Template; 2 | use serde::Deserialize; 3 | 4 | use crate::Author; 5 | 6 | /// 7 | #[derive(Deserialize)] 8 | pub struct ApiInstance { 9 | pub version: String, 10 | pub uri: String, 11 | } 12 | 13 | /// 14 | #[derive(Deserialize)] 15 | pub struct ApiStatus { 16 | pub content: String, 17 | pub url: String, 18 | pub account: ApiAccount, 19 | pub media_attachments: Vec, 20 | pub tags: Vec, 21 | pub created_at: String, 22 | } 23 | 24 | /// 25 | #[derive(Deserialize)] 26 | pub struct ApiStatusTag { 27 | pub name: String, 28 | pub url: String, 29 | } 30 | 31 | /// 32 | #[derive(Deserialize)] 33 | pub struct ApiMediaAttachment { 34 | pub r#type: String, 35 | pub description: Option, 36 | pub url: String, 37 | pub preview_url: String, 38 | } 39 | 40 | /// 41 | #[derive(Deserialize)] 42 | pub struct ApiAccount { 43 | pub url: String, 44 | pub display_name: String, 45 | pub username: String, 46 | pub acct: String, 47 | pub fqn: String, 48 | } 49 | 50 | #[derive(Template)] 51 | #[template(path = "akkoma-img.html")] 52 | pub struct AkkomaImgTemplate { 53 | pub data_akkoma_src: String, 54 | pub href: String, 55 | pub src: String, 56 | pub alt: Option, 57 | } 58 | 59 | impl From<&ApiAccount> for Author { 60 | fn from(account: &ApiAccount) -> Self { 61 | Self { 62 | href: account.url.clone(), 63 | name: if account.display_name.is_empty() { 64 | format!("@{}", account.fqn) 65 | } else { 66 | format!("{} (@{})", account.display_name, account.fqn) 67 | }, 68 | display_name: account.display_name.clone(), 69 | display_handle: format!("@{}", account.fqn), 70 | } 71 | } 72 | } 73 | 74 | #[test] 75 | fn test_author_from_api_account() { 76 | assert_eq!( 77 | Author::from(&ApiAccount { 78 | url: "https://posting.isincredibly.gay/users/ruby".to_owned(), 79 | display_name: "srxl".to_owned(), 80 | username: "ruby".to_owned(), 81 | acct: "ruby".to_owned(), 82 | fqn: "ruby@posting.isincredibly.gay".to_owned(), 83 | }), 84 | Author { 85 | href: "https://posting.isincredibly.gay/users/ruby".to_owned(), 86 | name: "srxl (@ruby@posting.isincredibly.gay)".to_owned(), 87 | display_name: "srxl".to_owned(), 88 | display_handle: "@ruby@posting.isincredibly.gay".to_owned(), 89 | } 90 | ); 91 | 92 | // not allowed by akkoma frontend, but theoretically possible 93 | assert_eq!( 94 | Author::from(&ApiAccount { 95 | url: "https://posting.isincredibly.gay/users/ruby".to_owned(), 96 | display_name: "".to_owned(), 97 | username: "ruby".to_owned(), 98 | acct: "ruby".to_owned(), 99 | fqn: "ruby@posting.isincredibly.gay".to_owned(), 100 | }), 101 | Author { 102 | href: "https://posting.isincredibly.gay/users/ruby".to_owned(), 103 | name: "@ruby@posting.isincredibly.gay".to_owned(), 104 | display_name: "".to_owned(), 105 | display_handle: "@ruby@posting.isincredibly.gay".to_owned(), 106 | } 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/attachments.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{copy, create_dir_all, read_dir, File}, 3 | io::{Read, Write}, 4 | path::Path, 5 | thread::sleep, 6 | time::Duration, 7 | }; 8 | 9 | use jane_eyre::eyre::{self, bail, OptionExt}; 10 | use reqwest::{redirect::Policy, StatusCode}; 11 | use sha2::{digest::generic_array::functional::FunctionalSequence, Digest, Sha256}; 12 | use tracing::{debug, error, trace, warn}; 13 | use uuid::Uuid; 14 | 15 | use crate::{ 16 | cohost::{attachment_id_to_url, Cacheable}, 17 | path::{ 18 | AttachmentsPath, SitePath, ATTACHMENTS_PATH_COHOST_AVATAR, ATTACHMENTS_PATH_COHOST_HEADER, 19 | ATTACHMENTS_PATH_COHOST_STATIC, ATTACHMENTS_PATH_ROOT, ATTACHMENTS_PATH_THUMBS, 20 | }, 21 | }; 22 | 23 | #[derive(Debug)] 24 | pub enum CachedFileResult { 25 | CachedPath(T), 26 | UncachedUrl(String), 27 | } 28 | 29 | impl CachedFileResult { 30 | pub fn site_path(&self) -> eyre::Result> { 31 | Ok(match self { 32 | Self::CachedPath(inner) => CachedFileResult::CachedPath(inner.site_path()?), 33 | Self::UncachedUrl(url) => CachedFileResult::UncachedUrl(url.to_owned()), 34 | }) 35 | } 36 | } 37 | 38 | impl CachedFileResult { 39 | pub fn base_relative_url(&self) -> String { 40 | match self { 41 | CachedFileResult::CachedPath(inner) => inner.base_relative_url(), 42 | CachedFileResult::UncachedUrl(url) => url.to_owned(), 43 | } 44 | } 45 | } 46 | 47 | pub trait AttachmentsContext { 48 | fn store(&self, input_path: &Path) -> eyre::Result; 49 | fn cache_imported(&self, url: &str, post_basename: &str) -> eyre::Result; 50 | fn cache_cohost_resource( 51 | &self, 52 | cacheable: &Cacheable, 53 | ) -> eyre::Result>; 54 | fn cache_cohost_thumb(&self, id: &str) -> eyre::Result>; 55 | } 56 | 57 | pub struct RealAttachmentsContext; 58 | impl AttachmentsContext for RealAttachmentsContext { 59 | #[tracing::instrument(skip(self))] 60 | fn store(&self, input_path: &Path) -> eyre::Result { 61 | let dir = ATTACHMENTS_PATH_ROOT.join(&Uuid::new_v4().to_string())?; 62 | create_dir_all(&dir)?; 63 | let filename = input_path.file_name().ok_or_eyre("no filename")?; 64 | let filename = filename.to_str().ok_or_eyre("unsupported filename")?; 65 | let path = dir.join(filename)?; 66 | copy(input_path, &path)?; 67 | 68 | Ok(path) 69 | } 70 | 71 | #[tracing::instrument(skip(self))] 72 | fn cache_imported(&self, url: &str, post_basename: &str) -> eyre::Result { 73 | let mut hash = Sha256::new(); 74 | hash.update(url); 75 | let hash = hash.finalize().map(|o| format!("{o:02x}")).join(""); 76 | let path = ATTACHMENTS_PATH_ROOT.join(&format!("imported-{post_basename}-{hash}"))?; 77 | trace!(?path); 78 | create_dir_all(&path)?; 79 | 80 | cache_imported_attachment(url, &path) 81 | } 82 | 83 | #[tracing::instrument(skip(self))] 84 | fn cache_cohost_resource( 85 | &self, 86 | cacheable: &Cacheable, 87 | ) -> eyre::Result> { 88 | match cacheable { 89 | Cacheable::Attachment { id, url } => { 90 | let redirect_url = attachment_id_to_url(id); 91 | let dir = &*ATTACHMENTS_PATH_ROOT; 92 | let path = dir.join(id)?; 93 | create_dir_all(&path)?; 94 | 95 | if cache_cohost_attachment(&redirect_url, &path, None)? { 96 | Ok(CachedFileResult::CachedPath(cached_attachment_url( 97 | id, dir, 98 | )?)) 99 | } else if let Some(original_url) = url { 100 | Ok(CachedFileResult::UncachedUrl((*original_url).to_owned())) 101 | } else { 102 | Ok(CachedFileResult::UncachedUrl(redirect_url)) 103 | } 104 | } 105 | 106 | Cacheable::Static { filename, url } => { 107 | let dir = &*ATTACHMENTS_PATH_COHOST_STATIC; 108 | create_dir_all(dir)?; 109 | let path = dir.join(filename)?; 110 | trace!(?path); 111 | 112 | cache_other_cohost_resource(url, &path).map(CachedFileResult::CachedPath) 113 | } 114 | 115 | Cacheable::Avatar { filename, url } => { 116 | let dir = &*ATTACHMENTS_PATH_COHOST_AVATAR; 117 | create_dir_all(dir)?; 118 | let path = dir.join(filename)?; 119 | trace!(?path); 120 | 121 | cache_other_cohost_resource(url, &path).map(CachedFileResult::CachedPath) 122 | } 123 | 124 | Cacheable::Header { filename, url } => { 125 | let dir = &*ATTACHMENTS_PATH_COHOST_HEADER; 126 | create_dir_all(dir)?; 127 | let path = dir.join(filename)?; 128 | trace!(?path); 129 | 130 | cache_other_cohost_resource(url, &path).map(CachedFileResult::CachedPath) 131 | } 132 | } 133 | } 134 | 135 | #[tracing::instrument(skip(self))] 136 | fn cache_cohost_thumb(&self, id: &str) -> eyre::Result> { 137 | fn thumb(url: &str) -> String { 138 | format!("{url}?width=675") 139 | } 140 | 141 | let redirect_url = attachment_id_to_url(id); 142 | let dir = &*ATTACHMENTS_PATH_THUMBS; 143 | let path = dir.join(id)?; 144 | create_dir_all(&path)?; 145 | 146 | if cache_cohost_attachment(&redirect_url, &path, Some(thumb))? { 147 | Ok(CachedFileResult::CachedPath(cached_attachment_url( 148 | id, dir, 149 | )?)) 150 | } else { 151 | Ok(CachedFileResult::UncachedUrl(redirect_url)) 152 | } 153 | } 154 | } 155 | 156 | fn cached_attachment_url(id: &str, dir: &AttachmentsPath) -> eyre::Result { 157 | let path = dir.join(id)?; 158 | let mut entries = read_dir(&path)?; 159 | let Some(entry) = entries.next() else { 160 | bail!("directory is empty: {path:?}"); 161 | }; 162 | 163 | Ok(path.join_dir_entry(&entry?)?) 164 | } 165 | 166 | fn cache_imported_attachment(url: &str, path: &AttachmentsPath) -> eyre::Result { 167 | // if the attachment id directory exists... 168 | if let Ok(mut entries) = read_dir(&path) { 169 | // and the directory contains a file... 170 | if let Some(entry) = entries.next() { 171 | // and we can open the file... 172 | // TODO: move this logic into path module 173 | let path = path.join_dir_entry(&entry?)?; 174 | if let Ok(mut file) = File::open(&path) { 175 | trace!("cache hit: {url}"); 176 | // check if we can read the file. 177 | let mut result = Vec::default(); 178 | file.read_to_end(&mut result)?; 179 | return Ok(path); 180 | } 181 | } 182 | } 183 | 184 | trace!("cache miss"); 185 | debug!("downloading attachment"); 186 | 187 | let response = reqwest::blocking::get(url)?; 188 | let extension = match response.headers().get("Content-Type") { 189 | Some(x) if x == "image/gif" => "gif", 190 | Some(x) if x == "image/jpeg" => "jpg", 191 | Some(x) if x == "image/png" => "png", 192 | Some(x) if x == "image/svg+xml" => "svg", 193 | Some(x) if x == "image/webp" => "webp", 194 | other => { 195 | warn!("unknown attachment mime type: {other:?}"); 196 | "bin" 197 | } 198 | }; 199 | let path = path.join(&format!("file.{extension}"))?; 200 | debug!(?path); 201 | 202 | let result = response.bytes()?.to_vec(); 203 | File::create(&path)?.write_all(&result)?; 204 | 205 | Ok(path) 206 | } 207 | 208 | /// given a cohost attachment redirect (`url`) and path to a uuid dir (`path`), 209 | /// return the cached attachment path (`path/original-filename.ext`). 210 | /// 211 | /// on cache miss, download the attachment from `url`, after first resolving the 212 | /// redirect and transforming the resultant url (`transform_redirect_target`). 213 | /// 214 | /// returns true iff the attachment exists and was successfully retrieved or 215 | /// stored in the attachment store. 216 | fn cache_cohost_attachment( 217 | url: &str, 218 | path: &AttachmentsPath, 219 | transform_redirect_target: Option String>, 220 | ) -> eyre::Result { 221 | // if the attachment id directory exists... 222 | if let Ok(mut entries) = read_dir(path) { 223 | // and the directory contains a file... 224 | if let Some(entry) = entries.next() { 225 | // and we can open the file... 226 | // TODO: move this logic into path module 227 | let path = path.join_dir_entry(&entry?)?; 228 | if let Ok(mut file) = File::open(&path) { 229 | trace!("cache hit: {url}"); 230 | // check if we can read the file. 231 | let mut result = Vec::default(); 232 | file.read_to_end(&mut result)?; 233 | return Ok(true); 234 | } 235 | } 236 | } 237 | 238 | trace!("cache miss: {url}"); 239 | debug!("downloading attachment"); 240 | 241 | let client = reqwest::blocking::Client::builder() 242 | .redirect(Policy::none()) 243 | .build()?; 244 | 245 | let mut retries = 4; 246 | let mut wait = Duration::from_secs(4); 247 | let mut redirect; 248 | let url = loop { 249 | let result = client.head(url).send(); 250 | match result { 251 | Ok(response) => redirect = response, 252 | Err(error) => { 253 | if retries == 0 { 254 | bail!("failed to get attachment redirect (after retries): {url}: {error:?}"); 255 | } else { 256 | warn!(?wait, url, ?error, "retrying failed request"); 257 | sleep(wait); 258 | wait *= 2; 259 | retries -= 1; 260 | continue; 261 | } 262 | } 263 | } 264 | let Some(url) = redirect.headers().get("location") else { 265 | // error without panicking if the chost refers to a 404 Not Found. 266 | // retry other requests if they are not client errors (http 4xx). 267 | // the attachment redirect endpoint occasionally returns 406 Not Acceptable, 268 | // so we retry those too. 269 | if redirect.status() == StatusCode::NOT_FOUND { 270 | error!( 271 | "bogus attachment redirect: http {}: {url}", 272 | redirect.status() 273 | ); 274 | return Ok(false); 275 | } else if redirect.status().is_client_error() 276 | && redirect.status() != StatusCode::NOT_ACCEPTABLE 277 | { 278 | bail!( 279 | "failed to get attachment redirect (no retries): http {}: {url}", 280 | redirect.status() 281 | ); 282 | } else if retries == 0 { 283 | bail!( 284 | "failed to get attachment redirect (after retries): http {}: {url}", 285 | redirect.status() 286 | ); 287 | } else { 288 | warn!(?wait, url, status = ?redirect.status(), "retrying failed request"); 289 | sleep(wait); 290 | wait *= 2; 291 | retries -= 1; 292 | continue; 293 | } 294 | }; 295 | break url.to_str()?; 296 | }; 297 | 298 | let Some((_, original_filename)) = url.rsplit_once("/") else { 299 | bail!("redirect target has no slashes: {url}"); 300 | }; 301 | let original_filename = urlencoding::decode(original_filename)?; 302 | 303 | // On Windows, `:` characters are not allowed in filenames (because it's used as a drive 304 | // separator) 305 | #[cfg(windows)] 306 | let original_filename = original_filename.replace(":", "-"); 307 | 308 | trace!("original filename: {original_filename}"); 309 | 310 | // cohost attachment redirects don’t preserve query params, so if we want to add any, 311 | // we need to add them to the destination of the redirect. 312 | // FIXME: this will silently misbehave if the endpoint introduces a second redirect! 313 | let url = if let Some(transform) = transform_redirect_target { 314 | let transformed_url = transform(url); 315 | trace!("transformed redirect target: {transformed_url}"); 316 | transformed_url 317 | } else { 318 | url.to_owned() 319 | }; 320 | 321 | let path = path.join(original_filename.as_ref())?; 322 | let result = reqwest::blocking::get(url)?.bytes()?.to_vec(); 323 | File::create(&path)?.write_all(&result)?; 324 | 325 | Ok(true) 326 | } 327 | 328 | fn cache_other_cohost_resource(url: &str, path: &AttachmentsPath) -> eyre::Result { 329 | // if we can open the cached file... 330 | if let Ok(mut file) = File::open(path) { 331 | trace!("cache hit: {url}"); 332 | // check if we can read the file. 333 | let mut result = Vec::default(); 334 | file.read_to_end(&mut result)?; 335 | return Ok(path.clone()); 336 | } 337 | 338 | trace!("cache miss"); 339 | debug!("downloading resource"); 340 | 341 | let response = reqwest::blocking::get(url)?; 342 | let result = response.bytes()?.to_vec(); 343 | File::create(path)?.write_all(&result)?; 344 | 345 | Ok(path.clone()) 346 | } 347 | -------------------------------------------------------------------------------- /src/cohost.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use serde_json::Value; 5 | use tracing::warn; 6 | 7 | use crate::Author; 8 | 9 | #[derive(Debug, Deserialize)] 10 | #[allow(non_snake_case)] 11 | pub struct PostsResponse { 12 | pub nItems: usize, 13 | pub nPages: usize, 14 | pub items: Vec, 15 | } 16 | 17 | #[derive(Debug, Deserialize, Serialize)] 18 | #[allow(non_snake_case)] 19 | pub struct Post { 20 | pub postId: usize, 21 | pub transparentShareOfPostId: Option, 22 | pub shareOfPostId: Option, 23 | pub filename: String, 24 | pub publishedAt: String, 25 | pub headline: String, 26 | pub tags: Vec, 27 | pub postingProject: PostingProject, 28 | pub shareTree: Vec, 29 | 30 | /// markdown source only, without attachments or asks. 31 | pub plainTextBody: String, 32 | 33 | /// post body (markdown), attachments, and asks (markdown). 34 | pub blocks: Vec, 35 | 36 | /// fully rendered versions of markdown blocks. 37 | pub astMap: AstMap, 38 | } 39 | 40 | #[derive(Debug, Deserialize, Serialize)] 41 | #[allow(non_snake_case)] 42 | pub struct PostingProject { 43 | pub handle: String, 44 | pub displayName: String, 45 | pub privacy: String, 46 | pub loggedOutPostVisibility: String, 47 | } 48 | 49 | #[derive(Debug, Deserialize, Serialize)] 50 | #[serde(tag = "type")] 51 | #[allow(non_snake_case)] 52 | pub enum Block { 53 | #[serde(rename = "markdown")] 54 | Markdown { markdown: Markdown }, 55 | 56 | #[serde(rename = "attachment")] 57 | Attachment { attachment: Attachment }, 58 | 59 | #[serde(rename = "attachment-row")] 60 | AttachmentRow { attachments: Vec }, 61 | 62 | #[serde(rename = "ask")] 63 | Ask { ask: Ask }, 64 | 65 | #[serde(untagged)] 66 | Unknown { 67 | #[serde(flatten)] 68 | fields: HashMap, 69 | }, 70 | } 71 | 72 | #[derive(Debug, Deserialize, Serialize)] 73 | #[allow(non_snake_case)] 74 | pub struct Markdown { 75 | pub content: String, 76 | } 77 | 78 | #[derive(Debug, Deserialize, Serialize)] 79 | #[serde(tag = "kind")] 80 | #[allow(non_snake_case)] 81 | pub enum Attachment { 82 | #[serde(rename = "image")] 83 | Image { 84 | attachmentId: String, 85 | altText: Option, 86 | width: Option, 87 | height: Option, 88 | }, 89 | 90 | #[serde(rename = "audio")] 91 | Audio { 92 | attachmentId: String, 93 | artist: String, 94 | title: String, 95 | }, 96 | 97 | #[serde(untagged)] 98 | Unknown { 99 | #[serde(flatten)] 100 | fields: HashMap, 101 | }, 102 | } 103 | 104 | #[derive(Debug, Deserialize, Serialize)] 105 | #[allow(non_snake_case)] 106 | pub struct Ask { 107 | pub content: String, 108 | pub askingProject: Option, 109 | pub anon: bool, 110 | pub loggedIn: bool, 111 | } 112 | 113 | #[derive(Debug, Deserialize, Serialize)] 114 | #[allow(non_snake_case)] 115 | pub struct AskingProject { 116 | pub handle: String, 117 | pub displayName: String, 118 | } 119 | 120 | #[derive(Debug, Deserialize, Serialize)] 121 | #[allow(non_snake_case)] 122 | pub struct AstMap { 123 | pub spans: Vec, 124 | } 125 | 126 | #[derive(Debug, Deserialize, Serialize)] 127 | #[allow(non_snake_case)] 128 | pub struct Span { 129 | pub ast: String, 130 | pub startIndex: usize, 131 | pub endIndex: usize, 132 | } 133 | 134 | #[derive(Debug, Deserialize)] 135 | #[allow(non_snake_case)] 136 | pub struct TrpcResponse { 137 | pub result: TrpcResult, 138 | } 139 | 140 | #[derive(Debug, Deserialize)] 141 | #[allow(non_snake_case)] 142 | pub struct TrpcResult { 143 | pub data: T, 144 | } 145 | 146 | #[derive(Debug, Deserialize)] 147 | #[allow(non_snake_case)] 148 | pub struct ListEditedProjectsResponse { 149 | pub projects: Vec, 150 | } 151 | 152 | #[derive(Debug, Deserialize)] 153 | #[allow(non_snake_case)] 154 | pub struct EditedProject { 155 | pub projectId: usize, 156 | pub handle: String, 157 | pub displayName: String, 158 | } 159 | 160 | #[derive(Debug, Deserialize)] 161 | #[allow(non_snake_case)] 162 | pub struct LoggedInResponse { 163 | pub projectId: usize, 164 | } 165 | 166 | #[derive(Debug, Deserialize)] 167 | #[allow(non_snake_case)] 168 | pub struct FollowedFeedResponse { 169 | pub projects: Vec, 170 | pub nextCursor: Option, 171 | } 172 | 173 | #[derive(Debug, Deserialize)] 174 | #[allow(non_snake_case)] 175 | pub struct FeedProject { 176 | pub project: FollowedProject, 177 | } 178 | 179 | #[derive(Debug, Deserialize)] 180 | #[allow(non_snake_case)] 181 | pub struct FollowedProject { 182 | pub projectId: usize, 183 | pub handle: String, 184 | } 185 | 186 | #[derive(Debug, Deserialize)] 187 | pub struct LikedPostsState { 188 | #[serde(rename = "liked-posts-feed")] 189 | pub liked_posts_feed: LikedPostsFeed, 190 | } 191 | 192 | #[derive(Debug, Deserialize)] 193 | #[allow(non_snake_case)] 194 | pub struct LikedPostsFeed { 195 | pub posts: Vec, 196 | pub paginationMode: PaginationMode, 197 | } 198 | 199 | #[derive(Debug, Deserialize)] 200 | #[allow(non_snake_case)] 201 | pub struct PaginationMode { 202 | pub currentSkip: usize, 203 | pub idealPageStride: usize, 204 | pub mode: String, 205 | pub morePagesForward: bool, 206 | pub morePagesBackward: bool, 207 | pub pageUrlFactoryName: String, 208 | pub refTimestamp: usize, 209 | } 210 | 211 | #[derive(Debug, Deserialize)] 212 | #[serde(tag = "type")] 213 | #[allow(non_snake_case)] 214 | pub enum Ast { 215 | #[serde(rename = "root")] 216 | Root { children: Vec }, 217 | 218 | #[serde(rename = "element")] 219 | Element { 220 | tagName: String, 221 | properties: HashMap, 222 | children: Vec, 223 | }, 224 | 225 | #[serde(rename = "text")] 226 | Text { value: String }, 227 | } 228 | 229 | #[derive(Debug, PartialEq)] 230 | pub enum Cacheable<'url> { 231 | /// cohost attachment (staging.cohostcdn.org/attachment or an equivalent redirect) 232 | Attachment { 233 | id: &'url str, 234 | url: Option<&'url str>, 235 | }, 236 | /// cohost emote, eggbug logo, or other static asset (cohost.org/static) 237 | Static { filename: &'url str, url: &'url str }, 238 | /// cohost avatar (static.cohostcdn.org/avatar) 239 | Avatar { filename: &'url str, url: &'url str }, 240 | /// cohost header (static.cohostcdn.org/header) 241 | Header { filename: &'url str, url: &'url str }, 242 | } 243 | 244 | impl<'url> Cacheable<'url> { 245 | pub fn attachment(id: &'url str, original_url: impl Into>) -> Self { 246 | Self::Attachment { 247 | id, 248 | url: original_url.into(), 249 | } 250 | } 251 | 252 | pub fn r#static(filename: &'url str, url: &'url str) -> Self { 253 | Self::Static { filename, url } 254 | } 255 | 256 | pub fn avatar(filename: &'url str, url: &'url str) -> Self { 257 | Self::Avatar { filename, url } 258 | } 259 | 260 | pub fn header(filename: &'url str, url: &'url str) -> Self { 261 | Self::Header { filename, url } 262 | } 263 | 264 | pub fn from_url(url: &'url str) -> Option { 265 | // attachment redirects just have the uuid in a fixed location. 266 | if let Some(attachment_id) = url 267 | .strip_prefix("https://cohost.org/rc/attachment-redirect/") 268 | .or_else(|| url.strip_prefix("https://cohost.org/api/v1/attachments/")) 269 | .filter(|id_plus| id_plus.len() >= 36) 270 | .map(|id_plus| &id_plus[..36]) 271 | { 272 | return Some(Self::attachment(attachment_id, url)); 273 | } 274 | // raw attachment urls have a mandatory trailing path component for the original filename, 275 | // preceded by a path component for the uuid, preceded by zero or more extra garbage path 276 | // components, which the server still accepts. people have used this in real posts. 277 | if let Some(attachment_id_etc) = 278 | url.strip_prefix("https://staging.cohostcdn.org/attachment/") 279 | { 280 | // remove query string, if any 281 | let attachment_id_etc = attachment_id_etc 282 | .split_once('?') 283 | .map(|(result, _query_string)| result) 284 | .unwrap_or(attachment_id_etc); 285 | // remove original filename 286 | if let Some(attachment_id_etc) = attachment_id_etc 287 | .rsplit_once('/') 288 | .map(|(result, _original_filename)| result) 289 | { 290 | // remove path components preceding uuid, if any 291 | let attachment_id = attachment_id_etc 292 | .rsplit_once('/') 293 | .map(|(_garbage, result)| result) 294 | .unwrap_or(attachment_id_etc); 295 | return Some(Self::attachment(attachment_id, url)); 296 | } 297 | } 298 | if let Some(static_filename) = url.strip_prefix("https://cohost.org/static/") { 299 | if static_filename.is_empty() { 300 | warn!(url, "skipping cohost static path without filename"); 301 | return None; 302 | } 303 | if static_filename.contains(['/', '?']) { 304 | warn!( 305 | url, 306 | "skipping cohost static path with unexpected slash or query string", 307 | ); 308 | return None; 309 | } 310 | return Some(Self::r#static(static_filename, url)); 311 | } 312 | if let Some(avatar_filename) = url.strip_prefix("https://staging.cohostcdn.org/avatar/") { 313 | if avatar_filename.is_empty() { 314 | warn!(url, "skipping cohost avatar path without filename"); 315 | return None; 316 | } 317 | if avatar_filename.contains('/') { 318 | warn!(url, "skipping cohost avatar path with unexpected slash"); 319 | return None; 320 | } 321 | if let Some((avatar_filename, _query_string)) = avatar_filename.split_once('?') { 322 | // some chosts use avatars with query parameters to resize etc, such as 323 | // . 324 | // to make things simpler for us, we only bother archiving the original. 325 | // if the chost relies on the intrinsic size of the resized avatar, tough luck. 326 | warn!(url, "dropping query string from cohost avatar path"); 327 | return Some(Self::avatar(avatar_filename, url)); 328 | } 329 | return Some(Self::avatar(avatar_filename, url)); 330 | } 331 | if let Some(header_filename) = url.strip_prefix("https://staging.cohostcdn.org/header/") { 332 | if header_filename.is_empty() { 333 | warn!(url, "skipping cohost header path without filename"); 334 | return None; 335 | } 336 | if header_filename.contains('/') { 337 | warn!(url, "skipping cohost header path with unexpected slash"); 338 | return None; 339 | } 340 | if let Some((header_filename, _query_string)) = header_filename.split_once('?') { 341 | // some chosts use headers with query parameters to resize etc, such as 342 | // . 343 | // to make things simpler for us, we only bother archiving the original. 344 | // if the chost relies on the intrinsic size of the resized header, tough luck. 345 | warn!(url, "dropping query string from cohost header path"); 346 | return Some(Self::header(header_filename, url)); 347 | } 348 | return Some(Self::header(header_filename, url)); 349 | } 350 | 351 | None 352 | } 353 | } 354 | 355 | pub fn attachment_id_to_url(id: &str) -> String { 356 | format!("https://cohost.org/rc/attachment-redirect/{id}") 357 | } 358 | 359 | #[test] 360 | fn test_cacheable() { 361 | assert_eq!( 362 | Cacheable::from_url( 363 | "https://cohost.org/rc/attachment-redirect/44444444-4444-4444-4444-444444444444?query", 364 | ), 365 | Some(Cacheable::Attachment { 366 | id: "44444444-4444-4444-4444-444444444444", 367 | url: "https://cohost.org/rc/attachment-redirect/44444444-4444-4444-4444-444444444444?query".into(), 368 | }), 369 | ); 370 | assert_eq!( 371 | Cacheable::from_url( 372 | "https://cohost.org/api/v1/attachments/44444444-4444-4444-4444-444444444444?query", 373 | ), 374 | Some(Cacheable::Attachment { 375 | id: "44444444-4444-4444-4444-444444444444", 376 | url: "https://cohost.org/api/v1/attachments/44444444-4444-4444-4444-444444444444?query" 377 | .into(), 378 | }), 379 | ); 380 | assert_eq!( 381 | Cacheable::from_url( 382 | "https://staging.cohostcdn.org/attachment/44444444-4444-4444-4444-444444444444/file.jpg?query", 383 | ), 384 | Some(Cacheable::Attachment { 385 | id: "44444444-4444-4444-4444-444444444444", 386 | url: "https://staging.cohostcdn.org/attachment/44444444-4444-4444-4444-444444444444/file.jpg?query".into(), 387 | }), 388 | ); 389 | assert_eq!( 390 | Cacheable::from_url( 391 | "https://staging.cohostcdn.org/attachment/https://staging.cohostcdn.org/attachment/d99a2208-5a1d-4212-b524-1d6e3493d6f4/silent_hills_pt_screen_20140814_02.jpg?query", 392 | ), 393 | Some(Cacheable::Attachment { 394 | id: "d99a2208-5a1d-4212-b524-1d6e3493d6f4", 395 | url: "https://staging.cohostcdn.org/attachment/https://staging.cohostcdn.org/attachment/d99a2208-5a1d-4212-b524-1d6e3493d6f4/silent_hills_pt_screen_20140814_02.jpg?query".into(), 396 | }), 397 | ); 398 | assert_eq!( 399 | Cacheable::from_url("https://cohost.org/static/f0c56e99113f1a0731b4.svg"), 400 | Some(Cacheable::Static { 401 | filename: "f0c56e99113f1a0731b4.svg", 402 | url: "https://cohost.org/static/f0c56e99113f1a0731b4.svg", 403 | }), 404 | ); 405 | assert_eq!(Cacheable::from_url("https://cohost.org/static/"), None); 406 | assert_eq!( 407 | Cacheable::from_url("https://cohost.org/static/f0c56e99113f1a0731b4.svg?query"), 408 | None 409 | ); 410 | assert_eq!( 411 | Cacheable::from_url("https://cohost.org/static/subdir/f0c56e99113f1a0731b4.svg"), 412 | None 413 | ); 414 | } 415 | 416 | impl From<&PostingProject> for Author { 417 | fn from(project: &PostingProject) -> Self { 418 | Self { 419 | href: format!("https://cohost.org/{}", project.handle), 420 | name: if project.displayName.is_empty() { 421 | format!("@{}", project.handle) 422 | } else { 423 | format!("{} (@{})", project.displayName, project.handle) 424 | }, 425 | display_name: project.displayName.clone(), 426 | display_handle: format!("@{}", project.handle), 427 | } 428 | } 429 | } 430 | 431 | #[test] 432 | fn test_author_from_posting_project() { 433 | assert_eq!( 434 | Author::from(&PostingProject { 435 | handle: "staff".to_owned(), 436 | displayName: "cohost dot org".to_owned(), 437 | privacy: "[any value]".to_owned(), 438 | loggedOutPostVisibility: "[any value]".to_owned(), 439 | }), 440 | Author { 441 | href: "https://cohost.org/staff".to_owned(), 442 | name: "cohost dot org (@staff)".to_owned(), 443 | display_name: "cohost dot org".to_owned(), 444 | display_handle: "@staff".to_owned(), 445 | } 446 | ); 447 | assert_eq!( 448 | Author::from(&PostingProject { 449 | handle: "VinDuv".to_owned(), 450 | displayName: "".to_owned(), 451 | privacy: "[any value]".to_owned(), 452 | loggedOutPostVisibility: "[any value]".to_owned(), 453 | }), 454 | Author { 455 | href: "https://cohost.org/VinDuv".to_owned(), 456 | name: "@VinDuv".to_owned(), 457 | display_name: "".to_owned(), 458 | display_handle: "@VinDuv".to_owned(), 459 | } 460 | ); 461 | } 462 | -------------------------------------------------------------------------------- /src/command/attach.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::create_dir_all, path::Path}; 2 | 3 | use clap::Parser as _; 4 | use jane_eyre::eyre; 5 | use tracing::info; 6 | 7 | use crate::{ 8 | attachments::{AttachmentsContext, RealAttachmentsContext}, 9 | migrations::run_migrations, 10 | path::ATTACHMENTS_PATH_ROOT, 11 | Command, 12 | }; 13 | 14 | #[derive(clap::Args, Debug)] 15 | pub struct Attach { 16 | paths: Vec, 17 | } 18 | 19 | #[tokio::main] 20 | pub async fn main() -> eyre::Result<()> { 21 | let Command::Attach(args) = Command::parse() else { 22 | unreachable!("guaranteed by subcommand call in entry point") 23 | }; 24 | run_migrations()?; 25 | create_dir_all(&*ATTACHMENTS_PATH_ROOT)?; 26 | 27 | for path in args.paths { 28 | let attachment_path = RealAttachmentsContext.store(&Path::new(&path))?; 29 | info!( 30 | "created attachment: <{}>", 31 | attachment_path.site_path()?.base_relative_url() 32 | ); 33 | } 34 | 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /src/command/cohost2json.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env::{self}, 3 | fs::{create_dir_all, File}, 4 | io::Write, 5 | path::Path, 6 | str, 7 | }; 8 | 9 | use clap::Parser as _; 10 | use jane_eyre::eyre::{self, bail, OptionExt}; 11 | use reqwest::{ 12 | header::{self, HeaderMap, HeaderValue}, 13 | Client, 14 | }; 15 | use scraper::{selector::Selector, Html}; 16 | use tracing::{error, info, warn}; 17 | 18 | use crate::{ 19 | cohost::{ 20 | LikedPostsState, ListEditedProjectsResponse, LoggedInResponse, Post, PostsResponse, 21 | TrpcResponse, 22 | }, 23 | http::{get_json, get_with_retries}, 24 | Command, 25 | }; 26 | 27 | #[derive(clap::Args, Debug)] 28 | pub struct Cohost2json { 29 | pub project_name: String, 30 | pub path_to_chosts: String, 31 | 32 | #[arg(long, help = "dump liked posts (requires COHOST_COOKIE)")] 33 | pub liked: bool, 34 | } 35 | 36 | #[tokio::main] 37 | pub async fn main() -> eyre::Result<()> { 38 | let Command::Cohost2json(args) = Command::parse() else { 39 | unreachable!("guaranteed by subcommand call in entry point") 40 | }; 41 | real_main(args).await 42 | } 43 | 44 | pub async fn real_main(args: Cohost2json) -> eyre::Result<()> { 45 | let requested_project = args.project_name; 46 | let output_path = args.path_to_chosts; 47 | let output_path = Path::new(&output_path); 48 | let mut dump_liked = args.liked; 49 | create_dir_all(output_path)?; 50 | 51 | let client = if let Ok(connect_sid) = env::var("COHOST_COOKIE") { 52 | info!("COHOST_COOKIE is set; output will include private or logged-in-only chosts!"); 53 | let mut cookie_value = HeaderValue::from_str(&format!("connect.sid={connect_sid}"))?; 54 | cookie_value.set_sensitive(true); 55 | let mut headers = HeaderMap::new(); 56 | headers.insert(header::COOKIE, cookie_value); 57 | let client = Client::builder().default_headers(headers).build()?; 58 | 59 | let edited_projects = get_json::>( 60 | &client, 61 | "https://cohost.org/api/v1/trpc/projects.listEditedProjects", 62 | ) 63 | .await? 64 | .result 65 | .data 66 | .projects; 67 | let logged_in_project_id = get_json::>( 68 | &client, 69 | "https://cohost.org/api/v1/trpc/login.loggedIn", 70 | ) 71 | .await? 72 | .result 73 | .data 74 | .projectId; 75 | let logged_in_project = edited_projects 76 | .iter() 77 | .find(|project| project.projectId == logged_in_project_id) 78 | .ok_or_eyre("you seem to be logged in as a project you don’t own")?; 79 | info!( 80 | "you are currently logged in as @{}", 81 | logged_in_project.handle 82 | ); 83 | 84 | if let Some(requested_project) = edited_projects 85 | .iter() 86 | .find(|project| project.handle == requested_project) 87 | { 88 | if requested_project.projectId != logged_in_project_id { 89 | bail!( 90 | "you wanted to dump chosts for @{}, but you are logged in as @{}", 91 | requested_project.handle, 92 | logged_in_project.handle, 93 | ); 94 | } else { 95 | info!( 96 | "dumping chosts for @{}, which you own and are logged in as", 97 | requested_project.handle 98 | ); 99 | } 100 | } else { 101 | info!( 102 | "dumping chosts for @{}, which you don’t own", 103 | requested_project 104 | ); 105 | if dump_liked { 106 | warn!( 107 | "you requested liked chosts, but not your own logged in project (@{}); skipping liked chosts", 108 | logged_in_project.handle 109 | ); 110 | dump_liked = false; 111 | } 112 | } 113 | 114 | client 115 | } else { 116 | info!("COHOST_COOKIE not set; output will exclude private or logged-in-only chosts!"); 117 | Client::builder().build()? 118 | }; 119 | 120 | let mut own_chosts = File::create(output_path.join("own_chosts.txt"))?; 121 | for page in 0.. { 122 | let url = 123 | format!("https://cohost.org/api/v1/project/{requested_project}/posts?page={page}"); 124 | let response: PostsResponse = get_json(&client, &url).await?; 125 | 126 | // nItems may be zero if none of the posts on this page are currently visible, 127 | // but nPages will only be zero when we have run out of pages. 128 | if response.nPages == 0 { 129 | break; 130 | } 131 | 132 | for post_value in response.items { 133 | let post: Post = serde_json::from_value(post_value.clone())?; 134 | let filename = format!("{}.json", post.postId); 135 | let path = output_path.join(&filename); 136 | info!("Writing {path:?}"); 137 | let output_file = File::create(path)?; 138 | serde_json::to_writer(output_file, &post_value)?; 139 | writeln!(own_chosts, "{filename}")?; 140 | } 141 | } 142 | 143 | if dump_liked { 144 | if env::var("COHOST_COOKIE").is_err() { 145 | warn!("requested liked posts, but COHOST_COOKIE not provided - skipping"); 146 | } else { 147 | info!("dumping liked chosts for @{}", requested_project); 148 | let mut liked_chosts = File::create(output_path.join("liked_chosts.txt"))?; 149 | for liked_page in 0.. { 150 | let url = format!( 151 | "https://cohost.org/rc/liked-posts?skipPosts={}", 152 | liked_page * 20 153 | ); 154 | 155 | let liked_store = get_with_retries(&client, &url, |body| { 156 | let body = str::from_utf8(&body)?; 157 | let document = Html::parse_document(body); 158 | let selector = Selector::parse("script#__COHOST_LOADER_STATE__") 159 | .expect("guaranteed by argument"); 160 | let node = document 161 | .select(&selector) 162 | .next() 163 | .ok_or_eyre("failed to find script#__COHOST_LOADER_STATE__")?; 164 | let texts = node.text().collect::>(); 165 | let (text, rest) = texts 166 | .split_first() 167 | .ok_or_eyre("script element has no text nodes")?; 168 | if !rest.is_empty() { 169 | error!("script element has more than one text node"); 170 | } 171 | let liked_store = 172 | serde_json::from_str::(text)?.liked_posts_feed; 173 | Ok(liked_store) 174 | }) 175 | .await?; 176 | 177 | for post in liked_store.posts { 178 | let filename = format!("{}.json", post.postId); 179 | let path = output_path.join(&filename); 180 | info!("Writing {path:?}"); 181 | let output_file = File::create(path)?; 182 | serde_json::to_writer(output_file, &post)?; 183 | writeln!(liked_chosts, "{filename}")?; 184 | } 185 | 186 | if !liked_store.paginationMode.morePagesForward { 187 | break; 188 | } 189 | } 190 | } 191 | } 192 | 193 | Ok(()) 194 | } 195 | -------------------------------------------------------------------------------- /src/command/cohost_archive.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env::{self, set_current_dir}, 3 | fs::{create_dir_all, exists, File}, 4 | io::Write, 5 | path::Path, 6 | }; 7 | 8 | use clap::Parser as _; 9 | use jane_eyre::eyre::{self, Context, OptionExt}; 10 | use reqwest::{ 11 | header::{self, HeaderMap, HeaderValue}, 12 | Client, 13 | }; 14 | use tracing::{info, warn}; 15 | 16 | use crate::{ 17 | cohost::{FollowedFeedResponse, ListEditedProjectsResponse, LoggedInResponse, TrpcResponse}, 18 | command::{cohost2autost::Cohost2autost, cohost2json::Cohost2json}, 19 | Command, RunDetailsWriter, 20 | }; 21 | 22 | #[derive(clap::Args, Debug)] 23 | pub struct CohostArchive { 24 | output_path: String, 25 | project_names: Vec, 26 | 27 | #[arg(long, help = "archive your liked posts")] 28 | liked: bool, 29 | } 30 | 31 | #[tokio::main] 32 | pub async fn main() -> eyre::Result<()> { 33 | let Command::CohostArchive(args) = Command::parse() else { 34 | unreachable!("guaranteed by subcommand call in entry point") 35 | }; 36 | create_dir_all(&args.output_path)?; 37 | set_current_dir(args.output_path)?; 38 | let mut run_details = RunDetailsWriter::create_in(".")?; 39 | 40 | let connect_sid = env::var("COHOST_COOKIE").wrap_err("failed to get COHOST_COOKIE")?; 41 | info!("COHOST_COOKIE is set; output will include private or logged-in-only chosts!"); 42 | let mut cookie_value = HeaderValue::from_str(&format!("connect.sid={connect_sid}"))?; 43 | cookie_value.set_sensitive(true); 44 | let mut headers = HeaderMap::new(); 45 | headers.insert(header::COOKIE, cookie_value); 46 | let client = Client::builder().default_headers(headers).build()?; 47 | 48 | info!("GET https://cohost.org/api/v1/trpc/projects.listEditedProjects"); 49 | let edited_projects = client 50 | .get("https://cohost.org/api/v1/trpc/projects.listEditedProjects") 51 | .send() 52 | .await? 53 | .json::>() 54 | .await? 55 | .result 56 | .data 57 | .projects; 58 | info!("GET https://cohost.org/api/v1/trpc/login.loggedIn"); 59 | let logged_in_project_id = client 60 | .get("https://cohost.org/api/v1/trpc/login.loggedIn") 61 | .send() 62 | .await? 63 | .json::>() 64 | .await? 65 | .result 66 | .data 67 | .projectId; 68 | let logged_in_project = edited_projects 69 | .iter() 70 | .find(|project| project.projectId == logged_in_project_id) 71 | .ok_or_eyre("you seem to be logged in as a project you don’t own")?; 72 | info!( 73 | "you are currently logged in as @{}", 74 | logged_in_project.handle 75 | ); 76 | 77 | run_details.write( 78 | "cohost-archive.logged_in_project.handle", 79 | &*logged_in_project.handle, 80 | )?; 81 | run_details.write( 82 | "cohost-archive.logged_in_project.projectId", 83 | i64::try_from(logged_in_project.projectId)?, 84 | )?; 85 | 86 | let project_names = if args.project_names.is_empty() { 87 | info!("GET https://cohost.org/api/v1/trpc/projects.followedFeed.query?input=%7B%22sortOrder%22:%22followed-asc%22,%22limit%22:1000,%22beforeTimestamp%22:1735199148430%7D"); 88 | let followed_feed = client 89 | .get("https://cohost.org/api/v1/trpc/projects.followedFeed.query?input=%7B%22sortOrder%22:%22followed-asc%22,%22limit%22:1000,%22beforeTimestamp%22:1735199148430%7D") 90 | .send() 91 | .await? 92 | .json::>() 93 | .await? 94 | .result 95 | .data; 96 | assert_eq!( 97 | followed_feed.nextCursor, None, 98 | "too many follows (needs pagination)" 99 | ); 100 | let mut handles = followed_feed 101 | .projects 102 | .into_iter() 103 | .map(|p| p.project.handle) 104 | .collect::>(); 105 | handles.sort(); 106 | handles.insert(0, logged_in_project.handle.clone()); 107 | handles 108 | } else { 109 | args.project_names 110 | }; 111 | info!(?project_names, "starting archive"); 112 | 113 | let project_names = project_names 114 | .into_iter() 115 | .filter(|handle| { 116 | let is_edited_project = edited_projects.iter().any(|p| p.handle == *handle); 117 | let is_logged_in_project = logged_in_project.handle == *handle; 118 | if is_edited_project && !is_logged_in_project { 119 | warn!( 120 | handle, 121 | "skipping project that you edit but are not logged in as" 122 | ); 123 | } 124 | is_edited_project == is_logged_in_project 125 | }) 126 | .collect::>(); 127 | 128 | if args.liked && !project_names.contains(&logged_in_project.handle) { 129 | warn!( 130 | "you requested liked chosts, but not your own logged in project (@{}); skipping liked chosts", 131 | logged_in_project.handle 132 | ); 133 | } 134 | 135 | for project_name in project_names { 136 | // only try to archive likes for the logged-in project 137 | let archive_likes = args.liked && project_name == logged_in_project.handle; 138 | archive_cohost_project(&project_name, archive_likes).await?; 139 | } 140 | 141 | run_details.ok()?; 142 | Ok(()) 143 | } 144 | 145 | #[tracing::instrument(level = "error")] 146 | async fn archive_cohost_project(project_name: &str, archive_likes: bool) -> eyre::Result<()> { 147 | info!("archiving"); 148 | let project_path = Path::new(project_name); 149 | create_dir_all(project_path)?; 150 | set_current_dir(project_path)?; 151 | 152 | let mut autost_toml = File::create("autost.toml")?; 153 | writeln!(autost_toml, r#"base_url = "/""#)?; 154 | writeln!(autost_toml, r#"external_base_url = "https://example.com/""#)?; 155 | writeln!(autost_toml, r#"site_title = "@{project_name}""#)?; 156 | writeln!(autost_toml, r#"other_self_authors = []"#)?; 157 | writeln!(autost_toml, r#"interesting_tags = []"#)?; 158 | writeln!(autost_toml, r#"[self_author]"#)?; 159 | writeln!(autost_toml, r#"href = "https://cohost.org/{project_name}""#)?; 160 | writeln!(autost_toml, r#"name = """#)?; 161 | writeln!(autost_toml, r#"display_name = """#)?; 162 | writeln!(autost_toml, r#"display_handle = "@{project_name}""#)?; 163 | writeln!(autost_toml, r#"[[nav]]"#)?; 164 | writeln!(autost_toml, r#"href = ".""#)?; 165 | writeln!(autost_toml, r#"text = "posts""#)?; 166 | 167 | if !exists("cohost2json.done")? { 168 | info!("autost cohost2json {project_name} chosts"); 169 | crate::command::cohost2json::real_main(Cohost2json { 170 | project_name: project_name.to_owned(), 171 | path_to_chosts: "chosts".to_owned(), 172 | liked: archive_likes, 173 | }) 174 | .await?; 175 | File::create("cohost2json.done")?; 176 | } 177 | 178 | if !exists("cohost2autost.done")? { 179 | info!("autost cohost2autost chosts"); 180 | crate::command::cohost2autost::main(Cohost2autost { 181 | path_to_chosts: "chosts".to_owned(), 182 | specific_chost_filenames: vec![], 183 | })?; 184 | File::create("cohost2autost.done")?; 185 | } 186 | 187 | set_current_dir("..")?; 188 | 189 | Ok(()) 190 | } 191 | -------------------------------------------------------------------------------- /src/command/new.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{create_dir_all, read_dir, File}, 3 | io::Write, 4 | path::Path, 5 | }; 6 | 7 | use jane_eyre::eyre::{self, bail}; 8 | use tracing::info; 9 | 10 | #[derive(clap::Args, Debug)] 11 | pub struct New { 12 | path: Option, 13 | } 14 | 15 | pub fn main(args: New) -> eyre::Result<()> { 16 | let path = args.path.unwrap_or(".".to_owned()); 17 | let path = Path::new(&path); 18 | info!("creating new site in {path:?}"); 19 | 20 | create_dir_all(path)?; 21 | for entry in read_dir(path)? { 22 | bail!("directory is not empty: {:?}", entry?.path()); 23 | } 24 | let mut settings = File::create_new(path.join("autost.toml"))?; 25 | settings.write_all(include_bytes!("../../autost.toml.example"))?; 26 | 27 | Ok(()) 28 | } 29 | -------------------------------------------------------------------------------- /src/command/server.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::File, 3 | io::{self, Write as _}, 4 | }; 5 | 6 | use crate::{ 7 | command::render::render_all, 8 | output::ThreadsContentTemplate, 9 | path::{PostsPath, POSTS_PATH_ROOT}, 10 | render_markdown, 11 | rocket_eyre::{self, EyreReport}, 12 | Command, PostMeta, TemplatedPost, Thread, SETTINGS, 13 | }; 14 | 15 | use askama_rocket::Template; 16 | use chrono::{SecondsFormat, Utc}; 17 | use clap::Parser as _; 18 | use jane_eyre::eyre::{Context, OptionExt as _}; 19 | use rocket::{ 20 | form::Form, 21 | fs::{FileServer, Options}, 22 | get, post, 23 | response::{content, Redirect}, 24 | routes, Config, FromForm, Responder, 25 | }; 26 | 27 | #[derive(clap::Args, Debug)] 28 | pub struct Server { 29 | #[arg(short, long)] 30 | port: Option, 31 | } 32 | #[derive(askama_rocket::Template)] 33 | #[template(path = "compose.html")] 34 | struct ComposeTemplate { 35 | source: String, 36 | } 37 | #[get("/compose?&&")] 38 | fn compose_route( 39 | reply_to: Option, 40 | tags: Vec, 41 | is_transparent_share: Option, 42 | ) -> rocket_eyre::Result { 43 | let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); 44 | 45 | let references = if let Some(reply_to) = reply_to { 46 | let reply_to = POSTS_PATH_ROOT 47 | .join(&reply_to) 48 | .map_err(EyreReport::BadRequest)?; 49 | let post = TemplatedPost::load(&reply_to)?; 50 | let thread = Thread::try_from(post)?; 51 | thread.posts.into_iter().flat_map(|x| x.path).collect() 52 | } else { 53 | vec![] 54 | }; 55 | let is_transparent_share = is_transparent_share.unwrap_or_default(); 56 | 57 | let meta = PostMeta { 58 | archived: None, 59 | references, 60 | title: (!is_transparent_share).then_some("headline".to_owned()), 61 | published: Some(now), 62 | author: SETTINGS.self_author.clone(), 63 | tags, 64 | is_transparent_share, 65 | }; 66 | let meta = meta.render().wrap_err("failed to render template")?; 67 | 68 | let source = if is_transparent_share { 69 | meta 70 | } else { 71 | format!("{meta}\n\npost body (accepts markdown!)") 72 | }; 73 | 74 | Ok(ComposeTemplate { source }) 75 | } 76 | 77 | #[derive(FromForm, Debug)] 78 | struct Body<'r> { 79 | source: &'r str, 80 | } 81 | 82 | #[post("/preview", data = "")] 83 | fn preview_route(body: Form>) -> rocket_eyre::Result> { 84 | let unsafe_source = body.source; 85 | let unsafe_html = render_markdown(unsafe_source); 86 | let post = TemplatedPost::filter(&unsafe_html, None)?; 87 | let thread = Thread::try_from(post)?; 88 | Ok(content::RawHtml( 89 | ThreadsContentTemplate::render_normal(&thread).wrap_err("failed to render template")?, 90 | )) 91 | } 92 | 93 | #[derive(Responder)] 94 | enum PublishResponse { 95 | Redirect(Box), 96 | Text(String), 97 | } 98 | 99 | #[post("/publish?", data = "")] 100 | fn publish_route(js: Option, body: Form>) -> rocket_eyre::Result { 101 | let js = js.unwrap_or_default(); 102 | let unsafe_source = body.source; 103 | 104 | // try rendering the post before writing it, to catch any errors. 105 | let unsafe_html = render_markdown(unsafe_source); 106 | let post = TemplatedPost::filter(&unsafe_html, None)?; 107 | let _thread = Thread::try_from(post)?; 108 | 109 | // cohost post ids are all less than 10000000. 110 | let (mut file, path) = (10000000..) 111 | .map(|id| { 112 | let path = PostsPath::markdown_post_path(id); 113 | File::create_new(&path).map(|file| (file, path)) 114 | }) 115 | .find(|file| !matches!(file, Err(error) if error.kind() == io::ErrorKind::AlreadyExists)) 116 | .expect("too many posts :(") 117 | .wrap_err("failed to create post")?; 118 | 119 | file.write_all(unsafe_source.as_bytes()) 120 | .wrap_err("failed to write post file")?; 121 | render_all()?; 122 | 123 | let post = TemplatedPost::load(&path)?; 124 | let _thread = Thread::try_from(post)?; 125 | let url = path 126 | .rendered_path()? 127 | .ok_or_eyre("path has no rendered path")? 128 | .internal_url(); 129 | 130 | // fetch api does not expose the redirect ‘location’ to scripts. 131 | // 132 | if js { 133 | Ok(PublishResponse::Text(url)) 134 | } else { 135 | Ok(PublishResponse::Redirect(Box::new(Redirect::to(url)))) 136 | } 137 | } 138 | 139 | // lower than FileServer, which uses rank 10 by default 140 | #[get("/", rank = 100)] 141 | fn root_route() -> Redirect { 142 | Redirect::to(&SETTINGS.base_url) 143 | } 144 | 145 | /// - site routes (all under `base_url`) 146 | /// - `GET compose` (`compose_route`) 147 | /// - `?reply_to=` (optional; zero or one) 148 | /// - `?tags=` (optional; any number of times) 149 | /// - `?is_transparent_share` (optional) 150 | /// - `POST preview` (`preview_route`) 151 | /// - `POST publish` (`publish_route`) 152 | /// - `GET ` (`static_route`) 153 | /// - `GET /` (`root_route`) 154 | #[rocket::main] 155 | pub async fn main() -> jane_eyre::eyre::Result<()> { 156 | let Command::Server(args) = Command::parse() else { 157 | unreachable!("guaranteed by subcommand call in entry point") 158 | }; 159 | 160 | render_all()?; 161 | 162 | let port = args.port.unwrap_or(SETTINGS.server_port()); 163 | let _rocket = rocket::custom( 164 | Config::figment() 165 | .merge(("port", port)) 166 | .merge(("address", "::1")), 167 | ) 168 | .mount( 169 | &SETTINGS.base_url, 170 | routes![compose_route, preview_route, publish_route], 171 | ) 172 | .mount("/", routes![root_route]) 173 | // serve attachments out of main attachment store, in case we need to preview a post 174 | // that refers to an attachment for the first time. otherwise they will 404, since 175 | // render won’t have hard-linked it into the site output dir. 176 | .mount( 177 | format!("{}attachments/", SETTINGS.base_url), 178 | FileServer::new( 179 | "./attachments", 180 | // DotFiles because attachment filenames can start with `.` 181 | // NormalizeDirs because relative links rely on directories ending with a `/` 182 | Options::Index | Options::DotFiles | Options::NormalizeDirs, 183 | ) 184 | .rank(9), 185 | ) 186 | // serve all other files out of `SITE_PATH_ROOT`. 187 | .mount( 188 | &SETTINGS.base_url, 189 | FileServer::new( 190 | "./site", 191 | // DotFiles because attachment filenames can start with `.` 192 | // NormalizeDirs because relative links rely on directories ending with a `/` 193 | Options::Index | Options::DotFiles | Options::NormalizeDirs, 194 | ) 195 | .rank(10), 196 | ) 197 | .launch() 198 | .await; 199 | 200 | Ok(()) 201 | } 202 | -------------------------------------------------------------------------------- /src/css.rs: -------------------------------------------------------------------------------- 1 | use cssparser::{ 2 | BasicParseError, BasicParseErrorKind, ParseError, Parser, ParserInput, ToCss, Token, 3 | }; 4 | use tracing::warn; 5 | 6 | #[derive(Debug)] 7 | pub enum InlineStyleToken { 8 | Url(String), 9 | String(String), 10 | Other(String), 11 | } 12 | 13 | pub fn parse_inline_style(style: &str) -> Vec { 14 | let mut input = ParserInput::new(style); 15 | let mut parser = Parser::new(&mut input); 16 | 17 | parse(&mut parser) 18 | } 19 | 20 | pub fn serialise_inline_style(tokens: &[InlineStyleToken]) -> String { 21 | tokens 22 | .iter() 23 | .map(|t| match t { 24 | InlineStyleToken::Url(url) => format!("url({})", serialise_string_value(url)), 25 | InlineStyleToken::String(string) => serialise_string_value(string), 26 | InlineStyleToken::Other(other) => other.to_owned(), 27 | }) 28 | .collect::>() 29 | .join("") 30 | } 31 | 32 | fn parse<'i>(parser: &'i mut Parser) -> Vec { 33 | let mut result = vec![]; 34 | loop { 35 | let token = match parser.next_including_whitespace_and_comments() { 36 | Ok(token) => token.clone(), 37 | Err(ref error @ BasicParseError { ref kind, .. }) => match kind { 38 | BasicParseErrorKind::UnexpectedToken(token) => { 39 | warn!(?error, "css parse error"); 40 | token.clone() 41 | } 42 | BasicParseErrorKind::EndOfInput => break, 43 | other => { 44 | warn!(error = ?other, "css parse error"); 45 | continue; 46 | } 47 | }, 48 | }; 49 | let function_name = match &token { 50 | Token::Function(name) => Some(&**name), 51 | _ => None, 52 | }; 53 | if function_name == Some("url") { 54 | assert!(matches!(token, Token::Function(..))); 55 | let nested_result = parser 56 | .parse_nested_block(|p| Ok::<_, ParseError<()>>(parse(p))) 57 | .expect("guaranteed by closure"); 58 | for token in nested_result { 59 | match token { 60 | InlineStyleToken::String(value) => { 61 | result.push(InlineStyleToken::Url(value)); 62 | } 63 | other => { 64 | warn!(token = ?other, "unexpected token in css url()"); 65 | } 66 | } 67 | } 68 | } else { 69 | match &token { 70 | Token::UnquotedUrl(url) => result.push(InlineStyleToken::Url((**url).to_owned())), 71 | Token::QuotedString(value) => { 72 | result.push(InlineStyleToken::String((**value).to_owned())) 73 | } 74 | other => result.push(InlineStyleToken::Other(other.to_css_string())), 75 | } 76 | if matches!( 77 | token, 78 | Token::Function(..) 79 | | Token::ParenthesisBlock 80 | | Token::CurlyBracketBlock 81 | | Token::SquareBracketBlock 82 | ) { 83 | let nested_result = parser 84 | .parse_nested_block(|p| Ok::<_, ParseError<()>>(parse(p))) 85 | .expect("guaranteed by closure"); 86 | result.extend(nested_result); 87 | } 88 | match &token { 89 | Token::Function(..) => result.push(InlineStyleToken::Other(")".to_owned())), 90 | Token::ParenthesisBlock => result.push(InlineStyleToken::Other(")".to_owned())), 91 | Token::SquareBracketBlock => result.push(InlineStyleToken::Other("]".to_owned())), 92 | Token::CurlyBracketBlock => result.push(InlineStyleToken::Other("}".to_owned())), 93 | _ => {} 94 | } 95 | } 96 | } 97 | 98 | result 99 | } 100 | 101 | fn serialise_string_value(string: &str) -> String { 102 | // newlines are not allowed in , but if we just backslash 103 | // escape the newline, the parser consumes it without appending anything to 104 | // the string value. instead, we need to escape it in hex. 105 | // 106 | // 107 | format!( 108 | "'{}'", 109 | string 110 | .replace(r#"\"#, r#"\\"#) 111 | .replace("'", r#"\'"#) 112 | .replace("\n", r#"\A "#) 113 | ) 114 | } 115 | 116 | #[test] 117 | fn test_round_trip_inline_style() { 118 | let original_style = r#"background:rgb(1 2 3);background:rgb(var(--color-cherry));background:url(http://x/y\'z);background:url('http://x/y\'z');background:url("http://x/y\"z");"#; 119 | let expected = r#"background:rgb(1 2 3);background:rgb(var(--color-cherry));background:url('http://x/y\'z');background:url('http://x/y\'z');background:url('http://x/y"z');"#; 120 | let tokens = parse_inline_style(original_style); 121 | assert_eq!(serialise_inline_style(&tokens), expected); 122 | } 123 | 124 | #[test] 125 | fn test_serialise_string_value() { 126 | assert_eq!(serialise_string_value(r#"http://test"#), r#"'http://test'"#); 127 | } 128 | -------------------------------------------------------------------------------- /src/http.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use bytes::Bytes; 4 | use jane_eyre::eyre::{self, bail}; 5 | use reqwest::{Client, Response}; 6 | use serde::de::DeserializeOwned; 7 | use tokio::time::sleep; 8 | use tracing::{info, warn}; 9 | 10 | pub async fn get_json(client: &Client, url: &str) -> eyre::Result { 11 | get_with_retries(client, url, json).await 12 | } 13 | 14 | pub async fn get_with_retries( 15 | client: &Client, 16 | url: &str, 17 | mut and_then: impl FnMut(Bytes) -> eyre::Result, 18 | ) -> eyre::Result { 19 | let mut retries = 4; 20 | let mut wait = Duration::from_secs(4); 21 | loop { 22 | let result = get_response_once(client, url).await; 23 | let status = result 24 | .as_ref() 25 | .map_or(None, |response| Some(response.status())); 26 | let result = match match result { 27 | Ok(response) => Ok(response.bytes().await), 28 | Err(error) => Err(error), 29 | } { 30 | Ok(Ok(bytes)) => Ok(bytes), 31 | Ok(Err(error)) | Err(error) => Err::(error.into()), 32 | }; 33 | // retry requests if they are neither client errors (http 4xx), nor if they are successful 34 | // (http 2xx) and the given fallible transformation fails. this includes server errors 35 | // (http 5xx), and requests that failed in a way that yields no response. 36 | let error = if status.is_some_and(|s| s.is_client_error()) { 37 | // client errors (http 4xx) should not be retried. 38 | bail!("GET request failed (no retries): http {:?}: {url}", status); 39 | } else if status.is_some_and(|s| s.is_success()) { 40 | // apply the given fallible transformation to the response body. 41 | // if that succeeds, we succeed, otherwise we retry. 42 | let result = result.and_then(&mut and_then); 43 | if result.is_ok() { 44 | return result; 45 | } 46 | result.err() 47 | } else { 48 | // when retrying server errors (http 5xx), error is None. 49 | // when retrying failures with no response, error is Some. 50 | result.err() 51 | }; 52 | if retries == 0 { 53 | bail!( 54 | "GET request failed (after retries): http {:?}: {url}", 55 | status, 56 | ); 57 | } 58 | warn!(?wait, ?status, url, ?error, "retrying failed GET request"); 59 | sleep(wait).await; 60 | wait *= 2; 61 | retries -= 1; 62 | } 63 | } 64 | 65 | async fn get_response_once(client: &Client, url: &str) -> reqwest::Result { 66 | info!("GET {url}"); 67 | client.get(url).send().await 68 | } 69 | 70 | fn json(body: Bytes) -> eyre::Result { 71 | Ok(serde_json::from_slice(&body)?) 72 | } 73 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::Ordering, 3 | collections::BTreeSet, 4 | env, 5 | fs::File, 6 | io::{ErrorKind, Read, Write}, 7 | path::Path, 8 | sync::LazyLock, 9 | }; 10 | 11 | use askama::Template; 12 | use chrono::{SecondsFormat, Utc}; 13 | use command::{ 14 | attach::Attach, 15 | cohost2autost::Cohost2autost, 16 | cohost2json::Cohost2json, 17 | cohost_archive::CohostArchive, 18 | import::{Import, Reimport}, 19 | new::New, 20 | render::Render, 21 | server::Server, 22 | }; 23 | use dom::{QualNameExt, Transform}; 24 | use html5ever::{Attribute, QualName}; 25 | use indexmap::{indexmap, IndexMap}; 26 | use jane_eyre::eyre::{self, bail, Context, OptionExt}; 27 | use markup5ever_rcdom::{NodeData, RcDom}; 28 | use renamore::rename_exclusive_fallback; 29 | use serde::{Deserialize, Serialize}; 30 | use toml::{toml, Value}; 31 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; 32 | 33 | use crate::{ 34 | dom::serialize_html_fragment, 35 | meta::extract_metadata, 36 | path::{PostsPath, SitePath}, 37 | settings::Settings, 38 | }; 39 | 40 | pub mod command { 41 | pub mod attach; 42 | pub mod cohost2autost; 43 | pub mod cohost2json; 44 | pub mod cohost_archive; 45 | pub mod import; 46 | pub mod new; 47 | pub mod render; 48 | pub mod server; 49 | } 50 | 51 | pub mod akkoma; 52 | pub mod attachments; 53 | pub mod cohost; 54 | pub mod css; 55 | pub mod dom; 56 | pub mod http; 57 | pub mod meta; 58 | pub mod migrations; 59 | pub mod output; 60 | pub mod path; 61 | pub mod rocket_eyre; 62 | pub mod settings; 63 | 64 | pub static SETTINGS: LazyLock = LazyLock::new(|| { 65 | #[cfg(test)] 66 | let result = Settings::load_example(); 67 | 68 | #[cfg(not(test))] 69 | let result = Settings::load_default(); 70 | 71 | result.context("failed to load settings").unwrap() 72 | }); 73 | 74 | #[derive(clap::Parser, Debug)] 75 | pub enum Command { 76 | Attach(Attach), 77 | Cohost2autost(Cohost2autost), 78 | Cohost2json(Cohost2json), 79 | CohostArchive(CohostArchive), 80 | Import(Import), 81 | New(New), 82 | Reimport(Reimport), 83 | Render(Render), 84 | Server(Server), 85 | } 86 | 87 | /// details about the run, to help with migrations and bug fixes. 88 | #[derive(Debug, Deserialize, Serialize)] 89 | pub struct RunDetails { 90 | pub version: String, 91 | pub args: Vec, 92 | pub start_time: String, 93 | #[serde(flatten)] 94 | pub rest: IndexMap, 95 | pub ok: Option, 96 | } 97 | pub struct RunDetailsWriter { 98 | file: File, 99 | } 100 | 101 | #[derive(Clone, Debug, Default, PartialEq, Template)] 102 | #[template(path = "post-meta.html")] 103 | pub struct PostMeta { 104 | pub archived: Option, 105 | pub references: Vec, 106 | pub title: Option, 107 | pub published: Option, 108 | pub author: Option, 109 | pub tags: Vec, 110 | pub is_transparent_share: bool, 111 | } 112 | 113 | #[derive(Clone, Debug, Deserialize, PartialEq)] 114 | pub struct Author { 115 | pub href: String, 116 | pub name: String, 117 | pub display_name: String, 118 | pub display_handle: String, 119 | } 120 | 121 | pub struct ExtractedPost { 122 | pub dom: RcDom, 123 | pub meta: PostMeta, 124 | pub needs_attachments: BTreeSet, 125 | pub og_image: Option, 126 | pub og_description: String, 127 | } 128 | 129 | #[derive(Clone, Debug)] 130 | pub struct Thread { 131 | pub path: Option, 132 | pub posts: Vec, 133 | pub meta: PostMeta, 134 | pub needs_attachments: BTreeSet, 135 | pub og_image: Option, 136 | pub og_description: Option, 137 | } 138 | 139 | #[derive(Clone, Debug)] 140 | pub struct TemplatedPost { 141 | pub path: Option, 142 | pub meta: PostMeta, 143 | pub original_html: String, 144 | pub safe_html: String, 145 | pub needs_attachments: BTreeSet, 146 | pub og_image: Option, 147 | pub og_description: String, 148 | } 149 | 150 | impl Default for RunDetails { 151 | fn default() -> Self { 152 | let version = if let Some(git_describe) = option_env!("VERGEN_GIT_DESCRIBE") { 153 | git_describe.to_owned() 154 | } else if option_env!("AUTOST_IS_NIX_BUILD").is_some_and(|e| e == "1") { 155 | // FIXME: nix package does not have access to git 156 | // 157 | format!("{}-nix", env!("CARGO_PKG_VERSION")) 158 | } else { 159 | // other cases, including crates.io (hypothetically) 160 | format!("{}-unknown", env!("CARGO_PKG_VERSION")) 161 | }; 162 | 163 | Self { 164 | version, 165 | args: env::args().skip(1).collect(), 166 | start_time: Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), 167 | rest: indexmap! {}, 168 | ok: None, 169 | } 170 | } 171 | } 172 | 173 | impl RunDetailsWriter { 174 | pub fn create_in(dir: impl AsRef) -> eyre::Result { 175 | let dir = dir.as_ref(); 176 | for i in 0.. { 177 | if let Err(error) = rename_exclusive_fallback( 178 | dir.join("run_details.toml"), 179 | dir.join(format!("run_details.{i}.toml")), 180 | ) { 181 | match error.kind() { 182 | ErrorKind::NotFound => break, 183 | ErrorKind::AlreadyExists => continue, 184 | other => bail!( 185 | "failed to hard link old run_details.toml at run_details.{i}.toml: {other:?}" 186 | ), 187 | } 188 | } else { 189 | break; 190 | } 191 | } 192 | 193 | // at this point, run_details.toml should not exist, unless there are concurrent shenanigans 194 | let mut file = File::create_new(dir.join("run_details.toml"))?; 195 | write!(file, "{}", toml::to_string(&RunDetails::default())?)?; 196 | 197 | Ok(Self { file }) 198 | } 199 | 200 | pub fn write(&mut self, key: &str, value: impl Into) -> eyre::Result<()> { 201 | let result = toml! { x = (value.into()) }.to_string(); 202 | let result = result 203 | .strip_prefix("x = ") 204 | .expect("guaranteed by definition"); 205 | 206 | Ok(write!(self.file, r#"{key} = {}"#, result)?) 207 | } 208 | 209 | pub fn ok(mut self) -> eyre::Result<()> { 210 | Ok(writeln!(self.file, "ok = true")?) 211 | } 212 | } 213 | 214 | impl PostMeta { 215 | pub fn is_main_self_author(&self, settings: &Settings) -> bool { 216 | self.author 217 | .as_ref() 218 | .map_or(settings.self_author.is_none(), |a| { 219 | settings.is_main_self_author(a) 220 | }) 221 | } 222 | 223 | pub fn is_any_self_author(&self, settings: &Settings) -> bool { 224 | let no_self_authors = 225 | settings.self_author.is_none() && settings.other_self_authors.is_empty(); 226 | 227 | self.author 228 | .as_ref() 229 | .map_or(no_self_authors, |a| settings.is_any_self_author(a)) 230 | } 231 | } 232 | 233 | #[test] 234 | fn test_is_main_self_author() -> eyre::Result<()> { 235 | let settings = Settings::load_example()?; 236 | 237 | let mut settings_no_self_author = Settings::load_example()?; 238 | let mut meta_no_author = PostMeta::default(); 239 | settings_no_self_author.self_author = None; 240 | meta_no_author.author = None; 241 | 242 | // same href as [self_author], but different name, display_name, and handle 243 | let mut meta_same_href = PostMeta::default(); 244 | meta_same_href.author = Some(Author { 245 | href: "https://example.com".to_owned(), 246 | name: "".to_owned(), 247 | display_name: "".to_owned(), 248 | display_handle: "".to_owned(), 249 | }); 250 | 251 | // different href from [self_author] 252 | let mut meta_different_href = PostMeta::default(); 253 | meta_different_href.author = Some(Author { 254 | href: "https://example.net".to_owned(), 255 | name: "".to_owned(), 256 | display_name: "".to_owned(), 257 | display_handle: "".to_owned(), 258 | }); 259 | 260 | assert!(meta_same_href.is_main_self_author(&settings)); 261 | assert!(!meta_different_href.is_main_self_author(&settings)); 262 | assert!(!meta_no_author.is_main_self_author(&settings)); 263 | assert!(!meta_same_href.is_main_self_author(&settings_no_self_author)); 264 | assert!(!meta_different_href.is_main_self_author(&settings_no_self_author)); 265 | assert!(meta_no_author.is_main_self_author(&settings_no_self_author)); 266 | 267 | Ok(()) 268 | } 269 | 270 | impl Thread { 271 | pub fn reverse_chronological(p: &Thread, q: &Thread) -> Ordering { 272 | p.meta.published.cmp(&q.meta.published).reverse() 273 | } 274 | 275 | pub fn url_for_original_path(&self) -> eyre::Result> { 276 | let result = self.path.as_ref().map(|path| path.references_url()); 277 | 278 | Ok(result) 279 | } 280 | 281 | pub fn url_for_fragment(&self) -> eyre::Result> { 282 | let result = self.path.as_ref().map(|path| path.references_url()); 283 | 284 | Ok(result) 285 | } 286 | 287 | pub fn url_for_html_permalink(&self) -> eyre::Result> { 288 | let result = self 289 | .path 290 | .as_ref() 291 | .map(|path| path.rendered_path()) 292 | .transpose()? 293 | .flatten() 294 | .map(|path| path.internal_url()); 295 | 296 | Ok(result) 297 | } 298 | 299 | pub fn url_for_atom_permalink(&self) -> eyre::Result> { 300 | let result = self 301 | .path 302 | .as_ref() 303 | .map(|path| path.rendered_path()) 304 | .transpose()? 305 | .flatten() 306 | .map(|path| path.external_url()); 307 | 308 | Ok(result) 309 | } 310 | 311 | pub fn atom_feed_entry_id(&self) -> eyre::Result> { 312 | let result = self 313 | .path 314 | .as_ref() 315 | .map(|path| path.rendered_path()) 316 | .transpose()? 317 | .flatten() 318 | .map(|path| path.atom_feed_entry_id()); 319 | 320 | Ok(result) 321 | } 322 | 323 | pub fn needs_attachments(&self) -> impl Iterator { 324 | self.needs_attachments.iter() 325 | } 326 | 327 | pub fn posts_in_thread(&self) -> impl Iterator + '_ { 328 | let len = self.posts.len(); 329 | 330 | self.posts 331 | .iter() 332 | .cloned() 333 | .enumerate() 334 | .map(move |(i, post)| { 335 | if i == len - 1 { 336 | PostInThread { 337 | inner: post, 338 | is_main_post: true, 339 | } 340 | } else { 341 | PostInThread { 342 | inner: post, 343 | is_main_post: false, 344 | } 345 | } 346 | }) 347 | } 348 | 349 | pub fn main_post(&self) -> eyre::Result<&TemplatedPost> { 350 | self.posts.last().ok_or_eyre("thread has no posts") 351 | } 352 | } 353 | 354 | pub struct PostInThread { 355 | inner: TemplatedPost, 356 | is_main_post: bool, 357 | } 358 | 359 | impl TryFrom for Thread { 360 | type Error = eyre::Report; 361 | 362 | fn try_from(mut post: TemplatedPost) -> eyre::Result { 363 | let path = post.path.clone(); 364 | let extra_tags = SETTINGS 365 | .extra_archived_thread_tags(&post) 366 | .into_iter() 367 | .filter(|tag| !post.meta.tags.contains(tag)) 368 | .map(|tag| tag.to_owned()) 369 | .collect::>(); 370 | let combined_tags = extra_tags 371 | .into_iter() 372 | .chain(post.meta.tags.into_iter()) 373 | .collect(); 374 | let resolved_tags = SETTINGS.resolve_tags(combined_tags); 375 | post.meta.tags = resolved_tags; 376 | let mut meta = post.meta.clone(); 377 | 378 | let mut posts = post 379 | .meta 380 | .references 381 | .iter() 382 | .map(|path| TemplatedPost::load(path)) 383 | .collect::, _>>()?; 384 | posts.push(post); 385 | 386 | // TODO: skip threads with other authors? 387 | // TODO: skip threads with private or logged-in-only authors? 388 | // TODO: gate sensitive posts behind an interaction? 389 | 390 | // for thread metadata, take the last post that is not a transparent share (which MAY have 391 | // tags, but SHOULD NOT have a title and MUST NOT have a body), and use its metadata if any. 392 | let last_non_transparent_share_post = posts 393 | .iter() 394 | .rev() 395 | .find(|post| !post.meta.is_transparent_share); 396 | meta.title = last_non_transparent_share_post.map(|post| { 397 | if let Some(title) = post.meta.title.clone().filter(|t| !t.is_empty()) { 398 | title 399 | } else if let Some(author) = post.meta.author.as_ref() { 400 | format!("untitled post by {}", author.display_handle) 401 | } else { 402 | "untitled post".to_owned() 403 | } 404 | }); 405 | let og_image = last_non_transparent_share_post 406 | .and_then(|post| post.og_image.as_deref()) 407 | .map(|og_image| SETTINGS.base_url_relativise(og_image)); 408 | let og_description = 409 | last_non_transparent_share_post.map(|post| post.og_description.to_owned()); 410 | 411 | let needs_attachments = posts 412 | .iter() 413 | .flat_map(|post| post.needs_attachments.iter()) 414 | .map(|attachment_path| attachment_path.to_owned()) 415 | .collect(); 416 | 417 | Ok(Thread { 418 | path, 419 | posts, 420 | meta, 421 | needs_attachments, 422 | og_image, 423 | og_description, 424 | }) 425 | } 426 | } 427 | 428 | impl TemplatedPost { 429 | pub fn load(path: &PostsPath) -> eyre::Result { 430 | let mut file = File::open(path)?; 431 | let mut unsafe_source = String::default(); 432 | file.read_to_string(&mut unsafe_source)?; 433 | 434 | let unsafe_html = if path.is_markdown_post() { 435 | // author step: render markdown to html. 436 | render_markdown(&unsafe_source) 437 | } else { 438 | unsafe_source 439 | }; 440 | 441 | Self::filter(&unsafe_html, Some(path.to_owned())) 442 | } 443 | 444 | pub fn filter(unsafe_html: &str, path: Option) -> eyre::Result { 445 | // reader step: extract metadata. 446 | let post = extract_metadata(unsafe_html)?; 447 | 448 | let mut transform = Transform::new(post.dom.document.clone()); 449 | while transform.next(|kids, new_kids| { 450 | for kid in kids { 451 | if let NodeData::Element { name, attrs, .. } = &kid.data { 452 | // reader step: make all `` elements lazy loaded. 453 | if name == &QualName::html("img") { 454 | attrs.borrow_mut().push(Attribute { 455 | name: QualName::attribute("loading"), 456 | value: "lazy".into(), 457 | }); 458 | } 459 | } 460 | new_kids.push(kid.clone()); 461 | } 462 | Ok(()) 463 | })? {} 464 | 465 | // reader step: filter html. 466 | let extracted_html = serialize_html_fragment(post.dom)?; 467 | let safe_html = ammonia::Builder::default() 468 | .add_generic_attributes(["style", "id", "aria-label"]) 469 | .add_generic_attributes(["data-cohost-href", "data-cohost-src"]) // cohost2autost 470 | .add_generic_attributes(["data-import-src"]) // autost import 471 | .add_tag_attributes("a", ["target"]) 472 | .add_tag_attributes("audio", ["controls", "src"]) 473 | .add_tag_attributes("details", ["open", "name"]) //
for cohost compatibility 474 | .add_tag_attributes("img", ["loading"]) 475 | .add_tag_attributes("video", ["controls", "src"]) 476 | .add_tags(["audio", "meta", "video"]) 477 | .add_tag_attributes("meta", ["name", "content"]) 478 | .id_prefix(Some("user-content-")) // cohost compatibility 479 | .clean(&extracted_html) 480 | .to_string(); 481 | 482 | Ok(TemplatedPost { 483 | path, 484 | meta: post.meta, 485 | original_html: unsafe_html.to_owned(), 486 | safe_html, 487 | needs_attachments: post.needs_attachments, 488 | og_image: post.og_image, 489 | og_description: post.og_description, 490 | }) 491 | } 492 | } 493 | 494 | pub fn cli_init() -> eyre::Result<()> { 495 | jane_eyre::install()?; 496 | tracing_subscriber::registry() 497 | // FIXME: rocket launch logging would print nicer if 498 | // it didn't have the module path etc 499 | .with(tracing_subscriber::fmt::layer()) 500 | .with(if std::env::var("RUST_LOG").is_ok() { 501 | EnvFilter::builder().from_env_lossy() 502 | } else { 503 | "autost=info,rocket=info".parse()? 504 | }) 505 | .init(); 506 | 507 | Ok(()) 508 | } 509 | 510 | /// render markdown in a cohost-compatible way. 511 | /// 512 | /// known discrepancies: 513 | /// - `~~strikethrough~~` not handled 514 | /// - @mentions not handled 515 | /// - :emotes: not handled 516 | /// - single newline always yields `
` 517 | /// (this was not the case for older chosts, as reflected in their `.astMap`) 518 | /// - blank lines in `
` close the element in some situations? 519 | /// - spaced numbered lists yield separate `
    ` instead of `
  1. ` 520 | pub fn render_markdown(markdown: &str) -> String { 521 | let mut options = comrak::Options::default(); 522 | options.render.unsafe_ = true; 523 | options.extension.table = true; 524 | options.extension.autolink = true; 525 | options.render.hardbreaks = true; 526 | let unsafe_html = comrak::markdown_to_html(&markdown, &options); 527 | 528 | unsafe_html 529 | } 530 | 531 | #[test] 532 | fn test_render_markdown() { 533 | assert_eq!( 534 | render_markdown("first\nsecond"), 535 | "

    first
    \nsecond

    \n" 536 | ); 537 | } 538 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use autost::{ 2 | cli_init, 3 | command::{self}, 4 | Command, RunDetails, SETTINGS, 5 | }; 6 | use clap::Parser; 7 | use jane_eyre::eyre; 8 | use tracing::info; 9 | 10 | fn main() -> eyre::Result<()> { 11 | cli_init()?; 12 | 13 | let command = Command::parse(); 14 | info!(run_details = ?RunDetails::default()); 15 | 16 | if matches!( 17 | command, 18 | Command::Attach { .. } 19 | | Command::Cohost2autost { .. } 20 | | Command::Import { .. } 21 | | Command::Reimport { .. } 22 | | Command::Render { .. } 23 | | Command::Server { .. } 24 | ) { 25 | // fail fast if there are any settings errors. 26 | let _ = &*SETTINGS; 27 | } 28 | 29 | match command { 30 | Command::Attach(_) => command::attach::main(), 31 | Command::Cohost2autost(args) => command::cohost2autost::main(args), 32 | Command::Cohost2json(_) => command::cohost2json::main(), 33 | Command::CohostArchive(_) => command::cohost_archive::main(), 34 | Command::Import(_) => command::import::main(), 35 | Command::New(args) => command::new::main(args), 36 | Command::Reimport(_) => command::import::reimport::main(), 37 | Command::Render(args) => command::render::main(args), 38 | Command::Server(_) => command::server::main(), 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/meta.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::BTreeSet, fs::create_dir_all}; 2 | 3 | use html5ever::QualName; 4 | use jane_eyre::eyre::{self, bail, OptionExt}; 5 | use markup5ever_rcdom::NodeData; 6 | use tracing::trace; 7 | 8 | use crate::{ 9 | css::{parse_inline_style, InlineStyleToken}, 10 | dom::{ 11 | html_attributes_with_urls, parse_html_fragment, text_content_for_summaries, AttrsRefExt, 12 | QualNameExt, TendrilExt, Transform, 13 | }, 14 | path::{hard_link_if_not_exists, PostsPath, SitePath}, 15 | Author, ExtractedPost, PostMeta, 16 | }; 17 | 18 | pub fn extract_metadata(unsafe_html: &str) -> eyre::Result { 19 | let dom = parse_html_fragment(&mut unsafe_html.as_bytes())?; 20 | 21 | let mut meta = PostMeta::default(); 22 | let mut needs_attachments = BTreeSet::default(); 23 | let mut og_image = None; 24 | let og_description = text_content_for_summaries(dom.document.clone())?; 25 | let mut author_href = None; 26 | let mut author_name = None; 27 | let mut author_display_name = None; 28 | let mut author_display_handle = None; 29 | let mut transform = Transform::new(dom.document.clone()); 30 | while transform.next(|kids, new_kids| { 31 | for kid in kids { 32 | if let NodeData::Element { name, attrs, .. } = &kid.data { 33 | let attrs = attrs.borrow(); 34 | if name == &QualName::html("meta") { 35 | let content = attrs.attr_str("content")?.map(|t| t.to_owned()); 36 | match attrs.attr_str("name")? { 37 | Some("title") => { 38 | meta.title = content; 39 | } 40 | Some("published") => { 41 | meta.published = content; 42 | } 43 | Some("author_display_name") => { 44 | author_display_name = content; 45 | } 46 | Some("author_display_handle") => { 47 | author_display_handle = content; 48 | } 49 | Some("tags") => { 50 | if let Some(tag) = content { 51 | meta.tags.push(tag); 52 | } 53 | } 54 | Some("is_transparent_share") => { 55 | meta.is_transparent_share = true; 56 | } 57 | _ => {} 58 | } 59 | continue; 60 | } else if name == &QualName::html("link") { 61 | let href = attrs.attr_str("href")?.map(|t| t.to_owned()); 62 | let name = attrs.attr_str("name")?.map(|t| t.to_owned()); 63 | match attrs.attr_str("rel")? { 64 | Some("archived") => { 65 | meta.archived = href; 66 | } 67 | Some("references") => { 68 | if let Some(href) = href { 69 | meta.references.push(PostsPath::from_references_url(&href)?); 70 | } 71 | } 72 | Some("author") => { 73 | author_href = href; 74 | author_name = name; 75 | } 76 | _ => {} 77 | } 78 | continue; 79 | } else { 80 | if let Some(attr_names) = html_attributes_with_urls().get(name) { 81 | for attr in attrs.iter() { 82 | if attr_names.contains(&attr.name) { 83 | if let Ok(url) = 84 | SitePath::from_rendered_attachment_url(attr.value.to_str()) 85 | { 86 | trace!("found attachment url in rendered post: {url:?}"); 87 | needs_attachments.insert(url); 88 | } 89 | } 90 | } 91 | } 92 | if let Some(style) = attrs.attr_str("style")? { 93 | for token in parse_inline_style(style) { 94 | if let InlineStyleToken::Url(url) = token { 95 | if let Ok(url) = SitePath::from_rendered_attachment_url(&url) { 96 | trace!("found attachment url in rendered post (inline styles): {url:?}"); 97 | needs_attachments.insert(url); 98 | } 99 | } 100 | } 101 | } 102 | // use the first , if any, as the og:image. 103 | if og_image.is_none() && name == &QualName::html("img") { 104 | if let Some(src) = attrs.attr_str("src")?.map(|t| t.to_owned()) { 105 | og_image = Some(src); 106 | } 107 | } 108 | } 109 | } 110 | new_kids.push(kid.clone()); 111 | } 112 | Ok(()) 113 | })? {} 114 | 115 | if author_href.is_some() 116 | || author_name.is_some() 117 | || author_display_name.is_some() 118 | || author_display_handle.is_some() 119 | { 120 | meta.author = Some(Author { 121 | href: author_href.unwrap_or("".to_owned()), 122 | name: author_name.unwrap_or("".to_owned()), 123 | display_name: author_display_name.unwrap_or("".to_owned()), 124 | display_handle: author_display_handle.unwrap_or("".to_owned()), 125 | }); 126 | } 127 | 128 | Ok(ExtractedPost { 129 | dom, 130 | meta, 131 | needs_attachments, 132 | og_image, 133 | og_description, 134 | }) 135 | } 136 | 137 | #[tracing::instrument(skip(site_paths))] 138 | pub fn hard_link_attachments_into_site<'paths>( 139 | site_paths: impl IntoIterator, 140 | ) -> eyre::Result<()> { 141 | for site_path in site_paths { 142 | trace!(?site_path); 143 | let attachments_path = site_path 144 | .attachments_path()? 145 | .ok_or_eyre("path is not an attachment path")?; 146 | let Some(parent) = site_path.parent() else { 147 | bail!("path has no parent: {site_path:?}"); 148 | }; 149 | create_dir_all(parent)?; 150 | hard_link_if_not_exists(attachments_path, &site_path)?; 151 | } 152 | 153 | Ok(()) 154 | } 155 | 156 | #[test] 157 | fn test_extract_metadata() -> eyre::Result<()> { 158 | use crate::dom::serialize_html_fragment; 159 | let post = extract_metadata(r#"bar"#)?; 160 | assert_eq!(serialize_html_fragment(post.dom)?, "bar"); 161 | assert_eq!(post.meta.title.as_deref(), Some("foo")); 162 | 163 | Ok(()) 164 | } 165 | -------------------------------------------------------------------------------- /src/migrations.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{create_dir_all, read_dir}; 2 | 3 | use jane_eyre::eyre::{self, bail}; 4 | use tracing::{info, trace}; 5 | 6 | use crate::path::{hard_link_if_not_exists, SitePath, SITE_PATH_ATTACHMENTS}; 7 | 8 | #[tracing::instrument] 9 | pub fn run_migrations() -> eyre::Result<()> { 10 | info!("hard linking attachments out of site/attachments"); 11 | create_dir_all(&*SITE_PATH_ATTACHMENTS)?; 12 | let mut dirs = vec![SITE_PATH_ATTACHMENTS.to_owned()]; 13 | let mut files: Vec = vec![]; 14 | while !dirs.is_empty() || !files.is_empty() { 15 | for site_path in files.drain(..) { 16 | trace!(?site_path); 17 | let Some(attachments_path) = site_path.attachments_path()? else { 18 | bail!("path is not an attachment path: {site_path:?}"); 19 | }; 20 | let Some(parent) = attachments_path.parent() else { 21 | bail!("path has no parent: {site_path:?}"); 22 | }; 23 | create_dir_all(parent)?; 24 | hard_link_if_not_exists(site_path, attachments_path)?; 25 | } 26 | if let Some(dir) = dirs.pop() { 27 | for entry in read_dir(&dir)? { 28 | let entry = entry?; 29 | let r#type = entry.file_type()?; 30 | let path = dir.join_dir_entry(&entry)?; 31 | if r#type.is_dir() { 32 | dirs.push(path); 33 | } else if r#type.is_file() { 34 | files.push(path); 35 | } else { 36 | bail!( 37 | "file in site/attachments with unexpected type: {:?}: {:?}", 38 | r#type, 39 | entry.path() 40 | ); 41 | } 42 | } 43 | } 44 | } 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /src/output.rs: -------------------------------------------------------------------------------- 1 | //! output templates. these templates are wrapped in a safe interface that 2 | //! guarantees that path-relative urls are made path-absolute. 3 | 4 | use askama::Template; 5 | use jane_eyre::eyre; 6 | use markup5ever_rcdom::{NodeData, RcDom}; 7 | use tracing::trace; 8 | 9 | use crate::{ 10 | css::{parse_inline_style, serialise_inline_style, InlineStyleToken}, 11 | dom::{ 12 | html_attributes_with_urls, parse_html_document, parse_html_fragment, 13 | serialize_html_document, serialize_html_fragment, AttrsMutExt, TendrilExt, Transform, 14 | }, 15 | path::{parse_path_relative_scheme_less_url_string, SitePath}, 16 | Author, PostMeta, Thread, SETTINGS, 17 | }; 18 | 19 | #[derive(Clone, Debug, Template)] 20 | #[template(path = "threads.html")] 21 | pub struct ThreadsPageTemplate<'template> { 22 | thread_page_meta: Option<&'template str>, 23 | /// not `threads: Vec`, to encourage us to cache ThreadsContentTemplate output between 24 | /// individual thread pages and combined collection pages. 25 | threads_content: &'template str, 26 | page_title: &'template str, 27 | feed_href: &'template Option, 28 | } 29 | 30 | #[derive(Clone, Debug, Template)] 31 | #[template(path = "threads-content.html")] 32 | pub struct ThreadsContentTemplate<'template> { 33 | thread: &'template Thread, 34 | simple_mode: bool, 35 | } 36 | 37 | #[derive(Clone, Debug, Template)] 38 | #[template(path = "thread-or-post-header.html")] 39 | pub struct ThreadOrPostHeaderTemplate<'template> { 40 | thread: &'template Thread, 41 | post_meta: &'template PostMeta, 42 | is_thread_header: bool, 43 | } 44 | 45 | #[derive(Clone, Debug, Template)] 46 | #[template(path = "thread-or-post-author.html")] 47 | pub struct ThreadOrPostAuthorTemplate<'template> { 48 | author: &'template Author, 49 | } 50 | 51 | #[derive(Clone, Debug, Template)] 52 | #[template(path = "thread-or-post-meta.html")] 53 | pub struct ThreadOrPostMetaTemplate<'template> { 54 | thread: &'template Thread, 55 | } 56 | 57 | #[derive(Clone, Debug, Template)] 58 | #[template(path = "feed.xml")] 59 | pub struct AtomFeedTemplate<'template> { 60 | thread_refs: Vec<&'template Thread>, 61 | feed_title: &'template str, 62 | updated: &'template str, 63 | } 64 | 65 | impl ThreadsPageTemplate<'_> { 66 | pub fn render( 67 | threads_content: &str, 68 | page_title: &str, 69 | feed_href: &Option, 70 | ) -> eyre::Result { 71 | fix_relative_urls_in_html_document( 72 | &ThreadsPageTemplate { 73 | thread_page_meta: None, 74 | threads_content, 75 | page_title, 76 | feed_href, 77 | } 78 | .render()?, 79 | ) 80 | } 81 | 82 | pub fn render_single_thread( 83 | thread: &Thread, 84 | threads_content: &str, 85 | page_title: &str, 86 | feed_href: &Option, 87 | ) -> eyre::Result { 88 | let thread_page_meta = ThreadOrPostMetaTemplate::render(thread)?; 89 | 90 | fix_relative_urls_in_html_document( 91 | &ThreadsPageTemplate { 92 | thread_page_meta: Some(&thread_page_meta), 93 | threads_content, 94 | page_title, 95 | feed_href, 96 | } 97 | .render()?, 98 | ) 99 | } 100 | } 101 | 102 | impl<'template> ThreadsContentTemplate<'template> { 103 | pub fn render_normal(thread: &'template Thread) -> eyre::Result { 104 | fix_relative_urls_in_html_fragment(&Self::render_normal_without_fixing_relative_urls( 105 | thread, 106 | )?) 107 | } 108 | 109 | pub fn render_normal_without_fixing_relative_urls( 110 | thread: &'template Thread, 111 | ) -> eyre::Result { 112 | Ok(Self { 113 | thread, 114 | simple_mode: false, 115 | } 116 | .render()?) 117 | } 118 | 119 | fn render_simple(thread: &'template Thread) -> eyre::Result { 120 | fix_relative_urls_in_html_fragment( 121 | &Self { 122 | thread, 123 | simple_mode: true, 124 | } 125 | .render()?, 126 | ) 127 | } 128 | } 129 | 130 | impl<'template> ThreadOrPostHeaderTemplate<'template> { 131 | pub fn render( 132 | thread: &'template Thread, 133 | post_meta: &'template PostMeta, 134 | is_thread_header: bool, 135 | ) -> eyre::Result { 136 | fix_relative_urls_in_html_fragment( 137 | &Self { 138 | thread, 139 | post_meta, 140 | is_thread_header, 141 | } 142 | .render()?, 143 | ) 144 | } 145 | } 146 | 147 | impl<'template> ThreadOrPostAuthorTemplate<'template> { 148 | pub fn render(author: &'template Author) -> eyre::Result { 149 | fix_relative_urls_in_html_fragment(&Self { author }.render()?) 150 | } 151 | } 152 | 153 | impl<'template> ThreadOrPostMetaTemplate<'template> { 154 | pub fn render(thread: &'template Thread) -> eyre::Result { 155 | fix_relative_urls_in_html_fragment(&Self { thread }.render()?) 156 | } 157 | } 158 | 159 | impl<'template> AtomFeedTemplate<'template> { 160 | pub fn render( 161 | thread_refs: Vec<&'template Thread>, 162 | feed_title: &'template str, 163 | updated: &'template str, 164 | ) -> eyre::Result { 165 | Ok(Self { 166 | thread_refs, 167 | feed_title, 168 | updated, 169 | } 170 | .render()?) 171 | } 172 | } 173 | 174 | fn fix_relative_urls_in_html_document(html: &str) -> eyre::Result { 175 | let dom = parse_html_document(html.as_bytes())?; 176 | let dom = fix_relative_urls(dom)?; 177 | 178 | serialize_html_document(dom) 179 | } 180 | 181 | fn fix_relative_urls_in_html_fragment(html: &str) -> eyre::Result { 182 | let dom = parse_html_fragment(html.as_bytes())?; 183 | let dom = fix_relative_urls(dom)?; 184 | 185 | serialize_html_fragment(dom) 186 | } 187 | 188 | fn fix_relative_urls(dom: RcDom) -> eyre::Result { 189 | let mut transform = Transform::new(dom.document.clone()); 190 | while transform.next(|kids, new_kids| { 191 | for kid in kids { 192 | if let NodeData::Element { name, attrs, .. } = &kid.data { 193 | if let Some(attr_names) = html_attributes_with_urls().get(name) { 194 | for attr in attrs.borrow_mut().iter_mut() { 195 | if attr_names.contains(&attr.name) { 196 | if let Some(url) = 197 | parse_path_relative_scheme_less_url_string(attr.value.to_str()) 198 | { 199 | attr.value = SETTINGS.base_url_relativise(&url).into(); 200 | } 201 | } 202 | } 203 | } 204 | if let Some(style) = attrs.borrow_mut().attr_mut("style") { 205 | let old_style = style.value.to_str(); 206 | let mut has_any_relative_urls = false; 207 | let mut tokens = vec![]; 208 | for token in parse_inline_style(style.value.to_str()) { 209 | tokens.push(match token { 210 | InlineStyleToken::Url(url) => { 211 | if let Some(url) = parse_path_relative_scheme_less_url_string(&url) 212 | { 213 | trace!(url, "found relative url in inline style"); 214 | has_any_relative_urls = true; 215 | InlineStyleToken::Url(SETTINGS.base_url_relativise(&url)) 216 | } else { 217 | InlineStyleToken::Url(url) 218 | } 219 | } 220 | other => other, 221 | }); 222 | } 223 | let new_style = serialise_inline_style(&tokens); 224 | if has_any_relative_urls { 225 | trace!("old style: {old_style}"); 226 | trace!("new style: {new_style}"); 227 | style.value = new_style.into(); 228 | } 229 | } 230 | } 231 | new_kids.push(kid.clone()); 232 | } 233 | Ok(()) 234 | })? {} 235 | 236 | Ok(dom) 237 | } 238 | -------------------------------------------------------------------------------- /src/rocket_eyre.rs: -------------------------------------------------------------------------------- 1 | //! Most of this is lifted from 2 | use jane_eyre::eyre; 3 | use rocket::{ 4 | http::Status, 5 | response::{self, content::RawText, Responder}, 6 | Request, 7 | }; 8 | use tracing::warn; 9 | 10 | /// A type alias with [`EyreReport`] to use `eyre::Result` in Rocket framework. 11 | pub type Result = std::result::Result; 12 | 13 | /// A wrapper of `eyre::Report` to be able to make use of `eyre` in Rocket framework. 14 | /// [`rocket::response::Responder`] is implemented to this type. 15 | #[derive(Debug)] 16 | pub enum EyreReport { 17 | BadRequest(eyre::Report), 18 | InternalServerError(eyre::Report), 19 | } 20 | 21 | impl From for EyreReport 22 | where 23 | E: Into, 24 | { 25 | fn from(error: E) -> Self { 26 | // default to InternalServerError 27 | EyreReport::InternalServerError(error.into()) 28 | } 29 | } 30 | 31 | impl<'r> Responder<'r, 'static> for EyreReport { 32 | fn respond_to(self, request: &Request<'_>) -> response::Result<'static> { 33 | let (status, error) = match self { 34 | Self::BadRequest(e) => (Status::BadRequest, e), 35 | Self::InternalServerError(e) => (Status::InternalServerError, e), 36 | }; 37 | 38 | warn!("Error: {:?}", error); 39 | let mut res = RawText(format!("{:?}", error)).respond_to(request)?; 40 | res.set_status(status); 41 | Ok(res) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{BTreeSet, HashMap}, 3 | fs::File, 4 | io::{BufRead, BufReader, Read}, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use jane_eyre::eyre::{self, bail}; 9 | use serde::Deserialize; 10 | use tracing::warn; 11 | 12 | use crate::{path::parse_path_relative_scheme_less_url_string, Author, TemplatedPost, Thread}; 13 | 14 | #[derive(Deserialize)] 15 | pub struct Settings { 16 | pub base_url: String, 17 | pub external_base_url: String, 18 | pub server_port: Option, 19 | pub site_title: String, 20 | pub other_self_authors: Vec, 21 | pub interesting_tags: Vec>, 22 | archived_thread_tags_path: Option, 23 | pub archived_thread_tags: Option>>, 24 | pub interesting_output_filenames_list_path: Option, 25 | interesting_archived_threads_list_path: Option, 26 | interesting_archived_threads_list: Option>, 27 | excluded_archived_threads_list_path: Option, 28 | excluded_archived_threads_list: Option>, 29 | pub self_author: Option, 30 | pub renamed_tags: Option>, 31 | pub implied_tags: Option>>, 32 | pub nav: Vec, 33 | 34 | #[deprecated(since = "0.3.0", note = "use path_to_static")] 35 | path_to_autost: Option, 36 | path_to_static: Option, 37 | } 38 | 39 | #[derive(Default, Deserialize)] 40 | pub struct TagDefinition { 41 | pub rename: Option, 42 | pub implies: Option>, 43 | } 44 | 45 | #[derive(Deserialize)] 46 | pub struct NavLink { 47 | pub href: String, 48 | pub text: String, 49 | } 50 | 51 | impl Settings { 52 | pub fn load_default() -> eyre::Result { 53 | Self::load("autost.toml") 54 | } 55 | 56 | pub fn load_example() -> eyre::Result { 57 | Self::load("autost.toml.example") 58 | } 59 | 60 | pub fn load(path: impl AsRef) -> eyre::Result { 61 | let mut result = String::default(); 62 | File::open(path)?.read_to_string(&mut result)?; 63 | let mut result: Settings = toml::from_str(&result)?; 64 | 65 | if !result.base_url.starts_with("/") { 66 | bail!("base_url setting must start with slash!"); 67 | } 68 | if result.base_url.starts_with("//") { 69 | bail!("base_url setting must not start with two slashes!"); 70 | } 71 | if !result.base_url.ends_with("/") { 72 | bail!("base_url setting must end with slash!"); 73 | } 74 | if !result.external_base_url.ends_with("/") { 75 | bail!("external_base_url setting must end with slash!"); 76 | } 77 | if let Some(path) = result.archived_thread_tags_path.as_ref() { 78 | let entries = BufReader::new(File::open(path)?) 79 | .lines() 80 | .collect::, _>>()?; 81 | let entries = entries 82 | .iter() 83 | .filter_map(|entry| entry.split_once(" ")) 84 | .map(|(archived, tags)| (archived, tags.split(","))) 85 | .map(|(archived, tags)| { 86 | ( 87 | archived.to_owned(), 88 | tags.map(ToOwned::to_owned).collect::>(), 89 | ) 90 | }) 91 | .collect(); 92 | result.archived_thread_tags = Some(entries); 93 | } 94 | if let Some(path) = result.interesting_archived_threads_list_path.as_ref() { 95 | let list = BufReader::new(File::open(path)?) 96 | .lines() 97 | .collect::, _>>()?; 98 | result.interesting_archived_threads_list = Some(list); 99 | } 100 | if let Some(path) = result.excluded_archived_threads_list_path.as_ref() { 101 | let list = BufReader::new(File::open(path)?) 102 | .lines() 103 | .collect::, _>>()?; 104 | result.excluded_archived_threads_list = Some(list); 105 | } 106 | #[allow(deprecated)] 107 | if result.path_to_autost.is_some() { 108 | warn!("path_to_autost setting is deprecated; use path_to_static instead"); 109 | if result.path_to_static.is_some() { 110 | bail!("path_to_autost and path_to_static settings are mutually exclusive"); 111 | } 112 | } 113 | 114 | Ok(result) 115 | } 116 | 117 | pub fn base_url_path_components(&self) -> impl Iterator { 118 | debug_assert_eq!(self.base_url.as_bytes()[0], b'/'); 119 | debug_assert_eq!(self.base_url.as_bytes()[self.base_url.len() - 1], b'/'); 120 | if self.base_url.len() > 1 { 121 | self.base_url[0..(self.base_url.len() - 1)] 122 | .split("/") 123 | .skip(1) 124 | } else { 125 | "".split("/").skip(1) 126 | } 127 | } 128 | 129 | pub fn base_url_relativise(&self, url: &str) -> String { 130 | if let Some(url) = parse_path_relative_scheme_less_url_string(url) { 131 | format!("{}{}", self.base_url, url) 132 | } else { 133 | url.to_owned() 134 | } 135 | } 136 | 137 | pub fn server_port(&self) -> u16 { 138 | self.server_port.unwrap_or(8420) 139 | } 140 | 141 | pub fn page_title(&self, title: Option<&str>) -> String { 142 | match title { 143 | Some(title) => format!("{} — {}", title, self.site_title), 144 | None => self.site_title.clone(), 145 | } 146 | } 147 | 148 | pub fn is_main_self_author(&self, author: &Author) -> bool { 149 | // compare href only, ignoring other fields 150 | self.self_author 151 | .as_ref() 152 | .map_or(false, |a| a.href == author.href) 153 | } 154 | 155 | pub fn is_any_self_author(&self, author: &Author) -> bool { 156 | // compare href only, ignoring other fields 157 | self.is_main_self_author(author) 158 | || self.other_self_authors.iter().any(|a| *a == author.href) 159 | } 160 | 161 | pub fn tag_is_interesting(&self, tag: &str) -> bool { 162 | self.interesting_tags_iter() 163 | .find(|&interesting_tag| interesting_tag == tag) 164 | .is_some() 165 | } 166 | 167 | pub fn interesting_tags_iter(&self) -> impl Iterator { 168 | self.interesting_tags.iter().flatten().map(|tag| &**tag) 169 | } 170 | 171 | pub fn interesting_tag_groups_iter(&self) -> impl Iterator { 172 | self.interesting_tags.iter().map(|tag| &**tag) 173 | } 174 | 175 | pub fn thread_is_on_interesting_archived_list(&self, thread: &Thread) -> bool { 176 | self.interesting_archived_threads_list 177 | .as_ref() 178 | .zip(thread.meta.archived.as_ref()) 179 | .is_some_and(|(list, archived)| list.iter().any(|x| x == archived)) 180 | } 181 | 182 | pub fn thread_is_on_excluded_archived_list(&self, thread: &Thread) -> bool { 183 | self.excluded_archived_threads_list 184 | .as_ref() 185 | .zip(thread.meta.archived.as_ref()) 186 | .is_some_and(|(list, archived)| list.iter().any(|x| x == archived)) 187 | } 188 | 189 | pub fn extra_archived_thread_tags(&self, post: &TemplatedPost) -> &[String] { 190 | self.archived_thread_tags 191 | .as_ref() 192 | .zip(post.meta.archived.as_ref()) 193 | .and_then(|(tags, archived)| tags.get(archived)) 194 | .map(|result| &**result) 195 | .unwrap_or(&[]) 196 | } 197 | 198 | pub fn resolve_tags(&self, tags: Vec) -> Vec { 199 | let mut seen = BTreeSet::default(); 200 | let mut result = tags; 201 | let mut old_len = 0; 202 | 203 | // loop until we fail to add any more tags. 204 | while result.len() > old_len { 205 | let old = result; 206 | old_len = old.len(); 207 | result = vec![]; 208 | for tag in old { 209 | let tag = self.renamed_tag(tag); 210 | if seen.insert(tag.clone()) { 211 | // prepend implied tags, such that more general tags go first. 212 | result.extend(self.implied_tags_shallow(&tag).to_vec()); 213 | } 214 | result.push(tag); 215 | } 216 | } 217 | 218 | let old = result; 219 | let mut result = vec![]; 220 | for tag in old { 221 | if !result.contains(&tag) { 222 | result.push(tag); 223 | } 224 | } 225 | 226 | result 227 | } 228 | 229 | fn renamed_tag(&self, tag: String) -> String { 230 | if let Some(tags) = &self.renamed_tags { 231 | if let Some(result) = tags.get(&tag) { 232 | return result.clone(); 233 | } 234 | } 235 | 236 | tag 237 | } 238 | 239 | fn implied_tags_shallow(&self, tag: &str) -> &[String] { 240 | if let Some(tags) = &self.implied_tags { 241 | if let Some(result) = tags.get(tag) { 242 | return result; 243 | } 244 | } 245 | 246 | &[] 247 | } 248 | 249 | pub fn path_to_static(&self) -> Option { 250 | #[allow(deprecated)] 251 | if let Some(path_to_autost) = self.path_to_autost.as_deref() { 252 | return Some(Path::new(path_to_autost).join("static")); 253 | } 254 | if let Some(path_to_static) = self.path_to_static.as_deref() { 255 | return Some(path_to_static.into()); 256 | } 257 | None 258 | } 259 | } 260 | 261 | #[test] 262 | fn test_example() -> eyre::Result<()> { 263 | Settings::load_example()?; 264 | 265 | Ok(()) 266 | } 267 | 268 | #[test] 269 | fn test_resolve_tags() -> eyre::Result<()> { 270 | let mut settings = Settings::load_example()?; 271 | settings.renamed_tags = Some( 272 | [ 273 | ("Foo".to_owned(), "foo".to_owned()), 274 | ("deep".to_owned(), "deep tag".to_owned()), 275 | ] 276 | .into_iter() 277 | .collect(), 278 | ); 279 | settings.implied_tags = Some( 280 | [ 281 | ("foo".to_owned(), vec!["bar".to_owned(), "baz".to_owned()]), 282 | ("bar".to_owned(), vec!["bar".to_owned(), "deep".to_owned()]), 283 | ("baz".to_owned(), vec!["foo".to_owned()]), 284 | ] 285 | .into_iter() 286 | .collect(), 287 | ); 288 | // resolving tags means 289 | // - implied tags are prepended in order 290 | // - implied tags are resolved recursively, avoiding cycles 291 | // - duplicate tags are removed by keeping the first occurrence 292 | assert_eq!( 293 | settings.resolve_tags(vec!["Foo".to_owned()]), 294 | ["bar", "deep tag", "foo", "baz"] 295 | ); 296 | 297 | Ok(()) 298 | } 299 | 300 | #[test] 301 | fn test_base_url_path_components() -> eyre::Result<()> { 302 | let mut settings = Settings::load_example()?; 303 | assert_eq!( 304 | settings.base_url_path_components().collect::>(), 305 | Vec::<&str>::default() 306 | ); 307 | 308 | settings.base_url = "/posts/".to_owned(); 309 | assert_eq!( 310 | settings.base_url_path_components().collect::>(), 311 | ["posts"] 312 | ); 313 | 314 | Ok(()) 315 | } 316 | -------------------------------------------------------------------------------- /static/Atkinson-Hyperlegible-Bold-102.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delan/autost/01e96bfc5b129e743e0424c6e11bfe4e74ae99ef/static/Atkinson-Hyperlegible-Bold-102.woff2 -------------------------------------------------------------------------------- /static/Atkinson-Hyperlegible-BoldItalic-102.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delan/autost/01e96bfc5b129e743e0424c6e11bfe4e74ae99ef/static/Atkinson-Hyperlegible-BoldItalic-102.woff2 -------------------------------------------------------------------------------- /static/Atkinson-Hyperlegible-Font-License-2020-1104.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delan/autost/01e96bfc5b129e743e0424c6e11bfe4e74ae99ef/static/Atkinson-Hyperlegible-Font-License-2020-1104.pdf -------------------------------------------------------------------------------- /static/Atkinson-Hyperlegible-Italic-102.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delan/autost/01e96bfc5b129e743e0424c6e11bfe4e74ae99ef/static/Atkinson-Hyperlegible-Italic-102.woff2 -------------------------------------------------------------------------------- /static/Atkinson-Hyperlegible-Regular-102.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delan/autost/01e96bfc5b129e743e0424c6e11bfe4e74ae99ef/static/Atkinson-Hyperlegible-Regular-102.woff2 -------------------------------------------------------------------------------- /static/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # usage: deploy.sh [-n] 3 | # -n = dry run 4 | set -eu 5 | dest=$1; shift 6 | interesting_output_filenames_list_path=$1; shift 7 | set -x 8 | upload() { 9 | # `--relative` means source paths like tagged/foo.feed.xml create a `tagged` 10 | # directory on the destination, without flattening the directory structure. 11 | rsync -av --no-i-r --info=progress2 --relative "$@" "$dest" 12 | } 13 | # `/./` means `--relative` only includes the part to the right, so the `site` 14 | # part still gets flattened on the destination. we do this instead of `cd site` 15 | # because the `$interesting_output_filenames_list_path` may be relative. 16 | upload "$@" site/./attachments site/./*.css site/./*.js site/./*.woff2 site/./*.pdf 17 | upload "$@" --files-from="$interesting_output_filenames_list_path" site/./ 18 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | const compose = document.querySelector("form.compose"); 2 | if (compose) { 3 | const sourceField = compose.querySelector(":scope > textarea.source"); 4 | const previewButton = compose.querySelector(":scope > button.preview"); 5 | const publishButton = compose.querySelector(":scope > button.publish"); 6 | const submitForm = async action => { 7 | const data = new URLSearchParams(new FormData(compose)); 8 | const response = await fetch(action, { 9 | method: "post", 10 | body: data, 11 | }); 12 | console.debug(`POST ${action}`); 13 | console.debug(response); 14 | return response; 15 | }; 16 | const error = e => { 17 | const error = compose.querySelector(":scope > pre.error"); 18 | if (e instanceof Error) { 19 | error.textContent = `${e.name}: ${e.message}`; 20 | } else { 21 | error.textContent = `${e}`; 22 | } 23 | renderTerminalError(error); 24 | }; 25 | const preview = async () => { 26 | try { 27 | const response = await submitForm(previewButton.formAction); 28 | const body = await response.text(); 29 | if (response.ok) { 30 | const preview = compose.querySelector(":scope > div.preview"); 31 | preview.innerHTML = body; 32 | const error = compose.querySelector(":scope > pre.error"); 33 | error.innerHTML = ""; 34 | } else { 35 | throw new Error(body); 36 | } 37 | } catch (e) { 38 | error(e); 39 | } 40 | }; 41 | const publish = async () => { 42 | try { 43 | const response = await submitForm(publishButton.formAction + "?js"); 44 | const body = await response.text(); 45 | if (response.ok) { 46 | location = body; 47 | return; 48 | } else { 49 | throw new Error(body); 50 | } 51 | } catch (e) { 52 | error(e); 53 | } 54 | }; 55 | compose.addEventListener("submit", event => { 56 | event.preventDefault(); 57 | if (event.submitter.value == "publish") { 58 | event.submitter.disabled = true; 59 | publish(); 60 | } else { 61 | event.preventDefault(); 62 | preview(); 63 | } 64 | }); 65 | sourceField.addEventListener("input", event => { 66 | preview(); 67 | }); 68 | previewButton.style.display = "none"; 69 | addEventListener("DOMContentLoaded", event => { 70 | preview(); 71 | }); 72 | } 73 | 74 | checkAutostServer(); 75 | 76 | async function checkAutostServer() { 77 | // if /compose exists, we are using the autost server. 78 | const composeUrl = `${document.body.dataset.baseUrl}compose`; 79 | const composeResponse = await fetch(composeUrl); 80 | if (!composeResponse.ok) return; 81 | 82 | const navUl = document.querySelector("nav > ul"); 83 | const li = document.createElement("li"); 84 | const a = document.createElement("a"); 85 | a.href = composeUrl; 86 | a.textContent = "compose"; 87 | a.className = "server"; 88 | li.append(a); 89 | navUl.append(li); 90 | 91 | for (const thread of document.querySelectorAll("article.thread")) { 92 | const actions = thread.querySelector(":scope > article.post:last-child > footer > .actions"); 93 | const a = document.createElement("a"); 94 | a.href = `${document.body.dataset.baseUrl}compose?${new URLSearchParams({ reply_to: thread.dataset.originalPath })}`; 95 | a.textContent = "reply"; 96 | a.className = "server"; 97 | actions.prepend(a); 98 | } 99 | 100 | for (const tag of document.querySelectorAll("nav .tags > li > .tag, article.post > footer .tags > .tag")) { 101 | const actions = tag.querySelector(":scope .actions"); 102 | const p_category = tag.querySelector(":scope .p-category"); 103 | const a = document.createElement("a"); 104 | a.href = `${document.body.dataset.baseUrl}compose?${new URLSearchParams({ tags: p_category.textContent })}`; 105 | a.textContent = "+"; 106 | a.className = "server"; 107 | actions.append(a); 108 | } 109 | } 110 | 111 | function renderTerminalError(pre) { 112 | // 113 | const csiRuns = pre.textContent.match(/\x1B\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]|[^\x1B]+|[^]/g); 114 | const result = []; 115 | let fgColor = 0; 116 | for (const run of csiRuns) { 117 | const match = run.match(/\x1B\[([\x30-\x3F]*)[\x20-\x2F]*([\x40-\x7E])/); 118 | if (match) { 119 | const [, params, mode] = match; 120 | /* sgr: select graphic rendition */ 121 | if (mode == "m") { 122 | for (const param of params.split(";")) { 123 | const num = parseInt(param || "0", 10); 124 | if (`${num}`.length != param.length) { 125 | continue; 126 | } 127 | if (num == 0 || num >= 30 && num <= 37 || num >= 90 && num <= 97) { 128 | fgColor = num; 129 | } 130 | } 131 | } 132 | } else { 133 | if (fgColor != 0) { 134 | const span = document.createElement("span"); 135 | span.style.color = `var(--sgr-${fgColor})`; 136 | span.append(run); 137 | result.push(span); 138 | } else { 139 | const text = document.createTextNode(run); 140 | result.push(text); 141 | } 142 | } 143 | } 144 | pre.innerHTML = ""; 145 | pre.append(...result); 146 | } 147 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | src: url(Atkinson-Hyperlegible-Regular-102.woff2); 3 | font-family: Atkinson Hyperlegible; 4 | } 5 | @font-face { 6 | src: url(Atkinson-Hyperlegible-Italic-102.woff2); 7 | font-family: Atkinson Hyperlegible; 8 | font-style: italic; 9 | } 10 | @font-face { 11 | src: url(Atkinson-Hyperlegible-Bold-102.woff2); 12 | font-family: Atkinson Hyperlegible; 13 | font-weight: bold; 14 | } 15 | @font-face { 16 | src: url(Atkinson-Hyperlegible-BoldItalic-102.woff2); 17 | font-family: Atkinson Hyperlegible; 18 | font-style: italic; 19 | font-weight: bold; 20 | } 21 | * { 22 | box-sizing: border-box; 23 | --not-white: #fff9f2; 24 | --not-black: #191919; 25 | --longan: #ffd8a8; 26 | --mango: #ffab5c; 27 | --line: #bfbab5; 28 | --shadow: 0px 4px 5px #00000024, 0px 1px 10px #0000001f, 0px 2px 4px #0003; 29 | --gray1: #827f7c; 30 | --gray2: #686664; 31 | --gray3: #4a4847; 32 | 33 | /* monokai terminal theme */ 34 | /* */ 35 | --monokai-fg: /* Editor Foreground */ #f8f8f2; 36 | --monokai-bg: /* Editor Background */ #272822; 37 | --sgr-30: /* Terminal ANSI Black */ #333333; 38 | --sgr-31: /* Terminal ANSI Red */ #c4265e; 39 | --sgr-32: /* Terminal ANSI Green */ #86b42b; 40 | --sgr-33: /* Terminal ANSI Yellow */ #b3b42b; 41 | --sgr-34: /* Terminal ANSI Blue */ #6a7ec8; 42 | --sgr-35: /* Terminal ANSI Magenta */ #8c6bc8; 43 | --sgr-36: /* Terminal ANSI Cyan */ #56adbc; 44 | --sgr-37: /* Terminal ANSI White */ #e3e3dd; 45 | --sgr-90: /* Terminal ANSI Bright Black */ #666666; 46 | --sgr-91: /* Terminal ANSI Bright Red */ #f92672; 47 | --sgr-92: /* Terminal ANSI Bright Green */ #a6e22e; 48 | --sgr-93: /* Terminal ANSI Bright Yellow */ #e2e22e; 49 | --sgr-94: /* Terminal ANSI Bright Blue */ #819aff; 50 | --sgr-95: /* Terminal ANSI Bright Magenta */ #ae81ff; 51 | --sgr-96: /* Terminal ANSI Bright Cyan */ #66d9ef; 52 | --sgr-97: /* Terminal ANSI Bright White */ #f8f8f2; 53 | } 54 | html { 55 | font-family: Atkinson Hyperlegible, system-ui, sans-serif; 56 | background: var(--not-white); 57 | color: var(--not-black); 58 | } 59 | body { 60 | margin: 1em auto; 61 | padding: 0 1em; 62 | max-width: 42em; 63 | } 64 | hr { 65 | color: var(--line); 66 | border: none; 67 | border-top: 1px solid; 68 | } 69 | body > nav { 70 | text-align: center; 71 | } 72 | body > nav ul { 73 | list-style: ""; 74 | padding: 0; 75 | } 76 | body > nav li { 77 | display: inline list-item; 78 | margin: 1em; 79 | } 80 | ul.tags a { 81 | display: inline-block; 82 | } 83 | article.thread { 84 | border: 1px solid var(--line); 85 | margin: 1em auto; 86 | background: white; 87 | border-radius: 0.5rem; 88 | overflow: hidden; 89 | box-shadow: var(--shadow); 90 | } 91 | body > nav :link, 92 | body > nav :visited, 93 | article.thread :link, 94 | article.thread :visited { 95 | color: currentColor; 96 | } 97 | article.post, article.post > .content { 98 | position: relative; 99 | contain: inline-size style layout paint; 100 | overflow: clip; 101 | overflow-wrap: break-word; 102 | } 103 | article.post:not(:first-child) { 104 | border-top: 1px solid var(--line); 105 | } 106 | article.thread > header, 107 | article.post > header, 108 | article.post > footer:not(:empty) { 109 | padding: 1em; 110 | background: var(--not-white); 111 | } 112 | article.thread > header .meta, 113 | article.post > header .meta { 114 | display: flex; 115 | flex-flow: row wrap; 116 | justify-content: space-between; 117 | } 118 | article.thread > header .handle, 119 | article.post > header .handle { 120 | color: var(--gray3); 121 | } 122 | article.thread > header .archived, 123 | article.post > header .archived, 124 | article.thread > header .time, 125 | article.post > header .time, 126 | article.thread > header time, 127 | article.post > header time { 128 | color: var(--gray1); 129 | } 130 | article.thread > header .gap, 131 | article.post > header .gap { 132 | width: 2em; 133 | visibility: hidden; 134 | text-align: center; 135 | } 136 | article.post > header > h1:not(:empty) { 137 | margin: 0.5rem 0 0; 138 | } 139 | article.post > footer { 140 | display: flex; 141 | flex-flow: row nowrap; 142 | justify-content: space-between; 143 | } 144 | body > nav ul.tags, 145 | article.post > footer .tag { 146 | color: var(--gray2); 147 | } 148 | article.post > .content > .e-content { 149 | margin: 1em 0; 150 | padding: 0 0.75em; 151 | } 152 | article.post > .content > .e-content > pre { 153 | overflow: auto; 154 | } 155 | @media screen and (max-width: 30em) { 156 | article.thread { 157 | margin-left: -1em; 158 | margin-right: -1em; 159 | } 160 | } 161 | /* cohost compatibility */ 162 | @keyframes spin { 163 | 100% { 164 | transform: rotate(360deg); 165 | } 166 | } 167 | @keyframes bounce { 168 | 0%, 100% { 169 | transform: translateY(-25%); 170 | animation-timing-function: cubic-bezier(0.8,0,1,1); 171 | } 172 | } 173 | :root { 174 | --color-notWhite: 255 249 242; 175 | --color-notBlack: 25 25 25; 176 | --color-cherry: 131 37 79; 177 | --color-strawberry: 229 107 111; 178 | --color-mango: 255 171 92; 179 | --color-longan: 255 216 168; 180 | --color-text: var(--color-notWhite); 181 | --color-bg-text: var(--color-notBlack); 182 | --color-foreground-100: 253 206 224; 183 | --color-foreground-200: 238 173 199; 184 | --color-foreground-300: 211 116 155; 185 | --color-foreground-400: 174 68 115; 186 | --color-foreground-500: 131 37 79; 187 | --color-foreground-600: 103 26 61; 188 | --color-foreground-700: 81 17 46; 189 | --color-foreground-800: 59 9 32; 190 | --color-foreground: var(--color-cherry); 191 | --color-secondary-200: 244 187 187; 192 | --color-secondary-300: 238 153 155; 193 | --color-secondary-400: 229 107 111; 194 | --color-secondary-600: 164 42 47; 195 | --color-secondary-700: 123 27 31; 196 | --color-secondary: var(--color-strawberry); 197 | --color-tertiary: var(--color-longan); 198 | --color-tertiary-200: 255 229 196; 199 | --color-tertiary-300: 255 216 168; 200 | --color-tertiary-400: 255 202 122; 201 | --color-tertiary-500: 183 133 61; 202 | --color-tertiary-600: 183 133 61; 203 | --color-tertiary-700: 132 94 38; 204 | --color-accent: var(--color-mango); 205 | --color-background: var(--color-notWhite); 206 | --color-sidebar-bg: var(--color-notWhite); 207 | --color-sidebar-text: var(--color-notBlack); 208 | --color-sidebar-accent: var(--color-cherry); 209 | --color-compose-button: var(--color-foreground); 210 | --color-compose-button-400: var(--color-foreground-400); 211 | --color-compose-button-600: var(--color-foreground-600); 212 | } 213 | article.post.cohost > .content img, 214 | article.post.cohost > .content video { 215 | max-width: 100%; 216 | height: auto; 217 | } 218 | article.post.cohost > .content img, 219 | article.post.cohost > .content video, 220 | article.post.cohost > .content audio { 221 | display: block; 222 | } 223 | article.post.cohost > .content :where(img):not(:where([class~="not-prose"], [class~="not-prose"] *)) { 224 | margin-top: 2em; 225 | margin-bottom: 2em; 226 | } 227 | 228 | /* server features */ 229 | 230 | article.post > footer > .actions:not(:empty) { 231 | margin-left: 1em; 232 | align-self: flex-end; 233 | } 234 | .server:not(#specificity) { 235 | color: var(--not-black); 236 | background: var(--mango); 237 | text-decoration: none; 238 | padding: 0.25em 0.5em; 239 | } 240 | 241 | /* post composer */ 242 | 243 | form.compose > textarea.source { 244 | width: 100%; 245 | height: 30vh; 246 | } 247 | form.compose > details.expand { 248 | cursor: pointer; 249 | user-select: none; 250 | width: max-content; 251 | } 252 | /* when “show shared posts in full?” is closed, show only the header of shared posts in the preview. */ 253 | form.compose > details.expand:not([open]) ~ .preview > article.thread > article.post:not(:last-child) > :not(header) { 254 | display: none; 255 | } 256 | form.compose > .error:not(:empty) { 257 | color: var(--monokai-fg); 258 | background: var(--monokai-bg); 259 | padding: 1em; 260 | } 261 | -------------------------------------------------------------------------------- /templates/akkoma-img.html: -------------------------------------------------------------------------------- 1 | {{ alt }} 2 | -------------------------------------------------------------------------------- /templates/ask.html: -------------------------------------------------------------------------------- 1 | 2 | 18 | -------------------------------------------------------------------------------- /templates/cohost-audio.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
      4 |
    • artist: {{ artist }} 5 |
    • title: {{ title }} 6 |
    7 |
    8 | -------------------------------------------------------------------------------- /templates/cohost-img.html: -------------------------------------------------------------------------------- 1 | {{ alt }} 2 | -------------------------------------------------------------------------------- /templates/compose.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | autost 5 | 6 | 11 |
    12 | 13 | 14 | 15 |
    show shared posts in full?
    16 |
    
    17 |     
    18 |
    19 | 20 | 21 | -------------------------------------------------------------------------------- /templates/feed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ updated }} 4 | {{ feed_title }} 5 | {% for thread in thread_refs %} 6 | 7 | {% if let Some(id) = thread.atom_feed_entry_id()? %}{{ id }}{% endif %} 8 | 9 | {% if let Some(published) = thread.meta.published %}{{ published }}{% endif %} 10 | {% if let Some(title) = thread.meta.title %}{{ title }}{% endif %} 11 | {% if let Some(author) = thread.meta.author %} 12 | {{ author.name }} 13 | {{ author.href }} 14 | {% endif %} 15 | {% for tag in thread.main_post()?.meta.tags.iter() %}{% endfor %} 16 | 17 | {#- fluent-reader needs html base tag, not xml:base (yang991178/fluent-reader#692) -#} 18 | <base href="{{ SETTINGS.external_base_url }}"> 19 | {{ ThreadsContentTemplate::render_simple(thread)? }} 20 | 21 | 22 | {% endfor %} 23 | 24 | -------------------------------------------------------------------------------- /templates/post-meta.html: -------------------------------------------------------------------------------- 1 | {%~ if let Some(archived) = archived ~%}{#- https://microformats.org/wiki/existing-rel-values#HTML5_link_type_extensions -#}{%~ endif ~%} 2 | {%~ for url in references ~%}{{~ "\n" ~}}{%~ endfor -%} 3 | {%~ if let Some(title) = title ~%}{%~ endif ~%} 4 | {%~ if let Some(published) = published ~%}{%~ endif ~%} 5 | {%~ if let Some(author) = author -%} 6 | 7 | 8 | 9 | {%- endif ~%} 10 | {%~ for tag in tags ~%}{{ "\n" }}{%~ endfor -%} 11 | {%~ if is_transparent_share ~%}{%~ endif ~%} 12 | -------------------------------------------------------------------------------- /templates/thread-or-post-author.html: -------------------------------------------------------------------------------- 1 | 2 | {%- if author.display_name.is_empty() -%} 3 | {{ author.display_handle }} 4 | {%- else -%} 5 | {{ author.display_name }} ({{ author.display_handle }}) 6 | {%- endif -%} 7 | 8 | -------------------------------------------------------------------------------- /templates/thread-or-post-header.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | {% if let Some(author) = post_meta.author %}{{ ThreadOrPostAuthorTemplate::render(author)?|safe }}{% endif %} 4 | {% if post_meta.author.is_some() && post_meta.published.is_some() %}—{% endif %} 5 | 6 | {% if let Some(archived) = post_meta.archived %}[archived]{% endif %} 7 | {% if is_thread_header || thread.meta.references.is_empty() %}{% endif %} 8 | {% if let Some(published) = post_meta.published %}{% endif %} 9 | {% if is_thread_header || thread.meta.references.is_empty() %}{% endif %} 10 | 11 |
    12 | {% if !is_thread_header %}

    13 | {% if thread.meta.references.is_empty() %}{% endif %} 14 | {% if let Some(title) = post_meta.title %}{{ title }}{% endif %} 15 | {% if thread.meta.references.is_empty() %}{% endif %} 16 |

    {% endif %} 17 |
    18 | -------------------------------------------------------------------------------- /templates/thread-or-post-meta.html: -------------------------------------------------------------------------------- 1 | 2 | {%~ if let Some(og_image) = thread.og_image ~%}{%~ endif ~%} 3 | {%~ if let Some(og_description) = thread.og_description ~%}{%~ endif ~%} 4 | -------------------------------------------------------------------------------- /templates/threads-content.html: -------------------------------------------------------------------------------- 1 |
    2 | {% if !simple_mode && !thread.meta.references.is_empty() %} 3 | {{ ThreadOrPostHeaderTemplate::render(thread,thread.meta,true)?|safe }} 4 | {% endif %} 5 | {% for post in thread.posts_in_thread() %} 6 | <{% if simple_mode && !post.is_main_post %}blockquote style=" 7 | margin: 1rem; 8 | padding: 1rem; 9 | border: 1px solid #bfbab5; 10 | border-radius: 0.5rem; 11 | box-shadow: 0px 4px 5px #00000024, 0px 1px 10px #0000001f, 0px 2px 4px #0003; 12 | "{% else %}article{% endif %} class="post cohost{% if !post.is_main_post %} h-entry{% endif %}"> 13 | {% if !simple_mode || !post.is_main_post %}{{ ThreadOrPostHeaderTemplate::render(thread,post.inner.meta,false)?|safe }}{% endif %} 14 | {% if !post.inner.meta.is_transparent_share %} 15 |
    {{ post.inner.safe_html|safe }}
    16 | {% endif %} 17 | 26 | 27 | {% endfor %} 28 |
    29 | -------------------------------------------------------------------------------- /templates/threads.html: -------------------------------------------------------------------------------- 1 | 2 | {%~ if let Some(feed_href) = feed_href ~%}{%~ endif ~%} 3 | 4 | 5 | {{ page_title }} 6 | {%~ if let Some(thread_page_meta) = thread_page_meta ~%}{{ thread_page_meta|safe }}{%~ endif ~%} 7 | 8 | 22 | {{ threads_content|safe }} 23 | 24 | -------------------------------------------------------------------------------- /wix/main.wxs: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 46 | 47 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 69 | 70 | 80 | 81 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 106 | 111 | 112 | 113 | 114 | 122 | 123 | 124 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 145 | 146 | 150 | 151 | 152 | 153 | 154 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 191 | 1 192 | 1 193 | 194 | 195 | 196 | 197 | 202 | 203 | 204 | 205 | 213 | 214 | 215 | 216 | 224 | 225 | 226 | 227 | 228 | 229 | --------------------------------------------------------------------------------