Compare commits

..

49 Commits

Author SHA1 Message Date
Igor Galić 3c4abcff81 Allow updating of custom_domain 2019-08-21 11:09:42 +02:00
Igor Galić d242e6df11 restore df7adaf0e0 ← after fixing mount-point; fix query() parsing 2019-08-20 15:21:01 +02:00
Igor Galić f395c1046c fix mount point for blog post routing function 2019-08-20 15:06:57 +02:00
Igor Galić df7adaf0e0 when building links, skip both, /custom_domain/ and <custom_domain> 2019-08-20 15:04:29 +02:00
Igor Galić 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
Igor Galić 064dd79eef Allow editing blogs with custom_domain
n.b.: no validation yet
2019-08-20 12:24:36 +02:00
Igor Galić df47cddb87 also mount domain_validation into /custom_domains/ 2019-08-20 11:47:24 +02:00
Igor Galić f67ce93d94 fix getter router for domain_validation! 2019-08-20 11:33:00 +02:00
Igor Galić 38ece9b5a6 do not panic if reqwest does not work 2019-08-20 11:18:23 +02:00
Igor Galić 5e46922ed0 validate custom domain! 2019-08-19 22:42:34 +02:00
Igor Galić 6072351840 one step closer to validating custom domains! 2019-08-19 22:04:22 +02:00
Igor Galić 444a4673f4 correctly mutex-lock and modify valid_domains
thanks to @fdb-hiroshima for the review
2019-08-19 21:19:52 +02:00
Igor Galić 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
Igor Galić 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
Ana Gelez 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
Ana Gelez 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
fdb-hiroshima fb60236a54 update dependancies (#643)
* Bump activitystreams-derive from 0.1.0 to 0.1.1
* Bump tantivy from 0.9.1 to 0.10.1
* Bump rpassword from 3.0.2 to 4.0.1
* Bump num_cpus from 1.10.0 to 1.10.1
* Bump serde_qs from 0.4.6 to 0.5.0
* Bump stdweb-internal-runtime from 0.1.3 to 0.1.4
* [Security] Bump smallvec from 0.6.9 to 0.6.10
* Bump chrono from 0.4.6 to 0.4.7
* Bump bcrypt from 0.4.0 to 0.5.0
* Bump serde_json from 1.0.39 to 1.0.40
* Bump tokio from 0.1.21 to 0.1.22
* Bump scheduled-thread-pool from 0.2.0 to 0.2.2
* Bump stdweb from 0.4.14 to 0.4.18
* Bump hyper from 0.12.29 to 0.12.33
* Bump reqwest from 0.9.17 to 0.9.19
* Bump url from 1.7.2 to 2.1.0
2019-08-14 15:04:30 +02:00
39 changed files with 1483 additions and 1116 deletions
Generated
+353 -158
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -18,16 +18,17 @@ guid-create = "0.1"
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.0"
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" }
rpassword = "3.0"
rpassword = "4.0"
runtime-fmt = "0.3.0"
scheduled-thread-pool = "0.2.0"
scheduled-thread-pool = "0.2.2"
serde = "1.0"
serde_json = "1.0"
serde_qs = "0.4"
serde_qs = "0.5"
shrinkwraprs = "0.2.1"
validator = "0.8"
validator_derive = "0.8"
@@ -64,8 +65,8 @@ path = "plume-common"
path = "plume-models"
[dependencies.rocket_csrf]
git = "https://github.com/Plume-org/rocket_csrf"
rev = "89ecb380266234f858c651354216bf5bf3cc09b2"
git = "https://github.com/fdb-hiroshima/rocket_csrf"
rev = "4a72ea2ec716cb0b26188fb00bccf2ef7d1e031c"
[build-dependencies]
ructe = "0.6.2"
@@ -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;
@@ -1,2 +0,0 @@
-- This file should undo anything in `up.sql`
DELETE FROM apps WHERE name = 'Plume web interface';
@@ -1,35 +0,0 @@
-- Your SQL goes here
--#!|conn: &Connection, path: &Path| {
--#! use plume_common::utils::random_hex;
--#!
--#! let client_id = random_hex();
--#! let client_secret = random_hex();
--#! let app = crate::apps::App::insert(
--#! &*conn,
--#! crate::apps::NewApp {
--#! name: "Plume web interface".into(),
--#! client_id,
--#! client_secret,
--#! redirect_uri: None,
--#! website: Some("https://joinplu.me".into()),
--#! },
--#! ).unwrap();
--#!
--#! for i in 0..=(crate::users::User::count_local(conn).unwrap() as i32 / 20) {
--#! if let Ok(page) = crate::users::User::get_local_page(conn, (i * 20, (i + 1) * 20)) {
--#! for user in page {
--#! crate::api_tokens::ApiToken::insert(
--#! conn,
--#! crate::api_tokens::NewApiToken {
--#! app_id: app.id,
--#! user_id: user.id,
--#! value: random_hex(),
--#! scopes: "read+write".into(),
--#! },
--#! ).unwrap();
--#! }
--#! }
--#! }
--#!
--#! Ok(())
--#!}
@@ -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" (
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;
@@ -1,2 +0,0 @@
-- This file should undo anything in `up.sql`
DELETE FROM apps WHERE name = 'Plume web interface';
@@ -1,35 +0,0 @@
-- Your SQL goes here
--#!|conn: &Connection, path: &Path| {
--#! use plume_common::utils::random_hex;
--#!
--#! let client_id = random_hex();
--#! let client_secret = random_hex();
--#! let app = crate::apps::App::insert(
--#! &*conn,
--#! crate::apps::NewApp {
--#! name: "Plume web interface".into(),
--#! client_id,
--#! client_secret,
--#! redirect_uri: None,
--#! website: Some("https://joinplu.me".into()),
--#! },
--#! ).unwrap();
--#!
--#! for i in 0..=(crate::users::User::count_local(conn).unwrap() as i32 / 20) {
--#! if let Ok(page) = crate::users::User::get_local_page(conn, (i * 20, (i + 1) * 20)) {
--#! for user in page {
--#! crate::api_tokens::ApiToken::insert(
--#! conn,
--#! crate::api_tokens::NewApiToken {
--#! app_id: app.id,
--#! user_id: user.id,
--#! value: random_hex(),
--#! scopes: "read+write".into(),
--#! },
--#! ).unwrap();
--#! }
--#! }
--#! }
--#!
--#! Ok(())
--#!}
-1
View File
@@ -28,5 +28,4 @@ pub struct PostData {
pub license: String,
pub tags: Vec<String>,
pub cover_id: Option<i32>,
pub url: String,
}
+1 -1
View File
@@ -10,7 +10,7 @@ path = "src/main.rs"
[dependencies]
clap = "2.33"
dotenv = "0.14"
rpassword = "3.0"
rpassword = "4.0"
[dependencies.diesel]
features = ["r2d2", "chrono"]
+3 -3
View File
@@ -5,13 +5,13 @@ authors = ["Plume contributors"]
[dependencies]
activitypub = "0.1.1"
activitystreams-derive = "0.1.0"
activitystreams-derive = "0.1.1"
activitystreams-traits = "0.1.0"
array_tool = "1.0"
base64 = "0.10"
heck = "0.3.0"
hex = "0.3"
hyper = "0.12.28"
hyper = "0.12.33"
openssl = "0.10.22"
rocket = "0.4.0"
reqwest = "0.9"
@@ -19,7 +19,7 @@ serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
shrinkwraprs = "0.2.1"
tokio = "0.1.21"
tokio = "0.1.22"
[dependencies.chrono]
features = ["serde"]
+2 -8
View File
@@ -4,15 +4,9 @@ version = "0.3.0"
authors = ["Plume contributors"]
[dependencies]
stdweb = "=0.4.14"
stdweb-internal-runtime = "=0.1.3"
stdweb = "=0.4.18"
stdweb-internal-runtime = "=0.1.4"
gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699fbc66502b480a37cc66c1daa7f3" }
gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
lazy_static = "1.3"
plume-api = { path = "../plume-api" }
serde_json = "1.0"
[dependencies.pulldown-cmark]
default-features = false
version = "0.5"
+285 -417
View File
@@ -1,212 +1,32 @@
use pulldown_cmark::{Event, Options, Parser, Tag};
use stdweb::{
unstable::{TryFrom, TryInto},
web::{event::*, html_element::*, *},
};
use CATALOG;
fn from_md(md: &str) {
let md_parser = Parser::new_ext(md, Options::all());
md_parser.fold(
document().get_element_by_id("editor-main").unwrap(),
|last_elt, event| {
match event {
Event::Start(tag) => {
let new = match tag {
Tag::Paragraph => document().create_element("p").unwrap(),
Tag::Rule => document().create_element("hr").unwrap(),
Tag::Header(level) => {
document().create_element(&format!("h{}", level)).unwrap()
}
Tag::BlockQuote => document().create_element("blockquote").unwrap(),
Tag::CodeBlock(code) => {
let pre = document().create_element("pre").unwrap();
let code_elt = document().create_element("code").unwrap();
code_elt.append_child(&document().create_text_node(&code));
pre.append_child(&code_elt);
pre
}
Tag::List(None) => document().create_element("ul").unwrap(),
Tag::List(Some(_start_index)) => document().create_element("ol").unwrap(), // TODO: handle start_index
Tag::Item => document().create_element("li").unwrap(),
Tag::FootnoteDefinition(def) => {
let note = document().create_element("div").unwrap();
note.class_list().add("footnote");
note.append_child(&document().create_text_node(&def));
note
}
Tag::HtmlBlock => document().create_element("div").unwrap(),
Tag::Table(_alignements) => document().create_element("table").unwrap(), // TODO: handle alignements
Tag::TableHead => document().create_element("th").unwrap(),
Tag::TableRow => document().create_element("tr").unwrap(),
Tag::TableCell => document().create_element("td").unwrap(),
Tag::Emphasis => document().create_element("em").unwrap(),
Tag::Strong => document().create_element("strong").unwrap(),
Tag::Strikethrough => document().create_element("s").unwrap(),
Tag::Link(_link_type, url, text) => {
let url: &str = &url;
let text: &str = &text;
let link = document().create_element("a").unwrap();
js! {
@{&link}.href = @{url};
@{&link}.title = @{text};
};
link
}
Tag::Image(_link_type, url, text) => {
let url: &str = &url;
let text: &str = &text;
let img = document().create_element("img").unwrap();
js! {
@{&img}.src = @{url};
@{&img}.title = @{text};
@{&img}.alt = @{text};
};
img
}
};
last_elt.append_child(&new);
new
}
Event::End(_) => last_elt.parent_element().unwrap(),
Event::Text(text) => {
let node = document().create_text_node(&text);
last_elt.append_child(&node);
last_elt
}
Event::Code(code) => {
let elt = document().create_element("code").unwrap();
let content = document().create_text_node(&code);
elt.append_child(&content);
last_elt.append_child(&elt);
last_elt
}
Event::Html(html) => {
// TODO: sanitize it?
last_elt.set_attribute("innerHtml", &html);
last_elt
}
Event::InlineHtml(html) => {
let elt = document().create_element("span").unwrap();
elt.set_attribute("innerHtml", &html);
last_elt.append_child(&elt);
last_elt
}
Event::FootnoteReference(reference) => {
last_elt // TODO
}
Event::SoftBreak => {
last_elt.append_child(&document().create_element("br").unwrap());
last_elt
}
Event::HardBreak => {
last_elt // TODO
}
Event::TaskListMarker(done) => {
last_elt // TODO
}
}
},
);
MutationObserver::new(|muts, _obs| {
for m in muts {
console!(log, "mut!!");
macro_rules! mv {
( $( $var:ident ),* => $exp:expr ) => {
{
$( let $var = $var.clone(); )*
$exp
}
})
.observe(
&document().get_element_by_id("editor-main").unwrap(),
MutationObserverInit {
child_list: true,
attributes: true,
character_data: false,
subtree: true,
attribute_old_value: true,
character_data_old_value: false,
attribute_filter: None,
},
);
}
fn to_md() -> String {
let root = document().get_element_by_id("editor-main").unwrap();
fold_children(&root).join("")
}
fn fold_children(elt: &Element) -> Vec<String> {
elt.child_nodes().iter().fold(vec![], |mut blocks, node| {
blocks.push(html_to_md(&node));
blocks
})
}
fn html_to_md(node: &Node) -> String {
console!(log, node);
if let Ok(elt) = Element::try_from(node.clone()) {
console!(log, elt.node_name().to_lowercase());
match elt.node_name().to_lowercase().as_ref() {
"hr" => "---".into(),
"h1" => format!("# {}\n\n", fold_children(&elt).join("")),
"h2" => format!("## {}\n\n", fold_children(&elt).join("")),
"h3" => format!("### {}\n\n", fold_children(&elt).join("")),
"h4" => format!("#### {}\n\n", fold_children(&elt).join("")),
"h5" => format!("##### {}\n\n", fold_children(&elt).join("")),
"h6" => format!("###### {}\n\n", fold_children(&elt).join("")),
"blockquote" => format!("> {}\n\n", fold_children(&elt).join("> ")),
"pre" => format!("```\n{}\n```\n\n", node.text_content().unwrap_or_default()),
"li" => match elt
.parent_element()
.unwrap()
.node_name()
.to_lowercase()
.as_ref()
{
"ol" => format!(
"{}. {}\n",
elt.parent_element()
.unwrap()
.child_nodes()
.iter()
.position(|n| Element::try_from(n).unwrap() == elt)
.unwrap_or_default(),
fold_children(&elt).join(""),
),
_ => format!("- {}\n", fold_children(&elt).join("")),
},
"em" => format!("_{}_", fold_children(&elt).join("")),
"strong" => format!("**{}**", fold_children(&elt).join("")),
"s" => format!("~~{}~~", fold_children(&elt).join("")),
"a" => format!(
"[{}]({})",
fold_children(&elt).join(""),
String::try_from(js! { return @{&elt}.href }).unwrap()
),
"img" => format!(
"![{}]({})",
String::try_from(js! { return @{&elt}.alt }).unwrap(),
String::try_from(js! { return @{&elt}.src }).unwrap()
),
other => {
console!(log, "Warning: unhandled element:", other);
String::new()
} // TODO: refs, tables, raw html
}
} else {
node.text_content().unwrap_or_default()
}
}
fn get_elt_value(id: &'static str) -> String {
let elt = document().get_element_by_id(id).unwrap();
let inp: Result<InputElement, _> = elt.clone().try_into();
let select: Result<SelectElement, _> = elt.clone().try_into();
let textarea: Result<TextAreaElement, _> = elt.try_into();
let res = inp.map(|i| i.raw_value()).unwrap_or_else(|_| {
textarea
.map(|t| t.value())
.unwrap_or_else(|_| select.unwrap().value().unwrap_or_default())
});
res
inp.map(|i| i.raw_value())
.unwrap_or_else(|_| textarea.unwrap().value())
}
fn set_value<S: AsRef<str>>(id: &'static str, val: S) {
let elt = document().get_element_by_id(id).unwrap();
let inp: Result<InputElement, _> = elt.clone().try_into();
let textarea: Result<TextAreaElement, _> = elt.try_into();
inp.map(|i| i.set_raw_value(val.as_ref()))
.unwrap_or_else(|_| textarea.unwrap().set_value(val.as_ref()))
}
fn no_return(evt: KeyDownEvent) {
@@ -243,7 +63,33 @@ impl From<stdweb::private::ConversionError> for EditorError {
}
}
fn filter_paste(elt: &Element) {
fn init_widget(
parent: &Element,
tag: &'static str,
placeholder_text: String,
content: String,
disable_return: bool,
) -> Result<HtmlElement, EditorError> {
let widget = placeholder(make_editable(tag).try_into()?, &placeholder_text);
if !content.is_empty() {
widget.dataset().insert("edited", "true")?;
}
widget.append_child(&document().create_text_node(&content));
if disable_return {
widget.add_event_listener(no_return);
}
parent.append_child(&widget);
// We need to do that to make sure the placeholder is correctly rendered
widget.focus();
widget.blur();
filter_paste(&widget);
Ok(widget)
}
fn filter_paste(elt: &HtmlElement) {
// Only insert text when pasting something
js! {
@{&elt}.addEventListener("paste", function (evt) {
@@ -281,69 +127,64 @@ pub fn init() -> Result<(), EditorError> {
fn init_editor() -> Result<(), EditorError> {
if let Some(ed) = document().get_element_by_id("plume-editor") {
document().body()?.set_attribute("id", "editor")?;
let aside = document().get_element_by_id("plume-editor-aside")?;
// Show the editor
js! {
@{&ed}.style.display = "grid";
@{&aside}.style.display = "block";
};
js! { @{&ed}.style.display = "block"; };
// And hide the HTML-only fallback
let old_ed = document().get_element_by_id("plume-fallback-editor")?;
let old_title = document().get_element_by_id("plume-editor-title")?;
js! {
@{&old_ed}.style.display = "none";
@{&old_title}.style.display = "none";
};
// Get content from the old editor (when editing an article for instance)
let title_val = get_elt_value("title");
let subtitle_val = get_elt_value("subtitle");
let content_val = get_elt_value("editor-content");
// And pre-fill the new editor with this values
let title = document().get_element_by_id("editor-title")?;
let subtitle = document().get_element_by_id("editor-subtitle")?;
let source = get_elt_value("editor-content");
let title = init_widget(&ed, "h1", i18n!(CATALOG, "Title"), title_val, true)?;
let subtitle = init_widget(
&ed,
"h2",
i18n!(CATALOG, "Subtitle, or summary"),
subtitle_val,
true,
)?;
let content = init_widget(
&ed,
"article",
i18n!(CATALOG, "Write your article here. Markdown is supported."),
content_val.clone(),
false,
)?;
js! { @{&content}.innerHTML = @{content_val}; };
setup_toolbar();
from_md(&source);
title.add_event_listener(no_return);
subtitle.add_event_listener(no_return);
filter_paste(&title);
filter_paste(&subtitle);
// TODO: filter_paste(&content);
document()
.get_element_by_id("publish")?
.add_event_listener(|_: ClickEvent| {
let publish_page = document().get_element_by_id("publish-page").unwrap();
let options_page = document().get_element_by_id("options-page").unwrap();
js! {
@{&options_page}.style.display = "none";
@{&publish_page}.style.display = "flex";
// character counter
content.add_event_listener(mv!(content => move |_: KeyDownEvent| {
window().set_timeout(mv!(content => move || {
if let Some(e) = document().get_element_by_id("char-count") {
let count = chars_left("#plume-fallback-editor", &content).unwrap_or_default();
let text = i18n!(CATALOG, "Around {} characters left"; count);
HtmlElement::try_from(e).map(|e| {
js!{@{e}.innerText = @{text}};
}).ok();
};
});
}), 0);
}));
document()
.get_element_by_id("cancel-publish")?
.add_event_listener(|_: ClickEvent| {
let publish_page = document().get_element_by_id("publish-page").unwrap();
let options_page = document().get_element_by_id("options-page").unwrap();
js! {
@{&publish_page}.style.display = "none";
@{&options_page}.style.display = "flex";
};
});
document().get_element_by_id("publish")?.add_event_listener(
mv!(title, subtitle, content, old_ed => move |_: ClickEvent| {
let popup = document().get_element_by_id("publish-popup").or_else(||
init_popup(&title, &subtitle, &content, &old_ed).ok()
).unwrap();
let bg = document().get_element_by_id("popup-bg").or_else(||
init_popup_bg().ok()
).unwrap();
document()
.get_element_by_id("confirm-publish")?
.add_event_listener(|_: ClickEvent| {
save(false);
});
document()
.get_element_by_id("save-draft")?
.add_event_listener(|_: ClickEvent| {
save(true);
});
popup.class_list().add("show").unwrap();
bg.class_list().add("show").unwrap();
}),
);
show_errors();
setup_close_button();
@@ -351,176 +192,6 @@ fn init_editor() -> Result<(), EditorError> {
Ok(())
}
fn select_style(style: &str) {
if let Some(select) = document()
.get_element_by_id("toolbar-style")
.and_then(|e| SelectElement::try_from(e).ok())
{
select.set_value(Some(style));
}
}
fn setup_toolbar() {
let toolbar = document().get_element_by_id("editor-toolbar").unwrap();
// List of styles (headings, quote, code, etc)
let style_select =
SelectElement::try_from(document().create_element("select").unwrap()).unwrap();
let options = vec![
("p", i18n!(CATALOG, "Paragraph")),
("ul", i18n!(CATALOG, "List")),
("ol", i18n!(CATALOG, "Ordered list")),
("h1", i18n!(CATALOG, "Heading 1")),
("h2", i18n!(CATALOG, "Heading 2")),
("h3", i18n!(CATALOG, "Heading 3")),
("h4", i18n!(CATALOG, "Heading 4")),
("h5", i18n!(CATALOG, "Heading 5")),
("h6", i18n!(CATALOG, "Heading 6")),
("blockquote", i18n!(CATALOG, "Quote")),
("pre", i18n!(CATALOG, "Code")),
];
for (tag, name) in options.clone() {
let opt = document().create_element("option").unwrap();
opt.set_attribute("value", tag);
opt.append_child(&document().create_text_node(&name));
style_select.append_child(&opt)
}
style_select.set_attribute("id", "toolbar-style");
let options_clone = options.clone();
document().add_event_listener(move |_: SelectionChangeEvent| {
let block = std::iter::successors(
window().get_selection().and_then(|s| s.anchor_node()),
|node| {
let t = node.node_name().to_lowercase();
if options_clone.iter().any(|(tag, _)| *tag == &t) {
None
} else {
node.parent_node()
}
},
)
.last();
if let Some(b) = block {
select_style(&b.node_name().to_lowercase());
}
});
style_select.add_event_listener(move |_: ChangeEvent| {
let block = std::iter::successors(
window().get_selection().and_then(|s| s.anchor_node()),
|node| {
let t = node.node_name().to_lowercase();
if options.iter().any(|(tag, _)| *tag == &t) {
None
} else {
node.parent_node()
}
},
)
.last();
if let Some(block) = block {
if let Some(select) = document()
.get_element_by_id("toolbar-style")
.and_then(|e| SelectElement::try_from(e).ok())
{
let tag = select.value();
let new = document().create_element(&tag.unwrap_or_default()).unwrap();
for ch in block.child_nodes() {
block.remove_child(&ch);
new.append_child(&ch);
}
block.parent_node().unwrap().replace_child(&new, &block);
}
}
});
// Bold
// Italics
// Insert an image
toolbar.append_child(&style_select);
}
fn save(is_draft: bool) {
let req = XmlHttpRequest::new();
if bool::try_from(js! { return window.editing }).unwrap_or(false) {
req.open(
"PUT",
&format!(
"/api/v1/posts/{}",
i32::try_from(js! { return window.post_id }).unwrap()
),
)
.unwrap();
} else {
req.open("POST", "/api/v1/posts").unwrap();
}
req.set_request_header("Accept", "application/json")
.unwrap();
req.set_request_header("Content-Type", "application/json")
.unwrap();
req.set_request_header(
"Authorization",
&format!(
"Bearer {}",
String::try_from(js! { return window.api_token }).unwrap()
),
)
.unwrap();
let req_clone = req.clone();
req.add_event_listener(move |_: ProgressLoadEvent| {
if let Ok(Some(res)) = req_clone.response_text() {
serde_json::from_str(&res)
.map(|res: plume_api::posts::PostData| {
let url = res.url;
js! {
window.location.href = @{url};
};
})
.map_err(|_| {
let json: serde_json::Value = serde_json::from_str(&res).unwrap();
window().alert(&format!(
"Error: {}",
json["error"].as_str().unwrap_or_default()
));
})
.ok();
}
});
console!(log, to_md());
let data = plume_api::posts::NewPostData {
title: HtmlElement::try_from(document().get_element_by_id("editor-title").unwrap())
.unwrap()
.inner_text(),
subtitle: document()
.get_element_by_id("editor-subtitle")
.map(|s| HtmlElement::try_from(s).unwrap().inner_text()),
source: to_md(),
author: String::new(), // it is ignored anyway (TODO: remove it ??)
blog_id: i32::try_from(js! { return window.blog_id }).ok(),
published: Some(!is_draft),
creation_date: None,
license: Some(get_elt_value("license")),
tags: Some(
get_elt_value("tags")
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect(),
),
cover_id: get_elt_value("cover").parse().ok(),
};
let json = serde_json::to_string(&data).unwrap();
req.send_with_string(&json).unwrap();
}
fn setup_close_button() {
if let Some(button) = document().get_element_by_id("close-editor") {
button.add_event_listener(|_: ClickEvent| {
@@ -552,3 +223,200 @@ fn show_errors() {
.unwrap();
}
}
fn init_popup(
title: &HtmlElement,
subtitle: &HtmlElement,
content: &HtmlElement,
old_ed: &Element,
) -> Result<Element, EditorError> {
let popup = document().create_element("div")?;
popup.class_list().add("popup")?;
popup.set_attribute("id", "publish-popup")?;
let tags = get_elt_value("tags")
.split(',')
.map(str::trim)
.map(str::to_string)
.collect::<Vec<_>>();
let license = get_elt_value("license");
make_input(&i18n!(CATALOG, "Tags"), "popup-tags", &popup).set_raw_value(&tags.join(", "));
make_input(&i18n!(CATALOG, "License"), "popup-license", &popup).set_raw_value(&license);
let cover_label = document().create_element("label")?;
cover_label.append_child(&document().create_text_node(&i18n!(CATALOG, "Cover")));
cover_label.set_attribute("for", "cover")?;
let cover = document().get_element_by_id("cover")?;
cover.parent_element()?.remove_child(&cover).ok();
popup.append_child(&cover_label);
popup.append_child(&cover);
if let Some(draft_checkbox) = document().get_element_by_id("draft") {
let draft_label = document().create_element("label")?;
draft_label.set_attribute("for", "popup-draft")?;
let draft = document().create_element("input").unwrap();
js! {
@{&draft}.id = "popup-draft";
@{&draft}.name = "popup-draft";
@{&draft}.type = "checkbox";
@{&draft}.checked = @{&draft_checkbox}.checked;
};
draft_label.append_child(&draft);
draft_label.append_child(&document().create_text_node(&i18n!(CATALOG, "This is a draft")));
popup.append_child(&draft_label);
}
let button = document().create_element("input")?;
js! {
@{&button}.type = "submit";
@{&button}.value = @{i18n!(CATALOG, "Publish")};
};
button.append_child(&document().create_text_node(&i18n!(CATALOG, "Publish")));
button.add_event_listener(
mv!(title, subtitle, content, old_ed => move |_: ClickEvent| {
title.focus(); // Remove the placeholder before publishing
set_value("title", title.inner_text());
subtitle.focus();
set_value("subtitle", subtitle.inner_text());
content.focus();
set_value("editor-content", content.child_nodes().iter().fold(String::new(), |md, ch| {
let to_append = match ch.node_type() {
NodeType::Element => {
if js!{ return @{&ch}.tagName; } == "DIV" {
(js!{ return @{&ch}.innerHTML; }).try_into().unwrap_or_default()
} else {
(js!{ return @{&ch}.outerHTML; }).try_into().unwrap_or_default()
}
},
NodeType::Text => ch.node_value().unwrap_or_default(),
_ => unreachable!(),
};
format!("{}\n\n{}", md, to_append)
}));
set_value("tags", get_elt_value("popup-tags"));
if let Some(draft) = document().get_element_by_id("popup-draft") {
js!{
document.getElementById("draft").checked = @{draft}.checked;
};
}
let cover = document().get_element_by_id("cover").unwrap();
cover.parent_element().unwrap().remove_child(&cover).ok();
old_ed.append_child(&cover);
set_value("license", get_elt_value("popup-license"));
js! {
@{&old_ed}.submit();
};
}),
);
popup.append_child(&button);
document().body()?.append_child(&popup);
Ok(popup)
}
fn init_popup_bg() -> Result<Element, EditorError> {
let bg = document().create_element("div")?;
bg.class_list().add("popup-bg")?;
bg.set_attribute("id", "popup-bg")?;
document().body()?.append_child(&bg);
bg.add_event_listener(|_: ClickEvent| close_popup());
Ok(bg)
}
fn chars_left(selector: &str, content: &HtmlElement) -> Option<i32> {
match document().query_selector(selector) {
Ok(Some(form)) => HtmlElement::try_from(form).ok().and_then(|form| {
if let Some(len) = form
.get_attribute("content-size")
.and_then(|s| s.parse::<i32>().ok())
{
(js! {
let x = encodeURIComponent(@{content}.innerHTML)
.replace(/%20/g, "+")
.replace(/%0A/g, "%0D%0A")
.replace(new RegExp("[!'*()]", "g"), "XXX") // replace exceptions of encodeURIComponent with placeholder
.length + 2;
console.log(x);
return x;
})
.try_into()
.map(|c: i32| len - c)
.ok()
} else {
None
}
}),
_ => None,
}
}
fn close_popup() {
let hide = |x: Element| x.class_list().remove("show");
document().get_element_by_id("publish-popup").map(hide);
document().get_element_by_id("popup-bg").map(hide);
}
fn make_input(label_text: &str, name: &'static str, form: &Element) -> InputElement {
let label = document().create_element("label").unwrap();
label.append_child(&document().create_text_node(label_text));
label.set_attribute("for", name).unwrap();
let inp: InputElement = document()
.create_element("input")
.unwrap()
.try_into()
.unwrap();
inp.set_attribute("name", name).unwrap();
inp.set_attribute("id", name).unwrap();
form.append_child(&label);
form.append_child(&inp);
inp
}
fn make_editable(tag: &'static str) -> Element {
let elt = document()
.create_element(tag)
.expect("Couldn't create editable element");
elt.set_attribute("contenteditable", "true")
.expect("Couldn't make the element editable");
elt
}
fn placeholder(elt: HtmlElement, text: &str) -> HtmlElement {
elt.dataset().insert("placeholder", text).unwrap();
elt.dataset().insert("edited", "false").unwrap();
elt.add_event_listener(mv!(elt => move |_: FocusEvent| {
if elt.dataset().get("edited").unwrap().as_str() != "true" {
clear_children(&elt);
}
}));
elt.add_event_listener(mv!(elt => move |_: BlurEvent| {
if elt.dataset().get("edited").unwrap().as_str() != "true" {
clear_children(&elt);
let ph = document().create_element("span").expect("Couldn't create placeholder");
ph.class_list().add("placeholder").expect("Couldn't add class");
ph.append_child(&document().create_text_node(&elt.dataset().get("placeholder").unwrap_or_default()));
elt.append_child(&ph);
}
}));
elt.add_event_listener(mv!(elt => move |_: KeyUpEvent| {
elt.dataset().insert("edited", if elt.inner_text().trim_matches('\n').is_empty() {
"false"
} else {
"true"
}).expect("Couldn't update edition state");
}));
elt
}
fn clear_children(elt: &HtmlElement) {
for child in elt.child_nodes() {
elt.remove_child(&child).unwrap();
}
}
-14
View File
@@ -6,10 +6,8 @@ extern crate gettext;
extern crate gettext_macros;
#[macro_use]
extern crate lazy_static;
extern crate pulldown_cmark;
#[macro_use]
extern crate stdweb;
extern crate serde_json;
use stdweb::web::{event::*, *};
@@ -58,18 +56,6 @@ lazy_static! {
}
fn main() {
std::panic::set_hook(Box::new(|info: &std::panic::PanicInfo| {
let mut msg = info.to_string();
msg.push_str("\n\nStack:\n\n");
let e = error::Error::new("Panicked");
let stack = js! { return @{&e}.stack; }
.into_string()
.unwrap_or_default();
msg.push_str(&stack);
msg.push_str("\n\n");
console!(error, msg);
}));
menu();
search();
editor::init()
+4 -5
View File
@@ -7,7 +7,7 @@ authors = ["Plume contributors"]
activitypub = "0.1.1"
ammonia = "2.1.1"
askama_escape = "0.1"
bcrypt = "0.4"
bcrypt = "0.5"
guid-create = "0.1"
heck = "0.3.0"
itertools = "0.8.0"
@@ -17,15 +17,14 @@ openssl = "0.10.22"
rocket = "0.4.0"
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }
reqwest = "0.9"
scheduled-thread-pool = "0.2.0"
scheduled-thread-pool = "0.2.2"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
tantivy = "0.9.1"
url = "1.7"
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]
-12
View File
@@ -44,18 +44,6 @@ impl ApiToken {
get!(api_tokens);
insert!(api_tokens, NewApiToken);
find_by!(api_tokens, find_by_value, value as &str);
find_by!(
api_tokens,
find_by_app_and_user,
app_id as i32,
user_id as i32
);
/// The token for Plume's front-end
pub fn web_token(conn: &crate::Connection, user_id: i32) -> Result<ApiToken> {
let app = crate::apps::App::find_by_name(conn, "Plume web interface")?;
Self::find_by_app_and_user(conn, app.id, user_id)
}
pub fn can(&self, what: &'static str, scope: &'static str) -> bool {
let full_scope = what.to_owned() + ":" + scope;
-1
View File
@@ -29,5 +29,4 @@ impl App {
get!(apps);
insert!(apps, NewApp);
find_by!(apps, find_by_client_id, client_id as &str);
find_by!(apps, find_by_name, name as &str);
}
+120
View File
@@ -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(),
)
+2 -1
View File
@@ -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;
@@ -64,7 +66,6 @@ pub enum Error {
Signature,
Unauthorized,
Url,
Validation(String),
Webfinger,
Expired,
}
+77 -76
View File
@@ -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,
+9 -9
View File
@@ -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 ""
-1
View File
@@ -37,7 +37,6 @@ impl<'r> Responder<'r> for ApiError {
"error": "You are not authorized to access this resource"
}))
.respond_to(req),
Error::Validation(msg) => Json(json!({ "error": msg })).respond_to(req),
_ => Json(json!({
"error": "Server error"
}))
+3 -154
View File
@@ -1,7 +1,6 @@
use chrono::{NaiveDateTime, Utc};
use chrono::NaiveDateTime;
use heck::{CamelCase, KebabCase};
use rocket_contrib::json::Json;
use std::collections::HashSet;
use crate::api::{authorization::*, Api};
use plume_api::posts::*;
@@ -45,7 +44,6 @@ pub fn get(id: i32, auth: Option<Authorization<Read, Post>>, conn: DbConn) -> Ap
published: post.published,
license: post.license,
cover_id: post.cover_id,
url: post.ap_url,
}))
}
@@ -93,7 +91,6 @@ pub fn list(
published: p.published,
license: p.license,
cover_id: p.cover_id,
url: p.ap_url,
})
})
.collect(),
@@ -117,20 +114,6 @@ pub fn create(
NaiveDateTime::parse_from_str(format!("{} 00:00:00", d).as_ref(), "%Y-%m-%d %H:%M:%S").ok()
});
if slug.as_str() == "new" {
return Err(
Error::Validation("Sorry, but your article can't have this title.".into()).into(),
);
}
if payload.title.is_empty() {
return Err(Error::Validation("You have to give your article a title.".into()).into());
}
if payload.source.is_empty() {
return Err(Error::Validation("Your article can't be empty.".into()).into());
}
let domain = &Instance::get_local()?.public_domain;
let (content, mentions, hashtags) = md_to_html(
&payload.source,
@@ -148,10 +131,6 @@ pub fn create(
}
})?;
if !author.is_author_in(conn, &Blog::get(conn, blog)?)? {
return Err(Error::Unauthorized.into());
}
if Post::find_by_slug(conn, slug, blog).is_ok() {
return Err(Error::InvalidValue.into());
}
@@ -187,19 +166,11 @@ pub fn create(
)?;
if let Some(ref tags) = payload.tags {
let tags = tags
.iter()
.map(|t| t.to_camel_case())
.filter(|t| !t.is_empty())
.collect::<HashSet<_>>()
.into_iter()
.filter_map(|t| Tag::build_activity(t).ok());
for tag in tags {
Tag::insert(
conn,
NewTag {
tag: tag.name_string().unwrap(),
tag: tag.to_string(),
is_hashtag: false,
post_id: post.id,
},
@@ -240,6 +211,7 @@ pub fn create(
.into_iter()
.map(|t| t.tag)
.collect(),
id: post.id,
title: post.title,
subtitle: post.subtitle,
@@ -249,132 +221,9 @@ pub fn create(
published: post.published,
license: post.license,
cover_id: post.cover_id,
url: post.ap_url,
}))
}
#[put("/posts/<id>", data = "<payload>")]
pub fn update(
id: i32,
auth: Authorization<Write, Post>,
payload: Json<NewPostData>,
rockets: PlumeRocket,
) -> Api<PostData> {
let conn = &*rockets.conn;
let mut post = Post::get(&*conn, id)?;
let author = User::get(conn, auth.0.user_id)?;
let b = post.get_blog(&*conn)?;
let new_slug = if !post.published {
payload.title.to_string().to_kebab_case()
} else {
post.slug.clone()
};
if new_slug != post.slug && Post::find_by_slug(&*conn, &new_slug, b.id).is_ok() {
return Err(Error::Validation("A post with the same title already exists.".into()).into());
}
if !author.is_author_in(&*conn, &b)? {
Err(Error::Unauthorized.into())
} else {
let (content, mentions, hashtags) = md_to_html(
&payload.source,
Some(&Instance::get_local()?.public_domain),
false,
Some(Media::get_media_processor(
&conn,
b.list_authors(&conn)?.iter().collect(),
)),
);
// update publication date if when this article is no longer a draft
let newly_published = if !post.published && payload.published.unwrap_or(post.published) {
post.published = true;
post.creation_date = Utc::now().naive_utc();
true
} else {
false
};
post.slug = new_slug.clone();
post.title = payload.title.clone();
post.subtitle = payload.subtitle.clone().unwrap_or_default();
post.content = SafeString::new(&content);
post.source = payload.source.clone();
post.license = payload.license.clone().unwrap_or_default();
post.cover_id = payload.cover_id;
post.update(&*conn, &rockets.searcher)?;
if post.published {
post.update_mentions(
&conn,
mentions
.into_iter()
.filter_map(|m| Mention::build_activity(&rockets, &m).ok())
.collect(),
)?;
}
let tags = payload
.tags
.clone()
.unwrap_or_default()
.iter()
.map(|t| t.trim().to_camel_case())
.filter(|t| !t.is_empty())
.collect::<HashSet<_>>()
.into_iter()
.filter_map(|t| Tag::build_activity(t).ok())
.collect::<Vec<_>>();
post.update_tags(&conn, tags)?;
let hashtags = hashtags
.into_iter()
.map(|h| h.to_camel_case())
.collect::<HashSet<_>>()
.into_iter()
.filter_map(|t| Tag::build_activity(t).ok())
.collect::<Vec<_>>();
post.update_hashtags(&conn, hashtags)?;
if post.published {
if newly_published {
let act = post.create_activity(&conn)?;
let dest = User::one_by_instance(&*conn)?;
rockets
.worker
.execute(move || broadcast(&author, act, dest));
} else {
let act = post.update_activity(&*conn)?;
let dest = User::one_by_instance(&*conn)?;
rockets
.worker
.execute(move || broadcast(&author, act, dest));
}
}
Ok(Json(PostData {
authors: post.get_authors(conn)?.into_iter().map(|a| a.fqn).collect(),
creation_date: post.creation_date.format("%Y-%m-%d").to_string(),
tags: Tag::for_post(conn, post.id)?
.into_iter()
.map(|t| t.tag)
.collect(),
id: post.id,
title: post.title,
subtitle: post.subtitle,
content: post.content.to_string(),
source: Some(post.source),
blog_id: post.blog_id,
published: post.published,
license: post.license,
cover_id: post.cover_id,
url: post.ap_url,
}))
}
}
#[delete("/posts/<id>")]
pub fn delete(auth: Authorization<Write, Post>, rockets: PlumeRocket, id: i32) -> Api<()> {
let author = User::get(&*rockets.conn, auth.0.user_id)?;
+52 -6
View File
@@ -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();
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![
@@ -275,7 +316,6 @@ Then try to restart Plume
api::posts::get,
api::posts::list,
api::posts::create,
api::posts::update,
api::posts::delete,
],
)
@@ -289,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()
@@ -300,18 +341,23 @@ Then try to restart Plume
(
"/inbox".to_owned(),
"/inbox".to_owned(),
Some(rocket::http::Method::Post),
rocket::http::Method::Post,
),
(
"/@/<name>/inbox".to_owned(),
"/@/<name>/inbox".to_owned(),
Some(rocket::http::Method::Post),
rocket::http::Method::Post,
),
(
"/api/<path..>".to_owned(),
"/api/<path..>".to_owned(),
rocket::http::Method::Post,
),
("/api/<path..>".to_owned(), "/api/<path..>".to_owned(), None),
])
.finalize()
.expect("main: csrf fairing creation error"),
);
)
.attach(custom_domain_fairing);
#[cfg(feature = "test")]
let rocket = rocket.mount("/test", routes![test_routes::health,]);
+174 -12
View File
@@ -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 {
))
}
// 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)]
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(),
);
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>")]
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(
+21 -4
View File
@@ -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)]
+8 -1
View File
@@ -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> {
+89 -32
View File
@@ -13,7 +13,6 @@ use validator::{Validate, ValidationError, ValidationErrors};
use plume_common::activity_pub::{broadcast, ActivityStream, ApRequest};
use plume_common::utils;
use plume_models::{
api_tokens::ApiToken,
blogs::*,
comments::{Comment, CommentTree},
inbox::inbox,
@@ -32,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;
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())?;
@@ -62,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!(
@@ -95,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)]
@@ -157,8 +220,7 @@ pub fn new(blog: String, cl: ContentLen, rockets: PlumeRocket) -> Result<Ructe,
None,
ValidationErrors::default(),
medias,
cl.0,
ApiToken::web_token(&*conn, user.id)?.value
cl.0
)))
}
@@ -212,8 +274,7 @@ pub fn edit(
Some(post),
ValidationErrors::default(),
medias,
cl.0,
ApiToken::web_token(&*conn, user.id)?.value
cl.0
)))
}
@@ -369,10 +430,7 @@ pub fn update(
Some(post),
errors.clone(),
medias.clone(),
cl.0,
ApiToken::web_token(&*conn, user.id)
.expect("The default API token cannot be retrieved")
.value
cl.0
))
.into()
}
@@ -556,8 +614,7 @@ pub fn create(
None,
errors.clone(),
medias,
cl.0,
ApiToken::web_token(&*conn, user.id)?.value
cl.0
))
.into())
}
+21 -2
View File
@@ -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)
}
}
+101 -1
View File
@@ -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)*)
};
}
+14 -71
View File
@@ -364,89 +364,32 @@ main .article-meta {
}
#plume-editor {
margin: 0;
grid: 50px 1fr / 1fr auto 20%;
min-height: 80vh;
& > header {
header {
display: flex;
flex-direction: row-reverse;
background: transparent;
align-items: center;
justify-content: space-between;
padding: 0px 20px;
border-bottom: 1px solid $purple;
max-height: 90px;
background: $background;
grid-column: 1 / 3;
grid-row: 1 / 1;
button {
flex: 0 0 10em;
font-size: 1.25em;
margin: .5em 0em .5em 1em;
}
}
#edition-area {
margin: 0;
max-width: none;
max-height: 90vh;
overflow-y: auto;
}
#edition-area > * {
& > * {
min-height: 1em;
outline: none;
margin-left: 20%;
margin-bottom: 0.5em;
padding-right: 5%;
&:empty::before {
content: attr(data-placeholder);
display: none;
color: transparentize($black, 0.6);
cursor: text;
}
&:empty:not(:focus)::before {
display: inline;
}
}
#edition-area > h1 {
margin-top: 110px;
.placeholder {
color: transparentize($black, 0.6);
}
#editor-title, #editor-subtitle, #editor-main > * {
padding-left: 18px;
border-left: 2px solid transparent;
transition: border-left-color 0.1s ease-in;
&:hover {
border-left-color: transparentize($black, 0.6);
}
}
aside {
background: $gray;
margin: 0;
flex: 0 0 15%;
padding: 0 1em;
grid-row: 1 / 4;
& > * {
display: flex;
flex-direction: column;
}
label {
margin: 2em 0 .5em;
}
button {
font-size: 1.25em;
margin: 0;
}
}
}
body#editor {
footer {
margin-top: 0;
article {
max-width: none;
min-height: 80vh;
}
}
+4 -4
View File
@@ -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())
+2
View File
@@ -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>
+4 -1
View File
@@ -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>
})
+2 -3
View File
@@ -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>
+3 -3
View File
@@ -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>
}
+5 -35
View File
@@ -9,52 +9,22 @@
@use routes::posts::NewPostForm;
@use routes::*;
@(ctx: BaseContext, title: String, blog: Blog, editing: bool, form: &NewPostForm, is_draft: bool, article: Option<Post>, errors: ValidationErrors, medias: Vec<Media>, content_len: u64, api_token: String)
@(ctx: BaseContext, title: String, blog: Blog, editing: bool, form: &NewPostForm, is_draft: bool, article: Option<Post>, errors: ValidationErrors, medias: Vec<Media>, content_len: u64)
@:base(ctx, title.clone(), {}, {}, {
<script>
window.blog_id = @blog.id
window.api_token = '@api_token'
@if editing {
window.editing = true
window.post_id = @article.clone().unwrap().id
} else {
window.editing = false
}
</script>
<h1 id="plume-editor-title" dir="auto">@title</h1>
<div id="plume-editor" style="display: none;" dir="auto">
<header id="editor-toolbar">
<header>
<button id="publish" class="button">@i18n!(ctx.1, "Publish")</button>
<p id="char-count">@content_len</p>
<a href="#" id="close-editor">@i18n!(ctx.1, "Classic editor (any changes will be lost)")</a>
</header>
<article id="edition-area">
<h1 contenteditable id="editor-title" data-placeholder="@i18n!(ctx.1, "Type your title")">@form.title</h1>
<h2 contenteditable id="editor-subtitle" data-placeholder="@i18n!(ctx.1, "Type a subtitle or a summary")">@form.subtitle</h2>
<article contenteditable id="editor-main" data-placeholder="@i18n!(ctx.1, "Write your article here. You can use markdown.")"></article>
</article>
<aside id="plume-editor-aside" style="display: none;">
<div id="options-page">
<button id="publish" class="button">@i18n!(ctx.1, "Publish")</button>
@input!(ctx.1, tags (optional text), "Tags, separated by commas", form, errors.clone(), "")
@input!(ctx.1, license (optional text), "License", "Leave it empty to reserve all rights", form, errors.clone(), "")
@:image_select(ctx, "cover", i18n!(ctx.1, "Illustration"), true, medias.clone(), form.cover)
</div>
<div id="publish-page" style="display: none">
<a href="#" id="cancel-publish">@i18n!(ctx.1, "Cancel")</a>
<p>@i18n!(ctx.1, "You are about to publish this article in {}"; &blog.title)</p>
<button id="confirm-publish" class="button">@i18n!(ctx.1, "Confirm publication")</button>
<button id="save-draft" class="button secondary">@i18n!(ctx.1, "Save as draft")</button>
</div>
</aside>
</div>
@if let Some(article) = article {
<form id="plume-fallback-editor" class="new-post" method="post" action="@uri!(posts::update: blog = blog.actor_id, slug = &article.slug)" content-size="@content_len">
} else {
<form id="plume-fallback-editor" class="new-post" method="post" action="@uri!(posts::new: blog = blog.actor_id)" content-size="@content_len">
}
<h1 id="plume-editor-title" dir="auto">@title</h1>
@input!(ctx.1, title (text), "Title", form, errors.clone(), "required")
@input!(ctx.1, subtitle (optional text), "Subtitle", form, errors.clone(), "")