diff --git a/Cargo.lock b/Cargo.lock index 91f7f611d..39ebce759 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1973,6 +1973,7 @@ dependencies = [ "plume-api 0.3.0", "plume-common 0.3.0", "plume-models 0.3.0", + "reqwest 0.9.19 (registry+https://github.com/rust-lang/crates.io-index)", "rocket 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", "rocket_contrib 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", "rocket_csrf 0.1.0 (git+https://github.com/fdb-hiroshima/rocket_csrf?rev=4a72ea2ec716cb0b26188fb00bccf2ef7d1e031c)", diff --git a/Cargo.toml b/Cargo.toml index 0254f681e..a6495f190 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ heck = "0.3.0" lettre = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" } lettre_email = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" } num_cpus = "1.10" +reqwest = "0.9" rocket = "0.4.0" rocket_contrib = { version = "0.4.0", features = ["json"] } rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" } diff --git a/migrations/postgres/2019-05-22-124143_add_custom_domains/down.sql b/migrations/postgres/2019-05-22-124143_add_custom_domains/down.sql new file mode 100644 index 000000000..7f1817140 --- /dev/null +++ b/migrations/postgres/2019-05-22-124143_add_custom_domains/down.sql @@ -0,0 +1,2 @@ +-- undo the adding of custom_domain column to blogs table. +ALTER TABLE blogs DROP COLUMN custom_domain; diff --git a/migrations/postgres/2019-05-22-124143_add_custom_domains/up.sql b/migrations/postgres/2019-05-22-124143_add_custom_domains/up.sql new file mode 100644 index 000000000..d00f5a29e --- /dev/null +++ b/migrations/postgres/2019-05-22-124143_add_custom_domains/up.sql @@ -0,0 +1,2 @@ +--- Adding custom domain to Blog as an optional field +ALTER TABLE blogs ADD COLUMN custom_domain VARCHAR DEFAULT NULL UNIQUE; diff --git a/migrations/sqlite/2019-05-22-124153_add_custom_domains/down.sql b/migrations/sqlite/2019-05-22-124153_add_custom_domains/down.sql new file mode 100644 index 000000000..ed5d92315 --- /dev/null +++ b/migrations/sqlite/2019-05-22-124153_add_custom_domains/down.sql @@ -0,0 +1,56 @@ +-- undo the adding of "custom_domain" to blogs +CREATE TABLE IF NOT EXISTS "blogs_drop_custom_domain" ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + actor_id VARCHAR NOT NULL, + title VARCHAR NOT NULL, + summary TEXT NOT NULL DEFAULT '', + outbox_url VARCHAR NOT NULL UNIQUE, + inbox_url VARCHAR NOT NULL UNIQUE, + instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL, + creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ap_url text not null default '' UNIQUE, + private_key TEXT, + public_key TEXT NOT NULL DEFAULT '', + fqn TEXT NOT NULL DEFAULT '', + summary_html TEXT NOT NULL DEFAULT '', + icon_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL, + banner_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL, + CONSTRAINT blog_unique UNIQUE (actor_id, instance_id) +); + +INSERT INTO blogs_drop_custom_domain ( + id, + actor_id, + title, + summary, + outbox_url, + inbox_url, + instance_id, + creation_date, + ap_url, + private_key, + public_key, + fqn, + summary_html, + icon_id, + banner_id +) SELECT + id, + actor_id, + title, + summary, + outbox_url, + inbox_url, + instance_id, + creation_date, + ap_url, + private_key, + public_key, + fqn, + summary_html, + icon_id, + banner_id +FROM blogs; + +DROP TABLE blogs; +ALTER TABLE "blogs_drop_custom_domain" RENAME to blogs; diff --git a/migrations/sqlite/2019-05-22-124153_add_custom_domains/up.sql b/migrations/sqlite/2019-05-22-124153_add_custom_domains/up.sql new file mode 100644 index 000000000..1cdffa7c5 --- /dev/null +++ b/migrations/sqlite/2019-05-22-124153_add_custom_domains/up.sql @@ -0,0 +1,57 @@ +-- add custom_domain to blogs +CREATE TABLE IF NOT EXISTS "blogs_add_custom_domain" ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + actor_id VARCHAR NOT NULL, + title VARCHAR NOT NULL, + summary TEXT NOT NULL DEFAULT '', + outbox_url VARCHAR NOT NULL UNIQUE, + inbox_url VARCHAR NOT NULL UNIQUE, + instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL, + creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ap_url text not null default '' UNIQUE, + private_key TEXT, + public_key TEXT NOT NULL DEFAULT '', + fqn TEXT NOT NULL DEFAULT '', + summary_html TEXT NOT NULL DEFAULT '', + icon_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL, + banner_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL, + custom_domain text default NULL UNIQUE, + CONSTRAINT blog_unique UNIQUE (actor_id, instance_id) +); + +INSERT INTO blogs_add_custom_domain ( + id, + actor_id, + title, + summary, + outbox_url, + inbox_url, + instance_id, + creation_date, + ap_url, + private_key, + public_key, + fqn, + summary_html, + icon_id, + banner_id +) SELECT + id, + actor_id, + title, + summary, + outbox_url, + inbox_url, + instance_id, + creation_date, + ap_url, + private_key, + public_key, + fqn, + summary_html, + icon_id, + banner_id +FROM blogs; + +DROP TABLE blogs; +ALTER TABLE "blogs_add_custom_domain" RENAME to blogs; diff --git a/plume-models/Cargo.toml b/plume-models/Cargo.toml index 436d67b4f..8889d8892 100644 --- a/plume-models/Cargo.toml +++ b/plume-models/Cargo.toml @@ -25,7 +25,6 @@ tantivy = "0.10.1" url = "2.1" webfinger = "0.4.1" whatlang = "0.7.1" -shrinkwraprs = "0.2.1" diesel-derive-newtype = "0.1.2" [dependencies.chrono] diff --git a/plume-models/src/blogs.rs b/plume-models/src/blogs.rs index 8c55f4021..67664658d 100644 --- a/plume-models/src/blogs.rs +++ b/plume-models/src/blogs.rs @@ -7,6 +7,11 @@ use openssl::{ rsa::Rsa, sign::{Signer, Verifier}, }; +use rocket::{ + http::RawStr, + outcome::IntoOutcome, + request::{self, FromFormValue, FromRequest, Request}, +}; use serde_json; use url::Url; use webfinger::*; @@ -21,11 +26,70 @@ use posts::Post; use safe_string::SafeString; use schema::blogs; use search::Searcher; +use std::default::Default; +use std::fmt::{self, Display}; +use std::ops::Deref; +use std::sync::RwLock; use users::User; use {Connection, Error, PlumeRocket, Result}; pub type CustomGroup = CustomObject; +#[derive(Clone, Debug, PartialEq, DieselNewType)] +pub struct Host(String); + +impl Host { + pub fn new(host: impl ToString) -> Host { + Host(host.to_string()) + } +} + +impl Deref for Host { + type Target = str; + fn deref(&self) -> &str { + &self.0 + } +} + +impl Display for Host { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for Host { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl<'a, 'r> FromRequest<'a, 'r> for Host { + type Error = (); + + fn from_request(request: &'a Request<'r>) -> request::Outcome { + request + .headers() + .get_one("Host") + .and_then(|x| { + if Blog::list_custom_domains().contains(&x.to_string()) { + Some(Host::new(x)) + } else { + None + } + }) + .or_forward(()) + } +} + +impl<'v> FromFormValue<'v> for Host { + type Error = &'v RawStr; + + fn from_form_value(form_value: &'v RawStr) -> std::result::Result { + let val = String::from_form_value(form_value)?; + Ok(Host::new(&val)) + } +} + #[derive(Queryable, Identifiable, Clone, AsChangeset)] #[changeset_options(treat_none_as_null = "true")] pub struct Blog { @@ -44,6 +108,7 @@ pub struct Blog { pub summary_html: SafeString, pub icon_id: Option, pub banner_id: Option, + pub custom_domain: Option, } #[derive(Default, Insertable)] @@ -61,10 +126,15 @@ pub struct NewBlog { pub summary_html: SafeString, pub icon_id: Option, pub banner_id: Option, + pub custom_domain: Option, } const BLOG_PREFIX: &str = "~"; +lazy_static! { + static ref CUSTOM_DOMAINS: RwLock> = RwLock::new(vec![]); +} + impl Blog { insert!(blogs, NewBlog, |inserted, conn| { let instance = inserted.get_instance(conn)?; @@ -144,6 +214,13 @@ impl Blog { } } + pub fn find_by_host(c: &PlumeRocket, host: Host) -> Result { + blogs::table + .filter(blogs::custom_domain.eq(host)) + .first::(&*c.conn) + .map_err(|_| Error::NotFound) + } + fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result { resolve_with_prefix(Prefix::Group, acct.to_owned(), true)? .links @@ -269,6 +346,19 @@ impl Blog { }) } + pub fn url(&self) -> String { + format!( + "https://{}", + self.custom_domain + .clone() + .unwrap_or_else(|| Host::new(format!( + "{}/~/{}", + Instance::get_local().unwrap().public_domain, + self.fqn, + ))) + ) + } + pub fn icon_url(&self, conn: &Connection) -> String { self.icon_id .and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok()) @@ -290,6 +380,23 @@ impl Blog { .map(|_| ()) .map_err(Error::from) } + + pub fn list_custom_domains() -> Vec { + CUSTOM_DOMAINS.read().unwrap().clone() + } + + pub fn cache_custom_domains(conn: &Connection) { + *CUSTOM_DOMAINS.write().unwrap() = Blog::list_custom_domains_uncached(conn).unwrap(); + } + + pub fn list_custom_domains_uncached(conn: &Connection) -> Result> { + blogs::table + .filter(blogs::custom_domain.is_not_null()) + .select(blogs::custom_domain) + .load::>(conn) + .map_err(Error::from) + .map(|res| res.into_iter().map(Option::unwrap).collect::>()) + } } impl IntoId for Blog { @@ -392,6 +499,7 @@ impl FromId for Blog { .summary_string() .unwrap_or_default(), ), + custom_domain: None, }, ) } @@ -441,6 +549,7 @@ impl NewBlog { title: String, summary: String, instance_id: i32, + custom_domain: Option, ) -> Result { let (pub_key, priv_key) = sign::gen_keypair(); Ok(NewBlog { @@ -450,6 +559,7 @@ impl NewBlog { instance_id, public_key: String::from_utf8(pub_key).or(Err(Error::Signature))?, private_key: Some(String::from_utf8(priv_key).or(Err(Error::Signature))?), + custom_domain, ..NewBlog::default() }) } @@ -477,6 +587,7 @@ pub(crate) mod tests { "Blog name".to_owned(), "This is a small blog".to_owned(), Instance::get_local().unwrap().id, + None, ) .unwrap(), ) @@ -488,6 +599,7 @@ pub(crate) mod tests { "My blog".to_owned(), "Welcome to my blog".to_owned(), Instance::get_local().unwrap().id, + Some(Host::new("blog.myname.me")), ) .unwrap(), ) @@ -499,6 +611,7 @@ pub(crate) mod tests { "Why I like Plume".to_owned(), "In this blog I will explay you why I like Plume so much".to_owned(), Instance::get_local().unwrap().id, + None, ) .unwrap(), ) @@ -559,6 +672,7 @@ pub(crate) mod tests { "Some name".to_owned(), "This is some blog".to_owned(), Instance::get_local().unwrap().id, + Some(Host::new("some.blog.com")), ) .unwrap(), ) @@ -587,6 +701,7 @@ pub(crate) mod tests { "Some name".to_owned(), "This is some blog".to_owned(), Instance::get_local().unwrap().id, + None, ) .unwrap(), ) @@ -598,6 +713,7 @@ pub(crate) mod tests { "Blog".to_owned(), "I've named my blog Blog".to_owned(), Instance::get_local().unwrap().id, + Some(Host::new("named.example.blog")), ) .unwrap(), ) @@ -690,6 +806,7 @@ pub(crate) mod tests { "Some name".to_owned(), "This is some blog".to_owned(), Instance::get_local().unwrap().id, + None, ) .unwrap(), ) @@ -714,6 +831,7 @@ pub(crate) mod tests { "Some name".to_owned(), "This is some blog".to_owned(), Instance::get_local().unwrap().id, + Some(Host::new("some.blog.com")), ) .unwrap(), ) @@ -752,6 +870,7 @@ pub(crate) mod tests { "Some name".to_owned(), "This is some blog".to_owned(), Instance::get_local().unwrap().id, + None, ) .unwrap(), ) @@ -763,6 +882,7 @@ pub(crate) mod tests { "Blog".to_owned(), "I've named my blog Blog".to_owned(), Instance::get_local().unwrap().id, + Some(Host::new("my.blog.com")), ) .unwrap(), ) diff --git a/plume-models/src/lib.rs b/plume-models/src/lib.rs index 2c30cb8b3..e43c19c03 100644 --- a/plume-models/src/lib.rs +++ b/plume-models/src/lib.rs @@ -10,6 +10,8 @@ extern crate bcrypt; extern crate chrono; #[macro_use] extern crate diesel; +#[macro_use] +extern crate diesel_derive_newtype; extern crate guid_create; extern crate heck; extern crate itertools; diff --git a/plume-models/src/schema.rs b/plume-models/src/schema.rs index f2cc833c8..01570bac2 100644 --- a/plume-models/src/schema.rs +++ b/plume-models/src/schema.rs @@ -1,17 +1,17 @@ table! { api_tokens (id) { - id -> Int4, + id -> Integer, creation_date -> Timestamp, value -> Text, scopes -> Text, - app_id -> Int4, - user_id -> Int4, + app_id -> Integer, + user_id -> Integer, } } table! { apps (id) { - id -> Int4, + id -> Integer, name -> Text, client_id -> Text, client_secret -> Text, @@ -23,70 +23,71 @@ table! { table! { blog_authors (id) { - id -> Int4, - blog_id -> Int4, - author_id -> Int4, + id -> Integer, + blog_id -> Integer, + author_id -> Integer, is_owner -> Bool, } } table! { blogs (id) { - id -> Int4, - actor_id -> Varchar, - title -> Varchar, + id -> Integer, + actor_id -> Text, + title -> Text, summary -> Text, - outbox_url -> Varchar, - inbox_url -> Varchar, - instance_id -> Int4, + outbox_url -> Text, + inbox_url -> Text, + instance_id -> Integer, creation_date -> Timestamp, ap_url -> Text, private_key -> Nullable, public_key -> Text, fqn -> Text, summary_html -> Text, - icon_id -> Nullable, - banner_id -> Nullable, + icon_id -> Nullable, + banner_id -> Nullable, + custom_domain -> Nullable, + } +} + +table! { + comment_seers (id) { + id -> Integer, + comment_id -> Integer, + user_id -> Integer, } } table! { comments (id) { - id -> Int4, + id -> Integer, content -> Text, - in_response_to_id -> Nullable, - post_id -> Int4, - author_id -> Int4, + in_response_to_id -> Nullable, + post_id -> Integer, + author_id -> Integer, creation_date -> Timestamp, - ap_url -> Nullable, + ap_url -> Nullable, sensitive -> Bool, spoiler_text -> Text, public_visibility -> Bool, } } -table! { - comment_seers (id) { - id -> Int4, - comment_id -> Int4, - user_id -> Int4, - } -} - table! { follows (id) { - id -> Int4, - follower_id -> Int4, - following_id -> Int4, + id -> Integer, + follower_id -> Integer, + following_id -> Integer, ap_url -> Text, } } table! { instances (id) { - id -> Int4, - public_domain -> Varchar, - name -> Varchar, + id -> Integer, + public_domain -> Text, + name -> Text, local -> Bool, blocked -> Bool, creation_date -> Timestamp, @@ -94,50 +95,50 @@ table! { short_description -> Text, long_description -> Text, default_license -> Text, - long_description_html -> Varchar, - short_description_html -> Varchar, + long_description_html -> Text, + short_description_html -> Text, } } table! { likes (id) { - id -> Int4, - user_id -> Int4, - post_id -> Int4, + id -> Integer, + user_id -> Integer, + post_id -> Integer, creation_date -> Timestamp, - ap_url -> Varchar, + ap_url -> Text, } } table! { medias (id) { - id -> Int4, + id -> Integer, file_path -> Text, alt_text -> Text, is_remote -> Bool, remote_url -> Nullable, sensitive -> Bool, content_warning -> Nullable, - owner_id -> Int4, + owner_id -> Integer, } } table! { mentions (id) { - id -> Int4, - mentioned_id -> Int4, - post_id -> Nullable, - comment_id -> Nullable, + id -> Integer, + mentioned_id -> Integer, + post_id -> Nullable, + comment_id -> Nullable, } } table! { notifications (id) { - id -> Int4, - user_id -> Int4, + id -> Integer, + user_id -> Integer, creation_date -> Timestamp, - kind -> Varchar, - object_id -> Int4, + kind -> Text, + object_id -> Integer, } } @@ -152,67 +153,67 @@ table! { table! { post_authors (id) { - id -> Int4, - post_id -> Int4, - author_id -> Int4, + id -> Integer, + post_id -> Integer, + author_id -> Integer, } } table! { posts (id) { - id -> Int4, - blog_id -> Int4, - slug -> Varchar, - title -> Varchar, + id -> Integer, + blog_id -> Integer, + slug -> Text, + title -> Text, content -> Text, published -> Bool, - license -> Varchar, + license -> Text, creation_date -> Timestamp, - ap_url -> Varchar, + ap_url -> Text, subtitle -> Text, source -> Text, - cover_id -> Nullable, + cover_id -> Nullable, } } table! { reshares (id) { - id -> Int4, - user_id -> Int4, - post_id -> Int4, - ap_url -> Varchar, + id -> Integer, + user_id -> Integer, + post_id -> Integer, + ap_url -> Text, creation_date -> Timestamp, } } table! { tags (id) { - id -> Int4, + id -> Integer, tag -> Text, is_hashtag -> Bool, - post_id -> Int4, + post_id -> Integer, } } table! { users (id) { - id -> Int4, - username -> Varchar, - display_name -> Varchar, - outbox_url -> Varchar, - inbox_url -> Varchar, + id -> Integer, + username -> Text, + display_name -> Text, + outbox_url -> Text, + inbox_url -> Text, is_admin -> Bool, summary -> Text, email -> Nullable, hashed_password -> Nullable, - instance_id -> Int4, + instance_id -> Integer, creation_date -> Timestamp, ap_url -> Text, private_key -> Nullable, public_key -> Text, - shared_inbox_url -> Nullable, - followers_endpoint -> Varchar, - avatar_id -> Nullable, + shared_inbox_url -> Nullable, + followers_endpoint -> Text, + avatar_id -> Nullable, last_fetched_date -> Timestamp, fqn -> Text, summary_html -> Text, @@ -248,8 +249,8 @@ allow_tables_to_appear_in_same_query!( apps, blog_authors, blogs, - comments, comment_seers, + comments, follows, instances, likes, diff --git a/po/plume/plume.pot b/po/plume/plume.pot index 8e55c82eb..b362a602d 100644 --- a/po/plume/plume.pot +++ b/po/plume/plume.pot @@ -36,39 +36,39 @@ msgstr "" msgid "{0}'s avatar" msgstr "" -# src/routes/blogs.rs:64 +# src/routes/blogs.rs:96 msgid "To create a new blog, you need to be logged in" msgstr "" -# src/routes/blogs.rs:106 +# src/routes/blogs.rs:138 msgid "A blog with the same name already exists." msgstr "" -# src/routes/blogs.rs:141 +# src/routes/blogs.rs:173 msgid "Your blog was successfully created!" msgstr "" -# src/routes/blogs.rs:163 +# src/routes/blogs.rs:195 msgid "Your blog was deleted." msgstr "" -# src/routes/blogs.rs:170 +# src/routes/blogs.rs:202 msgid "You are not allowed to delete this blog." msgstr "" -# src/routes/blogs.rs:218 +# src/routes/blogs.rs:250 msgid "You are not allowed to edit this blog." msgstr "" -# src/routes/blogs.rs:263 +# src/routes/blogs.rs:295 msgid "You can't use this media as a blog icon." msgstr "" -# src/routes/blogs.rs:281 +# src/routes/blogs.rs:313 msgid "You can't use this media as a blog banner." msgstr "" -# src/routes/blogs.rs:314 +# src/routes/blogs.rs:346 msgid "Your blog information have been updated." msgstr "" diff --git a/src/main.rs b/src/main.rs index 0bdfaddc5..7ea006cf8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ extern crate num_cpus; extern crate plume_api; extern crate plume_common; extern crate plume_models; +extern crate reqwest; #[macro_use] extern crate rocket; extern crate rocket_contrib; @@ -42,17 +43,21 @@ extern crate webfinger; use clap::App; use diesel::r2d2::ConnectionManager; use plume_models::{ + blogs::Blog, + blogs::Host, db_conn::{DbPool, PragmaForeignKey}, instance::Instance, migrations::IMPORTED_MIGRATIONS, search::{Searcher as UnmanagedSearcher, SearcherError}, Connection, Error, CONFIG, }; +use rocket::{fairing::AdHoc, http::ext::IntoOwned, http::uri::Origin}; use rocket_csrf::CsrfFairingBuilder; use scheduled_thread_pool::ScheduledThreadPool; +use std::collections::HashMap; use std::process::exit; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; init_i18n!( "plume", ar, bg, ca, cs, de, en, eo, es, fr, gl, hi, hr, it, ja, nb, pl, pt, ro, ru, sr, sk, sv @@ -87,6 +92,7 @@ fn init_pool() -> Option { .build(manager) .ok()?; Instance::cache_local(&pool.get().unwrap()); + Blog::cache_custom_domains(&pool.get().unwrap()); Some(pool) } @@ -175,7 +181,42 @@ Then try to restart Plume println!("Please refer to the documentation to see how to configure it."); } + let custom_domain_fairing = AdHoc::on_request("Custom Blog Domains", |req, _data| { + let host = req.guard::(); + if host.is_success() + && req + .uri() + .segments() + .next() + .map(|path| path != "static" && path != "api") + .unwrap_or(true) + { + let rewrite_uri = format!("/custom_domains/{}/{}", host.unwrap(), req.uri()); + let uri = Origin::parse_owned(rewrite_uri).unwrap(); + let uri = uri.to_normalized().into_owned(); + req.set_uri(uri); + } + }); + + let valid_domains: HashMap = HashMap::new(); let rocket = rocket::custom(CONFIG.rocket.clone().unwrap()) + .mount( + "/custom_domains/domain_validation/", + routes![routes::blogs::custom::domain_validation,], + ) + .mount( + "/domain_validation/", + routes![routes::blogs::domain_validation,], + ) + .mount( + "/custom_domains/", + routes![ + routes::blogs::custom::details, + routes::posts::custom::details, + routes::blogs::custom::activity_details, + routes::search::custom::search, + ], + ) .mount( "/", routes![ @@ -288,6 +329,7 @@ Then try to restart Plume .manage(dbpool) .manage(Arc::new(workpool)) .manage(searcher) + .manage(Mutex::new(valid_domains)) .manage(include_i18n!()) .attach( CsrfFairingBuilder::new() @@ -314,7 +356,8 @@ Then try to restart Plume ]) .finalize() .expect("main: csrf fairing creation error"), - ); + ) + .attach(custom_domain_fairing); #[cfg(feature = "test")] let rocket = rocket.mount("/test", routes![test_routes::health,]); diff --git a/src/routes/blogs.rs b/src/routes/blogs.rs index 7ab9e2b35..4716bafd4 100644 --- a/src/routes/blogs.rs +++ b/src/routes/blogs.rs @@ -2,11 +2,14 @@ use activitypub::collection::OrderedCollection; use atom_syndication::{Entry, FeedBuilder}; use diesel::SaveChangesDsl; use rocket::{ - http::ContentType, + http::{ContentType, Status}, request::LenientForm, response::{content::Content, Flash, Redirect}, + State, }; use rocket_i18n::I18n; +use std::sync::Mutex; +use std::time::{Duration, Instant}; use std::{borrow::Cow, collections::HashMap}; use validator::{Validate, ValidationError, ValidationErrors}; @@ -16,14 +19,17 @@ use plume_models::{ blog_authors::*, blogs::*, instance::Instance, medias::*, posts::Post, safe_string::SafeString, users::User, Connection, PlumeRocket, }; +use reqwest::Client; use routes::{errors::ErrorPage, Page, RespondOrRedirect}; use template_utils::{IntoContext, Ructe}; -#[get("/~/?", rank = 2)] -pub fn details(name: String, page: Option, rockets: PlumeRocket) -> Result { +fn detail_guts( + blog: Blog, + page: Option, + rockets: PlumeRocket, +) -> Result { let page = page.unwrap_or_default(); let conn = &*rockets.conn; - let blog = Blog::find_by_fqn(&rockets, &name)?; let posts = Post::blog_page(conn, &blog, page.limits())?; let articles_count = Post::count_for_blog(conn, &blog)?; let authors = &blog.list_authors(conn)?; @@ -35,7 +41,43 @@ pub fn details(name: String, page: Option, rockets: PlumeRocket) -> Result page.0, Page::total(articles_count as i32), posts - ))) + )) + .into()) +} + +#[get("/~/?", rank = 2)] +pub fn details( + name: String, + page: Option, + rockets: PlumeRocket, +) -> Result { + let blog = Blog::find_by_fqn(&rockets, &name)?; + + // check this first, and return early + // doing this prevents partially moving `blog` into the `match (tuple)`, + // which makes it impossible to reuse then. + if blog.custom_domain == None { + return detail_guts(blog, page, rockets); + } + + match (blog.custom_domain, page) { + (Some(ref custom_domain), Some(ref page)) => { + Ok(Redirect::to(format!("https://{}/?page={}", custom_domain, page)).into()) + } + (Some(ref custom_domain), _) => { + Ok(Redirect::to(format!("https://{}/", custom_domain)).into()) + } + // we need this match arm, or the match won't compile + (None, _) => unreachable!("This code path should have already been handled!"), + } +} + +pub fn activity_detail_guts( + blog: Blog, + rockets: PlumeRocket, + _ap: ApRequest, +) -> Option> { + Some(ActivityStream::new(blog.to_activity(&*rockets.conn).ok()?)) } #[get("/~/", rank = 1)] @@ -45,7 +87,7 @@ pub fn activity_details( _ap: ApRequest, ) -> Option> { let blog = Blog::find_by_fqn(&rockets, &name).ok()?; - Some(ActivityStream::new(blog.to_activity(&*rockets.conn).ok()?)) + activity_detail_guts(blog, rockets, _ap) } #[get("/blogs/new")] @@ -57,6 +99,76 @@ pub fn new(rockets: PlumeRocket, _user: User) -> Ructe { )) } +// mounted as /domain_validation/ +#[get("/")] +pub fn domain_validation( + validation_id: String, + valid_domains: State>>, +) -> Status { + let mutex = valid_domains.inner().lock(); + let mut validation_map = mutex.unwrap(); + let validation_getter = validation_map.clone(); + + let value = validation_getter.get(&validation_id); + if value.is_none() { + // validation id not found + return Status::NotFound; + } + + // we have valid id, now check the time + let valid_until = value.unwrap(); + let now = Instant::now(); + + // nope, expired (410: gone) + if now.duration_since(*valid_until).as_secs() > 0 { + validation_map.remove(&validation_id); + // validation expired + return Status::Gone; + } + + validation_map.remove(&validation_id); + Status::Ok +} + +pub mod custom { + use plume_common::activity_pub::{ActivityStream, ApRequest}; + use plume_models::{blogs::Blog, blogs::CustomGroup, blogs::Host, PlumeRocket}; + use rocket::{http::Status, State}; + use routes::{errors::ErrorPage, Page, RespondOrRedirect}; + use std::collections::HashMap; + use std::sync::Mutex; + use std::time::Instant; + + #[get("/?", rank = 2)] + pub fn details( + custom_domain: String, + page: Option, + rockets: PlumeRocket, + ) -> Result { + let blog = Blog::find_by_host(&rockets, Host::new(custom_domain))?; + super::detail_guts(blog, page, rockets) + } + + #[get("/", rank = 1)] + pub fn activity_details( + custom_domain: String, + rockets: PlumeRocket, + _ap: ApRequest, + ) -> Option> { + let blog = Blog::find_by_host(&rockets, Host::new(custom_domain)).ok()?; + super::activity_detail_guts(blog, rockets, _ap) + } + + // mounted as /custom_domains/domain_validation/ + #[get("/")] + pub fn domain_validation( + validation_id: String, + valid_domains: State>>, + ) -> Status { + super::domain_validation(validation_id, valid_domains) + } +} + #[get("/blogs/new", rank = 2)] pub fn new_auth(i18n: I18n) -> Flash { utils::requires_login( @@ -72,6 +184,7 @@ pub fn new_auth(i18n: I18n) -> Flash { pub struct NewBlogForm { #[validate(custom(function = "valid_slug", message = "Invalid name"))] pub title: String, + pub custom_domain: String, } fn valid_slug(title: &str) -> Result<(), ValidationError> { @@ -83,13 +196,43 @@ fn valid_slug(title: &str) -> Result<(), ValidationError> { } } +fn valid_domain(domain: &str, valid_domains: State>>) -> bool { + let mutex = valid_domains.inner().lock(); + let mut validation_map = mutex.unwrap(); + + let random_id = utils::random_hex(); + validation_map.insert( + random_id.clone(), + Instant::now().checked_add(Duration::new(60, 0)).unwrap(), + ); + + let client = Client::new(); + let validation_uri = format!("https://{}/domain_validation/{}", domain, random_id); + + match client.get(&validation_uri).send() { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } +} + #[post("/blogs/new", data = "
")] -pub fn create(form: LenientForm, rockets: PlumeRocket) -> RespondOrRedirect { +pub fn create( + form: LenientForm, + rockets: PlumeRocket, + valid_domains: State>>, +) -> RespondOrRedirect { let slug = utils::make_actor_id(&form.title); let conn = &*rockets.conn; let intl = &rockets.intl.catalog; let user = rockets.user.clone().unwrap(); + let (custom_domain, dns_ok) = if form.custom_domain.is_empty() { + (None, true) + } else { + let dns_check = valid_domain(&form.custom_domain.clone(), valid_domains); + (Some(Host::new(form.custom_domain.clone())), dns_check) + }; + let mut errors = match form.validate() { Ok(_) => ValidationErrors::new(), Err(e) => e, @@ -121,6 +264,7 @@ pub fn create(form: LenientForm, rockets: PlumeRocket) -> RespondOr Instance::get_local() .expect("blog::create: instance error") .id, + custom_domain, ) .expect("blog::create: new local error"), ) @@ -136,11 +280,19 @@ pub fn create(form: LenientForm, rockets: PlumeRocket) -> RespondOr ) .expect("blog::create: author error"); - Flash::success( - Redirect::to(uri!(details: name = slug.clone(), page = _)), - &i18n!(intl, "Your blog was successfully created!"), - ) - .into() + if dns_ok { + Flash::success( + Redirect::to(uri!(details: name = slug.clone(), page = _)), + &i18n!(intl, "Your blog was successfully created!"), + ) + .into() + } else { + Flash::warning( + Redirect::to(uri!(details: name = slug.clone(), page = _)), + &i18n!(intl, "Your blog was successfully created, but the custom domain seems invalid. Please check it is correct from your blog's settings."), + ) + .into() + } } #[post("/~//delete")] @@ -181,6 +333,7 @@ pub struct EditForm { pub summary: String, pub icon: Option, pub banner: Option, + pub custom_domain: String, } #[get("/~//edit")] @@ -198,6 +351,10 @@ pub fn edit(name: String, rockets: PlumeRocket) -> Result { .clone() .expect("blogs::edit: User was None while it shouldn't"); let medias = Media::for_user(conn, user.id).expect("Couldn't list media"); + let custom_domain = match blog.custom_domain { + Some(ref c) => c.to_string(), + _ => String::from(""), + }; Ok(render!(blogs::edit( &rockets.to_context(), &blog, @@ -207,6 +364,7 @@ pub fn edit(name: String, rockets: PlumeRocket) -> Result { summary: blog.summary.clone(), icon: blog.icon_id, banner: blog.banner_id, + custom_domain: custom_domain, }, ValidationErrors::default() ))) @@ -318,6 +476,10 @@ pub fn update( ); blog.icon_id = form.icon; blog.banner_id = form.banner; + if !form.custom_domain.is_empty() { + blog.custom_domain = Some(Host::new(form.custom_domain.clone())) + } + blog.save_changes::(&*conn) .expect("Couldn't save blog changes"); Ok(Flash::success( diff --git a/src/routes/errors.rs b/src/routes/errors.rs index 1320a0681..626be8f5a 100644 --- a/src/routes/errors.rs +++ b/src/routes/errors.rs @@ -1,6 +1,6 @@ -use plume_models::{Error, PlumeRocket}; +use plume_models::{instance::Instance, Error, PlumeRocket}; use rocket::{ - response::{self, Responder}, + response::{self, Redirect, Responder}, Request, }; use template_utils::{IntoContext, Ructe}; @@ -29,9 +29,26 @@ impl<'r> Responder<'r> for ErrorPage { } #[catch(404)] -pub fn not_found(req: &Request) -> Ructe { +pub fn not_found(req: &Request) -> Result { let rockets = req.guard::().unwrap(); - render!(errors::not_found(&rockets.to_context())) + if req + .uri() + .segments() + .next() + .map(|path| path == "custom_domains") + .unwrap_or(false) + { + let path = req + .uri() + .segments() + .skip(2) + .collect::>() + .join("/"); + let public_domain = Instance::get_local().unwrap().public_domain; + Err(Redirect::to(format!("https://{}/{}", public_domain, path))) + } else { + Ok(render!(errors::not_found(&rockets.to_context()))) + } } #[catch(422)] diff --git a/src/routes/mod.rs b/src/routes/mod.rs index c29ab10f6..b3438a7f5 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -10,6 +10,7 @@ use rocket::{ response::{Flash, NamedFile, Redirect}, Outcome, }; +use std::fmt; use std::path::{Path, PathBuf}; use template_utils::Ructe; @@ -52,9 +53,15 @@ impl From> for RespondOrRedirect { } } -#[derive(Shrinkwrap, Copy, Clone, UriDisplayQuery)] +#[derive(Debug, Shrinkwrap, Copy, Clone, UriDisplayQuery)] pub struct Page(i32); +impl fmt::Display for Page { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + impl<'v> FromFormValue<'v> for Page { type Error = &'v RawStr; fn from_form_value(form_value: &'v RawStr) -> Result { diff --git a/src/routes/posts.rs b/src/routes/posts.rs index c78fe43c2..11e73048f 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -31,28 +31,14 @@ use routes::{ }; use template_utils::{IntoContext, Ructe}; -#[get("/~//?", rank = 4)] -pub fn details( - blog: String, - slug: String, +fn detail_guts( + blog: &Blog, + post: &Post, responding_to: Option, - rockets: PlumeRocket, -) -> Result { + rockets: &PlumeRocket, +) -> Result { let conn = &*rockets.conn; let user = rockets.user.clone(); - let blog = Blog::find_by_fqn(&rockets, &blog)?; - let post = Post::find_by_slug(&*conn, &slug, blog.id)?; - if !(post.published - || post - .get_authors(&*conn)? - .into_iter() - .any(|a| a.id == user.clone().map(|u| u.id).unwrap_or(0))) - { - return Ok(render!(errors::not_authorized( - &rockets.to_context(), - i18n!(rockets.intl.catalog, "This post isn't published yet.") - ))); - } let comments = CommentTree::from_post(&*conn, &post, user.as_ref())?; @@ -61,7 +47,7 @@ pub fn details( Ok(render!(posts::details( &rockets.to_context(), post.clone(), - blog, + blog.clone(), &NewCommentForm { warning: previous.clone().map(|p| p.spoiler_text).unwrap_or_default(), content: previous.clone().and_then(|p| Some(format!( @@ -94,7 +80,85 @@ pub fn details( user.clone().and_then(|u| u.has_reshared(&*conn, &post).ok()).unwrap_or(false), user.and_then(|u| u.is_following(&*conn, post.get_authors(&*conn).ok()?[0].id).ok()).unwrap_or(false), post.get_authors(&*conn)?[0].clone() - ))) + )).into()) +} + +#[get("/~//?", rank = 4)] +pub fn details( + blog: String, + slug: String, + responding_to: Option, + rockets: PlumeRocket, +) -> Result { + let conn = &*rockets.conn; + let user = rockets.user.clone(); + let blog = Blog::find_by_fqn(&rockets, &blog)?; + let post = Post::find_by_slug(&*conn, &slug, blog.id)?; + + if !(post.published + || post + .get_authors(&*conn)? + .into_iter() + .any(|a| a.id == user.clone().map(|u| u.id).unwrap_or(0))) + { + return Ok(render!(errors::not_authorized( + &rockets.to_context(), + i18n!(rockets.intl.catalog, "This post isn't published yet.") + )) + .into()); + } + + // check this first, and return early + // doing this prevents partially moving `blog` into the `match (tuple)`, + // which makes it impossible to reuse then. + if blog.custom_domain == None { + return detail_guts(&blog, &post, responding_to, &rockets); + } + + match (blog.custom_domain, responding_to) { + (Some(ref custom_domain), Some(ref responding_to)) => Ok(Redirect::to(format!( + "https://{}/{}?responding_to={}", + custom_domain, slug, responding_to + )) + .into()), + (Some(ref custom_domain), _) => { + Ok(Redirect::to(format!("https://{}/{}", custom_domain, slug)).into()) + } + (None, _) => unreachable!("This code path should have already been handled!"), + } +} + +pub mod custom { + use plume_models::{blogs::Blog, blogs::Host, posts::Post, PlumeRocket}; + use routes::{errors::ErrorPage, RespondOrRedirect}; + use template_utils::{IntoContext, Ructe}; + + #[get("//?", rank = 4)] + pub fn details( + custom_domain: String, + slug: String, + responding_to: Option, + rockets: PlumeRocket, + ) -> Result { + let conn = &*rockets.conn; + let user = rockets.user.clone(); + let blog = Blog::find_by_host(&rockets, Host::new(custom_domain))?; + let post = Post::find_by_slug(&*conn, &slug, blog.id)?; + if !(post.published + || post + .get_authors(&*conn)? + .into_iter() + .any(|a| a.id == user.clone().map(|u| u.id).unwrap_or(0))) + { + return Ok(render!(errors::not_authorized( + &rockets.to_context(), + i18n!(rockets.intl.catalog, "This post isn't published yet.") + )) + .into()); + } + + super::detail_guts(&blog, &post, responding_to, &rockets) + } } #[get("/~//", rank = 3)] diff --git a/src/routes/search.rs b/src/routes/search.rs index b605d1031..4df7ac77d 100644 --- a/src/routes/search.rs +++ b/src/routes/search.rs @@ -49,8 +49,7 @@ macro_rules! param_to_query { } } -#[get("/search?")] -pub fn search(query: Option>, rockets: PlumeRocket) -> Ructe { +fn search_guts(query: Option>, rockets: PlumeRocket) -> Ructe { let conn = &*rockets.conn; let query = query.map(Form::into_inner).unwrap_or_default(); let page = query.page.unwrap_or_default(); @@ -83,3 +82,23 @@ pub fn search(query: Option>, rockets: PlumeRocket) -> Ructe { )) } } + +#[get("/search?")] +pub fn search(query: Option>, rockets: PlumeRocket) -> Ructe { + search_guts(query, rockets) +} + +pub mod custom { + use plume_models::PlumeRocket; + use rocket::request::Form; + use template_utils::Ructe; + + #[get("/<_custom_domain>/search?")] + pub fn search( + _custom_domain: String, + query: Option>, + rockets: PlumeRocket, + ) -> Ructe { + super::search_guts(query, rockets) + } +} diff --git a/src/template_utils.rs b/src/template_utils.rs index 697084c1a..5f0e61f2d 100644 --- a/src/template_utils.rs +++ b/src/template_utils.rs @@ -1,7 +1,10 @@ use plume_models::{notifications::*, users::User, Connection, PlumeRocket}; use rocket::http::hyper::header::{ETag, EntityTag}; -use rocket::http::{Method, Status}; +use rocket::http::{ + uri::{FromUriParam, Query}, + Method, Status, +}; use rocket::request::Request; use rocket::response::{self, content::Html as HtmlCt, Responder, Response}; use rocket_i18n::Catalog; @@ -13,6 +16,16 @@ pub use askama_escape::escape; pub static CACHE_NAME: &str = env!("CACHE_ID"); +pub struct NoValue; // workarround for missing FromUriParam implementation for Option + +impl FromUriParam for Option { + type Target = Option; + + fn from_uri_param(_: NoValue) -> Self::Target { + None + } +} + pub type BaseContext<'a> = &'a ( &'a Connection, &'a Catalog, @@ -342,3 +355,90 @@ macro_rules! input { )) }}; } + +/// This macro imitate rocket's uri!, but with support for custom domains +/// +/// It takes one more argument, domain, which must appear first, and must be an Option<&str> +/// sample call : +/// assuming both take the same parameters +/// url!(custom_domain=Some("something.tld"), posts::details: slug = "title", responding_to = _, blog = "blogname")); +/// +/// assuming posts::details take one more parameter than posts::custom::details +/// url!(custom_domain=Some("something.tld"), posts::details: +/// common=[slug = "title", responding_to = _], +/// normal=[blog = "blogname"])); +/// +/// you can also provide custom=[] for custom-domain specific arguments +/// custom_domain can be changed to anything, indicating custom domain varname in the custom-domain +/// function (most likely custom_domain or _custom_domain) +macro_rules! url { + ($custom_domain:ident=$domain:expr, $module:ident::$route:ident: + common=[$($common_args:tt = $common_val:expr),*], + normal=[$($normal_args:tt = $normal_val:expr),*], + custom=[$($custom_args:tt = $custom_val:expr),*]) => {{ + let domain: &Option = &$domain; //for type inference with None + $( + let $common_args = $common_val; + )* + if let Some(domain) = domain { + $( + let $custom_args = $custom_val; + )* + let origin = uri!(crate::routes::$module::custom::$route: + $custom_domain = domain.to_string(), + $($common_args = $common_args,)* + $($custom_args = $custom_args,)* + ); + let path = origin + .segments() + .skip(1)// skip is part + .map(|seg| format!("/{}", seg)).collect::(); + let query = origin.query() + .filter(|q| !q.is_empty()) + .map(|q| format!("?{}", q)) + .unwrap_or_default(); + format!("https://{}{}{}", &domain, path, query) + } else { + $( + let $normal_args = $normal_val; + )* + url!($module::$route: + $($common_args = $common_args,)* + $($normal_args = $normal_args,)*) + .to_string() + } + }}; + ($cd:ident=$d:expr, $m:ident::$r:ident: + common=[$($tt:tt)*]) => { + url!($cd=$d, $m::$r: common=[$($tt)*], normal=[], custom=[]) + }; + ($cd:ident=$d:expr, $m:ident::$r:ident: + normal=[$($tt:tt)*]) => { + url!($cd=$d, $m::$r: common=[], normal=[$($tt)*], custom=[]) + }; + ($cd:ident=$d:expr, $m:ident::$r:ident: + custom=[$($tt:tt)*]) => { + url!($cd=$d, $m::$r: common=[], normal=[], custom=[$($tt)*]) + }; + ($cd:ident=$d:expr, $m:ident::$r:ident: + common=[$($co:tt)*], + normal=[$($no:tt)*]) => { + url!($cd=$d, $m::$r: common=[$($co)*], normal=[$($no)*], custom=[]) + }; + ($cd:ident=$d:expr, $m:ident::$r:ident: + common=[$($co:tt)*], + custom=[$($cu:tt)*]) => { + url!($cd=$d, $m::$r: common=[$($co)*], normal=[], custom=[$($cu)*]) + }; + ($cd:ident=$d:expr, $m:ident::$r:ident: + normal=[$($no:tt)*], + custom=[$($cu:tt)*]) => { + url!($cd=$d, $m::$r: common=[], normal=[$($no)*], custom=[$($cu)*]) + }; + ($custom_domain:ident=$domain:expr, $module:ident::$route:ident: $($common_args:tt)*) => { + url!($custom_domain=$domain, $module::$route: common=[$($common_args)*]) + }; + ($module:ident::$route:ident: $($tt:tt)*) => { + uri!(crate::routes::$module::$route: $($tt)*) + }; +} diff --git a/templates/blogs/details.rs.html b/templates/blogs/details.rs.html index 71c87a71a..d40353485 100644 --- a/templates/blogs/details.rs.html +++ b/templates/blogs/details.rs.html @@ -13,8 +13,8 @@ - - + + @@ -24,7 +24,7 @@ }, { - @blog.title + @blog.title }, { - +

