Compare commits

...

48 commits

Author SHA1 Message Date
3c4abcff81
Allow updating of custom_domain 2019-08-21 11:09:42 +02:00
d242e6df11
restore df7adaf0e0 ← after fixing mount-point; fix query() parsing 2019-08-20 15:21:01 +02:00
f395c1046c
fix mount point for blog post routing function 2019-08-20 15:06:57 +02:00
df7adaf0e0
when building links, skip both, /custom_domain/ and <custom_domain> 2019-08-20 15:04:29 +02:00
768f126f1d
use Status::* as @fdb-hiroshima suggested 2019-08-20 14:51:14 +02:00
Igor Galić
169469816d
use Custom status code
thanks, @fdb-hiroshima

Co-Authored-By: fdb-hiroshima <35889323+fdb-hiroshima@users.noreply.github.com>
2019-08-20 14:43:20 +02:00
064dd79eef
Allow editing blogs with custom_domain
n.b.: no validation yet
2019-08-20 12:24:36 +02:00
df47cddb87
also mount domain_validation into /custom_domains/ 2019-08-20 11:47:24 +02:00
f67ce93d94
fix getter router for domain_validation! 2019-08-20 11:33:00 +02:00
38ece9b5a6
do not panic if reqwest does not work 2019-08-20 11:18:23 +02:00
5e46922ed0 validate custom domain! 2019-08-19 22:42:34 +02:00
6072351840 one step closer to validating custom domains! 2019-08-19 22:04:22 +02:00
444a4673f4 correctly mutex-lock and modify valid_domains
thanks to @fdb-hiroshima for the review
2019-08-19 21:19:52 +02:00
ed30284386
implement domain validation using rocket::State
this doesn't work (yet?), because i don't know how to store something
mutable in State<>
2019-08-15 17:47:19 +02:00
bf1673dda1
start exploring some ideas for domain validation 2019-08-14 23:51:28 +02:00
Igor Galić
ff0c82efc1
review by @fdb-hiroshima
Co-Authored-By: fdb-hiroshima <35889323+fdb-hiroshima@users.noreply.github.com>
2019-08-14 23:51:27 +02:00
da6757c55c
Basic domain validation
The actual DNS check still needs to be written
2019-08-14 23:51:27 +02:00
Igor Galić
60270121dc
fix clippy warning
Co-Authored-By: fdb-hiroshima <35889323+fdb-hiroshima@users.noreply.github.com>
2019-08-14 23:51:26 +02:00
fdc7da0edf
Strings have to be cloned 2019-08-14 23:51:25 +02:00
Igor Galić
2dedcdbc53
start modifying /blogs/new to add custom_domain to the form 2019-08-14 23:51:24 +02:00
Trinity Pointard
b172a80e35
finally fix url!
also please a bit clippy
2019-08-14 23:51:23 +02:00
Trinity Pointard
c5f6b88b1d
make url! work better
there is still some issue with `None`, and an error making no sense at
some place
2019-08-14 23:51:23 +02:00
Igor Galić
6cd8bd89b2
fix syntax: we're now down to type errors 2019-08-14 23:51:22 +02:00
Trinity Pointard
cdc919e308
allow more syntaxes for url
allow for params that are used only by one of normal/custom routes
2019-08-14 23:51:21 +02:00
Igor Galić
f635dcf6c3
move custom_ route functions into a custom namespace
this way, we can actually use the url! macro
2019-08-14 23:51:20 +02:00
Trinity Pointard
7139119b8f
add url! macro for custom domain path uri creation
see doc-comment for limitations
2019-08-14 23:51:20 +02:00
Igor Galić
203da23cf2
follow up on @fdb-hiroshima & @BaptisteGelez review 2019-08-14 23:51:19 +02:00
Igor Galić
f73fba583a
custom_domainify posts::details 2019-08-14 23:51:18 +02:00
Igor Galić
fe110b5d8a
fix issues pointed out by @BaptisteGelez in review 2019-08-14 23:51:17 +02:00
Igor Galić
cc0df4ecb2
redirect blog urls to custom_domain if it exists 2019-08-14 23:51:16 +02:00
Igor Galić
9cee38ae6a
add url to Blog, this seems useful. 2019-08-14 23:51:16 +02:00
Igor Galić
8e7f789969
Allow searching from custom_domain 2019-08-14 23:51:15 +02:00
Igor Galić
92fbd174eb
extend 404 handler to handle all the requests our custom_ routes dont 2019-08-14 23:51:14 +02:00
Igor Galić
6253adf768
simplify / unify error handling
We want to return a Result<Blog>, instead of a QueryResult<Blog>.
We can use map_err() to map it to the desired error type.

thanks to review from @BaptisteGelez & @fdb-hiroshima.
2019-08-14 23:51:13 +02:00
Igor Galić
a0aef50674
extract common routing code into private "_guts()" functions 2019-08-14 23:51:13 +02:00
Igor Galić
8e6b1ab86e
simplify retrieval in find_by_host()
we can use "first()" instead of limit(1).load().etc…
since on a UNIQUE field, we only expect 1 result.

first() returns QueryResult, which is Result<T, diesel::Error>, so we
need to implement a converter for that error type.

This commit addresses @fdb-hiroshima's review.
2019-08-14 23:51:12 +02:00
Igor Galić
1c34ac38f7
replace if / else with ok_or() 2019-08-14 23:51:11 +02:00
Igor Galić
468e663344
Add custom_details and custom_activity_details as first routes
how this works: we use find_by_host() to find the Host in question, the
defer to the existing function!

Caveat: Currently, we, in that function, we do another lookup DB lookup
for the Blog, even thou we already know it.
It might be wise, to have both of those another wrapper here?!
2019-08-14 23:51:10 +02:00
Igor Galić
0645f7e253
CustomDomainFairing must not match /static/ and /api/
thanks, again, @fdb-hiroshima for helping with this code, when i got
stuck in lifetime-hecc.
2019-08-14 23:51:10 +02:00
Igor Galić
f94b0c79c5
move impl closer to mother struct 2019-08-14 23:51:09 +02:00
Igor Galić
b09b51c74b
normalize URLs before setting them 2019-08-14 23:51:08 +02:00
Igor Galić
e6747de998
cache custom_domains list
follow #572 and cache the list of custom domains.
2019-08-14 23:51:07 +02:00
Igor Galić
2746e088ae
appease clippy 2019-08-14 23:51:06 +02:00
Igor Galić
3a4c2f2cf9
create & attach an AdHoc Fairing for dealing with Custom Domains
this rewrites the URL to /custom_domain/<url>
(we have no route handlers for this path yet)

