feature: custom domains using Fairings #596

Closed
igalic wants to merge 48 commits from igalic/feat/custom-fairing-domains into master

1
Cargo.lock generated

@ -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)",

@ -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" }

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

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

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

@ -0,0 +1,57 @@
-- add custom_domain to blogs
CREATE TABLE IF NOT EXISTS "blogs_add_custom_domain" (
rfwatson commented 5 years ago (Migrated from github.com)
Review

Just wondering why copying the table is required, instead of just adding the column?

Just wondering why copying the table is required, instead of just adding the column?
igalic commented 5 years ago (Migrated from github.com)
Review
because [SQLite supports a limited subset of ALTER TABLE](https://www.sqlite.org/lang_altertable.html)
rfwatson commented 5 years ago (Migrated from github.com)
Review

Damn, I missed it was SQLite. Thanks.

Damn, I missed it was SQLite. Thanks.
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;

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

@ -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<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)]
#[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<i32>,
pub banner_id: Option<i32>,
pub custom_domain: Option<Host>,
}
#[derive(Default, Insertable)]
@ -61,10 +126,15 @@ pub struct NewBlog {
pub summary_html: SafeString,
pub icon_id: Option<i32>,
pub banner_id: Option<i32>,
pub custom_domain: Option<Host>,
}
const BLOG_PREFIX: &str = "~";
lazy_static! {
static ref CUSTOM_DOMAINS: RwLock<Vec<String>> = 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<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> {
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<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 {
@ -392,6 +499,7 @@ impl FromId<PlumeRocket> 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<Host>,
) -> Result<NewBlog> {
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(),
)

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

@ -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<Text>,
public_key -> Text,
fqn -> Text,
summary_html -> Text,
icon_id -> Nullable<Int4>,
banner_id -> Nullable<Int4>,
icon_id -> Nullable<Integer>,
banner_id -> Nullable<Integer>,
custom_domain -> Nullable<Text>,
}
}
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<Int4>,
post_id -> Int4,
author_id -> Int4,
in_response_to_id -> Nullable<Integer>,
post_id -> Integer,
author_id -> Integer,
creation_date -> Timestamp,
ap_url -> Nullable<Varchar>,
ap_url -> Nullable<Text>,
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<Text>,
sensitive -> Bool,
content_warning -> Nullable<Text>,
owner_id -> Int4,
owner_id -> Integer,
}
}
table! {
mentions (id) {
id -> Int4,
mentioned_id -> Int4,
post_id -> Nullable<Int4>,
comment_id -> Nullable<Int4>,
id -> Integer,
mentioned_id -> Integer,
post_id -> Nullable<Integer>,
comment_id -> Nullable<Integer>,
}
}
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<Int4>,
cover_id -> Nullable<Integer>,
}
}
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<Text>,
hashed_password -> Nullable<Text>,
instance_id -> Int4,
instance_id -> Integer,
creation_date -> Timestamp,
ap_url -> Text,
private_key -> Nullable<Text>,
public_key -> Text,
shared_inbox_url -> Nullable<Varchar>,
followers_endpoint -> Varchar,
avatar_id -> Nullable<Int4>,
shared_inbox_url -> Nullable<Text>,
followers_endpoint -> Text,
avatar_id -> Nullable<Integer>,
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,

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

@ -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<DbPool> {
.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::<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();
Review

I'd be in favor of using the newtype design pattern for this, so that it make more sense what it is, even at the type level.
I don't know where are the &str coming from, but you might want to use String instead, or you'll have some difficulties with lifetime

I'd be in favor of using the newtype design pattern for this, so that it make more sense what it is, even at the type level. I don't know where are the &str coming from, but you might want to use String instead, or you'll have some difficulties with lifetime
igalic commented 5 years ago (Migrated from github.com)
Review

aye. i haven't gotten that far yet, because i haven't implemented the other side of this

aye. i haven't gotten that far yet, because i haven't implemented the other side of this
req.set_uri(uri);
}
});
let valid_domains: HashMap<String, Instant> = 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,]);

@ -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("/~/<name>?<page>", rank = 2)]
pub fn details(name: String, page: Option<Page>, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
fn detail_guts(
blog: Blog,
page: Option<Page>,
rockets: PlumeRocket,
) -> Result<RespondOrRedirect, ErrorPage> {
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<Page>, rockets: PlumeRocket) -> Result
page.0,
Page::total(articles_count as i32),
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)]
@ -45,7 +87,7 @@ pub fn activity_details(
_ap: ApRequest,
) -> Option<ActivityStream<CustomGroup>> {
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 {
))
}
Review

I believe there are consts that already contains standards http status in the module

I believe there are consts that already contains standards http status in the module
Review

Same as above

Same as above
igalic commented 5 years ago (Migrated from github.com)
Review
error[E0308]: mismatched types
   --> src/routes/blogs.rs:113:28
    |
113 |         return Status::new(Status::NotFound, "validation id not found");
    |                            ^^^^^^^^^^^^^^^^ expected u16, found struct `rocket::http::Status`
    |
    = note: expected type `u16`
               found type `rocket::http::Status`

error[E0308]: mismatched types
   --> src/routes/blogs.rs:123:28
    |
123 |         return Status::new(Status::Gone, "validation expired");
    |                            ^^^^^^^^^^^^ expected u16, found struct `rocket::http::Status`
    |
    = note: expected type `u16`
               found type `rocket::http::Status`
``` error[E0308]: mismatched types --> src/routes/blogs.rs:113:28 | 113 | return Status::new(Status::NotFound, "validation id not found"); | ^^^^^^^^^^^^^^^^ expected u16, found struct `rocket::http::Status` | = note: expected type `u16` found type `rocket::http::Status` error[E0308]: mismatched types --> src/routes/blogs.rs:123:28 | 123 | return Status::new(Status::Gone, "validation expired"); | ^^^^^^^^^^^^ expected u16, found struct `rocket::http::Status` | = note: expected type `u16` found type `rocket::http::Status` ```
Review

