Compare commits

...

13 commits

Author SHA1 Message Date
Kitaiti Makoto 9bd8f5272f Define From<PreferredUsernameError> for Error 2023-01-15 08:57:08 +09:00
Kitaiti Makoto 39cd4f830d Remove anyhow from plume-common dependencies 2023-01-15 08:52:18 +09:00
Kitaiti Makoto cd9cb311c7 Define PreferredUsernameError and use it 2023-01-15 08:51:38 +09:00
Kitaiti Makoto 83ed168f9c Add thiserror to plume-common dependencies 2023-01-15 08:38:55 +09:00
Kitaiti Makoto 83c628d490 Remove heck from plume-common dependencies 2023-01-15 08:14:58 +09:00
Kitaiti Makoto badff3f3cb Remove unused make_fqn() 2023-01-15 08:14:58 +09:00
Kitaiti Makoto ba00e36884 use Fqn::make_local() instead of make_fqn() 2023-01-15 08:14:58 +09:00
Kitaiti Makoto 5ee84427bf Add functions to make FQN to Fqn 2023-01-15 08:14:58 +09:00
Kitaiti Makoto f203dddae5 Add heck to plume-models dependencies 2023-01-15 08:14:58 +09:00
Kitaiti Makoto ba1eac9482 Add test for blog title 2023-01-15 08:14:56 +09:00
Kitaiti Makoto 3dad83b179 Set up Rocket for testing environment 2023-01-15 06:58:01 +09:00
Kitaiti Makoto 4eab51b159 Make Config a argument for init_rocket() 2023-01-15 06:20:54 +09:00
Kitaiti Makoto abf0b28fd4 Move set up from init_rocket() to main() 2023-01-15 06:07:24 +09:00
11 changed files with 155 additions and 75 deletions

10
Cargo.lock generated
View file

