├── .dockerignore ├── .github ├── CODEOWNERS └── workflows │ ├── check.yml │ └── gh-pages.yml ├── contrib ├── hatsu.sysusers └── hatsu.service ├── src ├── commands │ └── mod.rs ├── utils │ ├── mod.rs │ └── styles.rs └── main.rs ├── crates ├── cron │ ├── README.md │ ├── src │ │ ├── jobs │ │ │ ├── mod.rs │ │ │ └── update │ │ │ │ ├── mod.rs │ │ │ │ ├── full.rs │ │ │ │ └── partial.rs │ │ ├── tasks │ │ │ └── mod.rs │ │ └── lib.rs │ └── Cargo.toml ├── api │ ├── src │ │ ├── lib.rs │ │ └── routes │ │ │ ├── generate_204.rs │ │ │ └── mod.rs │ └── Cargo.toml ├── frontend │ ├── src │ │ ├── partials │ │ │ └── mod.rs │ │ ├── lib.rs │ │ └── pages │ │ │ ├── mod.rs │ │ │ └── home.rs │ ├── assets │ │ └── custom.css │ └── Cargo.toml ├── utils │ ├── src │ │ ├── version.rs │ │ ├── lib.rs │ │ ├── date │ │ │ └── mod.rs │ │ ├── markdown.rs │ │ ├── codename.rs │ │ ├── graceful_shutdown.rs │ │ ├── url │ │ │ └── mod.rs │ │ ├── error.rs │ │ └── data.rs │ └── Cargo.toml ├── api_admin │ ├── src │ │ ├── lib.rs │ │ ├── entities │ │ │ ├── mod.rs │ │ │ ├── block_url_or_acct.rs │ │ │ └── create_remove_account.rs │ │ └── routes │ │ │ ├── unblock_url.rs │ │ │ ├── create_account.rs │ │ │ ├── block_url.rs │ │ │ └── mod.rs │ ├── README.md │ └── Cargo.toml ├── api_mastodon │ ├── src │ │ ├── lib.rs │ │ ├── entities │ │ │ ├── mod.rs │ │ │ ├── custom_emoji.rs │ │ │ ├── context.rs │ │ │ ├── status.rs │ │ │ └── account.rs │ │ └── routes │ │ │ ├── instance │ │ │ ├── mod.rs │ │ │ ├── v2.rs │ │ │ └── v1.rs │ │ │ ├── statuses │ │ │ ├── mod.rs │ │ │ ├── status_context.rs │ │ │ ├── status_favourited_by.rs │ │ │ └── status_reblogged_by.rs │ │ │ └── mod.rs │ ├── README.md │ └── Cargo.toml ├── well_known │ ├── src │ │ ├── lib.rs │ │ ├── entities │ │ │ ├── mod.rs │ │ │ ├── nodeinfo.rs │ │ │ ├── host_meta.rs │ │ │ └── webfinger.rs │ │ └── routes │ │ │ ├── nodeinfo.rs │ │ │ ├── mod.rs │ │ │ ├── webfinger.rs │ │ │ └── host_meta.rs │ └── Cargo.toml ├── apub │ ├── src │ │ ├── objects │ │ │ └── mod.rs │ │ ├── utils │ │ │ ├── mod.rs │ │ │ ├── generate_map.rs │ │ │ └── verify_blocked.rs │ │ ├── actors │ │ │ ├── mod.rs │ │ │ ├── user_image.rs │ │ │ └── user_attachment.rs │ │ ├── activities │ │ │ ├── db_activity_impl.rs │ │ │ ├── following │ │ │ │ ├── mod.rs │ │ │ │ ├── db_received_follow.rs │ │ │ │ ├── db_received_follow_impl.rs │ │ │ │ └── undo_follow.rs │ │ │ ├── mod.rs │ │ │ ├── db_activity.rs │ │ │ ├── like_or_announce │ │ │ │ ├── db_received_like.rs │ │ │ │ ├── db_received_announce.rs │ │ │ │ ├── db_received_like_impl.rs │ │ │ │ ├── db_received_announce_impl.rs │ │ │ │ ├── mod.rs │ │ │ │ └── undo_like_or_announce.rs │ │ │ ├── activity_lists.rs │ │ │ └── create_or_update │ │ │ │ └── mod.rs │ │ ├── links │ │ │ ├── mention.rs │ │ │ ├── mod.rs │ │ │ ├── hashtag.rs │ │ │ └── emoji.rs │ │ ├── lib.rs │ │ └── collections │ │ │ ├── mod.rs │ │ │ ├── collection.rs │ │ │ └── collection_page.rs │ ├── README.md │ ├── tests │ │ ├── actors.rs │ │ ├── activities.rs │ │ ├── collections_local.rs │ │ └── objects.rs │ ├── assets │ │ ├── mastodon │ │ │ └── activities │ │ │ │ ├── like_page.json │ │ │ │ ├── follow.json │ │ │ │ ├── undo_like_page.json │ │ │ │ └── undo_follow.json │ │ ├── akkoma │ │ │ └── objects │ │ │ │ └── note.json │ │ └── gotosocial │ │ │ └── objects │ │ │ └── note_without_tag.json │ └── Cargo.toml ├── backend │ ├── assets │ │ ├── favicon.ico │ │ └── favicon.svg │ ├── src │ │ ├── openapi.rs │ │ ├── favicon.rs │ │ ├── routes.rs │ │ └── lib.rs │ └── Cargo.toml ├── nodeinfo │ ├── src │ │ ├── lib.rs │ │ ├── routes.rs │ │ └── handler.rs │ └── Cargo.toml ├── db_schema │ ├── README.md │ ├── src │ │ ├── lib.rs │ │ ├── prelude.rs │ │ ├── blocked_url.rs │ │ ├── activity.rs │ │ ├── received_follow.rs │ │ ├── received_like.rs │ │ ├── received_announce.rs │ │ └── user_feed_item.rs │ └── Cargo.toml ├── db_migration │ ├── src │ │ ├── main.rs │ │ ├── m20241028_000001_user_language.rs │ │ ├── m20240926_000001_blocked_url.rs │ │ ├── m20240501_000001_received_like.rs │ │ ├── m20240501_000002_received_announce.rs │ │ ├── m20240131_000005_received_follow.rs │ │ ├── lib.rs │ │ ├── m20240131_000004_activity.rs │ │ ├── m20240131_000003_post.rs │ │ ├── m20240131_000002_user_feed_item.rs │ │ └── m20240515_000001_user_feed_hatsu_extension.rs │ ├── Cargo.toml │ └── README.md ├── feed │ ├── tests │ │ ├── validate_rss_feed.rs │ │ └── validate_json_feed.rs │ ├── src │ │ ├── lib.rs │ │ ├── user_feed_item_hatsu.rs │ │ └── user_feed_hatsu.rs │ └── Cargo.toml ├── api_apub │ ├── src │ │ ├── activities │ │ │ ├── mod.rs │ │ │ └── activity.rs │ │ ├── posts │ │ │ ├── mod.rs │ │ │ ├── notice.rs │ │ │ └── post.rs │ │ ├── users │ │ │ ├── user_inbox.rs │ │ │ ├── mod.rs │ │ │ └── user_following.rs │ │ └── lib.rs │ └── Cargo.toml └── tracing │ ├── Cargo.toml │ └── src │ └── lib.rs ├── .gitattributes ├── .vscode ├── settings.json └── extensions.json ├── docs ├── src │ ├── admins │ │ ├── install.md │ │ ├── install-binary.md │ │ ├── create-account.md │ │ ├── install-docker.md │ │ ├── install-nix.md │ │ └── block-instances-or-actors.md │ ├── users │ │ ├── backfeed-based-on-webmention.md │ │ ├── backfeed.md │ │ ├── redirecting-with-fep-612d.md │ │ ├── getting-started.md │ │ ├── backfeed-based-on-kkna.md │ │ ├── redirecting-with-redirects-file.md │ │ ├── redirecting.md │ │ ├── redirecting-with-platform-specific-config.md │ │ ├── redirecting-with-static-files-and-markup.md │ │ └── feed.md │ ├── developers │ │ ├── development-devcontainer.md │ │ ├── prepare.md │ │ ├── development-local.md │ │ └── development-docker.md │ ├── others │ │ ├── packaging-status.md │ │ ├── json-feed-extension.md │ │ ├── compatibility-chart.md │ │ └── federation.md │ ├── SUMMARY.md │ └── intro.md ├── theme │ ├── head.hbs │ ├── favicon.svg │ └── fonts │ │ └── fonts.css ├── README.md └── book.toml ├── examples ├── caddy │ └── Caddyfile └── docker-compose │ ├── docker-compose.yml │ └── litestream.docker-compose.yml ├── .env.example ├── .envrc ├── rust-toolchain.toml ├── docker-compose.yaml ├── .cargo └── config.toml ├── rustfmt.toml ├── Dockerfile ├── .gitignore ├── cspell.config.yaml ├── .devcontainer └── devcontainer.json ├── FEDERATION.md └── flake.nix /.dockerignore: -------------------------------------------------------------------------------- 1 | docs 2 | target 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @kwaa 2 | -------------------------------------------------------------------------------- /contrib/hatsu.sysusers: -------------------------------------------------------------------------------- 1 | u hatsu - "Hatsu user" 2 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | mod run; 2 | 3 | pub use run::run; 4 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod styles; 2 | 3 | pub use styles::styles; 4 | -------------------------------------------------------------------------------- /crates/cron/README.md: -------------------------------------------------------------------------------- 1 | # hatsu_cron 2 | 3 | A crate to check feed updates. 4 | -------------------------------------------------------------------------------- /crates/api/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod routes; 2 | 3 | pub use routes::{TAG, routes}; 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /crates/frontend/src/partials/mod.rs: -------------------------------------------------------------------------------- 1 | mod layout; 2 | 3 | pub use layout::layout; 4 | -------------------------------------------------------------------------------- /crates/utils/src/version.rs: -------------------------------------------------------------------------------- 1 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 2 | -------------------------------------------------------------------------------- /crates/frontend/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod pages; 2 | pub mod partials; 3 | 4 | pub use pages::routes; 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 4, 3 | "editor.detectIndentation": false, 4 | } 5 | -------------------------------------------------------------------------------- /crates/cron/src/jobs/mod.rs: -------------------------------------------------------------------------------- 1 | mod update; 2 | 3 | pub use update::{full_update, partial_update}; 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "rust-lang.rust-analyzer" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /crates/api_admin/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod entities; 2 | pub mod routes; 3 | 4 | pub use routes::{TAG, routes}; 5 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod entities; 2 | pub mod routes; 3 | 4 | pub use routes::{TAG, routes}; 5 | -------------------------------------------------------------------------------- /crates/well_known/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod entities; 2 | pub mod routes; 3 | 4 | pub use routes::{TAG, routes}; 5 | -------------------------------------------------------------------------------- /crates/apub/src/objects/mod.rs: -------------------------------------------------------------------------------- 1 | mod db_post; 2 | mod note; 3 | 4 | pub use db_post::ApubPost; 5 | pub use note::Note; 6 | -------------------------------------------------------------------------------- /crates/backend/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/importantimport/hatsu/HEAD/crates/backend/assets/favicon.ico -------------------------------------------------------------------------------- /crates/nodeinfo/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod handler; 2 | pub mod routes; 3 | pub mod schema; 4 | 5 | pub use routes::{TAG, routes}; 6 | -------------------------------------------------------------------------------- /crates/cron/src/jobs/update/mod.rs: -------------------------------------------------------------------------------- 1 | mod full; 2 | mod partial; 3 | 4 | pub use full::full_update; 5 | pub use partial::partial_update; 6 | -------------------------------------------------------------------------------- /docs/src/admins/install.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | ## [Docker Installation](./install-docker.md) 4 | 5 | ## [Binary Installation](./install-binary.md) 6 | -------------------------------------------------------------------------------- /docs/src/users/backfeed-based-on-webmention.md: -------------------------------------------------------------------------------- 1 | # Backfeed based on Webmention 2 | 3 | > This section is not yet implemented in Hatsu. 4 | 5 | TODO 6 | -------------------------------------------------------------------------------- /crates/apub/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod generate_map; 2 | mod verify_blocked; 3 | 4 | pub use generate_map::generate_map; 5 | pub use verify_blocked::verify_blocked; 6 | -------------------------------------------------------------------------------- /crates/cron/src/tasks/mod.rs: -------------------------------------------------------------------------------- 1 | mod check_feed_item; 2 | mod update; 3 | 4 | pub use check_feed_item::check_feed_item; 5 | pub use update::{full_update, partial_update}; 6 | -------------------------------------------------------------------------------- /crates/frontend/src/pages/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::{Router, routing::get}; 2 | 3 | mod home; 4 | 5 | pub fn routes() -> Router { 6 | Router::new().route("/", get(home::home)) 7 | } 8 | -------------------------------------------------------------------------------- /crates/backend/src/openapi.rs: -------------------------------------------------------------------------------- 1 | use utoipa::OpenApi; 2 | 3 | #[derive(OpenApi)] 4 | #[openapi(info(title = "Hatsu"), components(schemas(hatsu_utils::AppError)))] 5 | pub struct ApiDoc; 6 | -------------------------------------------------------------------------------- /examples/caddy/Caddyfile: -------------------------------------------------------------------------------- 1 | # or just `caddy reverse-proxy --from hatsu.example.com --to :3939` 2 | :443, hatsu.example.com { 3 | encode zstd gzip 4 | reverse_proxy :3939 5 | } 6 | -------------------------------------------------------------------------------- /crates/apub/README.md: -------------------------------------------------------------------------------- 1 | # hatsu_apub 2 | 3 | Hatsu's code for the ActivityPub implementation. 4 | 5 | Ref: [LemmyNet/lemmy/crates/apub](https://github.com/LemmyNet/lemmy/tree/main/crates/apub) 6 | -------------------------------------------------------------------------------- /crates/db_schema/README.md: -------------------------------------------------------------------------------- 1 | # hatsu_db_schema 2 | 3 | Generated via `just db schema`. [(cli options)](https://www.sea-ql.org/SeaORM/docs/generate-entity/sea-orm-cli/#generating-entity-files) 4 | -------------------------------------------------------------------------------- /crates/db_migration/src/main.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | // #[async_std::main] 4 | #[tokio::main] 5 | async fn main() { 6 | cli::run_cli(hatsu_db_migration::Migrator).await; 7 | } 8 | -------------------------------------------------------------------------------- /docs/src/developers/development-devcontainer.md: -------------------------------------------------------------------------------- 1 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/importantimport/hatsu?quickstart=1&machine=standardLinux32gb) 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | HATSU_LOG = "info,tokio::net=debug,sqlx::query=warn" 2 | HATSU_DATABASE_URL = "sqlite://hatsu.sqlite3" 3 | HATSU_DOMAIN = "hatsu.local" 4 | HATSU_LISTEN_HOST = "0.0.0.0" 5 | HATSU_LISTEN_PORT = "3939" 6 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then 2 | source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w=" 3 | fi 4 | 5 | use flake 6 | -------------------------------------------------------------------------------- /crates/api_admin/src/entities/mod.rs: -------------------------------------------------------------------------------- 1 | mod block_url_or_acct; 2 | mod create_remove_account; 3 | 4 | pub use block_url_or_acct::{BlockUrlQuery, BlockUrlResult}; 5 | pub use create_remove_account::{CreateRemoveAccountQuery, CreateRemoveAccountResult}; 6 | -------------------------------------------------------------------------------- /crates/well_known/src/entities/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod host_meta; 2 | pub mod nodeinfo; 3 | pub mod webfinger; 4 | 5 | pub use host_meta::{HostMeta, HostMetaLink}; 6 | pub use nodeinfo::{NodeInfoWellKnown, NodeInfoWellKnownLink}; 7 | pub use webfinger::{WebfingerSchema, WebfingerSchemaLink}; 8 | -------------------------------------------------------------------------------- /crates/api_admin/README.md: -------------------------------------------------------------------------------- 1 | # hatsu_api_admin 2 | 3 | This crate is Hatsu's Admin API `(/api/v0/admin/)`. 4 | 5 | For more information, you can run Hatsu and then view the [`http://localhost:3939/swagger-ui/`](http://localhost:3939/swagger-ui/) and jump to the `hatsu::admin` section. 6 | -------------------------------------------------------------------------------- /crates/apub/tests/actors.rs: -------------------------------------------------------------------------------- 1 | use hatsu_apub::{actors::User, tests::test_asset}; 2 | use hatsu_utils::AppError; 3 | 4 | #[test] 5 | fn test_parse_actors() -> Result<(), AppError> { 6 | test_asset::("assets/gotosocial/actors/kwa_hyp3r.link.json")?; 7 | 8 | Ok(()) 9 | } 10 | -------------------------------------------------------------------------------- /crates/api_mastodon/README.md: -------------------------------------------------------------------------------- 1 | # hatsu_api_mastodon 2 | 3 | This crate is Hatsu's Mastodon compatible API `(/api/v1/)`. 4 | 5 | For more information, you can run Hatsu and then view the [`http://localhost:3939/swagger-ui/`](http://localhost:3939/swagger-ui/) and jump to the `mastodon` section. 6 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | # https://rust-lang.github.io/rustup/overrides.html#the-toolchain-file 2 | 3 | [toolchain] 4 | channel = "nightly" 5 | # https://rust-lang.github.io/rustup/concepts/components.html 6 | components = ["rustc", "cargo", "rustfmt", "rust-analyzer", "clippy"] 7 | profile = "minimal" 8 | -------------------------------------------------------------------------------- /crates/apub/src/actors/mod.rs: -------------------------------------------------------------------------------- 1 | mod db_user; 2 | mod db_user_impl; 3 | mod user; 4 | mod user_attachment; 5 | mod user_image; 6 | 7 | pub use db_user::ApubUser; 8 | pub use user::{PublicKeySchema, User, UserType}; 9 | pub use user_attachment::UserAttachment; 10 | pub use user_image::UserImage; 11 | -------------------------------------------------------------------------------- /crates/apub/assets/mastodon/activities/like_page.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "id": "https://mastodon.madrid/users/felix#likes/212340", 4 | "type": "Like", 5 | "actor": "https://mastodon.madrid/users/felix", 6 | "object": "https://ds9.lemmy.ml/post/147" 7 | } 8 | -------------------------------------------------------------------------------- /docs/src/users/backfeed.md: -------------------------------------------------------------------------------- 1 | # Backfeed 2 | 3 | Display mentions received by Hatsu on your site. 4 | 5 | ## [based on KKna](./backfeed-based-on-kkna.md) 6 | 7 | ## [based on Mastodon Comments](./backfeed-based-on-mastodon-comments.md) 8 | 9 | ## [based on Webmention (TODO)](./backfeed-based-on-webmention.md) 10 | -------------------------------------------------------------------------------- /crates/db_schema/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1 2 | 3 | pub mod prelude; 4 | 5 | pub mod activity; 6 | pub mod blocked_url; 7 | pub mod post; 8 | pub mod received_announce; 9 | pub mod received_follow; 10 | pub mod received_like; 11 | pub mod user; 12 | pub mod user_feed_item; 13 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/entities/mod.rs: -------------------------------------------------------------------------------- 1 | mod account; 2 | mod context; 3 | mod custom_emoji; 4 | mod instance; 5 | mod status; 6 | 7 | pub use account::Account; 8 | pub use context::Context; 9 | pub use custom_emoji::CustomEmoji; 10 | pub use instance::{Instance, InstanceContact, InstanceV1}; 11 | pub use status::Status; 12 | -------------------------------------------------------------------------------- /crates/apub/assets/mastodon/activities/follow.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "id": "https://masto.asonix.dog/1ea87517-63c5-4118-8831-460ee641b2cf", 4 | "type": "Follow", 5 | "actor": "https://masto.asonix.dog/users/asonix", 6 | "object": "https://ds9.lemmy.ml/c/testcom" 7 | } 8 | -------------------------------------------------------------------------------- /crates/apub/src/activities/db_activity_impl.rs: -------------------------------------------------------------------------------- 1 | use hatsu_utils::AppError; 2 | use serde_json::Value; 3 | 4 | use super::ApubActivity; 5 | 6 | impl ApubActivity { 7 | // 转换为 JSON 8 | pub fn into_json(self) -> Result { 9 | Ok(serde_json::from_value(self.activity.clone())?) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /crates/feed/tests/validate_rss_feed.rs: -------------------------------------------------------------------------------- 1 | use hatsu_feed::UserFeedTopLevel; 2 | use hatsu_utils::AppError; 3 | use url::Url; 4 | 5 | #[tokio::test] 6 | async fn validate_rss_feed() -> Result<(), AppError> { 7 | UserFeedTopLevel::parse_xml_feed(Url::parse("https://lume.land/blog/feed.xml")?).await?; 8 | 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /crates/apub/src/activities/following/mod.rs: -------------------------------------------------------------------------------- 1 | mod db_received_follow; 2 | mod db_received_follow_impl; 3 | 4 | mod accept_follow; 5 | mod follow; 6 | mod undo_follow; 7 | 8 | pub use accept_follow::AcceptFollow; 9 | pub use db_received_follow::ApubReceivedFollow; 10 | pub use follow::Follow; 11 | pub use undo_follow::UndoFollow; 12 | -------------------------------------------------------------------------------- /crates/feed/tests/validate_json_feed.rs: -------------------------------------------------------------------------------- 1 | use hatsu_feed::UserFeedTopLevel; 2 | use hatsu_utils::AppError; 3 | use url::Url; 4 | 5 | #[tokio::test] 6 | async fn validate_json_feed() -> Result<(), AppError> { 7 | UserFeedTopLevel::parse_json_feed(Url::parse("https://lume.land/blog/feed.json")?).await?; 8 | 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | hatsu: 5 | build: 6 | args: 7 | PROFILE: debug 8 | context: . 9 | dockerfile: Dockerfile 10 | container_name: hatsu 11 | ports: 12 | - 3939:3939 13 | volumes: 14 | - ./.env:/app/.env 15 | - ./hatsu.sqlite3:/app/hatsu.sqlite3 16 | -------------------------------------------------------------------------------- /docs/theme/head.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /crates/utils/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod codename; 2 | mod data; 3 | pub mod date; 4 | mod error; 5 | mod graceful_shutdown; 6 | pub mod markdown; 7 | pub mod url; 8 | mod version; 9 | 10 | pub use codename::codename; 11 | pub use data::{AppData, AppEnv}; 12 | pub use error::AppError; 13 | pub use graceful_shutdown::shutdown_signal; 14 | pub use version::VERSION; 15 | -------------------------------------------------------------------------------- /crates/utils/src/date/mod.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, SecondsFormat, Utc}; 2 | 3 | use crate::AppError; 4 | 5 | #[must_use] 6 | pub fn now() -> String { 7 | Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) 8 | } 9 | 10 | pub fn parse(date: &str) -> Result, AppError> { 11 | Ok(DateTime::parse_from_rfc3339(date)?.with_timezone(&Utc)) 12 | } 13 | -------------------------------------------------------------------------------- /contrib/hatsu.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Hatsu - Self-hosted and fully-automated ActivityPub bridge for static sites 3 | Documentation=https://hatsu.cli.rs 4 | Wants=network-online.target 5 | After=network.target 6 | 7 | [Service] 8 | Type=simple 9 | User=hatsu 10 | ExecStart=/usr/bin/hatsu 11 | Restart=on-failure 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /crates/api_admin/src/entities/block_url_or_acct.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use url::Url; 3 | use utoipa::{IntoParams, ToSchema}; 4 | 5 | #[derive(Deserialize, IntoParams)] 6 | pub struct BlockUrlQuery { 7 | pub url: Url, 8 | } 9 | 10 | #[derive(Serialize, ToSchema)] 11 | pub struct BlockUrlResult { 12 | pub url: Url, 13 | pub message: String, 14 | } 15 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/routes/instance/mod.rs: -------------------------------------------------------------------------------- 1 | use utoipa::OpenApi; 2 | use utoipa_axum::{router::OpenApiRouter, routes}; 3 | 4 | use crate::routes::MastodonApi; 5 | 6 | pub mod v1; 7 | pub mod v2; 8 | 9 | pub fn routes() -> OpenApiRouter { 10 | OpenApiRouter::with_openapi(MastodonApi::openapi()) 11 | .routes(routes!(v1::v1)) 12 | .routes(routes!(v2::v2)) 13 | } 14 | -------------------------------------------------------------------------------- /crates/api_admin/src/entities/create_remove_account.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use utoipa::{IntoParams, ToSchema}; 3 | 4 | #[derive(Deserialize, IntoParams)] 5 | pub struct CreateRemoveAccountQuery { 6 | pub name: String, 7 | } 8 | 9 | #[derive(Serialize, ToSchema)] 10 | pub struct CreateRemoveAccountResult { 11 | pub name: String, 12 | pub message: String, 13 | } 14 | -------------------------------------------------------------------------------- /crates/feed/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod user_feed; 2 | mod user_feed_hatsu; 3 | mod user_feed_item; 4 | mod user_feed_item_hatsu; 5 | mod user_feed_top_level; 6 | 7 | pub use user_feed::UserFeed; 8 | pub use user_feed_hatsu::UserFeedHatsu; 9 | pub use user_feed_item::{UserFeedItem, WrappedUserFeedItem}; 10 | pub use user_feed_item_hatsu::UserFeedItemHatsu; 11 | pub use user_feed_top_level::UserFeedTopLevel; 12 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [rust] 2 | lld = true 3 | 4 | [build] 5 | rustflags = [ 6 | # UUID Unstable for Uuid::now_v7() 7 | # https://docs.rs/uuid/1.4.0/uuid/index.html#unstable-features 8 | "--cfg", 9 | "uuid_unstable", 10 | # Tokio Unstable for `console` feature 11 | # https://github.com/tokio-rs/console#instrumenting-your-program 12 | "--cfg", 13 | "tokio_unstable", 14 | ] 15 | -------------------------------------------------------------------------------- /crates/api_apub/src/activities/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::routing::get; 2 | use utoipa::OpenApi; 3 | use utoipa_axum::{router::OpenApiRouter, routes}; 4 | 5 | use crate::ApubApi; 6 | 7 | mod activity; 8 | 9 | pub fn routes() -> OpenApiRouter { 10 | OpenApiRouter::with_openapi(ApubApi::openapi()) 11 | .routes(routes!(activity::activity)) 12 | .route("/a/:activity", get(activity::redirect)) 13 | } 14 | -------------------------------------------------------------------------------- /crates/apub/src/utils/generate_map.rs: -------------------------------------------------------------------------------- 1 | use serde_json::{Value, json}; 2 | 3 | pub fn generate_map(content: &str, language: Option) -> Option { 4 | match language { 5 | Some(language) if language[.. 2].chars().all(|char| char.is_ascii_lowercase()) => 6 | Some(json!({ 7 | language[..2]: content 8 | })), 9 | _ => None, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /crates/utils/src/markdown.rs: -------------------------------------------------------------------------------- 1 | /// GitHub Flavored Markdown to HTML 2 | /// this function never errors with normal markdown because markdown does not have syntax errors. 3 | #[must_use] 4 | pub fn markdown_to_html(value: &str) -> String { 5 | match markdown::to_html_with_options(value, &markdown::Options::gfm()) { 6 | Ok(result) => result, 7 | Err(result) => result.to_string(), 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /crates/api/src/routes/generate_204.rs: -------------------------------------------------------------------------------- 1 | use axum::{debug_handler, http::StatusCode}; 2 | 3 | use crate::TAG; 4 | 5 | /// Generate 204 Response 6 | #[utoipa::path( 7 | get, 8 | tag = TAG, 9 | path = "/api/v0/generate_204", 10 | responses( 11 | (status = NO_CONTENT, description = "NO_CONTENT"), 12 | ) 13 | )] 14 | #[debug_handler] 15 | pub async fn generate_204() -> StatusCode { 16 | StatusCode::NO_CONTENT 17 | } 18 | -------------------------------------------------------------------------------- /crates/api/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | use utoipa::OpenApi; 2 | use utoipa_axum::{router::OpenApiRouter, routes}; 3 | 4 | mod generate_204; 5 | 6 | pub const TAG: &str = "hatsu"; 7 | 8 | #[derive(OpenApi)] 9 | #[openapi(tags((name = TAG, description = "Hatsu API (/api/v0/)")))] 10 | pub struct HatsuApi; 11 | 12 | pub fn routes() -> OpenApiRouter { 13 | OpenApiRouter::with_openapi(HatsuApi::openapi()).routes(routes!(generate_204::generate_204)) 14 | } 15 | -------------------------------------------------------------------------------- /crates/db_schema/src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1 2 | 3 | pub use super::{ 4 | activity::Entity as Activity, 5 | blocked_url::Entity as BlockedUrl, 6 | post::Entity as Post, 7 | received_announce::Entity as ReceivedAnnounce, 8 | received_follow::Entity as ReceivedFollow, 9 | received_like::Entity as ReceivedLike, 10 | user::Entity as User, 11 | user_feed_item::Entity as UserFeedItem, 12 | }; 13 | -------------------------------------------------------------------------------- /crates/api_apub/src/posts/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::routing::get; 2 | use utoipa::OpenApi; 3 | use utoipa_axum::router::OpenApiRouter; 4 | 5 | use crate::ApubApi; 6 | 7 | pub mod notice; 8 | pub mod post; 9 | 10 | pub fn routes() -> OpenApiRouter { 11 | OpenApiRouter::with_openapi(ApubApi::openapi()) 12 | .route("/notice/*notice", get(notice::notice)) 13 | .route("/posts/*post", get(post::post)) 14 | .route("/p/*post", get(post::redirect)) 15 | } 16 | -------------------------------------------------------------------------------- /crates/apub/src/links/mention.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::link::MentionType; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | use utoipa::ToSchema; 5 | 6 | /// 7 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema, Eq, PartialEq)] 8 | pub struct Mention { 9 | #[schema(value_type = String)] 10 | #[serde(rename = "type")] 11 | pub kind: MentionType, 12 | pub href: Url, 13 | pub name: String, 14 | } 15 | -------------------------------------------------------------------------------- /docs/src/users/redirecting-with-fep-612d.md: -------------------------------------------------------------------------------- 1 | # Redirecting with [FEP-612d](https://codeberg.org/fediverse/fep/src/branch/main/fep/612d/fep-612d.md) 2 | 3 | There doesn't seem to be software currently implements FEP-612d, but that won't stop us from setting it up. 4 | 5 | just add the following TXT record: 6 | 7 | > Replace `hatsu.local` with your Hatsu instance and `example.com` with your site. 8 | 9 | ``` 10 | _apobjid.example.com https://hatsu.local/users/example.com 11 | ``` 12 | 13 | That's it! 14 | -------------------------------------------------------------------------------- /crates/frontend/assets/custom.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: var(--md-sys-color-surface); 3 | color: var(--md-sys-color-on-surface); 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | } 8 | 9 | header, 10 | main, 11 | footer { 12 | width: 100%; 13 | max-width: 48rem; 14 | } 15 | 16 | a { 17 | color: var(--md-sys-color-primary, #6750A4); 18 | text-decoration: none; 19 | } 20 | 21 | a:focus-visible, 22 | a:hover { 23 | text-decoration: underline; 24 | } -------------------------------------------------------------------------------- /crates/apub/assets/mastodon/activities/undo_like_page.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "id": "https://mastodon.madrid/users/felix#likes/212341/undo", 4 | "type": "Undo", 5 | "actor": "https://mastodon.madrid/users/felix", 6 | "object": { 7 | "id": "https://mastodon.madrid/users/felix#likes/212341", 8 | "type": "Like", 9 | "actor": "https://mastodon.madrid/users/felix", 10 | "object": "https://ds9.lemmy.ml/post/147" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Hatsu Documentation 2 | 3 | ## Deployment 4 | 5 | This documentation is deployed at [hatsu.cli.rs](https://hatsu.cli.rs). 6 | 7 | ## Development 8 | 9 | 10 | 11 | ```bash 12 | cd docs 13 | cargo install mdbook 14 | # serve 15 | mdbook serve 16 | # build 17 | mdbook build 18 | ``` 19 | 20 | ### TODO 21 | 22 | - [ ] User Guide 23 | - [ ] Developer Guide 24 | - [ ] i18n via [mdbook-i18n-helpers](https://github.com/google/mdbook-i18n-helpers) 25 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | title = "Hatsu Documentation" 3 | authors = ["藍+85CD"] 4 | language = "en" 5 | multilingual = false 6 | src = "src" 7 | 8 | [rust] 9 | edition = "2021" 10 | 11 | [output.html] 12 | git-repository-url = "https://github.com/importantimport/hatsu" 13 | edit-url-template = "https://github.com/importantimport/hatsu/edit/main/docs/{path}" 14 | # https://github.com/catppuccin/mdBook#manual 15 | additional-css = ["./theme/catppuccin.css"] 16 | default-theme = "latte" 17 | preferred-dark-theme = "frappe" 18 | -------------------------------------------------------------------------------- /crates/db_schema/src/blocked_url.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "blocked_url")] 7 | pub struct Model { 8 | #[sea_orm(primary_key, auto_increment = false)] 9 | pub id: String, 10 | pub is_instance: bool, 11 | } 12 | 13 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 14 | pub enum Relation {} 15 | 16 | impl ActiveModelBehavior for ActiveModel {} 17 | -------------------------------------------------------------------------------- /crates/apub/assets/mastodon/activities/undo_follow.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "id": "https://masto.asonix.dog/users/asonix#follows/449/undo", 4 | "type": "Undo", 5 | "actor": "https://masto.asonix.dog/users/asonix", 6 | "object": { 7 | "id": "https://masto.asonix.dog/1ea87517-63c5-4118-8831-460ee641b2cf", 8 | "type": "Follow", 9 | "actor": "https://masto.asonix.dog/users/asonix", 10 | "object": "https://ds9.lemmy.ml/c/testcom" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /crates/db_migration/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_db_migration" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_db_migration" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | tokio = { workspace = true } 20 | sea-orm-migration = { workspace = true } 21 | -------------------------------------------------------------------------------- /crates/api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_api" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_api" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | axum = { workspace = true } 20 | utoipa = { workspace = true } 21 | utoipa-axum = { workspace = true } 22 | -------------------------------------------------------------------------------- /crates/apub/src/links/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_json::{Map, Value}; 3 | use utoipa::ToSchema; 4 | 5 | mod emoji; 6 | mod hashtag; 7 | mod mention; 8 | 9 | pub use emoji::{Emoji, EmojiIcon}; 10 | pub use hashtag::Hashtag; 11 | pub use mention::Mention; 12 | 13 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema, Eq, PartialEq)] 14 | #[serde(untagged)] 15 | pub enum Tag { 16 | Emoji(Emoji), 17 | Hashtag(Hashtag), 18 | Mention(Mention), 19 | Object(Map), // Do not use Value(Value), 20 | } 21 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/routes/statuses/mod.rs: -------------------------------------------------------------------------------- 1 | use utoipa::OpenApi; 2 | use utoipa_axum::{router::OpenApiRouter, routes}; 3 | 4 | use crate::routes::MastodonApi; 5 | 6 | mod status_context; 7 | mod status_favourited_by; 8 | mod status_reblogged_by; 9 | 10 | pub fn routes() -> OpenApiRouter { 11 | OpenApiRouter::with_openapi(MastodonApi::openapi()) 12 | .routes(routes!(status_context::status_context)) 13 | .routes(routes!(status_favourited_by::status_favourited_by)) 14 | .routes(routes!(status_reblogged_by::status_reblogged_by)) 15 | } 16 | -------------------------------------------------------------------------------- /crates/db_schema/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_db_schema" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_db_schema" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | sea-orm = { workspace = true } 20 | serde = { workspace = true } 21 | serde_json = { workspace = true } 22 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/rust-lang/rustfmt/blob/master/Configurations.md 2 | 3 | edition = "2021" 4 | empty_item_single_line = true 5 | group_imports = "StdExternalCrate" 6 | imports_granularity = "Crate" 7 | imports_layout = "HorizontalVertical" 8 | match_arm_blocks = false 9 | match_block_trailing_comma = true 10 | normalize_comments = true 11 | normalize_doc_attributes = true 12 | overflow_delimited_expr = true 13 | reorder_impl_items = true 14 | spaces_around_ranges = true 15 | unstable_features = true 16 | use_field_init_shorthand = true 17 | use_try_shorthand = true 18 | -------------------------------------------------------------------------------- /docs/theme/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/docker-compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | hatsu: 5 | container_name: hatsu 6 | image: ghcr.io/importantimport/hatsu:nightly 7 | restart: unless-stopped 8 | ports: 9 | - 3939:3939 10 | # env_file: 11 | # - .env 12 | environment: 13 | - HATSU_DATABASE_URL=sqlite://hatsu.sqlite3 14 | - HATSU_DOMAIN=hatsu.example.com 15 | - HATSU_LISTEN_HOST=0.0.0.0 16 | - HATSU_PRIMARY_ACCOUNT=blog.example.com 17 | volumes: 18 | # - ./.env:/app/.env 19 | - ./hatsu.sqlite3:/app/hatsu.sqlite3 20 | -------------------------------------------------------------------------------- /crates/apub/src/activities/mod.rs: -------------------------------------------------------------------------------- 1 | mod activity_lists; 2 | mod create_or_update; 3 | mod db_activity; 4 | mod db_activity_impl; 5 | mod following; 6 | mod like_or_announce; 7 | 8 | pub use activity_lists::UserInboxActivities; 9 | pub use create_or_update::{CreateOrUpdateNote, CreateOrUpdateType}; 10 | pub use db_activity::ApubActivity; 11 | pub use following::{AcceptFollow, ApubReceivedFollow, Follow, UndoFollow}; 12 | pub use like_or_announce::{ 13 | ApubReceivedAnnounce, 14 | ApubReceivedLike, 15 | LikeOrAnnounce, 16 | LikeOrAnnounceType, 17 | UndoLikeOrAnnounce, 18 | }; 19 | -------------------------------------------------------------------------------- /crates/backend/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bitnami/minideb:trixie 2 | 3 | ARG TARGETARCH 4 | 5 | WORKDIR /app 6 | 7 | COPY target_${TARGETARCH}/hatsu /app/hatsu 8 | 9 | RUN install_packages ca-certificates curl && \ 10 | # https://github.com/casey/just#pre-built-binaries 11 | curl -sSf https://just.systems/install.sh | bash -s -- --tag 1.23.0 --to /usr/local/bin && \ 12 | chmod +x /app/hatsu 13 | 14 | ENV HATSU_LISTEN_PORT=3939 15 | EXPOSE $HATSU_LISTEN_PORT 16 | 17 | HEALTHCHECK CMD [ "curl", "--fail", "http://localhost:${HATSU_LISTEN_PORT}/api/v0/generate_204" ] 18 | 19 | ENTRYPOINT [ "/app/hatsu" ] 20 | 21 | STOPSIGNAL SIGTERM 22 | -------------------------------------------------------------------------------- /docs/src/admins/install-binary.md: -------------------------------------------------------------------------------- 1 | # Binary Installation 2 | 3 | ## Releases 4 | 5 | You can download both stable and beta versions of Hatsu from the [Releases page](https://github.com/importantimport/hatsu/releases). 6 | 7 | ## Artifacts 8 | 9 | You can find the latest artifacts on the [Workflow runs page](https://github.com/importantimport/hatsu/actions/workflows/release.yml). 10 | 11 | GitHub has a document that tells you how to download artifact: [https://docs.github.com/en/actions/managing-workflow-runs/downloading-workflow-artifacts](https://docs.github.com/en/actions/managing-workflow-runs/downloading-workflow-artifacts) 12 | -------------------------------------------------------------------------------- /docs/theme/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter&display=swap"); 2 | @import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@500&display=swap"); 3 | 4 | html { 5 | /* https://open-props.style/#typography --font-sans */ 6 | font-family: "Inter", system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; 7 | } 8 | 9 | :root { 10 | /* https://open-props.style/#typography --font-mono */ 11 | --mono-font: "Fira Code", Dank Mono, Operator Mono, Inconsolata, Fira Mono, ui-monospace, SF Mono, Monaco, Droid Sans Mono, Source Code Pro, monospace; 12 | } 13 | -------------------------------------------------------------------------------- /crates/apub/src/activities/db_activity.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use hatsu_db_schema::activity::Model as DbActivity; 4 | 5 | #[derive(Clone, Debug, PartialEq, Eq)] 6 | pub struct ApubActivity(pub(crate) DbActivity); 7 | 8 | impl AsRef for ApubActivity { 9 | fn as_ref(&self) -> &DbActivity { 10 | &self.0 11 | } 12 | } 13 | 14 | impl Deref for ApubActivity { 15 | type Target = DbActivity; 16 | 17 | fn deref(&self) -> &Self::Target { 18 | &self.0 19 | } 20 | } 21 | 22 | impl From for ApubActivity { 23 | fn from(u: DbActivity) -> Self { 24 | Self(u) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/apub/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod activities; 2 | pub mod actors; 3 | pub mod collections; 4 | pub mod links; 5 | pub mod objects; 6 | mod utils; 7 | 8 | // #[cfg(test)] 9 | pub mod tests { 10 | use std::{fs::File, io::BufReader}; 11 | 12 | use activitypub_federation::protocol::context::WithContext; 13 | use hatsu_utils::AppError; 14 | use serde::de::DeserializeOwned; 15 | 16 | pub fn test_asset(path: &str) -> Result, AppError> { 17 | let asset = File::open(path)?; 18 | let reader = BufReader::new(asset); 19 | Ok(serde_json::from_reader(reader)?) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /crates/frontend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_frontend" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_frontend" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | activitypub_federation = { workspace = true } 20 | axum = { workspace = true } 21 | hatsu_utils = { workspace = true } 22 | maud = { workspace = true } 23 | serde_json = { workspace = true } 24 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/routes/instance/v2.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{Json, debug_handler}; 3 | use hatsu_utils::{AppData, AppError}; 4 | 5 | use crate::{TAG, entities::Instance}; 6 | 7 | /// View server information 8 | /// 9 | /// 10 | #[utoipa::path( 11 | get, 12 | tag = TAG, 13 | path = "/api/v2/instance", 14 | responses( 15 | (status = OK, description = "", body = Instance), 16 | ), 17 | )] 18 | #[debug_handler] 19 | pub async fn v2(data: Data) -> Result, AppError> { 20 | Ok(Json(Instance::new(&data).await?)) 21 | } 22 | -------------------------------------------------------------------------------- /crates/well_known/src/routes/nodeinfo.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{Json, debug_handler}; 3 | use hatsu_utils::AppData; 4 | 5 | use crate::{TAG, entities::NodeInfoWellKnown}; 6 | 7 | /// NodeInfo discovery. 8 | /// 9 | /// 10 | #[utoipa::path( 11 | get, 12 | tag = TAG, 13 | path = "/.well-known/nodeinfo", 14 | responses( 15 | (status = OK, description = "", body = NodeInfoWellKnown), 16 | ), 17 | )] 18 | #[debug_handler] 19 | pub async fn discovery(data: Data) -> Json { 20 | Json(NodeInfoWellKnown::new(&data)) 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | # Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # dotenv 17 | .env 18 | 19 | # sqlite 20 | *.sqlite3 21 | *.sqlite3-journal 22 | 23 | # mdBook 24 | docs/book 25 | 26 | # direnv / devenv 27 | .direnv 28 | .devenv 29 | 30 | # nix build result 31 | result 32 | -------------------------------------------------------------------------------- /crates/apub/src/actors/user_image.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::object::ImageType; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | use utoipa::ToSchema; 5 | 6 | /// Hatsu User Image 7 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct UserImage { 10 | #[schema(value_type = String)] 11 | #[serde(rename = "type")] 12 | pub kind: ImageType, 13 | // image src 14 | pub url: Url, 15 | } 16 | 17 | impl UserImage { 18 | #[must_use] 19 | pub const fn new(url: Url) -> Self { 20 | Self { 21 | kind: ImageType::Image, 22 | url, 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /crates/cron/src/jobs/update/full.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::FederationConfig; 2 | use apalis::prelude::Data; 3 | use chrono::{DateTime, Utc}; 4 | use hatsu_utils::AppData; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::tasks; 8 | 9 | #[derive(Debug, Default, Deserialize, Serialize)] 10 | pub struct FullUpdate(DateTime); 11 | 12 | impl From> for FullUpdate { 13 | fn from(t: DateTime) -> Self { 14 | Self(t) 15 | } 16 | } 17 | 18 | pub async fn full_update(_job: FullUpdate, data: Data>) -> bool { 19 | let app_data = data.to_request_data(); 20 | tasks::full_update(&app_data).await.is_ok() 21 | } 22 | -------------------------------------------------------------------------------- /crates/cron/src/jobs/update/partial.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::FederationConfig; 2 | use apalis::prelude::Data; 3 | use chrono::{DateTime, Utc}; 4 | use hatsu_utils::AppData; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::tasks; 8 | 9 | #[derive(Debug, Default, Deserialize, Serialize)] 10 | pub struct PartialUpdate(DateTime); 11 | 12 | impl From> for PartialUpdate { 13 | fn from(t: DateTime) -> Self { 14 | Self(t) 15 | } 16 | } 17 | 18 | pub async fn partial_update(_job: PartialUpdate, data: Data>) -> bool { 19 | let app_data = data.to_request_data(); 20 | tasks::partial_update(&app_data).await.is_ok() 21 | } 22 | -------------------------------------------------------------------------------- /docs/src/users/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Setting up your static site 4 | 5 | Once you register in Hatsu instance, it will be updated **fully automatically**. 6 | 7 | However, your site will need to make some changes accordingly to take advantage of the ActivityPub features that Hatsu brings to the table. 8 | 9 | - [Feed](./feed.md) 10 | - [Redirecting](./redirecting.md) 11 | - [Backfeed](./backfeed.md) 12 | 13 | ## Choose instance 14 | 15 | After Hatsu supports public instances, there may be a list of instances here. 16 | 17 | Until then, you'll need to [self-host the instance](../admins/install-docker.md) or find an person running a Hatsu instance and have them create an account. 18 | -------------------------------------------------------------------------------- /crates/apub/src/activities/like_or_announce/db_received_like.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use hatsu_db_schema::received_like::Model as DbReceivedLike; 4 | 5 | #[derive(Clone, Debug, PartialEq, Eq)] 6 | pub struct ApubReceivedLike(pub(crate) DbReceivedLike); 7 | 8 | impl AsRef for ApubReceivedLike { 9 | fn as_ref(&self) -> &DbReceivedLike { 10 | &self.0 11 | } 12 | } 13 | 14 | impl Deref for ApubReceivedLike { 15 | type Target = DbReceivedLike; 16 | 17 | fn deref(&self) -> &Self::Target { 18 | &self.0 19 | } 20 | } 21 | 22 | impl From for ApubReceivedLike { 23 | fn from(u: DbReceivedLike) -> Self { 24 | Self(u) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/apub/src/collections/mod.rs: -------------------------------------------------------------------------------- 1 | use hatsu_utils::AppError; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | use utoipa::ToSchema; 5 | 6 | mod collection; 7 | mod collection_page; 8 | 9 | pub use collection::Collection; 10 | pub use collection_page::CollectionPage; 11 | 12 | pub fn generate_collection_page_url(collection_id: &Url, page: u64) -> Result { 13 | Ok(Url::parse_with_params(collection_id.as_ref(), &[( 14 | "page", 15 | page.to_string(), 16 | )])?) 17 | } 18 | 19 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] 20 | #[serde(untagged)] 21 | pub enum CollectionOrPage { 22 | Collection(Collection), 23 | CollectionPage(CollectionPage), 24 | } 25 | -------------------------------------------------------------------------------- /crates/apub/src/activities/following/db_received_follow.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use hatsu_db_schema::received_follow::Model as DbReceivedFollow; 4 | 5 | #[derive(Clone, Debug, PartialEq, Eq)] 6 | pub struct ApubReceivedFollow(pub(crate) DbReceivedFollow); 7 | 8 | impl AsRef for ApubReceivedFollow { 9 | fn as_ref(&self) -> &DbReceivedFollow { 10 | &self.0 11 | } 12 | } 13 | 14 | impl Deref for ApubReceivedFollow { 15 | type Target = DbReceivedFollow; 16 | 17 | fn deref(&self) -> &Self::Target { 18 | &self.0 19 | } 20 | } 21 | 22 | impl From for ApubReceivedFollow { 23 | fn from(u: DbReceivedFollow) -> Self { 24 | Self(u) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/apub/tests/activities.rs: -------------------------------------------------------------------------------- 1 | use hatsu_apub::{ 2 | activities::{CreateOrUpdateNote, Follow, LikeOrAnnounce, UndoFollow, UndoLikeOrAnnounce}, 3 | tests::test_asset, 4 | }; 5 | use hatsu_utils::AppError; 6 | 7 | #[test] 8 | fn test_parse_activities() -> Result<(), AppError> { 9 | test_asset::("assets/mastodon/activities/create_note.json")?; 10 | test_asset::("assets/mastodon/activities/follow.json")?; 11 | test_asset::("assets/mastodon/activities/like_page.json")?; 12 | test_asset::("assets/mastodon/activities/undo_follow.json")?; 13 | test_asset::("assets/mastodon/activities/undo_like_page.json")?; 14 | 15 | Ok(()) 16 | } 17 | -------------------------------------------------------------------------------- /cspell.config.yaml: -------------------------------------------------------------------------------- 1 | version: "0.2" 2 | ignorePaths: [] 3 | dictionaryDefinitions: [] 4 | dictionaries: [] 5 | words: 6 | - activitypub 7 | - anstyle 8 | - apalis 9 | - apub 10 | - buildx 11 | - chrono 12 | - codegen 13 | - dotenv 14 | - dotenvy 15 | - dtolnay 16 | - fediverse 17 | - fenix 18 | - hatsu 19 | - importantimport 20 | - mdbook 21 | - mlugg 22 | - nixpkgs 23 | - nodeinfo 24 | - opencontainers 25 | - pkgs 26 | - pleroma 27 | - pname 28 | - reqwest 29 | - rustc 30 | - rustls 31 | - sccache 32 | - serde 33 | - snmalloc 34 | - softprops 35 | - sqlx 36 | - taiki 37 | - typescale 38 | - urlencoding 39 | - utoipa 40 | - zigbuild 41 | ignoreWords: [] 42 | import: [] 43 | -------------------------------------------------------------------------------- /crates/apub/src/activities/activity_lists.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{config::Data, traits::ActivityHandler}; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | 5 | use crate::activities::{ 6 | AcceptFollow, 7 | CreateOrUpdateNote, 8 | Follow, 9 | LikeOrAnnounce, 10 | UndoFollow, 11 | UndoLikeOrAnnounce, 12 | }; 13 | 14 | #[derive(Debug, Deserialize, Serialize)] 15 | #[serde(untagged)] 16 | #[enum_delegate::implement(ActivityHandler)] 17 | pub enum UserInboxActivities { 18 | CreateOrUpdateNote(CreateOrUpdateNote), 19 | Follow(Follow), 20 | AcceptFollow(AcceptFollow), 21 | UndoFollow(UndoFollow), 22 | LikeOrAnnounce(LikeOrAnnounce), 23 | UndoLikeOrAnnounce(UndoLikeOrAnnounce), 24 | } 25 | -------------------------------------------------------------------------------- /docs/src/developers/prepare.md: -------------------------------------------------------------------------------- 1 | # Prepare 2 | 3 | ## Clone Repository 4 | 5 | It will create a `hatsu` subfolder in the current path. 6 | 7 | ```bash 8 | git clone https://github.com/importantimport/hatsu.git && cd hatsu 9 | ``` 10 | 11 | ## Contributing 12 | 13 | Go to the `hatsu` folder and you can see these: 14 | 15 | - [`docs`](https://github.com/importantimport/hatsu/tree/main/docs) - The documentation you're looking at right now, uses [mdBook](https://github.com/rust-lang/mdBook) to build. 16 | - [`migration`](https://github.com/importantimport/hatsu/tree/main/migration) - [SeaORM Migration](https://www.sea-ql.org/SeaORM/docs/migration/setting-up-migration/). 17 | - [`src`](https://github.com/importantimport/hatsu/tree/main/src) - Main application. 18 | -------------------------------------------------------------------------------- /crates/apub/src/activities/like_or_announce/db_received_announce.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use hatsu_db_schema::received_announce::Model as DbReceivedAnnounce; 4 | 5 | #[derive(Clone, Debug, PartialEq, Eq)] 6 | pub struct ApubReceivedAnnounce(pub(crate) DbReceivedAnnounce); 7 | 8 | impl AsRef for ApubReceivedAnnounce { 9 | fn as_ref(&self) -> &DbReceivedAnnounce { 10 | &self.0 11 | } 12 | } 13 | 14 | impl Deref for ApubReceivedAnnounce { 15 | type Target = DbReceivedAnnounce; 16 | 17 | fn deref(&self) -> &Self::Target { 18 | &self.0 19 | } 20 | } 21 | 22 | impl From for ApubReceivedAnnounce { 23 | fn from(u: DbReceivedAnnounce) -> Self { 24 | Self(u) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/apub/src/links/hashtag.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::kind; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | use utoipa::ToSchema; 5 | 6 | kind!(HashtagType, Hashtag); 7 | 8 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema, Eq, PartialEq)] 9 | pub struct Hashtag { 10 | #[schema(value_type = String)] 11 | #[serde(rename = "type")] 12 | pub kind: HashtagType, 13 | /// 14 | pub href: Url, 15 | /// #foo 16 | pub name: String, 17 | } 18 | 19 | impl Hashtag { 20 | #[must_use] 21 | pub const fn new(href: Url, name: String) -> Self { 22 | Self { 23 | kind: HashtagType::Hashtag, 24 | href, 25 | name, 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/routes/instance/v1.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{Json, debug_handler}; 3 | use hatsu_utils::{AppData, AppError}; 4 | 5 | use crate::{ 6 | TAG, 7 | entities::{Instance, InstanceV1}, 8 | }; 9 | 10 | /// (DEPRECATED) View server information (V1) 11 | /// 12 | /// 13 | #[utoipa::path( 14 | get, 15 | tag = TAG, 16 | path = "/api/v1/instance", 17 | responses( 18 | (status = OK, description = "", body = InstanceV1), 19 | ), 20 | )] 21 | #[debug_handler] 22 | pub async fn v1(data: Data) -> Result, AppError> { 23 | Ok(Json(InstanceV1::from_instance( 24 | Instance::new(&data).await?, 25 | )?)) 26 | } 27 | -------------------------------------------------------------------------------- /crates/backend/src/favicon.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | debug_handler, 3 | http::header::{self, HeaderMap, HeaderValue}, 4 | }; 5 | 6 | #[debug_handler] 7 | pub async fn ico() -> (HeaderMap, Vec) { 8 | let mut headers = HeaderMap::new(); 9 | headers.insert( 10 | header::CONTENT_TYPE, 11 | HeaderValue::from_static("image/x-icon"), 12 | ); 13 | 14 | (headers, include_bytes!("../assets/favicon.ico").to_vec()) 15 | } 16 | 17 | #[debug_handler] 18 | pub async fn svg() -> (HeaderMap, Vec) { 19 | let mut headers = HeaderMap::new(); 20 | headers.insert( 21 | header::CONTENT_TYPE, 22 | HeaderValue::from_static("image/svg+xml"), 23 | ); 24 | 25 | (headers, include_bytes!("../assets/favicon.svg").to_vec()) 26 | } 27 | -------------------------------------------------------------------------------- /docs/src/users/backfeed-based-on-kkna.md: -------------------------------------------------------------------------------- 1 | # Backfeed based on KKna 2 | 3 | Written by the same authors as Hatsu, KKna provides the simplest integration for Hatsu. 4 | 5 | ## Examples 6 | 7 | > Replace `hatsu.local` with your Hatsu instance. 8 | 9 | ```html 10 | 18 | 19 | 20 | ``` 21 | 22 | You can use it with other presets or write your own components, see the [KKna Documentation](https://kkna.js.org/) for details. 23 | -------------------------------------------------------------------------------- /crates/apub/src/activities/create_or_update/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter, Result}; 2 | 3 | use activitypub_federation::kinds::activity::{CreateType, UpdateType}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | mod note; 7 | pub use note::CreateOrUpdateNote; 8 | 9 | #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] 10 | #[serde(untagged)] 11 | pub enum CreateOrUpdateType { 12 | CreateType(CreateType), 13 | UpdateType(UpdateType), 14 | } 15 | 16 | impl Display for CreateOrUpdateType { 17 | fn fmt(&self, f: &mut Formatter) -> Result { 18 | match self { 19 | Self::CreateType(_) => f.write_str(&CreateType::Create.to_string()), 20 | Self::UpdateType(_) => f.write_str(&UpdateType::Update.to_string()), 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /crates/nodeinfo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_nodeinfo" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_nodeinfo" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | hatsu_db_schema = { workspace = true } 20 | hatsu_utils = { workspace = true } 21 | activitypub_federation = { workspace = true } 22 | axum = { workspace = true } 23 | sea-orm = { workspace = true } 24 | serde = { workspace = true } 25 | serde_json = { workspace = true } 26 | utoipa = { workspace = true } 27 | utoipa-axum = { workspace = true } 28 | -------------------------------------------------------------------------------- /docs/src/others/packaging-status.md: -------------------------------------------------------------------------------- 1 | # Packaging Status 2 | 3 | [![Packaging status](https://repology.org/badge/vertical-allrepos/hatsu.svg)](https://repology.org/project/hatsu/versions) 4 | 5 | If you are interested in packaging Hatsu for other distros, please to let me know! 6 | 7 | ## Arch Linux 8 | 9 | ### [AUR](https://aur.archlinux.org/packages/hatsu) 10 | 11 | Maintainer: [@Decodetalkers] 12 | 13 | ## Nix / NixOS 14 | 15 | ### [Nixpkgs](https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/by-name/ha/hatsu/package.nix) 16 | 17 | Maintainer: [@kwaa] 18 | 19 | ### [NUR (sn0wm1x)](https://github.com/sn0wm1x/ur/blob/main/pkgs/by-name/hatsu/default.nix) 20 | 21 | Maintainer: [@kwaa](https://github.com/kwaa) 22 | 23 | [@kwaa]: https://github.com/kwaa 24 | [@Decodetalkers]: https://github.com/Decodetalkers 25 | -------------------------------------------------------------------------------- /crates/api_admin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_api_admin" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_api_admin" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | hatsu_apub = { workspace = true } 20 | hatsu_db_schema = { workspace = true } 21 | hatsu_utils = { workspace = true } 22 | activitypub_federation = { workspace = true } 23 | axum = { workspace = true } 24 | sea-orm = { workspace = true } 25 | serde = { workspace = true } 26 | url = { workspace = true } 27 | utoipa = { workspace = true } 28 | utoipa-axum = { workspace = true } 29 | -------------------------------------------------------------------------------- /crates/well_known/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_well_known" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_well_known" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | hatsu_db_schema = { workspace = true } 20 | hatsu_utils = { workspace = true } 21 | activitypub_federation = { workspace = true } 22 | axum = { workspace = true } 23 | sea-orm = { workspace = true } 24 | serde = { workspace = true } 25 | tracing = { workspace = true } 26 | url = { workspace = true } 27 | utoipa = { workspace = true } 28 | utoipa-axum = { workspace = true } 29 | -------------------------------------------------------------------------------- /docs/src/admins/create-account.md: -------------------------------------------------------------------------------- 1 | # Create Account 2 | 3 | > Ensure you set `HATSU_ACCESS_TOKEN` correctly in the [previous section](./environments.md#hatsu_access_token-optional) first, otherwise you will not be able to use the Hatsu Admin API. 4 | 5 | ## just 6 | 7 | The easiest way to create an account is the [`just`](https://github.com/casey/just) command line tool: 8 | 9 | ```bash 10 | just account create example.com 11 | ``` 12 | 13 | If you are using docker, you need to exec to the container first. 14 | 15 | ```bash 16 | docker exec -it hatsu /bin/bash 17 | ``` 18 | 19 | ## curl 20 | 21 | You can also access the API via curl, as `Justfile` does. 22 | 23 | ```bash 24 | NAME="example.com" curl -X POST "http://localhost:$(echo $HATSU_LISTEN_PORT)/api/v0/admin/create-account?name=$(echo $NAME)&token=$(echo $HATSU_ACCESS_TOKEN)" 25 | ``` 26 | -------------------------------------------------------------------------------- /crates/utils/src/codename.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::VERSION; 4 | 5 | /// https://github.com/importantimport/hatsu/milestones 6 | pub fn codename() -> &'static str { 7 | let version = String::from(VERSION); 8 | let hashmap: HashMap<&str, &str> = HashMap::from([ 9 | ("0.1", "01_ballade"), 10 | ("0.2", "celluloid"), 11 | ("0.3", "Strobe Nights"), 12 | ("0.4", "Clover Club"), 13 | ("0.5", "Sakura No Kisetsu"), 14 | ("0.6", "World Is Mine"), 15 | ("0.7", "Nisoku Hokou"), 16 | ("0.8", "Sweet Devil"), 17 | ("0.9", "Unfragment"), 18 | ("1.0", "Hoshi No Kakera"), 19 | ]); 20 | 21 | hashmap 22 | .iter() 23 | .find(|(major_minor, _)| version.starts_with(*major_minor)) 24 | .map_or("Cat Food", |(_, codename)| codename) 25 | } 26 | -------------------------------------------------------------------------------- /crates/utils/src/graceful_shutdown.rs: -------------------------------------------------------------------------------- 1 | /// from 2 | pub async fn shutdown_signal() -> std::io::Result<()> { 3 | let ctrl_c = async { 4 | tokio::signal::ctrl_c() 5 | .await 6 | .expect("failed to install Ctrl+C handler"); 7 | }; 8 | 9 | #[cfg(unix)] 10 | let terminate = async { 11 | tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) 12 | .expect("failed to install signal handler") 13 | .recv() 14 | .await; 15 | }; 16 | 17 | #[cfg(not(unix))] 18 | let terminate = std::future::pending::<()>(); 19 | 20 | tokio::select! { 21 | c = ctrl_c => Ok(c), 22 | t = terminate => Ok(t), 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /crates/tracing/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_tracing" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_tracing" 16 | path = "src/lib.rs" 17 | 18 | [features] 19 | default = [] 20 | console = ["dep:console-subscriber"] 21 | json = ["tracing-subscriber/json"] 22 | pretty = [] 23 | 24 | [dependencies] 25 | hatsu_utils = { workspace = true } 26 | tracing = { workspace = true } 27 | tracing-error = { workspace = true } 28 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 29 | 30 | # optional 31 | console-subscriber = { version = "0.4", optional = true } 32 | -------------------------------------------------------------------------------- /crates/utils/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_utils" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_utils" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | anyhow = { workspace = true } 20 | axum = { workspace = true } 21 | chrono = { workspace = true } 22 | markdown = { workspace = true } 23 | sea-orm = { workspace = true } 24 | serde = { workspace = true } 25 | serde_json = { workspace = true } 26 | tokio = { workspace = true } 27 | tracing-error = { workspace = true } 28 | utoipa = { workspace = true } 29 | url = { workspace = true } 30 | uuid = { workspace = true } 31 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - "**.md" 9 | pull_request: 10 | merge_group: 11 | workflow_dispatch: 12 | 13 | env: 14 | CARGO_TERM_COLOR: always 15 | # https://github.com/Mozilla-Actions/sccache-action#rust-code 16 | # RUSTC_WRAPPER: "sccache" 17 | # SCCACHE_GHA_ENABLED: "true" 18 | 19 | jobs: 20 | check: 21 | name: check 22 | runs-on: ubuntu-latest 23 | if: "!startsWith(github.ref, 'refs/tags')" 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: dtolnay/rust-toolchain@nightly 27 | - uses: rui314/setup-mold@v1 28 | - uses: taiki-e/install-action@just 29 | # - uses: mozilla-actions/sccache-action@v0.0.3 30 | - run: just fmt --check 31 | - run: just lint 32 | - run: just check 33 | - run: just test 34 | -------------------------------------------------------------------------------- /docs/src/users/redirecting-with-redirects-file.md: -------------------------------------------------------------------------------- 1 | # Redirecting with Redirects file 2 | 3 | Works with [Netlify](https://docs.netlify.com/routing/redirects/#syntax-for-the-redirects-file) and [Cloudflare Pages](https://developers.cloudflare.com/pages/platform/redirects). 4 | 5 | ## Well Known 6 | 7 | Create a `_redirects` file in the SSG static files directory containing the following: 8 | 9 | > Replace `hatsu.local` with your Hatsu instance. 10 | 11 | ```text 12 | /.well-known/host-meta* https://hatsu.local/.well-known/host-meta:splat 307 13 | /.well-known/nodeinfo* https://hatsu.local/.well-known/nodeinfo 307 14 | /.well-known/webfinger* https://hatsu.local/.well-known/webfinger 307 15 | ``` 16 | 17 | ## AS2 18 | 19 | > Redirects file only applies to `.well-known`. 20 | > for AS2 redirects, you need to use [AS2 Alternate](./redirecting-with-static-files-and-markup.md#as2-alternate). 21 | -------------------------------------------------------------------------------- /crates/apub/src/links/emoji.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::{kind, object::ImageType}; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | use utoipa::ToSchema; 5 | 6 | kind!(EmojiType, Emoji); 7 | 8 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema, Eq, PartialEq)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct Emoji { 11 | #[schema(value_type = String)] 12 | #[serde(rename = "type")] 13 | pub kind: EmojiType, 14 | pub icon: EmojiIcon, 15 | pub id: Url, 16 | pub name: String, 17 | pub updated: Option, 18 | } 19 | 20 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema, Eq, PartialEq)] 21 | #[serde(rename_all = "camelCase")] 22 | pub struct EmojiIcon { 23 | #[schema(value_type = String)] 24 | #[serde(rename = "type")] 25 | pub kind: ImageType, 26 | pub media_type: Option, 27 | pub url: Url, 28 | } 29 | -------------------------------------------------------------------------------- /crates/db_schema/src/activity.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "activity")] 7 | pub struct Model { 8 | #[sea_orm(primary_key, auto_increment = false)] 9 | pub id: String, 10 | pub activity: Json, 11 | pub actor: String, 12 | pub kind: String, 13 | pub published: Option, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 17 | pub enum Relation { 18 | #[sea_orm( 19 | belongs_to = "super::user::Entity", 20 | from = "Column::Actor", 21 | to = "super::user::Column::Id" 22 | )] 23 | User, 24 | } 25 | 26 | impl ActiveModelBehavior for ActiveModel {} 27 | 28 | impl Related for Entity { 29 | fn to() -> RelationDef { 30 | Relation::User.def() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /crates/feed/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_feed" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_feed" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | hatsu_db_schema = { workspace = true } 20 | hatsu_utils = { workspace = true } 21 | activitypub_federation = { workspace = true } 22 | async-recursion = { workspace = true } 23 | chrono = { workspace = true } 24 | feed-rs = { workspace = true } 25 | reqwest = { workspace = true } 26 | scraper = { workspace = true } 27 | serde = { workspace = true } 28 | serde_json = { workspace = true } 29 | url = { workspace = true } 30 | 31 | [dev-dependencies] 32 | tokio = { workspace = true } 33 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | use utoipa::OpenApi; 2 | use utoipa_axum::router::OpenApiRouter; 3 | 4 | use crate::entities::{ 5 | Account, 6 | Context, 7 | CustomEmoji, 8 | Instance, 9 | InstanceContact, 10 | InstanceV1, 11 | Status, 12 | }; 13 | 14 | mod instance; 15 | mod statuses; 16 | 17 | pub const TAG: &str = "mastodon"; 18 | 19 | #[derive(OpenApi)] 20 | #[openapi( 21 | components(schemas( 22 | Account, 23 | Context, 24 | CustomEmoji, 25 | Instance, 26 | InstanceContact, 27 | InstanceV1, 28 | Status 29 | )), 30 | tags((name = TAG, description = "Mastodon Compatible API (/api/v{1,2}/)")) 31 | )] 32 | pub struct MastodonApi; 33 | 34 | #[must_use] 35 | pub fn routes() -> OpenApiRouter { 36 | OpenApiRouter::with_openapi(MastodonApi::openapi()) 37 | .merge(instance::routes()) 38 | .merge(statuses::routes()) 39 | } 40 | -------------------------------------------------------------------------------- /crates/api_apub/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_api_apub" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_api_apub" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | hatsu_apub = { workspace = true } 20 | hatsu_db_schema = { workspace = true } 21 | hatsu_utils = { workspace = true } 22 | activitypub_federation = { workspace = true } 23 | axum = { workspace = true } 24 | base64-simd = { workspace = true } 25 | sea-orm = { workspace = true } 26 | serde = { workspace = true } 27 | serde_json = { workspace = true } 28 | tracing = { workspace = true } 29 | url = { workspace = true } 30 | utoipa = { workspace = true } 31 | utoipa-axum = { workspace = true } 32 | -------------------------------------------------------------------------------- /crates/db_schema/src/received_follow.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "received_follow")] 7 | pub struct Model { 8 | #[sea_orm(primary_key, auto_increment = false)] 9 | pub id: String, 10 | pub actor: String, 11 | #[sea_orm(column_type = "Text", nullable)] 12 | pub to: Option, 13 | pub object: String, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 17 | pub enum Relation { 18 | #[sea_orm( 19 | belongs_to = "super::user::Entity", 20 | from = "Column::Object", 21 | to = "super::user::Column::Id" 22 | )] 23 | User, 24 | } 25 | 26 | impl ActiveModelBehavior for ActiveModel {} 27 | 28 | impl Related for Entity { 29 | fn to() -> RelationDef { 30 | Relation::User.def() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | 3 | on: 4 | push: 5 | paths: 6 | - ".github/workflows/gh-pages.yml" 7 | - "docs/src/**" 8 | - "docs/theme/**" 9 | - "docs/book.toml" 10 | workflow_dispatch: 11 | 12 | defaults: 13 | run: 14 | working-directory: docs 15 | 16 | env: 17 | CARGO_TERM_COLOR: always 18 | 19 | jobs: 20 | deploy: 21 | if: ${{ github.ref == 'refs/heads/main' }} 22 | runs-on: ubuntu-latest 23 | concurrency: 24 | group: ${{ github.workflow }}-${{ github.ref }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | - uses: peaceiris/actions-mdbook@v1 30 | with: 31 | mdbook-version: latest 32 | - run: mdbook build 33 | - uses: peaceiris/actions-gh-pages@v3 34 | with: 35 | cname: hatsu.cli.rs 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | publish_dir: ./docs/book 38 | -------------------------------------------------------------------------------- /crates/cron/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_cron" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_cron" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | hatsu_apub = { workspace = true } 20 | hatsu_db_schema = { workspace = true } 21 | hatsu_feed = { workspace = true } 22 | hatsu_utils = { workspace = true } 23 | activitypub_federation = { workspace = true } 24 | anyhow = { workspace = true } 25 | apalis = { workspace = true } 26 | apalis-cron = { workspace = true } 27 | chrono = { workspace = true } 28 | sea-orm = { workspace = true } 29 | serde = { workspace = true } 30 | serde_json = { workspace = true } 31 | tracing = { workspace = true } 32 | url = { workspace = true } 33 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Hatsu Dev Container", 3 | "image": "mcr.microsoft.com/devcontainers/base:alpine", 4 | "features": { 5 | "ghcr.io/devcontainers/features/nix:1": { 6 | "multiUser": true, 7 | "version": "latest", 8 | "extraNixConfig": "experimental-features = nix-command flakes,keep-outputs = true,keep-derivations = true" 9 | } 10 | }, 11 | "onCreateCommand": { 12 | "install-direnv": "set -xeuo pipefail; nix profile install nixpkgs#direnv nixpkgs#nix-direnv && mkdir -p ~/.config/direnv && echo 'source $HOME/.nix-profile/share/nix-direnv/direnvrc' >> ~/.config/direnv/direnvrc && direnv allow && echo 'eval \"$(direnv hook bash)\"' >> ~/.bashrc" 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "settings": {}, 17 | "extensions": [ 18 | "mkhl.direnv", 19 | "rust-lang.rust-analyzer" 20 | ] 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /crates/apub/src/activities/like_or_announce/db_received_like_impl.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::activity::LikeType; 2 | use hatsu_db_schema::received_like::Model as DbReceivedLike; 3 | use hatsu_utils::AppError; 4 | use url::Url; 5 | 6 | use crate::activities::{ApubReceivedLike, LikeOrAnnounce, LikeOrAnnounceType}; 7 | 8 | impl ApubReceivedLike { 9 | pub fn into_json(self) -> Result { 10 | Ok(LikeOrAnnounce { 11 | kind: LikeOrAnnounceType::LikeType(LikeType::Like), 12 | id: Url::parse(&self.id)?, 13 | actor: Url::parse(&self.actor)?.into(), 14 | object: Url::parse(&self.object)?.into(), 15 | }) 16 | } 17 | 18 | pub fn from_json(json: &LikeOrAnnounce) -> Result { 19 | Ok(DbReceivedLike { 20 | id: json.id.to_string(), 21 | actor: json.actor.to_string(), 22 | object: json.object.to_string(), 23 | } 24 | .into()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/api_apub/src/users/user_inbox.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{ 2 | axum::inbox::{ActivityData, receive_activity}, 3 | config::Data, 4 | protocol::context::WithContext, 5 | }; 6 | use axum::{debug_handler, response::IntoResponse}; 7 | use hatsu_apub::{activities::UserInboxActivities, actors::ApubUser}; 8 | use hatsu_utils::{AppData, AppError}; 9 | 10 | use crate::TAG; 11 | 12 | /// User inbox 13 | #[utoipa::path( 14 | post, 15 | tag = TAG, 16 | path = "/users/{user}/inbox", 17 | responses( 18 | (status = OK), 19 | (status = NOT_FOUND, body = AppError), 20 | (status = INTERNAL_SERVER_ERROR, body = AppError) 21 | ), 22 | params( 23 | ("user" = String, Path, description = "The Domain of the User in the database.") 24 | ) 25 | )] 26 | #[debug_handler] 27 | pub async fn handler(data: Data, activity_data: ActivityData) -> impl IntoResponse { 28 | receive_activity::, ApubUser, AppData>(activity_data, &data) 29 | .await 30 | } 31 | -------------------------------------------------------------------------------- /crates/apub/src/activities/like_or_announce/db_received_announce_impl.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::activity::AnnounceType; 2 | use hatsu_db_schema::received_announce::Model as DbReceivedAnnounce; 3 | use hatsu_utils::AppError; 4 | use url::Url; 5 | 6 | use crate::activities::{ApubReceivedAnnounce, LikeOrAnnounce, LikeOrAnnounceType}; 7 | 8 | impl ApubReceivedAnnounce { 9 | pub fn into_json(self) -> Result { 10 | Ok(LikeOrAnnounce { 11 | kind: LikeOrAnnounceType::AnnounceType(AnnounceType::Announce), 12 | id: Url::parse(&self.id)?, 13 | actor: Url::parse(&self.actor)?.into(), 14 | object: Url::parse(&self.object)?.into(), 15 | }) 16 | } 17 | 18 | pub fn from_json(json: &LikeOrAnnounce) -> Result { 19 | Ok(DbReceivedAnnounce { 20 | id: json.id.to_string(), 21 | actor: json.actor.to_string(), 22 | object: json.object.to_string(), 23 | } 24 | .into()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/well_known/src/entities/nodeinfo.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use hatsu_utils::AppData; 3 | use serde::Serialize; 4 | use utoipa::ToSchema; 5 | 6 | #[derive(Serialize, ToSchema)] 7 | pub struct NodeInfoWellKnown { 8 | links: Vec, 9 | } 10 | 11 | impl NodeInfoWellKnown { 12 | pub fn new(data: &Data) -> Self { 13 | Self { 14 | links: vec![ 15 | NodeInfoWellKnownLink::new(data, "2.1"), 16 | NodeInfoWellKnownLink::new(data, "2.0"), 17 | ], 18 | } 19 | } 20 | } 21 | 22 | #[derive(Serialize, ToSchema)] 23 | pub struct NodeInfoWellKnownLink { 24 | rel: String, 25 | href: String, 26 | } 27 | 28 | impl NodeInfoWellKnownLink { 29 | pub fn new(data: &Data, version: &str) -> Self { 30 | Self { 31 | rel: format!("http://nodeinfo.diaspora.software/ns/schema/{version}"), 32 | href: format!("https://{}/nodeinfo/{}.json", data.domain(), version), 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "snmalloc")] 2 | #[global_allocator] 3 | static ALLOC: snmalloc_rs::SnMalloc = snmalloc_rs::SnMalloc; 4 | 5 | use clap::{Parser, Subcommand}; 6 | use hatsu_utils::AppError; 7 | use human_panic::{metadata, setup_panic}; 8 | 9 | mod commands; 10 | mod utils; 11 | 12 | #[derive(Debug, Parser)] 13 | #[command( 14 | styles = utils::styles(), 15 | name = "hatsu", 16 | version = hatsu_utils::VERSION, 17 | about, 18 | )] 19 | struct Args { 20 | #[command(subcommand)] 21 | command: Option, 22 | } 23 | 24 | #[derive(Debug, Subcommand)] 25 | enum Commands { 26 | Run, 27 | } 28 | 29 | #[tokio::main] 30 | async fn main() -> Result<(), AppError> { 31 | setup_panic!(metadata!().homepage("https://github.com/importantimport/hatsu/issues")); 32 | 33 | let args = Args::parse(); 34 | 35 | if let Some(command) = args.command { 36 | match command { 37 | Commands::Run => commands::run().await, 38 | } 39 | } else { 40 | commands::run().await 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /crates/api_mastodon/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_api_mastodon" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_api_mastodon" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | hatsu_apub = { workspace = true } 20 | hatsu_db_schema = { workspace = true } 21 | hatsu_utils = { workspace = true } 22 | activitypub_federation = { workspace = true } 23 | axum = { workspace = true } 24 | base64-simd = { workspace = true } 25 | chrono = { workspace = true } 26 | futures = { workspace = true } 27 | sea-orm = { workspace = true } 28 | serde = { workspace = true } 29 | serde_json = { workspace = true } 30 | tokio = { workspace = true } 31 | url = { workspace = true } 32 | urlencoding = { workspace = true } 33 | utoipa = { workspace = true } 34 | utoipa-axum = { workspace = true } 35 | -------------------------------------------------------------------------------- /crates/db_migration/src/m20241028_000001_user_language.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{prelude::*, schema::string_null}; 2 | 3 | use crate::m20240131_000001_user::User; 4 | 5 | #[derive(DeriveMigrationName)] 6 | pub struct Migration; 7 | 8 | #[async_trait::async_trait] 9 | impl MigrationTrait for Migration { 10 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 11 | manager 12 | .alter_table( 13 | Table::alter() 14 | .table(User::Table) 15 | .add_column(string_null(User::Language)) 16 | .to_owned(), 17 | ) 18 | .await?; 19 | 20 | Ok(()) 21 | } 22 | 23 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 24 | manager 25 | .alter_table( 26 | Table::alter() 27 | .table(User::Table) 28 | .drop_column(User::Language) 29 | .to_owned(), 30 | ) 31 | .await?; 32 | 33 | Ok(()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /crates/well_known/src/entities/host_meta.rs: -------------------------------------------------------------------------------- 1 | // https://www.rfc-editor.org/rfc/rfc6415 2 | 3 | use activitypub_federation::config::Data; 4 | use hatsu_utils::AppData; 5 | use serde::Serialize; 6 | use utoipa::ToSchema; 7 | 8 | #[derive(Serialize, ToSchema)] 9 | pub struct HostMeta { 10 | links: Vec, 11 | } 12 | 13 | impl HostMeta { 14 | pub fn new(data: &Data) -> Self { 15 | Self { 16 | links: vec![HostMetaLink::new(data)], 17 | } 18 | } 19 | } 20 | 21 | #[derive(Serialize, ToSchema)] 22 | pub struct HostMetaLink { 23 | rel: String, 24 | #[serde(rename = "type")] 25 | kind: String, 26 | template: String, 27 | } 28 | 29 | impl HostMetaLink { 30 | pub fn new(data: &Data) -> Self { 31 | Self { 32 | rel: String::from("lrdd"), 33 | kind: String::from("application/json"), 34 | template: format!( 35 | "https://{}/.well-known/webfinger?resource={{uri}}", 36 | data.domain() 37 | ), 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/docker-compose/litestream.docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | # volumes: 4 | # hatsu_database: 5 | 6 | services: 7 | hatsu: 8 | container_name: hatsu 9 | image: ghcr.io/importantimport/hatsu:nightly 10 | restart: unless-stopped 11 | ports: 12 | - 3939:3939 13 | # env_file: 14 | # - .env 15 | environment: 16 | - HATSU_DATABASE_URL=sqlite://hatsu.sqlite3 17 | - HATSU_DOMAIN=hatsu.example.com 18 | - HATSU_LISTEN_HOST=0.0.0.0 19 | - HATSU_PRIMARY_ACCOUNT=blog.example.com 20 | volumes: 21 | # - ./.env:/app/.env 22 | - ./hatsu.sqlite3:/app/hatsu.sqlite3 23 | 24 | # https://litestream.io/getting-started/ 25 | # litestream: 26 | # container_name: hatsu_litestream 27 | # image: litestream/litestream 28 | # tty: true 29 | # environment: 30 | # - LITESTREAM_ACCESS_KEY_ID=minioadmin 31 | # - LITESTREAM_SECRET_ACCESS_KEY=minioadmin 32 | # volumes_from: 33 | # - hatsu:rw 34 | # command: ["replicate", "/data/hatsu.sqlite3", "s3://mybkt.localhost:9000/hatsu.sqlite3"] 35 | -------------------------------------------------------------------------------- /docs/src/developers/development-local.md: -------------------------------------------------------------------------------- 1 | # Local Development 2 | 3 | > You'll need to complete [prepare](./01-prepare.md) before you do this. 4 | 5 | ## Dependencies 6 | 7 | To develop Hatsu, you should first [install Rust](https://www.rust-lang.org/tools/install) and some dependencies. 8 | 9 | ```bash 10 | # Arch-based distro 11 | sudo pacman -S git cargo 12 | 13 | # Debian-based distro 14 | sudo apt install git cargo 15 | ``` 16 | 17 | ## Running 18 | 19 | First copy the variables, 20 | 21 | Set `HATSU_DOMAIN` to your prepared domain 22 | (e.g. `hatsu.example.com` without `https://`) 23 | 24 | and `HATSU_PRIMARY_ACCOUNT` to your desired user domain 25 | (e.g. `blog.example.com` without `https://`) 26 | 27 | ```bash 28 | # copy env example 29 | cp .env.example .env 30 | # edit env 31 | nano .env 32 | ``` 33 | 34 | Then create the database file and run: 35 | 36 | ```bash 37 | # create database 38 | touch hatsu.sqlite3 39 | # run hatsu 40 | cargo run 41 | ``` 42 | 43 | Hatsu now listen on `localhost:3939`, and in order for it to connect to Fediverse, you'll also need to set up a reverse proxy. 44 | -------------------------------------------------------------------------------- /crates/backend/src/routes.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, Router, routing::get}; 2 | use utoipa::OpenApi; 3 | use utoipa_axum::router::OpenApiRouter; 4 | use utoipa_scalar::{Scalar, Servable}; 5 | 6 | use crate::{favicon, openapi::ApiDoc}; 7 | 8 | pub fn routes() -> Router { 9 | let (api_router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) 10 | .merge(hatsu_api::routes()) 11 | .merge(hatsu_api_admin::routes()) 12 | .merge(hatsu_api_apub::routes()) 13 | .merge(hatsu_api_mastodon::routes()) 14 | .merge(hatsu_nodeinfo::routes()) 15 | .merge(hatsu_well_known::routes()) 16 | .split_for_parts(); 17 | 18 | let openapi_json = api.clone(); 19 | 20 | let api_router = api_router 21 | .route("/openapi.json", get(|| async move { Json(openapi_json) })) 22 | .merge(Scalar::with_url("/scalar", api)); 23 | 24 | let router = Router::new() 25 | .route("/favicon.ico", get(favicon::ico)) 26 | .route("/favicon.svg", get(favicon::svg)) 27 | .merge(hatsu_frontend::routes()); 28 | 29 | router.merge(api_router) 30 | } 31 | -------------------------------------------------------------------------------- /crates/nodeinfo/src/routes.rs: -------------------------------------------------------------------------------- 1 | use axum::routing::get; 2 | use utoipa::OpenApi; 3 | use utoipa_axum::{router::OpenApiRouter, routes}; 4 | 5 | use crate::{ 6 | handler, 7 | schema::{ 8 | NodeInfo, 9 | NodeInfoMetadata, 10 | NodeInfoServices, 11 | NodeInfoSoftware, 12 | NodeInfoUsage, 13 | NodeInfoUsers, 14 | }, 15 | }; 16 | 17 | pub const TAG: &str = "nodeinfo"; 18 | 19 | #[derive(OpenApi)] 20 | #[openapi( 21 | components(schemas( 22 | NodeInfo, 23 | NodeInfoMetadata, 24 | NodeInfoServices, 25 | NodeInfoSoftware, 26 | NodeInfoUsage, 27 | NodeInfoUsers, 28 | )), 29 | tags((name = TAG, description = "NodeInfo (/nodeinfo/)")) 30 | )] 31 | pub struct NodeInfoApi; 32 | 33 | pub fn routes() -> OpenApiRouter { 34 | OpenApiRouter::with_openapi(NodeInfoApi::openapi()) 35 | .routes(routes!(handler::v2_0)) 36 | .routes(routes!(handler::v2_1)) 37 | // fallback routes 38 | .route("/nodeinfo/2.0", get(handler::v2_0)) 39 | .route("/nodeinfo/2.1", get(handler::v2_1)) 40 | } 41 | -------------------------------------------------------------------------------- /crates/apub/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_apub" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_apub" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | hatsu_db_schema = { workspace = true } 20 | hatsu_feed = { workspace = true } 21 | hatsu_utils = { workspace = true } 22 | activitypub_federation = { workspace = true } 23 | anyhow = { workspace = true } 24 | async-recursion = { workspace = true } 25 | async-trait = { workspace = true } 26 | axum = { workspace = true } 27 | chrono = { workspace = true } 28 | enum_delegate = { workspace = true } 29 | futures = { workspace = true } 30 | sea-orm = { workspace = true } 31 | serde = { workspace = true } 32 | serde_json = { workspace = true } 33 | tracing = { workspace = true } 34 | url = { workspace = true } 35 | urlencoding = { workspace = true } 36 | utoipa = { workspace = true } 37 | -------------------------------------------------------------------------------- /crates/backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hatsu_backend" 3 | version.workspace = true 4 | edition.workspace = true 5 | publish.workspace = true 6 | readme.workspace = true 7 | license.workspace = true 8 | authors.workspace = true 9 | description.workspace = true 10 | documentation.workspace = true 11 | homepage.workspace = true 12 | repository.workspace = true 13 | 14 | [lib] 15 | name = "hatsu_backend" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | hatsu_api = { workspace = true } 20 | hatsu_api_admin = { workspace = true } 21 | hatsu_api_apub = { workspace = true } 22 | hatsu_api_mastodon = { workspace = true } 23 | hatsu_cron = { workspace = true } 24 | hatsu_frontend = { workspace = true } 25 | hatsu_nodeinfo = { workspace = true } 26 | hatsu_well_known = { workspace = true } 27 | hatsu_utils = { workspace = true } 28 | activitypub_federation = { workspace = true } 29 | axum = { workspace = true } 30 | tokio = { workspace = true } 31 | tower-http = { workspace = true } 32 | tracing = { workspace = true } 33 | utoipa = { workspace = true } 34 | utoipa-axum = { workspace = true } 35 | utoipa-scalar = { workspace = true } 36 | -------------------------------------------------------------------------------- /crates/nodeinfo/src/handler.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{Json, debug_handler}; 3 | use hatsu_utils::{AppData, AppError}; 4 | 5 | use crate::{TAG, schema::NodeInfo}; 6 | 7 | /// NodeInfo schema version 2.0. 8 | /// 9 | /// 10 | #[utoipa::path( 11 | get, 12 | tag = TAG, 13 | path = "/nodeinfo/2.0.json", 14 | responses( 15 | (status = OK, description = "", body = NodeInfo), 16 | ), 17 | )] 18 | #[debug_handler] 19 | pub async fn v2_0(data: Data) -> Result, AppError> { 20 | Ok(Json(NodeInfo::v2_0(&data).await?)) 21 | } 22 | 23 | /// NodeInfo schema version 2.1. 24 | /// 25 | /// 26 | #[utoipa::path( 27 | get, 28 | tag = TAG, 29 | path = "/nodeinfo/2.1.json", 30 | responses( 31 | (status = OK, description = "", body = NodeInfo), 32 | ), 33 | )] 34 | #[debug_handler] 35 | pub async fn v2_1(data: Data) -> Result, AppError> { 36 | Ok(Json(NodeInfo::v2_1(&data).await?)) 37 | } 38 | -------------------------------------------------------------------------------- /crates/well_known/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | use utoipa::OpenApi; 2 | use utoipa_axum::{router::OpenApiRouter, routes}; 3 | 4 | use crate::entities::{ 5 | HostMeta, 6 | HostMetaLink, 7 | NodeInfoWellKnown, 8 | NodeInfoWellKnownLink, 9 | WebfingerSchema, 10 | WebfingerSchemaLink, 11 | }; 12 | 13 | mod host_meta; 14 | mod nodeinfo; 15 | mod webfinger; 16 | 17 | pub const TAG: &str = "well_known"; 18 | 19 | #[derive(OpenApi)] 20 | #[openapi( 21 | components(schemas( 22 | HostMeta, 23 | HostMetaLink, 24 | NodeInfoWellKnown, 25 | NodeInfoWellKnownLink, 26 | WebfingerSchema, 27 | WebfingerSchemaLink, 28 | )), 29 | tags((name = TAG, description = "Well Known (/.well-known/)")) 30 | )] 31 | pub struct WellKnownApi; 32 | 33 | pub fn routes() -> OpenApiRouter { 34 | OpenApiRouter::with_openapi(WellKnownApi::openapi()) 35 | .routes(routes!(host_meta::redirect)) 36 | .routes(routes!(host_meta::xml)) 37 | .routes(routes!(host_meta::json)) 38 | .routes(routes!(nodeinfo::discovery)) 39 | .routes(routes!(webfinger::webfinger)) 40 | } 41 | -------------------------------------------------------------------------------- /crates/api_apub/src/users/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::routing::{get, post}; 2 | use serde::Deserialize; 3 | use utoipa::{IntoParams, OpenApi}; 4 | use utoipa_axum::{router::OpenApiRouter, routes}; 5 | 6 | use crate::ApubApi; 7 | pub mod user; 8 | mod user_followers; 9 | mod user_following; 10 | mod user_inbox; 11 | mod user_outbox; 12 | 13 | #[derive(Deserialize, IntoParams)] 14 | pub struct Pagination { 15 | page: Option, 16 | } 17 | 18 | pub fn routes() -> OpenApiRouter { 19 | OpenApiRouter::with_openapi(ApubApi::openapi()) 20 | .routes(routes!(user::handler)) 21 | .routes(routes!(user_followers::handler)) 22 | .routes(routes!(user_following::handler)) 23 | .routes(routes!(user_inbox::handler)) 24 | .routes(routes!(user_outbox::handler)) 25 | // fallback routes 26 | .route("/u/:user", get(user::redirect)) 27 | .route("/u/:user/followers", get(user_followers::redirect)) 28 | .route("/u/:user/following", get(user_following::redirect)) 29 | .route("/u/:user/outbox", get(user_outbox::redirect)) 30 | .route("/u/:user/inbox", post(user_inbox::handler)) 31 | } 32 | -------------------------------------------------------------------------------- /crates/apub/src/activities/like_or_announce/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter, Result}; 2 | 3 | use activitypub_federation::kinds::activity::{AnnounceType, LikeType}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | mod db_received_announce; 7 | mod db_received_announce_impl; 8 | mod db_received_like; 9 | mod db_received_like_impl; 10 | 11 | mod like_or_announce; 12 | mod undo_like_or_announce; 13 | 14 | pub use db_received_announce::ApubReceivedAnnounce; 15 | pub use db_received_like::ApubReceivedLike; 16 | pub use like_or_announce::LikeOrAnnounce; 17 | pub use undo_like_or_announce::UndoLikeOrAnnounce; 18 | 19 | #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] 20 | #[serde(untagged)] 21 | pub enum LikeOrAnnounceType { 22 | LikeType(LikeType), 23 | AnnounceType(AnnounceType), 24 | } 25 | 26 | impl Display for LikeOrAnnounceType { 27 | fn fmt(&self, f: &mut Formatter) -> Result { 28 | match self { 29 | Self::LikeType(_) => f.write_str(&LikeType::Like.to_string()), 30 | Self::AnnounceType(_) => f.write_str(&AnnounceType::Announce.to_string()), 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/api_apub/src/posts/notice.rs: -------------------------------------------------------------------------------- 1 | use axum::{debug_handler, extract::Path, response::Redirect}; 2 | use hatsu_apub::objects::Note; 3 | use hatsu_utils::AppError; 4 | 5 | use crate::TAG; 6 | 7 | /// Get post by base64 url 8 | #[utoipa::path( 9 | get, 10 | tag = TAG, 11 | path = "/notice/{notice}", 12 | responses( 13 | (status = OK, description = "Post", body = Note), 14 | (status = NOT_FOUND, description = "Post does not exist", body = AppError) 15 | ), 16 | params( 17 | ("notice" = String, Path, description = "Base64 Post Url") 18 | ) 19 | )] 20 | #[debug_handler] 21 | pub async fn notice(Path(base64_url): Path) -> Result { 22 | let base64 = base64_simd::URL_SAFE; 23 | 24 | base64.decode_to_vec(&base64_url).map_or_else( 25 | |_| Err(AppError::not_found("Record", &base64_url)), 26 | |utf8_url| match String::from_utf8(utf8_url) { 27 | Ok(url) if url.starts_with("https://") => 28 | Ok(Redirect::permanent(&format!("/posts/{url}"))), 29 | _ => Err(AppError::not_found("Record", &base64_url)), 30 | }, 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /crates/db_schema/src/received_like.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "received_like")] 7 | pub struct Model { 8 | #[sea_orm(primary_key, auto_increment = false)] 9 | pub id: String, 10 | pub actor: String, 11 | pub object: String, 12 | } 13 | 14 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 15 | pub enum Relation { 16 | #[sea_orm( 17 | belongs_to = "super::post::Entity", 18 | from = "Column::Object", 19 | to = "super::post::Column::Id" 20 | )] 21 | Post, 22 | #[sea_orm( 23 | belongs_to = "super::user::Entity", 24 | from = "Column::Actor", 25 | to = "super::user::Column::Id" 26 | )] 27 | User, 28 | } 29 | 30 | impl ActiveModelBehavior for ActiveModel {} 31 | 32 | impl Related for Entity { 33 | fn to() -> RelationDef { 34 | Relation::Post.def() 35 | } 36 | } 37 | 38 | impl Related for Entity { 39 | fn to() -> RelationDef { 40 | Relation::User.def() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /crates/db_schema/src/received_announce.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 6 | #[sea_orm(table_name = "received_announce")] 7 | pub struct Model { 8 | #[sea_orm(primary_key, auto_increment = false)] 9 | pub id: String, 10 | pub actor: String, 11 | pub object: String, 12 | } 13 | 14 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 15 | pub enum Relation { 16 | #[sea_orm( 17 | belongs_to = "super::post::Entity", 18 | from = "Column::Object", 19 | to = "super::post::Column::Id" 20 | )] 21 | Post, 22 | #[sea_orm( 23 | belongs_to = "super::user::Entity", 24 | from = "Column::Actor", 25 | to = "super::user::Column::Id" 26 | )] 27 | User, 28 | } 29 | 30 | impl ActiveModelBehavior for ActiveModel {} 31 | 32 | impl Related for Entity { 33 | fn to() -> RelationDef { 34 | Relation::Post.def() 35 | } 36 | } 37 | 38 | impl Related for Entity { 39 | fn to() -> RelationDef { 40 | Relation::User.def() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /crates/apub/src/activities/following/db_received_follow_impl.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::activity::FollowType; 2 | use hatsu_db_schema::received_follow::Model as DbReceivedFollow; 3 | use hatsu_utils::AppError; 4 | use url::Url; 5 | 6 | use crate::activities::{ApubReceivedFollow, Follow}; 7 | 8 | impl ApubReceivedFollow { 9 | pub fn into_json(self) -> Result { 10 | Ok(Follow { 11 | kind: FollowType::Follow, 12 | id: Url::parse(&self.id)?, 13 | actor: Url::parse(&self.actor)?.into(), 14 | to: self 15 | .to 16 | .as_deref() 17 | .and_then(|to| serde_json::from_str(to).ok()), 18 | object: Url::parse(&self.object)?.into(), 19 | }) 20 | } 21 | 22 | pub fn from_json(json: Follow) -> Result { 23 | let received_follow = DbReceivedFollow { 24 | id: json.id.to_string(), 25 | actor: json.actor.to_string(), 26 | to: json.to.and_then(|to| serde_json::to_string(&to).ok()), 27 | object: json.object.to_string(), 28 | }; 29 | 30 | Ok(received_follow.into()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /crates/db_migration/src/m20240926_000001_blocked_url.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{ 2 | prelude::*, 3 | schema::{boolean, string}, 4 | }; 5 | 6 | #[derive(DeriveMigrationName)] 7 | pub struct Migration; 8 | 9 | #[async_trait::async_trait] 10 | impl MigrationTrait for Migration { 11 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 12 | manager 13 | .create_table( 14 | Table::create() 15 | .table(BlockedUrl::Table) 16 | .if_not_exists() 17 | .col(string(BlockedUrl::Id).primary_key()) 18 | .col(boolean(BlockedUrl::IsInstance)) 19 | .to_owned(), 20 | ) 21 | .await?; 22 | 23 | Ok(()) 24 | } 25 | 26 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 27 | manager 28 | .drop_table(Table::drop().table(BlockedUrl::Table).to_owned()) 29 | .await?; 30 | 31 | Ok(()) 32 | } 33 | } 34 | 35 | #[derive(Iden)] 36 | enum BlockedUrl { 37 | Table, 38 | // Url 39 | Id, 40 | // is instance (if false, then this is actor) 41 | IsInstance, 42 | } 43 | -------------------------------------------------------------------------------- /crates/apub/tests/collections_local.rs: -------------------------------------------------------------------------------- 1 | use hatsu_apub::collections::{ 2 | Collection, 3 | // TODO: test collection page 4 | // CollectionPage 5 | }; 6 | use hatsu_utils::AppError; 7 | use url::Url; 8 | 9 | #[test] 10 | fn new_collection() -> Result<(), AppError> { 11 | let collection_id = Url::parse("https://hatsu.local/collections/test1")?; 12 | let collection = Collection::new(&collection_id, 100, 10)?; 13 | 14 | let expected_first_url = Url::parse("https://hatsu.local/collections/test1?page=1")?; 15 | let expected_last_url = Url::parse("https://hatsu.local/collections/test1?page=10")?; 16 | 17 | assert_eq!(collection.first, expected_first_url); 18 | assert_eq!(collection.last, expected_last_url); 19 | 20 | Ok(()) 21 | } 22 | 23 | #[test] 24 | fn new_empty_collection() -> Result<(), AppError> { 25 | let collection_id = Url::parse("https://hatsu.local/collections/test2")?; 26 | let collection = Collection::new(&collection_id, 0, 0)?; 27 | 28 | let expected_url = Url::parse("https://hatsu.local/collections/test2?page=1")?; 29 | 30 | assert_eq!(collection.first, expected_url); 31 | assert_eq!(collection.last, expected_url); 32 | 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /crates/tracing/src/lib.rs: -------------------------------------------------------------------------------- 1 | use hatsu_utils::AppError; 2 | use tracing::level_filters::LevelFilter; 3 | use tracing_subscriber::{ 4 | EnvFilter, 5 | Registry, 6 | fmt::Layer, 7 | layer::{Layered, SubscriberExt}, 8 | util::SubscriberInitExt, 9 | }; 10 | 11 | pub fn init() -> Result<(), AppError> { 12 | let fmt_layer = fmt_layer(); 13 | 14 | #[cfg(feature = "pretty")] 15 | let fmt_layer = fmt_layer.pretty(); 16 | 17 | let registry = tracing_subscriber::registry() 18 | .with(filter_layer()) 19 | .with(fmt_layer) 20 | .with(tracing_error::ErrorLayer::default()); 21 | 22 | #[cfg(feature = "console")] 23 | let registry = registry.with(console_subscriber::spawn()); 24 | 25 | registry.init(); 26 | 27 | Ok(()) 28 | } 29 | 30 | fn filter_layer() -> EnvFilter { 31 | EnvFilter::builder() 32 | .with_default_directive(LevelFilter::INFO.into()) 33 | .with_env_var("HATSU_LOG") 34 | .from_env_lossy() 35 | } 36 | 37 | fn fmt_layer() -> Layer> { 38 | let fmt_layer = tracing_subscriber::fmt::layer(); 39 | 40 | #[cfg(feature = "json")] 41 | let fmt_layer = fmt_layer.json(); 42 | 43 | fmt_layer 44 | } 45 | -------------------------------------------------------------------------------- /docs/src/admins/install-docker.md: -------------------------------------------------------------------------------- 1 | # Docker Installation 2 | 3 | You can find images on GitHub: [https://github.com/importantimport/hatsu/pkgs/container/hatsu](https://github.com/importantimport/hatsu/pkgs/container/hatsu) 4 | 5 | Hatsu uses three primary tags: `latest` (stable), `beta` and `nightly`, literally. 6 | 7 | ## docker run 8 | 9 | > Replace `{{version}}` with the version you want to use. 10 | 11 | ```bash 12 | docker run -d \ 13 | --name hatsu \ 14 | --restart unless-stopped \ 15 | -p 3939:3939 \ 16 | -v /opt/hatsu/hatsu.sqlite3:/app/hatsu.sqlite3 \ 17 | -e HATSU_DATABASE_URL=sqlite://hatsu.sqlite3 \ 18 | -e HATSU_DOMAIN={{hatsu-instance-domain}} \ 19 | -e HATSU_LISTEN_HOST=0.0.0.0 \ 20 | -e HATSU_PRIMARY_ACCOUNT={{your-static-site}} \ 21 | -e HATSU_ACCESS_TOKEN=123e4567-e89b-12d3-a456-426614174000 \ 22 | ghcr.io/importantimport/hatsu:{{version}} 23 | ``` 24 | 25 | You need to specify all environment variables at once. For more information, see [Environments](./environments.md). 26 | 27 | ## docker compose 28 | 29 | The [examples](https://github.com/importantimport/hatsu/tree/main/examples) folder contains some sample docker compose configurations, 30 | 31 | You can make your own modifications based on them. 32 | -------------------------------------------------------------------------------- /docs/src/users/redirecting.md: -------------------------------------------------------------------------------- 1 | # Redirecting 2 | 3 | There are two types of redirects required by Hatsu: 4 | 5 | 1. Well Known files, redirecting them to make your username searchable. 6 | 7 | - before: `https://example.com/.well-known/webfinger?resource=acct:carol@example.com` 8 | - after: `https://hatsu.local/.well-known/webfinger?resource=acct:carol@example.com` 9 | 10 | 2. Requests accept of type `application/activity+json`, redirecting them to make your page searchable. 11 | - before: `https://example.com/foo/bar` 12 | - after: `https://hatsu.local/posts/https://example.com/foo/bar` 13 | 14 | There are many ways to redirect them and you can pick one you like: 15 | 16 | ## [with Static files and Markup](./redirecting-with-static-files-and-markup.md) 17 | 18 | This should apply to most hosting services and SSG. 19 | 20 | ## [with Redirects file](./redirecting-with-redirects-file.md) 21 | 22 | Works with Netlify and Cloudflare Pages. 23 | 24 | ## [with Platform-Specific Configuration](./redirecting-with-platform-specific-config.md) 25 | 26 | Works with Netlify and Vercel. 27 | 28 | ## [with Aoba (Lume & Hono)](./redirecting-with-aoba.md) 29 | 30 | SSG plugin for Lume and Server Middleware for Deno Deploy and Netlify. 31 | -------------------------------------------------------------------------------- /crates/api_apub/src/lib.rs: -------------------------------------------------------------------------------- 1 | use hatsu_apub::{ 2 | actors::{PublicKeySchema, User, UserAttachment, UserImage}, 3 | collections::{Collection, CollectionOrPage, CollectionPage}, 4 | links::{Emoji, EmojiIcon, Hashtag, Mention, Tag}, 5 | objects::Note, 6 | }; 7 | use utoipa::OpenApi; 8 | use utoipa_axum::router::OpenApiRouter; 9 | 10 | pub mod activities; 11 | pub mod posts; 12 | pub mod users; 13 | 14 | pub const TAG: &str = "apub"; 15 | 16 | #[derive(OpenApi)] 17 | #[openapi( 18 | paths( 19 | posts::notice::notice, 20 | posts::post::post, 21 | ), 22 | components(schemas( 23 | PublicKeySchema, 24 | User, 25 | UserAttachment, 26 | UserImage, 27 | Collection, 28 | CollectionOrPage, 29 | CollectionPage, 30 | Emoji, 31 | EmojiIcon, 32 | Hashtag, 33 | Mention, 34 | Tag, 35 | Note, 36 | )), 37 | tags((name = TAG, description = "ActivityPub API")) 38 | )] 39 | pub struct ApubApi; 40 | 41 | #[must_use] 42 | pub fn routes() -> OpenApiRouter { 43 | OpenApiRouter::with_openapi(ApubApi::openapi()) 44 | .merge(activities::routes()) 45 | .merge(posts::routes()) 46 | .merge(users::routes()) 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/styles.rs: -------------------------------------------------------------------------------- 1 | use anstyle::{AnsiColor, Color, Style}; 2 | use clap::builder::Styles; 3 | 4 | #[must_use] 5 | pub const fn styles() -> Styles { 6 | Styles::styled() 7 | .usage( 8 | Style::new() 9 | .bold() 10 | .underline() 11 | .fg_color(Some(Color::Ansi(AnsiColor::BrightCyan))), 12 | ) 13 | .header( 14 | Style::new() 15 | .bold() 16 | .underline() 17 | .fg_color(Some(Color::Ansi(AnsiColor::BrightCyan))), 18 | ) 19 | .literal(Style::new().fg_color(Some(Color::Ansi(AnsiColor::Magenta)))) 20 | .invalid( 21 | Style::new() 22 | .bold() 23 | .fg_color(Some(Color::Ansi(AnsiColor::Red))), 24 | ) 25 | .error( 26 | Style::new() 27 | .bold() 28 | .fg_color(Some(Color::Ansi(AnsiColor::Red))), 29 | ) 30 | .valid( 31 | Style::new() 32 | .bold() 33 | .underline() 34 | .fg_color(Some(Color::Ansi(AnsiColor::Magenta))), 35 | ) 36 | .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::White)))) 37 | } 38 | -------------------------------------------------------------------------------- /crates/apub/src/utils/verify_blocked.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::http::StatusCode; 3 | use hatsu_db_schema::prelude::BlockedUrl; 4 | use hatsu_utils::{AppData, AppError}; 5 | use sea_orm::EntityTrait; 6 | use url::Url; 7 | 8 | pub async fn verify_blocked(url: &Url, data: &Data) -> Result<(), AppError> { 9 | let blocked_url = BlockedUrl::find().all(&data.conn).await?; 10 | 11 | if blocked_url 12 | .iter() 13 | .filter(|url| url.is_instance) 14 | .filter_map(|url| Url::parse(&url.id).ok()) 15 | .map(|url| url.origin()) 16 | .any(|instance| url.origin().eq(&instance)) 17 | { 18 | Err(AppError::new( 19 | format!("blocked instance: {:?}", url.host_str()), 20 | None, 21 | Some(StatusCode::BAD_REQUEST), 22 | )) 23 | } else if blocked_url 24 | .iter() 25 | .filter(|url| !url.is_instance) 26 | .filter_map(|url| Url::parse(&url.id).ok()) 27 | .any(|actor| url.eq(&actor)) 28 | { 29 | Err(AppError::new( 30 | format!("blocked actor: {url}"), 31 | None, 32 | Some(StatusCode::BAD_REQUEST), 33 | )) 34 | } else { 35 | Ok(()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /crates/db_migration/src/m20240501_000001_received_like.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{prelude::*, schema::string}; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(ReceivedLike::Table) 13 | .if_not_exists() 14 | .col(string(ReceivedLike::Id).primary_key()) 15 | .col(string(ReceivedLike::Actor)) 16 | .col(string(ReceivedLike::Object)) 17 | .to_owned(), 18 | ) 19 | .await?; 20 | 21 | Ok(()) 22 | } 23 | 24 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 25 | manager 26 | .drop_table(Table::drop().table(ReceivedLike::Table).to_owned()) 27 | .await?; 28 | 29 | Ok(()) 30 | } 31 | } 32 | 33 | /// 34 | #[derive(Iden)] 35 | enum ReceivedLike { 36 | Table, 37 | // Like Activity Url 38 | Id, 39 | // Attributed To 40 | Actor, 41 | // Liked Post Url 42 | Object, 43 | } 44 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/entities/custom_emoji.rs: -------------------------------------------------------------------------------- 1 | use hatsu_apub::links::{Emoji, Tag}; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | use utoipa::ToSchema; 5 | 6 | /// 7 | #[derive(Debug, Deserialize, Serialize, ToSchema)] 8 | pub struct CustomEmoji { 9 | shortcode: String, 10 | url: Url, 11 | static_url: Url, 12 | visible_in_picker: bool, 13 | #[serde(skip_serializing_if = "Option::is_none")] 14 | category: Option, 15 | } 16 | 17 | impl CustomEmoji { 18 | #[must_use] 19 | pub fn from_json(tags: Vec) -> Vec { 20 | tags.into_iter() 21 | .filter_map(|tag| match tag { 22 | Tag::Emoji(emoji) => Some(Self::from_emoji(emoji)), 23 | _ => None, 24 | }) 25 | .collect() 26 | } 27 | 28 | #[must_use] 29 | pub fn from_emoji(emoji: Emoji) -> Self { 30 | Self { 31 | shortcode: emoji 32 | .name 33 | .trim_start_matches(':') 34 | .trim_end_matches(':') 35 | .to_string(), 36 | url: emoji.icon.url.clone(), 37 | static_url: emoji.icon.url, 38 | visible_in_picker: false, 39 | category: None, 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /crates/db_migration/src/m20240501_000002_received_announce.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{prelude::*, schema::string}; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(ReceivedAnnounce::Table) 13 | .if_not_exists() 14 | .col(string(ReceivedAnnounce::Id).primary_key()) 15 | .col(string(ReceivedAnnounce::Actor)) 16 | .col(string(ReceivedAnnounce::Object)) 17 | .to_owned(), 18 | ) 19 | .await?; 20 | 21 | Ok(()) 22 | } 23 | 24 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 25 | manager 26 | .drop_table(Table::drop().table(ReceivedAnnounce::Table).to_owned()) 27 | .await?; 28 | 29 | Ok(()) 30 | } 31 | } 32 | 33 | /// 34 | #[derive(Iden)] 35 | enum ReceivedAnnounce { 36 | Table, 37 | // Announce Activity Url 38 | Id, 39 | // Attributed To 40 | Actor, 41 | // Announced Post Url 42 | Object, 43 | } 44 | -------------------------------------------------------------------------------- /crates/cron/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use activitypub_federation::config::FederationConfig; 4 | use apalis::prelude::{Monitor, WorkerBuilder, WorkerFactoryFn}; 5 | use apalis_cron::{CronStream, Schedule}; 6 | use hatsu_utils::{AppData, AppError}; 7 | 8 | mod jobs; 9 | mod tasks; 10 | 11 | pub async fn run(federation_config: &FederationConfig) -> Result<(), AppError> { 12 | let partial_update_schedule = Schedule::from_str("0 */5 * * * *")?; 13 | let partial_update_worker = WorkerBuilder::new("hatsu_cron::jobs::PartialUpdate") 14 | .data(federation_config.clone()) 15 | // .layer(RetryLayer::new(RetryPolicy::retries(5))) 16 | // .layer(TraceLayer::new().make_span_with(ReminderSpan::new())) 17 | .backend(CronStream::new(partial_update_schedule)) 18 | .build_fn(jobs::partial_update); 19 | 20 | let full_update_schedule = Schedule::from_str("0 */10 * * * *")?; 21 | let full_update_worker = WorkerBuilder::new("hatsu_cron::jobs::FullUpdate") 22 | .data(federation_config.clone()) 23 | .backend(CronStream::new(full_update_schedule)) 24 | .build_fn(jobs::full_update); 25 | 26 | Monitor::new() 27 | .register(partial_update_worker) 28 | .register(full_update_worker) 29 | .run_with_signal(hatsu_utils::shutdown_signal()) 30 | .await?; 31 | 32 | Ok(()) 33 | } 34 | -------------------------------------------------------------------------------- /crates/db_migration/README.md: -------------------------------------------------------------------------------- 1 | # hatsu_db_migration 2 | 3 | ## Usage 4 | 5 | ```bash 6 | # via just 7 | just db_migration [COMMAND] [OPTIONS] 8 | # via sea-orm-cli 9 | sea-orm-cli migrate [COMMAND] [OPTIONS] -d crates/db_migration 10 | # via cargo 11 | cargo run -p hatsu_db_migration -- [COMMAND] [OPTIONS] 12 | # via cargo (in this directory) 13 | cargo run -- [COMMAND] [OPTIONS] 14 | ``` 15 | 16 | ### Options 17 | 18 | - Generate a new migration file 19 | ```sh 20 | cargo run -- migrate generate MIGRATION_NAME 21 | ``` 22 | - Apply all pending migrations 23 | ```sh 24 | cargo run 25 | ``` 26 | ```sh 27 | cargo run -- up 28 | ``` 29 | - Apply first 10 pending migrations 30 | ```sh 31 | cargo run -- up -n 10 32 | ``` 33 | - Rollback last applied migrations 34 | ```sh 35 | cargo run -- down 36 | ``` 37 | - Rollback last 10 applied migrations 38 | ```sh 39 | cargo run -- down -n 10 40 | ``` 41 | - Drop all tables from the database, then reapply all migrations 42 | ```sh 43 | cargo run -- fresh 44 | ``` 45 | - Rollback all applied migrations, then reapply all migrations 46 | ```sh 47 | cargo run -- refresh 48 | ``` 49 | - Rollback all applied migrations 50 | ```sh 51 | cargo run -- reset 52 | ``` 53 | - Check the status of all migrations 54 | ```sh 55 | cargo run -- status 56 | ``` 57 | -------------------------------------------------------------------------------- /crates/feed/src/user_feed_item_hatsu.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use hatsu_db_schema::user_feed_item::UserFeedItemHatsu as DbUserFeedItemHatsu; 4 | use serde::{Deserialize, Serialize}; 5 | use url::Url; 6 | 7 | /// Hatsu JSON Feed Item Extension 8 | /// 9 | /// 10 | /// 11 | /// 12 | #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] 13 | pub struct UserFeedItemHatsu { 14 | pub about: Option, 15 | } 16 | 17 | #[derive(Clone, Debug, PartialEq, Eq)] 18 | pub struct WrappedUserFeedItemHatsu(pub(crate) DbUserFeedItemHatsu); 19 | 20 | impl AsRef for WrappedUserFeedItemHatsu { 21 | fn as_ref(&self) -> &DbUserFeedItemHatsu { 22 | &self.0 23 | } 24 | } 25 | 26 | impl Deref for WrappedUserFeedItemHatsu { 27 | type Target = DbUserFeedItemHatsu; 28 | 29 | fn deref(&self) -> &Self::Target { 30 | &self.0 31 | } 32 | } 33 | 34 | impl From for WrappedUserFeedItemHatsu { 35 | fn from(u: DbUserFeedItemHatsu) -> Self { 36 | Self(u) 37 | } 38 | } 39 | 40 | impl UserFeedItemHatsu { 41 | #[must_use] 42 | pub fn into_db(self) -> DbUserFeedItemHatsu { 43 | DbUserFeedItemHatsu { 44 | about: self.about.map(|url| url.to_string()), 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/src/developers/development-docker.md: -------------------------------------------------------------------------------- 1 | # Docker Development 2 | 3 | > You'll need to complete [prepare](./01-prepare.md) before you do this. 4 | 5 | ## Dependencies 6 | 7 | To use Docker, you only need to install Docker and Docker Compose. 8 | 9 | ```bash 10 | # Arch-based distro 11 | sudo pacman -S docker docker-compose 12 | 13 | # Debian-based distro 14 | sudo apt install docker.io docker-compose 15 | ``` 16 | 17 | 18 | 19 | ## Running 20 | 21 | First copy the variables, 22 | 23 | Set `HATSU_DOMAIN` to your prepared domain 24 | (e.g. `hatsu.example.com` without `https://`) 25 | 26 | and `HATSU_PRIMARY_ACCOUNT` to your desired user domain 27 | (e.g. `blog.example.com` without `https://`) 28 | 29 | ```bash 30 | # copy env example 31 | cp .env.example .env 32 | # edit env 33 | nano .env 34 | ``` 35 | 36 | Then create the database file and run: 37 | 38 | ```bash 39 | # create database 40 | touch hatsu.sqlite3 41 | # run hatsu 42 | docker-compose up -d 43 | ``` 44 | 45 | If there is no build image, it will be built automatically at execution time. 46 | Hatsu uses [cargo-chef](https://crates.io/crates/cargo-chef) in the [Dockerfile](https://github.com/importantimport/hatsu/blob/main/Dockerfile), 47 | which caches dependencies to avoid duplicate build dependencies. 48 | 49 | If you need to rebuild, add the `--build` flag: 50 | 51 | ```bash 52 | docker-compose up -d --build 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/src/others/json-feed-extension.md: -------------------------------------------------------------------------------- 1 | # Hatsu JSON Feed Extension 2 | 3 | To allow you to customize your postings, Hatsu defines a JSON Feed extension that uses the `_hatsu` key. 4 | 5 | All extension keys for the Hatsu JSON Feed Extension are optional. 6 | 7 | > Note: everything here is experimental. It is always subject to breaking changes and does not follow semver. 8 | 9 | ## Top-level 10 | 11 | The following applies to the [Top-level JSON Feed](https://www.jsonfeed.org/version/1.1/#top-level-a-name-top-level-a). 12 | 13 | - `about` (optional but strongly recommended, string) is the URL used to introduce this extension to humans. should be [https://github.com/importantimport/hatsu/issues/1](https://github.com/importantimport/hatsu/issues/1) . 14 | - `aliases` (optional, string) is the customized username used for [FEP-4adb](https://codeberg.org/fediverse/fep/src/branch/main/fep/4adb/fep-4adb.md) and [FEP-2c59](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md). 15 | - `banner_image` (optional, string) is the URL of the banner image for the website in hatsu. 16 | 17 | ## Items 18 | 19 | The following applies to the [JSON Feed Item](https://www.jsonfeed.org/version/1.1/#items-a-name-items-a). 20 | 21 | - `about` (optional, string) is the URL used to introduce this extension to humans. should be [https://github.com/importantimport/hatsu/issues/1](https://github.com/importantimport/hatsu/issues/1) . 22 | -------------------------------------------------------------------------------- /crates/db_migration/src/m20240131_000005_received_follow.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{ 2 | prelude::*, 3 | schema::{string, text_null}, 4 | }; 5 | 6 | #[derive(DeriveMigrationName)] 7 | pub struct Migration; 8 | 9 | #[async_trait::async_trait] 10 | impl MigrationTrait for Migration { 11 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 12 | manager 13 | .create_table( 14 | Table::create() 15 | .table(ReceivedFollow::Table) 16 | .if_not_exists() 17 | .col(string(ReceivedFollow::Id).primary_key()) 18 | .col(string(ReceivedFollow::Actor)) 19 | .col(text_null(ReceivedFollow::To)) 20 | .col(string(ReceivedFollow::Object)) 21 | .to_owned(), 22 | ) 23 | .await?; 24 | 25 | Ok(()) 26 | } 27 | 28 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 29 | manager 30 | .drop_table(Table::drop().table(ReceivedFollow::Table).to_owned()) 31 | .await?; 32 | 33 | Ok(()) 34 | } 35 | } 36 | 37 | /// 38 | #[derive(Iden)] 39 | enum ReceivedFollow { 40 | Table, 41 | // Follow Url 42 | Id, 43 | // 关注者 ID 44 | Actor, 45 | // 可选,兼容性 46 | To, 47 | // 被关注者 Id 48 | Object, 49 | } 50 | -------------------------------------------------------------------------------- /crates/db_migration/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use sea_orm_migration::prelude::*; 2 | 3 | mod m20240131_000001_user; 4 | mod m20240131_000002_user_feed_item; 5 | mod m20240131_000003_post; 6 | mod m20240131_000004_activity; 7 | mod m20240131_000005_received_follow; 8 | mod m20240501_000001_received_like; 9 | mod m20240501_000002_received_announce; 10 | mod m20240515_000001_user_feed_hatsu_extension; 11 | mod m20240515_000002_user_feed; 12 | mod m20240926_000001_blocked_url; 13 | mod m20241028_000001_user_language; 14 | 15 | pub struct Migrator; 16 | 17 | #[async_trait::async_trait] 18 | impl MigratorTrait for Migrator { 19 | fn migrations() -> Vec> { 20 | vec![ 21 | Box::new(m20240131_000001_user::Migration), 22 | Box::new(m20240131_000002_user_feed_item::Migration), 23 | Box::new(m20240131_000003_post::Migration), 24 | Box::new(m20240131_000004_activity::Migration), 25 | Box::new(m20240131_000005_received_follow::Migration), 26 | Box::new(m20240501_000001_received_like::Migration), 27 | Box::new(m20240501_000002_received_announce::Migration), 28 | Box::new(m20240515_000001_user_feed_hatsu_extension::Migration), 29 | Box::new(m20240515_000002_user_feed::Migration), 30 | Box::new(m20240926_000001_blocked_url::Migration), 31 | Box::new(m20241028_000001_user_language::Migration), 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /crates/apub/src/collections/collection.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::collection::OrderedCollectionType; 2 | use hatsu_utils::AppError; 3 | use serde::{Deserialize, Serialize}; 4 | use url::Url; 5 | use utoipa::ToSchema; 6 | 7 | use crate::collections::generate_collection_page_url; 8 | 9 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct Collection { 12 | #[schema(value_type = String)] 13 | #[serde(rename = "type")] 14 | pub kind: OrderedCollectionType, 15 | // example: https://hatsu.local/users/example.com/collection 16 | pub id: Url, 17 | // example: https://hatsu.local/users/example.com/collection?page=1 18 | pub first: Url, 19 | // example: https://hatsu.local/users/example.com/collection?page=64 20 | pub last: Url, 21 | // collection count 22 | pub total_items: u64, 23 | } 24 | 25 | impl Collection { 26 | pub fn new(collection_id: &Url, total_items: u64, total_pages: u64) -> Result { 27 | Ok(Self { 28 | kind: OrderedCollectionType::OrderedCollection, 29 | id: collection_id.clone(), 30 | first: generate_collection_page_url(collection_id, 1)?, 31 | last: generate_collection_page_url(collection_id, match total_pages { 32 | page if page > 0 => page, 33 | _ => 1, 34 | })?, 35 | total_items, 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/src/admins/install-nix.md: -------------------------------------------------------------------------------- 1 | # Nix/NixOS Installation 2 | 3 | [![nixpkgs unstable package](https://repology.org/badge/version-for-repo/nix_unstable/hatsu.svg)](https://repology.org/project/hatsu/versions) 4 | 5 | Hatsu is available in Nixpkgs, NUR and Flakes. 6 | 7 | macOS (Darwin) is not supported. 8 | 9 | ## Nixpkgs 10 | 11 | Nixpkgs only has a stable version, you need nixos-24.11 or nixos-unstable. 12 | 13 | ```nix 14 | { pkgs, ... }: { 15 | environment.systemPackages = with pkgs; [ 16 | hatsu 17 | ]; 18 | } 19 | ``` 20 | 21 | ## NUR (SN0WM1X) 22 | 23 | The SN0WM1X NUR may contain beta versions, but there may be a delay. 24 | 25 | You need to [follow the instructions to set up NUR](https://github.com/nix-community/nur#installation) first. 26 | 27 | ```nix 28 | { pkgs, ... }: { 29 | environment.systemPackages = with pkgs; [ 30 | nur.repos.sn0wm1x.hatsu 31 | ]; 32 | } 33 | ``` 34 | 35 | ## Flakes 36 | 37 | > This is untested. 38 | 39 | Add the hatsu repository directly to your flake inputs, up to date but unstable. 40 | 41 | ```nix 42 | { 43 | inputs: { 44 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 45 | # ... 46 | hatsu.url = "github:importantimport/hatsu"; 47 | hatsu.inputs.nixpkgs.follows = "nixpkgs"; 48 | # ... 49 | }; 50 | } 51 | ``` 52 | 53 | ```nix 54 | { inputs, pkgs, ... }: { 55 | environment.systemPackages = [ 56 | inputs.hatsu.packages.${pkgs.system}.default; 57 | ]; 58 | } 59 | ``` 60 | -------------------------------------------------------------------------------- /crates/well_known/src/entities/webfinger.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::Serialize; 4 | use url::Url; 5 | use utoipa::ToSchema; 6 | 7 | /// impl `ToSchema` for `Webfinger` 8 | #[derive(Serialize, ToSchema)] 9 | pub struct WebfingerSchema { 10 | /// The actor which is described here, for example `acct:LemmyDev@mastodon.social` 11 | pub subject: String, 12 | /// Links where further data about `subject` can be retrieved 13 | pub links: Vec, 14 | /// Other Urls which identify the same actor as the `subject` 15 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 16 | pub aliases: Vec, 17 | /// Additional data about the subject 18 | #[serde(default, skip_serializing_if = "HashMap::is_empty")] 19 | pub properties: HashMap, 20 | } 21 | 22 | /// impl `ToSchema` for `WebfingerLink` 23 | #[derive(Serialize, ToSchema)] 24 | pub struct WebfingerSchemaLink { 25 | /// Relationship of the link, such as `self` or `http://webfinger.net/rel/profile-page` 26 | pub rel: Option, 27 | /// Media type of the target resource 28 | #[serde(rename = "type")] 29 | pub kind: Option, 30 | /// Url pointing to the target resource 31 | pub href: Option, 32 | /// Used for remote follow external interaction url 33 | pub template: Option, 34 | /// Additional data about the link 35 | #[serde(default, skip_serializing_if = "HashMap::is_empty")] 36 | pub properties: HashMap, 37 | } 38 | -------------------------------------------------------------------------------- /docs/src/others/compatibility-chart.md: -------------------------------------------------------------------------------- 1 | # Compatibility Chart 2 | 3 | Hatsu is primarily geared towards the micro-blogging platform in the Fediverse. 4 | 5 | Currently I've created a chart for all the platforms I expect to be compatible with, and hopefully it will be filled in later: 6 | 7 | ## Send 8 | 9 | | | [Create] (Note) | [Accept] (Follow) | 10 | | ------------ | --------------- | ----------------- | 11 | | [Mastodon] | ✅ | ✅ | 12 | | [GoToSocial] | | | 13 | | [Misskey] | | | 14 | | [Pleroma] | ✅ | | 15 | 16 | ## Receive 17 | 18 | | | [Follow] | | 19 | | ------------ | -------- | --- | 20 | | [Mastodon] | ✅ | | 21 | | [GoToSocial] | | | 22 | | [Misskey] | | | 23 | | [Pleroma] | | | 24 | 25 | [Akkoma], [Sharkey], etc. forks should be compatible with upstream, so they are not listed separately. 26 | 27 | [Create]: https://www.w3.org/ns/activitystreams#Note 28 | [Accept]: https://www.w3.org/ns/activitystreams#Accept 29 | [Follow]: https://www.w3.org/ns/activitystreams#Follow 30 | 31 | [Mastodon]: https://github.com/mastodon/mastodon 32 | [GoToSocial]: https://github.com/superseriousbusiness/gotosocial 33 | [Misskey]: https://github.com/misskey-dev/misskey 34 | [Pleroma]: https://git.pleroma.social/pleroma/pleroma/ 35 | [Akkoma]: https://akkoma.dev/AkkomaGang/akkoma/ 36 | [Sharkey]: https://activitypub.software/TransFem-org/Sharkey 37 | -------------------------------------------------------------------------------- /crates/apub/tests/objects.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::{link::MentionType, object::ImageType}; 2 | use hatsu_apub::{ 3 | links::{Emoji, EmojiIcon, Mention, Tag}, 4 | objects::Note, 5 | tests::test_asset, 6 | }; 7 | use hatsu_utils::AppError; 8 | use url::Url; 9 | 10 | #[test] 11 | fn test_parse_notes() -> Result<(), AppError> { 12 | // Akkoma 13 | let akkoma_note = test_asset::("assets/akkoma/objects/note.json")?; 14 | assert_eq!(akkoma_note.inner().clone().tag, vec![ 15 | Tag::Mention(Mention { 16 | href: Url::parse( 17 | "https://hatsu-nightly-debug.hyp3r.link/users/kwaa-blog-next.deno.dev" 18 | )?, 19 | name: String::from("@kwaa-blog-next.deno.dev@hatsu-nightly-debug.hyp3r.link"), 20 | kind: MentionType::Mention 21 | }), 22 | Tag::Emoji(Emoji { 23 | icon: EmojiIcon { 24 | media_type: None, 25 | kind: ImageType::Image, 26 | url: Url::parse("https://social.qunn.eu/emoji/mergans_cats/acat_chew.webp")?, 27 | }, 28 | id: Url::parse("https://social.qunn.eu/emoji/mergans_cats/acat_chew.webp")?, 29 | name: String::from(":acat_chew:"), 30 | kind: Default::default(), 31 | updated: Some(String::from("1970-01-01T00:00:00Z")), 32 | }) 33 | ]); 34 | 35 | // GoToSocial 36 | test_asset::("assets/gotosocial/objects/note.json")?; 37 | test_asset::("assets/gotosocial/objects/note_without_tag.json")?; 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /crates/frontend/src/pages/home.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use hatsu_utils::AppData; 3 | use maud::{Markup, html}; 4 | 5 | use crate::partials::layout; 6 | 7 | pub async fn home(data: Data) -> Markup { 8 | let title = data 9 | .env 10 | .hatsu_node_name 11 | .clone() 12 | .unwrap_or_else(|| String::from("Hatsu")); 13 | 14 | layout( 15 | &html! { 16 | @if let Some(description) = &data.env.hatsu_node_description { 17 | h2 class="md-typescale-title-large" style="margin-top: 0" { "About this instance" } 18 | p style="margin: 0" { (description) } 19 | br; 20 | md-divider {} 21 | h2 class="md-typescale-title-large" { "What is this?" } 22 | } @else { 23 | h2 class="md-typescale-title-large" style="margin-top: 0" { "What is this?" } 24 | } 25 | p style="margin: 0" { r#" 26 | The web page you're reading right now is served by an instance of Hatsu, 27 | a self-hosted bridge that interacts with Fediverse on behalf of static site. 28 | "# } 29 | br; 30 | md-divider {} 31 | h2 class="md-typescale-title-large" { "Register an Account on " (title) } 32 | p style="margin: 0" { 33 | "New account registration is currently " 34 | // TODO: add registration status 35 | b { "closed" } 36 | "." 37 | } 38 | }, 39 | &data, 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /crates/db_migration/src/m20240131_000004_activity.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{ 2 | prelude::*, 3 | schema::{json, string, string_null}, 4 | }; 5 | 6 | #[derive(DeriveMigrationName)] 7 | pub struct Migration; 8 | 9 | #[async_trait::async_trait] 10 | impl MigrationTrait for Migration { 11 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 12 | manager 13 | .create_table( 14 | Table::create() 15 | .table(Activity::Table) 16 | .if_not_exists() 17 | .col(string(Activity::Id).primary_key()) 18 | .col(json(Activity::Activity)) 19 | .col(string(Activity::Actor)) 20 | .col(string(Activity::Kind)) 21 | .col(string_null(Activity::Published)) 22 | .to_owned(), 23 | ) 24 | .await?; 25 | 26 | Ok(()) 27 | } 28 | 29 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 30 | manager 31 | .drop_table(Table::drop().table(Activity::Table).to_owned()) 32 | .await?; 33 | 34 | Ok(()) 35 | } 36 | } 37 | 38 | #[derive(Iden)] 39 | enum Activity { 40 | Table, 41 | // Activity URL 42 | Id, 43 | // Activity JSON 44 | /// 45 | #[allow(clippy::enum_variant_names)] 46 | Activity, 47 | // Activity Actor 48 | Actor, 49 | // Activity Type 50 | Kind, 51 | // Activity Publish Date (optional) 52 | Published, 53 | } 54 | -------------------------------------------------------------------------------- /crates/api_admin/src/routes/unblock_url.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{Json, debug_handler, extract::Query, http::StatusCode}; 3 | use hatsu_db_schema::prelude::BlockedUrl; 4 | use hatsu_utils::{AppData, AppError}; 5 | use sea_orm::{EntityTrait, ModelTrait}; 6 | 7 | use crate::{ 8 | TAG, 9 | entities::{BlockUrlQuery, BlockUrlResult}, 10 | }; 11 | 12 | /// Unblock URL 13 | #[utoipa::path( 14 | post, 15 | tag = TAG, 16 | path = "/api/v0/admin/unblock-url", 17 | params(BlockUrlQuery), 18 | responses( 19 | (status = OK, description = "unblock successfully", body = BlockUrlResult), 20 | (status = BAD_REQUEST, description = "error", body = AppError) 21 | ), 22 | security(("api_key" = ["token"])) 23 | )] 24 | #[debug_handler] 25 | pub async fn unblock_url( 26 | data: Data, 27 | query: Query, 28 | ) -> Result<(StatusCode, Json), AppError> { 29 | match BlockedUrl::find_by_id(query.url.to_string()) 30 | .one(&data.conn) 31 | .await? 32 | { 33 | Some(url) => { 34 | url.delete(&data.conn).await?; 35 | 36 | Ok(( 37 | StatusCode::OK, 38 | Json(BlockUrlResult { 39 | url: query.url.clone(), 40 | message: format!("The url was successfully unblocked: {}", &query.url), 41 | }), 42 | )) 43 | }, 44 | None => Err(AppError::new( 45 | format!("The url doesn't exist: {}", query.url), 46 | None, 47 | Some(StatusCode::BAD_REQUEST), 48 | )), 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /crates/db_schema/src/user_feed_item.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1 2 | 3 | use sea_orm::{FromJsonQueryResult, entity::prelude::*}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] 7 | #[sea_orm(table_name = "user_feed_item")] 8 | pub struct Model { 9 | #[sea_orm(primary_key, auto_increment = false)] 10 | pub id: String, 11 | pub user_id: String, 12 | pub post_id: Option, 13 | pub title: Option, 14 | pub summary: Option, 15 | pub language: Option, 16 | pub tags: Option, 17 | pub date_published: Option, 18 | pub date_modified: Option, 19 | pub hatsu: Option, 20 | } 21 | 22 | #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, FromJsonQueryResult)] 23 | pub struct UserFeedItemHatsu { 24 | pub about: Option, 25 | } 26 | 27 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 28 | pub enum Relation { 29 | #[sea_orm( 30 | belongs_to = "super::user::Entity", 31 | from = "Column::UserId", 32 | to = "super::user::Column::Id" 33 | )] 34 | User, 35 | #[sea_orm( 36 | belongs_to = "super::post::Entity", 37 | from = "Column::PostId", 38 | to = "super::post::Column::Id" 39 | )] 40 | Post, 41 | } 42 | 43 | impl ActiveModelBehavior for ActiveModel {} 44 | 45 | impl Related for Entity { 46 | fn to() -> RelationDef { 47 | Relation::User.def() 48 | } 49 | } 50 | 51 | impl Related for Entity { 52 | fn to() -> RelationDef { 53 | Relation::Post.def() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Intro](./intro.md) 4 | 5 | # for Users 6 | 7 | - [Getting Started](./users/getting-started.md) 8 | - [Feed](./users/feed.md) 9 | - [Redirecting](./users/redirecting.md) 10 | - [with Static files and Markup](./users/redirecting-with-static-files-and-markup.md) 11 | - [with Redirects file](./users/redirecting-with-redirects-file.md) 12 | - [with Platform-Specific Configuration](./users/redirecting-with-platform-specific-config.md) 13 | - [with Aoba (Lume & Hono)](./users/redirecting-with-aoba.md) 14 | - [with FEP-612d](./users/redirecting-with-fep-612d.md) 15 | - [Backfeed](./users/backfeed.md) 16 | - [based on KKna](./users/backfeed-based-on-kkna.md) 17 | - [based on Mastodon Comments](./users/backfeed-based-on-mastodon-comments.md) 18 | - [based on Webmention (TODO)](./users/backfeed-based-on-webmention.md) 19 | 20 | # for Admins 21 | 22 | - [Install](./admins/install.md) 23 | - [Docker Installation](./admins/install-docker.md) 24 | - [Binary Installation](./admins/install-binary.md) 25 | - [Nix/NixOS Installation](./admins/install-nix.md) 26 | - [Environments](./admins/environments.md) 27 | - [Create Account](./admins/create-account.md) 28 | - [Block Instances or Actors](./admins/block-instances-or-actors.md) 29 | 30 | # for Developers 31 | 32 | - [Prepare](./developers/prepare.md) 33 | - [Local Development](./developers/development-local.md) 34 | - [Docker Development](./developers/development-docker.md) 35 | 36 | # Others 37 | 38 | - [Compatibility Chart](./others/compatibility-chart.md) 39 | - [Federation](./others/federation.md) 40 | - [JSON Feed Extension](./others/json-feed-extension.md) 41 | - [Packaging Status](./others/packaging-status.md) 42 | -------------------------------------------------------------------------------- /docs/src/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Hatsu is a self-hosted bridge that interacts with Fediverse on behalf of your static site. 4 | 5 | Normally it can do all the: 6 | 7 | - When a Fediverse user searches for a user of your site (`@catch-all@example.com`), [redirects](./users/redirecting-with-static-files-and-markup.md#well-known) to the corresponding user of the Hatsu instance. 8 | - When a Fediverse user searches for your site URL (`https://example.com/hello-world`), [redirects](./users/redirecting-with-static-files-and-markup.md#as2-alternate) to the corresponding post on the Hatsu instance. 9 | - Accepts follow requests and pushes new posts to the follower's homepage as they become available. 10 | - Receive replies from Fediverse users and [backfeed](./users/backfeed.md) to your static site. 11 | 12 | Best of all, these are fully automated! Just set it up once and you won't need to do anything else. 13 | 14 | ## Comparison 15 | 16 | Hatsu is still a Work-In-Progress. It is similar to Bridgy Fed but different: 17 | 18 | - Hatsu uses Feed (JSON / Atom / RSS) as a data source instead of HTML pages with microformats2. 19 | - Hatsu doesn't require you to automatically or manually send Webmention reminders for create and update, it's all fully automated. 20 | - Hatsu is ActivityPub only, which means it doesn't handle Nostr, AT Protocol (Bluesky) or other protocols. 21 | 22 | If you don't want to self-host, you may still want to use Bridgy Fed or Bridgy in some cases: 23 | 24 | ### Bridgy Fed 25 | 26 | - You don't mind compatibility with platforms other than Mastodon. 27 | - Your site has good microformats2 markup. 28 | 29 | ### Bridgy 30 | 31 | - You already have a Fediverse account ready to be used for this purpose. 32 | - Your site has good microformats2 markup. 33 | -------------------------------------------------------------------------------- /crates/api_apub/src/posts/post.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{ 2 | axum::json::FederationJson, 3 | config::Data, 4 | protocol::context::WithContext, 5 | traits::Object, 6 | }; 7 | use axum::{debug_handler, extract::Path, response::Redirect}; 8 | use hatsu_apub::objects::{ApubPost, Note}; 9 | use hatsu_db_schema::prelude::Post; 10 | use hatsu_utils::{AppData, AppError}; 11 | use sea_orm::EntityTrait; 12 | 13 | use crate::TAG; 14 | 15 | /// Get post 16 | #[utoipa::path( 17 | get, 18 | tag = TAG, 19 | path = "/posts/{post}", 20 | responses( 21 | (status = OK, description = "Post", body = Note), 22 | (status = NOT_FOUND, description = "Post does not exist", body = AppError) 23 | ), 24 | params( 25 | ("post" = String, Path, description = "The Url of the Post in the database.") 26 | ) 27 | )] 28 | #[debug_handler] 29 | pub async fn post( 30 | Path(post_id): Path, 31 | data: Data, 32 | ) -> Result>, AppError> { 33 | tracing::info!("Reading post {}", post_id); 34 | 35 | let post_url = hatsu_utils::url::generate_post_url(data.domain(), post_id)?; 36 | 37 | match Post::find_by_id(post_url.to_string()) 38 | .one(&data.conn) 39 | .await? 40 | { 41 | Some(db_post) => { 42 | let apub_post: ApubPost = db_post.into(); 43 | Ok(FederationJson(WithContext::new_default( 44 | apub_post.into_json(&data).await?, 45 | ))) 46 | }, 47 | None => Err(AppError::not_found("Post", post_url.as_ref())), 48 | } 49 | } 50 | 51 | #[debug_handler] 52 | pub async fn redirect(Path(post_id): Path) -> Redirect { 53 | Redirect::permanent(&format!("/posts/{post_id}")) 54 | } 55 | -------------------------------------------------------------------------------- /crates/feed/src/user_feed_hatsu.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use hatsu_db_schema::user::UserHatsu as DbUserHatsu; 4 | use serde::{Deserialize, Serialize}; 5 | use url::Url; 6 | 7 | /// Hatsu JSON Feed Extension 8 | /// 9 | /// 10 | /// 11 | /// 12 | #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] 13 | pub struct UserFeedHatsu { 14 | pub about: Option, 15 | pub aliases: Option, 16 | pub banner_image: Option, 17 | } 18 | 19 | #[derive(Clone, Debug, PartialEq, Eq)] 20 | pub struct WrappedUserHatsu(pub(crate) DbUserHatsu); 21 | 22 | impl AsRef for WrappedUserHatsu { 23 | fn as_ref(&self) -> &DbUserHatsu { 24 | &self.0 25 | } 26 | } 27 | 28 | impl Deref for WrappedUserHatsu { 29 | type Target = DbUserHatsu; 30 | 31 | fn deref(&self) -> &Self::Target { 32 | &self.0 33 | } 34 | } 35 | 36 | impl From for WrappedUserHatsu { 37 | fn from(u: DbUserHatsu) -> Self { 38 | Self(u) 39 | } 40 | } 41 | 42 | impl UserFeedHatsu { 43 | #[must_use] 44 | pub fn into_db(self) -> DbUserHatsu { 45 | DbUserHatsu { 46 | about: self.about.map(|url| url.to_string()), 47 | aliases: self.aliases, 48 | banner_image: self.banner_image.map(|url| url.to_string()), 49 | } 50 | } 51 | 52 | #[must_use] 53 | pub fn from_db(db_hatsu: DbUserHatsu) -> Self { 54 | Self { 55 | about: db_hatsu.about.and_then(|url| Url::parse(&url).ok()), 56 | aliases: db_hatsu.aliases, 57 | banner_image: db_hatsu.banner_image.and_then(|url| Url::parse(&url).ok()), 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /FEDERATION.md: -------------------------------------------------------------------------------- 1 | # Federation in Hatsu 2 | 3 | ## Supported federation protocols and standards 4 | 5 | - [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server) 6 | - [Http Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) 7 | - [WebFinger](https://webfinger.net/) 8 | - [NodeInfo](https://nodeinfo.diaspora.software/) 9 | - [Web Host Metadata](https://datatracker.ietf.org/doc/html/rfc6415) 10 | 11 | ## Supported FEPs 12 | 13 | - [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) 14 | - [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) 15 | - [FEP-fffd: Proxy Objects](https://codeberg.org/fediverse/fep/src/branch/main/fep/fffd/fep-fffd.md) 16 | - [FEP-4adb: Dereferencing identifiers with webfinger](https://codeberg.org/fediverse/fep/src/branch/main/fep/4adb/fep-4adb.md) 17 | - [FEP-2c59: Discovery of a Webfinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md) 18 | 19 | ## ActivityPub 20 | 21 | The following activities and object types are supported: 22 | 23 | ### Send 24 | 25 | - `Accept(Follow)` 26 | - `Create(Note)`, `Update(Note)` 27 | 28 | 29 | 30 | ### Receive 31 | 32 | - `Follow(Actor)`, `Undo(Follow)` 33 | - `Create(Note)` 34 | - `Like(Note)`, `Undo(Like)` 35 | - `Announce(Note)`, `Undo(Announce)` 36 | 37 | 38 | 39 | Activities are implemented in way that is compatible with Mastodon and other 40 | popular ActivityPub servers. 41 | 42 | ### Notable differences 43 | 44 | - No shared inbox. 45 | 46 | ## Additional documentation 47 | 48 | - [Hatsu Documentation](https://hatsu.cli.rs) 49 | -------------------------------------------------------------------------------- /docs/src/others/federation.md: -------------------------------------------------------------------------------- 1 | # Federation in Hatsu 2 | 3 | ## Supported federation protocols and standards 4 | 5 | - [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server) 6 | - [Http Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) 7 | - [WebFinger](https://webfinger.net/) 8 | - [NodeInfo](https://nodeinfo.diaspora.software/) 9 | - [Web Host Metadata](https://datatracker.ietf.org/doc/html/rfc6415) 10 | 11 | ## Supported FEPs 12 | 13 | - [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) 14 | - [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) 15 | - [FEP-fffd: Proxy Objects](https://codeberg.org/fediverse/fep/src/branch/main/fep/fffd/fep-fffd.md) 16 | - [FEP-4adb: Dereferencing identifiers with webfinger](https://codeberg.org/fediverse/fep/src/branch/main/fep/4adb/fep-4adb.md) 17 | - [FEP-2c59: Discovery of a Webfinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md) 18 | 19 | ## ActivityPub 20 | 21 | The following activities and object types are supported: 22 | 23 | ### Send 24 | 25 | - `Accept(Follow)` 26 | - `Create(Note)`, `Update(Note)` 27 | 28 | 29 | 30 | ### Receive 31 | 32 | - `Follow(Actor)`, `Undo(Follow)` 33 | - `Create(Note)` 34 | - `Like(Note)`, `Undo(Like)` 35 | - `Announce(Note)`, `Undo(Announce)` 36 | 37 | 38 | 39 | Activities are implemented in way that is compatible with Mastodon and other 40 | popular ActivityPub servers. 41 | 42 | ### Notable differences 43 | 44 | - No shared inbox. 45 | 46 | ## Additional documentation 47 | 48 | - [Hatsu Documentation](https://hatsu.cli.rs) 49 | -------------------------------------------------------------------------------- /crates/api_apub/src/activities/activity.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{ 2 | axum::json::FederationJson, 3 | config::Data, 4 | protocol::context::WithContext, 5 | }; 6 | use axum::{debug_handler, extract::Path, response::Redirect}; 7 | use hatsu_apub::activities::ApubActivity; 8 | use hatsu_db_schema::prelude::Activity; 9 | use hatsu_utils::{AppData, AppError}; 10 | use sea_orm::EntityTrait; 11 | use serde_json::Value; 12 | 13 | use crate::TAG; 14 | 15 | /// Get activity 16 | #[utoipa::path( 17 | get, 18 | tag = TAG, 19 | path = "/activities/{activity}", 20 | responses( 21 | (status = OK, description = "Activity", body = Value), 22 | (status = NOT_FOUND, description = "Activity does not exist", body = AppError) 23 | ), 24 | params( 25 | ("activity" = String, Path, description = "The Uuid of the Activity in the database.") 26 | ) 27 | )] 28 | #[debug_handler] 29 | pub async fn activity( 30 | Path(activity_id): Path, 31 | data: Data, 32 | ) -> Result>, AppError> { 33 | tracing::info!("Reading activity {}", activity_id); 34 | 35 | match Activity::find_by_id(hatsu_utils::url::generate_activity_url( 36 | data.domain(), 37 | Some(activity_id.clone()), 38 | )?) 39 | .one(&data.conn) 40 | .await? 41 | { 42 | Some(activity) => { 43 | let activity: ApubActivity = activity.into(); 44 | Ok(FederationJson(WithContext::new_default( 45 | activity.into_json()?, 46 | ))) 47 | }, 48 | None => Err(AppError::not_found("Activity", &activity_id)), 49 | } 50 | } 51 | 52 | #[debug_handler] 53 | pub async fn redirect(Path(activity_id): Path) -> Redirect { 54 | Redirect::permanent(&format!("/activities/{activity_id}")) 55 | } 56 | -------------------------------------------------------------------------------- /docs/src/admins/block-instances-or-actors.md: -------------------------------------------------------------------------------- 1 | # Block Instances or Actors 2 | 3 | > Ensure you set `HATSU_ACCESS_TOKEN` correctly in the [previous section](./environments.md#hatsu_access_token-optional) first, otherwise you will not be able to use the Hatsu Admin API. 4 | 5 | ## Block URL 6 | 7 | Block URL. if path is `/`, it is recognized as an instance. 8 | 9 | Each time an activity is received an origin match is performed on blocked instances and an exact match is performed on blocked actors. 10 | 11 | ```bash 12 | BLOCK_URL="https://example.com" curl -X POST "http://localhost:$(echo $HATSU_LISTEN_PORT)/api/v0/admin/block-url?url=$(echo $BLOCK_URL)&token=$(echo $HATSU_ACCESS_TOKEN)" 13 | ``` 14 | 15 | ### Get the Actors URL for a Fediverse user 16 | 17 | In Fediverse, we see user IDs typically as `@foo@example.com`. so how do we get the corresponding URL? it's simple. here's an example of a JavaScript environment where you can run it in your browser: 18 | 19 | ```js 20 | const id = '@Gargron@mastodon.social' 21 | 22 | // split id by @ symbol 23 | // ['', 'Gargron', 'mastodon.social'] 24 | const [_, user, instance] = id.split('@') 25 | 26 | // get webfinger json 27 | const webfinger = await fetch( 28 | `https://${instance}/.well-known/webfinger?resource=acct:${user}@${instance}`, 29 | { headers: { accept: 'application/jrd+json' }} 30 | ).then(res => res.json()) 31 | 32 | // find rel=self 33 | const url = webfinger.links.find(({ rel }) => rel === 'self').href 34 | 35 | // https://mastodon.social/users/Gargron 36 | console.log(url) 37 | ``` 38 | 39 | That's it! you may also need to open the console on the web page of the instance the account belongs to, given cross-origin issues and such. 40 | 41 | ## Unblock URL 42 | 43 | The unblocked version of the above API, simply replaces the path `/api/v0/admin/block-url` with `/api/v0/admin/unblock-url`. 44 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/entities/context.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{config::Data, traits::Object}; 2 | use futures::future::TryJoinAll; 3 | use hatsu_apub::objects::{ApubPost, Note}; 4 | use hatsu_db_schema::{post, prelude::Post}; 5 | use hatsu_utils::{AppData, AppError}; 6 | use sea_orm::{EntityTrait, ModelTrait}; 7 | use serde::{Deserialize, Serialize}; 8 | use url::Url; 9 | use utoipa::ToSchema; 10 | 11 | use crate::entities::Status; 12 | 13 | /// 14 | #[derive(Debug, Deserialize, Serialize, ToSchema)] 15 | pub struct Context { 16 | /// should always be empty vec 17 | pub ancestors: Vec, 18 | pub descendants: Vec, 19 | } 20 | 21 | impl Context { 22 | pub async fn find_by_id(post_id: &Url, data: &Data) -> Result { 23 | match Post::find_by_id(post_id.to_string()) 24 | .one(&data.conn) 25 | .await? 26 | { 27 | Some(post) => { 28 | Ok(Self { 29 | ancestors: vec![], 30 | // https://www.sea-ql.org/SeaORM/docs/relation/chained-relations/ 31 | descendants: post 32 | .find_linked(post::SelfReferencingLink) 33 | .all(&data.conn) 34 | .await? 35 | .into_iter() 36 | .map(|post| async move { 37 | let apub_post: ApubPost = post.clone().into(); 38 | let note: Note = apub_post.into_json(data).await?; 39 | Status::from_json(note, data).await 40 | }) 41 | .collect::>() 42 | .await?, 43 | }) 44 | }, 45 | None => Err(AppError::not_found("Record", post_id.as_ref())), 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/src/users/redirecting-with-platform-specific-config.md: -------------------------------------------------------------------------------- 1 | # Redirecting with Platform-Specific Configuration 2 | 3 | Works with [Netlify](https://docs.netlify.com/routing/redirects/#syntax-for-the-netlify-configuration-file) and [Vercel](https://vercel.com/docs/projects/project-configuration#redirects). 4 | 5 | ## Well Known 6 | 7 | ### Netlify (`netlify.toml`) 8 | 9 | Create a `netlify.toml` file in the root directory containing the following: 10 | 11 | > Replace `hatsu.local` with your Hatsu instance. 12 | 13 | ```toml 14 | [[redirects]] 15 | from = "/.well-known/host-meta*" 16 | to = "https://hatsu.local/.well-known/host-meta:splat" 17 | status = 307 18 | [[redirects]] 19 | from = "/.well-known/nodeinfo*" 20 | to = "https://hatsu.local/.well-known/nodeinfo" 21 | status = 307 22 | [[redirects]] 23 | from = "/.well-known/webfinger*" 24 | to = "https://hatsu.local/.well-known/webfinger" 25 | status = 307 26 | ``` 27 | 28 | ### Vercel (`vercel.json`) 29 | 30 | Create a `vercel.json` file in the root directory containing the following: 31 | 32 | > Replace `hatsu.local` with your Hatsu instance. 33 | 34 | ```json 35 | { 36 | "redirects": [ 37 | { 38 | "source": "/.well-known/host-meta", 39 | "destination": "https://hatsu.local/.well-known/host-meta" 40 | }, 41 | { 42 | "source": "/.well-known/host-meta.json", 43 | "destination": "https://hatsu.local/.well-known/host-meta.json" 44 | }, 45 | { 46 | "source": "/.well-known/nodeinfo", 47 | "destination": "https://hatsu.local/.well-known/nodeinfo" 48 | }, 49 | { 50 | "source": "/.well-known/webfinger", 51 | "destination": "https://hatsu.local/.well-known/webfinger" 52 | } 53 | ] 54 | } 55 | ``` 56 | 57 | ## AS2 58 | 59 | > Redirects file only applies to `.well-known`. 60 | > for AS2 redirects, you need to use [AS2 Alternate](./redirecting-with-static-files-and-markup.md#as2-alternate). 61 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/entities/status.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{config::Data, traits::Object}; 2 | use hatsu_apub::objects::Note; 3 | use hatsu_utils::{AppData, AppError}; 4 | use serde::{Deserialize, Serialize}; 5 | use url::Url; 6 | use utoipa::ToSchema; 7 | 8 | use crate::entities::{Account, CustomEmoji}; 9 | 10 | /// 11 | #[derive(Debug, Deserialize, Serialize, ToSchema)] 12 | pub struct Status { 13 | pub id: Url, 14 | // pub in_reply_to_id: Option, 15 | pub in_reply_to_id: Option, 16 | pub uri: Url, 17 | pub url: Url, 18 | pub account: Account, 19 | pub created_at: String, 20 | pub content: String, 21 | pub emojis: Vec, 22 | /// depends on context 23 | pub replies_count: u64, 24 | /// should always be 0 25 | pub reblogs_count: u64, 26 | /// should always be 0 27 | pub favourites_count: u64, 28 | /// should always be "public" 29 | pub visibility: String, 30 | } 31 | 32 | impl Status { 33 | pub async fn from_json(note: Note, data: &Data) -> Result { 34 | let apub_user = note.attributed_to.dereference_local(data).await?; 35 | let user = apub_user.into_json(data).await?; 36 | 37 | Ok(Self { 38 | id: note.id.clone().into(), 39 | in_reply_to_id: note.in_reply_to.map(|url| url.to_string()), 40 | // TODO: replace 41 | uri: note.id.clone().into(), 42 | // TODO: replace 43 | url: note.id.into(), 44 | account: Account::from_json(user)?, 45 | created_at: note.published, 46 | content: note.content, 47 | emojis: CustomEmoji::from_json(note.tag), 48 | replies_count: 0, 49 | reblogs_count: 0, 50 | favourites_count: 0, 51 | visibility: String::from("public"), 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /crates/apub/src/activities/like_or_announce/undo_like_or_announce.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{ 2 | config::Data, 3 | fetch::object_id::ObjectId, 4 | kinds::activity::UndoType, 5 | traits::ActivityHandler, 6 | }; 7 | use hatsu_db_schema::prelude::{ReceivedAnnounce, ReceivedLike}; 8 | use hatsu_utils::{AppData, AppError}; 9 | use sea_orm::EntityTrait; 10 | use serde::{Deserialize, Serialize}; 11 | use url::Url; 12 | 13 | use super::LikeOrAnnounceType; 14 | use crate::{activities::LikeOrAnnounce, actors::ApubUser, utils::verify_blocked}; 15 | 16 | #[derive(Clone, Debug, Deserialize, Serialize)] 17 | #[serde(rename_all = "camelCase")] 18 | pub struct UndoLikeOrAnnounce { 19 | #[serde(rename = "type")] 20 | pub(crate) kind: UndoType, 21 | pub(crate) id: Url, 22 | pub(crate) actor: ObjectId, 23 | pub(crate) object: LikeOrAnnounce, 24 | } 25 | 26 | /// receive only 27 | #[async_trait::async_trait] 28 | impl ActivityHandler for UndoLikeOrAnnounce { 29 | type DataType = AppData; 30 | type Error = AppError; 31 | 32 | fn id(&self) -> &Url { 33 | &self.id 34 | } 35 | 36 | fn actor(&self) -> &Url { 37 | self.actor.inner() 38 | } 39 | 40 | async fn verify(&self, data: &Data) -> Result<(), Self::Error> { 41 | // TODO 42 | verify_blocked(self.actor(), data).await?; 43 | Ok(()) 44 | } 45 | 46 | async fn receive(self, data: &Data) -> Result<(), Self::Error> { 47 | match self.object.kind { 48 | LikeOrAnnounceType::LikeType(_) => 49 | ReceivedLike::delete_by_id(self.object.id) 50 | .exec(&data.conn) 51 | .await?, 52 | LikeOrAnnounceType::AnnounceType(_) => 53 | ReceivedAnnounce::delete_by_id(self.object.id) 54 | .exec(&data.conn) 55 | .await?, 56 | }; 57 | 58 | Ok(()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /crates/apub/src/actors/user_attachment.rs: -------------------------------------------------------------------------------- 1 | use hatsu_db_schema::user::UserFeed as DbUserFeed; 2 | // use hatsu_feed::UserFeed; 3 | use serde::{Deserialize, Serialize}; 4 | use url::Url; 5 | use utoipa::ToSchema; 6 | 7 | /// Hatsu User Attachment (Metadata) 8 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct UserAttachment { 11 | /// should be `PropertyValue` 12 | #[schema(value_type = String)] 13 | #[serde(rename = "type")] 14 | pub kind: String, 15 | /// Website / JSON Feed / Atom Feed / RSS Feed 16 | pub name: String, 17 | /// html string 18 | pub value: String, 19 | } 20 | 21 | impl UserAttachment { 22 | #[must_use] 23 | pub fn new(name: &str, value: String) -> Self { 24 | Self { 25 | kind: String::from("PropertyValue"), 26 | name: String::from(name), 27 | value, 28 | } 29 | } 30 | 31 | #[must_use] 32 | pub fn generate(domain: &Url, feed: DbUserFeed) -> Vec { 33 | let mut attachment = vec![Self::new( 34 | "Website", 35 | format!( 36 | "{domain}" 37 | ), 38 | )]; 39 | 40 | if let Some(json) = feed.json { 41 | attachment.push(Self::new("JSON Feed", format!("{json}"))); 42 | }; 43 | 44 | if let Some(atom) = feed.atom { 45 | attachment.push(Self::new("Atom Feed", format!("{atom}"))); 46 | }; 47 | 48 | if let Some(rss) = feed.rss { 49 | attachment.push(Self::new("RSS Feed", format!("{rss}"))); 50 | }; 51 | 52 | attachment 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /crates/api_admin/src/routes/create_account.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use activitypub_federation::config::Data; 4 | use axum::{Json, debug_handler, extract::Query, http::StatusCode}; 5 | use hatsu_apub::actors::ApubUser; 6 | use hatsu_db_schema::prelude::User; 7 | use hatsu_utils::{AppData, AppError}; 8 | use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel}; 9 | 10 | use crate::{ 11 | TAG, 12 | entities::{CreateRemoveAccountQuery, CreateRemoveAccountResult}, 13 | }; 14 | 15 | /// Create Account 16 | #[utoipa::path( 17 | post, 18 | tag = TAG, 19 | path = "/api/v0/admin/create-account", 20 | params(CreateRemoveAccountQuery), 21 | responses( 22 | (status = CREATED, description = "create successfully", body = CreateRemoveAccountResult), 23 | (status = BAD_REQUEST, description = "error", body = AppError) 24 | ), 25 | security(("api_key" = ["token"])) 26 | )] 27 | #[debug_handler] 28 | pub async fn create_account( 29 | data: Data, 30 | query: Query, 31 | ) -> Result<(StatusCode, Json), AppError> { 32 | if let Some(account) = User::find_by_id( 33 | hatsu_utils::url::generate_user_url(data.domain(), &query.name)?.to_string(), 34 | ) 35 | .one(&data.conn) 36 | .await? 37 | { 38 | Err(AppError::new( 39 | format!("The account already exists: {}", account.name), 40 | None, 41 | Some(StatusCode::BAD_REQUEST), 42 | )) 43 | } else { 44 | let account = ApubUser::new(data.domain(), &query.name) 45 | .await? 46 | .deref() 47 | .clone() 48 | .into_active_model() 49 | .insert(&data.conn) 50 | .await?; 51 | 52 | Ok(( 53 | StatusCode::CREATED, 54 | Json(CreateRemoveAccountResult { 55 | name: account.name.clone(), 56 | message: format!("The account was successfully created: {}", account.name), 57 | }), 58 | )) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /crates/db_migration/src/m20240131_000003_post.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{ 2 | prelude::*, 3 | schema::{boolean, string, string_null, text}, 4 | }; 5 | 6 | #[derive(DeriveMigrationName)] 7 | pub struct Migration; 8 | 9 | #[async_trait::async_trait] 10 | impl MigrationTrait for Migration { 11 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 12 | // DbPost 13 | // https://github.com/LemmyNet/activitypub-federation-rust/blob/61085a643f05dbb70502b3c519fd666214b7e308/examples/live_federation/objects/post.rs#L20C4-L25 14 | manager 15 | .create_table( 16 | Table::create() 17 | .table(Post::Table) 18 | .if_not_exists() 19 | .col(string(Post::Id).primary_key()) 20 | .col(text(Post::Object)) 21 | .col(string(Post::AttributedTo)) 22 | .col(string_null(Post::InReplyTo)) 23 | .col(string_null(Post::InReplyToRoot)) 24 | .col(string(Post::Published)) 25 | .col(string_null(Post::Updated)) 26 | .col(string(Post::LastRefreshedAt)) 27 | .col(boolean(Post::Local)) 28 | .to_owned(), 29 | ) 30 | .await?; 31 | 32 | Ok(()) 33 | } 34 | 35 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 36 | manager 37 | .drop_table(Table::drop().table(Post::Table).to_owned()) 38 | .await?; 39 | 40 | Ok(()) 41 | } 42 | } 43 | 44 | #[derive(Iden)] 45 | enum Post { 46 | Table, 47 | // Object ID 48 | Id, 49 | // Object JSON 50 | /// 51 | Object, 52 | // 作者 53 | // Author 54 | AttributedTo, 55 | // 回复帖文 56 | InReplyTo, 57 | // 顶层回复帖文 58 | InReplyToRoot, 59 | // 发布时间 60 | Published, 61 | // 更新时间 62 | Updated, 63 | // 最后更新时间 64 | LastRefreshedAt, 65 | // 是否为本地 66 | Local, 67 | } 68 | -------------------------------------------------------------------------------- /crates/backend/src/lib.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::{FederationConfig, FederationMiddleware}; 2 | use hatsu_utils::{AppData, AppError}; 3 | use tokio::net::TcpListener; 4 | use tower_http::{ 5 | cors::CorsLayer, 6 | trace::{self, TraceLayer}, 7 | }; 8 | use tracing::Level; 9 | 10 | mod favicon; 11 | mod openapi; 12 | mod routes; 13 | 14 | pub struct Server { 15 | pub federation_config: FederationConfig, 16 | } 17 | 18 | impl Server { 19 | #[must_use] 20 | pub fn new(federation_config: &FederationConfig) -> Self { 21 | Self { 22 | federation_config: federation_config.clone(), 23 | } 24 | } 25 | } 26 | 27 | pub async fn run(federation_config: FederationConfig) -> Result<(), AppError> { 28 | let data = federation_config.to_request_data(); 29 | 30 | // build our application with a route 31 | tracing::info!("creating app"); 32 | let app = routes::routes() 33 | .layer(FederationMiddleware::new(federation_config.clone())) 34 | .layer(CorsLayer::permissive()) 35 | .layer( 36 | TraceLayer::new_for_http() 37 | .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) 38 | .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), 39 | ); 40 | 41 | let http = async { 42 | let listener = TcpListener::bind(format!( 43 | "{}:{}", 44 | data.env.hatsu_listen_host, data.env.hatsu_listen_port 45 | )) 46 | .await?; 47 | tracing::debug!("listening on http://{}", listener.local_addr()?); 48 | axum::serve(listener, app) 49 | .with_graceful_shutdown(async { 50 | hatsu_utils::shutdown_signal() 51 | .await 52 | .expect("failed to install graceful shutdown handler"); 53 | }) 54 | .await?; 55 | 56 | Ok::<(), AppError>(()) 57 | }; 58 | 59 | let cron = hatsu_cron::run(&federation_config); 60 | 61 | let _res = tokio::join!(http, cron); 62 | 63 | Ok(()) 64 | } 65 | -------------------------------------------------------------------------------- /crates/api_apub/src/users/user_following.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{ 2 | axum::json::FederationJson, 3 | config::Data, 4 | protocol::context::WithContext, 5 | }; 6 | use axum::{ 7 | debug_handler, 8 | extract::{Path, Query}, 9 | response::Redirect, 10 | }; 11 | use hatsu_apub::collections::{Collection, CollectionOrPage, CollectionPage}; 12 | use hatsu_utils::{AppData, AppError}; 13 | 14 | use crate::{TAG, users::Pagination}; 15 | 16 | /// Get user following 17 | #[utoipa::path( 18 | get, 19 | tag = TAG, 20 | path = "/users/{user}/following", 21 | responses( 22 | (status = OK, description = "Following", body = CollectionOrPage), 23 | (status = NOT_FOUND, description = "User does not exist", body = AppError) 24 | ), 25 | params( 26 | ("user" = String, Path, description = "The Domain of the User in the database."), 27 | Pagination 28 | ) 29 | )] 30 | #[debug_handler] 31 | pub async fn handler( 32 | Path(name): Path, 33 | pagination: Query, 34 | data: Data, 35 | ) -> Result>, AppError> { 36 | match pagination.page { 37 | None => Ok(FederationJson(WithContext::new_default( 38 | CollectionOrPage::Collection(Collection::new( 39 | &hatsu_utils::url::generate_user_url(data.domain(), &name)? 40 | .join(&format!("{name}/following"))?, 41 | 0, 42 | 0, 43 | )?), 44 | ))), 45 | Some(page) => Ok(FederationJson(WithContext::new_default( 46 | CollectionOrPage::CollectionPage(CollectionPage::new( 47 | hatsu_utils::url::generate_user_url(data.domain(), &name)? 48 | .join(&format!("{name}/following"))?, 49 | 0, 50 | vec![], 51 | 0, 52 | page, 53 | )?), 54 | ))), 55 | } 56 | } 57 | 58 | #[debug_handler] 59 | pub async fn redirect(Path(name): Path) -> Redirect { 60 | Redirect::permanent(&format!("/users/{name}/followers")) 61 | } 62 | -------------------------------------------------------------------------------- /docs/src/users/redirecting-with-static-files-and-markup.md: -------------------------------------------------------------------------------- 1 | # Redirecting with Static files and Markup 2 | 3 | This should apply to most hosting services and SSG. 4 | 5 | ## Well Known 6 | 7 | For the `.well-known/*` files, you need to get the corresponding contents from the hatsu instance and output them as a static file. 8 | 9 | > Replace `hatsu.local` with your Hatsu instance and `example.com` with your site. 10 | 11 | Open your Hatsu instance home page in a browser and F12 -> Console to run: 12 | 13 | ```js 14 | // .well-known/webfinger 15 | await fetch('https://hatsu.local/.well-known/webfinger?resource=acct:example.com@hatsu.local').then(res => res.text()) 16 | // .well-known/nodeinfo 17 | await fetch('https://hatsu.local/.well-known/nodeinfo').then(res => res.text()) 18 | // .well-known/host-meta 19 | await fetch('https://hatsu.local/.well-known/host-meta').then(res => res.text()).then(text => text.split('\n').map(v => v.trim()).join('')) 20 | // .well-known/host-meta.json 21 | await fetch('https://hatsu.local/.well-known/host-meta.json').then(res => res.text()) 22 | ``` 23 | 24 | This will fetch their text contents, 25 | which you need to save to the SSG equivalent of the static files directory and make sure they are output to the `.well-known` folder. 26 | 27 | ## AS2 Alternate 28 | 29 | > Only Mastodon and Misskey (and their forks) is known to support auto-discovery, other software requires redirection to search correctly. 30 | > [w3c/activitypub#310](https://github.com/w3c/activitypub/issues/310) 31 | 32 | Make your posts searchable on Fediverse by setting up auto-discovery. 33 | 34 | Since Hatsu's object URLs are predictable, you just need to make sure: 35 | 36 | - The page you want to set up for auto-discovery is in the Feed. 37 | - The actual URL of the page is the same as in the Feed. (see [./feed](./feed.md)) 38 | 39 | That's it! For `https://example.com/foo/bar`, just add the following tag to the `document.head`: 40 | 41 | > Replace `hatsu.local` with your Hatsu instance. 42 | 43 | ```html 44 | 45 | ``` 46 | -------------------------------------------------------------------------------- /crates/apub/assets/akkoma/objects/note.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://www.w3.org/ns/activitystreams", 4 | "https://social.qunn.eu/schemas/litepub-0.1.jsonld", 5 | { 6 | "@language": "und" 7 | } 8 | ], 9 | "actor": "https://social.qunn.eu/users/user", 10 | "attachment": [], 11 | "attributedTo": "https://social.qunn.eu/users/user", 12 | "cc": [ 13 | "https://www.w3.org/ns/activitystreams#Public" 14 | ], 15 | "content": "This is a test message :acat_chew:", 16 | "contentMap": { 17 | "en": "This is a test message :acat_chew:" 18 | }, 19 | "context": "https://hatsu-nightly-debug.hyp3r.link/posts/https://kwaa-blog-next.deno.dev/articles/test/", 20 | "conversation": "https://hatsu-nightly-debug.hyp3r.link/posts/https://kwaa-blog-next.deno.dev/articles/test/", 21 | "id": "https://social.qunn.eu/objects/7f78a6ae-2e93-4fb5-b63e-c667ecfee62a", 22 | "inReplyTo": "https://hatsu-nightly-debug.hyp3r.link/posts/https://kwaa-blog-next.deno.dev/articles/test/", 23 | "published": "2024-03-15T15:47:02.984729Z", 24 | "sensitive": false, 25 | "source": { 26 | "content": "This is a test message :acat_chew:", 27 | "mediaType": "text/plain" 28 | }, 29 | "summary": "", 30 | "tag": [ 31 | { 32 | "href": "https://hatsu-nightly-debug.hyp3r.link/users/kwaa-blog-next.deno.dev", 33 | "name": "@kwaa-blog-next.deno.dev@hatsu-nightly-debug.hyp3r.link", 34 | "type": "Mention" 35 | }, 36 | { 37 | "icon": { 38 | "type": "Image", 39 | "url": "https://social.qunn.eu/emoji/mergans_cats/acat_chew.webp" 40 | }, 41 | "id": "https://social.qunn.eu/emoji/mergans_cats/acat_chew.webp", 42 | "name": ":acat_chew:", 43 | "type": "Emoji", 44 | "updated": "1970-01-01T00:00:00Z" 45 | } 46 | ], 47 | "to": [ 48 | "https://hatsu-nightly-debug.hyp3r.link/users/kwaa-blog-next.deno.dev", 49 | "https://social.qunn.eu/users/user/followers" 50 | ], 51 | "type": "Note" 52 | } -------------------------------------------------------------------------------- /crates/utils/src/url/mod.rs: -------------------------------------------------------------------------------- 1 | use std::str; 2 | 3 | use url::Url; 4 | use uuid::Uuid; 5 | 6 | use crate::AppError; 7 | 8 | pub fn absolutize_relative_url(relative_url: &str, domain: &str) -> Result { 9 | if relative_url.starts_with("https://") { 10 | Ok(Url::parse(relative_url)?) 11 | } else { 12 | Ok(Url::parse(&format!("https://{domain}"))?.join(relative_url)?) 13 | } 14 | } 15 | 16 | /// 创建一个 Activity URL 17 | /// 18 | /// Example: 19 | pub fn generate_activity_url(domain: &str, id: Option) -> Result { 20 | Ok(Url::parse(&format!( 21 | "https://{}/activities/{}", 22 | domain, 23 | id.unwrap_or_else(|| Uuid::now_v7().to_string()) 24 | ))?) 25 | } 26 | 27 | /// 创建一个 Post URL 28 | /// 29 | /// Example: 30 | pub fn generate_post_url(domain: &str, id: String) -> Result { 31 | match id { 32 | id if id.starts_with("https://") => 33 | Ok(Url::parse(&format!("https://{domain}/posts/{id}",))?), 34 | _ => Err(AppError::new( 35 | format!("Invalid Post ID: {id}"), 36 | serde_json::from_str("Post ID need to starts with https://")?, 37 | None, 38 | )), 39 | } 40 | } 41 | 42 | /// 创建一个 User URL 43 | /// 44 | /// Example: 45 | pub fn generate_user_url(domain: &str, id: &str) -> Result { 46 | match id { 47 | id if !id.starts_with("https://") => 48 | Ok(Url::parse(&format!("https://{domain}/users/{id}",))?), 49 | _ => Err(AppError::new( 50 | format!("Invalid User ID: {id}"), 51 | serde_json::from_str("User ID cannot starts with https://")?, 52 | None, 53 | )), 54 | } 55 | } 56 | 57 | // pub fn remove_https(url: String) -> String { 58 | // if str::starts_with(&url, "https://") { 59 | // let url_without_https = url.trim_start_matches("https://").to_string(); 60 | // url_without_https 61 | // } else { 62 | // url 63 | // } 64 | // } 65 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/routes/statuses/status_context.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{Json, debug_handler, extract::Path}; 3 | use hatsu_utils::{AppData, AppError}; 4 | 5 | use crate::{ 6 | TAG, 7 | entities::{Context, Status}, 8 | }; 9 | 10 | /// Get parent and child statuses in context 11 | /// 12 | /// 13 | #[utoipa::path( 14 | get, 15 | tag = TAG, 16 | path = "/api/v1/statuses/{id}/context", 17 | responses( 18 | (status = OK, description = "", body = Context), 19 | (status = NOT_FOUND, description = "Status is private or does not exist", body = AppError) 20 | ), 21 | params( 22 | ("id" = String, Path, description = "The ID of the Status in the database.") 23 | ) 24 | )] 25 | #[debug_handler] 26 | pub async fn status_context( 27 | Path(base64_url): Path, 28 | data: Data, 29 | ) -> Result, AppError> { 30 | let base64 = base64_simd::URL_SAFE; 31 | 32 | match base64.decode_to_vec(&base64_url) { 33 | Ok(utf8_url) => match String::from_utf8(utf8_url) { 34 | Ok(url) if url.starts_with("https://") => { 35 | let post_url = hatsu_utils::url::generate_post_url(data.domain(), url)?; 36 | let context = Context::find_by_id(&post_url, &data).await?; 37 | 38 | Ok(Json(Context { 39 | ancestors: vec![], 40 | descendants: context 41 | .descendants 42 | .into_iter() 43 | .map(|status| match status.in_reply_to_id { 44 | Some(id) if id == post_url.to_string() => Status { 45 | in_reply_to_id: Some(base64_url.clone()), 46 | ..status 47 | }, 48 | _ => status, 49 | }) 50 | .collect(), 51 | })) 52 | }, 53 | _ => Err(AppError::not_found("Record", &base64_url)), 54 | }, 55 | _ => Err(AppError::not_found("Record", &base64_url)), 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /crates/utils/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter}; 2 | 3 | use axum::{ 4 | Json, 5 | http::StatusCode, 6 | response::{IntoResponse, Response}, 7 | }; 8 | use serde::Serialize; 9 | use serde_json::Value; 10 | use tracing_error::SpanTrace; 11 | use utoipa::ToSchema; 12 | use uuid::Uuid; 13 | 14 | #[derive(Debug, Serialize, ToSchema)] 15 | pub struct AppError { 16 | /// An error message. 17 | pub error: String, 18 | /// A unique error ID. 19 | pub error_id: Uuid, 20 | /// Optional Additional error details. 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | pub error_details: Option, 23 | #[serde(skip)] 24 | pub status: StatusCode, 25 | #[serde(skip)] 26 | pub context: SpanTrace, 27 | } 28 | 29 | impl AppError { 30 | #[must_use] 31 | pub fn new(error: String, error_details: Option, status: Option) -> Self { 32 | Self { 33 | error, 34 | error_details, 35 | error_id: Uuid::now_v7(), 36 | status: status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), 37 | context: SpanTrace::capture(), 38 | } 39 | } 40 | 41 | #[must_use] 42 | pub fn not_found(kind: &str, name: &str) -> Self { 43 | Self { 44 | error: format!("Unable to find {kind} named {name}"), 45 | error_details: None, 46 | error_id: Uuid::now_v7(), 47 | status: StatusCode::NOT_FOUND, 48 | context: SpanTrace::capture(), 49 | } 50 | } 51 | 52 | #[must_use] 53 | pub fn anyhow(error: &anyhow::Error) -> Self { 54 | Self::new(error.to_string(), None, None) 55 | } 56 | } 57 | 58 | impl IntoResponse for AppError { 59 | fn into_response(self) -> Response { 60 | (self.status, Json(self)).into_response() 61 | } 62 | } 63 | 64 | impl Display for AppError { 65 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 66 | writeln!(f, "{:?}", self.error)?; 67 | self.context.fmt(f)?; 68 | Ok(()) 69 | } 70 | } 71 | 72 | impl From for AppError 73 | where 74 | T: Into, 75 | { 76 | fn from(t: T) -> Self { 77 | Self::anyhow(&t.into()) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /crates/api_admin/src/routes/block_url.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{Json, debug_handler, extract::Query, http::StatusCode}; 3 | use hatsu_db_schema::{blocked_url, prelude::BlockedUrl}; 4 | use hatsu_utils::{AppData, AppError}; 5 | use sea_orm::{ActiveModelTrait, EntityTrait, Set}; 6 | 7 | use crate::{ 8 | TAG, 9 | entities::{BlockUrlQuery, BlockUrlResult}, 10 | }; 11 | 12 | /// Block URL 13 | #[utoipa::path( 14 | post, 15 | tag = TAG, 16 | path = "/api/v0/admin/block-url", 17 | params(BlockUrlQuery), 18 | responses( 19 | (status = OK, description = "block successfully", body = BlockUrlResult), 20 | (status = BAD_REQUEST, description = "error", body = AppError) 21 | ), 22 | security(("api_key" = ["token"])) 23 | )] 24 | #[debug_handler] 25 | pub async fn block_url( 26 | data: Data, 27 | query: Query, 28 | ) -> Result<(StatusCode, Json), AppError> { 29 | match &query.url { 30 | url if url.query().is_some() => Err(AppError::new( 31 | format!("wrong url: {url} (can't contain search params)"), 32 | None, 33 | Some(StatusCode::BAD_REQUEST), 34 | )), 35 | _ => 36 | if let Some(url) = BlockedUrl::find_by_id(query.url.to_string()) 37 | .one(&data.conn) 38 | .await? 39 | { 40 | Err(AppError::new( 41 | format!("The url already blocked: {}", url.id), 42 | None, 43 | Some(StatusCode::BAD_REQUEST), 44 | )) 45 | } else { 46 | blocked_url::ActiveModel { 47 | id: Set(query.url.to_string()), 48 | is_instance: Set(query.url.path().eq("/")), 49 | } 50 | .insert(&data.conn) 51 | .await?; 52 | 53 | Ok(( 54 | StatusCode::OK, 55 | Json(BlockUrlResult { 56 | url: query.url.clone(), 57 | message: format!("The url was successfully blocked: {}", &query.url), 58 | }), 59 | )) 60 | }, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /crates/apub/src/collections/collection_page.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::kinds::collection::OrderedCollectionPageType; 2 | use hatsu_utils::AppError; 3 | use serde::{Deserialize, Serialize}; 4 | use serde_json::Value; 5 | use url::Url; 6 | use utoipa::ToSchema; 7 | 8 | use crate::collections::generate_collection_page_url; 9 | 10 | #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct CollectionPage { 13 | #[schema(value_type = String)] 14 | #[serde(rename = "type")] 15 | kind: OrderedCollectionPageType, 16 | // example: https://hatsu.local/users/example.com/collection?page=2 17 | id: Url, 18 | // example: https://hatsu.local/users/example.com/collection?page=1 19 | #[serde(skip_serializing_if = "Option::is_none")] 20 | prev: Option, 21 | // example: https://hatsu.local/users/example.com/collection?page=3 22 | #[serde(skip_serializing_if = "Option::is_none")] 23 | next: Option, 24 | // example: https://hatsu.local/users/example.com/collection 25 | part_of: Url, 26 | // collection item list 27 | ordered_items: Vec, 28 | // collection count 29 | total_items: u64, 30 | } 31 | 32 | impl CollectionPage { 33 | pub fn new( 34 | collection_id: Url, 35 | total_items: u64, 36 | ordered_items: Vec, 37 | total_pages: u64, 38 | page: u64, 39 | ) -> Result { 40 | Ok(Self { 41 | kind: OrderedCollectionPageType::OrderedCollectionPage, 42 | id: Url::parse_with_params(collection_id.as_ref(), &[("page", page.to_string())])?, 43 | // 如果当前页数大于 1,则提供上一页 44 | prev: match page { 45 | page if page > 1 => Some(generate_collection_page_url(&collection_id, page - 1)?), 46 | _ => None, 47 | }, 48 | // 如果当前页数小于总页数,则提供下一页 49 | next: match page { 50 | page if page < total_pages => 51 | Some(generate_collection_page_url(&collection_id, page + 1)?), 52 | _ => None, 53 | }, 54 | part_of: collection_id, 55 | ordered_items, 56 | total_items, 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /crates/well_known/src/routes/webfinger.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{ 2 | config::Data, 3 | fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name}, 4 | }; 5 | use axum::{Json, debug_handler, extract::Query}; 6 | use hatsu_db_schema::prelude::User; 7 | use hatsu_utils::{AppData, AppError}; 8 | use sea_orm::EntityTrait; 9 | use serde::Deserialize; 10 | use url::Url; 11 | 12 | use crate::{TAG, entities::WebfingerSchema}; 13 | 14 | #[derive(Deserialize)] 15 | pub struct WebfingerQuery { 16 | resource: String, 17 | } 18 | 19 | /// WebFinger. 20 | #[utoipa::path( 21 | get, 22 | tag = TAG, 23 | path = "/.well-known/webfinger", 24 | responses( 25 | (status = OK, description = "", body = WebfingerSchema), 26 | (status = NOT_FOUND, description = "", body = AppError), 27 | ), 28 | )] 29 | #[debug_handler] 30 | pub async fn webfinger( 31 | Query(query): Query, 32 | data: Data, 33 | ) -> Result, AppError> { 34 | tracing::info!("{}", &query.resource); 35 | 36 | let name = if let Ok(name) = extract_webfinger_name(&query.resource, &data) { 37 | Ok(name) 38 | } else { 39 | // extract webfinger domain 40 | let vec: Vec<&str> = query.resource.split('@').collect(); 41 | match vec.get(1) { 42 | // acct:example.com@hatsu.local => example.com 43 | Some(domain) if *domain == data.domain() => Ok(vec[0].trim_start_matches("acct:")), 44 | // acct:example.com@example.com => example.com 45 | Some(domain) if Url::parse(&format!("https://{domain}")).is_ok() => Ok(*domain), 46 | // acct:example.com@unknown => Err 47 | _ => Err(AppError::not_found("Subject", &query.resource)), 48 | } 49 | }?; 50 | 51 | let url = hatsu_utils::url::generate_user_url(data.domain(), name)?; 52 | 53 | match User::find_by_id(url.to_string()).one(&data.conn).await? { 54 | // TODO: (optional) http://webfinger.net/rel/avatar 55 | Some(user) => Ok(Json(build_webfinger_response( 56 | query.resource, 57 | Url::parse(&user.id)?, 58 | ))), 59 | None => Err(AppError::not_found("Subject", &query.resource)), 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /crates/db_migration/src/m20240131_000002_user_feed_item.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{ 2 | prelude::*, 3 | schema::{string, string_null}, 4 | }; 5 | 6 | #[derive(DeriveMigrationName)] 7 | pub struct Migration; 8 | 9 | #[async_trait::async_trait] 10 | impl MigrationTrait for Migration { 11 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 12 | manager 13 | .create_table( 14 | Table::create() 15 | .table(UserFeedItem::Table) 16 | .if_not_exists() 17 | .col(string(UserFeedItem::Id).primary_key()) 18 | .col(string(UserFeedItem::UserId)) 19 | .col(string_null(UserFeedItem::PostId)) 20 | .col(string_null(UserFeedItem::Title)) 21 | .col(string_null(UserFeedItem::Summary)) 22 | .col(string_null(UserFeedItem::Language)) 23 | .col(string_null(UserFeedItem::Tags)) 24 | .col(string_null(UserFeedItem::DatePublished)) 25 | .col(string_null(UserFeedItem::DateModified)) 26 | .to_owned(), 27 | ) 28 | .await?; 29 | 30 | Ok(()) 31 | } 32 | 33 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 34 | manager 35 | .drop_table(Table::drop().table(UserFeedItem::Table).to_owned()) 36 | .await?; 37 | 38 | Ok(()) 39 | } 40 | } 41 | 42 | #[derive(Iden)] 43 | pub enum UserFeedItem { 44 | Table, 45 | /// Hatsu JSON Feed Item Extension (`m20240515_000001`) 46 | /// 47 | /// 48 | Hatsu, 49 | /// JSON Feed Item `id` or `url` 50 | Id, 51 | /// User ID associated with this feed item. 52 | UserId, 53 | /// Post ID associated with this feed item. 54 | PostId, 55 | /// JSON Feed Item `title` 56 | Title, 57 | /// JSON Feed Item `summary` 58 | Summary, 59 | /// JSON Feed Item `language` 60 | Language, 61 | /// JSON Feed Item `tags` 62 | Tags, 63 | /// JSON Feed Item `date_published` 64 | DatePublished, 65 | /// JSON Feed Item `date_modified` 66 | DateModified, 67 | } 68 | -------------------------------------------------------------------------------- /crates/utils/src/data.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use sea_orm::DatabaseConnection; 4 | 5 | use crate::{AppError, VERSION, codename}; 6 | 7 | #[derive(Clone, Debug)] 8 | pub struct AppData { 9 | pub conn: DatabaseConnection, 10 | pub env: AppEnv, 11 | } 12 | 13 | #[derive(Clone, Debug)] 14 | pub struct AppEnv { 15 | pub hatsu_database_url: String, 16 | pub hatsu_domain: String, 17 | pub hatsu_listen_host: String, 18 | pub hatsu_listen_port: String, 19 | pub hatsu_primary_account: String, 20 | pub hatsu_access_token: Option, 21 | pub hatsu_node_name: Option, 22 | pub hatsu_node_description: Option, 23 | } 24 | 25 | impl AppEnv { 26 | /// # Panics 27 | /// 28 | /// If `HATSU_DOMAIN` and `HATSU_PRIMARY_ACCOUNT` are not set, 29 | /// it will cause a panic, please refer to the documentation. 30 | /// 31 | /// 32 | pub fn init() -> Result { 33 | Ok(Self { 34 | hatsu_database_url: env::var("HATSU_DATABASE_URL") 35 | .unwrap_or_else(|_| String::from("sqlite::memory:")), 36 | hatsu_domain: env::var("HATSU_DOMAIN") 37 | .expect("environment variable HATSU_DOMAIN not found. see https://hatsu.cli.rs/admins/environments.html#hatsu_domain"), 38 | hatsu_listen_host: env::var("HATSU_LISTEN_HOST") 39 | .unwrap_or_else(|_| String::from("127.0.0.1")), 40 | hatsu_listen_port: env::var("HATSU_LISTEN_PORT") 41 | .unwrap_or_else(|_| String::from("3939")), 42 | hatsu_primary_account: env::var("HATSU_PRIMARY_ACCOUNT") 43 | .expect("environment variable HATSU_PRIMARY_ACCOUNT not found. see https://hatsu.cli.rs/admins/environments.html#hatsu_primary_account"), 44 | hatsu_access_token: env::var("HATSU_ACCESS_TOKEN").ok(), 45 | hatsu_node_name: env::var("HATSU_NODE_NAME").ok(), 46 | hatsu_node_description: env::var("HATSU_NODE_DESCRIPTION").ok(), 47 | }) 48 | } 49 | 50 | #[must_use] 51 | pub fn info() -> String { 52 | let version = VERSION; 53 | let codename = codename(); 54 | 55 | format!("Hatsu v{version} \"{codename}\"") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /crates/db_migration/src/m20240515_000001_user_feed_hatsu_extension.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{ 2 | prelude::*, 3 | schema::{json_null, string_null}, 4 | }; 5 | 6 | use crate::{m20240131_000001_user::User, m20240131_000002_user_feed_item::UserFeedItem}; 7 | 8 | #[derive(DeriveMigrationName)] 9 | pub struct Migration; 10 | 11 | #[async_trait::async_trait] 12 | impl MigrationTrait for Migration { 13 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 14 | manager 15 | .alter_table( 16 | Table::alter() 17 | .table(User::Table) 18 | .add_column(json_null(User::Hatsu)) 19 | .to_owned(), 20 | ) 21 | .await?; 22 | 23 | manager 24 | .alter_table( 25 | Table::alter() 26 | .table(User::Table) 27 | .drop_column(User::Image) 28 | .to_owned(), 29 | ) 30 | .await?; 31 | 32 | manager 33 | .alter_table( 34 | Table::alter() 35 | .table(UserFeedItem::Table) 36 | .add_column(json_null(UserFeedItem::Hatsu)) 37 | .to_owned(), 38 | ) 39 | .await?; 40 | 41 | Ok(()) 42 | } 43 | 44 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 45 | manager 46 | .alter_table( 47 | Table::alter() 48 | .table(User::Table) 49 | .drop_column(User::Hatsu) 50 | .to_owned(), 51 | ) 52 | .await?; 53 | 54 | manager 55 | .alter_table( 56 | Table::alter() 57 | .table(User::Table) 58 | .add_column(string_null(User::Image)) 59 | .to_owned(), 60 | ) 61 | .await?; 62 | 63 | manager 64 | .alter_table( 65 | Table::alter() 66 | .table(UserFeedItem::Table) 67 | .drop_column(UserFeedItem::Hatsu) 68 | .to_owned(), 69 | ) 70 | .await?; 71 | 72 | Ok(()) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /docs/src/users/feed.md: -------------------------------------------------------------------------------- 1 | # Feed 2 | 3 | For Hatsu to work, your site needs to have one of the valid [JSON](https://jsonfeed.org/version/1.1/) / [Atom](https://en.wikipedia.org/wiki/Atom_(web_standard)) / [RSS](https://en.wikipedia.org/wiki/RSS) feeds. 4 | 5 | These feeds should be auto-discoverable on the homepage: 6 | 7 | ```html 8 | 9 | 10 | 11 | 12 | ... 13 | 14 | 15 | 16 | 17 | 18 | ... 19 | 20 | 21 | ``` 22 | 23 | Hatsu detects all available feeds and prioritizes them in order of `JSON > Atom > RSS`. 24 | 25 | ## JSON Feed 26 | 27 | Hatsu uses `serde` to parse JSON Feed directly, so you can expect it to have first-class support. 28 | 29 | Please make sure your feed is valid in [JSON Feed Validator](https://validator.jsonfeed.org/) first. 30 | 31 | ### JSON Feed Items 32 | 33 | Hatsu infers object id from `item.url` and `item.id`. 34 | 35 | It will use the `item.url` first, and if it doesn't exist, it will try to convert the `item.id` to an absolute url. 36 | 37 | ```text 38 | https://example.com/foo/bar => https://example.com/foo/bar 39 | /foo/bar => https://example.com/foo/bar 40 | foo/bar => https://example.com/foo/bar 41 | ``` 42 | 43 | Ideally, your `item.id` and `item.url` should be consistent absolute links: 44 | 45 | ```json 46 | { 47 | "id": "https://example.com/foo/bar", 48 | "url": "https://example.com/foo/bar", 49 | "title": "...", 50 | "content_html": "...", 51 | "date_published": "..." 52 | } 53 | ``` 54 | 55 | ### JSON Feed Extension 56 | 57 | If you can customize your site's JSON Feed, 58 | you might also want to check out the [Hatsu JSON Feed Extension](../others/json-feed-extension.md). 59 | 60 | ## Atom / RSS 61 | 62 | Hatsu uses `feed-rs` to parse XML feeds and convert them manually. 63 | 64 | Please make sure your feed is valid in [W3C Feed Validation Service](https://validator.w3.org/feed/) first. 65 | 66 | This section is currently lacking testing, so feel free to report bugs. 67 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/entities/account.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{config::Data, traits::Object}; 2 | use hatsu_apub::actors::{ApubUser, User}; 3 | use hatsu_db_schema::prelude::User as PreludeUser; 4 | use hatsu_utils::{AppData, AppError}; 5 | use sea_orm::EntityTrait; 6 | use serde::{Deserialize, Serialize}; 7 | use url::Url; 8 | use utoipa::ToSchema; 9 | 10 | use crate::entities::CustomEmoji; 11 | 12 | /// 13 | #[derive(Debug, Deserialize, Serialize, ToSchema)] 14 | pub struct Account { 15 | pub id: Url, 16 | pub username: String, 17 | pub url: Url, 18 | pub display_name: String, 19 | pub avatar: String, 20 | pub avatar_static: String, 21 | pub emojis: Vec, 22 | } 23 | 24 | impl Account { 25 | pub fn from_json(user: User) -> Result { 26 | let avatar = if let Some(icon) = user.icon { 27 | icon.url.to_string() 28 | } else { 29 | format!( 30 | "https://ui-avatars.com/api/?name={}&background=random&format=svg", 31 | urlencoding::encode(&user.name) 32 | ) 33 | }; 34 | 35 | Ok(Self { 36 | id: user.id.clone().into(), 37 | username: user.preferred_username, 38 | url: user.id.into(), 39 | display_name: user.name, 40 | avatar: avatar.clone(), 41 | avatar_static: avatar, 42 | emojis: CustomEmoji::from_json(user.tag), 43 | }) 44 | } 45 | 46 | pub async fn from_id(user_id: String, data: &Data) -> Result { 47 | match PreludeUser::find_by_id(&user_id).one(&data.conn).await? { 48 | Some(db_user) => { 49 | let apub_user: ApubUser = db_user.into(); 50 | let user: User = apub_user.into_json(data).await?; 51 | Ok(Self::from_json(user)?) 52 | }, 53 | None => Err(AppError::not_found("Account", &user_id)), 54 | } 55 | } 56 | 57 | pub async fn primary_account(data: &Data) -> Result { 58 | let user_id = 59 | hatsu_utils::url::generate_user_url(data.domain(), &data.env.hatsu_primary_account)? 60 | .to_string(); 61 | 62 | Self::from_id(user_id, data).await 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /crates/api_admin/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{ 3 | body::Body, 4 | http::{Request, StatusCode}, 5 | middleware::{self, Next}, 6 | response::Response, 7 | }; 8 | use hatsu_utils::AppData; 9 | use utoipa::{ 10 | Modify, 11 | OpenApi, 12 | openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, 13 | }; 14 | use utoipa_axum::{router::OpenApiRouter, routes}; 15 | 16 | use crate::entities::{BlockUrlResult, CreateRemoveAccountResult}; 17 | 18 | mod block_url; 19 | mod create_account; 20 | mod remove_account; 21 | mod unblock_url; 22 | 23 | pub const TAG: &str = "hatsu::admin"; 24 | 25 | #[derive(OpenApi)] 26 | #[openapi( 27 | components(schemas( 28 | BlockUrlResult, 29 | CreateRemoveAccountResult 30 | )), 31 | modifiers(&SecurityAddon), 32 | tags( 33 | (name = TAG, description = "Hatsu Admin API (/api/v0/admin/)"), 34 | ) 35 | )] 36 | pub struct HatsuAdminApi; 37 | 38 | pub struct SecurityAddon; 39 | 40 | impl Modify for SecurityAddon { 41 | fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { 42 | if let Some(components) = openapi.components.as_mut() { 43 | components.add_security_scheme( 44 | "api_key", 45 | SecurityScheme::ApiKey(ApiKey::Query(ApiKeyValue::new("token"))), 46 | ); 47 | } 48 | } 49 | } 50 | 51 | pub fn routes() -> OpenApiRouter { 52 | OpenApiRouter::with_openapi(HatsuAdminApi::openapi()) 53 | .routes(routes!(block_url::block_url)) 54 | .routes(routes!(create_account::create_account)) 55 | .routes(routes!(remove_account::remove_account)) 56 | .routes(routes!(unblock_url::unblock_url)) 57 | .layer(middleware::from_fn(auth)) 58 | } 59 | 60 | async fn auth( 61 | data: Data, 62 | request: Request, 63 | next: Next, 64 | ) -> Result { 65 | match &data.env.hatsu_access_token { 66 | Some(token) => match request.uri().query() { 67 | Some(queries) 68 | if queries 69 | .split('&') 70 | .any(|query| query.eq(&format!("token={token}"))) => 71 | Ok(next.run(request).await), 72 | _ => Err(StatusCode::UNAUTHORIZED), 73 | }, 74 | None => Err(StatusCode::UNAUTHORIZED), 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /crates/well_known/src/routes/host_meta.rs: -------------------------------------------------------------------------------- 1 | // https://www.rfc-editor.org/rfc/rfc6415 2 | 3 | use activitypub_federation::config::Data; 4 | use axum::{ 5 | Json, 6 | debug_handler, 7 | http::header::{self, HeaderMap, HeaderValue}, 8 | response::Redirect, 9 | }; 10 | use hatsu_utils::AppData; 11 | 12 | use crate::{TAG, entities::HostMeta}; 13 | 14 | /// The host-meta Redirect. 15 | #[utoipa::path( 16 | get, 17 | tag = TAG, 18 | path = "/.well-known/host-meta", 19 | responses((status = TEMPORARY_REDIRECT)), 20 | )] 21 | #[debug_handler] 22 | pub async fn redirect( 23 | // TODO: use axum_extra::TypedHeader 24 | // https://github.com/hyperium/headers/issues/53 25 | headers: HeaderMap, 26 | ) -> Redirect { 27 | headers.get(header::ACCEPT).map_or_else( 28 | || Redirect::temporary("/.well-known/host-meta.xml"), 29 | |accept| match accept.to_str() { 30 | Ok(accept) if accept.contains("json") => 31 | Redirect::temporary("/.well-known/host-meta.json"), 32 | _ => Redirect::temporary("/.well-known/host-meta.xml"), 33 | }, 34 | ) 35 | } 36 | 37 | /// The host-meta Document. 38 | #[utoipa::path( 39 | get, 40 | tag = "well_known", 41 | path = "/.well-known/host-meta.xml", 42 | responses( 43 | (status = OK, description = "", body = String), 44 | ), 45 | )] 46 | #[debug_handler] 47 | pub async fn xml(data: Data) -> (HeaderMap, String) { 48 | let mut headers = HeaderMap::new(); 49 | headers.insert( 50 | header::CONTENT_TYPE, 51 | HeaderValue::from_static("application/xml+xrd"), 52 | ); 53 | ( 54 | headers, 55 | format!( 56 | r#" 57 | 58 | 59 | "#, 60 | data.domain() 61 | ), 62 | ) 63 | } 64 | 65 | /// The host-meta.json Document. 66 | #[utoipa::path( 67 | get, 68 | tag = "well_known", 69 | path = "/.well-known/host-meta.json", 70 | responses( 71 | (status = OK, description = "", body = HostMeta), 72 | ), 73 | )] 74 | #[debug_handler] 75 | pub async fn json(data: Data) -> Json { 76 | Json(HostMeta::new(&data)) 77 | } 78 | -------------------------------------------------------------------------------- /crates/apub/src/activities/following/undo_follow.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::{ 2 | config::Data, 3 | fetch::object_id::ObjectId, 4 | kinds::activity::UndoType, 5 | protocol::helpers::deserialize_skip_error, 6 | traits::ActivityHandler, 7 | }; 8 | use hatsu_db_schema::prelude::ReceivedFollow; 9 | use hatsu_utils::{AppData, AppError}; 10 | use sea_orm::EntityTrait; 11 | use serde::{Deserialize, Serialize}; 12 | use url::Url; 13 | 14 | use crate::{activities::Follow, actors::ApubUser, utils::verify_blocked}; 15 | 16 | // https://github.com/LemmyNet/lemmy/blob/963d04b3526f8a5e9ff762960bfb5215e353bb27/crates/apub/src/protocol/activities/following/undo_follow.rs 17 | #[derive(Clone, Debug, Deserialize, Serialize)] 18 | #[serde(rename_all = "camelCase")] 19 | pub struct UndoFollow { 20 | pub(crate) actor: ObjectId, 21 | /// Optional, for compatibility with platforms that always expect recipient field 22 | #[serde(deserialize_with = "deserialize_skip_error", default)] 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub(crate) to: Option<[ObjectId; 1]>, 25 | pub(crate) object: Follow, 26 | #[serde(rename = "type")] 27 | pub(crate) kind: UndoType, 28 | pub(crate) id: Url, 29 | } 30 | 31 | /// 只接收,不发送 32 | /// receive only, without send 33 | /// 34 | #[async_trait::async_trait] 35 | impl ActivityHandler for UndoFollow { 36 | type DataType = AppData; 37 | type Error = AppError; 38 | 39 | fn id(&self) -> &Url { 40 | &self.id 41 | } 42 | 43 | fn actor(&self) -> &Url { 44 | self.actor.inner() 45 | } 46 | 47 | async fn verify(&self, data: &Data) -> Result<(), Self::Error> { 48 | // TODO 49 | verify_blocked(self.actor(), data).await?; 50 | Ok(()) 51 | } 52 | 53 | async fn receive(self, data: &Data) -> Result<(), Self::Error> { 54 | // 被取消关注者(本地账号), user 55 | // let object = self.object.object.dereference_local(data).await?; 56 | // 取消关注者, unfollower 57 | // let actor = self.actor.dereference(data).await?; 58 | 59 | // 删除关注记录 60 | ReceivedFollow::delete_by_id(self.object.id) 61 | .exec(&data.conn) 62 | .await?; 63 | 64 | Ok(()) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /crates/apub/assets/gotosocial/objects/note_without_tag.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://www.w3.org/ns/activitystreams", 4 | "http://joinmastodon.org/ns" 5 | ], 6 | "attachment": [], 7 | "attributedTo": "https://social.hyp3r.link/users/kwa", 8 | "cc": [ 9 | "https://social.hyp3r.link/users/kwa/followers", 10 | "https://hatsu-nightly-debug.hyp3r.link/users/kwaa-blog-next.deno.dev" 11 | ], 12 | "content": "