Have you tried simply

        return Status::Gone;

?

Have you tried simply ```suggestion return Status::Gone; ``` ?
igalic commented 5 years ago (Migrated from github.com)
Review

No, cuz I wanted to give a specific response.

No, cuz I wanted to give a specific response.
Review
        return Custom(Status::Gone, "validation expired");

imported from rocket::response::status::Custom
Your version is sending a standard error code with a non standard text, some implementations don't handle this well. This return the corresponding "error" text, and set the body of the result to the text you provide

```suggestion return Custom(Status::Gone, "validation expired"); ``` imported from rocket::response::status::Custom Your version is sending a standard error code with a non standard text, some implementations don't handle this well. This return the corresponding "error" text, and set the body of the result to the text you provide
// 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,
Review

clippy says

    let custom_domain = if form.custom_domain.is_empty() {
clippy says ```suggestion let custom_domain = if form.custom_domain.is_empty() { ```
elegaanz commented 5 years ago (Migrated from github.com)
Review

I can't accept your proposal via Github (apparently I don't have the right to push to this repository 🙃), but I made it manually, thanks.

I can't accept your proposal via Github (apparently I don't have the right to push to this repository :upside_down_face:), but I made it manually, thanks.
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)]
pub fn new_auth(i18n: I18n) -> Flash<Redirect> {
utils::requires_login(
@ -72,6 +184,7 @@ pub fn new_auth(i18n: I18n) -> Flash<Redirect> {
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<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(),
);
Review
        Flash::warning(
```suggestion Flash::warning( ```
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,
}
}
Review

You should remove the feature, as it's no longer used

You should remove the feature, as it's no longer used
#[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 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<NewBlogForm>, 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<NewBlogForm>, 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("/~/<name>/delete")]
@ -181,6 +333,7 @@ pub struct EditForm {
pub summary: String,
pub icon: Option<i32>,
pub banner: Option<i32>,
pub custom_domain: String,
}
#[get("/~/<name>/edit")]
@ -198,6 +351,10 @@ pub fn edit(name: String, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
.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<Ructe, ErrorPage> {
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::<Blog>(&*conn)
.expect("Couldn't save blog changes");
Ok(Flash::success(

@ -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<Ructe, Redirect> {
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)]

@ -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<Flash<Redirect>> 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<Page, &'v RawStr> {

@ -31,28 +31,14 @@ use routes::{
};
use template_utils::{IntoContext, Ructe};
#[get("/~/<blog>/<slug>?<responding_to>", rank = 4)]
pub fn details(
blog: String,
slug: String,
fn detail_guts(
blog: &Blog,
post: &Post,
responding_to: Option<i32>,
rockets: PlumeRocket,
) -> Result<Ructe, ErrorPage> {
rockets: &PlumeRocket,
) -> Result<RespondOrRedirect, ErrorPage> {
let conn = &*rockets.conn;
elegaanz commented 5 years ago (Migrated from github.com)
Review

Does that (and the blog: &Blog above) work?

Does that (and the `blog: &Blog` above) work?
elegaanz commented 5 years ago (Migrated from github.com)
Review

nvm, I thought it was a route, but it is just a regular function.

nvm, I thought it was a route, but it is just a regular function.
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("/~/<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)]

@ -49,8 +49,7 @@ macro_rules! param_to_query {
}
}
#[get("/search?<query..>")]
pub fn search(query: Option<Form<SearchQuery>>, rockets: PlumeRocket) -> Ructe {
fn search_guts(query: Option<Form<SearchQuery>>, 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<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)
}
}

@ -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<Query, NoValue> for Option<i32> {
type Target = Option<i32>;
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<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)*)
};
}

@ -13,8 +13,8 @@
<meta content="120" property="og:image:width" />
<meta content="120" property="og:image:height" />
<meta content="summary" property="twitter:card" />
<meta content="'@Instance::get_local().unwrap().name" property="og:site_name" />
<meta content="@blog.ap_url" property="og:url" />
<meta content="@Instance::get_local().unwrap().name" property="og:site_name" />
<meta content="@blog.url()" property="og:url" />
<meta content="@blog.fqn" property="profile:username" />
<meta content="@blog.title" property="og:title" />
<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='@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">
@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>
}
</div>
<main class="user-summary" dir="auto">
<p>
@i18n!(ctx.1, "There's one author on this blog: ", "There are {0} authors on this blog: "; authors.len())

@ -23,6 +23,8 @@
<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>
@input!(ctx.1, custom_domain (optional text), "Custom Domain", form, errors, "")
<p>
@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>

@ -9,7 +9,10 @@
@:base(ctx, i18n!(ctx.1, "New Blog"), {}, {}, {
<h1 dir="auto">@i18n!(ctx.1, "Create a blog")</h1>
<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!(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>
})

@ -9,7 +9,7 @@
<div class="cover" style="background-image: url('@Html(article.cover_url(ctx.0).unwrap_or_default())')"></div>
}
<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
</a>
</h3>
@ -25,10 +25,9 @@
@if article.published {
<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 {
⋅ @i18n!(ctx.1, "Draft")
}
</footer>
</div>

@ -17,10 +17,10 @@
@if article.cover_id.is_some() {
<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"/>
}, {
<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">
<header
@ -54,7 +54,7 @@
<ul class="tags" dir="auto">
@for tag in tags {
@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 {
<span class="hidden p-category">@tag.tag</span>
}

Loading…
Cancel
Save