Lots of help from @fdb-hiroshima & @BaptisteGelez in dealing with the
borrow checker — thank you 💜
2019-08-14 23:51:06 +02:00
Igor Galić
351c01f71c
list custom_domains
thanks a lot to @fdb-hiroshima and @BaptisteGelez for helping with the
code.
2019-08-14 23:51:05 +02:00
Igor Galić
65ae51b7e5
implement Host more completely by doing less
we now use DieselNewType and Shrinkwrap to automatically derive all the
things we need.
2019-08-14 23:51:04 +02:00
Igor Galić
92bbeeb45e
add Host(String) wrapper type
we can use this to handle rocket Requests (from_request)
but we still have to figure out how to automatically assign the type to
custom_domain: Optional<Host>, and public_domain: Host.
2019-08-14 23:51:03 +02:00
Igor Galić
6bcc4f0ab0
add custom_domain (default to NULL) to schema 2019-08-14 23:51:03 +02:00
23 changed files with 797 additions and 140 deletions

1
Cargo.lock generated
View file

@ -1973,6 +1973,7 @@ dependencies = [
"plume-api 0.3.0", "plume-api 0.3.0",
"plume-common 0.3.0", "plume-common 0.3.0",
"plume-models 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 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_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)", "rocket_csrf 0.1.0 (git+https://github.com/fdb-hiroshima/rocket_csrf?rev=4a72ea2ec716cb0b26188fb00bccf2ef7d1e031c)",

View file

@ -19,6 +19,7 @@ heck = "0.3.0"
lettre = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" } lettre = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" }
lettre_email = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" } lettre_email = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" }
num_cpus = "1.10" num_cpus = "1.10"
reqwest = "0.9"
rocket = "0.4.0" rocket = "0.4.0"
rocket_contrib = { version = "0.4.0", features = ["json"] } rocket_contrib = { version = "0.4.0", features = ["json"] }
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" } rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }

View file

@ -0,0 +1,2 @@
-- undo the adding of custom_domain column to blogs table.
ALTER TABLE blogs DROP COLUMN custom_domain;

View file

@ -0,0 +1,2 @@
--- Adding custom domain to Blog as an optional field
ALTER TABLE blogs ADD COLUMN custom_domain VARCHAR DEFAULT NULL UNIQUE;

View file

@ -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;

View file

@ -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;

View file

@ -25,7 +25,6 @@ tantivy = "0.10.1"
url = "2.1" url = "2.1"
webfinger = "0.4.1" webfinger = "0.4.1"
whatlang = "0.7.1" whatlang = "0.7.1"
shrinkwraprs = "0.2.1"
diesel-derive-newtype = "0.1.2" diesel-derive-newtype = "0.1.2"
[dependencies.chrono] [dependencies.chrono]

View file

@ -7,6 +7,11 @@ use openssl::{
rsa::Rsa, rsa::Rsa,
sign::{Signer, Verifier}, sign::{Signer, Verifier},
}; };
use rocket::{
http::RawStr,
outcome::IntoOutcome,
request::{self, FromFormValue, FromRequest, Request},
};
use serde_json; use serde_json;
use url::Url; use url::Url;
use webfinger::*; use webfinger::*;
@ -21,11 +26,70 @@ use posts::Post;
use safe_string::SafeString; use safe_string::SafeString;
use schema::blogs; use schema::blogs;
use search::Searcher; use search::Searcher;
use std::default::Default;
use std::fmt::{self, Display};
use std::ops::Deref;
use std::sync::RwLock;
use users::User; use users::User;
use {Connection, Error, PlumeRocket, Result}; use {Connection, Error, PlumeRocket, Result};
pub type CustomGroup = CustomObject<ApSignature, Group>; pub type CustomGroup = CustomObject<ApSignature, Group>;
#[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<str> 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<Host, ()> {
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<Host, &'v RawStr> {
let val = String::from_form_value(form_value)?;
Ok(Host::new(&val))
}
}
#[derive(Queryable, Identifiable, Clone, AsChangeset)] #[derive(Queryable, Identifiable, Clone, AsChangeset)]
#[changeset_options(treat_none_as_null = "true")] #[changeset_options(treat_none_as_null = "true")]
pub struct Blog { pub struct Blog {
@ -44,6 +108,7 @@ pub struct Blog {
pub summary_html: SafeString, pub summary_html: SafeString,
pub icon_id: Option<i32>, pub icon_id: Option<i32>,
pub banner_id: Option<i32>, pub banner_id: Option<i32>,
pub custom_domain: Option<Host>,
} }
#[derive(Default, Insertable)] #[derive(Default, Insertable)]
@ -61,10 +126,15 @@ pub struct NewBlog {
pub summary_html: SafeString, pub summary_html: SafeString,
pub icon_id: Option<i32>, pub icon_id: Option<i32>,
pub banner_id: Option<i32>, pub banner_id: Option<i32>,
pub custom_domain: Option<Host>,
} }
const BLOG_PREFIX: &str = "~"; const BLOG_PREFIX: &str = "~";
lazy_static! {
static ref CUSTOM_DOMAINS: RwLock<Vec<String>> = RwLock::new(vec![]);
}
impl Blog { impl Blog {
insert!(blogs, NewBlog, |inserted, conn| { insert!(blogs, NewBlog, |inserted, conn| {
let instance = inserted.get_instance(conn)?; let instance = inserted.get_instance(conn)?;
@ -144,6 +214,13 @@ impl Blog {
} }
} }
pub fn find_by_host(c: &PlumeRocket, host: Host) -> Result<Blog> {
blogs::table
.filter(blogs::custom_domain.eq(host))
.first::<Blog>(&*c.conn)
.map_err(|_| Error::NotFound)
}
fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<Blog> { fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<Blog> {
resolve_with_prefix(Prefix::Group, acct.to_owned(), true)? resolve_with_prefix(Prefix::Group, acct.to_owned(), true)?
.links .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 { pub fn icon_url(&self, conn: &Connection) -> String {
self.icon_id self.icon_id
.and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok()) .and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok())
@ -290,6 +380,23 @@ impl Blog {
.map(|_| ()) .map(|_| ())
.map_err(Error::from) .map_err(Error::from)
} }
pub fn list_custom_domains() -> Vec<String> {
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<Vec<String>> {
blogs::table
.filter(blogs::custom_domain.is_not_null())
.select(blogs::custom_domain)
.load::<Option<String>>(conn)
.map_err(Error::from)
.map(|res| res.into_iter().map(Option::unwrap).collect::<Vec<_>>())
}
} }
impl IntoId for Blog { impl IntoId for Blog {
@ -392,6 +499,7 @@ impl FromId<PlumeRocket> for Blog {
.summary_string() .summary_string()
.unwrap_or_default(), .unwrap_or_default(),
), ),
custom_domain: None,
}, },
) )
} }
@ -441,6 +549,7 @@ impl NewBlog {
title: String, title: String,
summary: String, summary: String,
instance_id: i32, instance_id: i32,
custom_domain: Option<Host>,
) -> Result<NewBlog> { ) -> Result<NewBlog> {
let (pub_key, priv_key) = sign::gen_keypair(); let (pub_key, priv_key) = sign::gen_keypair();
Ok(NewBlog { Ok(NewBlog {
@ -450,6 +559,7 @@ impl NewBlog {
instance_id, instance_id,
public_key: String::from_utf8(pub_key).or(Err(Error::Signature))?, public_key: String::from_utf8(pub_key).or(Err(Error::Signature))?,
private_key: Some(String::from_utf8(priv_key).or(Err(Error::Signature))?), private_key: Some(String::from_utf8(priv_key).or(Err(Error::Signature))?),
custom_domain,
..NewBlog::default() ..NewBlog::default()
}) })
} }
@ -477,6 +587,7 @@ pub(crate) mod tests {
"Blog name".to_owned(), "Blog name".to_owned(),
"This is a small blog".to_owned(), "This is a small blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
None,
) )
.unwrap(), .unwrap(),
) )
@ -488,6 +599,7 @@ pub(crate) mod tests {
"My blog".to_owned(), "My blog".to_owned(),
"Welcome to my blog".to_owned(), "Welcome to my blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
Some(Host::new("blog.myname.me")),
) )
.unwrap(), .unwrap(),
) )
@ -499,6 +611,7 @@ pub(crate) mod tests {
"Why I like Plume".to_owned(), "Why I like Plume".to_owned(),
"In this blog I will explay you why I like Plume so much".to_owned(), "In this blog I will explay you why I like Plume so much".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
None,
) )
.unwrap(), .unwrap(),
) )
@ -559,6 +672,7 @@ pub(crate) mod tests {
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
Some(Host::new("some.blog.com")),
) )
.unwrap(), .unwrap(),
) )
@ -587,6 +701,7 @@ pub(crate) mod tests {
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
None,
) )
.unwrap(), .unwrap(),
) )
@ -598,6 +713,7 @@ pub(crate) mod tests {
"Blog".to_owned(), "Blog".to_owned(),
"I've named my blog Blog".to_owned(), "I've named my blog Blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
Some(Host::new("named.example.blog")),
) )
.unwrap(), .unwrap(),
) )
@ -690,6 +806,7 @@ pub(crate) mod tests {
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
None,
) )
.unwrap(), .unwrap(),
) )
@ -714,6 +831,7 @@ pub(crate) mod tests {
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
Some(Host::new("some.blog.com")),
) )
.unwrap(), .unwrap(),
) )
@ -752,6 +870,7 @@ pub(crate) mod tests {
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
None,
) )
.unwrap(), .unwrap(),
) )
@ -763,6 +882,7 @@ pub(crate) mod tests {
"Blog".to_owned(), "Blog".to_owned(),
"I've named my blog Blog".to_owned(), "I've named my blog Blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
Some(Host::new("my.blog.com")),
) )
.unwrap(), .unwrap(),
) )