@kwaa-blog-next.deno.dev :roka_wink: hatsu custom emoji test :murasame_smile:
:ATRI_caution::ATRI_crab::ATRI_dive::ATRI_pillow::ATRI_question::ATRI_wow:
:trinoline01::trinoline02::trinoline03::trinoline04::trinoline05:

", 13 | "contentMap": { 14 | "zh": "

@kwaa-blog-next.deno.dev :roka_wink: hatsu custom emoji test :murasame_smile:
:ATRI_caution::ATRI_crab::ATRI_dive::ATRI_pillow::ATRI_question::ATRI_wow:
:trinoline01::trinoline02::trinoline03::trinoline04::trinoline05:

" 15 | }, 16 | "id": "https://social.hyp3r.link/users/kwa/statuses/01HRPDSA1VDYHZRGWAH4KTKD1E", 17 | "inReplyTo": "https://hatsu-nightly-debug.hyp3r.link/posts/https://kwaa-blog-next.deno.dev/articles/test/", 18 | "published": "2024-03-11T09:34:02Z", 19 | "replies": { 20 | "first": { 21 | "id": "https://social.hyp3r.link/users/kwa/statuses/01HRPDSA1VDYHZRGWAH4KTKD1E/replies?page=true", 22 | "next": "https://social.hyp3r.link/users/kwa/statuses/01HRPDSA1VDYHZRGWAH4KTKD1E/replies?only_other_accounts=false&page=true", 23 | "partOf": "https://social.hyp3r.link/users/kwa/statuses/01HRPDSA1VDYHZRGWAH4KTKD1E/replies", 24 | "type": "CollectionPage" 25 | }, 26 | "id": "https://social.hyp3r.link/users/kwa/statuses/01HRPDSA1VDYHZRGWAH4KTKD1E/replies", 27 | "type": "Collection" 28 | }, 29 | "sensitive": false, 30 | "summary": "", 31 | "to": "https://www.w3.org/ns/activitystreams#Public", 32 | "type": "Note", 33 | "url": "https://social.hyp3r.link/@kwa/statuses/01HRPDSA1VDYHZRGWAH4KTKD1E" 34 | } 35 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/routes/statuses/status_favourited_by.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{Json, debug_handler, extract::Path}; 3 | use futures::future::TryJoinAll; 4 | use hatsu_db_schema::prelude::{Post, ReceivedLike}; 5 | use hatsu_utils::{AppData, AppError}; 6 | use sea_orm::{EntityTrait, ModelTrait}; 7 | 8 | use crate::{TAG, entities::Account}; 9 | 10 | /// See who favourited a status 11 | /// 12 | /// 13 | #[utoipa::path( 14 | get, 15 | tag = TAG, 16 | path = "/api/v1/statuses/{id}/favourited_by", 17 | responses( 18 | (status = OK, description = "A list of accounts who favourited the status", body = Vec), 19 | (status = NOT_FOUND, description = "Status does not exist or is private", body = AppError), 20 | ), 21 | params( 22 | ("id" = String, Path, description = "The ID of the Status in the database.") 23 | ) 24 | )] 25 | #[debug_handler] 26 | pub async fn status_favourited_by( 27 | Path(base64_url): Path, 28 | data: Data, 29 | ) -> Result>, AppError> { 30 | let base64 = base64_simd::URL_SAFE; 31 | 32 | match base64.decode_to_vec(&base64_url) { 33 | Ok(utf8_url) => match String::from_utf8(utf8_url) { 34 | Ok(url) if url.starts_with("https://") => { 35 | let post_url = hatsu_utils::url::generate_post_url(data.domain(), url)?; 36 | 37 | match Post::find_by_id(post_url.to_string()) 38 | .one(&data.conn) 39 | .await? 40 | { 41 | Some(post) => Ok(Json( 42 | post.find_related(ReceivedLike) 43 | .all(&data.conn) 44 | .await? 45 | .into_iter() 46 | .map(|received_like| async { 47 | Account::from_id(received_like.actor, &data).await 48 | }) 49 | .collect::>() 50 | .await?, 51 | )), 52 | _ => Err(AppError::not_found("Record", &base64_url)), 53 | } 54 | }, 55 | _ => Err(AppError::not_found("Record", &base64_url)), 56 | }, 57 | _ => Err(AppError::not_found("Record", &base64_url)), 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /crates/api_mastodon/src/routes/statuses/status_reblogged_by.rs: -------------------------------------------------------------------------------- 1 | use activitypub_federation::config::Data; 2 | use axum::{Json, debug_handler, extract::Path}; 3 | use futures::future::TryJoinAll; 4 | use hatsu_db_schema::prelude::{Post, ReceivedAnnounce}; 5 | use hatsu_utils::{AppData, AppError}; 6 | use sea_orm::{EntityTrait, ModelTrait}; 7 | 8 | use crate::{TAG, entities::Account}; 9 | 10 | /// See who boosted a status 11 | /// 12 | /// 13 | #[utoipa::path( 14 | get, 15 | tag = TAG, 16 | path = "/api/v1/statuses/{id}/reblogged_by", 17 | responses( 18 | (status = OK, description = "A list of accounts that boosted the status", body = Vec), 19 | (status = NOT_FOUND, description = "Status does not exist or is private", body = AppError), 20 | ), 21 | params( 22 | ("id" = String, Path, description = "The ID of the Status in the database.") 23 | ) 24 | )] 25 | #[debug_handler] 26 | pub async fn status_reblogged_by( 27 | Path(base64_url): Path, 28 | data: Data, 29 | ) -> Result>, AppError> { 30 | let base64 = base64_simd::URL_SAFE; 31 | 32 | match base64.decode_to_vec(&base64_url) { 33 | Ok(utf8_url) => match String::from_utf8(utf8_url) { 34 | Ok(url) if url.starts_with("https://") => { 35 | let post_url = hatsu_utils::url::generate_post_url(data.domain(), url)?; 36 | 37 | match Post::find_by_id(post_url.to_string()) 38 | .one(&data.conn) 39 | .await? 40 | { 41 | Some(post) => Ok(Json( 42 | post.find_related(ReceivedAnnounce) 43 | .all(&data.conn) 44 | .await? 45 | .into_iter() 46 | .map(|received_like| async { 47 | Account::from_id(received_like.actor, &data).await 48 | }) 49 | .collect::>() 50 | .await?, 51 | )), 52 | _ => Err(AppError::not_found("Record", &base64_url)), 53 | } 54 | }, 55 | _ => Err(AppError::not_found("Record", &base64_url)), 56 | }, 57 | _ => Err(AppError::not_found("Record", &base64_url)), 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "github:importantimport/hatsu"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | 7 | flake-parts.url = "github:hercules-ci/flake-parts"; 8 | 9 | fenix.url = "github:nix-community/fenix/monthly"; 10 | fenix.inputs.nixpkgs.follows = "nixpkgs"; 11 | }; 12 | 13 | outputs = 14 | inputs@{ 15 | fenix, 16 | flake-parts, 17 | nixpkgs, 18 | ... 19 | }: 20 | flake-parts.lib.mkFlake { inherit inputs; } { 21 | imports = [ ]; 22 | systems = [ 23 | "x86_64-linux" 24 | "aarch64-linux" 25 | ]; 26 | 27 | perSystem = 28 | { 29 | config, 30 | self', 31 | inputs', 32 | lib, 33 | pkgs, 34 | system, 35 | ... 36 | }: 37 | let 38 | toolchain = 39 | with fenix.packages.${system}; 40 | combine [ 41 | complete.toolchain 42 | targets.aarch64-unknown-linux-gnu.latest.rust-std 43 | targets.aarch64-unknown-linux-musl.latest.rust-std 44 | targets.x86_64-unknown-linux-gnu.latest.rust-std 45 | targets.x86_64-unknown-linux-musl.latest.rust-std 46 | ]; 47 | in 48 | { 49 | devShells.default = pkgs.mkShell { 50 | packages = 51 | [ toolchain ] 52 | ++ (with pkgs; [ 53 | mdbook # ./docs/ 54 | 55 | # cargo-* 56 | cargo-watch 57 | cargo-zigbuild 58 | 59 | # sea-orm 60 | sea-orm-cli 61 | 62 | # just 63 | # mold 64 | # sccache 65 | ]) 66 | ++ (with fenix.packages.${system}; [ 67 | rust-analyzer 68 | ]); 69 | }; 70 | 71 | packages.default = 72 | let 73 | version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version; 74 | rustPlatform = pkgs.makeRustPlatform { 75 | cargo = toolchain; 76 | rustc = toolchain; 77 | }; 78 | in 79 | rustPlatform.buildRustPackage { 80 | inherit version; 81 | pname = "hatsu"; 82 | src = ./.; 83 | cargoLock.lockFile = ./Cargo.lock; 84 | }; 85 | }; 86 | }; 87 | } 88 | --------------------------------------------------------------------------------