@ -166,12 +166,6 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "anyhow"
version = "1.0.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61"
[[package]]
name = "arc-swap"
version = "1.6.0"
@ -3341,7 +3335,6 @@ version = "0.7.2"
dependencies = [
"activitystreams",
"activitystreams-ext",
"anyhow",
"array_tool",
"askama_escape",
"assert-json-diff",
@ -3349,7 +3342,6 @@ dependencies = [
"chrono",
"flume",
"futures 0.3.25",
"heck",
"hex",
"once_cell",
"openssl",
@ -3362,6 +3354,7 @@ dependencies = [
"serde_json",
"shrinkwraprs",
"syntect",
"thiserror",
"tokio 1.24.1",
"tracing",
"url 2.3.1",
@ -3407,6 +3400,7 @@ dependencies = [
"diesel_migrations",
"glob",
"guid-create",
"heck",
"itertools 0.10.5",
"lazy_static",
"ldap3",

View file

@ -25,8 +25,7 @@ url = "2.2.2"
flume = "0.10.13"
tokio = { version = "1.19.2", features = ["full"] }
futures = "0.3.25"
heck = "0.4.0"
anyhow = "1.0.68"
thiserror = "1.0.38"
[dependencies.chrono]
features = ["serde"]

View file

@ -1,4 +1,3 @@
use ::anyhow::{self, anyhow};
use activitystreams::{
actor::{ApActor, Group, Person},
base::{AnyBase, Base, Extends},
@ -247,16 +246,24 @@ pub trait IntoId {
fn into_id(self) -> Id;
}
#[derive(thiserror::Error, Debug)]
pub enum PreferredUsernameError {
#[error("preferredUsername must be longer than 2 characters")]
TooShort,
#[error("Invaliad character at {character:?}: {position:?}")]
InvalidCharacter { character: char, position: usize },
}
#[repr(transparent)]
#[derive(Shrinkwrap, PartialEq, Eq, Clone, Serialize, Deserialize, Debug)]
pub struct PreferredUsername(String);
// Mastodon allows only /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i for `preferredUsername`
impl PreferredUsername {
fn validate(name: &str) -> anyhow::Result<()> {
fn validate(name: &str) -> std::result::Result<(), PreferredUsernameError> {
let len = name.len();
if len < 3 {
return Err(anyhow!("FQN must be longer than 2 characters"));
return Err(PreferredUsernameError::TooShort);
}
match name.chars().enumerate().find(|(pos, c)| {
if pos == &0 || pos == &(len - 1) {
@ -268,7 +275,10 @@ impl PreferredUsername {
}
}
}) {
Some((pos, c)) => Err(anyhow!("Invaliad character at {}: {}", pos, c)),
Some((pos, c)) => Err(PreferredUsernameError::InvalidCharacter {
character: c,
position: pos,
}),
None => Ok(()),
}
}
@ -280,7 +290,7 @@ impl PreferredUsername {
Self(name)
}
pub fn new(name: String) -> anyhow::Result<Self> {
pub fn new(name: String) -> std::result::Result<Self, PreferredUsernameError> {
Self::validate(&name).map(|_| unsafe { Self::new_unchecked(name) })
}
}
@ -292,7 +302,7 @@ impl fmt::Display for PreferredUsername {
}
impl TryFrom<String> for PreferredUsername {
type Error = anyhow::Error;
type Error = PreferredUsernameError;
fn try_from(name: String) -> std::result::Result<Self, Self::Error> {
Self::new(name)
@ -300,7 +310,7 @@ impl TryFrom<String> for PreferredUsername {
}
impl TryFrom<&str> for PreferredUsername {
type Error = anyhow::Error;
type Error = PreferredUsernameError;
fn try_from(name: &str) -> std::result::Result<Self, Self::Error> {
Self::new(name.to_owned())
@ -320,7 +330,7 @@ impl AsRef<str> for PreferredUsername {
}
impl FromStr for PreferredUsername {
type Err = anyhow::Error;
type Err = PreferredUsernameError;
fn from_str(name: &str) -> std::result::Result<Self, Self::Err> {
name.try_into()

View file

@ -1,5 +1,4 @@
use activitystreams::iri_string::percent_encode::PercentEncodedForIri;
use heck::ToUpperCamelCase;
use openssl::rand::rand_bytes;
use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag};
use regex_syntax::is_word_character;
@ -25,13 +24,6 @@ pub fn iri_percent_encode_seg(segment: &str) -> String {
PercentEncodedForIri::from_path_segment(segment).to_string()
}
pub fn make_fqn(name: &str) -> String {
name.to_upper_camel_case()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
#[derive(Debug)]
enum State {
Mention,

View file

@ -35,6 +35,7 @@ once_cell = "1.12.0"
lettre = "0.9.6"
native-tls = "0.2.10"
activitystreams = "=0.7.0-alpha.20"
heck = "0.4.0"
[dependencies.chrono]
features = ["serde"]

View file

@ -25,7 +25,7 @@ use plume_common::{
sign, ActivityStream, ApSignature, CustomGroup, Id, IntoId, PublicKey, Source,
SourceProperty, ToAsString, ToAsUri,
},
utils::{iri_percent_encode_seg, make_fqn},
utils::iri_percent_encode_seg,
};
use webfinger::*;
@ -89,11 +89,10 @@ impl Blog {
if inserted.fqn.to_string().is_empty() {
// This might not enough for some titles such as all-Japanese title,
// but better than doing nothing.
let username = make_fqn(&inserted.title);
if instance.local {
inserted.fqn = Fqn::new_local(username)?;
inserted.fqn = Fqn::make_local(&inserted.title)?;
} else {
inserted.fqn = Fqn::new_remote(username, instance.public_domain)?;
inserted.fqn = Fqn::make_remote(&inserted.title, instance.public_domain)?;
}
}
@ -555,7 +554,7 @@ impl NewBlog {
let (pub_key, priv_key) = sign::gen_keypair();
Ok(NewBlog {
actor_id,
fqn: Fqn::new_local(make_fqn(&title))?,
fqn: Fqn::make_local(&title)?,
title,
summary,
instance_id,

View file

@ -41,7 +41,7 @@ pub enum InvalidRocketConfig {
SecretKey,
}
fn get_rocket_config() -> Result<RocketConfig, InvalidRocketConfig> {
pub fn get_rocket_config() -> Result<RocketConfig, InvalidRocketConfig> {
let mut c = RocketConfig::active().map_err(|_| InvalidRocketConfig::Env)?;
let address = var("ROCKET_ADDRESS").unwrap_or_else(|_| "localhost".to_owned());

View file

@ -20,9 +20,11 @@ use activitystreams::iri_string;
use diesel::backend::Backend;
use diesel::sql_types::Text;
use diesel::types::{FromSql, ToSql};
use heck::ToUpperCamelCase;
pub use lettre;
pub use lettre::smtp;
use once_cell::sync::Lazy;
use plume_common::activity_pub::PreferredUsernameError;
use plume_common::activity_pub::{inbox::InboxError, request, sign, PreferredUsername};
use posts::PostEvent;
use riker::actors::{channel, ActorSystem, ChannelRef, SystemBuilder};
@ -174,6 +176,13 @@ impl From<request::Error> for Error {
}
}
impl From<PreferredUsernameError> for Error {
fn from(err: PreferredUsernameError) -> Error {
tracing::trace!("{:?}", err);
Error::InvalidValue
}
}
pub type Result<T> = std::result::Result<T, Error>;
/// Adds a function to a model, that returns the first
@ -305,7 +314,7 @@ macro_rules! last {
}
mod config;
pub use config::CONFIG;
pub use config::{get_rocket_config, Config, SearchTokenizerConfig, CONFIG};
pub fn ap_url(url: &str) -> String {
format!("https://{}", url)
@ -348,16 +357,31 @@ pub enum Fqn {
impl Fqn {
pub fn new_local(username: String) -> Result<Self> {
Ok(Self::Local(
PreferredUsername::new(username).map_err(|_| Error::InvalidValue)?,
PreferredUsername::new(username)?,
))
}
pub fn new_remote(username: String, domain: String) -> Result<Self> {
Ok(Self::Remote(
PreferredUsername::new(username).map_err(|_| Error::InvalidValue)?,
PreferredUsername::new(username)?,
domain,
))
}
pub fn make_local_string(base: &str) -> String {
base.to_upper_camel_case()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
pub fn make_local(base: &str) -> Result<Self> {
Self::new_local(Self::make_local_string(base))
}
pub fn make_remote(base: &str, domain: String) -> Result<Self> {
Self::new_remote(Self::make_local_string(base), domain)
}
}
impl From<&Fqn> for String {

View file

@ -91,7 +91,7 @@ mod tests {
Connection as Conn, CONFIG,
};
use diesel::r2d2::ConnectionManager;
use plume_common::utils::{make_fqn, random_hex};
use plume_common::utils::random_hex;
use std::str::FromStr;
use std::sync::Arc;
use std::thread::sleep;
@ -198,7 +198,7 @@ mod tests {
ap_url: random_hex(),
inbox_url: random_hex(),
outbox_url: random_hex(),
fqn: Fqn::new_local(make_fqn(&title)).unwrap(),
fqn: Fqn::make_local(&title).unwrap(),
title,
summary: Default::default(),
summary_html: Default::default(),

View file

@ -16,7 +16,7 @@ use plume_models::{
migrations::IMPORTED_MIGRATIONS,
remote_fetch_actor::RemoteFetchActor,
search::{actor::SearchActor, Searcher as UnmanagedSearcher},
Connection, CONFIG,
Config, Connection, CONFIG,
};
use rocket_csrf::CsrfFairingBuilder;
use scheduled_thread_pool::ScheduledThreadPool;
@ -47,12 +47,12 @@ include!(concat!(env!("OUT_DIR"), "/templates.rs"));
compile_i18n!();
/// Initializes a database pool.
fn init_pool() -> Option<DbPool> {
fn init_pool(config: &Config) -> Option<DbPool> {
let manager = ConnectionManager::<Connection>::new(CONFIG.database_url.as_str());
let mut builder = DbPool::builder()
.connection_customizer(Box::new(PragmaForeignKey))
.min_idle(CONFIG.db_min_idle);
if let Some(max_size) = CONFIG.db_max_size {
.min_idle(config.db_min_idle);
if let Some(max_size) = config.db_max_size {
builder = builder.max_size(max_size);
};
let pool = builder.build(manager).ok()?;
@ -63,28 +63,8 @@ fn init_pool() -> Option<DbPool> {
Some(pool)
}
pub(crate) fn init_rocket() -> rocket::Rocket {
match dotenv::dotenv() {
Ok(path) => eprintln!("Configuration read from {}", path.display()),
Err(ref e) if e.not_found() => eprintln!("no .env was found"),
e => e.map(|_| ()).unwrap(),
}
tracing_subscriber::fmt::init();
App::new("Plume")
.bin_name("plume")
.version(env!("CARGO_PKG_VERSION"))
.about("Plume backend server")
.after_help(
r#"
The plume command should be run inside the directory
containing the `.env` configuration file and `static` directory.
See https://docs.joinplu.me/installation/config
and https://docs.joinplu.me/installation/init for more info.
"#,
)
.get_matches();
let dbpool = init_pool().expect("main: database pool initialization error");
pub(crate) fn init_rocket(config: &Config) -> rocket::Rocket {
let dbpool = init_pool(config).expect("main: database pool initialization error");
if IMPORTED_MIGRATIONS
.is_pending(&dbpool.get().unwrap())
.unwrap_or(true)
@ -104,8 +84,8 @@ Then try to restart Plume.
let workpool = ScheduledThreadPool::with_name("worker {}", num_cpus::get());
// we want a fast exit here, so
let searcher = Arc::new(UnmanagedSearcher::open_or_recreate(
&CONFIG.search_index,
&CONFIG.search_tokenizers,
&config.search_index,
&config.search_tokenizers,
));
RemoteFetchActor::init(dbpool.clone());
SearchActor::init(searcher.clone(), dbpool.clone());
@ -125,12 +105,12 @@ Then try to restart Plume.
.expect("Error setting Ctrl-c handler");
let mail = mail::init();
if mail.is_none() && CONFIG.rocket.as_ref().unwrap().environment.is_prod() {
if mail.is_none() && config.rocket.as_ref().unwrap().environment.is_prod() {
warn!("Warning: the email server is not configured (or not completely).");
warn!("Please refer to the documentation to see how to configure it.");
}
rocket::custom(CONFIG.rocket.clone().unwrap())
rocket::custom(config.rocket.clone().unwrap())
.mount(
"/",
routes![
@ -280,7 +260,28 @@ Then try to restart Plume.
}
fn main() {
let rocket = init_rocket();
match dotenv::dotenv() {
Ok(path) => eprintln!("Configuration read from {}", path.display()),
Err(ref e) if e.not_found() => eprintln!("no .env was found"),
e => e.map(|_| ()).unwrap(),
}
tracing_subscriber::fmt::init();
App::new("Plume")
.bin_name("plume")
.version(env!("CARGO_PKG_VERSION"))
.about("Plume backend server")
.after_help(
r#"
The plume command should be run inside the directory
containing the `.env` configuration file and `static` directory.
See https://docs.joinplu.me/installation/config
and https://docs.joinplu.me/installation/init for more info.
"#,
)
.get_matches();
let rocket = init_rocket(&CONFIG);
#[cfg(feature = "test")]
let rocket = rocket.mount("/test", routes![test_routes::health,]);

View file

@ -382,29 +382,53 @@ pub fn atom_feed(name: String, conn: DbConn) -> Option<Content<String>> {
#[cfg(test)]
mod tests {
use std::env::var;
use super::valid_slug;
use crate::init_rocket;
use diesel::Connection;
use plume_common::utils::{random_hex, make_fqn};
use plume_common::utils::random_hex;
use plume_models::{
blog_authors::{BlogAuthor, NewBlogAuthor},
blogs::{Blog, NewBlog},
db_conn::{DbConn, DbPool},
get_rocket_config,
instance::{Instance, NewInstance},
post_authors::{NewPostAuthor, PostAuthor},
posts::{NewPost, Post},
safe_string::SafeString,
users::{NewUser, User, AUTH_COOKIE}, Fqn,
search::Searcher,
users::{NewUser, User, AUTH_COOKIE},
Config, Fqn, SearchTokenizerConfig,
};
use rocket::{
http::{Cookie, Cookies, SameSite},
http::{ContentType, Cookie, Cookies, SameSite, Status},
local::{Client, LocalRequest},
};
type Models = (Instance, User, Blog, Post);
fn setup() -> (Client, Models) {
let rocket = init_rocket();
dotenv::from_path(".env.test").unwrap();
let config = Config {
base_url: var("BASE_URL").unwrap(),
db_name: "plume",
db_max_size: None,
db_min_idle: None,
signup: Default::default(),
database_url: var("DATABASE_URL").unwrap(),
search_index: format!("/tmp/plume_test-{}", random_hex()),
search_tokenizers: SearchTokenizerConfig::init(),
rocket: get_rocket_config(),
logo: Default::default(),
default_theme: Default::default(),
media_directory: format!("/tmp/plume_test-{}", random_hex()),
mail: None,
ldap: None,
proxy: None,
};
let _ = Searcher::create(&config.search_index, &config.search_tokenizers).unwrap();
let rocket = init_rocket(&config);
let client = Client::new(rocket).expect("valid rocket instance");
let dbpool = client.rocket().state::<DbPool>().unwrap();
let conn = &DbConn(dbpool.get().unwrap());
@ -413,7 +437,9 @@ mod tests {
}
fn teardown((client, (instance, user, _blog, _post)): (&Client, Models)) {
let dbpool = client.rocket().state::<DbPool>().unwrap();
let rocket = client.rocket();
let dbpool = rocket.state::<DbPool>().unwrap();
let conn = &DbConn(dbpool.get().unwrap());
user.delete(conn).unwrap();
@ -494,13 +520,14 @@ mod tests {
inbox_url: random_hex(),
outbox_url: random_hex(),
followers_endpoint: random_hex(),
fqn: random_hex(),
..Default::default()
};
let user = User::insert(conn, user).unwrap();
let title = random_hex();
let blog = NewBlog {
instance_id: instance.id,
fqn: Fqn::new_local(make_fqn(&title)).unwrap(),
fqn: Fqn::make_local(&title).unwrap(),
title,
actor_id: random_hex(),
ap_url: random_hex(),
@ -513,7 +540,6 @@ mod tests {
icon_id: Default::default(),
banner_id: Default::default(),
theme: Default::default(),
};
let blog = Blog::insert(conn, blog).unwrap();
BlogAuthor::insert(
@ -569,4 +595,38 @@ mod tests {
assert!(valid_slug("Blog Title").is_ok());
assert!(valid_slug("ブログ タイトル").is_ok());
}
#[test]
fn create_blog_with_same_title_twice() {
let (client, (instance, user, blog, post)) = setup();
let new_path = uri!(super::new).to_string();
let request = client.get(new_path);
login(&request, &user);
let mut response = request.dispatch();
let body = response.body_string().unwrap();
let prefix = r#"<input type="hidden" name="csrf-token" value=""#;
let pos = body.find(prefix).unwrap();
let token = body[pos + prefix.len()..pos + prefix.len() + 123].to_string();
let create_path = uri!(super::create).to_string();
let response = client
.post(&create_path)
.body(format!("title=My%20Blog&csrf-token={}", &token))
.header(ContentType::Form)
.dispatch();
let first_attempt = response;
let response = client
.post(&create_path)
.body(format!("title=My%20Blog&csrf-token={}", &token))
.header(ContentType::Form)
.dispatch();
let second_attempt = response;
teardown((&client, (instance, user, blog, post)));
assert_eq!(first_attempt.status(), Status::SeeOther);
assert_eq!(second_attempt.status(), Status::SeeOther);
}
}