View file

@ -10,6 +10,8 @@ extern crate bcrypt;
extern crate chrono; extern crate chrono;
#[macro_use] #[macro_use]
extern crate diesel; extern crate diesel;
#[macro_use]
extern crate diesel_derive_newtype;
extern crate guid_create; extern crate guid_create;
extern crate heck; extern crate heck;
extern crate itertools; extern crate itertools;

View file

@ -1,17 +1,17 @@
table! { table! {
api_tokens (id) { api_tokens (id) {
id -> Int4, id -> Integer,
creation_date -> Timestamp, creation_date -> Timestamp,
value -> Text, value -> Text,
scopes -> Text, scopes -> Text,
app_id -> Int4, app_id -> Integer,
user_id -> Int4, user_id -> Integer,
} }
} }
table! { table! {
apps (id) { apps (id) {
id -> Int4, id -> Integer,
name -> Text, name -> Text,
client_id -> Text, client_id -> Text,
client_secret -> Text, client_secret -> Text,
@ -23,70 +23,71 @@ table! {
table! { table! {
blog_authors (id) { blog_authors (id) {
id -> Int4, id -> Integer,
blog_id -> Int4, blog_id -> Integer,
author_id -> Int4, author_id -> Integer,
is_owner -> Bool, is_owner -> Bool,
} }
} }
table! { table! {
blogs (id) { blogs (id) {
id -> Int4, id -> Integer,
actor_id -> Varchar, actor_id -> Text,
title -> Varchar, title -> Text,
summary -> Text, summary -> Text,
outbox_url -> Varchar, outbox_url -> Text,
inbox_url -> Varchar, inbox_url -> Text,
instance_id -> Int4, instance_id -> Integer,
creation_date -> Timestamp, creation_date -> Timestamp,
ap_url -> Text, ap_url -> Text,
private_key -> Nullable<Text>, private_key -> Nullable<Text>,
public_key -> Text, public_key -> Text,
fqn -> Text, fqn -> Text,
summary_html -> Text, summary_html -> Text,
icon_id -> Nullable<Int4>, icon_id -> Nullable<Integer>,
banner_id -> Nullable<Int4>, banner_id -> Nullable<Integer>,
custom_domain -> Nullable<Text>,
}
}
table! {
comment_seers (id) {
id -> Integer,
comment_id -> Integer,
user_id -> Integer,
} }
} }
table! { table! {
comments (id) { comments (id) {
id -> Int4, id -> Integer,
content -> Text, content -> Text,
in_response_to_id -> Nullable<Int4>, in_response_to_id -> Nullable<Integer>,
post_id -> Int4, post_id -> Integer,
author_id -> Int4, author_id -> Integer,
creation_date -> Timestamp, creation_date -> Timestamp,
ap_url -> Nullable<Varchar>, ap_url -> Nullable<Text>,
sensitive -> Bool, sensitive -> Bool,
spoiler_text -> Text, spoiler_text -> Text,
public_visibility -> Bool, public_visibility -> Bool,
} }
} }
table! {
comment_seers (id) {
id -> Int4,
comment_id -> Int4,
user_id -> Int4,
}
}
table! { table! {
follows (id) { follows (id) {
id -> Int4, id -> Integer,
follower_id -> Int4, follower_id -> Integer,
following_id -> Int4, following_id -> Integer,
ap_url -> Text, ap_url -> Text,
} }
} }
table! { table! {
instances (id) { instances (id) {
id -> Int4, id -> Integer,
public_domain -> Varchar, public_domain -> Text,
name -> Varchar, name -> Text,
local -> Bool, local -> Bool,
blocked -> Bool, blocked -> Bool,
creation_date -> Timestamp, creation_date -> Timestamp,
@ -94,50 +95,50 @@ table! {
short_description -> Text, short_description -> Text,
long_description -> Text, long_description -> Text,
default_license -> Text, default_license -> Text,
long_description_html -> Varchar, long_description_html -> Text,
short_description_html -> Varchar, short_description_html -> Text,
} }
} }
table! { table! {
likes (id) { likes (id) {
id -> Int4, id -> Integer,
user_id -> Int4, user_id -> Integer,
post_id -> Int4, post_id -> Integer,
creation_date -> Timestamp, creation_date -> Timestamp,
ap_url -> Varchar, ap_url -> Text,
} }
} }
table! { table! {
medias (id) { medias (id) {
id -> Int4, id -> Integer,
file_path -> Text, file_path -> Text,
alt_text -> Text, alt_text -> Text,
is_remote -> Bool, is_remote -> Bool,
remote_url -> Nullable<Text>, remote_url -> Nullable<Text>,
sensitive -> Bool, sensitive -> Bool,
content_warning -> Nullable<Text>, content_warning -> Nullable<Text>,
owner_id -> Int4, owner_id -> Integer,
} }
} }
table! { table! {
mentions (id) { mentions (id) {
id -> Int4, id -> Integer,
mentioned_id -> Int4, mentioned_id -> Integer,
post_id -> Nullable<Int4>, post_id -> Nullable<Integer>,
comment_id -> Nullable<Int4>, comment_id -> Nullable<Integer>,
} }
} }
table! { table! {
notifications (id) { notifications (id) {
id -> Int4, id -> Integer,
user_id -> Int4, user_id -> Integer,
creation_date -> Timestamp, creation_date -> Timestamp,
kind -> Varchar, kind -> Text,
object_id -> Int4, object_id -> Integer,
} }
} }
@ -152,67 +153,67 @@ table! {
table! { table! {
post_authors (id) { post_authors (id) {
id -> Int4, id -> Integer,
post_id -> Int4, post_id -> Integer,
author_id -> Int4, author_id -> Integer,
} }
} }
table! { table! {
posts (id) { posts (id) {
id -> Int4, id -> Integer,
blog_id -> Int4, blog_id -> Integer,
slug -> Varchar, slug -> Text,
title -> Varchar, title -> Text,
content -> Text, content -> Text,
published -> Bool, published -> Bool,
license -> Varchar, license -> Text,
creation_date -> Timestamp, creation_date -> Timestamp,
ap_url -> Varchar, ap_url -> Text,
subtitle -> Text, subtitle -> Text,
source -> Text, source -> Text,
cover_id -> Nullable<Int4>, cover_id -> Nullable<Integer>,
} }
} }
table! { table! {
reshares (id) { reshares (id) {
id -> Int4, id -> Integer,
user_id -> Int4, user_id -> Integer,
post_id -> Int4, post_id -> Integer,
ap_url -> Varchar, ap_url -> Text,
creation_date -> Timestamp, creation_date -> Timestamp,
} }
} }
table! { table! {
tags (id) { tags (id) {
id -> Int4, id -> Integer,
tag -> Text, tag -> Text,
is_hashtag -> Bool, is_hashtag -> Bool,
post_id -> Int4, post_id -> Integer,
} }
} }
table! { table! {
users (id) { users (id) {
id -> Int4, id -> Integer,
username -> Varchar, username -> Text,
display_name -> Varchar, display_name -> Text,
outbox_url -> Varchar, outbox_url -> Text,
inbox_url -> Varchar, inbox_url -> Text,
is_admin -> Bool, is_admin -> Bool,
summary -> Text, summary -> Text,
email -> Nullable<Text>, email -> Nullable<Text>,
hashed_password -> Nullable<Text>, hashed_password -> Nullable<Text>,
instance_id -> Int4, instance_id -> Integer,
creation_date -> Timestamp, creation_date -> Timestamp,
ap_url -> Text, ap_url -> Text,
private_key -> Nullable<Text>, private_key -> Nullable<Text>,
public_key -> Text, public_key -> Text,
shared_inbox_url -> Nullable<Varchar>, shared_inbox_url -> Nullable<Text>,
followers_endpoint -> Varchar, followers_endpoint -> Text,
avatar_id -> Nullable<Int4>, avatar_id -> Nullable<Integer>,
last_fetched_date -> Timestamp, last_fetched_date -> Timestamp,
fqn -> Text, fqn -> Text,
summary_html -> Text, summary_html -> Text,
@ -248,8 +249,8 @@ allow_tables_to_appear_in_same_query!(
apps, apps,
blog_authors, blog_authors,
blogs, blogs,
comments,
comment_seers, comment_seers,
comments,
follows, follows,
instances, instances,
likes, likes,

View file

@ -36,39 +36,39 @@ msgstr ""
msgid "{0}'s avatar" msgid "{0}'s avatar"
msgstr "" msgstr ""
# src/routes/blogs.rs:64 # src/routes/blogs.rs:96
msgid "To create a new blog, you need to be logged in" msgid "To create a new blog, you need to be logged in"
msgstr "" msgstr ""
# src/routes/blogs.rs:106 # src/routes/blogs.rs:138
msgid "A blog with the same name already exists." msgid "A blog with the same name already exists."
msgstr "" msgstr ""
# src/routes/blogs.rs:141 # src/routes/blogs.rs:173
msgid "Your blog was successfully created!" msgid "Your blog was successfully created!"
msgstr "" msgstr ""
# src/routes/blogs.rs:163 # src/routes/blogs.rs:195
msgid "Your blog was deleted." msgid "Your blog was deleted."
msgstr "" msgstr ""
# src/routes/blogs.rs:170 # src/routes/blogs.rs:202
msgid "You are not allowed to delete this blog." msgid "You are not allowed to delete this blog."
msgstr "" msgstr ""
# src/routes/blogs.rs:218 # src/routes/blogs.rs:250
msgid "You are not allowed to edit this blog." msgid "You are not allowed to edit this blog."
msgstr "" msgstr ""
# src/routes/blogs.rs:263 # src/routes/blogs.rs:295
msgid "You can't use this media as a blog icon." msgid "You can't use this media as a blog icon."
msgstr "" msgstr ""
# src/routes/blogs.rs:281 # src/routes/blogs.rs:313
msgid "You can't use this media as a blog banner." msgid "You can't use this media as a blog banner."
msgstr "" msgstr ""
# src/routes/blogs.rs:314 # src/routes/blogs.rs:346
msgid "Your blog information have been updated." msgid "Your blog information have been updated."
msgstr "" msgstr ""

View file

@ -22,6 +22,7 @@ extern crate num_cpus;
extern crate plume_api; extern crate plume_api;
extern crate plume_common; extern crate plume_common;
extern crate plume_models; extern crate plume_models;
extern crate reqwest;
#[macro_use] #[macro_use]
extern crate rocket; extern crate rocket;
extern crate rocket_contrib; extern crate rocket_contrib;
@ -42,17 +43,21 @@ extern crate webfinger;
use clap::App; use clap::App;
use diesel::r2d2::ConnectionManager; use diesel::r2d2::ConnectionManager;
use plume_models::{ use plume_models::{
blogs::Blog,
blogs::Host,
db_conn::{DbPool, PragmaForeignKey}, db_conn::{DbPool, PragmaForeignKey},
instance::Instance, instance::Instance,
migrations::IMPORTED_MIGRATIONS, migrations::IMPORTED_MIGRATIONS,
search::{Searcher as UnmanagedSearcher, SearcherError}, search::{Searcher as UnmanagedSearcher, SearcherError},
Connection, Error, CONFIG, Connection, Error, CONFIG,
}; };
use rocket::{fairing::AdHoc, http::ext::IntoOwned, http::uri::Origin};
use rocket_csrf::CsrfFairingBuilder; use rocket_csrf::CsrfFairingBuilder;
use scheduled_thread_pool::ScheduledThreadPool; use scheduled_thread_pool::ScheduledThreadPool;
use std::collections::HashMap;
use std::process::exit; use std::process::exit;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::{Duration, Instant};
init_i18n!( 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 "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<DbPool> {
.build(manager) .build(manager)
.ok()?; .ok()?;
Instance::cache_local(&pool.get().unwrap()); Instance::cache_local(&pool.get().unwrap());
Blog::cache_custom_domains(&pool.get().unwrap());
Some(pool) Some(pool)
} }
@ -175,7 +181,42 @@ Then try to restart Plume
println!("Please refer to the documentation to see how to configure it."); 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::<Host>();
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<String, Instant> = HashMap::new();
let rocket = rocket::custom(CONFIG.rocket.clone().unwrap()) 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( .mount(
"/", "/",
routes![ routes![
@ -288,6 +329,7 @@ Then try to restart Plume
.manage(dbpool) .manage(dbpool)
.manage(Arc::new(workpool)) .manage(Arc::new(workpool))
.manage(searcher) .manage(searcher)
.manage(Mutex::new(valid_domains))
.manage(include_i18n!()) .manage(include_i18n!())
.attach( .attach(
CsrfFairingBuilder::new() CsrfFairingBuilder::new()
@ -314,7 +356,8 @@ Then try to restart Plume
]) ])
.finalize() .finalize()
.expect("main: csrf fairing creation error"), .expect("main: csrf fairing creation error"),
); )
.attach(custom_domain_fairing);
#[cfg(feature = "test")] #[cfg(feature = "test")]
let rocket = rocket.mount("/test", routes![test_routes::health,]); let rocket = rocket.mount("/test", routes![test_routes::health,]);

View file

@ -2,11 +2,14 @@ use activitypub::collection::OrderedCollection;
use atom_syndication::{Entry, FeedBuilder}; use atom_syndication::{Entry, FeedBuilder};
use diesel::SaveChangesDsl; use diesel::SaveChangesDsl;
use rocket::{ use rocket::{
http::ContentType, http::{ContentType, Status},
request::LenientForm, request::LenientForm,
response::{content::Content, Flash, Redirect}, response::{content::Content, Flash, Redirect},
State,
}; };
use rocket_i18n::I18n; use rocket_i18n::I18n;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::HashMap};
use validator::{Validate, ValidationError, ValidationErrors}; use validator::{Validate, ValidationError, ValidationErrors};
@ -16,14 +19,17 @@ use plume_models::{
blog_authors::*, blogs::*, instance::Instance, medias::*, posts::Post, safe_string::SafeString, blog_authors::*, blogs::*, instance::Instance, medias::*, posts::Post, safe_string::SafeString,
users::User, Connection, PlumeRocket, users::User, Connection, PlumeRocket,
}; };
use reqwest::Client;
use routes::{errors::ErrorPage, Page, RespondOrRedirect}; use routes::{errors::ErrorPage, Page, RespondOrRedirect};
use template_utils::{IntoContext, Ructe}; use template_utils::{IntoContext, Ructe};
#[get("/~/<name>?<page>", rank = 2)] fn detail_guts(
pub fn details(name: String, page: Option<Page>, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> { blog: Blog,
page: Option<Page>,
rockets: PlumeRocket,
) -> Result<RespondOrRedirect, ErrorPage> {
let page = page.unwrap_or_default(); let page = page.unwrap_or_default();
let conn = &*rockets.conn; let conn = &*rockets.conn;
let blog = Blog::find_by_fqn(&rockets, &name)?;
let posts = Post::blog_page(conn, &blog, page.limits())?; let posts = Post::blog_page(conn, &blog, page.limits())?;
let articles_count = Post::count_for_blog(conn, &blog)?; let articles_count = Post::count_for_blog(conn, &blog)?;
let authors = &blog.list_authors(conn)?; let authors = &blog.list_authors(conn)?;
@ -35,7 +41,43 @@ pub fn details(name: String, page: Option<Page>, rockets: PlumeRocket) -> Result
page.0, page.0,
Page::total(articles_count as i32), Page::total(articles_count as i32),
posts posts
))) ))
.into())
}
#[get("/~/<name>?<page>", rank = 2)]
pub fn details(
name: String,
page: Option<Page>,
rockets: PlumeRocket,
) -> Result<RespondOrRedirect, ErrorPage> {
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<ActivityStream<CustomGroup>> {
Some(ActivityStream::new(blog.to_activity(&*rockets.conn).ok()?))
} }
#[get("/~/<name>", rank = 1)] #[get("/~/<name>", rank = 1)]
@ -45,7 +87,7 @@ pub fn activity_details(
_ap: ApRequest, _ap: ApRequest,
) -> Option<ActivityStream<CustomGroup>> { ) -> Option<ActivityStream<CustomGroup>> {
let blog = Blog::find_by_fqn(&rockets, &name).ok()?; 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")] #[get("/blogs/new")]
@ -57,6 +99,76 @@ pub fn new(rockets: PlumeRocket, _user: User) -> Ructe {
)) ))
} }
// mounted as /domain_validation/
#[get("/<validation_id>")]
pub fn domain_validation(
validation_id: String,
valid_domains: State<Mutex<HashMap<String, Instant>>>,
) -> 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("/<custom_domain>?<page>", rank = 2)]
pub fn details(
custom_domain: String,
page: Option<Page>,
rockets: PlumeRocket,
) -> Result<RespondOrRedirect, ErrorPage> {
let blog = Blog::find_by_host(&rockets, Host::new(custom_domain))?;
super::detail_guts(blog, page, rockets)
}
#[get("/<custom_domain>", rank = 1)]
pub fn activity_details(
custom_domain: String,
rockets: PlumeRocket,
_ap: ApRequest,
) -> Option<ActivityStream<CustomGroup>> {
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("/<validation_id>")]
pub fn domain_validation(
validation_id: String,
valid_domains: State<Mutex<HashMap<String, Instant>>>,
) -> Status {
super::domain_validation(validation_id, valid_domains)
}
}
#[get("/blogs/new", rank = 2)] #[get("/blogs/new", rank = 2)]
pub fn new_auth(i18n: I18n) -> Flash<Redirect> { pub fn new_auth(i18n: I18n) -> Flash<Redirect> {
utils::requires_login( utils::requires_login(
@ -72,6 +184,7 @@ pub fn new_auth(i18n: I18n) -> Flash<Redirect> {
pub struct NewBlogForm { pub struct NewBlogForm {
#[validate(custom(function = "valid_slug", message = "Invalid name"))] #[validate(custom(function = "valid_slug", message = "Invalid name"))]
pub title: String, pub title: String,
pub custom_domain: String,
} }
fn valid_slug(title: &str) -> Result<(), ValidationError> { 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<Mutex<HashMap<String, Instant>>>) -> 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 = "<form>")] #[post("/blogs/new", data = "<form>")]
pub fn create(form: LenientForm<NewBlogForm>, rockets: PlumeRocket) -> RespondOrRedirect { pub fn create(
form: LenientForm<NewBlogForm>,
rockets: PlumeRocket,
valid_domains: State<Mutex<HashMap<String, Instant>>>,
) -> RespondOrRedirect {
let slug = utils::make_actor_id(&form.title); let slug = utils::make_actor_id(&form.title);
let conn = &*rockets.conn; let conn = &*rockets.conn;
let intl = &rockets.intl.catalog; let intl = &rockets.intl.catalog;
let user = rockets.user.clone().unwrap(); 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() { let mut errors = match form.validate() {
Ok(_) => ValidationErrors::new(), Ok(_) => ValidationErrors::new(),
Err(e) => e, Err(e) => e,
@ -121,6 +264,7 @@ pub fn create(form: LenientForm<NewBlogForm>, rockets: PlumeRocket) -> RespondOr
Instance::get_local() Instance::get_local()
.expect("blog::create: instance error") .expect("blog::create: instance error")
.id, .id,
custom_domain,
) )
.expect("blog::create: new local error"), .expect("blog::create: new local error"),
) )
@ -136,11 +280,19 @@ pub fn create(form: LenientForm<NewBlogForm>, rockets: PlumeRocket) -> RespondOr
) )
.expect("blog::create: author error"); .expect("blog::create: author error");
Flash::success( if dns_ok {
Redirect::to(uri!(details: name = slug.clone(), page = _)), Flash::success(
&i18n!(intl, "Your blog was successfully created!"), Redirect::to(uri!(details: name = slug.clone(), page = _)),
) &i18n!(intl, "Your blog was successfully created!"),
.into() )
.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("/~/<name>/delete")] #[post("/~/<name>/delete")]
@ -181,6 +333,7 @@ pub struct EditForm {
pub summary: String, pub summary: String,
pub icon: Option<i32>, pub icon: Option<i32>,
pub banner: Option<i32>, pub banner: Option<i32>,
pub custom_domain: String,
} }
#[get("/~/<name>/edit")] #[get("/~/<name>/edit")]
@ -198,6 +351,10 @@ pub fn edit(name: String, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
.clone() .clone()
.expect("blogs::edit: User was None while it shouldn't"); .expect("blogs::edit: User was None while it shouldn't");
let medias = Media::for_user(conn, user.id).expect("Couldn't list media"); 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( Ok(render!(blogs::edit(
&rockets.to_context(), &rockets.to_context(),
&blog, &blog,
@ -207,6 +364,7 @@ pub fn edit(name: String, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
summary: blog.summary.clone(), summary: blog.summary.clone(),
icon: blog.icon_id, icon: blog.icon_id,
banner: blog.banner_id, banner: blog.banner_id,
custom_domain: custom_domain,
}, },
ValidationErrors::default() ValidationErrors::default()
))) )))
@ -318,6 +476,10 @@ pub fn update(
); );
blog.icon_id = form.icon; blog.icon_id = form.icon;
blog.banner_id = form.banner; blog.banner_id = form.banner;
if !form.custom_domain.is_empty() {
blog.custom_domain = Some(Host::new(form.custom_domain.clone()))
}
blog.save_changes::<Blog>(&*conn) blog.save_changes::<Blog>(&*conn)
.expect("Couldn't save blog changes"); .expect("Couldn't save blog changes");
Ok(Flash::success( Ok(Flash::success(

View file

@ -1,6 +1,6 @@
use plume_models::{Error, PlumeRocket}; use plume_models::{instance::Instance, Error, PlumeRocket};
use rocket::{ use rocket::{
response::{self, Responder}, response::{self, Redirect, Responder},
Request, Request,
}; };
use template_utils::{IntoContext, Ructe}; use template_utils::{IntoContext, Ructe};
@ -29,9 +29,26 @@ impl<'r> Responder<'r> for ErrorPage {
} }
#[catch(404)] #[catch(404)]
pub fn not_found(req: &Request) -> Ructe { pub fn not_found(req: &Request) -> Result<Ructe, Redirect> {
let rockets = req.guard::<PlumeRocket>().unwrap(); let rockets = req.guard::<PlumeRocket>().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::<Vec<&str>>()
.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)] #[catch(422)]

View file

@ -10,6 +10,7 @@ use rocket::{
response::{Flash, NamedFile, Redirect}, response::{Flash, NamedFile, Redirect},
Outcome, Outcome,
}; };
use std::fmt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use template_utils::Ructe; use template_utils::Ructe;
@ -52,9 +53,15 @@ impl From<Flash<Redirect>> for RespondOrRedirect {
} }
} }
#[derive(Shrinkwrap, Copy, Clone, UriDisplayQuery)] #[derive(Debug, Shrinkwrap, Copy, Clone, UriDisplayQuery)]
pub struct Page(i32); 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 { impl<'v> FromFormValue<'v> for Page {
type Error = &'v RawStr; type Error = &'v RawStr;
fn from_form_value(form_value: &'v RawStr) -> Result<Page, &'v RawStr> { fn from_form_value(form_value: &'v RawStr) -> Result<Page, &'v RawStr> {

View file

@ -31,28 +31,14 @@ use routes::{
}; };
use template_utils::{IntoContext, Ructe}; use template_utils::{IntoContext, Ructe};
#[get("/~/<blog>/<slug>?<responding_to>", rank = 4)] fn detail_guts(
pub fn details( blog: &Blog,
blog: String, post: &Post,
slug: String,
responding_to: Option<i32>, responding_to: Option<i32>,
rockets: PlumeRocket, rockets: &PlumeRocket,
) -> Result<Ructe, ErrorPage> { ) -> Result<RespondOrRedirect, ErrorPage> {
let conn = &*rockets.conn; let conn = &*rockets.conn;
let user = rockets.user.clone(); 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())?; let comments = CommentTree::from_post(&*conn, &post, user.as_ref())?;
@ -61,7 +47,7 @@ pub fn details(
Ok(render!(posts::details( Ok(render!(posts::details(
&rockets.to_context(), &rockets.to_context(),
post.clone(), post.clone(),
blog, blog.clone(),
&NewCommentForm { &NewCommentForm {
warning: previous.clone().map(|p| p.spoiler_text).unwrap_or_default(), warning: previous.clone().map(|p| p.spoiler_text).unwrap_or_default(),
content: previous.clone().and_then(|p| Some(format!( 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.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), 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() post.get_authors(&*conn)?[0].clone()
))) )).into())
}
#[get("/~/<blog>/<slug>?<responding_to>", rank = 4)]
pub fn details(
blog: String,
slug: String,
responding_to: Option<i32>,
rockets: PlumeRocket,
) -> Result<RespondOrRedirect, ErrorPage> {
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("/<custom_domain>/<slug>?<responding_to>", rank = 4)]
pub fn details(
custom_domain: String,
slug: String,
responding_to: Option<i32>,
rockets: PlumeRocket,
) -> Result<RespondOrRedirect, ErrorPage> {
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("/~/<blog>/<slug>", rank = 3)] #[get("/~/<blog>/<slug>", rank = 3)]

View file

@ -49,8 +49,7 @@ macro_rules! param_to_query {
} }
} }
#[get("/search?<query..>")] fn search_guts(query: Option<Form<SearchQuery>>, rockets: PlumeRocket) -> Ructe {
pub fn search(query: Option<Form<SearchQuery>>, rockets: PlumeRocket) -> Ructe {
let conn = &*rockets.conn; let conn = &*rockets.conn;
let query = query.map(Form::into_inner).unwrap_or_default(); let query = query.map(Form::into_inner).unwrap_or_default();
let page = query.page.unwrap_or_default(); let page = query.page.unwrap_or_default();
@ -83,3 +82,23 @@ pub fn search(query: Option<Form<SearchQuery>>, rockets: PlumeRocket) -> Ructe {
)) ))
} }
} }
#[get("/search?<query..>")]
pub fn search(query: Option<Form<SearchQuery>>, 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?<query..>")]
pub fn search(
_custom_domain: String,
query: Option<Form<super::SearchQuery>>,
rockets: PlumeRocket,
) -> Ructe {
super::search_guts(query, rockets)
}
}

