├── .github └── workflows │ ├── ci.yml │ └── docs.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Cargo.lock ├── Cargo.toml ├── Changes.md ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── README.md ├── book.toml ├── index.html └── src │ ├── README.md │ ├── SUMMARY.md │ ├── cli │ ├── README.md │ ├── build.md │ ├── import.md │ ├── init.md │ ├── mastodon.md │ ├── serve.md │ └── upgrade.md │ ├── customization.md │ ├── developers │ └── README.md │ └── quick-start.md ├── scripts └── build-docs.sh └── src ├── activitystreams.rs ├── app.rs ├── cli.rs ├── cli ├── build.rs ├── fetch.rs ├── import.rs ├── init.rs ├── mastodon.rs ├── mastodon │ ├── code.rs │ ├── fetch.rs │ ├── link.rs │ └── verify.rs ├── serve.rs └── upgrade.rs ├── config.rs ├── db.rs ├── db ├── activities.rs ├── actors.rs └── migrations │ ├── 202306241304-init.sql │ ├── 202306261338-object-type-and-indexes-up.sql │ ├── 202306262036-actors-up.sql │ ├── 202307021314-ispublic-up.sql │ ├── 202307021325-index-ispublic-up.sql │ ├── 202307191416-ingest-mastodon-statuses-up.sql │ ├── 202404140958-id-from-mastodon-status.sql │ └── 202404141112-drop-old-activities.sql ├── downloader.rs ├── lib.rs ├── main.rs ├── mastodon.rs ├── mastodon ├── fetcher.rs ├── importer.rs └── instance.rs ├── resources ├── default_config.toml ├── test │ ├── activity-with-attachment.json │ ├── activity-with-emoji.json │ ├── actor-remote.json │ ├── actor.json │ ├── mastodon-export.tar │ ├── mastodon-export.tar.gz │ ├── mastodon-export.zip │ ├── mastodon-status-with-attachment.json │ └── outbox.json └── themes │ └── default │ ├── templates │ ├── activity.html │ ├── day.html │ ├── index.html │ └── layout.html │ └── web │ ├── index.css │ ├── index.js │ ├── lib │ ├── archive-activity-list.js │ ├── archive-main.js │ ├── archive-nav │ │ ├── date-selector.js │ │ └── index.js │ ├── formatted-time.js │ ├── lazy-load-observer.js │ ├── media-lightbox.js │ └── theme-selector.js │ └── vendor │ └── timeago.min.js ├── site_generator.rs ├── templates.rs ├── templates └── contexts.rs └── themes.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # borrowed from https://raw.githubusercontent.com/houseabsolute/precious/master/.github/workflows/ci.yml 2 | name: Test, Build, & Release 3 | 4 | on: 5 | push: 6 | branches: 7 | - "**" 8 | tags-ignore: 9 | - "fossilizer-*" 10 | pull_request: 11 | 12 | env: 13 | CRATE_NAME: fossilizer 14 | GITHUB_TOKEN: ${{ github.token }} 15 | RUST_BACKTRACE: 1 16 | 17 | jobs: 18 | test: 19 | name: ${{ matrix.platform.os_name }} with rust ${{ matrix.toolchain }} 20 | runs-on: ${{ matrix.platform.os }} 21 | permissions: 22 | contents: write 23 | discussions: write 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | platform: 28 | - os_name: Linux-x86_64 29 | os: ubuntu-20.04 30 | target: x86_64-unknown-linux-musl 31 | bin: fossilizer 32 | name: fossilizer-Linux-x86_64-musl.tar.gz 33 | - os_name: Linux-aarch64 34 | os: ubuntu-20.04 35 | target: aarch64-unknown-linux-musl 36 | bin: fossilizer 37 | name: fossilizer-Linux-aarch64-musl.tar.gz 38 | - os_name: Linux-arm 39 | os: ubuntu-20.04 40 | target: arm-unknown-linux-musleabi 41 | bin: fossilizer 42 | name: fossilizer-Linux-arm-musl.tar.gz 43 | - os_name: macOS-x86_64 44 | os: macOS-latest 45 | target: x86_64-apple-darwin 46 | bin: fossilizer 47 | name: fossilizer-Darwin-x86_64.tar.gz 48 | - os_name: macOS-aarch64 49 | os: macOS-latest 50 | target: aarch64-apple-darwin 51 | bin: fossilizer 52 | name: fossilizer-Darwin-aarch64.tar.gz 53 | skip_tests: true 54 | - os_name: Windows-x86_64 55 | os: windows-latest 56 | target: x86_64-pc-windows-msvc 57 | bin: fossilizer.exe 58 | name: fossilizer-Windows-x86_64.zip 59 | 60 | # fixme: this target fails on building "ring"? 61 | # - os_name: Linux-i686 62 | # os: ubuntu-20.04 63 | # target: i686-unknown-linux-musl 64 | # bin: fossilizer 65 | # name: fossilizer-Linux-i686-musl.tar.gz 66 | # skip_tests: true 67 | 68 | # todo: support all these targets? 69 | # - os_name: Windows-i686 70 | # os: windows-latest 71 | # target: i686-pc-windows-msvc 72 | # bin: fossilizer.exe 73 | # name: fossilizer-Windows-i686.zip 74 | # skip_tests: true 75 | # - os_name: FreeBSD-x86_64 76 | # os: ubuntu-20.04 77 | # target: x86_64-unknown-freebsd 78 | # bin: fossilizer 79 | # name: fossilizer-FreeBSD-x86_64.tar.gz 80 | # skip_tests: true 81 | # - os_name: Linux-mips 82 | # os: ubuntu-20.04 83 | # target: mips-unknown-linux-musl 84 | # bin: fossilizer 85 | # name: fossilizer-Linux-mips.tar.gz 86 | # - os_name: Linux-mipsel 87 | # os: ubuntu-20.04 88 | # target: mipsel-unknown-linux-musl 89 | # bin: fossilizer 90 | # name: fossilizer-Linux-mipsel.tar.gz 91 | # - os_name: Linux-mips64 92 | # os: ubuntu-20.04 93 | # target: mips64-unknown-linux-muslabi64 94 | # bin: fossilizer 95 | # name: fossilizer-Linux-mips64.tar.gz 96 | # skip_tests: true 97 | # - os_name: Linux-mips64el 98 | # os: ubuntu-20.04 99 | # target: mips64el-unknown-linux-muslabi64 100 | # bin: fossilizer 101 | # name: fossilizer-Linux-mips64el.tar.gz 102 | # skip_tests: true 103 | # - os_name: Linux-powerpc 104 | # os: ubuntu-20.04 105 | # target: powerpc-unknown-linux-gnu 106 | # bin: fossilizer 107 | # name: fossilizer-Linux-powerpc-gnu.tar.gz 108 | # skip_tests: true 109 | # - os_name: Linux-powerpc64 110 | # os: ubuntu-20.04 111 | # target: powerpc64-unknown-linux-gnu 112 | # bin: fossilizer 113 | # name: fossilizer-Linux-powerpc64-gnu.tar.gz 114 | # skip_tests: true 115 | # - os_name: Linux-powerpc64le 116 | # os: ubuntu-20.04 117 | # target: powerpc64le-unknown-linux-gnu 118 | # bin: fossilizer 119 | # name: fossilizer-Linux-powerpc64le.tar.gz 120 | # skip_tests: true 121 | # - os_name: Linux-riscv64 122 | # os: ubuntu-20.04 123 | # target: riscv64gc-unknown-linux-gnu 124 | # bin: fossilizer 125 | # name: fossilizer-Linux-riscv64gc-gnu.tar.gz 126 | # - os_name: Linux-s390x 127 | # os: ubuntu-20.04 128 | # target: s390x-unknown-linux-gnu 129 | # bin: fossilizer 130 | # name: fossilizer-Linux-s390x-gnu.tar.gz 131 | # skip_tests: true 132 | # - os_name: NetBSD-x86_64 133 | # os: ubuntu-20.04 134 | # target: x86_64-unknown-netbsd 135 | # bin: fossilizer 136 | # name: fossilizer-NetBSD-x86_64.tar.gz 137 | # skip_tests: true 138 | # - os_name: Windows-aarch64 139 | # os: windows-latest 140 | # target: aarch64-pc-windows-msvc 141 | # bin: fossilizer.exe 142 | # name: fossilizer-Windows-aarch64.zip 143 | # skip_tests: true 144 | toolchain: 145 | - stable 146 | # todo: do we care about all toolchains? 147 | # - beta 148 | # - nightly 149 | steps: 150 | - uses: actions/checkout@v4 151 | - name: Cache cargo & target directories 152 | uses: Swatinem/rust-cache@v2 153 | - name: Configure Git 154 | run: | 155 | git config --global user.email "me@lmorchard.com" 156 | git config --global user.name "Les Orchard" 157 | - name: Install musl-tools on Linux 158 | run: sudo apt-get update --yes && sudo apt-get install --yes musl-tools 159 | if: contains(matrix.platform.name, 'musl') 160 | - name: Build binary 161 | uses: houseabsolute/actions-rust-cross@v0 162 | with: 163 | command: "build" 164 | target: ${{ matrix.platform.target }} 165 | toolchain: ${{ matrix.toolchain }} 166 | args: "--locked --release" 167 | strip: true 168 | - name: Run tests 169 | uses: houseabsolute/actions-rust-cross@v0 170 | with: 171 | command: "test" 172 | target: ${{ matrix.platform.target }} 173 | toolchain: ${{ matrix.toolchain }} 174 | args: "--locked --release" 175 | if: ${{ !matrix.platform.skip_tests }} 176 | - name: Package as archive 177 | shell: bash 178 | run: | 179 | cd target/${{ matrix.platform.target }}/release 180 | if [[ "${{ matrix.platform.os }}" == "windows-latest" ]]; then 181 | 7z a ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }} 182 | else 183 | tar czvf ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }} 184 | fi 185 | cd - 186 | if: | 187 | matrix.toolchain == 'stable' && 188 | ( startsWith( github.ref, 'refs/tags/v' ) || 189 | github.ref == 'refs/tags/test-release' ) 190 | - name: Publish release artifacts 191 | uses: actions/upload-artifact@v4 192 | with: 193 | name: fossilizer-${{ matrix.platform.os_name }} 194 | path: "fossilizer-*" 195 | if: matrix.toolchain == 'stable' && github.ref == 'refs/tags/test-release' 196 | - name: Generate SHA-256 197 | run: shasum -a 256 ${{ matrix.platform.name }} 198 | if: | 199 | matrix.toolchain == 'stable' && 200 | matrix.platform.os == 'macOS-latest' && 201 | ( startsWith( github.ref, 'refs/tags/v' ) || 202 | github.ref == 'refs/tags/test-release' ) 203 | - name: Publish GitHub release 204 | uses: softprops/action-gh-release@v2 205 | with: 206 | draft: true 207 | files: "fossilizer-*" 208 | body_path: Changes.md 209 | if: matrix.toolchain == 'stable' && startsWith( github.ref, 'refs/tags/v' ) 210 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build & Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | discussions: write 11 | 12 | env: 13 | CRATE_NAME: fossilizer 14 | GITHUB_TOKEN: ${{ github.token }} 15 | RUST_BACKTRACE: 1 16 | 17 | jobs: 18 | build: 19 | name: "Build & Deploy Docs" 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: write 23 | concurrency: 24 | group: ${{ github.workflow }}-${{ github.ref }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Cache cargo & target directories 28 | uses: Swatinem/rust-cache@v2 29 | - name: Configure Git 30 | run: | 31 | git config --global user.email "me@lmorchard.com" 32 | git config --global user.name "Les Orchard" 33 | - name: Build Docs 34 | run: ./scripts/build-docs.sh 35 | - name: Deploy 36 | uses: peaceiris/actions-gh-pages@v3 37 | if: github.ref == 'refs/heads/main' 38 | with: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | publish_dir: ./docs/book 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pagefind 2 | /target 3 | tmp 4 | Settings.toml 5 | *.sqlite3* 6 | /build 7 | /data 8 | /.vscode 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | me@lmorchard.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fossilizer" 3 | version = "0.3.1" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | activitystreams = "0.6.2" 10 | anyhow = "1.0.71" 11 | chrono = { version = "0.4.26", features = ["serde"] } 12 | clap = { version = "4.3.8", features = ["derive", "env", "cargo"] } 13 | config = "0.13.3" 14 | dotenv = "0.15.0" 15 | env_logger = "0.10.0" 16 | fallible-iterator = "0.3.0" 17 | flate2 = "1.0.26" 18 | fs_extra = "1.3.0" 19 | futures = "0.3.28" 20 | lazy_static = "1.4.0" 21 | log = "0.4.19" 22 | megalodon = "0.13.4" 23 | # megalodon = { path = "../megalodon-rs" } 24 | rand = "0.8.5" 25 | rayon = "1.7.0" 26 | rusqlite = { version = "0.29.0", features = ["bundled", "array", "serde_json"] } 27 | rusqlite_migration = "1.0.2" 28 | rust-embed = "6.7.0" 29 | serde = { version = "1.0.164", features = ["derive"] } 30 | serde_json = "1.0.99" 31 | serde_repr = "0.1.14" 32 | sha256 = "1.1.4" 33 | simple-logging = "2.0.2" 34 | tar = "0.4.38" 35 | tera = "1.19.0" 36 | tokio = { version = "1.29.1", features = ["full", "windows-sys"] } 37 | url = { version = "2.4.0", features = ["serde"] } 38 | reqwest = { version = "0.11.18", default-features = false, features = ["gzip", "json", "default-tls"] } 39 | openssl = { version = "0.10.55", features = ["vendored"] } 40 | zip = "0.6.6" 41 | walkdir = "2.4.0" 42 | warp = "0.3.7" 43 | opener = "0.7.0" 44 | toml = "0.8.12" 45 | fallible-streaming-iterator = "0.1.9" 46 | 47 | [dev-dependencies] 48 | mockito = "1.1.0" 49 | test-log = { version = "0.2.12" } 50 | -------------------------------------------------------------------------------- /Changes.md: -------------------------------------------------------------------------------- 1 | ## 0.1.2 2 | 3 | - rework template contexts into defined structs and add customization documentation 4 | 5 | - refactor some site generator code 6 | 7 | ## 0.1.1 8 | 9 | - Disable some work-in-progress features for the release build (i.e. fetch and fetch-mastodon) 10 | 11 | - General code cleanup and documentation work 12 | 13 | ## 0.1.0 14 | 15 | - Attempting to get this thing wired up for release builds on GitHub 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Les Orchard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fossilizer 2 | 3 | [![view - Documentation](https://img.shields.io/badge/view-Documentation-blue)](https://lmorchard.github.io/fossilizer/ "Go to project documentation") 4 | [![CI status](https://github.com/lmorchard/fossilizer/actions/workflows/ci.yml/badge.svg)](https://github.com/lmorchard/fossilizer/actions) 5 | 6 | This is an attempt to build a static site generator that ingests Mastodon exports and produces a web site based on the content as a personal archive or even as a way to publish a backup copy of your stuff. 7 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Les Orchard"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "Fossilizer" 7 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | README - Fossilizer 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 | 41 | 42 | 43 | 57 | 58 | 59 | 69 | 70 | 71 | 83 | 84 | 90 | 91 | 92 | 112 | 113 |
114 | 115 |
116 | 117 | 146 | 147 | 157 | 158 | 159 | 166 | 167 |
168 |
169 |

README