@i18n!(ctx.1, "There's one author on this blog: ", "There are {0} authors on this blog: "; authors.len()) diff --git a/templates/blogs/edit.rs.html b/templates/blogs/edit.rs.html index dfa7f44fa..fa988cb0f 100644 --- a/templates/blogs/edit.rs.html +++ b/templates/blogs/edit.rs.html @@ -23,6 +23,8 @@

@i18n!(ctx.1, "Edit \"{}\""; &blog.title)

+ @input!(ctx.1, custom_domain (optional text), "Custom Domain", form, errors, "") +

@i18n!(ctx.1, "You can upload images to your gallery, to use them as blog icons, or banners.") @i18n!(ctx.1, "Upload images") diff --git a/templates/blogs/new.rs.html b/templates/blogs/new.rs.html index b9a186b10..118d78a7c 100644 --- a/templates/blogs/new.rs.html +++ b/templates/blogs/new.rs.html @@ -9,7 +9,10 @@ @:base(ctx, i18n!(ctx.1, "New Blog"), {}, {}, {

@i18n!(ctx.1, "Create a blog")

- @input!(ctx.1, title (text), "Title", form, errors, "required minlength=\"1\"") + @input!(ctx.1, title (text), "Title", form, errors.clone(), "required minlength=\"1\"") + + @input!(ctx.1, custom_domain (optional text), "Custom Domain", form, errors, "") + }) diff --git a/templates/partials/post_card.rs.html b/templates/partials/post_card.rs.html index e9e9f19a5..7a99a7f1b 100644 --- a/templates/partials/post_card.rs.html +++ b/templates/partials/post_card.rs.html @@ -9,7 +9,7 @@
}

- + @article.title

@@ -25,10 +25,9 @@

@if article.published { ⋅ @article.creation_date.format("%B %e, %Y") } - ⋅ @article.get_blog(ctx.0).unwrap().title + ⋅ @article.get_blog(ctx.0).unwrap().title @if !article.published { ⋅ @i18n!(ctx.1, "Draft") } - diff --git a/templates/posts/details.rs.html b/templates/posts/details.rs.html index f8a0803ff..75abe0557 100644 --- a/templates/posts/details.rs.html +++ b/templates/posts/details.rs.html @@ -17,10 +17,10 @@ @if article.cover_id.is_some() { } - + }, { - @blog.title + @blog.title }, {
@article.subtitle

    @for tag in tags { @if !tag.is_hashtag { -
  • @tag.tag
  • +
  • @tag.tag
  • } else { }