View file

@ -1,7 +1,10 @@
use plume_models::{notifications::*, users::User, Connection, PlumeRocket}; use plume_models::{notifications::*, users::User, Connection, PlumeRocket};
use rocket::http::hyper::header::{ETag, EntityTag}; 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::request::Request;
use rocket::response::{self, content::Html as HtmlCt, Responder, Response}; use rocket::response::{self, content::Html as HtmlCt, Responder, Response};
use rocket_i18n::Catalog; use rocket_i18n::Catalog;
@ -13,6 +16,16 @@ pub use askama_escape::escape;
pub static CACHE_NAME: &str = env!("CACHE_ID"); pub static CACHE_NAME: &str = env!("CACHE_ID");
pub struct NoValue; // workarround for missing FromUriParam implementation for Option
impl FromUriParam<Query, NoValue> for Option<i32> {
type Target = Option<i32>;
fn from_uri_param(_: NoValue) -> Self::Target {
None
}
}
pub type BaseContext<'a> = &'a ( pub type BaseContext<'a> = &'a (
&'a Connection, &'a Connection,
&'a Catalog, &'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<plume_models::blogs::Host> = &$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 <custom_domain> part
.map(|seg| format!("/{}", seg)).collect::<String>();
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)*)
};
}

View file

@ -13,8 +13,8 @@
<meta content="120" property="og:image:width" /> <meta content="120" property="og:image:width" />
<meta content="120" property="og:image:height" /> <meta content="120" property="og:image:height" />
<meta content="summary" property="twitter:card" /> <meta content="summary" property="twitter:card" />
<meta content="'@Instance::get_local().unwrap().name" property="og:site_name" /> <meta content="@Instance::get_local().unwrap().name" property="og:site_name" />
<meta content="@blog.ap_url" property="og:url" /> <meta content="@blog.url()" property="og:url" />
<meta content="@blog.fqn" property="profile:username" /> <meta content="@blog.fqn" property="profile:username" />
<meta content="@blog.title" property="og:title" /> <meta content="@blog.title" property="og:title" />
<meta content="@blog.summary_html" name="description"> <meta content="@blog.summary_html" name="description">
@ -24,7 +24,7 @@
<link href='@Instance::get_local().unwrap().compute_box("~", &blog.fqn, "atom.xml")' rel='alternate' type='application/atom+xml'> <link href='@Instance::get_local().unwrap().compute_box("~", &blog.fqn, "atom.xml")' rel='alternate' type='application/atom+xml'>
<link href='@blog.ap_url' rel='alternate' type='application/activity+json'> <link href='@blog.ap_url' rel='alternate' type='application/activity+json'>
}, { }, {
<a href="@uri!(blogs::details: name = &blog.fqn, page = _)" dir="auto">@blog.title</a> <a href="@url!(custom_domain = blog.custom_domain, blogs::details: common=[page = None], normal=[name = &blog.fqn])" dir="auto">@blog.title</a>
}, { }, {
<div class="hidden"> <div class="hidden">
@for author in authors { @for author in authors {
@ -55,7 +55,7 @@
<a href="@uri!(blogs::edit: name = &blog.fqn)" class="button" dir="auto">@i18n!(ctx.1, "Edit")</a> <a href="@uri!(blogs::edit: name = &blog.fqn)" class="button" dir="auto">@i18n!(ctx.1, "Edit")</a>
} }
</div> </div>
<main class="user-summary" dir="auto"> <main class="user-summary" dir="auto">
<p> <p>
@i18n!(ctx.1, "There's one author on this blog: ", "There are {0} authors on this blog: "; authors.len()) @i18n!(ctx.1, "There's one author on this blog: ", "There are {0} authors on this blog: "; authors.len())

View file

@ -23,6 +23,8 @@
<label for="summary">@i18n!(ctx.1, "Description")<small>@i18n!(ctx.1, "Markdown syntax is supported")</small></label> <label for="summary">@i18n!(ctx.1, "Description")<small>@i18n!(ctx.1, "Markdown syntax is supported")</small></label>
<textarea id="summary" name="summary" rows="20">@form.summary</textarea> <textarea id="summary" name="summary" rows="20">@form.summary</textarea>
@input!(ctx.1, custom_domain (optional text), "Custom Domain", form, errors, "")
<p> <p>
@i18n!(ctx.1, "You can upload images to your gallery, to use them as blog icons, or banners.") @i18n!(ctx.1, "You can upload images to your gallery, to use them as blog icons, or banners.")
<a href="@uri!(medias::new)">@i18n!(ctx.1, "Upload images")</a> <a href="@uri!(medias::new)">@i18n!(ctx.1, "Upload images")</a>

View file

@ -9,7 +9,10 @@
@:base(ctx, i18n!(ctx.1, "New Blog"), {}, {}, { @:base(ctx, i18n!(ctx.1, "New Blog"), {}, {}, {
<h1 dir="auto">@i18n!(ctx.1, "Create a blog")</h1> <h1 dir="auto">@i18n!(ctx.1, "Create a blog")</h1>
<form method="post" action="@uri!(blogs::create)"> <form method="post" action="@uri!(blogs::create)">
@input!(ctx.1, title (text), "Title", form, errors, "required minlength=\"1\"") @input!(ctx.1, title (text), "Title", form, errors.clone(), "required minlength=\"1\"")
<input type="submit" value="@i18n!(ctx.1, "Create blog")" dir="auto"/> <input type="submit" value="@i18n!(ctx.1, "Create blog")" dir="auto"/>
@input!(ctx.1, custom_domain (optional text), "Custom Domain", form, errors, "")
<input type="submit" value="@i18n!(ctx.1, "Make your blog available under a custom domain")" dir="auto"/>
</form> </form>
}) })

View file

@ -9,7 +9,7 @@
<div class="cover" style="background-image: url('@Html(article.cover_url(ctx.0).unwrap_or_default())')"></div> <div class="cover" style="background-image: url('@Html(article.cover_url(ctx.0).unwrap_or_default())')"></div>
} }
<h3 class="p-name" dir="auto"> <h3 class="p-name" dir="auto">
<a class="u-url" href="@uri!(posts::details: blog = article.get_blog(ctx.0).unwrap().fqn, slug = &article.slug, responding_to = _)"> <a class="u-url" href="@url!(custom_domain = article.get_blog(ctx.0).unwrap().custom_domain, posts::details: common=[ slug = &article.slug, responding_to = NoValue], normal=[blog = article.get_blog(ctx.0).unwrap().fqn])">
@article.title @article.title
</a> </a>
</h3> </h3>
@ -25,10 +25,9 @@
@if article.published { @if article.published {
<span class="dt-published" datetime="@article.creation_date.format("%F %T")">@article.creation_date.format("%B %e, %Y")</span> <span class="dt-published" datetime="@article.creation_date.format("%F %T")">@article.creation_date.format("%B %e, %Y")</span>
} }
<a href="@uri!(blogs::details: name = &article.get_blog(ctx.0).unwrap().fqn, page = _)">@article.get_blog(ctx.0).unwrap().title</a> <a href="@url!(custom_domain = article.get_blog(ctx.0).unwrap().custom_domain, blogs::details: common=[page = None], normal=[name = &article.get_blog(ctx.0).unwrap().fqn])">@article.get_blog(ctx.0).unwrap().title</a>
@if !article.published { @if !article.published {
⋅ @i18n!(ctx.1, "Draft") ⋅ @i18n!(ctx.1, "Draft")
} }
</footer> </footer>
</div> </div>