170 | 171 |
172 | 173 | 182 |
183 |
184 | 185 | 191 | 192 |
193 | 194 | 195 | 210 | 211 | 212 | 213 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 |
230 | 231 | 232 | -------------------------------------------------------------------------------- /docs/src/README.md: -------------------------------------------------------------------------------- 1 | {{#include ../../README.md}} -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [README](README.md) 4 | 5 | ## Reference Guide 6 | 7 | - [Quick Start](quick-start.md) 8 | - [Command Line Tool](cli/README.md) 9 | - [init](cli/init.md) 10 | - [import](cli/import.md) 11 | - [mastodon](cli/mastodon.md) 12 | - [build](cli/build.md) 13 | - [serve](cli/serve.md) 14 | - [upgrade](cli/upgrade.md) 15 | - [Customization](customization.md) 16 | - [Developers](developers/README.md) 17 | -------------------------------------------------------------------------------- /docs/src/cli/README.md: -------------------------------------------------------------------------------- 1 | # Command Line Tool 2 | 3 | The `fossilizer` command-line tool can be used to do all the things. 4 | 5 | The following sections describe the different commands available: 6 | 7 | - [`fossilizer init`](./init.md) 8 | - [`fossilizer import `](./import.md) 9 | - [`fossilizer mastodon`](./mastodon.md) 10 | - [`fossilizer build`](./build.md) 11 | - [`fossilizer serve`](./serve.md) 12 | - [`fossilizer upgrade`](./upgrade.md) 13 | -------------------------------------------------------------------------------- /docs/src/cli/build.md: -------------------------------------------------------------------------------- 1 | # The `build` command 2 | 3 | The `build` command is used to generate a static web site from imported 4 | content and media attachments. It's used like so: 5 | 6 | ```bash 7 | cd my-mastodon-site 8 | fossilzer build 9 | pagefind --keep-index-url --site build 10 | ``` 11 | 12 | Note: Until or unless [Pagefind can be integrated into Fossilzer](https://github.com/lmorchard/fossilizer/issues/7), it needs to be run as a separate command to provide search indexes and code modules for the site. 13 | 14 | After using the `build` command, you should end up with a `build` directory 15 | with a structure somewhat like this: 16 | 17 | ```bash 18 | my-mastodon-site/ 19 | ├── build 20 | │ ├── 2020 21 | │ ├── 2021 22 | │ ├── 2022 23 | │ ├── 2023 24 | │ ├── index.css 25 | │ ├── index.html 26 | │ ├── index.js 27 | │ ├── media 28 | │ ├── pagefind 29 | │ └── vendor 30 | ``` 31 | 32 | - Activities are organized into a `{year}/{month}/{day}.html` file structure 33 | 34 | - An `index.html` page is generated for the site overall, linking to the pages for each day 35 | 36 | - The `media` directory is copied directly from `data/media` 37 | 38 | - The `pagefind` directory is generated by Pagefind for client-side search 39 | 40 | - Other files and directories like `index.js`, `index.css`, `vendor` are static assets copied into the build 41 | 42 | You can customize both the templates and the static web assets used in this build. Check out [the `init --customize` option](./init.md#--customize) for more information. 43 | 44 | ## Options 45 | 46 | ### --theme 47 | Use the theme named `` for rendering the site. This will look for a directory named `themes/` in the `data` directory. 48 | 49 | ### --clean 50 | Delete build directory before proceeding 51 | 52 | ### --skip-index 53 | Skip building index page in HTML 54 | 55 | ### --skip-index-json 56 | Skip building index page in JSON 57 | 58 | ### --skip-activities 59 | Skip building pages for activities 60 | 61 | ### --skip-assets 62 | Skip copying over web assets 63 | -------------------------------------------------------------------------------- /docs/src/cli/import.md: -------------------------------------------------------------------------------- 1 | # The `import` command 2 | 3 | The `import` command is used to ingest the content from a Mastodon export into 4 | the SQLite database and extract media attachments. It's used like so: 5 | 6 | ```bash 7 | cd my-mastodon-site 8 | fossilizer import ../archive-20230720182703-36f08a7ce74bbf59f141b496b2b7f457.tar.gz 9 | ``` 10 | 11 | Depending on the size of your export, this command should take a few seconds or 12 | minutes to extract all the posts and attachments. 13 | 14 | Along with inserting database records, you'll find files like the following 15 | added to your data directory, including all the media attachments associated 16 | with the export under a directory based on the SHA-256 hash of the account 17 | address: 18 | 19 | ```bash 20 | my-mastodon-site/ 21 | └── data 22 | ├── data.sqlite3 23 | ├── media 24 | │ └── acc0bb231a7a2757c7e5c63aa68ce3cdbcfd32a43eb67a6bdedffe173c721184 25 | │ ├── avatar.png 26 | │ ├── header.jpg 27 | │ └── media_attachments 28 | │ └── files 29 | │ ├── 002 30 | │ │ ├── ... 31 | │ ├── 105 32 | │ │ ├── ... 33 | │ ├── 106 34 | │ │ ├── ... 35 | ``` 36 | 37 | You can run this command repeatedly, either with fresh exports from one 38 | Mastodon instance or with exports from many instances. All the data will be 39 | merged into the database from previous imports. 40 | 41 | After you've run this command, you can try [the `build` command](./build.md) to 42 | generate a static web site. 43 | -------------------------------------------------------------------------------- /docs/src/cli/init.md: -------------------------------------------------------------------------------- 1 | # The `init` command 2 | 3 | The `init` command prepares the current directory with data and configuration 4 | files needed by Fossilzer. It's used like so: 5 | 6 | ```bash 7 | mkdir my-mastodon-site 8 | cd my-mastodon-site 9 | fossilizer init 10 | ``` 11 | 12 | When using the `init` command for the first time, some files and directories 13 | will be set up for you: 14 | 15 | ```bash 16 | my-mastodon-site/ 17 | └── build 18 | └── data 19 | └── data.sqlite3 20 | ``` 21 | 22 | - The `build` directory is where your static site will be generated 23 | 24 | - The `data/data.sqlite3` file is a SQLite database into which things like 25 | posts and user account data will be stored. 26 | 27 | After you've run this command, you can try [the `import` command](./build.md) to 28 | ingest data from one or more Mastodon exports. 29 | 30 | ## Options 31 | 32 | ### `--clean` 33 | 34 | The `--clean` flag will delete existing `build` and `data` directories before 35 | setting things up. Be careful with this, because it will wipe out any existing 36 | data! 37 | 38 | ```bash 39 | fossilizer init --clean 40 | ``` 41 | 42 | ### `--customize` 43 | 44 | By default, Fossilzer will use templates and assets embedded in the executable 45 | to generate a static web site. However, if you'd like to customize how your 46 | site is generated, you can extract these into external files to edit: 47 | 48 | ```bash 49 | fossilizer init --customize 50 | ``` 51 | 52 | This will result in a file structure something like this: 53 | 54 | ```bash 55 | my-mastodon-site/ 56 | └── build 57 | └── data 58 | └── media 59 | ├── config.toml 60 | ├── data.sqlite3 61 | └── themes 62 | └── default 63 | ├── templates 64 | │ ├── activity.html 65 | │ ├── day.html 66 | │ ├── index.html 67 | │ └── layout.html 68 | └── web 69 | ├── index.css 70 | └── index.js 71 | ``` 72 | 73 | - The `config.toml` file can be used to supply configuration settings 74 | 75 | - The `data/themes` directory holds themes that can be used to customize the appearance of the site. The `default` theme is provided by default. If you want to use a different theme, you can copy the `default` directory and modify it under a directory with a different name. This name, then, can be supplied to the `build` command with the `--theme` option. 76 | 77 | - The `data/themes/default/templates` directory holds [Tera](https://tera.netlify.app/) templates used to generate HTML pages. 78 | 79 | - The `data/themes/default/web` directory holds web assets which will be copied into the root directory of your static site when it's generated. 80 | 81 | TODO: Need to document configuration settings and templates. For now, just play around with the templates [used by `cli/build.rs`](https://github.com/lmorchard/fossilizer/blob/main/src/cli/build.rs) and see what happens! 😅 Configuration settings can be found in the [`config.rs` module](https://github.com/lmorchard/fossilizer/blob/main/src/config.rs) 82 | -------------------------------------------------------------------------------- /docs/src/cli/mastodon.md: -------------------------------------------------------------------------------- 1 | # The `mastodon` sub-commands 2 | 3 | The `mastodon` collection of sub-commands is used to connect to a Mastodon instance and fetch toots from an account via the Mastodon API. 4 | 5 | To use these commands, first you'll need to connect to an existing account on a Mastodon instance using `link`, `code`, and then `verify` sub-commands. 6 | 7 | Then, you can fetch toots from that account and import them into the local database using the `fetch` sub-command. 8 | 9 | ## Selecting a Mastodon instance 10 | 11 | By default, the `mastodon` command will connect to the instance at `https://mastodon.social`. You can specify a different instance hostname with the `--instance` / `-i` option: 12 | 13 | ```bash 14 | fossilizer mastodon --instance mstdn.social link 15 | ``` 16 | 17 | Configuration and secrets for connecting to the selected Mastodon instance are stored in a file named `config-{instance}.toml` in the `data` directory. 18 | 19 | ## Connecting to a Mastodon instance 20 | 21 | Before importing toots from a Mastodon account, you'll need to connect to the instance and authenticate with an account. 22 | 23 | The `link` sub-command will begin this process by attempting to register a new application with your instance and then offering an authorization URL to visit in a web browser. For example: 24 | 25 | ```bash 26 | $ fossilizer mastodon link 27 | 28 | [2024-04-18T20:06:21Z INFO fossilizer::cli::mastodon::link] Visit this link to begin authorization: 29 | [2024-04-18T20:06:21Z INFO fossilizer::cli::mastodon::link] https://mastodon.social/oauth/authorize?client_id=w1pCC1ANqOqnrG6pk8cnbcMa0vTQjgmLQBHCrMqhEzY&scope=read+read%3Anotifications+read%3Astatuses+write+follow+push&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&response_type=code 30 | ``` 31 | 32 | Once you've visited this link and authorized the application, you'll be given a code to paste back into the terminal to complete the process. 33 | 34 | The `code` sub-command will complete the process by exchanging the code for an access token: 35 | 36 | ```bash 37 | $ fossilizer mastodon code 8675309jennyabcdefghiZZZFUVMixgjTlQMF0vK1I 38 | ``` 39 | 40 | After running the `code` sub-command, you can then run the `verify` sub-command to check that the connection is working: 41 | 42 | ```bash 43 | $ fossilizer mastodon verify 44 | 45 | [2024-04-18T20:09:04Z INFO fossilizer::cli::mastodon::verify] Verified as AuthVerifyResult { username: "lmorchard", url: "https://mastodon.social/@lmorchard", display_name: "Les Orchard 🕹\u{fe0f}🔧🐱🐰", created_at: "2016-11-01T00:00:00.000Z" } 46 | ``` 47 | 48 | Note that the access token secret obtained through the above steps is stored in the `config-{instance}.toml` file in the `data` directory: 49 | 50 | ``` 51 | data 52 | ├── config-instance-hackers.town.toml 53 | ├── config-instance-mastodon.social.toml 54 | └── data.sqlite3 55 | ``` 56 | 57 | Keep these files safe and don't publish them anywhere! Also, once you've connected to an instance, you can use the `--instance` / `-i` option to select it without needing to run `link` or `code` again. 58 | 59 | ## Fetching toots 60 | 61 | Once you've connected to a Mastodon instance, you can import toots from an account with the `fetch` sub-command. By default, this command will attempt to fetch and import the newest 100 toots in pages of 25. 62 | 63 | ```bash 64 | $ fossilizer mastodon fetch 65 | 66 | [2024-04-18T20:13:00Z INFO fossilizer::mastodon::fetcher] Fetching statuses for account https://mastodon.social/@lmorchard 67 | [2024-04-18T20:13:01Z INFO fossilizer::mastodon::fetcher] Fetched 25 (of 100 max)... 68 | [2024-04-18T20:13:04Z INFO fossilizer::mastodon::fetcher] Fetched 50 (of 100 max)... 69 | [2024-04-18T20:13:04Z INFO fossilizer::mastodon::fetcher] Fetched 75 (of 100 max)... 70 | [2024-04-18T20:13:05Z INFO fossilizer::mastodon::fetcher] Fetched 100 (of 100 max)... 71 | ``` 72 | 73 | You can adjust the number of toots fetched with the `--max` / `-m` option and the page size with the `--page` / `-p` option. However, note that the Mastodon API may limit the number of toots you can fetch in a single request: 74 | 75 | ```bash 76 | $ fossilizer mastodon fetch --max 200 --page 100 77 | 78 | [2024-04-18T20:15:28Z INFO fossilizer::mastodon::fetcher] Fetching statuses for account https://mastodon.social/@lmorchard 79 | [2024-04-18T20:15:29Z INFO fossilizer::mastodon::fetcher] Fetched 40 (of 200 max)... 80 | [2024-04-18T20:15:29Z INFO fossilizer::mastodon::fetcher] Fetched 80 (of 200 max)... 81 | [2024-04-18T20:15:30Z INFO fossilizer::mastodon::fetcher] Fetched 120 (of 200 max)... 82 | [2024-04-18T20:15:31Z INFO fossilizer::mastodon::fetcher] Fetched 160 (of 200 max)... 83 | [2024-04-18T20:15:31Z INFO fossilizer::mastodon::fetcher] Fetched 200 (of 200 max)... 84 | ``` 85 | 86 | ### Incremental fetching 87 | 88 | If you've already imported most of your toots and would like to fetch only the newest ones, you can use the `--incremental` option. This will stop the fetch process as soon as a page is encountered that contains a toot already in the database: 89 | 90 | ```bash 91 | $ fossilizer mastodon fetch --incremental 92 | 93 | 2024-04-18T20:17:49Z INFO fossilizer::mastodon::fetcher] Fetching statuses for account https://mastodon.social/@lmorchard 94 | [2024-04-18T20:17:50Z INFO fossilizer::mastodon::fetcher] Fetched 25 (of 100 max)... 95 | [2024-04-18T20:17:50Z INFO fossilizer::mastodon::fetcher] Stopping incremental fetch after catching up to imported activities 96 | ``` 97 | -------------------------------------------------------------------------------- /docs/src/cli/serve.md: -------------------------------------------------------------------------------- 1 | # The `serve` command 2 | 3 | The `serve` command starts up a local web server to allow access to the static web site. 4 | 5 | ```bash 6 | fossilizer serve 7 | ``` 8 | 9 | ## Options 10 | 11 | ### --host 12 | Listen on the specified `` address. Default is `127.0.0.1`. 13 | 14 | ### --port 15 | Listen on the specified `` number. Default is `8881`. 16 | 17 | ### --open 18 | Open a web browser to the server URL after starting. 19 | -------------------------------------------------------------------------------- /docs/src/cli/upgrade.md: -------------------------------------------------------------------------------- 1 | # The `upgrade` command 2 | 3 | The `upgrade` command is used to upgrade the database and perform any other 4 | necessary changes after downloading a new version of Fossilzer. 5 | 6 | Run this command whenever you upgrade Fossilzer. 7 | 8 | ```bash 9 | cd my-mastodon-site 10 | fossilzer upgrade 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/src/customization.md: -------------------------------------------------------------------------------- 1 | # Customization 2 | 3 | Try `fossilizer init --customize`, which unpacks the following for customization: 4 | 5 | - a `data/web` directory with static web assets that will be copied into the `build` directory 6 | 7 | - a `data/templates` directory with [Tera templates](https://tera.netlify.app/docs/) used to produce the HTML output 8 | 9 | - Note: this will *not* overwrite the database for an existing `data` directory, though it *will* overwrite any existing `templates` or `web` directories. 10 | 11 | Check out the templates to see how the pages are built. For a more in-depth reference on what variables are supplied when rendering templates, check out the crate documentation: 12 | 13 | - [`index.html` template context](./doc/fossilizer/templates/contexts/struct.IndexTemplateContext.html) 14 | - [`day.html` template context](./doc/fossilizer/templates/contexts/struct.DayTemplateContext.html) 15 | - [All template context structs](./doc/fossilizer/templates/contexts/index.html) 16 | -------------------------------------------------------------------------------- /docs/src/developers/README.md: -------------------------------------------------------------------------------- 1 | # For Developers 2 | 3 | TODO: jot down design notions and useful information for folks aiming to help contribute to or customize this software. 4 | 5 | `fossilizer` has not yet been published as a crate, but you can see the module docs here: 6 | 7 | - [Crate fossilizer](../doc/fossilizer/index.html) 8 | 9 | ## Odds & Ends 10 | 11 | - For some details on how SQLite is used here as an ad-hoc document database, check out this blog post on [Using SQLite as a document database for Mastodon exports](https://blog.lmorchard.com/2023/05/12/toots-in-sqlite/). TL;DR: JSON is stored as the main column in each row, while `json_extract()` is used mainly to generate virtual columns for lookup indexes. 12 | 13 | - When ingesting data, care is taken to attempt to store JSON as close to the original source as possible from APIs and input files. That way, data parsing and models can be incrementally upgraded over time without having lost any information from imported sources. 14 | -------------------------------------------------------------------------------- /docs/src/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | These are rough instructions for a rough command-line tool. There is no GUI, yet. 4 | 5 | 1. Request and download [an export from your Mastodon instance](https://docs.joinmastodon.org/user/moving/#export) (e.g. `archive-20230720182703-36f08a7ce74bbf59f141b496b2b7f457.tar.gz`) 6 | 1. Download [a release of pagefind](https://github.com/CloudCannon/pagefind/releases) and [install it](https://pagefind.app/docs/installation/) or use [a precompiled binary](https://pagefind.app/docs/installation/#downloading-a-precompiled-binary) 7 | 1. Download [a release of Fossilizer](https://github.com/lmorchard/fossilizer/releases) - there is no installation, just a standalone command. 8 | - Note: on macOS, you'll need to make an exception to run `fossilizer` in Security & Privacy settings 9 | 1. Make a working directory somewhere 10 | 1. Initialize the `data` directory: 11 | ``` 12 | fossilizer init 13 | ``` 14 | 1. Ingest your Mastodon export and extract media attachments: 15 | ``` 16 | fossilizer import archive-20230720182703-36f08a7ce74bbf59f141b496b2b7f457.tar.gz 17 | ``` 18 | 1. Build your static website in the `build` directory: 19 | ``` 20 | fossilizer build 21 | ``` 22 | 1. Build pagefind assets for search: 23 | ``` 24 | pagefind --keep-index-url --site build 25 | ``` 26 | 1. Serve the `build` directory up with a local web server - the `--open` option will attempt to open a browser: 27 | ``` 28 | fossilzer serve --open 29 | ``` 30 | 1. Enjoy a static web site of your Mastodon toots. 31 | 32 | ## Tips 33 | 34 | - Try `fossilizer` by itself for a list of subcommands, try `--help` as an option to get more details on any command. 35 | 36 | - Try `fossilizer upgrade` to upgrade the SQLite database and other assets when you download a new version. This is not (yet) automatic. 37 | 38 | - `data/config.toml` can be used to set many as-yet undocumented configuration options. 39 | 40 | - `data/data.sqlite3` is a a persistent SQLite database that accumulates all imported data. 41 | 42 | - `data/media` is where media attachments are unpacked. 43 | 44 | - You can repeatedly import data and import from multiple Mastodon instances. Everything will be merged. 45 | 46 | - Try `fossilizer init --customize`, which unpacks the following for customization: 47 | 48 | - a `data/web` directory with static web assets that will be copied into the `build` directory 49 | 50 | - a `data/templates` directory with [Tera](https://tera.netlify.app/docs/) templates used to produce the HTML output 51 | 52 | - Note: this will *not* overwrite the database for an existing `data` directory, though it *will* overwrite any existing `templates` or `web` directories. 53 | -------------------------------------------------------------------------------- /scripts/build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | # cargo install mdbook 5 | # mdbook build docs 6 | 7 | mkdir -p bin 8 | curl -sSL https://github.com/rust-lang/mdBook/releases/download/v0.4.37/mdbook-v0.4.37-x86_64-unknown-linux-gnu.tar.gz | tar -xz --directory=bin 9 | bin/mdbook build docs 10 | 11 | cargo doc --no-deps 12 | cp -r target/doc docs/book/ 13 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::path::Path; 3 | 4 | const DEFAULT_LOG_LEVEL: &str = "info"; 5 | 6 | use crate::config; 7 | 8 | pub fn init(config_path: &Path) -> Result<(), Box> { 9 | config::init(config_path)?; 10 | init_logging()?; 11 | Ok(()) 12 | } 13 | 14 | pub fn init_logging() -> Result<(), Box> { 15 | let log_level = config::get::("log_level").unwrap_or(DEFAULT_LOG_LEVEL.to_string()); 16 | let log_env = env_logger::Env::default().default_filter_or(log_level); 17 | env_logger::Builder::from_env(log_env).init(); 18 | 19 | Ok(()) 20 | } 21 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Parser, Subcommand}; 3 | use std::convert::From; 4 | use std::error::Error; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use fossilizer::app; 8 | 9 | pub mod build; 10 | pub mod import; 11 | pub mod init; 12 | pub mod mastodon; 13 | pub mod serve; 14 | pub mod upgrade; 15 | 16 | #[derive(Parser)] 17 | #[command(author, version, about, long_about = None)] 18 | #[command(propagate_version = true)] 19 | struct Cli { 20 | /// Sets a custom config file 21 | #[arg(short, long, value_name = "FILE")] 22 | config: Option, 23 | 24 | #[arg(short, long)] 25 | verbose: bool, 26 | 27 | #[arg(short, long)] 28 | quiet: bool, 29 | 30 | /// Turn debugging information on 31 | #[arg(short, long, action = clap::ArgAction::Count)] 32 | debug: u8, 33 | 34 | #[command(subcommand)] 35 | command: Commands, 36 | } 37 | 38 | #[derive(Subcommand)] 39 | enum Commands { 40 | /// Initialize the data directory 41 | Init(init::InitArgs), 42 | /// Upgrade the database 43 | Upgrade(upgrade::UpgradeArgs), 44 | /// Import Mastodon export tarballs 45 | Import(import::ImportArgs), 46 | /// Build the static site 47 | Build(build::BuildArgs), 48 | /// Serve the static site locally 49 | Serve(serve::ServeArgs), 50 | /// Connect to a Mastodon instance 51 | Mastodon(mastodon::Args), 52 | } 53 | 54 | pub async fn execute() -> Result<(), Box> { 55 | let cli = Cli::parse(); 56 | 57 | let config_path = match cli.config.as_deref() { 58 | Some(path) => path, 59 | None => Path::new("./data/config.toml"), 60 | }; 61 | 62 | app::init(config_path)?; 63 | 64 | match &cli.command { 65 | Commands::Init(args) => init::command(args).await, 66 | Commands::Upgrade(args) => upgrade::command(args).await, 67 | Commands::Import(args) => import::command(args).await, 68 | Commands::Build(args) => build::command(args).await, 69 | Commands::Serve(args) => serve::command(args).await, 70 | Commands::Mastodon(args) => mastodon::command(args).await, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/cli/build.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use fossilizer::{config, db, site_generator, templates}; 4 | use std::error::Error; 5 | 6 | #[derive(Debug, Args)] 7 | pub struct BuildArgs { 8 | /// Delete build directory before proceeding 9 | #[arg(short = 'k', long)] 10 | clean: bool, 11 | /// Skip building index page 12 | #[arg(long)] 13 | skip_index: bool, 14 | /// Skip building index JSON page 15 | #[arg(long)] 16 | skip_index_json: bool, 17 | /// Skip building pages for activities 18 | #[arg(long)] 19 | skip_activities: bool, 20 | /// Skip copying over web assets 21 | #[arg(long)] 22 | skip_assets: bool, 23 | /// Theme to use in building the static site 24 | #[arg(long)] 25 | theme: Option, 26 | } 27 | 28 | pub async fn command(args: &BuildArgs) -> Result<(), Box> { 29 | let clean = args.clean; 30 | let skip_index = args.skip_index; 31 | let skip_index_json = args.skip_index_json; 32 | let skip_activities = args.skip_activities; 33 | let skip_assets = args.skip_assets; 34 | 35 | config::update(|config| { 36 | if args.theme.is_some() { 37 | config.theme = args.theme.as_ref().unwrap().clone(); 38 | } 39 | })?; 40 | 41 | let config = config::config()?; 42 | debug!("Using theme {:?}", config.theme); 43 | 44 | site_generator::setup_build_path(&config.build_path, &clean)?; 45 | 46 | if !skip_assets { 47 | site_generator::copy_web_assets(&config.build_path).unwrap(); 48 | } 49 | 50 | if !skip_activities || !skip_index || !skip_index_json { 51 | let tera = templates::init().unwrap(); 52 | let db_conn = db::conn().unwrap(); 53 | let db_activities = db::activities::Activities::new(&db_conn); 54 | let db_actors = db::actors::Actors::new(&db_conn); 55 | 56 | let actors = db_actors.get_actors_by_id().unwrap(); 57 | let day_entries = 58 | site_generator::plan_activities_pages(&config.build_path, &db_activities).unwrap(); 59 | if !skip_index { 60 | site_generator::generate_index_page(&config.build_path, &day_entries, &tera).unwrap(); 61 | } 62 | if !skip_index_json { 63 | site_generator::generate_index_json(&config.build_path, &day_entries).unwrap(); 64 | } 65 | if !skip_activities { 66 | site_generator::generate_activities_pages( 67 | &config.build_path, 68 | &tera, 69 | &actors, 70 | &day_entries, 71 | ) 72 | .unwrap(); 73 | } 74 | } 75 | 76 | info!("Build finished"); 77 | 78 | Ok(()) 79 | } 80 | -------------------------------------------------------------------------------- /src/cli/fetch.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use reqwest::header; 3 | use std::convert::From; 4 | use std::error::Error; 5 | use url::Url; 6 | 7 | use fossilizer::activitystreams::{ 8 | Activity, Actor, Attachments, IdOrObject, OrderedCollection, OrderedCollectionPage, 9 | OrderedItems, CONTENT_TYPE, 10 | }; 11 | use fossilizer::{config, db, downloader}; 12 | 13 | #[derive(Debug, clap::Args)] 14 | pub struct Args { 15 | /// List of ActivityPub outbox URLs to be fetched 16 | actor_urls: Vec, 17 | } 18 | 19 | pub async fn command(args: &Args) -> Result<(), Box> { 20 | let config = config::config()?; 21 | let media_path = config.media_path(); 22 | let actor_urls = args.actor_urls.clone(); 23 | 24 | // todo: build a single-threaded activity import background task? 25 | 26 | let media_downloader = downloader::Downloader::default(); 27 | let media_download_manager = media_downloader.run(); 28 | 29 | let actor_downloader = tokio::spawn(async move { 30 | // todo: support plain fediverse address via webfinger lookup - @lmorchard@hackers.town 31 | 32 | let mut count = 0; 33 | 34 | let mut ap_default_headers = reqwest::header::HeaderMap::new(); 35 | ap_default_headers.insert( 36 | header::ACCEPT, 37 | header::HeaderValue::from_static(CONTENT_TYPE), 38 | ); 39 | let ap_client = reqwest::ClientBuilder::new() 40 | .default_headers(ap_default_headers) 41 | .build()?; 42 | 43 | for actor_url in actor_urls { 44 | let actor_raw: serde_json::Value = 45 | ap_client.get(actor_url).send().await?.json().await?; 46 | // todo: watch for "request not signed" error here! 47 | 48 | { 49 | let conn = db::conn().or(Err(anyhow!("database connection failed")))?; 50 | let actors = db::actors::Actors::new(&conn); 51 | actors.import_actor(&actor_raw)?; 52 | trace!("imported actor {:?}", actor_raw); 53 | } 54 | 55 | trace!("outside actor import block"); 56 | 57 | // todo: fetch media and adjust URLs in actor 58 | 59 | let actor = serde_json::from_value(actor_raw); 60 | trace!("parsed actor {:?}", actor); 61 | let actor: Actor = actor?; 62 | 63 | for attachment in actor.attachments() { 64 | let task = downloader::DownloadTask { 65 | url: Url::parse(attachment.url.as_str())?, 66 | destination: attachment.local_media_path(&media_path, &actor)?, 67 | }; 68 | trace!("enqueue actor attachment {:?}", task); 69 | media_downloader.queue(task)?; 70 | } 71 | 72 | let outbox: OrderedCollection = 73 | ap_client.get(&actor.outbox).send().await?.json().await?; 74 | 75 | let mut page_url = Some(outbox.first); 76 | while page_url.is_some() { 77 | debug!("importing {:?}", page_url); 78 | let page: OrderedCollectionPage = ap_client 79 | .get(page_url.unwrap()) 80 | .send() 81 | .await? 82 | .json() 83 | .await?; 84 | 85 | { 86 | let conn = db::conn().or(Err(anyhow!("database connection failed")))?; 87 | let activities = db::activities::Activities::new(&conn); 88 | activities.import_collection(&page)?; 89 | } 90 | 91 | // todo: fetch media and adjust URLs in items 92 | for activity in page.ordered_items() { 93 | let activity: Activity = serde_json::from_value(activity.clone())?; 94 | if let IdOrObject::Object(object) = &activity.object { 95 | for attachment in object.attachments() { 96 | media_downloader.queue(downloader::DownloadTask { 97 | url: Url::parse(attachment.url.as_str())?, 98 | destination: attachment.local_media_path(&media_path, &actor)?, 99 | })?; 100 | } 101 | } 102 | } 103 | 104 | page_url = page.next; 105 | 106 | count = count + 1; 107 | if count > 25 { 108 | //break; 109 | } 110 | } 111 | } 112 | 113 | media_downloader.close()?; 114 | 115 | anyhow::Ok(()) 116 | }); 117 | 118 | actor_downloader.await??; 119 | media_download_manager.await??; 120 | 121 | Ok(()) 122 | } 123 | -------------------------------------------------------------------------------- /src/cli/import.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use std::convert::From; 4 | use std::error::Error; 5 | use std::fs; 6 | use std::path::PathBuf; 7 | 8 | use fossilizer::{config, db, mastodon}; 9 | 10 | #[derive(Debug, Args)] 11 | pub struct ImportArgs { 12 | /// List of Mastodon .tar.gz export filenames to be imported 13 | filenames: Vec, 14 | /// Skip importing media files 15 | #[arg(long)] 16 | skip_media: bool, 17 | } 18 | 19 | pub async fn command(args: &ImportArgs) -> Result<(), Box> { 20 | let config = config::config()?; 21 | let skip_media = args.skip_media; 22 | 23 | for filename in &args.filenames { 24 | let data_path = PathBuf::from(&config.data_path); 25 | fs::create_dir_all(&data_path)?; 26 | 27 | let media_path = config.media_path(); 28 | fs::create_dir_all(&media_path)?; 29 | 30 | let conn = db::conn()?; 31 | 32 | let mut importer = mastodon::importer::Importer::new(conn, media_path, skip_media); 33 | let filename: PathBuf = filename.into(); 34 | info!("Importing {:?}", filename); 35 | importer.import(filename)?; 36 | } 37 | info!("Done"); 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /src/cli/init.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | 4 | use std::error::Error; 5 | 6 | use fossilizer::{db, site_generator}; 7 | 8 | #[derive(Debug, Args)] 9 | pub struct InitArgs { 10 | /// Delete any existing data directory before initializing 11 | #[arg(short = 'k', long)] 12 | clean: bool, 13 | /// Prepare the data directory with resources for customization 14 | #[arg(short, long)] 15 | customize: bool, 16 | } 17 | 18 | pub async fn command(args: &InitArgs) -> Result<(), Box> { 19 | site_generator::setup_data_path(&args.clean)?; 20 | db::upgrade()?; 21 | if args.customize { 22 | site_generator::unpack_customizable_resources()?; 23 | } 24 | Ok(()) 25 | } 26 | -------------------------------------------------------------------------------- /src/cli/mastodon.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Subcommand; 3 | use fossilizer::mastodon::instance::{load_instance_config, save_instance_config}; 4 | use std::error::Error; 5 | 6 | mod code; 7 | mod fetch; 8 | mod link; 9 | mod verify; 10 | 11 | #[derive(Debug, clap::Args)] 12 | pub struct Args { 13 | /// Host name of Mastodon instance 14 | #[arg(long, short = 'i', default_value = "mastodon.social")] 15 | instance: String, 16 | 17 | #[command(subcommand)] 18 | command: Commands, 19 | } 20 | 21 | #[derive(Debug, Subcommand)] 22 | enum Commands { 23 | /// Get a link to begin Mastodon authorization process 24 | Link(link::LinkArgs), 25 | /// Complete Mastodon authorization process with a code 26 | Code(code::CodeArgs), 27 | /// Verify authorized Mastodon account 28 | Verify(verify::VerifyArgs), 29 | /// Fetch new statuses from Mastodon account 30 | Fetch(fetch::FetchArgs), 31 | } 32 | 33 | pub async fn command(args: &Args) -> Result<(), Box> { 34 | let instance = args.instance.clone(); 35 | let mut instance_config = load_instance_config(&instance)?; 36 | match &args.command { 37 | Commands::Link(subcommand_args) => { 38 | link::command(subcommand_args, args, &mut instance_config).await? 39 | } 40 | Commands::Code(subcommand_args) => { 41 | code::command(subcommand_args, args, &mut instance_config).await? 42 | } 43 | Commands::Verify(subcommand_args) => { 44 | verify::command(subcommand_args, args, &mut instance_config).await? 45 | } 46 | Commands::Fetch(subcommand_args) => { 47 | fetch::command(subcommand_args, args, &mut instance_config).await? 48 | } 49 | } 50 | save_instance_config(&instance, &instance_config)?; 51 | Ok(()) 52 | } 53 | -------------------------------------------------------------------------------- /src/cli/mastodon/code.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crate::cli::mastodon::Args; 4 | use fossilizer::mastodon::{instance::InstanceConfig, OAUTH_SCOPES, REDIRECT_URI_OOB}; 5 | use serde::{Deserialize, Serialize}; 6 | use std::collections::HashMap; 7 | use std::error::Error; 8 | 9 | #[derive(Serialize, Deserialize, Debug)] 10 | struct CodeAuthResult { 11 | access_token: String, 12 | created_at: u32, 13 | } 14 | 15 | #[derive(Debug, clap::Args)] 16 | pub struct CodeArgs { 17 | /// Authorization code given by Mastodon authorization process 18 | #[arg()] 19 | code: String, 20 | } 21 | 22 | pub async fn command( 23 | args: &CodeArgs, 24 | parent_args: &Args, 25 | instance_config: &mut InstanceConfig, 26 | ) -> Result<(), Box> { 27 | let instance = &parent_args.instance; 28 | let code = &args.code; 29 | 30 | if instance_config.client_id.is_none() { 31 | // todo: throw an error if no client has been registered yet 32 | return Ok(()); 33 | } 34 | 35 | let mut params = HashMap::new(); 36 | params.insert("scopes", OAUTH_SCOPES); 37 | params.insert("redirect_uri", REDIRECT_URI_OOB); 38 | params.insert("grant_type", "authorization_code"); 39 | params.insert("code", code); 40 | 41 | let client_id = instance_config.client_id.as_ref().unwrap(); 42 | params.insert("client_id", client_id.as_str()); 43 | 44 | let client_secret = instance_config.client_secret.as_ref().unwrap(); 45 | params.insert("client_secret", client_secret.as_str()); 46 | 47 | let url = format!("https://{instance}/oauth/token"); 48 | let client = reqwest::ClientBuilder::new().build().unwrap(); 49 | let res = client.post(url).json(¶ms).send().await?; 50 | 51 | if res.status() == reqwest::StatusCode::OK { 52 | let result: CodeAuthResult = res.json().await?; 53 | instance_config.access_token = Some(result.access_token); 54 | instance_config.created_at = Some(result.created_at); 55 | println!("CODE {} {:?}", args.code, instance_config.access_token); 56 | Ok(()) 57 | } else { 58 | // todo: throw an error here 59 | error!("Failed to authorize with code"); 60 | Ok(()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/cli/mastodon/fetch.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::mastodon::Args; 2 | use anyhow::Result; 3 | use fossilizer::{db, mastodon::instance::InstanceConfig}; 4 | 5 | use std::error::Error; 6 | 7 | use fossilizer::{config, mastodon::fetcher::Fetcher}; 8 | 9 | #[derive(Debug, clap::Args)] 10 | pub struct FetchArgs { 11 | /// Number of statuses fetched per page 12 | #[arg(long, short = 'p', default_value = "25")] 13 | page: u32, 14 | /// Maximum number of statuses to fetch overall 15 | #[arg(long, short = 'm', default_value = "100")] 16 | max: u32, 17 | /// Stop fetching once a page includes statuses already in the database 18 | #[arg(long, short = 'n', default_value = "false")] 19 | incremental: bool, 20 | } 21 | 22 | pub async fn command( 23 | args: &FetchArgs, 24 | parent_args: &Args, 25 | instance_config: &mut InstanceConfig, 26 | ) -> Result<(), Box> { 27 | let max = args.max; 28 | let page = args.page; 29 | let incremental: bool = args.incremental; 30 | 31 | let config = config::config()?; 32 | let media_path = config.media_path(); 33 | 34 | let mut fetcher = Fetcher::new( 35 | db::conn()?, 36 | parent_args.instance.clone(), 37 | instance_config.clone(), 38 | media_path.clone(), 39 | page, 40 | max, 41 | incremental, 42 | ); 43 | 44 | fetcher.fetch().await?; 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /src/cli/mastodon/link.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::mastodon::Args; 2 | use anyhow::Result; 3 | use fossilizer::mastodon::{ 4 | instance::{register_client_app, InstanceConfig}, 5 | OAUTH_SCOPES, REDIRECT_URI_OOB, 6 | }; 7 | use std::error::Error; 8 | 9 | #[derive(Debug, clap::Args)] 10 | pub struct LinkArgs {} 11 | 12 | pub async fn command( 13 | _args: &LinkArgs, 14 | parent_args: &Args, 15 | instance_config: &mut InstanceConfig, 16 | ) -> Result<(), Box> { 17 | let instance = &parent_args.instance; 18 | 19 | if instance_config.client_id.is_none() { 20 | register_client_app(&instance, instance_config).await?; 21 | } 22 | 23 | let base_url = format!("https://{instance}/oauth/authorize"); 24 | let client_id = instance_config.client_id.as_ref().unwrap(); 25 | let params = [ 26 | ("client_id", client_id.as_str()), 27 | ("scope", OAUTH_SCOPES), 28 | ("redirect_uri", REDIRECT_URI_OOB), 29 | ("response_type", "code"), 30 | ]; 31 | let link = reqwest::Url::parse_with_params(&base_url, ¶ms)?; 32 | 33 | info!("Visit this link to begin authorization:"); 34 | info!("{link}"); 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /src/cli/mastodon/verify.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::cli::mastodon::Args; 6 | use fossilizer::mastodon::instance::InstanceConfig; 7 | use std::error::Error; 8 | 9 | #[derive(Serialize, Deserialize, Debug)] 10 | struct AuthVerifyResult { 11 | username: String, 12 | url: String, 13 | display_name: String, 14 | created_at: String, 15 | } 16 | 17 | #[derive(Debug, clap::Args)] 18 | pub struct VerifyArgs {} 19 | 20 | pub async fn command( 21 | _args: &VerifyArgs, 22 | parent_args: &Args, 23 | instance_config: &mut InstanceConfig, 24 | ) -> Result<(), Box> { 25 | let instance = &parent_args.instance; 26 | 27 | if instance_config.access_token.is_none() { 28 | // todo: throw error if no access_token has been acquired 29 | return Ok(()); 30 | } 31 | 32 | let access_token = instance_config.access_token.as_ref().unwrap(); 33 | let url = format!("https://{instance}/api/v1/accounts/verify_credentials"); 34 | let client = reqwest::ClientBuilder::new().build().unwrap(); 35 | let res = client 36 | .get(url) 37 | .header("Authorization", format!("Bearer {access_token}")) 38 | .send() 39 | .await?; 40 | 41 | if res.status() == reqwest::StatusCode::OK { 42 | let result: AuthVerifyResult = res.json().await?; 43 | info!("Verified as {:?}", result); 44 | Ok(()) 45 | } else { 46 | // todo: throw an error here 47 | error!("Failed to verify authorized user"); 48 | Ok(()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/cli/serve.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use fossilizer::config; 4 | use std::error::Error; 5 | use std::ffi::OsStr; 6 | use std::net::SocketAddr; 7 | 8 | #[derive(Debug, Args)] 9 | pub struct ServeArgs { 10 | /// Host at which to serve files 11 | #[arg(long, short = 'n', default_value = "127.0.0.1")] 12 | host: String, 13 | /// Port at which to serve files 14 | #[arg(long, short = 'p', default_value = "8881")] 15 | port: u16, 16 | /// Open a web browser after starting the server 17 | #[arg(long)] 18 | open: bool, 19 | } 20 | 21 | pub async fn command(args: &ServeArgs) -> Result<(), Box> { 22 | let open_browser = args.open; 23 | 24 | let config = config::config()?; 25 | let build_path = config.build_path; 26 | 27 | let addr = format!("{}:{}", args.host.clone(), args.port); 28 | let addr: SocketAddr = addr.parse().unwrap(); 29 | 30 | let serving_url = format!("http://{}", addr); 31 | 32 | info!( 33 | "Serving up {} at {}", 34 | build_path.to_str().unwrap(), 35 | serving_url 36 | ); 37 | 38 | if open_browser { 39 | open(serving_url); 40 | } 41 | 42 | warp::serve(warp::fs::dir(build_path)).run(addr).await; 43 | Ok(()) 44 | } 45 | 46 | fn open>(path: P) { 47 | info!("Opening web browser"); 48 | if let Err(e) = opener::open(path) { 49 | error!("Error opening web browser: {}", e); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/cli/upgrade.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use fossilizer::db; 4 | use std::error::Error; 5 | 6 | #[derive(Debug, Args)] 7 | pub struct UpgradeArgs {} 8 | 9 | pub async fn command(_args: &UpgradeArgs) -> Result<(), Box> { 10 | db::upgrade()?; 11 | Ok(()) 12 | } 13 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use config::Config; 2 | use dotenv; 3 | use serde::{Deserialize, Serialize}; 4 | use std::error::Error; 5 | use std::path::{Path, PathBuf}; 6 | use std::sync::RwLock; 7 | 8 | const ENV_PREFIX: &str = "APP"; 9 | 10 | lazy_static! { 11 | static ref CONTEXT: RwLock = RwLock::new(AppContext::default()); 12 | pub static ref DEFAULT_CONFIG: String = 13 | include_str!("./resources/default_config.toml").to_string(); 14 | } 15 | 16 | #[derive(Default, Clone)] 17 | pub struct AppContext { 18 | pub raw_config: Config, 19 | pub config: AppConfig, 20 | } 21 | 22 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 23 | pub struct AppConfig { 24 | #[serde(default = "default_build_path")] 25 | pub build_path: PathBuf, 26 | 27 | #[serde(default = "default_data_path")] 28 | pub data_path: PathBuf, 29 | 30 | #[serde(default = "default_theme")] 31 | pub theme: String, 32 | 33 | pub mastodon_access_token: Option, 34 | } 35 | pub fn default_build_path() -> PathBuf { 36 | PathBuf::from(".").join("build") 37 | } 38 | pub fn default_data_path() -> PathBuf { 39 | PathBuf::from(".").join("data") 40 | } 41 | pub fn default_theme() -> String { 42 | "default".into() 43 | } 44 | impl AppConfig { 45 | // todo: allow each of these to be individually overriden 46 | pub fn media_path(&self) -> PathBuf { 47 | self.build_path.join("media") 48 | } 49 | pub fn database_path(&self) -> PathBuf { 50 | self.data_path.join("data.sqlite3") 51 | } 52 | pub fn config_path(&self) -> PathBuf { 53 | self.data_path.join("config.toml") 54 | } 55 | pub fn themes_path(&self) -> PathBuf { 56 | self.data_path.join("themes") 57 | } 58 | pub fn templates_path(&self) -> PathBuf { 59 | self.themes_path().join(&self.theme).join("templates") 60 | } 61 | pub fn web_assets_path(&self) -> PathBuf { 62 | self.themes_path().join(&self.theme).join("web") 63 | } 64 | } 65 | 66 | pub fn init(config_path: &Path) -> Result<(), Box> { 67 | dotenv::dotenv().ok(); 68 | 69 | let mut config = Config::builder(); 70 | if config_path.is_file() { 71 | config = config.add_source(config::File::from(config_path)); 72 | } 73 | config = config.add_source(config::Environment::with_prefix(ENV_PREFIX)); 74 | 75 | let config = config.build().unwrap(); 76 | 77 | let mut context = CONTEXT.write()?; 78 | context.raw_config = config.clone(); 79 | context.config = config.try_deserialize().unwrap(); 80 | 81 | Ok(()) 82 | } 83 | 84 | pub fn config() -> Result> { 85 | Ok(CONTEXT.read()?.config.clone()) 86 | } 87 | 88 | pub fn update(updater: U) -> Result<(), Box> 89 | where 90 | U: FnOnce(&mut AppConfig) -> (), 91 | { 92 | let mut context = CONTEXT.write()?; 93 | updater(&mut context.config); 94 | Ok(()) 95 | } 96 | 97 | pub fn get<'de, T: Deserialize<'de>>(key: &str) -> Result> { 98 | let context = CONTEXT.read()?; 99 | let value = context.raw_config.get::(key)?; 100 | Ok(value) 101 | } 102 | 103 | /// Attempt to deserialize the entire configuration into the requested type. 104 | pub fn try_deserialize<'de, T: Deserialize<'de>>() -> Result> { 105 | let context = CONTEXT.read()?; 106 | let config = context.raw_config.clone(); 107 | let value = config.try_deserialize::()?; 108 | Ok(value) 109 | } 110 | -------------------------------------------------------------------------------- /src/db.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use lazy_static::lazy_static; 3 | use rusqlite::Connection; 4 | use rusqlite_migration::{Migrations, M}; 5 | use std::error::Error; 6 | use std::fs; 7 | use std::path::Path; 8 | 9 | pub mod activities; 10 | pub mod actors; 11 | 12 | use crate::config; 13 | 14 | lazy_static! { 15 | // TODO: iterate through directory using rust_embed? 16 | static ref MIGRATIONS: Migrations<'static> = Migrations::new(vec![ 17 | M::up(include_str!("./db/migrations/202306241304-init.sql")), 18 | M::up(include_str!( 19 | "./db/migrations/202306261338-object-type-and-indexes-up.sql" 20 | )), 21 | M::up(include_str!("./db/migrations/202306262036-actors-up.sql")), 22 | M::up(include_str!("./db/migrations/202307021314-ispublic-up.sql")), 23 | M::up(include_str!("./db/migrations/202307021325-index-ispublic-up.sql")), 24 | M::up(include_str!("./db/migrations/202307191416-ingest-mastodon-statuses-up.sql")), 25 | M::up(include_str!("./db/migrations/202404140958-id-from-mastodon-status.sql")), 26 | M::up(include_str!("./db/migrations/202404141112-drop-old-activities.sql")), 27 | ]); 28 | } 29 | 30 | pub fn conn() -> Result> { 31 | let config = config::config()?; 32 | 33 | let database_path = config.database_path(); 34 | let database_parent_path = Path::new(&database_path).parent().ok_or("no parent path")?; 35 | fs::create_dir_all(database_parent_path).unwrap(); 36 | 37 | let conn = Connection::open(&database_path)?; 38 | conn.pragma_update(None, "journal_mode", "WAL").unwrap(); 39 | conn.pragma_update(None, "foreign_keys", "ON").unwrap(); 40 | 41 | rusqlite::vtab::array::load_module(&conn)?; 42 | 43 | trace!("database connection opened {:?}", database_path); 44 | Ok(conn) 45 | } 46 | 47 | pub fn upgrade() -> Result<(), Box> { 48 | let config = config::config()?; 49 | 50 | let mut conn = Connection::open(config.database_path())?; 51 | MIGRATIONS.to_latest(&mut conn)?; 52 | Ok(()) 53 | } 54 | -------------------------------------------------------------------------------- /src/db/activities.rs: -------------------------------------------------------------------------------- 1 | use crate::activitystreams::{Activity, OrderedItems}; 2 | use anyhow::anyhow; 3 | use anyhow::Result; 4 | use megalodon::entities::Status; 5 | use rusqlite::types::Value; 6 | use rusqlite::{params, Connection}; 7 | use serde::Serialize; 8 | use std::string::ToString; 9 | use std::{rc::Rc, str::FromStr}; 10 | 11 | // todo: make this configurable? 12 | const IMPORT_TRANSACTION_PAGE_SIZE: usize = 500; 13 | 14 | const ACTIVITYSCHEMA_ACTIVITY: &str = "fossilizer::activitystreams::Activity"; 15 | const ACTIVITYSCHEMA_STATUS: &str = "megalodon::entities::Status"; 16 | 17 | #[derive(Default, Debug, Clone, PartialEq)] 18 | pub enum ActivitySchema { 19 | #[default] 20 | Activity, 21 | Status, 22 | Unknown(String), 23 | } 24 | impl FromStr for ActivitySchema { 25 | type Err = anyhow::Error; 26 | fn from_str(s: &str) -> std::result::Result { 27 | Ok(match s { 28 | ACTIVITYSCHEMA_ACTIVITY => ActivitySchema::Activity, 29 | ACTIVITYSCHEMA_STATUS => ActivitySchema::Status, 30 | _ => ActivitySchema::Unknown(s.to_string()), 31 | }) 32 | } 33 | } 34 | impl ToString for ActivitySchema { 35 | fn to_string(&self) -> String { 36 | match self { 37 | ActivitySchema::Activity => ACTIVITYSCHEMA_ACTIVITY.to_string(), 38 | ActivitySchema::Status => ACTIVITYSCHEMA_STATUS.to_string(), 39 | ActivitySchema::Unknown(s) => s.clone(), 40 | } 41 | } 42 | } 43 | pub trait WhichActivitySchema { 44 | fn which_activity_schema(&self) -> ActivitySchema; 45 | } 46 | impl WhichActivitySchema for megalodon::entities::Status { 47 | fn which_activity_schema(&self) -> ActivitySchema { 48 | ActivitySchema::Status 49 | } 50 | } 51 | impl WhichActivitySchema for crate::activitystreams::Activity { 52 | fn which_activity_schema(&self) -> ActivitySchema { 53 | ActivitySchema::Activity 54 | } 55 | } 56 | 57 | pub struct Activities<'a> { 58 | conn: &'a Connection, 59 | } 60 | 61 | impl<'a> Activities<'a> { 62 | pub fn new(conn: &'a Connection) -> Self { 63 | Self { conn } 64 | } 65 | 66 | pub fn import(&self, item: &T) -> Result<()> { 67 | let schema = item.which_activity_schema().to_string(); 68 | let json_text = serde_json::to_string_pretty(&item)?; 69 | let mut stmt = self.conn.prepare_cached( 70 | r#" 71 | INSERT OR REPLACE INTO activities 72 | (schema, json) 73 | VALUES 74 | (?1, ?2) 75 | "#, 76 | )?; 77 | stmt.execute(params![schema, json_text])?; 78 | 79 | Ok(()) 80 | } 81 | 82 | pub fn import_many(&self, activities: &[T]) -> Result<()> { 83 | let conn = self.conn; 84 | 85 | // todo: use conn.transaction()? 86 | conn.execute("BEGIN TRANSACTION", ())?; 87 | 88 | for (count, item) in activities.iter().enumerate() { 89 | if count > 0 && (count % IMPORT_TRANSACTION_PAGE_SIZE) == 0 { 90 | info!("Imported {:?} items", count); 91 | conn.execute("COMMIT TRANSACTION", ())?; 92 | conn.execute("BEGIN TRANSACTION", ())?; 93 | } 94 | trace!("Inserting {:?}", count); 95 | self.import(item)?; 96 | } 97 | 98 | conn.execute("COMMIT TRANSACTION", ())?; 99 | 100 | Ok(()) 101 | } 102 | 103 | pub fn import_activity(&self, activity: T) -> Result<()> { 104 | let json_text = serde_json::to_string_pretty(&activity)?; 105 | let mut stmt = self 106 | .conn 107 | .prepare_cached("INSERT OR REPLACE INTO activities (json) VALUES (?1)")?; 108 | stmt.execute(params![json_text])?; 109 | 110 | Ok(()) 111 | } 112 | 113 | pub fn import_collection(&self, collection: &impl OrderedItems) -> Result<()> { 114 | let conn = self.conn; 115 | 116 | // todo: use conn.transaction()? 117 | conn.execute("BEGIN TRANSACTION", ())?; 118 | 119 | for (count, item) in collection.ordered_items().iter().enumerate() { 120 | if count > 0 && (count % IMPORT_TRANSACTION_PAGE_SIZE) == 0 { 121 | info!("Imported {:?} items", count); 122 | conn.execute("COMMIT TRANSACTION", ())?; 123 | conn.execute("BEGIN TRANSACTION", ())?; 124 | } 125 | trace!("Inserting {:?}", count); 126 | self.import_activity(item)?; 127 | } 128 | 129 | conn.execute("COMMIT TRANSACTION", ())?; 130 | 131 | Ok(()) 132 | } 133 | 134 | pub fn get_published_years(&self) -> SingleColumnResult { 135 | query_single_column( 136 | self.conn, 137 | r#" 138 | SELECT publishedYear 139 | FROM activities 140 | WHERE isPublic = 1 141 | GROUP BY publishedYear 142 | "#, 143 | [], 144 | ) 145 | } 146 | 147 | pub fn get_published_months_for_year(&self, year: &String) -> SingleColumnResult { 148 | query_single_column( 149 | self.conn, 150 | r#" 151 | SELECT publishedYearMonth 152 | FROM activities 153 | WHERE publishedYear = ? AND isPublic = 1 154 | GROUP BY publishedYearMonth 155 | "#, 156 | [year], 157 | ) 158 | } 159 | 160 | pub fn get_published_days_for_month(&self, month: &String) -> SingleColumnResult { 161 | query_single_column( 162 | self.conn, 163 | r#" 164 | SELECT publishedYearMonthDay 165 | FROM activities 166 | WHERE publishedYearMonth = ?1 AND isPublic = 1 167 | GROUP BY publishedYearMonthDay 168 | "#, 169 | [month], 170 | ) 171 | } 172 | 173 | pub fn get_published_months(&self) -> SingleColumnResult { 174 | query_single_column( 175 | self.conn, 176 | r#" 177 | SELECT publishedYearMonth 178 | FROM activities 179 | WHERE isPublic = 1 180 | GROUP BY publishedYearMonth 181 | ORDER BY publishedYearMonth 182 | "#, 183 | [], 184 | ) 185 | } 186 | 187 | pub fn get_published_days(&self) -> Result, rusqlite::Error> { 188 | let conn = self.conn; 189 | let mut stmt = conn.prepare_cached( 190 | r#" 191 | SELECT publishedYearMonthDay, count(id) 192 | FROM activities 193 | WHERE isPublic = 1 194 | GROUP BY publishedYearMonthDay 195 | ORDER BY publishedYearMonthDay 196 | "#, 197 | )?; 198 | let res = stmt 199 | .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? 200 | .collect::, _>>()?; 201 | 202 | Ok(res) 203 | } 204 | 205 | pub fn get_activities_for_day(&self, day: &String) -> Result> { 206 | query_activities( 207 | self.conn, 208 | r#" 209 | SELECT json, schema 210 | FROM activities 211 | WHERE publishedYearMonthDay = ?1 AND isPublic = 1 212 | ORDER BY published ASC 213 | "#, 214 | [day], 215 | ) 216 | } 217 | 218 | pub fn get_activities_by_ids(&self, ids: &Vec) -> Result> { 219 | query_activities( 220 | self.conn, 221 | r#" 222 | SELECT json, schema 223 | FROM activities 224 | WHERE id IN rarray(?1) 225 | ORDER BY published ASC 226 | "#, 227 | [ids_to_rarray_param(ids)], 228 | ) 229 | } 230 | 231 | pub fn count_activities_by_ids(&self, ids: &Vec) -> Result { 232 | query_count( 233 | self.conn, 234 | r#" 235 | SELECT COUNT(id) 236 | FROM activities 237 | WHERE id IN rarray(?1) 238 | ORDER BY published ASC 239 | "#, 240 | [ids_to_rarray_param(ids)], 241 | ) 242 | } 243 | } 244 | 245 | // todo: move these query utilities into a separate module? 246 | 247 | type SingleColumnResult = Result, rusqlite::Error>; 248 | 249 | fn ids_to_rarray_param(ids: &Vec) -> Rc> { 250 | Rc::new(ids.iter().cloned().map(Value::from).collect::>()) 251 | } 252 | 253 | fn query_single_column

( 254 | conn: &Connection, 255 | sql: &str, 256 | params: P, 257 | ) -> Result, rusqlite::Error> 258 | where 259 | P: rusqlite::Params, 260 | { 261 | let mut stmt = conn.prepare_cached(sql)?; 262 | let result = stmt.query_map(params, |r| r.get(0))?.collect(); 263 | result 264 | } 265 | 266 | fn query_count

(conn: &Connection, sql: &str, params: P) -> Result 267 | where 268 | P: rusqlite::Params, 269 | { 270 | let mut stmt = conn.prepare_cached(sql)?; 271 | // todo: wow, this is ugly. find a more elegant way to extract count from rows? 272 | let count = stmt 273 | .query(params)? 274 | .next()? 275 | .ok_or(anyhow!("no count returned"))? 276 | .get(0)?; 277 | Ok(count) 278 | } 279 | 280 | fn query_activities

(conn: &Connection, sql: &str, params: P) -> Result> 281 | where 282 | P: rusqlite::Params, 283 | { 284 | let mut stmt = conn.prepare_cached(sql)?; 285 | let result = stmt 286 | .query_and_then(params, |r| -> Result { 287 | let json_data: String = r.get(0)?; 288 | let schema_str: String = r.get(1)?; 289 | match schema_str.parse::()? { 290 | ActivitySchema::Activity => Ok(serde_json::from_str::(&json_data)?), 291 | ActivitySchema::Status => Ok(serde_json::from_str::(&json_data)?.into()), 292 | _ => Err(anyhow!("unknown schema {:?}", schema_str)), 293 | } 294 | })? 295 | .collect::>>(); 296 | result 297 | } 298 | 299 | #[cfg(test)] 300 | mod tests { 301 | use super::*; 302 | use test_log::test; 303 | 304 | #[test] 305 | fn test_activityschema_serde() -> Result<()> { 306 | let cases = vec![ 307 | (ActivitySchema::Activity, ACTIVITYSCHEMA_ACTIVITY), 308 | (ActivitySchema::Status, ACTIVITYSCHEMA_STATUS), 309 | ( 310 | ActivitySchema::Unknown(String::from("lolbutts")), 311 | "lolbutts", 312 | ), 313 | ]; 314 | for (expected_schema, expected_str) in cases { 315 | assert_eq!(expected_schema.to_string(), expected_str); 316 | let result_schema: ActivitySchema = expected_str.parse()?; 317 | assert_eq!(result_schema, expected_schema); 318 | } 319 | Ok(()) 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/db/actors.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use rusqlite::{params, Connection}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::collections::HashMap; 5 | use std::error::Error; 6 | 7 | use crate::activitystreams; 8 | 9 | pub struct Actors<'a> { 10 | conn: &'a Connection, 11 | } 12 | 13 | impl<'a> Actors<'a> { 14 | pub fn new(conn: &'a Connection) -> Self { 15 | Self { conn } 16 | } 17 | 18 | pub fn import_actor(&self, actor: T) -> Result<()> { 19 | // todo: throw an error if id is null? 20 | let json_text = serde_json::to_string_pretty(&actor)?; 21 | self.conn.execute( 22 | "INSERT OR REPLACE INTO actors (json) VALUES (?1)", 23 | params![json_text], 24 | )?; 25 | Ok(()) 26 | } 27 | 28 | pub fn get_actor Deserialize<'de>>( 29 | &self, 30 | id: &String, 31 | ) -> Result> { 32 | let conn = &self.conn; 33 | let mut stmt = conn.prepare("SELECT json FROM actors WHERE id = ?")?; 34 | let json_text: String = stmt.query_row([id], |row| row.get(0))?; 35 | let actor: T = serde_json::from_str(json_text.as_str())?; 36 | Ok(actor) 37 | } 38 | 39 | pub fn get_actors Deserialize<'de>>(&self) -> Result> { 40 | let conn = &self.conn; 41 | // todo: fix actor import that results in null id? (i.e. failed request, error imported as "actor") 42 | let mut stmt = conn.prepare("SELECT json FROM actors WHERE id IS NOT NULL")?; 43 | let mut rows = stmt.query([])?; 44 | let mut out = Vec::new(); 45 | while let Some(r) = rows.next()? { 46 | let json_text: String = r.get(0)?; 47 | let actor: T = serde_json::from_str(json_text.as_str())?; 48 | out.push(actor); 49 | } 50 | Ok(out) 51 | } 52 | 53 | pub fn get_actors_by_id( 54 | &self, 55 | ) -> Result, Box> { 56 | Ok(self 57 | .get_actors::()? 58 | .into_iter() 59 | .map(|actor| (actor.id.clone(), actor)) 60 | .collect()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/db/migrations/202306241304-init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE activities ( 2 | json TEXT, 3 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 4 | 5 | id TEXT GENERATED ALWAYS AS (json_extract(json, "$.id")) VIRTUAL UNIQUE, 6 | type TEXT GENERATED ALWAYS AS (json_extract(json, "$.type")) VIRTUAL, 7 | url TEXT GENERATED ALWAYS AS (json_extract(json, "$.object.url")) VIRTUAL, 8 | actorId TEXT GENERATED ALWAYS AS (json_extract(json, "$.actor")) VIRTUAL, 9 | summary TEXT GENERATED ALWAYS AS (json_extract(json, "$.object.summary")) VIRTUAL, 10 | content TEXT GENERATED ALWAYS AS (json_extract(json, "$.object.content")) VIRTUAL, 11 | published DATETIME GENERATED ALWAYS AS (json_extract(json, "$.published")) VIRTUAL, 12 | publishedYear TEXT GENERATED ALWAYS AS (strftime("%Y", json_extract(json, "$.published"))) VIRTUAL, 13 | publishedYearMonth TEXT GENERATED ALWAYS AS (strftime("%Y/%m", json_extract(json, "$.published"))) VIRTUAL, 14 | publishedYearMonthDay TEXT GENERATED ALWAYS AS (strftime("%Y/%m/%d", json_extract(json, "$.published"))) VIRTUAL 15 | ) 16 | -------------------------------------------------------------------------------- /src/db/migrations/202306261338-object-type-and-indexes-up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE activities 2 | ADD COLUMN objectType TEXT GENERATED ALWAYS AS (json_extract(json, "$.object.type")) VIRTUAL; 3 | 4 | CREATE INDEX idx_activities_by_publishedYearMonthDay ON activities(publishedYearMonthDay); 5 | CREATE INDEX idx_activities_by_publishedYearMonth ON activities(publishedYearMonth); 6 | CREATE INDEX idx_activities_by_publishedYear ON activities(publishedYear); -------------------------------------------------------------------------------- /src/db/migrations/202306262036-actors-up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE actors ( 2 | json TEXT, 3 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 4 | id TEXT GENERATED ALWAYS AS (json_extract(json, "$.id")) VIRTUAL UNIQUE, 5 | type TEXT GENERATED ALWAYS AS (json_extract(json, "$.type")) VIRTUAL 6 | ); 7 | CREATE INDEX idx_actors_by_id ON actors(id); 8 | -------------------------------------------------------------------------------- /src/db/migrations/202307021314-ispublic-up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE activities 2 | ADD COLUMN isPublic INTEGER 3 | GENERATED ALWAYS AS 4 | ( 5 | like("%https://www.w3.org/ns/activitystreams#Public%", json_extract(json, "$.to")) or 6 | like("%https://www.w3.org/ns/activitystreams#Public%", json_extract(json, "$.cc")) 7 | ) 8 | VIRTUAL; 9 | 10 | CREATE INDEX idx_activities_by_ispublic ON activities(isPublic); 11 | -------------------------------------------------------------------------------- /src/db/migrations/202307021325-index-ispublic-up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_activities_by_publishedYearMonthDay_2 ON activities(publishedYearMonthDay, isPublic); 2 | CREATE INDEX idx_activities_by_publishedYearMonth_2 ON activities(publishedYearMonth, isPublic); 3 | CREATE INDEX idx_activities_by_publishedYear_2 ON activities(publishedYear, isPublic); 4 | 5 | DROP INDEX idx_activities_by_publishedYearMonthDay; 6 | DROP INDEX idx_activities_by_publishedYearMonth; 7 | DROP INDEX idx_activities_by_publishedYear; 8 | -------------------------------------------------------------------------------- /src/db/migrations/202307191416-ingest-mastodon-statuses-up.sql: -------------------------------------------------------------------------------- 1 | -- drop generated columns not directly involved in lookup indexes, we can always re-add later 2 | ALTER TABLE activities DROP COLUMN type; 3 | ALTER TABLE activities DROP COLUMN url; 4 | ALTER TABLE activities DROP COLUMN actorId; 5 | ALTER TABLE activities DROP COLUMN summary; 6 | ALTER TABLE activities DROP COLUMN content; 7 | -- drop indexes, because we're going to redefine the dependent columns 8 | DROP INDEX idx_activities_by_ispublic; 9 | DROP INDEX idx_activities_by_publishedYearMonthDay_2; 10 | DROP INDEX idx_activities_by_publishedYearMonth_2; 11 | DROP INDEX idx_activities_by_publishedYear_2; 12 | -- drop the columns for published time, since we're going to redefine these 13 | ALTER TABLE activities DROP COLUMN isPublic; 14 | ALTER TABLE activities DROP COLUMN published; 15 | ALTER TABLE activities DROP COLUMN publishedYear; 16 | ALTER TABLE activities DROP COLUMN publishedYearMonth; 17 | ALTER TABLE activities DROP COLUMN publishedYearMonthDay; 18 | -- add revised generated columns to accomodate mastodon status JSON 19 | ALTER TABLE activities 20 | ADD COLUMN isPublic INTEGER GENERATED ALWAYS AS ( 21 | json_extract(json, "$.visibility") == 'public' 22 | or like( 23 | '%https://www.w3.org/ns/activitystreams#Public%', 24 | json_extract(json, '$.to') 25 | ) 26 | or like( 27 | '%https://www.w3.org/ns/activitystreams#Public%', 28 | json_extract(json, '$.cc') 29 | ) 30 | ) VIRTUAL; 31 | ALTER TABLE activities 32 | ADD COLUMN published DATETIME GENERATED ALWAYS AS ( 33 | coalesce( 34 | json_extract(json, "$.published"), 35 | json_extract(json, "$.created_at") 36 | ) 37 | ) VIRTUAL; 38 | ALTER TABLE activities 39 | ADD COLUMN publishedYear DATETIME GENERATED ALWAYS AS ( 40 | strftime( 41 | "%Y", 42 | coalesce( 43 | json_extract(json, "$.published"), 44 | json_extract(json, "$.created_at") 45 | ) 46 | ) 47 | ) VIRTUAL; 48 | ALTER TABLE activities 49 | ADD COLUMN publishedYearMonth DATETIME GENERATED ALWAYS AS ( 50 | strftime( 51 | "%Y/%m", 52 | coalesce( 53 | json_extract(json, "$.published"), 54 | json_extract(json, "$.created_at") 55 | ) 56 | ) 57 | ) VIRTUAL; 58 | ALTER TABLE activities 59 | ADD COLUMN publishedYearMonthDay DATETIME GENERATED ALWAYS AS ( 60 | strftime( 61 | "%Y/%m/%d", 62 | coalesce( 63 | json_extract(json, "$.published"), 64 | json_extract(json, "$.created_at") 65 | ) 66 | ) 67 | ) VIRTUAL; 68 | -- all JSON added until now should be an activitystreams Activity 69 | ALTER TABLE activities 70 | ADD COLUMN schema TEXT DEFAULT "fossilizer::activitystreams::Activity"; 71 | -- re-add the lookup indexes 72 | CREATE INDEX idx_activities_by_ispublic ON activities(isPublic); 73 | CREATE INDEX idx_activities_by_publishedYearMonthDay ON activities(publishedYearMonthDay, isPublic); 74 | CREATE INDEX idx_activities_by_publishedYearMonth ON activities(publishedYearMonth, isPublic); 75 | CREATE INDEX idx_activities_by_publishedYear ON activities(publishedYear, isPublic); -------------------------------------------------------------------------------- /src/db/migrations/202404140958-id-from-mastodon-status.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE new_activities ( 2 | schema TEXT DEFAULT "fossilizer::activitystreams::Activity", 3 | json TEXT, 4 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | -- try to munge together an id column from Mastodon status properties which matches activitypub export 6 | id TEXT GENERATED ALWAYS AS ( 7 | iif( 8 | schema == "megalodon::entities::Status", 9 | iif( 10 | json_extract(json, '$.reblog') is not null, 11 | json_extract(json, '$.uri'), 12 | json_extract(json, '$.uri') || "/activity" 13 | ), 14 | json_extract(json, "$.id") 15 | ) 16 | ) VIRTUAL UNIQUE, 17 | objectType TEXT GENERATED ALWAYS AS ( 18 | json_extract(json, '$.object.type') 19 | ) VIRTUAL, 20 | isPublic INTEGER GENERATED ALWAYS AS ( 21 | json_extract(json, "$.visibility") == 'public' 22 | or like( 23 | '%https://www.w3.org/ns/activitystreams#Public%', 24 | json_extract(json, '$.to') 25 | ) 26 | or like( 27 | '%https://www.w3.org/ns/activitystreams#Public%', 28 | json_extract(json, '$.cc') 29 | ) 30 | ) VIRTUAL, 31 | published DATETIME GENERATED ALWAYS AS ( 32 | coalesce( 33 | json_extract(json, "$.published"), 34 | json_extract(json, "$.created_at") 35 | ) 36 | ) VIRTUAL, 37 | publishedYear DATETIME GENERATED ALWAYS AS ( 38 | strftime( 39 | "%Y", 40 | coalesce( 41 | json_extract(json, "$.published"), 42 | json_extract(json, "$.created_at") 43 | ) 44 | ) 45 | ) VIRTUAL, 46 | publishedYearMonth DATETIME GENERATED ALWAYS AS ( 47 | strftime( 48 | "%Y/%m", 49 | coalesce( 50 | json_extract(json, "$.published"), 51 | json_extract(json, "$.created_at") 52 | ) 53 | ) 54 | ) VIRTUAL, 55 | publishedYearMonthDay DATETIME GENERATED ALWAYS AS ( 56 | strftime( 57 | "%Y/%m/%d", 58 | coalesce( 59 | json_extract(json, "$.published"), 60 | json_extract(json, "$.created_at") 61 | ) 62 | ) 63 | ) VIRTUAL 64 | ); 65 | 66 | INSERT OR REPLACE INTO new_activities(schema, json, created_at) 67 | SELECT schema, json, created_at FROM activities; 68 | 69 | DROP INDEX IF EXISTS idx_activities_by_ispublic; 70 | DROP INDEX IF EXISTS idx_activities_by_publishedYearMonthDay; 71 | DROP INDEX IF EXISTS idx_activities_by_publishedYearMonth; 72 | DROP INDEX IF EXISTS idx_activities_by_publishedYear; 73 | 74 | ALTER TABLE activities RENAME TO old_activities; 75 | ALTER TABLE new_activities RENAME TO activities; 76 | 77 | CREATE INDEX idx_activities_by_ispublic ON activities(isPublic); 78 | CREATE INDEX idx_activities_by_publishedYearMonthDay ON activities(publishedYearMonthDay, isPublic); 79 | CREATE INDEX idx_activities_by_publishedYearMonth ON activities(publishedYearMonth, isPublic); 80 | CREATE INDEX idx_activities_by_publishedYear ON activities(publishedYear, isPublic); 81 | 82 | -- DROP TABLE old_activities; -------------------------------------------------------------------------------- /src/db/migrations/202404141112-drop-old-activities.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE old_activities; -------------------------------------------------------------------------------- /src/downloader.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::VecDeque; 4 | use std::fs; 5 | use std::path::PathBuf; 6 | use std::sync::{Arc, Mutex}; 7 | use std::{ 8 | fs::File, 9 | io::{copy, Cursor}, 10 | }; 11 | use tokio::sync::Notify; 12 | use tokio::task::JoinSet; 13 | use url::Url; 14 | 15 | static DEFAULT_CONCURRENCY: usize = 4; 16 | 17 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 18 | #[serde(rename_all = "camelCase")] 19 | pub struct DownloadTask { 20 | pub url: Url, 21 | pub destination: PathBuf, 22 | } 23 | 24 | impl DownloadTask { 25 | async fn execute(self) -> Result { 26 | // todo: download with progress narration? https://gist.github.com/giuliano-oliveira/4d11d6b3bb003dba3a1b53f43d81b30d 27 | let client = reqwest::ClientBuilder::new().build().unwrap(); 28 | let response = client.get(self.url.clone()).send().await?; 29 | 30 | let file_parent_path = self.destination.parent().ok_or(anyhow!("no parent path"))?; 31 | fs::create_dir_all(file_parent_path)?; 32 | 33 | let mut file = File::create(&self.destination)?; 34 | let mut content = Cursor::new(response.bytes().await?); 35 | 36 | copy(&mut content, &mut file)?; 37 | 38 | Ok(self) 39 | } 40 | } 41 | 42 | pub struct Downloader { 43 | // todo: make concurrency adjustable during run via channels? 44 | pub concurrency: usize, 45 | tasks: Arc>>, 46 | new_task_notify: Arc, 47 | queue_closed: Arc, 48 | } 49 | 50 | impl Default for Downloader { 51 | fn default() -> Self { 52 | Self::new(DEFAULT_CONCURRENCY) 53 | } 54 | } 55 | 56 | impl Downloader { 57 | pub fn new(concurrency: usize) -> Self { 58 | Self { 59 | concurrency, 60 | tasks: Arc::new(Mutex::new(VecDeque::new())), 61 | new_task_notify: Arc::new(Notify::new()), 62 | queue_closed: Arc::new(Notify::new()), 63 | } 64 | } 65 | 66 | pub fn queue(&self, task: DownloadTask) -> Result<()> { 67 | let mut tasks = self.tasks.lock().unwrap(); 68 | tasks.push_back(task); 69 | self.new_task_notify.notify_one(); 70 | Ok(()) 71 | } 72 | 73 | pub fn close(&self) -> Result<()> { 74 | self.queue_closed.notify_one(); 75 | Ok(()) 76 | } 77 | 78 | pub fn run(&self) -> tokio::task::JoinHandle> { 79 | let concurrency = self.concurrency; 80 | let tasks = self.tasks.clone(); 81 | let new_task_notify = self.new_task_notify.clone(); 82 | let queue_closed = self.queue_closed.clone(); 83 | 84 | tokio::spawn(async move { 85 | let mut should_exit_when_empty = false; 86 | let mut workers = JoinSet::new(); 87 | loop { 88 | // Check whether it's time to bail out when all known work is done 89 | { 90 | let tasks = tasks.lock().or(Err(anyhow!("failed to lock tasks")))?; 91 | if tasks.is_empty() && workers.is_empty() && should_exit_when_empty { 92 | trace!("Exiting after last task"); 93 | break; 94 | } 95 | } 96 | 97 | // Fire up workers for available tasks up to concurrency limit 98 | loop { 99 | let mut tasks = tasks.lock().or(Err(anyhow!("failed to lock tasks")))?; 100 | if tasks.is_empty() || workers.len() >= concurrency { 101 | trace!( 102 | "Not spawning worker - tasks.is_empty = {}; workers.len() = {}", 103 | tasks.is_empty(), 104 | workers.len() 105 | ); 106 | break; 107 | } 108 | if let Some(task) = tasks.pop_front() { 109 | trace!("Spawning worker for task - tasks.len() = {}; workers.len() = {} - {:?}", tasks.len(), workers.len(), task); 110 | workers.spawn(task.execute()); 111 | } 112 | } 113 | 114 | // Wait for something important to happen... 115 | tokio::select! { 116 | // todo: report progress via some channel 117 | _ = workers.join_next(), if !workers.is_empty() => { 118 | trace!("Worker done - workers.len() = {}", workers.len()); 119 | } 120 | _ = new_task_notify.notified() => { 121 | trace!("New task queued"); 122 | } 123 | _ = queue_closed.notified() => { 124 | trace!("Queue closed"); 125 | should_exit_when_empty = true; 126 | } 127 | } 128 | } 129 | anyhow::Ok(()) 130 | }) 131 | } 132 | } 133 | 134 | #[cfg(test)] 135 | mod tests { 136 | use super::*; 137 | use rand::prelude::*; 138 | use std::env; 139 | use test_log::test; 140 | use tokio::time::{sleep, Duration}; 141 | 142 | #[test(tokio::test)] 143 | async fn test_downloadtask_execute_downloads_url() -> Result<()> { 144 | let base_path = generate_base_path(); 145 | let mut server = mockito::Server::new(); 146 | let (task, mock, expected_data) = generate_download_task(&base_path, &mut server); 147 | task.clone().execute().await?; 148 | mock.assert(); 149 | let result_data = fs::read_to_string(task.destination)?; 150 | assert_eq!(result_data, expected_data); 151 | fs::remove_dir_all(base_path)?; 152 | Ok(()) 153 | } 154 | 155 | #[test(tokio::test)] 156 | async fn test_downloadtasks_producer_consumer() -> Result<()> { 157 | let base_path = generate_base_path(); 158 | 159 | let mut server = mockito::Server::new(); 160 | 161 | let task_count = 32; 162 | let mut test_downloads = Vec::new(); 163 | for _ in 0..task_count { 164 | test_downloads.push(generate_download_task(&base_path, &mut server)); 165 | } 166 | 167 | let tasks: Vec = test_downloads 168 | .iter() 169 | .map(|(task, _, _)| task.clone()) 170 | .collect(); 171 | 172 | let downloader = Downloader::default(); 173 | let consumer = downloader.run(); 174 | let producer = tokio::spawn(async move { 175 | for task in tasks { 176 | trace!("Enqueuing task {:?}", task); 177 | downloader 178 | .queue(task) 179 | .or(Err(anyhow!("downloader queue failed")))?; 180 | random_sleep(50, 200).await; 181 | } 182 | downloader 183 | .close() 184 | .or(Err(anyhow!("downloader close failed")))?; 185 | 186 | trace!("Consumer done enqueuing tasks"); 187 | anyhow::Ok(()) 188 | }); 189 | 190 | let result = tokio::join!(consumer, producer,); 191 | trace!("Consumer result = {:?}", result.0??); 192 | trace!("Producer result = {:?}", result.1??); 193 | 194 | for (task, mock, expected_data) in test_downloads { 195 | mock.assert(); 196 | let result_data = fs::read_to_string(task.destination)?; 197 | assert_eq!(result_data, expected_data); 198 | } 199 | 200 | fs::remove_dir_all(base_path)?; 201 | 202 | Ok(()) 203 | } 204 | 205 | async fn random_sleep(min: u64, max: u64) { 206 | let duration = { 207 | let mut rng = rand::thread_rng(); 208 | Duration::from_millis(rng.gen_range(min..max)) 209 | }; 210 | sleep(duration).await; 211 | } 212 | 213 | fn generate_base_path() -> PathBuf { 214 | let rand_path: u16 = random(); 215 | let base_path = env::temp_dir().join(format!("fossilizer-{rand_path}")); 216 | base_path 217 | } 218 | 219 | fn generate_download_task( 220 | base_path: &PathBuf, 221 | server: &mut mockito::ServerGuard, 222 | ) -> (DownloadTask, mockito::Mock, std::string::String) { 223 | let rand_path: u16 = random(); 224 | 225 | let data = format!("task {rand_path} data"); 226 | 227 | let url = Url::parse(&server.url()) 228 | .unwrap() 229 | .join(format!("/task-{rand_path}").as_str()) 230 | .unwrap(); 231 | 232 | let destination = base_path.join(format!("tasks/task-{rand_path}.txt")); 233 | 234 | let server_mock = server 235 | .mock("GET", url.path()) 236 | .with_status(200) 237 | .with_header("content-type", "text/plain") 238 | .with_body(data.clone()) 239 | .create(); 240 | 241 | let task = DownloadTask { url, destination }; 242 | 243 | (task, server_mock, data) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate lazy_static; 3 | 4 | #[macro_use] 5 | extern crate log; 6 | 7 | #[macro_use] 8 | extern crate tera; 9 | 10 | pub mod activitystreams; 11 | pub mod app; 12 | pub mod config; 13 | pub mod db; 14 | pub mod downloader; 15 | pub mod mastodon; 16 | pub mod site_generator; 17 | pub mod templates; 18 | pub mod themes; 19 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | 3 | #[macro_use] 4 | extern crate log; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | match cli::execute().await { 9 | Ok(_) => {} 10 | Err(err) => println!("Error: {err:?}"), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/mastodon.rs: -------------------------------------------------------------------------------- 1 | // todo: move this to config / cli options? 2 | pub static CLIENT_NAME: &str = "Fossilizer"; 3 | pub static CLIENT_WEBSITE: &str = "https://lmorchard.github.io/fossilizer/"; 4 | pub static OAUTH_SCOPES: &str = "read read:notifications read:statuses write follow push"; 5 | pub static REDIRECT_URI_OOB: &str = "urn:ietf:wg:oauth:2.0:oob"; 6 | 7 | pub mod fetcher; 8 | pub mod importer; 9 | pub mod instance; 10 | -------------------------------------------------------------------------------- /src/mastodon/fetcher.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use megalodon; 3 | use megalodon::entities::Status; 4 | use megalodon::megalodon::GetAccountStatusesInputOptions; 5 | use rusqlite::Connection; 6 | 7 | use std::default::Default; 8 | 9 | use std::path::PathBuf; 10 | use url::Url; 11 | 12 | use crate::mastodon::instance::InstanceConfig; 13 | use crate::{ 14 | activitystreams::{Activity, Attachments}, 15 | db, downloader, 16 | }; 17 | pub struct Fetcher { 18 | conn: Connection, 19 | instance: String, 20 | instance_config: InstanceConfig, 21 | media_path: PathBuf, 22 | page: u32, 23 | max: u32, 24 | incremental: bool, 25 | } 26 | 27 | impl Fetcher { 28 | pub fn new( 29 | conn: Connection, 30 | instance: String, 31 | instance_config: InstanceConfig, 32 | media_path: PathBuf, 33 | page: u32, 34 | max: u32, 35 | incremental: bool, 36 | ) -> Self { 37 | Self { 38 | conn, 39 | instance, 40 | instance_config, 41 | media_path, 42 | page, 43 | max, 44 | incremental, 45 | } 46 | } 47 | 48 | pub async fn fetch(&mut self) -> Result<()> { 49 | let max = self.max; 50 | let page = self.page; 51 | let incremental: bool = self.incremental; 52 | let media_path = self.media_path.clone(); 53 | let instance = self.instance.clone(); 54 | 55 | let media_downloader = downloader::Downloader::default(); 56 | let media_download_manager = media_downloader.run(); 57 | 58 | let access_token = self.instance_config.access_token.as_ref().unwrap().clone(); 59 | 60 | let client = megalodon::generator( 61 | megalodon::SNS::Mastodon, 62 | format!("https://{instance}"), 63 | Some(access_token), 64 | None, 65 | ); 66 | 67 | let account = client.verify_account_credentials().await?.json(); 68 | trace!("Fetched account {:?}", account); 69 | info!("Fetching statuses for account {}", account.url); 70 | // todo: update actor from mastodon data 71 | 72 | let conn = &self.conn; 73 | let db_activities = db::activities::Activities::new(&conn); 74 | let db_actors = db::actors::Actors::new(&conn); 75 | let actors = db_actors.get_actors_by_id().unwrap(); 76 | 77 | let mut keep_fetching = true; 78 | let mut fetched_count = 0; 79 | let mut current_fetch_options = GetAccountStatusesInputOptions { 80 | limit: Some(page), 81 | ..Default::default() 82 | }; 83 | 84 | // todo: should this loop be async to cooperate with the media downloader better? or is it fine as is? 85 | while keep_fetching && fetched_count < max { 86 | let statuses_resp = client 87 | .get_account_statuses(String::from(&account.id), Some(¤t_fetch_options)) 88 | .await?; 89 | 90 | let statuses_and_activities: Vec<(Status, Activity)> = statuses_resp 91 | .json() 92 | .iter() 93 | .map(|status| (status.clone(), status.clone().into())) 94 | .collect(); 95 | 96 | if statuses_and_activities.is_empty() { 97 | info!("Reached the end of available activities"); 98 | break; 99 | } 100 | 101 | if incremental { 102 | let activity_ids: Vec = statuses_and_activities 103 | .iter() 104 | .map(|item| item.1.id.clone()) 105 | .collect(); 106 | let existing_activities_count = 107 | db_activities.count_activities_by_ids(&activity_ids)?; 108 | if existing_activities_count > 0 { 109 | keep_fetching = false; 110 | } 111 | } 112 | 113 | for (status, activity) in statuses_and_activities { 114 | trace!("Importing status {:?}", status.url); 115 | db_activities.import(&status)?; 116 | fetched_count = fetched_count + 1; 117 | current_fetch_options.max_id = Some(status.id); 118 | 119 | // If this is a note, import any attachments 120 | if activity.object.is_object() { 121 | let object = activity.object.object().unwrap(); 122 | let actor_id: &String = activity.actor.id().unwrap(); 123 | let actor = actors.get(actor_id).unwrap(); 124 | 125 | trace!("Importing {} attachments", &object.attachments().len()); 126 | for &attachment in &object.attachments() { 127 | media_downloader.queue(downloader::DownloadTask { 128 | url: Url::parse(attachment.url.as_str())?, 129 | destination: attachment.local_media_path(&media_path, &actor)?, 130 | })?; 131 | } 132 | } 133 | } 134 | 135 | info!("Fetched {fetched_count} (of {max} max)..."); 136 | if !keep_fetching { 137 | info!("Stopping incremental fetch after catching up to imported activities"); 138 | } 139 | } 140 | 141 | // Signal that we're done enqueueing and wait for any remaining downloads to finish 142 | media_downloader.close()?; 143 | media_download_manager.await??; 144 | 145 | Ok(()) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/mastodon/importer.rs: -------------------------------------------------------------------------------- 1 | use crate::activitystreams::Actor; 2 | 3 | use crate::{activitystreams, db}; 4 | use anyhow::{anyhow, Result}; 5 | use flate2::read::GzDecoder; 6 | use rand::distributions::Alphanumeric; 7 | use rand::{thread_rng, Rng}; 8 | use rusqlite::Connection; 9 | 10 | use std::convert::From; 11 | 12 | use std::fs; 13 | use std::fs::File; 14 | 15 | use std::io::{copy, Read}; 16 | use std::path::{Component, PathBuf}; 17 | use tar::Archive; 18 | use walkdir::WalkDir; 19 | 20 | pub struct Importer { 21 | conn: Connection, 22 | media_path: PathBuf, 23 | skip_media: bool, 24 | current_media_subpath: String, 25 | } 26 | 27 | impl Importer { 28 | pub fn new(conn: Connection, media_path: PathBuf, skip_media: bool) -> Self { 29 | // Start with a temporary path, until we have found the actor JSON to derive a real path 30 | let current_media_subpath: String = format!( 31 | "tmp-{}", 32 | thread_rng() 33 | .sample_iter(&Alphanumeric) 34 | .take(30) 35 | .map(char::from) 36 | .collect::() 37 | ); 38 | Self { 39 | conn, 40 | media_path, 41 | skip_media, 42 | current_media_subpath, 43 | } 44 | } 45 | 46 | pub fn import(&mut self, filepath: PathBuf) -> Result<()> { 47 | let filepath = filepath.as_path(); 48 | let file = File::open(filepath)?; 49 | 50 | // todo: do something with filemagic here to auto-detect archive format based on file contents? 51 | let extension = filepath 52 | .extension() 53 | .ok_or(anyhow!("no file extension"))? 54 | .to_str() 55 | .ok_or(anyhow!("no file extension"))?; 56 | match extension { 57 | "gz" => self.import_tar(file, true)?, 58 | "tar" => self.import_tar(file, false)?, 59 | "zip" => self.import_zip(file)?, 60 | _ => println!("NO SCANNER AVAILABLE"), 61 | }; 62 | 63 | Ok(()) 64 | } 65 | 66 | pub fn import_tar(&mut self, file: File, use_gzip: bool) -> Result<()> { 67 | // hack: this optional decompression seems funky, but it works 68 | let file: Box = if use_gzip { 69 | Box::new(GzDecoder::new(file)) 70 | } else { 71 | Box::new(file) 72 | }; 73 | let mut archive = Archive::new(file); 74 | let entries = archive.entries()?; 75 | for entry in entries { 76 | let mut entry = entry?; 77 | let entry_path: PathBuf = entry.path()?.into(); 78 | self.handle_entry(&entry_path, &mut entry)?; 79 | } 80 | Ok(()) 81 | } 82 | 83 | pub fn import_zip(&mut self, file: File) -> Result<()> { 84 | let mut archive = zip::ZipArchive::new(file).unwrap(); 85 | 86 | for i in 0..archive.len() { 87 | let mut file = archive.by_index(i).unwrap(); 88 | let outpath = match file.enclosed_name() { 89 | Some(path) => path.to_owned(), 90 | None => continue, 91 | }; 92 | // is this really the best way to detect that an entry isn't a directory? 93 | if !(*file.name()).ends_with('/') { 94 | self.handle_entry(&outpath, &mut file)?; 95 | } 96 | } 97 | 98 | Ok(()) 99 | } 100 | 101 | fn handle_entry(&mut self, path: &PathBuf, read: &mut impl Read) -> Result<()> { 102 | if path.ends_with("outbox.json") { 103 | self.handle_outbox(read)?; 104 | } else if path.ends_with("actor.json") { 105 | self.handle_actor(read)?; 106 | } else if !self.skip_media { 107 | if path.to_str().unwrap().contains("media_attachments") { 108 | // HACK: some exports seem to have leading directory paths before `media_attachments`, so strip that off 109 | let normalized_path: PathBuf = path 110 | .components() 111 | .skip_while(|c| match c { 112 | Component::Normal(name) => name != &"media_attachments", 113 | _ => true, 114 | }) 115 | .collect(); 116 | self.handle_media_attachment(&normalized_path, read)?; 117 | } else if let Some(ext) = path.extension() { 118 | // mainly for {avatar,header}.{jpg,png}, but there may be more? 119 | if "png" == ext || "jpg" == ext { 120 | self.handle_media_attachment(path, read)?; 121 | } 122 | } 123 | } 124 | Ok(()) 125 | } 126 | 127 | fn handle_media_attachment(&self, entry_path: &PathBuf, entry_read: &mut R) -> Result<()> 128 | where 129 | R: ?Sized, 130 | R: Read, 131 | { 132 | let media_path = self.media_path.as_path(); 133 | 134 | let output_path = PathBuf::new() 135 | .join(media_path) 136 | .join(&self.current_media_subpath) 137 | .join(entry_path); 138 | 139 | debug!("Extracting {:?}", output_path); 140 | 141 | let output_parent_path = output_path.parent().unwrap(); 142 | fs::create_dir_all(output_parent_path)?; 143 | 144 | let mut output_file = fs::File::create(&output_path)?; 145 | copy(entry_read, &mut output_file)?; 146 | 147 | Ok(()) 148 | } 149 | 150 | fn handle_outbox(&self, read: &mut impl Read) -> Result<()> { 151 | let outbox: activitystreams::Outbox = serde_json::from_reader(read)?; 152 | info!("Found {:?} items", outbox.ordered_items.len()); 153 | let activities = db::activities::Activities::new(&self.conn); 154 | activities.import_collection(&outbox)?; 155 | Ok(()) 156 | } 157 | 158 | fn handle_actor(&mut self, read: &mut impl Read) -> Result<()> { 159 | debug!("Found actor"); 160 | 161 | // Grab the Actor as a Value to import it with max fidelity 162 | let actor: serde_json::Value = serde_json::from_reader(read)?; 163 | let actors = db::actors::Actors::new(&self.conn); 164 | actors.import_actor(&actor)?; 165 | 166 | // Convert the actor to our local type and figure out the new media subpath 167 | let local_actor: Actor = actor.into(); 168 | let previous_media_subpath = String::from(&self.current_media_subpath); 169 | self.current_media_subpath = local_actor.id_hash(); 170 | 171 | let temp_media_path = PathBuf::new() 172 | .join(&self.media_path) 173 | .join(&previous_media_subpath); 174 | 175 | // Move everything we have so far to the per-actor media path, if we have anything 176 | if temp_media_path.is_dir() { 177 | info!( 178 | "Moving temporary files from {:?} to {:?}", 179 | previous_media_subpath, self.current_media_subpath 180 | ); 181 | 182 | let new_media_path = PathBuf::new() 183 | .join(&self.media_path) 184 | .join(&self.current_media_subpath); 185 | 186 | for entry in WalkDir::new(&temp_media_path) 187 | .into_iter() 188 | .filter_map(|e| e.ok()) 189 | { 190 | if !entry.file_type().is_file() { 191 | continue; 192 | } 193 | 194 | let old_path = entry.path(); 195 | let new_path = &new_media_path.join(old_path.strip_prefix(&temp_media_path)?); 196 | 197 | let new_parent_path = new_path.parent().unwrap(); 198 | fs::create_dir_all(new_parent_path)?; 199 | 200 | trace!( 201 | "Moving temporary file from {:?} to {:?}", 202 | old_path, 203 | new_path 204 | ); 205 | fs::rename(old_path, new_path)?; 206 | } 207 | 208 | // Clean up the temporary media path 209 | fs::remove_dir_all(&temp_media_path)?; 210 | } 211 | 212 | Ok(()) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/mastodon/instance.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | 3 | use anyhow::Result; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | use std::collections::HashMap; 7 | 8 | use std::error::Error; 9 | use std::fs; 10 | 11 | use std::io::prelude::*; 12 | 13 | use std::path::PathBuf; 14 | 15 | use crate::mastodon::{CLIENT_NAME, CLIENT_WEBSITE, OAUTH_SCOPES, REDIRECT_URI_OOB}; 16 | 17 | #[derive(Default, Serialize, Deserialize, Debug, Clone)] 18 | pub struct InstanceConfig { 19 | pub host: String, 20 | pub client_id: Option, 21 | pub client_secret: Option, 22 | pub vapid_key: Option, 23 | pub access_token: Option, 24 | pub created_at: Option, 25 | } 26 | impl InstanceConfig { 27 | pub fn new(instance: &String) -> Self { 28 | InstanceConfig { 29 | host: instance.clone(), 30 | ..Default::default() 31 | } 32 | } 33 | } 34 | 35 | fn build_instance_config_path(instance: &String) -> Result> { 36 | let config = config::config()?; 37 | let data_path = config.data_path; 38 | // todo: hash the instance domain string rather than using it verbatim? 39 | Ok(data_path.join(format!("config-instance-{instance}.toml"))) 40 | } 41 | 42 | pub fn load_instance_config(instance: &String) -> Result> { 43 | let config_path = build_instance_config_path(instance)?; 44 | trace!( 45 | "Loading {} instance config file from {:?}", 46 | instance, 47 | config_path 48 | ); 49 | if config_path.exists() { 50 | let instance_config_file = fs::read_to_string(config_path)?; 51 | Ok(toml::from_str(instance_config_file.as_str())?) 52 | } else { 53 | Ok(InstanceConfig::new(instance)) 54 | } 55 | } 56 | 57 | pub fn save_instance_config( 58 | instance: &String, 59 | instance_config: &InstanceConfig, 60 | ) -> Result<(), Box> { 61 | let config_path = build_instance_config_path(instance)?; 62 | trace!( 63 | "Saving {} instance config file to {:?}", 64 | instance, 65 | config_path 66 | ); 67 | let instance_config_str = toml::to_string_pretty(&instance_config)?; 68 | let mut file = fs::File::create(config_path)?; 69 | file.write_all(instance_config_str.as_bytes())?; 70 | Ok(()) 71 | } 72 | 73 | #[derive(Serialize, Deserialize, Debug)] 74 | struct AppRegistrationResult { 75 | client_id: String, 76 | client_secret: String, 77 | vapid_key: String, 78 | } 79 | 80 | pub async fn register_client_app( 81 | instance: &String, 82 | instance_config: &mut InstanceConfig, 83 | ) -> Result<()> { 84 | let mut params = HashMap::new(); 85 | params.insert("client_name", CLIENT_NAME); 86 | params.insert("website", CLIENT_WEBSITE); 87 | params.insert("redirect_uris", REDIRECT_URI_OOB); 88 | params.insert("scopes", OAUTH_SCOPES); 89 | 90 | let url = format!("https://{instance}/api/v1/apps"); 91 | let client = reqwest::ClientBuilder::new().build().unwrap(); 92 | let res = client.post(url).json(¶ms).send().await?; 93 | 94 | debug!("Registering new app with instance {}", instance); 95 | 96 | if res.status() == reqwest::StatusCode::OK { 97 | let result: AppRegistrationResult = res.json().await?; 98 | instance_config.client_id = Some(result.client_id); 99 | instance_config.client_secret = Some(result.client_secret); 100 | instance_config.vapid_key = Some(result.vapid_key); 101 | Ok(()) 102 | } else { 103 | // todo: throw an error here 104 | error!("Failed to register app"); 105 | Ok(()) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/resources/default_config.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmorchard/fossilizer/1b9ef90af18c7e1e4d58e2a0509270c2630310d1/src/resources/default_config.toml -------------------------------------------------------------------------------- /src/resources/test/activity-with-attachment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "https://hackers.town/users/lmorchard/statuses/106502605909434719/activity", 3 | "type": "Create", 4 | "actor": "https://hackers.town/users/lmorchard", 5 | "published": "2021-07-01T00:53:18Z", 6 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 7 | "cc": [ 8 | "https://hackers.town/users/lmorchard/followers", 9 | "https://a2mi.social/users/samfirke", 10 | "https://a2mi.social/users/george" 11 | ], 12 | "object": { 13 | "id": "https://hackers.town/users/lmorchard/statuses/106502605909434719", 14 | "type": "Note", 15 | "summary": "poop (literally)", 16 | "inReplyTo": "https://hackers.town/users/lmorchard/statuses/106502601818051554", 17 | "published": "2021-07-01T00:53:18Z", 18 | "url": "https://hackers.town/@lmorchard/106502605909434719", 19 | "attributedTo": "https://hackers.town/users/lmorchard", 20 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 21 | "cc": [ 22 | "https://hackers.town/users/lmorchard/followers", 23 | "https://a2mi.social/users/samfirke", 24 | "https://a2mi.social/users/george" 25 | ], 26 | "sensitive": false, 27 | "atomUri": "https://hackers.town/users/lmorchard/statuses/106502605909434719", 28 | "inReplyToAtomUri": "https://hackers.town/users/lmorchard/statuses/106502601818051554", 29 | "conversation": "tag:hackers.town,2021-07-01:objectId=19198143:objectType=Conversation", 30 | "content": "\u003cp\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"https://a2mi.social/@george\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003egeorge\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e \u003cspan class=\"h-card\"\u003e\u003ca href=\"https://a2mi.social/@samfirke\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003esamfirke\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e but, the paint is because we're moving to a bigger house with a shed. So who knows, I might get a bigger bike with a trailer :ablobjoy:\u003c/p\u003e", 31 | "contentMap": { 32 | "en": "\u003cp\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"https://a2mi.social/@george\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003egeorge\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e \u003cspan class=\"h-card\"\u003e\u003ca href=\"https://a2mi.social/@samfirke\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003esamfirke\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e but, the paint is because we're moving to a bigger house with a shed. So who knows, I might get a bigger bike with a trailer :ablobjoy:\u003c/p\u003e" 33 | }, 34 | "attachment": [ 35 | { 36 | "type": "Document", 37 | "mediaType": "image/jpeg", 38 | "url": "media_attachments/files/002/337/518/original/ebbb5d342877102f.jpg", 39 | "name": null, 40 | "blurhash": "UTGRVb~pWBjGxGX9kXkWDiM{xuxunNWBRjRj", 41 | "width": 1478, 42 | "height": 1109 43 | }, 44 | { 45 | "type": "Document", 46 | "mediaType": "image/jpeg", 47 | "url": "media_attachments/files/002/337/520/original/63a81769839a7ef6.jpg", 48 | "name": null, 49 | "blurhash": "UUD+rPRj0KR*RjRjf7t7I;xas.M|RPWBtRt7", 50 | "width": 1478, 51 | "height": 1109 52 | } 53 | ], 54 | "tag": [ 55 | { 56 | "type": "Mention", 57 | "href": "https://a2mi.social/users/george", 58 | "name": "@george@a2mi.social" 59 | }, 60 | { 61 | "type": "Mention", 62 | "href": "https://a2mi.social/users/samfirke", 63 | "name": "@samfirke@a2mi.social" 64 | }, 65 | { 66 | "id": "https://hackers.town/emojis/43882", 67 | "type": "Emoji", 68 | "name": ":ablobjoy:", 69 | "updated": "2022-10-21T15:07:56Z", 70 | "icon": { 71 | "type": "Image", 72 | "mediaType": "image/png", 73 | "url": "https://hackers.town/system/custom_emojis/images/000/043/882/original/5cd6640bb919cf64.png" 74 | } 75 | } 76 | ], 77 | "replies": { 78 | "id": "https://hackers.town/users/lmorchard/statuses/106502605909434719/replies", 79 | "type": "Collection", 80 | "first": { 81 | "type": "CollectionPage", 82 | "next": "https://hackers.town/users/lmorchard/statuses/106502605909434719/replies?only_other_accounts=true\u0026page=true", 83 | "partOf": "https://hackers.town/users/lmorchard/statuses/106502605909434719/replies", 84 | "items": [] 85 | } 86 | } 87 | }, 88 | "signature": { 89 | "type": "RsaSignature2017", 90 | "creator": "https://hackers.town/users/lmorchard#main-key", 91 | "created": "2023-01-15T04:15:30Z", 92 | "signatureValue": "ganLLaH4rCmsf6bo6pnBpzbHGQiLIMqgMlk/usyuS8hiGJH9sR3gBjak35f2KRdIyPW64NgKJQPjaP6AYttsSTGxrtS0sCHo2/0ttkFU5QbnIK/VdzGeVOXHmXX/oP3eiUCDQnMwOQaR0jT9HPZuhNJ6LI0tyojDCLhd883cxt571VBv4DcvHVexKbuFHKdLw/og1erJthYk5RqkvMSM4J/dgOJx85yrmaIHdOmjUhU4sbb2ck9uYj64/sG0LlAUEORppe3FTL/N0wQr9xK92YcRoC19XuFnWThncvPX0J9O7PTrPyyeu/l+rtAwRv1xlEprO+vPbk0iEJWuZbpHMQ==" 93 | } 94 | } -------------------------------------------------------------------------------- /src/resources/test/activity-with-emoji.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "https://toot.cafe/users/lmorchard/statuses/100599986688993237/activity", 3 | "type": "Create", 4 | "actor": "https://toot.cafe/users/lmorchard", 5 | "published": "2018-08-23T14:19:36Z", 6 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 7 | "cc": ["https://toot.cafe/users/lmorchard/followers"], 8 | "object": { 9 | "atomUri": "https://toot.cafe/users/lmorchard/statuses/100599986688993237", 10 | "attachment": [], 11 | "attributedTo": "https://toot.cafe/users/lmorchard", 12 | "cc": ["https://toot.cafe/users/lmorchard/followers"], 13 | "content": "

@snailix@scicomm.xyz sounds like an amazing job :blobaww:

", 14 | "conversation": "tag:toot.cafe,2018-08-23:objectId=4009141:objectType=Conversation", 15 | "id": "https://toot.cafe/users/lmorchard/statuses/100599986688993237", 16 | "inReplyTo": null, 17 | "inReplyToAtomUri": null, 18 | "published": "2018-08-23T14:19:36Z", 19 | "replies": { 20 | "first": { 21 | "items": [], 22 | "next": "https://toot.cafe/users/lmorchard/statuses/100599986688993237/replies?only_other_accounts=true&page=true", 23 | "partOf": "https://toot.cafe/users/lmorchard/statuses/100599986688993237/replies", 24 | "type": "CollectionPage" 25 | }, 26 | "id": "https://toot.cafe/users/lmorchard/statuses/100599986688993237/replies", 27 | "type": "Collection" 28 | }, 29 | "sensitive": false, 30 | "summary": null, 31 | "tag": [ 32 | { 33 | "icon": { 34 | "mediaType": "image/png", 35 | "type": "Image", 36 | "url": "https://assets.toot.cafe/custom_emojis/images/000/001/051/original/75b826037cd7d344.png" 37 | }, 38 | "id": "https://toot.cafe/emojis/1051", 39 | "name": ":blobaww:", 40 | "type": "Emoji", 41 | "updated": "2017-10-24T00:45:35Z" 42 | } 43 | ], 44 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 45 | "type": "Note", 46 | "url": "https://toot.cafe/@lmorchard/100599986688993237" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/resources/test/actor-remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://www.w3.org/ns/activitystreams", 4 | "https://w3id.org/security/v1", 5 | { 6 | "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", 7 | "toot": "http://joinmastodon.org/ns#", 8 | "featured": { 9 | "@id": "toot:featured", 10 | "@type": "@id" 11 | }, 12 | "featuredTags": { 13 | "@id": "toot:featuredTags", 14 | "@type": "@id" 15 | }, 16 | "alsoKnownAs": { 17 | "@id": "as:alsoKnownAs", 18 | "@type": "@id" 19 | }, 20 | "movedTo": { 21 | "@id": "as:movedTo", 22 | "@type": "@id" 23 | }, 24 | "schema": "http://schema.org#", 25 | "PropertyValue": "schema:PropertyValue", 26 | "value": "schema:value", 27 | "discoverable": "toot:discoverable", 28 | "Device": "toot:Device", 29 | "Ed25519Signature": "toot:Ed25519Signature", 30 | "Ed25519Key": "toot:Ed25519Key", 31 | "Curve25519Key": "toot:Curve25519Key", 32 | "EncryptedMessage": "toot:EncryptedMessage", 33 | "publicKeyBase64": "toot:publicKeyBase64", 34 | "deviceId": "toot:deviceId", 35 | "claim": { 36 | "@type": "@id", 37 | "@id": "toot:claim" 38 | }, 39 | "fingerprintKey": { 40 | "@type": "@id", 41 | "@id": "toot:fingerprintKey" 42 | }, 43 | "identityKey": { 44 | "@type": "@id", 45 | "@id": "toot:identityKey" 46 | }, 47 | "devices": { 48 | "@type": "@id", 49 | "@id": "toot:devices" 50 | }, 51 | "messageFranking": "toot:messageFranking", 52 | "messageType": "toot:messageType", 53 | "cipherText": "toot:cipherText", 54 | "suspended": "toot:suspended", 55 | "focalPoint": { 56 | "@container": "@list", 57 | "@id": "toot:focalPoint" 58 | } 59 | } 60 | ], 61 | "id": "https://hackers.town/users/lmorchard", 62 | "type": "Person", 63 | "following": "https://hackers.town/users/lmorchard/following", 64 | "followers": "https://hackers.town/users/lmorchard/followers", 65 | "inbox": "https://hackers.town/users/lmorchard/inbox", 66 | "outbox": "https://hackers.town/users/lmorchard/outbox", 67 | "featured": "https://hackers.town/users/lmorchard/collections/featured", 68 | "featuredTags": "https://hackers.town/users/lmorchard/collections/tags", 69 | "preferredUsername": "lmorchard", 70 | "name": "Les Orchard", 71 | "summary": "

he / him; semi-hermit in PDX, USA; tinkerer; old adhd cat dad; serial enthusiast; editor-at-large for http://lmorchard.com; astra mortemque superare gradatim; tootfinder

", 72 | "url": "https://hackers.town/@lmorchard", 73 | "manuallyApprovesFollowers": false, 74 | "discoverable": true, 75 | "published": "2020-06-28T00:00:00Z", 76 | "devices": "https://hackers.town/users/lmorchard/collections/devices", 77 | "publicKey": { 78 | "id": "https://hackers.town/users/lmorchard#main-key", 79 | "owner": "https://hackers.town/users/lmorchard", 80 | "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp2fiC0Me5LwbTH+FkEtj\n+v9baRm9GD9BKwOqn+VE2z9lw6ZYu5nwiD9f0uDyjGtBQB0H+bvvWwetmZYRFM02\ncr+xJMlv1n6PWiKGvDr0rs1+iOvrCRWQIFgQ19DoZEd7HAgtM92LPxztfVlfZgll\n+OdG1nmW188cYM9BnFocJvZJpnzoDZrm5nXr53sBQet/+OulLq7anw7esvI1DuAC\n3W7kYyNyOUbHJ/CdniXlR0YlWGmzD1GR/YtV8CsC+K6e1kmHjeQTCPstXnbZwuj/\n5lIDDpXTEPunN5Nv3VelQNn8jqDrjo4/uoVJWcUvcub9l7a3YIxwcD34qV0Sqkqy\nyQIDAQAB\n-----END PUBLIC KEY-----\n" 81 | }, 82 | "tag": [], 83 | "attachment": [ 84 | { 85 | "type": "PropertyValue", 86 | "name": "Home", 87 | "value": "http://lmorchard.com/" 88 | }, 89 | { 90 | "type": "PropertyValue", 91 | "name": "Webring Enthusiasts!", 92 | "value": "https://fediverse-webring-enthusiasts.glitch.me/profiles/lmorchard_hackers.town/index.html" 93 | }, 94 | { 95 | "type": "PropertyValue", 96 | "name": "0xDECAFBAD BBS", 97 | "value": "https://bbs.decafbad.com/" 98 | } 99 | ], 100 | "endpoints": { 101 | "sharedInbox": "https://hackers.town/inbox" 102 | }, 103 | "icon": { 104 | "type": "Image", 105 | "mediaType": "image/png", 106 | "url": "https://hackers.town/system/accounts/avatars/000/136/533/original/1a8c651efe14fcd6.png" 107 | }, 108 | "image": { 109 | "type": "Image", 110 | "mediaType": "image/jpeg", 111 | "url": "https://hackers.town/system/accounts/headers/000/136/533/original/60af00520bbf3704.jpg" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/resources/test/actor.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://www.w3.org/ns/activitystreams", 4 | "https://w3id.org/security/v1", 5 | { 6 | "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", 7 | "toot": "http://joinmastodon.org/ns#", 8 | "featured": { "@id": "toot:featured", "@type": "@id" }, 9 | "featuredTags": { "@id": "toot:featuredTags", "@type": "@id" }, 10 | "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" }, 11 | "movedTo": { "@id": "as:movedTo", "@type": "@id" }, 12 | "schema": "http://schema.org#", 13 | "PropertyValue": "schema:PropertyValue", 14 | "value": "schema:value", 15 | "discoverable": "toot:discoverable", 16 | "Device": "toot:Device", 17 | "Ed25519Signature": "toot:Ed25519Signature", 18 | "Ed25519Key": "toot:Ed25519Key", 19 | "Curve25519Key": "toot:Curve25519Key", 20 | "EncryptedMessage": "toot:EncryptedMessage", 21 | "publicKeyBase64": "toot:publicKeyBase64", 22 | "deviceId": "toot:deviceId", 23 | "claim": { "@type": "@id", "@id": "toot:claim" }, 24 | "fingerprintKey": { "@type": "@id", "@id": "toot:fingerprintKey" }, 25 | "identityKey": { "@type": "@id", "@id": "toot:identityKey" }, 26 | "devices": { "@type": "@id", "@id": "toot:devices" }, 27 | "messageFranking": "toot:messageFranking", 28 | "messageType": "toot:messageType", 29 | "cipherText": "toot:cipherText", 30 | "suspended": "toot:suspended", 31 | "focalPoint": { "@container": "@list", "@id": "toot:focalPoint" } 32 | } 33 | ], 34 | "id": "https://mastodon.social/users/lmorchard", 35 | "type": "Person", 36 | "following": "https://mastodon.social/users/lmorchard/following", 37 | "followers": "https://mastodon.social/users/lmorchard/followers", 38 | "inbox": "https://mastodon.social/users/lmorchard/inbox", 39 | "outbox": "outbox.json", 40 | "featured": "https://mastodon.social/users/lmorchard/collections/featured", 41 | "featuredTags": "https://mastodon.social/users/lmorchard/collections/tags", 42 | "preferredUsername": "lmorchard", 43 | "name": "Les Orchard 🕹️🔧🐱🐰", 44 | "summary": "

Tinkerer; maker of things; webdev; crazy cat gentleman; serial enthusiast; Mozillian. He / him. Streams sometimes at https://twitch.tv/lmorchard/

", 45 | "url": "https://mastodon.social/@lmorchard", 46 | "manuallyApprovesFollowers": false, 47 | "discoverable": false, 48 | "published": "2016-11-01T00:00:00Z", 49 | "devices": "https://mastodon.social/users/lmorchard/collections/devices", 50 | "movedTo": "https://hackers.town/users/lmorchard", 51 | "publicKey": { 52 | "id": "https://mastodon.social/users/lmorchard#main-key", 53 | "owner": "https://mastodon.social/users/lmorchard", 54 | "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAupYJQDcOvBPNDIJlyCsj\nGbXNYw7UJB7KejjThB0mZXUv5QWPJDoZ/isETX9/n2ulOx9NBQbXSzEOd2FrWB2m\nTb2cvo3vtINp6acXEOQwE+DpchBxLPnbS1v1oZVCckADTcoZXfcOd8qUyvj+49PQ\ncGiZIFbd6zEfRGhldnoqiU6e3GTjHgh9HMbEVBqYuYYUMT+cmW3GhiDQjMqEUUnJ\nN4LigZuXrz1Fjrya0foFd2VvF3CsjmQxN1TndI/mGXHonuKhz6SkOYbusJ6IIfgC\nDqNWQuqAovAppoXND48sAf3uN1j8VPWc38qbFuAbxNq76Iu2Ub9aQe2IEiNxhKFU\ndQIDAQAB\n-----END PUBLIC KEY-----\n" 55 | }, 56 | "tag": [], 57 | "attachment": [], 58 | "endpoints": { "sharedInbox": "https://mastodon.social/inbox" }, 59 | "icon": { "type": "Image", "mediaType": "image/jpeg", "url": "avatar.jpg" }, 60 | "image": { "type": "Image", "mediaType": "image/jpeg", "url": "header.jpg" }, 61 | "likes": "likes.json", 62 | "bookmarks": "bookmarks.json" 63 | } 64 | -------------------------------------------------------------------------------- /src/resources/test/mastodon-export.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmorchard/fossilizer/1b9ef90af18c7e1e4d58e2a0509270c2630310d1/src/resources/test/mastodon-export.tar -------------------------------------------------------------------------------- /src/resources/test/mastodon-export.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmorchard/fossilizer/1b9ef90af18c7e1e4d58e2a0509270c2630310d1/src/resources/test/mastodon-export.tar.gz -------------------------------------------------------------------------------- /src/resources/test/mastodon-export.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmorchard/fossilizer/1b9ef90af18c7e1e4d58e2a0509270c2630310d1/src/resources/test/mastodon-export.zip -------------------------------------------------------------------------------- /src/resources/test/mastodon-status-with-attachment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "110726017288384411", 3 | "uri": "https://hackers.town/users/lmorchard/statuses/110726017288384411", 4 | "url": "https://hackers.town/@lmorchard/110726017288384411", 5 | "account": { 6 | "id": "136533", 7 | "username": "lmorchard", 8 | "acct": "lmorchard", 9 | "display_name": "Les Orchard", 10 | "locked": false, 11 | "discoverable": true, 12 | "group": false, 13 | "created_at": "2020-06-28T00:00:00Z", 14 | "followers_count": 2029, 15 | "following_count": 2802, 16 | "statuses_count": 7129, 17 | "note": "

he / him; semi-hermit in PDX, USA; tinkerer; old adhd cat dad; serial enthusiast; editor-at-large for http://lmorchard.com; astra mortemque superare gradatim; tootfinder

", 18 | "url": "https://hackers.town/@lmorchard", 19 | "avatar": "https://hackers.town/system/accounts/avatars/000/136/533/original/1a8c651efe14fcd6.png", 20 | "avatar_static": "https://hackers.town/system/accounts/avatars/000/136/533/original/1a8c651efe14fcd6.png", 21 | "header": "https://hackers.town/system/accounts/headers/000/136/533/original/60af00520bbf3704.jpg", 22 | "header_static": "https://hackers.town/system/accounts/headers/000/136/533/original/60af00520bbf3704.jpg", 23 | "emojis": [], 24 | "moved": null, 25 | "fields": [ 26 | { 27 | "name": "Home", 28 | "value": "http://lmorchard.com/", 29 | "verified_at": "2022-11-02T03:17:15.006Z" 30 | }, 31 | { 32 | "name": "Webring Enthusiasts!", 33 | "value": "https://fediverse-webring-enthusiasts.glitch.me/profiles/lmorchard_hackers.town/index.html", 34 | "verified_at": "2022-12-04T04:59:02.079Z" 35 | }, 36 | { 37 | "name": "0xDECAFBAD BBS", 38 | "value": "https://bbs.decafbad.com/", 39 | "verified_at": null 40 | } 41 | ], 42 | "bot": false, 43 | "source": null 44 | }, 45 | "in_reply_to_id": null, 46 | "in_reply_to_account_id": null, 47 | "reblog": null, 48 | "content": "

Also, belated happy #caturday from Cosmo, Cheddars, and Catsby all waiting for a treat

", 49 | "plain_content": null, 50 | "created_at": "2023-07-16T22:02:21.535Z", 51 | "emojis": [], 52 | "replies_count": 0, 53 | "reblogs_count": 1, 54 | "favourites_count": 22, 55 | "reblogged": false, 56 | "favourited": false, 57 | "muted": false, 58 | "sensitive": false, 59 | "spoiler_text": "", 60 | "visibility": "public", 61 | "media_attachments": [ 62 | { 63 | "id": "110726013786047891", 64 | "type": "image", 65 | "url": "https://hackers.town/system/media_attachments/files/110/726/013/786/047/891/original/6e65fa5605309c81.jpeg", 66 | "remote_url": null, 67 | "preview_url": "https://hackers.town/system/media_attachments/files/110/726/013/786/047/891/small/6e65fa5605309c81.jpeg", 68 | "text_url": null, 69 | "meta": { 70 | "original": { 71 | "width": 1152, 72 | "height": 1536, 73 | "size": "1152x1536", 74 | "aspect": 0.75, 75 | "frame_rate": null, 76 | "duration": null, 77 | "bitrate": null 78 | }, 79 | "small": { 80 | "width": 416, 81 | "height": 554, 82 | "size": "416x554", 83 | "aspect": 0.7509025270758123, 84 | "frame_rate": null, 85 | "duration": null, 86 | "bitrate": null 87 | }, 88 | "focus": { 89 | "x": 0.0, 90 | "y": 0.0 91 | }, 92 | "length": null, 93 | "duration": null, 94 | "fps": null, 95 | "size": null, 96 | "width": null, 97 | "height": null, 98 | "aspect": null, 99 | "audio_encode": null, 100 | "audio_bitrate": null, 101 | "audio_channel": null 102 | }, 103 | "description": "Three cats in a kitchen. Cosmo, a white and orange cat, sits on the corner of a counter licking his chops. Cheddars, a round orange and white cat, sits at my feet looking up at me. Catsby, a more horse-shaped orange and white cat, has his ears back and is about to yell at me.", 104 | "blurhash": "U98|#8=_DhD$?ws.R4MxAKM}ng$~NgE2NG%M" 105 | } 106 | ], 107 | "mentions": [], 108 | "tags": [ 109 | { 110 | "name": "caturday", 111 | "url": "https://hackers.town/tags/caturday" 112 | } 113 | ], 114 | "card": null, 115 | "poll": null, 116 | "application": { 117 | "name": "IceCubesApp", 118 | "website": "https://github.com/Dimillian/IceCubesApp", 119 | "vapid_key": null 120 | }, 121 | "language": "en", 122 | "pinned": false, 123 | "emoji_reactions": null, 124 | "quote": false, 125 | "bookmarked": false 126 | } 127 | -------------------------------------------------------------------------------- /src/resources/test/outbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "id": "outbox.json", 4 | "type": "OrderedCollection", 5 | "totalItems": 1, 6 | "orderedItems": [ 7 | { 8 | "id": "https://mastodon.social/users/lmorchard/statuses/55864/activity", 9 | "type": "Create", 10 | "actor": "https://mastodon.social/users/lmorchard", 11 | "published": "2016-11-01T19:18:43Z", 12 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 13 | "cc": ["https://mastodon.social/users/lmorchard/followers"], 14 | "object": { 15 | "id": "https://mastodon.social/users/lmorchard/statuses/55864", 16 | "type": "Note", 17 | "summary": null, 18 | "inReplyTo": null, 19 | "published": "2016-11-01T19:18:43Z", 20 | "url": "https://mastodon.social/@lmorchard/55864", 21 | "attributedTo": "https://mastodon.social/users/lmorchard", 22 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 23 | "cc": ["https://mastodon.social/users/lmorchard/followers"], 24 | "sensitive": false, 25 | "atomUri": "tag:mastodon.social,2016-11-01:objectId=55864:objectType=Status", 26 | "inReplyToAtomUri": null, 27 | "conversation": null, 28 | "content": "\u003cp\u003eHello world!\u003c/p\u003e", 29 | "contentMap": { "en": "\u003cp\u003eHello world!\u003c/p\u003e" }, 30 | "attachment": [], 31 | "tag": [], 32 | "replies": { 33 | "id": "https://mastodon.social/users/lmorchard/statuses/55864/replies", 34 | "type": "Collection", 35 | "first": { 36 | "type": "CollectionPage", 37 | "next": "https://mastodon.social/users/lmorchard/statuses/55864/replies?only_other_accounts=true\u0026page=true", 38 | "partOf": "https://mastodon.social/users/lmorchard/statuses/55864/replies", 39 | "items": [] 40 | } 41 | } 42 | }, 43 | "signature": { 44 | "type": "RsaSignature2017", 45 | "creator": "https://mastodon.social/users/lmorchard#main-key", 46 | "created": "2023-01-15T04:14:32Z", 47 | "signatureValue": "UYcMjb8l0j9zol/Ljjaxo+aEylaAAAD+Iw6hpohFr9zxb56K9j4fIWDVqYwnHX1JR7a92R6Ybn9dobonXzHQo/oKviIJhwxDW6qkqvYHV3iOZG3raA9wGa6JLDPwdl1MYdpuLmZneEo4BtHHLVsj3lGbNPjFjMGRbkmyczV37Sz/Hm6fqLzLRCfBOAC1GY83RsV04C25asZrPZTRNUDoU94bni81dubUR8pZYNH0OVSLAJH02B+N0YmP/ti3dyg8XUXLIXM6u1eW1IIU0L+e459BhLhTNvVH/ISnHn/n1QMZDuQ1G9VBU0NSdt7jnTrykdd2yv7pNbRxJ7HrvUtnkg==" 48 | } 49 | }, 50 | { 51 | "id": "https://mastodon.social/users/lmorchard/statuses/237393/activity", 52 | "type": "Create", 53 | "actor": "https://mastodon.social/users/lmorchard", 54 | "published": "2016-11-27T23:56:46Z", 55 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 56 | "cc": ["https://mastodon.social/users/lmorchard/followers"], 57 | "object": { 58 | "id": "https://mastodon.social/users/lmorchard/statuses/237393", 59 | "type": "Note", 60 | "summary": null, 61 | "inReplyTo": null, 62 | "published": "2016-11-27T23:56:46Z", 63 | "url": "https://mastodon.social/@lmorchard/237393", 64 | "attributedTo": "https://mastodon.social/users/lmorchard", 65 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 66 | "cc": ["https://mastodon.social/users/lmorchard/followers"], 67 | "sensitive": false, 68 | "atomUri": "tag:mastodon.social,2016-11-27:objectId=237393:objectType=Status", 69 | "inReplyToAtomUri": null, 70 | "conversation": null, 71 | "content": "\u003cp\u003eI should post here more, but I\u0026#39;ve been procrastinating installing something GNU-Social-ish on my own domain\u003c/p\u003e", 72 | "contentMap": { 73 | "en": "\u003cp\u003eI should post here more, but I\u0026#39;ve been procrastinating installing something GNU-Social-ish on my own domain\u003c/p\u003e" 74 | }, 75 | "attachment": [], 76 | "tag": [], 77 | "replies": { 78 | "id": "https://mastodon.social/users/lmorchard/statuses/237393/replies", 79 | "type": "Collection", 80 | "first": { 81 | "type": "CollectionPage", 82 | "next": "https://mastodon.social/users/lmorchard/statuses/237393/replies?only_other_accounts=true\u0026page=true", 83 | "partOf": "https://mastodon.social/users/lmorchard/statuses/237393/replies", 84 | "items": [] 85 | } 86 | } 87 | }, 88 | "signature": { 89 | "type": "RsaSignature2017", 90 | "creator": "https://mastodon.social/users/lmorchard#main-key", 91 | "created": "2023-01-15T04:14:32Z", 92 | "signatureValue": "BByb/GjzI/JAYONGumaySNWvwyyRX9NacsPlgppOb2MTAp6Qy1wUPA58vCeaa6zd5ItRBSYNJtx9TT7UVnpjNihlEZ4HEGA4IkHi1f8J9v6pNVD+5RfP8q5GTnlGqPul68dSKh/FOdjCwjoQiaGz/llOBcq+8lGbtdEw018cvcFDAaoxznJ8iIjtIj5IbmrRGwAgEZJFyB3F4jwn0sCve8gJL6x6qZpAb/nFXGgpoEcozBu0Hzb9yBIXrQOARcCMw54eQX2MrqqBvuzF1Y4w+uDuTu8OmFQS7RhBOqumJciokGkN4ZB/jRTTnNUZ4sEhklRbJ7Zq0+QAK42bod1WWA==" 93 | } 94 | } 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /src/resources/themes/default/templates/activity.html: -------------------------------------------------------------------------------- 1 | {%- set actor = activity.actor -%} 2 | {%- set actor_hash = actor.id | sha256 %} 3 | {%- set media_root = site_root ~ "/media/" ~ actor_hash -%} 4 | {%- set object = activity.object -%} 5 | 6 |
7 |

8 | {%- if "Create" == activity.type and "Note" == object.type -%} 9 | 10 | {%- else -%} 11 | 12 | {%- endif -%} 13 | # 14 |

15 |
{{ actor.name }}
16 | 17 | 18 |
19 |
20 |
21 | {%- if "Announce" == activity.type -%} 22 | {{ object }} 23 | {%- elif "Create" == activity.type and "Note" == object.type -%} 24 | {%- if object.summary -%} 25 |
{{ object.summary }}
26 | {%- endif -%} 27 | {%- if object.content -%} 28 | {{ object.content | safe }} 29 | {%- endif -%} 30 | {%- else -%} 31 | (unknown activity type {{ activity.type }}) 32 | {%- endif -%} 33 |
34 | {%- if "Create" == activity.type and "Note" == object.type and object.attachment -%} 35 | 36 | {%- for attachment in object.attachment -%} 37 | 38 | 39 | {%- if attachment.mediaType is starting_with("video/") -%} 40 | 43 | {%- else -%} 44 | 45 | {%- endif -%} 46 | 47 | 48 | {%- endfor -%} 49 | 50 | {%- endif -%} 51 |
52 |
53 | -------------------------------------------------------------------------------- /src/resources/themes/default/templates/day.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block head %} 4 | {{ day.current.date | safe }} 5 | {% if day.previous %} 6 | 7 | {% endif %} 8 | {% if day.next %} 9 | 10 | {% endif %} 11 | {% endblock head %} 12 | 13 | {% block pagetitle %} 14 | {{ day.current.date | safe }} 15 | {% endblock pagetitle %} 16 | 17 | {% block content %} 18 | 19 | 20 | {% for activity in activities %} 21 | {% include "activity.html" %} 22 | {% endfor %} 23 | 24 | {% if day.next %} 25 | 26 | 29 | {{ day.next.date }} 30 | 31 | 32 | {% endif %} 33 | 34 | {% endblock content %} 35 | -------------------------------------------------------------------------------- /src/resources/themes/default/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block head %} 4 | Fossilizer Index 5 | {% endblock head %} 6 | 7 | {% block pagetitle %}Fossilizer Index{% endblock pagetitle %} 8 | 9 | {% block content %} 10 |
11 |
12 | {%- for year, months in calendar -%} 13 |
14 |

{{ year }} ({{ months | length }})

15 | {%- for month, days in months -%} 16 |
17 |

{{ month }} ({{ days | length }})

18 |
19 | 26 |
27 |
28 | {%- endfor -%} 29 |
30 | {%- endfor -%} 31 |
32 |
33 | {% endblock content %} 34 | -------------------------------------------------------------------------------- /src/resources/themes/default/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% block head %} 9 | {% block title %}{% endblock title %} 10 | {% endblock head %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 49 | 50 |
51 |
52 | 53 | 54 | 55 | 56 | 57 | {% block content %} {% endblock content %} 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/resources/themes/default/web/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --avatar-size: 48px; 3 | --avatar-border-radius: 8px; 4 | 5 | --media-lightbox-item-img-width: 128px; 6 | --media-lightbox-item-video-width: 100%; 7 | --media-lightbox-item-max-height: 1024px; 8 | 9 | --pagefind-ui-scale: .8; 10 | --pagefind-ui-border-width: 2px; 11 | --pagefind-ui-border-radius: 8px; 12 | --pagefind-ui-image-border-radius: 8px; 13 | --pagefind-ui-image-box-ratio: 3 / 2; 14 | --pagefind-ui-font: sans-serif; 15 | --pagefind-ui-font: system, -apple-system, "BlinkMacSystemFont", ".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif; 16 | 17 | --theme-font-family: system, -apple-system, "BlinkMacSystemFont", ".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif; 18 | --theme-font-size: 15px; 19 | 20 | --activity-normal-width: 38em; 21 | } 22 | 23 | @media (prefers-color-scheme: light) { 24 | :root { 25 | --theme-bg-color: #eee; 26 | --theme-highlighted-bg-color: #dfdfdf; 27 | --theme-dialog-bg-color: rgba(192,192,192,0.8); 28 | --theme-text-color: #111; 29 | --theme-border-color: #333; 30 | --theme-link-color: #383; 31 | 32 | --pagefind-ui-primary: #393939; 33 | --pagefind-ui-text: #393939; 34 | --pagefind-ui-background: #ffffff; 35 | --pagefind-ui-border: #eeeeee; 36 | --pagefind-ui-tag: #eeeeee; 37 | } 38 | } 39 | 40 | @media (prefers-color-scheme: dark) { 41 | :root { 42 | --theme-bg-color: #222; 43 | --theme-highlighted-bg-color: #332f33; 44 | --theme-dialog-bg-color: rgba(0,0,0,0.8); 45 | --theme-text-color: #eee; 46 | --theme-border-color: #444; 47 | --theme-link-color: #a9f; 48 | 49 | --pagefind-ui-primary: #eeeeee; 50 | --pagefind-ui-text: #eeeeee; 51 | --pagefind-ui-background: rgba(21, 32, 40, 0.95); 52 | --pagefind-ui-border: #999; 53 | --pagefind-ui-tag: #999; 54 | } 55 | } 56 | 57 | html { 58 | scroll-padding-top: 5em; 59 | } 60 | 61 | body { 62 | padding: 0; 63 | margin: 3em 1em 1em 1em; 64 | 65 | font-family: var(--theme-font-family); 66 | font-size: var(--theme-font-size); 67 | 68 | background-color: var(--theme-bg-color); 69 | color: var(--theme-text-color); 70 | } 71 | 72 | a { 73 | color: var(--theme-link-color); 74 | } 75 | 76 | theme-selector {} 77 | 78 | theme-selector button { 79 | color: var(--theme-text-color); 80 | } 81 | 82 | theme-selector button .icon { 83 | display: none 84 | } 85 | 86 | archive-nav { 87 | position: fixed; 88 | top: 0; 89 | left: 0; 90 | z-index: 1000; 91 | width: 100vw; 92 | max-height: 100vh; 93 | overflow: auto; 94 | display: flex; 95 | flex-direction: column; 96 | align-items: center; 97 | justify-content: center; 98 | background-color: var(--theme-bg-color); 99 | } 100 | 101 | archive-nav > section { 102 | display: flex; 103 | flex-direction: row; 104 | width: calc(var(--activity-normal-width) + 3em); 105 | background-color: var(--theme-bg-color); 106 | align-items: flex-start; 107 | } 108 | 109 | archive-nav details { 110 | } 111 | 112 | archive-nav details summary:before { 113 | content: "☰"; 114 | font-size: 36px; 115 | padding: 8px; 116 | } 117 | 118 | archive-nav details summary { 119 | cursor: pointer; 120 | list-style: none; 121 | border: none; 122 | overflow: hidden; 123 | padding: 0 44px 0 0; 124 | margin: 0; 125 | width: 0px; 126 | } 127 | 128 | archive-nav details[open] section { 129 | position: fixed; 130 | display: flex; 131 | flex-direction: column; 132 | top: 3.5em; 133 | padding: 1em; 134 | background: var(--theme-dialog-bg-color); 135 | } 136 | archive-nav details[open] section > * { 137 | padding: 0.5em; 138 | } 139 | 140 | archive-nav archive-nav-date-selector {} 141 | 142 | archive-nav archive-nav-search { 143 | display: block; 144 | width: 100vw; 145 | max-height: 100vh; 146 | overflow: auto; 147 | } 148 | 149 | theme-selector button { 150 | border: none; 151 | background: none; 152 | cursor: pointer; 153 | } 154 | 155 | archive-main {} 156 | 157 | .index-calendar-outline { 158 | width: 100%; 159 | display: flex; 160 | flex-direction: column; 161 | align-items: center; 162 | padding-top: 1em; 163 | } 164 | 165 | .index-calendar-outline .year { 166 | width: 20em; 167 | margin: 0.5em; 168 | } 169 | 170 | .index-calendar-outline .year > .month { 171 | padding-left: 1.5em; 172 | margin: 0.5em; 173 | } 174 | 175 | .index-calendar-outline .year > .month ul.index-list { 176 | margin: 0.5em; 177 | } 178 | 179 | .index-calendar-outline .year h2 { 180 | display: inline; 181 | } 182 | 183 | .index-calendar-outline .year .month h3 { 184 | display: inline; 185 | } 186 | 187 | .index-calendar-outline .year .month ul.index-list { 188 | margin-left: 2em; 189 | padding: 0.5em 0; 190 | } 191 | 192 | .index-calendar-outline .year .month .day { 193 | margin-bottom: 0.5em; 194 | } 195 | 196 | archive-activity-list { 197 | display: flex; 198 | width: 100%; 199 | flex-direction: column; 200 | } 201 | 202 | archive-activity-list archive-activity-list-next-page { 203 | display: flex; 204 | width: 100%; 205 | flex-direction: row; 206 | justify-content: center; 207 | } 208 | 209 | archive-activity-list-controls { 210 | display: flex; 211 | flex-direction: column; 212 | justify-content: center; 213 | align-self: center; 214 | } 215 | 216 | archive-activity-list archive-activity-list-contents { 217 | display: flex; 218 | flex-direction: column; 219 | align-items: center; 220 | justify-content: space-around; 221 | } 222 | 223 | archive-activity { 224 | width: var(--activity-normal-width); 225 | padding: 1.5em; 226 | position: relative; 227 | overflow: auto; 228 | border-bottom: 1px solid var(--theme-border-color); 229 | } 230 | 231 | archive-activity.highlighted { 232 | background-color: var(--theme-highlighted-bg-color); 233 | } 234 | 235 | archive-activity .header { 236 | height: var(--avatar-size); 237 | padding-left: calc(var(--avatar-size) + 1em); 238 | } 239 | 240 | archive-activity .header .published { 241 | position: absolute; 242 | padding: 0; 243 | margin: 0; 244 | top: 1.5em; 245 | right: 1.5em; 246 | font-weight: normal; 247 | font-size: 0.85em; 248 | } 249 | 250 | archive-activity .header .published time { 251 | padding-right: 0.5em; 252 | } 253 | 254 | archive-activity .header .avatar { 255 | position: absolute; 256 | left: 1.5em; 257 | top: 1.5em; 258 | } 259 | 260 | archive-activity .header .avatar img { 261 | width: var(--avatar-size); 262 | height: var(--avatar-size); 263 | border-radius: var(--avatar-border-radius); 264 | } 265 | 266 | archive-activity .header .title { 267 | margin: 0 0 0.25em 0; 268 | font-size: 1em; 269 | } 270 | 271 | archive-activity .header .subtitle { 272 | padding: 0; 273 | margin: 0; 274 | font-size: 0.9em; 275 | } 276 | 277 | archive-activity .body .text .boost { 278 | display: block; 279 | margin: 1em 0; 280 | } 281 | 282 | archive-activity .body .text .boost:before { 283 | content: "♻️ "; 284 | } 285 | 286 | archive-activity .body .text .summary { 287 | padding: 0; 288 | margin: 1em 0; 289 | font-size: 1em; 290 | font-weight: bold; 291 | } 292 | 293 | archive-activity .body .text .summary:before { 294 | content: "⚠️ "; 295 | } 296 | 297 | archive-activity media-lightbox-list { 298 | display: flex; 299 | margin: 1em 0 0 0; 300 | width: 100%; 301 | flex-direction: row; 302 | flex-wrap: wrap; 303 | justify-items: flex-end; 304 | justify-content: flex-start; 305 | } 306 | 307 | archive-activity media-lightbox-list media-lightbox-item { 308 | margin: 0 0.5em 0 0; 309 | max-height: var(--media-lightbox-item-max-height); 310 | overflow: hidden; 311 | } 312 | 313 | archive-activity media-lightbox-list media-lightbox-item img { 314 | width: var(--media-lightbox-item-img-width); 315 | } 316 | 317 | archive-activity media-lightbox-list media-lightbox-item video { 318 | width: var(--media-lightbox-item-video-width); 319 | } 320 | 321 | archive-activity:last-of-type { 322 | border-bottom: none; 323 | } 324 | 325 | archive-activity-list.grid archive-activity-list-contents { 326 | flex-direction: row; 327 | flex-wrap: wrap; 328 | align-items: flex-start; 329 | } 330 | 331 | archive-activity-list.grid archive-activity-list-contents::after { 332 | content: ""; 333 | flex: auto; 334 | align-items: flex-start; 335 | } 336 | 337 | archive-activity-list.grid archive-activity-list-contents archive-activity { 338 | width: 24em; 339 | padding: 2em; 340 | border-bottom: 1px solid var(--theme-border-color); 341 | max-height: 18em; 342 | overflow: auto; 343 | border-bottom: none; 344 | border-top: 1px solid var(--theme-border-color); 345 | } 346 | 347 | @media (max-width: 700px) { 348 | :root { 349 | --activity-normal-width: 90vw; 350 | } 351 | archive-nav archive-nav-search { 352 | padding: 0; 353 | } 354 | } 355 | 356 | @media (max-width: 600px) { 357 | archive-activity .header { 358 | height: auto; 359 | padding-bottom: 0.5em; 360 | } 361 | archive-activity { 362 | padding-bottom: 2.5em; 363 | } 364 | archive-activity .header .published { 365 | top: inherit; 366 | bottom: 1.5em; 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/resources/themes/default/web/index.js: -------------------------------------------------------------------------------- 1 | import "./lib/theme-selector.js"; 2 | import "./lib/lazy-load-observer.js"; 3 | import "./lib/formatted-time.js"; 4 | import "./lib/media-lightbox.js"; 5 | import "./lib/archive-nav/index.js"; 6 | import "./lib/archive-main.js"; 7 | import "./lib/archive-activity-list.js"; 8 | -------------------------------------------------------------------------------- /src/resources/themes/default/web/lib/archive-activity-list.js: -------------------------------------------------------------------------------- 1 | class ArchiveActivityList extends HTMLElement { } 2 | 3 | customElements.define("archive-activity-list", ArchiveActivityList); 4 | 5 | class ArchiveActivityListContents extends HTMLElement { } 6 | 7 | customElements.define("archive-activity-list-contents", ArchiveActivityListContents); 8 | 9 | class ArchiveActivityListControls extends HTMLElement { 10 | connectedCallback() { 11 | this.querySelector("input[name=grid]") 12 | .addEventListener("change", (ev) => this.handleChangeGrid(ev)); 13 | this.querySelector("input[name=relative-time]") 14 | .addEventListener("change", (ev) => this.handleChangeRelativeTime(ev)); 15 | } 16 | 17 | handleChangeGrid(ev) { 18 | this.getList().classList[ev.target.checked ? "add" : "remove"]("grid"); 19 | } 20 | 21 | handleChangeRelativeTime(ev) { 22 | document.querySelector("formatted-time-context") 23 | .setRelativeTime(ev.target.checked); 24 | } 25 | 26 | getList() { 27 | const forId = this.getAttribute("for"); 28 | return forId 29 | ? document.body.querySelector(`#${forId}`) 30 | : this.closest("archive-activity-list"); 31 | } 32 | } 33 | 34 | customElements.define("archive-activity-list-controls", ArchiveActivityListControls); 35 | 36 | class ArchiveActivity extends HTMLElement { 37 | connectedCallback() { 38 | const hash = window.location.hash; 39 | if (hash === `#anchor-${this.id}`) { 40 | this.classList.add("highlighted"); 41 | } 42 | } 43 | } 44 | 45 | customElements.define("archive-activity", ArchiveActivity); 46 | 47 | class ArchiveActivityListNextPage extends HTMLElement { 48 | connectedCallback() { 49 | this.addEventListener("click", (ev) => this.handleClick(ev)); 50 | } 51 | 52 | async handleClick(ev) { 53 | if (ev.target.tagName !== "A") return; 54 | 55 | ev.preventDefault(); 56 | ev.stopPropagation(); 57 | 58 | // Get the nav link href and then remove the link from the DOM 59 | const target = ev.target; 60 | const href = target.getAttribute("href"); 61 | this.removeChild(target); 62 | 63 | // Fetch the nav link href and parse into a document 64 | const response = await fetch(href); 65 | const content = await response.text(); 66 | const parser = new DOMParser(); 67 | const doc = parser.parseFromString(content, "text/html"); 68 | const body = doc.body; 69 | 70 | // Find the archive-activity nodes in the loaded document, adopt them into the current page 71 | const parentList = this.closest("archive-activity-list"); 72 | const container = parentList.querySelector("archive-activity-list-contents"); 73 | for (const node of body.querySelectorAll("archive-activity")) { 74 | container.appendChild(document.adoptNode(node)); 75 | } 76 | 77 | // Find a next link from the loaded document, adopt it into the current page if found 78 | const newNextPageLink = body.querySelector("archive-activity-list-next-page a"); 79 | if (newNextPageLink) { 80 | this.appendChild(document.adoptNode(newNextPageLink)); 81 | } 82 | } 83 | } 84 | 85 | customElements.define("archive-activity-list-next-page", ArchiveActivityListNextPage); 86 | -------------------------------------------------------------------------------- /src/resources/themes/default/web/lib/archive-main.js: -------------------------------------------------------------------------------- 1 | class ArchiveMain extends HTMLElement { } 2 | 3 | customElements.define("archive-main", ArchiveMain); -------------------------------------------------------------------------------- /src/resources/themes/default/web/lib/archive-nav/date-selector.js: -------------------------------------------------------------------------------- 1 | class ArchiveNavDateSelector extends HTMLElement { 2 | constructor() { 3 | super(); 4 | } 5 | 6 | connectedCallback() { 7 | const linkTop = document.head.querySelector("link[rel=top]"); 8 | this.topUrl = new URL(linkTop.getAttribute("href"), window.location); 9 | this.indexJsonURL = new URL("./index.json", this.topUrl); 10 | 11 | this.addEventListener("change", ev => { 12 | if (ev.target.classList.contains("date-nav")) { 13 | this.handleNavigationChange(ev); 14 | } 15 | }); 16 | 17 | this.fetchIndexJSON(); 18 | } 19 | 20 | async fetchIndexJSON() { 21 | const resp = await fetch(this.indexJsonURL); 22 | const indexJson = await resp.json(); 23 | const pages = indexJson.sort((a, b) => a.current.date.localeCompare(b.current.date)); 24 | 25 | let previous, next; 26 | 27 | const innerHTML = [``); 41 | 42 | /* 43 | if (previous) { 44 | innerHTML.unshift(``); 45 | } 46 | if (next) { 47 | innerHTML.push(``); 48 | } 49 | */ 50 | 51 | this.innerHTML = innerHTML.join("\n"); 52 | } 53 | 54 | handleNavigationChange(ev) { 55 | const url = new URL(ev.target.value, this.topUrl); 56 | window.location.assign(url); 57 | } 58 | } 59 | 60 | customElements.define("archive-nav-date-selector", ArchiveNavDateSelector); -------------------------------------------------------------------------------- /src/resources/themes/default/web/lib/archive-nav/index.js: -------------------------------------------------------------------------------- 1 | import "./date-selector.js"; 2 | 3 | class ArchiveNav extends HTMLElement { 4 | constructor() { 5 | super(); 6 | } 7 | 8 | connectedCallback() { 9 | } 10 | } 11 | 12 | customElements.define("archive-nav", ArchiveNav); 13 | 14 | class ArchiveNavSearch extends HTMLElement { 15 | constructor() { 16 | super(); 17 | } 18 | connectedCallback() { 19 | const linkTop = document.head.querySelector("link[rel=base]"); 20 | const topUrl = new URL(linkTop.getAttribute("href"), window.location); 21 | const id = `archive-nav-search-${Date.now()}-${Math.ceil(1000 * Math.random())}`; 22 | this.setAttribute("id", id); 23 | 24 | if (PagefindUI) { 25 | new PagefindUI({ 26 | element: `#${this.id}`, 27 | showImages: true, 28 | showSubResults: true, 29 | highlightParam: "highlight", 30 | pageSize: 3, 31 | baseUrl: topUrl 32 | }); 33 | } 34 | } 35 | } 36 | 37 | customElements.define("archive-nav-search", ArchiveNavSearch); 38 | -------------------------------------------------------------------------------- /src/resources/themes/default/web/lib/formatted-time.js: -------------------------------------------------------------------------------- 1 | // TODO: control this via attribute in formatted-time-context 2 | const ARCHIVE_ACTIVITY_TIME_UPDATE_PERIOD = 10000; 3 | 4 | class FormattedTimeContext extends HTMLElement { 5 | connectedCallback() { 6 | this.updateTimer = setInterval( 7 | () => this.setAttribute("last-update", Date.now()), 8 | ARCHIVE_ACTIVITY_TIME_UPDATE_PERIOD 9 | ); 10 | } 11 | disconnectedCallback() { 12 | if (this.updateTimer) clearInterval(this.updateTimer); 13 | } 14 | toggleRelativeTime() { 15 | // TODO: switch to an attribute 16 | this.classList.toggle("relative-time"); 17 | } 18 | setRelativeTime(value) { 19 | this.classList[value ? "add" : "remove"]("relative-time"); 20 | } 21 | shouldUseRelativeTime() { 22 | return this.classList.contains("relative-time"); 23 | } 24 | } 25 | 26 | customElements.define("formatted-time-context", FormattedTimeContext); 27 | 28 | class FormattedTime extends HTMLTimeElement { 29 | connectedCallback() { 30 | this.update(); 31 | 32 | this.context = this.closest("formatted-time-context"); 33 | if (this.context) { 34 | this.contextObserver = new MutationObserver(() => this.update()); 35 | this.contextObserver.observe( 36 | this.context, 37 | { attributeFilter: ["class", "last-update"] } 38 | ) 39 | } 40 | } 41 | disconnectedCallback() { 42 | this.contextObserver.disconnect(); 43 | } 44 | update() { 45 | // TODO: maybe also control this with a local attribute? 46 | let timeSince = false; 47 | if (this.context && this.context.shouldUseRelativeTime()) { 48 | timeSince = true; 49 | } 50 | 51 | const datetime = new Date(this.getAttribute("datetime")); 52 | if (timeSince && timeago && timeago.format) { 53 | this.innerHTML = timeago.format(datetime); 54 | } else { 55 | this.innerHTML = datetime.toLocaleString(); 56 | } 57 | } 58 | } 59 | 60 | customElements.define("formatted-time", FormattedTime, { extends: "time" }); 61 | -------------------------------------------------------------------------------- /src/resources/themes/default/web/lib/lazy-load-observer.js: -------------------------------------------------------------------------------- 1 | const LAZY_LOAD_THRESHOLD = 0.1; 2 | const LAZY_LOAD_CLASS_NAME = "lazy-load"; 3 | 4 | class LazyLoadObserver extends HTMLElement { 5 | constructor() { 6 | super(); 7 | 8 | // TODO: use an attribute for these 9 | this.threshold = LAZY_LOAD_THRESHOLD; 10 | this.lazyLoadClassName = LAZY_LOAD_CLASS_NAME; 11 | 12 | this.intersectionObserver = new IntersectionObserver( 13 | entries => this.handleIntersections(entries), 14 | { threshold: this.threshold } 15 | ); 16 | 17 | this.mutationObserver = new MutationObserver( 18 | (records) => this.handleMutations(records) 19 | ); 20 | } 21 | 22 | connectedCallback() { 23 | this.mutationObserver.observe(this, { 24 | subtree: true, 25 | childList: true, 26 | }); 27 | for (const node of this.querySelectorAll(`.${this.lazyLoadClassName}`)) { 28 | this.intersectionObserver.observe(node); 29 | } 30 | } 31 | 32 | disconnectedCallback() { 33 | this.intersectionObserver.disconnect(); 34 | this.mutationObserver.disconnect(); 35 | } 36 | 37 | handleMutations(records) { 38 | for (const record of records) { 39 | for (const node of record.addedNodes) { 40 | if (node.nodeType === Node.ELEMENT_NODE) { 41 | for (const subnode of node.querySelectorAll(`.${this.lazyLoadClassName}`)) { 42 | this.intersectionObserver.observe(subnode); 43 | } 44 | if (node.classList.contains(this.lazyLoadClassName)) { 45 | this.intersectionObserver.observe(node); 46 | } 47 | } 48 | } 49 | for (const node of record.removedNodes) { 50 | if (node.nodeType === Node.ELEMENT_NODE) { 51 | for (const subnode of node.querySelectorAll(`.${this.lazyLoadClassName}`)) { 52 | this.intersectionObserver.unobserve(subnode); 53 | } 54 | if (node.classList.contains(this.lazyLoadClassName)) { 55 | this.intersectionObserver.unobserve(node); 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | handleIntersections(entries) { 63 | for (const entry of entries) { 64 | if (entry.isIntersecting) { 65 | this.handleIntersection(entry); 66 | } 67 | } 68 | } 69 | 70 | async handleIntersection({ target }) { 71 | if (/img/i.test(target.tagName)) { 72 | const src = target.getAttribute("data-src"); 73 | if (src) { 74 | target.setAttribute("src", src); 75 | target.removeAttribute("data-src"); 76 | } 77 | } 78 | 79 | if (target.classList.contains("load-href")) { 80 | await this.replaceElementWithHTMLResource( 81 | target.parentNode, 82 | target.getAttribute("href") 83 | ); 84 | } 85 | 86 | if (target.classList.contains("auto-click")) { 87 | target.classList.remove("auto-click"); 88 | target.click(); 89 | } 90 | } 91 | 92 | async replaceElementWithHTMLResource(element, href) { 93 | if (element.classList.contains("loading")) return; 94 | element.classList.add("loading"); 95 | element.setAttribute("disabled", true); 96 | 97 | const response = await fetch(href); 98 | const content = await response.text(); 99 | 100 | const parser = new DOMParser(); 101 | const doc = parser.parseFromString(content, "text/html"); 102 | const loadedNodes = Array.from(doc.body.children); 103 | 104 | const parent = element.parentNode; 105 | for (const node of loadedNodes) { 106 | parent.insertBefore(document.adoptNode(node), element); 107 | } 108 | 109 | element.remove(); 110 | } 111 | } 112 | 113 | customElements.define("lazy-load-observer", LazyLoadObserver); 114 | -------------------------------------------------------------------------------- /src/resources/themes/default/web/lib/media-lightbox.js: -------------------------------------------------------------------------------- 1 | class MediaLightboxContext extends HTMLElement { 2 | connectedCallback() { 3 | this.sheet = document.createElement("style"); 4 | this.sheet.type = "text/css"; 5 | this.sheet.innerText = this.constructor.css; 6 | document.head.appendChild(this.sheet); 7 | 8 | this.lightbox = document.adoptNode(document 9 | .createRange() 10 | .createContextualFragment(this.constructor.html).firstElementChild); 11 | document.body.appendChild(this.lightbox); 12 | } 13 | 14 | disconnectedCallback() { 15 | this.sheet.remove(); 16 | this.lightbox.remove(); 17 | } 18 | 19 | show(src, description, previous, next) { 20 | this.lightbox.show(src, description, previous, next); 21 | } 22 | 23 | static html = /*html*/ ` 24 | 25 |
26 | 27 |
28 |
29 | 30 | 31 | 32 |
33 | ` 34 | 35 | static css = /*css*/ ` 36 | media-lightbox { 37 | display: none; 38 | position: fixed; 39 | left: 0; 40 | top: 0; 41 | z-index: 10000; 42 | width: 100vw; 43 | height: 100vh; 44 | background: rgba(0, 0, 0, 0.9); 45 | } 46 | media-lightbox.visible { 47 | display: flex; 48 | flex-direction: column; 49 | align-items: center; 50 | justify-content: center; 51 | } 52 | media-lightbox section.main img { 53 | max-width: 80vw; 54 | max-height: 80vh; 55 | } 56 | media-lightbox .description { 57 | display: none; 58 | max-width: 50vw; 59 | margin-top: 3em; 60 | padding: 1.5em; 61 | background: rgba(255,255,255,0.125); 62 | } 63 | media-lightbox .description.visible { 64 | display: block; 65 | } 66 | media-lightbox button { 67 | display: none; 68 | position: absolute; 69 | font-size: 2.5em; 70 | cursor: pointer; 71 | border: none; 72 | background: none; 73 | color: #888; 74 | z-index: 25000; 75 | } 76 | media-lightbox button.visible { 77 | display: block; 78 | } 79 | media-lightbox button.dismiss { 80 | top: 1vh; 81 | right: 1vw; 82 | } 83 | media-lightbox button.previous { 84 | top: 50vh; 85 | left: 1vw; 86 | } 87 | media-lightbox button.next { 88 | top: 50vh; 89 | right: 1vw; 90 | } 91 | @media (prefers-color-scheme: light) { 92 | media-lightbox button.dismiss { 93 | color: #333; 94 | } 95 | media-lightbox section.main img { 96 | background: rgba(128,128,128,0.2); 97 | } 98 | media-lightbox .description { 99 | background: rgba(128,128,128,0.2); 100 | } 101 | } 102 | @media (prefers-color-scheme: dark) { 103 | media-lightbox button.dismiss { 104 | color: #ddd; 105 | } 106 | media-lightbox section.main img { 107 | border: 1px solid rgba(255,255,255,0.2); 108 | } 109 | media-lightbox .description { 110 | background: rgba(255,255,255,0.2); 111 | } 112 | } 113 | ` 114 | } 115 | 116 | customElements.define("media-lightbox-context", MediaLightboxContext); 117 | 118 | class MediaLightbox extends HTMLElement { 119 | connectedCallback() { 120 | this.addEventListener( 121 | "click", 122 | (ev) => (ev.target === this) && this.dismiss() 123 | ); 124 | 125 | Object.entries({ 126 | "button.dismiss": () => this.dismiss(), 127 | "button.previous": () => this.showPrevious(), 128 | "button.next": () => this.showNext(), 129 | }).forEach(([sel, fn]) => this.querySelector(sel).addEventListener("click", fn)); 130 | 131 | this.keyListener = (ev) => this.handleKeyDown(ev); 132 | document.addEventListener("keyup", this.keyListener); 133 | } 134 | 135 | disconnectedCallback() { 136 | document.removeEventListener("keyup", this.keyListener); 137 | } 138 | 139 | show(src, description, previous, next) { 140 | const img = this.querySelector("section.main img"); 141 | img.setAttribute("src", src); 142 | img.setAttribute("title", description); 143 | 144 | const descriptionEl = this.querySelector(".description"); 145 | descriptionEl.innerHTML = description; 146 | descriptionEl.classList[!!description ? "add" : "remove"]("visible"); 147 | 148 | this.previous = previous; 149 | this.querySelector("button.previous").classList[!!previous ? "add" : "remove"]("visible"); 150 | 151 | this.next = next; 152 | this.querySelector("button.next").classList[!!next ? "add" : "remove"]("visible"); 153 | 154 | this.classList.add("visible"); 155 | } 156 | 157 | dismiss() { 158 | this.classList.remove("visible"); 159 | } 160 | 161 | handleKeyDown(ev) { 162 | if (!this.classList.contains("visible")) return; 163 | 164 | switch (ev.key) { 165 | case "Escape": 166 | return this.dismiss(); 167 | case "ArrowLeft": 168 | return this.showPrevious(); 169 | case "ArrowRight": 170 | return this.showNext(); 171 | } 172 | } 173 | 174 | showPrevious() { 175 | this.previous && this.previous.show(); 176 | } 177 | 178 | showNext() { 179 | this.next && this.next.show(); 180 | } 181 | } 182 | 183 | customElements.define("media-lightbox", MediaLightbox); 184 | 185 | class MediaLightboxList extends HTMLElement { } 186 | 187 | customElements.define("media-lightbox-list", MediaLightboxList); 188 | 189 | class MediaLightboxItem extends HTMLElement { 190 | connectedCallback() { 191 | this.addEventListener("click", ev => this.handleClick(ev)); 192 | } 193 | 194 | handleClick(ev) { 195 | ev.preventDefault(); 196 | ev.stopPropagation(); 197 | this.show(); 198 | } 199 | 200 | show() { 201 | const context = this.closest("media-lightbox-context"); 202 | if (context) { 203 | const link = this.querySelector("a"); 204 | context.show( 205 | link.href, 206 | link.title, 207 | this.previousElementSibling, 208 | this.nextElementSibling 209 | ); 210 | } 211 | } 212 | } 213 | 214 | customElements.define("media-lightbox-item", MediaLightboxItem); 215 | -------------------------------------------------------------------------------- /src/resources/themes/default/web/lib/theme-selector.js: -------------------------------------------------------------------------------- 1 | // adapted from https://stackoverflow.com/questions/56300132/how-to-override-css-prefers-color-scheme-setting/75124760#75124760 2 | class ThemeSelector extends HTMLElement { 3 | 4 | connectedCallback() { 5 | for (const el of this.querySelectorAll(".icon")) { 6 | el.style.display = "none"; 7 | } 8 | 9 | const button = this.querySelector("button"); 10 | if (button) { 11 | button.addEventListener("click", () => this.toggleColorScheme()); 12 | } 13 | 14 | const scheme = this.getPreferredColorScheme(); 15 | this.applyPreferredColorScheme(scheme); 16 | } 17 | 18 | toggleColorScheme() { 19 | let newScheme = "light"; 20 | let scheme = this.getPreferredColorScheme(); 21 | if (scheme === "light") newScheme = "dark"; 22 | this.applyPreferredColorScheme(newScheme); 23 | this.savePreferredColorScheme(newScheme); 24 | } 25 | 26 | getPreferredColorScheme() { 27 | let systemScheme = 'light'; 28 | if (window.matchMedia('(prefers-color-scheme: dark)').matches) { 29 | systemScheme = 'dark'; 30 | } 31 | let chosenScheme = systemScheme; 32 | if (localStorage.getItem("scheme")) { 33 | chosenScheme = localStorage.getItem("scheme"); 34 | } 35 | if (systemScheme === chosenScheme) { 36 | localStorage.removeItem("scheme"); 37 | } 38 | return chosenScheme; 39 | } 40 | 41 | savePreferredColorScheme(scheme) { 42 | let systemScheme = 'light'; 43 | if (window.matchMedia('(prefers-color-scheme: dark)').matches) { 44 | systemScheme = 'dark'; 45 | } 46 | if (systemScheme === scheme) { 47 | localStorage.removeItem("scheme"); 48 | } 49 | else { 50 | localStorage.setItem("scheme", scheme); 51 | } 52 | } 53 | 54 | applyPreferredColorScheme(scheme) { 55 | for (let s = 0; s < document.styleSheets.length; s++) { 56 | for (let i = 0; i < document.styleSheets[s].cssRules.length; i++) { 57 | const rule = document.styleSheets[s].cssRules[i]; 58 | if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) { 59 | switch (scheme) { 60 | case "light": 61 | rule.media.appendMedium("original-prefers-color-scheme"); 62 | if (rule.media.mediaText.includes("light")) rule.media.deleteMedium("(prefers-color-scheme: light)"); 63 | if (rule.media.mediaText.includes("dark")) rule.media.deleteMedium("(prefers-color-scheme: dark)"); 64 | break; 65 | case "dark": 66 | rule.media.appendMedium("(prefers-color-scheme: light)"); 67 | rule.media.appendMedium("(prefers-color-scheme: dark)"); 68 | if (rule.media.mediaText.includes("original")) rule.media.deleteMedium("original-prefers-color-scheme"); 69 | break; 70 | default: 71 | rule.media.appendMedium("(prefers-color-scheme: dark)"); 72 | if (rule.media.mediaText.includes("light")) rule.media.deleteMedium("(prefers-color-scheme: light)"); 73 | if (rule.media.mediaText.includes("original")) rule.media.deleteMedium("original-prefers-color-scheme"); 74 | break; 75 | } 76 | } 77 | } 78 | } 79 | 80 | if (scheme === "dark") { 81 | this.querySelector(".icon.light").style.display = "inline"; 82 | this.querySelector(".icon.dark").style.display = "none"; 83 | } else { 84 | this.querySelector(".icon.dark").style.display = "inline"; 85 | this.querySelector(".icon.light").style.display = "none"; 86 | } 87 | } 88 | } 89 | 90 | customElements.define("theme-selector", ThemeSelector); 91 | -------------------------------------------------------------------------------- /src/resources/themes/default/web/vendor/timeago.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e=e||self).timeago={})}(this,function(e){"use strict";var r=["second","minute","hour","day","week","month","year"];var a=["秒","分钟","小时","天","周","个月","年"];function t(e,t){n[e]=t}function i(e){return n[e]||n.en_US}var n={},f=[60,60,24,7,365/7/12,12];function o(e){return e instanceof Date?e:!isNaN(e)||/^\d+$/.test(e)?new Date(parseInt(e)):(e=(e||"").trim().replace(/\.\d+/,"").replace(/-/,"/").replace(/-/,"/").replace(/(\d)T(\d)/,"$1 $2").replace(/Z/," UTC").replace(/([+-]\d\d):?(\d\d)/," $1$2"),new Date(e))}function d(e,t){for(var n=e<0?1:0,r=e=Math.abs(e),a=0;e>=f[a]&&a=f[n]&&n Result<(), Box> { 16 | if *clean { 17 | info!("Cleaning build path"); 18 | if let Err(err) = fs::remove_dir_all(build_path) { 19 | if err.kind() != std::io::ErrorKind::NotFound { 20 | // todo: improve error handling here 21 | return Err(Box::new(err)); 22 | } 23 | } 24 | } 25 | fs::create_dir_all(build_path)?; 26 | Ok(()) 27 | } 28 | 29 | pub fn setup_data_path(clean: &bool) -> Result<(), Box> { 30 | let config = config::config()?; 31 | let data_path = &config.data_path; 32 | 33 | if *clean { 34 | info!("Cleaning data path"); 35 | if let Err(err) = fs::remove_dir_all(data_path) { 36 | if err.kind() != std::io::ErrorKind::NotFound { 37 | // todo: improve error handling here 38 | return Err(Box::new(err)); 39 | } 40 | } 41 | } 42 | 43 | fs::create_dir_all(data_path)?; 44 | Ok(()) 45 | } 46 | 47 | pub fn unpack_customizable_resources() -> Result<(), Box> { 48 | let config = config::config()?; 49 | 50 | let mut config_outfile = open_outfile_with_parent_dir(&config.config_path())?; 51 | config_outfile.write_all(DEFAULT_CONFIG.as_bytes())?; 52 | 53 | copy_embedded_themes(&config.themes_path())?; 54 | 55 | Ok(()) 56 | } 57 | 58 | // todo: move this to a shared utils module? build.rs also uses 59 | pub fn copy_embedded_assets( 60 | assets_output_path: &PathBuf, 61 | ) -> Result<(), Box> { 62 | for filename in Assets::iter() { 63 | let file = Assets::get(&filename).ok_or("no asset")?; 64 | let outpath = PathBuf::from(&assets_output_path).join(&filename.to_string()); 65 | 66 | let mut outfile = open_outfile_with_parent_dir(&outpath)?; 67 | outfile.write_all(file.data.as_ref())?; 68 | 69 | debug!("Wrote {} to {:?}", filename, outpath); 70 | } 71 | Ok(()) 72 | } 73 | 74 | pub fn open_outfile_with_parent_dir(outpath: &PathBuf) -> Result> { 75 | let outparent = outpath.parent().ok_or("no parent path")?; 76 | fs::create_dir_all(outparent)?; 77 | let outfile = fs::File::create(outpath)?; 78 | Ok(outfile) 79 | } 80 | 81 | pub fn copy_web_assets(build_path: &PathBuf) -> Result<(), Box> { 82 | let config = config::config()?; 83 | 84 | let web_assets_path = config.web_assets_path(); 85 | if web_assets_path.is_dir() { 86 | let mut web_assets_contents = Vec::new(); 87 | for entry in (web_assets_path.read_dir()?).flatten() { 88 | web_assets_contents.push(entry.path()); 89 | } 90 | copy_files(web_assets_contents.as_slice(), build_path)?; 91 | } else { 92 | info!("Copying embedded static web assets"); 93 | copy_embedded_web_assets(&config.theme, build_path)?; 94 | } 95 | 96 | Ok(()) 97 | } 98 | 99 | pub fn copy_files

(media_path: &[P], build_path: &P) -> Result<(), Box> 100 | where 101 | P: AsRef + std::fmt::Debug, 102 | { 103 | info!("Copying {:?} to {:?}", media_path, build_path); 104 | fs_extra::copy_items_with_progress( 105 | media_path, 106 | build_path, 107 | &fs_extra::dir::CopyOptions { 108 | overwrite: true, 109 | skip_exist: false, 110 | buffer_size: 64000, 111 | copy_inside: true, 112 | content_only: false, 113 | depth: 0, 114 | }, 115 | |process_info| { 116 | debug!( 117 | "Copied {} ({} / {})", 118 | process_info.file_name, process_info.copied_bytes, process_info.total_bytes 119 | ); 120 | fs_extra::dir::TransitProcessResult::ContinueOrAbort 121 | }, 122 | )?; 123 | Ok(()) 124 | } 125 | 126 | pub fn plan_activities_pages( 127 | build_path: &PathBuf, 128 | db_activities: &db::activities::Activities<'_>, 129 | ) -> Result, Box> { 130 | let mut entries: Vec = Vec::new(); 131 | let all_days = db_activities.get_published_days()?; 132 | for (date, count) in all_days { 133 | let day_path = PathBuf::from(build_path).join(&date).with_extension("html"); 134 | let mut context = contexts::IndexDayContext { 135 | current: contexts::IndexDayEntry { 136 | date: date.clone(), 137 | path: day_path.clone().strip_prefix(build_path)?.to_path_buf(), 138 | count, 139 | }, 140 | previous: None, 141 | next: None, 142 | }; 143 | if let Some(mut previous) = entries.pop() { 144 | previous.next = Some(context.current.clone()); 145 | context.previous = Some(previous.current.clone()); 146 | entries.push(previous); 147 | } 148 | entries.push(context); 149 | } 150 | Ok(entries) 151 | } 152 | 153 | pub fn generate_activities_pages( 154 | build_path: &PathBuf, 155 | tera: &Tera, 156 | actors: &HashMap, 157 | day_entries: &Vec, 158 | ) -> Result<(), Box> { 159 | info!("Generating {} per-day pages", day_entries.len()); 160 | day_entries 161 | .par_iter() 162 | .for_each(|day_entry| generate_activity_page(build_path, tera, actors, day_entry).unwrap()); 163 | Ok(()) 164 | } 165 | 166 | pub fn generate_activity_page( 167 | build_path: &PathBuf, 168 | tera: &Tera, 169 | actors: &HashMap, 170 | day_entry: &contexts::IndexDayContext, 171 | ) -> Result<(), Box> { 172 | // let tera = templates::init()?; 173 | let db_conn = db::conn()?; 174 | let db_activities = db::activities::Activities::new(&db_conn); 175 | 176 | let day = &day_entry.current.date; 177 | let day_path = &day_entry.current.path; 178 | 179 | let items: Vec = db_activities 180 | .get_activities_for_day(day)? 181 | .iter() 182 | .map(|activity| { 183 | let actor_id: &String = activity.actor.id().unwrap(); 184 | let actor: &activitystreams::Actor = actors.get(actor_id).unwrap(); 185 | (activity, actor) 186 | }) 187 | .filter(|(activity, _actor)| { 188 | // todo: any actor-related filtering needed here? 189 | activity.is_public() 190 | }) 191 | .map(|(activity, actor)| { 192 | let mut activity = activity.clone(); 193 | activity.actor = activitystreams::IdOrObject::Object(actor.clone()); 194 | activity 195 | }) 196 | .collect(); 197 | 198 | templates::render_to_file( 199 | tera, 200 | &PathBuf::from(&build_path).join(day_path), 201 | "day.html", 202 | contexts::DayTemplateContext { 203 | site_root: "../..".to_string(), 204 | activities: items, 205 | day: day_entry.clone(), 206 | }, 207 | )?; 208 | 209 | Ok(()) 210 | } 211 | 212 | pub fn generate_index_page( 213 | build_path: &PathBuf, 214 | day_entries: &Vec, 215 | tera: &tera::Tera, 216 | ) -> Result<(), Box> { 217 | info!("Generating site index page"); 218 | 219 | let index_path = PathBuf::from(&build_path) 220 | .join("index") 221 | .with_extension("html"); 222 | 223 | templates::render_to_file( 224 | tera, 225 | &index_path, 226 | "index.html", 227 | contexts::IndexTemplateContext { 228 | site_root: ".".to_string(), 229 | calendar: day_entries.into(), 230 | }, 231 | )?; 232 | 233 | Ok(()) 234 | } 235 | 236 | pub fn generate_index_json( 237 | build_path: &PathBuf, 238 | day_entries: &Vec, 239 | ) -> Result<(), Box> { 240 | info!("Generating site index JSON"); 241 | 242 | let file_path = PathBuf::from(&build_path) 243 | .join("index") 244 | .with_extension("json"); 245 | 246 | let output = serde_json::to_string_pretty(&day_entries)?; 247 | 248 | let file_parent_path = file_path.parent().ok_or("no parent path")?; 249 | fs::create_dir_all(file_parent_path)?; 250 | 251 | let mut file = fs::File::create(file_path)?; 252 | file.write_all(output.as_bytes())?; 253 | 254 | Ok(()) 255 | } 256 | -------------------------------------------------------------------------------- /src/templates.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use serde_json::value::{to_value, Value}; 3 | use std::collections::HashMap; 4 | use std::error::Error; 5 | use std::fs; 6 | use std::io::prelude::*; 7 | use std::path::PathBuf; 8 | use tera::Tera; 9 | use url::Url; 10 | 11 | use crate::config; 12 | use crate::themes::templates_source; 13 | 14 | pub mod contexts; 15 | 16 | pub fn init() -> Result> { 17 | let config = config::config()?; 18 | 19 | let mut tera: Tera; 20 | let templates_path = config.templates_path(); 21 | if templates_path.is_dir() { 22 | debug!("Using templates from {:?}", templates_path); 23 | let templates_glob = templates_path.join("**/*.html"); 24 | tera = Tera::new( 25 | templates_glob 26 | .to_str() 27 | .ok_or("failed to construct templates glob")?, 28 | )?; 29 | } else { 30 | debug!("Using embedded templates"); 31 | tera = Tera::default(); 32 | let templates = templates_source(&config.theme); 33 | debug!("TEMPLATES {:?} {:?}", templates, &config); 34 | tera.add_raw_templates(templates)?; 35 | } 36 | 37 | tera.register_filter("sha256", filter_sha256); 38 | tera.register_filter("urlpath", filter_urlpath); 39 | 40 | Ok(tera) 41 | } 42 | 43 | /// Produce the sha256 hash of a string 44 | pub fn filter_sha256(value: &Value, _: &HashMap) -> tera::Result { 45 | let s = try_get_value!("filter_sha256", "value", String, value); 46 | Ok(to_value(sha256::digest(s)).unwrap()) 47 | } 48 | 49 | /// Strip a URL down to just its path 50 | pub fn filter_urlpath(value: &Value, _: &HashMap) -> tera::Result { 51 | let s = try_get_value!("filter_sha256", "value", String, value); 52 | // todo: this is pretty ugly: 53 | let url = Url::parse("http://example.com") 54 | .unwrap() 55 | .join(s.as_str()) 56 | .unwrap(); 57 | Ok(to_value(url.path()).unwrap()) 58 | } 59 | 60 | pub fn render_to_file( 61 | tera: &Tera, 62 | file_path: &PathBuf, 63 | template_name: &str, 64 | context: impl Serialize, 65 | ) -> Result<(), Box> { 66 | let file_parent_path = file_path.parent().ok_or("no parent path")?; 67 | fs::create_dir_all(file_parent_path)?; 68 | let context = tera::Context::from_serialize(context)?; 69 | let output = tera.render(template_name, &context)?; 70 | let mut file = fs::File::create(file_path)?; 71 | file.write_all(output.as_bytes())?; 72 | debug!("Wrote {} to {:?}", template_name, file_path); 73 | Ok(()) 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | /* TODO: get this test passing on windows-x86_64? 79 | use super::*; 80 | use std::{error::Error, path::Path}; 81 | 82 | use crate::activitystreams::{Activity, Actor, IdOrObject}; 83 | 84 | const JSON_ACTIVITY_WITH_ATTACHMENT: &str = 85 | include_str!("./resources/test/activity-with-attachment.json"); 86 | 87 | const JSON_ACTOR: &str = include_str!("./resources/test/actor.json"); 88 | 89 | #[test] 90 | fn test_activity_template_with_attachment() -> Result<(), Box> { 91 | config::init(&Path::new("./resources/default_config.toml"))?; 92 | let tera = init()?; 93 | 94 | let mut activity: Activity = serde_json::from_str(JSON_ACTIVITY_WITH_ATTACHMENT)?; 95 | let actor: Actor = serde_json::from_str(JSON_ACTOR)?; 96 | activity.actor = IdOrObject::Object(actor); 97 | 98 | let mut context = tera::Context::new(); 99 | context.insert("site_root", "../.."); 100 | context.insert("activity", &activity); 101 | 102 | let rendered_source = tera.render("activity.html", &context)?; 103 | println!("RENDERED {rendered_source}"); 104 | 105 | Ok(()) 106 | } 107 | */ 108 | } 109 | -------------------------------------------------------------------------------- /src/templates/contexts.rs: -------------------------------------------------------------------------------- 1 | //! Structs associated with templates to define available variables. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::collections::hash_map::Entry::{Occupied, Vacant}; 5 | use std::collections::HashMap; 6 | use std::path::PathBuf; 7 | 8 | use crate::activitystreams; 9 | 10 | /// Base template context for the `index.html` template. 11 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 12 | pub struct IndexTemplateContext { 13 | /// Relative path to the site root from the current page 14 | pub site_root: String, 15 | /// Calendar of nested hashmaps, organizing [IndexDayContext]s by year, month, and day 16 | pub calendar: CalendarContext, 17 | } 18 | 19 | /// Base template context for the `day.html` template. 20 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 21 | pub struct DayTemplateContext { 22 | /// Relative path to the site root from the current page 23 | pub site_root: String, 24 | /// Context for the page's current day 25 | pub day: IndexDayContext, 26 | /// The set of activities posted on the current day 27 | pub activities: Vec, 28 | } 29 | 30 | /// Context for a day, including previous and next days for navigation purposes 31 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 32 | pub struct IndexDayContext { 33 | pub previous: Option, 34 | pub current: IndexDayEntry, 35 | pub next: Option, 36 | } 37 | 38 | /// Details on a single day 39 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 40 | pub struct IndexDayEntry { 41 | /// The date in yyyy/mm/dd format 42 | pub date: String, 43 | /// The file path to the day's HTML page 44 | pub path: PathBuf, 45 | /// A count of activities posted on this day 46 | pub count: usize, 47 | } 48 | 49 | /// Calendar of [IndexDayContext] structs, organized by into nested [HashMap]s 50 | /// indexed by year, month, and day. 51 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 52 | pub struct CalendarContext(HashMap>>); 53 | 54 | impl CalendarContext { 55 | pub fn new() -> Self { 56 | CalendarContext(HashMap::new()) 57 | } 58 | 59 | /// Insert an [IndexDayContext] into the [CalendarContext], using 60 | /// year / month / day keys based on the date property 61 | pub fn insert(&mut self, day_context: &IndexDayContext) { 62 | let calendar = &mut self.0; 63 | 64 | let parts = day_context 65 | .current 66 | .date 67 | .split('/') 68 | .take(3) 69 | .collect::>(); 70 | 71 | if let [year, month, day] = parts[..] { 72 | let year_map = match calendar.entry(year.to_string()) { 73 | Vacant(entry) => entry.insert(HashMap::new()), 74 | Occupied(entry) => entry.into_mut(), 75 | }; 76 | let month_map = match year_map.entry(month.to_string()) { 77 | Vacant(entry) => entry.insert(HashMap::new()), 78 | Occupied(entry) => entry.into_mut(), 79 | }; 80 | month_map.insert(day.to_string(), day_context.clone()); 81 | } 82 | // else: throw an error because the date format was bunk? 83 | } 84 | } 85 | impl Default for CalendarContext { 86 | fn default() -> Self { 87 | Self::new() 88 | } 89 | } 90 | impl From<&Vec> for CalendarContext { 91 | /// Produce a [CalendarContext] from a [`Vec`] 92 | fn from(value: &Vec) -> Self { 93 | let mut calendar = Self::new(); 94 | for entry in value { 95 | calendar.insert(entry); 96 | } 97 | calendar 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/themes.rs: -------------------------------------------------------------------------------- 1 | use rust_embed::RustEmbed; 2 | use std::error::Error; 3 | use std::fs; 4 | use std::io::prelude::*; 5 | use std::path::PathBuf; 6 | 7 | #[derive(RustEmbed)] 8 | #[folder = "src/resources/themes"] 9 | pub struct ThemeAsset; 10 | 11 | pub fn copy_embedded_themes(assets_output_path: &PathBuf) -> Result<(), Box> { 12 | for filename in ThemeAsset::iter() { 13 | let file = ThemeAsset::get(&filename).ok_or("no asset")?; 14 | let outpath = PathBuf::from(&assets_output_path).join(&filename.to_string()); 15 | 16 | let mut outfile = open_outfile_with_parent_dir(&outpath)?; 17 | outfile.write_all(file.data.as_ref())?; 18 | 19 | debug!("Wrote {} to {:?}", filename, outpath); 20 | } 21 | Ok(()) 22 | } 23 | 24 | pub fn copy_embedded_web_assets( 25 | theme_prefix: &str, 26 | assets_output_path: &PathBuf, 27 | ) -> Result<(), Box> { 28 | let prefix = PathBuf::from(&theme_prefix) 29 | .join("web") 30 | .to_string_lossy() 31 | .into_owned(); 32 | for filename in ThemeAsset::iter() { 33 | if !filename.to_string().starts_with(&prefix) { 34 | continue; 35 | } 36 | // FIXME: this is all pretty ugly - can be done better? 37 | let local_path = PathBuf::from(filename.to_string()) 38 | .strip_prefix(&prefix) 39 | .unwrap() 40 | .to_string_lossy() 41 | .into_owned(); 42 | let file = ThemeAsset::get(&filename).ok_or("no asset")?; 43 | let outpath = PathBuf::from(&assets_output_path).join(&local_path); 44 | 45 | let mut outfile = open_outfile_with_parent_dir(&outpath)?; 46 | outfile.write_all(file.data.as_ref())?; 47 | 48 | debug!("Wrote {} to {:?}", filename, outpath); 49 | } 50 | Ok(()) 51 | } 52 | 53 | pub fn templates_source(theme_prefix: &str) -> Vec<(String, String)> { 54 | let prefix = PathBuf::from(&theme_prefix) 55 | .join("templates") 56 | .to_string_lossy() 57 | .into_owned(); 58 | ThemeAsset::iter() 59 | .filter(|filename| filename.to_string().starts_with(&prefix)) 60 | .map(|filename| { 61 | // FIXME: this is all pretty ugly - can be done better? 62 | let local_path = PathBuf::from(filename.to_string()) 63 | .strip_prefix(&prefix) 64 | .unwrap() 65 | .to_string_lossy() 66 | .into_owned(); 67 | let file = std::str::from_utf8(ThemeAsset::get(&filename).unwrap().data.as_ref()) 68 | .unwrap() 69 | .to_owned(); 70 | (local_path, file) 71 | }) 72 | .collect::>() 73 | } 74 | 75 | pub fn open_outfile_with_parent_dir(outpath: &PathBuf) -> Result> { 76 | let outparent = outpath.parent().ok_or("no parent path")?; 77 | fs::create_dir_all(outparent)?; 78 | let outfile = fs::File::create(outpath)?; 79 | Ok(outfile) 80 | } 81 | --------------------------------------------------------------------------------