Compare commits

..

38 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
Kitaiti Makoto 115b5b31a4 Follow Blog's API change 2023-01-14 03:40:25 +09:00
Kitaiti Makoto 5a03fd7340 Make Blog::fqn Fqn 2023-01-14 03:23:45 +09:00
Kitaiti Makoto e75449410f Add test for Fqn 2023-01-14 03:22:42 +09:00
Kitaiti Makoto c9bb31b8f5 Format 2023-01-14 03:22:21 +09:00
Kitaiti Makoto 0c2eaf0f1b Define Fqn struct 2023-01-14 03:22:18 +09:00
Kitaiti Makoto 71824aa524 Define PreferredUsername struct 2023-01-13 20:19:36 +09:00
Kitaiti Makoto fc848a8d53 Allow ASCII and numeric only for fqn 2023-01-10 23:49:37 +09:00
Kitaiti Makoto 53cdd8198b Add anyhow to dependencies 2023-01-09 19:42:54 +09:00
Kitaiti Makoto 08f4dac3d3 Set blog title in test fixture 2023-01-09 19:14:20 +09:00
Kitaiti Makoto 18a9ed5504 Extract setup() and teardown() from blogs test 2023-01-09 17:46:54 +09:00
Kitaiti Makoto 631359c3f7 Create local instance in create_models() function 2023-01-09 17:05:55 +09:00
Kitaiti Makoto 3111fa0735 Run migration 2023-01-09 16:19:30 +09:00
Kitaiti Makoto 890c9a0da4 Add migration for SQLite 2023-01-09 16:19:05 +09:00
Kitaiti Makoto 22b03710be Implement migration for PostgreSQL 2023-01-09 16:13:12 +09:00
Kitaiti Makoto e3609f7863 Generate migration to add unique constraint to ActivityPub related fields
% diesel migration generate add_unique_constraint_to_activity_pub_related_fields
2023-01-09 16:10:23 +09:00
Kitaiti Makoto 0714d2d010 Use User.fqn for user activity for consistency with blog 2023-01-09 15:39:58 +09:00
Kitaiti Makoto 5bd084eff7 Make test data follow blog test data change 2023-01-09 07:29:01 +09:00
Kitaiti Makoto f369fa9b25 Fix blog title conversion for ActivityPub 2023-01-09 07:22:17 +09:00
Kitaiti Makoto 8afcc1511e Define make_fqn() 2023-01-09 06:55:49 +09:00
Kitaiti Makoto ce89faef84 Install heck 2023-01-09 06:54:38 +09:00
Kitaiti Makoto e18b6e78f2 Add heck to plume-common's dependencies 2023-01-09 06:54:30 +09:00
Kitaiti Makoto 31e817385d Percent-encode slug in Blog::slug() 2023-01-09 06:18:58 +09:00
Kitaiti Makoto af7ed450e2 Fix iri_percent_encode_seg() to encode some missing characters 2023-01-09 06:15:29 +09:00
Kitaiti Makoto 55a5a64b1a Fix valid slug spec 2023-01-09 06:15:29 +09:00
Kitaiti Makoto 08b7d100fd Change blog title specification 2023-01-09 06:15:23 +09:00
38 changed files with 713 additions and 934 deletions
Generated
+30 -23
View File
@@ -219,9 +219,9 @@ dependencies = [
[[package]] [[package]]
name = "atom_syndication" name = "atom_syndication"
version = "0.12.0" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91a85f2ee28cbd1ecf91288460f6dc74661fd99b4e9a559836a667ccf63aa38c" checksum = "21fb6a0b39c6517edafe46f8137e53c51742425a4dae1c73ee12264a37ad7541"
dependencies = [ dependencies = [
"chrono", "chrono",
"derive_builder", "derive_builder",
@@ -947,9 +947,9 @@ dependencies = [
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.14.2" version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0dd3cd20dc6b5a876612a6e5accfe7f3dd883db6d07acfbf14c128f61550dfa" checksum = "5f2c43f534ea4b0b049015d00269734195e6d3f0f6635cb692251aca6f9f8b3c"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"darling_macro", "darling_macro",
@@ -957,9 +957,9 @@ dependencies = [
[[package]] [[package]]
name = "darling_core" name = "darling_core"
version = "0.14.2" version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a784d2ccaf7c98501746bf0be29b2022ba41fd62a2e622af997a03e9f972859f" checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36"
dependencies = [ dependencies = [
"fnv", "fnv",
"ident_case", "ident_case",
@@ -971,9 +971,9 @@ dependencies = [
[[package]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.14.2" version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7618812407e9402654622dd402b0a89dff9ba93badd6540781526117b92aab7e" checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote 1.0.23", "quote 1.0.23",
@@ -1024,18 +1024,18 @@ dependencies = [
[[package]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.12.0" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" checksum = "d13202debe11181040ae9063d739fa32cfcaaebe2275fe387703460ae2365b30"
dependencies = [ dependencies = [
"derive_builder_macro", "derive_builder_macro",
] ]
[[package]] [[package]]
name = "derive_builder_core" name = "derive_builder_core"
version = "0.12.0" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" checksum = "66e616858f6187ed828df7c64a6d71720d83767a7f19740b2d1b6fe6327b36e5"
dependencies = [ dependencies = [
"darling", "darling",
"proc-macro2 1.0.49", "proc-macro2 1.0.49",
@@ -1045,9 +1045,9 @@ dependencies = [
[[package]] [[package]]
name = "derive_builder_macro" name = "derive_builder_macro"
version = "0.12.0" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" checksum = "58a94ace95092c5acb1e97a7e846b310cfbd499652f72297da7493f618a98d73"
dependencies = [ dependencies = [
"derive_builder_core", "derive_builder_core",
"syn 1.0.107", "syn 1.0.107",
@@ -1632,9 +1632,9 @@ checksum = "dec7af912d60cdbd3677c1af9352ebae6fb8394d165568a2234df0fa00f87793"
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.1" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
[[package]] [[package]]
name = "guid" name = "guid"
@@ -1763,6 +1763,12 @@ dependencies = [
"ahash 0.7.6", "ahash 0.7.6",
] ]
[[package]]
name = "heck"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.1.19" version = "0.1.19"
@@ -3348,6 +3354,7 @@ dependencies = [
"serde_json", "serde_json",
"shrinkwraprs", "shrinkwraprs",
"syntect", "syntect",
"thiserror",
"tokio 1.24.1", "tokio 1.24.1",
"tracing", "tracing",
"url 2.3.1", "url 2.3.1",
@@ -3393,6 +3400,7 @@ dependencies = [
"diesel_migrations", "diesel_migrations",
"glob", "glob",
"guid-create", "guid-create",
"heck",
"itertools 0.10.5", "itertools 0.10.5",
"lazy_static", "lazy_static",
"ldap3", "ldap3",
@@ -3554,9 +3562,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.27.1" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffc053f057dd768a56f62cd7e434c42c831d296968997e9ac1f76ea7c2d14c41" checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"memchr", "memchr",
@@ -4124,11 +4132,10 @@ dependencies = [
[[package]] [[package]]
name = "rsass" name = "rsass"
version = "0.26.0" version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "665387d3be91521de331a04cc7118c8c129ffe43c5f4e8a61e78990850046b5a" checksum = "0ab003a9ff922b4d7b1f5f383bc54645887ad278f534dfc65e071096fcb30023"
dependencies = [ dependencies = [
"arc-swap",
"fastrand", "fastrand",
"lazy_static", "lazy_static",
"nom 7.1.2", "nom 7.1.2",
@@ -4142,9 +4149,9 @@ dependencies = [
[[package]] [[package]]
name = "ructe" name = "ructe"
version = "0.15.0" version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85517cd381cf0c34694881d8aaf173107c6af7670e66cec18d7a1a8bfce3b758" checksum = "ef279e568db5dad3a1867112fedc63e2f0567991e82beec01c04ade6bd53c08a"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"bytecount", "bytecount",
+3 -3
View File
@@ -6,7 +6,7 @@ repository = "https://github.com/Plume-org/Plume"
edition = "2018" edition = "2018"
[dependencies] [dependencies]
atom_syndication = "0.12.0" atom_syndication = "0.11.0"
clap = "2.33" clap = "2.33"
dotenv = "0.15.0" dotenv = "0.15.0"
gettext = "0.4.0" gettext = "0.4.0"
@@ -64,8 +64,8 @@ git = "https://git.joinplu.me/plume/rocket_csrf"
rev = "0.1.2" rev = "0.1.2"
[build-dependencies] [build-dependencies]
ructe = "0.15.0" ructe = "0.14.0"
rsass = "0.26" rsass = "0.25"
[features] [features]
default = ["postgres"] default = ["postgres"]
@@ -0,0 +1,3 @@
ALTER TABLE users DROP CONSTRAINT users_fqn;
ALTER TABLE blogs DROP CONSTRAINT blogs_actor_id;
ALTER TABLE blogs DROP CONSTRAINT blogs_fqn;
@@ -0,0 +1,3 @@
ALTER TABLE users ADD CONSTRAINT users_fqn UNIQUE (fqn);
ALTER TABLE blogs ADD CONSTRAINT blogs_actor_id UNIQUE (actor_id);
ALTER TABLE blogs ADD CONSTRAINT blogs_fqn UNIQUE (fqn);
@@ -0,0 +1,3 @@
DROP INDEX users_fqn;
DROP INDEX blogs_actor_id;
DROP INDEX blogs_fqn;
@@ -0,0 +1,3 @@
CREATE UNIQUE INDEX users_fqn ON users (fqn);
CREATE UNIQUE INDEX blogs_actor_id ON blogs (actor_id);
CREATE UNIQUE INDEX blogs_fqn ON blogs (fqn);
-262
View File
@@ -1,262 +0,0 @@
use clap::{App, Arg, ArgMatches, SubCommand};
use plume_models::{blogs::Blog, instance::Instance, lists::*, users::User, Connection};
pub fn command<'a, 'b>() -> App<'a, 'b> {
SubCommand::with_name("lists")
.about("Manage lists")
.subcommand(
SubCommand::with_name("new")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of this list"),
)
.arg(
Arg::with_name("type")
.short("t")
.long("type")
.takes_value(true)
.help(
r#"The type of this list (one of "user", "blog", "word" or "prefix")"#,
),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this list is for. Empty for an instance list"),
)
.about("Create a new list"),
)
.subcommand(
SubCommand::with_name("delete")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the list to delete"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this list was for. Empty for instance list"),
)
.arg(
Arg::with_name("yes")
.short("y")
.long("yes")
.help("Confirm the deletion"),
)
.about("Delete a list"),
)
.subcommand(
SubCommand::with_name("add")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the list to add an element to"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this list is for. Empty for instance list"),
)
.arg(
Arg::with_name("value")
.short("v")
.long("value")
.takes_value(true)
.help("The value to add"),
)
.about("Add element to a list"),
)
.subcommand(
SubCommand::with_name("rm")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the list to remove an element from"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this list is for. Empty for instance list"),
)
.arg(
Arg::with_name("value")
.short("v")
.long("value")
.takes_value(true)
.help("The value to remove"),
)
.about("Remove element from list"),
)
}
pub fn run<'a>(args: &ArgMatches<'a>, conn: &Connection) {
let conn = conn;
match args.subcommand() {
("new", Some(x)) => new(x, conn),
("delete", Some(x)) => delete(x, conn),
("add", Some(x)) => add(x, conn),
("rm", Some(x)) => rm(x, conn),
("", None) => command().print_help().unwrap(),
_ => println!("Unknown subcommand"),
}
}
fn get_list_identifier(args: &ArgMatches<'_>) -> (String, Option<String>) {
let name = args
.value_of("name")
.map(String::from)
.expect("No name provided for the list");
let user = args.value_of("user").map(String::from);
(name, user)
}
fn get_list_type(args: &ArgMatches<'_>) -> ListType {
let typ = args
.value_of("type")
.map(String::from)
.expect("No name type for the list");
match typ.as_str() {
"user" => ListType::User,
"blog" => ListType::Blog,
"word" => ListType::Word,
"prefix" => ListType::Prefix,
_ => panic!("Invalid list type: {}", typ),
}
}
fn get_value(args: &ArgMatches<'_>) -> String {
args.value_of("value")
.map(String::from)
.expect("No query provided")
}
fn resolve_user(username: &str, conn: &Connection) -> User {
let instance = Instance::get_local_uncached(conn).expect("Failed to load local instance");
User::find_by_name(conn, username, instance.id).expect("User not found")
}
fn new(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_list_identifier(args);
let typ = get_list_type(args);
let user = user.map(|user| resolve_user(&user, conn));
List::new(conn, &name, user.as_ref(), typ).expect("failed to create list");
}
fn delete(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_list_identifier(args);
if !args.is_present("yes") {
panic!("Warning, this operation is destructive. Add --yes to confirm you want to do it.")
}
let user = user.map(|user| resolve_user(&user, conn));
let list =
List::find_for_user_by_name(conn, user.map(|u| u.id), &name).expect("list not found");
list.delete(conn).expect("Failed to update list");
}
fn add(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_list_identifier(args);
let value = get_value(args);
let user = user.map(|user| resolve_user(&user, conn));
let list =
List::find_for_user_by_name(conn, user.map(|u| u.id), &name).expect("list not found");
match list.kind() {
ListType::Blog => {
let blog_id = Blog::find_by_fqn(conn, &value).expect("unknown blog").id;
if !list.contains_blog(conn, blog_id).unwrap() {
list.add_blogs(conn, &[blog_id]).unwrap();
}
}
ListType::User => {
let user_id = User::find_by_fqn(conn, &value).expect("unknown user").id;
if !list.contains_user(conn, user_id).unwrap() {
list.add_users(conn, &[user_id]).unwrap();
}
}
ListType::Word => {
if !list.contains_word(conn, &value).unwrap() {
list.add_words(conn, &[&value]).unwrap();
}
}
ListType::Prefix => {
if !list.contains_prefix(conn, &value).unwrap() {
list.add_prefixes(conn, &[&value]).unwrap();
}
}
}
}
fn rm(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_list_identifier(args);
let value = get_value(args);
let user = user.map(|user| resolve_user(&user, conn));
let list =
List::find_for_user_by_name(conn, user.map(|u| u.id), &name).expect("list not found");
match list.kind() {
ListType::Blog => {
let blog_id = Blog::find_by_fqn(conn, &value).expect("unknown blog").id;
let mut blogs = list.list_blogs(conn).unwrap();
if let Some(index) = blogs.iter().position(|b| b.id == blog_id) {
blogs.swap_remove(index);
let blogs = blogs.iter().map(|b| b.id).collect::<Vec<_>>();
list.set_blogs(conn, &blogs).unwrap();
}
}
ListType::User => {
let user_id = User::find_by_fqn(conn, &value).expect("unknown user").id;
let mut users = list.list_users(conn).unwrap();
if let Some(index) = users.iter().position(|u| u.id == user_id) {
users.swap_remove(index);
let users = users.iter().map(|u| u.id).collect::<Vec<_>>();
list.set_users(conn, &users).unwrap();
}
}
ListType::Word => {
let mut words = list.list_words(conn).unwrap();
if let Some(index) = words.iter().position(|w| *w == value) {
words.swap_remove(index);
let words = words.iter().map(String::as_str).collect::<Vec<_>>();
list.set_words(conn, &words).unwrap();
}
}
ListType::Prefix => {
let mut prefixes = list.list_prefixes(conn).unwrap();
if let Some(index) = prefixes.iter().position(|p| *p == value) {
prefixes.swap_remove(index);
let prefixes = prefixes.iter().map(String::as_str).collect::<Vec<_>>();
list.set_prefixes(conn, &prefixes).unwrap();
}
}
}
}
-8
View File
@@ -4,10 +4,8 @@ use plume_models::{instance::Instance, Connection as Conn, CONFIG};
use std::io::{self, prelude::*}; use std::io::{self, prelude::*};
mod instance; mod instance;
mod list;
mod migration; mod migration;
mod search; mod search;
mod timeline;
mod users; mod users;
fn main() { fn main() {
@@ -18,8 +16,6 @@ fn main() {
.subcommand(instance::command()) .subcommand(instance::command())
.subcommand(migration::command()) .subcommand(migration::command())
.subcommand(search::command()) .subcommand(search::command())
.subcommand(timeline::command())
.subcommand(list::command())
.subcommand(users::command()); .subcommand(users::command());
let matches = app.clone().get_matches(); let matches = app.clone().get_matches();
@@ -41,10 +37,6 @@ fn main() {
("search", Some(args)) => { ("search", Some(args)) => {
search::run(args, &conn.expect("Couldn't connect to the database.")) search::run(args, &conn.expect("Couldn't connect to the database."))
} }
("timeline", Some(args)) => {
timeline::run(args, &conn.expect("Couldn't connect to the database."))
}
("lists", Some(args)) => list::run(args, &conn.expect("Couldn't connect to the database.")),
("users", Some(args)) => { ("users", Some(args)) => {
users::run(args, &conn.expect("Couldn't connect to the database.")) users::run(args, &conn.expect("Couldn't connect to the database."))
} }
-257
View File
@@ -1,257 +0,0 @@
use clap::{App, Arg, ArgMatches, SubCommand};
use plume_models::{instance::Instance, posts::Post, timeline::*, users::*, Connection};
pub fn command<'a, 'b>() -> App<'a, 'b> {
SubCommand::with_name("timeline")
.about("Manage public timeline")
.subcommand(
SubCommand::with_name("new")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of this timeline"),
)
.arg(
Arg::with_name("query")
.short("q")
.long("query")
.takes_value(true)
.help("The query posts in this timelines have to match"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help(
"Username of whom this timeline is for. Empty for an instance timeline",
),
)
.arg(
Arg::with_name("preload-count")
.short("p")
.long("preload-count")
.takes_value(true)
.help("Number of posts to try to preload in this timeline at its creation"),
)
.about("Create a new timeline"),
)
.subcommand(
SubCommand::with_name("delete")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the timeline to delete"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help(
"Username of whom this timeline was for. Empty for instance timeline",
),
)
.arg(
Arg::with_name("yes")
.short("y")
.long("yes")
.help("Confirm the deletion"),
)
.about("Delete a timeline"),
)
.subcommand(
SubCommand::with_name("edit")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the timeline to edit"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this timeline is for. Empty for instance timeline"),
)
.arg(
Arg::with_name("query")
.short("q")
.long("query")
.takes_value(true)
.help("The query posts in this timelines have to match"),
)
.about("Edit the query of a timeline"),
)
.subcommand(
SubCommand::with_name("repopulate")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the timeline to repopulate"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help(
"Username of whom this timeline was for. Empty for instance timeline",
),
)
.arg(
Arg::with_name("preload-count")
.short("p")
.long("preload-count")
.takes_value(true)
.help("Number of posts to try to preload in this timeline at its creation"),
)
.about("Repopulate a timeline. Run this after modifying a list the timeline depends on."),
)
}
pub fn run<'a>(args: &ArgMatches<'a>, conn: &Connection) {
let conn = conn;
match args.subcommand() {
("new", Some(x)) => new(x, conn),
("edit", Some(x)) => edit(x, conn),
("delete", Some(x)) => delete(x, conn),
("repopulate", Some(x)) => repopulate(x, conn),
("", None) => command().print_help().unwrap(),
_ => println!("Unknown subcommand"),
}
}
fn get_timeline_identifier(args: &ArgMatches<'_>) -> (String, Option<String>) {
let name = args
.value_of("name")
.map(String::from)
.expect("No name provided for the timeline");
let user = args.value_of("user").map(String::from);
(name, user)
}
fn get_query(args: &ArgMatches<'_>) -> String {
let query = args
.value_of("query")
.map(String::from)
.expect("No query provided");
match TimelineQuery::parse(&query) {
Ok(_) => (),
Err(QueryError::SyntaxError(start, end, message)) => panic!(
"Query parsing error between {} and {}: {}",
start, end, message
),
Err(QueryError::UnexpectedEndOfQuery) => {
panic!("Query parsing error: unexpected end of query")
}
Err(QueryError::RuntimeError(message)) => panic!("Query parsing error: {}", message),
}
query
}
fn get_preload_count(args: &ArgMatches<'_>) -> usize {
args.value_of("preload-count")
.map(|arg| arg.parse().expect("invalid preload-count"))
.unwrap_or(plume_models::ITEMS_PER_PAGE as usize)
}
fn resolve_user(username: &str, conn: &Connection) -> User {
let instance = Instance::get_local_uncached(conn).expect("Failed to load local instance");
User::find_by_name(conn, username, instance.id).expect("User not found")
}
fn preload(timeline: Timeline, count: usize, conn: &Connection) {
timeline.remove_all_posts(conn).unwrap();
if count == 0 {
return;
}
let mut posts = Vec::with_capacity(count as usize);
for post in Post::list_filtered(conn, None, None, None)
.unwrap()
.into_iter()
.rev()
{
if timeline.matches(conn, &post, Kind::Original).unwrap() {
posts.push(post);
if posts.len() >= count {
break;
}
}
}
for post in posts.iter().rev() {
timeline.add_post(conn, post).unwrap();
}
}
fn new(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_timeline_identifier(args);
let query = get_query(args);
let preload_count = get_preload_count(args);
let user = user.map(|user| resolve_user(&user, conn));
let timeline = if let Some(user) = user {
Timeline::new_for_user(conn, user.id, name, query)
} else {
Timeline::new_for_instance(conn, name, query)
}
.expect("Failed to create new timeline");
preload(timeline, preload_count, conn);
}
fn edit(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_timeline_identifier(args);
let query = get_query(args);
let user = user.map(|user| resolve_user(&user, conn));
let mut timeline = Timeline::find_for_user_by_name(conn, user.map(|u| u.id), &name)
.expect("timeline not found");
timeline.query = query;
timeline.update(conn).expect("Failed to update timeline");
}
fn delete(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_timeline_identifier(args);
if !args.is_present("yes") {
panic!("Warning, this operation is destructive. Add --yes to confirm you want to do it.")
}
let user = user.map(|user| resolve_user(&user, conn));
let timeline = Timeline::find_for_user_by_name(conn, user.map(|u| u.id), &name)
.expect("timeline not found");
timeline.delete(conn).expect("Failed to update timeline");
}
fn repopulate(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_timeline_identifier(args);
let preload_count = get_preload_count(args);
let user = user.map(|user| resolve_user(&user, conn));
let timeline = Timeline::find_for_user_by_name(conn, user.map(|u| u.id), &name)
.expect("timeline not found");
preload(timeline, preload_count, conn);
}
+1
View File
@@ -25,6 +25,7 @@ url = "2.2.2"
flume = "0.10.13" flume = "0.10.13"
tokio = { version = "1.19.2", features = ["full"] } tokio = { version = "1.19.2", features = ["full"] }
futures = "0.3.25" futures = "0.3.25"
thiserror = "1.0.38"
[dependencies.chrono] [dependencies.chrono]
features = ["serde"] features = ["serde"]
+125
View File
@@ -18,6 +18,11 @@ use rocket::{
response::{Responder, Response}, response::{Responder, Response},
Outcome, Outcome,
}; };
use std::{
convert::{TryFrom, TryInto},
fmt,
str::FromStr,
};
use tokio::{ use tokio::{
runtime, runtime,
time::{sleep, Duration}, time::{sleep, Duration},
@@ -241,6 +246,97 @@ pub trait IntoId {
fn into_id(self) -> Id; 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) -> std::result::Result<(), PreferredUsernameError> {
let len = name.len();
if len < 3 {
return Err(PreferredUsernameError::TooShort);
}
match name.chars().enumerate().find(|(pos, c)| {
if pos == &0 || pos == &(len - 1) {
c != &'_' && !c.is_ascii_alphanumeric()
} else {
match c {
'_' | '\\' | '.' | '-' => false,
_ => !c.is_ascii_alphanumeric(),
}
}
}) {
Some((pos, c)) => Err(PreferredUsernameError::InvalidCharacter {
character: c,
position: pos,
}),
None => Ok(()),
}
}
/// # Safety
///
/// The given string must be match against /\A[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?\z/i in Ruby's RegExp which is required by Mastodon.
pub unsafe fn new_unchecked(name: String) -> Self {
Self(name)
}
pub fn new(name: String) -> std::result::Result<Self, PreferredUsernameError> {
Self::validate(&name).map(|_| unsafe { Self::new_unchecked(name) })
}
}
impl fmt::Display for PreferredUsername {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl TryFrom<String> for PreferredUsername {
type Error = PreferredUsernameError;
fn try_from(name: String) -> std::result::Result<Self, Self::Error> {
Self::new(name)
}
}
impl TryFrom<&str> for PreferredUsername {
type Error = PreferredUsernameError;
fn try_from(name: &str) -> std::result::Result<Self, Self::Error> {
Self::new(name.to_owned())
}
}
impl From<PreferredUsername> for String {
fn from(preferred_username: PreferredUsername) -> Self {
preferred_username.0
}
}
impl AsRef<str> for PreferredUsername {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl FromStr for PreferredUsername {
type Err = PreferredUsernameError;
fn from_str(name: &str) -> std::result::Result<Self, Self::Err> {
name.try_into()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ApSignature { pub struct ApSignature {
@@ -524,6 +620,35 @@ mod tests {
use assert_json_diff::assert_json_eq; use assert_json_diff::assert_json_eq;
use serde_json::{from_str, json, to_value}; use serde_json::{from_str, json, to_value};
#[test]
fn preferred_username() {
assert!(PreferredUsername::new("".into()).is_err());
assert!(PreferredUsername::new("a".into()).is_err());
assert!(PreferredUsername::new("ab".into()).is_err());
assert_eq!(
"abc",
PreferredUsername::new("abc".into()).unwrap().as_str()
);
assert_eq!(
"abcd",
PreferredUsername::new("abcd".into()).unwrap().as_str()
);
assert!(PreferredUsername::new("abc-".into()).is_err());
assert!(PreferredUsername::new("日本語".into()).is_err());
assert_eq!("abc", "abc".parse::<PreferredUsername>().unwrap().as_str());
assert!("abc-".parse::<PreferredUsername>().is_err());
assert_eq!(
PreferredUsername::new("admin".into()).unwrap(),
PreferredUsername("admin".into())
);
}
#[test]
fn prefferred_username_to_string() {
let pu = PreferredUsername::new("admin".into()).unwrap();
assert_eq!("admin".to_string(), pu.to_string());
}
#[test] #[test]
fn se_ap_signature() { fn se_ap_signature() {
let ap_signature = ApSignature { let ap_signature = ApSignature {
+2 -46
View File
@@ -1,7 +1,7 @@
use activitystreams::iri_string::percent_encode::PercentEncodedForIri;
use openssl::rand::rand_bytes; use openssl::rand::rand_bytes;
use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag}; use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag};
use regex_syntax::is_word_character; use regex_syntax::is_word_character;
use rocket::http::uri::Uri;
use std::collections::HashSet; use std::collections::HashSet;
use syntect::html::{ClassStyle, ClassedHTMLGenerator}; use syntect::html::{ClassStyle, ClassedHTMLGenerator};
use syntect::parsing::SyntaxSet; use syntect::parsing::SyntaxSet;
@@ -21,51 +21,7 @@ pub fn random_hex() -> String {
* Intended to be used for generating Post ap_url. * Intended to be used for generating Post ap_url.
*/ */
pub fn iri_percent_encode_seg(segment: &str) -> String { pub fn iri_percent_encode_seg(segment: &str) -> String {
segment.chars().map(iri_percent_encode_seg_char).collect() PercentEncodedForIri::from_path_segment(segment).to_string()
}
pub fn iri_percent_encode_seg_char(c: char) -> String {
if c.is_alphanumeric() {
c.to_string()
} else {
match c {
'-'
| '.'
| '_'
| '~'
| '\u{A0}'..='\u{D7FF}'
| '\u{20000}'..='\u{2FFFD}'
| '\u{30000}'..='\u{3FFFD}'
| '\u{40000}'..='\u{4FFFD}'
| '\u{50000}'..='\u{5FFFD}'
| '\u{60000}'..='\u{6FFFD}'
| '\u{70000}'..='\u{7FFFD}'
| '\u{80000}'..='\u{8FFFD}'
| '\u{90000}'..='\u{9FFFD}'
| '\u{A0000}'..='\u{AFFFD}'
| '\u{B0000}'..='\u{BFFFD}'
| '\u{C0000}'..='\u{CFFFD}'
| '\u{D0000}'..='\u{DFFFD}'
| '\u{E0000}'..='\u{EFFFD}'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '='
| ':'
| '@' => c.to_string(),
_ => {
let s = c.to_string();
Uri::percent_encode(&s).to_string()
}
}
}
} }
#[derive(Debug)] #[derive(Debug)]
+2 -1
View File
@@ -27,7 +27,7 @@ webfinger = "0.4.1"
whatlang = "0.16.2" whatlang = "0.16.2"
shrinkwraprs = "0.3.0" shrinkwraprs = "0.3.0"
diesel-derive-newtype = "1.0.0" diesel-derive-newtype = "1.0.0"
glob = "0.3.1" glob = "0.3.0"
lindera-tantivy = { version = "0.7.1", optional = true } lindera-tantivy = { version = "0.7.1", optional = true }
tracing = "0.1.35" tracing = "0.1.35"
riker = "0.4.2" riker = "0.4.2"
@@ -35,6 +35,7 @@ once_cell = "1.12.0"
lettre = "0.9.6" lettre = "0.9.6"
native-tls = "0.2.10" native-tls = "0.2.10"
activitystreams = "=0.7.0-alpha.20" activitystreams = "=0.7.0-alpha.20"
heck = "0.4.0"
[dependencies.chrono] [dependencies.chrono]
features = ["serde"] features = ["serde"]
+83 -60
View File
@@ -1,6 +1,7 @@
use crate::{ use crate::{
instance::*, medias::Media, posts::Post, safe_string::SafeString, schema::blogs, users::User, db_conn::DbConn, instance::*, medias::Media, posts::Post, safe_string::SafeString,
Connection, Error, PlumeRocket, Result, CONFIG, ITEMS_PER_PAGE, schema::blogs, users::User, Connection, Error, Fqn, PlumeRocket, Result, CONFIG,
ITEMS_PER_PAGE,
}; };
use activitystreams::{ use activitystreams::{
actor::{ApActor, ApActorExt, AsApActor, Group}, actor::{ApActor, ApActorExt, AsApActor, Group},
@@ -42,14 +43,14 @@ pub struct Blog {
pub ap_url: String, pub ap_url: String,
pub private_key: Option<String>, pub private_key: Option<String>,
pub public_key: String, pub public_key: String,
pub fqn: String, pub fqn: Fqn,
pub summary_html: SafeString, pub summary_html: SafeString,
pub icon_id: Option<i32>, pub icon_id: Option<i32>,
pub banner_id: Option<i32>, pub banner_id: Option<i32>,
pub theme: Option<String>, pub theme: Option<String>,
} }
#[derive(Default, Insertable)] #[derive(Insertable)]
#[table_name = "blogs"] #[table_name = "blogs"]
pub struct NewBlog { pub struct NewBlog {
pub actor_id: String, pub actor_id: String,
@@ -61,6 +62,7 @@ pub struct NewBlog {
pub ap_url: String, pub ap_url: String,
pub private_key: Option<String>, pub private_key: Option<String>,
pub public_key: String, pub public_key: String,
pub fqn: Fqn,
pub summary_html: SafeString, pub summary_html: SafeString,
pub icon_id: Option<i32>, pub icon_id: Option<i32>,
pub banner_id: Option<i32>, pub banner_id: Option<i32>,
@@ -84,15 +86,13 @@ impl Blog {
inserted.ap_url = instance.compute_box(BLOG_PREFIX, &inserted.actor_id, ""); inserted.ap_url = instance.compute_box(BLOG_PREFIX, &inserted.actor_id, "");
} }
if inserted.fqn.is_empty() { if inserted.fqn.to_string().is_empty() {
// This might not enough for some titles such as all-Japanese title,
// but better than doing nothing.
if instance.local { if instance.local {
inserted.fqn = iri_percent_encode_seg(&inserted.actor_id); inserted.fqn = Fqn::make_local(&inserted.title)?;
} else { } else {
inserted.fqn = format!( inserted.fqn = Fqn::make_remote(&inserted.title, instance.public_domain)?;
"{}@{}",
iri_percent_encode_seg(&inserted.actor_id),
instance.public_domain
);
} }
} }
@@ -102,8 +102,8 @@ impl Blog {
find_by!(blogs, find_by_ap_url, ap_url as &str); find_by!(blogs, find_by_ap_url, ap_url as &str);
find_by!(blogs, find_by_name, actor_id as &str, instance_id as i32); find_by!(blogs, find_by_name, actor_id as &str, instance_id as i32);
pub fn slug(title: &str) -> &str { pub fn slug(title: &str) -> String {
title iri_percent_encode_seg(title)
} }
pub fn get_instance(&self, conn: &Connection) -> Result<Instance> { pub fn get_instance(&self, conn: &Connection) -> Result<Instance> {
@@ -142,10 +142,10 @@ impl Blog {
.map_err(Error::from) .map_err(Error::from)
} }
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<Blog> { pub fn find_by_fqn(conn: &DbConn, fqn: &str) -> Result<Blog> {
let from_db = blogs::table let from_db = blogs::table
.filter(blogs::fqn.eq(fqn)) .filter(blogs::fqn.eq(fqn))
.first(conn) .first(&**conn)
.optional()?; .optional()?;
if let Some(from_db) = from_db { if let Some(from_db) = from_db {
Ok(from_db) Ok(from_db)
@@ -154,7 +154,7 @@ impl Blog {
} }
} }
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<Blog> { fn fetch_from_webfinger(conn: &DbConn, acct: &str) -> Result<Blog> {
resolve_with_prefix(Prefix::Group, acct.to_owned(), true)? resolve_with_prefix(Prefix::Group, acct.to_owned(), true)?
.links .links
.into_iter() .into_iter()
@@ -173,7 +173,7 @@ impl Blog {
pub fn to_activity(&self, conn: &Connection) -> Result<CustomGroup> { pub fn to_activity(&self, conn: &Connection) -> Result<CustomGroup> {
let mut blog = ApActor::new(self.inbox_url.parse()?, Group::new()); let mut blog = ApActor::new(self.inbox_url.parse()?, Group::new());
blog.set_preferred_username(iri_percent_encode_seg(&self.actor_id)); blog.set_preferred_username(self.fqn.to_string());
blog.set_name(self.title.clone()); blog.set_name(self.title.clone());
blog.set_outbox(self.outbox_url.parse()?); blog.set_outbox(self.outbox_url.parse()?);
blog.set_summary(self.summary_html.to_string()); blog.set_summary(self.summary_html.to_string());
@@ -372,15 +372,15 @@ impl IntoId for Blog {
} }
} }
impl FromId<Connection> for Blog { impl FromId<DbConn> for Blog {
type Error = Error; type Error = Error;
type Object = CustomGroup; type Object = CustomGroup;
fn from_db(conn: &Connection, id: &str) -> Result<Self> { fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id) Self::find_by_ap_url(conn, id)
} }
fn from_activity(conn: &Connection, acct: CustomGroup) -> Result<Self> { fn from_activity(conn: &DbConn, acct: CustomGroup) -> Result<Self> {
let (name, outbox_url, inbox_url) = { let (name, outbox_url, inbox_url) = {
let actor = acct.ap_actor_ref(); let actor = acct.ap_actor_ref();
let name = actor let name = actor
@@ -398,22 +398,18 @@ impl FromId<Connection> for Blog {
) )
}; };
let mut new_blog = NewBlog { let actor_id = iri_percent_encode_seg(
actor_id: name.to_string(), &acct
outbox_url, .name()
inbox_url, .and_then(|name| name.to_as_string())
public_key: acct.ext_one.public_key.public_key_pem.to_string(), .ok_or(Error::MissingApProperty)?,
private_key: None, );
theme: None,
..NewBlog::default()
};
let object = ApObject::new(acct.inner); let object = ApObject::new(acct.inner);
new_blog.title = object let title = object
.name() .name()
.and_then(|name| name.to_as_string()) .and_then(|name| name.to_as_string())
.unwrap_or(name); .unwrap_or(name.clone());
new_blog.summary_html = SafeString::new( let summary_html = SafeString::new(
&object &object
.summary() .summary()
.and_then(|summary| summary.to_as_string()) .and_then(|summary| summary.to_as_string())
@@ -435,7 +431,6 @@ impl FromId<Connection> for Blog {
}) })
}) })
.map(|m| m.id); .map(|m| m.id);
new_blog.icon_id = icon_id;
let banner_id = object let banner_id = object
.image() .image()
@@ -452,13 +447,12 @@ impl FromId<Connection> for Blog {
}) })
}) })
.map(|m| m.id); .map(|m| m.id);
new_blog.banner_id = banner_id;
new_blog.summary = acct.ext_two.source.content; let summary = acct.ext_two.source.content;
let any_base = AnyBase::from_extended(object)?; let any_base = AnyBase::from_extended(object)?;
let id = any_base.id().ok_or(Error::MissingApProperty)?; let id = any_base.id().ok_or(Error::MissingApProperty)?;
new_blog.ap_url = id.to_string(); let ap_url = id.to_string();
let inst = id let inst = id
.authority_components() .authority_components()
@@ -482,7 +476,29 @@ impl FromId<Connection> for Blog {
}, },
) )
})?; })?;
new_blog.instance_id = instance.id; let instance_id = instance.id;
let fqn = if instance.local {
Fqn::new_local(name)?
} else {
Fqn::new_remote(name, instance.public_domain)?
};
let new_blog = NewBlog {
actor_id,
outbox_url,
inbox_url,
fqn,
public_key: acct.ext_one.public_key.public_key_pem.to_string(),
private_key: None,
theme: None,
title,
summary,
ap_url,
summary_html,
icon_id,
banner_id,
instance_id,
};
Blog::insert(conn, new_blog) Blog::insert(conn, new_blog)
} }
@@ -538,12 +554,19 @@ impl NewBlog {
let (pub_key, priv_key) = sign::gen_keypair(); let (pub_key, priv_key) = sign::gen_keypair();
Ok(NewBlog { Ok(NewBlog {
actor_id, actor_id,
fqn: Fqn::make_local(&title)?,
title, title,
summary, summary,
instance_id, instance_id,
public_key: String::from_utf8(pub_key).or(Err(Error::Signature))?, public_key: String::from_utf8(pub_key).or(Err(Error::Signature))?,
private_key: Some(String::from_utf8(priv_key).or(Err(Error::Signature))?), private_key: Some(String::from_utf8(priv_key).or(Err(Error::Signature))?),
..NewBlog::default() outbox_url: Default::default(),
inbox_url: Default::default(),
ap_url: Default::default(),
summary_html: Default::default(),
icon_id: Default::default(),
banner_id: Default::default(),
theme: Default::default(),
}) })
} }
} }
@@ -565,8 +588,8 @@ pub(crate) mod tests {
let mut blog1 = Blog::insert( let mut blog1 = Blog::insert(
conn, conn,
NewBlog::new_local( NewBlog::new_local(
"BlogName".to_owned(), "Blog%20Name".to_owned(),
"Blog name".to_owned(), "Blog Name".to_owned(),
"This is a small blog".to_owned(), "This is a small blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
) )
@@ -576,7 +599,7 @@ pub(crate) mod tests {
let blog2 = Blog::insert( let blog2 = Blog::insert(
conn, conn,
NewBlog::new_local( NewBlog::new_local(
"MyBlog".to_owned(), "My%20Blog".to_owned(),
"My blog".to_owned(), "My blog".to_owned(),
"Welcome to my blog".to_owned(), "Welcome to my blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
@@ -587,7 +610,7 @@ pub(crate) mod tests {
let blog3 = Blog::insert( let blog3 = Blog::insert(
conn, conn,
NewBlog::new_local( NewBlog::new_local(
"WhyILikePlume".to_owned(), "Why%20I%20Like%20Plume".to_owned(),
"Why I like Plume".to_owned(), "Why I like Plume".to_owned(),
"In this blog I will explay you why I like Plume so much".to_owned(), "In this blog I will explay you why I like Plume so much".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
@@ -682,7 +705,7 @@ pub(crate) mod tests {
let blog = Blog::insert( let blog = Blog::insert(
conn, conn,
NewBlog::new_local( NewBlog::new_local(
"SomeName".to_owned(), "Some%20Name".to_owned(),
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
@@ -709,7 +732,7 @@ pub(crate) mod tests {
let b1 = Blog::insert( let b1 = Blog::insert(
conn, conn,
NewBlog::new_local( NewBlog::new_local(
"SomeName".to_owned(), "Some%20Name".to_owned(),
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
@@ -810,7 +833,7 @@ pub(crate) mod tests {
let blog = Blog::insert( let blog = Blog::insert(
conn, conn,
NewBlog::new_local( NewBlog::new_local(
"SomeName".to_owned(), "Some%20Name".to_owned(),
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
@@ -830,10 +853,10 @@ pub(crate) mod tests {
conn.test_transaction::<_, (), _>(|| { conn.test_transaction::<_, (), _>(|| {
fill_database(conn); fill_database(conn);
let blog = Blog::insert( let _ = Blog::insert(
conn, conn,
NewBlog::new_local( NewBlog::new_local(
"SomeName".to_owned(), "Some%20Name".to_owned(),
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
@@ -842,7 +865,6 @@ pub(crate) mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(blog.fqn, "SomeName");
Ok(()) Ok(())
}) })
} }
@@ -868,7 +890,7 @@ pub(crate) mod tests {
let b1 = Blog::insert( let b1 = Blog::insert(
conn, conn,
NewBlog::new_local( NewBlog::new_local(
"SomeName".to_owned(), "Some%20Name".to_owned(),
"Some name".to_owned(), "Some name".to_owned(),
"This is some blog".to_owned(), "This is some blog".to_owned(),
Instance::get_local().unwrap().id, Instance::get_local().unwrap().id,
@@ -968,6 +990,7 @@ pub(crate) mod tests {
let _: Blog = blogs[0].save_changes(&**conn).unwrap(); let _: Blog = blogs[0].save_changes(&**conn).unwrap();
let ap_repr = blogs[0].to_activity(conn).unwrap(); let ap_repr = blogs[0].to_activity(conn).unwrap();
blogs[0].delete(conn).unwrap(); blogs[0].delete(conn).unwrap();
eprintln!("{:#?}", &ap_repr);
let blog = Blog::from_activity(conn, ap_repr).unwrap(); let blog = Blog::from_activity(conn, ap_repr).unwrap();
assert_eq!(blog.actor_id, blogs[0].actor_id); assert_eq!(blog.actor_id, blogs[0].actor_id);
@@ -1001,19 +1024,19 @@ pub(crate) mod tests {
"type": "Image", "type": "Image",
"url": "https://plu.me/aaa.png" "url": "https://plu.me/aaa.png"
}, },
"id": "https://plu.me/~/BlogName/", "id": "https://plu.me/~/Blog%20Name/",
"image": { "image": {
"attributedTo": "https://plu.me/@/admin/", "attributedTo": "https://plu.me/@/admin/",
"type": "Image", "type": "Image",
"url": "https://plu.me/bbb.png" "url": "https://plu.me/bbb.png"
}, },
"inbox": "https://plu.me/~/BlogName/inbox", "inbox": "https://plu.me/~/Blog%20Name/inbox",
"name": "Blog name", "name": "Blog Name",
"outbox": "https://plu.me/~/BlogName/outbox", "outbox": "https://plu.me/~/Blog%20Name/outbox",
"preferredUsername": "BlogName", "preferredUsername": "BlogName",
"publicKey": { "publicKey": {
"id": "https://plu.me/~/BlogName/#main-key", "id": "https://plu.me/~/Blog%20Name/#main-key",
"owner": "https://plu.me/~/BlogName/", "owner": "https://plu.me/~/Blog%20Name/",
"publicKeyPem": blog.public_key "publicKeyPem": blog.public_key
}, },
"source": { "source": {
@@ -1041,8 +1064,8 @@ pub(crate) mod tests {
let expected = json!({ let expected = json!({
"items": [], "items": [],
"totalItems": 0, "totalItems": 0,
"first": "https://plu.me/~/BlogName/outbox?page=1", "first": "https://plu.me/~/Blog%20Name/outbox?page=1",
"last": "https://plu.me/~/BlogName/outbox?page=0", "last": "https://plu.me/~/Blog%20Name/outbox?page=0",
"type": "OrderedCollection" "type": "OrderedCollection"
}); });
@@ -1061,8 +1084,8 @@ pub(crate) mod tests {
let act = blog.outbox_collection_page(conn, (33, 36))?; let act = blog.outbox_collection_page(conn, (33, 36))?;
let expected = json!({ let expected = json!({
"next": "https://plu.me/~/BlogName/outbox?page=3", "next": "https://plu.me/~/Blog%20Name/outbox?page=3",
"prev": "https://plu.me/~/BlogName/outbox?page=1", "prev": "https://plu.me/~/Blog%20Name/outbox?page=1",
"items": [], "items": [],
"type": "OrderedCollectionPage" "type": "OrderedCollectionPage"
}); });
+22 -21
View File
@@ -1,5 +1,6 @@
use crate::{ use crate::{
comment_seers::{CommentSeers, NewCommentSeers}, comment_seers::{CommentSeers, NewCommentSeers},
db_conn::DbConn,
instance::Instance, instance::Instance,
medias::Media, medias::Media,
mentions::Mention, mentions::Mention,
@@ -110,7 +111,7 @@ impl Comment {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn to_activity(&self, conn: &Connection) -> Result<Note> { pub fn to_activity(&self, conn: &DbConn) -> Result<Note> {
let author = User::get(conn, self.author_id)?; let author = User::get(conn, self.author_id)?;
let (html, mentions, _hashtags) = utils::md_to_html( let (html, mentions, _hashtags) = utils::md_to_html(
self.content.get().as_ref(), self.content.get().as_ref(),
@@ -148,7 +149,7 @@ impl Comment {
Ok(note) Ok(note)
} }
pub fn create_activity(&self, conn: &Connection) -> Result<Create> { pub fn create_activity(&self, conn: &DbConn) -> Result<Create> {
let author = User::get(conn, self.author_id)?; let author = User::get(conn, self.author_id)?;
let note = self.to_activity(conn)?; let note = self.to_activity(conn)?;
@@ -216,15 +217,15 @@ impl Comment {
} }
} }
impl FromId<Connection> for Comment { impl FromId<DbConn> for Comment {
type Error = Error; type Error = Error;
type Object = Note; type Object = Note;
fn from_db(conn: &Connection, id: &str) -> Result<Self> { fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id) Self::find_by_ap_url(conn, id)
} }
fn from_activity(conn: &Connection, note: Note) -> Result<Self> { fn from_activity(conn: &DbConn, note: Note) -> Result<Self> {
let comm = { let comm = {
let previous_url = note let previous_url = note
.in_reply_to() .in_reply_to()
@@ -353,21 +354,21 @@ impl FromId<Connection> for Comment {
} }
} }
impl AsObject<User, Create, &Connection> for Comment { impl AsObject<User, Create, &DbConn> for Comment {
type Error = Error; type Error = Error;
type Output = Self; type Output = Self;
fn activity(self, _conn: &Connection, _actor: User, _id: &str) -> Result<Self> { fn activity(self, _conn: &DbConn, _actor: User, _id: &str) -> Result<Self> {
// The actual creation takes place in the FromId impl // The actual creation takes place in the FromId impl
Ok(self) Ok(self)
} }
} }
impl AsObject<User, Delete, &Connection> for Comment { impl AsObject<User, Delete, &DbConn> for Comment {
type Error = Error; type Error = Error;
type Output = (); type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> { fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
if self.author_id != actor.id { if self.author_id != actor.id {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
@@ -386,8 +387,8 @@ impl AsObject<User, Delete, &Connection> for Comment {
diesel::update(comments::table) diesel::update(comments::table)
.filter(comments::in_response_to_id.eq(self.id)) .filter(comments::in_response_to_id.eq(self.id))
.set(comments::in_response_to_id.eq(self.in_response_to_id)) .set(comments::in_response_to_id.eq(self.in_response_to_id))
.execute(conn)?; .execute(&**conn)?;
diesel::delete(&self).execute(conn)?; diesel::delete(&self).execute(&**conn)?;
Ok(()) Ok(())
} }
} }
@@ -462,13 +463,13 @@ mod tests {
assert_json_eq!(to_value(&act).unwrap(), json!({ assert_json_eq!(to_value(&act).unwrap(), json!({
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"], "cc": ["https://plu.me/@/admin/followers"],
"id": format!("https://plu.me/~/BlogName/testing/comment/{}/activity", original_comm.id), "id": format!("https://plu.me/~/Blog%20Name/testing/comment/{}/activity", original_comm.id),
"object": { "object": {
"attributedTo": "https://plu.me/@/admin/", "attributedTo": "https://plu.me/@/admin/",
"content": r###"<p dir="auto">My comment, mentioning to <a href="https://plu.me/@/user/" title="user">@user</a></p> "content": r###"<p dir="auto">My comment, mentioning to <a href="https://plu.me/@/user/" title="user">@user</a></p>
"###, "###,
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", original_comm.id), "id": format!("https://plu.me/~/Blog%20Name/testing/comment/{}", original_comm.id),
"inReplyTo": "https://plu.me/~/BlogName/testing", "inReplyTo": "https://plu.me/~/Blog%20Name/testing",
"published": format_datetime(&original_comm.creation_date), "published": format_datetime(&original_comm.creation_date),
"summary": "My CW", "summary": "My CW",
"tag": [ "tag": [
@@ -504,12 +505,12 @@ mod tests {
assert_json_eq!(to_value(&reply_act).unwrap(), json!({ assert_json_eq!(to_value(&reply_act).unwrap(), json!({
"actor": "https://plu.me/@/user/", "actor": "https://plu.me/@/user/",
"cc": ["https://plu.me/@/user/followers"], "cc": ["https://plu.me/@/user/followers"],
"id": format!("https://plu.me/~/BlogName/testing/comment/{}/activity", reply.id), "id": format!("https://plu.me/~/Blog%20Name/testing/comment/{}/activity", reply.id),
"object": { "object": {
"attributedTo": "https://plu.me/@/user/", "attributedTo": "https://plu.me/@/user/",
"content": "", "content": "",
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", reply.id), "id": format!("https://plu.me/~/Blog%20Name/testing/comment/{}", reply.id),
"inReplyTo": format!("https://plu.me/~/BlogName/testing/comment/{}", original_comm.id), "inReplyTo": format!("https://plu.me/~/Blog%20Name/testing/comment/{}", original_comm.id),
"published": format_datetime(&reply.creation_date), "published": format_datetime(&reply.creation_date),
"summary": "", "summary": "",
"tag": [], "tag": [],
@@ -553,8 +554,8 @@ mod tests {
"attributedTo": "https://plu.me/@/admin/", "attributedTo": "https://plu.me/@/admin/",
"content": r###"<p dir="auto">My comment, mentioning to <a href="https://plu.me/@/user/" title="user">@user</a></p> "content": r###"<p dir="auto">My comment, mentioning to <a href="https://plu.me/@/user/" title="user">@user</a></p>
"###, "###,
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", comment.id), "id": format!("https://plu.me/~/Blog%20Name/testing/comment/{}", comment.id),
"inReplyTo": "https://plu.me/~/BlogName/testing", "inReplyTo": "https://plu.me/~/Blog%20Name/testing",
"published": format_datetime(&comment.creation_date), "published": format_datetime(&comment.creation_date),
"summary": "My CW", "summary": "My CW",
"tag": [ "tag": [
@@ -583,9 +584,9 @@ mod tests {
let expected = json!({ let expected = json!({
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"id": format!("https://plu.me/~/BlogName/testing/comment/{}#delete", comment.id), "id": format!("https://plu.me/~/Blog%20Name/testing/comment/{}#delete", comment.id),
"object": { "object": {
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", comment.id), "id": format!("https://plu.me/~/Blog%20Name/testing/comment/{}", comment.id),
"type": "Tombstone" "type": "Tombstone"
}, },
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
+1 -1
View File
@@ -41,7 +41,7 @@ pub enum InvalidRocketConfig {
SecretKey, 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 mut c = RocketConfig::active().map_err(|_| InvalidRocketConfig::Env)?;
let address = var("ROCKET_ADDRESS").unwrap_or_else(|_| "localhost".to_owned()); let address = var("ROCKET_ADDRESS").unwrap_or_else(|_| "localhost".to_owned());
+11 -11
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
ap_url, instance::Instance, notifications::*, schema::follows, users::User, Connection, Error, ap_url, db_conn::DbConn, instance::Instance, notifications::*, schema::follows, users::User,
Result, CONFIG, Connection, Error, Result, CONFIG,
}; };
use activitystreams::{ use activitystreams::{
activity::{Accept, ActorAndObjectRef, Follow as FollowAct, Undo}, activity::{Accept, ActorAndObjectRef, Follow as FollowAct, Undo},
@@ -150,11 +150,11 @@ impl Follow {
} }
} }
impl AsObject<User, FollowAct, &Connection> for User { impl AsObject<User, FollowAct, &DbConn> for User {
type Error = Error; type Error = Error;
type Output = Follow; type Output = Follow;
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Follow> { fn activity(self, conn: &DbConn, actor: User, id: &str) -> Result<Follow> {
// Mastodon (at least) requires the full Follow object when accepting it, // Mastodon (at least) requires the full Follow object when accepting it,
// so we rebuilt it here // so we rebuilt it here
let follow = FollowAct::new(actor.ap_url.parse::<IriString>()?, id.parse::<IriString>()?); let follow = FollowAct::new(actor.ap_url.parse::<IriString>()?, id.parse::<IriString>()?);
@@ -162,15 +162,15 @@ impl AsObject<User, FollowAct, &Connection> for User {
} }
} }
impl FromId<Connection> for Follow { impl FromId<DbConn> for Follow {
type Error = Error; type Error = Error;
type Object = FollowAct; type Object = FollowAct;
fn from_db(conn: &Connection, id: &str) -> Result<Self> { fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
Follow::find_by_ap_url(conn, id) Follow::find_by_ap_url(conn, id)
} }
fn from_activity(conn: &Connection, follow: FollowAct) -> Result<Self> { fn from_activity(conn: &DbConn, follow: FollowAct) -> Result<Self> {
let actor = User::from_id( let actor = User::from_id(
conn, conn,
follow follow
@@ -202,18 +202,18 @@ impl FromId<Connection> for Follow {
} }
} }
impl AsObject<User, Undo, &Connection> for Follow { impl AsObject<User, Undo, &DbConn> for Follow {
type Error = Error; type Error = Error;
type Output = (); type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> { fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
let conn = conn; let conn = conn;
if self.follower_id == actor.id { if self.follower_id == actor.id {
diesel::delete(&self).execute(conn)?; diesel::delete(&self).execute(&**conn)?;
// delete associated notification if any // delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::FOLLOW, self.id) { if let Ok(notif) = Notification::find(conn, notification_kind::FOLLOW, self.id) {
diesel::delete(&notif).execute(conn)?; diesel::delete(&notif).execute(&**conn)?;
} }
Ok(()) Ok(())
+2 -2
View File
@@ -47,7 +47,7 @@ impl_into_inbox_result! {
} }
pub fn inbox(conn: &DbConn, act: serde_json::Value) -> Result<InboxResult, Error> { pub fn inbox(conn: &DbConn, act: serde_json::Value) -> Result<InboxResult, Error> {
Inbox::handle(&**conn, act) Inbox::handle(conn, act)
.with::<User, Announce, Post>(CONFIG.proxy()) .with::<User, Announce, Post>(CONFIG.proxy())
.with::<User, Create, Comment>(CONFIG.proxy()) .with::<User, Create, Comment>(CONFIG.proxy())
.with::<User, Create, Post>(CONFIG.proxy()) .with::<User, Create, Post>(CONFIG.proxy())
@@ -268,7 +268,7 @@ pub(crate) mod tests {
"actor": users[0].ap_url, "actor": users[0].ap_url,
"object": { "object": {
"type": "Article", "type": "Article",
"id": "https://plu.me/~/BlogName/testing", "id": "https://plu.me/~/Blog%20Name/testing",
"attributedTo": [users[0].ap_url, blogs[0].ap_url], "attributedTo": [users[0].ap_url, blogs[0].ap_url],
"content": "Hello.", "content": "Hello.",
"name": "My Article", "name": "My Article",
+3 -3
View File
@@ -9,7 +9,7 @@ use crate::{
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use diesel::{self, result::Error::NotFound, ExpressionMethods, QueryDsl, RunQueryDsl}; use diesel::{self, result::Error::NotFound, ExpressionMethods, QueryDsl, RunQueryDsl};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use plume_common::utils::{iri_percent_encode_seg, md_to_html}; use plume_common::utils::md_to_html;
use std::sync::RwLock; use std::sync::RwLock;
#[derive(Clone, Identifiable, Queryable)] #[derive(Clone, Identifiable, Queryable)]
@@ -173,8 +173,8 @@ impl Instance {
"{instance}/{prefix}/{name}/{box_name}", "{instance}/{prefix}/{name}/{box_name}",
instance = self.public_domain, instance = self.public_domain,
prefix = prefix, prefix = prefix,
name = iri_percent_encode_seg(name), name = name,
box_name = iri_percent_encode_seg(box_name) box_name = box_name
)) ))
} }
+117 -3
View File
@@ -17,12 +17,18 @@ extern crate serde_json;
extern crate tantivy; extern crate tantivy;
use activitystreams::iri_string; 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;
pub use lettre::smtp; pub use lettre::smtp;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use plume_common::activity_pub::{inbox::InboxError, request, sign}; use plume_common::activity_pub::PreferredUsernameError;
use plume_common::activity_pub::{inbox::InboxError, request, sign, PreferredUsername};
use posts::PostEvent; use posts::PostEvent;
use riker::actors::{channel, ActorSystem, ChannelRef, SystemBuilder}; use riker::actors::{channel, ActorSystem, ChannelRef, SystemBuilder};
use std::{fmt, io::Write, string::ToString};
use users::UserEvent; use users::UserEvent;
#[cfg(not(any(feature = "sqlite", feature = "postgres")))] #[cfg(not(any(feature = "sqlite", feature = "postgres")))]
@@ -170,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>; pub type Result<T> = std::result::Result<T, Error>;
/// Adds a function to a model, that returns the first /// Adds a function to a model, that returns the first
@@ -301,7 +314,7 @@ macro_rules! last {
} }
mod config; mod config;
pub use config::CONFIG; pub use config::{get_rocket_config, Config, SearchTokenizerConfig, CONFIG};
pub fn ap_url(url: &str) -> String { pub fn ap_url(url: &str) -> String {
format!("https://{}", url) format!("https://{}", url)
@@ -334,10 +347,97 @@ impl SmtpNewWithAddr for smtp::SmtpClient {
} }
} }
#[derive(AsExpression, PartialEq, Eq, Clone, FromSqlRow, Debug)]
#[sql_type = "Text"]
pub enum Fqn {
Local(PreferredUsername),
Remote(PreferredUsername, String),
}
impl Fqn {
pub fn new_local(username: String) -> Result<Self> {
Ok(Self::Local(
PreferredUsername::new(username)?,
))
}
pub fn new_remote(username: String, domain: String) -> Result<Self> {
Ok(Self::Remote(
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 {
fn from(fqn: &Fqn) -> Self {
match fqn {
Fqn::Local(username) => username.to_string(),
Fqn::Remote(username, domain) => format!("{}@{}", username, domain),
}
}
}
impl fmt::Display for Fqn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
String::from(self).fmt(f)
}
}
impl<DB> ToSql<Text, DB> for Fqn
where
DB: diesel::backend::Backend,
{
fn to_sql<W: Write>(
&self,
out: &mut diesel::serialize::Output<W, DB>,
) -> diesel::serialize::Result {
let fqn = match self {
Self::Local(username) => username.to_string(),
Self::Remote(username, domain) => format!("{}@{}", username, domain),
};
ToSql::<Text, DB>::to_sql::<W>(&fqn, out)
}
}
impl<DB> FromSql<Text, DB> for Fqn
where
DB: diesel::backend::Backend,
String: FromSql<Text, DB>,
{
/// We use PreferredUsername::new_unchecked() because, even if bytes is invalid as `preferredUsername`,
/// we need return some value.
fn from_sql(bytes: Option<&<DB as Backend>::RawValue>) -> diesel::deserialize::Result<Self> {
let value = <String as FromSql<Text, DB>>::from_sql(bytes)?;
Ok(match value.rsplit_once('@') {
None => Self::Local(unsafe { PreferredUsername::new_unchecked(value) }),
Some((username, domain)) => Self::Remote(
unsafe { PreferredUsername::new_unchecked(username.into()) },
domain.into(),
),
})
}
}
#[cfg(test)] #[cfg(test)]
#[macro_use] #[macro_use]
mod tests { mod tests {
use crate::{db_conn, migrations::IMPORTED_MIGRATIONS, Connection as Conn, CONFIG}; use crate::{db_conn, migrations::IMPORTED_MIGRATIONS, Connection as Conn, CONFIG, Fqn};
use chrono::{naive::NaiveDateTime, Datelike, Timelike}; use chrono::{naive::NaiveDateTime, Datelike, Timelike};
use diesel::r2d2::ConnectionManager; use diesel::r2d2::ConnectionManager;
use plume_common::utils::random_hex; use plume_common::utils::random_hex;
@@ -398,6 +498,20 @@ mod tests {
dt.second() dt.second()
) )
} }
#[test]
fn fqn() {
assert_eq!(
Fqn::new_local("admin".to_string()).unwrap(),
Fqn::new_local("admin".to_string()).unwrap()
);
assert!(Fqn::new_local("admin".to_string()).is_ok());
let fqn = Fqn::new_local("admin".to_string()).unwrap();
assert_eq!("admin".to_string(), String::from(&fqn));
let fqn = Fqn::new_local("admin".to_string()).unwrap();
assert_eq!("admin".to_string(), ToString::to_string(&fqn));
assert_eq!("admin".to_string(), fqn.to_string());
}
} }
pub mod admin; pub mod admin;
+16 -16
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
instance::Instance, notifications::*, posts::Post, schema::likes, timeline::*, users::User, db_conn::DbConn, instance::Instance, notifications::*, posts::Post, schema::likes, timeline::*,
Connection, Error, Result, CONFIG, users::User, Connection, Error, Result, CONFIG,
}; };
use activitystreams::{ use activitystreams::{
activity::{ActorAndObjectRef, Like as LikeAct, Undo}, activity::{ActorAndObjectRef, Like as LikeAct, Undo},
@@ -85,11 +85,11 @@ impl Like {
} }
} }
impl AsObject<User, LikeAct, &Connection> for Post { impl AsObject<User, LikeAct, &DbConn> for Post {
type Error = Error; type Error = Error;
type Output = Like; type Output = Like;
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Like> { fn activity(self, conn: &DbConn, actor: User, id: &str) -> Result<Like> {
let res = Like::insert( let res = Like::insert(
conn, conn,
NewLike { NewLike {
@@ -105,15 +105,15 @@ impl AsObject<User, LikeAct, &Connection> for Post {
} }
} }
impl FromId<Connection> for Like { impl FromId<DbConn> for Like {
type Error = Error; type Error = Error;
type Object = LikeAct; type Object = LikeAct;
fn from_db(conn: &Connection, id: &str) -> Result<Self> { fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
Like::find_by_ap_url(conn, id) Like::find_by_ap_url(conn, id)
} }
fn from_activity(conn: &Connection, act: LikeAct) -> Result<Self> { fn from_activity(conn: &DbConn, act: LikeAct) -> Result<Self> {
let res = Like::insert( let res = Like::insert(
conn, conn,
NewLike { NewLike {
@@ -154,17 +154,17 @@ impl FromId<Connection> for Like {
} }
} }
impl AsObject<User, Undo, &Connection> for Like { impl AsObject<User, Undo, &DbConn> for Like {
type Error = Error; type Error = Error;
type Output = (); type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> { fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
if actor.id == self.user_id { if actor.id == self.user_id {
diesel::delete(&self).execute(conn)?; diesel::delete(&self).execute(&**conn)?;
// delete associated notification if any // delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::LIKE, self.id) { if let Ok(notif) = Notification::find(conn, notification_kind::LIKE, self.id) {
diesel::delete(&notif).execute(conn)?; diesel::delete(&notif).execute(&**conn)?;
} }
Ok(()) Ok(())
} else { } else {
@@ -205,8 +205,8 @@ mod tests {
let expected = json!({ let expected = json!({
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"], "cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/like/https://plu.me/~/BlogName/testing", "id": "https://plu.me/@/admin/like/https://plu.me/~/Blog%20Name/testing",
"object": "https://plu.me/~/BlogName/testing", "object": "https://plu.me/~/Blog%20Name/testing",
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Like", "type": "Like",
}); });
@@ -229,12 +229,12 @@ mod tests {
let expected = json!({ let expected = json!({
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"], "cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/like/https://plu.me/~/BlogName/testing#delete", "id": "https://plu.me/@/admin/like/https://plu.me/~/Blog%20Name/testing#delete",
"object": { "object": {
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"], "cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/like/https://plu.me/~/BlogName/testing", "id": "https://plu.me/@/admin/like/https://plu.me/~/Blog%20Name/testing",
"object": "https://plu.me/~/BlogName/testing", "object": "https://plu.me/~/Blog%20Name/testing",
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Like", "type": "Like",
}, },
-22
View File
@@ -297,28 +297,6 @@ impl List {
.map_err(Error::from) .map_err(Error::from)
} }
pub fn delete(&self, conn: &Connection) -> Result<()> {
if let Some(user_id) = self.user_id {
diesel::delete(
lists::table
.filter(lists::user_id.eq(user_id))
.filter(lists::name.eq(&self.name)),
)
.execute(conn)
.map(|_| ())
.map_err(Error::from)
} else {
diesel::delete(
lists::table
.filter(lists::user_id.is_null())
.filter(lists::name.eq(&self.name)),
)
.execute(conn)
.map(|_| ())
.map_err(Error::from)
}
}
func! {set: set_users, User, add_users} func! {set: set_users, User, add_users}
func! {set: set_blogs, Blog, add_blogs} func! {set: set_blogs, Blog, add_blogs}
func! {set: set_words, Word, add_words} func! {set: set_words, Word, add_words}
+4 -4
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
ap_url, instance::Instance, safe_string::SafeString, schema::medias, users::User, Connection, ap_url, db_conn::DbConn, instance::Instance, safe_string::SafeString, schema::medias,
Error, Result, CONFIG, users::User, Connection, Error, Result, CONFIG,
}; };
use activitystreams::{object::Image, prelude::*}; use activitystreams::{object::Image, prelude::*};
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl}; use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
@@ -206,7 +206,7 @@ impl Media {
} }
// TODO: merge with save_remote? // TODO: merge with save_remote?
pub fn from_activity(conn: &Connection, image: &Image) -> Result<Media> { pub fn from_activity(conn: &DbConn, image: &Image) -> Result<Media> {
let remote_url = image let remote_url = image
.url() .url()
.and_then(|url| url.to_as_uri()) .and_then(|url| url.to_as_uri())
@@ -258,7 +258,7 @@ impl Media {
updated = true; updated = true;
} }
if updated { if updated {
diesel::update(&media).set(&media).execute(conn)?; diesel::update(&media).set(&media).execute(&**conn)?;
} }
Ok(media) Ok(media)
}) })
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
comments::Comment, notifications::*, posts::Post, schema::mentions, users::User, Connection, comments::Comment, db_conn::DbConn, notifications::*, posts::Post, schema::mentions,
Error, Result, users::User, Connection, Error, Result,
}; };
use activitystreams::{ use activitystreams::{
base::BaseExt, base::BaseExt,
@@ -60,7 +60,7 @@ impl Mention {
} }
} }
pub fn build_activity(conn: &Connection, ment: &str) -> Result<link::Mention> { pub fn build_activity(conn: &DbConn, ment: &str) -> Result<link::Mention> {
let user = User::find_by_fqn(conn, ment)?; let user = User::find_by_fqn(conn, ment)?;
let mut mention = link::Mention::new(); let mut mention = link::Mention::new();
mention.set_href(user.ap_url.parse::<IriString>()?); mention.set_href(user.ap_url.parse::<IriString>()?);
+34 -34
View File
@@ -1,7 +1,7 @@
use crate::{ use crate::{
ap_url, blogs::Blog, instance::Instance, medias::Media, mentions::Mention, post_authors::*, ap_url, blogs::Blog, db_conn::DbConn, instance::Instance, medias::Media, mentions::Mention,
safe_string::SafeString, schema::posts, tags::*, timeline::*, users::User, Connection, Error, post_authors::*, safe_string::SafeString, schema::posts, tags::*, timeline::*, users::User,
PostEvent::*, Result, CONFIG, POST_CHAN, Connection, Error, Fqn, PostEvent::*, Result, CONFIG, POST_CHAN,
}; };
use activitystreams::{ use activitystreams::{
activity::{Create, Delete, Update}, activity::{Create, Delete, Update},
@@ -28,7 +28,7 @@ use riker::actors::{Publish, Tell};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
static BLOG_FQN_CACHE: Lazy<Mutex<HashMap<i32, String>>> = Lazy::new(|| Mutex::new(HashMap::new())); static BLOG_FQN_CACHE: Lazy<Mutex<HashMap<i32, Fqn>>> = Lazy::new(|| Mutex::new(HashMap::new()));
#[derive(Queryable, Identifiable, Clone, AsChangeset, Debug)] #[derive(Queryable, Identifiable, Clone, AsChangeset, Debug)]
#[changeset_options(treat_none_as_null = "true")] #[changeset_options(treat_none_as_null = "true")]
@@ -255,7 +255,7 @@ impl Post {
ap_url(&format!( ap_url(&format!(
"{}/~/{}/{}/", "{}/~/{}/{}/",
CONFIG.base_url, CONFIG.base_url,
iri_percent_encode_seg(&blog.fqn), &blog.fqn,
iri_percent_encode_seg(slug) iri_percent_encode_seg(slug)
)) ))
} }
@@ -298,9 +298,9 @@ impl Post {
/// This caches query result. The best way to cache query result is holding it in `Post`s field /// This caches query result. The best way to cache query result is holding it in `Post`s field
/// but Diesel doesn't allow it currently. /// but Diesel doesn't allow it currently.
/// If sometime Diesel allow it, this method should be removed. /// If sometime Diesel allow it, this method should be removed.
pub fn get_blog_fqn(&self, conn: &Connection) -> String { pub fn get_blog_fqn(&self, conn: &Connection) -> Fqn {
if let Some(blog_fqn) = BLOG_FQN_CACHE.lock().unwrap().get(&self.blog_id) { if let Some(blog_fqn) = BLOG_FQN_CACHE.lock().unwrap().get(&self.blog_id) {
return blog_fqn.to_string(); return blog_fqn.to_owned();
} }
let blog_fqn = self.get_blog(conn).unwrap().fqn; let blog_fqn = self.get_blog(conn).unwrap().fqn;
BLOG_FQN_CACHE BLOG_FQN_CACHE
@@ -615,15 +615,15 @@ impl Post {
} }
} }
impl FromId<Connection> for Post { impl FromId<DbConn> for Post {
type Error = Error; type Error = Error;
type Object = LicensedArticle; type Object = LicensedArticle;
fn from_db(conn: &Connection, id: &str) -> Result<Self> { fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id) Self::find_by_ap_url(conn, id)
} }
fn from_activity(conn: &Connection, article: LicensedArticle) -> Result<Self> { fn from_activity(conn: &DbConn, article: LicensedArticle) -> Result<Self> {
let license = article.ext_one.license.unwrap_or_default(); let license = article.ext_one.license.unwrap_or_default();
let article = article.inner; let article = article.inner;
@@ -821,21 +821,21 @@ impl FromId<Connection> for Post {
} }
} }
impl AsObject<User, Create, &Connection> for Post { impl AsObject<User, Create, &DbConn> for Post {
type Error = Error; type Error = Error;
type Output = Self; type Output = Self;
fn activity(self, _conn: &Connection, _actor: User, _id: &str) -> Result<Self::Output> { fn activity(self, _conn: &DbConn, _actor: User, _id: &str) -> Result<Self::Output> {
// TODO: check that _actor is actually one of the author? // TODO: check that _actor is actually one of the author?
Ok(self) Ok(self)
} }
} }
impl AsObject<User, Delete, &Connection> for Post { impl AsObject<User, Delete, &DbConn> for Post {
type Error = Error; type Error = Error;
type Output = (); type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<Self::Output> { fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<Self::Output> {
let can_delete = self let can_delete = self
.get_authors(conn)? .get_authors(conn)?
.into_iter() .into_iter()
@@ -859,16 +859,16 @@ pub struct PostUpdate {
pub tags: Option<serde_json::Value>, pub tags: Option<serde_json::Value>,
} }
impl FromId<Connection> for PostUpdate { impl FromId<DbConn> for PostUpdate {
type Error = Error; type Error = Error;
type Object = LicensedArticle; type Object = LicensedArticle;
fn from_db(_: &Connection, _: &str) -> Result<Self> { fn from_db(_: &DbConn, _: &str) -> Result<Self> {
// Always fail because we always want to deserialize the AP object // Always fail because we always want to deserialize the AP object
Err(Error::NotFound) Err(Error::NotFound)
} }
fn from_activity(conn: &Connection, updated: Self::Object) -> Result<Self> { fn from_activity(conn: &DbConn, updated: Self::Object) -> Result<Self> {
let mut post_update = PostUpdate { let mut post_update = PostUpdate {
ap_url: updated ap_url: updated
.ap_object_ref() .ap_object_ref()
@@ -923,11 +923,11 @@ impl FromId<Connection> for PostUpdate {
} }
} }
impl AsObject<User, Update, &Connection> for PostUpdate { impl AsObject<User, Update, &DbConn> for PostUpdate {
type Error = Error; type Error = Error;
type Output = (); type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> { fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
let mut post = let mut post =
Post::from_id(conn, &self.ap_url, None, CONFIG.proxy()).map_err(|(_, e)| e)?; Post::from_id(conn, &self.ap_url, None, CONFIG.proxy()).map_err(|(_, e)| e)?;
@@ -1109,10 +1109,10 @@ mod tests {
let act = post.to_activity(&conn)?; let act = post.to_activity(&conn)?;
let expected = json!({ let expected = json!({
"attributedTo": ["https://plu.me/@/admin/", "https://plu.me/~/BlogName/"], "attributedTo": ["https://plu.me/@/admin/", "https://plu.me/~/Blog%20Name/"],
"cc": [], "cc": [],
"content": "Hello", "content": "Hello",
"id": "https://plu.me/~/BlogName/testing", "id": "https://plu.me/~/Blog%20Name/testing",
"license": "WTFPL", "license": "WTFPL",
"name": "Testing", "name": "Testing",
"published": format_datetime(&post.creation_date), "published": format_datetime(&post.creation_date),
@@ -1130,7 +1130,7 @@ mod tests {
], ],
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Article", "type": "Article",
"url": "https://plu.me/~/BlogName/testing" "url": "https://plu.me/~/Blog%20Name/testing"
}); });
assert_json_eq!(to_value(act)?, expected); assert_json_eq!(to_value(act)?, expected);
@@ -1149,12 +1149,12 @@ mod tests {
let expected = json!({ let expected = json!({
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"cc": [], "cc": [],
"id": "https://plu.me/~/BlogName/testing/activity", "id": "https://plu.me/~/Blog%20Name/testing/activity",
"object": { "object": {
"attributedTo": ["https://plu.me/@/admin/", "https://plu.me/~/BlogName/"], "attributedTo": ["https://plu.me/@/admin/", "https://plu.me/~/Blog%20Name/"],
"cc": [], "cc": [],
"content": "Hello", "content": "Hello",
"id": "https://plu.me/~/BlogName/testing", "id": "https://plu.me/~/Blog%20Name/testing",
"license": "WTFPL", "license": "WTFPL",
"name": "Testing", "name": "Testing",
"published": format_datetime(&post.creation_date), "published": format_datetime(&post.creation_date),
@@ -1172,7 +1172,7 @@ mod tests {
], ],
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Article", "type": "Article",
"url": "https://plu.me/~/BlogName/testing" "url": "https://plu.me/~/Blog%20Name/testing"
}, },
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Create" "type": "Create"
@@ -1194,12 +1194,12 @@ mod tests {
let expected = json!({ let expected = json!({
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"cc": [], "cc": [],
"id": "https://plu.me/~/BlogName/testing/update-", "id": "https://plu.me/~/Blog%20Name/testing/update-",
"object": { "object": {
"attributedTo": ["https://plu.me/@/admin/", "https://plu.me/~/BlogName/"], "attributedTo": ["https://plu.me/@/admin/", "https://plu.me/~/Blog%20Name/"],
"cc": [], "cc": [],
"content": "Hello", "content": "Hello",
"id": "https://plu.me/~/BlogName/testing", "id": "https://plu.me/~/Blog%20Name/testing",
"license": "WTFPL", "license": "WTFPL",
"name": "Testing", "name": "Testing",
"published": format_datetime(&post.creation_date), "published": format_datetime(&post.creation_date),
@@ -1217,7 +1217,7 @@ mod tests {
], ],
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Article", "type": "Article",
"url": "https://plu.me/~/BlogName/testing" "url": "https://plu.me/~/Blog%20Name/testing"
}, },
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Update" "type": "Update"
@@ -1226,10 +1226,10 @@ mod tests {
let id = actual["id"].to_string(); let id = actual["id"].to_string();
let (id_pre, id_post) = id.rsplit_once('-').unwrap(); let (id_pre, id_post) = id.rsplit_once('-').unwrap();
assert_eq!(post.ap_url, "https://plu.me/~/BlogName/testing"); assert_eq!(post.ap_url, "https://plu.me/~/Blog%20Name/testing");
assert_eq!( assert_eq!(
id_pre, id_pre,
to_value("\"https://plu.me/~/BlogName/testing/update") to_value("\"https://plu.me/~/Blog%20Name/testing/update")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap() .unwrap()
@@ -1259,9 +1259,9 @@ mod tests {
let expected = json!({ let expected = json!({
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"id": "https://plu.me/~/BlogName/testing#delete", "id": "https://plu.me/~/Blog%20Name/testing#delete",
"object": { "object": {
"id": "https://plu.me/~/BlogName/testing", "id": "https://plu.me/~/Blog%20Name/testing",
"type": "Tombstone" "type": "Tombstone"
}, },
"to": [ "to": [
+16 -16
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
instance::Instance, notifications::*, posts::Post, schema::reshares, timeline::*, users::User, db_conn::DbConn, instance::Instance, notifications::*, posts::Post, schema::reshares,
Connection, Error, Result, CONFIG, timeline::*, users::User, Connection, Error, Result, CONFIG,
}; };
use activitystreams::{ use activitystreams::{
activity::{ActorAndObjectRef, Announce, Undo}, activity::{ActorAndObjectRef, Announce, Undo},
@@ -113,11 +113,11 @@ impl Reshare {
} }
} }
impl AsObject<User, Announce, &Connection> for Post { impl AsObject<User, Announce, &DbConn> for Post {
type Error = Error; type Error = Error;
type Output = Reshare; type Output = Reshare;
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Reshare> { fn activity(self, conn: &DbConn, actor: User, id: &str) -> Result<Reshare> {
let conn = conn; let conn = conn;
let reshare = Reshare::insert( let reshare = Reshare::insert(
conn, conn,
@@ -134,15 +134,15 @@ impl AsObject<User, Announce, &Connection> for Post {
} }
} }
impl FromId<Connection> for Reshare { impl FromId<DbConn> for Reshare {
type Error = Error; type Error = Error;
type Object = Announce; type Object = Announce;
fn from_db(conn: &Connection, id: &str) -> Result<Self> { fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
Reshare::find_by_ap_url(conn, id) Reshare::find_by_ap_url(conn, id)
} }
fn from_activity(conn: &Connection, act: Announce) -> Result<Self> { fn from_activity(conn: &DbConn, act: Announce) -> Result<Self> {
let res = Reshare::insert( let res = Reshare::insert(
conn, conn,
NewReshare { NewReshare {
@@ -183,17 +183,17 @@ impl FromId<Connection> for Reshare {
} }
} }
impl AsObject<User, Undo, &Connection> for Reshare { impl AsObject<User, Undo, &DbConn> for Reshare {
type Error = Error; type Error = Error;
type Output = (); type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> { fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
if actor.id == self.user_id { if actor.id == self.user_id {
diesel::delete(&self).execute(conn)?; diesel::delete(&self).execute(&**conn)?;
// delete associated notification if any // delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::RESHARE, self.id) { if let Ok(notif) = Notification::find(conn, notification_kind::RESHARE, self.id) {
diesel::delete(&notif).execute(conn)?; diesel::delete(&notif).execute(&**conn)?;
} }
Ok(()) Ok(())
@@ -235,8 +235,8 @@ mod test {
let expected = json!({ let expected = json!({
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"], "cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/reshare/https://plu.me/~/BlogName/testing", "id": "https://plu.me/@/admin/reshare/https://plu.me/~/Blog%20Name/testing",
"object": "https://plu.me/~/BlogName/testing", "object": "https://plu.me/~/Blog%20Name/testing",
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Announce", "type": "Announce",
}); });
@@ -259,12 +259,12 @@ mod test {
let expected = json!({ let expected = json!({
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"], "cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/reshare/https://plu.me/~/BlogName/testing#delete", "id": "https://plu.me/@/admin/reshare/https://plu.me/~/Blog%20Name/testing#delete",
"object": { "object": {
"actor": "https://plu.me/@/admin/", "actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"], "cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/reshare/https://plu.me/~/BlogName/testing", "id": "https://plu.me/@/admin/reshare/https://plu.me/~/Blog%20Name/testing",
"object": "https://plu.me/~/BlogName/testing", "object": "https://plu.me/~/Blog%20Name/testing",
"to": ["https://www.w3.org/ns/activitystreams#Public"], "to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Announce" "type": "Announce"
}, },
+9 -9
View File
@@ -51,6 +51,14 @@ table! {
} }
} }
table! {
comment_seers (id) {
id -> Int4,
comment_id -> Int4,
user_id -> Int4,
}
}
table! { table! {
comments (id) { comments (id) {
id -> Int4, id -> Int4,
@@ -66,14 +74,6 @@ table! {
} }
} }
table! {
comment_seers (id) {
id -> Int4,
comment_id -> Int4,
user_id -> Int4,
}
}
table! { table! {
email_blocklist (id) { email_blocklist (id) {
id -> Int4, id -> Int4,
@@ -314,8 +314,8 @@ allow_tables_to_appear_in_same_query!(
apps, apps,
blog_authors, blog_authors,
blogs, blogs,
comments,
comment_seers, comment_seers,
comments,
email_blocklist, email_blocklist,
email_signups, email_signups,
follows, follows,
+11 -1
View File
@@ -77,6 +77,7 @@ impl ActorFactoryArgs<(Arc<Searcher>, DbPool)> for SearchActor {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::diesel::Connection; use crate::diesel::Connection;
use crate::Fqn;
use crate::{ use crate::{
blog_authors::{BlogAuthor, NewBlogAuthor}, blog_authors::{BlogAuthor, NewBlogAuthor},
blogs::{Blog, NewBlog}, blogs::{Blog, NewBlog},
@@ -190,13 +191,22 @@ mod tests {
}, },
) )
.unwrap(); .unwrap();
let title = random_hex();
let blog = NewBlog { let blog = NewBlog {
instance_id: instance.id, instance_id: instance.id,
actor_id: random_hex(), actor_id: random_hex(),
ap_url: random_hex(), ap_url: random_hex(),
inbox_url: random_hex(), inbox_url: random_hex(),
outbox_url: random_hex(), outbox_url: random_hex(),
..Default::default() fqn: Fqn::make_local(&title).unwrap(),
title,
summary: Default::default(),
summary_html: Default::default(),
private_key: Default::default(),
public_key: Default::default(),
icon_id: Default::default(),
banner_id: Default::default(),
theme: Default::default(),
}; };
let blog = Blog::insert(conn, blog).unwrap(); let blog = Blog::insert(conn, blog).unwrap();
BlogAuthor::insert( BlogAuthor::insert(
+4 -22
View File
@@ -1,4 +1,5 @@
use crate::{ use crate::{
db_conn::DbConn,
lists::List, lists::List,
posts::Post, posts::Post,
schema::{posts, timeline, timeline_definition}, schema::{posts, timeline, timeline_definition},
@@ -11,7 +12,7 @@ use std::ops::Deref;
pub(crate) mod query; pub(crate) mod query;
pub use self::query::Kind; pub use self::query::Kind;
pub use self::query::{QueryError, TimelineQuery}; use self::query::{QueryError, TimelineQuery};
#[derive(Clone, Debug, PartialEq, Eq, Queryable, Identifiable, AsChangeset)] #[derive(Clone, Debug, PartialEq, Eq, Queryable, Identifiable, AsChangeset)]
#[table_name = "timeline_definition"] #[table_name = "timeline_definition"]
@@ -219,7 +220,7 @@ impl Timeline {
.map_err(Error::from) .map_err(Error::from)
} }
pub fn add_to_all_timelines(conn: &Connection, post: &Post, kind: Kind<'_>) -> Result<()> { pub fn add_to_all_timelines(conn: &DbConn, post: &Post, kind: Kind<'_>) -> Result<()> {
let timelines = timeline_definition::table let timelines = timeline_definition::table
.load::<Self>(conn.deref()) .load::<Self>(conn.deref())
.map_err(Error::from)?; .map_err(Error::from)?;
@@ -245,26 +246,7 @@ impl Timeline {
Ok(()) Ok(())
} }
pub fn remove_post(&self, conn: &Connection, post: &Post) -> Result<bool> { pub fn matches(&self, conn: &DbConn, post: &Post, kind: Kind<'_>) -> Result<bool> {
if self.includes_post(conn, post)? {
return Ok(false);
}
diesel::delete(
timeline::table
.filter(timeline::timeline_id.eq(self.id))
.filter(timeline::post_id.eq(post.id)),
)
.execute(conn)?;
Ok(true)
}
pub fn remove_all_posts(&self, conn: &Connection) -> Result<u64> {
let count = diesel::delete(timeline::table.filter(timeline::timeline_id.eq(self.id)))
.execute(conn)?;
Ok(count as u64)
}
pub fn matches(&self, conn: &Connection, post: &Post, kind: Kind<'_>) -> Result<bool> {
let query = TimelineQuery::parse(&self.query)?; let query = TimelineQuery::parse(&self.query)?;
query.matches(conn, self, post, kind) query.matches(conn, self, post, kind)
} }
+7 -6
View File
@@ -1,11 +1,12 @@
use crate::{ use crate::{
blogs::Blog, blogs::Blog,
db_conn::DbConn,
lists::{self, ListType}, lists::{self, ListType},
posts::Post, posts::Post,
tags::Tag, tags::Tag,
timeline::Timeline, timeline::Timeline,
users::User, users::User,
Connection, Result, Result,
}; };
use plume_common::activity_pub::inbox::AsActor; use plume_common::activity_pub::inbox::AsActor;
use whatlang::{self, Lang}; use whatlang::{self, Lang};
@@ -154,7 +155,7 @@ enum TQ<'a> {
impl<'a> TQ<'a> { impl<'a> TQ<'a> {
fn matches( fn matches(
&self, &self,
conn: &Connection, conn: &DbConn,
timeline: &Timeline, timeline: &Timeline,
post: &Post, post: &Post,
kind: Kind<'_>, kind: Kind<'_>,
@@ -199,7 +200,7 @@ enum Arg<'a> {
impl<'a> Arg<'a> { impl<'a> Arg<'a> {
pub fn matches( pub fn matches(
&self, &self,
conn: &Connection, conn: &DbConn,
timeline: &Timeline, timeline: &Timeline,
post: &Post, post: &Post,
kind: Kind<'_>, kind: Kind<'_>,
@@ -224,7 +225,7 @@ enum WithList {
impl WithList { impl WithList {
pub fn matches( pub fn matches(
&self, &self,
conn: &Connection, conn: &DbConn,
timeline: &Timeline, timeline: &Timeline,
post: &Post, post: &Post,
list: &List<'_>, list: &List<'_>,
@@ -360,7 +361,7 @@ enum Bool {
impl Bool { impl Bool {
pub fn matches( pub fn matches(
&self, &self,
conn: &Connection, conn: &DbConn,
timeline: &Timeline, timeline: &Timeline,
post: &Post, post: &Post,
kind: Kind<'_>, kind: Kind<'_>,
@@ -653,7 +654,7 @@ impl<'a> TimelineQuery<'a> {
pub fn matches( pub fn matches(
&self, &self,
conn: &Connection, conn: &DbConn,
timeline: &Timeline, timeline: &Timeline,
post: &Post, post: &Post,
kind: Kind<'_>, kind: Kind<'_>,
+10 -10
View File
@@ -191,10 +191,10 @@ impl User {
.map_err(Error::from) .map_err(Error::from)
} }
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<User> { pub fn find_by_fqn(conn: &DbConn, fqn: &str) -> Result<User> {
let from_db = users::table let from_db = users::table
.filter(users::fqn.eq(fqn)) .filter(users::fqn.eq(fqn))
.first(conn) .first(&**conn)
.optional()?; .optional()?;
if let Some(from_db) = from_db { if let Some(from_db) = from_db {
Ok(from_db) Ok(from_db)
@@ -219,7 +219,7 @@ impl User {
.map_err(Error::from) .map_err(Error::from)
} }
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<User> { fn fetch_from_webfinger(conn: &DbConn, acct: &str) -> Result<User> {
let link = resolve(acct.to_owned(), true)? let link = resolve(acct.to_owned(), true)?
.links .links
.into_iter() .into_iter()
@@ -761,7 +761,7 @@ impl User {
actor.set_url(ap_url.clone()); actor.set_url(ap_url.clone());
actor.set_inbox(self.inbox_url.parse()?); actor.set_inbox(self.inbox_url.parse()?);
actor.set_outbox(self.outbox_url.parse()?); actor.set_outbox(self.outbox_url.parse()?);
actor.set_preferred_username(self.username.clone()); actor.set_preferred_username(&self.fqn);
actor.set_followers(self.followers_endpoint.parse()?); actor.set_followers(self.followers_endpoint.parse()?);
if let Some(shared_inbox_url) = self.shared_inbox_url.clone() { if let Some(shared_inbox_url) = self.shared_inbox_url.clone() {
@@ -921,15 +921,15 @@ impl IntoId for User {
impl Eq for User {} impl Eq for User {}
impl FromId<Connection> for User { impl FromId<DbConn> for User {
type Error = Error; type Error = Error;
type Object = CustomPerson; type Object = CustomPerson;
fn from_db(conn: &Connection, id: &str) -> Result<Self> { fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id) Self::find_by_ap_url(conn, id)
} }
fn from_activity(conn: &Connection, acct: CustomPerson) -> Result<Self> { fn from_activity(conn: &DbConn, acct: CustomPerson) -> Result<Self> {
let actor = acct.ap_actor_ref(); let actor = acct.ap_actor_ref();
let username = actor let username = actor
.preferred_username() .preferred_username()
@@ -1030,7 +1030,7 @@ impl FromId<Connection> for User {
} }
} }
impl AsActor<&Connection> for User { impl AsActor<&DbConn> for User {
fn get_inbox_url(&self) -> String { fn get_inbox_url(&self) -> String {
self.inbox_url.clone() self.inbox_url.clone()
} }
@@ -1046,11 +1046,11 @@ impl AsActor<&Connection> for User {
} }
} }
impl AsObject<User, Delete, &Connection> for User { impl AsObject<User, Delete, &DbConn> for User {
type Error = Error; type Error = Error;
type Output = (); type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> { fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
if self.id == actor.id { if self.id == actor.id {
self.delete(conn).map(|_| ()) self.delete(conn).map(|_| ())
} else { } else {
+32 -31
View File
@@ -16,7 +16,7 @@ use plume_models::{
migrations::IMPORTED_MIGRATIONS, migrations::IMPORTED_MIGRATIONS,
remote_fetch_actor::RemoteFetchActor, remote_fetch_actor::RemoteFetchActor,
search::{actor::SearchActor, Searcher as UnmanagedSearcher}, search::{actor::SearchActor, Searcher as UnmanagedSearcher},
Connection, CONFIG, Config, Connection, CONFIG,
}; };
use rocket_csrf::CsrfFairingBuilder; use rocket_csrf::CsrfFairingBuilder;
use scheduled_thread_pool::ScheduledThreadPool; use scheduled_thread_pool::ScheduledThreadPool;
@@ -47,12 +47,12 @@ include!(concat!(env!("OUT_DIR"), "/templates.rs"));
compile_i18n!(); compile_i18n!();
/// Initializes a database pool. /// 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 manager = ConnectionManager::<Connection>::new(CONFIG.database_url.as_str());
let mut builder = DbPool::builder() let mut builder = DbPool::builder()
.connection_customizer(Box::new(PragmaForeignKey)) .connection_customizer(Box::new(PragmaForeignKey))
.min_idle(CONFIG.db_min_idle); .min_idle(config.db_min_idle);
if let Some(max_size) = CONFIG.db_max_size { if let Some(max_size) = config.db_max_size {
builder = builder.max_size(max_size); builder = builder.max_size(max_size);
}; };
let pool = builder.build(manager).ok()?; let pool = builder.build(manager).ok()?;
@@ -63,28 +63,8 @@ fn init_pool() -> Option<DbPool> {
Some(pool) Some(pool)
} }
pub(crate) fn init_rocket() -> rocket::Rocket { pub(crate) fn init_rocket(config: &Config) -> rocket::Rocket {
match dotenv::dotenv() { let dbpool = init_pool(config).expect("main: database pool initialization error");
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");
if IMPORTED_MIGRATIONS if IMPORTED_MIGRATIONS
.is_pending(&dbpool.get().unwrap()) .is_pending(&dbpool.get().unwrap())
.unwrap_or(true) .unwrap_or(true)
@@ -104,8 +84,8 @@ Then try to restart Plume.
let workpool = ScheduledThreadPool::with_name("worker {}", num_cpus::get()); let workpool = ScheduledThreadPool::with_name("worker {}", num_cpus::get());
// we want a fast exit here, so // we want a fast exit here, so
let searcher = Arc::new(UnmanagedSearcher::open_or_recreate( let searcher = Arc::new(UnmanagedSearcher::open_or_recreate(
&CONFIG.search_index, &config.search_index,
&CONFIG.search_tokenizers, &config.search_tokenizers,
)); ));
RemoteFetchActor::init(dbpool.clone()); RemoteFetchActor::init(dbpool.clone());
SearchActor::init(searcher.clone(), dbpool.clone()); SearchActor::init(searcher.clone(), dbpool.clone());
@@ -125,12 +105,12 @@ Then try to restart Plume.
.expect("Error setting Ctrl-c handler"); .expect("Error setting Ctrl-c handler");
let mail = mail::init(); 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!("Warning: the email server is not configured (or not completely).");
warn!("Please refer to the documentation to see how to configure it."); 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( .mount(
"/", "/",
routes![ routes![
@@ -280,7 +260,28 @@ Then try to restart Plume.
} }
fn main() { 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")] #[cfg(feature = "test")]
let rocket = rocket.mount("/test", routes![test_routes::health,]); let rocket = rocket.mount("/test", routes![test_routes::health,]);
+127 -33
View File
@@ -1,4 +1,7 @@
use activitystreams::collection::{OrderedCollection, OrderedCollectionPage}; use activitystreams::{
collection::{OrderedCollection, OrderedCollectionPage},
iri_string::{spec::IriSpec, validate::iri_reference},
};
use diesel::SaveChangesDsl; use diesel::SaveChangesDsl;
use rocket::{ use rocket::{
http::ContentType, http::ContentType,
@@ -80,7 +83,7 @@ pub struct NewBlogForm {
fn valid_slug(title: &str) -> Result<(), ValidationError> { fn valid_slug(title: &str) -> Result<(), ValidationError> {
let slug = Blog::slug(title); let slug = Blog::slug(title);
if slug.is_empty() { if slug.is_empty() || iri_reference::<IriSpec>(&slug).is_err() {
Err(ValidationError::new("empty_slug")) Err(ValidationError::new("empty_slug"))
} else { } else {
Ok(()) Ok(())
@@ -101,7 +104,7 @@ pub fn create(
Ok(_) => ValidationErrors::new(), Ok(_) => ValidationErrors::new(),
Err(e) => e, Err(e) => e,
}; };
if Blog::find_by_fqn(&conn, slug).is_ok() { if Blog::find_by_fqn(&conn, &slug).is_ok() {
errors.add( errors.add(
"title", "title",
ValidationError { ValidationError {
@@ -122,7 +125,7 @@ pub fn create(
let blog = Blog::insert( let blog = Blog::insert(
&conn, &conn,
NewBlog::new_local( NewBlog::new_local(
slug.into(), slug.clone(),
form.title.to_string(), form.title.to_string(),
String::from(""), String::from(""),
Instance::get_local() Instance::get_local()
@@ -144,7 +147,7 @@ pub fn create(
.expect("blog::create: author error"); .expect("blog::create: author error");
Flash::success( Flash::success(
Redirect::to(uri!(details: name = slug, page = _)), Redirect::to(uri!(details: name = &slug, page = _)),
&i18n!(intl, "Your blog was successfully created!"), &i18n!(intl, "Your blog was successfully created!"),
) )
.into() .into()
@@ -379,6 +382,8 @@ pub fn atom_feed(name: String, conn: DbConn) -> Option<Content<String>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::env::var;
use super::valid_slug; use super::valid_slug;
use crate::init_rocket; use crate::init_rocket;
use diesel::Connection; use diesel::Connection;
@@ -387,62 +392,107 @@ mod tests {
blog_authors::{BlogAuthor, NewBlogAuthor}, blog_authors::{BlogAuthor, NewBlogAuthor},
blogs::{Blog, NewBlog}, blogs::{Blog, NewBlog},
db_conn::{DbConn, DbPool}, db_conn::{DbConn, DbPool},
get_rocket_config,
instance::{Instance, NewInstance}, instance::{Instance, NewInstance},
post_authors::{NewPostAuthor, PostAuthor}, post_authors::{NewPostAuthor, PostAuthor},
posts::{NewPost, Post}, posts::{NewPost, Post},
safe_string::SafeString, safe_string::SafeString,
search::Searcher,
users::{NewUser, User, AUTH_COOKIE}, users::{NewUser, User, AUTH_COOKIE},
Connection as Conn, CONFIG, Config, Fqn, SearchTokenizerConfig,
}; };
use rocket::{ use rocket::{
http::{Cookie, Cookies, SameSite}, http::{ContentType, Cookie, Cookies, SameSite, Status},
local::{Client, LocalRequest}, local::{Client, LocalRequest},
}; };
#[test] type Models = (Instance, User, Blog, Post);
fn edit_link_within_post_card() {
let conn = Conn::establish(CONFIG.database_url.as_str()).unwrap(); fn setup() -> (Client, Models) {
Instance::insert( dotenv::from_path(".env.test").unwrap();
&conn, let config = Config {
NewInstance { base_url: var("BASE_URL").unwrap(),
public_domain: "example.org".to_string(), db_name: "plume",
name: "Plume".to_string(), db_max_size: None,
local: true, db_min_idle: None,
long_description: SafeString::new(""), signup: Default::default(),
short_description: SafeString::new(""), database_url: var("DATABASE_URL").unwrap(),
default_license: "CC-BY-SA".to_string(), search_index: format!("/tmp/plume_test-{}", random_hex()),
open_registrations: true, search_tokenizers: SearchTokenizerConfig::init(),
short_description_html: String::new(), rocket: get_rocket_config(),
long_description_html: String::new(), logo: Default::default(),
}, default_theme: Default::default(),
) media_directory: format!("/tmp/plume_test-{}", random_hex()),
.unwrap(); mail: None,
let rocket = init_rocket(); 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 client = Client::new(rocket).expect("valid rocket instance");
let dbpool = client.rocket().state::<DbPool>().unwrap(); let dbpool = client.rocket().state::<DbPool>().unwrap();
let conn = &DbConn(dbpool.get().unwrap()); let conn = &DbConn(dbpool.get().unwrap());
let (_instance, user, blog, post) = create_models(conn); (client, create_models(conn))
}
let blog_path = uri!(super::activity_details: name = &blog.fqn).to_string(); fn teardown((client, (instance, user, _blog, _post)): (&Client, Models)) {
let rocket = client.rocket();
let dbpool = rocket.state::<DbPool>().unwrap();
let conn = &DbConn(dbpool.get().unwrap());
user.delete(conn).unwrap();
let _ = diesel::delete(&instance);
}
#[test]
fn edit_link_within_post_card() {
let (client, (instance, user, blog, post)) = setup();
let blog_path = uri!(super::activity_details: name = &blog.fqn.to_string()).to_string();
let edit_link = uri!( let edit_link = uri!(
super::super::posts::edit: blog = &blog.fqn, super::super::posts::edit: blog = &blog.fqn.to_string(),
slug = &post.slug slug = &post.slug
) )
.to_string(); .to_string();
let mut response = client.get(&blog_path).dispatch(); let mut response = client.get(&blog_path).dispatch();
let body = response.body_string().unwrap(); let body = response.body_string().unwrap();
assert!(!body.contains(&edit_link)); let body_not_contain_edit_link = !body.contains(&edit_link);
let request = client.get(&blog_path); let request = client.get(&blog_path);
login(&request, &user); login(&request, &user);
let mut response = request.dispatch(); let mut response = request.dispatch();
let body = response.body_string().unwrap(); let body = response.body_string().unwrap();
assert!(body.contains(&edit_link)); eprintln!("{:?}", &blog.fqn);
let body_contains_edit_lnk = body.contains(&edit_link);
teardown((&client, (instance, user, blog, post)));
assert!(body_not_contain_edit_link);
assert!(body_contains_edit_lnk);
} }
fn create_models(conn: &DbConn) -> (Instance, User, Blog, Post) { fn create_models(conn: &DbConn) -> Models {
Instance::find_by_domain(conn, "example.org").unwrap_or_else(|_| {
Instance::insert(
conn,
NewInstance {
public_domain: "example.org".to_string(),
name: "Plume".to_string(),
local: true,
long_description: SafeString::new(""),
short_description: SafeString::new(""),
default_license: "CC-BY-SA".to_string(),
open_registrations: true,
short_description_html: String::new(),
long_description_html: String::new(),
},
)
.unwrap()
});
conn.transaction::<(Instance, User, Blog, Post), diesel::result::Error, _>(|| { conn.transaction::<(Instance, User, Blog, Post), diesel::result::Error, _>(|| {
let instance = Instance::get_local().unwrap_or_else(|_| { let instance = Instance::get_local().unwrap_or_else(|_| {
let instance = Instance::insert( let instance = Instance::insert(
@@ -470,16 +520,26 @@ mod tests {
inbox_url: random_hex(), inbox_url: random_hex(),
outbox_url: random_hex(), outbox_url: random_hex(),
followers_endpoint: random_hex(), followers_endpoint: random_hex(),
fqn: random_hex(),
..Default::default() ..Default::default()
}; };
let user = User::insert(conn, user).unwrap(); let user = User::insert(conn, user).unwrap();
let title = random_hex();
let blog = NewBlog { let blog = NewBlog {
instance_id: instance.id, instance_id: instance.id,
fqn: Fqn::make_local(&title).unwrap(),
title,
actor_id: random_hex(), actor_id: random_hex(),
ap_url: random_hex(), ap_url: random_hex(),
inbox_url: random_hex(), inbox_url: random_hex(),
outbox_url: random_hex(), outbox_url: random_hex(),
..Default::default() summary: Default::default(),
summary_html: Default::default(),
public_key: Default::default(),
private_key: Default::default(),
icon_id: Default::default(),
banner_id: Default::default(),
theme: Default::default(),
}; };
let blog = Blog::insert(conn, blog).unwrap(); let blog = Blog::insert(conn, blog).unwrap();
BlogAuthor::insert( BlogAuthor::insert(
@@ -535,4 +595,38 @@ mod tests {
assert!(valid_slug("Blog Title").is_ok()); assert!(valid_slug("Blog Title").is_ok());
assert!(valid_slug("ブログ タイトル").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);
}
} }
+2 -2
View File
@@ -407,7 +407,7 @@ pub fn interact(conn: DbConn, user: Option<User>, target: String) -> Option<Redi
if let Ok(post) = Post::from_id(&conn, &target, None, CONFIG.proxy()) { if let Ok(post) = Post::from_id(&conn, &target, None, CONFIG.proxy()) {
return Some(Redirect::to(uri!( return Some(Redirect::to(uri!(
super::posts::details: blog = post.get_blog(&conn).expect("Can't retrieve blog").fqn, super::posts::details: blog = post.get_blog(&conn).expect("Can't retrieve blog").fqn.to_string(),
slug = &post.slug, slug = &post.slug,
responding_to = _ responding_to = _
))); )));
@@ -418,7 +418,7 @@ pub fn interact(conn: DbConn, user: Option<User>, target: String) -> Option<Redi
let post = comment.get_post(&conn).expect("Can't retrieve post"); let post = comment.get_post(&conn).expect("Can't retrieve post");
return Some(Redirect::to(uri!( return Some(Redirect::to(uri!(
super::posts::details: blog = super::posts::details: blog =
post.get_blog(&conn).expect("Can't retrieve blog").fqn, post.get_blog(&conn).expect("Can't retrieve blog").fqn.to_string(),
slug = &post.slug, slug = &post.slug,
responding_to = comment.id responding_to = comment.id
))); )));
+5 -5
View File
@@ -22,7 +22,7 @@
<meta content="@blog.summary_html" property="og:description" /> <meta content="@blog.summary_html" property="og:description" />
<meta content="@blog.icon_url(ctx.0)" property="og:image" /> <meta content="@blog.icon_url(ctx.0)" property="og:image" />
<link href='@Instance::get_local().unwrap().compute_box("~", &blog.fqn, "atom.xml")' rel='alternate' type='application/atom+xml'> <link href='@Instance::get_local().unwrap().compute_box("~", &blog.fqn.to_string(), "atom.xml")' rel='alternate' type='application/atom+xml'>
<link href='@blog.ap_url' rel='alternate' type='application/activity+json'> <link href='@blog.ap_url' rel='alternate' type='application/activity+json'>
<link href='@blog.ap_url' rel='canonical'> <link href='@blog.ap_url' rel='canonical'>
@if !ctx.2.clone().map(|u| u.hide_custom_css).unwrap_or(false) { @if !ctx.2.clone().map(|u| u.hide_custom_css).unwrap_or(false) {
@@ -31,7 +31,7 @@
} }
} }
}, { }, {
<a href="@uri!(blogs::details: name = &blog.fqn, page = _)" dir="auto">@blog.title</a> <a href="@uri!(blogs::details: name = &blog.fqn.to_string(), page = _)" dir="auto">@blog.title</a>
}, { }, {
<div class="hidden"> <div class="hidden">
@for author in authors { @for author in authors {
@@ -58,8 +58,8 @@
</h1> </h1>
@if ctx.2.clone().and_then(|u| u.is_author_in(ctx.0, &blog).ok()).unwrap_or(false) { @if ctx.2.clone().and_then(|u| u.is_author_in(ctx.0, &blog).ok()).unwrap_or(false) {
<a href="@uri!(posts::new: blog = &blog.fqn)" class="button" dir="auto">@i18n!(ctx.1, "New article")</a> <a href="@uri!(posts::new: blog = &blog.fqn.to_string())" class="button" dir="auto">@i18n!(ctx.1, "New article")</a>
<a href="@uri!(blogs::edit: name = &blog.fqn)" class="button" dir="auto">@i18n!(ctx.1, "Edit")</a> <a href="@uri!(blogs::edit: name = &blog.fqn.to_string())" class="button" dir="auto">@i18n!(ctx.1, "Edit")</a>
} }
</div> </div>
@@ -76,7 +76,7 @@
<section> <section>
<h2 dir="auto"> <h2 dir="auto">
@i18n!(ctx.1, "Latest articles") @i18n!(ctx.1, "Latest articles")
<small><a href="@uri!(blogs::atom_feed: name = &blog.fqn)" title="Atom feed">@icon!("rss")</a></small> <small><a href="@uri!(blogs::atom_feed: name = &blog.fqn.to_string())" title="Atom feed">@icon!("rss")</a></small>
</h2> </h2>
@if posts.is_empty() { @if posts.is_empty() {
<p dir="auto">@i18n!(ctx.1, "No posts to see here yet.")</p> <p dir="auto">@i18n!(ctx.1, "No posts to see here yet.")</p>
+3 -3
View File
@@ -12,10 +12,10 @@
@(ctx: BaseContext, blog: &Blog, medias: Vec<Media>, form: &EditForm, errors: ValidationErrors) @(ctx: BaseContext, blog: &Blog, medias: Vec<Media>, form: &EditForm, errors: ValidationErrors)
@:base(ctx, i18n!(ctx.1, "Edit \"{}\""; &blog.title), {}, { @:base(ctx, i18n!(ctx.1, "Edit \"{}\""; &blog.title), {}, {
<a href="@uri!(blogs::details: name = &blog.fqn, page = _)">@blog.title</a> <a href="@uri!(blogs::details: name = &blog.fqn.to_string(), page = _)">@blog.title</a>
}, { }, {
<h1>@i18n!(ctx.1, "Edit \"{}\""; &blog.title)</h1> <h1>@i18n!(ctx.1, "Edit \"{}\""; &blog.title)</h1>
<form method="post" action="@uri!(blogs::update: name = &blog.fqn)"> <form method="post" action="@uri!(blogs::update: name = &blog.fqn.to_string())">
<!-- Rocket hack to use various HTTP methods --> <!-- Rocket hack to use various HTTP methods -->
<input type=hidden name="_method" value="put"> <input type=hidden name="_method" value="put">
@@ -53,7 +53,7 @@
<h2>@i18n!(ctx.1, "Danger zone")</h2> <h2>@i18n!(ctx.1, "Danger zone")</h2>
<p>@i18n!(ctx.1, "Be very careful, any action taken here can't be reversed.")</p> <p>@i18n!(ctx.1, "Be very careful, any action taken here can't be reversed.")</p>
<form method="post" action="@uri!(blogs::delete: name = &blog.fqn)" onsubmit="return confirm('@i18n!(ctx.1, "Are you sure that you want to permanently delete this blog?")')"> <form method="post" action="@uri!(blogs::delete: name = &blog.fqn.to_string())" onsubmit="return confirm('@i18n!(ctx.1, "Are you sure that you want to permanently delete this blog?")')">
<input type="submit" class="inline-block button destructive" value="@i18n!(ctx.1, "Permanently delete this blog")"> <input type="submit" class="inline-block button destructive" value="@i18n!(ctx.1, "Permanently delete this blog")">
</form> </form>
}) })
+4 -4
View File
@@ -6,19 +6,19 @@
<div class="card h-entry"> <div class="card h-entry">
@if article.cover_id.is_some() { @if article.cover_id.is_some() {
<a class="cover-link" href="@uri!(posts::details: blog = article.get_blog_fqn(ctx.0), slug = &article.slug, responding_to = _)"> <a class="cover-link" href="@uri!(posts::details: blog = article.get_blog_fqn(ctx.0).to_string(), slug = &article.slug, responding_to = _)">
<div class="cover" style="background-image: url('@Html(article.cover_url(ctx.0).unwrap_or_default())')"></div> <div class="cover" style="background-image: url('@Html(article.cover_url(ctx.0).unwrap_or_default())')"></div>
</a> </a>
} }
<header dir="auto"> <header dir="auto">
<h3 class="p-name"> <h3 class="p-name">
<a class="u-url" href="@uri!(posts::details: blog = article.get_blog_fqn(ctx.0), slug = &article.slug, responding_to = _)"> <a class="u-url" href="@uri!(posts::details: blog = article.get_blog_fqn(ctx.0).to_string(), slug = &article.slug, responding_to = _)">
@article.title @article.title
</a> </a>
</h3> </h3>
@if ctx.2.clone().and_then(|u| article.is_author(ctx.0, u.id).ok()).unwrap_or(false) { @if ctx.2.clone().and_then(|u| article.is_author(ctx.0, u.id).ok()).unwrap_or(false) {
<div class="controls"> <div class="controls">
<a class="button" href="@uri!(posts::edit: blog = &article.get_blog_fqn(ctx.0), slug = &article.slug)">@i18n!(ctx.1, "Edit")</a> <a class="button" href="@uri!(posts::edit: blog = &article.get_blog_fqn(ctx.0).to_string(), slug = &article.slug)">@i18n!(ctx.1, "Edit")</a>
</div> </div>
} }
</header> </header>
@@ -35,7 +35,7 @@
@if article.published { @if article.published {
<span class="dt-published" datetime="@article.creation_date.format("%F %T")">@article.creation_date.format("%B %e, %Y")</span> <span class="dt-published" datetime="@article.creation_date.format("%F %T")">@article.creation_date.format("%B %e, %Y")</span>
} }
<a href="@uri!(blogs::details: name = &article.get_blog_fqn(ctx.0), page = _)">@article.get_blog(ctx.0).unwrap().title</a> <a href="@uri!(blogs::details: name = &article.get_blog_fqn(ctx.0).to_string(), page = _)">@article.get_blog(ctx.0).unwrap().title</a>
</div> </div>
@if !article.published { @if !article.published {
+12 -12
View File
@@ -18,7 +18,7 @@
@if article.cover_id.is_some() { @if article.cover_id.is_some() {
<meta property="og:image" content="@Html(article.cover_url(ctx.0).unwrap_or_default())"/> <meta property="og:image" content="@Html(article.cover_url(ctx.0).unwrap_or_default())"/>
} }
<meta property="og:url" content="@uri!(posts::details: blog = &blog.fqn, slug = &article.slug, responding_to = _)"/> <meta property="og:url" content="@uri!(posts::details: blog = &blog.fqn.to_string(), slug = &article.slug, responding_to = _)"/>
<meta property="og:description" content="@article.subtitle"/> <meta property="og:description" content="@article.subtitle"/>
<link rel="canonical" href="@article.ap_url"/> <link rel="canonical" href="@article.ap_url"/>
@@ -28,7 +28,7 @@
} }
} }
}, { }, {
<a href="@uri!(blogs::details: name = &blog.fqn, page = _)">@blog.title</a> <a href="@uri!(blogs::details: name = &blog.fqn.to_string(), page = _)">@blog.title</a>
}, { }, {
<div class="h-entry"> <div class="h-entry">
<header <header
@@ -78,7 +78,7 @@
</section> </section>
@if ctx.2.is_some() { @if ctx.2.is_some() {
<section class="actions"> <section class="actions">
<form id="likes" class="likes" action="@uri!(likes::create: blog = &blog.fqn, slug = &article.slug)#likes" method="POST"> <form id="likes" class="likes" action="@uri!(likes::create: blog = &blog.fqn.to_string(), slug = &article.slug)#likes" method="POST">
<p aria-label="@i18n!(ctx.1, "One like", "{0} likes"; n_likes)" title="@i18n!(ctx.1, "One like", "{0} likes"; n_likes)"> <p aria-label="@i18n!(ctx.1, "One like", "{0} likes"; n_likes)" title="@i18n!(ctx.1, "One like", "{0} likes"; n_likes)">
@n_likes @n_likes
</p> </p>
@@ -89,7 +89,7 @@
<button type="submit" class="action">@icon!("heart") @i18n!(ctx.1, "Add yours")</button> <button type="submit" class="action">@icon!("heart") @i18n!(ctx.1, "Add yours")</button>
} }
</form> </form>
<form id="reshares" class="reshares" action="@uri!(reshares::create: blog = &blog.fqn, slug = &article.slug)#reshares" method="POST"> <form id="reshares" class="reshares" action="@uri!(reshares::create: blog = &blog.fqn.to_string(), slug = &article.slug)#reshares" method="POST">
<p aria-label="@i18n!(ctx.1, "One boost", "{0} boosts"; n_reshares)" title="@i18n!(ctx.1, "One boost", "{0} boosts"; n_reshares)"> <p aria-label="@i18n!(ctx.1, "One boost", "{0} boosts"; n_reshares)" title="@i18n!(ctx.1, "One boost", "{0} boosts"; n_reshares)">
@n_reshares @n_reshares
</p> </p>
@@ -104,7 +104,7 @@
} else { } else {
<p class="center">@Html(i18n!(ctx.1, "{0}Log in{1}, or {2}use your Fediverse account{3} to interact with this article"; <p class="center">@Html(i18n!(ctx.1, "{0}Log in{1}, or {2}use your Fediverse account{3} to interact with this article";
format!("<a href='{}'>", escape(&uri!(session::new: m = _).to_string())), "</a>", format!("<a href='{}'>", escape(&uri!(session::new: m = _).to_string())), "</a>",
format!("<a href='{}'>", escape(&uri!(posts::remote_interact: blog_name = &blog.fqn, slug = &article.slug).to_string())), "</a>" format!("<a href='{}'>", escape(&uri!(posts::remote_interact: blog_name = &blog.fqn.to_string(), slug = &article.slug).to_string())), "</a>"
)) ))
</p> </p>
<section class="actions"> <section class="actions">
@@ -112,14 +112,14 @@
<p aria-label="@i18n!(ctx.1, "One like", "{0} likes"; n_likes)" title="@i18n!(ctx.1, "One like", "{0} likes"; n_likes)"> <p aria-label="@i18n!(ctx.1, "One like", "{0} likes"; n_likes)" title="@i18n!(ctx.1, "One like", "{0} likes"; n_likes)">
@n_likes @n_likes
</p> </p>
<a href="@uri!(posts::remote_interact: blog_name = &blog.fqn, slug = &article.slug)" class="action">@icon!("heart") @i18n!(ctx.1, "Add yours")</a> <a href="@uri!(posts::remote_interact: blog_name = &blog.fqn.to_string(), slug = &article.slug)" class="action">@icon!("heart") @i18n!(ctx.1, "Add yours")</a>
</div> </div>
<div id="reshares" class="reshares"> <div id="reshares" class="reshares">
<p aria-label="@i18n!(ctx.1, "One boost", "{0} boost"; n_reshares)" title="@i18n!(ctx.1, "One boost", "{0} boosts"; n_reshares)"> <p aria-label="@i18n!(ctx.1, "One boost", "{0} boost"; n_reshares)" title="@i18n!(ctx.1, "One boost", "{0} boosts"; n_reshares)">
@n_reshares @n_reshares
</p> </p>
<a href="@uri!(posts::remote_interact: blog_name = &blog.fqn, slug = &article.slug)" class="action">@icon!("repeat") @i18n!(ctx.1, "Boost")</a> <a href="@uri!(posts::remote_interact: blog_name = &blog.fqn.to_string(), slug = &article.slug)" class="action">@icon!("repeat") @i18n!(ctx.1, "Boost")</a>
</div> </div>
</section> </section>
} }
@@ -144,7 +144,7 @@
<h2>@i18n!(ctx.1, "Comments")</h2> <h2>@i18n!(ctx.1, "Comments")</h2>
@if ctx.2.is_some() { @if ctx.2.is_some() {
<form method="post" action="@uri!(comments::create: blog_name = &blog.fqn, slug = &article.slug)#comments"> <form method="post" action="@uri!(comments::create: blog_name = &blog.fqn.to_string(), slug = &article.slug)#comments">
@(Input::new("warning", i18n!(ctx.1, "Content warning")) @(Input::new("warning", i18n!(ctx.1, "Content warning"))
.default(&comment_form.warning) .default(&comment_form.warning)
.error(&comment_errors) .error(&comment_errors)
@@ -162,7 +162,7 @@
@if !comments.is_empty() { @if !comments.is_empty() {
@for comm in comments { @for comm in comments {
@:comment(ctx, &comm, Some(&article.ap_url), &blog.fqn, &article.slug) @:comment(ctx, &comm, Some(&article.ap_url), &blog.fqn.to_string(), &article.slug)
} }
} else { } else {
<p class="center" dir="auto">@i18n!(ctx.1, "No comments yet. Be the first to react!")</p> <p class="center" dir="auto">@i18n!(ctx.1, "No comments yet. Be the first to react!")</p>
@@ -173,7 +173,7 @@
@if ctx.2.clone().and_then(|u| article.is_author(ctx.0, u.id).ok()).unwrap_or(false) { @if ctx.2.clone().and_then(|u| article.is_author(ctx.0, u.id).ok()).unwrap_or(false) {
<aside class="bottom-bar"> <aside class="bottom-bar">
<div> <div>
<form class="inline" method="post" action="@uri!(posts::delete: blog_name = &blog.fqn, slug = &article.slug)"> <form class="inline" method="post" action="@uri!(posts::delete: blog_name = &blog.fqn.to_string(), slug = &article.slug)">
<input class="button destructive" onclick="return confirm('@i18n!(ctx.1, "Are you sure?")')" type="submit" value="@i18n!(ctx.1, "Delete")"> <input class="button destructive" onclick="return confirm('@i18n!(ctx.1, "Are you sure?")')" type="submit" value="@i18n!(ctx.1, "Delete")">
</form> </form>
</div> </div>
@@ -186,9 +186,9 @@
</div> </div>
<div> <div>
@if !article.published { @if !article.published {
<a class="button secondary" href="@uri!(posts::edit: blog = &blog.fqn, slug = &article.slug)">@i18n!(ctx.1, "Publish")</a> <a class="button secondary" href="@uri!(posts::edit: blog = &blog.fqn.to_string(), slug = &article.slug)">@i18n!(ctx.1, "Publish")</a>
} }
<a class="button" href="@uri!(posts::edit: blog = &blog.fqn, slug = &article.slug)">@i18n!(ctx.1, "Edit")</a> <a class="button" href="@uri!(posts::edit: blog = &blog.fqn.to_string(), slug = &article.slug)">@i18n!(ctx.1, "Edit")</a>
</div> </div>
</aside> </aside>
} }