View file

@ -17,10 +17,10 @@
@if article.cover_id.is_some() { @if article.cover_id.is_some() {
<meta property="og:image" content="@Html(article.cover_url(ctx.0).unwrap_or_default())"/> <meta property="og:image" content="@Html(article.cover_url(ctx.0).unwrap_or_default())"/>
} }
<meta property="og:url" content="@uri!(posts::details: blog = &blog.fqn, slug = &article.slug, responding_to = _)"/> <meta property="og:url" content="@url!(custom_domain = blog.custom_domain, posts::details: common=[slug = &article.slug, responding_to = NoValue], normal=[blog = &blog.fqn])"/>
<meta property="og:description" content="@article.subtitle"/> <meta property="og:description" content="@article.subtitle"/>
}, { }, {
<a href="@uri!(blogs::details: name = &blog.fqn, page = _)">@blog.title</a> <a href="@url!(custom_domain = &blog.custom_domain, blogs::details: common=[page = None], normal=[name = &blog.fqn])">@blog.title</a>
}, { }, {
<div class="h-entry"> <div class="h-entry">
<header <header
@ -54,7 +54,7 @@
<ul class="tags" dir="auto"> <ul class="tags" dir="auto">
@for tag in tags { @for tag in tags {
@if !tag.is_hashtag { @if !tag.is_hashtag {
<li><a class="p-category" href="@uri!(tags::tag: name = &tag.tag, page = _)">@tag.tag</a></li> <li><a class="p-category" href="@uri!(tags::tag: name = &tag.tag, page = None)">@tag.tag</a></li>
} else { } else {
<span class="hidden p-category">@tag.tag</span> <span class="hidden p-category">@tag.tag</span>
} }